feat: implement initial PolyWeather browser extension with side panel and options page
This commit is contained in:
@@ -37,3 +37,7 @@ frontend/.vercel/
|
||||
.npm-cache/
|
||||
.env.local
|
||||
.vercel/
|
||||
|
||||
# Browser extension build artifacts
|
||||
/extension.zip
|
||||
/extension-*.zip
|
||||
|
||||
@@ -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 再使用。
|
||||
@@ -0,0 +1,6 @@
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.sidePanel
|
||||
.setPanelBehavior({ openPanelOnActionClick: true })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PolyWeather Extension Settings</title>
|
||||
<link rel="stylesheet" href="./options.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<h1>PolyWeather 侧边栏设置</h1>
|
||||
|
||||
<label class="field">
|
||||
<span>Site Base URL</span>
|
||||
<input id="siteBaseInput" type="text" placeholder="https://polyweather-pro.vercel.app" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>API Base URL</span>
|
||||
<input id="apiBaseInput" type="text" placeholder="https://polyweather-pro.vercel.app" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Bearer Token(可选)</span>
|
||||
<textarea
|
||||
id="tokenInput"
|
||||
rows="3"
|
||||
placeholder="公开模式留空即可;仅当后端返回 401 时再填写。"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button id="saveBtn">保存</button>
|
||||
<button id="testBtn" class="ghost">测试 /api/cities</button>
|
||||
</div>
|
||||
|
||||
<pre id="resultBox" class="result"></pre>
|
||||
</main>
|
||||
|
||||
<script src="./options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PolyWeather Panel</title>
|
||||
<link rel="stylesheet" href="./sidepanel.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="panel">
|
||||
<div id="loadingOverlay" class="loading-overlay hidden" aria-live="polite">
|
||||
<div class="loading-spinner" aria-hidden="true"></div>
|
||||
<div id="loadingText" class="loading-text">正在加载温度数据...</div>
|
||||
</div>
|
||||
<header class="topbar">
|
||||
<div id="riskBadge" class="risk-badge medium">中风险</div>
|
||||
<div class="city-picker-wrap">
|
||||
<label for="citySelect">城市</label>
|
||||
<select id="citySelect"></select>
|
||||
</div>
|
||||
<button id="refreshBtn" class="refresh-btn" title="刷新数据" aria-label="刷新数据">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="section">
|
||||
<h3>城市档案</h3>
|
||||
<div class="grid2">
|
||||
<article class="mini-card">
|
||||
<span class="mini-label" id="settlementLabel">结算站点</span>
|
||||
<strong id="settlementValue">--</strong>
|
||||
</article>
|
||||
<article class="mini-card">
|
||||
<span class="mini-label">站点距离</span>
|
||||
<strong id="distanceValue">--</strong>
|
||||
</article>
|
||||
<article class="mini-card">
|
||||
<span class="mini-label">观测更新</span>
|
||||
<strong id="obsTimeValue">--</strong>
|
||||
</article>
|
||||
<article class="mini-card">
|
||||
<span class="mini-label">周边站点</span>
|
||||
<strong id="nearbyValue">--</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3>今日日内走势(简版)</h3>
|
||||
<div class="chart-wrap">
|
||||
<canvas id="trendCanvas" width="560" height="220"></canvas>
|
||||
<div id="chartTooltip" class="chart-tooltip hidden"></div>
|
||||
</div>
|
||||
<div id="chartLegend" class="legend-text">--</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3>多日预报</h3>
|
||||
<div id="forecastRow" class="forecast-row"></div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<button id="openFullBtn" class="btn btn-open-full">打开完整网站分析</button>
|
||||
</section>
|
||||
|
||||
<section id="errorBox" class="error hidden"></section>
|
||||
</main>
|
||||
|
||||
<script src="./sidepanel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user