mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: save settings toggles immediately
This commit is contained in:
@@ -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>>;
|
||||||
|
|||||||
@@ -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" })}>
|
||||||
导出配置备份
|
导出配置备份
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: (
|
||||||
|
|||||||
Reference in New Issue
Block a user