feat: Banner UI美化 & 新增文章/公告/图库/媒体管理等功能

- Banner: Ken Burns缩放动效、左右导航箭头、进度条指示器、hover暂停、暗角遮罩、shimmer按钮动画
- 新增文章管理(CRUD)与公开文章页
- 新增Banner/Gallery图片管理API
- 新增媒体管理页面
- 新增更新日志页面
- 新增页面访问追踪
- 新增Markdown渲染组件
- .gitignore排除.cursor目录

Made-with: Cursor
This commit is contained in:
rucky
2026-03-25 09:17:35 +08:00
parent 241a76caeb
commit bf92a69332
66 changed files with 5241 additions and 155 deletions

View File

@@ -0,0 +1,33 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { ArticleEditor } from "@/components/admin/ArticleEditor";
export const dynamic = "force-dynamic";
export default async function EditArticlePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const article = await prisma.article.findUnique({ where: { id } });
if (!article) notFound();
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold"></h1>
<ArticleEditor
initial={{
id: article.id,
title: article.title,
slug: article.slug,
summary: article.summary,
content: article.content,
coverImage: article.coverImage,
published: article.published,
}}
/>
</div>
);
}

View File

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

View File

@@ -0,0 +1,73 @@
import Link from "next/link";
import { prisma } from "@/lib/db";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Plus } from "lucide-react";
import { ArticleActions } from "@/components/admin/ArticleActions";
export const dynamic = "force-dynamic";
export default async function ArticlesPage() {
const articles = await prisma.article.findMany({
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/articles/new" />}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{articles.length === 0 ? (
<p className="py-10 text-center text-muted-foreground">
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{articles.map((article) => (
<TableRow key={article.id}>
<TableCell className="font-medium">{article.title}</TableCell>
<TableCell>
<Badge variant={article.published ? "default" : "secondary"}>
{article.published ? "已发布" : "草稿"}
</Badge>
</TableCell>
<TableCell>
{new Date(article.createdAt).toLocaleDateString("zh-CN")}
</TableCell>
<TableCell>
{new Date(article.updatedAt).toLocaleDateString("zh-CN")}
</TableCell>
<TableCell className="text-right">
<ArticleActions id={article.id} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { prisma } from "@/lib/db";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { MediaManager } from "@/components/admin/MediaManager";
export const dynamic = "force-dynamic";
export default async function MediaPage() {
const [banners, gallery] = await Promise.all([
prisma.bannerImage.findMany({ orderBy: { sortOrder: "asc" } }),
prisma.galleryImage.findMany({ orderBy: { sortOrder: "asc" } }),
]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-muted-foreground">
Banner
</p>
</div>
<Tabs defaultValue="banner">
<TabsList>
<TabsTrigger value="banner">Banner </TabsTrigger>
<TabsTrigger value="gallery"></TabsTrigger>
</TabsList>
<TabsContent value="banner" className="mt-6">
<MediaManager type="banner" initial={JSON.parse(JSON.stringify(banners))} />
</TabsContent>
<TabsContent value="gallery" className="mt-6">
<MediaManager type="gallery" initial={JSON.parse(JSON.stringify(gallery))} />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -6,39 +6,70 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Package, Download, FileUp } from "lucide-react";
import { Package, Download, FileUp, Eye, Users, TrendingUp } from "lucide-react";
export const dynamic = "force-dynamic";
function getLast7Days() {
const days: string[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
days.push(d.toISOString().slice(0, 10));
}
return days;
}
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 today = new Date().toISOString().slice(0, 10);
const days = getLast7Days();
const [
addonCount,
totalDownloads,
releaseCount,
recentReleases,
todayPV,
totalPV,
todayUV,
pvByDay,
] = 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 } } },
}),
prisma.pageView.count({ where: { date: today } }),
prisma.pageView.count(),
prisma.pageView.groupBy({
by: ["ip"],
where: { date: today, ip: { not: "" } },
}).then((r) => r.length),
prisma.pageView.groupBy({
by: ["date"],
where: { date: { in: days } },
_count: true,
orderBy: { date: "asc" },
}),
]);
const pvMap = new Map(pvByDay.map((d) => [d.date, d._count]));
const chartData = days.map((d) => ({
date: d.slice(5),
pv: pvMap.get(d) || 0,
}));
const maxPV = Math.max(...chartData.map((d) => d.pv), 1);
const stats = [
{
title: "插件总数",
value: addonCount,
icon: Package,
},
{
title: "总下载量",
value: totalDownloads._sum.totalDownloads || 0,
icon: Download,
},
{
title: "版本发布数",
value: releaseCount,
icon: FileUp,
},
{ title: "插件总数", value: addonCount, icon: Package },
{ title: "总下载量", value: totalDownloads._sum.totalDownloads || 0, icon: Download },
{ title: "版本发布数", value: releaseCount, icon: FileUp },
{ title: "今日访问 (PV)", value: todayPV, icon: Eye },
{ title: "今日独立访客 (UV)", value: todayUV, icon: Users },
{ title: "累计访问量", value: totalPV, icon: TrendingUp },
];
return (
@@ -55,12 +86,42 @@ export default async function DashboardPage() {
<stat.icon className="h-5 w-5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{stat.value}</div>
<div className="text-3xl font-bold">
{stat.value.toLocaleString()}
</div>
</CardContent>
</Card>
))}
</div>
{/* 7-day PV chart */}
<Card>
<CardHeader>
<CardTitle> 7 访</CardTitle>
<CardDescription> (PV)</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-48 items-end gap-2">
{chartData.map((d) => (
<div key={d.date} className="flex flex-1 flex-col items-center gap-1">
<span className="text-xs font-medium text-muted-foreground">
{d.pv}
</span>
<div
className="w-full rounded-t bg-primary/80 transition-all"
style={{
height: `${Math.max((d.pv / maxPV) * 160, 4)}px`,
}}
/>
<span className="text-[10px] text-muted-foreground">
{d.date}
</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>