Compare commits
15
Commits
go
...
861e0bf207
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
861e0bf207 | ||
|
|
c81cfcb377 | ||
|
|
5b565d57fc | ||
|
|
4c547757a7 | ||
|
|
f707e6ad68 | ||
|
|
1403b84b0b | ||
|
|
9452e30538 | ||
|
|
6ef98d03c2 | ||
|
|
357dd490e9 | ||
|
|
d1ba129e99 | ||
|
|
d4593c44cd | ||
|
|
f535b1570b | ||
|
|
c42570c0ee | ||
|
|
22f3904ad7 | ||
|
|
8539af13e6 |
@@ -0,0 +1,35 @@
|
||||
# 羚牛氢能 · 加氢站导航
|
||||
|
||||
面向运维和司机调度的独立公共站点导航服务,默认本地端口为 `20804`。
|
||||
|
||||
它复用运营地图已经按 GPS 逆地理解析、落盘缓存的站点目录,并只对外提供:名称、位置、省市区、地址与合作状态。车辆数据、加氢量及其他运营字段不会被加载或从这个服务的 API 返回。
|
||||
|
||||
## 本地运行
|
||||
|
||||
```bash
|
||||
export OPEN_PLATFORM_APP_KEY='<32位AppKey>'
|
||||
./start-local.sh
|
||||
```
|
||||
|
||||
访问 <http://127.0.0.1:20804/>。
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/stations`:公开站点目录,不包含加氢量字段;
|
||||
- `GET /api/health`:进程健康状态;
|
||||
- 其他 `/api/*` 路由均返回 404。
|
||||
|
||||
站点目录在服务端缓存 2 分钟,并在服务启动后后台预热;因此正常页面打开直接返回缓存数据,最多延迟 2 分钟更新一次。省、市、区由运营地图根据 GCJ-02 坐标调用高德逆地理服务生成,结果按坐标缓存 7 天;详细地址仍直接展示资产原始字段。
|
||||
|
||||
## ECS 部署
|
||||
|
||||
服务运行在 `20804`,与运营地图端口隔离。部署单元复用
|
||||
`/opt/lingniu-vehicle-map/env/vehicle-map.env` 中已有的 Open Platform 凭据,并通过
|
||||
`VEHICLE_MAP_ASSET_ROOT=/opt/lingniu-vehicle-map/current` 复用一致的品牌样式。
|
||||
|
||||
```bash
|
||||
install -m 0644 deploy/lingniu-station-navigation.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now lingniu-station-navigation.service
|
||||
deploy/install-release.sh <release-id> <release.tgz>
|
||||
```
|
||||
@@ -0,0 +1,264 @@
|
||||
const state = { stations: [], query: '', searchText: '', locationFilters: [], suggestions: [], activeSuggestion: -1, searchDebounce: null, stationFilter: 'all', selected: null, userLocation: null, map: null, markers: [], userMarker: null, pitch: true, regionPicker: null };
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => { initClock(); initMap(); loadStations(); });
|
||||
document.addEventListener('pointerdown', event => { if (!event.target?.closest?.('.map-explore-toolbar')) closeStationSearchSuggestions(); });
|
||||
|
||||
async function loadStations() {
|
||||
setStatus('正在同步加氢站数据…');
|
||||
try {
|
||||
const response = await fetch('/api/stations', { headers: { Accept: 'application/json' } });
|
||||
const payload = await response.json();
|
||||
if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`);
|
||||
state.stations = payload.stations.filter(hasCoordinates);
|
||||
updateSummary(payload.summary);
|
||||
setStatus(`站点数据已同步 · ${formatNumber(payload.summary.totalStations)} 座加氢站 · ${payload.asOf}`);
|
||||
render();
|
||||
} catch (error) {
|
||||
console.error('station directory refresh failed', error);
|
||||
setStatus('站点数据同步失败 · 将自动重试', true);
|
||||
}
|
||||
}
|
||||
|
||||
function initMap() {
|
||||
if (typeof AMap === 'undefined') { setStatus('地图组件加载失败', true); return; }
|
||||
state.map = new AMap.Map('amapContainer', { zoom: 4.8, center: [108.948024, 34.263161], viewMode: '3D', pitch: 30, mapStyle: 'amap://styles/light', showBuildingBlock: false, showLabel: true });
|
||||
state.map.on('complete', render);
|
||||
state.map.on('zoomend', render);
|
||||
state.map.on('moveend', render);
|
||||
}
|
||||
|
||||
function updateSummary(summary) {
|
||||
const total = Number(summary.totalStations || 0), partner = Number(summary.cooperativeStations || 0), external = Math.max(0, total - partner);
|
||||
setHTML('kpiTotal', `${formatNumber(total)} <small>座</small>`); setHTML('kpiPartner', `${formatNumber(partner)} <small>座</small>`);
|
||||
setHTML('statusTotal', `${formatNumber(total)} <small>座</small>`); setHTML('statusPartner', `${formatNumber(partner)} <small>座</small>`); setHTML('statusExternal', `${formatNumber(external)} <small>座</small>`);
|
||||
setHTML('mobileStatusTotal', `${formatNumber(total)} <small>座</small>`); setHTML('mobileStatusPartner', `${formatNumber(partner)} <small>座</small>`); setHTML('mobileStatusExternal', `${formatNumber(external)} <small>座</small>`);
|
||||
document.getElementById('partnerSegment').style.width = `${total ? partner * 100 / total : 0}%`;
|
||||
document.getElementById('externalSegment').style.width = `${total ? external * 100 / total : 0}%`;
|
||||
document.getElementById('mobilePartnerSegment').style.width = `${total ? partner * 100 / total : 0}%`;
|
||||
document.getElementById('mobileExternalSegment').style.width = `${total ? external * 100 / total : 0}%`;
|
||||
}
|
||||
|
||||
function stationMatchesQuery(station, query = state.query) { const needle = normalize(query); return !needle || [station.name, station.shortName, station.address].some(value => normalize(value).includes(needle)); }
|
||||
function locationFilterKey(filter) { return [filter.level || 'location', filter.province || '', filter.city || '', filter.district || ''].join('|'); }
|
||||
function stationMatchesLocationFilter(station, filter) { return (!filter.province || station.province === filter.province) && (!filter.city || station.city === filter.city) && (!filter.district || stationDistrictName(station) === filter.district); }
|
||||
function stationMatchesLocation(station) { return !state.locationFilters.length || state.locationFilters.some(filter => stationMatchesLocationFilter(station, filter)); }
|
||||
function stationTypeScope() { return state.stations.filter(station => state.stationFilter !== 'partner' || station.cooperative); }
|
||||
function filteredStations() { return stationTypeScope().filter(station => stationMatchesLocation(station) && stationMatchesQuery(station)); }
|
||||
function stationSearchScore(station, query) {
|
||||
const fields = [station.name, station.shortName, station.address].filter(Boolean);
|
||||
for (const value of fields) { const text = normalize(value); if (text.includes(query)) return 3; const pinyin = toPinyin(value); if (pinyin.includes(query) || initials(pinyin).includes(query)) return 2; }
|
||||
return 0;
|
||||
}
|
||||
function toPinyin(value) { const converter = globalThis.pinyinPro?.pinyin; return converter ? normalize(converter(String(value), { toneType: 'none', separator: ' ' })) : ''; }
|
||||
function initials(value) { return String(value).split(/\s+/).map(word => word[0] || '').join(''); }
|
||||
function normalize(value) { return String(value || '').trim().toLocaleLowerCase(); }
|
||||
function hasCoordinates(station) { return Number.isFinite(Number(station.longitude)) && Number.isFinite(Number(station.latitude)); }
|
||||
function coords(station) { return [Number(station.longitude), Number(station.latitude)]; }
|
||||
function stationDistrictName(station) { if (station?.district) return String(station.district).trim(); let address = String(station?.address || '').trim(); for (const prefix of [station?.province, station?.city]) { const normalized = String(prefix || '').trim(); if (normalized) address = address.split(normalized).join(''); } return address.match(/^(.{2,10}?(?:区|县|旗|市))/)?.[1] || ''; }
|
||||
|
||||
function render() { renderMarkers(); renderList(); }
|
||||
function renderMarkers() {
|
||||
if (!state.map || typeof AMap === 'undefined') return;
|
||||
state.markers.forEach(marker => marker.remove()); state.markers = [];
|
||||
const nodes = stationNodes(true);
|
||||
nodes.forEach(node => {
|
||||
const point = node.kind === 'station', content = document.createElement('div');
|
||||
content.className = 'province-info-badge-wrap';
|
||||
const label = point ? (node.cooperative ? 'H₂ · 合作' : '') : `${formatNumber(node.count)} 座`;
|
||||
const subline = point ? node.adminPath : '';
|
||||
content.innerHTML = `<div class="province-info-card${point ? ` is-station-point${node.cooperative ? '' : ' is-external'}${state.selected?.id === node.station.id ? ' is-selected' : ''}` : ''}"><div class="p-head"><span class="p-name">${escapeHTML(node.name)}</span>${label ? `<span class="p-total">${escapeHTML(label)}</span>` : ''}</div>${subline ? `<div class="p-sub">${escapeHTML(subline)}</div>` : ''}</div>`;
|
||||
const marker = new AMap.Marker({ position: node.lnglat, content, offset: new AMap.Pixel(-30, -12), title: node.name, zIndex: point ? 20 : 10 });
|
||||
marker.on('click', () => { if (point) selectStation(node.station, true); else state.map.setZoomAndCenter(nextZoomForLevel(node.level), node.lnglat); }); marker.setMap(state.map); state.markers.push(marker);
|
||||
});
|
||||
renderUserMarker();
|
||||
}
|
||||
function stationHierarchyLevel(zoom = state.map?.getZoom?.() || 0) {
|
||||
if (normalize(state.query)) return 'station';
|
||||
if (zoom < 7) return 'province';
|
||||
if (zoom < 11) return 'city';
|
||||
return 'station';
|
||||
}
|
||||
function stationGroupName(station, level) {
|
||||
const raw = (level === 'province' ? station.province : station.city) || '未标注区域';
|
||||
return level === 'province' ? String(raw).replace(/(壮族|回族|维吾尔)?自治区$|特别行政区$|省$|市$/, '') : String(raw).replace(/自治州$|地区$|林区$|[盟区县市]$/, '');
|
||||
}
|
||||
function buildStationNodes(stations = filteredStations(), level = stationHierarchyLevel()) {
|
||||
if (level === 'station') return stations.map(station => ({ id: `station-${station.id}`, kind: 'station', level, name: station.name || station.shortName || '未命名站点', lnglat: coords(station), station, count: 1, online: station.cooperative ? 1 : 0, adminPath: stationRegion(station), detail: [stationRegion(station), station.address].filter(Boolean).join(' · '), cooperative: Boolean(station.cooperative) }));
|
||||
const grouped = new Map();
|
||||
for (const station of stations) {
|
||||
const key = (level === 'province' ? station.province : `${station.province || ''}/${station.city || ''}`) || 'UNSPECIFIED';
|
||||
const group = grouped.get(key) || { kind: level === 'province' ? 'stationProvince' : 'stationCity', level, name: stationGroupName(station, level), lng: 0, lat: 0, count: 0, online: 0, stations: [] };
|
||||
group.lng += Number(station.longitude); group.lat += Number(station.latitude); group.count += 1; group.online += station.cooperative ? 1 : 0; group.stations.push(station); grouped.set(key, group);
|
||||
}
|
||||
return [...grouped.values()].map((group, index) => ({ ...group, id: `station-${level}-${index}`, lnglat: [group.lng / group.count, group.lat / group.count], detail: group.stations.slice(0, 5).map(station => station.name).join('、') }));
|
||||
}
|
||||
function currentMapBounds() { const bounds = state.map?.getBounds?.(), sw = bounds?.getSouthWest?.(), ne = bounds?.getNorthEast?.(); if (!sw || !ne) return null; return { west: sw.getLng(), south: sw.getLat(), east: ne.getLng(), north: ne.getLat() }; }
|
||||
function nodesInsideCurrentMapBounds(nodes) { const bounds = currentMapBounds(); if (!bounds) return nodes; return nodes.filter(node => node.lnglat[0] >= bounds.west && node.lnglat[0] <= bounds.east && node.lnglat[1] >= bounds.south && node.lnglat[1] <= bounds.north); }
|
||||
function stationNodes(visibleOnly = false) { const nodes = buildStationNodes(); return visibleOnly && !normalize(state.query) ? nodesInsideCurrentMapBounds(nodes) : nodes; }
|
||||
function stationViewportSummary() { const level = stationHierarchyLevel(), allNodes = stationNodes(false), visibleNodes = normalize(state.query) ? allNodes : nodesInsideCurrentMapBounds(allNodes); return { level, allNodes, visibleNodes }; }
|
||||
function nextZoomForLevel(level) { return level === 'province' ? 7.2 : level === 'city' ? 11.2 : 14; }
|
||||
|
||||
function renderList() {
|
||||
const box = document.getElementById('stationList'); if (!box) return;
|
||||
const { level, allNodes, visibleNodes } = stationViewportSummary();
|
||||
const display = [...visibleNodes].sort(compareNodes);
|
||||
document.getElementById('resultMeta').textContent = `视野 ${formatNumber(visibleNodes.reduce((sum, node) => sum + node.count, 0))} / ${formatNumber(filteredStations().length)}`;
|
||||
document.getElementById('rankViewportMeta').textContent = `当前视野 ${formatNumber(visibleNodes.length)} · 全部 ${formatNumber(allNodes.length)}`;
|
||||
document.getElementById('panelRankTitle').textContent = state.userLocation && level === 'station' ? `附近${state.stationFilter === 'partner' ? '合作' : ''}加氢站` : level === 'province' ? '加氢站省份 TOP 排名' : level === 'city' ? '加氢站城市 TOP 排名' : '加氢站 TOP 排名';
|
||||
if (!display.length) { box.innerHTML = '<div class="rank-empty">未找到匹配站点<br>请尝试名称、地址、城市或拼音首字母</div>'; return; }
|
||||
box.innerHTML = display.slice(0, 100).map((node, index) => { const distance = node.kind === 'station' ? stationDistance(node.station) : Number.POSITIVE_INFINITY, partnerTag = node.kind === 'station' ? `<span class="station-list-partner-tag${node.cooperative ? '' : ' is-placeholder'}"${node.cooperative ? '' : ' aria-hidden="true"'}>${node.cooperative ? '合作' : ''}</span>` : '', rowValue = Number.isFinite(distance) ? `${formatNumber(distance, 1)} km` : node.kind === 'station' ? '' : `${formatNumber(node.count)} 座`; return `<button type="button" class="rank-glass-row${node.kind === 'station' && state.selected?.id === node.station.id ? ' is-selected' : ''}" data-node-id="${escapeAttribute(node.id)}"><span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${partnerTag}<span class="station-list-name">${escapeHTML(node.name)}</span></span>${rowValue ? `<span class="r-val">${rowValue}</span>` : ''}</button>`; }).join('');
|
||||
box.querySelectorAll('[data-node-id]').forEach(button => button.addEventListener('click', () => { const node = display.find(item => item.id === button.dataset.nodeId); if (node?.kind === 'station') selectStation(node.station, true); else if (node) state.map?.setZoomAndCenter(nextZoomForLevel(node.level), node.lnglat); }));
|
||||
}
|
||||
function compareNodes(left, right) { if (state.userLocation && left.kind === 'station' && right.kind === 'station') return stationDistance(left.station) - stationDistance(right.station); return right.count - left.count || String(left.name || '').localeCompare(String(right.name || ''), 'zh-CN'); }
|
||||
|
||||
function fuzzySearchScore(value, query) {
|
||||
const needle = normalize(query); if (!needle) return 4;
|
||||
const text = normalize(value); if (text === needle) return 0; if (text.startsWith(needle)) return 1; if (text.includes(needle)) return 2;
|
||||
const pinyin = toPinyin(value); if (pinyin === needle || pinyin.startsWith(needle) || initials(pinyin).startsWith(needle)) return 3;
|
||||
return pinyin.includes(needle) || initials(pinyin).includes(needle) ? 4 : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
function compactPath(parts) { return parts.filter(Boolean).join(' · '); }
|
||||
function stationLocationOptions() {
|
||||
const groups = new Map();
|
||||
const add = (level, station) => {
|
||||
const province = station.province || '', city = station.city || '', district = station.district || '';
|
||||
const key = level === 'province' ? `province|${province}` : level === 'city' ? `city|${province}|${city}` : `district|${province}|${city}|${district}`;
|
||||
const filter = level === 'province' ? { province, city: '', district: '' } : level === 'city' ? { province, city, district: '' } : { province, city, district };
|
||||
if (!groups.has(key)) groups.set(key, { type: 'location', level, ...filter, stations: [] });
|
||||
groups.get(key).stations.push(station);
|
||||
};
|
||||
for (const station of stationTypeScope()) { const district = stationDistrictName(station); if (station.province) add('province', station); if (station.province && station.city) add('city', station); if (station.province && station.city && district) add('district', { ...station, district }); }
|
||||
return [...groups.values()].map(option => {
|
||||
const target = option.level === 'province' ? option.province : option.level === 'city' ? option.city : option.district;
|
||||
const longitude = option.stations.reduce((sum, station) => sum + Number(station.longitude), 0) / option.stations.length;
|
||||
const latitude = option.stations.reduce((sum, station) => sum + Number(station.latitude), 0) / option.stations.length;
|
||||
return { ...option, label: stationGroupName({ province: target, city: target }, option.level === 'province' ? 'province' : 'city'), path: compactPath([option.province, option.city, option.district]), lnglat: [longitude, latitude] };
|
||||
});
|
||||
}
|
||||
function stationLocationSuggestions(query) {
|
||||
return stationLocationOptions().map(option => ({ ...option, score: Math.min(fuzzySearchScore(option.label, query), fuzzySearchScore(option.path, query)) }))
|
||||
.filter(option => Number.isFinite(option.score)).sort((left, right) => left.score - right.score || left.label.localeCompare(right.label, 'zh-CN')).slice(0, normalize(query) ? 8 : 6);
|
||||
}
|
||||
function stationEntitySuggestions(query) {
|
||||
if (!normalize(query)) return [];
|
||||
return filteredStations().map(station => ({ type: 'station', station, score: stationSearchScore(station, query) }))
|
||||
.filter(item => item.score > 0).sort((left, right) => right.score - left.score || String(left.station.name || '').localeCompare(String(right.station.name || ''), 'zh-CN')).slice(0, 5);
|
||||
}
|
||||
function renderStationSearchSuggestions() {
|
||||
const box = document.getElementById('stationSearchSuggestions'); if (!box || box.hidden) return;
|
||||
state.suggestions = stationEntitySuggestions(state.searchText); state.activeSuggestion = Math.min(state.activeSuggestion, state.suggestions.length - 1);
|
||||
if (!state.suggestions.length) { box.innerHTML = '<span class="suggestion-empty">未找到匹配的站点或详细地址</span>'; return; }
|
||||
box.innerHTML = `<span class="suggestion-section-label">加氢站</span>${state.suggestions.map((item, index) => { const station = item.station; return `<button type="button" class="explore-suggestion${index === state.activeSuggestion ? ' is-active' : ''}" role="option" onclick="selectStationSearchSuggestion(${index})"><span class="suggestion-symbol">H₂</span><span class="suggestion-copy"><strong>${escapeHTML(station.name || station.shortName || '未命名站点')}</strong><small>${escapeHTML(station.address || stationRegion(station) || '')}</small></span><span class="suggestion-kind">加氢站</span></button>`; }).join('')}`;
|
||||
}
|
||||
function openStationSearchSuggestions() { const box = document.getElementById('stationSearchSuggestions'); if (!box) return; box.hidden = false; renderStationSearchSuggestions(); }
|
||||
function closeStationSearchSuggestions() { const box = document.getElementById('stationSearchSuggestions'); if (box) box.hidden = true; state.activeSuggestion = -1; }
|
||||
function locationFilterLabel(filter) { return filter.label || (filter.district ? stationGroupName({ city: filter.district }, 'city') : filter.city ? stationGroupName({ city: filter.city }, 'city') : stationGroupName({ province: filter.province }, 'province')); }
|
||||
function renderLocationFilterChips() {
|
||||
const rail = document.getElementById('selectedLocationFilters'); if (!rail) return;
|
||||
rail.hidden = !state.locationFilters.length;
|
||||
rail.innerHTML = state.locationFilters.map(filter => { const key = locationFilterKey(filter), label = locationFilterLabel(filter); return `<button class="selected-location-filter-chip" type="button" onclick="removeLocationFilter('${escapeAttribute(key)}')" aria-label="移除位置筛选 ${escapeAttribute(label)}"><span>${escapeHTML(label)}</span><b aria-hidden="true">×</b></button>`; }).join('');
|
||||
const count = document.getElementById('regionPickerTriggerCount'); if (count) { count.hidden = !state.locationFilters.length; count.textContent = state.locationFilters.length; }
|
||||
}
|
||||
function removeLocationFilter(key) {
|
||||
state.locationFilters = state.locationFilters.filter(filter => locationFilterKey(filter) !== key);
|
||||
state.selected = null; closeDetail(); renderLocationFilterChips(); render(); renderStationSearchSuggestions();
|
||||
document.getElementById('selectedRegionHint').textContent = state.locationFilters.length ? `视角: 已筛选 ${state.locationFilters.map(locationFilterLabel).join('、')}` : '视角: 全国加氢站网络';
|
||||
}
|
||||
function addLocationFilter(option) {
|
||||
const filter = { level: option.level, province: option.province, city: option.city, district: option.district, label: option.label };
|
||||
if (!state.locationFilters.some(current => locationFilterKey(current) === locationFilterKey(filter))) state.locationFilters.push(filter);
|
||||
renderLocationFilterChips();
|
||||
}
|
||||
function cloneLocationFilter(filter) { return { level: filter.level, province: filter.province || '', city: filter.city || '', district: filter.district || '', label: filter.label || '' }; }
|
||||
function openRegionPicker() {
|
||||
state.regionPicker = { level: 'province', province: '', city: '', search: '', draft: state.locationFilters.map(cloneLocationFilter), options: [] };
|
||||
const modal = document.getElementById('regionPickerModal'); if (modal) modal.hidden = false;
|
||||
renderRegionPicker(); window.setTimeout(() => document.getElementById('regionPickerSearch')?.focus(), 0);
|
||||
}
|
||||
function closeRegionPicker() { state.regionPicker = null; const modal = document.getElementById('regionPickerModal'); if (modal) modal.hidden = true; }
|
||||
function regionPickerOptions() {
|
||||
const picker = state.regionPicker; if (!picker) return [];
|
||||
const all = stationLocationOptions(); const needle = normalize(picker.search);
|
||||
if (needle) return all.map(option => ({ ...option, score: Math.min(fuzzySearchScore(option.label, needle), fuzzySearchScore(option.path, needle)) })).filter(option => Number.isFinite(option.score)).sort((left, right) => left.score - right.score || left.label.localeCompare(right.label, 'zh-CN')).slice(0, 80);
|
||||
if (picker.level === 'province') return all.filter(option => option.level === 'province');
|
||||
if (picker.level === 'city') return all.filter(option => option.level === 'city' && option.province === picker.province);
|
||||
return all.filter(option => option.level === 'district' && option.province === picker.province && option.city === picker.city);
|
||||
}
|
||||
function regionPickerOptionPath(option) {
|
||||
const parts = option.level === 'province' ? [option.province] : option.level === 'city' ? [option.province, option.city] : [option.province, option.city, option.district];
|
||||
return `${compactPath(parts)} · ${formatNumber(option.stations.length)} 座站点`;
|
||||
}
|
||||
function regionPickerOptionSelected(option) { return state.regionPicker?.draft.some(filter => locationFilterKey(filter) === locationFilterKey(option)); }
|
||||
function renderRegionPicker() {
|
||||
const picker = state.regionPicker; if (!picker) return;
|
||||
const search = document.getElementById('regionPickerSearch'); if (search && search.value !== picker.search) search.value = picker.search;
|
||||
const clear = document.getElementById('regionPickerSearchClear'); if (clear) clear.hidden = !picker.search;
|
||||
const selected = document.getElementById('regionPickerSelected');
|
||||
if (selected) selected.innerHTML = picker.draft.length ? `<span class="region-picker-selected-label">已选</span>${picker.draft.map((filter, index) => `<button type="button" class="region-picker-selected-chip" onclick="removeRegionPickerDraft(${index})">${escapeHTML(locationFilterLabel(filter))}<b aria-hidden="true">×</b></button>`).join('')}` : '<span class="region-picker-selected-placeholder">可同时选择多个省 / 市 / 区县</span>';
|
||||
const path = document.getElementById('regionPickerPath');
|
||||
if (path) {
|
||||
const crumbs = [{ label: '全部省份', level: 'province' }];
|
||||
if (picker.province) crumbs.push({ label: stationGroupName({ province: picker.province }, 'province'), level: 'city' });
|
||||
if (picker.city) crumbs.push({ label: stationGroupName({ city: picker.city }, 'city'), level: 'district' });
|
||||
path.innerHTML = picker.search ? '<span>搜索结果</span>' : crumbs.map((crumb, index) => `<button type="button" class="${index === crumbs.length - 1 ? 'is-current' : ''}" onclick="navigateRegionPicker('${crumb.level}')">${escapeHTML(crumb.label)}</button>`).join('<i>›</i>');
|
||||
}
|
||||
const options = regionPickerOptions(); picker.options = options;
|
||||
const list = document.getElementById('regionPickerList');
|
||||
if (list) list.innerHTML = options.length ? options.map((option, index) => {
|
||||
const selectedOption = regionPickerOptionSelected(option), canDescend = !picker.search && option.level !== 'district';
|
||||
return `<article class="region-picker-row${selectedOption ? ' is-selected' : ''}"><button type="button" class="region-picker-check" onclick="toggleRegionPickerOption(${index})" aria-label="${selectedOption ? '取消选择' : '选择'} ${escapeAttribute(option.path)}"><span>${selectedOption ? '✓' : ''}</span></button><button type="button" class="region-picker-row-main" onclick="toggleRegionPickerOption(${index})"><strong>${escapeHTML(option.label)}</strong><small>${escapeHTML(regionPickerOptionPath(option))}</small></button>${canDescend ? `<button type="button" class="region-picker-next" onclick="descendRegionPicker(${index})" aria-label="浏览${escapeAttribute(option.label)}下级">›</button>` : '<span class="region-picker-next is-empty"></span>'}</article>`;
|
||||
}).join('') : '<div class="region-picker-empty">没有可选择的运营区域</div>';
|
||||
const summary = document.getElementById('regionPickerSummary'); if (summary) summary.textContent = `已选 ${picker.draft.length} 项`;
|
||||
const confirm = document.querySelector('.region-picker-confirm'); if (confirm) confirm.textContent = picker.draft.length ? `确认(${picker.draft.length})` : '确认';
|
||||
}
|
||||
function setRegionPickerSearch(value) { if (!state.regionPicker) return; state.regionPicker.search = value; renderRegionPicker(); }
|
||||
function navigateRegionPicker(level) { if (!state.regionPicker) return; state.regionPicker.search = ''; if (level === 'province') { state.regionPicker.level = 'province'; state.regionPicker.province = ''; state.regionPicker.city = ''; } else if (level === 'city') { state.regionPicker.level = 'city'; state.regionPicker.city = ''; } else state.regionPicker.level = 'district'; renderRegionPicker(); }
|
||||
function descendRegionPicker(index) { const option = state.regionPicker?.options[index]; if (!option || option.level === 'district') return; state.regionPicker.search = ''; state.regionPicker.level = option.level === 'province' ? 'city' : 'district'; state.regionPicker.province = option.province; state.regionPicker.city = option.city || ''; renderRegionPicker(); }
|
||||
function toggleRegionPickerOption(index) { const option = state.regionPicker?.options[index]; if (!option) return; const key = locationFilterKey(option), draft = state.regionPicker.draft; const found = draft.findIndex(filter => locationFilterKey(filter) === key); if (found >= 0) draft.splice(found, 1); else draft.push(cloneLocationFilter(option)); renderRegionPicker(); }
|
||||
function removeRegionPickerDraft(index) { if (!state.regionPicker) return; state.regionPicker.draft.splice(index, 1); renderRegionPicker(); }
|
||||
function clearRegionPickerDraft() { if (!state.regionPicker) return; state.regionPicker.draft = []; renderRegionPicker(); }
|
||||
function confirmRegionPicker() { if (!state.regionPicker) return; state.locationFilters = state.regionPicker.draft.map(cloneLocationFilter); state.query = ''; state.searchText = ''; const input = document.getElementById('stationSearch'); if (input) input.value = ''; const clear = document.getElementById('clearSearch'); if (clear) clear.hidden = true; state.selected = null; closeDetail(); closeStationSearchSuggestions(); renderLocationFilterChips(); closeRegionPicker(); render(); if (state.locationFilters.length) { focusSearchResults(); document.getElementById('selectedRegionHint').textContent = `视角: 已筛选 ${state.locationFilters.map(locationFilterLabel).join('、')}`; } else resetMap(); }
|
||||
function focusSearchResults() {
|
||||
const stations = filteredStations(); if (!state.map || !stations.length) return;
|
||||
const longitudes = stations.map(station => Number(station.longitude)), latitudes = stations.map(station => Number(station.latitude));
|
||||
const center = [(Math.min(...longitudes) + Math.max(...longitudes)) / 2, (Math.min(...latitudes) + Math.max(...latitudes)) / 2];
|
||||
const span = Math.max(Math.max(...longitudes) - Math.min(...longitudes), Math.max(...latitudes) - Math.min(...latitudes));
|
||||
const zoom = stations.length === 1 ? 14 : span < .1 ? 13 : span < .35 ? 11.5 : span < 1 ? 9.5 : 7;
|
||||
state.map.setZoomAndCenter(zoom, center);
|
||||
}
|
||||
function handleStationSearchInput(value) { state.searchText = value; document.getElementById('clearSearch').hidden = !normalize(value); state.activeSuggestion = -1; openStationSearchSuggestions(); window.clearTimeout(state.searchDebounce); state.searchDebounce = window.setTimeout(() => { state.query = value; state.selected = null; closeDetail(); render(); if (normalize(value)) focusSearchResults(); }, 180); }
|
||||
function selectStationSearchSuggestion(index) {
|
||||
const item = state.suggestions[index]; if (!item) return;
|
||||
closeStationSearchSuggestions(); selectStation(item.station, true);
|
||||
}
|
||||
function setStationFilter(filter) { if (!['all', 'partner'].includes(filter) || state.stationFilter === filter) return; state.stationFilter = filter; state.selected = null; closeDetail(); document.querySelectorAll('[data-station-filter]').forEach(button => button.classList.toggle('is-active', button.dataset.stationFilter === filter)); render(); renderStationSearchSuggestions(); }
|
||||
function clearSearch() { window.clearTimeout(state.searchDebounce); document.getElementById('stationSearch').value = ''; state.query = ''; state.searchText = ''; state.locationFilters = []; renderLocationFilterChips(); closeStationSearchSuggestions(); closeDetail(); resetMap(); render(); }
|
||||
function handleSearchKeydown(event) { if (event.key === 'Escape') { closeStationSearchSuggestions(); event.currentTarget.blur(); return; } if (!['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)) return; const box = document.getElementById('stationSearchSuggestions'); if (box?.hidden) openStationSearchSuggestions(); if (event.key === 'Enter' && state.activeSuggestion >= 0) { event.preventDefault(); selectStationSearchSuggestion(state.activeSuggestion); return; } if (event.key !== 'Enter') { event.preventDefault(); const direction = event.key === 'ArrowDown' ? 1 : -1; state.activeSuggestion = (state.activeSuggestion + direction + state.suggestions.length) % Math.max(state.suggestions.length, 1); renderStationSearchSuggestions(); } }
|
||||
|
||||
function selectStation(station, fit = false) {
|
||||
if (!station) return; state.selected = station; if (fit) state.map?.setZoomAndCenter(14.5, coords(station));
|
||||
document.getElementById('detailName').textContent = station.name || station.shortName || '未命名站点'; document.getElementById('detailRegion').textContent = stationRegion(station);
|
||||
const cooperative = Boolean(station.cooperative), tag = document.getElementById('detailType'); tag.textContent = cooperative ? '合作站' : '外部站'; tag.classList.toggle('is-station', cooperative);
|
||||
const distance = stationDistance(station); document.getElementById('detailFields').innerHTML = `${detailField('站点状态', cooperative ? '合作站点' : '外部站点')}${detailField('行政区域', stationRegion(station))}${detailField('详细地址', station.address || '暂无详细地址', true)}${Number.isFinite(distance) ? detailField('距离我', `${formatNumber(distance, 1)} km`) : ''}`;
|
||||
document.getElementById('stationDetail').hidden = false; renderMarkers(); renderList();
|
||||
}
|
||||
function detailField(label, value, full = false) { return `<div class="detail-field${full ? ' is-full' : ''}"><dt>${escapeHTML(label)}</dt><dd>${escapeHTML(value)}</dd></div>`; }
|
||||
function stationRegion(station) { return [station.province, station.city, stationDistrictName(station)].filter(value => value && value !== '[]').filter((value, index, list) => index === 0 || value !== list[index - 1]).join(' · '); }
|
||||
function closeDetail() { const detail = document.getElementById('stationDetail'); if (detail) detail.hidden = true; }
|
||||
|
||||
function locateMe() { if (!navigator.geolocation) return locationFailed('当前设备不支持定位'); const button = document.getElementById('locateDeviceBtn'); button.disabled = true; button.classList.remove('is-error'); document.getElementById('locateDeviceLabel').textContent = '定位中'; navigator.geolocation.getCurrentPosition(position => { state.userLocation = wgs84ToGcj02(Number(position.coords.longitude), Number(position.coords.latitude)); button.disabled = false; button.classList.add('is-active'); document.getElementById('locateDeviceLabel').textContent = '已定位'; state.map?.setZoomAndCenter(13, state.userLocation); document.getElementById('selectedRegionHint').textContent = '视角: 我的位置附近'; render(); }, error => locationFailed(error?.code === 1 ? '请允许位置权限' : '定位失败'), { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 }); }
|
||||
function locationFailed(label) { const button = document.getElementById('locateDeviceBtn'); button.disabled = false; button.classList.add('is-error'); document.getElementById('locateDeviceLabel').textContent = label; window.setTimeout(() => { button.classList.remove('is-error'); document.getElementById('locateDeviceLabel').textContent = state.userLocation ? '已定位' : '定位'; }, 2600); }
|
||||
function renderUserMarker() { if (!state.map || !state.userLocation || typeof AMap === 'undefined') return; if (state.userMarker) state.userMarker.remove(); const content = document.createElement('div'); content.className = 'user-location-marker'; state.userMarker = new AMap.Marker({ position: state.userLocation, content, offset: new AMap.Pixel(-9, -9), zIndex: 50 }); state.userMarker.setMap(state.map); }
|
||||
function stationDistance(station) { return state.userLocation ? distanceKm(state.userLocation, coords(station)) : Number.POSITIVE_INFINITY; }
|
||||
function distanceKm(origin, target) { const radians = value => value * Math.PI / 180, dLat = radians(target[1] - origin[1]), dLng = radians(target[0] - origin[0]), a = Math.sin(dLat / 2) ** 2 + Math.cos(radians(origin[1])) * Math.cos(radians(target[1])) * Math.sin(dLng / 2) ** 2; return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); }
|
||||
function resetMap() { state.map?.setZoomAndCenter(4.8, [108.948024, 34.263161]); state.map?.setPitch(30); document.getElementById('selectedRegionHint').textContent = '视角: 全国加氢站网络'; }
|
||||
function toggleMapPitch() { state.pitch = !state.pitch; state.map?.setPitch(state.pitch ? 30 : 0); }
|
||||
function setTheme(theme) { document.body.className = theme; document.querySelectorAll('.bw-btn').forEach(button => button.classList.remove('active')); document.querySelector(theme === 'theme-white' ? '.theme-white-btn' : '.theme-dark-btn')?.classList.add('active'); state.map?.setMapStyle(theme === 'theme-white' ? 'amap://styles/light' : 'amap://styles/darkblue'); }
|
||||
function navigateToStation() { if (!state.selected) return; const [lng, lat] = coords(state.selected), name = encodeURIComponent(state.selected.name || state.selected.shortName || '加氢站'); const overlay = document.getElementById('navigationOverlay'); overlay.hidden = false; window.setTimeout(() => window.location.assign(`https://uri.amap.com/navigation?to=${lng.toFixed(6)},${lat.toFixed(6)},${name}&mode=car&coordinate=gaode&callnative=1`), 140); }
|
||||
function setStatus(text, error = false) { const status = document.getElementById('mapStatusText'); if (status) status.textContent = text; status?.parentElement?.classList.toggle('is-error', error); }
|
||||
function setHTML(id, value) { const element = document.getElementById(id); if (element) element.innerHTML = value; }
|
||||
function formatNumber(value, digits = 0) { return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: digits, minimumFractionDigits: digits }); }
|
||||
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); }
|
||||
function escapeAttribute(value) { return escapeHTML(value).replace(/`/g, '`'); }
|
||||
function initClock() { const clock = document.getElementById('clockTime'), update = () => { if (clock) clock.textContent = new Date().toLocaleTimeString('zh-CN', { hour12: false }); }; update(); window.setInterval(update, 1000); }
|
||||
function wgs84ToGcj02(lng, lat) { if (lng < 72.004 || lng > 137.8347 || lat < .8293 || lat > 55.8271) return [lng, lat]; const transformLat = (x, y) => -100 + 2*x + 3*y + .2*y*y + .1*x*y + .2*Math.sqrt(Math.abs(x)) + (20*Math.sin(6*x*Math.PI)+20*Math.sin(2*x*Math.PI))*2/3 + (20*Math.sin(y*Math.PI)+40*Math.sin(y/3*Math.PI))*2/3 + (160*Math.sin(y/12*Math.PI)+320*Math.sin(y*Math.PI/30))*2/3; const transformLng = (x, y) => 300 + x + 2*y + .1*x*x + .1*x*y + .1*Math.sqrt(Math.abs(x)) + (20*Math.sin(6*x*Math.PI)+20*Math.sin(2*x*Math.PI))*2/3 + (20*Math.sin(x*Math.PI)+40*Math.sin(x/3*Math.PI))*2/3 + (150*Math.sin(x/12*Math.PI)+300*Math.sin(x/30*Math.PI))*2/3; const a = 6378245, ee = .00669342162296594323; let dLat = transformLat(lng-105, lat-35), dLng = transformLng(lng-105, lat-35); const radLat = lat / 180 * Math.PI, magic = 1 - ee * Math.sin(radLat) ** 2, sqrtMagic = Math.sqrt(magic); dLat = dLat * 180 / ((a * (1-ee)) / (magic * sqrtMagic) * Math.PI); dLng = dLng * 180 / (a / sqrtMagic * Math.cos(radLat) * Math.PI); return [lng + dLng, lat + dLat]; }
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
release_id=${1:?release id is required}
|
||||
archive=${2:?release archive is required}
|
||||
root=${STATION_NAVIGATION_ROOT:-/opt/lingniu-station-navigation}
|
||||
service=${STATION_NAVIGATION_SERVICE:-lingniu-station-navigation.service}
|
||||
base_url=${STATION_NAVIGATION_BASE_URL:-http://127.0.0.1:20804}
|
||||
|
||||
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 station-navigation >/dev/null 2>&1; then
|
||||
useradd --system --home-dir "$root" --shell /sbin/nologin station-navigation
|
||||
fi
|
||||
mkdir -p "$root/releases"
|
||||
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:station-navigation "$next"
|
||||
chmod -R u=rwX,g=rX,o= "$next"
|
||||
|
||||
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" | grep -q '"service":"station-navigation"'; then
|
||||
curl -fsS "$base_url/" | grep -q 'id="dashboardTitle"'
|
||||
curl -fsS "$base_url/api/stations" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["status"]=="ok" and d["summary"]["totalStations"]>400; forbidden={"hydrogenVolume","fuelingVolume","dailyHydrogenVolume"}; assert not (forbidden & set().union(*(x.keys() for x in d["stations"])))'
|
||||
printf 'station_navigation_release_install=ok release=%s\n' "$release_id"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
systemctl status "$service" --no-pager >&2 || true
|
||||
exit 1
|
||||
@@ -0,0 +1,31 @@
|
||||
[Unit]
|
||||
Description=Lingniu Public Hydrogen Station Navigation
|
||||
After=network-online.target lingniu-vehicle-open-platform.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=station-navigation
|
||||
Group=station-navigation
|
||||
# The public process needs read-only access to the operations map's shared
|
||||
# logo and stylesheet. Its own release remains owned by station-navigation.
|
||||
SupplementaryGroups=vehicle-map
|
||||
WorkingDirectory=/opt/lingniu-station-navigation/current
|
||||
# Reuse the operations map's existing Open Platform credential; the public
|
||||
# service still returns only the station-directory allowlist in server.py.
|
||||
EnvironmentFile=/opt/lingniu-vehicle-map/env/vehicle-map.env
|
||||
Environment=STATION_NAVIGATION_HOST=0.0.0.0
|
||||
Environment=STATION_NAVIGATION_PORT=20804
|
||||
Environment=STATION_NAVIGATION_CACHE_SECONDS=120
|
||||
Environment=VEHICLE_MAP_ASSET_ROOT=/opt/lingniu-vehicle-map/current
|
||||
ExecStart=/usr/bin/python3 /opt/lingniu-station-navigation/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
|
||||
@@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>羚牛氢能 - 加氢站导航</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/assets/vehicle-map.css?v=__ASSET_VERSION__">
|
||||
<link rel="stylesheet" href="styles.css?v=__ASSET_VERSION__">
|
||||
<script>window._AMapSecurityConfig = { securityJsCode: '0b54a41143bec162788d01deba851340' };</script>
|
||||
<script src="https://webapi.amap.com/maps?v=2.0&key=1868920ac8ff6b6f88dbe9fa2609c183&plugin=AMap.Scale,AMap.ToolBar,AMap.Marker"></script>
|
||||
</head>
|
||||
<body class="theme-white">
|
||||
<div class="liquid-cockpit-wrapper station-navigation-shell">
|
||||
<header class="liquid-header floating-glass">
|
||||
<div class="header-left">
|
||||
<div class="brand-block"><img class="brand-logo-svg" src="/assets/logo_light.svg" width="150" height="36" alt="羚牛氢能"></div>
|
||||
<div class="brand-divider"></div>
|
||||
<div class="cockpit-title-wrap"><h1 class="cockpit-title">加氢站导航</h1></div>
|
||||
</div>
|
||||
<div class="header-kpi-group">
|
||||
<div class="kpi-glass-capsule"><span class="capsule-lbl">站点总数</span><span class="capsule-val" id="kpiTotal">-- <small>座</small></span></div>
|
||||
<div class="kpi-glass-capsule primary-capsule"><span class="capsule-lbl">合作站点</span><span class="capsule-val" id="kpiPartner">-- <small>座</small></span></div>
|
||||
</div>
|
||||
<div class="header-controls">
|
||||
<div class="theme-switcher-bw">
|
||||
<button class="bw-btn theme-dark-btn" title="深色模式" onclick="setTheme('theme-dark')"><svg class="bw-svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg><span>深色</span></button>
|
||||
<button class="bw-btn theme-white-btn active" title="浅色模式" onclick="setTheme('theme-white')"><svg class="bw-svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="12" cy="12" r="5"></circle><path d="M12 1v2m0 18v2M1 12h2m18 0h2M4.2 4.2l1.4 1.4m12.8 12.8 1.4 1.4m0-15.6-1.4 1.4M5.6 18.4l-1.4 1.4"></path></svg><span>浅色</span></button>
|
||||
</div>
|
||||
<div class="time-widget" id="clockTime"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="glass-panel station-summary-strip" aria-label="加氢站类型分布"><div class="panel-header"><span class="panel-title">加氢站类型分布</span><span class="panel-tag">STATUS</span></div><div class="status-glass-grid3"><div class="glass-cell"><div class="cell-num" id="mobileStatusTotal">-- <small>座</small></div><div class="cell-label"><span class="status-dot dot-total"></span>全部站点</div></div><div class="glass-cell"><div class="cell-num" id="mobileStatusPartner">-- <small>座</small></div><div class="cell-label"><span class="status-dot dot-mint"></span>合作站</div></div><div class="glass-cell"><div class="cell-num" id="mobileStatusExternal">-- <small>座</small></div><div class="cell-label"><span class="status-dot dot-blue"></span>外部站</div></div></div><div class="liquid-progress-bar"><div class="seg seg-running" id="mobilePartnerSegment"></div><div class="seg seg-stopped" id="mobileExternalSegment"></div></div></section>
|
||||
|
||||
<main class="cockpit-main-grid">
|
||||
<section class="map-spatial-container floating-glass">
|
||||
<div class="map-top-bar"><div class="status-glass-pill"><span class="pulse-ring"></span><span id="mapStatusText">正在同步加氢站数据…</span></div></div>
|
||||
<div class="map-explore-toolbar" aria-label="地图搜索与定位">
|
||||
<button class="locate-btn" id="locateDeviceBtn" type="button" onclick="locateMe()" aria-label="定位附近站点"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"></circle><path d="M12 2v3M12 19v3M2 12h3M19 12h3"></path><circle cx="12" cy="12" r="8"></circle></svg><span id="locateDeviceLabel">定位</span></button>
|
||||
<button class="region-picker-trigger" id="regionPickerTrigger" type="button" onclick="openRegionPicker()" aria-haspopup="dialog"><span class="region-picker-trigger-icon">⌖</span><span>运营区域</span><b id="regionPickerTriggerCount" hidden>0</b></button>
|
||||
<div class="explore-search-shell"><label class="entity-search-field" for="stationSearch"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.6-3.6"></path></svg><input id="stationSearch" type="search" autocomplete="off" placeholder="搜索站点或详细地址" aria-label="搜索站点或详细地址" oninput="handleStationSearchInput(this.value)" onfocus="openStationSearchSuggestions()" onkeydown="handleSearchKeydown(event)"><button class="search-clear-btn" id="clearSearch" type="button" onclick="clearSearch()" aria-label="清除搜索" hidden>×</button></label><div class="explore-suggestions" id="stationSearchSuggestions" role="listbox" aria-label="站点搜索建议" hidden></div></div>
|
||||
<div class="selected-location-filter-rail" id="selectedLocationFilters" aria-label="已选位置" hidden></div>
|
||||
<div class="filter-chip-rail" id="filterChipRail" role="group" aria-label="站点类型筛选"><button class="map-filter-chip is-active" type="button" data-station-filter="all" onclick="setStationFilter('all')">全部站点</button><button class="map-filter-chip" type="button" data-station-filter="partner" onclick="setStationFilter('partner')">合作站</button></div>
|
||||
<span class="filter-result-meta" id="resultMeta" aria-live="polite">全部 0</span>
|
||||
</div>
|
||||
<div class="amap-canvas-box">
|
||||
<div id="amapContainer"></div>
|
||||
<div class="navigation-launch-overlay" id="navigationOverlay" role="status" aria-live="polite" hidden><div class="navigation-launch-panel"><span class="navigation-launch-spinner"></span><div><strong>正在打开高德地图</strong><span>正在准备导航路线,请稍候</span></div></div></div>
|
||||
<article class="map-detail-card" id="stationDetail" aria-live="polite" hidden>
|
||||
<div class="detail-card-accent is-station"></div><div class="detail-card-header"><div class="detail-title-wrap"><span class="detail-type-pill is-station" id="detailType">合作站</span><h2 id="detailName">—</h2><p id="detailRegion"></p></div><div class="detail-card-actions"><button class="detail-navigate-btn" id="navigateButton" type="button" onclick="navigateToStation()" aria-label="导航"><svg viewBox="0 0 24 24"><path d="M20.7 3.3 14.4 20.1a1.2 1.2 0 0 1-2.2.1l-2.6-5.8-5.8-2.6a1.2 1.2 0 0 1 .1-2.2l16.8-6.3Z"></path><path d="m9.5 14.5 4.9-4.9"></path></svg></button><button class="detail-close-btn" type="button" onclick="closeDetail()" aria-label="关闭详情">×</button></div></div><dl class="detail-grid" id="detailFields"></dl>
|
||||
</article>
|
||||
<div class="map-action-controls"><button class="glass-btn" onclick="resetMap()">复位视角</button><button class="glass-btn" id="pitchButton" onclick="toggleMapPitch()">3D/2D 视角</button><button class="glass-btn" onclick="clearSearch()">全国视图</button></div>
|
||||
</div>
|
||||
<div class="map-bottom-info"><span>地图引擎: 羚牛氢能 GIS (AMap 3D Engine)</span><span>站点数据服务 · 公开目录</span><span id="selectedRegionHint">视角: 全国加氢站网络</span></div>
|
||||
</section>
|
||||
<aside class="sidebar-operations">
|
||||
<div class="glass-panel sidebar-card station-status-card"><div class="panel-header"><span class="panel-title">加氢站类型分布</span><span class="panel-tag">STATUS</span></div><div class="status-glass-grid3"><div class="glass-cell"><div class="cell-num" id="statusTotal">-- <small>座</small></div><div class="cell-label"><span class="status-dot dot-total"></span>全部站点</div></div><div class="glass-cell"><div class="cell-num" id="statusPartner">-- <small>座</small></div><div class="cell-label"><span class="status-dot dot-mint"></span>合作站</div></div><div class="glass-cell"><div class="cell-num" id="statusExternal">-- <small>座</small></div><div class="cell-label"><span class="status-dot dot-blue"></span>外部站</div></div></div><div class="liquid-progress-bar"><div class="seg seg-running" id="partnerSegment"></div><div class="seg seg-stopped" id="externalSegment"></div></div></div>
|
||||
<div class="glass-panel sidebar-card flex-fill-auto"><div class="panel-header"><div class="panel-heading-stack"><span class="panel-title" id="panelRankTitle">加氢站省份 TOP 排名</span><span class="viewport-meta" id="rankViewportMeta"></span></div><div class="glass-tab-control"><button class="gtab active" id="rankPrimaryTab">按站点数</button></div></div><div class="liquid-ranking-list" id="stationList"></div></div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
<div class="region-picker-backdrop" id="regionPickerModal" role="dialog" aria-modal="true" aria-labelledby="regionPickerTitle" hidden>
|
||||
<section class="region-picker-dialog">
|
||||
<header class="region-picker-header"><div><span class="region-picker-eyebrow">加氢站导航</span><h2 id="regionPickerTitle">运营区域选择 <em>多选</em></h2></div><button type="button" class="region-picker-close" onclick="closeRegionPicker()" aria-label="关闭">×</button></header>
|
||||
<div class="region-picker-selected" id="regionPickerSelected"></div>
|
||||
<label class="region-picker-search" for="regionPickerSearch"><span>⌕</span><input id="regionPickerSearch" type="search" autocomplete="off" placeholder="搜索省 / 市 / 区县" aria-label="搜索省、市或区县" oninput="setRegionPickerSearch(this.value)"><button type="button" id="regionPickerSearchClear" onclick="setRegionPickerSearch('')" aria-label="清除搜索" hidden>×</button></label>
|
||||
<nav class="region-picker-path" id="regionPickerPath" aria-label="行政区域路径"></nav>
|
||||
<div class="region-picker-list" id="regionPickerList" aria-live="polite"></div>
|
||||
<footer class="region-picker-footer"><span id="regionPickerSummary">已选 0 项</span><div><button type="button" class="region-picker-text-button" onclick="clearRegionPickerDraft()">清空</button><button type="button" class="region-picker-confirm" onclick="confirmRegionPicker()">确认</button></div></footer>
|
||||
</section>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/pinyin-pro@3.28.2/dist/index.js" crossorigin="anonymous"></script>
|
||||
<script src="app.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Public hydrogen station navigation service.
|
||||
|
||||
This process deliberately has a smaller API boundary than the operations map:
|
||||
it retrieves only station directory fields and never exposes vehicle records or
|
||||
hydrogen-volume fields.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from socketserver import ThreadingMixIn
|
||||
import threading
|
||||
import time
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
# Production deliberately reuses the operations map's visual assets. Keeping
|
||||
# this configurable lets the standalone release live under its own directory.
|
||||
VEHICLE_MAP_ROOT = Path(os.getenv("VEHICLE_MAP_ASSET_ROOT", str(ROOT.parent / "vehicle-map")))
|
||||
HOST = os.getenv("STATION_NAVIGATION_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("STATION_NAVIGATION_PORT", "20804"))
|
||||
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"))
|
||||
STATION_CACHE_SECONDS = int(os.getenv("STATION_NAVIGATION_CACHE_SECONDS", "120"))
|
||||
VEHICLE_MAP_INTERNAL_BASE_URL = os.getenv("VEHICLE_MAP_INTERNAL_BASE_URL", "http://127.0.0.1:20800").rstrip("/")
|
||||
_cache_lock = threading.Lock()
|
||||
_cache = {}
|
||||
|
||||
|
||||
def _static_asset_version():
|
||||
digest = hashlib.sha256()
|
||||
for filename in ("app.js", "styles.css"):
|
||||
digest.update((ROOT / filename).read_bytes())
|
||||
digest.update((VEHICLE_MAP_ROOT / "styles.css").read_bytes())
|
||||
return digest.hexdigest()[:16]
|
||||
|
||||
|
||||
STATIC_ASSET_VERSION = _static_asset_version()
|
||||
|
||||
|
||||
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
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-station-navigation/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_station_directory():
|
||||
"""Use the operations map's GPS-resolved public station directory.
|
||||
|
||||
The map service owns reverse-geocoding and its persistent cache, keeping
|
||||
the public navigation view and the operations view on identical boundaries.
|
||||
"""
|
||||
request = Request(
|
||||
VEHICLE_MAP_INTERNAL_BASE_URL + "/api/stations",
|
||||
headers={"Accept": "application/json", "User-Agent": "lingniu-station-navigation/1.0"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=UPSTREAM_TIMEOUT_SECONDS) as response:
|
||||
payload = json.load(response)
|
||||
except (HTTPError, URLError, ValueError) as exc:
|
||||
raise RuntimeError("vehicle map station directory unavailable: {}".format(exc)) from exc
|
||||
if payload.get("status") != "ok" or not isinstance(payload.get("stations"), list):
|
||||
raise RuntimeError("vehicle map returned an invalid station directory")
|
||||
return payload
|
||||
|
||||
|
||||
def _prewarm_station_directory_cache():
|
||||
"""Fetch the public directory after startup so first navigation is warm."""
|
||||
try:
|
||||
_cached("station-directory", STATION_CACHE_SECONDS, _load_station_directory)
|
||||
except Exception as exc: # The request path will retry without blocking startup.
|
||||
print(f"station navigation cache warmup deferred: {exc}")
|
||||
|
||||
|
||||
class StationNavigationHandler(SimpleHTTPRequestHandler):
|
||||
server_version = "LingniuStationNavigation/1.0"
|
||||
|
||||
def do_GET(self):
|
||||
path = urlparse(self.path).path
|
||||
if path in {"/", "/index.html"}:
|
||||
self._serve_index()
|
||||
return
|
||||
if path == "/api/health":
|
||||
self._write_json(200, {
|
||||
"status": "ok",
|
||||
"service": "station-navigation",
|
||||
"openPlatformConfigured": bool(OPEN_PLATFORM_APP_KEY),
|
||||
})
|
||||
return
|
||||
if path == "/api/stations":
|
||||
try:
|
||||
self._write_json(200, _cached("station-directory", STATION_CACHE_SECONDS, _load_station_directory))
|
||||
except Exception as exc:
|
||||
self.log_error("station directory refresh failed: %s", exc)
|
||||
self._write_json(502, {
|
||||
"status": "error",
|
||||
"code": "UPSTREAM_UNAVAILABLE",
|
||||
"message": "加氢站数据暂时不可用,请稍后重试",
|
||||
})
|
||||
return
|
||||
if path == "/assets/logo_light.svg":
|
||||
self._serve_logo()
|
||||
return
|
||||
if path == "/assets/vehicle-map.css":
|
||||
self._serve_vehicle_map_styles()
|
||||
return
|
||||
if path.startswith("/api/"):
|
||||
self._write_json(404, {"status": "error", "code": "NOT_FOUND"})
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def _serve_index(self):
|
||||
try:
|
||||
template = (ROOT / "index.html").read_text(encoding="utf-8")
|
||||
body = template.replace("__ASSET_VERSION__", STATIC_ASSET_VERSION).encode("utf-8")
|
||||
except OSError as exc:
|
||||
self.log_error("index template unavailable: %s", exc)
|
||||
self.send_error(500, "Station navigation is temporarily unavailable")
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _serve_logo(self):
|
||||
try:
|
||||
body = (VEHICLE_MAP_ROOT / "logo_light.svg").read_bytes()
|
||||
except OSError:
|
||||
self.send_error(404)
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "image/svg+xml")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "public, max-age=86400")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _serve_vehicle_map_styles(self):
|
||||
try:
|
||||
body = (VEHICLE_MAP_ROOT / "styles.css").read_bytes()
|
||||
except OSError:
|
||||
self.send_error(404)
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/css; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def end_headers(self):
|
||||
if self.path == "/" or self.path.split("?", 1)[0].endswith((".html", ".css", ".js", ".svg")):
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
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__":
|
||||
# Python 3.6 on ECS predates SimpleHTTPRequestHandler(directory=...).
|
||||
# Resolve static assets from this release directory before serving.
|
||||
os.chdir(str(ROOT))
|
||||
server = ThreadingHTTPServer((HOST, PORT), StationNavigationHandler)
|
||||
threading.Thread(target=_prewarm_station_directory_cache, daemon=True).start()
|
||||
print(f"Lingniu Station Navigation listening on http://{HOST}:{PORT}")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
export STATION_NAVIGATION_HOST="${STATION_NAVIGATION_HOST:-127.0.0.1}"
|
||||
export STATION_NAVIGATION_PORT="${STATION_NAVIGATION_PORT:-20804}"
|
||||
exec python3 "$(dirname "$0")/server.py"
|
||||
@@ -0,0 +1,220 @@
|
||||
/* Keep the public service visually identical to the main map shell. */
|
||||
.station-navigation-shell .station-summary-strip { display: none; }
|
||||
.station-navigation-shell .station-summary-strip .panel-header { margin-bottom: 8px; }
|
||||
.station-navigation-shell .station-summary-strip .status-glass-grid3 { margin-bottom: 7px; }
|
||||
.station-navigation-shell .station-summary-strip .glass-cell { padding: 6px 9px; }
|
||||
.station-navigation-shell .map-filter-chip { min-height: 27px; padding: 0 9px; border: 1px solid var(--glass-border); border-radius: 8px; background: var(--pill-bg); color: var(--text-muted); font-size: 10px; font-weight: 700; cursor: pointer; }
|
||||
.station-navigation-shell .map-filter-chip.is-active { border-color: rgba(0,113,67,.24); background: rgba(0,113,67,.1); color: var(--accent-primary); }
|
||||
.station-navigation-shell .filter-chip-rail { display: flex; gap: 6px; }
|
||||
.station-navigation-shell .region-picker-trigger { display: inline-flex; align-items: center; gap: 5px; min-height: 29px; padding: 0 9px; border: 1px solid var(--glass-border); border-radius: 9px; background: var(--pill-bg); color: var(--text-main); font-size: 10px; font-weight: 700; white-space: nowrap; cursor: pointer; }
|
||||
.station-navigation-shell .region-picker-trigger:hover { border-color: rgba(0,113,67,.32); color: var(--accent-primary); }
|
||||
.station-navigation-shell .region-picker-trigger-icon { color: var(--accent-primary); font-size: 14px; line-height: 1; }
|
||||
.station-navigation-shell .region-picker-trigger b { display: grid; min-width: 15px; height: 15px; place-items: center; border-radius: 99px; background: var(--accent-primary); color: #fff; font-size: 9px; }
|
||||
.region-picker-backdrop { position: fixed; z-index: 1000; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(15,23,42,.38); backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px); }
|
||||
.region-picker-backdrop[hidden] { display: none; }
|
||||
.region-picker-dialog { display: flex; width: min(500px, 100%); max-height: min(720px, calc(100dvh - 40px)); flex-direction: column; overflow: hidden; border: 1px solid var(--glass-border); border-radius: 18px; background: var(--glass-bg); box-shadow: 0 28px 70px rgba(15,23,42,.28); }
|
||||
.region-picker-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 17px 12px; border-bottom: 1px solid var(--glass-border); }
|
||||
.region-picker-eyebrow { display: block; margin-bottom: 3px; color: var(--text-muted); font-size: 9px; font-weight: 700; letter-spacing: .07em; }
|
||||
.region-picker-header h2 { margin: 0; color: var(--text-main); font-size: 16px; letter-spacing: -.02em; }
|
||||
.region-picker-header h2 em { margin-left: 5px; color: var(--accent-primary); font-size: 11px; font-style: normal; font-weight: 700; }
|
||||
.region-picker-close { display: grid; width: 30px; height: 30px; flex: 0 0 30px; place-items: center; border: 1px solid var(--glass-border); border-radius: 9px; background: var(--pill-bg); color: var(--text-muted); font: 400 20px/1 -apple-system, BlinkMacSystemFont, sans-serif; cursor: pointer; }
|
||||
.region-picker-selected { display: flex; min-height: 38px; align-items: center; gap: 6px; overflow-x: auto; padding: 8px 17px; border-bottom: 1px solid var(--glass-border); scrollbar-width: none; }
|
||||
.region-picker-selected::-webkit-scrollbar { display: none; }
|
||||
.region-picker-selected-label { flex: 0 0 auto; color: var(--text-muted); font-size: 10px; font-weight: 700; }
|
||||
.region-picker-selected-placeholder { color: var(--text-muted); font-size: 11px; }
|
||||
.region-picker-selected-chip { display: inline-flex; min-height: 25px; flex: 0 0 auto; align-items: center; gap: 6px; padding: 0 8px; border: 1px solid rgba(0,113,67,.2); border-radius: 99px; background: rgba(0,113,67,.09); color: var(--accent-primary); font-size: 10px; font-weight: 700; cursor: pointer; }
|
||||
.region-picker-selected-chip b { font: 700 14px/1 -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||
.region-picker-search { display: flex; height: 37px; align-items: center; gap: 8px; margin: 12px 17px 9px; padding: 0 10px; border: 1px solid var(--glass-border); border-radius: 10px; background: var(--pill-bg); color: var(--text-muted); }
|
||||
.region-picker-search span { font-size: 17px; line-height: 1; }
|
||||
.region-picker-search input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--text-main); font: 11px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
.region-picker-search button { display: grid; width: 19px; height: 19px; place-items: center; border: 0; border-radius: 50%; background: var(--segmented-bg); color: var(--text-muted); font-size: 14px; cursor: pointer; }
|
||||
.region-picker-search button[hidden] { display: none; }
|
||||
.region-picker-path { display: flex; min-height: 28px; align-items: center; gap: 6px; padding: 0 17px 7px; color: var(--text-muted); font-size: 10px; }
|
||||
.region-picker-path button { padding: 0; border: 0; background: transparent; color: var(--text-muted); font-size: inherit; cursor: pointer; }
|
||||
.region-picker-path button.is-current { color: var(--text-main); font-weight: 700; cursor: default; }
|
||||
.region-picker-path i { color: var(--text-sub); font-style: normal; }
|
||||
.region-picker-list { min-height: 240px; flex: 1 1 auto; overflow-y: auto; padding: 2px 9px 10px; }
|
||||
.region-picker-row { display: grid; grid-template-columns: 31px minmax(0, 1fr) 32px; min-height: 54px; align-items: center; border-bottom: 1px solid color-mix(in srgb, var(--glass-border) 72%, transparent); }
|
||||
.region-picker-row.is-selected { background: rgba(0,113,67,.045); }
|
||||
.region-picker-check { display: grid; width: 19px; height: 19px; margin-left: 6px; place-items: center; border: 1px solid var(--glass-border); border-radius: 5px; background: var(--pill-bg); color: #fff; font-size: 12px; cursor: pointer; }
|
||||
.region-picker-row.is-selected .region-picker-check { border-color: var(--accent-primary); background: var(--accent-primary); }
|
||||
.region-picker-row-main { min-width: 0; padding: 8px 3px; border: 0; background: transparent; color: var(--text-main); text-align: left; cursor: pointer; }
|
||||
.region-picker-row-main strong { display: block; overflow: hidden; font-size: 12px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.region-picker-row-main small { display: block; overflow: hidden; margin-top: 2px; color: var(--text-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.region-picker-next { display: grid; width: 30px; height: 30px; place-items: center; border: 0; background: transparent; color: var(--text-muted); font: 400 22px/1 -apple-system, BlinkMacSystemFont, sans-serif; cursor: pointer; }
|
||||
.region-picker-next:hover { color: var(--accent-primary); }
|
||||
.region-picker-next.is-empty { visibility: hidden; }
|
||||
.region-picker-empty { display: grid; min-height: 170px; place-items: center; color: var(--text-muted); font-size: 11px; }
|
||||
.region-picker-footer { display: flex; min-height: 60px; align-items: center; justify-content: space-between; gap: 14px; padding: 10px 17px; border-top: 1px solid var(--glass-border); }
|
||||
.region-picker-footer > span { color: var(--text-muted); font-size: 11px; }
|
||||
.region-picker-footer > div { display: flex; align-items: center; gap: 8px; }
|
||||
.region-picker-text-button, .region-picker-confirm { min-height: 32px; padding: 0 12px; border-radius: 8px; font-size: 11px; font-weight: 700; cursor: pointer; }
|
||||
.region-picker-text-button { border: 1px solid var(--glass-border); background: var(--pill-bg); color: var(--text-muted); }
|
||||
.region-picker-confirm { border: 1px solid var(--accent-primary); background: var(--accent-primary); color: #fff; }
|
||||
.station-navigation-shell .selected-location-filter-rail { display: flex; align-items: center; gap: 6px; min-width: 0; max-width: min(360px, 30vw); overflow-x: auto; scrollbar-width: none; }
|
||||
.station-navigation-shell .selected-location-filter-rail::-webkit-scrollbar { display: none; }
|
||||
.station-navigation-shell .selected-location-filter-rail[hidden] { display: none; }
|
||||
.station-navigation-shell .selected-location-filter-chip { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 7px; min-height: 29px; max-width: 132px; padding: 0 8px 0 10px; border: 1px solid rgba(0,113,67,.22); border-radius: 999px; background: rgba(0,113,67,.09); color: var(--accent-primary); font-size: 10px; font-weight: 700; cursor: pointer; }
|
||||
.station-navigation-shell .selected-location-filter-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.station-navigation-shell .selected-location-filter-chip b { font: 700 15px/1 -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||
.station-navigation-shell .province-info-card.is-station-point { min-width: 92px; }
|
||||
.station-navigation-shell .province-info-card.is-station-point .p-total { color: var(--accent-primary); }
|
||||
.station-navigation-shell .province-info-card.is-external .p-total { color: var(--text-muted); }
|
||||
.station-navigation-shell .rank-glass-row { cursor: pointer; }
|
||||
.station-navigation-shell .rank-glass-row.is-selected { background: rgba(0,113,67,.09); border-color: rgba(0,113,67,.2); }
|
||||
.station-navigation-shell .r-name { display: flex; min-width: 0; align-items: flex-start; gap: 5px; line-height: 1.35; }
|
||||
.station-navigation-shell .station-list-name { min-width: 0; overflow-wrap: anywhere; white-space: normal; }
|
||||
.station-navigation-shell .station-list-partner-tag { flex: 0 0 30px; min-width: 30px; margin-top: 1px; padding: 2px 5px; border-radius: 4px; background: rgba(0,113,67,.1); color: var(--accent-primary); font-size: 9px; font-weight: 700; line-height: 1; text-align: center; }
|
||||
.station-navigation-shell .station-list-partner-tag.is-placeholder { visibility: hidden; }
|
||||
.station-navigation-shell .rank-empty { display: grid; min-height: 160px; place-items: center; color: var(--text-muted); font-size: 12px; text-align: center; }
|
||||
.station-navigation-shell .map-detail-card .detail-grid { margin-bottom: 0; }
|
||||
@media (max-width: 760px) {
|
||||
/* Mobile is navigation-first: keep the first screen focused on map,
|
||||
search and the next action, rather than dashboard chrome. */
|
||||
.station-navigation-shell .liquid-header {
|
||||
position: relative;
|
||||
padding: 9px 10px 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.station-navigation-shell .header-left {
|
||||
width: calc(100% - 78px);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.station-navigation-shell .brand-block { flex-basis: 118px; min-width: 96px; }
|
||||
.station-navigation-shell .brand-logo-svg { max-width: 118px; }
|
||||
.station-navigation-shell .brand-divider { height: 18px; }
|
||||
.station-navigation-shell .cockpit-title { font-size: 14px; }
|
||||
|
||||
.station-navigation-shell .header-controls {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: auto;
|
||||
order: initial;
|
||||
}
|
||||
|
||||
.station-navigation-shell .theme-switcher-bw { padding: 2px; border-radius: 9px; }
|
||||
.station-navigation-shell .theme-switcher-bw .bw-btn { display: grid; width: 31px; min-height: 28px; padding: 4px; place-items: center; }
|
||||
.station-navigation-shell .theme-switcher-bw .bw-btn span { display: none; }
|
||||
.station-navigation-shell .header-kpi-group,
|
||||
.station-navigation-shell .station-status-card { display: none; }
|
||||
.station-navigation-shell .station-summary-strip { display: block; padding: 9px 11px; border-radius: 14px; flex: 0 0 auto; }
|
||||
.station-navigation-shell .station-summary-strip .panel-header { margin-bottom: 7px; padding-bottom: 5px; }
|
||||
.station-navigation-shell .station-summary-strip .status-glass-grid3 { gap: 5px; margin-bottom: 6px; }
|
||||
.station-navigation-shell .station-summary-strip .glass-cell { padding: 6px; }
|
||||
|
||||
.station-navigation-shell .map-spatial-container {
|
||||
height: clamp(430px, 58dvh, 540px);
|
||||
min-height: clamp(430px, 58dvh, 540px);
|
||||
}
|
||||
|
||||
.station-navigation-shell .map-top-bar { display: none; }
|
||||
|
||||
.station-navigation-shell .map-explore-toolbar {
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: 36px max-content minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
padding: 5px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.station-navigation-shell .region-picker-trigger { grid-column: 1 / 3; grid-row: 2; min-height: 30px; padding: 0 9px; border-radius: 9px; }
|
||||
.station-navigation-shell .explore-search-shell { grid-column: 2 / -1; grid-row: 1; min-width: 0; }
|
||||
|
||||
.station-navigation-shell .locate-btn {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
flex-basis: 36px;
|
||||
width: 36px;
|
||||
min-height: 36px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.station-navigation-shell .entity-search-field {
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.station-navigation-shell .entity-search-field input { font-size: 12px; }
|
||||
.station-navigation-shell .selected-location-filter-rail { grid-column: 1 / -1; grid-row: 3; max-width: none; padding: 1px 0 0; }
|
||||
.station-navigation-shell .filter-chip-rail { grid-column: 3; grid-row: 2; min-width: 0; max-width: none; overflow-x: auto; }
|
||||
.station-navigation-shell .map-filter-chip { min-height: 30px; padding: 0 8px; font-size: 10px; }
|
||||
.station-navigation-shell .filter-result-meta { display: none; }
|
||||
|
||||
.region-picker-backdrop { align-items: end; padding: 0; }
|
||||
.region-picker-dialog { width: 100%; max-height: min(82dvh, 700px); border-radius: 20px 20px 0 0; }
|
||||
.region-picker-header { padding: 14px 15px 10px; }
|
||||
.region-picker-selected { padding: 8px 15px; }
|
||||
.region-picker-search { margin: 10px 15px 7px; }
|
||||
.region-picker-path { padding: 0 15px 6px; }
|
||||
.region-picker-footer { padding: 10px 15px calc(10px + env(safe-area-inset-bottom)); }
|
||||
|
||||
.station-navigation-shell .explore-suggestions {
|
||||
top: calc(100% + 43px);
|
||||
left: -42px;
|
||||
width: calc(100vw - 32px);
|
||||
max-height: min(280px, calc(100dvh - 250px));
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.station-navigation-shell .map-explore-toolbar:has(.selected-location-filter-rail:not([hidden])) .explore-suggestions {
|
||||
top: calc(100% + 79px);
|
||||
}
|
||||
|
||||
.station-navigation-shell .map-action-controls {
|
||||
right: 8px;
|
||||
bottom: 31px;
|
||||
}
|
||||
|
||||
.station-navigation-shell .map-action-controls .glass-btn:nth-child(1),
|
||||
.station-navigation-shell .map-action-controls .glass-btn:nth-child(2) { display: none; }
|
||||
|
||||
.station-navigation-shell .map-action-controls .glass-btn {
|
||||
min-height: 32px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.station-navigation-shell .map-detail-card {
|
||||
bottom: 46px;
|
||||
max-height: min(42dvh, 300px);
|
||||
padding: 12px;
|
||||
border-radius: 17px;
|
||||
}
|
||||
|
||||
.station-navigation-shell .detail-title-wrap h2 {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.station-navigation-shell .detail-navigate-btn,
|
||||
.station-navigation-shell .detail-close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-basis: 32px;
|
||||
}
|
||||
|
||||
.station-navigation-shell .detail-navigate-btn svg { width: 15px; height: 15px; }
|
||||
.station-navigation-shell .sidebar-operations { gap: 8px; }
|
||||
.station-navigation-shell .sidebar-card { padding: 10px 11px; border-radius: 14px; }
|
||||
.station-navigation-shell .status-glass-grid3 { gap: 5px; margin-bottom: 7px; }
|
||||
.station-navigation-shell .glass-cell { padding: 6px; }
|
||||
.station-navigation-shell .rank-glass-row { min-height: 42px; padding: 7px 8px; }
|
||||
}
|
||||
|
||||
@media (max-width: 390px) {
|
||||
.station-navigation-shell .header-left { width: calc(100% - 76px); }
|
||||
.station-navigation-shell .brand-block { flex-basis: 104px; min-width: 88px; }
|
||||
.station-navigation-shell .brand-logo-svg { max-width: 104px; }
|
||||
.station-navigation-shell .cockpit-title { font-size: 13px; }
|
||||
.station-navigation-shell .brand-divider { margin-left: -2px; }
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const styles = fs.readFileSync(new URL('../styles.css', import.meta.url), 'utf8');
|
||||
const sandbox = {
|
||||
console,
|
||||
document: { addEventListener() {}, getElementById() { return null; } },
|
||||
window: {},
|
||||
pinyinPro: { pinyin(value) { return value === '广州合作站' ? 'guang zhou he zuo zhan' : value; } },
|
||||
navigator: {},
|
||||
};
|
||||
sandbox.globalThis = sandbox;
|
||||
vm.runInNewContext(source, sandbox, { filename: 'app.js' });
|
||||
|
||||
assert.equal(sandbox.stationSearchScore({ name: '广州合作站' }, 'gz'), 2);
|
||||
assert.equal(sandbox.stationSearchScore({ address: '广东省广州市黄埔区' }, '黄埔'), 3);
|
||||
assert.equal(sandbox.stationSearchScore({ name: '广州合作站' }, '北京'), 0);
|
||||
assert.equal(sandbox.hasCoordinates({ longitude: 113.2, latitude: 23.1 }), true);
|
||||
assert.equal(sandbox.hasCoordinates({ longitude: 'bad', latitude: 23.1 }), false);
|
||||
vm.runInNewContext(`
|
||||
state.stations = [
|
||||
{ id: 'partner', name: '广州合作站', cooperative: true },
|
||||
{ id: 'external', name: '广州外部站', cooperative: false }
|
||||
];
|
||||
globalThis.allStationIds = filteredStations().map(station => station.id);
|
||||
state.stationFilter = 'partner';
|
||||
globalThis.partnerStationIds = filteredStations().map(station => station.id);
|
||||
`, sandbox);
|
||||
assert.equal(sandbox.allStationIds.join(','), 'partner,external');
|
||||
assert.equal(sandbox.partnerStationIds.join(','), 'partner');
|
||||
vm.runInNewContext(`
|
||||
state.stations = [
|
||||
{ id: 'gd-hp', name: '黄埔合作站', province: '广东省', city: '广州市', district: '黄埔区', longitude: 113.3, latitude: 23.1, cooperative: true },
|
||||
{ id: 'gd-nh', name: '南海合作站', province: '广东省', city: '佛山市', district: '南海区', longitude: 113.1, latitude: 23.0, cooperative: true },
|
||||
{ id: 'zj-jx', name: '嘉兴合作站', province: '浙江省', city: '嘉兴市', district: '秀洲区', longitude: 120.7, latitude: 30.7, cooperative: true }
|
||||
];
|
||||
globalThis.hierarchy = [stationHierarchyLevel(4.8), stationHierarchyLevel(8), stationHierarchyLevel(13)];
|
||||
globalThis.provinceNodeCount = buildStationNodes(filteredStations(), 'province').length;
|
||||
globalThis.cityNodeCount = buildStationNodes(filteredStations(), 'city').length;
|
||||
globalThis.stationNodeCount = buildStationNodes(filteredStations(), 'station').length;
|
||||
globalThis.locationOptionCount = stationLocationOptions().length;
|
||||
globalThis.provinceOptionScope = stationLocationOptions().find(option => option.level === 'province' && option.province === '广东省');
|
||||
globalThis.cityOptionScope = stationLocationOptions().find(option => option.level === 'city' && option.city === '广州市');
|
||||
`, sandbox);
|
||||
assert.equal(sandbox.hierarchy.join(','), 'province,city,station');
|
||||
assert.equal(sandbox.provinceNodeCount, 2);
|
||||
assert.equal(sandbox.cityNodeCount, 3);
|
||||
assert.equal(sandbox.stationNodeCount, 3);
|
||||
assert.equal(sandbox.locationOptionCount, 8);
|
||||
assert.equal(sandbox.provinceOptionScope.city, '');
|
||||
assert.equal(sandbox.provinceOptionScope.district, '');
|
||||
assert.equal(sandbox.cityOptionScope.district, '');
|
||||
vm.runInNewContext(`
|
||||
state.regionPicker = { level: 'province', province: '', city: '', search: '', draft: [], options: [] };
|
||||
globalThis.regionProvinceLabels = regionPickerOptions().map(option => option.label).sort().join(',');
|
||||
state.regionPicker = { level: 'city', province: '广东省', city: '', search: '', draft: [], options: [] };
|
||||
globalThis.regionCityLabels = regionPickerOptions().map(option => option.label).sort().join(',');
|
||||
state.regionPicker = { level: 'province', province: '', city: '', search: '嘉兴', draft: [], options: [] };
|
||||
globalThis.regionSearchLabels = regionPickerOptions().map(option => option.label).join(',');
|
||||
`, sandbox);
|
||||
assert.equal(sandbox.regionProvinceLabels, '广东,浙江');
|
||||
assert.equal(sandbox.regionCityLabels, '佛山,广州');
|
||||
assert.match(sandbox.regionSearchLabels, /嘉兴/);
|
||||
vm.runInNewContext(`
|
||||
globalThis.inferredDistrict = stationDistrictName({ province: '浙江省', city: '嘉兴市', address: '嘉兴市海盐县海盐经济开发区' });
|
||||
state.locationFilters = [];
|
||||
state.query = '广州';
|
||||
globalThis.queryMatches = stationMatchesQuery({ name: '广州合作站' });
|
||||
globalThis.queryMisses = stationMatchesQuery({ name: '杭州合作站' });
|
||||
`, sandbox);
|
||||
assert.equal(sandbox.inferredDistrict, '海盐县');
|
||||
assert.equal(sandbox.queryMatches, true);
|
||||
assert.equal(sandbox.queryMisses, false);
|
||||
vm.runInNewContext(`
|
||||
state.query = '';
|
||||
state.locationFilters = [
|
||||
{ level: 'city', province: '广东省', city: '广州市', district: '', label: '广州' },
|
||||
{ level: 'city', province: '浙江省', city: '嘉兴市', district: '', label: '嘉兴' }
|
||||
];
|
||||
globalThis.multiLocationMatches = [
|
||||
stationMatchesLocation({ province: '广东省', city: '广州市', district: '白云区' }),
|
||||
stationMatchesLocation({ province: '浙江省', city: '嘉兴市', district: '海盐县' }),
|
||||
stationMatchesLocation({ province: '广东省', city: '深圳市', district: '南山区' })
|
||||
];
|
||||
`, sandbox);
|
||||
assert.equal(sandbox.multiLocationMatches.join(','), 'true,true,false');
|
||||
assert.ok(Math.abs(sandbox.distanceKm([113, 23], [114, 23]) - 102.4) < 1);
|
||||
assert.match(source, /zoom < 11\) return 'city'/);
|
||||
assert.match(source, /state\.userLocation && left\.kind === 'station'/);
|
||||
assert.doesNotMatch(source, /rankSecondaryTab|setRank\(/);
|
||||
assert.match(source, /stationSearchSuggestions/);
|
||||
assert.match(source, /handleStationSearchInput/);
|
||||
assert.match(source, /locationFilters/);
|
||||
assert.match(source, /renderLocationFilterChips/);
|
||||
assert.match(source, /openRegionPicker/);
|
||||
assert.match(source, /kpiTotal|kpiPartner/);
|
||||
const index = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8');
|
||||
assert.match(index, /station-summary-strip/);
|
||||
assert.match(index, /station-status-card/);
|
||||
assert.equal((index.match(/id="statusTotal"/g) || []).length, 1);
|
||||
assert.equal((index.match(/id="mobileStatusTotal"/g) || []).length, 1);
|
||||
assert.match(source, /window\.setTimeout\(\(\) => \{ state\.query = value/);
|
||||
assert.match(source, /focusSearchResults\(\)/);
|
||||
assert.match(source, /visibleOnly && !normalize\(state\.query\)/);
|
||||
assert.match(source, /station-list-partner-tag/);
|
||||
assert.match(source, /station-list-partner-tag\$\{node\.cooperative \? '' : ' is-placeholder'\}/);
|
||||
assert.match(source, /node\.kind === 'station' \? '' : `\$\{formatNumber\(node\.count\)\} 座`/);
|
||||
assert.match(source, /node\.cooperative \? 'H₂ · 合作' : ''/);
|
||||
assert.doesNotMatch(source, /在线 \$\{formatNumber\(node\.online\)\}/);
|
||||
assert.doesNotMatch(source, /monthlyHydrogenKg|totalHydrogenKg|vehicle/);
|
||||
assert.match(source, /uri\.amap\.com\/navigation/);
|
||||
assert.equal((source.match(/function navigateToStation\(/g) || []).length, 1);
|
||||
assert.match(styles, /station-summary-strip/);
|
||||
assert.match(styles, /header-kpi-group,\n \.station-navigation-shell \.station-status-card/);
|
||||
assert.match(styles, /theme-switcher-bw \.bw-btn span \{ display: none; \}/);
|
||||
assert.match(styles, /height: clamp\(430px, 58dvh, 540px\)/);
|
||||
assert.match(styles, /map-action-controls \.glass-btn:nth-child\(2\)/);
|
||||
console.log('station navigation app tests: ok');
|
||||
@@ -0,0 +1,54 @@
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("station_navigation_server", Path(__file__).parents[1] / "server.py")
|
||||
server = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(server)
|
||||
|
||||
|
||||
class StationNavigationTest(unittest.TestCase):
|
||||
def test_directory_is_a_positive_allowlist_without_hydrogen_data(self):
|
||||
source = {
|
||||
"status": "ok",
|
||||
"asOf": "2026-08-12 12:00:00",
|
||||
"summary": {"totalStations": 1, "cooperativeStations": 1},
|
||||
"stations": [{
|
||||
"id": "GD-1", "name": "广州合作站", "province": "广东省", "city": "广州市",
|
||||
"district": "黄埔区", "address": "开源大道1号", "longitude": 113.2, "latitude": 23.1,
|
||||
"cooperative": True,
|
||||
}],
|
||||
}
|
||||
with patch.object(server, "urlopen", return_value=io.BytesIO(json.dumps(source).encode("utf-8"))):
|
||||
result = server._load_station_directory()
|
||||
|
||||
self.assertEqual(result["summary"], {"totalStations": 1, "cooperativeStations": 1})
|
||||
station = result["stations"][0]
|
||||
self.assertEqual(station["name"], "广州合作站")
|
||||
self.assertEqual(station["province"], "广东省")
|
||||
self.assertEqual(server.VEHICLE_MAP_INTERNAL_BASE_URL, "http://127.0.0.1:20800")
|
||||
|
||||
def test_static_assets_are_fingerprinted(self):
|
||||
self.assertRegex(server.STATIC_ASSET_VERSION, r"^[0-9a-f]{16}$")
|
||||
|
||||
def test_static_assets_resolve_from_the_release_directory(self):
|
||||
self.assertIn('os.chdir(str(ROOT))', Path(__file__).parents[1].joinpath('server.py').read_text(encoding='utf-8'))
|
||||
|
||||
def test_default_cache_window_is_two_minutes(self):
|
||||
self.assertEqual(server.STATION_CACHE_SECONDS, 120)
|
||||
|
||||
def test_cache_reuses_directory_snapshot_within_window(self):
|
||||
calls = []
|
||||
with patch.object(server, "_cache", {}):
|
||||
first = server._cached("station-directory", 120, lambda: calls.append("load") or {"version": 1})
|
||||
second = server._cached("station-directory", 120, lambda: calls.append("load") or {"version": 2})
|
||||
self.assertEqual(calls, ["load"])
|
||||
self.assertEqual(first, second)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -45,6 +45,10 @@ Content-Type: application/json</pre>
|
||||
<p class="note">查询日没有有效里程但此前存在有效累计里程时,日里程补 0,累计总里程、来源协议和数据时间沿用最近有效统计;updatedAt 显示上一个统计周期的计算时间。</p>
|
||||
<h3><span class="method">POST</span>/api/v1/vehicles/total-mileage/query</h3>
|
||||
<p>按 VIN 和北京时间查询不晚于指定时刻的最近一条总里程,返回实际采集协议、记录时间和时间差秒数。</p>
|
||||
<h3><span class="method">POST</span>/api/v1/vehicles/realtime/query</h3>
|
||||
<p>查询当前有效授权车辆的最新位置、在线及运动状态、速度、累计里程、实际协议和记录时间。请求体传 <code>{}</code> 返回全部授权车辆。</p>
|
||||
<h3><span class="method">POST</span>/api/v1/hydrogen-stations/query</h3>
|
||||
<p>只读查询资产管理库中有有效坐标的加氢站;可按 <code>province</code>、<code>city</code>、<code>cooperateOnly</code> 筛选。</p>
|
||||
|
||||
<h2>总里程协议口径</h2>
|
||||
<table>
|
||||
@@ -100,6 +104,15 @@ Content-Type: application/json</pre>
|
||||
}'</pre>
|
||||
<p class="note">下一页保持原请求参数不变,并传入上一页 nextCursor;同一次分页查询的 snapshotId 保持不变。</p>
|
||||
|
||||
<h3>实时车辆与全部加氢站</h3>
|
||||
<pre>curl -X POST 'https://your-host/api/v1/vehicles/realtime/query' \
|
||||
-H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
|
||||
-H 'Content-Type: application/json' -d '{}'
|
||||
|
||||
curl -X POST 'https://your-host/api/v1/hydrogen-stations/query' \
|
||||
-H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
|
||||
-H 'Content-Type: application/json' -d '{}'</pre>
|
||||
|
||||
<h2>响应示例</h2>
|
||||
<h3>车辆单日里程</h3>
|
||||
<pre>{
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 车辆数据开放平台 API
|
||||
version: 1.5.0
|
||||
version: 1.6.0
|
||||
license:
|
||||
name: Proprietary
|
||||
description: |
|
||||
向授权合作方开放车辆单日用氢量、单日里程、区间日里程和指定时刻总里程。
|
||||
向授权合作方开放车辆单日用氢量、单日里程、区间日里程、指定时刻总里程、实时位置状态及加氢站地图点位。
|
||||
appKey 和逐车授权必须完整覆盖查询自然日。
|
||||
servers:
|
||||
- url: /
|
||||
@@ -160,6 +160,68 @@ paths:
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/api/v1/vehicles/realtime/query:
|
||||
post:
|
||||
tags: [合作方数据接口]
|
||||
summary: 查询授权车辆实时位置与状态
|
||||
description: |
|
||||
plateNumbers 省略或传空数组时返回应用当前有效授权的全部车辆。
|
||||
实时来源优先级为 GB32960 > YUTONG_MQTT > JT808;所有来源超过10分钟时改按最新记录选择。
|
||||
任一采集协议在最近60秒内上报即视为在线;protocol、位置、速度和记录时间仍按上述来源优先级选择。
|
||||
在线且所选来源速度大于3km/h为行驶中,否则为静止中。
|
||||
operationId: queryRealtimeVehicles
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RealtimeVehicleQuery'
|
||||
example: {}
|
||||
responses:
|
||||
'200':
|
||||
description: 查询成功;没有实时记录的授权车辆以 NO_DATA 返回
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RealtimeVehicleQueryResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/api/v1/hydrogen-stations/query:
|
||||
post:
|
||||
tags: [合作方数据接口]
|
||||
summary: 查询加氢站地图点位
|
||||
description: 只读返回资产管理数据库内具有有效经纬度的加氢站,可按省、市和合作状态筛选;无需车辆授权但要求有效 appKey。
|
||||
operationId: queryHydrogenStations
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HydrogenStationQuery'
|
||||
example: {}
|
||||
responses:
|
||||
'200':
|
||||
description: 查询成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HydrogenStationQueryResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/api/v2/open-platform/apps:
|
||||
get:
|
||||
tags: [开放平台管理]
|
||||
@@ -430,6 +492,32 @@ components:
|
||||
type: string
|
||||
enum: [GB32960, YUTONG_MQTT, JT808]
|
||||
description: 可选;只接受平台统一协议标识;不传时按 GB32960 > YUTONG_MQTT > JT808
|
||||
RealtimeVehicleQuery:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
plateNumbers:
|
||||
type: array
|
||||
maxItems: 2000
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
description: 可选;省略或传空数组时查询当前有效授权的全部车辆
|
||||
HydrogenStationQuery:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
province:
|
||||
type: string
|
||||
maxLength: 32
|
||||
city:
|
||||
type: string
|
||||
maxLength: 32
|
||||
cooperateOnly:
|
||||
type: boolean
|
||||
description: true仅合作站,false仅外部站,省略则返回全部
|
||||
HydrogenResult:
|
||||
type: object
|
||||
required: [plateNumber, date, hydrogenConsumptionKg, status]
|
||||
@@ -614,6 +702,55 @@ components:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TotalMileageResult'
|
||||
RealtimeVehicleResult:
|
||||
type: object
|
||||
required: [vin, plateNumber, online, motionStatus, locationAvailable, status]
|
||||
properties:
|
||||
vin: { type: string }
|
||||
plateNumber: { type: string }
|
||||
protocol: { type: string, enum: [GB32960, YUTONG_MQTT, JT808] }
|
||||
longitude: { type: number, format: double, nullable: true }
|
||||
latitude: { type: number, format: double, nullable: true }
|
||||
speedKmh: { type: number, format: double, nullable: true }
|
||||
totalMileageKm: { type: number, format: double, nullable: true }
|
||||
recordTime: { type: string }
|
||||
timeDifferenceSeconds: { type: integer, format: int64, minimum: 0 }
|
||||
online: { type: boolean, description: 任一采集协议是否在最近60秒内上报 }
|
||||
motionStatus: { type: string, enum: [driving, idle, offline] }
|
||||
locationAvailable: { type: boolean }
|
||||
status: { $ref: '#/components/schemas/DataStatus' }
|
||||
RealtimeVehicleQueryResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SuccessEnvelope'
|
||||
- type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/RealtimeVehicleResult' }
|
||||
HydrogenStation:
|
||||
type: object
|
||||
required: [id, name, longitude, latitude, cooperative]
|
||||
properties:
|
||||
id: { type: string, description: 站点ID使用字符串避免JavaScript整数精度损失 }
|
||||
name: { type: string }
|
||||
shortName: { type: string }
|
||||
address: { type: string }
|
||||
longitude: { type: number, format: double }
|
||||
latitude: { type: number, format: double }
|
||||
province: { type: string }
|
||||
city: { type: string }
|
||||
district: { type: string }
|
||||
cooperative: { type: boolean }
|
||||
HydrogenStationQueryResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SuccessEnvelope'
|
||||
- type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/HydrogenStation' }
|
||||
SuccessEnvelope:
|
||||
type: object
|
||||
required: [code, message, traceId]
|
||||
|
||||
@@ -17,10 +17,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
HydrogenQueryPath = "/api/v1/vehicles/hydrogen-consumption/query"
|
||||
MileageQueryPath = "/api/v1/vehicles/mileage/query"
|
||||
MileageRangeQueryPath = "/api/v1/vehicles/mileage/range/query"
|
||||
TotalMileageQueryPath = "/api/v1/vehicles/total-mileage/query"
|
||||
HydrogenQueryPath = "/api/v1/vehicles/hydrogen-consumption/query"
|
||||
MileageQueryPath = "/api/v1/vehicles/mileage/query"
|
||||
MileageRangeQueryPath = "/api/v1/vehicles/mileage/range/query"
|
||||
TotalMileageQueryPath = "/api/v1/vehicles/total-mileage/query"
|
||||
RealtimeVehicleQueryPath = "/api/v1/vehicles/realtime/query"
|
||||
HydrogenStationQueryPath = "/api/v1/hydrogen-stations/query"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
@@ -59,6 +61,8 @@ func (h *Handler) registerExternalDataRoutes() {
|
||||
h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage)
|
||||
h.mux.HandleFunc("POST "+MileageRangeQueryPath, h.mileageRange)
|
||||
h.mux.HandleFunc("POST "+TotalMileageQueryPath, h.totalMileage)
|
||||
h.mux.HandleFunc("POST "+RealtimeVehicleQueryPath, h.realtimeVehicles)
|
||||
h.mux.HandleFunc("POST "+HydrogenStationQueryPath, h.hydrogenStations)
|
||||
}
|
||||
|
||||
func (h *Handler) registerAdminAppRoutes() {
|
||||
@@ -106,6 +110,8 @@ func NewDataHandler(service *Service) *Handler {
|
||||
handler.mux.HandleFunc("POST "+MileageQueryPath, handler.mileage)
|
||||
handler.mux.HandleFunc("POST "+MileageRangeQueryPath, handler.mileageRange)
|
||||
handler.mux.HandleFunc("POST "+TotalMileageQueryPath, handler.totalMileage)
|
||||
handler.mux.HandleFunc("POST "+RealtimeVehicleQueryPath, handler.realtimeVehicles)
|
||||
handler.mux.HandleFunc("POST "+HydrogenStationQueryPath, handler.hydrogenStations)
|
||||
return handler
|
||||
}
|
||||
|
||||
@@ -114,7 +120,35 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func IsPublicPath(path string) bool {
|
||||
return path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath
|
||||
return path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath
|
||||
}
|
||||
|
||||
func (h *Handler) realtimeVehicles(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := externalTraceID(r)
|
||||
var request RealtimeVehicleRequest
|
||||
if !decodeExternalBody(w, r, traceID, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.QueryRealtimeVehicles(r.Context(), externalBearer(r), traceID, request)
|
||||
if err != nil {
|
||||
writeExternalError(w, traceID, err)
|
||||
return
|
||||
}
|
||||
writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID})
|
||||
}
|
||||
|
||||
func (h *Handler) hydrogenStations(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := externalTraceID(r)
|
||||
var request HydrogenStationRequest
|
||||
if !decodeExternalBody(w, r, traceID, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.QueryHydrogenStations(r.Context(), externalBearer(r), traceID, request)
|
||||
if err != nil {
|
||||
writeExternalError(w, traceID, err)
|
||||
return
|
||||
}
|
||||
writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID})
|
||||
}
|
||||
|
||||
func (h *Handler) hydrogen(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -725,5 +759,17 @@ func dataProducts() []DataProduct {
|
||||
Version: "v1", Status: "available", Method: http.MethodPost,
|
||||
Path: TotalMileageQueryPath, Unit: "km",
|
||||
},
|
||||
{
|
||||
Code: "realtime_vehicle", Name: "车辆实时位置与状态",
|
||||
Description: "查询应用授权车辆的最新位置、在线状态、速度、总里程和采集协议。",
|
||||
Version: "v1", Status: "available", Method: http.MethodPost,
|
||||
Path: RealtimeVehicleQueryPath, Unit: "",
|
||||
},
|
||||
{
|
||||
Code: "hydrogen_station", Name: "加氢站地图点位",
|
||||
Description: "只读查询资产管理库中的加氢站名称、经纬度、行政区划和合作状态。",
|
||||
Version: "v1", Status: "available", Method: http.MethodPost,
|
||||
Path: HydrogenStationQueryPath, Unit: "座",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,56 @@ type TotalMileagePoint struct {
|
||||
TotalMileageKm float64
|
||||
}
|
||||
|
||||
type RealtimeVehicleRequest struct {
|
||||
PlateNumbers []string `json:"plateNumbers,omitempty"`
|
||||
}
|
||||
|
||||
type RealtimeVehiclePoint struct {
|
||||
VIN string
|
||||
Protocol string
|
||||
Longitude float64
|
||||
Latitude float64
|
||||
SpeedKmh float64
|
||||
TotalMileageKm float64
|
||||
ObservedAt time.Time
|
||||
Online bool
|
||||
}
|
||||
|
||||
type RealtimeVehicleResult struct {
|
||||
VIN string `json:"vin"`
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
Longitude *float64 `json:"longitude"`
|
||||
Latitude *float64 `json:"latitude"`
|
||||
SpeedKmh *float64 `json:"speedKmh"`
|
||||
TotalMileageKm *float64 `json:"totalMileageKm"`
|
||||
RecordTime string `json:"recordTime,omitempty"`
|
||||
TimeDifferenceSeconds *int64 `json:"timeDifferenceSeconds,omitempty"`
|
||||
Online bool `json:"online"`
|
||||
MotionStatus string `json:"motionStatus"`
|
||||
LocationAvailable bool `json:"locationAvailable"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type HydrogenStationRequest struct {
|
||||
Province string `json:"province,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
CooperateOnly *bool `json:"cooperateOnly,omitempty"`
|
||||
}
|
||||
|
||||
type HydrogenStation struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ShortName string `json:"shortName,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Province string `json:"province,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
District string `json:"district,omitempty"`
|
||||
Cooperative bool `json:"cooperative"`
|
||||
}
|
||||
|
||||
type ExternalResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
|
||||
@@ -137,6 +137,97 @@ func (r *MySQLRepository) TotalMileage(ctx context.Context, vin string, at time.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) RealtimeVehicles(ctx context.Context, vins []string, now time.Time) (map[string]RealtimeVehiclePoint, error) {
|
||||
out := make(map[string]RealtimeVehiclePoint, len(vins))
|
||||
if len(vins) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
|
||||
args := make([]any, 0, len(vins)+1)
|
||||
for _, vin := range vins {
|
||||
args = append(args, vin)
|
||||
}
|
||||
args = append(args, now.Add(-10*time.Minute))
|
||||
query := `
|
||||
SELECT l.vin,l.protocol,COALESCE(l.longitude,0),COALESCE(l.latitude,0),
|
||||
COALESCE(l.speed_kmh,0),COALESCE(l.total_mileage_km,0),l.updated_at
|
||||
FROM vehicle_realtime_location l
|
||||
WHERE BINARY l.vin IN (` + placeholders + `)
|
||||
ORDER BY l.vin,
|
||||
CASE WHEN l.updated_at>=? THEN 0 ELSE 1 END,
|
||||
CASE l.protocol WHEN 'GB32960' THEN 10 WHEN 'YUTONG_MQTT' THEN 20 WHEN 'JT808' THEN 30 ELSE 100 END,
|
||||
l.updated_at DESC,l.protocol ASC`
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
onlineThreshold := now.Add(-time.Minute)
|
||||
for rows.Next() {
|
||||
var point RealtimeVehiclePoint
|
||||
if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &point.TotalMileageKm, &point.ObservedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
point.Online = !point.ObservedAt.Before(onlineThreshold)
|
||||
if selected, exists := out[point.VIN]; !exists {
|
||||
out[point.VIN] = point
|
||||
} else if point.Online && !selected.Online {
|
||||
// The selected source still follows the documented protocol priority, but
|
||||
// online means that any source for this VIN reported in the last minute.
|
||||
selected.Online = true
|
||||
out[point.VIN] = selected
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) HydrogenStations(ctx context.Context, request HydrogenStationRequest) ([]HydrogenStation, error) {
|
||||
where := []string{
|
||||
"s.longitude BETWEEN -180 AND 180",
|
||||
"s.latitude BETWEEN -90 AND 90",
|
||||
"NOT (s.longitude=0 AND s.latitude=0)",
|
||||
}
|
||||
args := make([]any, 0, 3)
|
||||
if request.Province != "" {
|
||||
where = append(where, "s.province=?")
|
||||
args = append(args, request.Province)
|
||||
}
|
||||
if request.City != "" {
|
||||
where = append(where, "s.city=?")
|
||||
args = append(args, request.City)
|
||||
}
|
||||
if request.CooperateOnly != nil {
|
||||
if *request.CooperateOnly {
|
||||
where = append(where, "s.inner_site_id IS NOT NULL")
|
||||
} else {
|
||||
where = append(where, "s.inner_site_id IS NULL")
|
||||
}
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT CAST(s.id AS CHAR),COALESCE(NULLIF(s.fixed_station_name,''),NULLIF(s.station_name,''),''),
|
||||
COALESCE(h.station_short_name,''),COALESCE(s.station_address,''),
|
||||
s.longitude,s.latitude,COALESCE(s.province,''),COALESCE(s.city,''),COALESCE(s.district,''),
|
||||
CASE WHEN s.inner_site_id IS NULL THEN 0 ELSE 1 END
|
||||
FROM ln_asset_management.tab_outside_hydrogen_site s
|
||||
LEFT JOIN ln_asset_management.hydrogen_station h ON h.id=s.inner_site_id AND h.del_flag='0'
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY s.province,s.city,s.fixed_station_name,s.id
|
||||
LIMIT 2000`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
stations := make([]HydrogenStation, 0, 512)
|
||||
for rows.Next() {
|
||||
var station HydrogenStation
|
||||
if err := rows.Scan(&station.ID, &station.Name, &station.ShortName, &station.Address, &station.Longitude, &station.Latitude, &station.Province, &station.City, &station.District, &station.Cooperative); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stations = append(stations, station)
|
||||
}
|
||||
return stations, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) DailyHydrogen(ctx context.Context, vins []string, date string) (map[string]DailyHydrogen, error) {
|
||||
if len(vins) == 0 {
|
||||
return map[string]DailyHydrogen{}, nil
|
||||
|
||||
@@ -185,6 +185,33 @@ func TestTotalMileageUsesProtocolPriorityAndLatestRecordAtOrBeforeTime(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeVehiclesAnyFreshProtocolKeepsSelectedSourceOnline(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
now := time.Date(2026, 8, 3, 21, 37, 30, 0, time.Local)
|
||||
vin := "LTEST32960VIN0001"
|
||||
mock.ExpectQuery("SELECT l.vin,l.protocol.*FROM vehicle_realtime_location").
|
||||
WithArgs(vin, now.Add(-10*time.Minute)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "longitude", "latitude", "speed_kmh", "total_mileage_km", "updated_at"}).
|
||||
AddRow(vin, "GB32960", 120.1, 30.2, 0, 1000, now.Add(-2*time.Minute)).
|
||||
AddRow(vin, "JT808", 120.2, 30.3, 10, 0, now.Add(-20*time.Second)))
|
||||
|
||||
points, err := NewMySQLRepository(db).RealtimeVehicles(context.Background(), []string{vin}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
point := points[vin]
|
||||
if point.Protocol != "GB32960" || !point.Online || !point.ObservedAt.Equal(now.Add(-2*time.Minute)) {
|
||||
t.Fatalf("selected source and aggregate online state mismatch: %#v", point)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListVehicleGrantsUsesBinaryVINJoin(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
|
||||
@@ -38,6 +38,8 @@ type Repository interface {
|
||||
LoadMileageSnapshot(context.Context, string, uint64, time.Time) (MileageSnapshot, error)
|
||||
AuthorizedVIN(context.Context, uint64, string, time.Time) (bool, error)
|
||||
TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error)
|
||||
RealtimeVehicles(context.Context, []string, time.Time) (map[string]RealtimeVehiclePoint, error)
|
||||
HydrogenStations(context.Context, HydrogenStationRequest) ([]HydrogenStation, error)
|
||||
Audit(context.Context, uint64, string, string, string, int, string) error
|
||||
|
||||
CreateApp(context.Context, AppInput, [sha256.Size]byte, string, time.Time, *time.Time, string) (App, error)
|
||||
@@ -48,6 +50,87 @@ type Repository interface {
|
||||
ListVehicleGrants(context.Context, uint64) ([]VehicleGrant, error)
|
||||
}
|
||||
|
||||
func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID string, request RealtimeVehicleRequest) ([]RealtimeVehicleResult, error) {
|
||||
now := s.now().In(s.location)
|
||||
plates, err := normalizePlates(request.PlateNumbers, 2000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
app, vehicles, err := s.authorize(ctx, appKey, plates, now, now)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "denied", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
if len(plates) == 0 {
|
||||
plates = vehiclePlates(vehicles)
|
||||
}
|
||||
points, err := s.repository.RealtimeVehicles(ctx, vehicleVINs(vehicles), now)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
results := make([]RealtimeVehicleResult, 0, len(plates))
|
||||
for _, plate := range plates {
|
||||
vehicle := vehicles[plate]
|
||||
item := RealtimeVehicleResult{VIN: vehicle.VIN, PlateNumber: plate, MotionStatus: "offline", Status: StatusNoData}
|
||||
if point, ok := points[vehicle.VIN]; ok {
|
||||
difference := int64(now.Sub(point.ObservedAt.In(s.location)).Seconds())
|
||||
if difference < 0 {
|
||||
difference = 0
|
||||
}
|
||||
item.Protocol = point.Protocol
|
||||
item.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05")
|
||||
item.TimeDifferenceSeconds = &difference
|
||||
item.Online = point.Online
|
||||
item.MotionStatus = "offline"
|
||||
if item.Online && point.SpeedKmh > 3 {
|
||||
item.MotionStatus = "driving"
|
||||
} else if item.Online {
|
||||
item.MotionStatus = "idle"
|
||||
}
|
||||
speed, mileage := round3(point.SpeedKmh), round3(point.TotalMileageKm)
|
||||
item.SpeedKmh, item.TotalMileageKm = &speed, &mileage
|
||||
if validCoordinate(point.Longitude, point.Latitude) {
|
||||
longitude, latitude := point.Longitude, point.Latitude
|
||||
item.Longitude, item.Latitude = &longitude, &latitude
|
||||
item.LocationAvailable = true
|
||||
}
|
||||
item.Status = StatusNormal
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "success", traceID, len(results), "")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *Service) QueryHydrogenStations(ctx context.Context, appKey, traceID string, request HydrogenStationRequest) ([]HydrogenStation, error) {
|
||||
now := s.now().In(s.location)
|
||||
if !appKeyPattern.MatchString(appKey) {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
request.Province = strings.TrimSpace(request.Province)
|
||||
request.City = strings.TrimSpace(request.City)
|
||||
if len([]rune(request.Province)) > 32 || len([]rune(request.City)) > 32 {
|
||||
return nil, fmt.Errorf("%w: province or city too long", ErrInvalidRequest)
|
||||
}
|
||||
app, err := s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(appKey))), now, now, now)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, 0, "hydrogen_station_query", "denied", traceID, 0, ErrUnauthorized.Error())
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
stations, err := s.repository.HydrogenStations(ctx, request)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "hydrogen_station_query", "error", traceID, 0, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
_ = s.repository.Audit(ctx, app.ID, "hydrogen_station_query", "success", traceID, 0, "")
|
||||
return stations, nil
|
||||
}
|
||||
|
||||
func validCoordinate(longitude, latitude float64) bool {
|
||||
return longitude >= -180 && longitude <= 180 && latitude >= -90 && latitude <= 90 && !(longitude == 0 && latitude == 0)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
now func() time.Time
|
||||
|
||||
@@ -21,6 +21,8 @@ type fakeRepository struct {
|
||||
priorMileage map[string]DailyMileage
|
||||
authorizedVIN bool
|
||||
totalMileage *TotalMileagePoint
|
||||
realtime map[string]RealtimeVehiclePoint
|
||||
stations []HydrogenStation
|
||||
audits []string
|
||||
createdHash [sha256.Size]byte
|
||||
createdPrefix string
|
||||
@@ -78,10 +80,79 @@ func (f *fakeRepository) AuthorizedVIN(context.Context, uint64, string, time.Tim
|
||||
func (f *fakeRepository) TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error) {
|
||||
return f.totalMileage, nil
|
||||
}
|
||||
func (f *fakeRepository) RealtimeVehicles(context.Context, []string, time.Time) (map[string]RealtimeVehiclePoint, error) {
|
||||
return f.realtime, nil
|
||||
}
|
||||
func (f *fakeRepository) HydrogenStations(context.Context, HydrogenStationRequest) ([]HydrogenStation, error) {
|
||||
return f.stations, nil
|
||||
}
|
||||
func (f *fakeRepository) Audit(_ context.Context, _ uint64, endpoint, result, _ string, _ int, _ string) error {
|
||||
f.audits = append(f.audits, endpoint+":"+result)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRealtimeVehicleAndHydrogenStationQueries(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 19, 30, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 9, Name: "vehicle-map"},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"浙A12345": {VIN: "LTEST32960VIN0001", Plate: "浙A12345"},
|
||||
"浙B67890": {VIN: "LTEST32960VIN0002", Plate: "浙B67890"},
|
||||
},
|
||||
realtime: map[string]RealtimeVehiclePoint{
|
||||
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", Protocol: "GB32960", Longitude: 120.1, Latitude: 30.2, SpeedKmh: 42.5, TotalMileageKm: 12345.6, ObservedAt: now.Add(-30 * time.Second), Online: true},
|
||||
},
|
||||
stations: []HydrogenStation{{ID: "1", Name: "测试加氢站", Longitude: 120.2, Latitude: 30.3}},
|
||||
}
|
||||
service := NewService(repository)
|
||||
service.now = func() time.Time { return now }
|
||||
const key = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
vehicles, err := service.QueryRealtimeVehicles(context.Background(), key, "trace-v", RealtimeVehicleRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vehicles) != 2 || !vehicles[0].Online || vehicles[0].MotionStatus != "driving" || !vehicles[0].LocationAvailable {
|
||||
t.Fatalf("unexpected realtime vehicles: %#v", vehicles)
|
||||
}
|
||||
if vehicles[1].Status != StatusNoData || vehicles[1].MotionStatus != "offline" {
|
||||
t.Fatalf("missing realtime row should remain explicit: %#v", vehicles[1])
|
||||
}
|
||||
stations, err := service.QueryHydrogenStations(context.Background(), key, "trace-s", HydrogenStationRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stations) != 1 || stations[0].Name != "测试加氢站" {
|
||||
t.Fatalf("unexpected stations: %#v", stations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeVehicleOnlineUsesAnyFreshProtocol(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 19, 30, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 9, Name: "vehicle-map"},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"浙A12345": {VIN: "LTEST32960VIN0001", Plate: "浙A12345"},
|
||||
},
|
||||
realtime: map[string]RealtimeVehiclePoint{
|
||||
// The selected GB32960 point can be older while JT808/MQTT keeps the VIN online.
|
||||
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", Protocol: "GB32960", Longitude: 120.1, Latitude: 30.2, ObservedAt: now.Add(-2 * time.Minute), Online: true},
|
||||
},
|
||||
}
|
||||
service := NewService(repository)
|
||||
service.now = func() time.Time { return now }
|
||||
|
||||
vehicles, err := service.QueryRealtimeVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "trace-any-source", RealtimeVehicleRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vehicles) != 1 || !vehicles[0].Online || vehicles[0].MotionStatus != "idle" {
|
||||
t.Fatalf("fresh alternate protocol should keep vehicle online: %#v", vehicles)
|
||||
}
|
||||
if vehicles[0].TimeDifferenceSeconds == nil || *vehicles[0].TimeDifferenceSeconds != 120 {
|
||||
t.Fatalf("selected source record time must remain auditable: %#v", vehicles[0])
|
||||
}
|
||||
}
|
||||
func (f *fakeRepository) CreateApp(_ context.Context, input AppInput, hash [sha256.Size]byte, prefix string, from time.Time, to *time.Time, actor string) (App, error) {
|
||||
f.createdHash, f.createdPrefix = hash, prefix
|
||||
return App{ID: 1, Name: input.Name, AppKeyPrefix: prefix, Status: input.Status, ValidFrom: from, ValidTo: to, CreatedBy: actor}, nil
|
||||
|
||||
@@ -40,10 +40,40 @@ export const products: Product[] = [
|
||||
method: "POST",
|
||||
path: "/api/v1/vehicles/total-mileage/query",
|
||||
unit: "km"
|
||||
},
|
||||
{
|
||||
code: "realtime_vehicle",
|
||||
name: "车辆实时位置与状态",
|
||||
description: "查询应用授权车辆的最新位置、在线状态、速度、总里程和采集协议。",
|
||||
version: "v1",
|
||||
status: "available",
|
||||
method: "POST",
|
||||
path: "/api/v1/vehicles/realtime/query",
|
||||
unit: ""
|
||||
},
|
||||
{
|
||||
code: "hydrogen_station",
|
||||
name: "加氢站地图点位",
|
||||
description: "只读查询资产管理库加氢站的名称、经纬度、行政区划和合作状态。",
|
||||
version: "v1",
|
||||
status: "available",
|
||||
method: "POST",
|
||||
path: "/api/v1/hydrogen-stations/query",
|
||||
unit: "座"
|
||||
}
|
||||
];
|
||||
|
||||
export const curlExample = (product: Product) => {
|
||||
if (product.code === "realtime_vehicle") return `curl --request POST \\
|
||||
--url https://open.d.lnoneos.com${product.path} \\
|
||||
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data '{}'`;
|
||||
if (product.code === "hydrogen_station") return `curl --request POST \\
|
||||
--url https://open.d.lnoneos.com${product.path} \\
|
||||
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data '{"province":"浙江省"}'`;
|
||||
if (product.code === "total_mileage_at_time") return `curl --request POST \\
|
||||
--url https://open.d.lnoneos.com${product.path} \\
|
||||
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
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=5
|
||||
STATION_CACHE_SECONDS=3600
|
||||
# 高德 Web 服务 Key;用于以 GCJ-02 坐标逆地理得到省、市、区。
|
||||
AMAP_REGEOCODE_KEY=replace-with-amap-web-service-key
|
||||
# 行政区结果按坐标落盘缓存 7 天,避免每次刷新重复调用高德。
|
||||
STATION_GEOCODE_CACHE_SECONDS=604800
|
||||
STATION_GEOCODE_CACHE_PATH=/opt/lingniu-vehicle-map/cache/station-geocode.json
|
||||
# 高德 Web 服务常见 QPS 配额较低;全局限速以避免冷缓存时触发 10021。
|
||||
AMAP_REGEOCODE_MIN_INTERVAL_SECONDS=0.25
|
||||
@@ -0,0 +1,5 @@
|
||||
.DS_Store
|
||||
*.log
|
||||
node_modules/
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -0,0 +1,45 @@
|
||||
# 羚牛 Vehicle Map
|
||||
|
||||
全国氢能车辆与加氢站运营驾驶舱。浏览器只访问本服务的 `/api/dashboard`,服务端使用开放平台 AppKey 获取其授权车辆、当日里程和资产库只读加氢站点位,AppKey 不会下发到浏览器。
|
||||
|
||||
## 当前部署
|
||||
|
||||
- ECS 服务:`lingniu-vehicle-map.service`
|
||||
- 访问地址:<https://map.d.lnoneos.com/>
|
||||
- 健康检查:<https://map.d.lnoneos.com/api/health>
|
||||
|
||||
原始仓库只有静态页面和静态文件服务,没有车辆数据接口;当前版本已改为通过服务端代理对接车辆数据开放平台,页面展示的数据不再使用硬编码车辆和加氢站样例。
|
||||
|
||||
## 本地运行
|
||||
|
||||
```bash
|
||||
export OPEN_PLATFORM_APP_KEY='<32位AppKey>'
|
||||
python3 server.py
|
||||
```
|
||||
|
||||
默认地址:`http://127.0.0.1:20800`。
|
||||
|
||||
请通过上述 HTTP 地址访问,不能直接双击打开 `index.html`:页面需要同源调用本服务的数据 API。
|
||||
|
||||
## 数据访问
|
||||
|
||||
- `GET /api/dashboard` 公开返回车辆信息、站点基础信息及加氢量。
|
||||
- 不保存访问码、不创建浏览器会话,也不区分公开或受保护的数据接口。
|
||||
|
||||
## 接口依赖
|
||||
|
||||
- `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`。同一配置文件中的 `AMAP_REGEOCODE_KEY` 仅在服务端使用:站点省、市、区按 GCJ-02 坐标调用高德逆地理生成,不能使用资产表的行政区字段;请求通过全局速率限制避免高德 QPS 限流,结果以坐标为键落盘缓存 7 天。详细地址仍直接保留资产原始字段。
|
||||
|
||||
本服务提供:
|
||||
|
||||
- `GET /api/health`:进程及配置状态;
|
||||
- `GET /api/dashboard`:面向前端的公开聚合数据,默认缓存 2 分钟;加氢站目录同样缓存 2 分钟。服务启动后会后台预热缓存,避免首位访问者等待上游数据请求。
|
||||
- `GET /api/stations`:已按 GPS 解析行政区的公开站点目录,供独立加氢站导航服务复用;不含加氢量。
|
||||
|
||||
全国视图按车辆最新 GPS 坐标落入省级行政区,不按车牌归属地推断;没有有效实时坐标的车辆会单独计入“无实时位置”,不会伪造省份归属。页面每 15 秒请求一次,但服务端最多每 2 分钟刷新一次上游快照,以换取更快、更稳定的首屏加载。
|
||||
|
||||
车辆地图按缩放级别逐级下钻:全国视角(小于7级)按省聚合,7–9.5级按市聚合,9.5–12级按区县聚合,12级及以上显示当前视野内的单车真实位置。点击省、市、区县气泡会自动进入下一级。
|
||||
+1681
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
#!/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" "$root/cache"
|
||||
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"
|
||||
chown vehicle-map:vehicle-map "$root/cache"
|
||||
chmod 0750 "$root/cache"
|
||||
|
||||
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 and d["summary"]["totalVehicles"]>1000'
|
||||
printf 'vehicle_map_release_install=ok release=%s\n' "$release_id"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
systemctl status "$service" --no-pager >&2 || true
|
||||
exit 1
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=Lingniu Vehicle Map Dashboard
|
||||
After=network-online.target lingniu-vehicle-open-platform.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=vehicle-map
|
||||
Group=vehicle-map
|
||||
WorkingDirectory=/opt/lingniu-vehicle-map/current
|
||||
EnvironmentFile=/opt/lingniu-vehicle-map/env/vehicle-map.env
|
||||
ExecStart=/usr/bin/python3 /opt/lingniu-vehicle-map/current/server.py
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,250 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>羚牛氢能 - 车辆网络 | Lingniu H2 Executive Cockpit</title>
|
||||
<!-- Google Fonts: Inter & JetBrains Mono -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="styles.css?v=__ASSET_VERSION__">
|
||||
|
||||
<!-- 高德地图 (AutoNavi AMap) Web JS API v2.0 Configuration -->
|
||||
<script type="text/javascript">
|
||||
window._AMapSecurityConfig = {
|
||||
securityJsCode: '0b54a41143bec162788d01deba851340',
|
||||
}
|
||||
</script>
|
||||
<script type="text/javascript" src="https://webapi.amap.com/maps?v=2.0&key=1868920ac8ff6b6f88dbe9fa2609c183&plugin=AMap.Scale,AMap.ToolBar,AMap.Marker"></script>
|
||||
</head>
|
||||
<body class="theme-white">
|
||||
|
||||
<!-- APPLE LIQUID GLASS EXECUTIVE COCKPIT WRAPPER -->
|
||||
<div class="liquid-cockpit-wrapper">
|
||||
|
||||
<!-- 1. TOP FLOATING NAVIGATION BAR -->
|
||||
<header class="liquid-header floating-glass">
|
||||
<div class="header-left">
|
||||
<div class="brand-block" title="羚牛氢能 Lingniu Link">
|
||||
<!-- Official Lingniu Hydrogen Logo (logo_light.svg) -->
|
||||
<div class="brand-logo-switcher">
|
||||
<img class="brand-logo-svg" id="brandLogo" src="logo_light.svg" width="150" height="36" alt="羚牛氢能">
|
||||
<div class="brand-logo-en" id="brandLogoEnglish" hidden aria-label="Lingniu Hydrogen Mobility">
|
||||
<span class="brand-mark-crop"><img src="logo_light.svg" alt=""></span>
|
||||
<img src="logo_en.svg?v=20260804045500" alt="Lingniu Hydrogen Mobility">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="brand-divider"></div>
|
||||
<div class="cockpit-title-wrap">
|
||||
<h1 class="cockpit-title" id="dashboardTitle" data-i18n="vehicleModeTitle">车辆网络</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Three Clean Operational KPI Capsules -->
|
||||
<div class="header-kpi-group">
|
||||
<div class="kpi-glass-capsule">
|
||||
<span class="capsule-lbl" id="kpiTotalLabel" data-i18n="kpiTotalFleet">车辆总数</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" id="kpiActiveLabel" data-i18n="kpiOnlineFleet">当前在线运行</span>
|
||||
<span class="capsule-val" id="kpiFleetOnline">-- <small data-i18n-append="onlinePct">辆</small></span>
|
||||
</div>
|
||||
<div class="kpi-glass-capsule">
|
||||
<span class="capsule-lbl" id="kpiActivityLabel" data-i18n="kpiDailyMileage">今日运营里程</span>
|
||||
<span class="capsule-val" id="kpiDailyDist">-- <small>km</small></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header Controls: Mode Switcher, Theme Switcher & Language Switcher -->
|
||||
<div class="header-controls">
|
||||
<div class="glass-segmented-control">
|
||||
<button class="segment-btn active" id="btnModeStation" onclick="switchMode('station')" data-i18n="btnStation">加氢站</button>
|
||||
<button class="segment-btn is-locked" id="btnModeVehicle" onclick="switchMode('vehicle')" data-i18n="btnVehicle">车辆</button>
|
||||
</div>
|
||||
|
||||
<!-- Bilingual ZH / EN Switcher -->
|
||||
<div class="glass-segmented-control lang-switcher-bw">
|
||||
<button class="segment-btn lang-zh-btn active" onclick="setLanguage('zh')">中</button>
|
||||
<button class="segment-btn lang-en-btn" onclick="setLanguage('en')">EN</button>
|
||||
</div>
|
||||
|
||||
<!-- Professional Vector SVG Theme Switcher (DEFAULT: LIGHT MODE) -->
|
||||
<div class="theme-switcher-bw">
|
||||
<button class="bw-btn theme-dark-btn" title="深色模式 (Dark Mode)" onclick="setTheme('theme-dark')">
|
||||
<svg class="bw-svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
<span data-i18n="themeDark">深色</span>
|
||||
</button>
|
||||
<button class="bw-btn theme-white-btn active" title="浅色模式 (Light Mode)" onclick="setTheme('theme-white')">
|
||||
<svg class="bw-svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
<span data-i18n="themeLight">浅色</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="time-widget" id="clockTime">19:04:00</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 2. MAIN BODY (CENTER MAP CANVAS + RIGHT SIDEBAR) -->
|
||||
<main class="cockpit-main-grid">
|
||||
|
||||
<!-- SPATIAL AMAP 3D CANVAS LAYER -->
|
||||
<section class="map-spatial-container floating-glass">
|
||||
|
||||
<!-- Map Floating Status Pill -->
|
||||
<div class="map-top-bar">
|
||||
<div class="status-glass-pill">
|
||||
<span class="pulse-ring"></span>
|
||||
<span id="mapStatusText">正在同步开放平台数据…</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="map-explore-toolbar" aria-label="地图筛选与定位">
|
||||
<button class="locate-btn" id="locateDeviceBtn" type="button" onclick="locateDevice()" aria-label="定位到我的位置" title="定位到我的位置">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3"></path>
|
||||
<circle cx="12" cy="12" r="8"></circle>
|
||||
</svg>
|
||||
<span id="locateDeviceLabel" data-i18n="btnLocate">定位</span>
|
||||
</button>
|
||||
|
||||
<div class="explore-search-shell">
|
||||
<label class="entity-search-field" for="entitySearch">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.6-3.6"></path></svg>
|
||||
<input id="entitySearch" type="search" autocomplete="off" placeholder="搜索位置、站点、车牌或 VIN" aria-label="搜索位置、站点、车牌或 VIN" oninput="handleExploreInput(this.value)" onfocus="openExploreSuggestions()" onkeydown="handleExploreSearchKeydown(event)">
|
||||
<button class="search-clear-btn" id="searchClearBtn" type="button" onclick="clearNameFilter()" aria-label="清除名称筛选" hidden>×</button>
|
||||
</label>
|
||||
<div class="explore-suggestions" id="exploreSuggestions" role="listbox" aria-label="搜索建议" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="filter-chip-rail" id="filterChipRail" aria-label="当前筛选" hidden></div>
|
||||
<span class="filter-result-meta" id="filterResultMeta" aria-live="polite">全部 0</span>
|
||||
<span class="location-feedback" id="locationFeedback" role="status" hidden></span>
|
||||
</div>
|
||||
|
||||
<!-- AMap 3D Canvas -->
|
||||
<div class="amap-canvas-box">
|
||||
<div id="amapContainer"></div>
|
||||
|
||||
<div class="navigation-launch-overlay" id="navigationLaunchOverlay" role="status" aria-live="polite" hidden>
|
||||
<div class="navigation-launch-panel">
|
||||
<span class="navigation-launch-spinner" aria-hidden="true"></span>
|
||||
<div>
|
||||
<strong data-i18n="navigationLaunchingTitle">正在打开高德地图</strong>
|
||||
<span data-i18n="navigationLaunchingDescription">正在准备导航路线,请稍候</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article class="map-detail-card" id="mapDetailCard" aria-live="polite" hidden>
|
||||
<div class="detail-card-accent" id="detailCardAccent"></div>
|
||||
<div class="detail-card-header">
|
||||
<div class="detail-title-wrap">
|
||||
<span class="detail-type-pill" id="detailTypePill">车辆</span>
|
||||
<h2 id="detailTitle">—</h2>
|
||||
<p id="detailSubtitle"></p>
|
||||
</div>
|
||||
<div class="detail-card-actions">
|
||||
<button class="detail-navigate-btn" id="detailNavigateBtn" type="button" onclick="navigateToSelectedEntity()" aria-label="导航" title="导航" hidden>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20.7 3.3 14.4 20.1a1.2 1.2 0 0 1-2.2.1l-2.6-5.8-5.8-2.6a1.2 1.2 0 0 1 .1-2.2l16.8-6.3Z"></path><path d="m9.5 14.5 4.9-4.9"></path></svg>
|
||||
</button>
|
||||
<button class="detail-close-btn" type="button" onclick="closeEntityDetails()" aria-label="关闭详情">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="detail-grid" id="detailGrid"></dl>
|
||||
<button class="detail-expand-btn" id="detailExpandBtn" type="button" onclick="toggleEntityDetails()" hidden>展开详情</button>
|
||||
</article>
|
||||
|
||||
<!-- Bottom Right Floating Controls -->
|
||||
<div class="map-action-controls">
|
||||
<button class="glass-btn" onclick="resetMapView()" data-i18n="btnResetView">复位视角</button>
|
||||
<button class="glass-btn" onclick="togglePitchView()" data-i18n="btn3dView">3D/2D 视角</button>
|
||||
<button class="glass-btn" id="filterBtn" onclick="clearProvinceFilter()" data-i18n="btnNationalView">全国视图</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Footer Bar -->
|
||||
<div class="map-bottom-info">
|
||||
<span id="mapFooterGis" data-i18n="mapFooterGis">高质感地图引擎: 羚牛氢能GIS (AMap 3D Engine)</span>
|
||||
<span id="mapFooterStatus" data-i18n="mapFooterStatus">系统状态: 稳定连接 (AES-256)</span>
|
||||
<span id="selectedRegionHint" data-i18n="hintNational">视角: 全国运营态势</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RIGHT SIDEBAR (2 LIQUID GLASS CARDS) -->
|
||||
<aside class="sidebar-operations">
|
||||
|
||||
<!-- Card 1: vehicles show total / driving / idle; offline is supporting information -->
|
||||
<div class="glass-panel sidebar-card">
|
||||
<div class="panel-header">
|
||||
<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" 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" 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" 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>
|
||||
|
||||
<!-- Apple Style Segmented Progress Track -->
|
||||
<div class="liquid-progress-bar">
|
||||
<div class="seg seg-running" style="width: 52.9%;"></div>
|
||||
<div class="seg seg-stopped" style="width: 30.4%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card 2: 重点区域 TOP 排名 (Liquid Glass Ranking List) -->
|
||||
<div class="glass-panel sidebar-card flex-fill-auto">
|
||||
<div class="panel-header">
|
||||
<div class="panel-heading-stack">
|
||||
<span class="panel-title" id="panelRankTitle">车辆累计里程 TOP 排名</span>
|
||||
<span class="viewport-meta" id="rankViewportMeta" hidden></span>
|
||||
</div>
|
||||
<div class="glass-tab-control">
|
||||
<button class="gtab active" id="rankPrimaryTab" onclick="switchRankTab('primary')">按数量</button>
|
||||
<button class="gtab" id="rankSecondaryTab" onclick="switchRankTab('secondary')">按里程</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="liquid-ranking-list" id="rankingListBox">
|
||||
<!-- Dynamic Ranking Rows -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 生产发布包不携带 node_modules;固定版本的 CDN 让中文地点保持拼音/首字母搜索能力。 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/pinyin-pro@3.28.2/dist/index.js" crossorigin="anonymous"></script>
|
||||
<script src="app.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg width="130" height="36" viewBox="0 0 130 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Lingniu Hydrogen Mobility</title>
|
||||
<g fill="#282221" font-family="Arial, Helvetica, sans-serif" font-style="italic">
|
||||
<text x="0" y="19.5" font-size="18" font-weight="900" letter-spacing="1.1">LINGNIU</text>
|
||||
<text x="0" y="31.5" font-size="7.5" font-weight="800" letter-spacing=".28">HYDROGEN MOBILITY</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 438 B |
@@ -0,0 +1,43 @@
|
||||
<svg width="150" height="36" viewBox="0 0 150 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.0459 4.69253L24.4084 4.6875L17.3427 16.9334C16.0108 19.2369 14.67 21.5335 13.357 23.8492C12.8279 24.6836 12.7967 25.5367 14.0379 25.5663C17.6611 25.6526 21.3268 25.5356 24.9495 25.6042C24.4224 26.7246 23.2874 28.5534 22.6381 29.6706L21.6984 31.3061L13.8032 31.3074C11.2823 31.3084 7.42295 31.774 6.15339 28.9623C5.10923 26.6498 6.73472 24.2701 7.86608 22.3109L10.1604 18.3408L18.0459 4.69253Z" fill="currentColor"/>
|
||||
<path d="M30.7029 4.69293L38.1664 4.6931C40.8704 4.68656 45.5224 4.1044 46.4284 7.56236C47.0408 9.9001 45.3543 12.286 44.21 14.218C42.2951 14.125 39.809 14.1965 37.8457 14.1908C38.3321 13.3838 40.2747 10.8182 38.8199 10.5324C37.4531 10.2639 35.1947 10.4548 33.7653 10.4209C32.0072 13.3847 30.3349 16.4036 28.5844 19.3723C28.4964 19.5215 28.3602 19.7716 28.257 19.9038L21.9204 19.9023L30.7029 4.69293Z" fill="currentColor"/>
|
||||
<path d="M89.1232 4.67032C89.7835 4.62775 90.9099 4.66078 91.6053 4.65867L90.978 7.75816L98.7808 7.75999C98.6607 8.46499 98.5297 9.16816 98.3877 9.86921L90.5769 9.86774C90.1934 11.0614 89.853 13.7315 89.4731 15.1689L97.9522 15.1851C97.8321 15.8946 97.6872 16.5697 97.5364 17.2721C94.9175 17.1811 91.7297 17.2538 89.0954 17.2677C88.594 19.3603 88.1541 21.9308 87.7303 24.0635C86.9061 24.0335 86.0812 24.0264 85.2563 24.0421C85.7525 22.0904 86.18 19.3243 86.6177 17.2638L85.2372 17.257L77.0092 17.2648C77.1446 16.5758 77.2727 15.8857 77.3957 15.1944C79.8624 15.1112 82.3687 15.2077 84.839 15.1714C85.5417 15.161 86.2942 15.1588 86.9939 15.2057C87.447 13.5537 87.6608 11.5742 88.1087 9.87872C86.2986 9.82772 84.3142 9.86591 82.4909 9.86569C81.7604 11.226 81.0028 12.4816 80.2657 13.8163L77.5362 13.8114C79.6816 10.4975 80.6808 8.44223 82.2311 4.86287C83.0545 4.83579 84.0075 4.85801 84.8405 4.85771C84.4152 5.82928 83.998 6.81499 83.512 7.75669C85.1516 7.77967 86.8351 7.76247 88.4769 7.76452C88.7163 6.8407 88.9417 5.61995 89.1232 4.67032Z" fill="currentColor"/>
|
||||
<path d="M70.2503 4.65824L73.7308 4.65625C74.7336 7.08713 76.1309 9.57515 77.5099 11.8054C76.5818 11.7752 75.527 11.798 74.5887 11.7967C73.5397 9.99962 72.6069 8.13727 71.7957 6.22117C71.2298 6.89947 70.7149 7.63591 70.1079 8.35496L72.1324 8.35415C72.359 9.56198 72.4332 11.2339 72.4619 12.4558L70.2398 12.4478C70.2372 11.0171 70.1331 9.90113 70.0025 8.48344C69.1539 9.57259 67.962 10.814 66.9868 11.7877L65.7182 11.7932L64.2958 11.7963C64.2202 12.4282 64.0755 13.0643 63.9444 13.6881L60.6789 13.6869C60.5363 14.5168 60.3766 15.3436 60.1998 16.1668L63.9171 16.1664L63.5281 18.0826C62.277 18.0555 60.9445 18.0784 59.687 18.0782C58.9474 20.2403 57.828 22.2532 56.3813 24.0224C55.6509 24.0768 54.3913 24.03 53.5917 24.0442C55.3706 21.7397 56.0946 20.7987 57.3125 18.0802L53.6008 18.0731C53.7608 17.396 53.882 16.8655 53.9824 16.1718C55.2848 16.1587 56.5873 16.1591 57.8898 16.1732L58.3652 13.6896L54.9231 13.6853C55.1139 13.0172 55.1958 12.5027 55.3015 11.8164C56.2519 11.7504 57.7795 11.7881 58.7587 11.8109C58.8945 11.0981 59.037 10.3867 59.1861 9.67657C57.9106 9.6642 56.6351 9.66427 55.3597 9.67657C55.4544 9.05996 55.5544 8.44415 55.66 7.8293C57.5354 7.80764 59.4484 7.82827 61.3266 7.83003C61.9933 6.86852 62.6551 5.74668 63.0825 4.66341L65.5379 4.65888C64.9966 5.88817 64.5907 6.67983 63.9019 7.83288L65.5427 7.82645C65.4452 8.4301 65.2987 9.07927 65.1744 9.68205C63.9717 9.64854 62.7013 9.67152 61.4934 9.67532C61.3775 10.3714 61.2146 11.1007 61.0712 11.7947L63.9987 11.7913C66.5788 9.20798 68.0943 7.61586 70.2503 4.65824Z" fill="currentColor"/>
|
||||
<path d="M122.532 11.8166C125.798 11.7231 129.441 11.7977 132.734 11.7984C132.466 13.5695 131.978 15.624 131.64 17.4038C131.42 18.5691 130.997 20.9151 130.604 21.9327C130.313 22.7085 129.869 23.4177 129.299 24.0182C128.475 24.0895 127.218 24.0195 126.289 24.0612C126.736 23.701 127.014 23.4316 127.42 23.0228C128.176 22.1519 128.359 21.6669 128.744 20.5722C126.97 20.5048 124.822 20.5601 123.018 20.5584C122.945 21.3158 122.507 23.1882 122.339 24.0641C121.646 24.031 120.793 24.0186 120.111 24.0547C120.247 23.0506 120.522 21.8389 120.724 20.8256L121.799 15.4428C121.945 14.7076 122.299 12.3722 122.532 11.8166ZM129.104 18.8694C129.21 18.2708 129.304 17.7024 129.465 17.1139C127.646 17.0946 125.48 17.1746 123.726 17.1053C123.609 17.6915 123.487 18.2767 123.359 18.8607C125.251 18.8633 127.22 18.8364 129.104 18.8694ZM124.059 15.3809C124.989 15.3848 129.088 15.5282 129.805 15.3507C129.899 14.7602 130.003 14.261 130.135 13.6774C128.223 13.6522 126.311 13.6515 124.399 13.6752C124.302 14.1834 124.187 14.895 124.059 15.3809Z" fill="currentColor"/>
|
||||
<path d="M28.257 19.9042C30.3408 19.8373 32.5067 19.9107 34.6003 19.8799C33.5304 21.7989 32.4375 23.705 31.3217 25.5977L37.6027 25.587C37.1076 26.6547 36.2454 28.0061 35.655 29.0623C35.3804 29.5534 34.6405 30.8847 34.3403 31.3077L28.0049 31.3022C28.8125 29.6956 29.8532 28.1298 30.7172 26.5471C30.8889 26.2323 31.0858 25.9059 31.2849 25.6084C29.1922 25.6072 27.049 25.5816 24.9604 25.61C25.6026 24.4466 26.26 23.2918 26.9324 22.1456C27.3144 21.4843 27.8407 20.5151 28.257 19.9042Z" fill="#007143"/>
|
||||
<path d="M99.7206 10.1076C106.203 9.98573 113.088 10.0971 119.599 10.1018C118.777 13.8389 118.005 17.7955 117.477 21.5875C117.37 22.3581 118.045 23.5899 118.033 24.042C117.125 24.0338 116.218 24.0361 115.311 24.0488C114.92 21.1109 115.519 19.0866 116.068 16.2425C116.357 14.7439 116.652 13.209 116.981 11.7202L99.4205 11.7137C99.5633 11.1642 99.6343 10.6691 99.7206 10.1076Z" fill="currentColor"/>
|
||||
<path d="M64.5255 12.945C68.4291 12.8445 72.7488 12.9376 76.6835 12.9394C76.5327 13.651 76.4317 14.5237 75.9691 15.0919C75.4018 15.7882 74.7928 16.4582 74.197 17.1312L70.7419 21.0305L69.7438 21.059C70.2176 22.0474 70.6693 23.0462 71.0985 24.0548C70.1752 24.0352 69.2515 24.0344 68.3282 24.0525C67.9964 23.2947 67.6583 22.5397 67.3138 21.7875C66.4548 19.874 65.5159 17.9972 64.5 16.1621C65.3591 16.1484 66.2391 16.156 67.1001 16.1542C67.8294 17.3343 68.5019 18.5485 69.1155 19.7926C70.6881 18.1842 72.2203 16.5366 73.7103 14.8514L64.1427 14.8496C64.2745 14.2155 64.402 13.5807 64.5255 12.945Z" fill="currentColor"/>
|
||||
<path d="M100.218 12.4635C104.946 12.4535 109.674 12.4727 114.401 12.5212C114.273 12.9715 114.112 13.3776 113.946 13.814C112.657 14.4639 111.179 15.1061 109.857 15.7056C111.599 15.949 113.275 15.9778 115.027 16.028C114.844 16.6716 114.645 17.3102 114.427 17.943C112.64 17.957 110.455 17.6924 108.705 17.3225C108.285 17.2407 107.508 16.991 107.072 16.8654C104.084 18.0184 101.851 17.949 98.7192 17.9316C98.791 17.2822 98.9052 16.6738 99.0208 16.0327C102.633 15.9161 105.599 15.8462 108.933 14.123C106.178 14.2266 102.668 14.1367 99.8604 14.135C99.9731 13.5764 100.092 13.0192 100.218 12.4635Z" fill="currentColor"/>
|
||||
<path d="M98.7625 18.3504C100.425 18.3036 102.282 18.3451 103.963 18.3452L114.077 18.3459C113.979 18.982 113.891 19.4244 113.733 20.0481C111.681 19.9793 109.274 20.0318 107.196 20.0488C107.14 20.5854 106.873 21.7822 106.759 22.3601C108.872 22.2731 111.751 22.3361 113.883 22.3568C113.718 22.9049 113.8 23.5534 113.504 23.9553C113.406 24.0871 113.154 24.042 112.96 24.0414C107.731 23.9823 102.283 24.0035 97.0497 24.0421C97.15 23.4629 97.2678 22.9333 97.3996 22.3599C99.6372 22.3158 102.265 22.3081 104.496 22.3613C104.62 21.6 104.787 20.8055 104.936 20.0457C102.829 19.9936 100.54 20.0389 98.4199 20.0394L98.7625 18.3504Z" fill="currentColor"/>
|
||||
<path d="M133.256 13.9166C133.997 13.9023 134.788 13.9161 135.533 13.9168C135.386 14.8072 135.174 15.7896 135.001 16.6831C137.384 16.4394 139.769 16.2153 142.155 16.0109C141.98 16.6792 141.965 17.2281 141.716 17.9362C141.421 17.9321 141.131 17.9513 140.839 17.9823C138.756 18.2028 136.663 18.3336 134.585 18.5982C134.457 19.6086 134.11 21.1394 133.89 22.1662L135.596 22.1591H140.893C140.792 22.7924 140.684 23.4247 140.569 24.0557C137.586 23.9698 134.244 24.0318 131.246 24.0396C131.9 20.9278 132.738 17.0234 133.256 13.9166Z" fill="currentColor"/>
|
||||
<path d="M135.098 4.62931C135.853 4.62166 136.607 4.62419 137.363 4.6369C137.162 5.41299 137.017 6.28154 136.868 7.07464C139.102 6.84738 141.908 6.69114 144.053 6.37755C143.943 7.01342 143.835 7.73358 143.657 8.34683L136.465 8.98722C136.314 9.81163 136.157 10.6349 135.994 11.4569C136.445 11.4309 137.106 11.4499 137.573 11.4482C139.397 11.4591 141.222 11.4559 143.046 11.4386C142.935 12.1076 142.839 12.6758 142.674 13.3374C139.624 13.2772 136.387 13.3227 133.323 13.3214C133.552 12.5671 133.793 11.1594 133.955 10.3413C134.324 8.43507 134.706 6.53103 135.098 4.62931Z" fill="currentColor"/>
|
||||
<path d="M102.916 4.63357C103.716 4.61473 104.573 4.6323 105.378 4.63596L104.934 5.48128L121.832 5.48318C121.739 6.10161 121.672 6.52446 121.507 7.12456C119.818 7.05448 117.769 7.10604 116.053 7.10625L103.984 7.11772C103.38 7.9858 102.762 8.89904 102.054 9.68109C101.302 9.67604 100.124 9.63799 99.419 9.71446C99.8816 9.06719 100.543 8.33907 101.04 7.66743C101.837 6.59222 102.296 5.78928 102.916 4.63357Z" fill="currentColor"/>
|
||||
<path d="M126 4.67433C126.546 4.62029 128.007 4.65657 128.619 4.65502C127.72 6.37437 126.984 7.53498 125.875 9.09798C127.492 9.05503 129.258 9.08605 130.886 9.08474C130.822 8.136 130.773 7.13368 130.53 6.2156C131.306 6.15771 132.166 6.17288 132.949 6.17043C133.021 7.06163 133.221 8.13834 133.316 9.09988C133.309 9.63037 133.157 10.3841 133.057 10.9028C129.465 10.8026 125.423 10.8945 121.795 10.8945C123.602 8.78078 124.778 7.14823 126 4.67433Z" fill="currentColor"/>
|
||||
<path d="M37.8456 14.1928C39.809 14.1984 42.2951 14.127 44.2099 14.22L41.9937 18.0919C41.7788 18.4647 41.1512 19.6063 40.9155 19.9025L34.5791 19.8957C35.6336 17.9833 36.7911 16.1017 37.8456 14.1928Z" fill="#007143"/>
|
||||
<path d="M40.9155 19.902C43.0195 19.8499 45.1502 19.9224 47.2506 19.8789C46.4721 21.4168 44.8928 24.105 43.9715 25.602L37.6138 25.6077C38.4831 23.9831 39.9311 21.447 40.9155 19.902Z" fill="#007143"/>
|
||||
<path d="M104.415 7.76391L120.496 7.76172C120.35 8.3135 120.238 8.87355 120.16 9.43887C118.084 9.37345 115.82 9.41552 113.733 9.4153L104.085 9.42408C104.155 8.93055 104.312 8.26469 104.415 7.76391Z" fill="currentColor"/>
|
||||
<path d="M96.4304 26.879C99.2507 26.6667 98.9579 30.7714 95.9312 31.3474C92.5488 31.3098 93.6973 27.1881 96.4304 26.879ZM95.9883 30.5709C96.9304 30.2786 97.7955 29.3384 97.4662 28.3229C97.3944 28.096 97.2327 27.9087 97.0175 27.8056C96.7964 27.6975 96.4978 27.6448 96.2518 27.6614C95.3083 27.9393 94.49 28.8604 94.7842 29.8668C94.8501 30.099 95.0075 30.2944 95.2205 30.4079C95.4752 30.5427 95.7065 30.5599 95.9883 30.5709Z" fill="currentColor"/>
|
||||
<path d="M120.579 26.8756C123.424 26.7179 123.034 30.7691 120.086 31.3456C116.806 31.3978 117.733 27.1994 120.579 26.8756ZM120.22 30.5368C122.019 29.9774 122.223 27.4751 120.314 27.6674C119.642 27.9156 119.439 28.0285 119.093 28.7081C118.616 29.6484 119.009 30.7721 120.22 30.5368Z" fill="currentColor"/>
|
||||
<path d="M116.66 26.9238L117.992 26.9219C117.747 28.2834 117.347 29.9417 117.049 31.3153L116.157 31.3083C116.347 30.6023 116.495 29.7902 116.644 29.0672C116.688 28.9313 116.767 28.502 116.801 28.3408C116.395 29.0335 115.459 30.9511 114.675 31.3444C114.566 31.399 114.465 31.3038 114.371 31.2359C114.165 30.71 113.925 28.8772 113.836 28.242L113.198 31.3117L112.31 31.3165L113.23 26.9279L114.491 26.9225C114.605 27.7811 114.833 28.7942 114.973 29.6896C115.472 28.9993 116.215 27.6823 116.66 26.9238Z" fill="currentColor"/>
|
||||
<path d="M86.5921 26.9211C86.9917 26.9147 87.4287 26.9264 87.8225 26.9095C90.4759 26.7956 90.1867 29.6754 88.5223 30.9111C87.5305 31.4374 86.7699 31.3173 85.6661 31.3024C85.7957 30.2878 86.3578 28.007 86.5921 26.9211ZM86.7231 30.4522C87.1359 30.4503 87.7347 30.4871 88.1007 30.316C88.6401 29.8127 89.3033 28.7778 88.6848 28.0956C88.3158 27.6887 87.7976 27.7551 87.2911 27.7533C87.1257 28.5612 86.9295 29.669 86.7231 30.4522Z" fill="currentColor"/>
|
||||
<path d="M123.457 26.9226C124.213 26.9143 125.634 26.8007 126.186 27.3128C126.71 27.7978 125.896 28.5423 125.68 28.9376C125.595 29.0947 126.087 29.5814 126.078 29.8632C126.065 30.2604 125.746 30.6635 125.492 30.9391C124.737 31.4476 123.501 31.3147 122.564 31.3117C122.81 29.8751 123.187 28.3774 123.457 26.9226ZM123.593 30.5161C124.141 30.5116 124.358 30.5255 124.891 30.3884C125.067 30.151 125.129 30.0825 125.184 29.7933C125.024 29.2759 124.325 29.3801 123.862 29.3789L123.593 30.5161ZM123.999 28.6156C124.282 28.6098 124.866 28.6145 125.123 28.5796C125.245 28.3715 125.336 28.252 125.328 28.0082C125.082 27.6121 124.658 27.6901 124.214 27.6711C124.144 27.9762 124.058 28.3111 123.999 28.6156Z" fill="currentColor"/>
|
||||
<path d="M90.735 26.9215C92.024 26.9243 94.8238 26.5442 93.2493 29.0441C93.0876 29.3009 92.7831 29.446 92.5137 29.583C92.7384 30.1413 92.9478 30.7419 93.1556 31.3095C92.8314 31.3198 92.3036 31.3998 92.0979 31.1551C91.9179 30.6395 91.7971 30.0956 91.3931 29.741C90.8792 29.6812 90.7592 30.8999 90.6765 31.3136L89.8201 31.3149C90.1114 29.8477 90.4166 28.3831 90.735 26.9215ZM91.2167 28.8258C91.4736 28.8227 92.2707 28.8268 92.4815 28.7911C92.6447 28.5511 92.7304 28.4484 92.7926 28.1652C92.6755 27.6152 91.9171 27.7496 91.4458 27.7535C91.366 28.1101 91.2891 28.4675 91.2167 28.8258Z" fill="currentColor"/>
|
||||
<path d="M69.3081 26.922L70.1771 26.9141C69.9698 28.186 69.5337 30.0383 69.2388 31.3177L68.1065 31.3098C67.8398 30.3494 67.4692 29.2815 67.1696 28.3128C67.0189 29.2546 66.7757 30.3753 66.5616 31.3076L65.6787 31.3079L66.6075 26.9221L67.7409 26.9223C68.0639 27.8733 68.37 28.898 68.6717 29.8604C68.873 28.8787 69.0851 27.8992 69.3081 26.9222Z" fill="currentColor"/>
|
||||
<path d="M108.881 29.8868C109.027 29.2478 109.289 27.5001 109.54 27.0002C109.723 26.889 110.12 26.9136 110.352 26.9105C110.263 27.8288 109.679 30.3103 109.457 31.3037L108.341 31.3085C108.011 30.3531 107.719 29.367 107.384 28.4004L106.772 31.3134L105.9 31.3077C106.155 29.911 106.523 28.3126 106.832 26.9175L107.966 26.9206C108.2 27.8185 108.6 28.9795 108.881 29.8868Z" fill="currentColor"/>
|
||||
<path d="M61.0197 26.9225L61.8629 26.9102C61.7079 28.0991 61.2332 30.0627 60.9722 31.3099L59.8503 31.3086C59.519 30.3592 59.2069 29.3429 58.8931 28.3815L58.2722 31.3125L57.3802 31.3049C57.7123 30.0129 58.0673 28.24 58.3208 26.9184L59.4601 26.922C59.6904 27.8033 60.1149 29.0009 60.4008 29.8927C60.5467 28.9635 60.7999 27.8383 61.0197 26.9225Z" fill="currentColor"/>
|
||||
<path d="M78.6518 26.9215L79.5294 26.9146C79.4255 27.4595 79.2959 28.0172 79.1766 28.5603C79.689 28.553 80.216 28.5592 80.7298 28.5592L81.0951 26.923L81.9493 26.9141C81.8519 27.9269 81.2576 30.0868 81.0526 31.1643C81.0343 31.2615 80.9867 31.26 80.8865 31.3094C80.6281 31.3129 80.44 31.3262 80.1889 31.262C80.2658 30.6683 80.4253 30.0042 80.5534 29.4127L78.998 29.4118C78.867 30.0925 78.7586 30.6456 78.5669 31.3166L77.7339 31.3159C77.9893 29.9039 78.3363 28.3198 78.6518 26.9215Z" fill="currentColor"/>
|
||||
<path d="M103.391 26.918L106.201 26.9222L106.032 27.7871C105.48 27.7389 104.623 27.7531 104.059 27.7493C104.008 28.0771 103.939 28.4261 103.88 28.7546L105.576 28.7521L105.445 29.4786C105.093 29.4693 104.014 29.408 103.727 29.5427L103.677 29.7236L103.488 30.4471L105.46 30.4415C105.391 30.7111 105.332 31.0336 105.273 31.3098C104.347 31.2889 103.359 31.3061 102.428 31.3067C102.714 29.9523 103.028 28.2233 103.391 26.918Z" fill="currentColor"/>
|
||||
<path d="M101.23 26.8806C101.741 26.8186 102.269 26.978 102.715 27.2175C102.67 27.4649 102.637 27.8569 102.466 28.0164C102.325 28.0441 101.618 27.7141 101.272 27.6162C100.683 27.7595 100.181 27.9029 99.8684 28.4645C99.2265 29.6163 99.5405 30.8072 101.032 30.4857C101.475 30.0635 101.026 29.4324 101.687 29.1473C101.888 29.1663 102.02 29.1878 102.216 29.2257C102.312 29.5647 101.987 30.9407 101.742 31.0531C97.9931 32.7738 97.5708 27.2933 101.23 26.8806Z" fill="currentColor"/>
|
||||
<path d="M74.9385 26.9214C75.1712 26.9118 75.4808 26.8744 75.6821 26.9707C75.7905 27.2226 75.7246 27.2838 75.6726 27.6035C75.3345 28.829 75.382 30.2578 74.2211 31.0737C73.6941 31.4969 72.454 31.4555 72.0957 30.8256C71.57 29.9017 72.2194 27.9607 72.4365 26.9236L73.3245 26.923C73.2315 27.4568 72.5294 29.9806 72.8643 30.2786C74.3522 31.6025 74.7181 27.8186 74.9385 26.9214Z" fill="currentColor"/>
|
||||
<path d="M64.4934 26.879C65.0658 26.8499 65.4038 26.9622 65.9183 27.1614C65.9004 27.4821 65.8231 27.8782 65.7694 28.201C65.3237 27.8267 65.0369 27.7669 64.4898 27.6111C64.0346 27.7299 63.4555 27.898 63.1815 28.3015C62.8659 28.7125 62.5666 30.01 63.1207 30.2988C64.5127 31.0242 64.4083 30.3038 64.6865 29.2341C64.6957 29.1988 65.0789 29.1547 65.1737 29.1403L65.443 29.2169C65.5798 29.5824 65.2474 30.6016 65.1325 31.0264C64.7903 31.1835 64.4256 31.2859 64.0516 31.3298C60.9761 31.6856 61.3322 27.1955 64.4934 26.8798Z" fill="currentColor"/>
|
||||
<path d="M85.1552 26.9215L86.2297 26.918C85.7693 27.4771 85.3097 28.0605 84.8551 28.6258C84.028 29.5226 84.0163 30.1406 83.7762 31.3103L82.8773 31.3089C83.4446 29.0983 83.1291 29.0171 82.3298 26.9199L83.3107 26.9197C83.512 27.4507 83.7059 27.9844 83.8926 28.5204L85.1552 26.9215Z" fill="currentColor"/>
|
||||
<path d="M139.373 26.9247L140.422 26.918C140.11 27.4009 139.441 28.178 139.059 28.6479C138.207 29.6578 138.305 30.0537 137.985 31.3112L137.113 31.3094C137.262 30.7606 137.396 30.0375 137.516 29.4702C137.197 28.6188 136.873 27.7696 136.543 26.9227L137.516 26.921C137.728 27.4476 137.932 27.9767 138.13 28.5083C138.556 27.9893 138.97 27.4613 139.373 26.9247Z" fill="currentColor"/>
|
||||
<path d="M57.3993 4.67527C58.1061 4.65383 58.8637 4.66377 59.5748 4.66016C59.8347 5.63537 59.8946 6.41442 59.9652 7.40979C59.3798 7.36896 58.4268 7.39837 57.8139 7.39757C57.7331 6.3945 57.6463 5.64817 57.3993 4.67527Z" fill="currentColor"/>
|
||||
<path d="M133.406 26.9219L136.447 26.9253L136.333 27.7751C135.986 27.7619 135.585 27.7671 135.234 27.7638C135.054 28.5778 134.891 29.4212 134.722 30.2397L134.468 31.3148L133.589 31.3145C133.858 30.1379 134.11 28.9573 134.343 27.7731C134.007 27.7561 133.613 27.7725 133.273 27.7783L133.406 26.9219Z" fill="currentColor"/>
|
||||
<path d="M53.4287 26.918L54.3232 26.9254L53.5875 30.453L55.5179 30.4498L55.3763 31.3123C54.4204 31.2926 53.4227 31.3031 52.4631 31.2988C52.7142 30.6151 53.2746 27.7241 53.4287 26.918Z" fill="currentColor"/>
|
||||
<path d="M128.859 26.924L129.753 26.9219C129.475 28.0093 129.226 29.3489 128.993 30.4563L130.915 30.4496L130.763 31.3133C129.848 31.2883 128.851 31.3078 127.93 31.3087L128.859 26.924Z" fill="currentColor"/>
|
||||
<path d="M127.308 26.9204L128.164 26.918L127.239 31.3152L126.351 31.3151C126.65 29.8952 126.949 28.3149 127.308 26.9204Z" fill="currentColor"/>
|
||||
<path d="M56.7724 26.918L57.6656 26.9141C57.3133 28.3287 57.0132 29.8795 56.7123 31.3129L55.8318 31.3086C56.1293 29.8417 56.4429 28.3781 56.7724 26.918Z" fill="currentColor"/>
|
||||
<path d="M70.8595 26.9214L71.7381 26.918C71.567 28.0722 71.0941 30.1443 70.8119 31.3022L69.9707 31.3105C70.1852 29.9437 70.5812 28.3064 70.8595 26.9214Z" fill="currentColor"/>
|
||||
<path d="M132.146 26.9187L133.056 26.918C132.809 28.082 132.418 30.2402 132.087 31.3152L131.25 31.3052C131.533 29.8444 131.902 28.3846 132.146 26.9187Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 18 KiB |
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "lingniu-truck-map",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lingniu-truck-map",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"pinyin-pro": "3.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/pinyin-pro": {
|
||||
"version": "3.28.2",
|
||||
"resolved": "https://registry.npmjs.org/pinyin-pro/-/pinyin-pro-3.28.2.tgz",
|
||||
"integrity": "sha512-jV38yxXHLfidirMC4hrXasLDozLCSq/4DfX88GnHcSEJ2+GpSedG6I9VOiEXJu6iQ5dbJC/RjmzyMuS5h/wH5A==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "lingniu-truck-map",
|
||||
"version": "1.0.0",
|
||||
"description": "羚牛氢能 - 全国氢能物流运营驾驶舱 (Apple Design Spec)",
|
||||
"main": "index.html",
|
||||
"scripts": {
|
||||
"start": "python3 server.py",
|
||||
"dev": "VEHICLE_MAP_PORT=20800 python3 server.py",
|
||||
"test": "python3 -m unittest discover -s tests -v && node tests/test_app.mjs"
|
||||
},
|
||||
"keywords": [
|
||||
"lingniu",
|
||||
"hydrogen",
|
||||
"map",
|
||||
"cockpit"
|
||||
],
|
||||
"author": "Antigravity",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"pinyin-pro": "3.28.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
"""Vehicle Map static server and server-side proxy for the vehicle open platform."""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import hashlib
|
||||
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.parse import urlencode, urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def _static_asset_version():
|
||||
"""Content fingerprint used to bypass upstream static-asset caches on release."""
|
||||
digest = hashlib.sha256()
|
||||
for filename in ("app.js", "styles.css"):
|
||||
digest.update((ROOT / filename).read_bytes())
|
||||
return digest.hexdigest()[:16]
|
||||
|
||||
|
||||
STATIC_ASSET_VERSION = _static_asset_version()
|
||||
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", "120"))
|
||||
STATION_CACHE_SECONDS = int(os.getenv("STATION_CACHE_SECONDS", "120"))
|
||||
AMAP_REGEOCODE_KEY = os.getenv("AMAP_REGEOCODE_KEY", "").strip()
|
||||
STATION_GEOCODE_CACHE_SECONDS = int(os.getenv("STATION_GEOCODE_CACHE_SECONDS", "604800"))
|
||||
STATION_GEOCODE_CACHE_PATH = Path(os.getenv(
|
||||
"STATION_GEOCODE_CACHE_PATH", str(ROOT / ".station-geocode-cache.json")
|
||||
))
|
||||
AMAP_REGEOCODE_MIN_INTERVAL_SECONDS = float(os.getenv("AMAP_REGEOCODE_MIN_INTERVAL_SECONDS", "0.25"))
|
||||
_cache_lock = threading.Lock()
|
||||
_cache = {}
|
||||
_cache_load_locks = {}
|
||||
_station_geocode_cache_lock = threading.Lock()
|
||||
_station_geocode_cache = None
|
||||
_station_geocode_cache_dirty = False
|
||||
_station_geocode_rate_lock = threading.Lock()
|
||||
_station_geocode_next_request_at = 0.0
|
||||
|
||||
|
||||
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
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]
|
||||
load_lock = _cache_load_locks.setdefault(key, threading.Lock())
|
||||
# Startup prewarming and the first browser request can arrive together.
|
||||
# Coalesce them so a cold geocode cache is filled only once.
|
||||
with load_lock:
|
||||
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 _station_coordinate_key(station):
|
||||
try:
|
||||
return "{:.6f},{:.6f}".format(float(station.get("longitude")), float(station.get("latitude")))
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
def _component_value(value):
|
||||
if isinstance(value, list):
|
||||
return str(value[0]).strip() if value else ""
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _load_station_geocode_cache():
|
||||
global _station_geocode_cache
|
||||
with _station_geocode_cache_lock:
|
||||
if _station_geocode_cache is not None:
|
||||
return _station_geocode_cache
|
||||
try:
|
||||
with STATION_GEOCODE_CACHE_PATH.open("r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
_station_geocode_cache = payload if isinstance(payload, dict) else {}
|
||||
except (OSError, ValueError, TypeError):
|
||||
_station_geocode_cache = {}
|
||||
return _station_geocode_cache
|
||||
|
||||
|
||||
def _persist_station_geocode_cache():
|
||||
try:
|
||||
STATION_GEOCODE_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = STATION_GEOCODE_CACHE_PATH.with_suffix(STATION_GEOCODE_CACHE_PATH.suffix + ".tmp")
|
||||
with temporary.open("w", encoding="utf-8") as handle:
|
||||
json.dump(_station_geocode_cache, handle, ensure_ascii=False, separators=(",", ":"))
|
||||
os.replace(str(temporary), str(STATION_GEOCODE_CACHE_PATH))
|
||||
except OSError as exc:
|
||||
print("station geocode cache write deferred: {}".format(exc))
|
||||
|
||||
|
||||
def _reverse_geocode_station(longitude, latitude):
|
||||
global _station_geocode_next_request_at
|
||||
if not AMAP_REGEOCODE_KEY:
|
||||
raise RuntimeError("AMAP_REGEOCODE_KEY is not configured")
|
||||
with _station_geocode_rate_lock:
|
||||
now = time.monotonic()
|
||||
wait_seconds = max(0.0, _station_geocode_next_request_at - now)
|
||||
_station_geocode_next_request_at = max(now, _station_geocode_next_request_at) + AMAP_REGEOCODE_MIN_INTERVAL_SECONDS
|
||||
if wait_seconds:
|
||||
time.sleep(wait_seconds)
|
||||
query = urlencode({
|
||||
"key": AMAP_REGEOCODE_KEY,
|
||||
"location": "{:.6f},{:.6f}".format(longitude, latitude),
|
||||
"extensions": "base",
|
||||
"radius": "1000",
|
||||
"batch": "false",
|
||||
})
|
||||
request = Request(
|
||||
"https://restapi.amap.com/v3/geocode/regeo?" + query,
|
||||
headers={"Accept": "application/json", "User-Agent": "lingniu-station-geocoder/1.0"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=UPSTREAM_TIMEOUT_SECONDS) as response:
|
||||
payload = json.load(response)
|
||||
except (HTTPError, URLError, ValueError) as exc:
|
||||
raise RuntimeError("AMap reverse geocode unavailable: {}".format(exc)) from exc
|
||||
if payload.get("status") != "1":
|
||||
raise RuntimeError("AMap reverse geocode rejected request: {} {}".format(payload.get("infocode"), payload.get("info")))
|
||||
component = (payload.get("regeocode") or {}).get("addressComponent") or {}
|
||||
province = _component_value(component.get("province"))
|
||||
city = _component_value(component.get("city")) or province
|
||||
district = _component_value(component.get("district"))
|
||||
if not province:
|
||||
raise RuntimeError("AMap reverse geocode returned no province")
|
||||
return {"province": province, "city": city, "district": district, "adcode": _component_value(component.get("adcode"))}
|
||||
|
||||
|
||||
def _station_region_from_gps(station, now=None):
|
||||
global _station_geocode_cache_dirty
|
||||
coordinate_key = _station_coordinate_key(station)
|
||||
if not coordinate_key:
|
||||
return {"province": "", "city": "", "district": ""}
|
||||
now = time.time() if now is None else now
|
||||
cache = _load_station_geocode_cache()
|
||||
cached = cache.get(coordinate_key) or {}
|
||||
if now - float(cached.get("updatedAt") or 0) < STATION_GEOCODE_CACHE_SECONDS:
|
||||
return cached.get("region") or {"province": "", "city": "", "district": ""}
|
||||
try:
|
||||
longitude, latitude = (float(value) for value in coordinate_key.split(","))
|
||||
region = _reverse_geocode_station(longitude, latitude)
|
||||
except Exception as exc:
|
||||
print("station reverse geocode deferred for {}: {}".format(coordinate_key, exc))
|
||||
return cached.get("region") or {"province": "", "city": "", "district": ""}
|
||||
with _station_geocode_cache_lock:
|
||||
cache[coordinate_key] = {"updatedAt": now, "region": region}
|
||||
_station_geocode_cache_dirty = True
|
||||
return region
|
||||
|
||||
|
||||
def _stations_with_gps_regions(stations):
|
||||
global _station_geocode_cache_dirty
|
||||
def enrich(station):
|
||||
item = dict(station)
|
||||
region = _station_region_from_gps(item)
|
||||
# Never fall back to data-table administrative fields. The raw address
|
||||
# remains untouched and is the only directly displayed location text.
|
||||
item.update({key: region.get(key, "") for key in ("province", "city", "district")})
|
||||
return item
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
enriched = list(executor.map(enrich, stations))
|
||||
with _station_geocode_cache_lock:
|
||||
if _station_geocode_cache_dirty:
|
||||
_persist_station_geocode_cache()
|
||||
_station_geocode_cache_dirty = False
|
||||
return enriched
|
||||
|
||||
|
||||
def _load_stations():
|
||||
return _cached(
|
||||
"stations",
|
||||
STATION_CACHE_SECONDS,
|
||||
lambda: _stations_with_gps_regions(_post_open_platform("/api/v1/hydrogen-stations/query", {})),
|
||||
)
|
||||
|
||||
|
||||
def _public_station_directory():
|
||||
stations = _load_stations()
|
||||
fields = ("id", "name", "shortName", "province", "city", "district", "address", "longitude", "latitude", "cooperative")
|
||||
directory = [{field: station.get(field) for field in fields if station.get(field) is not None} for station in stations]
|
||||
return {
|
||||
"status": "ok",
|
||||
"asOf": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||
"summary": {
|
||||
"totalStations": len(directory),
|
||||
"cooperativeStations": sum(1 for station in directory if station.get("cooperative")),
|
||||
},
|
||||
"stations": directory,
|
||||
}
|
||||
|
||||
|
||||
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(_load_stations)
|
||||
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"
|
||||
}
|
||||
# The daily activity view places every vehicle that reported
|
||||
# today is placed in exactly one bucket using its latest selected speed.
|
||||
active_vehicles = [item for item in vehicles if item.get("activeToday")]
|
||||
driving = sum(1 for item in active_vehicles if float(item.get("speedKmh") or 0) > 3)
|
||||
idle = len(active_vehicles) - driving
|
||||
offline = sum(1 for item in vehicles if item.get("motionStatus") == "offline")
|
||||
active_today = len(active_vehicles)
|
||||
daily_mileage = round(
|
||||
sum(float(row.get("dailyMileageKm") or 0) for row in mileage_rows), 3
|
||||
)
|
||||
monthly_hydrogen = round(
|
||||
sum(float(station.get("monthlyHydrogenKg") or 0) for station in stations), 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": active_today,
|
||||
"drivingVehicles": driving,
|
||||
"idleVehicles": idle,
|
||||
"offlineVehicles": offline,
|
||||
"todayMileageKm": daily_mileage,
|
||||
"monthlyHydrogenKg": monthly_hydrogen,
|
||||
"totalStations": len(stations),
|
||||
"cooperativeStations": sum(1 for item in stations if item.get("cooperative")),
|
||||
},
|
||||
"vehicles": vehicles,
|
||||
"stations": stations,
|
||||
}
|
||||
|
||||
|
||||
def _prewarm_dashboard_cache():
|
||||
"""Warm the first dashboard snapshot after a process restart.
|
||||
|
||||
A warm snapshot lets the first visitor receive the cached response instead
|
||||
of waiting for the three upstream requests to complete.
|
||||
"""
|
||||
try:
|
||||
_cached("dashboard", DASHBOARD_CACHE_SECONDS, _load_dashboard)
|
||||
except Exception as exc: # A later request can retry; startup must stay available.
|
||||
print(f"vehicle map cache warmup deferred: {exc}")
|
||||
|
||||
|
||||
class VehicleMapHandler(SimpleHTTPRequestHandler):
|
||||
server_version = "LingniuVehicleMap/1.0"
|
||||
|
||||
def do_GET(self):
|
||||
path = urlparse(self.path).path
|
||||
if path in {"/", "/index.html"}:
|
||||
self._serve_index()
|
||||
return
|
||||
if path == "/api/health":
|
||||
self._write_json(200, {
|
||||
"status": "ok",
|
||||
"service": "vehicle-map",
|
||||
"openPlatformConfigured": bool(OPEN_PLATFORM_APP_KEY),
|
||||
})
|
||||
return
|
||||
if 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
|
||||
if path == "/api/stations":
|
||||
try:
|
||||
self._write_json(200, _public_station_directory())
|
||||
except Exception as exc:
|
||||
self.log_error("station directory refresh failed: %s", exc)
|
||||
self._write_json(502, {
|
||||
"status": "error",
|
||||
"code": "UPSTREAM_UNAVAILABLE",
|
||||
"message": "加氢站数据暂时不可用,请稍后重试",
|
||||
})
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def _serve_index(self):
|
||||
try:
|
||||
template = (ROOT / "index.html").read_text(encoding="utf-8")
|
||||
body = template.replace("__ASSET_VERSION__", STATIC_ASSET_VERSION).encode("utf-8")
|
||||
except OSError as exc:
|
||||
self.log_error("index template unavailable: %s", exc)
|
||||
self.send_error(500, "Map application is temporarily unavailable")
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def end_headers(self):
|
||||
if self.path == "/" or self.path.split("?", 1)[0].endswith((".html", ".css", ".js", ".svg")):
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
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, headers=None):
|
||||
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)))
|
||||
for name, value in (headers or {}).items():
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = ThreadingHTTPServer((HOST, PORT), VehicleMapHandler)
|
||||
threading.Thread(target=_prewarm_dashboard_cache, daemon=True).start()
|
||||
print(f"Lingniu Vehicle Map listening on http://{HOST}:{PORT}")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
# 羚牛氢能驾驶舱 - 局域网本地部署启动脚本
|
||||
|
||||
echo "=================================================="
|
||||
echo " 羚牛氢能 - 全国氢能物流运营驾驶舱"
|
||||
echo " 局域网 (LAN) 服务启动中..."
|
||||
echo "=================================================="
|
||||
|
||||
IP_ADDR=$(ifconfig | grep "inet " | grep -v 127.0.0.1 | head -n 1 | awk '{print $2}')
|
||||
|
||||
PORT="${VEHICLE_MAP_PORT:-20800}"
|
||||
echo "本机访问地址: http://localhost:${PORT}"
|
||||
echo "局域网访问地址: http://${IP_ADDR}:${PORT}"
|
||||
echo "=================================================="
|
||||
|
||||
exec python3 server.py
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const checks = `
|
||||
dashboard = {
|
||||
vehicles: [
|
||||
{ vin: 'VIN-GD-1', plateNumber: '粤A00001', online: true, dailyMileageKm: 10, locationAvailable: true, longitude: 113.2, latitude: 23.1 },
|
||||
{ vin: 'VIN-GD-2', plateNumber: '粤B00002', online: false, dailyMileageKm: 5, locationAvailable: false },
|
||||
{ vin: 'VIN-ZJ-1', plateNumber: '浙F00003', online: true, dailyMileageKm: 8, locationAvailable: true, longitude: 120.7, latitude: 30.7 },
|
||||
{ vin: 'VIN-X-1', plateNumber: '观光车001', online: false, dailyMileageKm: 0, locationAvailable: false }
|
||||
]
|
||||
};
|
||||
filterState.query = '粤a';
|
||||
assert.deepEqual(filteredVehicles().map(vehicle => vehicle.vin), ['VIN-GD-1']);
|
||||
filterState.query = '';
|
||||
vehicleFilterGroups.provinces = [{ adcode: '440000', vehicles: [dashboard.vehicles[0]] }];
|
||||
filterState.province = '440000';
|
||||
assert.deepEqual(filteredVehicles().map(vehicle => vehicle.vin), ['VIN-GD-1']);
|
||||
filterState.province = '';
|
||||
const areaNode = {
|
||||
groupByPosition(points) {
|
||||
return [
|
||||
{ subFeatureIndex: 0, subFeature: { properties: { adcode: '440000', name: '广东省', center: [113.2, 23.1] } }, points: points.filter(point => point.vin === 'VIN-GD-1') },
|
||||
{ subFeatureIndex: 1, subFeature: { properties: { adcode: '330000', name: '浙江省', center: [120.2, 30.3] } }, points: points.filter(point => point.vin === 'VIN-ZJ-1') }
|
||||
];
|
||||
}
|
||||
};
|
||||
const partition = partitionVehiclesByArea(areaNode, locatedVehicles(), 'province');
|
||||
assert.equal(partition.groups.length, 2);
|
||||
assert.equal(partition.unmatched.length, 0);
|
||||
const provinceNodes = regionNodesFromGroups(partition.groups, 'province');
|
||||
assert.equal(provinceNodes.length, 2);
|
||||
const guangdong = provinceNodes.find(node => node.nameZh === '广东');
|
||||
assert.equal(guangdong.count, 1);
|
||||
assert.equal(guangdong.online, 1);
|
||||
assert.equal(guangdong.dist, 10);
|
||||
|
||||
assert.equal(hierarchyLevelForZoom(4.8), 'province');
|
||||
assert.equal(hierarchyLevelForZoom(7), 'city');
|
||||
assert.equal(hierarchyLevelForZoom(9.5), 'district');
|
||||
assert.equal(hierarchyLevelForZoom(12), 'vehicle');
|
||||
|
||||
vehicleRegionSummary = { level: 'province', nodes: provinceNodes, unassigned: 2, loading: false };
|
||||
map = { getZoom: () => 4.8 };
|
||||
assert.equal(vehicleNodes().length, 2);
|
||||
assert.ok(vehicleNodes().every(node => node.kind === 'vehicleProvince'));
|
||||
|
||||
const cityPartition = partitionVehiclesByArea({
|
||||
groupByPosition(points) {
|
||||
return [{ subFeatureIndex: 0, subFeature: { properties: { adcode: '440100', name: '广州市', center: [113.3, 23.1] } }, points }];
|
||||
}
|
||||
}, [dashboard.vehicles[0]], 'city');
|
||||
const cityNodes = regionNodesFromGroups(cityPartition.groups, 'city');
|
||||
assert.equal(cityNodes[0].kind, 'vehicleCity');
|
||||
assert.equal(cityNodes[0].nameZh, '广州');
|
||||
assert.equal(cityNodes[0].nameEn, 'Guangzhou City');
|
||||
|
||||
assert.equal(administrativeEnglishName('广东省', 'province'), 'Guangdong');
|
||||
assert.equal(administrativeEnglishName('广州市', 'city'), 'Guangzhou City');
|
||||
assert.equal(administrativeEnglishName('黄埔区', 'district'), 'Huangpu District');
|
||||
assert.equal(administrativeEnglishName('两江新区', 'district'), 'Liangjiang New Area');
|
||||
assert.equal(stationAdministrativePath({ province: '广东省', city: '广州市', district: '黄埔区' }, 'en'), 'Guangdong · Guangzhou City · Huangpu District');
|
||||
assert.deepEqual(modeKpiLabels(i18n.zh, 'station'), { total: '站点总数', active: '合作站点' });
|
||||
assert.deepEqual(modeKpiLabels(i18n.en, 'station'), { total: 'Total Stations', active: 'Partner Stations' });
|
||||
assert.equal(markerLabel({ kind: 'stationCluster', count: 65 }, i18n.en), '65 stations');
|
||||
assert.equal(markerLabel({ kind: 'stationCluster', count: 1 }, i18n.en), '1 station');
|
||||
assert.equal(markerLabel({ kind: 'station', cooperative: true }, i18n.en), 'H₂ · Partner');
|
||||
|
||||
map = {
|
||||
getZoom: () => 12,
|
||||
getBounds: () => ({
|
||||
getSouthWest: () => ({ getLng: () => 100, getLat: () => 20 }),
|
||||
getNorthEast: () => ({ getLng: () => 125, getLat: () => 35 })
|
||||
})
|
||||
};
|
||||
const points = vehiclePointNodes();
|
||||
assert.equal(points.length, 2);
|
||||
assert.ok(points.every(node => node.kind === 'vehiclePoint'));
|
||||
assert.equal(points.find(node => node.nameZh === '粤A00001').online, 1);
|
||||
|
||||
// A selected district is a semantic filter, not a viewport filter. Its full
|
||||
// fleet must survive a camera fit even when a valid vehicle sits outside the
|
||||
// initial district-centroid viewport.
|
||||
currentMode = 'vehicle';
|
||||
dashboard = {
|
||||
vehicles: [
|
||||
{ vin: 'VIN-BY-1', plateNumber: '粤A10001', activeToday: true, locationAvailable: true, longitude: 113.22, latitude: 23.18 },
|
||||
{ vin: 'VIN-BY-2', plateNumber: '粤A10002', activeToday: true, locationAvailable: true, longitude: 113.41, latitude: 23.38 }
|
||||
]
|
||||
};
|
||||
vehicleFilterGroups.districts = [{ adcode: '440111', vehicles: dashboard.vehicles }];
|
||||
filterState.district = '440111';
|
||||
map = {
|
||||
getZoom: () => 11.6,
|
||||
getBounds: () => ({
|
||||
getSouthWest: () => ({ getLng: () => 113.1, getLat: () => 23.1 }),
|
||||
getNorthEast: () => ({ getLng: () => 113.3, getLat: () => 23.3 })
|
||||
})
|
||||
};
|
||||
assert.equal(hierarchyLevelForZoom(), 'vehicle');
|
||||
assert.equal(vehiclePointNodes().length, 2);
|
||||
filterState.district = '';
|
||||
|
||||
dashboard = {
|
||||
stations: [
|
||||
{ id: 'GD-1', name: '广州合作站', province: '广东省', city: '广州市', address: '广东省广州市黄埔区开源大道1号', longitude: 113.2, latitude: 23.1, cooperative: true, totalHydrogenKg: 20 },
|
||||
{ id: 'GD-2', name: '佛山外部站', province: '广东省', city: '佛山市', longitude: 113.1, latitude: 23.0, cooperative: false, totalHydrogenKg: 10 },
|
||||
{ id: 'ZJ-1', name: '嘉兴合作站', province: '浙江省', city: '嘉兴市', longitude: 120.7, latitude: 30.7, cooperative: true, totalHydrogenKg: 30 }
|
||||
]
|
||||
};
|
||||
currentMode = 'station';
|
||||
assert.equal(stationDistrictName(dashboard.stations[0]), '黄埔区');
|
||||
assert.equal(fuzzySearchScore('广州', 'gz'), 3);
|
||||
assert.equal(fuzzySearchScore('广州市', '广州'), 1);
|
||||
assert.ok(stationLocationOptions().some(option => option.level === 'district' && option.district === '黄埔区'));
|
||||
filterState.query = '合作';
|
||||
assert.deepEqual(filteredStations().map(station => station.id).sort(), ['GD-1', 'ZJ-1']);
|
||||
filterState.query = '';
|
||||
filterState.province = '广东省'; filterState.city = '广州市'; filterState.district = '黄埔区';
|
||||
assert.deepEqual(filteredStations().map(station => station.id), ['GD-1']);
|
||||
filterState.province = ''; filterState.city = ''; filterState.district = '';
|
||||
filterState.stationLocations = [
|
||||
{ province: '广东省', city: '广州市', district: '' },
|
||||
{ province: '浙江省', city: '嘉兴市', district: '' }
|
||||
];
|
||||
assert.deepEqual(filteredStations().map(station => station.id).sort(), ['GD-1', 'ZJ-1']);
|
||||
assert.equal(stationLocationFilterLabel(filterState.stationLocations[0]), '广州');
|
||||
filterState.stationLocations = [];
|
||||
assert.ok(Math.abs(distanceKm([113, 23], [114, 23]) - 102.4) < 1);
|
||||
assert.equal(locationCoordinates({ coords: { longitude: NaN, latitude: 23 } }), null);
|
||||
map = {
|
||||
getZoom: () => 12,
|
||||
getBounds: () => ({
|
||||
getSouthWest: () => ({ getLng: () => 112, getLat: () => 22 }),
|
||||
getNorthEast: () => ({ getLng: () => 114, getLat: () => 24 })
|
||||
})
|
||||
};
|
||||
assert.equal(stationNodes(false).length, 3);
|
||||
assert.equal(stationNodes(true).length, 2);
|
||||
assert.deepEqual(stationNodes(true).map(node => node.name).sort(), ['佛山外部站', '广州合作站']);
|
||||
const stationNavigationUrl = navigationUrlForEntity('station', dashboard.stations[0]);
|
||||
assert.ok(stationNavigationUrl.startsWith('https://uri.amap.com/navigation?to=113.200000,23.100000,'));
|
||||
assert.match(stationNavigationUrl, /%E5%B9%BF%E5%B7%9E/);
|
||||
assert.match(stationNavigationUrl, /coordinate=gaode&callnative=1$/);
|
||||
assert.equal(navigationUrlForEntity('vehicle', { plateNumber: '无位置车辆' }), null);
|
||||
const launchClassSet = new Set();
|
||||
const launchOverlay = { hidden: true, classList: { add(name) { launchClassSet.add(name); }, remove(name) { launchClassSet.delete(name); } } };
|
||||
const launchButton = { disabled: false, dataset: { navigationUrl: stationNavigationUrl }, classList: { add(name) { launchClassSet.add('button:' + name); }, remove(name) { launchClassSet.delete('button:' + name); }, contains(name) { return launchClassSet.has('button:' + name); } } };
|
||||
const launchTimers = [];
|
||||
document.getElementById = id => id === 'navigationLaunchOverlay' ? launchOverlay : id === 'detailNavigateBtn' ? launchButton : null;
|
||||
window.requestAnimationFrame = callback => callback();
|
||||
window.setTimeout = (callback, delay) => { launchTimers.push({ callback, delay }); return launchTimers.length; };
|
||||
window.clearTimeout = () => {};
|
||||
window.location = { assign() {} };
|
||||
selectedEntity = { mode: 'station', entity: dashboard.stations[0] };
|
||||
navigateToSelectedEntity();
|
||||
assert.equal(launchOverlay.hidden, false);
|
||||
assert.ok(launchClassSet.has('is-visible'));
|
||||
assert.ok(launchClassSet.has('button:is-launching'));
|
||||
assert.equal(launchButton.disabled, true);
|
||||
assert.deepEqual(launchTimers.map(timer => timer.delay), [140, 4000]);
|
||||
const stationView = stationViewportSummary();
|
||||
assert.equal(stationView.level, 'station');
|
||||
assert.equal(stationView.visibleNodes.length, 2);
|
||||
assert.equal(stationView.allNodes.length, 3);
|
||||
|
||||
map = {
|
||||
getZoom: () => 4.8,
|
||||
getBounds: () => ({
|
||||
getSouthWest: () => ({ getLng: () => 112, getLat: () => 22 }),
|
||||
getNorthEast: () => ({ getLng: () => 114, getLat: () => 24 })
|
||||
})
|
||||
};
|
||||
const provinceView = stationViewportSummary();
|
||||
assert.equal(provinceView.allNodes.length, 2);
|
||||
assert.equal(provinceView.visibleNodes.length, 1);
|
||||
assert.equal(provinceView.visibleNodes[0].name, '广东');
|
||||
assert.equal(provinceView.visibleNodes[0].count, 2);
|
||||
`;
|
||||
|
||||
const sandbox = {
|
||||
assert,
|
||||
console,
|
||||
pinyinPro: {
|
||||
pinyin(value) {
|
||||
return ({ 广州: 'guang zhou', 黄埔: 'huang pu', 两江: 'liang jiang' })[value] || value;
|
||||
}
|
||||
},
|
||||
document: { addEventListener() {}, querySelector() {}, createElement() { return {}; }, head: { appendChild() {} } },
|
||||
window: {},
|
||||
setInterval() {},
|
||||
clearInterval() {}
|
||||
};
|
||||
assert.match(source, /navigationLaunchOverlay/);
|
||||
assert.match(source, /正在打开高德地图/);
|
||||
vm.runInNewContext(`${source}\n${checks}`, sandbox, { filename: 'app.js' });
|
||||
console.log('vehicle hierarchy aggregation tests: ok');
|
||||
@@ -0,0 +1,89 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
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_public_vehicle_and_station_data(self):
|
||||
def fake_post(path, body):
|
||||
if path.endswith("realtime/query"):
|
||||
return [
|
||||
{"vin": "VIN1", "motionStatus": "driving", "speedKmh": 18, "activeToday": True, "online": True},
|
||||
{"vin": "VIN2", "motionStatus": "idle", "speedKmh": 0, "activeToday": True, "online": True},
|
||||
{"vin": "VIN3", "motionStatus": "offline", "speedKmh": 0, "activeToday": False, "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)
|
||||
|
||||
def test_static_asset_version_is_a_content_fingerprint(self):
|
||||
self.assertEqual(len(server.STATIC_ASSET_VERSION), 16)
|
||||
self.assertRegex(server.STATIC_ASSET_VERSION, r"^[0-9a-f]{16}$")
|
||||
index_template = (Path(__file__).parents[1] / "index.html").read_text(encoding="utf-8")
|
||||
self.assertEqual(index_template.count("__ASSET_VERSION__"), 2)
|
||||
|
||||
def test_default_cache_window_is_two_minutes(self):
|
||||
self.assertEqual(server.DASHBOARD_CACHE_SECONDS, 120)
|
||||
self.assertEqual(server.STATION_CACHE_SECONDS, 120)
|
||||
|
||||
def test_cache_reuses_dashboard_snapshot_within_window(self):
|
||||
calls = []
|
||||
with patch.object(server, "_cache", {}):
|
||||
first = server._cached("dashboard", 120, lambda: calls.append("load") or {"version": 1})
|
||||
second = server._cached("dashboard", 120, lambda: calls.append("load") or {"version": 2})
|
||||
self.assertEqual(calls, ["load"])
|
||||
self.assertEqual(first, second)
|
||||
|
||||
def test_station_region_comes_from_gps_reverse_geocode_and_persists_by_coordinate(self):
|
||||
station = {
|
||||
"longitude": 113.200001,
|
||||
"latitude": 23.100001,
|
||||
"province": "错误省份",
|
||||
"city": "错误城市",
|
||||
"district": "错误区县",
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
cache_path = Path(directory) / "station-geocode.json"
|
||||
with patch.object(server, "STATION_GEOCODE_CACHE_PATH", cache_path), \
|
||||
patch.object(server, "_station_geocode_cache", None), \
|
||||
patch.object(server, "_station_geocode_cache_dirty", False), \
|
||||
patch.object(server, "_reverse_geocode_station", return_value={
|
||||
"province": "广东省", "city": "广州市", "district": "黄埔区", "adcode": "440112"
|
||||
}) as reverse:
|
||||
first = server._stations_with_gps_regions([station])[0]
|
||||
second = server._stations_with_gps_regions([dict(station, province="仍然错误")])[0]
|
||||
self.assertEqual(first["province"], "广东省")
|
||||
self.assertEqual(first["city"], "广州市")
|
||||
self.assertEqual(first["district"], "黄埔区")
|
||||
self.assertEqual(second["province"], "广东省")
|
||||
self.assertEqual(reverse.call_count, 1)
|
||||
self.assertTrue(cache_path.exists())
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user