From e7ee8583aadebe9f53b7954a8aa42f4d02cb316b Mon Sep 17 00:00:00 2001 From: lingniu Date: Wed, 2 Sep 2026 14:05:49 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E8=BD=A6=E8=BE=86=E5=9C=B0=E5=9B=BE=E5=90=88=E4=BD=9C=E7=AB=99?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E4=B8=8E=E7=BC=93=E5=AD=98=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vehicle-map/app.js | 34 +++++-- vehicle-map/server.py | 147 ++++++++++++++++++++++++++++--- vehicle-map/styles.css | 34 +++++++ vehicle-map/tests/test_app.mjs | 12 ++- vehicle-map/tests/test_server.py | 55 ++++++++++++ 5 files changed, 262 insertions(+), 20 deletions(-) diff --git a/vehicle-map/app.js b/vehicle-map/app.js index 378cedc4..6cca6e29 100644 --- a/vehicle-map/app.js +++ b/vehicle-map/app.js @@ -22,7 +22,7 @@ const i18n = { filterResult: '筛选', searchVehicle: '搜索车牌 / VIN', searchStation: '搜索站点名称 / 地址', nearbyVehicles: '附近车辆', nearbyStations: '附近加氢站', detailVehicle: '车辆', detailStation: '加氢站', detailPlate: '车牌', detailVin: 'VIN', detailStatus: '车辆状态', detailActive: '今日上线', detailSpeed: '当前速度', detailDailyMileage: '今日里程', detailTotalMileage: '累计里程', detailLocation: '实时位置', detailCoordinate: '坐标', - detailStationType: '站点类型', detailAdmin: '行政区域', detailAddress: '详细地址', detailMonthlyHydrogen: '本月加氢量', detailTotalHydrogen: '累计加氢量', detailNavigate: '导航', + detailStationType: '站点类型', detailAdmin: '行政区域', detailAddress: '详细地址', detailContactPerson: '联系人', detailContactPhone: '联系方式', detailUnitPrice: '单价', detailMonthlyHydrogen: '本月加氢量', detailTotalHydrogen: '累计加氢量', detailNavigate: '导航', navigationLaunchingTitle: '正在打开高德地图', navigationLaunchingDescription: '正在准备导航路线,请稍候', valueYes: '是', valueNo: '否', valueUnavailable: '暂无数据', locatePermissionHint: '无法获取位置,请在浏览器中允许位置权限后重试', 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', 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', - 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.', 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.', @@ -1247,6 +1247,17 @@ function detailField(label, value, wide = false, extra = false) { return `
${escapeHTML(label)}
${escapeHTML(value)}
`; } +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 `
${escapeHTML(label)}
${escapeHTML(value)}
`; +} + +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) { const status = vehicleActiveToday(vehicle) ? Number(vehicle?.speedKmh || 0) > 3 ? i18n[currentLang].statusRunning : i18n[currentLang].statusStopped @@ -1307,8 +1318,15 @@ function navigateToSelectedEntity() { } button?.classList.add('is-launching'); if (button) button.disabled = true; - // Paint the acknowledgement before Safari hands control to the AMap URL scheme. - navigationLaunchTimer = window.setTimeout(() => window.location.assign(url), 140); + // Open synchronously to avoid Safari's popup blocker, then let the launch + // 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); } @@ -1337,6 +1355,11 @@ function showEntityDetails(mode, entity) { const publicFields = [ detailField(dict.detailStationType, entity.cooperative ? dict.stationCooperative : dict.stationExternal), 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.detailCoordinate, `${formatNumber(entity.longitude, 6)}, ${formatNumber(entity.latitude, 6)}`, true, true) ]; @@ -1483,6 +1506,7 @@ function renderRankingList(type) { name: node.name, value: userLocation && level === 'station' ? `${formatNumber(distanceKm(userLocation, node.lnglat), 1)} km` : byHydrogen ? `${formatNumber(node.hydrogenKg, 1)} kg` : countLabel(node.count, 'station'), + price: level === 'station' && node.station?.cooperative ? stationUnitPrice(node.station, '') : '', location: node.lnglat, zoom: level === 'province' ? 7.2 : level === 'city' ? 11.2 : 14, node, entity: node.kind === 'station' ? node.station : null, mode: 'station' @@ -1496,7 +1520,7 @@ function renderRankingList(type) { rows.forEach((item, index) => { const row = document.createElement('div'); row.className = `rank-glass-row${item.entity && selectedEntity?.key === entityKey(item.mode, item.entity) ? ' is-selected' : ''}`; - row.innerHTML = `${index + 1}${escapeHTML(item.name)}${escapeHTML(item.value)}`; + row.innerHTML = `${index + 1}${escapeHTML(item.name)}${item.price ? `${escapeHTML(item.price)}` : ''}${escapeHTML(item.value)}`; row.onclick = () => { if (item.location) map?.setZoomAndCenter(item.zoom || 9, item.location); if (item.entity) showEntityDetails(item.mode, item.entity); diff --git a/vehicle-map/server.py b/vehicle-map/server.py index 5a7961c5..7e7071b8 100644 --- a/vehicle-map/server.py +++ b/vehicle-map/server.py @@ -42,11 +42,15 @@ AMAP_REGEOCODE_MIN_INTERVAL_SECONDS = float(os.getenv("AMAP_REGEOCODE_MIN_INTERV _cache_lock = threading.Lock() _cache = {} _cache_load_locks = {} +_cache_refreshing = set() _station_geocode_cache_lock = threading.Lock() _station_geocode_cache = None _station_geocode_cache_dirty = False _station_geocode_rate_lock = threading.Lock() _station_geocode_next_request_at = 0.0 +_station_geocode_refresh_lock = threading.Lock() +_station_geocode_refresh_pending = {} +_station_geocode_refresh_running = False class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): @@ -85,24 +89,54 @@ def _post_open_platform(path, body): 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): now = time.time() + refresh_in_background = False with _cache_lock: cached = _cache.get(key) if cached and now - cached[0] < ttl_seconds: 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()) - # Startup prewarming and the first browser request can arrive together. - # Coalesce them so a cold geocode cache is filled only once. + if cached: + 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: now = time.time() with _cache_lock: cached = _cache.get(key) - if cached and now - cached[0] < ttl_seconds: - return cached[1] + if cached: + return _cached(key, ttl_seconds, loader) value = loader() with _cache_lock: - _cache[key] = (now, value) + _cache[key] = (time.time(), 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"))} -def _station_region_from_gps(station, now=None): +def _station_region_from_gps(station, now=None, refresh_queue=None): global _station_geocode_cache_dirty coordinate_key = _station_coordinate_key(station) if not coordinate_key: @@ -191,6 +225,11 @@ def _station_region_from_gps(station, now=None): cached = cache.get(coordinate_key) or {} if now - float(cached.get("updatedAt") or 0) < STATION_GEOCODE_CACHE_SECONDS: return cached.get("region") or {"province": "", "city": "", "district": ""} + cached_region = cached.get("region") + if isinstance(cached_region, dict): + if refresh_queue is not None: + refresh_queue.append(station) + return cached_region try: longitude, latitude = (float(value) for value in coordinate_key.split(",")) region = _reverse_geocode_station(longitude, latitude) @@ -203,22 +242,92 @@ def _station_region_from_gps(station, now=None): 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): global _station_geocode_cache_dirty def enrich(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 # remains untouched and is the only directly displayed location text. 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: - 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: if _station_geocode_cache_dirty: _persist_station_geocode_cache() _station_geocode_cache_dirty = False + if stale_stations: + _schedule_station_region_refresh(stale_stations) return enriched @@ -233,7 +342,13 @@ def _load_stations(): def _public_station_directory(): stations = _load_stations() fields = ("id", "name", "shortName", "province", "city", "district", "address", "longitude", "latitude", "cooperative") - directory = [{field: station.get(field) for field in fields if station.get(field) is not None} for station in stations] + 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 { "status": "ok", "asOf": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), @@ -331,7 +446,6 @@ class VehicleMapHandler(SimpleHTTPRequestHandler): if path == "/api/dashboard": try: payload = _cached("dashboard", DASHBOARD_CACHE_SECONDS, _load_dashboard) - self._write_json(200, payload) except Exception as exc: # keep upstream details server-side only self.log_error("dashboard refresh failed: %s", exc) self._write_json(502, { @@ -339,10 +453,12 @@ class VehicleMapHandler(SimpleHTTPRequestHandler): "code": "UPSTREAM_UNAVAILABLE", "message": "地图数据暂时不可用,请稍后重试", }) + return + self._write_json(200, payload) return if path == "/api/stations": try: - self._write_json(200, _public_station_directory()) + payload = _public_station_directory() except Exception as exc: self.log_error("station directory refresh failed: %s", exc) self._write_json(502, { @@ -350,6 +466,8 @@ class VehicleMapHandler(SimpleHTTPRequestHandler): "code": "UPSTREAM_UNAVAILABLE", "message": "加氢站数据暂时不可用,请稍后重试", }) + return + self._write_json(200, payload) return super().do_GET() @@ -385,7 +503,10 @@ class VehicleMapHandler(SimpleHTTPRequestHandler): for name, value in (headers or {}).items(): self.send_header(name, value) self.end_headers() - self.wfile.write(body) + try: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + self.log_message("client disconnected before JSON response completed") if __name__ == "__main__": diff --git a/vehicle-map/styles.css b/vehicle-map/styles.css index a1f409aa..b5b72cd7 100644 --- a/vehicle-map/styles.css +++ b/vehicle-map/styles.css @@ -919,6 +919,15 @@ body { 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; } .map-detail-card.is-expanded .detail-field.is-extra { display: block; } @@ -1216,6 +1225,21 @@ body { 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 */ ::-webkit-scrollbar { width: 3px; } ::-webkit-scrollbar-track { background: transparent; } @@ -1578,6 +1602,16 @@ body { max-height: none; 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) { diff --git a/vehicle-map/tests/test_app.mjs b/vehicle-map/tests/test_app.mjs index 653c3579..3a1ce9a8 100644 --- a/vehicle-map/tests/test_app.mjs +++ b/vehicle-map/tests/test_app.mjs @@ -105,13 +105,15 @@ filterState.district = ''; dashboard = { 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: 'ZJ-1', name: '嘉兴合作站', province: '浙江省', city: '嘉兴市', longitude: 120.7, latitude: 30.7, cooperative: true, totalHydrogenKg: 30 } ] }; currentMode = 'station'; assert.equal(stationDistrictName(dashboard.stations[0]), '黄埔区'); +assert.equal(stationUnitPrice(dashboard.stations[0]), '¥12.50 / kg'); +assert.match(detailPhoneField('联系方式', dashboard.stations[0].contactPhone), /tel:13800000000/); assert.equal(fuzzySearchScore('广州', 'gz'), 3); assert.equal(fuzzySearchScore('广州市', '广州'), 1); 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.setTimeout = (callback, delay) => { launchTimers.push({ callback, delay }); return launchTimers.length; }; 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] }; navigateToSelectedEntity(); assert.equal(launchOverlay.hidden, false); @@ -161,6 +165,10 @@ assert.ok(launchClassSet.has('is-visible')); assert.ok(launchClassSet.has('button:is-launching')); assert.equal(launchButton.disabled, true); assert.deepEqual(launchTimers.map(timer => timer.delay), [140, 4000]); +assert.deepEqual(openedNavigation[0], ['', '_blank']); +launchTimers[0].callback(); +assert.equal(navigationTarget.opener, null); +assert.equal(navigationTarget.location.href, stationNavigationUrl); const stationView = stationViewportSummary(); assert.equal(stationView.level, 'station'); assert.equal(stationView.visibleNodes.length, 2); diff --git a/vehicle-map/tests/test_server.py b/vehicle-map/tests/test_server.py index ac8ba4ad..2357923f 100644 --- a/vehicle-map/tests/test_server.py +++ b/vehicle-map/tests/test_server.py @@ -1,6 +1,8 @@ import importlib.util from pathlib import Path import tempfile +import threading +import time import unittest from unittest.mock import patch @@ -52,6 +54,20 @@ class DashboardTest(unittest.TestCase): self.assertEqual(server.DASHBOARD_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): calls = [] with patch.object(server, "_cache", {}): @@ -60,6 +76,45 @@ class DashboardTest(unittest.TestCase): self.assertEqual(calls, ["load"]) 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): station = { "longitude": 113.200001,