官网 初版
This commit is contained in:
23
src/app/admin/(dashboard)/addons/[id]/edit/page.tsx
Normal file
23
src/app/admin/(dashboard)/addons/[id]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/app/admin/(dashboard)/addons/new/page.tsx
Normal file
10
src/app/admin/(dashboard)/addons/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
src/app/admin/(dashboard)/addons/page.tsx
Normal file
87
src/app/admin/(dashboard)/addons/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/app/admin/(dashboard)/layout.tsx
Normal file
14
src/app/admin/(dashboard)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
src/app/admin/(dashboard)/page.tsx
Normal file
99
src/app/admin/(dashboard)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/app/admin/(dashboard)/releases/new/page.tsx
Normal file
24
src/app/admin/(dashboard)/releases/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
src/app/admin/(dashboard)/releases/page.tsx
Normal file
88
src/app/admin/(dashboard)/releases/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
src/app/admin/(dashboard)/settings/page.tsx
Normal file
93
src/app/admin/(dashboard)/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/app/admin/(dashboard)/software/[id]/edit/page.tsx
Normal file
26
src/app/admin/(dashboard)/software/[id]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
84
src/app/admin/(dashboard)/software/new/page.tsx
Normal file
84
src/app/admin/(dashboard)/software/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
src/app/admin/(dashboard)/software/page.tsx
Normal file
133
src/app/admin/(dashboard)/software/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user