From abc2d4aa7210aadeee2c2c6abb076e816251183b Mon Sep 17 00:00:00 2001 From: JetSprow Date: Thu, 30 Apr 2026 16:20:27 +1000 Subject: [PATCH] feat: save settings toggles immediately --- src/actions/admin/settings.ts | 90 +++++++ .../(admin)/admin/settings/settings-form.tsx | 252 +++++++++++------- src/components/ui/boolean-toggle.tsx | 2 +- src/components/ui/sonner.tsx | 1 + 4 files changed, 249 insertions(+), 96 deletions(-) diff --git a/src/actions/admin/settings.ts b/src/actions/admin/settings.ts index 6c031b2..0bbab3a 100644 --- a/src/actions/admin/settings.ts +++ b/src/actions/admin/settings.ts @@ -12,6 +12,24 @@ import { encrypt, isEncryptedValue } from "@/lib/crypto"; import { getErrorMessage } from "@/lib/errors"; import { sendSmtpTestEmail } from "@/services/email"; +const booleanSettingFields = [ + "allowRegistration", + "emailVerificationRequired", + "requireInviteCode", + "autoReminderDispatchEnabled", + "trafficSyncEnabled", + "networkRecommendationsEnabled", + "networkInsightsEnabled", + "subscriptionRiskEnabled", + "subscriptionRiskAutoSuspend", + "nodeAccessRiskEnabled", + "inviteRewardEnabled", + "smtpEnabled", + "smtpSecure", +] as const; + +export type BooleanSettingField = (typeof booleanSettingFields)[number]; + const settingsSchema = z.object({ siteName: z.string().trim().min(1, "站点名称不能为空"), siteUrl: z.string().trim().optional(), @@ -68,6 +86,11 @@ const smtpTestSettingsSchema = settingsSchema.extend({ smtpTestEmail: smtpTestEmailSchema, }); +const booleanSettingSchema = z.object({ + field: z.enum(booleanSettingFields), + value: z.boolean(), +}); + type AdminSession = Awaited>; type SettingsActionResult = { ok: true } | { ok: false; error: string }; type SmtpTestActionResult = @@ -98,6 +121,42 @@ function optionalBoolean(value: string | undefined, fallback: boolean) { return value == null ? fallback : value === "true"; } +function booleanSettingData(field: BooleanSettingField, value: boolean) { + return { + allowRegistration: { allowRegistration: value }, + emailVerificationRequired: { emailVerificationRequired: value }, + requireInviteCode: { requireInviteCode: value }, + autoReminderDispatchEnabled: { autoReminderDispatchEnabled: value }, + trafficSyncEnabled: { trafficSyncEnabled: value }, + networkRecommendationsEnabled: { networkRecommendationsEnabled: value }, + networkInsightsEnabled: { networkInsightsEnabled: value }, + subscriptionRiskEnabled: { subscriptionRiskEnabled: value }, + subscriptionRiskAutoSuspend: { subscriptionRiskAutoSuspend: value }, + nodeAccessRiskEnabled: { nodeAccessRiskEnabled: value }, + inviteRewardEnabled: { inviteRewardEnabled: value }, + smtpEnabled: { smtpEnabled: value }, + smtpSecure: { smtpSecure: value }, + }[field]; +} + +function assertBooleanSettingAllowed( + field: BooleanSettingField, + value: boolean, + current: Awaited>, +) { + const smtpReady = Boolean(current.smtpHost && current.smtpPort && current.smtpFromEmail); + + if (field === "smtpEnabled" && value && !smtpReady) { + throw new Error("开启邮件服务前,请先保存 SMTP 主机、端口和发件邮箱"); + } + if (field === "smtpEnabled" && !value && current.emailVerificationRequired) { + throw new Error("关闭邮件服务前,请先关闭注册邮箱验证"); + } + if (field === "emailVerificationRequired" && value && (!current.smtpEnabled || !smtpReady)) { + throw new Error("开启注册邮箱验证前,请先开启邮件服务并完整配置 SMTP"); + } +} + function buildSettingsUpdate(parsed: z.infer, current: Awaited>) { const smtpEnabled = optionalBoolean(parsed.smtpEnabled, current.smtpEnabled); const emailVerificationRequired = optionalBoolean( @@ -283,6 +342,37 @@ export async function saveAppSettings(formData: FormData): Promise { + try { + const session = await requireAdmin(); + const parsed = booleanSettingSchema.parse(input); + const current = await getAppConfig(); + assertBooleanSettingAllowed(parsed.field, parsed.value, current); + + await prisma.appConfig.update({ + where: { id: current.id }, + data: booleanSettingData(parsed.field, parsed.value), + }); + + await recordAuditLog({ + actor: actorFromSession(session), + action: "settings.toggle", + targetType: "AppConfig", + targetId: current.id, + targetLabel: current.siteName, + message: `${parsed.value ? "开启" : "关闭"}系统开关 ${parsed.field}`, + }); + + revalidateSettingsViews(); + return { ok: true }; + } catch (error) { + return { ok: false, error: formatActionError(error, "更新开关失败") }; + } +} + export async function testSmtpSettings(formData: FormData): Promise { let parsed: z.infer; let next: Awaited>; diff --git a/src/app/(admin)/admin/settings/settings-form.tsx b/src/app/(admin)/admin/settings/settings-form.tsx index d777b0d..e26af04 100644 --- a/src/app/(admin)/admin/settings/settings-form.tsx +++ b/src/app/(admin)/admin/settings/settings-form.tsx @@ -8,7 +8,12 @@ import { Button, buttonVariants } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { saveAppSettings, testSmtpSettings } from "@/actions/admin/settings"; +import { + saveAppSettings, + saveBooleanAppSetting, + testSmtpSettings, + type BooleanSettingField, +} from "@/actions/admin/settings"; import { toast } from "sonner"; import { getErrorMessage } from "@/lib/errors"; @@ -67,15 +72,119 @@ interface CouponOption { const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-sm outline-none"; +type ToggleValues = Record; + +const booleanSettingLabels: Record = { + allowRegistration: "开放注册", + emailVerificationRequired: "注册邮箱验证", + requireInviteCode: "邀请码注册", + autoReminderDispatchEnabled: "自动提醒派发", + trafficSyncEnabled: "3x-ui 流量定时同步", + networkRecommendationsEnabled: "三网推荐", + networkInsightsEnabled: "线路体验", + subscriptionRiskEnabled: "风控总控", + subscriptionRiskAutoSuspend: "自动暂停", + nodeAccessRiskEnabled: "节点日志风控", + inviteRewardEnabled: "自动发放奖励", + smtpEnabled: "邮件服务", + smtpSecure: "SSL 直连", +}; + +function initialToggleValues(config: AppConfig): ToggleValues { + return { + allowRegistration: config.allowRegistration, + emailVerificationRequired: config.emailVerificationRequired, + requireInviteCode: config.requireInviteCode, + autoReminderDispatchEnabled: config.autoReminderDispatchEnabled, + trafficSyncEnabled: config.trafficSyncEnabled, + networkRecommendationsEnabled: config.networkRecommendationsEnabled, + networkInsightsEnabled: config.networkInsightsEnabled, + subscriptionRiskEnabled: config.subscriptionRiskEnabled, + subscriptionRiskAutoSuspend: config.subscriptionRiskAutoSuspend, + nodeAccessRiskEnabled: config.nodeAccessRiskEnabled, + inviteRewardEnabled: config.inviteRewardEnabled, + smtpEnabled: config.smtpEnabled, + smtpSecure: config.smtpSecure, + }; +} + export function SettingsForm({ config, coupons }: { config: AppConfig; coupons: CouponOption[] }) { const router = useRouter(); const [saving, setSaving] = useState(false); const [testingEmail, setTestingEmail] = useState(false); const [riskSettingsOpen, setRiskSettingsOpen] = useState(false); + const [toggleValues, setToggleValues] = useState(() => initialToggleValues(config)); + const [pendingToggles, setPendingToggles] = useState>>({}); + const hasPendingToggle = Object.values(pendingToggles).some(Boolean); + + function setToggleValue(field: BooleanSettingField, value: boolean) { + setToggleValues((current) => ({ ...current, [field]: value })); + } + + function setTogglePending(field: BooleanSettingField, pending: boolean) { + setPendingToggles((current) => { + const next = { ...current }; + if (pending) { + next[field] = true; + } else { + delete next[field]; + } + return next; + }); + } + + async function handleImmediateToggle(field: BooleanSettingField, value: boolean) { + if (pendingToggles[field] || toggleValues[field] === value) return; + + const previousValue = toggleValues[field]; + const label = booleanSettingLabels[field]; + const actionLabel = value ? "开启" : "关闭"; + setToggleValue(field, value); + setTogglePending(field, true); + + try { + const result = await saveBooleanAppSetting({ field, value }); + if (!result.ok) { + setToggleValue(field, previousValue); + toast.error(`${label}${actionLabel}失败:${getErrorMessage(result.error, "更新失败")}`); + return; + } + router.refresh(); + toast.success(`${label}${actionLabel}成功`); + } catch (error) { + setToggleValue(field, previousValue); + toast.error(`${label}${actionLabel}失败:${getErrorMessage(error, "更新失败")}`); + } finally { + setTogglePending(field, false); + } + } + + function renderImmediateToggle( + field: BooleanSettingField, + options: { + id: string; + trueLabel?: string; + falseLabel?: string; + ariaLabel?: string; + }, + ) { + return ( + void handleImmediateToggle(field, value)} + trueLabel={options.trueLabel} + falseLabel={options.falseLabel} + ariaLabel={options.ariaLabel ?? booleanSettingLabels[field]} + disabled={saving || Boolean(pendingToggles[field])} + /> + ); + } async function handleSubmit(event: FormEvent) { event.preventDefault(); - if (saving) return; + if (saving || hasPendingToggle) return; const form = event.currentTarget; setSaving(true); @@ -206,12 +315,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
- + {renderImmediateToggle("autoReminderDispatchEnabled", { id: "autoReminderDispatchEnabled" })}
@@ -219,12 +323,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
- + {renderImmediateToggle("trafficSyncEnabled", { id: "trafficSyncEnabled" })}
@@ -249,24 +348,14 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
- + {renderImmediateToggle("networkRecommendationsEnabled", { id: "networkRecommendationsEnabled" })}

开启后,商城展示电信、联通、移动当前最低延迟推荐;点击推荐会直接打开对应套餐详情。

- + {renderImmediateToggle("networkInsightsEnabled", { id: "networkInsightsEnabled" })}

开启后,套餐详情展示节点延迟、趋势和访问路径;关闭后只保留购买所需的线路入口选择。

@@ -287,7 +376,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons: 订阅访问风控 - 控制订阅接口限流、跨地区访问告警和自动暂停,当前{config.subscriptionRiskEnabled ? "已开启" : "已关闭"}。 + 控制订阅接口限流、跨地区访问告警和自动暂停,当前{toggleValues.subscriptionRiskEnabled ? "已开启" : "已关闭"}。 @@ -302,23 +391,16 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
- + {renderImmediateToggle("subscriptionRiskEnabled", { id: "subscriptionRiskEnabled" })}
- + {renderImmediateToggle("subscriptionRiskAutoSuspend", { + id: "subscriptionRiskAutoSuspend", + trueLabel: "开启自动封停", + falseLabel: "只记录警告", + ariaLabel: "自动暂停", + })}
@@ -421,14 +503,12 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
- + {renderImmediateToggle("nodeAccessRiskEnabled", { + id: "nodeAccessRiskEnabled", + trueLabel: "接收日志", + falseLabel: "仅订阅风控", + ariaLabel: "节点日志风控", + })}
@@ -461,36 +541,30 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
- + {renderImmediateToggle("allowRegistration", { + id: "allowRegistration", + trueLabel: "开放", + falseLabel: "关闭", + ariaLabel: "开放注册", + })}
- + {renderImmediateToggle("requireInviteCode", { + id: "requireInviteCode", + trueLabel: "必须", + falseLabel: "不需要", + ariaLabel: "注册必须邀请码", + })}
- + {renderImmediateToggle("emailVerificationRequired", { + id: "emailVerificationRequired", + trueLabel: "开启验证", + falseLabel: "关闭", + ariaLabel: "注册邮箱验证", + })}

开启后,新用户注册会先收到验证邮件,完成验证后才能登录;关闭后注册成功即可登录。

@@ -506,12 +580,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
- + {renderImmediateToggle("smtpEnabled", { id: "smtpEnabled" })}
@@ -523,14 +592,12 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
- + {renderImmediateToggle("smtpSecure", { + id: "smtpSecure", + trueLabel: "SSL 直连", + falseLabel: "STARTTLS", + ariaLabel: "TLS / SSL", + })}
@@ -568,12 +635,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
- + {renderImmediateToggle("inviteRewardEnabled", { id: "inviteRewardEnabled" })}
@@ -646,8 +708,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
- 导出配置备份 diff --git a/src/components/ui/boolean-toggle.tsx b/src/components/ui/boolean-toggle.tsx index 369b2b8..dbf9590 100644 --- a/src/components/ui/boolean-toggle.tsx +++ b/src/components/ui/boolean-toggle.tsx @@ -3,7 +3,7 @@ import { useId, useState } from "react"; import { cn } from "@/lib/utils"; -interface BooleanToggleProps { +export interface BooleanToggleProps { id?: string; name?: string; value?: boolean; diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index 9280ee5..bc1d46c 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -10,6 +10,7 @@ const Toaster = ({ ...props }: ToasterProps) => { return (