官网 初版

This commit is contained in:
rucky
2026-03-18 17:13:27 +08:00
parent 879c4bdfc8
commit 241a76caeb
95 changed files with 8889 additions and 113 deletions

View File

@@ -0,0 +1,23 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { AddonForm } from "@/components/admin/AddonForm";
export const dynamic = "force-dynamic";
export default async function EditAddonPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const addon = await prisma.addon.findUnique({ where: { id } });
if (!addon) notFound();
return (
<div className="mx-auto max-w-2xl space-y-6">
<h1 className="text-3xl font-bold"></h1>
<AddonForm initialData={addon} />
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { AddonForm } from "@/components/admin/AddonForm";
export default function NewAddonPage() {
return (
<div className="mx-auto max-w-2xl space-y-6">
<h1 className="text-3xl font-bold"></h1>
<AddonForm />
</div>
);
}

View File

@@ -0,0 +1,87 @@
import Link from "next/link";
import { prisma } from "@/lib/db";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Plus } from "lucide-react";
import { DeleteAddonButton } from "@/components/admin/DeleteAddonButton";
export const dynamic = "force-dynamic";
export default async function AdminAddonsPage() {
const addons = await prisma.addon.findMany({
include: { _count: { select: { releases: true } } },
orderBy: { updatedAt: "desc" },
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold"></h1>
<Button render={<Link href="/admin/addons/new" />}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>Slug</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{addons.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
addons.map((addon) => (
<TableRow key={addon.id}>
<TableCell className="font-medium">{addon.name}</TableCell>
<TableCell className="text-muted-foreground">
{addon.slug}
</TableCell>
<TableCell>
<Badge variant="secondary">{addon.category}</Badge>
</TableCell>
<TableCell>
<Badge variant={addon.published ? "default" : "outline"}>
{addon.published ? "已发布" : "草稿"}
</Badge>
</TableCell>
<TableCell>{addon._count.releases}</TableCell>
<TableCell>{addon.totalDownloads}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" render={<Link href={`/admin/addons/${addon.id}/edit`} />}>
</Button>
<DeleteAddonButton addonId={addon.id} addonName={addon.name} />
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { Sidebar } from "@/components/admin/Sidebar";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 overflow-y-auto bg-muted/40 p-8">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { prisma } from "@/lib/db";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Package, Download, FileUp } from "lucide-react";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const [addonCount, totalDownloads, releaseCount, recentReleases] =
await Promise.all([
prisma.addon.count(),
prisma.addon.aggregate({ _sum: { totalDownloads: true } }),
prisma.release.count(),
prisma.release.findMany({
take: 5,
orderBy: { createdAt: "desc" },
include: { addon: { select: { name: true } } },
}),
]);
const stats = [
{
title: "插件总数",
value: addonCount,
icon: Package,
},
{
title: "总下载量",
value: totalDownloads._sum.totalDownloads || 0,
icon: Download,
},
{
title: "版本发布数",
value: releaseCount,
icon: FileUp,
},
];
return (
<div className="space-y-8">
<h1 className="text-3xl font-bold"></h1>
<div className="grid gap-4 md:grid-cols-3">
{stats.map((stat) => (
<Card key={stat.title}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{stat.title}
</CardTitle>
<stat.icon className="h-5 w-5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{stat.value}</div>
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{recentReleases.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="space-y-4">
{recentReleases.map((release) => (
<div
key={release.id}
className="flex items-center justify-between border-b pb-3 last:border-0"
>
<div>
<p className="font-medium">{release.addon.name}</p>
<p className="text-sm text-muted-foreground">
v{release.version}
{release.gameVersion &&
` · WoW ${release.gameVersion}`}
</p>
</div>
<div className="text-right text-sm text-muted-foreground">
<p>{release.downloadCount} </p>
<p>{new Date(release.createdAt).toLocaleDateString("zh-CN")}</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { prisma } from "@/lib/db";
import { ReleaseForm } from "@/components/admin/ReleaseForm";
export const dynamic = "force-dynamic";
export default async function NewReleasePage() {
const addons = await prisma.addon.findMany({
select: { id: true, name: true },
orderBy: { name: "asc" },
});
return (
<div className="mx-auto max-w-2xl space-y-6">
<h1 className="text-3xl font-bold"></h1>
{addons.length === 0 ? (
<p className="text-muted-foreground">
</p>
) : (
<ReleaseForm addons={addons} />
)}
</div>
);
}

View File

@@ -0,0 +1,88 @@
import Link from "next/link";
import { prisma } from "@/lib/db";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Plus } from "lucide-react";
export const dynamic = "force-dynamic";
export default async function AdminReleasesPage() {
const releases = await prisma.release.findMany({
include: { addon: { select: { name: true, slug: true } } },
orderBy: { createdAt: "desc" },
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold"></h1>
<Button render={<Link href="/admin/releases/new" />}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{releases.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground"
>
</TableCell>
</TableRow>
) : (
releases.map((release) => (
<TableRow key={release.id}>
<TableCell className="font-medium">
{release.addon.name}
</TableCell>
<TableCell>v{release.version}</TableCell>
<TableCell className="text-muted-foreground">
{release.gameVersion || "-"}
</TableCell>
<TableCell>
<Badge variant="secondary">
{release.downloadType === "local" ? "本地文件" : "外部链接"}
</Badge>
</TableCell>
<TableCell>{release.downloadCount}</TableCell>
<TableCell className="text-muted-foreground">
{new Date(release.createdAt).toLocaleDateString("zh-CN")}
</TableCell>
<TableCell>
{release.isLatest && (
<Badge></Badge>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
export default function SettingsPage() {
const [loading, setLoading] = useState(false);
async function handleSubmit(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-lg space-y-6">
<h1 className="text-3xl font-bold"></h1>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} 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>
);
}

View File

@@ -0,0 +1,26 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { SoftwareEditForm } from "@/components/admin/SoftwareEditForm";
export const dynamic = "force-dynamic";
export default async function EditSoftwarePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const software = await prisma.software.findUnique({
where: { id },
include: { versions: { orderBy: { versionCode: "desc" } } },
});
if (!software) notFound();
return (
<div className="mx-auto max-w-2xl space-y-6">
<h1 className="text-3xl font-bold"> - {software.name}</h1>
<SoftwareEditForm software={software} />
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { SoftwareVersionForm } from "@/components/admin/SoftwareVersionForm";
export const dynamic = "force-dynamic";
export default async function NewSoftwareVersionPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const software = await prisma.software.findUnique({
where: { id },
select: { id: true, name: true, slug: true },
});
if (!software) notFound();
return (
<div className="mx-auto max-w-2xl space-y-6">
<h1 className="text-3xl font-bold">
- {software.name}
</h1>
<SoftwareVersionForm softwareId={software.id} />
</div>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
export default function NewSoftwarePage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
function generateSlug(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
const fd = new FormData(e.currentTarget);
const res = await fetch("/api/software", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: fd.get("name"),
slug: fd.get("slug"),
description: fd.get("description"),
}),
});
if (res.ok) {
toast.success("创建成功");
router.push("/admin/software");
router.refresh();
} else {
const err = await res.json();
toast.error(err.error || "创建失败");
}
setLoading(false);
}
return (
<div className="mx-auto max-w-lg space-y-6">
<h1 className="text-3xl font-bold"></h1>
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name"> *</Label>
<Input
id="name" name="name" required
onChange={(e) => {
const slugInput = document.getElementById("slug") as HTMLInputElement;
if (slugInput) slugInput.value = generateSlug(e.target.value);
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug *</Label>
<Input id="slug" name="slug" required pattern="[a-z0-9-]+" />
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea id="description" name="description" rows={3} />
</div>
<div className="flex gap-3">
<Button type="submit" disabled={loading}>
{loading ? "创建中..." : "创建"}
</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,133 @@
import Link from "next/link";
import { prisma } from "@/lib/db";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Plus } from "lucide-react";
import { SoftwareVersionTable } from "@/components/admin/SoftwareVersionTable";
const SOFTWARE_DEFS = [
{
slug: "nanami-launcher",
name: "Nanami 启动器(全量包)",
description: "Nanami 插件启动器完整安装包,用户首次下载或大版本更新",
badgeLabel: "全量",
},
{
slug: "nanami-launcher-patch",
name: "Nanami 热更新包",
description: "app.asar 热更新包,客户端静默下载替换实现热更新",
badgeLabel: "热更新",
},
];
async function ensureSoftware(slug: string, name: string, description: string) {
let sw = await prisma.software.findUnique({ where: { slug } });
if (!sw) {
sw = await prisma.software.create({ data: { name, slug, description } });
}
return sw;
}
export const dynamic = "force-dynamic";
export default async function AdminSoftwarePage() {
const softwareItems = await Promise.all(
SOFTWARE_DEFS.map(async (def) => {
const sw = await ensureSoftware(def.slug, def.name, def.description);
const versions = await prisma.softwareVersion.findMany({
where: { softwareId: sw.id },
orderBy: { versionCode: "desc" },
});
const totalDownloads = versions.reduce((s, v) => s + v.downloadCount, 0);
const latestVersion = versions.find((v) => v.isLatest);
return { ...def, sw, versions, totalDownloads, latestVersion };
})
);
return (
<div className="space-y-10">
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="mt-1 text-muted-foreground">
slug
</p>
</div>
{softwareItems.map((item) => {
const serializedVersions = item.versions.map((v) => ({
...v,
createdAt: v.createdAt.toISOString(),
}));
return (
<div key={item.slug} className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold">{item.name}</h2>
<span className="rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
slug: {item.slug}
</span>
</div>
<Button
render={
<Link
href={`/admin/software/${item.sw.id}/versions/new`}
/>
}
>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">
{item.latestVersion
? `v${item.latestVersion.version}`
: "未发布"}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{item.versions.length}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{item.totalDownloads}</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="p-0">
<SoftwareVersionTable versions={serializedVersions} />
</CardContent>
</Card>
</div>
);
})}
</div>
);
}