Add admin pinning and user favorites with role management
This commit is contained in:
@@ -4,8 +4,9 @@ import { AdminUserManager } from "@/components/AdminUserManager";
|
||||
import { CreatePostForm } from "@/components/CreatePostForm";
|
||||
import { cookieName, isAdminSession, verifySession } from "@/lib/auth";
|
||||
import { getDb } from "@/lib/mongo";
|
||||
import { buildOwnedPostFilter, serializePost } from "@/lib/posts";
|
||||
import { buildOwnedPostFilter, buildPinnedSort, serializePost } from "@/lib/posts";
|
||||
import { findUserById, getEffectiveDailyPostLimit, getShanghaiDayRange } from "@/lib/users";
|
||||
import { Post } from "@/types/post";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,11 +14,17 @@ type ManagedUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
role: "admin" | "user";
|
||||
role: "user" | "sponsor" | "admin";
|
||||
dailyPostLimit: number;
|
||||
postCount: number;
|
||||
todayPostCount: number;
|
||||
posts: Array<{ slug: string; title: string; createdAt: string }>;
|
||||
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>>) {
|
||||
@@ -25,7 +32,7 @@ async function fetchRecentPosts(session: Awaited<ReturnType<typeof verifySession
|
||||
const posts = await db
|
||||
.collection("posts")
|
||||
.find(buildOwnedPostFilter(session), { projection: { markdown: 0 } })
|
||||
.sort({ createdAt: -1 })
|
||||
.sort(buildPinnedSort())
|
||||
.limit(20)
|
||||
.toArray();
|
||||
|
||||
@@ -38,6 +45,33 @@ async function fetchRecentPosts(session: Awaited<ReturnType<typeof verifySession
|
||||
}));
|
||||
}
|
||||
|
||||
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
|
||||
@@ -90,8 +124,20 @@ async function fetchManagedUsers(): Promise<ManagedUser[]> {
|
||||
|
||||
const posts = await db
|
||||
.collection("posts")
|
||||
.find({}, { projection: { slug: 1, title: 1, createdAt: 1, ownerId: 1, author: 1 } })
|
||||
.sort({ createdAt: -1 })
|
||||
.find(
|
||||
{},
|
||||
{
|
||||
projection: {
|
||||
slug: 1,
|
||||
title: 1,
|
||||
createdAt: 1,
|
||||
ownerId: 1,
|
||||
author: 1,
|
||||
isPinned: 1
|
||||
}
|
||||
}
|
||||
)
|
||||
.sort(buildPinnedSort())
|
||||
.toArray();
|
||||
|
||||
const authorToUserId = new Map<string, string>();
|
||||
@@ -104,7 +150,10 @@ async function fetchManagedUsers(): Promise<ManagedUser[]> {
|
||||
|
||||
const postCountMap = new Map<string, number>();
|
||||
const todayCountMap = new Map<string, number>();
|
||||
const postsByOwner = new Map<string, Array<{ slug: string; title: string; createdAt: string }>>();
|
||||
const postsByOwner = new Map<
|
||||
string,
|
||||
Array<{ slug: string; title: string; createdAt: string; isPinned?: boolean }>
|
||||
>();
|
||||
|
||||
posts.forEach((post: any) => {
|
||||
const resolvedOwnerId =
|
||||
@@ -115,7 +164,8 @@ async function fetchManagedUsers(): Promise<ManagedUser[]> {
|
||||
list.push({
|
||||
slug: post.slug,
|
||||
title: post.title ?? "未命名",
|
||||
createdAt: post.createdAt ?? new Date().toISOString()
|
||||
createdAt: post.createdAt ?? new Date().toISOString(),
|
||||
isPinned: Boolean(post.isPinned)
|
||||
});
|
||||
postsByOwner.set(resolvedOwnerId, list);
|
||||
postCountMap.set(resolvedOwnerId, (postCountMap.get(resolvedOwnerId) ?? 0) + 1);
|
||||
@@ -134,7 +184,10 @@ async function fetchManagedUsers(): Promise<ManagedUser[]> {
|
||||
id,
|
||||
username: user.username ?? "",
|
||||
displayName: user.displayName ?? user.username ?? "",
|
||||
role: user.role === "admin" ? "admin" : "user",
|
||||
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,
|
||||
@@ -148,9 +201,11 @@ 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, availableTags, publishQuota, managedUsers] = await Promise.all([
|
||||
const [recentPosts, favoritePosts, availableTags, publishQuota, managedUsers] = await Promise.all([
|
||||
fetchRecentPosts(session),
|
||||
fetchFavoritePosts(session),
|
||||
fetchAvailableTags(session),
|
||||
fetchPublishQuota(session),
|
||||
adminView ? fetchManagedUsers() : Promise.resolve([] as ManagedUser[])
|
||||
@@ -159,11 +214,24 @@ export default async function AdminPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-2xl bg-white/80 p-5 shadow-sm ring-1 ring-slate-100">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold text-slate-900">内容后台</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
登录用户只能发布和修改自己的内容;删除内容、删除用户和设置发布额度仅管理员可操作。
|
||||
</p>
|
||||
<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>
|
||||
|
||||
@@ -173,7 +241,21 @@ export default async function AdminPage() {
|
||||
todayCount={publishQuota.todayCount}
|
||||
/>
|
||||
|
||||
<AdminPostList initialPosts={recentPosts} canDelete={false} />
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user