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",
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user