diff --git a/frontend/app/api/system/update-announcement/route.ts b/frontend/app/api/system/update-announcement/route.ts new file mode 100644 index 00000000..71d64015 --- /dev/null +++ b/frontend/app/api/system/update-announcement/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; +const BACKEND = API_BASE ? `${API_BASE}/api/system/update-announcement` : ""; + +export async function GET() { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const res = await fetch(BACKEND, { cache: "no-store" }); + const raw = await res.text(); + return new NextResponse(raw, { + status: res.status, + headers: { + "Cache-Control": "no-store", + "Content-Type": "application/json", + }, + }); + } catch (error) { + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch update announcement", + }); + } +} diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 89babeca..c3ee89f7 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -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({ {t("dashboard", isEn)} +
+ +
{onlineCount != null && (
diff --git a/frontend/components/dashboard/scan-terminal/UpdateAnnouncementButton.tsx b/frontend/components/dashboard/scan-terminal/UpdateAnnouncementButton.tsx new file mode 100644 index 00000000..d7586dcb --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/UpdateAnnouncementButton.tsx @@ -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(null); + const [open, setOpen] = useState(false); + const shellRef = useRef(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 ( +
+ + {open && ( +
+
+
+ +
+
+
+ {text.title || (isEn ? "PolyWeather update" : "PolyWeather 更新")} +
+ {updatedAt && ( +
+ {isEn ? "Updated" : "更新"} {updatedAt} +
+ )} +
+ +
+ {text.body && ( +

+ {text.body} +

+ )} +
+ )} +
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/updateAnnouncement.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/updateAnnouncement.test.ts new file mode 100644 index 00000000..d180b7f7 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/updateAnnouncement.test.ts @@ -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(" null)) as Partial | 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() {

配置 API 尚未就绪(需要后端支持)

) : (
- {configs.map((cfg) => ( -
-
-
{cfg.key}
-
{cfg.description}
+ {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 ( +
+
+
+
+
{cfg.key}
+ + {sourceLabel} + +
+
{cfg.description}
+
+ 更新人 {cfg.updated_by || "-"} + 更新时间 {cfg.updated_at || "-"} +
+
+
+ {cfg.multiline ? ( +