Files
J-Board-Lite/src/app/(admin)/admin/settings/settings-form.tsx
2026-04-29 15:20:23 +10:00

386 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { Bell, Clock3, Gift, Mail, Send, Settings2, ShieldAlert, ShieldCheck } from "lucide-react";
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 { toast } from "sonner";
import { getErrorMessage } from "@/lib/errors";
interface AppConfig {
siteName: string;
siteUrl: string | null;
supportContact: string | null;
maintenanceNotice: string | null;
siteNotice: string | null;
allowRegistration: boolean;
emailVerificationRequired: boolean;
requireInviteCode: boolean;
autoReminderDispatchEnabled: boolean;
reminderDispatchIntervalMinutes: number;
trafficSyncEnabled: boolean;
trafficSyncIntervalSeconds: number;
inviteRewardEnabled: boolean;
inviteRewardRate: number;
inviteRewardCouponId: string | null;
turnstileSiteKey: string | null;
turnstileSecretKey: string | null;
smtpEnabled: boolean;
smtpHost: string | null;
smtpPort: number;
smtpSecure: boolean;
smtpUser: string | null;
smtpFromName: string | null;
smtpFromEmail: string | null;
}
interface CouponOption {
id: string;
code: string;
name: string;
}
const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-sm outline-none";
export function SettingsForm({ config, coupons }: { config: AppConfig; coupons: CouponOption[] }) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [testingEmail, setTestingEmail] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
setSaving(true);
try {
const result = await saveAppSettings(new FormData(form));
if (!result.ok) {
toast.error(getErrorMessage(result.error, "保存设置失败"));
return;
}
clearPasswordField(form);
router.refresh();
toast.success("设置已保存");
} catch (error) {
toast.error(getErrorMessage(error, "保存失败"));
} finally {
setSaving(false);
}
}
async function handleTestEmail() {
const form = document.getElementById("app-settings-form") as HTMLFormElement | null;
if (!form) return;
setTestingEmail(true);
try {
const result = await testSmtpSettings(new FormData(form));
if (!result.ok) {
if (result.settingsSaved) {
clearPasswordField(form);
router.refresh();
}
toast.error(
result.settingsSaved
? `设置已保存,但测试邮件没有发出:${getErrorMessage(result.error, "测试邮件发送失败")}`
: getErrorMessage(result.error, "测试邮件发送失败"),
);
return;
}
clearPasswordField(form);
router.refresh();
toast.success("设置已保存,测试邮件已发送");
} catch (error) {
toast.error(getErrorMessage(error, "测试邮件发送失败"));
} finally {
setTestingEmail(false);
}
}
function clearPasswordField(form: HTMLFormElement) {
const password = form.elements.namedItem("smtpPassword");
if (password instanceof HTMLInputElement) {
password.value = "";
}
}
return (
<form id="app-settings-form" onSubmit={handleSubmit} className="form-panel space-y-6">
<div className="flex items-start gap-3">
<span className="flex size-11 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<Settings2 className="size-5" />
</span>
<div>
<h3 className="text-lg font-semibold tracking-tight"></h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground"></p>
</div>
</div>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<Settings2 className="size-4 text-primary" />
</div>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="siteName"></Label>
<Input id="siteName" name="siteName" defaultValue={config.siteName} required />
</div>
<div className="space-y-2">
<Label htmlFor="siteUrl"> / URL</Label>
<Input id="siteUrl" name="siteUrl" defaultValue={config.siteUrl ?? ""} placeholder="https://example.com" />
<p className="text-xs leading-5 text-muted-foreground"> Agent </p>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="supportContact"></Label>
<Input id="supportContact" name="supportContact" defaultValue={config.supportContact ?? ""} />
</div>
</div>
</section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<Clock3 className="size-4 text-primary" />
</div>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="autoReminderDispatchEnabled"></Label>
<select
id="autoReminderDispatchEnabled"
name="autoReminderDispatchEnabled"
defaultValue={String(config.autoReminderDispatchEnabled)}
className={selectClassName}
>
<option value="true"></option>
<option value="false"></option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="reminderDispatchIntervalMinutes"></Label>
<Input id="reminderDispatchIntervalMinutes" name="reminderDispatchIntervalMinutes" type="number" min={1} defaultValue={config.reminderDispatchIntervalMinutes} />
</div>
<div className="space-y-2">
<Label htmlFor="trafficSyncEnabled">3x-ui </Label>
<select
id="trafficSyncEnabled"
name="trafficSyncEnabled"
defaultValue={String(config.trafficSyncEnabled)}
className={selectClassName}
>
<option value="true"></option>
<option value="false"></option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="trafficSyncIntervalSeconds"></Label>
<Input
id="trafficSyncIntervalSeconds"
name="trafficSyncIntervalSeconds"
type="number"
min={10}
step={1}
defaultValue={config.trafficSyncIntervalSeconds}
placeholder="60"
/>
<p className="text-xs leading-5 text-muted-foreground"> 60 10 </p>
</div>
</div>
</section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<ShieldCheck className="size-4 text-primary" />
</div>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="allowRegistration"></Label>
<select
id="allowRegistration"
name="allowRegistration"
defaultValue={String(config.allowRegistration)}
className={selectClassName}
>
<option value="true"></option>
<option value="false"></option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="requireInviteCode"></Label>
<select
id="requireInviteCode"
name="requireInviteCode"
defaultValue={String(config.requireInviteCode)}
className={selectClassName}
>
<option value="false"></option>
<option value="true"></option>
</select>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="emailVerificationRequired"></Label>
<select
id="emailVerificationRequired"
name="emailVerificationRequired"
defaultValue={String(config.emailVerificationRequired)}
className={selectClassName}
>
<option value="false"></option>
<option value="true"></option>
</select>
<p className="text-xs leading-5 text-muted-foreground"></p>
</div>
</div>
</section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<Mail className="size-4 text-primary" /> SMTP
</div>
<p className="text-xs leading-5 text-muted-foreground">
SMTP
</p>
<div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="smtpEnabled"></Label>
<select id="smtpEnabled" name="smtpEnabled" defaultValue={String(config.smtpEnabled)} className={selectClassName}>
<option value="false"></option>
<option value="true"></option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="smtpHost">SMTP </Label>
<Input id="smtpHost" name="smtpHost" defaultValue={config.smtpHost ?? ""} placeholder="smtp.example.com" />
</div>
<div className="space-y-2">
<Label htmlFor="smtpPort">SMTP </Label>
<Input id="smtpPort" name="smtpPort" type="number" min={1} max={65535} defaultValue={config.smtpPort} />
</div>
<div className="space-y-2">
<Label htmlFor="smtpSecure">TLS / SSL</Label>
<select id="smtpSecure" name="smtpSecure" defaultValue={String(config.smtpSecure)} className={selectClassName}>
<option value="false">STARTTLS / </option>
<option value="true">SSL </option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="smtpUser">SMTP </Label>
<Input id="smtpUser" name="smtpUser" defaultValue={config.smtpUser ?? ""} autoComplete="username" />
</div>
<div className="space-y-2">
<Label htmlFor="smtpPassword">SMTP </Label>
<Input id="smtpPassword" name="smtpPassword" type="password" placeholder="留空保持不变" autoComplete="new-password" />
</div>
<div className="space-y-2">
<Label htmlFor="smtpFromName"></Label>
<Input id="smtpFromName" name="smtpFromName" defaultValue={config.smtpFromName ?? ""} placeholder={config.siteName} />
</div>
<div className="space-y-2">
<Label htmlFor="smtpFromEmail"></Label>
<Input id="smtpFromEmail" name="smtpFromEmail" type="email" defaultValue={config.smtpFromEmail ?? ""} placeholder="noreply@example.com" />
</div>
<div className="space-y-2">
<Label htmlFor="smtpTestEmail"></Label>
<div className="flex gap-2">
<Input id="smtpTestEmail" name="smtpTestEmail" type="email" placeholder="you@example.com" />
<Button type="button" variant="outline" onClick={handleTestEmail} disabled={testingEmail}>
<Send className="size-4" />
{testingEmail ? "发送中" : "测试"}
</Button>
</div>
</div>
</div>
</section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<Gift className="size-4 text-primary" />
</div>
<div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="inviteRewardEnabled"></Label>
<select
id="inviteRewardEnabled"
name="inviteRewardEnabled"
defaultValue={String(config.inviteRewardEnabled)}
className={selectClassName}
>
<option value="false"></option>
<option value="true"></option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="inviteRewardRate">%</Label>
<Input id="inviteRewardRate" name="inviteRewardRate" type="number" min={0} max={100} step="0.01" defaultValue={config.inviteRewardRate} />
</div>
<div className="space-y-2">
<Label htmlFor="inviteRewardCouponId"></Label>
<select
id="inviteRewardCouponId"
name="inviteRewardCouponId"
defaultValue={config.inviteRewardCouponId ?? ""}
className={selectClassName}
>
<option value=""></option>
{coupons.map((coupon) => (
<option key={coupon.id} value={coupon.id}>
{coupon.name} · {coupon.code}
</option>
))}
</select>
</div>
</div>
<p className="text-xs leading-5 text-muted-foreground">
</p>
</section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<ShieldAlert className="size-4 text-primary" /> Cloudflare Turnstile
</div>
<p className="text-xs leading-5 text-muted-foreground">
</p>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="turnstileSiteKey">Site Key</Label>
<Input id="turnstileSiteKey" name="turnstileSiteKey" defaultValue={config.turnstileSiteKey ?? ""} placeholder="0x4AAAAAAA..." />
</div>
<div className="space-y-2">
<Label htmlFor="turnstileSecretKey">Secret Key</Label>
<Input id="turnstileSecretKey" name="turnstileSecretKey" type="password" defaultValue={config.turnstileSecretKey ?? ""} placeholder="0x4AAAAAAA..." />
</div>
</div>
</section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<Bell className="size-4 text-primary" />
</div>
<div className="grid gap-5 lg:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="siteNotice"></Label>
<Textarea id="siteNotice" name="siteNotice" rows={4} defaultValue={config.siteNotice ?? ""} />
</div>
<div className="space-y-2">
<Label htmlFor="maintenanceNotice"></Label>
<Textarea id="maintenanceNotice" name="maintenanceNotice" rows={4} defaultValue={config.maintenanceNotice ?? ""} />
</div>
</div>
</section>
<div className="flex flex-col gap-3 sm:flex-row">
<Button type="submit" size="lg" disabled={saving}>
{saving ? "保存中..." : "保存设置"}
</Button>
<a href="/api/admin/export/config" className={buttonVariants({ variant: "outline", size: "lg" })}>
</a>
</div>
</form>
);
}