refactor(map): remove access gate

This commit is contained in:
lingniu
2026-08-11 19:24:32 +08:00
parent d1ba129e99
commit 357dd490e9
9 changed files with 140 additions and 580 deletions
-12
View File
@@ -5,15 +5,3 @@ OPEN_PLATFORM_APP_KEY=replace-with-32-character-app-key
UPSTREAM_TIMEOUT_SECONDS=15 UPSTREAM_TIMEOUT_SECONDS=15
DASHBOARD_CACHE_SECONDS=5 DASHBOARD_CACHE_SECONDS=5
STATION_CACHE_SECONDS=3600 STATION_CACHE_SECONDS=3600
# Public station map: name, location, address and cooperation status only.
# Operations data (all vehicles and hydrogen volumes) requires this server-only code.
VEHICLE_MAP_AUTH_PROVIDER=local
VEHICLE_MAP_ACCESS_CODE=replace-with-an-access-code
VEHICLE_MAP_SESSION_SECRET=replace-with-at-least-32-random-characters
# An operations session expires after 30 minutes.
VEHICLE_MAP_SESSION_TTL_SECONDS=1800
# true behind the HTTPS production reverse proxy; false only for localhost development.
VEHICLE_MAP_COOKIE_SECURE=true
# For future unified authentication: set provider to auth-center and configure its introspection endpoint.
VEHICLE_MAP_AUTH_CENTER_INTROSPECTION_URL=
VEHICLE_MAP_AUTH_CENTER_CLIENT_TOKEN=
+5 -20
View File
@@ -19,27 +19,12 @@ python3 server.py
默认地址:`http://127.0.0.1:20800` 默认地址:`http://127.0.0.1:20800`
请通过上述 HTTP 地址访问,不能直接双击打开 `index.html`:页面需要同源调用本服务的公开站点与授权 API。 请通过上述 HTTP 地址访问,不能直接双击打开 `index.html`:页面需要同源调用本服务的数据 API。
## 公开与授权数据边界 ## 数据访问
- `GET /api/public/stations` 无需授权,仅返回站点名称、坐标、省市区、地址与合作状态;加氢量字段不在响应中 - `GET /api/dashboard` 公开返回车辆信息、站点基础信息及加氢量
- `GET /api/dashboard` 需要 `operations:read` 会话,返回全部车辆信息及加氢站加氢量 - 不保存访问码、不创建浏览器会话,也不区分公开或受保护的数据接口
- `POST /api/session` 接收访问码,服务端验证后写入短期 HttpOnly 会话 Cookie;浏览器可保存该会话,访问码不会下发或存入前端。
- `DELETE /api/session` 清除当前浏览器的运营会话。
当前默认 `VEHICLE_MAP_AUTH_PROVIDER=local`,生产环境在 `/opt/lingniu-vehicle-map/env/vehicle-map.env` 设置:
```bash
VEHICLE_MAP_ACCESS_CODE=<访问码>
VEHICLE_MAP_SESSION_SECRET=<至少32位随机密钥>
VEHICLE_MAP_SESSION_TTL_SECONDS=1800
VEHICLE_MAP_COOKIE_SECURE=true
```
授权会话有效期为 30 分钟;到期后自动回到仅展示公开加氢站目录的状态。
后续接入统一鉴权中心时,将 `VEHICLE_MAP_AUTH_PROVIDER` 改为 `auth-center` 并配置 `VEHICLE_MAP_AUTH_CENTER_INTROSPECTION_URL`;前端流程与受保护接口不需要调整。鉴权中心适配器的约定为接收 `{accessCode, audience:"vehicle-map"}`,返回 `{active, subject, scopes}`,且 scopes 需要包含 `operations:read`
## 接口依赖 ## 接口依赖
@@ -52,7 +37,7 @@ VEHICLE_MAP_COOKIE_SECURE=true
本服务提供: 本服务提供:
- `GET /api/health`:进程及配置状态; - `GET /api/health`:进程及配置状态;
- `GET /api/dashboard`:面向前端的聚合数据,默认缓存5秒;加氢站缓存1小时。 - `GET /api/dashboard`:面向前端的公开聚合数据,默认缓存5秒;加氢站缓存1小时。
全国视图按车辆最新 GPS 坐标落入省级行政区,不按车牌归属地推断;没有有效实时坐标的车辆会单独计入“无实时位置”,不会伪造省份归属。页面每15秒刷新一次。 全国视图按车辆最新 GPS 坐标落入省级行政区,不按车牌归属地推断;没有有效实时坐标的车辆会单独计入“无实时位置”,不会伪造省份归属。页面每15秒刷新一次。
+42 -178
View File
@@ -23,14 +23,11 @@ const i18n = {
detailVehicle: '车辆', detailStation: '加氢站', detailPlate: '车牌', detailVin: 'VIN', detailStatus: '车辆状态', detailActive: '今日上线', detailVehicle: '车辆', detailStation: '加氢站', detailPlate: '车牌', detailVin: 'VIN', detailStatus: '车辆状态', detailActive: '今日上线',
detailSpeed: '当前速度', detailDailyMileage: '今日里程', detailTotalMileage: '累计里程', detailLocation: '实时位置', detailCoordinate: '坐标', detailSpeed: '当前速度', detailDailyMileage: '今日里程', detailTotalMileage: '累计里程', detailLocation: '实时位置', detailCoordinate: '坐标',
detailStationType: '站点类型', detailAdmin: '行政区域', detailAddress: '详细地址', detailMonthlyHydrogen: '本月加氢量', detailTotalHydrogen: '累计加氢量', detailNavigate: '导航', detailStationType: '站点类型', detailAdmin: '行政区域', detailAddress: '详细地址', detailMonthlyHydrogen: '本月加氢量', detailTotalHydrogen: '累计加氢量', detailNavigate: '导航',
navigationLaunchingTitle: '正在打开高德地图', navigationLaunchingDescription: '正在准备导航路线,请稍候',
valueYes: '是', valueNo: '否', valueUnavailable: '暂无数据', locatePermissionHint: '无法获取位置,请在浏览器中允许位置权限后重试', valueYes: '是', valueNo: '否', valueUnavailable: '暂无数据', locatePermissionHint: '无法获取位置,请在浏览器中允许位置权限后重试',
locateUnavailableHint: '暂时无法获取设备位置,请稍后重试', locateTimeoutHint: '定位超时,请到开阔区域后重试', locateUnavailableHint: '暂时无法获取设备位置,请稍后重试', locateTimeoutHint: '定位超时,请到开阔区域后重试',
searchUniversal: '搜索位置、站点、车牌或 VIN', searchLocations: '位置', searchEntities: '业务对象', searchEmpty: '没有匹配的地点或对象', searchUniversal: '搜索位置、站点、车牌或 VIN', searchLocations: '位置', searchEntities: '业务对象', searchEmpty: '没有匹配的地点或对象',
suggestionLocation: '地点', suggestionVehicle: '车辆', suggestionStation: '加氢站', detailMore: '展开全部信息', detailLess: '收起详细信息', suggestionLocation: '地点', suggestionVehicle: '车辆', suggestionStation: '加氢站', detailMore: '展开全部信息', detailLess: '收起详细信息'
publicStationTitle: '加氢站网络', publicDataStatus: '公共站点目录 · 位置、地址与合作状态', publicDataSource: '公共站点网络',
authEntry: '运营授权', authGranted: '已授权', authTitle: '进入运营数据', authDescription: '车辆全量信息与加氢量仅向获得授权的用户展示。',
authCodeLabel: '访问码', authCodePlaceholder: '输入访问码', authSubmit: '验证并进入', authCancel: '暂不授权', authRequired: '运营数据需要授权访问', authExpiry: '本次授权有效期为 30 分钟,到期后将自动恢复为公开站点视图。',
authDenied: '访问码无效,请重试', authUnavailable: '授权服务暂不可用,请稍后重试', authHydrogenRequired: '授权后查看加氢量', publicHydrogenHidden: '运营数据受保护', authLogout: '退出授权'
}, },
en: { en: {
vehicleModeTitle: 'Vehicle Network', stationModeTitle: 'H₂ Station Network', vehicleModeTitle: 'Vehicle Network', stationModeTitle: 'H₂ Station Network',
@@ -54,14 +51,11 @@ const i18n = {
detailVehicle: 'Vehicle', detailStation: 'H₂ station', detailPlate: 'Plate', detailVin: 'VIN', detailStatus: 'Status', detailActive: 'Active today', detailVehicle: 'Vehicle', detailStation: 'H₂ station', detailPlate: 'Plate', detailVin: 'VIN', detailStatus: 'Status', detailActive: 'Active today',
detailSpeed: 'Speed', detailDailyMileage: 'Today mileage', detailTotalMileage: 'Total mileage', detailLocation: 'Live location', detailCoordinate: 'Coordinates', detailSpeed: 'Speed', detailDailyMileage: 'Today mileage', detailTotalMileage: 'Total mileage', detailLocation: 'Live location', detailCoordinate: 'Coordinates',
detailStationType: 'Station type', detailAdmin: 'Region', detailAddress: 'Address', detailMonthlyHydrogen: 'Monthly hydrogen', detailTotalHydrogen: 'Total hydrogen', detailNavigate: 'Navigate', detailStationType: 'Station type', detailAdmin: 'Region', detailAddress: 'Address', detailMonthlyHydrogen: 'Monthly hydrogen', detailTotalHydrogen: 'Total hydrogen', detailNavigate: 'Navigate',
navigationLaunchingTitle: 'Opening AMap', navigationLaunchingDescription: 'Preparing your route. Just a moment.',
valueYes: 'Yes', valueNo: 'No', valueUnavailable: 'Unavailable', locatePermissionHint: 'Location access is blocked. Allow it in your browser and try again.', valueYes: 'Yes', valueNo: 'No', valueUnavailable: 'Unavailable', locatePermissionHint: 'Location access is blocked. Allow it in your browser and try again.',
locateUnavailableHint: 'Your location is temporarily unavailable. Please try again.', locateTimeoutHint: 'Location timed out. Move to an open area and try again.', locateUnavailableHint: 'Your location is temporarily unavailable. Please try again.', locateTimeoutHint: 'Location timed out. Move to an open area and try again.',
searchUniversal: 'Search location, station, plate, or VIN', searchLocations: 'Locations', searchEntities: 'Results', searchEmpty: 'No matching location or entity', searchUniversal: 'Search location, station, plate, or VIN', searchLocations: 'Locations', searchEntities: 'Results', searchEmpty: 'No matching location or entity',
suggestionLocation: 'Location', suggestionVehicle: 'Vehicle', suggestionStation: 'H₂ station', detailMore: 'Show all details', detailLess: 'Collapse details', suggestionLocation: 'Location', suggestionVehicle: 'Vehicle', suggestionStation: 'H₂ station', detailMore: 'Show all details', detailLess: 'Collapse details'
publicStationTitle: 'H₂ Station Network', publicDataStatus: 'Public station directory · location, address and partner status', publicDataSource: 'Public station network',
authEntry: 'Operations access', authGranted: 'Access granted', authTitle: 'Enter operations data', authDescription: 'Vehicle information and hydrogen volumes require authorized access.',
authCodeLabel: 'Access code', authCodePlaceholder: 'Enter access code', authSubmit: 'Verify and continue', authCancel: 'Not now', authRequired: 'Operations data requires authorized access', authExpiry: 'This authorization lasts 30 minutes. When it expires, the public station view is restored.',
authDenied: 'The access code is invalid. Try again.', authUnavailable: 'Authorization is temporarily unavailable. Try again later.', authHydrogenRequired: 'Authorize to view hydrogen volume', publicHydrogenHidden: 'Operations data protected', authLogout: 'Sign out'
} }
}; };
@@ -116,8 +110,8 @@ let vehicleExploreCatalog = { ready: false, loading: null, options: [], province
let exploreSuggestionItems = []; let exploreSuggestionItems = [];
let activeExploreSuggestionIndex = -1; let activeExploreSuggestionIndex = -1;
let detailExpanded = false; let detailExpanded = false;
let navigationLaunchTimer = null;
const filterState = { province: '', city: '', district: '', query: '' }; const filterState = { province: '', city: '', district: '', query: '' };
let accessState = { authorized: false, principal: null, pendingMode: null };
const REGION_ZOOM = { city: 7, district: 9.5, vehicle: 12 }; const REGION_ZOOM = { city: 7, district: 9.5, vehicle: 12 };
const MUNICIPALITIES = new Set(['北京', '天津', '上海', '重庆']); const MUNICIPALITIES = new Set(['北京', '天津', '上海', '重庆']);
@@ -125,66 +119,19 @@ const MUNICIPALITIES = new Set(['北京', '天津', '上海', '重庆']);
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initClock(); initClock();
initAMapInstance(); initAMapInstance();
initializeDataAccess(); loadDashboard();
refreshTimer = window.setInterval(loadDashboard, 15000);
}); });
document.addEventListener('pointerdown', event => { document.addEventListener('pointerdown', event => {
if (!event.target?.closest?.('.map-explore-toolbar')) closeExploreSuggestions(); if (!event.target?.closest?.('.map-explore-toolbar')) closeExploreSuggestions();
}); });
async function initializeDataAccess() { async function loadDashboard() {
await loadSession();
await loadPublicStations();
if (accessState.authorized) loadOperationalDashboard();
refreshTimer = window.setInterval(() => {
if (accessState.authorized) loadOperationalDashboard();
else loadPublicStations();
}, 15000);
}
async function loadSession() {
try {
const response = await fetch('/api/session', { headers: { Accept: 'application/json' } });
const payload = await response.json();
accessState.authorized = Boolean(payload.authorized);
accessState.principal = payload.principal || null;
} catch (error) {
accessState.authorized = false;
accessState.principal = null;
}
updateAccessUI();
}
async function loadPublicStations() {
setDataState('loading');
try {
const response = await fetch('/api/public/stations', { headers: { Accept: 'application/json' } });
const payload = await response.json();
if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`);
if (!accessState.authorized || currentMode === 'station') dashboard = payload;
void syncFilterControls(payload);
updateDashboardUI();
if (currentMode === 'station') refreshStationViewport();
setDataState('ready');
} catch (error) {
console.error('public station refresh failed', error);
setDataState('error');
}
}
async function loadOperationalDashboard() {
setDataState('loading'); setDataState('loading');
try { try {
const response = await fetch('/api/dashboard', { headers: { Accept: 'application/json' } }); const response = await fetch('/api/dashboard', { headers: { Accept: 'application/json' } });
const payload = await response.json(); const payload = await response.json();
if (response.status === 401) {
accessState.authorized = false;
accessState.principal = null;
updateAccessUI();
if (currentMode === 'vehicle') switchMode('station');
await loadPublicStations();
return;
}
if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`); if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`);
dashboard = payload; dashboard = payload;
void syncFilterControls(payload); void syncFilterControls(payload);
@@ -193,7 +140,7 @@ async function loadOperationalDashboard() {
else refreshStationViewport(); else refreshStationViewport();
setDataState('ready'); setDataState('ready');
} catch (error) { } catch (error) {
console.error('operational dashboard refresh failed', error); console.error('dashboard refresh failed', error);
setDataState('error'); setDataState('error');
} }
} }
@@ -205,97 +152,6 @@ function setDataState(state) {
if (state === 'error') el.textContent = currentLang === 'zh' ? '数据同步失败 · 将自动重试' : 'Data sync failed · Retrying'; if (state === 'error') el.textContent = currentLang === 'zh' ? '数据同步失败 · 将自动重试' : 'Data sync failed · Retrying';
} }
function operationalAccessGranted() {
return Boolean(accessState.authorized);
}
function updateAccessUI() {
const dict = i18n[currentLang];
const trigger = document.getElementById('accessTrigger');
if (trigger) {
trigger.classList.toggle('is-authorized', operationalAccessGranted());
trigger.textContent = operationalAccessGranted() ? dict.authGranted : dict.authEntry;
trigger.setAttribute('aria-label', operationalAccessGranted() ? dict.authLogout : dict.authEntry);
trigger.title = operationalAccessGranted() ? dict.authLogout : dict.authEntry;
}
document.getElementById('btnModeVehicle')?.classList.toggle('is-locked', !operationalAccessGranted());
}
function openAccessDialog(targetMode = 'vehicle', message = '') {
accessState.pendingMode = targetMode;
const dict = i18n[currentLang];
const dialog = document.getElementById('accessDialog');
const hint = document.getElementById('accessDialogHint');
const input = document.getElementById('accessCodeInput');
if (hint) hint.textContent = message || dict.authDescription;
if (input) input.value = '';
if (dialog?.showModal) dialog.showModal();
else dialog?.setAttribute('open', '');
window.setTimeout(() => input?.focus(), 0);
}
function closeAccessDialog() {
const dialog = document.getElementById('accessDialog');
if (dialog?.close) dialog.close();
else dialog?.removeAttribute('open');
const error = document.getElementById('accessDialogError');
if (error) { error.hidden = true; error.textContent = ''; }
}
async function authorizeOperationsAccess() {
const dict = i18n[currentLang];
const input = document.getElementById('accessCodeInput');
const submit = document.getElementById('accessSubmitBtn');
const error = document.getElementById('accessDialogError');
const accessCode = input?.value?.trim();
if (!accessCode) {
if (error) { error.hidden = false; error.textContent = dict.authDenied; }
input?.focus();
return;
}
if (submit) submit.disabled = true;
if (error) error.hidden = true;
try {
const response = await fetch('/api/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ accessCode }),
});
const payload = await response.json();
if (!response.ok || !payload.authorized) {
if (error) { error.hidden = false; error.textContent = response.status === 503 ? dict.authUnavailable : dict.authDenied; }
return;
}
accessState.authorized = true;
accessState.principal = payload.principal || null;
updateAccessUI();
closeAccessDialog();
await loadOperationalDashboard();
const targetMode = accessState.pendingMode || 'vehicle';
accessState.pendingMode = null;
if (targetMode === 'vehicle') switchMode('vehicle', { skipAuthCheck: true });
else updateDashboardUI();
} catch (requestError) {
if (error) { error.hidden = false; error.textContent = dict.authUnavailable; }
} finally {
if (submit) submit.disabled = false;
}
}
async function toggleOperationsAccess() {
if (!operationalAccessGranted()) {
openAccessDialog('vehicle');
return;
}
try { await fetch('/api/session', { method: 'DELETE', headers: { Accept: 'application/json' } }); } catch (error) { /* local state still resets safely */ }
accessState.authorized = false;
accessState.principal = null;
accessState.pendingMode = null;
updateAccessUI();
if (currentMode === 'vehicle') switchMode('station', { skipAuthCheck: true });
await loadPublicStations();
}
function normalizeSearch(value) { function normalizeSearch(value) {
return String(value || '').trim().toLocaleLowerCase(); return String(value || '').trim().toLocaleLowerCase();
} }
@@ -864,9 +720,7 @@ function updateDashboardUI() {
document.getElementById('kpiFleetOnline').innerHTML = `${formatNumber(active)} <small>${countUnit(active, currentMode, dict)} (${activeRate}%)</small>`; document.getElementById('kpiFleetOnline').innerHTML = `${formatNumber(active)} <small>${countUnit(active, currentMode, dict)} (${activeRate}%)</small>`;
const activity = currentMode === 'vehicle' const activity = currentMode === 'vehicle'
? { label: dict.kpiDailyMileage, value: summary.todayMileageKm, unit: 'km' } ? { label: dict.kpiDailyMileage, value: summary.todayMileageKm, unit: 'km' }
: operationalAccessGranted() : { label: dict.kpiMonthlyHydrogen, value: summary.monthlyHydrogenKg, unit: 'kg' };
? { label: dict.kpiMonthlyHydrogen, value: summary.monthlyHydrogenKg, unit: 'kg' }
: { label: dict.publicHydrogenHidden, value: null, unit: '' };
document.getElementById('kpiActivityLabel').textContent = activity.label; document.getElementById('kpiActivityLabel').textContent = activity.label;
document.getElementById('kpiDailyDist').innerHTML = activity.value == null document.getElementById('kpiDailyDist').innerHTML = activity.value == null
? `${activity.unit ? ` <small>${activity.unit}</small>` : ''}` ? `${activity.unit ? ` <small>${activity.unit}</small>` : ''}`
@@ -884,17 +738,14 @@ function mapStatusSummaryText(summary, regionSummary, asOf, mode = currentMode,
if (mode === 'station') { if (mode === 'station') {
const cooperative = Number(summary.cooperativeStations || 0); const cooperative = Number(summary.cooperativeStations || 0);
const external = Math.max(0, Number(summary.totalStations || 0) - cooperative); const external = Math.max(0, Number(summary.totalStations || 0) - cooperative);
const accessHint = operationalAccessGranted()
? ''
: lang === 'zh' ? ' · 站点目录公开展示' : ' · public station directory';
return lang === 'zh' return lang === 'zh'
? `开放平台已同步 · ${summary.totalStations}座加氢站 · ${cooperative}座合作站点 · ${external}座外部站点${accessHint} · ${asOf}` ? `开放平台已同步 · ${summary.totalStations}座加氢站 · ${cooperative}座合作站点 · ${external}座外部站点 · ${asOf}`
: `Open platform synced · ${summary.totalStations} H₂ stations · ${cooperative} partner stations · ${external} external stations${accessHint} · ${asOf}`; : `Open platform synced · ${summary.totalStations} H₂ stations · ${cooperative} partner stations · ${external} external stations · ${asOf}`;
} }
const regionText = regionSummary.loading ? regionLoadingText(regionSummary.level, lang) : regionSummaryText(regionSummary, lang); const regionText = regionSummary.loading ? regionLoadingText(regionSummary.level, lang) : regionSummaryText(regionSummary, lang);
return lang === 'zh' return lang === 'zh'
? `开放平台已同步 · ${summary.totalVehicles}授权车辆 · ${regionText} · ${asOf}` ? `开放平台已同步 · ${summary.totalVehicles}辆车辆 · ${regionText} · ${asOf}`
: `Open platform synced · ${summary.totalVehicles} authorized vehicles · ${regionText} · ${asOf}`; : `Open platform synced · ${summary.totalVehicles} vehicles · ${regionText} · ${asOf}`;
} }
function modeKpiLabels(dict = i18n[currentLang], mode = currentMode) { function modeKpiLabels(dict = i18n[currentLang], mode = currentMode) {
@@ -1390,9 +1241,31 @@ function updateDetailNavigation(mode, entity) {
button.title = label; button.title = label;
} }
function resetNavigationLaunch() {
const overlay = document.getElementById('navigationLaunchOverlay');
const button = document.getElementById('detailNavigateBtn');
overlay?.classList.remove('is-visible');
window.setTimeout(() => { if (overlay) overlay.hidden = true; }, 180);
button?.classList.remove('is-launching');
if (button && button.dataset.navigationUrl) button.disabled = false;
}
function navigateToSelectedEntity() { function navigateToSelectedEntity() {
const url = selectedEntity && navigationUrlForEntity(selectedEntity.mode, selectedEntity.entity); const url = selectedEntity && navigationUrlForEntity(selectedEntity.mode, selectedEntity.entity);
if (url) window.location.assign(url); if (!url) return;
const overlay = document.getElementById('navigationLaunchOverlay');
const button = document.getElementById('detailNavigateBtn');
if (button?.classList.contains('is-launching')) return;
if (navigationLaunchTimer) window.clearTimeout(navigationLaunchTimer);
if (overlay) {
overlay.hidden = false;
window.requestAnimationFrame(() => overlay.classList.add('is-visible'));
}
button?.classList.add('is-launching');
if (button) button.disabled = true;
// Paint the acknowledgement before Safari hands control to the AMap URL scheme.
navigationLaunchTimer = window.setTimeout(() => window.location.assign(url), 140);
window.setTimeout(resetNavigationLaunch, 4000);
} }
function showEntityDetails(mode, entity) { function showEntityDetails(mode, entity) {
@@ -1423,11 +1296,11 @@ function showEntityDetails(mode, entity) {
detailField(dict.detailAddress, detailValue(entity.address), true), detailField(dict.detailAddress, detailValue(entity.address), true),
detailField(dict.detailCoordinate, `${formatNumber(entity.longitude, 6)}, ${formatNumber(entity.latitude, 6)}`, true, true) detailField(dict.detailCoordinate, `${formatNumber(entity.longitude, 6)}, ${formatNumber(entity.latitude, 6)}`, true, true)
]; ];
const operationalFields = operationalAccessGranted() ? [ const hydrogenFields = [
detailField(dict.detailMonthlyHydrogen, entity.monthlyHydrogenKg == null ? dict.valueUnavailable : `${formatNumber(entity.monthlyHydrogenKg, 1)} kg`), detailField(dict.detailMonthlyHydrogen, entity.monthlyHydrogenKg == null ? dict.valueUnavailable : `${formatNumber(entity.monthlyHydrogenKg, 1)} kg`),
detailField(dict.detailTotalHydrogen, entity.totalHydrogenKg == null ? dict.valueUnavailable : `${formatNumber(entity.totalHydrogenKg, 1)} kg`, false, true), detailField(dict.detailTotalHydrogen, entity.totalHydrogenKg == null ? dict.valueUnavailable : `${formatNumber(entity.totalHydrogenKg, 1)} kg`, false, true),
] : []; ];
grid.innerHTML = [...publicFields, ...operationalFields].join(''); grid.innerHTML = [...publicFields, ...hydrogenFields].join('');
} else { } else {
title.textContent = entity.plateNumber || entity.vin || dict.detailVehicle; title.textContent = entity.plateNumber || entity.vin || dict.detailVehicle;
subtitle.textContent = entity.vin || ''; subtitle.textContent = entity.vin || '';
@@ -1615,25 +1488,17 @@ function updateRankingControls() {
document.getElementById('rankPrimaryTab').textContent = currentMode === 'vehicle' ? dict.rankByFleet : dict.rankByStations; document.getElementById('rankPrimaryTab').textContent = currentMode === 'vehicle' ? dict.rankByFleet : dict.rankByStations;
document.getElementById('rankSecondaryTab').textContent = currentMode === 'vehicle' document.getElementById('rankSecondaryTab').textContent = currentMode === 'vehicle'
? dict.rankByDist ? dict.rankByDist
: operationalAccessGranted() ? dict.rankByHydrogen : dict.authHydrogenRequired; : dict.rankByHydrogen;
document.querySelectorAll('.gtab').forEach(button => button.classList.toggle('active', button.id === (currentRankType === 'primary' ? 'rankPrimaryTab' : 'rankSecondaryTab'))); document.querySelectorAll('.gtab').forEach(button => button.classList.toggle('active', button.id === (currentRankType === 'primary' ? 'rankPrimaryTab' : 'rankSecondaryTab')));
} }
function switchRankTab(type) { function switchRankTab(type) {
if (currentMode === 'station' && type === 'secondary' && !operationalAccessGranted()) {
openAccessDialog('station', i18n[currentLang].authHydrogenRequired);
return;
}
currentRankType = type; currentRankType = type;
updateRankingControls(); updateRankingControls();
renderRankingList(type); renderRankingList(type);
} }
function switchMode(mode, { skipAuthCheck = false } = {}) { function switchMode(mode) {
if (mode === 'vehicle' && !skipAuthCheck && !operationalAccessGranted()) {
openAccessDialog('vehicle', i18n[currentLang].authRequired);
return;
}
currentMode = mode; currentMode = mode;
filterState.province = ''; filterState.city = ''; filterState.district = ''; filterState.query = ''; filterState.province = ''; filterState.city = ''; filterState.district = ''; filterState.query = '';
closeExploreSuggestions(); closeExploreSuggestions();
@@ -1672,7 +1537,6 @@ function setLanguage(lang) {
} }
void syncFilterControls(dashboard); void syncFilterControls(dashboard);
updateLocateButton(userLocation ? 'active' : 'idle'); updateLocateButton(userLocation ? 'active' : 'idle');
updateAccessUI();
updateDashboardUI(); updateDashboardUI();
if (selectedEntity?.entity) showEntityDetails(selectedEntity.mode, selectedEntity.entity); if (selectedEntity?.entity) showEntityDetails(selectedEntity.mode, selectedEntity.entity);
} }
+1 -1
View File
@@ -40,7 +40,7 @@ systemctl restart "$service"
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
if systemctl is-active --quiet "$service" && curl -fsS "$base_url/api/health" >/dev/null; then 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/" | grep -q 'id="dashboardTitle"'
curl -fsS "$base_url/api/public/stations" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["status"]=="ok" and d["summary"]["totalStations"]>400' 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" printf 'vehicle_map_release_install=ok release=%s\n' "$release_id"
exit 0 exit 0
fi fi
+10 -22
View File
@@ -95,8 +95,6 @@
</button> </button>
</div> </div>
<button class="access-trigger" id="accessTrigger" type="button" onclick="toggleOperationsAccess()" data-i18n="authEntry">运营授权</button>
<div class="time-widget" id="clockTime">19:04:00</div> <div class="time-widget" id="clockTime">19:04:00</div>
</div> </div>
</header> </header>
@@ -144,6 +142,16 @@
<div class="amap-canvas-box"> <div class="amap-canvas-box">
<div id="amapContainer"></div> <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> <article class="map-detail-card" id="mapDetailCard" aria-live="polite" hidden>
<div class="detail-card-accent" id="detailCardAccent"></div> <div class="detail-card-accent" id="detailCardAccent"></div>
<div class="detail-card-header"> <div class="detail-card-header">
@@ -235,26 +243,6 @@
</div> </div>
<dialog class="access-dialog" id="accessDialog" aria-labelledby="accessDialogTitle">
<form class="access-dialog-card" method="dialog" onsubmit="event.preventDefault(); authorizeOperationsAccess();">
<div class="access-dialog-icon" aria-hidden="true"></div>
<div class="access-dialog-heading">
<h2 id="accessDialogTitle" data-i18n="authTitle">进入运营数据</h2>
<p id="accessDialogHint" data-i18n="authDescription">车辆全量信息与加氢量仅向获得授权的用户展示。</p>
</div>
<p class="access-session-note" data-i18n="authExpiry">本次授权有效期为 30 分钟,到期后将自动恢复为公开站点视图。</p>
<label class="access-code-field" for="accessCodeInput">
<span data-i18n="authCodeLabel">访问码</span>
<input id="accessCodeInput" type="password" autocomplete="current-password" enterkeyhint="done" data-i18n-placeholder="authCodePlaceholder" placeholder="输入访问码">
</label>
<p class="access-dialog-error" id="accessDialogError" role="alert" hidden></p>
<div class="access-dialog-actions">
<button type="button" class="access-cancel-btn" onclick="closeAccessDialog()" data-i18n="authCancel">暂不授权</button>
<button type="submit" class="access-submit-btn" id="accessSubmitBtn" data-i18n="authSubmit">验证并进入</button>
</div>
</form>
</dialog>
<!-- 生产发布包不携带 node_modules;固定版本的 CDN 让中文地点保持拼音/首字母搜索能力。 --> <!-- 生产发布包不携带 node_modules;固定版本的 CDN 让中文地点保持拼音/首字母搜索能力。 -->
<script src="https://cdn.jsdelivr.net/npm/pinyin-pro@3.28.2/dist/index.js" crossorigin="anonymous"></script> <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> <script src="app.js?v=__ASSET_VERSION__"></script>
+2 -218
View File
@@ -1,10 +1,7 @@
"""Vehicle Map static server and server-side proxy for the vehicle open platform.""" """Vehicle Map static server and server-side proxy for the vehicle open platform."""
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
import base64
import binascii
import hashlib import hashlib
import hmac
from http.server import HTTPServer, SimpleHTTPRequestHandler from http.server import HTTPServer, SimpleHTTPRequestHandler
import json import json
import os import os
@@ -36,134 +33,10 @@ OPEN_PLATFORM_APP_KEY = os.getenv("OPEN_PLATFORM_APP_KEY", "").strip()
UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "15")) UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "15"))
DASHBOARD_CACHE_SECONDS = int(os.getenv("DASHBOARD_CACHE_SECONDS", "5")) DASHBOARD_CACHE_SECONDS = int(os.getenv("DASHBOARD_CACHE_SECONDS", "5"))
STATION_CACHE_SECONDS = int(os.getenv("STATION_CACHE_SECONDS", "3600")) STATION_CACHE_SECONDS = int(os.getenv("STATION_CACHE_SECONDS", "3600"))
AUTH_PROVIDER = os.getenv("VEHICLE_MAP_AUTH_PROVIDER", "local").strip().lower()
LOCAL_ACCESS_CODE = os.getenv("VEHICLE_MAP_ACCESS_CODE", "").strip()
SESSION_SECRET = os.getenv("VEHICLE_MAP_SESSION_SECRET", "").encode("utf-8")
SESSION_TTL_SECONDS = int(os.getenv("VEHICLE_MAP_SESSION_TTL_SECONDS", "1800"))
COOKIE_SECURE = os.getenv("VEHICLE_MAP_COOKIE_SECURE", "true").strip().lower() not in {"0", "false", "no"}
AUTH_CENTER_INTROSPECTION_URL = os.getenv("VEHICLE_MAP_AUTH_CENTER_INTROSPECTION_URL", "").strip()
AUTH_CENTER_CLIENT_TOKEN = os.getenv("VEHICLE_MAP_AUTH_CENTER_CLIENT_TOKEN", "").strip()
OPERATIONS_READ_SCOPE = "operations:read"
SESSION_COOKIE_NAME = "ln_map_session"
_cache_lock = threading.Lock() _cache_lock = threading.Lock()
_cache = {} _cache = {}
class AuthenticationError(RuntimeError):
"""The request does not carry a valid operations-read session."""
class AuthenticationUnavailable(RuntimeError):
"""The selected authentication provider is not configured."""
class AccessAuthorizer:
"""Small provider boundary: replace this class when the auth center contract is ready."""
def authorize_access_code(self, access_code):
raise NotImplementedError
class LocalAccessCodeAuthorizer(AccessAuthorizer):
def authorize_access_code(self, access_code):
if not LOCAL_ACCESS_CODE or not SESSION_SECRET:
raise AuthenticationUnavailable("local access-code authentication is not configured")
if not hmac.compare_digest(str(access_code or ""), LOCAL_ACCESS_CODE):
raise AuthenticationError("invalid access code")
return {"subject": "local-access-code", "scopes": [OPERATIONS_READ_SCOPE]}
class AuthCenterAccessCodeAuthorizer(AccessAuthorizer):
"""Adapter for an auth-center access-code introspection endpoint.
The endpoint contract is deliberately small: it receives accessCode and audience,
and returns {active, subject, scopes}. No frontend change is needed when enabled.
"""
def authorize_access_code(self, access_code):
if not AUTH_CENTER_INTROSPECTION_URL:
raise AuthenticationUnavailable("auth-center introspection URL is not configured")
headers = {"Content-Type": "application/json", "Accept": "application/json"}
if AUTH_CENTER_CLIENT_TOKEN:
headers["Authorization"] = "Bearer " + AUTH_CENTER_CLIENT_TOKEN
request = Request(
AUTH_CENTER_INTROSPECTION_URL,
data=_json_bytes({"accessCode": str(access_code or ""), "audience": "vehicle-map"}),
method="POST",
headers=headers,
)
try:
with urlopen(request, timeout=UPSTREAM_TIMEOUT_SECONDS) as response:
payload = json.load(response)
except (HTTPError, URLError, ValueError) as exc:
raise AuthenticationUnavailable("auth-center is unavailable") from exc
scopes = payload.get("scopes") or []
if not payload.get("active") or OPERATIONS_READ_SCOPE not in scopes:
raise AuthenticationError("access code does not grant operations read")
return {"subject": str(payload.get("subject") or "auth-center"), "scopes": scopes}
def _authorizer():
if AUTH_PROVIDER == "local":
return LocalAccessCodeAuthorizer()
if AUTH_PROVIDER in {"auth-center", "auth_center"}:
return AuthCenterAccessCodeAuthorizer()
raise AuthenticationUnavailable("unknown authentication provider")
def _b64encode(value):
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
def _b64decode(value):
padded = str(value) + "=" * (-len(str(value)) % 4)
return base64.urlsafe_b64decode(padded.encode("ascii"))
def _create_session(principal):
if not SESSION_SECRET:
raise AuthenticationUnavailable("session signing secret is not configured")
payload = {
# Incrementing the session format invalidates earlier long-lived sessions.
"v": 2,
"sub": principal["subject"],
"scopes": principal.get("scopes") or [],
"exp": int(time.time()) + max(300, SESSION_TTL_SECONDS),
}
encoded = _b64encode(_json_bytes(payload))
signature = _b64encode(hmac.new(SESSION_SECRET, encoded.encode("ascii"), hashlib.sha256).digest())
return encoded + "." + signature
def _read_session(cookie_header):
cookies = {}
for item in str(cookie_header or "").split(";"):
if "=" in item:
key, value = item.strip().split("=", 1)
cookies[key] = value
token = cookies.get(SESSION_COOKIE_NAME, "")
try:
encoded, signature = token.split(".", 1)
expected = _b64encode(hmac.new(SESSION_SECRET, encoded.encode("ascii"), hashlib.sha256).digest())
if not SESSION_SECRET or not hmac.compare_digest(signature, expected):
return None
payload = json.loads(_b64decode(encoded))
if payload.get("v") != 2 or int(payload.get("exp", 0)) <= int(time.time()):
return None
if OPERATIONS_READ_SCOPE not in (payload.get("scopes") or []):
return None
return {"subject": str(payload.get("sub") or ""), "scopes": payload["scopes"]}
except (ValueError, TypeError, UnicodeDecodeError, binascii.Error, json.JSONDecodeError):
return None
def _session_cookie(value, max_age):
secure = "; Secure" if COOKIE_SECURE else ""
return f"{SESSION_COOKIE_NAME}={value}; Path=/; Max-Age={max_age}; HttpOnly; SameSite=Lax{secure}"
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True daemon_threads = True
allow_reuse_address = True allow_reuse_address = True
@@ -220,29 +93,6 @@ def _load_stations():
) )
def _public_station(station):
"""Public station directory contract. Keep this a positive field allowlist."""
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_public_stations():
stations = _load_stations()
return {
"status": "ok",
"access": {"level": "public", "stations": "directory", "vehicles": "restricted", "hydrogen": "restricted"},
"asOf": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
"summary": {
"totalStations": len(stations),
"cooperativeStations": sum(1 for station in stations if station.get("cooperative")),
},
"stations": [_public_station(station) for station in stations],
}
def _load_dashboard(): def _load_dashboard():
today = time.strftime("%Y-%m-%d", time.localtime()) today = time.strftime("%Y-%m-%d", time.localtime())
with ThreadPoolExecutor(max_workers=3) as executor: with ThreadPoolExecutor(max_workers=3) as executor:
@@ -262,7 +112,7 @@ def _load_dashboard():
for row in mileage_rows for row in mileage_rows
if row.get("vin") and row.get("status") == "NORMAL" if row.get("vin") and row.get("status") == "NORMAL"
} }
# The operations card is a daily activity view: every vehicle that reported # The daily activity view places every vehicle that reported
# today is placed in exactly one bucket using its latest selected speed. # today is placed in exactly one bucket using its latest selected speed.
active_vehicles = [item for item in vehicles if item.get("activeToday")] 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) driving = sum(1 for item in active_vehicles if float(item.get("speedKmh") or 0) > 3)
@@ -312,32 +162,9 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
"status": "ok", "status": "ok",
"service": "vehicle-map", "service": "vehicle-map",
"openPlatformConfigured": bool(OPEN_PLATFORM_APP_KEY), "openPlatformConfigured": bool(OPEN_PLATFORM_APP_KEY),
"authProvider": AUTH_PROVIDER,
})
return
if path == "/api/public/stations":
try:
payload = _cached("public-stations", DASHBOARD_CACHE_SECONDS, _load_public_stations)
self._write_json(200, payload)
except Exception as exc: # keep upstream details server-side only
self.log_error("public station refresh failed: %s", exc)
self._write_json(502, {
"status": "error",
"code": "UPSTREAM_UNAVAILABLE",
"message": "加氢站目录暂时不可用,请稍后重试",
})
return
if path == "/api/session":
principal = _read_session(self.headers.get("Cookie"))
self._write_json(200, {
"status": "ok",
"authorized": bool(principal),
"principal": principal,
}) })
return return
if path == "/api/dashboard": if path == "/api/dashboard":
if not self._require_operations_access():
return
try: try:
payload = _cached("dashboard", DASHBOARD_CACHE_SECONDS, _load_dashboard) payload = _cached("dashboard", DASHBOARD_CACHE_SECONDS, _load_dashboard)
self._write_json(200, payload) self._write_json(200, payload)
@@ -346,54 +173,11 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
self._write_json(502, { self._write_json(502, {
"status": "error", "status": "error",
"code": "UPSTREAM_UNAVAILABLE", "code": "UPSTREAM_UNAVAILABLE",
"message": "运营数据暂时不可用,请稍后重试", "message": "地图数据暂时不可用,请稍后重试",
}) })
return return
super().do_GET() super().do_GET()
def do_POST(self):
path = urlparse(self.path).path
if path != "/api/session":
self._write_json(404, {"status": "error", "code": "NOT_FOUND"})
return
try:
content_length = int(self.headers.get("Content-Length", "0"))
if content_length < 1 or content_length > 4096:
raise ValueError("invalid request length")
payload = json.loads(self.rfile.read(content_length))
principal = _authorizer().authorize_access_code(payload.get("accessCode"))
session = _create_session(principal)
except AuthenticationError:
self._write_json(401, {"status": "error", "code": "ACCESS_DENIED", "message": "访问码无效"})
return
except AuthenticationUnavailable:
self._write_json(503, {"status": "error", "code": "AUTH_UNAVAILABLE", "message": "授权服务暂不可用"})
return
except (ValueError, TypeError, json.JSONDecodeError):
self._write_json(400, {"status": "error", "code": "INVALID_REQUEST", "message": "访问码格式不正确"})
return
self._write_json(200, {
"status": "ok",
"authorized": True,
"principal": principal,
}, headers={"Set-Cookie": _session_cookie(session, max(300, SESSION_TTL_SECONDS))})
def do_DELETE(self):
if urlparse(self.path).path != "/api/session":
self._write_json(404, {"status": "error", "code": "NOT_FOUND"})
return
self._write_json(200, {"status": "ok", "authorized": False}, headers={"Set-Cookie": _session_cookie("", 0)})
def _require_operations_access(self):
if _read_session(self.headers.get("Cookie")):
return True
self._write_json(401, {
"status": "error",
"code": "AUTH_REQUIRED",
"message": "运营数据需要授权访问",
})
return False
def _serve_index(self): def _serve_index(self):
try: try:
template = (ROOT / "index.html").read_text(encoding="utf-8") template = (ROOT / "index.html").read_text(encoding="utf-8")
+61 -80
View File
@@ -290,80 +290,6 @@ body {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
} }
.segment-btn.is-locked::after {
content: '⌁';
margin-left: 1px;
color: var(--text-sub);
font-size: 10px;
}
.access-trigger {
min-height: 28px;
padding: 0 10px;
border: 1px solid rgba(0, 113, 67, 0.18);
border-radius: 8px;
background: rgba(0, 113, 67, 0.07);
color: var(--accent-primary);
font-size: 10px;
font-weight: 700;
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease;
}
.access-trigger:hover { border-color: rgba(0, 113, 67, 0.34); background: rgba(0, 113, 67, 0.12); transform: translateY(-1px); }
.access-trigger.is-authorized { border-color: rgba(2, 132, 199, 0.25); background: rgba(2, 132, 199, 0.09); color: var(--accent-blue); }
.access-dialog {
position: fixed;
inset: 0;
margin: auto;
width: min(390px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
padding: 0;
border: 0;
border-radius: 20px;
background: transparent;
color: var(--text-main);
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.3);
}
.access-dialog::backdrop { background: rgba(15, 23, 42, 0.42); backdrop-filter: blur(5px); }
.access-dialog-card {
display: grid;
gap: 16px;
padding: 22px;
border: 1px solid var(--glass-border);
border-radius: inherit;
background: color-mix(in srgb, var(--glass-bg) 98%, white);
}
.access-dialog-icon {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border-radius: 12px;
background: rgba(0, 113, 67, 0.1);
color: var(--accent-primary);
font-size: 21px;
font-weight: 800;
}
.access-dialog-heading h2 { margin: 0; font-size: 18px; letter-spacing: -0.02em; }
.access-dialog-heading p { margin: 6px 0 0; color: var(--text-muted); font-size: 12px; line-height: 1.6; }
.access-session-note { margin: -7px 0 0; padding: 9px 10px; border-radius: 10px; background: rgba(2, 132, 199, 0.08); color: #0369a1; font-size: 11px; line-height: 1.55; }
.access-code-field { display: grid; gap: 7px; color: var(--text-muted); font-size: 11px; font-weight: 700; }
.access-code-field input { width: 100%; height: 42px; padding: 0 12px; border: 1px solid var(--glass-border); border-radius: 10px; outline: 0; background: var(--pill-bg); color: var(--text-main); font: 13px/1.2 'JetBrains Mono', monospace; box-sizing: border-box; }
.access-code-field input:focus { border-color: rgba(2, 132, 199, 0.5); box-shadow: 0 0 0 3px rgba(2, 132, 199, 0.1); }
.access-dialog-error { margin: -6px 0 0; color: #dc2626; font-size: 11px; }
.access-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
.access-cancel-btn, .access-submit-btn { min-height: 36px; padding: 0 13px; border-radius: 9px; font-size: 12px; font-weight: 700; cursor: pointer; }
.access-cancel-btn { border: 1px solid var(--glass-border); background: transparent; color: var(--text-muted); }
.access-submit-btn { border: 1px solid var(--accent-primary); background: var(--accent-primary); color: white; }
.access-submit-btn:disabled { opacity: 0.58; cursor: wait; }
.bw-svg { .bw-svg {
stroke: currentColor; stroke: currentColor;
} }
@@ -795,6 +721,55 @@ body {
height: 100%; height: 100%;
} }
.navigation-launch-overlay {
position: absolute;
z-index: 28;
inset: 0;
display: grid;
place-items: center;
padding: 22px;
background: rgba(15, 23, 42, 0.14);
backdrop-filter: blur(1.5px);
-webkit-backdrop-filter: blur(1.5px);
opacity: 0;
pointer-events: none;
transition: opacity 0.18s ease;
}
.navigation-launch-overlay[hidden] { display: none; }
.navigation-launch-overlay.is-visible { opacity: 1; pointer-events: auto; }
.navigation-launch-panel {
display: flex;
align-items: center;
gap: 11px;
max-width: min(280px, calc(100vw - 48px));
padding: 13px 15px;
border: 1px solid rgba(255, 255, 255, 0.64);
border-radius: 16px;
background: color-mix(in srgb, var(--glass-bg) 94%, white);
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.2);
transform: translateY(8px) scale(0.97);
transition: transform 0.22s cubic-bezier(.2, .75, .25, 1);
}
.navigation-launch-overlay.is-visible .navigation-launch-panel { transform: translateY(0) scale(1); }
.navigation-launch-panel strong, .navigation-launch-panel span { display: block; }
.navigation-launch-panel strong { color: var(--text-main); font-size: 12px; line-height: 1.35; }
.navigation-launch-panel div > span { margin-top: 2px; color: var(--text-muted); font-size: 10px; line-height: 1.45; }
.navigation-launch-spinner {
width: 20px;
height: 20px;
flex: 0 0 20px;
border: 2px solid rgba(0, 113, 67, 0.18);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: navigation-spin 0.78s linear infinite;
}
@keyframes navigation-spin { to { transform: rotate(360deg); } }
.map-detail-card { .map-detail-card {
position: absolute; position: absolute;
left: 14px; left: 14px;
@@ -910,6 +885,17 @@ body {
.detail-navigate-btn:hover { transform: translateY(-1px); border-color: rgba(0, 113, 67, 0.34); background: rgba(0, 113, 67, 0.15); } .detail-navigate-btn:hover { transform: translateY(-1px); border-color: rgba(0, 113, 67, 0.34); background: rgba(0, 113, 67, 0.15); }
.detail-navigate-btn:disabled { cursor: default; opacity: 0.45; } .detail-navigate-btn:disabled { cursor: default; opacity: 0.45; }
.detail-navigate-btn svg { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.9; } .detail-navigate-btn svg { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.9; }
.detail-navigate-btn.is-launching { transform: scale(0.92); background: rgba(0, 113, 67, 0.17); }
.detail-navigate-btn.is-launching svg { animation: navigation-pulse 0.7s ease-in-out infinite alternate; }
@keyframes navigation-pulse { to { transform: scale(1.15); } }
@media (prefers-reduced-motion: reduce) {
.navigation-launch-overlay,
.navigation-launch-panel { transition: none; }
.navigation-launch-spinner,
.detail-navigate-btn.is-launching svg { animation: none; }
}
.detail-grid { .detail-grid {
display: grid; display: grid;
@@ -1462,16 +1448,11 @@ body {
} }
.segment-btn, .segment-btn,
.bw-btn, .bw-btn {
.access-trigger {
padding: 4px 8px; padding: 4px 8px;
font-size: 10px; font-size: 10px;
} }
.access-trigger { min-height: 26px; }
.access-dialog-card { padding: 18px; }
.time-widget { .time-widget {
display: none; display: none;
} }
+18
View File
@@ -138,6 +138,22 @@ assert.ok(stationNavigationUrl.startsWith('https://uri.amap.com/navigation?to=11
assert.match(stationNavigationUrl, /%E5%B9%BF%E5%B7%9E/); assert.match(stationNavigationUrl, /%E5%B9%BF%E5%B7%9E/);
assert.match(stationNavigationUrl, /coordinate=gaode&callnative=1$/); assert.match(stationNavigationUrl, /coordinate=gaode&callnative=1$/);
assert.equal(navigationUrlForEntity('vehicle', { plateNumber: '无位置车辆' }), null); 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(); const stationView = stationViewportSummary();
assert.equal(stationView.level, 'station'); assert.equal(stationView.level, 'station');
assert.equal(stationView.visibleNodes.length, 2); assert.equal(stationView.visibleNodes.length, 2);
@@ -170,5 +186,7 @@ const sandbox = {
setInterval() {}, setInterval() {},
clearInterval() {} clearInterval() {}
}; };
assert.match(source, /navigationLaunchOverlay/);
assert.match(source, /正在打开高德地图/);
vm.runInNewContext(`${source}\n${checks}`, sandbox, { filename: 'app.js' }); vm.runInNewContext(`${source}\n${checks}`, sandbox, { filename: 'app.js' });
console.log('vehicle hierarchy aggregation tests: ok'); console.log('vehicle hierarchy aggregation tests: ok');
+1 -49
View File
@@ -1,9 +1,5 @@
import importlib.util import importlib.util
import hashlib
import hmac
import json
from pathlib import Path from pathlib import Path
import time
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
@@ -14,7 +10,7 @@ SPEC.loader.exec_module(server)
class DashboardTest(unittest.TestCase): class DashboardTest(unittest.TestCase):
def test_dashboard_aggregates_authorized_vehicle_and_station_data(self): def test_dashboard_aggregates_public_vehicle_and_station_data(self):
def fake_post(path, body): def fake_post(path, body):
if path.endswith("realtime/query"): if path.endswith("realtime/query"):
return [ return [
@@ -45,55 +41,11 @@ class DashboardTest(unittest.TestCase):
self.assertEqual(result["vehicles"][0]["dailyMileageKm"], 12.345) self.assertEqual(result["vehicles"][0]["dailyMileageKm"], 12.345)
self.assertEqual(result["vehicles"][2]["dailyMileageKm"], 0) self.assertEqual(result["vehicles"][2]["dailyMileageKm"], 0)
def test_public_station_directory_uses_an_allowlist(self):
stations = [{
"id": "GD-1", "name": "广州合作站", "shortName": "合作站", "province": "广东省",
"city": "广州市", "district": "黄埔区", "address": "开源大道1号", "longitude": 113.2,
"latitude": 23.1, "cooperative": True, "monthlyHydrogenKg": 88.8,
"totalHydrogenKg": 999.9, "internalOwner": "must-not-leak",
}]
with patch.object(server, "_post_open_platform", return_value=stations):
with patch.object(server, "_cache", {}):
result = server._load_public_stations()
station = result["stations"][0]
self.assertEqual(result["access"]["level"], "public")
self.assertEqual(station["name"], "广州合作站")
self.assertTrue(station["cooperative"])
self.assertNotIn("monthlyHydrogenKg", station)
self.assertNotIn("totalHydrogenKg", station)
self.assertNotIn("internalOwner", station)
def test_static_asset_version_is_a_content_fingerprint(self): def test_static_asset_version_is_a_content_fingerprint(self):
self.assertEqual(len(server.STATIC_ASSET_VERSION), 16) self.assertEqual(len(server.STATIC_ASSET_VERSION), 16)
self.assertRegex(server.STATIC_ASSET_VERSION, r"^[0-9a-f]{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") index_template = (Path(__file__).parents[1] / "index.html").read_text(encoding="utf-8")
self.assertEqual(index_template.count("__ASSET_VERSION__"), 2) self.assertEqual(index_template.count("__ASSET_VERSION__"), 2)
def test_signed_operations_session_requires_the_operations_scope(self):
with patch.object(server, "LOCAL_ACCESS_CODE", "LN-map-test-code"), patch.object(server, "SESSION_SECRET", b"test-session-secret"), patch.object(server, "SESSION_TTL_SECONDS", 600):
principal = server._authorizer().authorize_access_code("LN-map-test-code")
token = server._create_session(principal)
session = server._read_session(f"{server.SESSION_COOKIE_NAME}={token}")
self.assertEqual(session["subject"], "local-access-code")
self.assertIn(server.OPERATIONS_READ_SCOPE, session["scopes"])
self.assertIsNone(server._read_session(f"{server.SESSION_COOKIE_NAME}={token}tampered"))
legacy_payload = {"v": 1, "sub": "legacy", "scopes": [server.OPERATIONS_READ_SCOPE], "exp": 4_102_444_800}
legacy_encoded = server._b64encode(server._json_bytes(legacy_payload))
legacy_signature = server._b64encode(hmac.new(server.SESSION_SECRET, legacy_encoded.encode("ascii"), hashlib.sha256).digest())
self.assertIsNone(server._read_session(f"{server.SESSION_COOKIE_NAME}={legacy_encoded}.{legacy_signature}"))
def test_default_operations_session_expires_after_thirty_minutes(self):
self.assertEqual(server.SESSION_TTL_SECONDS, 1800)
with patch.object(server, "SESSION_SECRET", b"test-session-secret"), patch.object(server, "SESSION_TTL_SECONDS", 1800):
token = server._create_session({"subject": "test", "scopes": [server.OPERATIONS_READ_SCOPE]})
encoded, _ = token.split(".", 1)
payload = json.loads(server._b64decode(encoded))
self.assertGreaterEqual(payload["exp"], int(time.time()) + 1798)
self.assertLessEqual(payload["exp"], int(time.time()) + 1801)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()