feat(admin): 管理后台 - 统一桌面端信息架构与视觉规范

This commit is contained in:
HANG LU committed 2026-08-30 05:05:46 +08:00
1 parent d89f170a08
commit 10cebf8c69
32 files changed
+17069 -1859

No files matched your search

@@ -1,67 +1,517 @@
import { useEffect, useState } from "react";
import { App, Button, Form, Input, Select, Space, Switch, Tag } from "antd";
import { MailCheck } from "lucide-react";
import { App, Button, Form, Input, InputNumber, Select, Skeleton, Switch } from "antd";
import { AlertTriangle, AtSign, BadgeCheck, KeyRound, MailCheck, RefreshCw, RotateCcw, Save, Send, Server } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useBlocker } from "react-router";
import { cn } from "@/lib/utils";
import { getAdminEmailSetting, updateAdminEmailSetting, type EmailSetting } from "@/services/api/wallet";
import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "./admin-ui";
import { AdminStatTile, AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "./admin-ui";
type EmailFormValues = Omit<EmailSetting, "hasPassword" | "updatedAt">;
type EmailFormValues = Pick<EmailSetting, "enabled" | "host" | "port" | "username" | "password" | "encryption" | "fromEmail" | "fromName">;
export default function EmailSettingsPanel() {
const { message } = App.useApp();
const { message, modal } = App.useApp();
const [setting, setSetting] = useState<EmailSetting | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [draftEnabled, setDraftEnabled] = useState(false);
const [loadError, setLoadError] = useState("");
const [saveError, setSaveError] = useState("");
const [form] = Form.useForm<EmailFormValues>();
const requestVersionRef = useRef(0);
const navigationConfirmOpenRef = useRef(false);
const navigationTriggerRef = useRef<HTMLElement | null>(null);
const load = useCallback(
async (initial = false, announce = false) => {
const requestVersion = ++requestVersionRef.current;
if (initial) setLoading(true);
else setRefreshing(true);
setLoadError("");
try {
const result = await getAdminEmailSetting();
if (requestVersion !== requestVersionRef.current) return;
if (!isEmailSetting(result.setting)) throw new Error("服务端返回的邮件配置格式无效");
setSetting(result.setting);
setDirty(false);
setSaveError("");
if (announce) message.success("已重新读取当前邮件服务配置");
} catch (error) {
if (requestVersion !== requestVersionRef.current) return;
const errorMessage = error instanceof Error ? error.message : "读取邮件配置失败";
setLoadError(errorMessage);
if (!initial) message.error(errorMessage);
} finally {
if (requestVersion === requestVersionRef.current) {
setLoading(false);
setRefreshing(false);
}
}
},
[message],
);
useEffect(() => {
void getAdminEmailSetting()
.then(({ setting: value }) => {
setSetting(value);
form.setFieldsValue({ ...value, password: "" });
})
.catch((error) => message.error(error instanceof Error ? error.message : "读取邮件配置失败"))
.finally(() => setLoading(false));
}, [form, message]);
void load(true);
return () => {
requestVersionRef.current += 1;
};
}, [load]);
const save = async () => {
const values = await form.validateFields();
if (values.enabled && values.username?.trim() && !values.password?.trim() && !setting?.hasPassword) {
message.error("启用 SMTP 登录前请填写密码");
useEffect(() => {
if (loading || !setting) return;
form.setFieldsValue(toEmailFormValues(setting));
setDraftEnabled(setting.enabled);
}, [form, loading, setting]);
const blocker = useBlocker(dirty && !saving);
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!dirty || saving) return;
event.preventDefault();
};
window.addEventListener("beforeunload", beforeUnload);
return () => window.removeEventListener("beforeunload", beforeUnload);
}, [dirty, saving]);
useEffect(() => {
if (blocker.state !== "blocked" || navigationConfirmOpenRef.current) return;
navigationConfirmOpenRef.current = true;
navigationTriggerRef.current = document.activeElement instanceof HTMLElement && document.activeElement !== document.body ? document.activeElement : null;
modal.confirm({
title: "放弃邮件服务调整?",
content: "当前 SMTP 表单有尚未保存的调整,离开后这些内容会丢失。服务端正在使用的邮件配置不会改变。",
okText: "放弃并离开",
cancelText: "继续编辑",
okButtonProps: { danger: true },
onOk: () => {
navigationConfirmOpenRef.current = false;
navigationTriggerRef.current = null;
blocker.proceed();
},
onCancel: () => {
navigationConfirmOpenRef.current = false;
blocker.reset();
window.requestAnimationFrame(() => {
const fallback = document.querySelector<HTMLButtonElement>(".admin-email-command-actions button");
const target = navigationTriggerRef.current?.isConnected ? navigationTriggerRef.current : fallback;
target?.focus();
navigationTriggerRef.current = null;
});
},
});
}, [blocker, modal]);
const resetDraft = () => {
if (!setting || saving) return;
form.setFieldsValue(toEmailFormValues(setting));
form.setFields([]);
setDraftEnabled(setting.enabled);
setDirty(false);
setSaveError("");
message.info("已撤销邮件服务的未保存调整");
};
const requestRefresh = () => {
if (!dirty) {
void load(false, true);
return;
}
modal.confirm({
title: "放弃调整并重新读取?",
content: "重新读取会丢弃当前 SMTP 表单中的未保存内容,并以服务端配置为准。",
okText: "放弃并刷新",
cancelText: "继续编辑",
okButtonProps: { danger: true },
onOk: () => load(false, true),
});
};
const save = async (values: EmailFormValues) => {
const expected = normalizeEmailFormValues(values);
setSaving(true);
setSaveError("");
try {
const result = await updateAdminEmailSetting({ ...values, password: values.password?.trim() || "" });
const result = await updateAdminEmailSetting(expected);
if (!isEmailSetting(result.setting) || !emailResponseMatches(result.setting, expected)) throw new Error("服务端返回的邮件配置与本次保存内容不一致,请重新读取后核对");
setSetting(result.setting);
form.setFieldsValue({ ...result.setting, password: "" });
form.setFieldsValue(toEmailFormValues(result.setting));
setDraftEnabled(result.setting.enabled);
setDirty(false);
message.success("注册邮件配置已保存");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存邮件配置失败");
const errorMessage = error instanceof Error ? error.message : "保存邮件配置失败";
setSaveError(`${errorMessage}。未自动重试,请重新读取当前配置后再决定是否保存。`);
message.error(errorMessage);
throw error;
} finally {
setSaving(false);
}
};
return (
<SettingsSectionCard
icon={<MailCheck className="size-4" />}
title="注册验证邮件"
status={<Space size={6}><AdminStatusBadge label={setting?.enabled ? "已启用" : "未启用"} tone={setting?.enabled ? "success" : "neutral"} />{setting?.hasPassword ? <AdminStatusBadge label={configuredSecretText} tone="info" /> : null}</Space>}
footer={<><span className="text-xs text-foreground/45">SMTP 密码使用服务端密钥加密,接口不回显明文。</span><Button type="primary" loading={saving} onClick={() => void save()}>保存邮件配置</Button></>}
>
<Form form={form} layout="vertical" requiredMark={false} disabled={loading}>
<div className="grid gap-x-5 px-5 pt-5 md:grid-cols-2">
<Form.Item name="enabled" label="启用注册验证邮件" valuePropName="checked" extra="公开注册开启后,普通邮箱注册必须完成验证码校验。"><Switch /></Form.Item>
<Form.Item name="encryption" label="连接加密" rules={[{ required: true, message: "请选择连接加密方式" }]}><Select options={[{ label: "STARTTLS(推荐,通常 587)", value: "starttls" }, { label: "TLS(通常 465)", value: "tls" }, { label: "无加密", value: "none" }]} /></Form.Item>
<Form.Item name="host" label="SMTP 主机"><Input placeholder="smtp.example.com" /></Form.Item>
<Form.Item name="port" label="SMTP 端口"><Input type="number" min={1} max={65535} placeholder="587" /></Form.Item>
<Form.Item name="username" label="SMTP 用户名"><Input autoComplete="off" placeholder="通常为完整邮箱地址" /></Form.Item>
<Form.Item name="password" label={setting?.hasPassword ? `SMTP 密码(${configuredSecretText})` : "SMTP 密码"}><Input.Password autoComplete="new-password" placeholder={setting?.hasPassword ? "留空保留原密码" : "SMTP 密码或授权码"} /></Form.Item>
<Form.Item name="fromEmail" label="发件邮箱" rules={[{ type: "email", message: "请输入有效的发件邮箱" }]}><Input placeholder="noreply@example.com" /></Form.Item>
<Form.Item name="fromName" label="发件人名称"><Input placeholder="影策" /></Form.Item>
const submitSave = async () => {
let values: EmailFormValues;
try {
values = await form.validateFields();
} catch {
return;
}
const validationError = validateEmailDraft(values, setting);
if (validationError) {
message.error(validationError);
return;
}
try {
await save(values);
} catch {
// 保存错误已在 save 中就地提示。
}
};
const toggleEnabled = async (enabled: boolean) => {
if (!setting || saving || enabled === setting.enabled) return;
const values = (enabled ? { ...form.getFieldsValue(true), enabled } : { ...toEmailFormValues(setting), enabled, password: "" }) as EmailFormValues;
const validationError = validateEmailDraft(values, setting);
if (validationError) {
form.setFieldValue("enabled", setting.enabled);
setDraftEnabled(setting.enabled);
message.error(validationError);
return;
}
form.setFieldValue("enabled", enabled);
setDraftEnabled(enabled);
try {
await save(values);
} catch {
form.setFieldValue("enabled", setting.enabled);
setDraftEnabled(setting.enabled);
}
};
if (loading && !setting) {
return (
<div className="admin-settings-stack admin-email-settings" aria-label="正在读取邮件服务配置" role="status">
<div className="admin-email-command-bar">
<Skeleton active title={{ width: 180 }} paragraph={false} />
</div>
</Form>
</SettingsSectionCard>
<div className="admin-email-overview">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="admin-stat-tile">
<Skeleton active title={{ width: 96 }} paragraph={{ rows: 1 }} />
</div>
))}
</div>
<div className="admin-email-loading-card">
<Skeleton active paragraph={{ rows: 7 }} />
</div>
</div>
);
}
if (!setting) {
return (
<div className="admin-settings-stack admin-email-settings">
<div className="admin-email-load-error" role="alert">
<span className="admin-email-load-error-icon">
<AlertTriangle className="size-5" aria-hidden="true" />
</span>
<div>
<h2>无法读取邮件服务配置</h2>
<p>{loadError || "当前没有可显示的配置,请稍后重试。"}</p>
</div>
<Button icon={<RefreshCw className="size-4" />} loading={refreshing} onClick={() => void load(false, true)}>
重新读取
</Button>
</div>
</div>
);
}
const currentValues = normalizeEmailFormValues(form.getFieldsValue(true));
const connectionConfigured = Boolean((dirty ? currentValues.host : setting.host) && (dirty ? currentValues.port : setting.port));
const authenticationConfigured = Boolean(dirty ? currentValues.username : setting.username);
const senderConfigured = Boolean(dirty ? currentValues.fromEmail : setting.fromEmail);
const currentEncryption = dirty ? currentValues.encryption : setting.encryption;
return (
<div className="admin-settings-stack admin-email-settings">
<div className={cn("admin-email-command-bar", dirty && "is-dirty")}>
<div className="admin-email-command-copy" aria-live="polite">
<span className="admin-email-command-icon">
<MailCheck className="size-4" aria-hidden="true" />
</span>
<div>
<div className="flex flex-wrap items-center gap-2">
<strong>{dirty ? "邮件服务有调整待保存" : "邮件服务配置已同步"}</strong>
<AdminStatusBadge label={dirty ? "尚未生效" : "服务端当前值"} tone={dirty ? "warning" : "neutral"} />
</div>
<p>{dirty ? "启用状态、SMTP 连接和发件身份只在本页暂存,核对保存后才会生效。" : "当前页面展示服务端生效值;读取和保存都不会触发测试邮件。"}</p>
</div>
</div>
<div className="admin-email-command-actions">
{dirty ? (
<Button icon={<RotateCcw className="size-4" />} disabled={saving} onClick={resetDraft}>
撤销调整
</Button>
) : null}
<Button icon={<RefreshCw className="size-4" />} loading={refreshing} disabled={saving} onClick={requestRefresh}>
刷新状态
</Button>
</div>
</div>
{loadError || saveError ? (
<div className="admin-email-inline-alert" role="alert">
<AlertTriangle className="size-4 shrink-0" aria-hidden="true" />
<span>{saveError || `${loadError}。页面仍显示上一次成功读取的配置。`}</span>
</div>
) : null}
<div className="admin-email-overview" aria-label="邮件服务配置概览">
<AdminStatTile label="注册邮件" value={draftEnabled ? "启用" : "停用"} detail={dirty ? "暂存状态预览" : formatSettingTime(setting.updatedAt, "使用系统默认值")} />
<AdminStatTile label="SMTP 服务器" value={connectionConfigured ? "已配置" : "未配置"} detail={connectionConfigured ? `${dirty ? currentValues.host : setting.host}:${dirty ? currentValues.port : setting.port}` : "启用前需要主机与端口"} />
<AdminStatTile label="连接安全" value={encryptionLabel(currentEncryption)} detail={authenticationConfigured ? "使用 SMTP 账号验证" : "未使用 SMTP 账号验证"} />
<AdminStatTile label="发件身份" value={senderConfigured ? "已配置" : "未配置"} detail={senderConfigured ? (dirty ? currentValues.fromEmail : setting.fromEmail) : "启用前需要发件邮箱"} />
</div>
<div id="admin-email-delivery" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-email-section admin-email-delivery-section"
icon={<Send className="size-4" aria-hidden="true" />}
title="注册验证码投递"
description="控制注册流程是否通过 SMTP 发送邮箱验证码,调整会随整张表单一起保存。"
status={<AdminStatusBadge label={draftEnabled ? (dirty && !setting.enabled ? "待启用" : "已启用") : dirty && setting.enabled ? "待停用" : "未启用"} tone={dirty ? "warning" : draftEnabled ? "success" : "neutral"} />}
>
<div className="admin-email-delivery-policy">
<span className="admin-email-policy-icon">
<MailCheck className="size-5" aria-hidden="true" />
</span>
<div className="admin-email-policy-copy">
<div className="flex flex-wrap items-center gap-2">
<strong>发送注册邮箱验证码</strong>
<AdminStatusBadge label="切换即保存" tone="info" />
</div>
<p>启用后,普通邮箱注册需要获取并校验 6 位验证码;邮件发送失败时不会创建可用验证码。</p>
<span>关闭只停止后续验证码邮件,不改变新用户注册开关,也不影响已有账号。</span>
</div>
<Switch checked={draftEnabled} disabled={loading || refreshing || saving} aria-label="发送注册邮箱验证码,切换后立即保存" onChange={(checked) => void toggleEnabled(checked)} />
</div>
</SettingsSectionCard>
</div>
<div id="admin-email-smtp" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-email-section admin-email-configuration-section"
icon={<Server className="size-4" aria-hidden="true" />}
title="SMTP 连接与发件身份"
description="配置邮件服务器、身份验证与邮件中的发件人信息;页面不会主动探测或发送邮件。"
status={<AdminStatusBadge label={dirty ? "有调整" : setting.host && setting.fromEmail ? "已配置" : "待配置"} tone={dirty ? "warning" : setting.host && setting.fromEmail ? "success" : "neutral"} />}
footer={
<>
<div className="admin-email-footer-note">
<BadgeCheck className="size-4" aria-hidden="true" />
<span>{formatSettingTime(setting.updatedAt, "尚未保存邮件管理配置")} · SMTP 密码由服务端加密保存且不回显明文</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{dirty ? (
<Button icon={<RotateCcw className="size-4" />} disabled={saving} onClick={resetDraft}>
撤销
</Button>
) : null}
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || loading || refreshing} onClick={() => void submitSave()}>
保存修改
</Button>
</div>
</>
}
>
<Form
form={form}
layout="vertical"
requiredMark={false}
disabled={loading || refreshing || saving}
onValuesChange={() => {
const values = form.getFieldsValue(true);
setDraftEnabled(Boolean(values.enabled));
setDirty(hasEmailChanges(values, setting));
setSaveError("");
}}
>
<Form.Item name="enabled" valuePropName="checked" hidden>
<Switch />
</Form.Item>
<div className="admin-email-form-section">
<FormSectionTitle icon={<Server className="size-4" />} title="服务器连接" description="填写 SMTP 主机、端口和传输加密;STARTTLS 通常使用 587,直接 TLS 通常使用 465。" />
<div className="admin-email-form-grid is-connection">
<Form.Item name="host" label="SMTP 主机" extra="仅填写主机名或 IP,不包含协议和端口。">
<Input autoComplete="off" placeholder="smtp.example.com" />
</Form.Item>
<Form.Item
name="port"
label="SMTP 端口"
rules={[
{
validator: (_, value: number | null | undefined) => (!draftEnabled || (Number(value) >= 1 && Number(value) <= 65535) ? Promise.resolve() : Promise.reject(new Error("请输入 1 至 65535 的端口"))),
},
]}
>
<InputNumber min={1} max={65535} precision={0} placeholder="587" controls={false} />
</Form.Item>
<Form.Item name="encryption" label="连接加密" rules={[{ required: true, message: "请选择连接加密方式" }]}>
<Select
options={[
{ label: "STARTTLS(推荐,通常 587)", value: "starttls" },
{ label: "直接 TLS(通常 465)", value: "tls" },
{ label: "无加密(仅限可信网络)", value: "none" },
]}
/>
</Form.Item>
</div>
</div>
<div className="admin-email-form-section">
<FormSectionTitle icon={<KeyRound className="size-4" />} title="SMTP 身份验证" description="服务器要求登录时填写账号和密码;密码留空会保留服务端已配置值。" />
<div className="admin-email-form-grid">
<Form.Item name="username" label="SMTP 用户名" extra="通常是完整邮箱地址;无需验证的服务器可留空。">
<Input autoComplete="off" placeholder="mailer@example.com" />
</Form.Item>
<Form.Item name="password" label={setting.hasPassword ? `SMTP 密码(${configuredSecretText})` : "SMTP 密码"} extra="只在需要新增或替换密码时填写。">
<Input.Password autoComplete="new-password" placeholder={setting.hasPassword ? "留空保留原密码" : "SMTP 密码或服务商授权码"} />
</Form.Item>
</div>
</div>
<div className="admin-email-form-section">
<FormSectionTitle icon={<AtSign className="size-4" />} title="发件人身份" description="这组名称和地址会显示在注册验证码邮件的 From 信息中。" />
<div className="admin-email-form-grid">
<Form.Item
name="fromEmail"
label="发件邮箱"
rules={[
{
validator: (_, value: string | undefined) => (!draftEnabled || !value || isValidEmail(value.trim()) ? Promise.resolve() : Promise.reject(new Error("请输入有效的发件邮箱"))),
},
]}
extra="通常需要与 SMTP 账号或服务商验证域名一致。"
>
<Input autoComplete="off" inputMode="email" placeholder="noreply@example.com" />
</Form.Item>
<Form.Item
name="fromName"
label="发件人名称"
rules={[
{
validator: (_, value: string | undefined) => (!draftEnabled || !value || !/[\r\n]/.test(value) ? Promise.resolve() : Promise.reject(new Error("发件人名称不能包含换行"))),
},
]}
extra="留空时服务端使用“影策”。"
>
<Input placeholder="影策" />
</Form.Item>
</div>
</div>
</Form>
</SettingsSectionCard>
</div>
</div>
);
}
function FormSectionTitle({ icon, title, description }: { icon: ReactNode; title: string; description: string }) {
return (
<div className="admin-email-form-section-heading">
<span>{icon}</span>
<div>
<h3>{title}</h3>
<p>{description}</p>
</div>
</div>
);
}
function toEmailFormValues(setting: EmailSetting): EmailFormValues {
return {
enabled: setting.enabled,
host: setting.host,
port: setting.port,
username: setting.username,
password: "",
encryption: setting.encryption,
fromEmail: setting.fromEmail,
fromName: setting.fromName,
};
}
function normalizeEmailFormValues(values: Partial<EmailFormValues>): EmailFormValues {
return {
enabled: Boolean(values.enabled),
host: values.host?.trim() || "",
port: Number(values.port) || 587,
username: values.username?.trim() || "",
password: values.password?.trim() || "",
encryption: values.encryption === "tls" || values.encryption === "none" ? values.encryption : "starttls",
fromEmail: values.fromEmail?.trim().toLowerCase() || "",
fromName: values.fromName?.trim() || "影策",
};
}
function hasEmailChanges(values: Partial<EmailFormValues>, setting: EmailSetting | null) {
if (!setting) return false;
const draft = normalizeEmailFormValues(values);
const saved = normalizeEmailFormValues(toEmailFormValues(setting));
if (draft.password) return true;
return (Object.keys(saved) as Array<keyof EmailFormValues>).some((key) => key !== "password" && draft[key] !== saved[key]);
}
function validateEmailDraft(values: EmailFormValues, setting: EmailSetting | null) {
const draft = normalizeEmailFormValues(values);
if (!draft.enabled) return "";
if (/\r|\n/.test(draft.fromName)) return "发件人名称不能包含换行";
if (!draft.host || draft.port < 1 || draft.port > 65535 || !draft.fromEmail) return "启用注册邮件前请完整填写 SMTP 主机、端口和发件邮箱";
if (!isValidEmail(draft.fromEmail)) return "发件邮箱格式不正确";
if (draft.username && !draft.password && !setting?.hasPassword) return "SMTP 用户名已填写,请同时填写密码或服务商授权码";
return "";
}
function emailResponseMatches(setting: EmailSetting, expected: EmailFormValues) {
const actual = normalizeEmailFormValues(toEmailFormValues(setting));
const fields: Array<keyof EmailFormValues> = ["enabled", "host", "port", "username", "encryption", "fromEmail", "fromName"];
if (expected.password && !setting.hasPassword) return false;
return fields.every((key) => actual[key] === expected[key]);
}
function isEmailSetting(value: unknown): value is EmailSetting {
if (!value || typeof value !== "object") return false;
const setting = value as Partial<EmailSetting>;
return (
typeof setting.enabled === "boolean" &&
typeof setting.host === "string" &&
typeof setting.port === "number" &&
typeof setting.username === "string" &&
["starttls", "tls", "none"].includes(setting.encryption || "") &&
typeof setting.fromEmail === "string" &&
typeof setting.fromName === "string" &&
typeof setting.hasPassword === "boolean"
);
}
function isValidEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
function encryptionLabel(value: EmailSetting["encryption"]) {
if (value === "tls") return "直接 TLS";
if (value === "none") return "无加密";
return "STARTTLS";
}
function formatSettingTime(value: string | undefined, fallback: string) {
if (!value) return fallback;
const date = new Date(value);
if (Number.isNaN(date.getTime()) || date.getFullYear() < 2000) return fallback;
return `更新于 ${date.toLocaleString("zh-CN", { hour12: false })}`;
}