Files
jiang13-bbs/frontend/app/admin/settings/OperationsPanel.tsx

1076 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import {
Activity,
ClipboardList,
FileSearch,
Loader2,
Mail,
Trash2,
} from "lucide-react";
import {
AdminSegmented,
AdminSettingsActions,
AdminSettingsGroup,
AdminSettingsRow,
AdminSwitch,
} from "@/components/admin";
import { apiOperations } from "@/lib/api";
import ConfirmDialog from "@/components/ConfirmDialog";
import FilterRules, { type Rule } from "./FilterRules";
export type ModuleName = "security" | "mail" | "storage" | "filter" | "maintenance";
type Data = Record<string, string | number | boolean | string[] | Rule[]>;
type Snapshot = {
data: Data;
version: number;
state: string;
site_url: string;
local_directory: string;
encryption_ready: boolean;
recovery_override: boolean;
template_preview?: string;
};
type Field = {
key: string;
label: string;
type?: "number" | "switch" | "secret" | "select" | "list" | "textarea" | "datetime";
hint?: string;
/** 控件下方较长说明(如模板变量释义) */
hintBelow?: ReactNode;
min?: number;
max?: number;
rows?: number;
wide?: boolean;
options?: [string, string][];
advanced?: boolean;
s3?: boolean;
};
type Group = { title: string; description?: string; fields: Field[] };
const NUM = (key: string, label: string, min: number, max: number, unit: string): Field => ({
key,
label,
type: "number",
min,
max,
hint: `${min}–${max} ${unit}`.trim(),
});
/** 邮件模板占位符释义(注册验证与找回密码共用) */
const MAIL_TEMPLATE_VARS = (
<dl className="ops-mail-vars">
<div>
<dt>{"{{site_name}}"}</dt>
<dd>站点名称(取自基本信息)</dd>
</div>
<div>
<dt>{"{{purpose}}"}</dt>
<dd>用途文案:账号验证 / 密码找回</dd>
</div>
<div>
<dt>{"{{code}}"}</dt>
<dd>验证码(正文必须包含)</dd>
</div>
<div>
<dt>{"{{link}}"}</dt>
<dd>引导用户回站完成验证/重置的链接</dd>
</div>
<div>
<dt>{"{{site_url}}"}</dt>
<dd>站点正式地址(SITE_URL)</dd>
</div>
<div>
<dt>{"{{logo}}"}</dt>
<dd>浅色 Logo 的完整 img 标签(勿写入 src)</dd>
</div>
<div>
<dt>{"{{logo_url}}"}</dt>
<dd>浅色 Logo 的绝对图片 URL</dd>
</div>
</dl>
);
const schemas: Record<ModuleName, Group[]> = {
security: [
{
title: "注册准入",
fields: [
{ key: "allow_register", label: "开放注册", type: "switch", hint: "关闭后仍保留注册入口,用户可看到关闭提示" },
{ key: "register_notice", label: "关闭注册提示", hint: "关闭注册时在注册页展示" },
{ key: "verify_email", label: "注册邮箱验证", type: "switch" },
{ key: "password_reset", label: "邮件找回密码", type: "switch" },
],
},
{
title: "登录保护与频率",
fields: [
NUM("login_window", "失败观察窗口", 60, 3600, "秒"),
NUM("login_failures", "账号失败阈值", 3, 50, "次"),
NUM("post_interval", "发帖最小间隔", 0, 3600, "秒"),
NUM("comment_interval", "评论最小间隔", 0, 3600, "秒"),
NUM("search_minute", "搜索频率", 1, 120, "次/分"),
],
},
{
title: "账号邮件保护",
fields: [
NUM("resend_interval", "验证码重发间隔", 30, 3600, "秒"),
NUM("email_hourly", "邮箱发送上限", 1, 20, "次/时"),
],
},
],
mail: [
{
title: "SMTP 服务",
fields: [
{ key: "enabled", label: "启用邮件", type: "switch" },
{ key: "host", label: "SMTP 主机" },
{
key: "tls",
label: "加密方式",
type: "select",
options: [
["tls", "隐式 TLS(465)"],
["starttls", "STARTTLS(587)"],
],
},
NUM("port", "SMTP 端口", 1, 65535, ""),
{ key: "username", label: "登录用户名" },
{ key: "password", label: "密码 / 授权码", type: "secret" },
],
},
{
title: "发件身份",
fields: [
{ key: "from_name", label: "发件人名称" },
{ key: "from", label: "发件人邮箱" },
{ key: "reply_to", label: "回复邮箱" },
{ ...NUM("timeout", "连接超时", 2, 30, "秒"), advanced: true },
{ ...NUM("retention", "发送记录保留", 1, 365, "天"), advanced: true },
],
},
{
title: "邮件模板",
description:
"注册验证与找回密码共用;主题或正文留空则恢复内置模板。发送与预览会把 <style> 中的 class 规则内联到元素上(手机 QQ 等客户端会丢弃 style 标签)。变量值发送时转义;正文不做 HTML 过滤。",
fields: [
{
key: "subject_template",
label: "主题",
hint: "建议含站点名与用途",
hintBelow: (
<p className="ops-mail-vars-lead">
可用 {"{{site_name}}"}(站点名)、{"{{purpose}}"}(账号验证 / 密码找回)。
</p>
),
},
{
key: "body_template",
label: "正文 HTML",
type: "textarea",
rows: 10,
wide: true,
hint: "必须包含 {{code}};样式写在元素 style 或 <style> 的 class 规则中",
hintBelow: (
<>
<p className="ops-mail-vars-lead">可用占位符(发送时替换为实际内容):</p>
{MAIL_TEMPLATE_VARS}
</>
),
},
],
},
],
storage: [
{
title: "新上传目标",
fields: [
{
key: "backend",
label: "存储后端",
type: "select",
options: [
["local", "本地存储"],
["s3", "S3 兼容存储"],
],
},
{ key: "endpoint", label: "Endpoint", s3: true },
{ key: "bucket", label: "Bucket", s3: true },
{ key: "region", label: "Region", s3: true },
{ key: "access_key", label: "Access Key", type: "secret", s3: true },
{ key: "secret_key", label: "Secret Key", type: "secret", s3: true },
{ key: "cdn", label: "公开图片 CDN", s3: true, advanced: true },
{ key: "prefix", label: "对象前缀", s3: true, hint: "以 / 结尾" },
{ key: "path_style", label: "路径寻址", type: "switch", s3: true, advanced: true },
],
},
{
title: "上传规则",
fields: [
NUM("image_max_mb", "正文图片上限", 1, 50, "MiB"),
NUM("attachment_max_mb", "单个附件上限", 1, 100, "MiB"),
NUM("attachment_max_count", "每帖附件数", 1, 20, "个"),
{ key: "attachment_ext_limit", label: "扩展名白名单", type: "switch" },
{ key: "attachment_exts", label: "允许扩展名", type: "list", hint: "逗号分隔、不含点" },
],
},
{ title: "文件访问", fields: [] },
],
filter: [
{
title: "内容过滤",
fields: [{ key: "enabled", label: "启用过滤", type: "switch" }],
},
],
maintenance: [
{
title: "运行模式",
fields: [
{
key: "mode",
label: "模式",
type: "select",
options: [
["normal", "正常运行"],
["readonly", "只读"],
["paused", "暂停访问(503)"],
],
},
{ key: "title", label: "维护标题" },
{ key: "message", label: "维护说明", type: "textarea", rows: 3 },
{ key: "until", label: "预计恢复", type: "datetime" },
{ key: "contact", label: "联系方式" },
NUM("retry_after", "建议重试间隔", 30, 86400, "秒"),
],
},
{
title: "临时文件",
fields: [NUM("temp_days", "临时文件保留", 1, 365, "天")],
},
],
};
function isoToLocal(iso: string): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
}
function localToIso(local: string): string {
if (!local) return "";
const d = new Date(local);
if (Number.isNaN(d.getTime())) return "";
return d.toISOString();
}
function formatLocalDisplay(iso: string): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString("zh-CN", { hour12: false });
}
/** 预览用 srcDoc:去掉脚本后注入量高助手;沙箱仅 allow-scripts、不同源 */
function mailPreviewSrcDoc(html: string): string {
const cleaned = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "");
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"/><title>邮件预览</title><style>html,body{margin:0;padding:0;overflow:hidden;background:#fff}</style></head><body>${cleaned}<script>
(function(){
function send(){
var h=Math.max(document.documentElement.scrollHeight,document.body?document.body.scrollHeight:0);
parent.postMessage({source:"j13-mail-preview",height:h},"*");
}
send();
if(window.ResizeObserver){new ResizeObserver(send).observe(document.documentElement);}
document.querySelectorAll("img").forEach(function(img){if(!img.complete)img.addEventListener("load",send);});
window.addEventListener("message",function(e){if(e.data&&e.data.source==="j13-mail-preview-ping")send();});
})();
<\/script></body></html>`;
}
/** 按正文高度撑开 iframe,避免固定高度出现内部滚动条 */
function MailTemplatePreview({ html }: { html: string }) {
const frameRef = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(0);
const srcDoc = mailPreviewSrcDoc(html);
useEffect(() => {
setHeight(0);
}, [html]);
useEffect(() => {
const onMsg = (e: MessageEvent) => {
if (e.source !== frameRef.current?.contentWindow) return;
const data = e.data as { source?: string; height?: number } | null;
if (data?.source !== "j13-mail-preview" || typeof data.height !== "number") return;
const h = Math.ceil(data.height);
if (h > 0) setHeight(h);
};
window.addEventListener("message", onMsg);
return () => window.removeEventListener("message", onMsg);
}, []);
return (
<details
className="ops-preview"
onToggle={(e) => {
if (!(e.target as HTMLDetailsElement).open) return;
// 展开后请 iframe 再报一次高度
frameRef.current?.contentWindow?.postMessage({ source: "j13-mail-preview-ping" }, "*");
}}
>
<summary>注册验证邮件预览</summary>
<iframe
ref={frameRef}
title="邮件正文预览"
sandbox="allow-scripts"
srcDoc={srcDoc}
className="ops-mail-preview"
style={height > 0 ? { height } : undefined}
/>
</details>
);
}
export default function OperationsPanel({
name,
onDirtyChange,
}: {
name: ModuleName;
onDirtyChange: (dirty: boolean) => void;
}) {
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
const [draft, setDraft] = useState<Data>({});
const [clear, setClear] = useState<string[]>([]);
const [editingSecret, setEditingSecret] = useState<string[]>([]);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const [busy, setBusy] = useState("");
const [result, setResult] = useState<unknown>(null);
const [tested, setTested] = useState("");
const [recipient, setRecipient] = useState("");
const [text, setText] = useState("");
const [scope, setScope] = useState("body");
const [records, setRecords] = useState<Record<string, unknown>[]>([]);
const [recordsLoaded, setRecordsLoaded] = useState(false);
const [confirmClean, setConfirmClean] = useState(false);
const [cleanKind, setCleanKind] = useState("clear-mail-logs");
const [candidates, setCandidates] = useState<string[]>([]);
const serialized = JSON.stringify(draft) + JSON.stringify(clear) + recipient + scope + text;
const dirty = !!snapshot && (JSON.stringify(draft) !== JSON.stringify(snapshot.data) || clear.length > 0);
useEffect(() => {
onDirtyChange(dirty);
return () => onDirtyChange(false);
}, [dirty, onDirtyChange]);
useEffect(() => {
let active = true;
setRecords([]);
setRecordsLoaded(false);
setResult(null);
apiOperations<Snapshot>("/api/admin/settings/modules/" + name)
.then((s) => {
if (!active) return;
setSnapshot(s);
setDraft(s.data);
})
.catch((e) => {
if (active) setError(e.message);
});
return () => {
active = false;
};
}, [name]);
const update = (key: string, value: Data[string]) => setDraft((d) => ({ ...d, [key]: value }));
const save = async () => {
if (!snapshot || saving) return;
setSaving(true);
setError("");
try {
const s = await apiOperations<Snapshot>("/api/admin/settings/modules/" + name, "PUT", {
version: snapshot.version,
data: draft,
clear,
});
setSnapshot(s);
setDraft(s.data);
setClear([]);
setEditingSecret([]);
} catch (e) {
const msg = (e as Error).message;
setError(msg);
const key = msg.split(":")[0];
document.getElementById("ops-" + key)?.focus();
document.getElementById("ops-" + key)?.scrollIntoView({ block: "center", behavior: "smooth" });
document.querySelector(".ops-savebar")?.scrollIntoView({ block: "nearest", behavior: "smooth" });
} finally {
setSaving(false);
}
};
const run = async (action: string) => {
if (busy) return;
setBusy(action);
setError("");
try {
let output: unknown;
if (action === "records") {
const r = await apiOperations<{ records: Record<string, unknown>[] }>(
"/api/admin/settings/modules/" + name + "/records"
);
setRecords((r.records || []).slice(0, 50));
setRecordsLoaded(true);
return;
}
if (action === "diagnostics") output = await apiOperations("/api/admin/diagnostics");
else if (action === "scan" || action === "clear-mail-logs" || action === "clean-temporary")
output = await apiOperations("/api/admin/maintenance/actions", "POST", {
action,
confirm: action !== "scan",
ids: candidates,
});
else
output = await apiOperations("/api/admin/settings/modules/" + name + "/test", "POST", {
data: draft,
clear,
action,
recipient,
text,
scope,
});
if (action === "scan")
setCandidates(((output as { candidates?: { id: string }[] }).candidates || []).map((c) => c.id));
if (action === "clean-temporary") setCandidates([]);
setResult(output);
setTested(serialized);
} catch (e) {
setError((e as Error).message);
setResult(null);
} finally {
setBusy("");
}
};
const beginReplace = (key: string) => {
setEditingSecret((keys) => (keys.includes(key) ? keys : [...keys, key]));
setClear((keys) => keys.filter((k) => k !== key));
update(key, "");
};
const cancelReplace = (key: string) => {
setEditingSecret((keys) => keys.filter((k) => k !== key));
update(key, "");
};
const beginRemove = (key: string) => {
setEditingSecret((keys) => keys.filter((k) => k !== key));
setClear((keys) => (keys.includes(key) ? keys : [...keys, key]));
update(key, "");
};
const cancelRemove = (key: string) => setClear((keys) => keys.filter((k) => k !== key));
const fields = (list: Field[]) =>
list
.filter((f) => !f.s3 || draft.backend === "s3")
.map((f) => {
const id = "ops-" + f.key;
const configured = !!draft[f.key + "_configured"];
const removing = f.type === "secret" && clear.includes(f.key);
const replacing = f.type === "secret" && !removing && (!configured || editingSecret.includes(f.key));
if (f.key === "mode" && f.type === "select" && f.options) {
return (
<AdminSettingsRow key={f.key} label={f.label}>
<AdminSegmented
role="group"
size="sm"
ariaLabel={f.label}
value={String(draft[f.key] ?? "normal")}
onChange={(v) => update(f.key, v)}
options={f.options.map(([k, l]) => ({ key: k, label: l }))}
/>
</AdminSettingsRow>
);
}
return (
<AdminSettingsRow
key={f.key}
wide={f.wide}
label={replacing || f.type !== "secret" ? <label htmlFor={id}>{f.label}</label> : f.label}
hint={f.type === "secret" || f.type === "datetime" ? undefined : f.hint}
hintBelow={f.hintBelow}
>
{f.type === "switch" ? (
<AdminSwitch
label={f.label}
checked={!!draft[f.key]}
disabled={saving}
onChange={() => update(f.key, !draft[f.key])}
/>
) : f.type === "select" ? (
<select
id={id}
className="field ops-select"
value={String(draft[f.key] ?? "")}
onChange={(e) => update(f.key, e.target.value)}
>
{f.options?.map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
) : f.type === "datetime" ? (
<div className="ops-datetime">
<input
id={id}
type="datetime-local"
className="field ops-datetime-input"
value={isoToLocal(String(draft[f.key] ?? ""))}
onChange={(e) => update(f.key, localToIso(e.target.value))}
/>
<div className="ops-datetime-actions">
{draft[f.key] ? (
<span className="meta text-[12px]">{formatLocalDisplay(String(draft[f.key]))}</span>
) : (
<span className="meta text-[12px]">未设置</span>
)}
<button type="button" className="ops-text-btn" onClick={() => update(f.key, "")}>
清除
</button>
</div>
</div>
) : f.type === "textarea" ? (
<textarea
id={id}
className={"field " + (f.wide ? "ops-template" : "ops-input")}
rows={f.rows || 3}
value={String(draft[f.key] ?? "")}
onChange={(e) => update(f.key, e.target.value)}
/>
) : f.type === "secret" ? (
<SecretControl
id={id}
noun={secretNoun(f.key)}
configured={configured}
removing={removing}
replacing={replacing}
value={String(draft[f.key] ?? "")}
onChange={(v) => update(f.key, v)}
onReplace={() => beginReplace(f.key)}
onCancelReplace={() => cancelReplace(f.key)}
onRemove={() => beginRemove(f.key)}
onCancelRemove={() => cancelRemove(f.key)}
/>
) : (
<input
id={id}
className={"field " + (f.type === "number" ? "ops-number" : "ops-input")}
type={f.type === "number" ? "number" : "text"}
min={f.min}
max={f.max}
value={
f.type === "list"
? ((draft[f.key] as string[]) || []).join(",")
: String(draft[f.key] ?? "")
}
onChange={(e) =>
update(
f.key,
f.type === "number"
? Number(e.target.value)
: f.type === "list"
? e.target.value.split(",").map((s) => s.trim())
: e.target.value
)
}
/>
)}
</AdminSettingsRow>
);
});
if (!snapshot) return <div className="panel p-5" role="status">{error || "正在读取配置…"}</div>;
const recordsTitle =
name === "mail"
? "最近发送记录"
: name === "storage"
? "历史存储引用"
: name === "filter"
? "过滤相关记录"
: name === "security"
? "本模块变更记录"
: "维护操作记录";
return (
<div className="ops-panel">
{(name === "mail" || name === "storage") && !snapshot.encryption_ready && (
<p className="ops-hint">
未配置 SETTINGS_MASTER_KEY,无法保存凭据。本地写在 backend/app.ini [security],生产用同名环境变量。
</p>
)}
{name === "maintenance" && snapshot.recovery_override && (
<p role="alert" className="ops-alert">
部署恢复开关正强制正常运行;关闭后已保存模式才会生效。
</p>
)}
<fieldset disabled={saving} className="ops-fields">
{schemas[name].map((group) => (
<AdminSettingsGroup key={group.title} title={group.title} description={group.description}>
{fields(group.fields.filter((f) => !f.advanced))}
{group.fields.some((f) => f.advanced && (!f.s3 || draft.backend === "s3")) && (
<details className="ops-advanced">
<summary>高级</summary>
{fields(group.fields.filter((f) => f.advanced))}
</details>
)}
</AdminSettingsGroup>
))}
{name === "storage" && (
<p className="ops-hint">部署目录(只读):{snapshot.local_directory}</p>
)}
{name === "mail" && snapshot.template_preview && (
<MailTemplatePreview html={snapshot.template_preview} />
)}
{name === "filter" && (
<FilterRules rules={(draft.rules as Rule[]) || []} onChange={(rules) => update("rules", rules)} />
)}
</fieldset>
<section className="admin-settings-group ops-savebar">
<div className="admin-settings-group-body">
<AdminSettingsActions
dirty={dirty}
saving={saving}
saveLabel="保存本页"
error={error}
saveDisabled={!dirty || saving || !!busy}
onSave={() => void save()}
onDiscard={() => {
setDraft(snapshot.data);
setClear([]);
setEditingSecret([]);
setError("");
}}
/>
</div>
</section>
{(name === "mail" || name === "storage" || name === "filter") && (
<AdminSettingsGroup title="测试">
{name === "mail" && (
<AdminSettingsRow label="测试收件邮箱">
<input
aria-label="测试收件邮箱"
className="field ops-input"
type="email"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
/>
</AdminSettingsRow>
)}
{name === "filter" && (
<>
<AdminSettingsRow label="范围">
<select
aria-label="测试范围"
className="field ops-select"
value={scope}
onChange={(e) => setScope(e.target.value)}
>
<option value="body">正文</option>
<option value="title">标题</option>
<option value="comment">评论</option>
<option value="username">用户名</option>
</select>
</AdminSettingsRow>
<AdminSettingsRow label="文本" wide>
<textarea
aria-label="测试文本"
rows={4}
className="field ops-input"
value={text}
onChange={(e) => setText(e.target.value)}
/>
</AdminSettingsRow>
</>
)}
<div className="ops-actions">
{name === "mail" ? (
<>
<button
type="button"
className="btn btn-line btn-sm"
disabled={!!busy || saving}
onClick={() => void run("connection")}
>
测试连接
</button>
<button
type="button"
className="btn btn-line btn-sm"
disabled={!!busy || !recipient || saving}
onClick={() => void run("send")}
>
发送测试邮件
</button>
</>
) : (
<button
type="button"
className="btn btn-line btn-sm"
disabled={!!busy || saving}
onClick={() => void run("test")}
>
{name === "filter" ? "测试规则" : "测试读写与清理"}
</button>
)}
</div>
</AdminSettingsGroup>
)}
{name === "maintenance" && (
<AdminSettingsGroup title="诊断与维护">
<div className="ops-tool-grid">
<button
type="button"
className="ops-tool-card"
disabled={!!busy}
onClick={() => void run("diagnostics")}
>
<Activity size={18} aria-hidden />
<span className="ops-tool-title">运行诊断</span>
<span className="ops-tool-desc">数据库、存储、任务与邮件状态</span>
</button>
<button type="button" className="ops-tool-card" disabled={!!busy} onClick={() => void run("scan")}>
<FileSearch size={18} aria-hidden />
<span className="ops-tool-title">扫描临时文件</span>
<span className="ops-tool-desc">只扫描,不删除</span>
</button>
<button
type="button"
className="ops-tool-card"
disabled={!!busy || !candidates.length}
onClick={() => {
setCleanKind("clean-temporary");
setConfirmClean(true);
}}
>
<Trash2 size={18} aria-hidden />
<span className="ops-tool-title">清理临时文件</span>
<span className="ops-tool-desc">
{candidates.length ? `已扫描 ${candidates.length} 个` : "请先扫描"}
</span>
</button>
<button
type="button"
className="ops-tool-card"
disabled={!!busy}
onClick={() => {
setCleanKind("clear-mail-logs");
setConfirmClean(true);
}}
>
<Mail size={18} aria-hidden />
<span className="ops-tool-title">清理邮件记录</span>
<span className="ops-tool-desc">超过保留期的已完成记录</span>
</button>
</div>
</AdminSettingsGroup>
)}
{busy && (
<p className="ops-busy" role="status">
<Loader2 size={14} className="animate-spin" aria-hidden /> 正在执行…
</p>
)}
{result !== null && (
<div className="ops-result-panel" role="status">
<div className="ops-result-head">
<strong>{tested !== serialized ? "结果已过期(表单已改)" : "操作结果"}</strong>
<button type="button" className="ops-text-btn" onClick={() => setResult(null)}>
关闭
</button>
</div>
<div className="ops-result-body">
<ResultView value={result} />
</div>
</div>
)}
<AdminSettingsGroup title={recordsTitle}>
<div className="ops-actions">
<button type="button" className="btn btn-line btn-sm" disabled={!!busy} onClick={() => void run("records")}>
<ClipboardList size={14} aria-hidden />
{recordsLoaded ? "刷新记录" : "读取记录"}
</button>
{recordsLoaded && <span className="meta text-[12.5px]">{records.length} 条(最多 50)</span>}
</div>
{recordsLoaded && records.length === 0 && <p className="ops-hint">暂无记录</p>}
{recordsLoaded && records.length > 0 && (
<div className="ops-records">
{records.map((r, i) => (
<details className="ops-record" key={String(r.id || r.revision || i)}>
<summary className="ops-record-summary">
<RecordSummary row={r} />
</summary>
<div className="ops-record-body">
<ResultView value={r} />
</div>
</details>
))}
</div>
)}
</AdminSettingsGroup>
<ConfirmDialog
open={confirmClean}
title={cleanKind === "clean-temporary" ? "清理已扫描的临时文件" : "清理过期邮件记录"}
message={
cleanKind === "clean-temporary"
? "仅删除扫描出的过期临时文件;保留草稿与正在上传的文件。"
: "仅删除超过保留期且已完成或最终失败的记录。"
}
onConfirm={() => {
setConfirmClean(false);
void run(cleanKind);
}}
onCancel={() => setConfirmClean(false)}
/>
</div>
);
}
function secretNoun(key: string) {
if (key === "password") return "授权码";
if (key === "access_key") return "Access Key";
if (key === "secret_key") return "Secret Key";
return "凭据";
}
function SecretControl({
id,
noun,
configured,
removing,
replacing,
value,
onChange,
onReplace,
onCancelReplace,
onRemove,
onCancelRemove,
}: {
id: string;
noun: string;
configured: boolean;
removing: boolean;
replacing: boolean;
value: string;
onChange: (value: string) => void;
onReplace: () => void;
onCancelReplace: () => void;
onRemove: () => void;
onCancelRemove: () => void;
}) {
if (removing)
return (
<div className="ops-secret" id={id} tabIndex={-1}>
<div className="ops-secret-card is-remove" role="status">
<div>
<p className="ops-secret-title">保存后将移除</p>
<p className="ops-secret-note">已存的{noun}会被删除。</p>
</div>
<button type="button" className="btn btn-line btn-sm" onClick={onCancelRemove}>
保留
</button>
</div>
</div>
);
if (!replacing)
return (
<div className="ops-secret" id={id}>
<div className="ops-secret-card">
<div>
<p className="ops-secret-title">已保存</p>
<p className="ops-secret-note">保存本页时继续使用。</p>
</div>
<div className="ops-secret-actions">
<button type="button" className="btn btn-line btn-sm" onClick={onReplace}>
更换
</button>
<button type="button" className="ops-text-btn is-danger" onClick={onRemove}>
移除
</button>
</div>
</div>
</div>
);
return (
<div className="ops-secret">
<input
id={id}
autoComplete="new-password"
className="field ops-input"
type="password"
value={value}
placeholder={configured ? `新的${noun}` : noun}
onChange={(e) => onChange(e.target.value)}
autoFocus={configured}
/>
{configured && (
<button type="button" className="ops-text-btn" onClick={onCancelReplace}>
取消更换
</button>
)}
</div>
);
}
function RecordSummary({ row }: { row: Record<string, unknown> }) {
const kind = String(row.kind || row.module || row.action || row.status || "记录");
const when = String(row.created_at || row.updated_at || row.tested_at || "");
const extra = String(row.recipient || row.summary || row.word || row.state || "");
return (
<>
<span className="ops-record-kind">{translateEnum(kind)}</span>
{extra ? <span className="ops-record-extra">{truncate(extra, 48)}</span> : null}
{when ? <span className="ops-record-when meta">{formatLocalDisplay(when) || when}</span> : null}
</>
);
}
const ENUM_MAP: Record<string, string> = {
block: "拦截",
log: "仅记录",
pass: "通过",
body: "正文",
title: "标题",
comment: "评论",
username: "用户名",
queued: "排队",
sending: "发送中",
retry: "等待重试",
accepted: "已接受",
failed: "失败",
register: "注册验证",
reset: "密码找回",
};
function translateEnum(value: string) {
return ENUM_MAP[value] || value;
}
function truncate(s: string, max: number) {
if (s.length <= max) return s;
return s.slice(0, max - 1) + "…";
}
const labels: Record<string, string> = {
candidates: "可清理文件",
size: "字节",
removed: "已清理",
failed: "失败",
message: "说明",
result: "结果",
text: "可读文本",
hits: "命中",
skipped: "跳过区域",
id: "编号",
word: "词语",
scope: "范围",
start: "起点",
end: "终点",
action: "动作",
excepted: "例外覆盖",
version: "应用版本",
uptime_seconds: "运行秒数",
database: "数据库",
cache: "缓存",
tasks: "任务服务",
queued_mail: "排队邮件",
storage: "存储",
mail_test: "邮件历史测试",
tested_at: "测试时间",
note: "备注",
state: "状态",
kind: "业务类型",
recipient: "脱敏收件人",
status: "状态",
attempts: "尝试次数",
summary: "摘要",
created_at: "创建时间",
updated_at: "更新时间",
revision: "配置版本",
references: "引用对象数",
actor_id: "操作者",
module: "模块",
fields: "变更字段",
expired_partial_count: "过期临时文件数",
expired_partial_bytes: "占用字节",
protected_draft_attachments: "保留的草稿附件数",
retention_days: "保留天数",
count: "处理数量",
};
export function ResultView({ value, field = "" }: { value: unknown; field?: string }) {
if (Array.isArray(value)) {
if (value.length === 0) return <span>—</span>;
if (value.length > 20)
return (
<div className="ops-results">
<p className="meta text-[12px] mb-1">共 {value.length} 项,显示前 20</p>
<ul className="ops-results-list">
{value.slice(0, 20).map((v, i) => (
<li key={i}>
<ResultView value={v} />
</li>
))}
</ul>
</div>
);
return (
<ul className="ops-results-list">
{value.map((v, i) => (
<li key={i}>
<ResultView value={v} />
</li>
))}
</ul>
);
}
if (value && typeof value === "object")
return (
<dl className="ops-results">
{Object.entries(value).map(([k, v]) => (
<div key={k}>
<dt>{labels[k] || k}</dt>
<dd>
<ResultView value={v} field={k} />
</dd>
</div>
))}
</dl>
);
let display: unknown = value;
if (typeof value === "string" && ["result", "action", "scope", "status", "kind"].includes(field)) {
display = translateEnum(value);
}
if (typeof display === "string" && display.length > 120) {
return (
<span className="ops-truncate" title={display}>
{truncate(display, 120)}
</span>
);
}
return <span>{display === true ? "是" : display === false ? "否" : String(display ?? "—")}</span>;
}