From 466b7c3fb663d16c88a11795efbadac93f5bf462 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 11:51:58 +0800 Subject: [PATCH] Implement per-user post permissions and move stats into dedicated pages --- app/admin/edit/[slug]/page.tsx | 36 +-- app/admin/page.tsx | 343 ++++++++++---------------- app/api/admin/stats/route.ts | 33 ++- app/api/admin/users/[userId]/route.ts | 86 +++++++ app/api/login/route.ts | 17 +- app/api/posts/[slug]/route.ts | 62 +++-- app/api/posts/route.ts | 52 ++-- app/api/register/route.ts | 18 +- app/layout.tsx | 39 ++- app/login/page.tsx | 8 +- app/p/[slug]/page.tsx | 41 ++- app/page.tsx | 35 +-- app/register/page.tsx | 6 +- app/register/register-form.tsx | 2 +- app/rss/route.ts | 12 +- app/stats/page.tsx | 248 +++++++++++++++++++ app/tags/[tag]/page.tsx | 33 +-- app/tags/page.tsx | 6 +- components/AdminPostList.tsx | 40 +-- components/AdminUserManager.tsx | 239 ++++++++++++++++++ components/CreatePostForm.tsx | 86 ++++--- components/EditPostForm.tsx | 71 ++---- components/PostCard.tsx | 9 +- lib/auth.ts | 19 +- lib/posts.ts | 96 +++++++ lib/stats.ts | 212 ++++++++++++++++ lib/users.ts | 38 +++ middleware.ts | 3 +- types/post.ts | 1 + 29 files changed, 1416 insertions(+), 475 deletions(-) create mode 100644 app/api/admin/users/[userId]/route.ts create mode 100644 app/stats/page.tsx create mode 100644 components/AdminUserManager.tsx create mode 100644 lib/posts.ts create mode 100644 lib/stats.ts create mode 100644 lib/users.ts diff --git a/app/admin/edit/[slug]/page.tsx b/app/admin/edit/[slug]/page.tsx index e06a9e2..acb4ac2 100644 --- a/app/admin/edit/[slug]/page.tsx +++ b/app/admin/edit/[slug]/page.tsx @@ -1,32 +1,32 @@ -import { getDb } from "@/lib/mongo"; +import { cookies } from "next/headers"; import { notFound } from "next/navigation"; import { EditPostForm } from "@/components/EditPostForm"; +import { cookieName, verifySession } from "@/lib/auth"; +import { getDb } from "@/lib/mongo"; +import { canEditPost, serializePost } from "@/lib/posts"; import { Post } from "@/types/post"; -import { DEFAULT_OPC_SIGNAL } from "@/lib/opc"; export const dynamic = "force-dynamic"; -async function fetchPost(slug: string): Promise { +async function fetchPost( + slug: string, + session: Awaited> +): Promise { const db = await getDb(); const post = await db.collection("posts").findOne({ slug }); - if (!post) return null; - return { - _id: post._id?.toString(), - title: post.title ?? "", - slug: post.slug ?? slug, - markdown: post.markdown ?? "", - cover: post.cover, - tags: post.tags ?? [], - signal: post.signal ?? DEFAULT_OPC_SIGNAL, - author: post.author ?? "佚名", - createdAt: post.createdAt ?? new Date().toISOString(), - updatedAt: post.updatedAt ?? post.createdAt ?? new Date().toISOString(), - views: post.views ?? 0 - }; + + if (!post || !canEditPost(post, session)) { + return null; + } + + return serializePost(post); } export default async function EditPostPage({ params }: { params: { slug: string } }) { - const post = await fetchPost(params.slug); + const token = cookies().get(cookieName)?.value; + const session = await verifySession(token); + const post = await fetchPost(params.slug, session); + if (!post) { notFound(); } diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 58ab2e9..34dfa74 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,264 +1,181 @@ -import { CreatePostForm } from "@/components/CreatePostForm"; +import { cookies } from "next/headers"; import { AdminPostList } from "@/components/AdminPostList"; +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 { findUserById, getEffectiveDailyPostLimit, getShanghaiDayRange } from "@/lib/users"; export const dynamic = "force-dynamic"; -const cardClass = - "rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100 transition-[transform,box-shadow] duration-300 will-change-transform transform-gpu hover:shadow-lg hover:[transform:perspective(900px)_translateY(-4px)_rotateX(2deg)_rotateY(-2deg)]"; +type ManagedUser = { + id: string; + username: string; + displayName: string; + role: "admin" | "user"; + dailyPostLimit: number; + postCount: number; + todayPostCount: number; + posts: Array<{ slug: string; title: string; createdAt: string }>; +}; -async function fetchStats() { - const db = await getDb(); - const collection = db.collection("posts"); - const total = await collection.countDocuments(); - const latest = await collection - .find({}, { projection: { title: 1, createdAt: 1 } }) - .sort({ createdAt: -1 }) - .limit(1) - .toArray(); - const top = await collection - .find({}, { projection: { title: 1, views: 1, slug: 1 } }) - .sort({ views: -1 }) - .limit(3) - .toArray(); - const viewsAgg = await collection - .aggregate([{ $group: { _id: null, totalViews: { $sum: { $ifNull: ["$views", 0] } } } }]) - .toArray(); - const totalViews = viewsAgg[0]?.totalViews ?? 0; - const avgViews = total > 0 ? Math.round(totalViews / total) : 0; - const tagCount = (await collection.distinct("tags")).filter(Boolean).length; - const authorCount = (await collection.distinct("author")).filter(Boolean).length; - - return { - total, - latest: latest[0] || null, - top, - totalViews, - avgViews, - tagCount, - authorCount - }; -} - -async function fetchRecentPosts() { +async function fetchRecentPosts(session: Awaited>) { const db = await getDb(); const posts = await db .collection("posts") - .find({}, { projection: { markdown: 0 } }) + .find(buildOwnedPostFilter(session), { projection: { markdown: 0 } }) .sort({ createdAt: -1 }) .limit(20) .toArray(); - return posts.map((p: any) => ({ - ...p, - _id: p._id?.toString(), - author: p.author ?? "佚名", - createdAtText: new Date(p.createdAt).toLocaleString("zh-CN", { + + return posts.map((post: any) => ({ + ...serializePost(post), + createdAtText: new Date(post.createdAt).toLocaleString("zh-CN", { hour12: false, timeZone: "Asia/Shanghai" }) })); } -async function fetchAllTags() { +async function fetchAvailableTags(session: Awaited>) { 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((t: any) => t._id).filter(Boolean); + + return tags.map((item: any) => item._id).filter(Boolean); } -async function fetchTagStats() { - const db = await getDb(); - const tags = await db - .collection("posts") - .aggregate([ - { $unwind: "$tags" }, - { $group: { _id: "$tags", count: { $sum: 1 } } }, - { $sort: { count: -1, _id: 1 } }, - { $limit: 12 } - ]) - .toArray(); - return tags.map((t: any) => ({ tag: t._id, count: t.count })); +async function fetchPublishQuota(session: Awaited>) { + 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 fetchAuthorStats() { +async function fetchManagedUsers(): Promise { const db = await getDb(); - const authors = await db - .collection("posts") - .aggregate([ - { $group: { _id: { $ifNull: ["$author", "佚名"] }, count: { $sum: 1 } } }, - { $sort: { count: -1, _id: 1 } }, - { $limit: 10 } - ]) + const { startIso, endIso } = getShanghaiDayRange(); + const users = await db + .collection("users") + .find( + {}, + { + projection: { + username: 1, + displayName: 1, + role: 1, + dailyPostLimit: 1 + } + } + ) + .sort({ createdAt: 1 }) .toArray(); - return authors.map((a: any) => ({ author: a._id, count: a.count })); -} -async function fetchDailyStats() { - const db = await getDb(); - const now = new Date(); - const days: { key: string; label: string }[] = []; - for (let i = 6; i >= 0; i -= 1) { - const d = new Date(now); - d.setDate(now.getDate() - i); - const key = d.toISOString().slice(0, 10); - const label = `${d.getMonth() + 1}/${d.getDate()}`; - days.push({ key, label }); - } - const since = `${days[0].key}T00:00:00.000Z`; - const raw = await db + const posts = await db .collection("posts") - .aggregate([ - { $match: { createdAt: { $gte: since } } }, - { $addFields: { day: { $dateToString: { format: "%Y-%m-%d", date: { $toDate: "$createdAt" } } } } }, - { $group: { _id: "$day", count: { $sum: 1 } } } - ]) + .find({}, { projection: { slug: 1, title: 1, createdAt: 1, ownerId: 1, author: 1 } }) + .sort({ createdAt: -1 }) .toArray(); - const map = new Map(); - raw.forEach((item: any) => map.set(item._id, item.count)); - return days.map((d) => ({ label: d.label, count: map.get(d.key) ?? 0 })); + + const authorToUserId = new Map(); + 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(); + const todayCountMap = new Map(); + const postsByOwner = new Map>(); + + 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() + }); + 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" ? "admin" : "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 stats = await fetchStats(); - const recentPosts = await fetchRecentPosts(); - const availableTags = await fetchAllTags(); - const tagStats = await fetchTagStats(); - const authorStats = await fetchAuthorStats(); - const dailyStats = await fetchDailyStats(); - const tagMax = Math.max(...tagStats.map((t) => t.count), 1); - const authorMax = Math.max(...authorStats.map((a) => a.count), 1); - const dailyMax = Math.max(...dailyStats.map((d) => d.count), 1); + const token = cookies().get(cookieName)?.value; + const session = await verifySession(token); + const adminView = isAdminSession(session); + + const [recentPosts, availableTags, publishQuota, managedUsers] = await Promise.all([ + fetchRecentPosts(session), + fetchAvailableTags(session), + fetchPublishQuota(session), + adminView ? fetchManagedUsers() : Promise.resolve([] as ManagedUser[]) + ]); return (
-
-
-

总内容数

-

{stats.total}

-
-
-

最新发布

-

{stats.latest?.title ?? "暂无"}

-

- {stats.latest?.createdAt - ? new Date(stats.latest.createdAt).toLocaleString("zh-CN", { hour12: false }) - : ""} +

+
+

内容后台

+

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

-
-

Top 阅读

-
    - {stats.top.map((item: any) => ( -
  • - {item.title} - {item.views ?? 0} 阅读 -
  • - ))} -
-
-
-

统计面板

-
-
-

总阅读

-

{stats.totalViews}

-
-
-

平均阅读

-

{stats.avgViews}

-
-
-

标签数量

-

{stats.tagCount}

-
-
-

作者数量

-

{stats.authorCount}

-
-
+ -
-
-

标签分布

-
- {tagStats.length === 0 ? ( -

暂无标签数据

- ) : ( - tagStats.map((item) => ( -
-
- #{item.tag} - {item.count} -
-
-
-
-
- )) - )} -
-
+ -
-

作者分布

-
- {authorStats.length === 0 ? ( -

暂无作者数据

- ) : ( - authorStats.map((item) => ( -
-
- {item.author} - {item.count} -
-
-
-
-
- )) - )} -
-
-
- -
-

近 7 天发布

-
- {dailyStats.map((item) => ( -
-
- {item.label} - {item.count} -
-
-
-
-
- ))} -
-
-
- - - - + {adminView ? : null}
); } diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts index ca6b186..9bd5fd9 100644 --- a/app/api/admin/stats/route.ts +++ b/app/api/admin/stats/route.ts @@ -1,16 +1,27 @@ -import { NextResponse } from "next/server"; -import { getDb } from "@/lib/mongo"; +import { NextRequest, NextResponse } from "next/server"; +import { cookieName, isAdminSession, verifySession } from "@/lib/auth"; +import { buildOwnedPostFilter } from "@/lib/posts"; +import { fetchAuthorBreakdown, fetchDailyStats, fetchStatsSummary, fetchTagStats } from "@/lib/stats"; -export async function GET() { - const db = await getDb(); - const collection = db.collection("posts"); - const total = await collection.countDocuments(); - const latest = await collection.find({}, { projection: { title: 1, createdAt: 1 } }).sort({ createdAt: -1 }).limit(1).toArray(); - const top = await collection.find({}, { projection: { title: 1, views: 1, slug: 1 } }).sort({ views: -1 }).limit(3).toArray(); +export async function GET(req: NextRequest) { + const token = req.cookies.get(cookieName)?.value; + const session = await verifySession(token); + const personalFilter = buildOwnedPostFilter(session); + const adminView = isAdminSession(session); + + const [mine, overall, myTags, myDaily, authors] = await Promise.all([ + fetchStatsSummary(personalFilter), + fetchStatsSummary({}), + fetchTagStats(personalFilter, 8), + fetchDailyStats(personalFilter, 7), + adminView ? fetchAuthorBreakdown() : Promise.resolve([]) + ]); return NextResponse.json({ - total, - latest: latest[0] || null, - top + mine, + overall, + myTags, + myDaily, + authors }); } diff --git a/app/api/admin/users/[userId]/route.ts b/app/api/admin/users/[userId]/route.ts new file mode 100644 index 0000000..7f4a031 --- /dev/null +++ b/app/api/admin/users/[userId]/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from "next/server"; +import { ObjectId } from "mongodb"; +import { z } from "zod"; +import { cookieName, isAdminSession, verifySession } from "@/lib/auth"; +import { getDb } from "@/lib/mongo"; +import { DEFAULT_DAILY_POST_LIMIT } from "@/lib/users"; + +async function requireAdmin(req: NextRequest) { + const token = req.cookies.get(cookieName)?.value; + const session = await verifySession(token); + if (!isAdminSession(session)) { + return null; + } + return session; +} + +export async function PATCH(req: NextRequest, { params }: { params: { userId: string } }) { + const session = await requireAdmin(req); + if (!session) { + return NextResponse.json({ error: "未授权" }, { status: 401 }); + } + if (!ObjectId.isValid(params.userId)) { + return NextResponse.json({ error: "用户 ID 无效" }, { status: 400 }); + } + + const body = await req.json().catch(() => ({})); + const schema = z.object({ + dailyPostLimit: z.number().int().min(0).max(1000) + }); + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + } + + const db = await getDb(); + const result = await db.collection("users").updateOne( + { _id: new ObjectId(params.userId) }, + { $set: { dailyPostLimit: parsed.data.dailyPostLimit } } + ); + if (result.matchedCount === 0) { + return NextResponse.json({ error: "用户不存在" }, { status: 404 }); + } + + return NextResponse.json({ + ok: true, + dailyPostLimit: parsed.data.dailyPostLimit ?? DEFAULT_DAILY_POST_LIMIT + }); +} + +export async function DELETE(req: NextRequest, { params }: { params: { userId: string } }) { + const session = await requireAdmin(req); + if (!session) { + return NextResponse.json({ error: "未授权" }, { status: 401 }); + } + if (!ObjectId.isValid(params.userId)) { + return NextResponse.json({ error: "用户 ID 无效" }, { status: 400 }); + } + if (session.uid === params.userId) { + return NextResponse.json({ error: "不能删除当前登录的管理员账号" }, { status: 400 }); + } + + const db = await getDb(); + const user = await db.collection("users").findOne({ _id: new ObjectId(params.userId) }); + if (!user) { + return NextResponse.json({ error: "用户不存在" }, { status: 404 }); + } + + await db.collection("posts").deleteMany({ + $or: [ + { ownerId: params.userId }, + { + $and: [ + { $or: [{ ownerId: { $exists: false } }, { ownerId: null }] }, + { + author: { + $in: [user.displayName, user.username].filter(Boolean) + } + } + ] + } + ] + }); + await db.collection("users").deleteOne({ _id: new ObjectId(params.userId) }); + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/login/route.ts b/app/api/login/route.ts index 77c0796..c006853 100644 --- a/app/api/login/route.ts +++ b/app/api/login/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; -import { signSession, cookieName } from "@/lib/auth"; +import { z } from "zod"; +import { signSession, cookieName, isAdminName } from "@/lib/auth"; import { getDb } from "@/lib/mongo"; import { verifyPassword } from "@/lib/password"; -import { z } from "zod"; export async function POST(req: NextRequest) { const body = await req.json().catch(() => ({})); @@ -13,7 +13,7 @@ export async function POST(req: NextRequest) { const parsed = schema.safeParse(body); if (!parsed.success) { - return NextResponse.json({ error: "请输入正确的用户名与密码" }, { status: 400 }); + return NextResponse.json({ error: "用户名或密码格式不正确" }, { status: 400 }); } const { username, password } = parsed.data; @@ -29,13 +29,20 @@ 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 exp = Date.now() + 24 * 60 * 60 * 1000; const token = await signSession({ - role: "admin", + role, iat: Date.now(), exp, uid: user._id?.toString(), - name + name, + username: user.username || username }); const res = NextResponse.json({ ok: true, name }); res.cookies.set(cookieName, token, { diff --git a/app/api/posts/[slug]/route.ts b/app/api/posts/[slug]/route.ts index 321be9f..6b1fddb 100644 --- a/app/api/posts/[slug]/route.ts +++ b/app/api/posts/[slug]/route.ts @@ -1,29 +1,30 @@ import { NextRequest, NextResponse } from "next/server"; -import { getDb } from "@/lib/mongo"; -import { ObjectId } from "mongodb"; import { z } from "zod"; +import { cookieName, verifySession } from "@/lib/auth"; +import { getDb } from "@/lib/mongo"; import { OPC_SIGNAL_VALUES } from "@/lib/opc"; +import { canDeletePost, canEditPost, serializePost } from "@/lib/posts"; + +async function getSessionFromRequest(req: NextRequest) { + const token = req.cookies.get(cookieName)?.value; + return verifySession(token); +} export async function GET(_: NextRequest, { params }: { params: { slug: string } }) { const db = await getDb(); - const post = await db.collection("posts").findOneAndUpdate( - { slug: params.slug }, - { $inc: { views: 1 } }, - { returnDocument: "after" } - ); + const post = await db.collection("posts").findOne({ slug: params.slug }); - if (!post || !post.value) { - return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (!post) { + return NextResponse.json({ error: "内容不存在" }, { status: 404 }); } - return NextResponse.json({ - ...post.value, - author: post.value.author ?? "佚名", - _id: (post.value._id as ObjectId)?.toString() - }); + await db.collection("posts").updateOne({ _id: post._id }, { $inc: { views: 1 } }); + return NextResponse.json(serializePost({ ...post, views: (post.views ?? 0) + 1 })); } export async function PATCH(req: NextRequest, { params }: { params: { slug: string } }) { + const session = await getSessionFromRequest(req); + const db = await getDb(); const body = await req.json().catch(() => ({})); const schema = z.object({ title: z.string().min(2).max(80).optional(), @@ -37,6 +38,14 @@ export async function PATCH(req: NextRequest, { params }: { params: { slug: stri return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); } + const existingPost = await db.collection("posts").findOne({ slug: params.slug }); + if (!existingPost) { + return NextResponse.json({ error: "内容不存在" }, { status: 404 }); + } + if (!canEditPost(existingPost, session)) { + return NextResponse.json({ error: "只能修改自己发布的内容" }, { status: 403 }); + } + const data = parsed.data; const set: Record = { updatedAt: new Date().toISOString() @@ -54,21 +63,26 @@ export async function PATCH(req: NextRequest, { params }: { params: { slug: stri if (typeof data.signal === "string") set.signal = data.signal; const update: Record = { $set: set }; - if (Object.keys(unset).length > 0) update.$unset = unset; - - const db = await getDb(); - const result = await db.collection("posts").updateOne({ slug: params.slug }, update); - if (result.matchedCount === 0) { - return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (Object.keys(unset).length > 0) { + update.$unset = unset; } + + await db.collection("posts").updateOne({ _id: existingPost._id }, update); return NextResponse.json({ ok: true }); } -export async function DELETE(_: NextRequest, { params }: { params: { slug: string } }) { +export async function DELETE(req: NextRequest, { params }: { params: { slug: string } }) { + const session = await getSessionFromRequest(req); const db = await getDb(); - const result = await db.collection("posts").deleteOne({ slug: params.slug }); - if (result.deletedCount === 0) { - return NextResponse.json({ error: "Not found" }, { status: 404 }); + const existingPost = await db.collection("posts").findOne({ slug: params.slug }); + + if (!existingPost) { + return NextResponse.json({ error: "内容不存在" }, { status: 404 }); } + if (!canDeletePost(existingPost, session)) { + return NextResponse.json({ error: "只有管理员可以删除内容" }, { status: 403 }); + } + + await db.collection("posts").deleteOne({ _id: existingPost._id }); return NextResponse.json({ ok: true }); } diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts index 3e382a6..40282f6 100644 --- a/app/api/posts/route.ts +++ b/app/api/posts/route.ts @@ -1,9 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import { getDb } from "@/lib/mongo"; import { z } from "zod"; -import { generateSlug } from "@/lib/slug"; -import { DEFAULT_OPC_SIGNAL, OPC_SIGNAL_VALUES } from "@/lib/opc"; import { cookieName, verifySession } from "@/lib/auth"; +import { getDb } from "@/lib/mongo"; +import { DEFAULT_OPC_SIGNAL, OPC_SIGNAL_VALUES } from "@/lib/opc"; +import { generateSlug } from "@/lib/slug"; +import { findUserById, getEffectiveDailyPostLimit, getShanghaiDayRange } from "@/lib/users"; const postSchema = z.object({ title: z.string().min(2).max(80), @@ -23,10 +24,10 @@ export async function GET() { .toArray(); return NextResponse.json( - posts.map((p) => ({ - ...p, - author: p.author ?? "佚名", - _id: p._id?.toString() + posts.map((post) => ({ + ...post, + author: post.author ?? "匿名", + _id: post._id?.toString() })) ); } @@ -39,36 +40,53 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); } - const data = parsed.data; const token = req.cookies.get(cookieName)?.value; const session = await verifySession(token); - if (!session) { - return NextResponse.json({ error: "未登录" }, { status: 401 }); + if (!session?.uid) { + return NextResponse.json({ error: "请先登录后再发布" }, { status: 401 }); } - const author = session.name ?? "佚名"; + + const user = await findUserById(session.uid); + if (!user) { + return NextResponse.json({ error: "用户不存在或已被删除" }, { status: 401 }); + } + + const limit = getEffectiveDailyPostLimit(user); + const { startIso, endIso } = getShanghaiDayRange(); + const db = await getDb(); + const todayCount = await db.collection("posts").countDocuments({ + ownerId: session.uid, + createdAt: { $gte: startIso, $lt: endIso } + }); + if (todayCount >= limit) { + return NextResponse.json({ error: `今日发布数量已达到上限(${limit})` }, { status: 429 }); + } + + const data = parsed.data; + const author = session.name ?? "匿名"; const now = new Date().toISOString(); let slug = generateSlug(); - const db = await getDb(); for (let attempt = 0; attempt < 5; attempt += 1) { const exists = await db.collection("posts").findOne({ slug }); if (!exists) break; slug = generateSlug(); } + const existsAfter = await db.collection("posts").findOne({ slug }); if (existsAfter) { - return NextResponse.json({ error: "Failed to allocate slug" }, { status: 500 }); + return NextResponse.json({ error: "生成内容标识失败" }, { status: 500 }); } - const doc = { + await db.collection("posts").insertOne({ ...data, author, + ownerId: session.uid, slug, createdAt: now, updatedAt: now, views: 0 - }; + }); - await db.collection("posts").insertOne(doc); - return NextResponse.json({ ok: true, slug }); + 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 a8b708b..4a584bf 100644 --- a/app/api/register/route.ts +++ b/app/api/register/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { signSession, cookieName, isAdminName } from "@/lib/auth"; +import { DEFAULT_DAILY_POST_LIMIT } from "@/lib/users"; import { getDb } from "@/lib/mongo"; import { hashPassword } from "@/lib/password"; -import { signSession, cookieName } from "@/lib/auth"; -import { z } from "zod"; export async function POST(req: NextRequest) { const body = await req.json().catch(() => ({})); @@ -13,11 +14,13 @@ export async function POST(req: NextRequest) { }); const parsed = schema.safeParse(body); if (!parsed.success) { - return NextResponse.json({ error: "请填写有效的用户名与密码" }, { status: 400 }); + return NextResponse.json({ error: "用户名或密码格式不正确" }, { status: 400 }); } 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 }); @@ -30,7 +33,9 @@ export async function POST(req: NextRequest) { const doc = { username, usernameLower, - displayName: displayName || username, + displayName: resolvedDisplayName, + role, + dailyPostLimit: DEFAULT_DAILY_POST_LIMIT, passwordHash: hash, passwordSalt: salt, createdAt: now @@ -41,11 +46,12 @@ export async function POST(req: NextRequest) { const name = doc.displayName; const exp = Date.now() + 24 * 60 * 60 * 1000; const token = await signSession({ - role: "admin", + role, iat: Date.now(), exp, uid: result.insertedId?.toString(), - name + name, + username }); const res = NextResponse.json({ ok: true, name }); res.cookies.set(cookieName, token, { diff --git a/app/layout.tsx b/app/layout.tsx index 845e04c..3f5ef78 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,17 +3,17 @@ import type { Metadata } from "next"; import Link from "next/link"; import { ReactNode } from "react"; import { cookies } from "next/headers"; -import { cookieName, getAdminName, verifySession } from "@/lib/auth"; +import { cookieName, verifySession } from "@/lib/auth"; export const metadata: Metadata = { title: "OPC Solo Feed", - description: "轻量信息流发布平台,支持 Markdown 与多端浏览。" + description: "一个支持公开浏览、用户发布和个人管理的轻量信息流。" }; export default async function RootLayout({ children }: { children: ReactNode }) { const token = cookies().get(cookieName)?.value; const session = await verifySession(token); - const userName = session?.name ?? getAdminName(); + const userName = session?.name ?? "访客"; return ( @@ -24,8 +24,9 @@ export default async function RootLayout({ children }: { children: ReactNode }) Solo - solo-feed + 信息流 +
+ {session ? ( {userName} ) : ( - - 登录 - +
+ + 登录 + + + 注册 + +
)}
+
{children}
+
- 素材来自 img.020417.xyz - 为朋友制作 · {new Date().getFullYear()} + Solo company feed 信息流 + {new Date().getFullYear()}
diff --git a/app/login/page.tsx b/app/login/page.tsx index 36bc05c..0e9752f 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -7,15 +7,17 @@ export const metadata = { export default function LoginPage() { return (
-

管理员登录

-

使用已注册账号登录。

+

登录

+

+ 登录后可以发布内容、修改自己的内容,并查看个人统计。 +

还没有账号?{" "} - 去注册 + 立即注册

diff --git a/app/p/[slug]/page.tsx b/app/p/[slug]/page.tsx index d8f4c02..c98b3c8 100644 --- a/app/p/[slug]/page.tsx +++ b/app/p/[slug]/page.tsx @@ -1,11 +1,10 @@ -import { getDb } from "@/lib/mongo"; -import { MarkdownViewer } from "@/components/MarkdownViewer"; import { notFound } from "next/navigation"; -import { normalizeImageUrl } from "@/lib/normalize"; -import { Post } from "@/types/post"; +import { MarkdownViewer } from "@/components/MarkdownViewer"; import { SharePanel } from "@/components/SharePanel"; +import { getDb } from "@/lib/mongo"; +import { serializePost } from "@/lib/posts"; +import { normalizeImageUrl } from "@/lib/normalize"; import { getSiteUrl } from "@/lib/site"; -import { DEFAULT_OPC_SIGNAL } from "@/lib/opc"; export const dynamic = "force-dynamic"; @@ -13,33 +12,23 @@ type Props = { params: { slug: string }; }; -async function fetchPost(slug: string): Promise { +async function fetchPost(slug: string) { const db = await getDb(); const doc = await db.collection("posts").findOne({ slug }); - if (!doc) return null; - // fire-and-forget view increment - db.collection("posts").updateOne({ slug }, { $inc: { views: 1 } }).catch(() => {}); - return { - _id: doc._id?.toString(), - title: doc.title ?? "", - slug: doc.slug ?? slug, - markdown: doc.markdown ?? "", - cover: doc.cover, - tags: doc.tags ?? [], - signal: doc.signal ?? DEFAULT_OPC_SIGNAL, - author: doc.author ?? "佚名", - createdAt: doc.createdAt ?? new Date().toISOString(), - updatedAt: doc.updatedAt ?? doc.createdAt ?? new Date().toISOString(), - views: doc.views ?? 0 - }; + if (!doc) { + return null; + } + + db.collection("posts").updateOne({ _id: doc._id }, { $inc: { views: 1 } }).catch(() => {}); + return serializePost({ ...doc, views: (doc.views ?? 0) + 1 }); } export default async function PostPage({ params }: Props) { const post = await fetchPost(params.slug); - if (!post) { notFound(); } + const coverUrl = normalizeImageUrl(post.cover); const shareUrl = `${getSiteUrl()}/p/${post.slug}`; @@ -51,7 +40,11 @@ export default async function PostPage({ params }: Props) {

- {post.author || "佚名"} · {new Date(post.createdAt).toLocaleString("zh-CN", { hour12: false })} + {post.author || "匿名"} |{" "} + {new Date(post.createdAt).toLocaleString("zh-CN", { + hour12: false, + timeZone: "Asia/Shanghai" + })}

{coverUrl ? ( { const db = await getDb(); const filters: Record[] = []; + if (params.tag) { filters.push({ tags: params.tag }); } + const searchFilter = buildSearchFilter(params.q); if (searchFilter) { filters.push(searchFilter as Record); } + const filter = filters.length > 0 ? { $and: filters } : {}; const total = await db.collection("posts").countDocuments(filter); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); @@ -32,12 +36,9 @@ async function fetchPosts(params: { .skip((page - 1) * PAGE_SIZE) .limit(PAGE_SIZE) .toArray(); + return { - posts: docs.map((d: any) => ({ - ...d, - author: d.author ?? "佚名", - _id: d._id?.toString() - })), + posts: docs.map((doc: any) => serializePost(doc)), total, page, totalPages @@ -80,9 +81,9 @@ export default async function HomePage({ return (
-

OPC 信息流

+

OPC Feed

- 用 Markdown 记录进展、发布动态,让一人公司也有自己的信息流平台。 + 未登录用户可以浏览全部内容;登录用户只能发布和修改自己的内容。

{tag ? (
@@ -124,9 +125,7 @@ export default async function HomePage({ {posts.length === 0 ? ( -

- 暂无内容,去后台发布吧。 -

+

暂无内容。

) : (
{posts.map((post) => ( @@ -142,13 +141,21 @@ export default async function HomePage({
上一页 = totalPages ? "pointer-events-none text-slate-300" : "bg-white/80 ring-1 ring-slate-200 hover:text-brand-600"}`} + className={`rounded-full px-3 py-1 ${ + currentPage >= totalPages + ? "pointer-events-none text-slate-300" + : "bg-white/80 ring-1 ring-slate-200 hover:text-brand-600" + }`} > 下一页 diff --git a/app/register/page.tsx b/app/register/page.tsx index b8da842..e07d87b 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -7,8 +7,10 @@ export const metadata = { export default function RegisterPage() { return (
-

注册账号

-

注册后 24 小时内有效,过期需重新登录。

+

注册

+

+ 注册后可以发布和修改自己的内容,默认每个用户每天最多发布 10 条。 +

diff --git a/app/register/register-form.tsx b/app/register/register-form.tsx index 9cf686a..89c8014 100644 --- a/app/register/register-form.tsx +++ b/app/register/register-form.tsx @@ -58,7 +58,7 @@ export default function RegisterForm() { value={displayName} onChange={(e) => setDisplayName(e.target.value)} className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none" - placeholder="不填则使用用户名" + placeholder="用于内容展示的名称" /> diff --git a/app/rss/route.ts b/app/rss/route.ts index 47ec14f..88eb74d 100644 --- a/app/rss/route.ts +++ b/app/rss/route.ts @@ -3,7 +3,7 @@ import { getSiteUrl } from "@/lib/site"; export const dynamic = "force-dynamic"; -function escapeXml(input: string): string { +function escapeXml(input: string) { return input .replace(/&/g, "&") .replace(/*_~\-]+/g, " ") .replace(/\s+/g, " ") .trim(); @@ -42,7 +42,7 @@ export async function GET() { const pubDate = new Date(post.createdAt || post.updatedAt || Date.now()).toUTCString(); return [ "", - `${escapeXml(post.title || "Untitled")}`, + `${escapeXml(post.title || "未命名")}`, `${escapeXml(link)}`, `${escapeXml(link)}`, `${pubDate}`, @@ -55,9 +55,9 @@ export async function GET() { const xml = ` -不务正业的木子 +OPC Feed ${escapeXml(siteUrl)} -less is more +Public feed ${now} ${items} diff --git a/app/stats/page.tsx b/app/stats/page.tsx new file mode 100644 index 0000000..d504bf2 --- /dev/null +++ b/app/stats/page.tsx @@ -0,0 +1,248 @@ +import { cookies } from "next/headers"; +import { cookieName, isAdminSession, verifySession } from "@/lib/auth"; +import { buildOwnedPostFilter } from "@/lib/posts"; +import { fetchAuthorBreakdown, fetchDailyStats, fetchStatsSummary, fetchTagStats } from "@/lib/stats"; + +export const dynamic = "force-dynamic"; + +const cardClass = "rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100"; + +function formatTime(input?: string | null) { + if (!input) { + return "暂无数据"; + } + + return new Date(input).toLocaleString("zh-CN", { + hour12: false, + timeZone: "Asia/Shanghai" + }); +} + +function MetricCard({ + label, + value +}: { + label: string; + value: number | string; +}) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function ProgressList({ + items, + emptyText, + colorClass, + valueLabel +}: { + items: Array<{ label: string; count: number }>; + emptyText: string; + colorClass: string; + valueLabel?: (count: number) => string; +}) { + const maxValue = Math.max(...items.map((item) => item.count), 1); + + if (items.length === 0) { + return

{emptyText}

; + } + + return ( +
+ {items.map((item) => ( +
+
+ {item.label} + {valueLabel ? valueLabel(item.count) : item.count} +
+
+
+
+
+ ))} +
+ ); +} + +function TopPosts({ + title, + items, + latestText +}: { + title: string; + items: Array<{ title: string; slug: string; views: number }>; + latestText: string; +}) { + return ( +
+

{title}

+
+ {items.length === 0 ? ( +

暂无内容。

+ ) : ( + items.map((item) => ( +
+ + {item.title} + + {item.views} 次浏览 +
+ )) + )} +
+

{latestText}

+
+ ); +} + +export default async function StatsPage() { + const token = cookies().get(cookieName)?.value; + const session = await verifySession(token); + const personalFilter = buildOwnedPostFilter(session); + const adminView = isAdminSession(session); + + const [mySummary, overallSummary, myTags, myDaily, authorBreakdown] = await Promise.all([ + fetchStatsSummary(personalFilter), + fetchStatsSummary({}), + fetchTagStats(personalFilter, 8), + fetchDailyStats(personalFilter, 7), + adminView ? fetchAuthorBreakdown() : Promise.resolve([]) + ]); + + return ( +
+
+
+
+

统计中心

+

+ 登录用户可以查看自己的发布统计和全站汇总,管理员还可以查看所有用户的统计结果。 +

+
+ + 返回后台 + +
+
+ +
+
+

我的统计

+ {session?.name || "未知用户"} +
+ +
+ + + + +
+ +
+ + +
+

常用标签

+
+ ({ label: `#${item.tag}`, count: item.count }))} + emptyText="暂无标签统计。" + colorClass="bg-brand-500/80" + /> +
+
+
+ +
+

最近 7 天

+
+ ({ label: item.label, count: item.count }))} + emptyText="最近 7 天暂无发布记录。" + colorClass="bg-emerald-500/80" + /> +
+
+
+ +
+

全站统计

+ +
+ + + + + +
+ + +
+ + {adminView ? ( +
+
+
+

全用户统计

+

管理员可以在这里查看每个用户的发布量和浏览汇总。

+
+
+ + {authorBreakdown.length === 0 ? ( +

暂无用户统计数据。

+ ) : ( +
+ + + + + + + + + + + + {authorBreakdown.map((item) => ( + + + + + + + + ))} + +
用户发布数浏览数篇均浏览最近发布时间
{item.author}{item.count}{item.totalViews}{item.avgViews}{formatTime(item.latestCreatedAt)}
+
+ )} +
+ ) : null} +
+ ); +} diff --git a/app/tags/[tag]/page.tsx b/app/tags/[tag]/page.tsx index 1e515cc..65f6f42 100644 --- a/app/tags/[tag]/page.tsx +++ b/app/tags/[tag]/page.tsx @@ -1,7 +1,8 @@ -import { getDb } from "@/lib/mongo"; import { PostCard } from "@/components/PostCard"; -import { Post } from "@/types/post"; +import { getDb } from "@/lib/mongo"; +import { serializePost } from "@/lib/posts"; import { buildSearchFilter } from "@/lib/search"; +import { Post } from "@/types/post"; export const dynamic = "force-dynamic"; @@ -18,6 +19,7 @@ async function fetchTagPosts(params: { if (searchFilter) { filters.push(searchFilter as Record); } + const filter = { $and: filters }; const total = await db.collection("posts").countDocuments(filter); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); @@ -29,12 +31,9 @@ async function fetchTagPosts(params: { .skip((page - 1) * PAGE_SIZE) .limit(PAGE_SIZE) .toArray(); + return { - posts: docs.map((d: any) => ({ - ...d, - author: d.author ?? "佚名", - _id: d._id?.toString() - })), + posts: docs.map((doc: any) => serializePost(doc)), total, page, totalPages @@ -64,7 +63,7 @@ export default async function TagDetailPage({ return (
-

标签 · {tag}

+

标签 / {tag}

共 {total} 条内容

@@ -76,7 +75,7 @@ export default async function TagDetailPage({ + {canDelete ? ( + + ) : null}
))} diff --git a/components/AdminUserManager.tsx b/components/AdminUserManager.tsx new file mode 100644 index 0000000..fe09608 --- /dev/null +++ b/components/AdminUserManager.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { useMemo, useState } from "react"; + +type ManagedPost = { + slug: string; + title: string; + createdAt: string; +}; + +type ManagedUser = { + id: string; + username: string; + displayName: string; + role: "admin" | "user"; + dailyPostLimit: number; + postCount: number; + todayPostCount: number; + posts: ManagedPost[]; +}; + +export function AdminUserManager({ + initialUsers, + currentUserId +}: { + initialUsers: ManagedUser[]; + currentUserId: string; +}) { + const [users, setUsers] = useState(initialUsers); + const [query, setQuery] = useState(""); + const [savingId, setSavingId] = useState(null); + + const visibleUsers = useMemo(() => { + const keyword = query.trim().toLowerCase(); + if (!keyword) return users; + return users.filter( + (user) => + user.username.toLowerCase().includes(keyword) || + user.displayName.toLowerCase().includes(keyword) + ); + }, [query, users]); + + async function handleDeletePost(slug: string) { + if (!window.confirm("确定要删除这条内容吗?")) return; + + const res = await fetch(`/api/posts/${slug}`, { method: "DELETE" }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + alert(data.error || "删除失败"); + return; + } + + setUsers((prev) => + prev.map((user) => { + const target = user.posts.find((post) => post.slug === slug); + if (!target) return user; + return { + ...user, + postCount: Math.max(0, user.postCount - 1), + todayPostCount: + toShanghaiDateKey(target.createdAt) === toShanghaiDateKey(new Date().toISOString()) + ? Math.max(0, user.todayPostCount - 1) + : user.todayPostCount, + posts: user.posts.filter((post) => post.slug !== slug) + }; + }) + ); + } + + async function handleSaveLimit(userId: string, dailyPostLimit: number) { + setSavingId(userId); + try { + const res = await fetch(`/api/admin/users/${userId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dailyPostLimit }) + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + alert(data.error || "保存失败"); + return; + } + + setUsers((prev) => + prev.map((user) => + user.id === userId ? { ...user, dailyPostLimit: data.dailyPostLimit ?? dailyPostLimit } : user + ) + ); + } finally { + setSavingId(null); + } + } + + async function handleDeleteUser(userId: string) { + if (!window.confirm("确定要删除该用户及其全部内容吗?")) return; + + const res = await fetch(`/api/admin/users/${userId}`, { method: "DELETE" }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + alert(data.error || "删除失败"); + return; + } + + setUsers((prev) => prev.filter((user) => user.id !== userId)); + } + + return ( +
+
+
+

用户管理

+

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

+
+ setQuery(e.target.value)} + placeholder="搜索用户名" + className="w-44 rounded-full border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none" + /> +
+ +
+ {visibleUsers.length === 0 ? ( +

没有匹配的用户。

+ ) : ( + visibleUsers.map((user) => ( + + )) + )} +
+
+ ); +} + +function toShanghaiDateKey(input: string) { + const date = new Date(input); + const shifted = new Date(date.getTime() + 8 * 60 * 60 * 1000); + return shifted.toISOString().slice(0, 10); +} + +function AdminUserCard({ + user, + currentUserId, + saving, + onDeletePost, + onDeleteUser, + onSaveLimit +}: { + user: ManagedUser; + currentUserId: string; + saving: boolean; + onDeletePost: (slug: string) => Promise; + onDeleteUser: (userId: string) => Promise; + onSaveLimit: (userId: string, dailyPostLimit: number) => Promise; +}) { + const [limit, setLimit] = useState(user.dailyPostLimit); + + return ( +
+
+
+

+ {user.displayName} (@{user.username}) +

+

+ 角色:{user.role === "admin" ? "管理员" : "用户"} | 总发布:{user.postCount} | 今日发布:{user.todayPostCount} +

+
+ +
+ setLimit(Number(e.target.value))} + className="w-24 rounded-full border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none" + /> + + {user.id !== currentUserId ? ( + + ) : null} +
+
+ +
+ {user.posts.length === 0 ? ( +

该用户暂无内容。

+ ) : ( + user.posts.map((post) => ( +
+
+ + {post.title} + +

+ {new Date(post.createdAt).toLocaleString("zh-CN", { + hour12: false, + timeZone: "Asia/Shanghai" + })} +

+
+ +
+ )) + )} +
+
+ ); +} diff --git a/components/CreatePostForm.tsx b/components/CreatePostForm.tsx index 0dbe838..d54c596 100644 --- a/components/CreatePostForm.tsx +++ b/components/CreatePostForm.tsx @@ -2,19 +2,26 @@ import { FormEvent, useState } from "react"; import { useRouter } from "next/navigation"; -import { normalizeImageUrl } from "@/lib/normalize"; import { MarkdownPreview } from "@/components/MarkdownPreview"; +import { normalizeImageUrl } from "@/lib/normalize"; -const defaultIntro = `## solo-feed 记录模板 +const defaultIntro = `## 今日进展 - 今日目标: -- 产品/交付: -- 客户/收入: -- 增长/运营: -- 学习/复盘: -`; +- 产品 / 交付: +- 客户 / 收入: +- 增长 / 运营: +- 学习 / 复盘:`; -export function CreatePostForm({ availableTags = [] }: { availableTags?: string[] }) { +export function CreatePostForm({ + availableTags = [], + publishLimit = 10, + todayCount = 0 +}: { + availableTags?: string[]; + publishLimit?: number; + todayCount?: number; +}) { const [title, setTitle] = useState(""); const [cover, setCover] = useState(""); const [tags, setTags] = useState(""); @@ -27,7 +34,7 @@ export function CreatePostForm({ availableTags = [] }: { availableTags?: string[ const current = new Set( tags .split(",") - .map((t) => t.trim()) + .map((item) => item.trim()) .filter(Boolean) ); current.add(tag); @@ -50,7 +57,7 @@ export function CreatePostForm({ availableTags = [] }: { availableTags?: string[ new Set( tags .split(",") - .map((t) => t.trim()) + .map((item) => item.trim()) .filter(Boolean) ) ) @@ -60,11 +67,12 @@ export function CreatePostForm({ availableTags = [] }: { availableTags?: string[ if (!res.ok) { const data = await res.json().catch(() => ({})); alert(data.error ? JSON.stringify(data.error) : "发布失败"); - } else { - const data = await res.json(); - router.push(`/p/${data.slug}`); - router.refresh(); + return; } + + const data = await res.json(); + router.push(`/p/${data.slug}`); + router.refresh(); } finally { setLoading(false); } @@ -74,25 +82,12 @@ export function CreatePostForm({ availableTags = [] }: { availableTags?: string[
-

发布新内容

-

以信息流的方式记录进展、沉淀经验。

-
-
- - +

发布内容

+

用信息流的形式记录今天的进展。

+ + 今日已发 {todayCount} / {publishLimit} +
- Markdown 内容 +
+ 正文 +
+ + +
+
+ {!preview ? (