Add terminal update announcements
This commit is contained in:
@@ -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",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user