Files
ttstd-infinite-canvas/web/src/pages/admin/components/email-settings-panel.tsx

499 lines
26 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.
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";
type EmailFormValues = Pick<EmailSetting, "enabled" | "host" | "port" | "username" | "password" | "encryption" | "fromEmail" | "fromName">;
export default function EmailSettingsPanel() {
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 load(true);
return () => {
requestVersionRef.current += 1;
};
}, [load]);
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(expected);
if (!isEmailSetting(result.setting) || !emailResponseMatches(result.setting, expected)) throw new Error("服务端返回的邮件配置与本次保存内容不一致,请重新读取后核对");
setSetting(result.setting);
form.setFieldsValue(toEmailFormValues(result.setting));
setDraftEnabled(result.setting.enabled);
setDirty(false);
message.success("注册邮件配置已保存");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "保存邮件配置失败";
setSaveError(`${errorMessage}。未自动重试,请重新读取当前配置后再决定是否保存。`);
message.error(errorMessage);
throw error;
} finally {
setSaving(false);
}
};
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 = (enabled: boolean) => {
if (!setting || saving) return;
form.setFieldValue("enabled", enabled);
setDraftEnabled(enabled);
setDirty(hasEmailChanges({ ...form.getFieldsValue(true), enabled }, setting));
setSaveError("");
};
if (loading && !setting) {
return (
<div className="admin-settings-stack admin-email-settings" aria-label="正在读取邮件服务配置" role="status">
<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 smtpReady = Boolean(currentValues.host && currentValues.port && currentValues.fromEmail);
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>
<strong>{dirty ? "有未保存的邮件调整" : `注册验证码:${setting.enabled ? "已启用" : "未启用"}`}</strong>
<p>{dirty ? "完成当前配置后保存生效。" : formatSettingTime(setting.updatedAt, "使用系统默认值")}</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 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="1. 是否发送注册邮箱验证码"
description="这是邮件服务的主开关。关闭时不需要配置 SMTP;开启后再填写连接与发件信息。"
status={<AdminStatusBadge label={draftEnabled ? (dirty && !setting.enabled ? "待启用" : "已启用") : dirty && setting.enabled ? "待停用" : "未启用"} tone={dirty ? "warning" : draftEnabled ? "success" : "neutral"} />}
footer={
!draftEnabled ? (
<>
<div className="admin-email-footer-note">
<BadgeCheck className="size-4" aria-hidden="true" />
<span>关闭后,普通邮箱注册不再要求邮箱验证码。</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>
</>
) : undefined
}
>
<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={toggleEnabled} />
</div>
</SettingsSectionCard>
</div>
{draftEnabled ? (
<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="2. 配置 SMTP 连接与发件身份"
description="填写邮件服务器、身份验证和发件人信息。保存不会主动探测或发送测试邮件。"
status={<AdminStatusBadge label={dirty ? "待保存" : smtpReady ? "已配置" : "待配置"} tone={dirty ? "warning" : smtpReady ? "success" : "neutral"} />}
footer={
<>
<div className="admin-email-footer-note">
<BadgeCheck className="size-4" aria-hidden="true" />
<span>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>
) : null}
</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 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 })}`;
}