import { useState, type ReactNode } from "react"; import { adminSystemBackup, adminSystemRestore, adminSystemRevokeKeys, adminSystemRotateJWT, getStoredRole, } from "../../lib/api"; import type { SystemBackupResp, SystemRestoreResp, SystemRevokeKeysResp, SystemRotateJwtResp, } from "../../lib/types"; import { useAuditStore } from "../audit"; import { KeyValueRow, Notice, PageHeader } from "../components"; import { Button, Surface, Tag } from "../../components/primitives"; import { Icon } from "../../components/Icon"; /** * 系统设置 (design/10 §3 模块14 + design/11 §4 敏感操作代审)。后端已实现 4 个 * owner-only 且审计的**可写**系统操作:吊销全部 API key / 轮换 JWT 密钥 / 备份 / * 恢复(见 internal/services/gateway/admin_system.go)。这里把它们做成真实可点的 * Miuix 卡片,每步带确认弹窗防误触;只读事实卡保持不变。 * 未实现(编辑环境变量 / 重启服务 / 检查更新)诚实标注为「后端未实现,不提供」。 */ export function SystemModule(): React.ReactNode { const isOwner = getStoredRole() === "owner"; const log = useAuditStore((s) => s.log); // 结果反馈(成功 tone="ok" / 失败 tone="err")。 const [msg, setMsg] = useState<{ text: string; tone: "ok" | "err" } | null>(null); const [busy, setBusy] = useState(false); // 当前打开的确认弹窗(null 表示无)。rotate / restore 需要输入,故各自持有字段。 const [pending, setPending] = useState<"revoke" | "rotate" | "backup" | "restore" | null>(null); const [newSecret, setNewSecret] = useState(""); const [secretErr, setSecretErr] = useState(null); const [restorePath, setRestorePath] = useState(""); const [restoreErr, setRestoreErr] = useState(null); const close = () => { setPending(null); setSecretErr(null); setRestoreErr(null); }; const done = (tone: "ok" | "err", text: string) => setMsg({ tone, text }); const doRevoke = (): void => { setBusy(true); setMsg(null); adminSystemRevokeKeys() .then((r: SystemRevokeKeysResp) => { log("system.revoke_keys", "ok"); done("ok", `已吊销 ${r.revoked} 个 API key;这些 key 立即失效。`); close(); }) .catch((e: unknown) => { log("system.revoke_keys", "error"); done("err", errText(e, "吊销失败")); }) .finally(() => setBusy(false)); }; const doRotate = (): void => { const secret = newSecret.trim(); if (secret.length < 32) { setSecretErr("新密钥长度不足 32 位"); return; } setBusy(true); setMsg(null); adminSystemRotateJWT(secret) .then((r: SystemRotateJwtResp) => { log("system.rotate_jwt", "ok"); done("ok", `JWT 密钥已轮换。${r.note}——请返回登录页重新登录以继续。`); setNewSecret(""); close(); }) .catch((e: unknown) => { log("system.rotate_jwt", "error"); done("err", errText(e, "轮换失败")); }) .finally(() => setBusy(false)); }; const doBackup = (): void => { setBusy(true); setMsg(null); adminSystemBackup() .then((r: SystemBackupResp) => { log("system.backup", "ok"); done("ok", `备份完成:${formatBytes(r.size)} → ${r.path}`); close(); }) .catch((e: unknown) => { log("system.backup", "error"); done("err", errText(e, "备份失败")); }) .finally(() => setBusy(false)); }; const doRestore = (): void => { const path = restorePath.trim(); if (!path) { setRestoreErr("请填写要恢复的备份文件路径"); return; } setBusy(true); setMsg(null); adminSystemRestore(path) .then((r: SystemRestoreResp) => { log("system.restore", "ok"); done("ok", `已安排恢复:${r.note}(恢复前已备份到 ${r.backup})`); setRestorePath(""); close(); }) .catch((e: unknown) => { log("system.restore", "error"); done("err", errText(e, "恢复失败")); }) .finally(() => setBusy(false)); }; const genSecret = (): void => { let out = ""; if (typeof crypto !== "undefined" && "getRandomValues" in crypto) { const bytes = new Uint8Array(24); crypto.getRandomValues(bytes); out = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); } else { for (let i = 0; i < 48; i += 1) out += Math.floor(Math.random() * 16).toString(16); } setNewSecret(out); setSecretErr(null); }; return (
} title="安全 & 密钥" ownerOnly /> } title="存储" /> } title="环境变量" /> {/* 键名必须与 `.env` / `.env.example` **逐字一致**:面板是只读事实卡, 用户会照着这里抄键名去改 .env —— 写错一个字母就是静默不生效(AGENTS §10 E6)。 真键:HOST / PORT / ADMIN_USERNAME / ADMIN_PASSWORD(见 .env.example)。 */}

