feat: add localization and site settings

This commit is contained in:
rucky
2026-05-12 09:58:25 +08:00
parent 9dc6c0dcce
commit fa7aedb8e7
67 changed files with 5221 additions and 888 deletions

View File

@@ -0,0 +1,379 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Badge } from "@/components/ui/badge";
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { toast } from "sonner";
import { Download, Pencil, Trash2, Check, X, Upload, Star } from "lucide-react";
interface Release {
id: string;
version: string;
changelog: string;
changelogEn: string;
wowVersion: string;
downloadType: string;
filePath: string | null;
externalUrl: string | null;
gameVersion: string;
downloadCount: number;
isLatest: boolean;
createdAt: string;
addon: { name: string; slug: string };
}
export function ReleasesTable({ releases: initial }: { releases: Release[] }) {
const router = useRouter();
const [editingId, setEditingId] = useState<string | null>(null);
async function handleSetLatest(releaseId: string) {
const res = await fetch(`/api/releases/${releaseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isLatest: true }),
});
if (res.ok) {
toast.success("已设为最新版本");
router.refresh();
} else {
toast.error("设置失败");
}
}
async function handleDelete(r: Release) {
if (!confirm(`确定要删除 ${r.addon.name} v${r.version} 吗?此操作不可撤销。`)) return;
const res = await fetch(`/api/releases/${r.id}`, { method: "DELETE" });
if (res.ok) {
toast.success("版本已删除");
router.refresh();
} else {
toast.error("删除失败");
}
}
return (
<div className="space-y-0">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>WoW</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{initial.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
initial.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.addon.name}</TableCell>
<TableCell>v{r.version}</TableCell>
<TableCell>
<Badge variant="outline" className="border-amber-500/40 text-amber-300/90">
{r.wowVersion}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{r.gameVersion || "-"}
</TableCell>
<TableCell>
<Badge variant="secondary">
{r.downloadType === "local" ? "本地文件" : "外部链接"}
</Badge>
</TableCell>
<TableCell>
<span className="flex items-center gap-1">
<Download className="h-3.5 w-3.5 text-muted-foreground" />
{r.downloadCount}
</span>
</TableCell>
<TableCell className="text-muted-foreground">
{new Date(r.createdAt).toLocaleDateString("zh-CN")}
</TableCell>
<TableCell>
{r.isLatest && <Badge></Badge>}
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
{!r.isLatest && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="设为最新版本"
onClick={() => handleSetLatest(r.id)}
>
<Star className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="编辑"
onClick={() => setEditingId(editingId === r.id ? null : r.id)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
title="删除"
onClick={() => handleDelete(r)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{editingId && (
<ReleaseEditPanel
release={initial.find((r) => r.id === editingId)!}
onClose={() => setEditingId(null)}
onSaved={() => {
setEditingId(null);
router.refresh();
}}
/>
)}
</div>
);
}
function ReleaseEditPanel({
release: r,
onClose,
onSaved,
}: {
release: Release;
onClose: () => void;
onSaved: () => void;
}) {
const [saving, setSaving] = useState(false);
const [version, setVersion] = useState(r.version);
const [changelog, setChangelog] = useState(r.changelog);
const [changelogEn, setChangelogEn] = useState(r.changelogEn || "");
const [wowVersion, setWowVersion] = useState<"1.18" | "1.17">(
(r.wowVersion as "1.18" | "1.17") || "1.18"
);
const [gameVersion, setGameVersion] = useState(r.gameVersion);
const [downloadType, setDownloadType] = useState(r.downloadType);
const [externalUrl, setExternalUrl] = useState(r.externalUrl || "");
const [filePath, setFilePath] = useState(r.filePath || "");
const [uploading, setUploading] = useState(false);
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: fd });
if (res.ok) {
const data = await res.json();
setFilePath(data.filePath);
toast.success(`文件 ${data.originalName} 上传成功`);
} else {
toast.error("文件上传失败");
}
setUploading(false);
}
async function handleSave() {
setSaving(true);
if (downloadType === "local" && !filePath) {
toast.error("请先上传文件");
setSaving(false);
return;
}
if (downloadType === "url" && !externalUrl) {
toast.error("请输入外部链接");
setSaving(false);
return;
}
const res = await fetch(`/api/releases/${r.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
version,
changelog,
changelogEn,
wowVersion,
gameVersion,
downloadType,
filePath: downloadType === "local" ? filePath : null,
externalUrl: downloadType === "url" ? externalUrl : null,
}),
});
if (res.ok) {
toast.success("版本更新成功");
onSaved();
} else {
const err = await res.json();
toast.error(err.error || "更新失败");
}
setSaving(false);
}
return (
<div className="border-t bg-muted/30 p-6">
<div className="mx-auto max-w-2xl space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold">
{r.addon.name} v{r.version}
</h3>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label></Label>
<Input value={version} onChange={(e) => setVersion(e.target.value)} />
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={gameVersion}
onChange={(e) => setGameVersion(e.target.value)}
placeholder="1.18.1"
/>
</div>
<div className="space-y-2">
<Label>WoW</Label>
<div className="inline-flex w-full rounded-md border bg-muted/40 p-0.5">
{(["1.18", "1.17"] as const).map((wv) => (
<button
key={wv}
type="button"
onClick={() => setWowVersion(wv)}
className={`flex-1 rounded px-3 py-1.5 text-sm font-medium transition-colors ${
wv === wowVersion
? "bg-background shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{wv}
</button>
))}
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<Textarea
value={changelog}
onChange={(e) => setChangelog(e.target.value)}
rows={5}
/>
</div>
<div className="space-y-2">
<Label>Changelog (English)</Label>
<Textarea
value={changelogEn}
onChange={(e) => setChangelogEn(e.target.value)}
rows={5}
placeholder="为空则回退到中文 / Falls back to Chinese if empty"
/>
</div>
</div>
<div className="space-y-3">
<Label></Label>
<div className="flex gap-3">
<Button
type="button"
size="sm"
variant={downloadType === "local" ? "default" : "outline"}
onClick={() => setDownloadType("local")}
>
</Button>
<Button
type="button"
size="sm"
variant={downloadType === "url" ? "default" : "outline"}
onClick={() => setDownloadType("url")}
>
</Button>
</div>
{downloadType === "local" ? (
<div className="space-y-2">
<Label
htmlFor={`edit-release-file-${r.id}`}
className="flex cursor-pointer items-center gap-2 rounded-lg border-2 border-dashed px-4 py-3 text-sm transition-colors hover:border-primary"
>
<Upload className="h-4 w-4" />
{uploading ? "上传中..." : filePath ? "重新选择文件" : "选择文件"}
</Label>
<Input
id={`edit-release-file-${r.id}`}
type="file"
className="hidden"
accept=".zip,.rar,.7z,.tar.gz"
onChange={handleFileUpload}
/>
{filePath && (
<p className="text-xs text-muted-foreground">: {filePath}</p>
)}
</div>
) : (
<div className="space-y-2">
<Input
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://..."
/>
</div>
)}
</div>
<div className="flex gap-2 pt-2">
<Button size="sm" onClick={handleSave} disabled={saving}>
<Check className="mr-1.5 h-3.5 w-3.5" />
{saving ? "保存中..." : "保存修改"}
</Button>
<Button size="sm" variant="outline" onClick={onClose}>
</Button>
</div>
</div>
</div>
);
}