feat(map): add cached public station navigation

This commit is contained in:
lingniu
2026-08-12 10:50:06 +08:00
parent 357dd490e9
commit 6ef98d03c2
13 changed files with 780 additions and 4 deletions
+35
View File
@@ -0,0 +1,35 @@
# 羚牛氢能 · 加氢站导航
面向运维和司机调度的独立公共站点导航服务,默认本地端口为 `20804`
它只调用一次上游加氢站目录接口,并通过正向字段白名单对外提供:名称、位置、省市区、地址与合作状态。车辆数据、加氢量及其他运营字段不会被加载或从这个服务的 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 分钟更新一次。
## 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>
```
+197
View File
@@ -0,0 +1,197 @@
const state = { stations: [], query: '', searchText: '', locationFilter: { province: '', city: '', district: '' }, suggestions: [], activeSuggestion: -1, searchDebounce: null, stationFilter: 'all', selected: null, userLocation: null, map: null, markers: [], userMarker: null, pitch: true };
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>`);
document.getElementById('partnerSegment').style.width = `${total ? partner * 100 / total : 0}%`;
document.getElementById('externalSegment').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 stationMatchesLocation(station) { const filter = state.locationFilter; return (!filter.province || station.province === filter.province) && (!filter.city || station.city === filter.city) && (!filter.district || stationDistrictName(station) === filter.district); }
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.province, station.city, station.district, 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' && node.cooperative ? '<span class="station-list-partner-tag">合作</span>' : ''; 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><span class="r-val">${Number.isFinite(distance) ? `${formatNumber(distance, 1)} km` : `${formatNumber(node.count)}`}</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}`;
if (!groups.has(key)) groups.set(key, { type: 'location', level, province, city, district, 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;
const locations = stationLocationSuggestions(state.searchText), stations = stationEntitySuggestions(state.searchText);
state.suggestions = [...locations, ...stations]; state.activeSuggestion = Math.min(state.activeSuggestion, state.suggestions.length - 1);
if (!state.suggestions.length) { box.innerHTML = '<span class="suggestion-empty">未找到匹配的位置或站点</span>'; return; }
let html = '';
if (locations.length) html += `<span class="suggestion-section-label">位置</span>${locations.map((item, index) => `<button type="button" class="explore-suggestion${index === state.activeSuggestion ? ' is-active' : ''}" role="option" onclick="selectStationSearchSuggestion(${index})"><span class="suggestion-symbol is-location">⌖</span><span class="suggestion-copy"><strong>${escapeHTML(item.label)}</strong><small>${escapeHTML(item.path)}</small></span><span class="suggestion-kind">地点</span></button>`).join('')}`;
if (stations.length) html += `<span class="suggestion-section-label">加氢站</span>${stations.map((item, offset) => { const index = locations.length + offset, 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(stationRegion(station) || station.address || '')}</small></span><span class="suggestion-kind">加氢站</span></button>`; }).join('')}`;
box.innerHTML = html;
}
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 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;
if (item.type === 'location') { state.locationFilter = { province: item.province, city: item.city, district: item.district }; state.query = ''; state.searchText = ''; document.getElementById('stationSearch').value = ''; document.getElementById('clearSearch').hidden = true; state.map?.setZoomAndCenter(item.level === 'province' ? 7.2 : item.level === 'city' ? 10.5 : 13, item.lnglat); document.getElementById('selectedRegionHint').textContent = `视角: ${item.path}`; closeStationSearchSuggestions(); render(); 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.locationFilter = { province: '', city: '', district: '' }; 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char])); }
function escapeAttribute(value) { return escapeHTML(value).replace(/`/g, '&#96;'); }
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]; }
+49
View File
@@ -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
+64
View File
@@ -0,0 +1,64 @@
<!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>
<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>
<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="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"><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>
<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>
+230
View File
@@ -0,0 +1,230 @@
"""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"))
_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 _public_station(station):
"""Positive allowlist: do not add operational or hydrogen fields here."""
fields = (
"id", "name", "shortName", "province", "city", "district", "address",
"longitude", "latitude", "cooperative",
)
return {field: station.get(field) for field in fields if station.get(field) is not None}
def _load_station_directory():
stations = _post_open_platform("/api/v1/hydrogen-stations/query", {})
directory = [_public_station(station) 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 _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__":
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()
+6
View File
@@ -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"
+20
View File
@@ -0,0 +1,20 @@
/* Keep the public service visually identical to the main map shell. */
.station-navigation-shell .header-kpi-group { display: flex; }
.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 .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 auto; 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; }
.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) {
.station-navigation-shell .header-kpi-group { display: flex; }
.station-navigation-shell .filter-chip-rail { max-width: 124px; overflow: hidden; }
.station-navigation-shell .map-filter-chip { padding: 0 7px; font-size: 9px; }
}
+73
View File
@@ -0,0 +1,73 @@
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 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;
`, 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);
vm.runInNewContext(`
globalThis.inferredDistrict = stationDistrictName({ province: '浙江省', city: '嘉兴市', address: '嘉兴市海盐县海盐经济开发区' });
state.locationFilter = { province: '', city: '', district: '' };
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);
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, /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, /node\.cooperative \? 'H₂ · 合作' : ''/);
assert.doesNotMatch(source, /在线 \$\{formatNumber\(node\.online\)\}/);
assert.doesNotMatch(source, /monthlyHydrogenKg|totalHydrogenKg|vehicle/);
assert.match(source, /uri\.amap\.com\/navigation/);
console.log('station navigation app tests: ok');
+46
View File
@@ -0,0 +1,46 @@
import importlib.util
from pathlib import Path
import unittest
from unittest.mock import patch
SPEC = importlib.util.spec_from_file_location("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 = [{
"id": "GD-1", "name": "广州合作站", "province": "广东省", "city": "广州市",
"district": "黄埔区", "address": "开源大道1号", "longitude": 113.2, "latitude": 23.1,
"cooperative": True, "monthlyHydrogenKg": 88.8, "totalHydrogenKg": 999.9,
"vehicleCount": 20,
}]
with patch.object(server, "_post_open_platform", return_value=source):
result = server._load_station_directory()
self.assertEqual(result["summary"], {"totalStations": 1, "cooperativeStations": 1})
station = result["stations"][0]
self.assertEqual(station["name"], "广州合作站")
self.assertNotIn("monthlyHydrogenKg", station)
self.assertNotIn("totalHydrogenKg", station)
self.assertNotIn("vehicleCount", station)
def test_static_assets_are_fingerprinted(self):
self.assertRegex(server.STATIC_ASSET_VERSION, r"^[0-9a-f]{16}$")
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()
+2 -2
View File
@@ -37,8 +37,8 @@ python3 server.py
本服务提供:
- `GET /api/health`:进程及配置状态;
- `GET /api/dashboard`:面向前端的公开聚合数据,默认缓存5秒;加氢站缓存1小时
- `GET /api/dashboard`:面向前端的公开聚合数据,默认缓存 2 分钟;加氢站目录同样缓存 2 分钟。服务启动后会后台预热缓存,避免首位访问者等待上游数据请求
全国视图按车辆最新 GPS 坐标落入省级行政区,不按车牌归属地推断;没有有效实时坐标的车辆会单独计入“无实时位置”,不会伪造省份归属。页面每15秒刷新一次
全国视图按车辆最新 GPS 坐标落入省级行政区,不按车牌归属地推断;没有有效实时坐标的车辆会单独计入“无实时位置”,不会伪造省份归属。页面每 15 秒请求一次,但服务端最多每 2 分钟刷新一次上游快照,以换取更快、更稳定的首屏加载
车辆地图按缩放级别逐级下钻:全国视角(小于7级)按省聚合,7–9.5级按市聚合,9.5–12级按区县聚合,12级及以上显示当前视野内的单车真实位置。点击省、市、区县气泡会自动进入下一级。
+15 -2
View File
@@ -31,8 +31,8 @@ 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", "5"))
STATION_CACHE_SECONDS = int(os.getenv("STATION_CACHE_SECONDS", "3600"))
DASHBOARD_CACHE_SECONDS = int(os.getenv("DASHBOARD_CACHE_SECONDS", "120"))
STATION_CACHE_SECONDS = int(os.getenv("STATION_CACHE_SECONDS", "120"))
_cache_lock = threading.Lock()
_cache = {}
@@ -149,6 +149,18 @@ def _load_dashboard():
}
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"
@@ -215,6 +227,7 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
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()
+12
View File
@@ -47,5 +47,17 @@ class DashboardTest(unittest.TestCase):
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)
if __name__ == "__main__":
unittest.main()