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小时。
+215 -388
View File
@@ -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;
markerList.forEach(m => m.remove());
markerList = [];
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';
}
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 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);
}
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];
const btnVehicle = document.getElementById('btnModeVehicle');
const btnStation = document.getElementById('btnModeStation');
document.getElementById('btnModeVehicle').classList.toggle('active', mode === 'vehicle');
document.getElementById('btnModeStation').classList.toggle('active', mode === 'station');
updateDashboardUI();
}
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[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);
}
+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="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
View File
@@ -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",
+173 -17
View File
@@ -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()
+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}')
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
View File
@@ -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;
}
+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()