Add terminal update announcements

This commit is contained in:
2569718930@qq.com
2026-06-07 00:04:08 +08:00
parent a40b1ba137
commit 3951735259
11 changed files with 648 additions and 31 deletions
@@ -61,6 +61,7 @@ import {
type FeedbackDraft,
} from "@/components/dashboard/scan-terminal/UserFeedbackModal";
import { UserFeedbackStatusButton } from "@/components/dashboard/scan-terminal/UserFeedbackStatusButton";
import { UpdateAnnouncementButton } from "@/components/dashboard/scan-terminal/UpdateAnnouncementButton";
import {
mergeAccessStateWithAuthPayload,
type AuthProfilePayload,
@@ -951,6 +952,9 @@ function PolyWeatherTerminal({
<Activity size={13} />
{t("dashboard", isEn)}
</div>
<div className="hidden lg:block">
<UpdateAnnouncementButton isEn={isEn} />
</div>
{onlineCount != null && (
<div className="hidden items-center gap-1 text-[10px] font-medium text-slate-400 lg:flex">
<Users size={12} />
@@ -0,0 +1,137 @@
"use client";
import { Megaphone, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
type AnnouncementText = {
title?: string;
body?: string;
};
type UpdateAnnouncementPayload = {
enabled?: boolean;
zh?: AnnouncementText;
en?: AnnouncementText;
updated_at?: string;
};
type UpdateAnnouncementButtonProps = {
isEn: boolean;
};
function pickAnnouncementText(payload: UpdateAnnouncementPayload, isEn: boolean) {
const primary = isEn ? payload.en : payload.zh;
const fallback = isEn ? payload.zh : payload.en;
return {
title: String(primary?.title || fallback?.title || "").trim(),
body: String(primary?.body || fallback?.body || "").trim(),
};
}
function formatUpdatedAt(value: string | undefined, isEn: boolean) {
if (!value) return "";
const date = new Date(value);
if (!Number.isFinite(date.getTime())) return "";
return date.toLocaleString(isEn ? "en-US" : "zh-CN", {
hour12: false,
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export function UpdateAnnouncementButton({ isEn }: UpdateAnnouncementButtonProps) {
const [announcement, setAnnouncement] = useState<UpdateAnnouncementPayload | null>(null);
const [open, setOpen] = useState(false);
const shellRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let cancelled = false;
async function loadAnnouncement() {
try {
const res = await fetch("/api/system/update-announcement", { cache: "no-store" });
if (!res.ok) return;
const data = (await res.json()) as UpdateAnnouncementPayload;
if (!cancelled) {
setAnnouncement(data?.enabled ? data : null);
}
} catch {
if (!cancelled) setAnnouncement(null);
}
}
void loadAnnouncement();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
if (!shellRef.current?.contains(event.target as Node)) {
setOpen(false);
}
};
window.addEventListener("pointerdown", handlePointerDown);
return () => window.removeEventListener("pointerdown", handlePointerDown);
}, [open]);
const text = useMemo(
() => (announcement ? pickAnnouncementText(announcement, isEn) : { title: "", body: "" }),
[announcement, isEn],
);
const updatedAt = useMemo(
() => formatUpdatedAt(announcement?.updated_at, isEn),
[announcement?.updated_at, isEn],
);
if (!announcement || (!text.title && !text.body)) return null;
return (
<div ref={shellRef} className="relative">
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="inline-flex h-7 items-center gap-1.5 rounded border border-blue-200 bg-blue-50 px-2 text-[10px] font-bold uppercase tracking-wide text-blue-700 transition-colors hover:border-blue-300 hover:bg-blue-100"
title={isEn ? "Update announcement" : "更新公告"}
aria-expanded={open}
>
<Megaphone size={12} />
{isEn ? "Updates" : "更新公告"}
</button>
{open && (
<div className="absolute left-0 top-8 z-50 w-[min(360px,calc(100vw-32px))] rounded-md border border-slate-200 bg-white p-3 text-left shadow-lg">
<div className="flex items-start gap-3">
<div className="grid h-7 w-7 shrink-0 place-items-center rounded border border-blue-100 bg-blue-50 text-blue-600">
<Megaphone size={14} />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-bold leading-5 text-slate-900">
{text.title || (isEn ? "PolyWeather update" : "PolyWeather 更新")}
</div>
{updatedAt && (
<div className="mt-0.5 font-mono text-[10px] text-slate-400">
{isEn ? "Updated" : "更新"} {updatedAt}
</div>
)}
</div>
<button
type="button"
onClick={() => setOpen(false)}
className="grid h-6 w-6 shrink-0 place-items-center rounded border border-slate-200 text-slate-400 hover:bg-slate-50 hover:text-slate-700"
title={isEn ? "Close" : "关闭"}
>
<X size={12} />
</button>
</div>
{text.body && (
<p className="mt-3 whitespace-pre-line text-[12px] font-medium leading-5 text-slate-600">
{text.body}
</p>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,92 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const repoRoot = path.resolve(projectRoot, "..");
const dashboardSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "ScanTerminalDashboard.tsx"),
"utf8",
);
const opsConfigSource = fs.readFileSync(
path.join(projectRoot, "components", "ops", "config", "ConfigPageClient.tsx"),
"utf8",
);
const nextRoutePath = path.join(
projectRoot,
"app",
"api",
"system",
"update-announcement",
"route.ts",
);
const componentPath = path.join(
projectRoot,
"components",
"dashboard",
"scan-terminal",
"UpdateAnnouncementButton.tsx",
);
const opsApiSource = fs.readFileSync(path.join(repoRoot, "web", "services", "ops_api.py"), "utf8");
const systemApiSource = fs.readFileSync(path.join(repoRoot, "web", "services", "system_api.py"), "utf8");
const systemRouterSource = fs.readFileSync(path.join(repoRoot, "web", "routers", "system.py"), "utf8");
const dbSource = fs.readFileSync(path.join(repoRoot, "src", "database", "db_manager.py"), "utf8");
const middlewareSource = fs.readFileSync(path.join(projectRoot, "middleware.ts"), "utf8");
assert(fs.existsSync(componentPath), "terminal must have a compact update announcement component");
assert(fs.existsSync(nextRoutePath), "frontend must proxy the public update announcement API");
const componentSource = fs.readFileSync(componentPath, "utf8");
const routeSource = fs.readFileSync(nextRoutePath, "utf8");
assert(
dashboardSource.includes("UpdateAnnouncementButton") &&
dashboardSource.includes("<UpdateAnnouncementButton") &&
dashboardSource.includes("isEn={isEn}"),
"terminal header must render a bilingual update announcement entry beside the dashboard title",
);
assert(
componentSource.includes("/api/system/update-announcement") &&
componentSource.includes("Megaphone") &&
componentSource.includes("zh") &&
componentSource.includes("en") &&
!componentSource.includes("setInterval("),
"announcement component must fetch the public announcement once, support zh/en content, and avoid aggressive polling",
);
assert(
routeSource.includes(`${"api/system/update-announcement"}`) &&
routeSource.includes("cache: \"no-store\""),
"Next.js announcement proxy must call the backend public endpoint without caching stale admin content",
);
assert(
middlewareSource.includes('pathname === "/api/system/update-announcement"'),
"update announcement proxy must stay public because it only returns non-sensitive release notes",
);
assert(
opsConfigSource.includes("multiline") &&
opsConfigSource.includes("<textarea") &&
opsConfigSource.includes("DB 持久化"),
"ops config page must support persistent multiline announcement fields",
);
assert(
opsApiSource.includes("POLYWEATHER_UPDATE_ANNOUNCEMENT_TITLE_ZH") &&
opsApiSource.includes("POLYWEATHER_UPDATE_ANNOUNCEMENT_BODY_EN") &&
opsApiSource.includes("_RUNTIME_CONFIG_KEYS"),
"ops API must expose editable zh/en announcement keys through the runtime config store",
);
assert(
systemApiSource.includes("get_public_update_announcement") &&
systemRouterSource.includes("/api/system/update-announcement"),
"backend must expose a public read-only update announcement endpoint",
);
assert(
dbSource.includes("CREATE TABLE IF NOT EXISTS runtime_config") &&
dbSource.includes("set_runtime_config") &&
dbSource.includes("get_runtime_config_value"),
"database manager must persist non-sensitive runtime config independently from runtime secrets",
);
}
@@ -9,6 +9,11 @@ type EditableConfig = {
key: string;
value: string;
description: string;
multiline?: boolean;
persistent?: boolean;
source?: string;
updated_at?: string;
updated_by?: string;
};
type SensitiveConfig = {
@@ -73,8 +78,13 @@ export function ConfigPageClient() {
body: JSON.stringify({ key, value: newVal }),
});
if (res.ok) {
const data = (await res.json().catch(() => null)) as Partial<EditableConfig> | null;
setResult(`${key} 已更新`);
setConfigs((prev) => prev.map((c) => (c.key === key ? { ...c, value: newVal } : c)));
setConfigs((prev) => prev.map((c) => (
c.key === key
? { ...c, ...(data ?? {}), value: String(data?.value ?? newVal) }
: c
)));
setEditing((prev) => { const n = { ...prev }; delete n[key]; return n; });
} else {
setResult(`保存失败: ${await res.text().catch(() => "")}`);
@@ -139,28 +149,89 @@ export function ConfigPageClient() {
<p className="text-slate-500 text-sm"> API </p>
) : (
<div className="space-y-3">
{configs.map((cfg) => (
<div key={cfg.key} className="flex items-center gap-3 rounded-lg border border-white/5 bg-white/5 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="text-white text-sm font-medium">{cfg.key}</div>
<div className="text-slate-500 text-xs mt-0.5">{cfg.description}</div>
{configs.map((cfg) => {
const currentValue = editing[cfg.key] ?? cfg.value;
const dirty = editing[cfg.key] != null && editing[cfg.key] !== cfg.value;
const persistent = Boolean(cfg.persistent || cfg.source === "runtime_config");
const sourceLabel = persistent
? "DB 持久化"
: cfg.source === "environment"
? "环境变量"
: cfg.source || "当前进程";
if (persistent) {
return (
<div key={cfg.key} className="rounded-lg border border-white/5 bg-white/5 px-4 py-4">
<div className="flex flex-col gap-3 xl:flex-row xl:items-start">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="text-white text-sm font-medium">{cfg.key}</div>
<span className="rounded-full bg-cyan-400/10 px-2 py-0.5 text-[11px] text-cyan-300">
{sourceLabel}
</span>
</div>
<div className="text-slate-500 text-xs mt-1">{cfg.description}</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
<span> <span className="font-mono text-slate-300">{cfg.updated_by || "-"}</span></span>
<span> <span className="font-mono text-slate-300">{cfg.updated_at || "-"}</span></span>
</div>
</div>
<div className="flex w-full flex-col gap-2 xl:w-[560px]">
{cfg.multiline ? (
<textarea
value={currentValue}
rows={4}
spellCheck={false}
onChange={(e) => setEditing((prev) => ({ ...prev, [cfg.key]: e.target.value }))}
className="min-h-24 w-full resize-y rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm leading-5 text-white outline-none focus:border-cyan-400/50"
/>
) : (
<input
value={currentValue}
onChange={(e) => setEditing((prev) => ({ ...prev, [cfg.key]: e.target.value }))}
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white font-mono outline-none focus:border-cyan-400/50"
/>
)}
<div className="flex justify-end">
<Button
size="sm"
variant="outline"
disabled={saving || !dirty}
onClick={() => handleSave(cfg.key)}
className="gap-1"
>
<Save className="h-3 w-3" />
</Button>
</div>
</div>
</div>
</div>
);
}
return (
<div key={cfg.key} className="flex items-center gap-3 rounded-lg border border-white/5 bg-white/5 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="text-white text-sm font-medium">{cfg.key}</div>
<div className="text-slate-500 text-xs mt-0.5">{cfg.description}</div>
</div>
<input
value={currentValue}
onChange={(e) => setEditing((prev) => ({ ...prev, [cfg.key]: e.target.value }))}
className="w-24 rounded-lg border border-white/10 bg-black/30 px-3 py-1.5 text-sm text-white font-mono text-center outline-none focus:border-cyan-400/50"
/>
<Button
size="sm"
variant="outline"
disabled={saving || !dirty}
onClick={() => handleSave(cfg.key)}
className="gap-1"
>
<Save className="h-3 w-3" />
</Button>
</div>
<input
value={editing[cfg.key] ?? cfg.value}
onChange={(e) => setEditing((prev) => ({ ...prev, [cfg.key]: e.target.value }))}
className="w-24 rounded-lg border border-white/10 bg-black/30 px-3 py-1.5 text-sm text-white font-mono text-center outline-none focus:border-cyan-400/50"
/>
<Button
size="sm"
variant="outline"
disabled={saving || editing[cfg.key] === cfg.value || editing[cfg.key] == null}
onClick={() => handleSave(cfg.key)}
className="gap-1"
>
<Save className="h-3 w-3" />
</Button>
</div>
))}
);
})}
</div>
)}
{result && (
@@ -169,7 +240,7 @@ export function ConfigPageClient() {
</p>
)}
<p className="mt-4 text-xs text-slate-500">
使
DB
</p>
</CardContent>
</Card>