From 53506afbdefa92a5775017cbe734b1d9f0ad44eb Mon Sep 17 00:00:00 2001 From: lingniu Date: Mon, 3 Aug 2026 19:55:38 +0800 Subject: [PATCH] feat: connect vehicle map to open platform --- .env.example | 7 + README.md | 33 ++ app.js | 603 ++++++++++------------------- deploy/install-release.sh | 51 +++ deploy/lingniu-vehicle-map.service | 22 ++ index.html | 24 +- package.json | 5 +- server.py | 190 ++++++++- start.sh | 7 +- styles.css | 19 + tests/test_server.py | 46 +++ 11 files changed, 585 insertions(+), 422 deletions(-) create mode 100644 .env.example create mode 100644 README.md create mode 100644 deploy/install-release.sh create mode 100644 deploy/lingniu-vehicle-map.service create mode 100644 tests/test_server.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b64074b --- /dev/null +++ b/.env.example @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f17afb2 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# 羚牛 Vehicle Map + +全国氢能车辆与加氢站运营驾驶舱。浏览器只访问本服务的 `/api/dashboard`,服务端使用开放平台 AppKey 获取其授权车辆、当日里程和资产库只读加氢站点位,AppKey 不会下发到浏览器。 + +## 当前部署 + +- ECS 服务:`lingniu-vehicle-map.service` +- 访问地址: +- 健康检查: + +原始仓库只有静态页面和静态文件服务,没有车辆数据接口;当前版本已改为通过服务端代理对接车辆数据开放平台,页面展示的数据不再使用硬编码车辆和加氢站样例。 + +## 本地运行 + +```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 仅保存在 ECS 的 `/opt/lingniu-vehicle-map/env/vehicle-map.env`,文件权限为 `root:vehicle-map 0640`。 + +本服务提供: + +- `GET /api/health`:进程及配置状态; +- `GET /api/dashboard`:面向前端的聚合数据,默认缓存20秒;加氢站缓存1小时。 diff --git a/app.js b/app.js index 73838fb..a05fd1c 100644 --- a/app.js +++ b/app.js @@ -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)} ${unit}`; + document.getElementById('kpiFleetOnline').innerHTML = `${formatNumber(active)} ${unit} (${activeRate}%)`; + document.getElementById('kpiDailyDist').innerHTML = `${formatNumber(summary.todayMileageKm, 1)} km`; + 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])} ${currentMode === 'vehicle' ? dict.unitVehicles : dict.unitStations}`; + 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 = ` -
-
- - ${displayName} - ${node.count} -
-
- ${dict.onlineText} ${node.online} -
-
- `; - - 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 = `
${escapeHTML(node.name)}${escapeHTML(label)}
${node.kind === 'vehicle' ? `
${dict.onlineText} ${node.online}
` : ''}
`; + 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: ` -
-
- ${overviewTitle} -
-
-
${dict.fleetTotalLabel}: ${node.count}
-
${dict.onlineRunLabel}: ${node.online} ${dict.unitTail}
-
${dict.descLabel}: ${node.detail}
-
-
- `, + content: `
${escapeHTML(node.name)}
${escapeHTML(node.detail || '')}
${node.kind === 'vehicle' ? `
${dict.onlineText}: ${node.online}/${node.count} · ${formatNumber(node.dist, 1)} km
` : ''}
`, 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 = ` - ${idx + 1} - ${displayName} - ${valText} - `; - 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 ${dict.unitVehicles}`; - document.getElementById('kpiFleetOnline').innerHTML = `823 ${dict.unitVehicles} (80.4%)`; + 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 ${dict.unitStations}`; - document.getElementById('kpiFleetOnline').innerHTML = `39 ${dict.unitStations} (92.8%)`; + 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 = `${index + 1}${escapeHTML(item.name)}${escapeHTML(item.value)}`; + 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 ${dict.unitVehicles}`; - document.getElementById('kpiFleetOnline').innerHTML = `823 ${dict.unitVehicles} (80.4%)`; - } else { - btnStation.classList.add('active'); - btnVehicle.classList.remove('active'); - document.getElementById('dashboardTitle').textContent = dict.stationModeTitle; - document.getElementById('kpiFleetTotal').innerHTML = `42 ${dict.unitStations}`; - document.getElementById('kpiFleetOnline').innerHTML = `39 ${dict.unitStations} (92.8%)`; - } - - renderAMapMarkers(); +function setLanguage(lang) { + currentLang = lang; + document.querySelectorAll('.lang-switcher-bw .segment-btn').forEach(button => button.classList.remove('active')); + document.querySelector(`.lang-${lang}-btn`)?.classList.add('active'); + const dict = i18n[lang]; + document.querySelectorAll('[data-i18n]').forEach(element => { + const key = element.getAttribute('data-i18n'); if (dict[key]) element.textContent = dict[key]; + }); + updateDashboardUI(); } function setTheme(themeName) { - currentTheme = themeName; - document.body.className = themeName; - - document.querySelectorAll('.bw-btn').forEach(b => b.classList.remove('active')); - if (themeName === 'theme-dark') document.querySelector('.theme-dark-btn')?.classList.add('active'); - if (themeName === 'theme-white') document.querySelector('.theme-white-btn')?.classList.add('active'); - - if (map) { - map.setMapStyle(getAMapThemeStyle(themeName)); - renderAMapMarkers(); - } -} - -function getThemeColor(varName, fallback) { - const val = getComputedStyle(document.body).getPropertyValue(varName).trim(); - return val || fallback; -} - -function resetMapView() { - if (map) { - map.setZoomAndCenter(4.8, [108.948024, 34.263161]); - map.setPitch(30); - } -} - -function togglePitchView() { - if (!map) return; - is3DPitch = !is3DPitch; - map.setPitch(is3DPitch ? 30 : 0); + currentTheme = themeName; document.body.className = themeName; + document.querySelectorAll('.bw-btn').forEach(button => button.classList.remove('active')); + document.querySelector(themeName === 'theme-dark' ? '.theme-dark-btn' : '.theme-white-btn')?.classList.add('active'); + if (map) { map.setMapStyle(getAMapThemeStyle(themeName)); renderAMapMarkers(); } } +function getAMapThemeStyle(theme) { return theme === 'theme-white' ? 'amap://styles/light' : 'amap://styles/darkblue'; } +function resetMapView() { if (map) { map.setZoomAndCenter(4.8, [108.948024, 34.263161]); map.setPitch(30); } } +function clearProvinceFilter() { resetMapView(); } +function togglePitchView() { if (map) { is3DPitch = !is3DPitch; map.setPitch(is3DPitch ? 30 : 0); } } +function formatNumber(value, digits = 0) { return Number(value || 0).toLocaleString(currentLang === 'zh' ? 'zh-CN' : 'en-US', { maximumFractionDigits: digits, minimumFractionDigits: digits }); } +function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); } function initClock() { - const clockTime = document.getElementById('clockTime'); - function update() { - const now = new Date(); - const hrs = String(now.getHours()).padStart(2, '0'); - const mins = String(now.getMinutes()).padStart(2, '0'); - const secs = String(now.getSeconds()).padStart(2, '0'); - if (clockTime) clockTime.textContent = `${hrs}:${mins}:${secs}`; - } - update(); - setInterval(update, 1000); + const clock = document.getElementById('clockTime'); + const update = () => { if (clock) clock.textContent = new Date().toLocaleTimeString('zh-CN', { hour12: false }); }; + update(); window.setInterval(update, 1000); } diff --git a/deploy/install-release.sh b/deploy/install-release.sh new file mode 100644 index 0000000..c522db8 --- /dev/null +++ b/deploy/install-release.sh @@ -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 diff --git a/deploy/lingniu-vehicle-map.service b/deploy/lingniu-vehicle-map.service new file mode 100644 index 0000000..92c20f0 --- /dev/null +++ b/deploy/lingniu-vehicle-map.service @@ -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 diff --git a/index.html b/index.html index 73cd0f0..3279011 100644 --- a/index.html +++ b/index.html @@ -83,15 +83,15 @@
车辆总数 - 1,024 + --
当前在线运行 - 823 辆 (80.4%) + --
今日运营里程 - 512,178 km ↑6.2% + -- km
@@ -146,7 +146,7 @@
- 全域实时节点已同步 · 15个省份 · 42座加氢站 + 正在同步开放平台数据…
@@ -182,22 +182,22 @@