feat: Implement initial PolyWeather browser extension including sidepanel, options page, and core weather display logic.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 826 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
+12
-1
@@ -2,13 +2,24 @@
|
||||
"manifest_version": 3,
|
||||
"name": "PolyWeather Side Panel",
|
||||
"description": "PolyWeather 右侧城市卡片(浏览器侧边栏)",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"icons": {
|
||||
"16": "icon-16.png",
|
||||
"32": "icon-32.png",
|
||||
"48": "icon-48.png",
|
||||
"128": "icon-128.png"
|
||||
},
|
||||
"permissions": ["sidePanel", "storage", "tabs"],
|
||||
"host_permissions": ["https://*/*", "http://*/*"],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_icon": {
|
||||
"16": "icon-16.png",
|
||||
"32": "icon-32.png",
|
||||
"48": "icon-48.png"
|
||||
},
|
||||
"default_title": "Open PolyWeather"
|
||||
},
|
||||
"side_panel": {
|
||||
|
||||
@@ -8,20 +8,20 @@
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<h1>PolyWeather 侧边栏设置</h1>
|
||||
<h1 id="settingsTitle">PolyWeather 侧边栏设置</h1>
|
||||
|
||||
<label class="field">
|
||||
<span>Site Base URL</span>
|
||||
<span id="siteBaseLabel">Site Base URL</span>
|
||||
<input id="siteBaseInput" type="text" placeholder="https://polyweather-pro.vercel.app" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>API Base URL</span>
|
||||
<span id="apiBaseLabel">API Base URL</span>
|
||||
<input id="apiBaseInput" type="text" placeholder="https://polyweather-pro.vercel.app" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Bearer Token(可选)</span>
|
||||
<span id="tokenLabel">Bearer Token(可选)</span>
|
||||
<textarea
|
||||
id="tokenInput"
|
||||
rows="3"
|
||||
|
||||
+55
-4
@@ -4,11 +4,50 @@ const DEFAULT_CONFIG = {
|
||||
authToken: "",
|
||||
selectedCity: ""
|
||||
};
|
||||
const locale = String(navigator.language || "en").toLowerCase().startsWith("zh")
|
||||
? "zh"
|
||||
: "en";
|
||||
const I18N = {
|
||||
zh: {
|
||||
settingsTitle: "PolyWeather 侧边栏设置",
|
||||
tokenLabel: "Bearer Token(可选)",
|
||||
tokenPlaceholder: "公开模式留空即可;仅当后端返回 401 时再填写。",
|
||||
save: "保存",
|
||||
test: "测试 /api/cities",
|
||||
saved: "已保存。公开模式下 Token 可留空。",
|
||||
connectOk: "连接成功,返回城市数",
|
||||
tokenOptional: "Token 可留空",
|
||||
testFailed: "测试失败",
|
||||
backendAuthHint: "说明后端仍要求鉴权;若你要公开插件,请先放开 /api/cities 与 /api/city/*/detail。"
|
||||
},
|
||||
en: {
|
||||
settingsTitle: "PolyWeather Side Panel Settings",
|
||||
tokenLabel: "Bearer Token (Optional)",
|
||||
tokenPlaceholder: "Leave empty in public mode; only fill it if the backend returns 401.",
|
||||
save: "Save",
|
||||
test: "Test /api/cities",
|
||||
saved: "Saved. Token can be empty in public mode.",
|
||||
connectOk: "Connected successfully, city count",
|
||||
tokenOptional: "Token can be empty",
|
||||
testFailed: "Test failed",
|
||||
backendAuthHint: "The backend still requires auth. If the extension should be public, allow /api/cities and /api/city/*/detail."
|
||||
}
|
||||
};
|
||||
|
||||
function t(key) {
|
||||
return I18N[locale][key] || I18N.zh[key] || key;
|
||||
}
|
||||
|
||||
const apiBaseInput = document.getElementById("apiBaseInput");
|
||||
const siteBaseInput = document.getElementById("siteBaseInput");
|
||||
const tokenInput = document.getElementById("tokenInput");
|
||||
const resultBox = document.getElementById("resultBox");
|
||||
const settingsTitle = document.getElementById("settingsTitle");
|
||||
const siteBaseLabel = document.getElementById("siteBaseLabel");
|
||||
const apiBaseLabel = document.getElementById("apiBaseLabel");
|
||||
const tokenLabel = document.getElementById("tokenLabel");
|
||||
const saveBtn = document.getElementById("saveBtn");
|
||||
const testBtn = document.getElementById("testBtn");
|
||||
|
||||
function normalizeBase(url) {
|
||||
return String(url || "").trim().replace(/\/+$/, "");
|
||||
@@ -44,7 +83,7 @@ async function saveForm() {
|
||||
authToken: String(tokenInput.value || "").trim()
|
||||
};
|
||||
await setStorage(next);
|
||||
writeResult("已保存。公开模式下 Token 可留空。");
|
||||
writeResult(t("saved"));
|
||||
}
|
||||
|
||||
async function testApi() {
|
||||
@@ -73,17 +112,28 @@ async function testApi() {
|
||||
: Array.isArray(data?.cities)
|
||||
? data.cities.length
|
||||
: 0;
|
||||
writeResult(`连接成功,返回城市数: ${count}(Token 可留空)`);
|
||||
writeResult(`${t("connectOk")}: ${count} (${t("tokenOptional")})`);
|
||||
} catch (err) {
|
||||
const msg = String(err?.message || err || "");
|
||||
if (msg.includes("HTTP 401")) {
|
||||
writeResult(`测试失败: ${msg}\n说明后端仍要求鉴权;若你要公开插件,请先放开 /api/cities 与 /api/city/*/detail。`);
|
||||
writeResult(`${t("testFailed")}: ${msg}\n${t("backendAuthHint")}`);
|
||||
return;
|
||||
}
|
||||
writeResult(`测试失败: ${msg}`);
|
||||
writeResult(`${t("testFailed")}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function applyStaticTranslations() {
|
||||
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
|
||||
if (settingsTitle) settingsTitle.textContent = t("settingsTitle");
|
||||
if (tokenLabel) tokenLabel.textContent = t("tokenLabel");
|
||||
if (tokenInput) tokenInput.placeholder = t("tokenPlaceholder");
|
||||
if (saveBtn) saveBtn.textContent = t("save");
|
||||
if (testBtn) testBtn.textContent = t("test");
|
||||
if (siteBaseLabel) siteBaseLabel.textContent = "Site Base URL";
|
||||
if (apiBaseLabel) apiBaseLabel.textContent = "API Base URL";
|
||||
}
|
||||
|
||||
document.getElementById("saveBtn").addEventListener("click", () => {
|
||||
void saveForm();
|
||||
});
|
||||
@@ -91,4 +141,5 @@ document.getElementById("testBtn").addEventListener("click", () => {
|
||||
void testApi();
|
||||
});
|
||||
|
||||
applyStaticTranslations();
|
||||
void loadForm();
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<header class="topbar">
|
||||
<div id="riskBadge" class="risk-badge medium">中风险</div>
|
||||
<div class="city-picker-wrap">
|
||||
<label for="citySelect">城市</label>
|
||||
<label id="cityLabel" for="citySelect">城市</label>
|
||||
<select id="citySelect"></select>
|
||||
</div>
|
||||
<button id="refreshBtn" class="refresh-btn" title="刷新数据" aria-label="刷新数据">
|
||||
@@ -27,29 +27,29 @@
|
||||
</header>
|
||||
|
||||
<section class="section">
|
||||
<h3>城市档案</h3>
|
||||
<h3 id="profileTitle">城市档案</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>
|
||||
<span id="distanceLabel" class="mini-label">站点距离</span>
|
||||
<strong id="distanceValue">--</strong>
|
||||
</article>
|
||||
<article class="mini-card">
|
||||
<span class="mini-label">观测更新</span>
|
||||
<span id="obsTimeLabel" class="mini-label">观测更新</span>
|
||||
<strong id="obsTimeValue">--</strong>
|
||||
</article>
|
||||
<article class="mini-card">
|
||||
<span class="mini-label">周边站点</span>
|
||||
<span id="nearbyLabel" class="mini-label">周边站点</span>
|
||||
<strong id="nearbyValue">--</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3>今日日内走势(简版)</h3>
|
||||
<h3 id="trendTitle">今日日内走势(简版)</h3>
|
||||
<div class="chart-wrap">
|
||||
<canvas id="trendCanvas" width="560" height="220"></canvas>
|
||||
<div id="chartTooltip" class="chart-tooltip hidden"></div>
|
||||
@@ -58,7 +58,7 @@
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3>多日预报</h3>
|
||||
<h3 id="forecastTitle">多日预报</h3>
|
||||
<div id="forecastRow" class="forecast-row"></div>
|
||||
</section>
|
||||
|
||||
|
||||
+124
-22
@@ -5,6 +5,79 @@ const DEFAULT_CONFIG = {
|
||||
siteBase: "https://polyweather-pro.vercel.app"
|
||||
};
|
||||
const CACHE_VERSION = "v1";
|
||||
const locale = String(navigator.language || "en").toLowerCase().startsWith("zh")
|
||||
? "zh"
|
||||
: "en";
|
||||
const I18N = {
|
||||
zh: {
|
||||
loadingWeather: "正在加载温度数据...",
|
||||
loadingWeatherRefresh: "正在刷新最新温度数据...",
|
||||
loadingCities: "正在加载城市列表...",
|
||||
riskLow: "低风险",
|
||||
riskMedium: "中风险",
|
||||
riskHigh: "高风险",
|
||||
settlementSource: "结算源",
|
||||
settlementAirport: "结算机场",
|
||||
hko: "香港天文台 (HKO)",
|
||||
cwa: "交通部中央气象署 (CWA)",
|
||||
city: "城市",
|
||||
refresh: "刷新数据",
|
||||
cityProfile: "城市档案",
|
||||
distance: "站点距离",
|
||||
obsUpdate: "观测更新",
|
||||
nearbyStations: "周边站点",
|
||||
intradayTrend: "今日日内走势(简版)",
|
||||
forecast: "多日预报",
|
||||
openFull: "打开完整网站分析",
|
||||
noTrendData: "暂无趋势数据",
|
||||
noForecast: "暂无多日预报",
|
||||
noContinuousObs: "暂无连续观测",
|
||||
nearbyMonitoringSuffix: "个参与监控",
|
||||
today: "今天",
|
||||
debSeries: "DEB",
|
||||
loadCityDetailFailed: "加载城市详情失败",
|
||||
refreshFailed: "刷新温度数据失败",
|
||||
initFailed: "初始化失败",
|
||||
publicReadHint: "当前插件是公开读模式,Token 可留空。请检查后端是否仍开启了接口鉴权。",
|
||||
publicModeHint: "公开模式只需配置 API Base;Token 可留空。"
|
||||
},
|
||||
en: {
|
||||
loadingWeather: "Loading weather data...",
|
||||
loadingWeatherRefresh: "Refreshing latest weather data...",
|
||||
loadingCities: "Loading city list...",
|
||||
riskLow: "Low Risk",
|
||||
riskMedium: "Medium Risk",
|
||||
riskHigh: "High Risk",
|
||||
settlementSource: "Settlement Source",
|
||||
settlementAirport: "Settlement Airport",
|
||||
hko: "Hong Kong Observatory (HKO)",
|
||||
cwa: "Central Weather Administration (CWA)",
|
||||
city: "City",
|
||||
refresh: "Refresh data",
|
||||
cityProfile: "City Profile",
|
||||
distance: "Station Distance",
|
||||
obsUpdate: "Observation Update",
|
||||
nearbyStations: "Nearby Stations",
|
||||
intradayTrend: "Today's Intraday Trend",
|
||||
forecast: "Forecast",
|
||||
openFull: "Open Full Site Analysis",
|
||||
noTrendData: "No trend data available",
|
||||
noForecast: "No multi-day forecast",
|
||||
noContinuousObs: "No continuous observations",
|
||||
nearbyMonitoringSuffix: " stations monitored",
|
||||
today: "Today",
|
||||
debSeries: "DEB",
|
||||
loadCityDetailFailed: "Failed to load city detail",
|
||||
refreshFailed: "Failed to refresh weather data",
|
||||
initFailed: "Initialization failed",
|
||||
publicReadHint: "The extension is in public read mode. Token can be empty. Check whether the backend still requires auth.",
|
||||
publicModeHint: "In public mode only API Base is required; Token can be empty."
|
||||
}
|
||||
};
|
||||
|
||||
function t(key) {
|
||||
return I18N[locale][key] || I18N.zh[key] || key;
|
||||
}
|
||||
|
||||
let state = {
|
||||
config: { ...DEFAULT_CONFIG },
|
||||
@@ -35,7 +108,14 @@ const els = {
|
||||
errorBox: document.getElementById("errorBox"),
|
||||
openFullBtn: document.getElementById("openFullBtn"),
|
||||
loadingOverlay: document.getElementById("loadingOverlay"),
|
||||
loadingText: document.getElementById("loadingText")
|
||||
loadingText: document.getElementById("loadingText"),
|
||||
cityLabel: document.getElementById("cityLabel"),
|
||||
profileTitle: document.getElementById("profileTitle"),
|
||||
distanceLabel: document.getElementById("distanceLabel"),
|
||||
obsTimeLabel: document.getElementById("obsTimeLabel"),
|
||||
nearbyLabel: document.getElementById("nearbyLabel"),
|
||||
trendTitle: document.getElementById("trendTitle"),
|
||||
forecastTitle: document.getElementById("forecastTitle")
|
||||
};
|
||||
|
||||
function normalizeBase(url) {
|
||||
@@ -183,7 +263,7 @@ function clearError() {
|
||||
els.errorBox.classList.add("hidden");
|
||||
}
|
||||
|
||||
function setLoading(loading, text = "正在加载温度数据...") {
|
||||
function setLoading(loading, text = t("loadingWeather")) {
|
||||
if (!els.loadingOverlay) return;
|
||||
if (loading) {
|
||||
state.loadingCount += 1;
|
||||
@@ -279,29 +359,29 @@ function setRefreshing(isRefreshing) {
|
||||
|
||||
function riskText(level) {
|
||||
const low = String(level || "medium").toLowerCase();
|
||||
if (low === "low") return "低风险";
|
||||
if (low === "high") return "高风险";
|
||||
return "中风险";
|
||||
if (low === "low") return t("riskLow");
|
||||
if (low === "high") return t("riskHigh");
|
||||
return t("riskMedium");
|
||||
}
|
||||
|
||||
function getSettlementSourceDisplay(detail) {
|
||||
const source = String(detail?.current?.settlement_source || "").toLowerCase();
|
||||
if (source === "hko") {
|
||||
return {
|
||||
label: "结算源",
|
||||
value: "香港天文台 (HKO)"
|
||||
label: t("settlementSource"),
|
||||
value: t("hko")
|
||||
};
|
||||
}
|
||||
if (source === "cwa") {
|
||||
return {
|
||||
label: "结算源",
|
||||
value: "交通部中央气象署 (CWA)"
|
||||
label: t("settlementSource"),
|
||||
value: t("cwa")
|
||||
};
|
||||
}
|
||||
const airport = detail?.risk?.airport || "--";
|
||||
const icao = detail?.risk?.icao ? ` (${detail.risk.icao})` : "";
|
||||
return {
|
||||
label: "结算机场",
|
||||
label: t("settlementAirport"),
|
||||
value: `${airport}${icao}`
|
||||
};
|
||||
}
|
||||
@@ -313,7 +393,7 @@ function formatTemp(v, symbol) {
|
||||
}
|
||||
|
||||
function formatForecastDate(day, index) {
|
||||
if (index === 0) return "今天";
|
||||
if (index === 0) return t("today");
|
||||
const str = String(day || "");
|
||||
const m = str.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!m) return str || "--";
|
||||
@@ -500,7 +580,7 @@ function drawTrendChart(detail) {
|
||||
setChartHover([], tempSymbol);
|
||||
ctx.fillStyle = "#8ba0be";
|
||||
ctx.font = "14px Inter, sans-serif";
|
||||
ctx.fillText("暂无趋势数据", 18, 40);
|
||||
ctx.fillText(t("noTrendData"), 18, 40);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -555,7 +635,7 @@ function drawTrendChart(detail) {
|
||||
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" });
|
||||
hoverPoints.push({ x, y, time: p.t, value: p.v, series: t("debSeries") });
|
||||
if (idx === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
@@ -630,7 +710,7 @@ function renderForecast(detail) {
|
||||
els.forecastRow.appendChild(card);
|
||||
}
|
||||
if (!daily.length) {
|
||||
els.forecastRow.textContent = "暂无多日预报";
|
||||
els.forecastRow.textContent = t("noForecast");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -646,7 +726,9 @@ function renderDetail(detail) {
|
||||
: "--";
|
||||
els.obsTimeValue.textContent = detail?.current?.obs_time || "--";
|
||||
const nearby = Array.isArray(detail?.mgm_nearby) ? detail.mgm_nearby.length : 0;
|
||||
els.nearbyValue.textContent = `${nearby} 个参与监控`;
|
||||
els.nearbyValue.textContent = locale === "zh"
|
||||
? `${nearby} ${t("nearbyMonitoringSuffix")}`
|
||||
: `${nearby}${t("nearbyMonitoringSuffix")}`;
|
||||
|
||||
drawTrendChart(detail);
|
||||
renderForecast(detail);
|
||||
@@ -658,7 +740,7 @@ function renderDetail(detail) {
|
||||
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}: 暂无连续观测`;
|
||||
els.chartLegend.textContent = `${sourceTag}: ${t("noContinuousObs")}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -703,6 +785,25 @@ function normalizeAggregateDetail(payload) {
|
||||
};
|
||||
}
|
||||
|
||||
function applyStaticTranslations() {
|
||||
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
|
||||
if (els.cityLabel) els.cityLabel.textContent = t("city");
|
||||
if (els.profileTitle) els.profileTitle.textContent = t("cityProfile");
|
||||
if (els.distanceLabel) els.distanceLabel.textContent = t("distance");
|
||||
if (els.obsTimeLabel) els.obsTimeLabel.textContent = t("obsUpdate");
|
||||
if (els.nearbyLabel) els.nearbyLabel.textContent = t("nearbyStations");
|
||||
if (els.trendTitle) els.trendTitle.textContent = t("intradayTrend");
|
||||
if (els.forecastTitle) els.forecastTitle.textContent = t("forecast");
|
||||
if (els.openFullBtn) els.openFullBtn.textContent = t("openFull");
|
||||
if (els.refreshBtn) {
|
||||
els.refreshBtn.title = t("refresh");
|
||||
els.refreshBtn.setAttribute("aria-label", t("refresh"));
|
||||
}
|
||||
if (els.loadingText && state.loadingCount === 0) {
|
||||
els.loadingText.textContent = t("loadingWeather");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail(cityName, options = {}) {
|
||||
const { forceRefresh = false } = options;
|
||||
const targetCity = String(cityName || "");
|
||||
@@ -714,7 +815,7 @@ async function loadDetail(cityName, options = {}) {
|
||||
return { fromCache: true };
|
||||
}
|
||||
|
||||
setLoading(true, forceRefresh ? "正在刷新最新温度数据..." : "正在加载温度数据...");
|
||||
setLoading(true, forceRefresh ? t("loadingWeatherRefresh") : t("loadingWeather"));
|
||||
try {
|
||||
const encoded = encodeURIComponent(targetCity);
|
||||
const suffix = forceRefresh ? "?force_refresh=1" : "";
|
||||
@@ -762,7 +863,7 @@ async function loadCities(options = {}) {
|
||||
return { fromCache: true };
|
||||
}
|
||||
|
||||
setLoading(true, "正在加载城市列表...");
|
||||
setLoading(true, t("loadingCities"));
|
||||
try {
|
||||
const list = await apiGet("/api/cities");
|
||||
const cities = Array.isArray(list)
|
||||
@@ -856,7 +957,7 @@ function bindEvents() {
|
||||
try {
|
||||
await setSelectedCity(value, { persist: true, reloadDetail: true });
|
||||
} catch (err) {
|
||||
showError(`加载城市详情失败: ${err.message}`);
|
||||
showError(`${t("loadCityDetailFailed")}: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -869,7 +970,7 @@ function bindEvents() {
|
||||
try {
|
||||
await loadDetail(city, { forceRefresh: true });
|
||||
} catch (err) {
|
||||
showError(`刷新温度数据失败: ${err.message}`);
|
||||
showError(`${t("refreshFailed")}: ${err.message}`);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
@@ -887,6 +988,7 @@ function bindEvents() {
|
||||
|
||||
async function boot() {
|
||||
bindEvents();
|
||||
applyStaticTranslations();
|
||||
clearError();
|
||||
try {
|
||||
state.config = await getConfig();
|
||||
@@ -897,11 +999,11 @@ async function boot() {
|
||||
const msg = String(err?.message || err || "");
|
||||
if (msg.includes("HTTP 401")) {
|
||||
showError(
|
||||
`初始化失败: ${msg}\n当前插件是公开读模式,Token 可留空。请检查后端是否仍开启了接口鉴权。`
|
||||
`${t("initFailed")}: ${msg}\n${t("publicReadHint")}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
showError(`初始化失败: ${msg}\n公开模式只需配置 API Base;Token 可留空。`);
|
||||
showError(`${t("initFailed")}: ${msg}\n${t("publicModeHint")}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user