feat(admin): 管理后台 - 完善配置运营页面并修复导航、筛选与积分计费

This commit is contained in:
ddCat
2026-08-30 11:10:52 +08:00
parent 90da7298b6
commit c8b60cea07
26 changed files with 2468 additions and 1578 deletions

View File

@@ -5,7 +5,7 @@ import { useBlocker } from "react-router";
import { cn } from "@/lib/utils";
import { getAdminEmailSetting, updateAdminEmailSetting, type EmailSetting } from "@/services/api/wallet";
import { AdminStatTile, AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "./admin-ui";
import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "./admin-ui";
type EmailFormValues = Pick<EmailSetting, "enabled" | "host" | "port" | "username" | "password" | "encryption" | "fromEmail" | "fromName">;
@@ -171,39 +171,17 @@ export default function EmailSettingsPanel() {
}
};
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;
}
const toggleEnabled = (enabled: boolean) => {
if (!setting || saving) return;
form.setFieldValue("enabled", enabled);
setDraftEnabled(enabled);
try {
await save(values);
} catch {
form.setFieldValue("enabled", setting.enabled);
setDraftEnabled(setting.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-command-bar">
<Skeleton active title={{ width: 180 }} paragraph={false} />
</div>
<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>
@@ -231,10 +209,7 @@ export default function EmailSettingsPanel() {
}
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;
const smtpReady = Boolean(currentValues.host && currentValues.port && currentValues.fromEmail);
return (
<div className="admin-settings-stack admin-email-settings">
@@ -244,11 +219,8 @@ export default function EmailSettingsPanel() {
<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>
<strong>{dirty ? "有未保存的邮件调整" : `注册验证码:${setting.enabled ? "已启用" : "未启用"}`}</strong>
<p>{dirty ? "完成当前配置后保存生效。" : formatSettingTime(setting.updatedAt, "使用系统默认值")}</p>
</div>
</div>
<div className="admin-email-command-actions">
@@ -270,20 +242,33 @@ export default function EmailSettingsPanel() {
</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 发送邮箱验证码,调整会随整张表单一起保存。"
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">
@@ -292,131 +277,133 @@ export default function EmailSettingsPanel() {
<div className="admin-email-policy-copy">
<div className="flex flex-wrap items-center gap-2">
<strong>发送注册邮箱验证码</strong>
<AdminStatusBadge label="切换即保存" tone="info" />
<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)} />
<Switch checked={draftEnabled} disabled={loading || refreshing || saving} aria-label="发送注册邮箱验证码" onChange={toggleEnabled} />
</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}>
撤销
{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>
) : 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("");
}}
</div>
</>
}
>
<Form.Item name="enabled" valuePropName="checked" hidden>
<Switch />
</Form.Item>
<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" },
<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 的端口"))),
},
]}
/>
</Form.Item>
>
<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>
<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 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>
<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 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>
</div>
</Form>
</SettingsSectionCard>
</div>
</Form>
</SettingsSectionCard>
</div>
) : null}
</div>
);
}
@@ -503,12 +490,6 @@ 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);