feat: Banner UI美化 & 新增文章/公告/图库/媒体管理等功能
- Banner: Ken Burns缩放动效、左右导航箭头、进度条指示器、hover暂停、暗角遮罩、shimmer按钮动画 - 新增文章管理(CRUD)与公开文章页 - 新增Banner/Gallery图片管理API - 新增媒体管理页面 - 新增更新日志页面 - 新增页面访问追踪 - 新增Markdown渲染组件 - .gitignore排除.cursor目录 Made-with: Cursor
This commit is contained in:
33
src/app/admin/(dashboard)/articles/[id]/edit/page.tsx
Normal file
33
src/app/admin/(dashboard)/articles/[id]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/app/admin/(dashboard)/articles/new/page.tsx
Normal file
10
src/app/admin/(dashboard)/articles/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src/app/admin/(dashboard)/articles/page.tsx
Normal file
73
src/app/admin/(dashboard)/articles/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/app/admin/(dashboard)/media/page.tsx
Normal file
38
src/app/admin/(dashboard)/media/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user