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