官网 初版
This commit is contained in:
209
src/app/(public)/addons/[slug]/page.tsx
Normal file
209
src/app/(public)/addons/[slug]/page.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Download, Package, Calendar, Tag } from "lucide-react";
|
||||
import { DownloadButton } from "@/components/public/DownloadButton";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const addon = await prisma.addon.findUnique({
|
||||
where: { slug },
|
||||
select: { name: true, summary: true },
|
||||
});
|
||||
if (!addon) return { title: "Not Found" };
|
||||
return {
|
||||
title: `${addon.name} - Nanami`,
|
||||
description: addon.summary,
|
||||
};
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AddonDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const addon = await prisma.addon.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
releases: { orderBy: { createdAt: "desc" } },
|
||||
screenshots: { orderBy: { sortOrder: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!addon || !addon.published) notFound();
|
||||
|
||||
const latestRelease = addon.releases.find((r) => r.isLatest);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-12">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
|
||||
{addon.iconUrl ? (
|
||||
<img
|
||||
src={addon.iconUrl}
|
||||
alt={addon.name}
|
||||
className="h-20 w-20 rounded-2xl object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-primary/10">
|
||||
<Package className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold">{addon.name}</h1>
|
||||
<p className="mt-2 text-lg text-muted-foreground">{addon.summary}</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<Badge>{addon.category}</Badge>
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{addon.totalDownloads.toLocaleString()} 次下载
|
||||
</span>
|
||||
{latestRelease && (
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Tag className="h-3.5 w-3.5" />
|
||||
v{latestRelease.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{latestRelease && (
|
||||
<div className="shrink-0">
|
||||
<DownloadButton
|
||||
releaseId={latestRelease.id}
|
||||
version={latestRelease.version}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="my-8" />
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-3">
|
||||
{/* Description */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>介绍</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="prose prose-neutral max-w-none dark:prose-invert">
|
||||
<MarkdownContent content={addon.description} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Screenshots */}
|
||||
{addon.screenshots.length > 0 && (
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>截图</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{addon.screenshots.map((ss) => (
|
||||
<img
|
||||
key={ss.id}
|
||||
src={ss.imageUrl}
|
||||
alt="Screenshot"
|
||||
className="rounded-lg border object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar - Releases */}
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>版本历史</CardTitle>
|
||||
<CardDescription>
|
||||
共 {addon.releases.length} 个版本
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{addon.releases.map((release) => (
|
||||
<div
|
||||
key={release.id}
|
||||
className="rounded-lg border p-4 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">v{release.version}</span>
|
||||
{release.isLatest && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
最新
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<DownloadButton
|
||||
releaseId={release.id}
|
||||
version={release.version}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
{release.gameVersion && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
WoW {release.gameVersion}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(release.createdAt).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Download className="h-3 w-3" />
|
||||
{release.downloadCount}
|
||||
</span>
|
||||
</div>
|
||||
{release.changelog && (
|
||||
<p className="mt-2 text-sm text-muted-foreground whitespace-pre-line">
|
||||
{release.changelog}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{addon.releases.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">暂无版本发布</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownContent({ content }: { content: string }) {
|
||||
const html = content
|
||||
.replace(/^### (.*$)/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
|
||||
.replace(/^## (.*$)/gm, '<h2 class="text-xl font-semibold mt-6 mb-3">$1</h2>')
|
||||
.replace(/^# (.*$)/gm, '<h1 class="text-2xl font-bold mt-6 mb-3">$1</h1>')
|
||||
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/\*(.*?)\*/g, "<em>$1</em>")
|
||||
.replace(/`(.*?)`/g, '<code class="rounded bg-muted px-1.5 py-0.5 text-sm">$1</code>')
|
||||
.replace(/^- (.*$)/gm, '<li class="ml-4 list-disc">$1</li>')
|
||||
.replace(/\n\n/g, '<br/><br/>')
|
||||
.replace(/\n/g, "<br/>");
|
||||
|
||||
return <div dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
101
src/app/(public)/addons/page.tsx
Normal file
101
src/app/(public)/addons/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { AddonCard } from "@/components/public/AddonCard";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import Link from "next/link";
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
general: "通用",
|
||||
gameplay: "游戏玩法",
|
||||
ui: "界面增强",
|
||||
combat: "战斗",
|
||||
raid: "团队副本",
|
||||
pvp: "PvP",
|
||||
tradeskill: "专业技能",
|
||||
utility: "实用工具",
|
||||
};
|
||||
|
||||
export const metadata = {
|
||||
title: "插件列表 - Nanami",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AddonsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ category?: string; search?: string }>;
|
||||
}) {
|
||||
const { category, search } = await searchParams;
|
||||
|
||||
const where: Record<string, unknown> = { published: true };
|
||||
if (category) where.category = category;
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ name: { contains: search, mode: "insensitive" } },
|
||||
{ summary: { contains: search, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
const addons = await prisma.addon.findMany({
|
||||
where,
|
||||
include: {
|
||||
releases: {
|
||||
where: { isLatest: true },
|
||||
select: { version: true },
|
||||
},
|
||||
},
|
||||
orderBy: { totalDownloads: "desc" },
|
||||
});
|
||||
|
||||
const categories = await prisma.addon.groupBy({
|
||||
by: ["category"],
|
||||
where: { published: true },
|
||||
_count: { id: true },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-12">
|
||||
<h1 className="text-3xl font-bold">插件列表</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
浏览和下载 World of Warcraft 插件
|
||||
</p>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
<Link href="/addons">
|
||||
<Badge
|
||||
variant={!category ? "default" : "outline"}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
全部
|
||||
</Badge>
|
||||
</Link>
|
||||
{categories.map((cat) => (
|
||||
<Link key={cat.category} href={`/addons?category=${cat.category}`}>
|
||||
<Badge
|
||||
variant={category === cat.category ? "default" : "outline"}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{categoryLabels[cat.category] || cat.category} ({cat._count.id})
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Addon Grid */}
|
||||
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{addons.map((addon) => (
|
||||
<AddonCard key={addon.id} addon={addon} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{addons.length === 0 && (
|
||||
<div className="mt-16 text-center">
|
||||
<p className="text-lg text-muted-foreground">
|
||||
{search ? `没有找到"${search}"相关的插件` : "暂无插件"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/app/(public)/layout.tsx
Normal file
16
src/app/(public)/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Navbar } from "@/components/public/Navbar";
|
||||
import { Footer } from "@/components/public/Footer";
|
||||
|
||||
export default function PublicLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="dark flex min-h-screen flex-col bg-[#0d0b15]">
|
||||
<Navbar />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
src/app/(public)/page.tsx
Normal file
110
src/app/(public)/page.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AddonCard } from "@/components/public/AddonCard";
|
||||
import { HeroBanner } from "@/components/public/HeroBanner";
|
||||
import { Sparkles, Shield, Zap } from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function HomePage() {
|
||||
const [featuredAddons, totalDownloads, launcher] = await Promise.all([
|
||||
prisma.addon.findMany({
|
||||
where: { published: true },
|
||||
include: {
|
||||
releases: {
|
||||
where: { isLatest: true },
|
||||
select: { version: true },
|
||||
},
|
||||
},
|
||||
orderBy: { totalDownloads: "desc" },
|
||||
take: 6,
|
||||
}),
|
||||
prisma.addon.aggregate({
|
||||
_sum: { totalDownloads: true },
|
||||
}),
|
||||
prisma.software.findUnique({
|
||||
where: { slug: "nanami-launcher" },
|
||||
include: {
|
||||
versions: {
|
||||
where: { isLatest: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const launcherVersion = launcher?.versions[0]?.version ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeroBanner
|
||||
totalDownloads={totalDownloads._sum.totalDownloads ?? undefined}
|
||||
launcherVersion={launcherVersion}
|
||||
/>
|
||||
|
||||
{/* Features */}
|
||||
<section className="relative border-t border-amber-900/20 bg-gradient-to-b from-[#0d0b15] to-[#110f1a] dark:from-[#0d0b15] dark:to-[#110f1a]">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(168,85,247,0.06)_0%,transparent_70%)]" />
|
||||
<div className="relative mx-auto max-w-6xl px-4 py-16">
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
<div className="flex flex-col items-center rounded-xl border border-amber-500/10 bg-white/5 p-6 text-center backdrop-blur transition-colors hover:border-amber-500/25">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-amber-500/10">
|
||||
<Sparkles className="h-6 w-6 text-amber-400" />
|
||||
</div>
|
||||
<h3 className="mt-4 font-semibold text-amber-100">深度适配</h3>
|
||||
<p className="mt-2 text-sm text-gray-400">
|
||||
专为乌龟服 1.18.0 打造,兼容自定义内容与新种族,稳定流畅
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center rounded-xl border border-amber-500/10 bg-white/5 p-6 text-center backdrop-blur transition-colors hover:border-amber-500/25">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-500/10">
|
||||
<Shield className="h-6 w-6 text-purple-400" />
|
||||
</div>
|
||||
<h3 className="mt-4 font-semibold text-amber-100">
|
||||
一键安装管理
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-gray-400">
|
||||
通过 Nanami 启动器自动安装、更新,告别手动拖拽文件夹
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center rounded-xl border border-amber-500/10 bg-white/5 p-6 text-center backdrop-blur transition-colors hover:border-amber-500/25">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-cyan-500/10">
|
||||
<Zap className="h-6 w-6 text-cyan-400" />
|
||||
</div>
|
||||
<h3 className="mt-4 font-semibold text-amber-100">
|
||||
内置 AI 翻译
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-gray-400">
|
||||
自带智能翻译引擎,轻松畅玩英文服务器,语言不再是障碍
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Addons */}
|
||||
{featuredAddons.length > 0 && (
|
||||
<section className="border-t border-amber-900/20 bg-gradient-to-b from-[#110f1a] to-[#0d0b15] dark:from-[#110f1a] dark:to-[#0d0b15]">
|
||||
<div className="mx-auto max-w-6xl px-4 py-16">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-amber-100">热门插件</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-amber-500/20 text-amber-200 hover:border-amber-500/40 hover:bg-amber-500/10 hover:text-amber-100"
|
||||
render={<Link href="/addons" />}
|
||||
>
|
||||
查看全部
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{featuredAddons.map((addon) => (
|
||||
<AddonCard key={addon.id} addon={addon} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
11
src/app/admin/layout.tsx
Normal file
11
src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export const metadata = {
|
||||
title: "管理后台 - Nanami",
|
||||
};
|
||||
|
||||
export default function AdminRootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
7
src/app/admin/login/layout.tsx
Normal file
7
src/app/admin/login/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function LoginLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
84
src/app/admin/login/page.tsx
Normal file
84
src/app/admin/login/page.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { signIn } from "next-auth/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 {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const result = await signIn("credentials", {
|
||||
username: formData.get("username"),
|
||||
password: formData.get("password"),
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setError("用户名或密码错误");
|
||||
setLoading(false);
|
||||
} else {
|
||||
router.push("/admin");
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/40">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">管理后台</CardTitle>
|
||||
<CardDescription>请输入管理员账号登录</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<Input
|
||||
id="username"
|
||||
name="username"
|
||||
required
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/app/api/addons/[id]/route.ts
Normal file
82
src/app/api/addons/[id]/route.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
const addon = await prisma.addon.findFirst({
|
||||
where: { OR: [{ id }, { slug: id }] },
|
||||
include: {
|
||||
releases: { orderBy: { createdAt: "desc" } },
|
||||
screenshots: { orderBy: { sortOrder: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!addon) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(addon);
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { name, slug, summary, description, iconUrl, category, published } =
|
||||
body;
|
||||
|
||||
if (slug) {
|
||||
const existing = await prisma.addon.findFirst({
|
||||
where: { slug, NOT: { id } },
|
||||
});
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "Slug already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const addon = await prisma.addon.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(name !== undefined && { name }),
|
||||
...(slug !== undefined && { slug }),
|
||||
...(summary !== undefined && { summary }),
|
||||
...(description !== undefined && { description }),
|
||||
...(iconUrl !== undefined && { iconUrl }),
|
||||
...(category !== undefined && { category }),
|
||||
...(published !== undefined && { published }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(addon);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
await prisma.addon.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
72
src/app/api/addons/route.ts
Normal file
72
src/app/api/addons/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const category = searchParams.get("category");
|
||||
const search = searchParams.get("search");
|
||||
const publishedOnly = searchParams.get("published") !== "false";
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (publishedOnly) where.published = true;
|
||||
if (category) where.category = category;
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ name: { contains: search, mode: "insensitive" } },
|
||||
{ summary: { contains: search, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
const addons = await prisma.addon.findMany({
|
||||
where,
|
||||
include: {
|
||||
releases: {
|
||||
where: { isLatest: true },
|
||||
take: 1,
|
||||
},
|
||||
_count: { select: { releases: true } },
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
|
||||
return NextResponse.json(addons);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, slug, summary, description, iconUrl, category } = body;
|
||||
|
||||
if (!name || !slug || !summary) {
|
||||
return NextResponse.json(
|
||||
{ error: "name, slug, summary are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await prisma.addon.findUnique({ where: { slug } });
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "Slug already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const addon = await prisma.addon.create({
|
||||
data: {
|
||||
name,
|
||||
slug,
|
||||
summary,
|
||||
description: description || "",
|
||||
iconUrl: iconUrl || null,
|
||||
category: category || "general",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(addon, { status: 201 });
|
||||
}
|
||||
48
src/app/api/admin/change-password/route.ts
Normal file
48
src/app/api/admin/change-password/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = await request.json();
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: "请填写当前密码和新密码" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
return NextResponse.json(
|
||||
{ error: "新密码长度不能少于6位" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const admin = await prisma.admin.findFirst({
|
||||
where: { username: session.user.name! },
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(currentPassword, admin.passwordHash);
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: "当前密码错误" }, { status: 403 });
|
||||
}
|
||||
|
||||
const newHash = await bcrypt.hash(newPassword, 12);
|
||||
await prisma.admin.update({
|
||||
where: { id: admin.id },
|
||||
data: { passwordHash: newHash },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
60
src/app/api/download/[id]/route.ts
Normal file
60
src/app/api/download/[id]/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { readFile, stat } from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
const release = await prisma.release.findUnique({
|
||||
where: { id },
|
||||
include: { addon: { select: { name: true, slug: true } } },
|
||||
});
|
||||
|
||||
if (!release) {
|
||||
return NextResponse.json({ error: "Release not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.release.update({
|
||||
where: { id },
|
||||
data: { downloadCount: { increment: 1 } },
|
||||
});
|
||||
|
||||
await prisma.addon.update({
|
||||
where: { id: release.addonId },
|
||||
data: { totalDownloads: { increment: 1 } },
|
||||
});
|
||||
|
||||
if (release.downloadType === "url" && release.externalUrl) {
|
||||
return NextResponse.redirect(release.externalUrl);
|
||||
}
|
||||
|
||||
if (release.filePath) {
|
||||
const filePath = path.join(process.cwd(), release.filePath);
|
||||
|
||||
try {
|
||||
await stat(filePath);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const fileBuffer = await readFile(filePath);
|
||||
const fileName = `${release.addon.slug}-${release.version}${path.extname(release.filePath)}`;
|
||||
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${fileName}"`,
|
||||
"Content-Length": fileBuffer.length.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "No download source available" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
76
src/app/api/releases/route.ts
Normal file
76
src/app/api/releases/route.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const addonId = searchParams.get("addonId");
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (addonId) where.addonId = addonId;
|
||||
|
||||
const releases = await prisma.release.findMany({
|
||||
where,
|
||||
include: { addon: { select: { id: true, name: true, slug: true } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return NextResponse.json(releases);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
addonId,
|
||||
version,
|
||||
changelog,
|
||||
downloadType,
|
||||
filePath,
|
||||
externalUrl,
|
||||
gameVersion,
|
||||
} = body;
|
||||
|
||||
if (!addonId || !version) {
|
||||
return NextResponse.json(
|
||||
{ error: "addonId and version are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (downloadType === "url" && !externalUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "externalUrl is required for url type" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const addon = await prisma.addon.findUnique({ where: { id: addonId } });
|
||||
if (!addon) {
|
||||
return NextResponse.json({ error: "Addon not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.release.updateMany({
|
||||
where: { addonId, isLatest: true },
|
||||
data: { isLatest: false },
|
||||
});
|
||||
|
||||
const release = await prisma.release.create({
|
||||
data: {
|
||||
addonId,
|
||||
version,
|
||||
changelog: changelog || "",
|
||||
downloadType: downloadType || "local",
|
||||
filePath: filePath || null,
|
||||
externalUrl: externalUrl || null,
|
||||
gameVersion: gameVersion || "",
|
||||
isLatest: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(release, { status: 201 });
|
||||
}
|
||||
61
src/app/api/software/[id]/route.ts
Normal file
61
src/app/api/software/[id]/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
const software = await prisma.software.findFirst({
|
||||
where: { OR: [{ id }, { slug: id }] },
|
||||
include: {
|
||||
versions: { orderBy: { versionCode: "desc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!software) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(software);
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
const software = await prisma.software.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.name !== undefined && { name: body.name }),
|
||||
...(body.slug !== undefined && { slug: body.slug }),
|
||||
...(body.description !== undefined && { description: body.description }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(software);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
await prisma.software.delete({ where: { id } });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
67
src/app/api/software/[id]/versions/route.ts
Normal file
67
src/app/api/software/[id]/versions/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: softwareId } = await params;
|
||||
const body = await request.json();
|
||||
const {
|
||||
version,
|
||||
versionCode,
|
||||
changelog,
|
||||
downloadType,
|
||||
filePath,
|
||||
externalUrl,
|
||||
fileSize,
|
||||
forceUpdate,
|
||||
minVersion,
|
||||
} = body;
|
||||
|
||||
if (!version || !versionCode) {
|
||||
return NextResponse.json(
|
||||
{ error: "version and versionCode are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const software = await prisma.software.findUnique({
|
||||
where: { id: softwareId },
|
||||
});
|
||||
if (!software) {
|
||||
return NextResponse.json(
|
||||
{ error: "Software not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.softwareVersion.updateMany({
|
||||
where: { softwareId, isLatest: true },
|
||||
data: { isLatest: false },
|
||||
});
|
||||
|
||||
const sv = await prisma.softwareVersion.create({
|
||||
data: {
|
||||
softwareId,
|
||||
version,
|
||||
versionCode: Number(versionCode),
|
||||
changelog: changelog || "",
|
||||
downloadType: downloadType || "local",
|
||||
filePath: filePath || null,
|
||||
externalUrl: externalUrl || null,
|
||||
fileSize: Number(fileSize) || 0,
|
||||
forceUpdate: forceUpdate || false,
|
||||
minVersion: minVersion || null,
|
||||
isLatest: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(sv, { status: 201 });
|
||||
}
|
||||
57
src/app/api/software/check-update/route.ts
Normal file
57
src/app/api/software/check-update/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const slug = searchParams.get("slug");
|
||||
const currentVersionCode = searchParams.get("versionCode");
|
||||
|
||||
if (!slug) {
|
||||
return NextResponse.json(
|
||||
{ error: "slug parameter is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const software = await prisma.software.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
versions: {
|
||||
where: { isLatest: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!software || software.versions.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Software or latest version not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const latest = software.versions[0];
|
||||
const clientVersionCode = currentVersionCode
|
||||
? parseInt(currentVersionCode, 10)
|
||||
: 0;
|
||||
|
||||
const hasUpdate = latest.versionCode > clientVersionCode;
|
||||
|
||||
const downloadUrl = latest.downloadType === "url" && latest.externalUrl
|
||||
? latest.externalUrl
|
||||
: `${request.nextUrl.origin}/api/software/download/${latest.id}`;
|
||||
|
||||
return NextResponse.json({
|
||||
hasUpdate,
|
||||
forceUpdate: hasUpdate && latest.forceUpdate,
|
||||
latest: {
|
||||
version: latest.version,
|
||||
versionCode: latest.versionCode,
|
||||
changelog: latest.changelog,
|
||||
downloadUrl,
|
||||
fileSize: latest.fileSize,
|
||||
minVersion: latest.minVersion,
|
||||
createdAt: latest.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
58
src/app/api/software/download/[id]/route.ts
Normal file
58
src/app/api/software/download/[id]/route.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { readFile, stat } from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
const sv = await prisma.softwareVersion.findUnique({
|
||||
where: { id },
|
||||
include: { software: { select: { slug: true } } },
|
||||
});
|
||||
|
||||
if (!sv) {
|
||||
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.softwareVersion.update({
|
||||
where: { id },
|
||||
data: { downloadCount: { increment: 1 } },
|
||||
});
|
||||
|
||||
if (sv.downloadType === "url" && sv.externalUrl) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: sv.externalUrl },
|
||||
});
|
||||
}
|
||||
|
||||
if (sv.filePath) {
|
||||
const filePath = path.join(process.cwd(), sv.filePath);
|
||||
try {
|
||||
await stat(filePath);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const fileBuffer = await readFile(filePath);
|
||||
const ext = path.extname(sv.filePath);
|
||||
const fileName = `${sv.software.slug}-v${sv.version}${ext}`;
|
||||
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${fileName}"`,
|
||||
"Content-Length": fileBuffer.length.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "No download source available" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
85
src/app/api/software/latest/route.ts
Normal file
85
src/app/api/software/latest/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { readFile, stat } from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
const LAUNCHER_SLUG = "nanami-launcher";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const infoOnly = searchParams.get("info") === "1";
|
||||
|
||||
const software = await prisma.software.findUnique({
|
||||
where: { slug: LAUNCHER_SLUG },
|
||||
include: {
|
||||
versions: {
|
||||
where: { isLatest: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!software || software.versions.length === 0) {
|
||||
if (infoOnly) {
|
||||
return NextResponse.json({ available: false });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "暂无可下载版本" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const latest = software.versions[0];
|
||||
|
||||
if (infoOnly) {
|
||||
const downloadUrl =
|
||||
latest.downloadType === "url" && latest.externalUrl
|
||||
? latest.externalUrl
|
||||
: `/api/software/download/${latest.id}`;
|
||||
return NextResponse.json({
|
||||
available: true,
|
||||
version: latest.version,
|
||||
versionCode: latest.versionCode,
|
||||
changelog: latest.changelog,
|
||||
fileSize: latest.fileSize,
|
||||
createdAt: latest.createdAt,
|
||||
downloadUrl,
|
||||
downloadType: latest.downloadType,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.softwareVersion.update({
|
||||
where: { id: latest.id },
|
||||
data: { downloadCount: { increment: 1 } },
|
||||
});
|
||||
|
||||
if (latest.downloadType === "url" && latest.externalUrl) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: latest.externalUrl },
|
||||
});
|
||||
}
|
||||
|
||||
if (latest.filePath) {
|
||||
const filePath = path.join(process.cwd(), latest.filePath);
|
||||
try {
|
||||
await stat(filePath);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "文件不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const fileBuffer = await readFile(filePath);
|
||||
const ext = path.extname(latest.filePath);
|
||||
const fileName = `nanami-launcher-v${latest.version}${ext}`;
|
||||
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${fileName}"`,
|
||||
"Content-Length": fileBuffer.length.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "无下载来源" }, { status: 404 });
|
||||
}
|
||||
47
src/app/api/software/route.ts
Normal file
47
src/app/api/software/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export async function GET() {
|
||||
const software = await prisma.software.findMany({
|
||||
include: {
|
||||
versions: {
|
||||
where: { isLatest: true },
|
||||
take: 1,
|
||||
},
|
||||
_count: { select: { versions: true } },
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
return NextResponse.json(software);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { name, slug, description } = await request.json();
|
||||
|
||||
if (!name || !slug) {
|
||||
return NextResponse.json(
|
||||
{ error: "name and slug are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await prisma.software.findUnique({ where: { slug } });
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "Slug already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const software = await prisma.software.create({
|
||||
data: { name, slug, description: description || "" },
|
||||
});
|
||||
|
||||
return NextResponse.json(software, { status: 201 });
|
||||
}
|
||||
71
src/app/api/software/versions/[versionId]/route.ts
Normal file
71
src/app/api/software/versions/[versionId]/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ versionId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { versionId } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
const existing = await prisma.softwareVersion.findUnique({
|
||||
where: { id: versionId },
|
||||
});
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const setLatest = body.isLatest === true && !existing.isLatest;
|
||||
if (setLatest) {
|
||||
await prisma.softwareVersion.updateMany({
|
||||
where: { softwareId: existing.softwareId, isLatest: true },
|
||||
data: { isLatest: false },
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await prisma.softwareVersion.update({
|
||||
where: { id: versionId },
|
||||
data: {
|
||||
...(body.version !== undefined && { version: body.version }),
|
||||
...(body.versionCode !== undefined && {
|
||||
versionCode: Number(body.versionCode),
|
||||
}),
|
||||
...(body.changelog !== undefined && { changelog: body.changelog }),
|
||||
...(body.downloadType !== undefined && {
|
||||
downloadType: body.downloadType,
|
||||
}),
|
||||
...(body.filePath !== undefined && { filePath: body.filePath || null }),
|
||||
...(body.externalUrl !== undefined && {
|
||||
externalUrl: body.externalUrl || null,
|
||||
}),
|
||||
...(body.fileSize !== undefined && { fileSize: Number(body.fileSize) }),
|
||||
...(body.forceUpdate !== undefined && { forceUpdate: body.forceUpdate }),
|
||||
...(body.minVersion !== undefined && {
|
||||
minVersion: body.minVersion || null,
|
||||
}),
|
||||
...(body.isLatest !== undefined && { isLatest: body.isLatest }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ versionId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { versionId } = await params;
|
||||
await prisma.softwareVersion.delete({ where: { id: versionId } });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
37
src/app/api/upload/route.ts
Normal file
37
src/app/api/upload/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { writeFile, mkdir } from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
const timestamp = Date.now();
|
||||
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
const filename = `${timestamp}-${safeName}`;
|
||||
const uploadDir = path.join(process.cwd(), "uploads");
|
||||
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
|
||||
const filepath = path.join(uploadDir, filename);
|
||||
await writeFile(filepath, buffer);
|
||||
|
||||
return NextResponse.json({
|
||||
filePath: `/uploads/${filename}`,
|
||||
originalName: file.name,
|
||||
size: file.size,
|
||||
});
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 9.4 KiB |
@@ -7,8 +7,8 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-sans: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: "SF Mono", "Cascadia Code", "Menlo", "Consolas", monospace;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -126,4 +126,71 @@
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Hero Aurora Effects ---- */
|
||||
.hero-aurora-wrapper {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.hero-aurora {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 80px;
|
||||
z-index: 30;
|
||||
pointer-events: none;
|
||||
filter: blur(30px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.hero-aurora--top {
|
||||
top: -30px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(168, 85, 247, 0.4) 15%,
|
||||
rgba(59, 130, 246, 0.3) 30%,
|
||||
rgba(236, 72, 153, 0.35) 50%,
|
||||
rgba(139, 92, 246, 0.4) 70%,
|
||||
rgba(6, 182, 212, 0.3) 85%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: auroraShift 8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.hero-aurora--bottom {
|
||||
bottom: -30px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(245, 158, 11, 0.35) 15%,
|
||||
rgba(168, 85, 247, 0.3) 35%,
|
||||
rgba(6, 182, 212, 0.35) 55%,
|
||||
rgba(236, 72, 153, 0.3) 75%,
|
||||
rgba(245, 158, 11, 0.35) 90%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: auroraShift 8s ease-in-out infinite alternate-reverse;
|
||||
}
|
||||
|
||||
@keyframes auroraShift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
background-position: 100% 50%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Force background-size for aurora animation */
|
||||
.hero-aurora--top,
|
||||
.hero-aurora--bottom {
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
@@ -1,20 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Nanami - WoW Addons",
|
||||
description: "World of Warcraft 插件发布与下载平台",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,11 +14,12 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<body className="antialiased">
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user