功能:完善车辆地图合作站信息与缓存刷新
This commit is contained in:
+29
-5
@@ -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);
|
||||||
|
|||||||
+134
-13
@@ -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, {
|
||||||
@@ -339,10 +453,12 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
|
|||||||
"code": "UPSTREAM_UNAVAILABLE",
|
"code": "UPSTREAM_UNAVAILABLE",
|
||||||
"message": "地图数据暂时不可用,请稍后重试",
|
"message": "地图数据暂时不可用,请稍后重试",
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
self._write_json(200, payload)
|
||||||
return
|
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, {
|
||||||
@@ -350,6 +466,8 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
|
|||||||
"code": "UPSTREAM_UNAVAILABLE",
|
"code": "UPSTREAM_UNAVAILABLE",
|
||||||
"message": "加氢站数据暂时不可用,请稍后重试",
|
"message": "加氢站数据暂时不可用,请稍后重试",
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
self._write_json(200, payload)
|
||||||
return
|
return
|
||||||
super().do_GET()
|
super().do_GET()
|
||||||
|
|
||||||
@@ -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()
|
||||||
self.wfile.write(body)
|
try:
|
||||||
|
self.wfile.write(body)
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
self.log_message("client disconnected before JSON response completed")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user