feat: connect vehicle map to open platform

This commit is contained in:
lingniu
2026-08-03 19:55:38 +08:00
parent d72f3442b2
commit 53506afbde
11 changed files with 585 additions and 422 deletions
+7
View File
@@ -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
+33
View File
@@ -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小时。
+214 -387
View File
@@ -1,443 +1,270 @@
/** /** Lingniu Vehicle Map — live data from the vehicle open platform. */
* 羚牛氢能 - 车辆网络 (Bilingual & Restrained Apple Engine)
* Key: 1868920ac8ff6b6f88dbe9fa2609c183
* SecurityCode: 0b54a41143bec162788d01deba851340
*/
// ==========================================
// BILINGUAL TRANSLATION DICTIONARY
// ==========================================
const i18n = { const i18n = {
zh: { zh: {
vehicleModeTitle: '车辆网络', vehicleModeTitle: '车辆网络', stationModeTitle: '加氢站网络',
stationModeTitle: '加氢站网络', kpiTotalFleet: '车辆总数', kpiOnlineFleet: '当前在线运行', kpiDailyMileage: '今日运营里程',
kpiTotalFleet: '车辆总数', unitVehicles: '辆', unitStations: '座', btnVehicle: '车辆', btnStation: '加氢站',
kpiOnlineFleet: '当前在线运行', themeDark: '深色', themeLight: '浅色', legendPrimary: '核心节点 (>100)',
kpiDailyMileage: '今日运营里程', legendMid: '重点节点 (20-100)', legendLow: '普通节点 (<20)', btnResetView: '复位视角',
unitVehicles: '', btn3dView: '3D/2D 视角', btnNationalView: '全国视图',
unitStations: '', mapFooterGis: '地图引擎: 羚牛氢能 GIS (AMap 3D Engine)',
btnVehicle: '车辆', mapFooterStatus: '数据来源: 羚牛车辆数据开放平台', hintNational: '视角: 全国运营态势',
btnStation: '加氢站', panelStatusVehicle: '车辆运营状态分布', panelStatusStation: '加氢站类型分布',
themeDark: '深色', panelRankVehicle: '车辆累计里程 TOP 排名', panelRankStation: '加氢站省份 TOP 排名',
themeLight: '浅色', statusRunning: '运行中', statusStopped: '静止中', statusOffline: '离线',
mapStatusText: '全域实时节点已同步 · 15个省份 · 42座加氢站', stationCooperative: '合作站', stationExternal: '外部站', stationTotal: '全部站点',
legendPrimary: '核心区域 (>100)', rankByFleet: '按数量', rankByDist: '按里程', onlineText: '在线', unitTail: '台'
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: '说明'
}, },
en: { en: {
vehicleModeTitle: 'Vehicle Network', vehicleModeTitle: 'Vehicle Network', stationModeTitle: 'H₂ Station Network',
stationModeTitle: 'H₂ Station Network', kpiTotalFleet: 'Total Fleet', kpiOnlineFleet: 'Active Online', kpiDailyMileage: 'Today Mileage',
kpiTotalFleet: 'Total Fleet', unitVehicles: 'units', unitStations: 'stations', btnVehicle: 'Fleet', btnStation: 'H₂ Stations',
kpiOnlineFleet: 'Active Online', themeDark: 'Dark', themeLight: 'Light', legendPrimary: 'Core (>100)',
kpiDailyMileage: 'Today Mileage', legendMid: 'Key (20-100)', legendLow: 'Standard (<20)', btnResetView: 'Reset View',
unitVehicles: 'units', btn3dView: '3D/2D View', btnNationalView: 'National View',
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',
mapFooterGis: 'Engine: Lingniu H₂ GIS (AMap 3D Engine)', mapFooterGis: 'Engine: Lingniu H₂ GIS (AMap 3D Engine)',
mapFooterStatus: 'Status: Secure Connected (AES-256)', mapFooterStatus: 'Source: Lingniu Vehicle Open Platform', hintNational: 'View: National Overview',
hintNational: 'View: National Overview', panelStatusVehicle: 'Fleet Status Breakdown', panelStatusStation: 'Station Type Breakdown',
hintProvince: 'View: {prov} Focus', panelRankVehicle: 'Top Vehicles by Odometer', panelRankStation: 'Top Provinces by Stations',
panelStatusTitle: 'Fleet Status Breakdown', statusRunning: 'Active', statusStopped: 'Idle', statusOffline: 'Offline',
panelRankTitle: 'TOP Regional Operations', stationCooperative: 'Partners', stationExternal: 'External', stationTotal: 'All Stations',
statusRunning: 'Active', rankByFleet: 'By Count', rankByDist: 'By Mileage', onlineText: 'Online', unitTail: 'units'
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'
} }
}; };
// 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 map = null;
let markerList = []; let markerList = [];
let is3DPitch = true; let is3DPitch = true;
let currentMode = 'vehicle'; // 'vehicle' | 'station' let currentMode = 'vehicle';
let currentTheme = 'theme-white'; // DEFAULT: LIGHT MODE (纯白/浅色模式) let currentTheme = 'theme-white';
let currentLang = 'zh'; // 'zh' | 'en' let currentLang = 'zh';
let selectedProvince = null;
let currentRankType = 'fleet'; 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', () => { document.addEventListener('DOMContentLoaded', () => {
initClock(); initClock();
initAMapInstance(); initAMapInstance();
renderRankingList(currentRankType); loadDashboard();
refreshTimer = window.setInterval(loadDashboard, 30000);
}); });
function initAMapInstance() { async function loadDashboard() {
if (typeof AMap === 'undefined') return; setDataState('loading');
try {
map = new AMap.Map('amapContainer', { const response = await fetch('/api/dashboard', { headers: { Accept: 'application/json' } });
zoom: 4.8, const payload = await response.json();
center: [108.948024, 34.263161], if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`);
viewMode: '3D', dashboard = payload;
pitch: 30, updateDashboardUI();
mapStyle: getAMapThemeStyle(currentTheme), setDataState('ready');
showBuildingBlock: false, } catch (error) {
showLabel: true console.error('dashboard refresh failed', error);
}); setDataState('error');
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;
} }
return name;
} }
// Render Restrained Province Markers function setDataState(state) {
function renderAMapMarkers() { const el = document.getElementById('mapStatusText');
if (!map) return; 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()); function initAMapInstance() {
markerList = []; 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; function updateDashboardUI() {
const cyanColor = getThemeColor('--accent-cyan', '#0284C7'); if (!dashboard) return;
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)');
const dict = i18n[currentLang]; 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 => { function updateStatusPanel() {
const isSelected = (selectedProvince === node.name); const dict = i18n[currentLang];
const dotColor = cyanColor; const summary = dashboard.summary;
const displayName = getLocalizedProv(node.name); 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'); const markerContent = document.createElement('div');
markerContent.className = 'province-info-badge-wrap'; markerContent.className = 'province-info-badge-wrap';
markerContent.innerHTML = ` const label = node.kind === 'station' ? (node.cooperative ? 'H₂ · 合作' : 'H₂') : node.kind === 'stationCluster' ? `${node.count}` : `${node.count}`;
<div class="province-info-card" style=" 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>`;
background: ${isSelected ? 'var(--pill-bg)' : cardBg}; const marker = new AMap.Marker({ position: node.lnglat, content: markerContent, offset: new AMap.Pixel(-30, -12), title: node.name });
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 infoWindow = new AMap.InfoWindow({ const infoWindow = new AMap.InfoWindow({
isCustom: true, isCustom: true,
content: ` 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>`,
<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>
`,
offset: new AMap.Pixel(0, -32) offset: new AMap.Pixel(0, -32)
}); });
marker.on('mouseover', () => infoWindow.open(map, node.lnglat)); marker.on('mouseover', () => infoWindow.open(map, node.lnglat));
marker.on('mouseout', () => infoWindow.close()); marker.on('mouseout', () => infoWindow.close());
marker.on('click', () => filterByProvince(node.name)); if (node.kind === 'stationCluster') marker.on('click', () => map.setZoomAndCenter(7.2, node.lnglat));
marker.setMap(map); markerList.push(marker);
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);
} }
function renderRankingList(type) { function renderRankingList(type) {
const box = document.getElementById('rankingListBox'); const box = document.getElementById('rankingListBox');
if (!box) return; if (!box || !dashboard) return;
box.innerHTML = ''; box.innerHTML = '';
let rows;
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
if (currentMode === 'vehicle') { if (currentMode === 'vehicle') {
document.getElementById('dashboardTitle').textContent = dict.vehicleModeTitle; rows = [...dashboard.vehicles]
document.getElementById('kpiFleetTotal').innerHTML = `1,024 <small>${dict.unitVehicles}</small>`; .filter(item => item.totalMileageKm != null)
document.getElementById('kpiFleetOnline').innerHTML = `823 <small>${dict.unitVehicles} (80.4%)</small>`; .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 { } else {
document.getElementById('dashboardTitle').textContent = dict.stationModeTitle; const provinces = new Map();
document.getElementById('kpiFleetTotal').innerHTML = `42 <small>${dict.unitStations}</small>`; for (const station of dashboard.stations) {
document.getElementById('kpiFleetOnline').innerHTML = `39 <small>${dict.unitStations} (92.8%)</small>`; 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 function switchRankTab(type) {
if (selectedProvince) { currentRankType = type;
const displayName = getLocalizedProv(selectedProvince); document.querySelectorAll('.gtab').forEach(button => button.classList.remove('active'));
document.getElementById('selectedRegionHint').textContent = dict.hintProvince.replace('{prov}', displayName); if (window.event?.target) window.event.target.classList.add('active');
} else { renderRankingList(type);
document.getElementById('selectedRegionHint').textContent = dict.hintNational;
}
renderRankingList(currentRankType);
renderAMapMarkers();
} }
// ==========================================
// MODE & BLACK/WHITE THEME SWITCHER
// ==========================================
function switchMode(mode) { function switchMode(mode) {
currentMode = 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'); function setLanguage(lang) {
const btnStation = document.getElementById('btnModeStation'); currentLang = lang;
document.querySelectorAll('.lang-switcher-bw .segment-btn').forEach(button => button.classList.remove('active'));
if (mode === 'vehicle') { document.querySelector(`.lang-${lang}-btn`)?.classList.add('active');
btnVehicle.classList.add('active'); const dict = i18n[lang];
btnStation.classList.remove('active'); document.querySelectorAll('[data-i18n]').forEach(element => {
document.getElementById('dashboardTitle').textContent = dict.vehicleModeTitle; const key = element.getAttribute('data-i18n'); if (dict[key]) element.textContent = dict[key];
document.getElementById('kpiFleetTotal').innerHTML = `1,024 <small>${dict.unitVehicles}</small>`; });
document.getElementById('kpiFleetOnline').innerHTML = `823 <small>${dict.unitVehicles} (80.4%)</small>`; updateDashboardUI();
} 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 setTheme(themeName) { function setTheme(themeName) {
currentTheme = themeName; currentTheme = themeName; document.body.className = 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');
document.querySelectorAll('.bw-btn').forEach(b => b.classList.remove('active')); if (map) { map.setMapStyle(getAMapThemeStyle(themeName)); renderAMapMarkers(); }
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);
} }
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char])); }
function initClock() { function initClock() {
const clockTime = document.getElementById('clockTime'); const clock = document.getElementById('clockTime');
function update() { const update = () => { if (clock) clock.textContent = new Date().toLocaleTimeString('zh-CN', { hour12: false }); };
const now = new Date(); update(); window.setInterval(update, 1000);
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);
} }
+51
View File
@@ -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
+22
View File
@@ -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
View File
@@ -83,15 +83,15 @@
<div class="header-kpi-group"> <div class="header-kpi-group">
<div class="kpi-glass-capsule"> <div class="kpi-glass-capsule">
<span class="capsule-lbl" data-i18n="kpiTotalFleet">车辆总数</span> <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>
<div class="kpi-glass-capsule primary-capsule"> <div class="kpi-glass-capsule primary-capsule">
<span class="capsule-lbl" data-i18n="kpiOnlineFleet">当前在线运行</span> <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>
<div class="kpi-glass-capsule"> <div class="kpi-glass-capsule">
<span class="capsule-lbl" data-i18n="kpiDailyMileage">今日运营里程</span> <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>
</div> </div>
@@ -146,7 +146,7 @@
<div class="map-top-bar"> <div class="map-top-bar">
<div class="status-glass-pill"> <div class="status-glass-pill">
<span class="pulse-ring"></span> <span class="pulse-ring"></span>
<span id="mapStatusText" data-i18n="mapStatusText">全域实时节点已同步 · 15个省份 · 42座加氢站</span> <span id="mapStatusText">正在同步开放平台数据…</span>
</div> </div>
<div class="map-legend-glass"> <div class="map-legend-glass">
@@ -182,22 +182,22 @@
<!-- Card 1: 车辆运营状态 (3 Clean States: 运行中, 静止中, 离线) --> <!-- Card 1: 车辆运营状态 (3 Clean States: 运行中, 静止中, 离线) -->
<div class="glass-panel sidebar-card"> <div class="glass-panel sidebar-card">
<div class="panel-header"> <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> <span class="panel-tag">STATUS</span>
</div> </div>
<div class="status-glass-grid3"> <div class="status-glass-grid3">
<div class="glass-cell cell-running"> <div class="glass-cell cell-running">
<div class="cell-num">542 <small data-i18n="unitVehicles"></small></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 data-i18n="statusRunning">运行中</span> (52.9%)</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>
<div class="glass-cell cell-stopped"> <div class="glass-cell cell-stopped">
<div class="cell-num">311 <small data-i18n="unitVehicles"></small></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 data-i18n="statusStopped">静止中</span> (30.4%)</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>
<div class="glass-cell cell-offline"> <div class="glass-cell cell-offline">
<div class="cell-num">171 <small data-i18n="unitVehicles"></small></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 data-i18n="statusOffline">离线</span> (16.7%)</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>
</div> </div>
@@ -212,7 +212,7 @@
<!-- Card 2: 重点区域 TOP 排名 (Liquid Glass Ranking List) --> <!-- Card 2: 重点区域 TOP 排名 (Liquid Glass Ranking List) -->
<div class="glass-panel sidebar-card flex-fill-auto"> <div class="glass-panel sidebar-card flex-fill-auto">
<div class="panel-header"> <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"> <div class="glass-tab-control">
<button class="gtab active" onclick="switchRankTab('fleet')" data-i18n="rankByFleet">按车辆</button> <button class="gtab active" onclick="switchRankTab('fleet')" data-i18n="rankByFleet">按车辆</button>
<button class="gtab" onclick="switchRankTab('dist')" data-i18n="rankByDist">按里程</button> <button class="gtab" onclick="switchRankTab('dist')" data-i18n="rankByDist">按里程</button>
+3 -2
View File
@@ -4,8 +4,9 @@
"description": "羚牛氢能 - 全国氢能物流运营驾驶舱 (Apple Design Spec)", "description": "羚牛氢能 - 全国氢能物流运营驾驶舱 (Apple Design Spec)",
"main": "index.html", "main": "index.html",
"scripts": { "scripts": {
"start": "python3 -m http.server 8080", "start": "python3 server.py",
"dev": "python3 -m http.server 8080" "dev": "VEHICLE_MAP_PORT=20800 python3 server.py",
"test": "python3 -m unittest discover -s tests -v"
}, },
"keywords": ["lingniu", "hydrogen", "map", "cockpit"], "keywords": ["lingniu", "hydrogen", "map", "cockpit"],
"author": "Antigravity", "author": "Antigravity",
+173 -17
View File
@@ -1,22 +1,178 @@
import http.server """Vehicle Map static server and server-side proxy for the vehicle open platform."""
import socketserver
import socket
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 allow_reuse_address = True
Handler = http.server.SimpleHTTPRequestHandler
# Explicitly bind to IPv4 address 0.0.0.0 so all LAN devices can connect def _json_bytes(value):
try: return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
with ReusableTCPServer(("0.0.0.0", PORT), Handler) as httpd:
print(f"==================================================")
print(f" 羚牛氢能驾驶舱 - 局域网 (IPv4) 服务已建立") def _post_open_platform(path, body):
print(f" 本机访问: http://localhost:{PORT}") if not OPEN_PLATFORM_APP_KEY:
print(f" 局域网访问: http://192.168.110.62:{PORT}") raise RuntimeError("OPEN_PLATFORM_APP_KEY is not configured")
print(f"==================================================") request = Request(
httpd.serve_forever() OPEN_PLATFORM_BASE_URL + path,
except Exception as e: data=_json_bytes(body),
print(f"Server error: {e}") 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()
+4 -3
View File
@@ -8,8 +8,9 @@ echo "=================================================="
IP_ADDR=$(ifconfig | grep "inet " | grep -v 127.0.0.1 | head -n 1 | awk '{print $2}') IP_ADDR=$(ifconfig | grep "inet " | grep -v 127.0.0.1 | head -n 1 | awk '{print $2}')
echo "本机访问地址: http://localhost:8080" PORT="${VEHICLE_MAP_PORT:-20800}"
echo "局域网访问地址: http://${IP_ADDR}:8080" echo "本机访问地址: http://localhost:${PORT}"
echo "局域网访问地址: http://${IP_ADDR}:${PORT}"
echo "==================================================" echo "=================================================="
python3 -m http.server 8080 exec python3 server.py
+19
View File
@@ -592,6 +592,25 @@ body {
z-index: 10; 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 { .province-info-badge-wrap:hover {
z-index: 999 !important; z-index: 999 !important;
} }
+46
View File
@@ -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()