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();