feat: connect vehicle map to open platform
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
VEHICLE_MAP_HOST=0.0.0.0
|
||||
VEHICLE_MAP_PORT=20800
|
||||
OPEN_PLATFORM_BASE_URL=https://open.d.lnoneos.com
|
||||
OPEN_PLATFORM_APP_KEY=replace-with-32-character-app-key
|
||||
UPSTREAM_TIMEOUT_SECONDS=15
|
||||
DASHBOARD_CACHE_SECONDS=20
|
||||
STATION_CACHE_SECONDS=3600
|
||||
@@ -0,0 +1,33 @@
|
||||
# 羚牛 Vehicle Map
|
||||
|
||||
全国氢能车辆与加氢站运营驾驶舱。浏览器只访问本服务的 `/api/dashboard`,服务端使用开放平台 AppKey 获取其授权车辆、当日里程和资产库只读加氢站点位,AppKey 不会下发到浏览器。
|
||||
|
||||
## 当前部署
|
||||
|
||||
- ECS 服务:`lingniu-vehicle-map.service`
|
||||
- 访问地址:<http://115.29.187.205:20800/>
|
||||
- 健康检查:<http://115.29.187.205:20800/api/health>
|
||||
|
||||
原始仓库只有静态页面和静态文件服务,没有车辆数据接口;当前版本已改为通过服务端代理对接车辆数据开放平台,页面展示的数据不再使用硬编码车辆和加氢站样例。
|
||||
|
||||
## 本地运行
|
||||
|
||||
```bash
|
||||
export OPEN_PLATFORM_APP_KEY='<32位AppKey>'
|
||||
python3 server.py
|
||||
```
|
||||
|
||||
默认地址:`http://127.0.0.1:20800`。
|
||||
|
||||
## 接口依赖
|
||||
|
||||
- `POST /api/v1/vehicles/realtime/query`:全部授权车辆实时位置与状态;
|
||||
- `POST /api/v1/vehicles/mileage/query`:全部授权车辆当日里程;
|
||||
- `POST /api/v1/hydrogen-stations/query`:资产库只读加氢站地图点位。
|
||||
|
||||
以上三个接口均使用开放平台的 `Authorization: Bearer <AppKey>` 鉴权。生产 AppKey 仅保存在 ECS 的 `/opt/lingniu-vehicle-map/env/vehicle-map.env`,文件权限为 `root:vehicle-map 0640`。
|
||||
|
||||
本服务提供:
|
||||
|
||||
- `GET /api/health`:进程及配置状态;
|
||||
- `GET /api/dashboard`:面向前端的聚合数据,默认缓存20秒;加氢站缓存1小时。
|
||||
@@ -1,443 +1,270 @@
|
||||
/**
|
||||
* 羚牛氢能 - 车辆网络 (Bilingual & Restrained Apple Engine)
|
||||
* Key: 1868920ac8ff6b6f88dbe9fa2609c183
|
||||
* SecurityCode: 0b54a41143bec162788d01deba851340
|
||||
*/
|
||||
/** Lingniu Vehicle Map — live data from the vehicle open platform. */
|
||||
|
||||
// ==========================================
|
||||
// BILINGUAL TRANSLATION DICTIONARY
|
||||
// ==========================================
|
||||
const i18n = {
|
||||
zh: {
|
||||
vehicleModeTitle: '车辆网络',
|
||||
stationModeTitle: '加氢站网络',
|
||||
kpiTotalFleet: '车辆总数',
|
||||
kpiOnlineFleet: '当前在线运行',
|
||||
kpiDailyMileage: '今日运营里程',
|
||||
unitVehicles: '辆',
|
||||
unitStations: '座',
|
||||
btnVehicle: '车辆',
|
||||
btnStation: '加氢站',
|
||||
themeDark: '深色',
|
||||
themeLight: '浅色',
|
||||
mapStatusText: '全域实时节点已同步 · 15个省份 · 42座加氢站',
|
||||
legendPrimary: '核心区域 (>100)',
|
||||
legendMid: '重点分布 (20-100)',
|
||||
legendLow: '普通节点 (<20)',
|
||||
btnResetView: '复位视角',
|
||||
btn3dView: '3D/2D 视角',
|
||||
btnNationalView: '全国视图',
|
||||
mapFooterGis: '高质感地图引擎: 羚牛氢能GIS (AMap 3D Engine)',
|
||||
mapFooterStatus: '系统状态: 稳定连接 (AES-256)',
|
||||
hintNational: '视角: 全国运营态势',
|
||||
hintProvince: '视角: {prov}专向统计',
|
||||
panelStatusTitle: '车辆运营状态分布',
|
||||
panelRankTitle: '重点区域运营 TOP 排名',
|
||||
statusRunning: '运行中',
|
||||
statusStopped: '静止中',
|
||||
statusOffline: '离线',
|
||||
rankByFleet: '按车辆',
|
||||
rankByDist: '按里程',
|
||||
onlineText: '在线',
|
||||
fleetOverview: '📍 {prov}省运营概况',
|
||||
fleetTotalLabel: '车辆总量',
|
||||
onlineRunLabel: '在线运行',
|
||||
unitTail: '台',
|
||||
descLabel: '说明'
|
||||
vehicleModeTitle: '车辆网络', stationModeTitle: '加氢站网络',
|
||||
kpiTotalFleet: '车辆总数', kpiOnlineFleet: '当前在线运行', kpiDailyMileage: '今日运营里程',
|
||||
unitVehicles: '辆', unitStations: '座', btnVehicle: '车辆', btnStation: '加氢站',
|
||||
themeDark: '深色', themeLight: '浅色', legendPrimary: '核心节点 (>100)',
|
||||
legendMid: '重点节点 (20-100)', legendLow: '普通节点 (<20)', btnResetView: '复位视角',
|
||||
btn3dView: '3D/2D 视角', btnNationalView: '全国视图',
|
||||
mapFooterGis: '地图引擎: 羚牛氢能 GIS (AMap 3D Engine)',
|
||||
mapFooterStatus: '数据来源: 羚牛车辆数据开放平台', hintNational: '视角: 全国运营态势',
|
||||
panelStatusVehicle: '车辆运营状态分布', panelStatusStation: '加氢站类型分布',
|
||||
panelRankVehicle: '车辆累计里程 TOP 排名', panelRankStation: '加氢站省份 TOP 排名',
|
||||
statusRunning: '运行中', statusStopped: '静止中', statusOffline: '离线',
|
||||
stationCooperative: '合作站', stationExternal: '外部站', stationTotal: '全部站点',
|
||||
rankByFleet: '按数量', rankByDist: '按里程', onlineText: '在线', unitTail: '台'
|
||||
},
|
||||
en: {
|
||||
vehicleModeTitle: 'Vehicle Network',
|
||||
stationModeTitle: 'H₂ Station Network',
|
||||
kpiTotalFleet: 'Total Fleet',
|
||||
kpiOnlineFleet: 'Active Online',
|
||||
kpiDailyMileage: 'Today Mileage',
|
||||
unitVehicles: 'units',
|
||||
unitStations: 'stations',
|
||||
btnVehicle: 'Fleet',
|
||||
btnStation: 'H₂ Stations',
|
||||
themeDark: 'Dark',
|
||||
themeLight: 'Light',
|
||||
mapStatusText: 'Real-time Nodes Synced · 15 Provinces · 42 H₂ Stations',
|
||||
legendPrimary: 'Core (>100)',
|
||||
legendMid: 'Key (20-100)',
|
||||
legendLow: 'Standard (<20)',
|
||||
btnResetView: 'Reset View',
|
||||
btn3dView: '3D/2D View',
|
||||
btnNationalView: 'National View',
|
||||
vehicleModeTitle: 'Vehicle Network', stationModeTitle: 'H₂ Station Network',
|
||||
kpiTotalFleet: 'Total Fleet', kpiOnlineFleet: 'Active Online', kpiDailyMileage: 'Today Mileage',
|
||||
unitVehicles: 'units', unitStations: 'stations', btnVehicle: 'Fleet', btnStation: 'H₂ Stations',
|
||||
themeDark: 'Dark', themeLight: 'Light', legendPrimary: 'Core (>100)',
|
||||
legendMid: 'Key (20-100)', legendLow: 'Standard (<20)', btnResetView: 'Reset View',
|
||||
btn3dView: '3D/2D View', btnNationalView: 'National View',
|
||||
mapFooterGis: 'Engine: Lingniu H₂ GIS (AMap 3D Engine)',
|
||||
mapFooterStatus: 'Status: Secure Connected (AES-256)',
|
||||
hintNational: 'View: National Overview',
|
||||
hintProvince: 'View: {prov} Focus',
|
||||
panelStatusTitle: 'Fleet Status Breakdown',
|
||||
panelRankTitle: 'TOP Regional Operations',
|
||||
statusRunning: 'Active',
|
||||
statusStopped: 'Idle',
|
||||
statusOffline: 'Offline',
|
||||
rankByFleet: 'By Fleet',
|
||||
rankByDist: 'By Mileage',
|
||||
onlineText: 'Online',
|
||||
fleetOverview: '📍 {prov} Province Overview',
|
||||
fleetTotalLabel: 'Total Fleet',
|
||||
onlineRunLabel: 'Active Fleet',
|
||||
unitTail: 'units',
|
||||
descLabel: 'Note'
|
||||
mapFooterStatus: 'Source: Lingniu Vehicle Open Platform', hintNational: 'View: National Overview',
|
||||
panelStatusVehicle: 'Fleet Status Breakdown', panelStatusStation: 'Station Type Breakdown',
|
||||
panelRankVehicle: 'Top Vehicles by Odometer', panelRankStation: 'Top Provinces by Stations',
|
||||
statusRunning: 'Active', statusStopped: 'Idle', statusOffline: 'Offline',
|
||||
stationCooperative: 'Partners', stationExternal: 'External', stationTotal: 'All Stations',
|
||||
rankByFleet: 'By Count', rankByDist: 'By Mileage', onlineText: 'Online', unitTail: 'units'
|
||||
}
|
||||
};
|
||||
|
||||
// Province name English mapping
|
||||
const provinceEnMap = {
|
||||
'浙江': 'Zhejiang',
|
||||
'广东': 'Guangdong',
|
||||
'上海': 'Shanghai',
|
||||
'江苏': 'Jiangsu',
|
||||
'新疆': 'Xinjiang',
|
||||
'湖北': 'Hubei',
|
||||
'四川': 'Sichuan',
|
||||
'陕西': 'Shaanxi',
|
||||
'安徽': 'Anhui',
|
||||
'重庆': 'Chongqing',
|
||||
'山东': 'Shandong',
|
||||
'天津': 'Tianjin',
|
||||
'河南': 'Henan',
|
||||
'北京': 'Beijing'
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// STATE MANAGEMENT (DEFAULT: LIGHT MODE)
|
||||
// ==========================================
|
||||
let map = null;
|
||||
let markerList = [];
|
||||
let is3DPitch = true;
|
||||
let currentMode = 'vehicle'; // 'vehicle' | 'station'
|
||||
let currentTheme = 'theme-white'; // DEFAULT: LIGHT MODE (纯白/浅色模式)
|
||||
let currentLang = 'zh'; // 'zh' | 'en'
|
||||
let selectedProvince = null;
|
||||
let currentMode = 'vehicle';
|
||||
let currentTheme = 'theme-white';
|
||||
let currentLang = 'zh';
|
||||
let currentRankType = 'fleet';
|
||||
let dashboard = null;
|
||||
let refreshTimer = null;
|
||||
|
||||
// Province-Level Aggregates for Vehicle Operations
|
||||
const vehicleGeoNodes = [
|
||||
{ id: 'zj', name: '浙江', lnglat: [120.155070, 30.274085], count: 322, online: 286, dist: 168420, detail: '322台在途车辆 (在线率 88.8%)' },
|
||||
{ id: 'gd', name: '广东', lnglat: [113.264385, 23.129112], count: 209, online: 175, dist: 124150, detail: '209台大湾区冷链运力' },
|
||||
{ id: 'sh', name: '上海', lnglat: [121.473701, 31.230416], count: 54, online: 48, dist: 38200, detail: '54台港口短驳及城配集群' },
|
||||
{ id: 'js', name: '江苏', lnglat: [118.796877, 32.060255], count: 35, online: 31, dist: 28900, detail: '35台49吨牵引车集群' },
|
||||
{ id: 'xj', name: '新疆', lnglat: [87.617733, 43.792818], count: 28, online: 21, dist: 31200, detail: '28台重载矿用牵引车' },
|
||||
{ id: 'hb', name: '湖北', lnglat: [114.305393, 30.593099], count: 27, online: 24, dist: 21800, detail: '27台双飞翼货车' },
|
||||
{ id: 'sc', name: '四川', lnglat: [104.066541, 30.572269], count: 25, online: 22, dist: 24500, detail: '25台成渝零碳走廊车队' },
|
||||
{ id: 'sx', name: '陕西', lnglat: [108.948024, 34.263161], count: 19, online: 16, dist: 14200, detail: '19台物流货车' },
|
||||
{ id: 'ah', name: '安徽', lnglat: [117.283042, 31.861190], count: 19, online: 17, dist: 15600, detail: '19台长途牵引车' },
|
||||
{ id: 'cq', name: '重庆', lnglat: [106.551556, 29.563009], count: 16, online: 14, dist: 12800, detail: '16台厢式货车' },
|
||||
{ id: 'sd', name: '山东', lnglat: [117.000923, 36.675807], count: 90, online: 82, dist: 58300, detail: '90台青岛港重卡车队' },
|
||||
{ id: 'tj', name: '天津', lnglat: [117.200983, 39.084158], count: 6, online: 5, dist: 4200, detail: '6台港口短驳车' },
|
||||
{ id: 'hn', name: '河南', lnglat: [113.665412, 34.757975], count: 3, online: 3, dist: 2800, detail: '3台冷链物流车' },
|
||||
{ id: 'bj', name: '北京', lnglat: [116.407526, 39.904030], count: 1, online: 1, dist: 980, detail: '1台平谷干线示范车' }
|
||||
];
|
||||
|
||||
// Province-Level Station Data
|
||||
const stationGeoNodes = [
|
||||
{ id: 'gd-st', name: '广东', lnglat: [113.264385, 23.129112], count: '12 站', online: 11, detail: '南海撬装站、佛山示范站等 12座' },
|
||||
{ id: 'sh-st', name: '上海', lnglat: [121.473701, 31.230416], count: '8 站', online: 8, detail: '嘉定70MPa加氢母站等 8座' },
|
||||
{ id: 'bj-st', name: '北京', lnglat: [116.407526, 39.904030], count: '6 站', online: 6, detail: '大兴国际氢能枢纽站等 6座' },
|
||||
{ id: 'js-st', name: '江苏', lnglat: [118.796877, 32.060255], count: '5 站', online: 5, detail: '苏州港加氢站等 5座' },
|
||||
{ id: 'zj-st', name: '浙江', lnglat: [120.155070, 30.274085], count: '4 站', online: 4, detail: '嘉兴嘉善站等 4座' },
|
||||
{ id: 'sd-st', name: '山东', lnglat: [117.000923, 36.675807], count: '3 站', online: 3, detail: '青岛港加氢站等 3座' },
|
||||
{ id: 'sc-st', name: '四川', lnglat: [104.066541, 30.572269], count: '2 站', online: 2, detail: '成都皮口加注中心等 2座' },
|
||||
{ id: 'hb-st', name: '湖北', lnglat: [114.305393, 30.593099], count: '2 站', online: 2, detail: '武汉东湖高新加氢站等 2座' }
|
||||
];
|
||||
|
||||
// ==========================================
|
||||
// INITIALIZATION
|
||||
// ==========================================
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initClock();
|
||||
initAMapInstance();
|
||||
renderRankingList(currentRankType);
|
||||
loadDashboard();
|
||||
refreshTimer = window.setInterval(loadDashboard, 30000);
|
||||
});
|
||||
|
||||
function initAMapInstance() {
|
||||
if (typeof AMap === 'undefined') return;
|
||||
|
||||
map = new AMap.Map('amapContainer', {
|
||||
zoom: 4.8,
|
||||
center: [108.948024, 34.263161],
|
||||
viewMode: '3D',
|
||||
pitch: 30,
|
||||
mapStyle: getAMapThemeStyle(currentTheme),
|
||||
showBuildingBlock: false,
|
||||
showLabel: true
|
||||
});
|
||||
|
||||
map.on('complete', () => {
|
||||
renderAMapMarkers();
|
||||
});
|
||||
}
|
||||
|
||||
function getAMapThemeStyle(theme) {
|
||||
return (theme === 'theme-white') ? 'amap://styles/light' : 'amap://styles/darkblue';
|
||||
}
|
||||
|
||||
function getLocalizedProv(name) {
|
||||
if (currentLang === 'en') {
|
||||
return provinceEnMap[name] || name;
|
||||
async function loadDashboard() {
|
||||
setDataState('loading');
|
||||
try {
|
||||
const response = await fetch('/api/dashboard', { headers: { Accept: 'application/json' } });
|
||||
const payload = await response.json();
|
||||
if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`);
|
||||
dashboard = payload;
|
||||
updateDashboardUI();
|
||||
setDataState('ready');
|
||||
} catch (error) {
|
||||
console.error('dashboard refresh failed', error);
|
||||
setDataState('error');
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
// Render Restrained Province Markers
|
||||
function renderAMapMarkers() {
|
||||
if (!map) return;
|
||||
function setDataState(state) {
|
||||
const el = document.getElementById('mapStatusText');
|
||||
if (!el) return;
|
||||
if (state === 'loading' && !dashboard) el.textContent = currentLang === 'zh' ? '正在同步开放平台数据…' : 'Syncing open-platform data…';
|
||||
if (state === 'error') el.textContent = currentLang === 'zh' ? '数据同步失败 · 将自动重试' : 'Data sync failed · Retrying';
|
||||
}
|
||||
|
||||
markerList.forEach(m => m.remove());
|
||||
markerList = [];
|
||||
function initAMapInstance() {
|
||||
if (typeof AMap === 'undefined') {
|
||||
setDataState('error');
|
||||
return;
|
||||
}
|
||||
map = new AMap.Map('amapContainer', {
|
||||
zoom: 4.8, center: [108.948024, 34.263161], viewMode: '3D', pitch: 30,
|
||||
mapStyle: getAMapThemeStyle(currentTheme), showBuildingBlock: false, showLabel: true
|
||||
});
|
||||
map.on('complete', renderAMapMarkers);
|
||||
map.on('zoomend', renderAMapMarkers);
|
||||
}
|
||||
|
||||
const nodes = (currentMode === 'vehicle') ? vehicleGeoNodes : stationGeoNodes;
|
||||
const cyanColor = getThemeColor('--accent-cyan', '#0284C7');
|
||||
const cardBg = getThemeColor('--glass-bg', 'rgba(255, 255, 255, 0.85)');
|
||||
const textMain = getThemeColor('--text-main', '#1D1D1F');
|
||||
const cardBorder = getThemeColor('--glass-border', 'rgba(0, 0, 0, 0.08)');
|
||||
function updateDashboardUI() {
|
||||
if (!dashboard) return;
|
||||
const dict = i18n[currentLang];
|
||||
const summary = dashboard.summary;
|
||||
const total = currentMode === 'vehicle' ? summary.totalVehicles : summary.totalStations;
|
||||
const active = currentMode === 'vehicle' ? summary.onlineVehicles : summary.cooperativeStations;
|
||||
const unit = currentMode === 'vehicle' ? dict.unitVehicles : dict.unitStations;
|
||||
const activeRate = total ? (active * 100 / total).toFixed(1) : '0.0';
|
||||
document.getElementById('dashboardTitle').textContent = currentMode === 'vehicle' ? dict.vehicleModeTitle : dict.stationModeTitle;
|
||||
document.getElementById('kpiFleetTotal').innerHTML = `${formatNumber(total)} <small>${unit}</small>`;
|
||||
document.getElementById('kpiFleetOnline').innerHTML = `${formatNumber(active)} <small>${unit} (${activeRate}%)</small>`;
|
||||
document.getElementById('kpiDailyDist').innerHTML = `${formatNumber(summary.todayMileageKm, 1)} <small>km</small>`;
|
||||
document.getElementById('mapStatusText').textContent = currentLang === 'zh'
|
||||
? `开放平台已同步 · ${summary.totalVehicles}辆授权车辆 · ${summary.totalStations}座加氢站 · ${dashboard.asOf}`
|
||||
: `Open platform synced · ${summary.totalVehicles} vehicles · ${summary.totalStations} stations · ${dashboard.asOf}`;
|
||||
updateStatusPanel();
|
||||
renderRankingList(currentRankType);
|
||||
renderAMapMarkers();
|
||||
}
|
||||
|
||||
nodes.forEach(node => {
|
||||
const isSelected = (selectedProvince === node.name);
|
||||
const dotColor = cyanColor;
|
||||
const displayName = getLocalizedProv(node.name);
|
||||
function updateStatusPanel() {
|
||||
const dict = i18n[currentLang];
|
||||
const summary = dashboard.summary;
|
||||
const title = document.getElementById('panelStatusTitle');
|
||||
const labels = [document.getElementById('statusLabel1'), document.getElementById('statusLabel2'), document.getElementById('statusLabel3')];
|
||||
const values = [document.getElementById('statusValue1'), document.getElementById('statusValue2'), document.getElementById('statusValue3')];
|
||||
let counts;
|
||||
if (currentMode === 'vehicle') {
|
||||
title.textContent = dict.panelStatusVehicle;
|
||||
counts = [summary.drivingVehicles, summary.idleVehicles, summary.offlineVehicles];
|
||||
labels[0].textContent = dict.statusRunning; labels[1].textContent = dict.statusStopped; labels[2].textContent = dict.statusOffline;
|
||||
} else {
|
||||
title.textContent = dict.panelStatusStation;
|
||||
counts = [summary.cooperativeStations, summary.totalStations - summary.cooperativeStations, summary.totalStations];
|
||||
labels[0].textContent = dict.stationCooperative; labels[1].textContent = dict.stationExternal; labels[2].textContent = dict.stationTotal;
|
||||
}
|
||||
const denominator = currentMode === 'vehicle' ? summary.totalVehicles : summary.totalStations;
|
||||
values.forEach((element, index) => {
|
||||
const pct = denominator ? counts[index] * 100 / denominator : 0;
|
||||
element.innerHTML = `${formatNumber(counts[index])} <small>${currentMode === 'vehicle' ? dict.unitVehicles : dict.unitStations}</small>`;
|
||||
const pctElement = document.getElementById(`statusPct${index + 1}`);
|
||||
if (pctElement) pctElement.textContent = `(${pct.toFixed(1)}%)`;
|
||||
});
|
||||
const segments = document.querySelectorAll('.liquid-progress-bar .seg');
|
||||
if (segments.length === 3) {
|
||||
const segmentCounts = currentMode === 'vehicle' ? counts : [counts[0], counts[1], 0];
|
||||
segments.forEach((segment, index) => segment.style.width = `${denominator ? segmentCounts[index] * 100 / denominator : 0}%`);
|
||||
}
|
||||
document.getElementById('panelRankTitle').textContent = currentMode === 'vehicle' ? dict.panelRankVehicle : dict.panelRankStation;
|
||||
}
|
||||
|
||||
function buildVehicleClusters() {
|
||||
const clusters = new Map();
|
||||
for (const vehicle of dashboard?.vehicles || []) {
|
||||
if (!vehicle.locationAvailable || vehicle.longitude == null || vehicle.latitude == null) continue;
|
||||
const key = `${Math.round(vehicle.longitude * 2) / 2},${Math.round(vehicle.latitude * 2) / 2}`;
|
||||
if (!clusters.has(key)) clusters.set(key, { kind: 'vehicle', lng: 0, lat: 0, count: 0, online: 0, dist: 0, vehicles: [] });
|
||||
const cluster = clusters.get(key);
|
||||
cluster.lng += Number(vehicle.longitude); cluster.lat += Number(vehicle.latitude); cluster.count += 1;
|
||||
cluster.online += vehicle.online ? 1 : 0; cluster.dist += Number(vehicle.dailyMileageKm || 0); cluster.vehicles.push(vehicle);
|
||||
}
|
||||
return [...clusters.values()].map((cluster, index) => ({
|
||||
...cluster, id: `vehicle-${index}`, lnglat: [cluster.lng / cluster.count, cluster.lat / cluster.count],
|
||||
name: currentLang === 'zh' ? '车辆集群' : 'Vehicle cluster', detail: cluster.vehicles.slice(0, 5).map(v => v.plateNumber || v.vin).join('、')
|
||||
}));
|
||||
}
|
||||
|
||||
function stationNodes() {
|
||||
const stations = dashboard?.stations || [];
|
||||
if ((map?.getZoom?.() || 0) >= 7) {
|
||||
return stations.map(station => ({
|
||||
id: `station-${station.id}`, kind: 'station', name: station.name, lnglat: [station.longitude, station.latitude],
|
||||
count: 1, online: station.cooperative ? 1 : 0,
|
||||
detail: [station.province, station.city, station.district, station.address].filter(Boolean).join(' '), cooperative: station.cooperative
|
||||
}));
|
||||
}
|
||||
const grouped = new Map();
|
||||
for (const station of stations) {
|
||||
const province = station.province || (currentLang === 'zh' ? '未标注区域' : 'Unspecified');
|
||||
if (!grouped.has(province)) grouped.set(province, { kind: 'stationCluster', name: province, lng: 0, lat: 0, count: 0, online: 0, stations: [] });
|
||||
const group = grouped.get(province);
|
||||
group.lng += Number(station.longitude); group.lat += Number(station.latitude); group.count += 1;
|
||||
group.online += station.cooperative ? 1 : 0; group.stations.push(station);
|
||||
}
|
||||
return [...grouped.values()].map((group, index) => ({
|
||||
...group, id: `station-cluster-${index}`, lnglat: [group.lng / group.count, group.lat / group.count],
|
||||
detail: group.stations.slice(0, 5).map(station => station.name).join('、')
|
||||
}));
|
||||
}
|
||||
|
||||
function renderAMapMarkers() {
|
||||
if (!map || !dashboard) return;
|
||||
markerList.forEach(marker => marker.remove()); markerList = [];
|
||||
const nodes = currentMode === 'vehicle' ? buildVehicleClusters() : stationNodes();
|
||||
const dict = i18n[currentLang];
|
||||
for (const node of nodes) {
|
||||
const markerContent = document.createElement('div');
|
||||
markerContent.className = 'province-info-badge-wrap';
|
||||
markerContent.innerHTML = `
|
||||
<div class="province-info-card" style="
|
||||
background: ${isSelected ? 'var(--pill-bg)' : cardBg};
|
||||
border-color: ${isSelected ? dotColor : cardBorder};
|
||||
">
|
||||
<div class="p-head">
|
||||
<span class="p-dot" style="background:${dotColor};"></span>
|
||||
<span class="p-name">${displayName}</span>
|
||||
<span class="p-total" style="color:${dotColor};">${node.count}</span>
|
||||
</div>
|
||||
<div class="p-sub">
|
||||
${dict.onlineText} ${node.online}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const marker = new AMap.Marker({
|
||||
position: node.lnglat,
|
||||
content: markerContent,
|
||||
offset: new AMap.Pixel(-30, -12),
|
||||
title: `${displayName}: ${node.count}`
|
||||
});
|
||||
|
||||
const overviewTitle = dict.fleetOverview.replace('{prov}', displayName);
|
||||
|
||||
const label = node.kind === 'station' ? (node.cooperative ? 'H₂ · 合作' : 'H₂') : node.kind === 'stationCluster' ? `${node.count} 站` : `${node.count}`;
|
||||
markerContent.innerHTML = `<div class="province-info-card"><div class="p-head"><span class="p-dot"></span><span class="p-name">${escapeHTML(node.name)}</span><span class="p-total">${escapeHTML(label)}</span></div>${node.kind === 'vehicle' ? `<div class="p-sub">${dict.onlineText} ${node.online}</div>` : ''}</div>`;
|
||||
const marker = new AMap.Marker({ position: node.lnglat, content: markerContent, offset: new AMap.Pixel(-30, -12), title: node.name });
|
||||
const infoWindow = new AMap.InfoWindow({
|
||||
isCustom: true,
|
||||
content: `
|
||||
<div style="
|
||||
background: ${cardBg};
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid ${cardBorder};
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
color: ${textMain};
|
||||
font-family: -apple-system, sans-serif;
|
||||
min-width: 180px;
|
||||
box-shadow: 0 12px 28px rgba(0,0,0,0.1);
|
||||
">
|
||||
<div style="font-weight:600; color:${textMain}; font-size:12px; margin-bottom:4px;">
|
||||
${overviewTitle}
|
||||
</div>
|
||||
<div style="font-size:11px; color:var(--text-muted); line-height:1.4;">
|
||||
<div>${dict.fleetTotalLabel}: <strong style="color:${textMain};">${node.count}</strong></div>
|
||||
<div>${dict.onlineRunLabel}: <strong style="color:var(--accent-mint);">${node.online} ${dict.unitTail}</strong></div>
|
||||
<div>${dict.descLabel}: ${node.detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
content: `<div class="map-info-window"><strong>${escapeHTML(node.name)}</strong><div>${escapeHTML(node.detail || '')}</div>${node.kind === 'vehicle' ? `<div>${dict.onlineText}: ${node.online}/${node.count} · ${formatNumber(node.dist, 1)} km</div>` : ''}</div>`,
|
||||
offset: new AMap.Pixel(0, -32)
|
||||
});
|
||||
|
||||
marker.on('mouseover', () => infoWindow.open(map, node.lnglat));
|
||||
marker.on('mouseout', () => infoWindow.close());
|
||||
marker.on('click', () => filterByProvince(node.name));
|
||||
|
||||
marker.setMap(map);
|
||||
markerList.push(marker);
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// RIGHT PANEL INTERACTION & RANKING LIST
|
||||
// ==========================================
|
||||
function switchRankTab(type) {
|
||||
currentRankType = type;
|
||||
document.querySelectorAll('.gtab').forEach(b => b.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
renderRankingList(type);
|
||||
if (node.kind === 'stationCluster') marker.on('click', () => map.setZoomAndCenter(7.2, node.lnglat));
|
||||
marker.setMap(map); markerList.push(marker);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRankingList(type) {
|
||||
const box = document.getElementById('rankingListBox');
|
||||
if (!box) return;
|
||||
if (!box || !dashboard) return;
|
||||
box.innerHTML = '';
|
||||
|
||||
let list = [...vehicleGeoNodes];
|
||||
if (type === 'fleet') {
|
||||
list.sort((a, b) => b.count - a.count);
|
||||
} else {
|
||||
list.sort((a, b) => b.dist - a.dist);
|
||||
}
|
||||
|
||||
const dict = i18n[currentLang];
|
||||
|
||||
list.forEach((item, idx) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'rank-glass-row';
|
||||
const topClass = idx === 0 ? 'r-top1' : (idx === 1 ? 'r-top2' : (idx === 2 ? 'r-top3' : ''));
|
||||
const displayName = getLocalizedProv(item.name);
|
||||
const valText = type === 'fleet' ? `${item.count} ${dict.unitVehicles}` : `${(item.dist / 1000).toFixed(1)}k km`;
|
||||
|
||||
div.innerHTML = `
|
||||
<span class="r-badge ${topClass}">${idx + 1}</span>
|
||||
<span class="r-name">${displayName}</span>
|
||||
<span class="r-val">${valText}</span>
|
||||
`;
|
||||
div.onclick = () => filterByProvince(item.name);
|
||||
box.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function filterByProvince(provName) {
|
||||
selectedProvince = provName;
|
||||
const dict = i18n[currentLang];
|
||||
const displayName = getLocalizedProv(provName);
|
||||
document.getElementById('selectedRegionHint').textContent = dict.hintProvince.replace('{prov}', displayName);
|
||||
renderAMapMarkers();
|
||||
|
||||
const match = vehicleGeoNodes.find(n => n.name === provName);
|
||||
if (match && map) {
|
||||
map.setZoomAndCenter(6.5, match.lnglat);
|
||||
}
|
||||
}
|
||||
|
||||
function clearProvinceFilter() {
|
||||
selectedProvince = null;
|
||||
const dict = i18n[currentLang];
|
||||
document.getElementById('selectedRegionHint').textContent = dict.hintNational;
|
||||
resetMapView();
|
||||
renderAMapMarkers();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// LANGUAGE SWITCHER (ZH / EN)
|
||||
// ==========================================
|
||||
function setLanguage(lang) {
|
||||
currentLang = lang;
|
||||
|
||||
document.querySelectorAll('.lang-switcher-bw .segment-btn').forEach(b => b.classList.remove('active'));
|
||||
if (lang === 'zh') document.querySelector('.lang-zh-btn')?.classList.add('active');
|
||||
if (lang === 'en') document.querySelector('.lang-en-btn')?.classList.add('active');
|
||||
|
||||
const dict = i18n[lang];
|
||||
|
||||
// Translate static data-i18n attributes
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
if (dict[key]) {
|
||||
el.textContent = dict[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Dynamic titles & KPIs
|
||||
let rows;
|
||||
if (currentMode === 'vehicle') {
|
||||
document.getElementById('dashboardTitle').textContent = dict.vehicleModeTitle;
|
||||
document.getElementById('kpiFleetTotal').innerHTML = `1,024 <small>${dict.unitVehicles}</small>`;
|
||||
document.getElementById('kpiFleetOnline').innerHTML = `823 <small>${dict.unitVehicles} (80.4%)</small>`;
|
||||
rows = [...dashboard.vehicles]
|
||||
.filter(item => item.totalMileageKm != null)
|
||||
.sort((a, b) => type === 'dist' ? Number(b.dailyMileageKm || 0) - Number(a.dailyMileageKm || 0) : Number(b.totalMileageKm || 0) - Number(a.totalMileageKm || 0))
|
||||
.slice(0, 12)
|
||||
.map(item => ({ name: item.plateNumber || item.vin, value: type === 'dist' ? `${formatNumber(item.dailyMileageKm, 1)} km` : `${formatNumber(item.totalMileageKm, 1)} km`, location: item.locationAvailable ? [item.longitude, item.latitude] : null }));
|
||||
} else {
|
||||
document.getElementById('dashboardTitle').textContent = dict.stationModeTitle;
|
||||
document.getElementById('kpiFleetTotal').innerHTML = `42 <small>${dict.unitStations}</small>`;
|
||||
document.getElementById('kpiFleetOnline').innerHTML = `39 <small>${dict.unitStations} (92.8%)</small>`;
|
||||
const provinces = new Map();
|
||||
for (const station of dashboard.stations) {
|
||||
const name = station.province || (currentLang === 'zh' ? '未标注' : 'Unspecified');
|
||||
provinces.set(name, (provinces.get(name) || 0) + 1);
|
||||
}
|
||||
rows = [...provinces.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12).map(([name, value]) => ({ name, value: `${value} ${i18n[currentLang].unitStations}` }));
|
||||
}
|
||||
rows.forEach((item, index) => {
|
||||
const row = document.createElement('div'); row.className = 'rank-glass-row';
|
||||
row.innerHTML = `<span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${escapeHTML(item.name)}</span><span class="r-val">${escapeHTML(item.value)}</span>`;
|
||||
if (item.location) row.onclick = () => map?.setZoomAndCenter(9, item.location);
|
||||
box.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Update selected region hint
|
||||
if (selectedProvince) {
|
||||
const displayName = getLocalizedProv(selectedProvince);
|
||||
document.getElementById('selectedRegionHint').textContent = dict.hintProvince.replace('{prov}', displayName);
|
||||
} else {
|
||||
document.getElementById('selectedRegionHint').textContent = dict.hintNational;
|
||||
}
|
||||
|
||||
renderRankingList(currentRankType);
|
||||
renderAMapMarkers();
|
||||
function switchRankTab(type) {
|
||||
currentRankType = type;
|
||||
document.querySelectorAll('.gtab').forEach(button => button.classList.remove('active'));
|
||||
if (window.event?.target) window.event.target.classList.add('active');
|
||||
renderRankingList(type);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// MODE & BLACK/WHITE THEME SWITCHER
|
||||
// ==========================================
|
||||
function switchMode(mode) {
|
||||
currentMode = mode;
|
||||
const dict = i18n[currentLang];
|
||||
document.getElementById('btnModeVehicle').classList.toggle('active', mode === 'vehicle');
|
||||
document.getElementById('btnModeStation').classList.toggle('active', mode === 'station');
|
||||
updateDashboardUI();
|
||||
}
|
||||
|
||||
const btnVehicle = document.getElementById('btnModeVehicle');
|
||||
const btnStation = document.getElementById('btnModeStation');
|
||||
|
||||
if (mode === 'vehicle') {
|
||||
btnVehicle.classList.add('active');
|
||||
btnStation.classList.remove('active');
|
||||
document.getElementById('dashboardTitle').textContent = dict.vehicleModeTitle;
|
||||
document.getElementById('kpiFleetTotal').innerHTML = `1,024 <small>${dict.unitVehicles}</small>`;
|
||||
document.getElementById('kpiFleetOnline').innerHTML = `823 <small>${dict.unitVehicles} (80.4%)</small>`;
|
||||
} else {
|
||||
btnStation.classList.add('active');
|
||||
btnVehicle.classList.remove('active');
|
||||
document.getElementById('dashboardTitle').textContent = dict.stationModeTitle;
|
||||
document.getElementById('kpiFleetTotal').innerHTML = `42 <small>${dict.unitStations}</small>`;
|
||||
document.getElementById('kpiFleetOnline').innerHTML = `39 <small>${dict.unitStations} (92.8%)</small>`;
|
||||
}
|
||||
|
||||
renderAMapMarkers();
|
||||
function setLanguage(lang) {
|
||||
currentLang = lang;
|
||||
document.querySelectorAll('.lang-switcher-bw .segment-btn').forEach(button => button.classList.remove('active'));
|
||||
document.querySelector(`.lang-${lang}-btn`)?.classList.add('active');
|
||||
const dict = i18n[lang];
|
||||
document.querySelectorAll('[data-i18n]').forEach(element => {
|
||||
const key = element.getAttribute('data-i18n'); if (dict[key]) element.textContent = dict[key];
|
||||
});
|
||||
updateDashboardUI();
|
||||
}
|
||||
|
||||
function setTheme(themeName) {
|
||||
currentTheme = themeName;
|
||||
document.body.className = themeName;
|
||||
|
||||
document.querySelectorAll('.bw-btn').forEach(b => b.classList.remove('active'));
|
||||
if (themeName === 'theme-dark') document.querySelector('.theme-dark-btn')?.classList.add('active');
|
||||
if (themeName === 'theme-white') document.querySelector('.theme-white-btn')?.classList.add('active');
|
||||
|
||||
if (map) {
|
||||
map.setMapStyle(getAMapThemeStyle(themeName));
|
||||
renderAMapMarkers();
|
||||
}
|
||||
}
|
||||
|
||||
function getThemeColor(varName, fallback) {
|
||||
const val = getComputedStyle(document.body).getPropertyValue(varName).trim();
|
||||
return val || fallback;
|
||||
}
|
||||
|
||||
function resetMapView() {
|
||||
if (map) {
|
||||
map.setZoomAndCenter(4.8, [108.948024, 34.263161]);
|
||||
map.setPitch(30);
|
||||
}
|
||||
}
|
||||
|
||||
function togglePitchView() {
|
||||
if (!map) return;
|
||||
is3DPitch = !is3DPitch;
|
||||
map.setPitch(is3DPitch ? 30 : 0);
|
||||
currentTheme = themeName; document.body.className = themeName;
|
||||
document.querySelectorAll('.bw-btn').forEach(button => button.classList.remove('active'));
|
||||
document.querySelector(themeName === 'theme-dark' ? '.theme-dark-btn' : '.theme-white-btn')?.classList.add('active');
|
||||
if (map) { map.setMapStyle(getAMapThemeStyle(themeName)); renderAMapMarkers(); }
|
||||
}
|
||||
|
||||
function getAMapThemeStyle(theme) { return theme === 'theme-white' ? 'amap://styles/light' : 'amap://styles/darkblue'; }
|
||||
function resetMapView() { if (map) { map.setZoomAndCenter(4.8, [108.948024, 34.263161]); map.setPitch(30); } }
|
||||
function clearProvinceFilter() { resetMapView(); }
|
||||
function togglePitchView() { if (map) { is3DPitch = !is3DPitch; map.setPitch(is3DPitch ? 30 : 0); } }
|
||||
function formatNumber(value, digits = 0) { return Number(value || 0).toLocaleString(currentLang === 'zh' ? 'zh-CN' : 'en-US', { maximumFractionDigits: digits, minimumFractionDigits: digits }); }
|
||||
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); }
|
||||
function initClock() {
|
||||
const clockTime = document.getElementById('clockTime');
|
||||
function update() {
|
||||
const now = new Date();
|
||||
const hrs = String(now.getHours()).padStart(2, '0');
|
||||
const mins = String(now.getMinutes()).padStart(2, '0');
|
||||
const secs = String(now.getSeconds()).padStart(2, '0');
|
||||
if (clockTime) clockTime.textContent = `${hrs}:${mins}:${secs}`;
|
||||
}
|
||||
update();
|
||||
setInterval(update, 1000);
|
||||
const clock = document.getElementById('clockTime');
|
||||
const update = () => { if (clock) clock.textContent = new Date().toLocaleTimeString('zh-CN', { hour12: false }); };
|
||||
update(); window.setInterval(update, 1000);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
release_id=${1:?release id is required}
|
||||
archive=${2:?release archive is required}
|
||||
root=${VEHICLE_MAP_ROOT:-/opt/lingniu-vehicle-map}
|
||||
service=${VEHICLE_MAP_SERVICE:-lingniu-vehicle-map.service}
|
||||
base_url=${VEHICLE_MAP_BASE_URL:-http://127.0.0.1:20800}
|
||||
|
||||
case "$release_id" in
|
||||
''|*[!A-Za-z0-9._-]*) printf 'invalid release id: %s\n' "$release_id" >&2; exit 1 ;;
|
||||
esac
|
||||
test -f "$archive"
|
||||
|
||||
if ! id vehicle-map >/dev/null 2>&1; then
|
||||
useradd --system --home-dir "$root" --shell /sbin/nologin vehicle-map
|
||||
fi
|
||||
mkdir -p "$root/releases" "$root/env"
|
||||
next="$root/releases/$release_id"
|
||||
test ! -e "$next"
|
||||
mkdir -p "$next"
|
||||
|
||||
while IFS= read -r member; do
|
||||
case "$member" in
|
||||
/*|../*|*/../*|*/..) printf 'archive contains unsafe path: %s\n' "$member" >&2; exit 1 ;;
|
||||
esac
|
||||
done < <(tar -tzf "$archive")
|
||||
tar --no-same-owner -xzf "$archive" -C "$next"
|
||||
test -f "$next/server.py"
|
||||
test -f "$next/index.html"
|
||||
chown -R root:vehicle-map "$next"
|
||||
chmod -R u=rwX,g=rX,o= "$next"
|
||||
chown root:vehicle-map "$root/env/vehicle-map.env"
|
||||
chmod 0640 "$root/env/vehicle-map.env"
|
||||
|
||||
ln -s "$next" "$root/current.next"
|
||||
python3 -c 'import os,sys; os.replace(sys.argv[1],sys.argv[2])' "$root/current.next" "$root/current"
|
||||
systemctl restart "$service"
|
||||
|
||||
for _ in $(seq 1 30); do
|
||||
if systemctl is-active --quiet "$service" && curl -fsS "$base_url/api/health" >/dev/null; then
|
||||
curl -fsS "$base_url/" | grep -q 'id="dashboardTitle"'
|
||||
curl -fsS "$base_url/api/dashboard" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["status"]=="ok" and d["summary"]["totalStations"]>400'
|
||||
printf 'vehicle_map_release_install=ok release=%s\n' "$release_id"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
systemctl status "$service" --no-pager >&2 || true
|
||||
exit 1
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=Lingniu Vehicle Map Dashboard
|
||||
After=network-online.target lingniu-vehicle-open-platform.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=vehicle-map
|
||||
Group=vehicle-map
|
||||
WorkingDirectory=/opt/lingniu-vehicle-map/current
|
||||
EnvironmentFile=/opt/lingniu-vehicle-map/env/vehicle-map.env
|
||||
ExecStart=/usr/bin/python3 /opt/lingniu-vehicle-map/current/server.py
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+12
-12
@@ -83,15 +83,15 @@
|
||||
<div class="header-kpi-group">
|
||||
<div class="kpi-glass-capsule">
|
||||
<span class="capsule-lbl" data-i18n="kpiTotalFleet">车辆总数</span>
|
||||
<span class="capsule-val" id="kpiFleetTotal">1,024 <small data-i18n="unitVehicles">辆</small></span>
|
||||
<span class="capsule-val" id="kpiFleetTotal">-- <small data-i18n="unitVehicles">辆</small></span>
|
||||
</div>
|
||||
<div class="kpi-glass-capsule primary-capsule">
|
||||
<span class="capsule-lbl" data-i18n="kpiOnlineFleet">当前在线运行</span>
|
||||
<span class="capsule-val" id="kpiFleetOnline">823 <small data-i18n-append="onlinePct">辆 (80.4%)</small></span>
|
||||
<span class="capsule-val" id="kpiFleetOnline">-- <small data-i18n-append="onlinePct">辆</small></span>
|
||||
</div>
|
||||
<div class="kpi-glass-capsule">
|
||||
<span class="capsule-lbl" data-i18n="kpiDailyMileage">今日运营里程</span>
|
||||
<span class="capsule-val" id="kpiDailyDist">512,178 <small>km</small> <em class="trend-pill">↑6.2%</em></span>
|
||||
<span class="capsule-val" id="kpiDailyDist">-- <small>km</small></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
<div class="map-top-bar">
|
||||
<div class="status-glass-pill">
|
||||
<span class="pulse-ring"></span>
|
||||
<span id="mapStatusText" data-i18n="mapStatusText">全域实时节点已同步 · 15个省份 · 42座加氢站</span>
|
||||
<span id="mapStatusText">正在同步开放平台数据…</span>
|
||||
</div>
|
||||
|
||||
<div class="map-legend-glass">
|
||||
@@ -182,22 +182,22 @@
|
||||
<!-- Card 1: 车辆运营状态 (3 Clean States: 运行中, 静止中, 离线) -->
|
||||
<div class="glass-panel sidebar-card">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n="panelStatusTitle">车辆运营状态分布</span>
|
||||
<span class="panel-title" id="panelStatusTitle">车辆运营状态分布</span>
|
||||
<span class="panel-tag">STATUS</span>
|
||||
</div>
|
||||
|
||||
<div class="status-glass-grid3">
|
||||
<div class="glass-cell cell-running">
|
||||
<div class="cell-num">542 <small data-i18n="unitVehicles">辆</small></div>
|
||||
<div class="cell-label"><span class="status-dot dot-mint"></span> <span data-i18n="statusRunning">运行中</span> (52.9%)</div>
|
||||
<div class="cell-num" id="statusValue1">-- <small data-i18n="unitVehicles">辆</small></div>
|
||||
<div class="cell-label"><span class="status-dot dot-mint"></span> <span id="statusLabel1" data-i18n="statusRunning">运行中</span> <span id="statusPct1"></span></div>
|
||||
</div>
|
||||
<div class="glass-cell cell-stopped">
|
||||
<div class="cell-num">311 <small data-i18n="unitVehicles">辆</small></div>
|
||||
<div class="cell-label"><span class="status-dot dot-blue"></span> <span data-i18n="statusStopped">静止中</span> (30.4%)</div>
|
||||
<div class="cell-num" id="statusValue2">-- <small data-i18n="unitVehicles">辆</small></div>
|
||||
<div class="cell-label"><span class="status-dot dot-blue"></span> <span id="statusLabel2" data-i18n="statusStopped">静止中</span> <span id="statusPct2"></span></div>
|
||||
</div>
|
||||
<div class="glass-cell cell-offline">
|
||||
<div class="cell-num">171 <small data-i18n="unitVehicles">辆</small></div>
|
||||
<div class="cell-label"><span class="status-dot dot-sub"></span> <span data-i18n="statusOffline">离线</span> (16.7%)</div>
|
||||
<div class="cell-num" id="statusValue3">-- <small data-i18n="unitVehicles">辆</small></div>
|
||||
<div class="cell-label"><span class="status-dot dot-sub"></span> <span id="statusLabel3" data-i18n="statusOffline">离线</span> <span id="statusPct3"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -212,7 +212,7 @@
|
||||
<!-- Card 2: 重点区域 TOP 排名 (Liquid Glass Ranking List) -->
|
||||
<div class="glass-panel sidebar-card flex-fill-auto">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n="panelRankTitle">重点区域运营 TOP 排名</span>
|
||||
<span class="panel-title" id="panelRankTitle">车辆累计里程 TOP 排名</span>
|
||||
<div class="glass-tab-control">
|
||||
<button class="gtab active" onclick="switchRankTab('fleet')" data-i18n="rankByFleet">按车辆</button>
|
||||
<button class="gtab" onclick="switchRankTab('dist')" data-i18n="rankByDist">按里程</button>
|
||||
|
||||
+3
-2
@@ -4,8 +4,9 @@
|
||||
"description": "羚牛氢能 - 全国氢能物流运营驾驶舱 (Apple Design Spec)",
|
||||
"main": "index.html",
|
||||
"scripts": {
|
||||
"start": "python3 -m http.server 8080",
|
||||
"dev": "python3 -m http.server 8080"
|
||||
"start": "python3 server.py",
|
||||
"dev": "VEHICLE_MAP_PORT=20800 python3 server.py",
|
||||
"test": "python3 -m unittest discover -s tests -v"
|
||||
},
|
||||
"keywords": ["lingniu", "hydrogen", "map", "cockpit"],
|
||||
"author": "Antigravity",
|
||||
|
||||
@@ -1,22 +1,178 @@
|
||||
import http.server
|
||||
import socketserver
|
||||
import socket
|
||||
"""Vehicle Map static server and server-side proxy for the vehicle open platform."""
|
||||
|
||||
PORT = 8080
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
from socketserver import ThreadingMixIn
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
class ReusableTCPServer(socketserver.TCPServer):
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
HOST = os.getenv("VEHICLE_MAP_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("VEHICLE_MAP_PORT", "20800"))
|
||||
OPEN_PLATFORM_BASE_URL = os.getenv("OPEN_PLATFORM_BASE_URL", "https://open.d.lnoneos.com").rstrip("/")
|
||||
OPEN_PLATFORM_APP_KEY = os.getenv("OPEN_PLATFORM_APP_KEY", "").strip()
|
||||
UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "15"))
|
||||
DASHBOARD_CACHE_SECONDS = int(os.getenv("DASHBOARD_CACHE_SECONDS", "20"))
|
||||
STATION_CACHE_SECONDS = int(os.getenv("STATION_CACHE_SECONDS", "3600"))
|
||||
|
||||
_cache_lock = threading.Lock()
|
||||
_cache = {}
|
||||
|
||||
|
||||
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
Handler = http.server.SimpleHTTPRequestHandler
|
||||
|
||||
# Explicitly bind to IPv4 address 0.0.0.0 so all LAN devices can connect
|
||||
try:
|
||||
with ReusableTCPServer(("0.0.0.0", PORT), Handler) as httpd:
|
||||
print(f"==================================================")
|
||||
print(f" 羚牛氢能驾驶舱 - 局域网 (IPv4) 服务已建立")
|
||||
print(f" 本机访问: http://localhost:{PORT}")
|
||||
print(f" 局域网访问: http://192.168.110.62:{PORT}")
|
||||
print(f"==================================================")
|
||||
httpd.serve_forever()
|
||||
except Exception as e:
|
||||
print(f"Server error: {e}")
|
||||
def _json_bytes(value):
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def _post_open_platform(path, body):
|
||||
if not OPEN_PLATFORM_APP_KEY:
|
||||
raise RuntimeError("OPEN_PLATFORM_APP_KEY is not configured")
|
||||
request = Request(
|
||||
OPEN_PLATFORM_BASE_URL + path,
|
||||
data=_json_bytes(body),
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": "Bearer " + OPEN_PLATFORM_APP_KEY,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "lingniu-vehicle-map/1.0",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=UPSTREAM_TIMEOUT_SECONDS) as response:
|
||||
payload = json.load(response)
|
||||
except HTTPError as exc:
|
||||
detail = exc.read(2048).decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"open platform returned HTTP {exc.code}: {detail}") from exc
|
||||
except URLError as exc:
|
||||
raise RuntimeError(f"open platform unavailable: {exc.reason}") from exc
|
||||
if payload.get("code") != "SUCCESS":
|
||||
raise RuntimeError(f"open platform rejected request: {payload.get('code')} {payload.get('message')}")
|
||||
return payload.get("data") or []
|
||||
|
||||
|
||||
def _cached(key, ttl_seconds, loader):
|
||||
now = time.time()
|
||||
with _cache_lock:
|
||||
cached = _cache.get(key)
|
||||
if cached and now - cached[0] < ttl_seconds:
|
||||
return cached[1]
|
||||
value = loader()
|
||||
with _cache_lock:
|
||||
_cache[key] = (now, value)
|
||||
return value
|
||||
|
||||
|
||||
def _load_dashboard():
|
||||
today = time.strftime("%Y-%m-%d", time.localtime())
|
||||
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||
realtime_future = executor.submit(
|
||||
_post_open_platform, "/api/v1/vehicles/realtime/query", {}
|
||||
)
|
||||
mileage_future = executor.submit(
|
||||
_post_open_platform, "/api/v1/vehicles/mileage/query", {"date": today}
|
||||
)
|
||||
stations_future = executor.submit(
|
||||
_cached,
|
||||
"stations",
|
||||
STATION_CACHE_SECONDS,
|
||||
lambda: _post_open_platform("/api/v1/hydrogen-stations/query", {}),
|
||||
)
|
||||
vehicles = realtime_future.result()
|
||||
mileage_rows = mileage_future.result()
|
||||
stations = stations_future.result()
|
||||
|
||||
mileage_by_vin = {
|
||||
row.get("vin"): row
|
||||
for row in mileage_rows
|
||||
if row.get("vin") and row.get("status") == "NORMAL"
|
||||
}
|
||||
driving = sum(1 for item in vehicles if item.get("motionStatus") == "driving")
|
||||
idle = sum(1 for item in vehicles if item.get("motionStatus") == "idle")
|
||||
offline = sum(1 for item in vehicles if item.get("motionStatus") == "offline")
|
||||
daily_mileage = round(
|
||||
sum(float(row.get("dailyMileageKm") or 0) for row in mileage_rows), 3
|
||||
)
|
||||
for vehicle in vehicles:
|
||||
mileage = mileage_by_vin.get(vehicle.get("vin"), {})
|
||||
vehicle["dailyMileageKm"] = mileage.get("dailyMileageKm", 0)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"asOf": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||
"date": today,
|
||||
"summary": {
|
||||
"totalVehicles": len(vehicles),
|
||||
"onlineVehicles": driving + idle,
|
||||
"drivingVehicles": driving,
|
||||
"idleVehicles": idle,
|
||||
"offlineVehicles": offline,
|
||||
"todayMileageKm": daily_mileage,
|
||||
"totalStations": len(stations),
|
||||
"cooperativeStations": sum(1 for item in stations if item.get("cooperative")),
|
||||
},
|
||||
"vehicles": vehicles,
|
||||
"stations": stations,
|
||||
}
|
||||
|
||||
|
||||
class VehicleMapHandler(SimpleHTTPRequestHandler):
|
||||
server_version = "LingniuVehicleMap/1.0"
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/api/health":
|
||||
self._write_json(200, {
|
||||
"status": "ok",
|
||||
"service": "vehicle-map",
|
||||
"openPlatformConfigured": bool(OPEN_PLATFORM_APP_KEY),
|
||||
})
|
||||
return
|
||||
if self.path == "/api/dashboard":
|
||||
try:
|
||||
payload = _cached("dashboard", DASHBOARD_CACHE_SECONDS, _load_dashboard)
|
||||
self._write_json(200, payload)
|
||||
except Exception as exc: # keep upstream details server-side only
|
||||
self.log_error("dashboard refresh failed: %s", exc)
|
||||
self._write_json(502, {
|
||||
"status": "error",
|
||||
"code": "UPSTREAM_UNAVAILABLE",
|
||||
"message": "车辆数据暂时不可用,请稍后重试",
|
||||
})
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def end_headers(self):
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
self.send_header("X-Frame-Options", "SAMEORIGIN")
|
||||
super().end_headers()
|
||||
|
||||
def _write_json(self, status, payload):
|
||||
body = _json_bytes(payload)
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = ThreadingHTTPServer((HOST, PORT), VehicleMapHandler)
|
||||
print(f"Lingniu Vehicle Map listening on http://{HOST}:{PORT}")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
@@ -8,8 +8,9 @@ echo "=================================================="
|
||||
|
||||
IP_ADDR=$(ifconfig | grep "inet " | grep -v 127.0.0.1 | head -n 1 | awk '{print $2}')
|
||||
|
||||
echo "本机访问地址: http://localhost:8080"
|
||||
echo "局域网访问地址: http://${IP_ADDR}:8080"
|
||||
PORT="${VEHICLE_MAP_PORT:-20800}"
|
||||
echo "本机访问地址: http://localhost:${PORT}"
|
||||
echo "局域网访问地址: http://${IP_ADDR}:${PORT}"
|
||||
echo "=================================================="
|
||||
|
||||
python3 -m http.server 8080
|
||||
exec python3 server.py
|
||||
|
||||
+19
@@ -592,6 +592,25 @@ body {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.map-info-window {
|
||||
min-width: 190px;
|
||||
max-width: 300px;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
background: var(--glass-bg);
|
||||
color: var(--text-main);
|
||||
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.16);
|
||||
backdrop-filter: blur(18px);
|
||||
font: 12px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.map-info-window strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.province-info-badge-wrap:hover {
|
||||
z-index: 999 !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("vehicle_map_server", Path(__file__).parents[1] / "server.py")
|
||||
server = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(server)
|
||||
|
||||
|
||||
class DashboardTest(unittest.TestCase):
|
||||
def test_dashboard_aggregates_authorized_vehicle_and_station_data(self):
|
||||
def fake_post(path, body):
|
||||
if path.endswith("realtime/query"):
|
||||
return [
|
||||
{"vin": "VIN1", "motionStatus": "driving", "online": True},
|
||||
{"vin": "VIN2", "motionStatus": "idle", "online": True},
|
||||
{"vin": "VIN3", "motionStatus": "offline", "online": False},
|
||||
]
|
||||
if path.endswith("mileage/query"):
|
||||
return [
|
||||
{"vin": "VIN1", "status": "NORMAL", "dailyMileageKm": 12.345},
|
||||
{"vin": "VIN2", "status": "NORMAL", "dailyMileageKm": 7.655},
|
||||
{"vin": "VIN3", "status": "NO_DATA", "dailyMileageKm": None},
|
||||
]
|
||||
if path.endswith("hydrogen-stations/query"):
|
||||
return [{"id": "1", "cooperative": True}, {"id": "2", "cooperative": False}]
|
||||
raise AssertionError(path)
|
||||
|
||||
with patch.object(server, "_post_open_platform", side_effect=fake_post):
|
||||
with patch.object(server, "_cache", {}):
|
||||
result = server._load_dashboard()
|
||||
|
||||
self.assertEqual(result["summary"]["totalVehicles"], 3)
|
||||
self.assertEqual(result["summary"]["onlineVehicles"], 2)
|
||||
self.assertEqual(result["summary"]["drivingVehicles"], 1)
|
||||
self.assertEqual(result["summary"]["todayMileageKm"], 20.0)
|
||||
self.assertEqual(result["summary"]["totalStations"], 2)
|
||||
self.assertEqual(result["summary"]["cooperativeStations"], 1)
|
||||
self.assertEqual(result["vehicles"][0]["dailyMileageKm"], 12.345)
|
||||
self.assertEqual(result["vehicles"][2]["dailyMileageKm"], 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user