以上为 .env.example 的默认值(这几条没有只读读取端点,不是运行实例的实时值; 带凭据的键面板刻意不回值)。改值请编辑 .env 后重启服务。

要看运行实例的实时生效值,去两处只读事实卡:记忆管理页的「记忆整理」与后台思考页的「安静时段」 (GET /api/v1/admin/config-facts,契约 v0.68)。

} title="运行时" />
{msg ? {msg.text} : null}

可写系统操作

owner 专属 真实读写 · 已审计 · 每步需确认
} title="吊销全部 API key" desc="清空 key 表:所有已注册的 API key 立即失效,需重新签发。不可撤销。" buttonText="吊销" variant="danger" disabled={!isOwner || busy} onClick={() => setPending("revoke")} /> } title="轮换 JWT 密钥" desc="换用新的签名密钥(≥32 位)。现有 token 立即失效,需重新登录;新密钥不回显。" buttonText="轮换" variant="outline" disabled={!isOwner || busy} onClick={() => setPending("rotate")} /> } title="备份数据库" desc="用 SQLite VACUUM INTO 快照当前库(含已提交 WAL)到数据目录,返回路径与大小。" buttonText="备份" variant="primary" disabled={!isOwner || busy} onClick={() => setPending("backup")} /> } title="恢复数据库" desc="从数据目录内的备份文件恢复;恢复前自动备份当前库,替换需重启服务生效。" buttonText="恢复" variant="danger" disabled={!isOwner || busy} onClick={() => setPending("restore")} />
以下系统操作后端尚未实现,当前不提供:编辑环境变量 · 重启服务 · 检查更新。均为真实读写,未实现前不做占位按钮。 {!isOwner ? ( 安全 / 密钥 / 系统设置类敏感操作仅 owner 可执行;当前角色 {getStoredRole()}。 ) : null} {pending === "revoke" ? ( 将吊销全部已注册的 API key;操作完成后所有使用这些 key 的客户端立即失效, 需重新签发。此操作不可撤销,确定继续?

} onConfirm={doRevoke} onClose={close} /> ) : null} {pending === "rotate" ? (

输入新的 JWT 签名密钥(≥32 位)。轮换后所有现有 token 立即失效,需重新登录; 新密钥不会被后端回显,请在提交前保存好(提交后输入框即清空)。

{secretErr ? {secretErr} : null} } onConfirm={doRotate} onClose={close} /> ) : null} {pending === "backup" ? ( 用 SQLite VACUUM INTO 把当前数据库 (含已提交 WAL 内容)快照到数据目录,返回备份文件的路径与大小。不会影响正在运行的服务。

} onConfirm={doBackup} onClose={close} /> ) : null} {pending === "restore" ? (

从数据目录内的备份文件恢复。恢复前会自动备份当前数据库;替换后需 重启服务 才生效,且会覆盖现有数据。

{restoreErr ? {restoreErr} : null} } onConfirm={doRestore} onClose={close} /> ) : null}
); } function SectionTitle({ icon, title, ownerOnly = false }: { icon: ReactNode; title: string; ownerOnly?: boolean }): ReactNode { return (

{icon} {title} {ownerOnly ? owner : null}

); } interface WriteActionProps { icon: ReactNode; title: string; desc: string; buttonText: string; variant: "primary" | "soft" | "ghost" | "outline" | "danger"; disabled: boolean; onClick: () => void; } /** One owner-only write action as a Miuix card; clicking opens the confirm dialog. */ function WriteAction({ icon, title, desc, buttonText, variant, disabled, onClick }: WriteActionProps): ReactNode { return (
{icon} {title} 已实现

{desc}

); } interface ConfirmDialogProps { title: string; body: ReactNode; confirmText: string; danger?: boolean; busy: boolean; onConfirm: () => void; onClose: () => void; } /** A Miuix confirmation overlay (防误触). Blocks the action until the owner confirms. */ function ConfirmDialog({ title, body, confirmText, danger = false, busy, onConfirm, onClose }: ConfirmDialogProps): ReactNode { return (
e.stopPropagation()}>
{title} 确认操作 {danger ? "危险" : "请确认"}
{body}
); } const inputCls = "w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"; function errText(err: unknown, fallback: string): string { return err instanceof Error ? err.message : fallback; } /** Human-readable byte size (MiB default unit for a DB snapshot). */ function formatBytes(n: number): string { if (n < 1024) return `${n} B`; const units = ["KiB", "MiB", "GiB", "TiB"]; let v = n; let u = -1; do { v /= 1024; u += 1; } while (v >= 1024 && u < units.length - 1); return `${v.toFixed(1)} ${units[u]}`; }