From f0fcb1e379c774c92aabf91a32764653de0a82d2 Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Wed, 18 Mar 2026 12:51:24 +0800
Subject: [PATCH] feat: implement initial PolyWeather browser extension with
side panel and options page
---
.gitignore | 4 +
extension/README.md | 46 ++
extension/background.js | 6 +
extension/manifest.json | 18 +
extension/options.css | 78 ++++
extension/options.html | 42 ++
extension/options.js | 94 ++++
extension/sidepanel.css | 308 +++++++++++++
extension/sidepanel.html | 74 ++++
extension/sidepanel.js | 908 +++++++++++++++++++++++++++++++++++++++
10 files changed, 1578 insertions(+)
create mode 100644 extension/README.md
create mode 100644 extension/background.js
create mode 100644 extension/manifest.json
create mode 100644 extension/options.css
create mode 100644 extension/options.html
create mode 100644 extension/options.js
create mode 100644 extension/sidepanel.css
create mode 100644 extension/sidepanel.html
create mode 100644 extension/sidepanel.js
diff --git a/.gitignore b/.gitignore
index 1e56e00e..8e70c05a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,7 @@ frontend/.vercel/
.npm-cache/
.env.local
.vercel/
+
+# Browser extension build artifacts
+/extension.zip
+/extension-*.zip
diff --git a/extension/README.md b/extension/README.md
new file mode 100644
index 00000000..285095ae
--- /dev/null
+++ b/extension/README.md
@@ -0,0 +1,46 @@
+# PolyWeather Side Panel Extension (MVP)
+
+这是一个 Chrome / Edge 的侧边栏扩展 MVP,用于把 PolyWeather 右侧城市卡片移植到浏览器侧边栏中。
+
+## 功能
+
+- 侧边栏展示:
+ - 城市选择
+ - 风险徽章
+ - 城市档案(结算源/距离/观测更新/周边站点)
+ - 今日日内走势(简版 Canvas)
+ - 多日预报
+- 快捷按钮:
+ - 今日日内分析
+ - 历史对账
+ - 打开完整网站分析
+- 自动识别城市:
+ - 监听当前激活标签页 URL(例如 Polymarket `.../event/highest-temperature-in-ankara-...`)
+ - 自动将侧边栏城市切换为 URL 对应城市
+- 设置页可配置:
+ - Site Base URL
+ - API Base URL
+ - Bearer Token(可选)
+
+## 本地安装(开发者模式)
+
+1. 打开 Chrome/Edge 扩展页面:
+ - Chrome: `chrome://extensions`
+ - Edge: `edge://extensions`
+2. 打开“开发者模式”。
+3. 选择“加载已解压的扩展程序”。
+4. 选择目录:`extension/`。
+5. 点击扩展图标,侧边栏会打开。
+
+## 设置
+
+首次建议打开扩展“选项页”并确认:
+
+- `Site Base URL`:你的前端域名(例如 `https://polyweather-pro.vercel.app`)
+- `API Base URL`:你的后端 API 域名(若同域也可填前端域名)
+- `Bearer Token`:后端开启鉴权时填写
+
+## 说明
+
+- 当前是 MVP,重点是“导流回站”,未接入支付链路。
+- 若你的 API 做了严格鉴权,请先在设置页填 token 再使用。
diff --git a/extension/background.js b/extension/background.js
new file mode 100644
index 00000000..7b183420
--- /dev/null
+++ b/extension/background.js
@@ -0,0 +1,6 @@
+chrome.runtime.onInstalled.addListener(() => {
+ chrome.sidePanel
+ .setPanelBehavior({ openPanelOnActionClick: true })
+ .catch(() => {});
+});
+
diff --git a/extension/manifest.json b/extension/manifest.json
new file mode 100644
index 00000000..9d0e5818
--- /dev/null
+++ b/extension/manifest.json
@@ -0,0 +1,18 @@
+{
+ "manifest_version": 3,
+ "name": "PolyWeather Side Panel",
+ "description": "PolyWeather 右侧城市卡片(浏览器侧边栏)",
+ "version": "0.1.0",
+ "permissions": ["sidePanel", "storage", "tabs"],
+ "host_permissions": ["https://*/*", "http://*/*"],
+ "background": {
+ "service_worker": "background.js"
+ },
+ "action": {
+ "default_title": "Open PolyWeather"
+ },
+ "side_panel": {
+ "default_path": "sidepanel.html"
+ },
+ "options_page": "options.html"
+}
diff --git a/extension/options.css b/extension/options.css
new file mode 100644
index 00000000..75140c88
--- /dev/null
+++ b/extension/options.css
@@ -0,0 +1,78 @@
+body {
+ margin: 0;
+ background: #0b1225;
+ color: #e5eefb;
+ font-family: "Inter", "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
+}
+
+.wrap {
+ max-width: 840px;
+ margin: 24px auto;
+ padding: 0 16px;
+}
+
+h1 {
+ margin: 0 0 18px;
+ font-size: 24px;
+}
+
+.field {
+ display: grid;
+ gap: 6px;
+ margin-bottom: 14px;
+}
+
+.field span {
+ color: #9fb0c9;
+ font-size: 13px;
+}
+
+input,
+textarea {
+ width: 100%;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 10px;
+ background: #121c38;
+ color: #f1f5ff;
+ padding: 10px 12px;
+ font-size: 14px;
+}
+
+textarea {
+ resize: vertical;
+}
+
+.actions {
+ display: flex;
+ gap: 10px;
+ margin-top: 8px;
+}
+
+button {
+ border: 1px solid rgba(34, 211, 238, 0.4);
+ background: rgba(34, 211, 238, 0.12);
+ color: #ccf7ff;
+ border-radius: 9px;
+ padding: 10px 14px;
+ cursor: pointer;
+ font-weight: 700;
+}
+
+button.ghost {
+ border-color: rgba(99, 102, 241, 0.4);
+ background: rgba(99, 102, 241, 0.14);
+ color: #dbe4ff;
+}
+
+.result {
+ margin-top: 14px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 10px;
+ padding: 12px;
+ white-space: pre-wrap;
+ word-break: break-word;
+ min-height: 56px;
+ color: #9fb0c9;
+}
+
diff --git a/extension/options.html b/extension/options.html
new file mode 100644
index 00000000..3a9a9082
--- /dev/null
+++ b/extension/options.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+ PolyWeather Extension Settings
+
+
+
+
+ PolyWeather 侧边栏设置
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/extension/options.js b/extension/options.js
new file mode 100644
index 00000000..52e88fa9
--- /dev/null
+++ b/extension/options.js
@@ -0,0 +1,94 @@
+const DEFAULT_CONFIG = {
+ apiBase: "https://polyweather-pro.vercel.app",
+ siteBase: "https://polyweather-pro.vercel.app",
+ authToken: "",
+ selectedCity: ""
+};
+
+const apiBaseInput = document.getElementById("apiBaseInput");
+const siteBaseInput = document.getElementById("siteBaseInput");
+const tokenInput = document.getElementById("tokenInput");
+const resultBox = document.getElementById("resultBox");
+
+function normalizeBase(url) {
+ return String(url || "").trim().replace(/\/+$/, "");
+}
+
+function writeResult(text) {
+ resultBox.textContent = text;
+}
+
+function getStorage() {
+ return new Promise((resolve) => {
+ chrome.storage.sync.get(DEFAULT_CONFIG, (items) => resolve(items));
+ });
+}
+
+function setStorage(values) {
+ return new Promise((resolve) => {
+ chrome.storage.sync.set(values, resolve);
+ });
+}
+
+async function loadForm() {
+ const cfg = await getStorage();
+ apiBaseInput.value = cfg.apiBase || DEFAULT_CONFIG.apiBase;
+ siteBaseInput.value = cfg.siteBase || cfg.apiBase || DEFAULT_CONFIG.siteBase;
+ tokenInput.value = cfg.authToken || "";
+}
+
+async function saveForm() {
+ const next = {
+ apiBase: normalizeBase(apiBaseInput.value),
+ siteBase: normalizeBase(siteBaseInput.value || apiBaseInput.value),
+ authToken: String(tokenInput.value || "").trim()
+ };
+ await setStorage(next);
+ writeResult("已保存。公开模式下 Token 可留空。");
+}
+
+async function testApi() {
+ const apiBase = normalizeBase(apiBaseInput.value);
+ const authToken = String(tokenInput.value || "").trim();
+ try {
+ const headers = { Accept: "application/json" };
+ if (authToken) headers.Authorization = `Bearer ${authToken}`;
+
+ const res = await fetch(`${apiBase}/api/cities`, {
+ 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) {
+ throw new Error(`HTTP ${res.status}: ${typeof data === "string" ? data : JSON.stringify(data)}`);
+ }
+ const count = Array.isArray(data)
+ ? data.length
+ : Array.isArray(data?.cities)
+ ? data.cities.length
+ : 0;
+ writeResult(`连接成功,返回城市数: ${count}(Token 可留空)`);
+ } catch (err) {
+ const msg = String(err?.message || err || "");
+ if (msg.includes("HTTP 401")) {
+ writeResult(`测试失败: ${msg}\n说明后端仍要求鉴权;若你要公开插件,请先放开 /api/cities 与 /api/city/*/detail。`);
+ return;
+ }
+ writeResult(`测试失败: ${msg}`);
+ }
+}
+
+document.getElementById("saveBtn").addEventListener("click", () => {
+ void saveForm();
+});
+document.getElementById("testBtn").addEventListener("click", () => {
+ void testApi();
+});
+
+void loadForm();
diff --git a/extension/sidepanel.css b/extension/sidepanel.css
new file mode 100644
index 00000000..047d0074
--- /dev/null
+++ b/extension/sidepanel.css
@@ -0,0 +1,308 @@
+:root {
+ --bg: #070d1f;
+ --panel: #0d152b;
+ --card: rgba(255, 255, 255, 0.03);
+ --border: rgba(255, 255, 255, 0.08);
+ --text: #e5eefb;
+ --muted: #8ba0be;
+ --cyan: #22d3ee;
+ --blue: #3b82f6;
+ --green: #34d399;
+ --amber: #f59e0b;
+ --red: #f87171;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ background: radial-gradient(circle at top, #0c1735, var(--bg) 45%);
+ color: var(--text);
+ font-family: "Inter", "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
+}
+
+.panel {
+ position: relative;
+ min-height: 100vh;
+ padding: 14px;
+}
+
+.loading-overlay {
+ position: absolute;
+ inset: 0;
+ z-index: 20;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ border-radius: 14px;
+ background: rgba(7, 13, 31, 0.7);
+ backdrop-filter: blur(2px);
+}
+
+.loading-spinner {
+ width: 28px;
+ height: 28px;
+ border-radius: 999px;
+ border: 3px solid rgba(34, 211, 238, 0.28);
+ border-top-color: #22d3ee;
+ animation: panel-loading-spin 0.75s linear infinite;
+}
+
+.loading-text {
+ color: #b7dcff;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.topbar {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ gap: 8px;
+ align-items: center;
+ margin-bottom: 12px;
+}
+
+.risk-badge {
+ padding: 5px 9px;
+ border-radius: 10px;
+ font-size: 12px;
+ font-weight: 700;
+ border: 1px solid transparent;
+}
+
+.risk-badge.low {
+ color: #86efac;
+ border-color: rgba(52, 211, 153, 0.5);
+ background: rgba(52, 211, 153, 0.14);
+}
+
+.risk-badge.medium {
+ color: #fcd34d;
+ border-color: rgba(245, 158, 11, 0.45);
+ background: rgba(245, 158, 11, 0.14);
+}
+
+.risk-badge.high {
+ color: #fca5a5;
+ border-color: rgba(248, 113, 113, 0.5);
+ background: rgba(248, 113, 113, 0.12);
+}
+
+.city-picker-wrap {
+ display: grid;
+ gap: 4px;
+}
+
+.city-picker-wrap label {
+ font-size: 11px;
+ color: var(--muted);
+}
+
+#citySelect {
+ width: 100%;
+ height: 34px;
+ border-radius: 9px;
+ border: 1px solid var(--border);
+ background: #0f1b35;
+ color: var(--text);
+ padding: 0 10px;
+}
+
+.refresh-btn {
+ width: 34px;
+ height: 34px;
+ border-radius: 9px;
+ border: 1px solid var(--border);
+ background: #0f1b35;
+ color: #9cecff;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ padding: 0;
+}
+
+.refresh-btn:hover {
+ filter: brightness(1.08);
+}
+
+.refresh-btn:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.refresh-btn svg {
+ width: 16px;
+ height: 16px;
+}
+
+.refresh-btn.spinning svg {
+ animation: refresh-spin 0.8s linear infinite;
+}
+
+@keyframes refresh-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes panel-loading-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.btn {
+ height: 34px;
+ border-radius: 10px;
+ border: 1px solid rgba(34, 211, 238, 0.5);
+ color: #9cecff;
+ background: rgba(34, 211, 238, 0.07);
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.btn:hover {
+ filter: brightness(1.08);
+}
+
+.section {
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: var(--card);
+ padding: 12px;
+ margin-bottom: 12px;
+}
+
+.section h3 {
+ margin: 0 0 10px 0;
+ font-size: 15px;
+}
+
+.grid2 {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+}
+
+.mini-card {
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 10px;
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.mini-label {
+ display: block;
+ color: var(--muted);
+ font-size: 11px;
+ margin-bottom: 6px;
+}
+
+.mini-card strong {
+ font-size: 16px;
+ line-height: 1.25;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+}
+
+.chart-wrap {
+ position: relative;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.01);
+ padding: 8px;
+}
+
+#trendCanvas {
+ width: 100%;
+ height: auto;
+ display: block;
+}
+
+.legend-text {
+ margin-top: 8px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.forecast-row {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.forecast-card {
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.02);
+ padding: 8px;
+ min-height: 68px;
+}
+
+.forecast-card.today {
+ border-color: rgba(34, 211, 238, 0.62);
+ background: rgba(34, 211, 238, 0.08);
+}
+
+.f-date {
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.f-temp {
+ margin-top: 7px;
+ font-size: 16px;
+ font-weight: 800;
+ line-height: 1.2;
+ font-variant-numeric: tabular-nums;
+}
+
+.btn-open-full {
+ width: 100%;
+ height: 40px;
+ border-color: rgba(59, 130, 246, 0.5);
+ color: #d9ebff;
+ background: rgba(59, 130, 246, 0.15);
+}
+
+.error {
+ border: 1px solid rgba(248, 113, 113, 0.45);
+ border-radius: 12px;
+ background: rgba(248, 113, 113, 0.12);
+ color: #fecaca;
+ padding: 10px;
+ font-size: 12px;
+ line-height: 1.4;
+}
+
+.hidden {
+ display: none;
+}
+
+.chart-tooltip {
+ position: absolute;
+ z-index: 3;
+ pointer-events: none;
+ max-width: 180px;
+ padding: 6px 8px;
+ border-radius: 8px;
+ border: 1px solid rgba(34, 211, 238, 0.45);
+ background: rgba(9, 17, 36, 0.92);
+ color: #dff7ff;
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1.3;
+ white-space: nowrap;
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
+}
diff --git a/extension/sidepanel.html b/extension/sidepanel.html
new file mode 100644
index 00000000..251986e1
--- /dev/null
+++ b/extension/sidepanel.html
@@ -0,0 +1,74 @@
+
+
+
+
+
+ PolyWeather Panel
+
+
+
+
+
+
+ 中风险
+
+
+
+
+
+
+
+
+ 城市档案
+
+
+ 结算站点
+ --
+
+
+ 站点距离
+ --
+
+
+ 观测更新
+ --
+
+
+ 周边站点
+ --
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/extension/sidepanel.js b/extension/sidepanel.js
new file mode 100644
index 00000000..38daee69
--- /dev/null
+++ b/extension/sidepanel.js
@@ -0,0 +1,908 @@
+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 Base;Token 可留空。`);
+ }
+}
+
+boot();