Files
PolyWeather/extension/sidepanel.js
T

909 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const DEFAULT_CONFIG = {
apiBase: "https://polyweather-pro.vercel.app",
authToken: "",
selectedCity: "",
siteBase: "https://polyweather-pro.vercel.app"
};
const CACHE_VERSION = "v1";
let state = {
config: { ...DEFAULT_CONFIG },
cities: [],
detail: null,
lastActiveUrl: "",
syncBusy: false,
loadingCount: 0,
chartHover: {
points: [],
tempSymbol: "°C"
}
};
const els = {
citySelect: document.getElementById("citySelect"),
refreshBtn: document.getElementById("refreshBtn"),
riskBadge: document.getElementById("riskBadge"),
settlementLabel: document.getElementById("settlementLabel"),
settlementValue: document.getElementById("settlementValue"),
distanceValue: document.getElementById("distanceValue"),
obsTimeValue: document.getElementById("obsTimeValue"),
nearbyValue: document.getElementById("nearbyValue"),
trendCanvas: document.getElementById("trendCanvas"),
chartTooltip: document.getElementById("chartTooltip"),
chartLegend: document.getElementById("chartLegend"),
forecastRow: document.getElementById("forecastRow"),
errorBox: document.getElementById("errorBox"),
openFullBtn: document.getElementById("openFullBtn"),
loadingOverlay: document.getElementById("loadingOverlay"),
loadingText: document.getElementById("loadingText")
};
function normalizeBase(url) {
return String(url || "").trim().replace(/\/+$/, "");
}
function buildCacheKey(kind, cityName = "") {
const apiBase = normalizeBase(state.config?.apiBase || DEFAULT_CONFIG.apiBase);
const city = String(cityName || "").trim().toLowerCase();
return `polyweather:${CACHE_VERSION}:${apiBase}:${kind}:${city}`;
}
async function getLocalValue(key) {
return new Promise((resolve) => {
chrome.storage.local.get([key], (items) => {
resolve(items?.[key] ?? null);
});
});
}
async function setLocalValue(key, value) {
return new Promise((resolve) => {
chrome.storage.local.set({ [key]: value }, resolve);
});
}
function normalizeForMatch(value) {
let out = decodeURIComponent(String(value || "")).toLowerCase();
try {
out = out.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
} catch (_e) {
// Ignore unicode normalization failures.
}
return out;
}
function escapeRegExp(value) {
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function getCityAliasTokens(rawCityName) {
const normalized = normalizeForMatch(rawCityName).trim().replace(/\s+/g, " ");
const compact = normalized.replace(/\s+/g, "");
const hyphen = normalized.replace(/\s+/g, "-");
const aliases = new Set([normalized, compact, hyphen]);
if (normalized === "hong kong") {
aliases.add("hongkong");
aliases.add("hong-kong");
aliases.add("hk");
}
if (normalized === "taipei") {
aliases.add("taipei-city");
aliases.add("tp");
aliases.add("tpe");
}
if (normalized === "new york") {
aliases.add("new-york");
aliases.add("nyc");
}
if (normalized === "sao paulo") {
aliases.add("sao-paulo");
aliases.add("saopaulo");
}
if (normalized === "tel aviv") {
aliases.add("tel-aviv");
aliases.add("telaviv");
}
if (normalized === "buenos aires") {
aliases.add("buenos-aires");
aliases.add("buenosaires");
}
return [...aliases].filter((item) => item && item.length >= 2);
}
function getCityAliasIndex() {
const out = [];
for (const city of state.cities) {
const names = [
String(city?.name || ""),
String(city?.display_name || "")
];
const seen = new Set();
for (const name of names) {
for (const alias of getCityAliasTokens(name)) {
if (seen.has(alias)) continue;
seen.add(alias);
out.push({ alias, cityName: city.name });
}
}
}
out.sort((a, b) => b.alias.length - a.alias.length);
return out;
}
function matchCityInText(text) {
const target = normalizeForMatch(text);
if (!target) return "";
const aliasIndex = getCityAliasIndex();
for (const row of aliasIndex) {
const alias = row.alias;
if (!alias) continue;
if (/^[a-z0-9-]+$/.test(alias)) {
const re = new RegExp(`(^|[^a-z0-9])${escapeRegExp(alias)}([^a-z0-9]|$)`);
if (re.test(target)) return row.cityName;
} else if (target.includes(alias)) {
return row.cityName;
}
}
return "";
}
function inferCityFromUrl(url) {
if (!url) return "";
let parsed = null;
try {
parsed = new URL(url);
} catch (_e) {
parsed = null;
}
if (parsed) {
const queryCity = parsed.searchParams.get("city") || parsed.searchParams.get("c");
if (queryCity) {
const byQuery = matchCityInText(queryCity);
if (byQuery) return byQuery;
}
const hostPath = `${parsed.hostname} ${parsed.pathname} ${parsed.hash || ""}`;
const byPath = matchCityInText(hostPath);
if (byPath) return byPath;
}
return matchCityInText(url);
}
function showError(message) {
els.errorBox.textContent = message;
els.errorBox.classList.remove("hidden");
}
function clearError() {
els.errorBox.textContent = "";
els.errorBox.classList.add("hidden");
}
function setLoading(loading, text = "正在加载温度数据...") {
if (!els.loadingOverlay) return;
if (loading) {
state.loadingCount += 1;
} else {
state.loadingCount = Math.max(0, state.loadingCount - 1);
}
const isVisible = state.loadingCount > 0;
els.loadingOverlay.classList.toggle("hidden", !isVisible);
if (isVisible && els.loadingText) {
els.loadingText.textContent = text;
}
}
function hideChartTooltip() {
if (!els.chartTooltip) return;
els.chartTooltip.classList.add("hidden");
}
function setChartHover(points, tempSymbol) {
state.chartHover = {
points: Array.isArray(points) ? points : [],
tempSymbol: tempSymbol || "°C"
};
if (!state.chartHover.points.length) {
hideChartTooltip();
}
}
function onTrendCanvasHover(event) {
const canvas = els.trendCanvas;
const tooltip = els.chartTooltip;
const hoverPoints = state.chartHover?.points || [];
if (!canvas || !tooltip || !hoverPoints.length) {
hideChartTooltip();
return;
}
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / Math.max(rect.width, 1);
const scaleY = canvas.height / Math.max(rect.height, 1);
const x = (event.clientX - rect.left) * scaleX;
const y = (event.clientY - rect.top) * scaleY;
if (x < 0 || y < 0 || x > canvas.width || y > canvas.height) {
hideChartTooltip();
return;
}
let nearest = null;
let nearestDistSq = Number.POSITIVE_INFINITY;
for (const p of hoverPoints) {
const dx = p.x - x;
const dy = p.y - y;
const distSq = dx * dx + dy * dy;
if (distSq < nearestDistSq) {
nearestDistSq = distSq;
nearest = p;
}
}
if (!nearest) {
hideChartTooltip();
return;
}
const symbol = state.chartHover?.tempSymbol || "°C";
tooltip.textContent = `${nearest.series} ${nearest.time} ${nearest.value.toFixed(1)}${symbol}`;
tooltip.classList.remove("hidden");
const wrap = canvas.parentElement;
if (!wrap) return;
const wrapRect = wrap.getBoundingClientRect();
const localX = event.clientX - wrapRect.left;
const localY = event.clientY - wrapRect.top;
let left = localX + 10;
let top = localY - 28;
const tipW = tooltip.offsetWidth || 120;
const tipH = tooltip.offsetHeight || 28;
if (left + tipW + 8 > wrap.clientWidth) left = wrap.clientWidth - tipW - 8;
if (left < 8) left = 8;
if (top < 8) top = localY + 12;
if (top + tipH + 8 > wrap.clientHeight) top = wrap.clientHeight - tipH - 8;
tooltip.style.left = `${Math.round(left)}px`;
tooltip.style.top = `${Math.round(top)}px`;
}
function setRefreshing(isRefreshing) {
if (!els.refreshBtn) return;
els.refreshBtn.disabled = Boolean(isRefreshing);
els.refreshBtn.classList.toggle("spinning", Boolean(isRefreshing));
}
function riskText(level) {
const low = String(level || "medium").toLowerCase();
if (low === "low") return "低风险";
if (low === "high") return "高风险";
return "中风险";
}
function getSettlementSourceDisplay(detail) {
const source = String(detail?.current?.settlement_source || "").toLowerCase();
if (source === "hko") {
return {
label: "结算源",
value: "香港天文台 (HKO)"
};
}
if (source === "cwa") {
return {
label: "结算源",
value: "交通部中央气象署 (CWA)"
};
}
const airport = detail?.risk?.airport || "--";
const icao = detail?.risk?.icao ? ` (${detail.risk.icao})` : "";
return {
label: "结算机场",
value: `${airport}${icao}`
};
}
function formatTemp(v, symbol) {
const n = Number(v);
if (!Number.isFinite(n)) return "--";
return `${n.toFixed(1)}${symbol || "°C"}`;
}
function formatForecastDate(day, index) {
if (index === 0) return "今天";
const str = String(day || "");
const m = str.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!m) return str || "--";
return `${m[2]}/${m[3]}`;
}
function parseTimeToMinute(value) {
const text = String(value || "");
const m = text.match(/(\d{1,2}):(\d{2})/);
if (!m) return Number.NaN;
const hh = Number(m[1]);
const mm = Number(m[2]);
if (!Number.isFinite(hh) || !Number.isFinite(mm)) return Number.NaN;
if (hh < 0 || hh > 23 || mm < 0 || mm > 59) return Number.NaN;
return hh * 60 + mm;
}
function getObservationRows(detail) {
const obsSource = Array.isArray(detail?.settlement_today_obs) && detail.settlement_today_obs.length
? detail.settlement_today_obs
: Array.isArray(detail?.metar_today_obs)
? detail.metar_today_obs
: [];
const rows = [];
for (const row of obsSource) {
const temp = Number(row?.temp);
const time = String(row?.time || "");
if (!Number.isFinite(temp) || !time) continue;
rows.push({
temp,
time,
minute: parseTimeToMinute(time)
});
}
const sortable = rows.every((row) => Number.isFinite(row.minute));
if (sortable) {
rows.sort((a, b) => a.minute - b.minute);
}
return rows;
}
async function getConfig() {
return new Promise((resolve) => {
chrome.storage.sync.get(DEFAULT_CONFIG, (items) => {
resolve({
apiBase: normalizeBase(items.apiBase || DEFAULT_CONFIG.apiBase),
siteBase: normalizeBase(items.siteBase || items.apiBase || DEFAULT_CONFIG.siteBase),
authToken: String(items.authToken || ""),
selectedCity: String(items.selectedCity || "")
});
});
});
}
async function saveConfigPatch(patch) {
const next = { ...state.config, ...patch };
state.config = next;
return new Promise((resolve) => {
chrome.storage.sync.set(next, resolve);
});
}
async function getCachedCities() {
const cached = await getLocalValue(buildCacheKey("cities"));
const cities = cached?.cities;
return Array.isArray(cities) && cities.length ? cities : null;
}
async function setCachedCities(cities) {
if (!Array.isArray(cities) || !cities.length) return;
await setLocalValue(buildCacheKey("cities"), {
updated_at: new Date().toISOString(),
cities
});
}
async function getCachedDetail(cityName) {
if (!cityName) return null;
const cached = await getLocalValue(buildCacheKey("detail", cityName));
return cached?.detail || null;
}
async function setCachedDetail(cityName, detail) {
if (!cityName || !detail || typeof detail !== "object") return;
await setLocalValue(buildCacheKey("detail", cityName), {
updated_at: new Date().toISOString(),
detail
});
}
async function setSelectedCity(cityName, options = {}) {
const { persist = true, reloadDetail = true } = options;
const target = String(cityName || "");
if (!target) return;
if (!state.cities.find((city) => city.name === target)) return;
const changed = state.config.selectedCity !== target;
state.config.selectedCity = target;
if (changed && persist) {
await saveConfigPatch({ selectedCity: target });
}
renderCitySelect();
if (reloadDetail) {
await loadDetail(target);
}
}
async function apiGet(path) {
const headers = { Accept: "application/json" };
if (state.config.authToken) {
headers.Authorization = `Bearer ${state.config.authToken}`;
}
const url = `${normalizeBase(state.config.apiBase)}${path}`;
const res = await fetch(url, { headers, cache: "no-store" });
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (_e) {
data = text;
}
if (!res.ok) {
const errMsg = typeof data === "object" && data
? JSON.stringify(data)
: String(data || res.statusText);
throw new Error(`HTTP ${res.status}: ${errMsg}`);
}
return data;
}
function renderCitySelect() {
const current = state.config.selectedCity;
els.citySelect.innerHTML = "";
for (const c of state.cities) {
const op = document.createElement("option");
op.value = c.name;
op.textContent = c.display_name || c.name;
if (c.name === current) op.selected = true;
els.citySelect.appendChild(op);
}
}
function renderRiskBadge(detail) {
const lvl = String(detail?.risk?.level || "medium").toLowerCase();
els.riskBadge.classList.remove("low", "medium", "high");
els.riskBadge.classList.add(lvl === "low" || lvl === "high" ? lvl : "medium");
els.riskBadge.textContent = riskText(lvl);
}
function extractTrendSeries(detail) {
const hourly = detail?.hourly || {};
const times = Array.isArray(hourly.times) ? hourly.times : [];
const temps = Array.isArray(hourly.temps) ? hourly.temps : [];
const trend = [];
for (let i = 0; i < times.length; i += 1) {
const t = Number(temps[i]);
if (!Number.isFinite(t)) continue;
const timeText = String(times[i]);
trend.push({ t: timeText, v: t, m: parseTimeToMinute(timeText) });
}
const obs = [];
for (const row of getObservationRows(detail)) {
obs.push({ t: row.time, v: row.temp, m: row.minute });
}
return { trend, obs };
}
function drawTrendChart(detail) {
const canvas = els.trendCanvas;
const ctx = canvas.getContext("2d");
const width = canvas.width;
const height = canvas.height;
ctx.clearRect(0, 0, width, height);
const { trend, obs } = extractTrendSeries(detail);
const points = [...trend, ...obs];
const hoverPoints = [];
const tempSymbol = detail?.temp_symbol || "°C";
const obsSeriesLabel = String(detail?.current?.settlement_source_label || "OBS").toUpperCase();
if (!points.length) {
setChartHover([], tempSymbol);
ctx.fillStyle = "#8ba0be";
ctx.font = "14px Inter, sans-serif";
ctx.fillText("暂无趋势数据", 18, 40);
return;
}
const minVal = Math.min(...points.map((p) => p.v));
const maxVal = Math.max(...points.map((p) => p.v));
const vPad = Math.max(1, (maxVal - minVal) * 0.2);
const yMin = minVal - vPad;
const yMax = maxVal + vPad;
const inner = { left: 38, top: 16, right: width - 12, bottom: height - 30 };
const w = inner.right - inner.left;
const h = inner.bottom - inner.top;
const minuteValues = points.map((p) => p.m).filter((v) => Number.isFinite(v));
const canUseMinuteAxis = minuteValues.length >= 2 && Math.max(...minuteValues) > Math.min(...minuteValues);
const minMinute = canUseMinuteAxis ? Math.min(...minuteValues) : 0;
const maxMinute = canUseMinuteAxis ? Math.max(...minuteValues) : 0;
function xFromIndex(idx, total) {
if (total <= 1) return inner.left;
return inner.left + (idx / (total - 1)) * w;
}
function xFromMinute(minute) {
if (!canUseMinuteAxis || !Number.isFinite(minute)) return inner.left;
return inner.left + ((minute - minMinute) / (maxMinute - minMinute)) * w;
}
function yFromValue(v) {
return inner.bottom - ((v - yMin) / (yMax - yMin)) * h;
}
ctx.strokeStyle = "rgba(255,255,255,0.08)";
ctx.lineWidth = 1;
for (let i = 0; i < 4; i += 1) {
const y = inner.top + (h / 3) * i;
ctx.beginPath();
ctx.moveTo(inner.left, y);
ctx.lineTo(inner.right, y);
ctx.stroke();
}
ctx.fillStyle = "#7f95b2";
ctx.font = "12px Inter, sans-serif";
for (let i = 0; i < 4; i += 1) {
const val = yMax - ((yMax - yMin) / 3) * i;
const y = inner.top + (h / 3) * i + 4;
ctx.fillText(`${val.toFixed(0)}°`, 6, y);
}
if (trend.length) {
ctx.strokeStyle = "#facc15";
ctx.lineWidth = 2.5;
ctx.beginPath();
trend.forEach((p, idx) => {
const x = canUseMinuteAxis ? xFromMinute(p.m) : xFromIndex(idx, trend.length);
const y = yFromValue(p.v);
hoverPoints.push({ x, y, time: p.t, value: p.v, series: "DEB" });
if (idx === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
}
if (obs.length) {
ctx.strokeStyle = "rgba(34, 211, 238, 0.9)";
ctx.lineWidth = 1.8;
ctx.beginPath();
for (let i = 0; i < obs.length; i += 1) {
const x = canUseMinuteAxis ? xFromMinute(obs[i].m) : xFromIndex(i, obs.length);
const y = yFromValue(obs[i].v);
hoverPoints.push({ x, y, time: obs[i].t, value: obs[i].v, series: obsSeriesLabel });
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Keep dots sparse to avoid a "full row of points" look.
const markerStep = Math.max(1, Math.ceil(obs.length / 12));
ctx.fillStyle = "#22d3ee";
for (let i = 0; i < obs.length; i += markerStep) {
const x = canUseMinuteAxis ? xFromMinute(obs[i].m) : xFromIndex(i, obs.length);
const y = yFromValue(obs[i].v);
ctx.beginPath();
ctx.arc(x, y, 3.2, 0, Math.PI * 2);
ctx.fill();
}
// Always draw last marker for latest observation.
const lastObs = obs[obs.length - 1];
const lastX = canUseMinuteAxis ? xFromMinute(lastObs.m) : xFromIndex(obs.length - 1, obs.length);
const lastY = yFromValue(lastObs.v);
ctx.beginPath();
ctx.arc(lastX, lastY, 3.6, 0, Math.PI * 2);
ctx.fill();
}
const ticks = trend.length ? trend : obs;
ctx.fillStyle = "#7f95b2";
ctx.font = "11px Inter, sans-serif";
const step = Math.max(1, Math.floor(ticks.length / 4));
for (let i = 0; i < ticks.length; i += step) {
const x = canUseMinuteAxis ? xFromMinute(ticks[i].m) : xFromIndex(i, ticks.length);
const text = String(ticks[i].t).slice(0, 5);
ctx.fillText(text, x - 12, height - 8);
}
setChartHover(hoverPoints, tempSymbol);
}
function renderForecast(detail) {
const symbol = detail?.temp_symbol || "°C";
const daily = Array.isArray(detail?.forecast?.daily) ? detail.forecast.daily : [];
els.forecastRow.innerHTML = "";
for (let i = 0; i < Math.min(daily.length, 6); i += 1) {
const day = daily[i];
const card = document.createElement("div");
card.className = `forecast-card ${i === 0 ? "today" : ""}`;
const d = document.createElement("div");
d.className = "f-date";
d.textContent = formatForecastDate(day?.date, i);
card.appendChild(d);
const v = document.createElement("div");
v.className = "f-temp";
v.textContent = formatTemp(day?.max_temp, symbol);
card.appendChild(v);
els.forecastRow.appendChild(card);
}
if (!daily.length) {
els.forecastRow.textContent = "暂无多日预报";
}
}
function renderDetail(detail) {
state.detail = detail;
renderRiskBadge(detail);
const profile = getSettlementSourceDisplay(detail);
els.settlementLabel.textContent = profile.label;
els.settlementValue.textContent = profile.value;
els.distanceValue.textContent = Number.isFinite(Number(detail?.risk?.distance_km))
? `${Number(detail.risk.distance_km)} km`
: "--";
els.obsTimeValue.textContent = detail?.current?.obs_time || "--";
const nearby = Array.isArray(detail?.mgm_nearby) ? detail.mgm_nearby.length : 0;
els.nearbyValue.textContent = `${nearby} 个参与监控`;
drawTrendChart(detail);
renderForecast(detail);
const sourceTag = String(detail?.current?.settlement_source_label || "").toUpperCase() || "OBS";
const obs = getObservationRows(detail);
if (obs.length >= 2) {
const first = obs[0];
const last = obs[obs.length - 1];
els.chartLegend.textContent = `${sourceTag}: ${first.temp}°C@${first.time} -> ${last.temp}°C@${last.time}`;
} else {
els.chartLegend.textContent = `${sourceTag}: 暂无连续观测`;
}
}
function normalizeAggregateDetail(payload) {
const overview = payload?.overview || {};
const risk = payload?.risk || {};
const officialCurrent = payload?.official?.metar?.current || {};
const timeseries = payload?.timeseries || {};
const probs = payload?.probabilities || {};
return {
name: overview.name || payload.city || "",
display_name: overview.display_name || overview.name || payload.city || "",
lat: overview.lat,
lon: overview.lon,
temp_symbol: overview.temp_symbol || "°C",
local_time: overview.local_time,
local_date: overview.local_date,
risk: {
level: risk.level || overview.risk_level || "medium",
emoji: risk.emoji,
airport: risk.airport || overview.airport,
icao: risk.icao || overview.icao,
distance_km: risk.distance_km,
warning: risk.warning || overview.risk_warning
},
current: {
...(officialCurrent || {}),
settlement_source: overview.settlement_source || officialCurrent?.settlement_source,
settlement_source_label:
overview.settlement_source_label || officialCurrent?.settlement_source_label
},
mgm_nearby: payload?.official?.mgm_nearby || [],
forecast: {
daily: Array.isArray(timeseries.forecast_daily) ? timeseries.forecast_daily : []
},
hourly: timeseries.hourly || { times: [], temps: [] },
metar_today_obs: timeseries.metar_today_obs || [],
settlement_today_obs: timeseries.settlement_today_obs || [],
probabilities: probs || { mu: null, distribution: [] },
updated_at: payload?.fetched_at
};
}
async function loadDetail(cityName, options = {}) {
const { forceRefresh = false } = options;
const targetCity = String(cityName || "");
if (!targetCity) return { fromCache: false };
const cachedDetail = await getCachedDetail(targetCity);
if (!forceRefresh && cachedDetail) {
renderDetail(cachedDetail);
return { fromCache: true };
}
setLoading(true, forceRefresh ? "正在刷新最新温度数据..." : "正在加载温度数据...");
try {
const encoded = encodeURIComponent(targetCity);
const suffix = forceRefresh ? "?force_refresh=1" : "";
let detail = null;
try {
// Preferred legacy endpoint: already matches frontend card schema.
detail = await apiGet(`/api/city/${encoded}${suffix}`);
} catch (_legacyErr) {
// Fallback to aggregate endpoint and normalize structure.
const payload = await apiGet(`/api/city/${encoded}/detail${suffix}`);
detail =
payload && payload.overview && payload.timeseries
? normalizeAggregateDetail(payload)
: payload;
}
renderDetail(detail);
await setCachedDetail(targetCity, detail);
return { fromCache: false };
} catch (err) {
if (cachedDetail) {
renderDetail(cachedDetail);
}
throw err;
} finally {
setLoading(false);
}
}
async function loadCities(options = {}) {
const { forceRefresh = false } = options;
const applyCities = async (cities) => {
state.cities = cities;
if (!state.config.selectedCity || !cities.find((c) => c.name === state.config.selectedCity)) {
const preferred = cities.find((c) => c.is_major) || cities[0];
await setSelectedCity(preferred.name, { persist: true, reloadDetail: false });
} else {
renderCitySelect();
}
};
const cachedCities = await getCachedCities();
if (!forceRefresh && cachedCities) {
await applyCities(cachedCities);
return { fromCache: true };
}
setLoading(true, "正在加载城市列表...");
try {
const list = await apiGet("/api/cities");
const cities = Array.isArray(list)
? list
: Array.isArray(list?.cities)
? list.cities
: [];
if (!Array.isArray(cities)) {
throw new Error(
`Invalid /api/cities response: ${
typeof list === "string" ? list : JSON.stringify(list)
}`
);
}
if (!cities.length) throw new Error("No cities returned.");
await applyCities(cities);
await setCachedCities(cities);
return { fromCache: false };
} catch (err) {
if (cachedCities) {
await applyCities(cachedCities);
return { fromCache: true };
}
throw err;
} finally {
setLoading(false);
}
}
function getActiveTabUrl() {
return new Promise((resolve) => {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const first = Array.isArray(tabs) && tabs.length ? tabs[0] : null;
resolve(String(first?.url || ""));
});
});
}
async function syncCityFromActiveUrl() {
if (state.syncBusy || !state.cities.length) return;
state.syncBusy = true;
try {
const url = await getActiveTabUrl();
if (!url) return;
if (url === state.lastActiveUrl) return;
state.lastActiveUrl = url;
const inferred = inferCityFromUrl(url);
if (!inferred) return;
if (inferred === state.config.selectedCity) return;
await setSelectedCity(inferred, { persist: true, reloadDetail: true });
} catch (_err) {
// URL sync failure should never block panel rendering.
} finally {
state.syncBusy = false;
}
}
function bindUrlSync() {
const trigger = () => {
void syncCityFromActiveUrl();
};
chrome.tabs.onActivated.addListener(trigger);
chrome.tabs.onUpdated.addListener((_tabId, changeInfo, tab) => {
if (!tab?.active) return;
if (changeInfo.url || changeInfo.status === "complete") {
trigger();
}
});
window.addEventListener("focus", trigger);
document.addEventListener("visibilitychange", () => {
if (!document.hidden) trigger();
});
setInterval(trigger, 4000);
}
function openMainSite(view) {
const city = encodeURIComponent(state.config.selectedCity || "");
const siteBase = normalizeBase(state.config.siteBase || state.config.apiBase);
const url = `${siteBase}/?city=${city}&view=${encodeURIComponent(view || "dashboard")}`;
chrome.tabs.create({ url });
}
function bindEvents() {
els.citySelect.addEventListener("change", async (event) => {
const value = String(event.target.value || "");
clearError();
try {
await setSelectedCity(value, { persist: true, reloadDetail: true });
} catch (err) {
showError(`加载城市详情失败: ${err.message}`);
}
});
if (els.refreshBtn) {
els.refreshBtn.addEventListener("click", async () => {
const city = String(state.config.selectedCity || "");
if (!city) return;
clearError();
setRefreshing(true);
try {
await loadDetail(city, { forceRefresh: true });
} catch (err) {
showError(`刷新温度数据失败: ${err.message}`);
} finally {
setRefreshing(false);
}
});
}
if (els.trendCanvas) {
els.trendCanvas.addEventListener("mousemove", onTrendCanvasHover);
els.trendCanvas.addEventListener("mouseleave", hideChartTooltip);
}
els.openFullBtn.addEventListener("click", () => openMainSite("dashboard"));
bindUrlSync();
}
async function boot() {
bindEvents();
clearError();
try {
state.config = await getConfig();
await loadCities();
await syncCityFromActiveUrl();
await loadDetail(state.config.selectedCity);
} catch (err) {
const msg = String(err?.message || err || "");
if (msg.includes("HTTP 401")) {
showError(
`初始化失败: ${msg}\n当前插件是公开读模式,Token 可留空。请检查后端是否仍开启了接口鉴权。`
);
return;
}
showError(`初始化失败: ${msg}\n公开模式只需配置 API BaseToken 可留空。`);
}
}
boot();