功能:完善加氢站导航详情与缓存刷新

This commit is contained in:
lingniu
2026-09-02 14:05:49 +08:00
parent e7ee8583aa
commit a26179c8fe
5 changed files with 151 additions and 17 deletions
+25 -10
View File
@@ -52,6 +52,9 @@ function stationSearchScore(station, query) {
function toPinyin(value) { const converter = globalThis.pinyinPro?.pinyin; return converter ? normalize(converter(String(value), { toneType: 'none', separator: ' ' })) : ''; } function toPinyin(value) { const converter = globalThis.pinyinPro?.pinyin; return converter ? normalize(converter(String(value), { toneType: 'none', separator: ' ' })) : ''; }
function initials(value) { return String(value).split(/\s+/).map(word => word[0] || '').join(''); } function initials(value) { return String(value).split(/\s+/).map(word => word[0] || '').join(''); }
function normalize(value) { return String(value || '').trim().toLocaleLowerCase(); } function normalize(value) { return String(value || '').trim().toLocaleLowerCase(); }
function adminNameKey(value) { return String(value || '').trim().replace(/(特别行政区|壮族自治区|回族自治区|维吾尔自治区|自治区|省|市)$/, ''); }
function isMunicipalityProvince(value) { return ['北京', '上海', '天津', '重庆'].includes(adminNameKey(value)); }
function isMunicipalityCity(province, city) { return isMunicipalityProvince(province) && adminNameKey(province) === adminNameKey(city); }
function hasCoordinates(station) { return Number.isFinite(Number(station.longitude)) && Number.isFinite(Number(station.latitude)); } function hasCoordinates(station) { return Number.isFinite(Number(station.longitude)) && Number.isFinite(Number(station.latitude)); }
function coords(station) { return [Number(station.longitude), Number(station.latitude)]; } function coords(station) { return [Number(station.longitude), Number(station.latitude)]; }
function stationDistrictName(station) { if (station?.district) return String(station.district).trim(); let address = String(station?.address || '').trim(); for (const prefix of [station?.province, station?.city]) { const normalized = String(prefix || '').trim(); if (normalized) address = address.split(normalized).join(''); } return address.match(/^(.{2,10}?(?:区|县|旗|市))/)?.[1] || ''; } function stationDistrictName(station) { if (station?.district) return String(station.district).trim(); let address = String(station?.address || '').trim(); for (const prefix of [station?.province, station?.city]) { const normalized = String(prefix || '').trim(); if (normalized) address = address.split(normalized).join(''); } return address.match(/^(.{2,10}?(?:区|县|旗|市))/)?.[1] || ''; }
@@ -106,7 +109,14 @@ function renderList() {
document.getElementById('rankViewportMeta').textContent = `当前视野 ${formatNumber(visibleNodes.length)} · 全部 ${formatNumber(allNodes.length)}`; document.getElementById('rankViewportMeta').textContent = `当前视野 ${formatNumber(visibleNodes.length)} · 全部 ${formatNumber(allNodes.length)}`;
document.getElementById('panelRankTitle').textContent = state.userLocation && level === 'station' ? `附近${state.stationFilter === 'partner' ? '合作' : ''}加氢站` : level === 'province' ? '加氢站省份 TOP 排名' : level === 'city' ? '加氢站城市 TOP 排名' : '加氢站 TOP 排名'; document.getElementById('panelRankTitle').textContent = state.userLocation && level === 'station' ? `附近${state.stationFilter === 'partner' ? '合作' : ''}加氢站` : level === 'province' ? '加氢站省份 TOP 排名' : level === 'city' ? '加氢站城市 TOP 排名' : '加氢站 TOP 排名';
if (!display.length) { box.innerHTML = '<div class="rank-empty">未找到匹配站点<br>请尝试名称、地址、城市或拼音首字母</div>'; return; } if (!display.length) { box.innerHTML = '<div class="rank-empty">未找到匹配站点<br>请尝试名称、地址、城市或拼音首字母</div>'; return; }
box.innerHTML = display.slice(0, 100).map((node, index) => { const distance = node.kind === 'station' ? stationDistance(node.station) : Number.POSITIVE_INFINITY, partnerTag = node.kind === 'station' ? `<span class="station-list-partner-tag${node.cooperative ? '' : ' is-placeholder'}"${node.cooperative ? '' : ' aria-hidden="true"'}>${node.cooperative ? '合作' : ''}</span>` : '', rowValue = Number.isFinite(distance) ? `${formatNumber(distance, 1)} km` : node.kind === 'station' ? '' : `${formatNumber(node.count)}`; return `<button type="button" class="rank-glass-row${node.kind === 'station' && state.selected?.id === node.station.id ? ' is-selected' : ''}" data-node-id="${escapeAttribute(node.id)}"><span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${partnerTag}<span class="station-list-name">${escapeHTML(node.name)}</span></span>${rowValue ? `<span class="r-val">${rowValue}</span>` : ''}</button>`; }).join(''); box.innerHTML = display.slice(0, 100).map((node, index) => {
const station = node.kind === 'station' ? node.station : null, distance = station ? stationDistance(station) : Number.POSITIVE_INFINITY;
const partnerTag = station ? `<span class="station-list-partner-tag${node.cooperative ? '' : ' is-placeholder'}"${node.cooperative ? '' : ' aria-hidden="true"'}>${node.cooperative ? '合作' : ''}</span>` : '';
const rowValue = Number.isFinite(distance) ? `${formatNumber(distance, 1)} km` : station ? '' : `${formatNumber(node.count)}`;
const price = station?.cooperative ? stationUnitPrice(station) : '';
const valueStack = price || rowValue ? `<span class="r-value-stack">${price ? `<span class="station-list-price">${escapeHTML(price)}</span>` : ''}${rowValue ? `<span class="r-val">${escapeHTML(rowValue)}</span>` : ''}</span>` : '';
return `<button type="button" class="rank-glass-row${station && state.selected?.id === station.id ? ' is-selected' : ''}" data-node-id="${escapeAttribute(node.id)}"><span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${partnerTag}<span class="station-list-name">${escapeHTML(node.name)}</span></span>${valueStack}</button>`;
}).join('');
box.querySelectorAll('[data-node-id]').forEach(button => button.addEventListener('click', () => { const node = display.find(item => item.id === button.dataset.nodeId); if (node?.kind === 'station') selectStation(node.station, true); else if (node) state.map?.setZoomAndCenter(nextZoomForLevel(node.level), node.lnglat); })); box.querySelectorAll('[data-node-id]').forEach(button => button.addEventListener('click', () => { const node = display.find(item => item.id === button.dataset.nodeId); if (node?.kind === 'station') selectStation(node.station, true); else if (node) state.map?.setZoomAndCenter(nextZoomForLevel(node.level), node.lnglat); }));
} }
function compareNodes(left, right) { if (state.userLocation && left.kind === 'station' && right.kind === 'station') return stationDistance(left.station) - stationDistance(right.station); return right.count - left.count || String(left.name || '').localeCompare(String(right.name || ''), 'zh-CN'); } function compareNodes(left, right) { if (state.userLocation && left.kind === 'station' && right.kind === 'station') return stationDistance(left.station) - stationDistance(right.station); return right.count - left.count || String(left.name || '').localeCompare(String(right.name || ''), 'zh-CN'); }
@@ -117,7 +127,7 @@ function fuzzySearchScore(value, query) {
const pinyin = toPinyin(value); if (pinyin === needle || pinyin.startsWith(needle) || initials(pinyin).startsWith(needle)) return 3; const pinyin = toPinyin(value); if (pinyin === needle || pinyin.startsWith(needle) || initials(pinyin).startsWith(needle)) return 3;
return pinyin.includes(needle) || initials(pinyin).includes(needle) ? 4 : Number.POSITIVE_INFINITY; return pinyin.includes(needle) || initials(pinyin).includes(needle) ? 4 : Number.POSITIVE_INFINITY;
} }
function compactPath(parts) { return parts.filter(Boolean).join(' · '); } function compactPath(parts) { return parts.filter(Boolean).filter((part, index, list) => index === 0 || adminNameKey(part) !== adminNameKey(list[index - 1])).join(' · '); }
function stationLocationOptions() { function stationLocationOptions() {
const groups = new Map(); const groups = new Map();
const add = (level, station) => { const add = (level, station) => {
@@ -127,12 +137,13 @@ function stationLocationOptions() {
if (!groups.has(key)) groups.set(key, { type: 'location', level, ...filter, stations: [] }); if (!groups.has(key)) groups.set(key, { type: 'location', level, ...filter, stations: [] });
groups.get(key).stations.push(station); groups.get(key).stations.push(station);
}; };
for (const station of stationTypeScope()) { const district = stationDistrictName(station); if (station.province) add('province', station); if (station.province && station.city) add('city', station); if (station.province && station.city && district) add('district', { ...station, district }); } for (const station of stationTypeScope()) { const district = stationDistrictName(station); if (station.province) add('province', station); if (station.province && station.city && !isMunicipalityCity(station.province, station.city)) add('city', station); if (station.province && station.city && district) add('district', { ...station, district }); }
return [...groups.values()].map(option => { return [...groups.values()].map(option => {
const target = option.level === 'province' ? option.province : option.level === 'city' ? option.city : option.district; const target = option.level === 'province' ? option.province : option.level === 'city' ? option.city : option.district;
const longitude = option.stations.reduce((sum, station) => sum + Number(station.longitude), 0) / option.stations.length; const longitude = option.stations.reduce((sum, station) => sum + Number(station.longitude), 0) / option.stations.length;
const latitude = option.stations.reduce((sum, station) => sum + Number(station.latitude), 0) / option.stations.length; const latitude = option.stations.reduce((sum, station) => sum + Number(station.latitude), 0) / option.stations.length;
return { ...option, label: stationGroupName({ province: target, city: target }, option.level === 'province' ? 'province' : 'city'), path: compactPath([option.province, option.city, option.district]), lnglat: [longitude, latitude] }; const label = option.level === 'district' ? option.district : stationGroupName({ province: target, city: target }, option.level === 'province' ? 'province' : 'city');
return { ...option, label, path: compactPath([option.province, option.city, option.district]), lnglat: [longitude, latitude] };
}); });
} }
function stationLocationSuggestions(query) { function stationLocationSuggestions(query) {
@@ -180,9 +191,10 @@ function regionPickerOptions() {
const picker = state.regionPicker; if (!picker) return []; const picker = state.regionPicker; if (!picker) return [];
const all = stationLocationOptions(); const needle = normalize(picker.search); const all = stationLocationOptions(); const needle = normalize(picker.search);
if (needle) return all.map(option => ({ ...option, score: Math.min(fuzzySearchScore(option.label, needle), fuzzySearchScore(option.path, needle)) })).filter(option => Number.isFinite(option.score)).sort((left, right) => left.score - right.score || left.label.localeCompare(right.label, 'zh-CN')).slice(0, 80); if (needle) return all.map(option => ({ ...option, score: Math.min(fuzzySearchScore(option.label, needle), fuzzySearchScore(option.path, needle)) })).filter(option => Number.isFinite(option.score)).sort((left, right) => left.score - right.score || left.label.localeCompare(right.label, 'zh-CN')).slice(0, 80);
if (picker.level === 'province') return all.filter(option => option.level === 'province'); if (picker.level === 'province') return all.filter(option => option.level === 'province').sort((left, right) => right.stations.length - left.stations.length || left.label.localeCompare(right.label, 'zh-CN'));
if (picker.level === 'city' && isMunicipalityProvince(picker.province)) return all.filter(option => option.level === 'district' && option.province === picker.province);
if (picker.level === 'city') return all.filter(option => option.level === 'city' && option.province === picker.province); if (picker.level === 'city') return all.filter(option => option.level === 'city' && option.province === picker.province);
return all.filter(option => option.level === 'district' && option.province === picker.province && option.city === picker.city); return all.filter(option => option.level === 'district' && option.province === picker.province && (!picker.city || option.city === picker.city));
} }
function regionPickerOptionPath(option) { function regionPickerOptionPath(option) {
const parts = option.level === 'province' ? [option.province] : option.level === 'city' ? [option.province, option.city] : [option.province, option.city, option.district]; const parts = option.level === 'province' ? [option.province] : option.level === 'city' ? [option.province, option.city] : [option.province, option.city, option.district];
@@ -213,7 +225,7 @@ function renderRegionPicker() {
} }
function setRegionPickerSearch(value) { if (!state.regionPicker) return; state.regionPicker.search = value; renderRegionPicker(); } function setRegionPickerSearch(value) { if (!state.regionPicker) return; state.regionPicker.search = value; renderRegionPicker(); }
function navigateRegionPicker(level) { if (!state.regionPicker) return; state.regionPicker.search = ''; if (level === 'province') { state.regionPicker.level = 'province'; state.regionPicker.province = ''; state.regionPicker.city = ''; } else if (level === 'city') { state.regionPicker.level = 'city'; state.regionPicker.city = ''; } else state.regionPicker.level = 'district'; renderRegionPicker(); } function navigateRegionPicker(level) { if (!state.regionPicker) return; state.regionPicker.search = ''; if (level === 'province') { state.regionPicker.level = 'province'; state.regionPicker.province = ''; state.regionPicker.city = ''; } else if (level === 'city') { state.regionPicker.level = 'city'; state.regionPicker.city = ''; } else state.regionPicker.level = 'district'; renderRegionPicker(); }
function descendRegionPicker(index) { const option = state.regionPicker?.options[index]; if (!option || option.level === 'district') return; state.regionPicker.search = ''; state.regionPicker.level = option.level === 'province' ? 'city' : 'district'; state.regionPicker.province = option.province; state.regionPicker.city = option.city || ''; renderRegionPicker(); } function descendRegionPicker(index) { const option = state.regionPicker?.options[index]; if (!option || option.level === 'district') return; state.regionPicker.search = ''; state.regionPicker.level = option.level === 'province' && isMunicipalityProvince(option.province) ? 'district' : option.level === 'province' ? 'city' : 'district'; state.regionPicker.province = option.province; state.regionPicker.city = option.level === 'province' && isMunicipalityProvince(option.province) ? '' : option.city || ''; renderRegionPicker(); }
function toggleRegionPickerOption(index) { const option = state.regionPicker?.options[index]; if (!option) return; const key = locationFilterKey(option), draft = state.regionPicker.draft; const found = draft.findIndex(filter => locationFilterKey(filter) === key); if (found >= 0) draft.splice(found, 1); else draft.push(cloneLocationFilter(option)); renderRegionPicker(); } function toggleRegionPickerOption(index) { const option = state.regionPicker?.options[index]; if (!option) return; const key = locationFilterKey(option), draft = state.regionPicker.draft; const found = draft.findIndex(filter => locationFilterKey(filter) === key); if (found >= 0) draft.splice(found, 1); else draft.push(cloneLocationFilter(option)); renderRegionPicker(); }
function removeRegionPickerDraft(index) { if (!state.regionPicker) return; state.regionPicker.draft.splice(index, 1); renderRegionPicker(); } function removeRegionPickerDraft(index) { if (!state.regionPicker) return; state.regionPicker.draft.splice(index, 1); renderRegionPicker(); }
function clearRegionPickerDraft() { if (!state.regionPicker) return; state.regionPicker.draft = []; renderRegionPicker(); } function clearRegionPickerDraft() { if (!state.regionPicker) return; state.regionPicker.draft = []; renderRegionPicker(); }
@@ -239,11 +251,14 @@ function selectStation(station, fit = false) {
if (!station) return; state.selected = station; if (fit) state.map?.setZoomAndCenter(14.5, coords(station)); if (!station) return; state.selected = station; if (fit) state.map?.setZoomAndCenter(14.5, coords(station));
document.getElementById('detailName').textContent = station.name || station.shortName || '未命名站点'; document.getElementById('detailRegion').textContent = stationRegion(station); document.getElementById('detailName').textContent = station.name || station.shortName || '未命名站点'; document.getElementById('detailRegion').textContent = stationRegion(station);
const cooperative = Boolean(station.cooperative), tag = document.getElementById('detailType'); tag.textContent = cooperative ? '合作站' : '外部站'; tag.classList.toggle('is-station', cooperative); const cooperative = Boolean(station.cooperative), tag = document.getElementById('detailType'); tag.textContent = cooperative ? '合作站' : '外部站'; tag.classList.toggle('is-station', cooperative);
const distance = stationDistance(station); document.getElementById('detailFields').innerHTML = `${detailField('站点状态', cooperative ? '合作站点' : '外部站点')}${detailField('行政区域', stationRegion(station))}${detailField('详细地址', station.address || '暂无详细地址', true)}${Number.isFinite(distance) ? detailField('距离我', `${formatNumber(distance, 1)} km`) : ''}`; const distance = stationDistance(station), partnerFields = cooperative ? `${detailField('单价', stationUnitPrice(station) || '暂无')}${detailField('联系人', station.contactPerson || '暂无')}${detailPhoneField('联系方式', station.contactPhone)}` : '';
document.getElementById('detailFields').innerHTML = `${detailField('站点状态', cooperative ? '合作站点' : '外部站点')}${partnerFields}${detailField('行政区域', stationRegion(station))}${detailField('详细地址', station.address || '暂无详细地址', true)}${Number.isFinite(distance) ? detailField('距离我', `${formatNumber(distance, 1)} km`) : ''}`;
document.getElementById('stationDetail').hidden = false; renderMarkers(); renderList(); document.getElementById('stationDetail').hidden = false; renderMarkers(); renderList();
} }
function detailField(label, value, full = false) { return `<div class="detail-field${full ? ' is-full' : ''}"><dt>${escapeHTML(label)}</dt><dd>${escapeHTML(value)}</dd></div>`; } function detailField(label, value, full = false) { return `<div class="detail-field${full ? ' is-full' : ''}"><dt>${escapeHTML(label)}</dt><dd>${escapeHTML(value)}</dd></div>`; }
function stationRegion(station) { return [station.province, station.city, stationDistrictName(station)].filter(value => value && value !== '[]').filter((value, index, list) => index === 0 || value !== list[index - 1]).join(' · '); } function detailPhoneField(label, phone) { const value = String(phone || '').trim(), href = value.replace(/[^\d+]/g, ''); return `<div class="detail-field"><dt>${escapeHTML(label)}</dt><dd>${href ? `<a class="detail-phone-link" href="tel:${escapeAttribute(href)}">${escapeHTML(value)}</a>` : '暂无'}</dd></div>`; }
function stationUnitPrice(station) { const value = Number(station?.unitPrice); return Number.isFinite(value) && value > 0 ? `¥${formatNumber(value, 2)} / kg` : ''; }
function stationRegion(station) { return compactPath([station.province, station.city, stationDistrictName(station)].filter(value => value && value !== '[]')); }
function closeDetail() { const detail = document.getElementById('stationDetail'); if (detail) detail.hidden = true; } function closeDetail() { const detail = document.getElementById('stationDetail'); if (detail) detail.hidden = true; }
function locateMe() { if (!navigator.geolocation) return locationFailed('当前设备不支持定位'); const button = document.getElementById('locateDeviceBtn'); button.disabled = true; button.classList.remove('is-error'); document.getElementById('locateDeviceLabel').textContent = '定位中'; navigator.geolocation.getCurrentPosition(position => { state.userLocation = wgs84ToGcj02(Number(position.coords.longitude), Number(position.coords.latitude)); button.disabled = false; button.classList.add('is-active'); document.getElementById('locateDeviceLabel').textContent = '已定位'; state.map?.setZoomAndCenter(13, state.userLocation); document.getElementById('selectedRegionHint').textContent = '视角: 我的位置附近'; render(); }, error => locationFailed(error?.code === 1 ? '请允许位置权限' : '定位失败'), { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 }); } function locateMe() { if (!navigator.geolocation) return locationFailed('当前设备不支持定位'); const button = document.getElementById('locateDeviceBtn'); button.disabled = true; button.classList.remove('is-error'); document.getElementById('locateDeviceLabel').textContent = '定位中'; navigator.geolocation.getCurrentPosition(position => { state.userLocation = wgs84ToGcj02(Number(position.coords.longitude), Number(position.coords.latitude)); button.disabled = false; button.classList.add('is-active'); document.getElementById('locateDeviceLabel').textContent = '已定位'; state.map?.setZoomAndCenter(13, state.userLocation); document.getElementById('selectedRegionHint').textContent = '视角: 我的位置附近'; render(); }, error => locationFailed(error?.code === 1 ? '请允许位置权限' : '定位失败'), { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 }); }
@@ -254,7 +269,7 @@ function distanceKm(origin, target) { const radians = value => value * Math.PI /
function resetMap() { state.map?.setZoomAndCenter(4.8, [108.948024, 34.263161]); state.map?.setPitch(30); document.getElementById('selectedRegionHint').textContent = '视角: 全国加氢站网络'; } function resetMap() { state.map?.setZoomAndCenter(4.8, [108.948024, 34.263161]); state.map?.setPitch(30); document.getElementById('selectedRegionHint').textContent = '视角: 全国加氢站网络'; }
function toggleMapPitch() { state.pitch = !state.pitch; state.map?.setPitch(state.pitch ? 30 : 0); } function toggleMapPitch() { state.pitch = !state.pitch; state.map?.setPitch(state.pitch ? 30 : 0); }
function setTheme(theme) { document.body.className = theme; document.querySelectorAll('.bw-btn').forEach(button => button.classList.remove('active')); document.querySelector(theme === 'theme-white' ? '.theme-white-btn' : '.theme-dark-btn')?.classList.add('active'); state.map?.setMapStyle(theme === 'theme-white' ? 'amap://styles/light' : 'amap://styles/darkblue'); } function setTheme(theme) { document.body.className = theme; document.querySelectorAll('.bw-btn').forEach(button => button.classList.remove('active')); document.querySelector(theme === 'theme-white' ? '.theme-white-btn' : '.theme-dark-btn')?.classList.add('active'); state.map?.setMapStyle(theme === 'theme-white' ? 'amap://styles/light' : 'amap://styles/darkblue'); }
function navigateToStation() { if (!state.selected) return; const [lng, lat] = coords(state.selected), name = encodeURIComponent(state.selected.name || state.selected.shortName || '加氢站'); const overlay = document.getElementById('navigationOverlay'); overlay.hidden = false; window.setTimeout(() => window.location.assign(`https://uri.amap.com/navigation?to=${lng.toFixed(6)},${lat.toFixed(6)},${name}&mode=car&coordinate=gaode&callnative=1`), 140); } function navigateToStation() { if (!state.selected) return; const [lng, lat] = coords(state.selected), name = encodeURIComponent(state.selected.name || state.selected.shortName || '加氢站'), target = window.open('', '_blank'); const overlay = document.getElementById('navigationOverlay'); overlay.hidden = false; window.setTimeout(() => { const url = `https://uri.amap.com/navigation?to=${lng.toFixed(6)},${lat.toFixed(6)},${name}&mode=car&coordinate=gaode&callnative=1`; if (target) { target.opener = null; target.location.href = url; } else window.open(url, '_blank', 'noopener'); }, 140); }
function setStatus(text, error = false) { const status = document.getElementById('mapStatusText'); if (status) status.textContent = text; status?.parentElement?.classList.toggle('is-error', error); } function setStatus(text, error = false) { const status = document.getElementById('mapStatusText'); if (status) status.textContent = text; status?.parentElement?.classList.toggle('is-error', error); }
function setHTML(id, value) { const element = document.getElementById(id); if (element) element.innerHTML = value; } function setHTML(id, value) { const element = document.getElementById(id); if (element) element.innerHTML = value; }
function formatNumber(value, digits = 0) { return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: digits, minimumFractionDigits: digits }); } function formatNumber(value, digits = 0) { return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: digits, minimumFractionDigits: digits }); }
+48 -6
View File
@@ -31,6 +31,8 @@ STATION_CACHE_SECONDS = int(os.getenv("STATION_NAVIGATION_CACHE_SECONDS", "120")
VEHICLE_MAP_INTERNAL_BASE_URL = os.getenv("VEHICLE_MAP_INTERNAL_BASE_URL", "http://127.0.0.1:20800").rstrip("/") VEHICLE_MAP_INTERNAL_BASE_URL = os.getenv("VEHICLE_MAP_INTERNAL_BASE_URL", "http://127.0.0.1:20800").rstrip("/")
_cache_lock = threading.Lock() _cache_lock = threading.Lock()
_cache = {} _cache = {}
_cache_load_locks = {}
_cache_refreshing = set()
def _static_asset_version(): def _static_asset_version():
@@ -80,16 +82,51 @@ 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):
threading.Thread(target=_refresh_cached_value, args=(key, loader), daemon=True).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]
value = loader() if cached:
with _cache_lock: if key not in _cache_refreshing:
_cache[key] = (now, value) _cache_refreshing.add(key)
return value refresh_in_background = True
stale_value = cached[1]
else:
stale_value = None
load_lock = _cache_load_locks.setdefault(key, threading.Lock())
if cached:
if refresh_in_background:
_start_cache_refresh(key, loader)
return stale_value
with load_lock:
with _cache_lock:
cached = _cache.get(key)
if cached:
return _cached(key, ttl_seconds, loader)
value = loader()
with _cache_lock:
_cache[key] = (time.time(), value)
return value
def _load_station_directory(): def _load_station_directory():
@@ -137,7 +174,7 @@ class StationNavigationHandler(SimpleHTTPRequestHandler):
return return
if path == "/api/stations": if path == "/api/stations":
try: try:
self._write_json(200, _cached("station-directory", STATION_CACHE_SECONDS, _load_station_directory)) payload = _cached("station-directory", STATION_CACHE_SECONDS, _load_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, {
@@ -145,6 +182,8 @@ class StationNavigationHandler(SimpleHTTPRequestHandler):
"code": "UPSTREAM_UNAVAILABLE", "code": "UPSTREAM_UNAVAILABLE",
"message": "加氢站数据暂时不可用,请稍后重试", "message": "加氢站数据暂时不可用,请稍后重试",
}) })
return
self._write_json(200, payload)
return return
if path == "/assets/logo_light.svg": if path == "/assets/logo_light.svg":
self._serve_logo() self._serve_logo()
@@ -213,7 +252,10 @@ class StationNavigationHandler(SimpleHTTPRequestHandler):
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)))
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__":
+5
View File
@@ -66,6 +66,9 @@
.station-navigation-shell .station-list-name { min-width: 0; overflow-wrap: anywhere; white-space: normal; } .station-navigation-shell .station-list-name { min-width: 0; overflow-wrap: anywhere; white-space: normal; }
.station-navigation-shell .station-list-partner-tag { flex: 0 0 30px; min-width: 30px; margin-top: 1px; padding: 2px 5px; border-radius: 4px; background: rgba(0,113,67,.1); color: var(--accent-primary); font-size: 9px; font-weight: 700; line-height: 1; text-align: center; } .station-navigation-shell .station-list-partner-tag { flex: 0 0 30px; min-width: 30px; margin-top: 1px; padding: 2px 5px; border-radius: 4px; background: rgba(0,113,67,.1); color: var(--accent-primary); font-size: 9px; font-weight: 700; line-height: 1; text-align: center; }
.station-navigation-shell .station-list-partner-tag.is-placeholder { visibility: hidden; } .station-navigation-shell .station-list-partner-tag.is-placeholder { visibility: hidden; }
.station-navigation-shell .r-value-stack { display: flex; flex: 0 0 auto; align-items: flex-end; gap: 4px; margin-left: auto; }
.station-navigation-shell .station-list-price { color: var(--accent-primary); font: 700 9px/1 'JetBrains Mono', monospace; letter-spacing: -.03em; white-space: nowrap; }
.station-navigation-shell .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; }
.station-navigation-shell .rank-empty { display: grid; min-height: 160px; place-items: center; color: var(--text-muted); font-size: 12px; text-align: center; } .station-navigation-shell .rank-empty { display: grid; min-height: 160px; place-items: center; color: var(--text-muted); font-size: 12px; text-align: center; }
.station-navigation-shell .map-detail-card .detail-grid { margin-bottom: 0; } .station-navigation-shell .map-detail-card .detail-grid { margin-bottom: 0; }
@media (max-width: 760px) { @media (max-width: 760px) {
@@ -209,6 +212,8 @@
.station-navigation-shell .status-glass-grid3 { gap: 5px; margin-bottom: 7px; } .station-navigation-shell .status-glass-grid3 { gap: 5px; margin-bottom: 7px; }
.station-navigation-shell .glass-cell { padding: 6px; } .station-navigation-shell .glass-cell { padding: 6px; }
.station-navigation-shell .rank-glass-row { min-height: 42px; padding: 7px 8px; } .station-navigation-shell .rank-glass-row { min-height: 42px; padding: 7px 8px; }
.station-navigation-shell .r-value-stack { flex-direction: column; align-items: flex-end; gap: 3px; }
.station-navigation-shell .station-list-price { font-size: 8px; }
} }
@media (max-width: 390px) { @media (max-width: 390px) {
+48 -1
View File
@@ -63,6 +63,30 @@ vm.runInNewContext(`
assert.equal(sandbox.regionProvinceLabels, '广东,浙江'); assert.equal(sandbox.regionProvinceLabels, '广东,浙江');
assert.equal(sandbox.regionCityLabels, '佛山,广州'); assert.equal(sandbox.regionCityLabels, '佛山,广州');
assert.match(sandbox.regionSearchLabels, /嘉兴/); assert.match(sandbox.regionSearchLabels, /嘉兴/);
vm.runInNewContext(`
state.stations = [
{ id: 'sh-hp', name: '上海黄浦站', province: '上海市', city: '上海市', district: '黄浦区', longitude: 121.5, latitude: 31.2, cooperative: true },
{ id: 'sh-pd', name: '上海浦东站', province: '上海市', city: '上海市', district: '浦东新区', longitude: 121.6, latitude: 31.2, cooperative: true },
{ id: 'gd-gz', name: '广州站', province: '广东省', city: '广州市', district: '天河区', longitude: 113.3, latitude: 23.1, cooperative: true },
{ id: 'zj-hz-1', name: '杭州站一', province: '浙江省', city: '杭州市', district: '西湖区', longitude: 120.1, latitude: 30.2, cooperative: true },
{ id: 'zj-hz-2', name: '杭州站二', province: '浙江省', city: '杭州市', district: '上城区', longitude: 120.2, latitude: 30.2, cooperative: true },
{ id: 'zj-hz-3', name: '杭州站三', province: '浙江省', city: '杭州市', district: '拱墅区', longitude: 120.3, latitude: 30.2, cooperative: true }
];
globalThis.shanghaiCityOption = stationLocationOptions().find(option => option.level === 'city' && option.province === '上海市');
state.regionPicker = { level: 'province', province: '', city: '', search: '', draft: [], options: [] };
globalThis.primaryRegionLabels = regionPickerOptions().map(option => option.label).join(',');
state.regionPicker = { level: 'city', province: '上海市', city: '', search: '', draft: [], options: [] };
globalThis.shanghaiDistrictLabels = regionPickerOptions().map(option => option.label).sort().join(',');
globalThis.shanghaiPath = compactPath(['上海市', '上海市', '黄浦区']);
globalThis.shanghaiRegion = stationRegion(state.stations[0]);
globalThis.directMunicipalityFlags = ['北京市', '上海市', '天津市', '重庆市'].map(isMunicipalityProvince).join(',');
`, sandbox);
assert.equal(sandbox.shanghaiCityOption, undefined);
assert.equal(sandbox.primaryRegionLabels, '浙江,上海,广东');
assert.equal(sandbox.shanghaiDistrictLabels, '浦东新区,黄浦区');
assert.equal(sandbox.shanghaiPath, '上海市 · 黄浦区');
assert.equal(sandbox.shanghaiRegion, '上海市 · 黄浦区');
assert.equal(sandbox.directMunicipalityFlags, 'true,true,true,true');
vm.runInNewContext(` vm.runInNewContext(`
globalThis.inferredDistrict = stationDistrictName({ province: '浙江省', city: '嘉兴市', address: '嘉兴市海盐县海盐经济开发区' }); globalThis.inferredDistrict = stationDistrictName({ province: '浙江省', city: '嘉兴市', address: '嘉兴市海盐县海盐经济开发区' });
state.locationFilters = []; state.locationFilters = [];
@@ -87,6 +111,26 @@ vm.runInNewContext(`
`, sandbox); `, sandbox);
assert.equal(sandbox.multiLocationMatches.join(','), 'true,true,false'); assert.equal(sandbox.multiLocationMatches.join(','), 'true,true,false');
assert.ok(Math.abs(sandbox.distanceKm([113, 23], [114, 23]) - 102.4) < 1); assert.ok(Math.abs(sandbox.distanceKm([113, 23], [114, 23]) - 102.4) < 1);
assert.equal(sandbox.stationUnitPrice({ unitPrice: 12.5 }), '¥12.50 / kg');
assert.equal(sandbox.stationUnitPrice({ unitPrice: 0 }), '');
assert.match(sandbox.detailPhoneField('联系方式', '138 0000 0000'), /href="tel:13800000000"/);
vm.runInNewContext(`
const detailNodes = {
detailName: { textContent: '' }, detailRegion: { textContent: '' },
detailType: { textContent: '', classList: { toggle() {} } },
detailFields: { innerHTML: '' }, stationDetail: { hidden: true }
};
document.getElementById = id => detailNodes[id] || null;
state.map = null; state.selected = null;
selectStation({ id: 'partner-detail', name: '合作站', cooperative: true, province: '广东省', city: '广州市', district: '黄埔区', longitude: 113.3, latitude: 23.1, contactPerson: '张工', contactPhone: '138 0000 0000', unitPrice: 12.5 });
globalThis.partnerDetailHtml = detailNodes.detailFields.innerHTML;
selectStation({ id: 'external-detail', name: '外部站', cooperative: false, province: '广东省', city: '广州市', district: '黄埔区', longitude: 113.4, latitude: 23.1, contactPerson: '李工', contactPhone: '13900000000', unitPrice: 10.5 });
globalThis.externalDetailHtml = detailNodes.detailFields.innerHTML;
`, sandbox);
assert.match(sandbox.partnerDetailHtml, /张工/);
assert.match(sandbox.partnerDetailHtml, /tel:13800000000/);
assert.match(sandbox.partnerDetailHtml, /¥12\.50 \/ kg/);
assert.doesNotMatch(sandbox.externalDetailHtml, /李工|13900000000|¥10\.50/);
assert.match(source, /zoom < 11\) return 'city'/); assert.match(source, /zoom < 11\) return 'city'/);
assert.match(source, /state\.userLocation && left\.kind === 'station'/); assert.match(source, /state\.userLocation && left\.kind === 'station'/);
assert.doesNotMatch(source, /rankSecondaryTab|setRank\(/); assert.doesNotMatch(source, /rankSecondaryTab|setRank\(/);
@@ -106,12 +150,15 @@ assert.match(source, /focusSearchResults\(\)/);
assert.match(source, /visibleOnly && !normalize\(state\.query\)/); assert.match(source, /visibleOnly && !normalize\(state\.query\)/);
assert.match(source, /station-list-partner-tag/); assert.match(source, /station-list-partner-tag/);
assert.match(source, /station-list-partner-tag\$\{node\.cooperative \? '' : ' is-placeholder'\}/); assert.match(source, /station-list-partner-tag\$\{node\.cooperative \? '' : ' is-placeholder'\}/);
assert.match(source, /node\.kind === 'station' \? '' : `\$\{formatNumber\(node\.count\)\} 座`/); assert.match(source, /const rowValue = Number\.isFinite\(distance\)/);
assert.match(source, /node\.cooperative \? 'H₂ · 合作' : ''/); assert.match(source, /node\.cooperative \? 'H₂ · 合作' : ''/);
assert.doesNotMatch(source, /在线 \$\{formatNumber\(node\.online\)\}/); assert.doesNotMatch(source, /在线 \$\{formatNumber\(node\.online\)\}/);
assert.doesNotMatch(source, /monthlyHydrogenKg|totalHydrogenKg|vehicle/); assert.doesNotMatch(source, /monthlyHydrogenKg|totalHydrogenKg|vehicle/);
assert.match(source, /uri\.amap\.com\/navigation/); assert.match(source, /uri\.amap\.com\/navigation/);
assert.match(source, /window\.open\('', '_blank'\)/);
assert.doesNotMatch(source, /window\.location\.assign/);
assert.equal((source.match(/function navigateToStation\(/g) || []).length, 1); assert.equal((source.match(/function navigateToStation\(/g) || []).length, 1);
assert.match(source, /station-list-price/);
assert.match(styles, /station-summary-strip/); assert.match(styles, /station-summary-strip/);
assert.match(styles, /header-kpi-group,\n \.station-navigation-shell \.station-status-card/); assert.match(styles, /header-kpi-group,\n \.station-navigation-shell \.station-status-card/);
assert.match(styles, /theme-switcher-bw \.bw-btn span \{ display: none; \}/); assert.match(styles, /theme-switcher-bw \.bw-btn span \{ display: none; \}/);
+25
View File
@@ -2,6 +2,8 @@ import importlib.util
import io import io
import json import json
from pathlib import Path from pathlib import Path
import threading
import time
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
@@ -49,6 +51,29 @@ class StationNavigationTest(unittest.TestCase):
self.assertEqual(calls, ["load"]) self.assertEqual(calls, ["load"])
self.assertEqual(first, second) self.assertEqual(first, second)
def test_expired_directory_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 = {"station-directory": (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("station-directory", 120, loader)
self.assertEqual(result, {"version": 1})
self.assertTrue(started.wait(1))
release.set()
for _ in range(100):
if cache["station-directory"][1] == {"version": 2}:
break
time.sleep(0.01)
self.assertEqual(cache["station-directory"][1], {"version": 2})
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()