1076 lines
36 KiB
TypeScript
1076 lines
36 KiB
TypeScript
"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>;
|
||
}
|