功能:完善车辆地图合作站信息与缓存刷新

This commit is contained in:
lingniu
2026-09-02 14:05:49 +08:00
parent c6875fbb98
commit e7ee8583aa
5 changed files with 262 additions and 20 deletions
+29 -5
View File
@@ -22,7 +22,7 @@ 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: '累计加氢量', detailNavigate: '导航', detailStationType: '站点类型', detailAdmin: '行政区域', detailAddress: '详细地址', detailContactPerson: '联系人', detailContactPhone: '联系方式', detailUnitPrice: '单价', detailMonthlyHydrogen: '本月加氢量', detailTotalHydrogen: '累计加氢量', detailNavigate: '导航',
navigationLaunchingTitle: '正在打开高德地图', navigationLaunchingDescription: '正在准备导航路线,请稍候', navigationLaunchingTitle: '正在打开高德地图', navigationLaunchingDescription: '正在准备导航路线,请稍候',
valueYes: '是', valueNo: '否', valueUnavailable: '暂无数据', locatePermissionHint: '无法获取位置,请在浏览器中允许位置权限后重试', valueYes: '是', valueNo: '否', valueUnavailable: '暂无数据', locatePermissionHint: '无法获取位置,请在浏览器中允许位置权限后重试',
locateUnavailableHint: '暂时无法获取设备位置,请稍后重试', locateTimeoutHint: '定位超时,请到开阔区域后重试', locateUnavailableHint: '暂时无法获取设备位置,请稍后重试', locateTimeoutHint: '定位超时,请到开阔区域后重试',
@@ -50,7 +50,7 @@ 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', detailNavigate: 'Navigate', detailStationType: 'Station type', detailAdmin: 'Region', detailAddress: 'Address', detailContactPerson: 'Contact', detailContactPhone: 'Phone', detailUnitPrice: 'Unit price', detailMonthlyHydrogen: 'Monthly hydrogen', detailTotalHydrogen: 'Total hydrogen', detailNavigate: 'Navigate',
navigationLaunchingTitle: 'Opening AMap', navigationLaunchingDescription: 'Preparing your route. Just a moment.', 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.',
@@ -1247,6 +1247,17 @@ function detailField(label, value, wide = false, extra = false) {
return `<div class="detail-field${wide ? ' is-wide' : ''}${extra ? ' is-extra' : ''}"><dt>${escapeHTML(label)}</dt><dd>${escapeHTML(value)}</dd></div>`; return `<div class="detail-field${wide ? ' is-wide' : ''}${extra ? ' is-extra' : ''}"><dt>${escapeHTML(label)}</dt><dd>${escapeHTML(value)}</dd></div>`;
} }
function detailPhoneField(label, phone, wide = false) {
const value = String(phone || '').trim(), href = value.replace(/[^\d+]/g, '');
if (!href) return detailField(label, i18n[currentLang].valueUnavailable, wide);
return `<div class="detail-field${wide ? ' is-wide' : ''}"><dt>${escapeHTML(label)}</dt><dd><a class="detail-phone-link" href="tel:${escapeHTML(href)}">${escapeHTML(value)}</a></dd></div>`;
}
function stationUnitPrice(station, fallback = i18n[currentLang].valueUnavailable) {
const value = Number(station?.unitPrice);
return Number.isFinite(value) && value > 0 ? `¥${formatNumber(value, 2)} / kg` : fallback;
}
function vehicleStatusLabel(vehicle) { function vehicleStatusLabel(vehicle) {
const status = vehicleActiveToday(vehicle) const status = vehicleActiveToday(vehicle)
? Number(vehicle?.speedKmh || 0) > 3 ? i18n[currentLang].statusRunning : i18n[currentLang].statusStopped ? Number(vehicle?.speedKmh || 0) > 3 ? i18n[currentLang].statusRunning : i18n[currentLang].statusStopped
@@ -1307,8 +1318,15 @@ function navigateToSelectedEntity() {
} }
button?.classList.add('is-launching'); button?.classList.add('is-launching');
if (button) button.disabled = true; if (button) button.disabled = true;
// Paint the acknowledgement before Safari hands control to the AMap URL scheme. // Open synchronously to avoid Safari's popup blocker, then let the launch
navigationLaunchTimer = window.setTimeout(() => window.location.assign(url), 140); // animation acknowledge the action while the current map remains intact.
const target = window.open('', '_blank');
navigationLaunchTimer = window.setTimeout(() => {
if (target) {
target.opener = null;
target.location.href = url;
} else window.open(url, '_blank', 'noopener');
}, 140);
window.setTimeout(resetNavigationLaunch, 4000); window.setTimeout(resetNavigationLaunch, 4000);
} }
@@ -1337,6 +1355,11 @@ function showEntityDetails(mode, entity) {
const publicFields = [ 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)),
...(entity.cooperative ? [
detailField(dict.detailUnitPrice, stationUnitPrice(entity)),
detailField(dict.detailContactPerson, detailValue(entity.contactPerson)),
detailPhoneField(dict.detailContactPhone, entity.contactPhone),
] : []),
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)
]; ];
@@ -1483,6 +1506,7 @@ function renderRankingList(type) {
name: node.name, name: node.name,
value: userLocation && level === 'station' ? `${formatNumber(distanceKm(userLocation, node.lnglat), 1)} km` value: userLocation && level === 'station' ? `${formatNumber(distanceKm(userLocation, node.lnglat), 1)} km`
: byHydrogen ? `${formatNumber(node.hydrogenKg, 1)} kg` : countLabel(node.count, 'station'), : byHydrogen ? `${formatNumber(node.hydrogenKg, 1)} kg` : countLabel(node.count, 'station'),
price: level === 'station' && node.station?.cooperative ? stationUnitPrice(node.station, '') : '',
location: node.lnglat, location: node.lnglat,
zoom: level === 'province' ? 7.2 : level === 'city' ? 11.2 : 14, zoom: level === 'province' ? 7.2 : level === 'city' ? 11.2 : 14,
node, entity: node.kind === 'station' ? node.station : null, mode: 'station' node, entity: node.kind === 'station' ? node.station : null, mode: 'station'
@@ -1496,7 +1520,7 @@ function renderRankingList(type) {
rows.forEach((item, index) => { rows.forEach((item, index) => {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = `rank-glass-row${item.entity && selectedEntity?.key === entityKey(item.mode, item.entity) ? ' is-selected' : ''}`; row.className = `rank-glass-row${item.entity && selectedEntity?.key === entityKey(item.mode, item.entity) ? ' is-selected' : ''}`;
row.innerHTML = `<span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${escapeHTML(item.name)}</span><span class="r-val">${escapeHTML(item.value)}</span>`; row.innerHTML = `<span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${escapeHTML(item.name)}</span><span class="r-value-stack">${item.price ? `<span class="station-list-price">${escapeHTML(item.price)}</span>` : ''}<span class="r-val">${escapeHTML(item.value)}</span></span>`;
row.onclick = () => { row.onclick = () => {
if (item.location) map?.setZoomAndCenter(item.zoom || 9, item.location); if (item.location) map?.setZoomAndCenter(item.zoom || 9, item.location);
if (item.entity) showEntityDetails(item.mode, item.entity); if (item.entity) showEntityDetails(item.mode, item.entity);
+133 -12
View File
@@ -42,11 +42,15 @@ AMAP_REGEOCODE_MIN_INTERVAL_SECONDS = float(os.getenv("AMAP_REGEOCODE_MIN_INTERV
_cache_lock = threading.Lock() _cache_lock = threading.Lock()
_cache = {} _cache = {}
_cache_load_locks = {} _cache_load_locks = {}
_cache_refreshing = set()
_station_geocode_cache_lock = threading.Lock() _station_geocode_cache_lock = threading.Lock()
_station_geocode_cache = None _station_geocode_cache = None
_station_geocode_cache_dirty = False _station_geocode_cache_dirty = False
_station_geocode_rate_lock = threading.Lock() _station_geocode_rate_lock = threading.Lock()
_station_geocode_next_request_at = 0.0 _station_geocode_next_request_at = 0.0
_station_geocode_refresh_lock = threading.Lock()
_station_geocode_refresh_pending = {}
_station_geocode_refresh_running = False
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
@@ -85,24 +89,54 @@ def _post_open_platform(path, body):
return payload.get("data") or [] return payload.get("data") or []
def _refresh_cached_value(key, loader):
try:
value = loader()
except Exception as exc:
print("{} cache background refresh deferred: {}".format(key, exc))
else:
with _cache_lock:
_cache[key] = (time.time(), value)
finally:
with _cache_lock:
_cache_refreshing.discard(key)
def _start_cache_refresh(key, loader):
thread = threading.Thread(target=_refresh_cached_value, args=(key, loader), daemon=True)
thread.start()
def _cached(key, ttl_seconds, loader): def _cached(key, ttl_seconds, loader):
now = time.time() now = time.time()
refresh_in_background = False
with _cache_lock: with _cache_lock:
cached = _cache.get(key) cached = _cache.get(key)
if cached and now - cached[0] < ttl_seconds: if cached and now - cached[0] < ttl_seconds:
return cached[1] return cached[1]
if cached:
if key not in _cache_refreshing:
_cache_refreshing.add(key)
refresh_in_background = True
stale_value = cached[1]
else:
stale_value = None
load_lock = _cache_load_locks.setdefault(key, threading.Lock()) load_lock = _cache_load_locks.setdefault(key, threading.Lock())
# Startup prewarming and the first browser request can arrive together. if cached:
# Coalesce them so a cold geocode cache is filled only once. if refresh_in_background:
_start_cache_refresh(key, loader)
return stale_value
# Cold starts still need one synchronous load. Coalesce startup prewarming
# and the first browser request so only one upstream request is issued.
with load_lock: with load_lock:
now = time.time() now = time.time()
with _cache_lock: with _cache_lock:
cached = _cache.get(key) cached = _cache.get(key)
if cached and now - cached[0] < ttl_seconds: if cached:
return cached[1] return _cached(key, ttl_seconds, loader)
value = loader() value = loader()
with _cache_lock: with _cache_lock:
_cache[key] = (now, value) _cache[key] = (time.time(), value)
return value return value
@@ -181,7 +215,7 @@ def _reverse_geocode_station(longitude, latitude):
return {"province": province, "city": city, "district": district, "adcode": _component_value(component.get("adcode"))} return {"province": province, "city": city, "district": district, "adcode": _component_value(component.get("adcode"))}
def _station_region_from_gps(station, now=None): def _station_region_from_gps(station, now=None, refresh_queue=None):
global _station_geocode_cache_dirty global _station_geocode_cache_dirty
coordinate_key = _station_coordinate_key(station) coordinate_key = _station_coordinate_key(station)
if not coordinate_key: if not coordinate_key:
@@ -191,6 +225,11 @@ def _station_region_from_gps(station, now=None):
cached = cache.get(coordinate_key) or {} cached = cache.get(coordinate_key) or {}
if now - float(cached.get("updatedAt") or 0) < STATION_GEOCODE_CACHE_SECONDS: if now - float(cached.get("updatedAt") or 0) < STATION_GEOCODE_CACHE_SECONDS:
return cached.get("region") or {"province": "", "city": "", "district": ""} return cached.get("region") or {"province": "", "city": "", "district": ""}
cached_region = cached.get("region")
if isinstance(cached_region, dict):
if refresh_queue is not None:
refresh_queue.append(station)
return cached_region
try: try:
longitude, latitude = (float(value) for value in coordinate_key.split(",")) longitude, latitude = (float(value) for value in coordinate_key.split(","))
region = _reverse_geocode_station(longitude, latitude) region = _reverse_geocode_station(longitude, latitude)
@@ -203,22 +242,92 @@ def _station_region_from_gps(station, now=None):
return region return region
def _refresh_station_region(station):
coordinate_key = _station_coordinate_key(station)
if not coordinate_key:
return False
now = time.time()
cache = _load_station_geocode_cache()
with _station_geocode_cache_lock:
cached = cache.get(coordinate_key) or {}
if now - float(cached.get("updatedAt") or 0) < STATION_GEOCODE_CACHE_SECONDS:
return False
try:
longitude, latitude = (float(value) for value in coordinate_key.split(","))
region = _reverse_geocode_station(longitude, latitude)
except Exception as exc:
print("station reverse geocode refresh deferred for {}: {}".format(coordinate_key, exc))
return False
with _station_geocode_cache_lock:
cache[coordinate_key] = {"updatedAt": now, "region": region}
return True
def _refresh_station_regions(stations):
with ThreadPoolExecutor(max_workers=8) as executor:
changed = any(list(executor.map(_refresh_station_region, stations)))
if changed:
with _station_geocode_cache_lock:
_persist_station_geocode_cache()
def _drain_station_region_refreshes():
global _station_geocode_refresh_running
restart = False
try:
while True:
with _station_geocode_refresh_lock:
batch = list(_station_geocode_refresh_pending.values())
_station_geocode_refresh_pending.clear()
if not batch:
return
_refresh_station_regions(batch)
except Exception as exc:
print("station reverse geocode background refresh deferred: {}".format(exc))
finally:
with _station_geocode_refresh_lock:
_station_geocode_refresh_running = False
restart = bool(_station_geocode_refresh_pending)
if restart:
_schedule_station_region_refresh([])
def _schedule_station_region_refresh(stations):
global _station_geocode_refresh_running
start_worker = False
with _station_geocode_refresh_lock:
for station in stations:
coordinate_key = _station_coordinate_key(station)
if coordinate_key:
_station_geocode_refresh_pending[coordinate_key] = station
if _station_geocode_refresh_pending and not _station_geocode_refresh_running:
_station_geocode_refresh_running = True
start_worker = True
if start_worker:
threading.Thread(target=_drain_station_region_refreshes, daemon=True).start()
def _stations_with_gps_regions(stations): def _stations_with_gps_regions(stations):
global _station_geocode_cache_dirty global _station_geocode_cache_dirty
def enrich(station): def enrich(station):
item = dict(station) item = dict(station)
region = _station_region_from_gps(item) refresh_queue = []
region = _station_region_from_gps(item, refresh_queue=refresh_queue)
# Never fall back to data-table administrative fields. The raw address # Never fall back to data-table administrative fields. The raw address
# remains untouched and is the only directly displayed location text. # remains untouched and is the only directly displayed location text.
item.update({key: region.get(key, "") for key in ("province", "city", "district")}) item.update({key: region.get(key, "") for key in ("province", "city", "district")})
return item return item, refresh_queue[0] if refresh_queue else None
with ThreadPoolExecutor(max_workers=8) as executor: with ThreadPoolExecutor(max_workers=8) as executor:
enriched = list(executor.map(enrich, stations)) results = list(executor.map(enrich, stations))
enriched = [item for item, _ in results]
stale_stations = [station for _, station in results if station is not None]
with _station_geocode_cache_lock: with _station_geocode_cache_lock:
if _station_geocode_cache_dirty: if _station_geocode_cache_dirty:
_persist_station_geocode_cache() _persist_station_geocode_cache()
_station_geocode_cache_dirty = False _station_geocode_cache_dirty = False
if stale_stations:
_schedule_station_region_refresh(stale_stations)
return enriched return enriched
@@ -233,7 +342,13 @@ def _load_stations():
def _public_station_directory(): def _public_station_directory():
stations = _load_stations() stations = _load_stations()
fields = ("id", "name", "shortName", "province", "city", "district", "address", "longitude", "latitude", "cooperative") fields = ("id", "name", "shortName", "province", "city", "district", "address", "longitude", "latitude", "cooperative")
directory = [{field: station.get(field) for field in fields if station.get(field) is not None} for station in stations] partner_fields = ("contactPerson", "contactPhone", "unitPrice")
directory = []
for station in stations:
item = {field: station.get(field) for field in fields if station.get(field) is not None}
if item.get("cooperative"):
item.update({field: station.get(field) for field in partner_fields if station.get(field) not in (None, "", 0)})
directory.append(item)
return { return {
"status": "ok", "status": "ok",
"asOf": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), "asOf": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
@@ -331,7 +446,6 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
if path == "/api/dashboard": if path == "/api/dashboard":
try: try:
payload = _cached("dashboard", DASHBOARD_CACHE_SECONDS, _load_dashboard) payload = _cached("dashboard", DASHBOARD_CACHE_SECONDS, _load_dashboard)
self._write_json(200, payload)
except Exception as exc: # keep upstream details server-side only except Exception as exc: # keep upstream details server-side only
self.log_error("dashboard refresh failed: %s", exc) self.log_error("dashboard refresh failed: %s", exc)
self._write_json(502, { self._write_json(502, {
@@ -340,9 +454,11 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
"message": "地图数据暂时不可用,请稍后重试", "message": "地图数据暂时不可用,请稍后重试",
}) })
return return
self._write_json(200, payload)
return
if path == "/api/stations": if path == "/api/stations":
try: try:
self._write_json(200, _public_station_directory()) payload = _public_station_directory()
except Exception as exc: except Exception as exc:
self.log_error("station directory refresh failed: %s", exc) self.log_error("station directory refresh failed: %s", exc)
self._write_json(502, { self._write_json(502, {
@@ -351,6 +467,8 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
"message": "加氢站数据暂时不可用,请稍后重试", "message": "加氢站数据暂时不可用,请稍后重试",
}) })
return return
self._write_json(200, payload)
return
super().do_GET() super().do_GET()
def _serve_index(self): def _serve_index(self):
@@ -385,7 +503,10 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
for name, value in (headers or {}).items(): for name, value in (headers or {}).items():
self.send_header(name, value) self.send_header(name, value)
self.end_headers() self.end_headers()
try:
self.wfile.write(body) self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
self.log_message("client disconnected before JSON response completed")
if __name__ == "__main__": if __name__ == "__main__":
+34
View File
@@ -919,6 +919,15 @@ body {
font: 600 10px/1.35 'JetBrains Mono', -apple-system, sans-serif; font: 600 10px/1.35 'JetBrains Mono', -apple-system, sans-serif;
} }
.detail-phone-link {
color: var(--accent-primary);
font: inherit;
font-weight: 700;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--accent-primary) 35%, transparent);
text-underline-offset: 2px;
}
.detail-field.is-extra { display: none; } .detail-field.is-extra { display: none; }
.map-detail-card.is-expanded .detail-field.is-extra { display: block; } .map-detail-card.is-expanded .detail-field.is-extra { display: block; }
@@ -1216,6 +1225,21 @@ body {
color: var(--text-main); color: var(--text-main);
} }
.r-value-stack {
display: inline-flex;
align-items: baseline;
justify-content: flex-end;
gap: 7px;
min-width: 0;
}
.station-list-price {
color: var(--accent-primary);
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
/* Custom Scrollbars */ /* Custom Scrollbars */
::-webkit-scrollbar { width: 3px; } ::-webkit-scrollbar { width: 3px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
@@ -1578,6 +1602,16 @@ body {
max-height: none; max-height: none;
overflow: visible; overflow: visible;
} }
.rank-glass-row {
align-items: flex-start;
}
.r-value-stack {
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
} }
@media (max-width: 390px) { @media (max-width: 390px) {
+10 -2
View File
@@ -105,13 +105,15 @@ 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, contactPerson: '张工', contactPhone: '13800000000', unitPrice: 12.5, totalHydrogenKg: 20 },
{ id: 'GD-2', name: '佛山外部站', province: '广东省', city: '佛山市', longitude: 113.1, latitude: 23.0, cooperative: false, totalHydrogenKg: 10 }, { id: 'GD-2', name: '佛山外部站', province: '广东省', city: '佛山市', longitude: 113.1, latitude: 23.0, cooperative: false, totalHydrogenKg: 10 },
{ id: 'ZJ-1', name: '嘉兴合作站', province: '浙江省', city: '嘉兴市', longitude: 120.7, latitude: 30.7, cooperative: true, totalHydrogenKg: 30 } { id: 'ZJ-1', name: '嘉兴合作站', province: '浙江省', city: '嘉兴市', longitude: 120.7, latitude: 30.7, cooperative: true, totalHydrogenKg: 30 }
] ]
}; };
currentMode = 'station'; currentMode = 'station';
assert.equal(stationDistrictName(dashboard.stations[0]), '黄埔区'); assert.equal(stationDistrictName(dashboard.stations[0]), '黄埔区');
assert.equal(stationUnitPrice(dashboard.stations[0]), '¥12.50 / kg');
assert.match(detailPhoneField('联系方式', dashboard.stations[0].contactPhone), /tel:13800000000/);
assert.equal(fuzzySearchScore('广州', 'gz'), 3); assert.equal(fuzzySearchScore('广州', 'gz'), 3);
assert.equal(fuzzySearchScore('广州市', '广州'), 1); assert.equal(fuzzySearchScore('广州市', '广州'), 1);
assert.ok(stationLocationOptions().some(option => option.level === 'district' && option.district === '黄埔区')); assert.ok(stationLocationOptions().some(option => option.level === 'district' && option.district === '黄埔区'));
@@ -153,7 +155,9 @@ document.getElementById = id => id === 'navigationLaunchOverlay' ? launchOverlay
window.requestAnimationFrame = callback => callback(); window.requestAnimationFrame = callback => callback();
window.setTimeout = (callback, delay) => { launchTimers.push({ callback, delay }); return launchTimers.length; }; window.setTimeout = (callback, delay) => { launchTimers.push({ callback, delay }); return launchTimers.length; };
window.clearTimeout = () => {}; window.clearTimeout = () => {};
window.location = { assign() {} }; const navigationTarget = { opener: {}, location: {} };
const openedNavigation = [];
window.open = (...args) => { openedNavigation.push(args); return navigationTarget; };
selectedEntity = { mode: 'station', entity: dashboard.stations[0] }; selectedEntity = { mode: 'station', entity: dashboard.stations[0] };
navigateToSelectedEntity(); navigateToSelectedEntity();
assert.equal(launchOverlay.hidden, false); assert.equal(launchOverlay.hidden, false);
@@ -161,6 +165,10 @@ assert.ok(launchClassSet.has('is-visible'));
assert.ok(launchClassSet.has('button:is-launching')); assert.ok(launchClassSet.has('button:is-launching'));
assert.equal(launchButton.disabled, true); assert.equal(launchButton.disabled, true);
assert.deepEqual(launchTimers.map(timer => timer.delay), [140, 4000]); assert.deepEqual(launchTimers.map(timer => timer.delay), [140, 4000]);
assert.deepEqual(openedNavigation[0], ['', '_blank']);
launchTimers[0].callback();
assert.equal(navigationTarget.opener, null);
assert.equal(navigationTarget.location.href, stationNavigationUrl);
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);
+55
View File
@@ -1,6 +1,8 @@
import importlib.util import importlib.util
from pathlib import Path from pathlib import Path
import tempfile import tempfile
import threading
import time
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
@@ -52,6 +54,20 @@ class DashboardTest(unittest.TestCase):
self.assertEqual(server.DASHBOARD_CACHE_SECONDS, 120) self.assertEqual(server.DASHBOARD_CACHE_SECONDS, 120)
self.assertEqual(server.STATION_CACHE_SECONDS, 120) self.assertEqual(server.STATION_CACHE_SECONDS, 120)
def test_public_station_directory_limits_partner_contact_and_price_to_cooperative_stations(self):
stations = [
{"id": "partner", "cooperative": True, "contactPerson": "张工", "contactPhone": "13800000000", "unitPrice": 12.5},
{"id": "external", "cooperative": False, "contactPerson": "李工", "contactPhone": "13900000000", "unitPrice": 10.0},
]
with patch.object(server, "_load_stations", return_value=stations):
directory = server._public_station_directory()["stations"]
self.assertEqual(directory[0]["contactPerson"], "张工")
self.assertEqual(directory[0]["contactPhone"], "13800000000")
self.assertEqual(directory[0]["unitPrice"], 12.5)
self.assertNotIn("contactPerson", directory[1])
self.assertNotIn("contactPhone", directory[1])
self.assertNotIn("unitPrice", directory[1])
def test_cache_reuses_dashboard_snapshot_within_window(self): def test_cache_reuses_dashboard_snapshot_within_window(self):
calls = [] calls = []
with patch.object(server, "_cache", {}): with patch.object(server, "_cache", {}):
@@ -60,6 +76,45 @@ class DashboardTest(unittest.TestCase):
self.assertEqual(calls, ["load"]) self.assertEqual(calls, ["load"])
self.assertEqual(first, second) self.assertEqual(first, second)
def test_expired_cache_returns_stale_value_while_refreshing_in_background(self):
started = threading.Event()
release = threading.Event()
def loader():
started.set()
release.wait(1)
return {"version": 2}
cache = {"dashboard": (time.time() - 121, {"version": 1})}
with patch.object(server, "_cache", cache), \
patch.object(server, "_cache_refreshing", set()), \
patch.object(server, "_cache_load_locks", {}):
result = server._cached("dashboard", 120, loader)
self.assertEqual(result, {"version": 1})
self.assertTrue(started.wait(1))
release.set()
for _ in range(100):
if cache["dashboard"][1] == {"version": 2}:
break
time.sleep(0.01)
self.assertEqual(cache["dashboard"][1], {"version": 2})
def test_expired_station_region_is_served_while_refresh_is_scheduled(self):
station = {"longitude": 113.2, "latitude": 23.1, "province": "错误省份"}
coordinate = server._station_coordinate_key(station)
stale_region = {"province": "广东省", "city": "广州市", "district": "黄埔区"}
cache = {coordinate: {"updatedAt": time.time() - server.STATION_GEOCODE_CACHE_SECONDS - 1, "region": stale_region}}
with patch.object(server, "_station_geocode_cache", cache), \
patch.object(server, "_station_geocode_cache_dirty", False), \
patch.object(server, "_schedule_station_region_refresh") as schedule, \
patch.object(server, "_reverse_geocode_station") as reverse:
result = server._stations_with_gps_regions([station])
self.assertEqual(result[0]["province"], "广东省")
self.assertEqual(result[0]["city"], "广州市")
reverse.assert_not_called()
schedule.assert_called_once()
self.assertEqual(len(schedule.call_args[0][0]), 1)
def test_station_region_comes_from_gps_reverse_geocode_and_persists_by_coordinate(self): def test_station_region_comes_from_gps_reverse_geocode_and_persists_by_coordinate(self):
station = { station = {
"longitude": 113.200001, "longitude": 113.200001,