Files
nanami-web/src/components/admin/ReleasesTable.tsx
2026-05-12 09:58:25 +08:00

380 lines
12 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 } 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>
);
}