Files
nanami-web/src/app/admin/(dashboard)/settings/page.tsx
2026-05-12 09:58:25 +08:00

491 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 { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
import { Trash2, Upload, Copy, ExternalLink } from "lucide-react";
type SiteSettings = {
grayscale: boolean;
shutdownBannerEnabled: boolean;
shutdownTitle: string;
shutdownTitleEn: string;
shutdownSubtitle: string;
shutdownSubtitleEn: string;
shutdownAt: string | null;
bgmUrl: string;
bgmAutoplay: boolean;
bgmVolume: number;
};
function toDatetimeLocal(iso: string | null): string {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
d.getHours()
)}:${pad(d.getMinutes())}`;
}
export default function SettingsPage() {
const [loading, setLoading] = useState(false);
const [launcherUrl, setLauncherUrl] = useState("");
useEffect(() => {
if (typeof window !== "undefined") {
setLauncherUrl(`${window.location.origin}/download/launcher`);
}
}, []);
async function handleCopyLauncherUrl() {
try {
await navigator.clipboard.writeText(launcherUrl);
toast.success("已复制到剪贴板");
} catch {
toast.error("复制失败");
}
}
const [siteLoading, setSiteLoading] = useState(true);
const [siteSaving, setSiteSaving] = useState(false);
const [grayscale, setGrayscale] = useState(false);
const [bannerEnabled, setBannerEnabled] = useState(false);
const [title, setTitle] = useState("");
const [titleEn, setTitleEn] = useState("");
const [subtitle, setSubtitle] = useState("");
const [subtitleEn, setSubtitleEn] = useState("");
const [shutdownAtLocal, setShutdownAtLocal] = useState("");
const [bgmUrl, setBgmUrl] = useState("");
const [bgmAutoplay, setBgmAutoplay] = useState(false);
const [bgmVolume, setBgmVolume] = useState(50);
const [bgmUploading, setBgmUploading] = useState(false);
const bgmFileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
(async () => {
try {
const res = await fetch("/api/admin/site-settings");
if (!res.ok) throw new Error();
const data: SiteSettings = await res.json();
setGrayscale(data.grayscale);
setBannerEnabled(data.shutdownBannerEnabled);
setTitle(data.shutdownTitle);
setTitleEn(data.shutdownTitleEn ?? "");
setSubtitle(data.shutdownSubtitle);
setSubtitleEn(data.shutdownSubtitleEn ?? "");
setShutdownAtLocal(toDatetimeLocal(data.shutdownAt));
setBgmUrl(data.bgmUrl || "");
setBgmAutoplay(!!data.bgmAutoplay);
setBgmVolume(
typeof data.bgmVolume === "number" ? data.bgmVolume : 50
);
} catch {
toast.error("加载站点设置失败");
} finally {
setSiteLoading(false);
}
})();
}, []);
async function handleSiteSave(e: React.FormEvent) {
e.preventDefault();
setSiteSaving(true);
const payload = {
grayscale,
shutdownBannerEnabled: bannerEnabled,
shutdownTitle: title,
shutdownTitleEn: titleEn,
shutdownSubtitle: subtitle,
shutdownSubtitleEn: subtitleEn,
shutdownAt: shutdownAtLocal
? new Date(shutdownAtLocal).toISOString()
: null,
bgmUrl,
bgmAutoplay,
bgmVolume,
};
const res = await fetch("/api/admin/site-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.ok) {
toast.success("站点设置已保存");
} else {
const data = await res.json().catch(() => ({}));
toast.error(data?.error || "保存失败");
}
setSiteSaving(false);
}
async function handleBgmUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("audio/")) {
toast.error("请选择音频文件");
return;
}
setBgmUploading(true);
try {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: form });
if (!res.ok) throw new Error();
const data = await res.json();
setBgmUrl(data.filePath as string);
toast.success("音频上传成功,请记得保存");
} catch {
toast.error("音频上传失败");
} finally {
setBgmUploading(false);
if (bgmFileRef.current) bgmFileRef.current.value = "";
}
}
async function handlePasswordSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
const formData = new FormData(e.currentTarget);
const newPassword = formData.get("newPassword") as string;
const confirmPassword = formData.get("confirmPassword") as string;
if (newPassword !== confirmPassword) {
toast.error("两次输入的新密码不一致");
setLoading(false);
return;
}
const res = await fetch("/api/admin/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
currentPassword: formData.get("currentPassword"),
newPassword,
}),
});
const data = await res.json();
if (res.ok) {
toast.success("密码修改成功");
(e.target as HTMLFormElement).reset();
} else {
toast.error(data.error || "修改失败");
}
setLoading(false);
}
return (
<div className="mx-auto max-w-2xl space-y-6">
<h1 className="text-3xl font-bold"></h1>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSiteSave} className="space-y-6">
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div className="space-y-1">
<Label className="text-base"></Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
checked={grayscale}
onCheckedChange={setGrayscale}
disabled={siteLoading}
/>
</div>
<div className="space-y-4 rounded-lg border p-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label className="text-base"> Banner</Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
checked={bannerEnabled}
onCheckedChange={setBannerEnabled}
disabled={siteLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="shutdownTitle"></Label>
<Textarea
id="shutdownTitle"
value={title}
onChange={(e) => setTitle(e.target.value)}
rows={2}
placeholder={"例如:旅程即将终结\n再见乌龟服"}
disabled={siteLoading}
/>
<p className="text-xs text-muted-foreground">
Markdown <code>****</code><code>**</code><code>[](https://...)</code>。
</p>
</div>
<div className="space-y-2">
<Label htmlFor="shutdownTitleEn">Title (English)</Label>
<Textarea
id="shutdownTitleEn"
value={titleEn}
onChange={(e) => setTitleEn(e.target.value)}
rows={2}
placeholder={"e.g. Every journey ends,\nbut our story carries on"}
disabled={siteLoading}
/>
<p className="text-xs text-muted-foreground">
访退
</p>
</div>
<div className="space-y-2">
<Label htmlFor="shutdownSubtitle"> / </Label>
<Textarea
id="shutdownSubtitle"
value={subtitle}
onChange={(e) => setSubtitle(e.target.value)}
rows={4}
placeholder="例如《Turtle WoW》即将关闭服务器将于欧洲时间 5月15日 凌晨 00:00 正式关闭。"
disabled={siteLoading}
/>
<p className="text-xs text-muted-foreground">
Markdown
</p>
</div>
<div className="space-y-2">
<Label htmlFor="shutdownSubtitleEn">Subtitle (English)</Label>
<Textarea
id="shutdownSubtitleEn"
value={subtitleEn}
onChange={(e) => setSubtitleEn(e.target.value)}
rows={4}
placeholder="e.g. Turtle WoW will be shutting down. Servers go offline at midnight European time on May 15."
disabled={siteLoading}
/>
<p className="text-xs text-muted-foreground">
访退
</p>
</div>
<div className="space-y-2">
<Label htmlFor="shutdownAt"></Label>
<Input
id="shutdownAt"
type="datetime-local"
value={shutdownAtLocal}
onChange={(e) => setShutdownAtLocal(e.target.value)}
disabled={siteLoading}
/>
<p className="text-xs text-muted-foreground">
使访
</p>
</div>
</div>
<div className="space-y-4 rounded-lg border p-4">
<div>
<Label className="text-base">BGM</Label>
<p className="mt-1 text-xs text-muted-foreground">
mp3 / ogg / wav
</p>
</div>
<div className="space-y-2">
<Label></Label>
{bgmUrl ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm">
<span className="flex-1 truncate font-mono text-xs text-muted-foreground">
{bgmUrl}
</span>
<button
type="button"
onClick={() => setBgmUrl("")}
className="text-destructive hover:text-destructive/80"
title="移除"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<audio
src={bgmUrl}
controls
className="w-full"
preload="metadata"
/>
<div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => bgmFileRef.current?.click()}
disabled={bgmUploading}
>
{bgmUploading ? "上传中..." : "替换音频"}
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="outline"
onClick={() => bgmFileRef.current?.click()}
disabled={bgmUploading}
className="gap-2"
>
<Upload className="h-4 w-4" />
{bgmUploading ? "上传中..." : "上传音频"}
</Button>
)}
<input
ref={bgmFileRef}
type="file"
accept="audio/*"
className="hidden"
onChange={handleBgmUpload}
/>
</div>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label></Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
checked={bgmAutoplay}
onCheckedChange={setBgmAutoplay}
disabled={siteLoading || !bgmUrl}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="bgmVolume"></Label>
<span className="font-mono text-xs text-muted-foreground tabular-nums">
{bgmVolume}
</span>
</div>
<input
id="bgmVolume"
type="range"
min={0}
max={100}
value={bgmVolume}
onChange={(e) => setBgmVolume(Number(e.target.value))}
disabled={siteLoading || !bgmUrl}
className="w-full"
/>
</div>
</div>
<Button type="submit" disabled={siteLoading || siteSaving}>
{siteSaving ? "保存中..." : "保存"}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
访
Nanami
</p>
<div className="flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-2">
<span className="flex-1 truncate font-mono text-xs">
{launcherUrl || "/download/launcher"}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopyLauncherUrl}
disabled={!launcherUrl}
className="gap-1"
>
<Copy className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
launcherUrl && window.open(launcherUrl, "_blank")
}
disabled={!launcherUrl}
className="gap-1"
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handlePasswordSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="currentPassword"></Label>
<Input
id="currentPassword"
name="currentPassword"
type="password"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="newPassword"></Label>
<Input
id="newPassword"
name="newPassword"
type="password"
required
minLength={6}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword"></Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
required
minLength={6}
/>
</div>
<Button type="submit" disabled={loading}>
{loading ? "修改中..." : "修改密码"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}