270 lines
8.6 KiB
TypeScript
270 lines
8.6 KiB
TypeScript
import { cookies } from "next/headers";
|
||
import { AdminPostList } from "@/components/AdminPostList";
|
||
import { AdminUserManager } from "@/components/AdminUserManager";
|
||
import { CreatePostForm } from "@/components/CreatePostForm";
|
||
import { SocialLinksManager } from "@/components/SocialLinksManager";
|
||
import { cookieName, isAdminSession, verifySession } from "@/lib/auth";
|
||
import { getDb } from "@/lib/mongo";
|
||
import { buildOwnedPostFilter, buildPinnedSort, serializePost } from "@/lib/posts";
|
||
import { getDefaultSocialLinks, getSiteSettings } from "@/lib/site-settings";
|
||
import { findUserById, getEffectiveDailyPostLimit, getShanghaiDayRange } from "@/lib/users";
|
||
import { Post } from "@/types/post";
|
||
|
||
export const dynamic = "force-dynamic";
|
||
|
||
type ManagedUser = {
|
||
id: string;
|
||
username: string;
|
||
displayName: string;
|
||
role: "user" | "sponsor" | "admin";
|
||
dailyPostLimit: number;
|
||
postCount: number;
|
||
todayPostCount: number;
|
||
posts: Array<{ slug: string; title: string; createdAt: string; isPinned?: boolean }>;
|
||
};
|
||
|
||
const ROLE_LABELS: Record<ManagedUser["role"], string> = {
|
||
user: "普通",
|
||
sponsor: "赞助",
|
||
admin: "管理员"
|
||
};
|
||
|
||
async function fetchRecentPosts(session: Awaited<ReturnType<typeof verifySession>>) {
|
||
const db = await getDb();
|
||
const posts = await db
|
||
.collection("posts")
|
||
.find(buildOwnedPostFilter(session), { projection: { markdown: 0 } })
|
||
.sort(buildPinnedSort())
|
||
.limit(20)
|
||
.toArray();
|
||
|
||
return posts.map((post: any) => ({
|
||
...serializePost(post),
|
||
createdAtText: new Date(post.createdAt).toLocaleString("zh-CN", {
|
||
hour12: false,
|
||
timeZone: "Asia/Shanghai"
|
||
})
|
||
}));
|
||
}
|
||
|
||
async function fetchFavoritePosts(session: Awaited<ReturnType<typeof verifySession>>): Promise<Post[]> {
|
||
if (!session?.uid) {
|
||
return [];
|
||
}
|
||
|
||
const db = await getDb();
|
||
const favorites = await db
|
||
.collection("favorites")
|
||
.find({ ownerId: session.uid }, { projection: { postSlug: 1, createdAt: 1 } })
|
||
.sort({ createdAt: -1 })
|
||
.limit(20)
|
||
.toArray();
|
||
|
||
const slugs = favorites.map((item: any) => item.postSlug).filter(Boolean);
|
||
if (slugs.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const posts = await db
|
||
.collection("posts")
|
||
.find({ slug: { $in: slugs } }, { projection: { markdown: 0 } })
|
||
.toArray();
|
||
|
||
const postMap = new Map(posts.map((post: any) => [post.slug, serializePost(post)]));
|
||
return slugs.map((slug) => postMap.get(slug)).filter(Boolean) as Post[];
|
||
}
|
||
|
||
async function fetchAvailableTags(session: Awaited<ReturnType<typeof verifySession>>) {
|
||
const db = await getDb();
|
||
const tags = await db
|
||
.collection("posts")
|
||
.aggregate([
|
||
{ $match: buildOwnedPostFilter(session) },
|
||
{ $unwind: "$tags" },
|
||
{ $group: { _id: "$tags", count: { $sum: 1 } } },
|
||
{ $sort: { count: -1, _id: 1 } }
|
||
])
|
||
.toArray();
|
||
|
||
return tags.map((item: any) => item._id).filter(Boolean);
|
||
}
|
||
|
||
async function fetchPublishQuota(session: Awaited<ReturnType<typeof verifySession>>) {
|
||
const user = await findUserById(session?.uid);
|
||
const limit = getEffectiveDailyPostLimit(user || undefined);
|
||
const { startIso, endIso } = getShanghaiDayRange();
|
||
const todayCount = session?.uid
|
||
? await getDb().then((db) =>
|
||
db.collection("posts").countDocuments({
|
||
ownerId: session.uid,
|
||
createdAt: { $gte: startIso, $lt: endIso }
|
||
})
|
||
)
|
||
: 0;
|
||
|
||
return { limit, todayCount };
|
||
}
|
||
|
||
async function fetchManagedUsers(): Promise<ManagedUser[]> {
|
||
const db = await getDb();
|
||
const { startIso, endIso } = getShanghaiDayRange();
|
||
const users = await db
|
||
.collection("users")
|
||
.find(
|
||
{},
|
||
{
|
||
projection: {
|
||
username: 1,
|
||
displayName: 1,
|
||
role: 1,
|
||
dailyPostLimit: 1
|
||
}
|
||
}
|
||
)
|
||
.sort({ createdAt: 1 })
|
||
.toArray();
|
||
|
||
const posts = await db
|
||
.collection("posts")
|
||
.find(
|
||
{},
|
||
{
|
||
projection: {
|
||
slug: 1,
|
||
title: 1,
|
||
createdAt: 1,
|
||
ownerId: 1,
|
||
author: 1,
|
||
isPinned: 1
|
||
}
|
||
}
|
||
)
|
||
.sort(buildPinnedSort())
|
||
.toArray();
|
||
|
||
const authorToUserId = new Map<string, string>();
|
||
users.forEach((user: any) => {
|
||
const id = user._id?.toString();
|
||
if (!id) return;
|
||
if (user.username) authorToUserId.set(String(user.username).trim().toLowerCase(), id);
|
||
if (user.displayName) authorToUserId.set(String(user.displayName).trim().toLowerCase(), id);
|
||
});
|
||
|
||
const postCountMap = new Map<string, number>();
|
||
const todayCountMap = new Map<string, number>();
|
||
const postsByOwner = new Map<
|
||
string,
|
||
Array<{ slug: string; title: string; createdAt: string; isPinned?: boolean }>
|
||
>();
|
||
|
||
posts.forEach((post: any) => {
|
||
const resolvedOwnerId =
|
||
post.ownerId || authorToUserId.get(String(post.author || "").trim().toLowerCase());
|
||
if (!resolvedOwnerId) return;
|
||
|
||
const list = postsByOwner.get(resolvedOwnerId) ?? [];
|
||
list.push({
|
||
slug: post.slug,
|
||
title: post.title ?? "未命名",
|
||
createdAt: post.createdAt ?? new Date().toISOString(),
|
||
isPinned: Boolean(post.isPinned)
|
||
});
|
||
postsByOwner.set(resolvedOwnerId, list);
|
||
postCountMap.set(resolvedOwnerId, (postCountMap.get(resolvedOwnerId) ?? 0) + 1);
|
||
|
||
if (post.createdAt >= startIso && post.createdAt < endIso) {
|
||
todayCountMap.set(resolvedOwnerId, (todayCountMap.get(resolvedOwnerId) ?? 0) + 1);
|
||
}
|
||
});
|
||
|
||
return users
|
||
.map((user: any) => {
|
||
const id = user._id?.toString();
|
||
if (!id) return null;
|
||
|
||
return {
|
||
id,
|
||
username: user.username ?? "",
|
||
displayName: user.displayName ?? user.username ?? "",
|
||
role:
|
||
user.role === "admin" || user.role === "sponsor" || user.role === "user"
|
||
? user.role
|
||
: "user",
|
||
dailyPostLimit: getEffectiveDailyPostLimit(user),
|
||
postCount: postCountMap.get(id) ?? 0,
|
||
todayPostCount: todayCountMap.get(id) ?? 0,
|
||
posts: postsByOwner.get(id) ?? []
|
||
};
|
||
})
|
||
.filter(Boolean) as ManagedUser[];
|
||
}
|
||
|
||
export default async function AdminPage() {
|
||
const token = cookies().get(cookieName)?.value;
|
||
const session = await verifySession(token);
|
||
const adminView = isAdminSession(session);
|
||
const roleLabel = ROLE_LABELS[(session?.role as ManagedUser["role"]) || "user"];
|
||
|
||
const [recentPosts, favoritePosts, availableTags, publishQuota, managedUsers, siteSettings] =
|
||
await Promise.all([
|
||
fetchRecentPosts(session),
|
||
fetchFavoritePosts(session),
|
||
fetchAvailableTags(session),
|
||
fetchPublishQuota(session),
|
||
adminView ? fetchManagedUsers() : Promise.resolve([] as ManagedUser[]),
|
||
adminView ? getSiteSettings() : Promise.resolve({ socialLinks: getDefaultSocialLinks() })
|
||
]);
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<section className="rounded-2xl bg-white/80 p-5 shadow-sm ring-1 ring-slate-100">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="space-y-2">
|
||
<h1 className="text-2xl font-semibold text-slate-900">内容后台</h1>
|
||
<p className="text-sm text-slate-500">
|
||
登录用户可以发布、编辑自己的内容和管理自己的收藏;管理员额外拥有置顶、删帖、删用户、调整用户等级/额度,以及维护首页社交链接的全部权限。
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<span className="rounded-full bg-slate-100 px-3 py-1 text-sm text-slate-700 ring-1 ring-slate-200">
|
||
{session?.name || "未登录"} · {roleLabel}
|
||
</span>
|
||
<a
|
||
href="/stats"
|
||
className="rounded-full bg-brand-50 px-4 py-2 text-sm font-medium text-brand-700 ring-1 ring-brand-100 hover:bg-brand-100"
|
||
>
|
||
查看统计
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{adminView ? <SocialLinksManager initialLinks={siteSettings.socialLinks} /> : null}
|
||
|
||
<CreatePostForm
|
||
availableTags={availableTags}
|
||
publishLimit={publishQuota.limit}
|
||
todayCount={publishQuota.todayCount}
|
||
/>
|
||
|
||
<AdminPostList
|
||
initialPosts={recentPosts}
|
||
title="我的内容"
|
||
description="你只能编辑自己的内容;管理员可在这里快速置顶或删除自己的内容。"
|
||
canDelete={adminView}
|
||
canPin={adminView}
|
||
/>
|
||
|
||
<AdminPostList
|
||
initialPosts={favoritePosts}
|
||
title="我的收藏"
|
||
description="收藏仅自己可见,方便回看喜欢的内容。"
|
||
emptyText="你还没有收藏任何内容。"
|
||
showEdit={false}
|
||
/>
|
||
|
||
{adminView ? <AdminUserManager initialUsers={managedUsers} currentUserId={session?.uid || ""} /> : null}
|
||
</div>
|
||
);
|
||
}
|