From 8e6bd210a8b1488d7dde56ec9b90fe9f0492d13c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=88=B1=E5=96=9D=E6=B0=B4=E7=9A=84=E6=9C=A8=E5=AD=90?= Date: Fri, 20 Mar 2026 13:55:27 +0800 Subject: [PATCH] Add admin pinning and user favorites with role management --- app/admin/page.tsx | 114 +++++++++++++++++++---- app/api/admin/users/[userId]/route.ts | 48 ++++++++-- app/api/login/route.ts | 14 ++- app/api/posts/[slug]/favorite/route.ts | 60 +++++++++++++ app/api/posts/[slug]/pin/route.ts | 55 ++++++++++++ app/api/posts/[slug]/route.ts | 1 + app/api/posts/route.ts | 14 ++- app/api/register/route.ts | 15 ++-- app/layout.tsx | 4 +- app/p/[slug]/page.tsx | 49 +++++++++- app/page.tsx | 6 +- app/tags/[tag]/page.tsx | 6 +- components/AdminPostList.tsx | 112 ++++++++++++++++++----- components/AdminUserManager.tsx | 119 +++++++++++++++++++++---- components/FavoriteButton.tsx | 70 +++++++++++++++ components/PostCard.tsx | 5 ++ lib/auth.ts | 16 +++- lib/posts.ts | 18 +++- types/post.ts | 4 + 19 files changed, 629 insertions(+), 101 deletions(-) create mode 100644 app/api/posts/[slug]/favorite/route.ts create mode 100644 app/api/posts/[slug]/pin/route.ts create mode 100644 components/FavoriteButton.tsx diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 34dfa74..53678dc 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -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 = { + user: "普通", + sponsor: "赞助", + admin: "管理员" }; async function fetchRecentPosts(session: Awaited>) { @@ -25,7 +32,7 @@ async function fetchRecentPosts(session: Awaited>): Promise { + 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>) { const db = await getDb(); const tags = await db @@ -90,8 +124,20 @@ async function fetchManagedUsers(): Promise { 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(); @@ -104,7 +150,10 @@ async function fetchManagedUsers(): Promise { const postCountMap = new Map(); const todayCountMap = new Map(); - const postsByOwner = new Map>(); + 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 { 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 { 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 (
-
-

内容后台

-

- 登录用户只能发布和修改自己的内容;删除内容、删除用户和设置发布额度仅管理员可操作。 -

+
+
+

内容后台

+

+ 登录用户可以发布、编辑自己的内容和管理自己的收藏;管理员额外拥有置顶、删帖、删用户和调整用户等级/额度的全部权限。 +

+
+
+ + {session?.name || "未登录"} · {roleLabel} + + + 查看统计 + +
@@ -173,7 +241,21 @@ export default async function AdminPage() { todayCount={publishQuota.todayCount} /> - + + + {adminView ? : null}
diff --git a/app/api/admin/users/[userId]/route.ts b/app/api/admin/users/[userId]/route.ts index 7f4a031..aa0e32d 100644 --- a/app/api/admin/users/[userId]/route.ts +++ b/app/api/admin/users/[userId]/route.ts @@ -24,26 +24,49 @@ export async function PATCH(req: NextRequest, { params }: { params: { userId: st } const body = await req.json().catch(() => ({})); - const schema = z.object({ - dailyPostLimit: z.number().int().min(0).max(1000) - }); + const schema = z + .object({ + dailyPostLimit: z.number().int().min(0).max(1000).optional(), + role: z.enum(["user", "sponsor", "admin"]).optional() + }) + .refine((value) => value.dailyPostLimit !== undefined || value.role !== undefined, { + message: "至少需要提交一个要修改的字段" + }); const parsed = schema.safeParse(body); if (!parsed.success) { return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); } + if (session.uid === params.userId && parsed.data.role && parsed.data.role !== "admin") { + return NextResponse.json({ error: "不能把当前登录管理员降级" }, { status: 400 }); + } + const db = await getDb(); + const setPayload: Record = {}; + if (parsed.data.dailyPostLimit !== undefined) { + setPayload.dailyPostLimit = parsed.data.dailyPostLimit; + } + if (parsed.data.role) { + setPayload.role = parsed.data.role; + } + const result = await db.collection("users").updateOne( { _id: new ObjectId(params.userId) }, - { $set: { dailyPostLimit: parsed.data.dailyPostLimit } } + { $set: setPayload } ); if (result.matchedCount === 0) { return NextResponse.json({ error: "用户不存在" }, { status: 404 }); } + const updatedUser = await db.collection("users").findOne( + { _id: new ObjectId(params.userId) }, + { projection: { dailyPostLimit: 1, role: 1 } } + ); + return NextResponse.json({ ok: true, - dailyPostLimit: parsed.data.dailyPostLimit ?? DEFAULT_DAILY_POST_LIMIT + dailyPostLimit: updatedUser?.dailyPostLimit ?? DEFAULT_DAILY_POST_LIMIT, + role: updatedUser?.role ?? "user" }); } @@ -65,7 +88,7 @@ export async function DELETE(req: NextRequest, { params }: { params: { userId: s return NextResponse.json({ error: "用户不存在" }, { status: 404 }); } - await db.collection("posts").deleteMany({ + const postFilter = { $or: [ { ownerId: params.userId }, { @@ -79,6 +102,19 @@ export async function DELETE(req: NextRequest, { params }: { params: { userId: s ] } ] + }; + + const ownedPosts = await db + .collection("posts") + .find(postFilter, { projection: { slug: 1 } }) + .toArray(); + + await db.collection("posts").deleteMany(postFilter); + await db.collection("favorites").deleteMany({ + $or: [ + { ownerId: params.userId }, + { postSlug: { $in: ownedPosts.map((post: any) => post.slug).filter(Boolean) } } + ] }); await db.collection("users").deleteOne({ _id: new ObjectId(params.userId) }); diff --git a/app/api/login/route.ts b/app/api/login/route.ts index c006853..f56cd8b 100644 --- a/app/api/login/route.ts +++ b/app/api/login/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { signSession, cookieName, isAdminName } from "@/lib/auth"; +import { cookieName, isAdminName, resolveUserRole, signSession } from "@/lib/auth"; import { getDb } from "@/lib/mongo"; import { verifyPassword } from "@/lib/password"; @@ -19,6 +19,7 @@ export async function POST(req: NextRequest) { const { username, password } = parsed.data; const db = await getDb(); const user = await db.collection("users").findOne({ usernameLower: username.toLowerCase() }); + if ( !user || typeof user.passwordSalt !== "string" || @@ -29,12 +30,8 @@ export async function POST(req: NextRequest) { } const name = user.displayName || user.username || username; - const role = - user.role === "admin" || user.role === "user" - ? user.role - : isAdminName(user.username) || isAdminName(name) - ? "admin" - : "user"; + const storedRole = resolveUserRole(user.role); + const role = storedRole || (isAdminName(user.username) || isAdminName(name) ? "admin" : "user"); const exp = Date.now() + 24 * 60 * 60 * 1000; const token = await signSession({ role, @@ -44,7 +41,8 @@ export async function POST(req: NextRequest) { name, username: user.username || username }); - const res = NextResponse.json({ ok: true, name }); + + const res = NextResponse.json({ ok: true, name, role }); res.cookies.set(cookieName, token, { httpOnly: true, sameSite: "lax", diff --git a/app/api/posts/[slug]/favorite/route.ts b/app/api/posts/[slug]/favorite/route.ts new file mode 100644 index 0000000..b863b41 --- /dev/null +++ b/app/api/posts/[slug]/favorite/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server"; +import { cookieName, verifySession } from "@/lib/auth"; +import { getDb } from "@/lib/mongo"; + +async function getSessionFromRequest(req: NextRequest) { + const token = req.cookies.get(cookieName)?.value; + return verifySession(token); +} + +async function countFavorites(postSlug: string) { + const db = await getDb(); + return db.collection("favorites").countDocuments({ postSlug }); +} + +export async function POST(req: NextRequest, { params }: { params: { slug: string } }) { + const session = await getSessionFromRequest(req); + if (!session?.uid) { + return NextResponse.json({ error: "请先登录后再收藏" }, { status: 401 }); + } + + const db = await getDb(); + const post = await db.collection("posts").findOne({ slug: params.slug }, { projection: { _id: 1 } }); + if (!post) { + return NextResponse.json({ error: "内容不存在" }, { status: 404 }); + } + + await db.collection("favorites").updateOne( + { ownerId: session.uid, postSlug: params.slug }, + { + $setOnInsert: { + ownerId: session.uid, + postSlug: params.slug, + createdAt: new Date().toISOString() + } + }, + { upsert: true } + ); + + return NextResponse.json({ + ok: true, + isFavorited: true, + favoriteCount: await countFavorites(params.slug) + }); +} + +export async function DELETE(req: NextRequest, { params }: { params: { slug: string } }) { + const session = await getSessionFromRequest(req); + if (!session?.uid) { + return NextResponse.json({ error: "请先登录后再取消收藏" }, { status: 401 }); + } + + const db = await getDb(); + await db.collection("favorites").deleteOne({ ownerId: session.uid, postSlug: params.slug }); + + return NextResponse.json({ + ok: true, + isFavorited: false, + favoriteCount: await countFavorites(params.slug) + }); +} diff --git a/app/api/posts/[slug]/pin/route.ts b/app/api/posts/[slug]/pin/route.ts new file mode 100644 index 0000000..44a0d27 --- /dev/null +++ b/app/api/posts/[slug]/pin/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { cookieName, verifySession } from "@/lib/auth"; +import { getDb } from "@/lib/mongo"; +import { canPinPost } from "@/lib/posts"; + +async function getSessionFromRequest(req: NextRequest) { + const token = req.cookies.get(cookieName)?.value; + return verifySession(token); +} + +async function getPost(slug: string) { + const db = await getDb(); + const post = await db.collection("posts").findOne({ slug }); + return { db, post }; +} + +export async function POST(req: NextRequest, { params }: { params: { slug: string } }) { + const session = await getSessionFromRequest(req); + const { db, post } = await getPost(params.slug); + + if (!post) { + return NextResponse.json({ error: "内容不存在" }, { status: 404 }); + } + if (!canPinPost(post, session)) { + return NextResponse.json({ error: "只有管理员可以置顶内容" }, { status: 403 }); + } + + const now = new Date().toISOString(); + await db.collection("posts").updateOne( + { _id: post._id }, + { $set: { isPinned: true, pinnedAt: now, updatedAt: now } } + ); + + return NextResponse.json({ ok: true, isPinned: true, pinnedAt: now }); +} + +export async function DELETE(req: NextRequest, { params }: { params: { slug: string } }) { + const session = await getSessionFromRequest(req); + const { db, post } = await getPost(params.slug); + + if (!post) { + return NextResponse.json({ error: "内容不存在" }, { status: 404 }); + } + if (!canPinPost(post, session)) { + return NextResponse.json({ error: "只有管理员可以取消置顶" }, { status: 403 }); + } + + const now = new Date().toISOString(); + await db.collection("posts").updateOne( + { _id: post._id }, + { $set: { isPinned: false, updatedAt: now }, $unset: { pinnedAt: "" } } + ); + + return NextResponse.json({ ok: true, isPinned: false }); +} diff --git a/app/api/posts/[slug]/route.ts b/app/api/posts/[slug]/route.ts index 6b1fddb..01b2a3a 100644 --- a/app/api/posts/[slug]/route.ts +++ b/app/api/posts/[slug]/route.ts @@ -84,5 +84,6 @@ export async function DELETE(req: NextRequest, { params }: { params: { slug: str } await db.collection("posts").deleteOne({ _id: existingPost._id }); + await db.collection("favorites").deleteMany({ postSlug: params.slug }); return NextResponse.json({ ok: true }); } diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts index 40282f6..a224963 100644 --- a/app/api/posts/route.ts +++ b/app/api/posts/route.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { cookieName, verifySession } from "@/lib/auth"; import { getDb } from "@/lib/mongo"; import { DEFAULT_OPC_SIGNAL, OPC_SIGNAL_VALUES } from "@/lib/opc"; +import { buildPinnedSort, serializePost } from "@/lib/posts"; import { generateSlug } from "@/lib/slug"; import { findUserById, getEffectiveDailyPostLimit, getShanghaiDayRange } from "@/lib/users"; @@ -19,17 +20,11 @@ export async function GET() { const posts = await db .collection("posts") .find({}, { projection: { markdown: 0 } }) - .sort({ createdAt: -1 }) + .sort(buildPinnedSort()) .limit(50) .toArray(); - return NextResponse.json( - posts.map((post) => ({ - ...post, - author: post.author ?? "匿名", - _id: post._id?.toString() - })) - ); + return NextResponse.json(posts.map((post) => serializePost(post))); } export async function POST(req: NextRequest) { @@ -85,7 +80,8 @@ export async function POST(req: NextRequest) { slug, createdAt: now, updatedAt: now, - views: 0 + views: 0, + isPinned: false }); return NextResponse.json({ ok: true, slug, todayCount: todayCount + 1, limit }); diff --git a/app/api/register/route.ts b/app/api/register/route.ts index 4a584bf..9ca5a6c 100644 --- a/app/api/register/route.ts +++ b/app/api/register/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { signSession, cookieName, isAdminName } from "@/lib/auth"; +import { cookieName, signSession } from "@/lib/auth"; import { DEFAULT_DAILY_POST_LIMIT } from "@/lib/users"; import { getDb } from "@/lib/mongo"; import { hashPassword } from "@/lib/password"; @@ -13,6 +13,7 @@ export async function POST(req: NextRequest) { displayName: z.string().trim().min(2).max(32).optional() }); const parsed = schema.safeParse(body); + if (!parsed.success) { return NextResponse.json({ error: "用户名或密码格式不正确" }, { status: 400 }); } @@ -20,7 +21,6 @@ export async function POST(req: NextRequest) { const { username, password, displayName } = parsed.data; const usernameLower = username.toLowerCase(); const resolvedDisplayName = displayName || username; - const role = isAdminName(username) || isAdminName(resolvedDisplayName) ? "admin" : "user"; const db = await getDb(); const exists = await db.collection("users").findOne({ usernameLower }); @@ -34,7 +34,7 @@ export async function POST(req: NextRequest) { username, usernameLower, displayName: resolvedDisplayName, - role, + role: "user" as const, dailyPostLimit: DEFAULT_DAILY_POST_LIMIT, passwordHash: hash, passwordSalt: salt, @@ -42,18 +42,17 @@ export async function POST(req: NextRequest) { }; const result = await db.collection("users").insertOne(doc); - - const name = doc.displayName; const exp = Date.now() + 24 * 60 * 60 * 1000; const token = await signSession({ - role, + role: doc.role, iat: Date.now(), exp, uid: result.insertedId?.toString(), - name, + name: doc.displayName, username }); - const res = NextResponse.json({ ok: true, name }); + + const res = NextResponse.json({ ok: true, name: doc.displayName, role: doc.role }); res.cookies.set(cookieName, token, { httpOnly: true, sameSite: "lax", diff --git a/app/layout.tsx b/app/layout.tsx index bac882a..33814a7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -15,6 +15,8 @@ export default async function RootLayout({ children }: { children: ReactNode }) const token = cookies().get(cookieName)?.value; const session = await verifySession(token); const userName = session?.name ?? "访客"; + const roleLabel = + session?.role === "admin" ? "管理员" : session?.role === "sponsor" ? "赞助" : "普通"; return ( @@ -47,7 +49,7 @@ export default async function RootLayout({ children }: { children: ReactNode }) {session ? (
- {userName} + {userName} · {roleLabel}
diff --git a/app/p/[slug]/page.tsx b/app/p/[slug]/page.tsx index c98b3c8..e935257 100644 --- a/app/p/[slug]/page.tsx +++ b/app/p/[slug]/page.tsx @@ -1,8 +1,12 @@ +import { cookies } from "next/headers"; +import Link from "next/link"; import { notFound } from "next/navigation"; +import { FavoriteButton } from "@/components/FavoriteButton"; import { MarkdownViewer } from "@/components/MarkdownViewer"; import { SharePanel } from "@/components/SharePanel"; +import { cookieName, verifySession } from "@/lib/auth"; import { getDb } from "@/lib/mongo"; -import { serializePost } from "@/lib/posts"; +import { canEditPost, serializePost } from "@/lib/posts"; import { normalizeImageUrl } from "@/lib/normalize"; import { getSiteUrl } from "@/lib/site"; @@ -20,15 +24,23 @@ async function fetchPost(slug: string) { } db.collection("posts").updateOne({ _id: doc._id }, { $inc: { views: 1 } }).catch(() => {}); - return serializePost({ ...doc, views: (doc.views ?? 0) + 1 }); + const favoriteCount = await db.collection("favorites").countDocuments({ postSlug: slug }); + return serializePost({ ...doc, views: (doc.views ?? 0) + 1, favoriteCount }); } export default async function PostPage({ params }: Props) { + const token = cookies().get(cookieName)?.value; + const session = await verifySession(token); const post = await fetchPost(params.slug); if (!post) { notFound(); } + const db = await getDb(); + const isFavorited = session?.uid + ? Boolean(await db.collection("favorites").findOne({ ownerId: session.uid, postSlug: post.slug })) + : false; + const canEdit = canEditPost(post, session); const coverUrl = normalizeImageUrl(post.cover); const shareUrl = `${getSiteUrl()}/p/${post.slug}`; @@ -36,8 +48,33 @@ export default async function PostPage({ params }: Props) {
-

{post.title}

- +
+
+ {post.isPinned ? ( + + 置顶 + + ) : null} +

{post.title}

+
+
+
+ {canEdit ? ( + + 编辑 + + ) : null} + + +

{post.author || "匿名"} |{" "} @@ -45,6 +82,10 @@ export default async function PostPage({ params }: Props) { hour12: false, timeZone: "Asia/Shanghai" })} + {" · "} + 浏览 {post.views ?? 0} + {" · "} + 收藏 {post.favoriteCount ?? 0}

{coverUrl ? (

OPC Feed

- 未登录用户可以浏览全部内容;登录用户只能发布和修改自己的内容。 + 未登录用户可以浏览全部内容;登录用户可以发布并修改自己的内容;置顶内容会优先展示。

{tag ? (
diff --git a/app/tags/[tag]/page.tsx b/app/tags/[tag]/page.tsx index 65f6f42..f3563a0 100644 --- a/app/tags/[tag]/page.tsx +++ b/app/tags/[tag]/page.tsx @@ -1,6 +1,6 @@ import { PostCard } from "@/components/PostCard"; import { getDb } from "@/lib/mongo"; -import { serializePost } from "@/lib/posts"; +import { buildPinnedSort, serializePost } from "@/lib/posts"; import { buildSearchFilter } from "@/lib/search"; import { Post } from "@/types/post"; @@ -27,7 +27,7 @@ async function fetchTagPosts(params: { const docs = await db .collection("posts") .find(filter, { projection: { markdown: 0 } }) - .sort({ createdAt: -1 }) + .sort(buildPinnedSort()) .skip((page - 1) * PAGE_SIZE) .limit(PAGE_SIZE) .toArray(); @@ -64,7 +64,7 @@ export default async function TagDetailPage({

标签 / {tag}

-

共 {total} 条内容

+

共 {total} 条内容,置顶内容会优先显示。

(initialPosts); const [tagQuery, setTagQuery] = useState(""); + const [busySlug, setBusySlug] = useState(null); const visiblePosts = useMemo(() => { const query = tagQuery.trim().toLowerCase(); @@ -25,33 +36,74 @@ export function AdminPostList({ async function handleDelete(slug: string) { if (!window.confirm("确定要删除这条内容吗?此操作不可恢复。")) return; - const res = await fetch(`/api/posts/${slug}`, { method: "DELETE" }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - alert(data.error || "删除失败"); - return; - } + setBusySlug(slug); + try { + const res = await fetch(`/api/posts/${slug}`, { method: "DELETE" }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + alert(data.error || "删除失败"); + return; + } - setPosts((prev) => prev.filter((post) => post.slug !== slug)); + setPosts((prev) => prev.filter((post) => post.slug !== slug)); + } finally { + setBusySlug(null); + } + } + + async function handleTogglePin(post: AdminPost) { + setBusySlug(post.slug); + try { + const res = await fetch(`/api/posts/${post.slug}/pin`, { + method: post.isPinned ? "DELETE" : "POST" + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + alert(data.error || "置顶操作失败"); + return; + } + + setPosts((prev) => + [...prev] + .map((item) => + item.slug === post.slug + ? { + ...item, + isPinned: Boolean(data.isPinned), + pinnedAt: data.pinnedAt + } + : item + ) + .sort((a, b) => { + const pinnedDiff = Number(Boolean(b.isPinned)) - Number(Boolean(a.isPinned)); + if (pinnedDiff !== 0) return pinnedDiff; + const pinTimeDiff = (b.pinnedAt || "").localeCompare(a.pinnedAt || ""); + if (pinTimeDiff !== 0) return pinTimeDiff; + return b.createdAt.localeCompare(a.createdAt); + }) + ); + } finally { + setBusySlug(null); + } } if (posts.length === 0) { return (
- 暂无内容。 + {emptyText}
); } - const summary = tagQuery - ? `匹配 ${visiblePosts.length} / 共 ${posts.length} 条` - : `共 ${posts.length} 条`; + const summary = tagQuery ? `匹配 ${visiblePosts.length} / 共 ${posts.length} 条` : `共 ${posts.length} 条`; return (
-

最近内容

+

{title}

+ {description ?

{description}

: null}

{summary}

@@ -80,7 +132,12 @@ export function AdminPostList({ className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-100 bg-white/70 p-3" >
- + {post.isPinned ? ( + + 置顶 + + ) : null} + {post.title}

@@ -103,17 +160,30 @@ export function AdminPostList({ ) : null}

- - 编辑 - + {showEdit ? ( + + 编辑 + + ) : null} + {canPin ? ( + + ) : null} {canDelete ? ( diff --git a/components/AdminUserManager.tsx b/components/AdminUserManager.tsx index fe09608..8a261c4 100644 --- a/components/AdminUserManager.tsx +++ b/components/AdminUserManager.tsx @@ -6,19 +6,26 @@ type ManagedPost = { slug: string; title: string; createdAt: string; + isPinned?: boolean; }; type ManagedUser = { id: string; username: string; displayName: string; - role: "admin" | "user"; + role: "user" | "sponsor" | "admin"; dailyPostLimit: number; postCount: number; todayPostCount: number; posts: ManagedPost[]; }; +const ROLE_OPTIONS: Array<{ value: ManagedUser["role"]; label: string }> = [ + { value: "user", label: "普通" }, + { value: "sponsor", label: "赞助" }, + { value: "admin", label: "管理员" } +]; + export function AdminUserManager({ initialUsers, currentUserId @@ -67,13 +74,46 @@ export function AdminUserManager({ ); } - async function handleSaveLimit(userId: string, dailyPostLimit: number) { + async function handleTogglePin(userId: string, slug: string, isPinned: boolean) { + const res = await fetch(`/api/posts/${slug}/pin`, { + method: isPinned ? "DELETE" : "POST" + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + alert(data.error || "置顶操作失败"); + return; + } + + setUsers((prev) => + prev.map((user) => + user.id !== userId + ? user + : { + ...user, + posts: [...user.posts] + .map((post) => + post.slug === slug ? { ...post, isPinned: Boolean(data.isPinned) } : post + ) + .sort((a, b) => { + const pinnedDiff = Number(Boolean(b.isPinned)) - Number(Boolean(a.isPinned)); + if (pinnedDiff !== 0) return pinnedDiff; + return b.createdAt.localeCompare(a.createdAt); + }) + } + ) + ); + } + + async function handleSaveUser( + userId: string, + payload: { dailyPostLimit: number; role: ManagedUser["role"] } + ) { setSavingId(userId); try { const res = await fetch(`/api/admin/users/${userId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ dailyPostLimit }) + body: JSON.stringify(payload) }); const data = await res.json().catch(() => ({})); if (!res.ok) { @@ -83,7 +123,13 @@ export function AdminUserManager({ setUsers((prev) => prev.map((user) => - user.id === userId ? { ...user, dailyPostLimit: data.dailyPostLimit ?? dailyPostLimit } : user + user.id === userId + ? { + ...user, + dailyPostLimit: data.dailyPostLimit ?? payload.dailyPostLimit, + role: data.role ?? payload.role + } + : user ) ); } finally { @@ -109,7 +155,9 @@ export function AdminUserManager({

用户管理

-

按用户名搜索,删除指定内容、删除用户,并设置每日发布额度。

+

+ 可按用户名搜索,设置用户等级与每日发布额度,并删除用户或其指定内容。 +

)) )} @@ -151,17 +200,23 @@ function AdminUserCard({ currentUserId, saving, onDeletePost, + onTogglePin, onDeleteUser, - onSaveLimit + onSaveUser }: { user: ManagedUser; currentUserId: string; saving: boolean; onDeletePost: (slug: string) => Promise; + onTogglePin: (userId: string, slug: string, isPinned: boolean) => Promise; onDeleteUser: (userId: string) => Promise; - onSaveLimit: (userId: string, dailyPostLimit: number) => Promise; + onSaveUser: ( + userId: string, + payload: { dailyPostLimit: number; role: ManagedUser["role"] } + ) => Promise; }) { const [limit, setLimit] = useState(user.dailyPostLimit); + const [role, setRole] = useState(user.role); return (
@@ -171,11 +226,23 @@ function AdminUserCard({ {user.displayName} (@{user.username})

- 角色:{user.role === "admin" ? "管理员" : "用户"} | 总发布:{user.postCount} | 今日发布:{user.todayPostCount} + 角色:{ROLE_OPTIONS.find((item) => item.value === user.role)?.label || "普通"} | 总发布: + {user.postCount} | 今日发布:{user.todayPostCount}

+ onSaveLimit(user.id, limit)} + onClick={() => onSaveUser(user.id, { dailyPostLimit: limit, role })} className="rounded-full bg-brand-50 px-3 py-2 text-xs font-medium text-brand-700 ring-1 ring-brand-100 hover:bg-brand-100 disabled:opacity-60" > - 保存额度 + 保存设置 {user.id !== currentUserId ? ( +
+ + +
)) )} diff --git a/components/FavoriteButton.tsx b/components/FavoriteButton.tsx new file mode 100644 index 0000000..ccb7f2e --- /dev/null +++ b/components/FavoriteButton.tsx @@ -0,0 +1,70 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; + +type FavoriteButtonProps = { + slug: string; + initialFavorited: boolean; + initialCount: number; + canFavorite: boolean; +}; + +export function FavoriteButton({ + slug, + initialFavorited, + initialCount, + canFavorite +}: FavoriteButtonProps) { + const [favorited, setFavorited] = useState(initialFavorited); + const [count, setCount] = useState(initialCount); + const [loading, setLoading] = useState(false); + + async function handleToggle() { + if (loading) return; + + setLoading(true); + try { + const res = await fetch(`/api/posts/${slug}/favorite`, { + method: favorited ? "DELETE" : "POST" + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + alert(data.error || "收藏操作失败"); + return; + } + + setFavorited(Boolean(data.isFavorited)); + setCount(typeof data.favoriteCount === "number" ? data.favoriteCount : count); + } finally { + setLoading(false); + } + } + + if (!canFavorite) { + return ( + + 登录后收藏 + + ); + } + + return ( + + ); +} diff --git a/components/PostCard.tsx b/components/PostCard.tsx index f106281..d3b5216 100644 --- a/components/PostCard.tsx +++ b/components/PostCard.tsx @@ -14,6 +14,11 @@ export function PostCard({ post }: Props) {
+ {post.isPinned ? ( + + 置顶 + + ) : null} { export async function verifySession(token?: string): Promise { if (!token) return null; + const secret = getSecret(); const [base, sig] = token.split("."); if (!base || !sig) return null; + const check = await hmacSha256(base, secret); if (check !== sig) return null; + try { const payload = JSON.parse(Buffer.from(base, "base64url").toString()); - if (payload?.role !== "admin" && payload?.role !== "user") { + if (!resolveUserRole(payload?.role)) { return null; } if (typeof payload?.exp !== "number") { @@ -83,6 +94,7 @@ export async function verifySession(token?: string): Promise payload.exp) { return null; } + return payload; } catch { return null; diff --git a/lib/posts.ts b/lib/posts.ts index 1dc7da5..96049a4 100644 --- a/lib/posts.ts +++ b/lib/posts.ts @@ -78,6 +78,18 @@ export function canDeletePost(doc: any, session?: SessionPayload | null) { return Boolean(doc && isAdminSession(session)); } +export function canPinPost(doc: any, session?: SessionPayload | null) { + return Boolean(doc && isAdminSession(session)); +} + +export function buildPinnedSort() { + return { + isPinned: -1 as const, + pinnedAt: -1 as const, + createdAt: -1 as const + }; +} + export function serializePost(doc: any): Post { return { _id: doc._id?.toString(), @@ -91,6 +103,10 @@ export function serializePost(doc: any): Post { ownerId: doc.ownerId, createdAt: doc.createdAt ?? new Date().toISOString(), updatedAt: doc.updatedAt ?? doc.createdAt ?? new Date().toISOString(), - views: doc.views ?? 0 + views: doc.views ?? 0, + isPinned: Boolean(doc.isPinned), + pinnedAt: doc.pinnedAt, + favoriteCount: typeof doc.favoriteCount === "number" ? doc.favoriteCount : undefined, + isFavorited: typeof doc.isFavorited === "boolean" ? doc.isFavorited : undefined }; } diff --git a/types/post.ts b/types/post.ts index e36fc6e..21d8a74 100644 --- a/types/post.ts +++ b/types/post.ts @@ -11,4 +11,8 @@ export type Post = { createdAt: string; updatedAt: string; views?: number; + isPinned?: boolean; + pinnedAt?: string; + favoriteCount?: number; + isFavorited?: boolean; };