feat: save settings toggles immediately

This commit is contained in:
JetSprow
2026-04-30 16:20:27 +10:00
parent 901219f39c
commit abc2d4aa72
4 changed files with 249 additions and 96 deletions

View File

@@ -12,6 +12,24 @@ import { encrypt, isEncryptedValue } from "@/lib/crypto";
import { getErrorMessage } from "@/lib/errors"; import { getErrorMessage } from "@/lib/errors";
import { sendSmtpTestEmail } from "@/services/email"; 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({ const settingsSchema = z.object({
siteName: z.string().trim().min(1, "站点名称不能为空"), siteName: z.string().trim().min(1, "站点名称不能为空"),
siteUrl: z.string().trim().optional(), siteUrl: z.string().trim().optional(),
@@ -68,6 +86,11 @@ const smtpTestSettingsSchema = settingsSchema.extend({
smtpTestEmail: smtpTestEmailSchema, smtpTestEmail: smtpTestEmailSchema,
}); });
const booleanSettingSchema = z.object({
field: z.enum(booleanSettingFields),
value: z.boolean(),
});
type AdminSession = Awaited<ReturnType<typeof requireAdmin>>; type AdminSession = Awaited<ReturnType<typeof requireAdmin>>;
type SettingsActionResult = { ok: true } | { ok: false; error: string }; type SettingsActionResult = { ok: true } | { ok: false; error: string };
type SmtpTestActionResult = type SmtpTestActionResult =
@@ -98,6 +121,42 @@ function optionalBoolean(value: string | undefined, fallback: boolean) {
return value == null ? fallback : value === "true"; 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<ReturnType<typeof getAppConfig>>,
) {
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<typeof settingsSchema>, current: Awaited<ReturnType<typeof getAppConfig>>) { function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Awaited<ReturnType<typeof getAppConfig>>) {
const smtpEnabled = optionalBoolean(parsed.smtpEnabled, current.smtpEnabled); const smtpEnabled = optionalBoolean(parsed.smtpEnabled, current.smtpEnabled);
const emailVerificationRequired = optionalBoolean( const emailVerificationRequired = optionalBoolean(
@@ -283,6 +342,37 @@ export async function saveAppSettings(formData: FormData): Promise<SettingsActio
} }
} }
export async function saveBooleanAppSetting(input: {
field: BooleanSettingField;
value: boolean;
}): Promise<SettingsActionResult> {
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<SmtpTestActionResult> { export async function testSmtpSettings(formData: FormData): Promise<SmtpTestActionResult> {
let parsed: z.infer<typeof smtpTestSettingsSchema>; let parsed: z.infer<typeof smtpTestSettingsSchema>;
let next: Awaited<ReturnType<typeof persistAppSettings>>; let next: Awaited<ReturnType<typeof persistAppSettings>>;

View File

@@ -8,7 +8,12 @@ import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; 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 { toast } from "sonner";
import { getErrorMessage } from "@/lib/errors"; 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"; const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-sm outline-none";
type ToggleValues = Record<BooleanSettingField, boolean>;
const booleanSettingLabels: Record<BooleanSettingField, string> = {
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[] }) { export function SettingsForm({ config, coupons }: { config: AppConfig; coupons: CouponOption[] }) {
const router = useRouter(); const router = useRouter();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [testingEmail, setTestingEmail] = useState(false); const [testingEmail, setTestingEmail] = useState(false);
const [riskSettingsOpen, setRiskSettingsOpen] = useState(false); const [riskSettingsOpen, setRiskSettingsOpen] = useState(false);
const [toggleValues, setToggleValues] = useState<ToggleValues>(() => initialToggleValues(config));
const [pendingToggles, setPendingToggles] = useState<Partial<Record<BooleanSettingField, boolean>>>({});
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 (
<BooleanToggle
id={options.id}
name={field}
value={toggleValues[field]}
onChange={(value) => 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<HTMLFormElement>) { async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (saving) return; if (saving || hasPendingToggle) return;
const form = event.currentTarget; const form = event.currentTarget;
setSaving(true); setSaving(true);
@@ -206,12 +315,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="autoReminderDispatchEnabled"></Label> <Label htmlFor="autoReminderDispatchEnabled"></Label>
<BooleanToggle {renderImmediateToggle("autoReminderDispatchEnabled", { id: "autoReminderDispatchEnabled" })}
id="autoReminderDispatchEnabled"
name="autoReminderDispatchEnabled"
defaultValue={config.autoReminderDispatchEnabled}
ariaLabel="自动提醒派发"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="reminderDispatchIntervalMinutes"></Label> <Label htmlFor="reminderDispatchIntervalMinutes"></Label>
@@ -219,12 +323,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="trafficSyncEnabled">3x-ui </Label> <Label htmlFor="trafficSyncEnabled">3x-ui </Label>
<BooleanToggle {renderImmediateToggle("trafficSyncEnabled", { id: "trafficSyncEnabled" })}
id="trafficSyncEnabled"
name="trafficSyncEnabled"
defaultValue={config.trafficSyncEnabled}
ariaLabel="3x-ui 流量定时同步"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="trafficSyncIntervalSeconds"></Label> <Label htmlFor="trafficSyncIntervalSeconds"></Label>
@@ -249,24 +348,14 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="networkRecommendationsEnabled"></Label> <Label htmlFor="networkRecommendationsEnabled"></Label>
<BooleanToggle {renderImmediateToggle("networkRecommendationsEnabled", { id: "networkRecommendationsEnabled" })}
id="networkRecommendationsEnabled"
name="networkRecommendationsEnabled"
defaultValue={config.networkRecommendationsEnabled}
ariaLabel="三网推荐"
/>
<p className="text-xs leading-5 text-muted-foreground"> <p className="text-xs leading-5 text-muted-foreground">
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="networkInsightsEnabled">线</Label> <Label htmlFor="networkInsightsEnabled">线</Label>
<BooleanToggle {renderImmediateToggle("networkInsightsEnabled", { id: "networkInsightsEnabled" })}
id="networkInsightsEnabled"
name="networkInsightsEnabled"
defaultValue={config.networkInsightsEnabled}
ariaLabel="线路体验"
/>
<p className="text-xs leading-5 text-muted-foreground"> <p className="text-xs leading-5 text-muted-foreground">
访线 访线
</p> </p>
@@ -287,7 +376,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<span className="min-w-0"> <span className="min-w-0">
<span className="block text-sm font-semibold">访</span> <span className="block text-sm font-semibold">访</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground"> <span className="mt-1 block text-xs leading-5 text-muted-foreground">
访{config.subscriptionRiskEnabled ? "已开启" : "已关闭"} 访{toggleValues.subscriptionRiskEnabled ? "已开启" : "已关闭"}
</span> </span>
</span> </span>
</span> </span>
@@ -302,23 +391,16 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<div className="grid gap-5 md:grid-cols-3"> <div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="subscriptionRiskEnabled"></Label> <Label htmlFor="subscriptionRiskEnabled"></Label>
<BooleanToggle {renderImmediateToggle("subscriptionRiskEnabled", { id: "subscriptionRiskEnabled" })}
id="subscriptionRiskEnabled"
name="subscriptionRiskEnabled"
defaultValue={config.subscriptionRiskEnabled}
ariaLabel="风控总控"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="subscriptionRiskAutoSuspend"></Label> <Label htmlFor="subscriptionRiskAutoSuspend"></Label>
<BooleanToggle {renderImmediateToggle("subscriptionRiskAutoSuspend", {
id="subscriptionRiskAutoSuspend" id: "subscriptionRiskAutoSuspend",
name="subscriptionRiskAutoSuspend" trueLabel: "开启自动封停",
defaultValue={config.subscriptionRiskAutoSuspend} falseLabel: "只记录警告",
trueLabel="开启自动停" ariaLabel: "自动停",
falseLabel="只记录警告" })}
ariaLabel="自动暂停"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="subscriptionRiskWindowHours"></Label> <Label htmlFor="subscriptionRiskWindowHours"></Label>
@@ -421,14 +503,12 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="nodeAccessRiskEnabled"></Label> <Label htmlFor="nodeAccessRiskEnabled"></Label>
<BooleanToggle {renderImmediateToggle("nodeAccessRiskEnabled", {
id="nodeAccessRiskEnabled" id: "nodeAccessRiskEnabled",
name="nodeAccessRiskEnabled" trueLabel: "接收日志",
defaultValue={config.nodeAccessRiskEnabled} falseLabel: "仅订阅风控",
trueLabel="接收日志" ariaLabel: "节点日志风控",
falseLabel="仅订阅风控" })}
ariaLabel="节点日志风控"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="nodeAccessConnectionWarning"></Label> <Label htmlFor="nodeAccessConnectionWarning"></Label>
@@ -461,36 +541,30 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="allowRegistration"></Label> <Label htmlFor="allowRegistration"></Label>
<BooleanToggle {renderImmediateToggle("allowRegistration", {
id="allowRegistration" id: "allowRegistration",
name="allowRegistration" trueLabel: "开放",
defaultValue={config.allowRegistration} falseLabel: "关闭",
trueLabel="开放" ariaLabel: "开放注册",
falseLabel="关闭" })}
ariaLabel="开放注册"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="requireInviteCode"></Label> <Label htmlFor="requireInviteCode"></Label>
<BooleanToggle {renderImmediateToggle("requireInviteCode", {
id="requireInviteCode" id: "requireInviteCode",
name="requireInviteCode" trueLabel: "必须",
defaultValue={config.requireInviteCode} falseLabel: "不需要",
trueLabel="必须" ariaLabel: "注册必须邀请码",
falseLabel="不需要" })}
ariaLabel="注册必须邀请码"
/>
</div> </div>
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
<Label htmlFor="emailVerificationRequired"></Label> <Label htmlFor="emailVerificationRequired"></Label>
<BooleanToggle {renderImmediateToggle("emailVerificationRequired", {
id="emailVerificationRequired" id: "emailVerificationRequired",
name="emailVerificationRequired" trueLabel: "开启验证",
defaultValue={config.emailVerificationRequired} falseLabel: "关闭",
trueLabel="开启验证" ariaLabel: "注册邮箱验证",
falseLabel="关闭" })}
ariaLabel="注册邮箱验证"
/>
<p className="text-xs leading-5 text-muted-foreground"></p> <p className="text-xs leading-5 text-muted-foreground"></p>
</div> </div>
</div> </div>
@@ -506,12 +580,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<div className="grid gap-5 md:grid-cols-3"> <div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="smtpEnabled"></Label> <Label htmlFor="smtpEnabled"></Label>
<BooleanToggle {renderImmediateToggle("smtpEnabled", { id: "smtpEnabled" })}
id="smtpEnabled"
name="smtpEnabled"
defaultValue={config.smtpEnabled}
ariaLabel="邮件服务"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="smtpHost">SMTP </Label> <Label htmlFor="smtpHost">SMTP </Label>
@@ -523,14 +592,12 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="smtpSecure">TLS / SSL</Label> <Label htmlFor="smtpSecure">TLS / SSL</Label>
<BooleanToggle {renderImmediateToggle("smtpSecure", {
id="smtpSecure" id: "smtpSecure",
name="smtpSecure" trueLabel: "SSL 直连",
defaultValue={config.smtpSecure} falseLabel: "STARTTLS",
trueLabel="SSL 直连" ariaLabel: "TLS / SSL",
falseLabel="STARTTLS" })}
ariaLabel="TLS / SSL"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="smtpUser">SMTP </Label> <Label htmlFor="smtpUser">SMTP </Label>
@@ -568,12 +635,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<div className="grid gap-5 md:grid-cols-3"> <div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="inviteRewardEnabled"></Label> <Label htmlFor="inviteRewardEnabled"></Label>
<BooleanToggle {renderImmediateToggle("inviteRewardEnabled", { id: "inviteRewardEnabled" })}
id="inviteRewardEnabled"
name="inviteRewardEnabled"
defaultValue={config.inviteRewardEnabled}
ariaLabel="自动发放奖励"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="inviteRewardRate">%</Label> <Label htmlFor="inviteRewardRate">%</Label>
@@ -646,8 +708,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</section> </section>
<div className="flex flex-col gap-3 sm:flex-row"> <div className="flex flex-col gap-3 sm:flex-row">
<Button type="submit" size="lg" disabled={saving}> <Button type="submit" size="lg" disabled={saving || hasPendingToggle}>
{saving ? "保存中..." : "保存设置"} {saving ? "保存中..." : hasPendingToggle ? "开关更新中..." : "保存设置"}
</Button> </Button>
<a href="/api/admin/export/config" className={buttonVariants({ variant: "outline", size: "lg" })}> <a href="/api/admin/export/config" className={buttonVariants({ variant: "outline", size: "lg" })}>

View File

@@ -3,7 +3,7 @@
import { useId, useState } from "react"; import { useId, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface BooleanToggleProps { export interface BooleanToggleProps {
id?: string; id?: string;
name?: string; name?: string;
value?: boolean; value?: boolean;

View File

@@ -10,6 +10,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps["theme"]}
position="bottom-right"
className="toaster group" className="toaster group"
icons={{ icons={{
success: ( success: (