491 lines
17 KiB
TypeScript
491 lines
17 KiB
TypeScript
"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>
|
||
);
|
||
}
|