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 { 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<ReturnType<typeof requireAdmin>>;
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<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>>) {
const smtpEnabled = optionalBoolean(parsed.smtpEnabled, current.smtpEnabled);
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> {
let parsed: z.infer<typeof smtpTestSettingsSchema>;
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 { 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<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[] }) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [testingEmail, setTestingEmail] = 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>) {
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:
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="autoReminderDispatchEnabled"></Label>
<BooleanToggle
id="autoReminderDispatchEnabled"
name="autoReminderDispatchEnabled"
defaultValue={config.autoReminderDispatchEnabled}
ariaLabel="自动提醒派发"
/>
{renderImmediateToggle("autoReminderDispatchEnabled", { id: "autoReminderDispatchEnabled" })}
</div>
<div className="space-y-2">
<Label htmlFor="reminderDispatchIntervalMinutes"></Label>
@@ -219,12 +323,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div>
<div className="space-y-2">
<Label htmlFor="trafficSyncEnabled">3x-ui </Label>
<BooleanToggle
id="trafficSyncEnabled"
name="trafficSyncEnabled"
defaultValue={config.trafficSyncEnabled}
ariaLabel="3x-ui 流量定时同步"
/>
{renderImmediateToggle("trafficSyncEnabled", { id: "trafficSyncEnabled" })}
</div>
<div className="space-y-2">
<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="space-y-2">
<Label htmlFor="networkRecommendationsEnabled"></Label>
<BooleanToggle
id="networkRecommendationsEnabled"
name="networkRecommendationsEnabled"
defaultValue={config.networkRecommendationsEnabled}
ariaLabel="三网推荐"
/>
{renderImmediateToggle("networkRecommendationsEnabled", { id: "networkRecommendationsEnabled" })}
<p className="text-xs leading-5 text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label htmlFor="networkInsightsEnabled">线</Label>
<BooleanToggle
id="networkInsightsEnabled"
name="networkInsightsEnabled"
defaultValue={config.networkInsightsEnabled}
ariaLabel="线路体验"
/>
{renderImmediateToggle("networkInsightsEnabled", { id: "networkInsightsEnabled" })}
<p className="text-xs leading-5 text-muted-foreground">
访线
</p>
@@ -287,7 +376,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<span className="min-w-0">
<span className="block text-sm font-semibold">访</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
访{config.subscriptionRiskEnabled ? "已开启" : "已关闭"}
访{toggleValues.subscriptionRiskEnabled ? "已开启" : "已关闭"}
</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="space-y-2">
<Label htmlFor="subscriptionRiskEnabled"></Label>
<BooleanToggle
id="subscriptionRiskEnabled"
name="subscriptionRiskEnabled"
defaultValue={config.subscriptionRiskEnabled}
ariaLabel="风控总控"
/>
{renderImmediateToggle("subscriptionRiskEnabled", { id: "subscriptionRiskEnabled" })}
</div>
<div className="space-y-2">
<Label htmlFor="subscriptionRiskAutoSuspend"></Label>
<BooleanToggle
id="subscriptionRiskAutoSuspend"
name="subscriptionRiskAutoSuspend"
defaultValue={config.subscriptionRiskAutoSuspend}
trueLabel="开启自动停"
falseLabel="只记录警告"
ariaLabel="自动暂停"
/>
{renderImmediateToggle("subscriptionRiskAutoSuspend", {
id: "subscriptionRiskAutoSuspend",
trueLabel: "开启自动封停",
falseLabel: "只记录警告",
ariaLabel: "自动停",
})}
</div>
<div className="space-y-2">
<Label htmlFor="subscriptionRiskWindowHours"></Label>
@@ -421,14 +503,12 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div>
<div className="space-y-2">
<Label htmlFor="nodeAccessRiskEnabled"></Label>
<BooleanToggle
id="nodeAccessRiskEnabled"
name="nodeAccessRiskEnabled"
defaultValue={config.nodeAccessRiskEnabled}
trueLabel="接收日志"
falseLabel="仅订阅风控"
ariaLabel="节点日志风控"
/>
{renderImmediateToggle("nodeAccessRiskEnabled", {
id: "nodeAccessRiskEnabled",
trueLabel: "接收日志",
falseLabel: "仅订阅风控",
ariaLabel: "节点日志风控",
})}
</div>
<div className="space-y-2">
<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="space-y-2">
<Label htmlFor="allowRegistration"></Label>
<BooleanToggle
id="allowRegistration"
name="allowRegistration"
defaultValue={config.allowRegistration}
trueLabel="开放"
falseLabel="关闭"
ariaLabel="开放注册"
/>
{renderImmediateToggle("allowRegistration", {
id: "allowRegistration",
trueLabel: "开放",
falseLabel: "关闭",
ariaLabel: "开放注册",
})}
</div>
<div className="space-y-2">
<Label htmlFor="requireInviteCode"></Label>
<BooleanToggle
id="requireInviteCode"
name="requireInviteCode"
defaultValue={config.requireInviteCode}
trueLabel="必须"
falseLabel="不需要"
ariaLabel="注册必须邀请码"
/>
{renderImmediateToggle("requireInviteCode", {
id: "requireInviteCode",
trueLabel: "必须",
falseLabel: "不需要",
ariaLabel: "注册必须邀请码",
})}
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="emailVerificationRequired"></Label>
<BooleanToggle
id="emailVerificationRequired"
name="emailVerificationRequired"
defaultValue={config.emailVerificationRequired}
trueLabel="开启验证"
falseLabel="关闭"
ariaLabel="注册邮箱验证"
/>
{renderImmediateToggle("emailVerificationRequired", {
id: "emailVerificationRequired",
trueLabel: "开启验证",
falseLabel: "关闭",
ariaLabel: "注册邮箱验证",
})}
<p className="text-xs leading-5 text-muted-foreground"></p>
</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="space-y-2">
<Label htmlFor="smtpEnabled"></Label>
<BooleanToggle
id="smtpEnabled"
name="smtpEnabled"
defaultValue={config.smtpEnabled}
ariaLabel="邮件服务"
/>
{renderImmediateToggle("smtpEnabled", { id: "smtpEnabled" })}
</div>
<div className="space-y-2">
<Label htmlFor="smtpHost">SMTP </Label>
@@ -523,14 +592,12 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div>
<div className="space-y-2">
<Label htmlFor="smtpSecure">TLS / SSL</Label>
<BooleanToggle
id="smtpSecure"
name="smtpSecure"
defaultValue={config.smtpSecure}
trueLabel="SSL 直连"
falseLabel="STARTTLS"
ariaLabel="TLS / SSL"
/>
{renderImmediateToggle("smtpSecure", {
id: "smtpSecure",
trueLabel: "SSL 直连",
falseLabel: "STARTTLS",
ariaLabel: "TLS / SSL",
})}
</div>
<div className="space-y-2">
<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="space-y-2">
<Label htmlFor="inviteRewardEnabled"></Label>
<BooleanToggle
id="inviteRewardEnabled"
name="inviteRewardEnabled"
defaultValue={config.inviteRewardEnabled}
ariaLabel="自动发放奖励"
/>
{renderImmediateToggle("inviteRewardEnabled", { id: "inviteRewardEnabled" })}
</div>
<div className="space-y-2">
<Label htmlFor="inviteRewardRate">%</Label>
@@ -646,8 +708,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</section>
<div className="flex flex-col gap-3 sm:flex-row">
<Button type="submit" size="lg" disabled={saving}>
{saving ? "保存中..." : "保存设置"}
<Button type="submit" size="lg" disabled={saving || hasPendingToggle}>
{saving ? "保存中..." : hasPendingToggle ? "开关更新中..." : "保存设置"}
</Button>
<a href="/api/admin/export/config" className={buttonVariants({ variant: "outline", size: "lg" })}>

View File

@@ -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;

View File

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