feat(map): add protected operations access
This commit is contained in:
@@ -5,3 +5,15 @@ 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=
|
||||||
|
|||||||
@@ -19,6 +19,28 @@ python3 server.py
|
|||||||
|
|
||||||
默认地址:`http://127.0.0.1:20800`。
|
默认地址:`http://127.0.0.1:20800`。
|
||||||
|
|
||||||
|
请通过上述 HTTP 地址访问,不能直接双击打开 `index.html`:页面需要同源调用本服务的公开站点与授权 API。
|
||||||
|
|
||||||
|
## 公开与授权数据边界
|
||||||
|
|
||||||
|
- `GET /api/public/stations` 无需授权,仅返回站点名称、坐标、省市区、地址与合作状态;加氢量字段不在响应中。
|
||||||
|
- `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`。
|
||||||
|
|
||||||
## 接口依赖
|
## 接口依赖
|
||||||
|
|
||||||
- `POST /api/v1/vehicles/realtime/query`:全部授权车辆实时位置与状态;
|
- `POST /api/v1/vehicles/realtime/query`:全部授权车辆实时位置与状态;
|
||||||
|
|||||||
+263
-20
@@ -22,11 +22,15 @@ const i18n = {
|
|||||||
filterResult: '筛选', searchVehicle: '搜索车牌 / VIN', searchStation: '搜索站点名称 / 地址', nearbyVehicles: '附近车辆', nearbyStations: '附近加氢站',
|
filterResult: '筛选', searchVehicle: '搜索车牌 / VIN', searchStation: '搜索站点名称 / 地址', nearbyVehicles: '附近车辆', nearbyStations: '附近加氢站',
|
||||||
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: '累计加氢量',
|
detailStationType: '站点类型', detailAdmin: '行政区域', detailAddress: '详细地址', detailMonthlyHydrogen: '本月加氢量', detailTotalHydrogen: '累计加氢量', detailNavigate: '导航',
|
||||||
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',
|
||||||
@@ -49,11 +53,15 @@ const i18n = {
|
|||||||
filterResult: 'Filtered', searchVehicle: 'Search plate / VIN', searchStation: 'Search station / address', nearbyVehicles: 'Nearby vehicles', nearbyStations: 'Nearby H₂ stations',
|
filterResult: 'Filtered', searchVehicle: 'Search plate / VIN', searchStation: 'Search station / address', nearbyVehicles: 'Nearby vehicles', nearbyStations: 'Nearby H₂ stations',
|
||||||
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',
|
detailStationType: 'Station type', detailAdmin: 'Region', detailAddress: 'Address', detailMonthlyHydrogen: 'Monthly hydrogen', detailTotalHydrogen: 'Total hydrogen', detailNavigate: 'Navigate',
|
||||||
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'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,7 +96,7 @@ let map = null;
|
|||||||
let markerList = [];
|
let markerList = [];
|
||||||
let infoWindowList = [];
|
let infoWindowList = [];
|
||||||
let is3DPitch = true;
|
let is3DPitch = true;
|
||||||
let currentMode = 'vehicle';
|
let currentMode = 'station';
|
||||||
let currentTheme = 'theme-white';
|
let currentTheme = 'theme-white';
|
||||||
let currentLang = 'zh';
|
let currentLang = 'zh';
|
||||||
let currentRankType = 'primary';
|
let currentRankType = 'primary';
|
||||||
@@ -109,6 +117,7 @@ let exploreSuggestionItems = [];
|
|||||||
let activeExploreSuggestionIndex = -1;
|
let activeExploreSuggestionIndex = -1;
|
||||||
let detailExpanded = false;
|
let detailExpanded = false;
|
||||||
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(['北京', '天津', '上海', '重庆']);
|
||||||
@@ -116,27 +125,75 @@ const MUNICIPALITIES = new Set(['北京', '天津', '上海', '重庆']);
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initClock();
|
initClock();
|
||||||
initAMapInstance();
|
initAMapInstance();
|
||||||
loadDashboard();
|
initializeDataAccess();
|
||||||
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 loadDashboard() {
|
async function initializeDataAccess() {
|
||||||
|
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);
|
||||||
updateDashboardUI();
|
updateDashboardUI();
|
||||||
refreshVehicleRegionNodes(payload);
|
if (currentMode === 'vehicle') refreshVehicleRegionNodes(payload);
|
||||||
|
else refreshStationViewport();
|
||||||
setDataState('ready');
|
setDataState('ready');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('dashboard refresh failed', error);
|
console.error('operational dashboard refresh failed', error);
|
||||||
setDataState('error');
|
setDataState('error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,6 +205,97 @@ 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();
|
||||||
}
|
}
|
||||||
@@ -181,6 +329,10 @@ function selectedVehicleFilterGroup() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasPinnedVehicleDistrictFilter() {
|
||||||
|
return currentMode === 'vehicle' && Boolean(filterState.district) && !normalizeSearch(filterState.query);
|
||||||
|
}
|
||||||
|
|
||||||
function filteredVehicles(vehicles = dashboard?.vehicles || []) {
|
function filteredVehicles(vehicles = dashboard?.vehicles || []) {
|
||||||
const group = selectedVehicleFilterGroup();
|
const group = selectedVehicleFilterGroup();
|
||||||
const allowedVins = group ? new Set(group.vehicles.map(vehicle => vehicle.vin)) : null;
|
const allowedVins = group ? new Set(group.vehicles.map(vehicle => vehicle.vin)) : null;
|
||||||
@@ -572,10 +724,40 @@ function focusCoordinates(items, zoom) {
|
|||||||
map.setZoomAndCenter(zoom, [center[0] / located.length, center[1] / located.length]);
|
map.setZoomAndCenter(zoom, [center[0] / located.length, center[1] / located.length]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function focusVehicleSet(vehicles) {
|
||||||
|
const points = locatedVehicles(vehicles).map(vehicle => wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude)));
|
||||||
|
if (!map || !points.length) return;
|
||||||
|
if (points.length === 1) {
|
||||||
|
map.setZoomAndCenter(14, points[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const longitudes = points.map(point => point[0]);
|
||||||
|
const latitudes = points.map(point => point[1]);
|
||||||
|
const minLng = Math.min(...longitudes); const maxLng = Math.max(...longitudes);
|
||||||
|
const minLat = Math.min(...latitudes); const maxLat = Math.max(...latitudes);
|
||||||
|
const center = [(minLng + maxLng) / 2, (minLat + maxLat) / 2];
|
||||||
|
const latitudeScale = Math.cos(center[1] * Math.PI / 180);
|
||||||
|
const spanMeters = Math.max((maxLat - minLat) * 111320, (maxLng - minLng) * 111320 * latitudeScale, 400);
|
||||||
|
const size = map.getSize?.();
|
||||||
|
const width = Number(size?.getWidth?.()) || 900;
|
||||||
|
const height = Number(size?.getHeight?.()) || 560;
|
||||||
|
// Reserve room for the toolbar, detail card and map controls on every viewport.
|
||||||
|
const usablePixels = Math.max(180, Math.min(width * 0.72, height * 0.68));
|
||||||
|
const metersPerPixel = spanMeters / usablePixels;
|
||||||
|
const zoom = Math.max(7, Math.min(14, Math.log2((156543.03392 * latitudeScale) / metersPerPixel)));
|
||||||
|
map.setZoomAndCenter(zoom, center);
|
||||||
|
}
|
||||||
|
|
||||||
function focusCurrentRegion(level) {
|
function focusCurrentRegion(level) {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
if (currentMode === 'vehicle') {
|
if (currentMode === 'vehicle') {
|
||||||
const group = selectedVehicleFilterGroup();
|
const group = selectedVehicleFilterGroup();
|
||||||
|
if (level === 'district' && hasPinnedVehicleDistrictFilter() && locatedVehicles(filteredVehicles()).length) {
|
||||||
|
// District borders can be elongated. Fit the actual filtered fleet rather
|
||||||
|
// than centering on the administrative centroid, which can hide a valid car.
|
||||||
|
focusVehicleSet(filteredVehicles());
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (group?.lnglat) map.setZoomAndCenter(level === 'province' ? 7.4 : level === 'city' ? 10 : 12.5, group.lnglat);
|
if (group?.lnglat) map.setZoomAndCenter(level === 'province' ? 7.4 : level === 'city' ? 10 : 12.5, group.lnglat);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -682,10 +864,12 @@ 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' }
|
||||||
: { label: dict.kpiMonthlyHydrogen, value: summary.monthlyHydrogenKg, unit: 'kg' };
|
: operationalAccessGranted()
|
||||||
|
? { 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
|
||||||
? `— <small>${activity.unit}</small>`
|
? `—${activity.unit ? ` <small>${activity.unit}</small>` : ''}`
|
||||||
: `${formatNumber(activity.value, 1)} <small>${activity.unit}</small>`;
|
: `${formatNumber(activity.value, 1)} <small>${activity.unit}</small>`;
|
||||||
const regionSummary = buildVehicleRegionNodes();
|
const regionSummary = buildVehicleRegionNodes();
|
||||||
document.getElementById('mapStatusText').textContent = mapStatusSummaryText(summary, regionSummary, dashboard.asOf);
|
document.getElementById('mapStatusText').textContent = mapStatusSummaryText(summary, regionSummary, dashboard.asOf);
|
||||||
@@ -700,9 +884,12 @@ 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}座外部站点 · ${asOf}`
|
? `开放平台已同步 · ${summary.totalStations}座加氢站 · ${cooperative}座合作站点 · ${external}座外部站点${accessHint} · ${asOf}`
|
||||||
: `Open platform synced · ${summary.totalStations} H₂ stations · ${cooperative} partner stations · ${external} external stations · ${asOf}`;
|
: `Open platform synced · ${summary.totalStations} H₂ stations · ${cooperative} partner stations · ${external} external stations${accessHint} · ${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'
|
||||||
@@ -818,6 +1005,7 @@ function stationAdministrativePath(station, lang = currentLang) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hierarchyLevelForZoom(zoom = map?.getZoom?.() || 0) {
|
function hierarchyLevelForZoom(zoom = map?.getZoom?.() || 0) {
|
||||||
|
if (hasPinnedVehicleDistrictFilter()) return 'vehicle';
|
||||||
if (zoom < REGION_ZOOM.city) return 'province';
|
if (zoom < REGION_ZOOM.city) return 'province';
|
||||||
if (zoom < REGION_ZOOM.district) return 'city';
|
if (zoom < REGION_ZOOM.district) return 'city';
|
||||||
if (zoom < REGION_ZOOM.vehicle) return 'district';
|
if (zoom < REGION_ZOOM.vehicle) return 'district';
|
||||||
@@ -1005,7 +1193,7 @@ function vehiclePointNodes(vehicles = filteredVehicles()) {
|
|||||||
return locatedVehicles(vehicles).map(vehicle => {
|
return locatedVehicles(vehicles).map(vehicle => {
|
||||||
const lnglat = wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude));
|
const lnglat = wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude));
|
||||||
return { vehicle, lnglat };
|
return { vehicle, lnglat };
|
||||||
}).filter(({ lnglat }) => !bounds || (lnglat[0] >= bounds.west && lnglat[0] <= bounds.east && lnglat[1] >= bounds.south && lnglat[1] <= bounds.north))
|
}).filter(({ lnglat }) => hasPinnedVehicleDistrictFilter() || !bounds || (lnglat[0] >= bounds.west && lnglat[0] <= bounds.east && lnglat[1] >= bounds.south && lnglat[1] <= bounds.north))
|
||||||
.map(({ vehicle, lnglat }) => ({
|
.map(({ vehicle, lnglat }) => ({
|
||||||
id: `vehicle-point-${vehicle.vin}`, kind: 'vehiclePoint', level: 'vehicle',
|
id: `vehicle-point-${vehicle.vin}`, kind: 'vehiclePoint', level: 'vehicle',
|
||||||
nameZh: vehicle.plateNumber || vehicle.vin, nameEn: vehicle.plateNumber || vehicle.vin,
|
nameZh: vehicle.plateNumber || vehicle.vin, nameEn: vehicle.plateNumber || vehicle.vin,
|
||||||
@@ -1171,6 +1359,42 @@ function vehicleStatusLabel(vehicle) {
|
|||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function navigationCoordinates(mode, entity) {
|
||||||
|
const longitude = Number(entity?.longitude);
|
||||||
|
const latitude = Number(entity?.latitude);
|
||||||
|
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return null;
|
||||||
|
// Vehicle telemetry is WGS-84 and is converted before rendering on AMap.
|
||||||
|
// Station assets already use AMap (GCJ-02) coordinates.
|
||||||
|
return mode === 'vehicle' ? wgs84ToGcj02(longitude, latitude) : [longitude, latitude];
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigationUrlForEntity(mode, entity) {
|
||||||
|
const coordinates = navigationCoordinates(mode, entity);
|
||||||
|
if (!coordinates?.every(Number.isFinite)) return null;
|
||||||
|
const name = mode === 'station'
|
||||||
|
? entity?.name || entity?.shortName || i18n[currentLang].detailStation
|
||||||
|
: entity?.plateNumber || entity?.vin || i18n[currentLang].detailVehicle;
|
||||||
|
const [longitude, latitude] = coordinates.map(value => Number(value).toFixed(6));
|
||||||
|
return `https://uri.amap.com/navigation?to=${longitude},${latitude},${encodeURIComponent(name)}&mode=car&coordinate=gaode&callnative=1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDetailNavigation(mode, entity) {
|
||||||
|
const button = document.getElementById('detailNavigateBtn');
|
||||||
|
if (!button) return;
|
||||||
|
const url = navigationUrlForEntity(mode, entity);
|
||||||
|
const label = i18n[currentLang].detailNavigate;
|
||||||
|
button.hidden = !url;
|
||||||
|
button.disabled = !url;
|
||||||
|
button.dataset.navigationUrl = url || '';
|
||||||
|
button.setAttribute('aria-label', label);
|
||||||
|
button.title = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToSelectedEntity() {
|
||||||
|
const url = selectedEntity && navigationUrlForEntity(selectedEntity.mode, selectedEntity.entity);
|
||||||
|
if (url) window.location.assign(url);
|
||||||
|
}
|
||||||
|
|
||||||
function showEntityDetails(mode, entity) {
|
function showEntityDetails(mode, entity) {
|
||||||
if (!entity) return;
|
if (!entity) return;
|
||||||
const dict = i18n[currentLang];
|
const dict = i18n[currentLang];
|
||||||
@@ -1193,14 +1417,17 @@ function showEntityDetails(mode, entity) {
|
|||||||
title.textContent = entity.name || entity.shortName || dict.detailStation;
|
title.textContent = entity.name || entity.shortName || dict.detailStation;
|
||||||
subtitle.textContent = entity.shortName && entity.shortName !== entity.name ? entity.shortName : stationAdministrativePath(entity);
|
subtitle.textContent = entity.shortName && entity.shortName !== entity.name ? entity.shortName : stationAdministrativePath(entity);
|
||||||
const admin = [stationAdministrativePath(entity), stationDistrictName(entity)].filter(Boolean).filter((value, index, items) => index === 0 || !items[0].includes(value)).join(' · ');
|
const admin = [stationAdministrativePath(entity), stationDistrictName(entity)].filter(Boolean).filter((value, index, items) => index === 0 || !items[0].includes(value)).join(' · ');
|
||||||
grid.innerHTML = [
|
const publicFields = [
|
||||||
detailField(dict.detailStationType, entity.cooperative ? dict.stationCooperative : dict.stationExternal),
|
detailField(dict.detailStationType, entity.cooperative ? dict.stationCooperative : dict.stationExternal),
|
||||||
detailField(dict.detailAdmin, detailValue(admin)),
|
detailField(dict.detailAdmin, detailValue(admin)),
|
||||||
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)
|
||||||
|
];
|
||||||
|
const operationalFields = operationalAccessGranted() ? [
|
||||||
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),
|
||||||
detailField(dict.detailCoordinate, `${formatNumber(entity.longitude, 6)}, ${formatNumber(entity.latitude, 6)}`, true, true)
|
] : [];
|
||||||
].join('');
|
grid.innerHTML = [...publicFields, ...operationalFields].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 || '';
|
||||||
@@ -1219,6 +1446,7 @@ function showEntityDetails(mode, entity) {
|
|||||||
}
|
}
|
||||||
expand.hidden = false;
|
expand.hidden = false;
|
||||||
expand.textContent = dict.detailMore;
|
expand.textContent = dict.detailMore;
|
||||||
|
updateDetailNavigation(mode, entity);
|
||||||
card.hidden = false;
|
card.hidden = false;
|
||||||
renderAMapMarkers();
|
renderAMapMarkers();
|
||||||
renderRankingList(currentRankType);
|
renderRankingList(currentRankType);
|
||||||
@@ -1385,17 +1613,27 @@ function updateRankingControls() {
|
|||||||
document.getElementById('panelRankTitle').textContent = title;
|
document.getElementById('panelRankTitle').textContent = title;
|
||||||
if (currentMode === 'vehicle') updateStationViewportMeta(0, 0);
|
if (currentMode === 'vehicle') updateStationViewportMeta(0, 0);
|
||||||
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' ? dict.rankByDist : dict.rankByHydrogen;
|
document.getElementById('rankSecondaryTab').textContent = currentMode === 'vehicle'
|
||||||
|
? dict.rankByDist
|
||||||
|
: operationalAccessGranted() ? dict.rankByHydrogen : dict.authHydrogenRequired;
|
||||||
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) {
|
function switchMode(mode, { skipAuthCheck = false } = {}) {
|
||||||
|
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();
|
||||||
@@ -1409,6 +1647,7 @@ function switchMode(mode) {
|
|||||||
updateLocateButton(userLocation ? 'active' : 'idle');
|
updateLocateButton(userLocation ? 'active' : 'idle');
|
||||||
updateDashboardUI();
|
updateDashboardUI();
|
||||||
if (mode === 'vehicle') refreshVehicleRegionNodes(dashboard);
|
if (mode === 'vehicle') refreshVehicleRegionNodes(dashboard);
|
||||||
|
else refreshStationViewport();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLanguage(lang) {
|
function setLanguage(lang) {
|
||||||
@@ -1420,6 +1659,9 @@ function setLanguage(lang) {
|
|||||||
document.querySelectorAll('[data-i18n]').forEach(element => {
|
document.querySelectorAll('[data-i18n]').forEach(element => {
|
||||||
const key = element.getAttribute('data-i18n'); if (dict[key]) element.textContent = dict[key];
|
const key = element.getAttribute('data-i18n'); if (dict[key]) element.textContent = dict[key];
|
||||||
});
|
});
|
||||||
|
document.querySelectorAll('[data-i18n-placeholder]').forEach(element => {
|
||||||
|
const key = element.getAttribute('data-i18n-placeholder'); if (dict[key]) element.placeholder = dict[key];
|
||||||
|
});
|
||||||
const brandLogo = document.getElementById('brandLogo');
|
const brandLogo = document.getElementById('brandLogo');
|
||||||
const brandLogoEnglish = document.getElementById('brandLogoEnglish');
|
const brandLogoEnglish = document.getElementById('brandLogoEnglish');
|
||||||
if (brandLogo) {
|
if (brandLogo) {
|
||||||
@@ -1430,6 +1672,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/dashboard" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["status"]=="ok" and d["summary"]["totalStations"]>400'
|
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'
|
||||||
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
|
||||||
|
|||||||
+33
-5
@@ -2,7 +2,7 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>羚牛氢能 - 车辆网络 | Lingniu H2 Executive Cockpit</title>
|
<title>羚牛氢能 - 车辆网络 | Lingniu H2 Executive Cockpit</title>
|
||||||
<!-- Google Fonts: Inter & JetBrains Mono -->
|
<!-- Google Fonts: Inter & JetBrains Mono -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
@@ -61,8 +61,8 @@
|
|||||||
<!-- Header Controls: Mode Switcher, Theme Switcher & Language Switcher -->
|
<!-- Header Controls: Mode Switcher, Theme Switcher & Language Switcher -->
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
<div class="glass-segmented-control">
|
<div class="glass-segmented-control">
|
||||||
<button class="segment-btn active" id="btnModeVehicle" onclick="switchMode('vehicle')" data-i18n="btnVehicle">车辆</button>
|
<button class="segment-btn active" id="btnModeStation" onclick="switchMode('station')" data-i18n="btnStation">加氢站</button>
|
||||||
<button class="segment-btn" id="btnModeStation" onclick="switchMode('station')" data-i18n="btnStation">加氢站</button>
|
<button class="segment-btn is-locked" id="btnModeVehicle" onclick="switchMode('vehicle')" data-i18n="btnVehicle">车辆</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bilingual ZH / EN Switcher -->
|
<!-- Bilingual ZH / EN Switcher -->
|
||||||
@@ -95,6 +95,8 @@
|
|||||||
</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>
|
||||||
@@ -150,7 +152,12 @@
|
|||||||
<h2 id="detailTitle">—</h2>
|
<h2 id="detailTitle">—</h2>
|
||||||
<p id="detailSubtitle"></p>
|
<p id="detailSubtitle"></p>
|
||||||
</div>
|
</div>
|
||||||
<button class="detail-close-btn" type="button" onclick="closeEntityDetails()" aria-label="关闭详情">×</button>
|
<div class="detail-card-actions">
|
||||||
|
<button class="detail-navigate-btn" id="detailNavigateBtn" type="button" onclick="navigateToSelectedEntity()" aria-label="导航" title="导航" hidden>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20.7 3.3 14.4 20.1a1.2 1.2 0 0 1-2.2.1l-2.6-5.8-5.8-2.6a1.2 1.2 0 0 1 .1-2.2l16.8-6.3Z"></path><path d="m9.5 14.5 4.9-4.9"></path></svg>
|
||||||
|
</button>
|
||||||
|
<button class="detail-close-btn" type="button" onclick="closeEntityDetails()" aria-label="关闭详情">×</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dl class="detail-grid" id="detailGrid"></dl>
|
<dl class="detail-grid" id="detailGrid"></dl>
|
||||||
<button class="detail-expand-btn" id="detailExpandBtn" type="button" onclick="toggleEntityDetails()" hidden>展开详情</button>
|
<button class="detail-expand-btn" id="detailExpandBtn" type="button" onclick="toggleEntityDetails()" hidden>展开详情</button>
|
||||||
@@ -228,7 +235,28 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="node_modules/pinyin-pro/dist/index.js?v=3.28.2"></script>
|
<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 让中文地点保持拼音/首字母搜索能力。 -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/pinyin-pro@3.28.2/dist/index.js" crossorigin="anonymous"></script>
|
||||||
<script src="app.js?v=20260812113000"></script>
|
<script src="app.js?v=20260812113000"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+233
-10
@@ -1,6 +1,10 @@
|
|||||||
"""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 hmac
|
||||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -9,6 +13,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from socketserver import ThreadingMixIn
|
from socketserver import ThreadingMixIn
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.parse import urlparse
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
@@ -20,11 +25,134 @@ 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
|
||||||
@@ -73,6 +201,37 @@ def _cached(key, ttl_seconds, loader):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _load_stations():
|
||||||
|
return _cached(
|
||||||
|
"stations",
|
||||||
|
STATION_CACHE_SECONDS,
|
||||||
|
lambda: _post_open_platform("/api/v1/hydrogen-stations/query", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
@@ -82,12 +241,7 @@ def _load_dashboard():
|
|||||||
mileage_future = executor.submit(
|
mileage_future = executor.submit(
|
||||||
_post_open_platform, "/api/v1/vehicles/mileage/query", {"date": today}
|
_post_open_platform, "/api/v1/vehicles/mileage/query", {"date": today}
|
||||||
)
|
)
|
||||||
stations_future = executor.submit(
|
stations_future = executor.submit(_load_stations)
|
||||||
_cached,
|
|
||||||
"stations",
|
|
||||||
STATION_CACHE_SECONDS,
|
|
||||||
lambda: _post_open_platform("/api/v1/hydrogen-stations/query", {}),
|
|
||||||
)
|
|
||||||
vehicles = realtime_future.result()
|
vehicles = realtime_future.result()
|
||||||
mileage_rows = mileage_future.result()
|
mileage_rows = mileage_future.result()
|
||||||
stations = stations_future.result()
|
stations = stations_future.result()
|
||||||
@@ -138,14 +292,38 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
|
|||||||
server_version = "LingniuVehicleMap/1.0"
|
server_version = "LingniuVehicleMap/1.0"
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
if self.path == "/api/health":
|
path = urlparse(self.path).path
|
||||||
|
if path == "/api/health":
|
||||||
self._write_json(200, {
|
self._write_json(200, {
|
||||||
"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
|
return
|
||||||
if self.path == "/api/dashboard":
|
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
|
||||||
|
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)
|
||||||
@@ -154,11 +332,54 @@ 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 end_headers(self):
|
def end_headers(self):
|
||||||
if self.path == "/" or self.path.split("?", 1)[0].endswith((".html", ".css", ".js", ".svg")):
|
if self.path == "/" or self.path.split("?", 1)[0].endswith((".html", ".css", ".js", ".svg")):
|
||||||
self.send_header("Cache-Control", "no-cache")
|
self.send_header("Cache-Control", "no-cache")
|
||||||
@@ -167,12 +388,14 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
|
|||||||
self.send_header("X-Frame-Options", "SAMEORIGIN")
|
self.send_header("X-Frame-Options", "SAMEORIGIN")
|
||||||
super().end_headers()
|
super().end_headers()
|
||||||
|
|
||||||
def _write_json(self, status, payload):
|
def _write_json(self, status, payload, headers=None):
|
||||||
body = _json_bytes(payload)
|
body = _json_bytes(payload)
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
self.send_header("Cache-Control", "no-store")
|
self.send_header("Cache-Control", "no-store")
|
||||||
self.send_header("Content-Length", str(len(body)))
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
for name, value in (headers or {}).items():
|
||||||
|
self.send_header(name, value)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
|||||||
+99
-1
@@ -290,6 +290,80 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -766,6 +840,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-title-wrap { min-width: 0; }
|
.detail-title-wrap { min-width: 0; }
|
||||||
|
.detail-card-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 5px; }
|
||||||
|
|
||||||
.detail-type-pill {
|
.detail-type-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -818,6 +893,24 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-navigate-btn {
|
||||||
|
display: grid;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
flex: 0 0 26px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgba(0, 113, 67, 0.16);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 113, 67, 0.09);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.18s ease, background 0.18s ease, border-color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 svg { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.9; }
|
||||||
|
|
||||||
.detail-grid {
|
.detail-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -1369,11 +1462,16 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,29 @@ assert.equal(points.length, 2);
|
|||||||
assert.ok(points.every(node => node.kind === 'vehiclePoint'));
|
assert.ok(points.every(node => node.kind === 'vehiclePoint'));
|
||||||
assert.equal(points.find(node => node.nameZh === '粤A00001').online, 1);
|
assert.equal(points.find(node => node.nameZh === '粤A00001').online, 1);
|
||||||
|
|
||||||
|
// A selected district is a semantic filter, not a viewport filter. Its full
|
||||||
|
// fleet must survive a camera fit even when a valid vehicle sits outside the
|
||||||
|
// initial district-centroid viewport.
|
||||||
|
currentMode = 'vehicle';
|
||||||
|
dashboard = {
|
||||||
|
vehicles: [
|
||||||
|
{ vin: 'VIN-BY-1', plateNumber: '粤A10001', activeToday: true, locationAvailable: true, longitude: 113.22, latitude: 23.18 },
|
||||||
|
{ vin: 'VIN-BY-2', plateNumber: '粤A10002', activeToday: true, locationAvailable: true, longitude: 113.41, latitude: 23.38 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
vehicleFilterGroups.districts = [{ adcode: '440111', vehicles: dashboard.vehicles }];
|
||||||
|
filterState.district = '440111';
|
||||||
|
map = {
|
||||||
|
getZoom: () => 11.6,
|
||||||
|
getBounds: () => ({
|
||||||
|
getSouthWest: () => ({ getLng: () => 113.1, getLat: () => 23.1 }),
|
||||||
|
getNorthEast: () => ({ getLng: () => 113.3, getLat: () => 23.3 })
|
||||||
|
})
|
||||||
|
};
|
||||||
|
assert.equal(hierarchyLevelForZoom(), 'vehicle');
|
||||||
|
assert.equal(vehiclePointNodes().length, 2);
|
||||||
|
filterState.district = '';
|
||||||
|
|
||||||
dashboard = {
|
dashboard = {
|
||||||
stations: [
|
stations: [
|
||||||
{ id: 'GD-1', name: '广州合作站', province: '广东省', city: '广州市', address: '广东省广州市黄埔区开源大道1号', longitude: 113.2, latitude: 23.1, cooperative: true, totalHydrogenKg: 20 },
|
{ id: 'GD-1', name: '广州合作站', province: '广东省', city: '广州市', address: '广东省广州市黄埔区开源大道1号', longitude: 113.2, latitude: 23.1, cooperative: true, totalHydrogenKg: 20 },
|
||||||
@@ -110,6 +133,11 @@ map = {
|
|||||||
assert.equal(stationNodes(false).length, 3);
|
assert.equal(stationNodes(false).length, 3);
|
||||||
assert.equal(stationNodes(true).length, 2);
|
assert.equal(stationNodes(true).length, 2);
|
||||||
assert.deepEqual(stationNodes(true).map(node => node.name).sort(), ['佛山外部站', '广州合作站']);
|
assert.deepEqual(stationNodes(true).map(node => node.name).sort(), ['佛山外部站', '广州合作站']);
|
||||||
|
const stationNavigationUrl = navigationUrlForEntity('station', dashboard.stations[0]);
|
||||||
|
assert.ok(stationNavigationUrl.startsWith('https://uri.amap.com/navigation?to=113.200000,23.100000,'));
|
||||||
|
assert.match(stationNavigationUrl, /%E5%B9%BF%E5%B7%9E/);
|
||||||
|
assert.match(stationNavigationUrl, /coordinate=gaode&callnative=1$/);
|
||||||
|
assert.equal(navigationUrlForEntity('vehicle', { plateNumber: '无位置车辆' }), null);
|
||||||
const 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);
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -41,6 +45,49 @@ 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_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()
|
||||||
|
|||||||
Reference in New Issue
Block a user