Implement per-user post permissions and move stats into dedicated pages

This commit is contained in:
爱喝水的木子
2026-03-20 11:51:58 +08:00
parent 17f5f6adcb
commit 466b7c3fb6
29 changed files with 1416 additions and 475 deletions

View File

@@ -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<Post | null> {
async function fetchPost(
slug: string,
session: Awaited<ReturnType<typeof verifySession>>
): Promise<Post | null> {
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();
}

View File

@@ -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<ReturnType<typeof verifySession>>) {
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<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((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<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 fetchAuthorStats() {
async function fetchManagedUsers(): Promise<ManagedUser[]> {
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<string, number>();
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<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 }>>();
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 (
<div className="space-y-6">
<section className="grid gap-4 sm:grid-cols-3">
<div className={cardClass}>
<p className="text-sm text-slate-500"></p>
<p className="mt-1 text-3xl font-semibold">{stats.total}</p>
</div>
<div className={cardClass}>
<p className="text-sm text-slate-500"></p>
<p className="mt-1 text-base font-semibold">{stats.latest?.title ?? "暂无"}</p>
<p className="text-xs text-slate-500">
{stats.latest?.createdAt
? new Date(stats.latest.createdAt).toLocaleString("zh-CN", { hour12: false })
: ""}
<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>
<div className={cardClass}>
<p className="text-sm text-slate-500">Top </p>
<ul className="mt-1 space-y-1 text-sm">
{stats.top.map((item: any) => (
<li key={item.slug} className="flex items-center justify-between">
<span className="truncate">{item.title}</span>
<span className="text-slate-500">{item.views ?? 0} </span>
</li>
))}
</ul>
</div>
</section>
<section className="rounded-2xl bg-white/80 p-5 shadow-sm ring-1 ring-slate-100">
<h3 className="text-lg font-semibold"></h3>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className={cardClass}>
<p className="text-sm text-slate-500"></p>
<p className="mt-1 text-2xl font-semibold">{stats.totalViews}</p>
</div>
<div className={cardClass}>
<p className="text-sm text-slate-500"></p>
<p className="mt-1 text-2xl font-semibold">{stats.avgViews}</p>
</div>
<div className={cardClass}>
<p className="text-sm text-slate-500"></p>
<p className="mt-1 text-2xl font-semibold">{stats.tagCount}</p>
</div>
<div className={cardClass}>
<p className="text-sm text-slate-500"></p>
<p className="mt-1 text-2xl font-semibold">{stats.authorCount}</p>
</div>
</div>
<CreatePostForm
availableTags={availableTags}
publishLimit={publishQuota.limit}
todayCount={publishQuota.todayCount}
/>
<div className="mt-6 grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100">
<h4 className="text-sm font-semibold text-slate-700"></h4>
<div className="mt-3 space-y-3">
{tagStats.length === 0 ? (
<p className="text-xs text-slate-500"></p>
) : (
tagStats.map((item) => (
<div key={item.tag} className="space-y-1">
<div className="flex items-center justify-between text-xs text-slate-600">
<span>#{item.tag}</span>
<span>{item.count}</span>
</div>
<div className="h-2 rounded-full bg-slate-100">
<div
className="h-2 rounded-full bg-brand-500/80"
style={{ width: `${(item.count / tagMax) * 100}%` }}
/>
</div>
</div>
))
)}
</div>
</div>
<AdminPostList initialPosts={recentPosts} canDelete={false} />
<div className="rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100">
<h4 className="text-sm font-semibold text-slate-700"></h4>
<div className="mt-3 space-y-3">
{authorStats.length === 0 ? (
<p className="text-xs text-slate-500"></p>
) : (
authorStats.map((item) => (
<div key={item.author} className="space-y-1">
<div className="flex items-center justify-between text-xs text-slate-600">
<span>{item.author}</span>
<span>{item.count}</span>
</div>
<div className="h-2 rounded-full bg-slate-100">
<div
className="h-2 rounded-full bg-indigo-500/80"
style={{ width: `${(item.count / authorMax) * 100}%` }}
/>
</div>
</div>
))
)}
</div>
</div>
</div>
<div className="mt-6 rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100">
<h4 className="text-sm font-semibold text-slate-700"> 7 </h4>
<div className="mt-3 space-y-3">
{dailyStats.map((item) => (
<div key={item.label} className="space-y-1">
<div className="flex items-center justify-between text-xs text-slate-600">
<span>{item.label}</span>
<span>{item.count}</span>
</div>
<div className="h-2 rounded-full bg-slate-100">
<div
className="h-2 rounded-full bg-emerald-500/80"
style={{ width: `${(item.count / dailyMax) * 100}%` }}
/>
</div>
</div>
))}
</div>
</div>
</section>
<CreatePostForm availableTags={availableTags} />
<AdminPostList initialPosts={recentPosts} />
{adminView ? <AdminUserManager initialUsers={managedUsers} currentUserId={session?.uid || ""} /> : null}
</div>
);
}

View File

@@ -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
});
}

View File

@@ -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 });
}

View File

@@ -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, {

View File

@@ -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<string, unknown> = {
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<string, unknown> = { $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 });
}

View File

@@ -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 });
}

View File

@@ -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, {

View File

@@ -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 (
<html lang="zh-CN">
@@ -24,8 +24,9 @@ export default async function RootLayout({ children }: { children: ReactNode })
<span className="rounded-xl bg-brand-100 px-2 py-1 text-xs font-bold uppercase text-brand-700">
Solo
</span>
<span>solo-feed</span>
<span></span>
</Link>
<div className="flex items-center gap-3 text-sm text-slate-600">
<nav className="flex items-center gap-3">
<Link href="/" className="hover:text-brand-600">
@@ -34,28 +35,42 @@ export default async function RootLayout({ children }: { children: ReactNode })
<Link href="/tags" className="hover:text-brand-600">
</Link>
<Link href="/stats" className="hover:text-brand-600">
</Link>
<Link href="/admin" className="hover:text-brand-600">
</Link>
</nav>
{session ? (
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">
{userName}
</span>
) : (
<Link
href="/login"
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700 hover:text-brand-600"
>
</Link>
<div className="flex items-center gap-2">
<Link
href="/login"
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700 hover:text-brand-600"
>
</Link>
<Link
href="/register"
className="rounded-full bg-brand-600 px-3 py-1 text-xs font-medium text-white hover:bg-brand-700"
>
</Link>
</div>
)}
</div>
</header>
<main className="flex-1">{children}</main>
<footer className="mt-10 flex items-center justify-between border-t border-slate-200 pt-6 text-xs text-slate-500">
<span> img.020417.xyz</span>
<span> · {new Date().getFullYear()}</span>
<span>Solo company feed </span>
<span>{new Date().getFullYear()}</span>
</footer>
</div>
</body>

View File

@@ -7,15 +7,17 @@ export const metadata = {
export default function LoginPage() {
return (
<div className="mx-auto max-w-md rounded-2xl bg-white/90 p-8 shadow-sm ring-1 ring-slate-100">
<h1 className="text-2xl font-semibold text-slate-900"></h1>
<p className="mt-2 text-sm text-slate-600">使</p>
<h1 className="text-2xl font-semibold text-slate-900"></h1>
<p className="mt-2 text-sm text-slate-600">
</p>
<div className="mt-6">
<LoginForm />
</div>
<p className="mt-4 text-sm text-slate-500">
{" "}
<a href="/register" className="text-brand-600 hover:text-brand-700">
</a>
</p>
</div>

View File

@@ -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<Post | null> {
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) {
<SharePanel url={shareUrl} />
</div>
<p className="mt-2 text-sm text-slate-500">
{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"
})}
</p>
{coverUrl ? (
<img

View File

@@ -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";
@@ -14,13 +15,16 @@ async function fetchPosts(params: {
}): Promise<{ posts: Post[]; total: number; page: number; totalPages: number }> {
const db = await getDb();
const filters: Record<string, unknown>[] = [];
if (params.tag) {
filters.push({ tags: params.tag });
}
const searchFilter = buildSearchFilter(params.q);
if (searchFilter) {
filters.push(searchFilter as Record<string, unknown>);
}
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 (
<div className="space-y-6">
<div className="rounded-2xl bg-gradient-to-r from-brand-500 to-brand-700 p-6 text-white shadow-lg">
<h1 className="text-2xl font-semibold">OPC </h1>
<h1 className="text-2xl font-semibold">OPC Feed</h1>
<p className="mt-2 text-sm text-white/80">
Markdown
</p>
{tag ? (
<div className="mt-3 inline-flex items-center gap-2 rounded-full bg-white/15 px-3 py-1 text-xs font-medium">
@@ -124,9 +125,7 @@ export default async function HomePage({
</form>
{posts.length === 0 ? (
<p className="rounded-xl bg-white/70 p-4 text-sm text-slate-500 ring-1 ring-slate-100">
</p>
<p className="rounded-xl bg-white/70 p-4 text-sm text-slate-500 ring-1 ring-slate-100"></p>
) : (
<div className="grid gap-4">
{posts.map((post) => (
@@ -142,13 +141,21 @@ export default async function HomePage({
<div className="flex items-center gap-2">
<a
href={buildHref(Math.max(1, currentPage - 1))}
className={`rounded-full px-3 py-1 ${currentPage <= 1 ? "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 <= 1
? "pointer-events-none text-slate-300"
: "bg-white/80 ring-1 ring-slate-200 hover:text-brand-600"
}`}
>
</a>
<a
href={buildHref(Math.min(totalPages, currentPage + 1))}
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"}`}
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"
}`}
>
</a>

View File

@@ -7,8 +7,10 @@ export const metadata = {
export default function RegisterPage() {
return (
<div className="mx-auto max-w-md rounded-2xl bg-white/90 p-8 shadow-sm ring-1 ring-slate-100">
<h1 className="text-2xl font-semibold text-slate-900"></h1>
<p className="mt-2 text-sm text-slate-600"> 24 </p>
<h1 className="text-2xl font-semibold text-slate-900"></h1>
<p className="mt-2 text-sm text-slate-600">
10
</p>
<div className="mt-6">
<RegisterForm />
</div>

View File

@@ -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="用于内容展示的名称"
/>
</label>

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
@@ -12,12 +12,12 @@ function escapeXml(input: string): string {
.replace(/'/g, "&apos;");
}
function stripMarkdown(input: string): string {
function stripMarkdown(input: string) {
return input
.replace(/```[\s\S]*?```/g, " ")
.replace(/`[^`]*`/g, " ")
.replace(/!\[[^\]]*]\([^)]+\)/g, " ")
.replace(/\[[^\]]*]\([^)]+\)/g, "$1")
.replace(/\[([^\]]*)\]\([^)]+\)/g, "$1")
.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 [
"<item>",
`<title>${escapeXml(post.title || "Untitled")}</title>`,
`<title>${escapeXml(post.title || "未命名")}</title>`,
`<link>${escapeXml(link)}</link>`,
`<guid>${escapeXml(link)}</guid>`,
`<pubDate>${pubDate}</pubDate>`,
@@ -55,9 +55,9 @@ export async function GET() {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>不务正业的木子</title>
<title>OPC Feed</title>
<link>${escapeXml(siteUrl)}</link>
<description>less is more</description>
<description>Public feed</description>
<lastBuildDate>${now}</lastBuildDate>
${items}
</channel>

248
app/stats/page.tsx Normal file
View File

@@ -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 (
<div className={cardClass}>
<p className="text-sm text-slate-500">{label}</p>
<p className="mt-2 text-3xl font-semibold text-slate-900">{value}</p>
</div>
);
}
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 <p className="text-sm text-slate-500">{emptyText}</p>;
}
return (
<div className="space-y-3">
{items.map((item) => (
<div key={item.label} className="space-y-1">
<div className="flex items-center justify-between text-xs text-slate-600">
<span>{item.label}</span>
<span>{valueLabel ? valueLabel(item.count) : item.count}</span>
</div>
<div className="h-2 rounded-full bg-slate-100">
<div
className={`h-2 rounded-full ${colorClass}`}
style={{ width: `${(item.count / maxValue) * 100}%` }}
/>
</div>
</div>
))}
</div>
);
}
function TopPosts({
title,
items,
latestText
}: {
title: string;
items: Array<{ title: string; slug: string; views: number }>;
latestText: string;
}) {
return (
<div className={cardClass}>
<h3 className="text-base font-semibold text-slate-900">{title}</h3>
<div className="mt-3 space-y-3">
{items.length === 0 ? (
<p className="text-sm text-slate-500"></p>
) : (
items.map((item) => (
<div key={item.slug} className="flex items-center justify-between gap-3 text-sm">
<a href={`/p/${item.slug}`} className="truncate text-slate-800 hover:text-brand-600">
{item.title}
</a>
<span className="shrink-0 text-slate-500">{item.views} </span>
</div>
))
)}
</div>
<p className="mt-4 text-xs text-slate-500">{latestText}</p>
</div>
);
}
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 (
<div className="space-y-6">
<section className="rounded-2xl bg-white/80 p-6 shadow-sm ring-1 ring-slate-100">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-slate-900"></h1>
<p className="mt-2 text-sm text-slate-500">
</p>
</div>
<a
href="/admin"
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>
</section>
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900"></h2>
<span className="text-sm text-slate-500">{session?.name || "未知用户"}</span>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard label="我的发布数" value={mySummary.total} />
<MetricCard label="我的总浏览" value={mySummary.totalViews} />
<MetricCard label="篇均浏览" value={mySummary.avgViews} />
<MetricCard label="我的标签数" value={mySummary.tagCount} />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<TopPosts
title="热门内容"
items={mySummary.top}
latestText={
mySummary.latest
? `最近发布:${mySummary.latest.title} · ${formatTime(mySummary.latest.createdAt)}`
: "最近发布:暂无数据"
}
/>
<div className={cardClass}>
<h3 className="text-base font-semibold text-slate-900"></h3>
<div className="mt-3">
<ProgressList
items={myTags.map((item) => ({ label: `#${item.tag}`, count: item.count }))}
emptyText="暂无标签统计。"
colorClass="bg-brand-500/80"
/>
</div>
</div>
</div>
<div className={cardClass}>
<h3 className="text-base font-semibold text-slate-900"> 7 </h3>
<div className="mt-3">
<ProgressList
items={myDaily.map((item) => ({ label: item.label, count: item.count }))}
emptyText="最近 7 天暂无发布记录。"
colorClass="bg-emerald-500/80"
/>
</div>
</div>
</section>
<section className="space-y-4">
<h2 className="text-lg font-semibold text-slate-900"></h2>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
<MetricCard label="总发布数" value={overallSummary.total} />
<MetricCard label="总浏览数" value={overallSummary.totalViews} />
<MetricCard label="篇均浏览" value={overallSummary.avgViews} />
<MetricCard label="标签总数" value={overallSummary.tagCount} />
<MetricCard label="作者数" value={overallSummary.authorCount} />
</div>
<TopPosts
title="全站热门内容"
items={overallSummary.top}
latestText={
overallSummary.latest
? `最近发布:${overallSummary.latest.title} · ${formatTime(overallSummary.latest.createdAt)}`
: "最近发布:暂无数据"
}
/>
</section>
{adminView ? (
<section className={cardClass}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-slate-900"></h2>
<p className="mt-1 text-sm text-slate-500"></p>
</div>
</div>
{authorBreakdown.length === 0 ? (
<p className="mt-4 text-sm text-slate-500"></p>
) : (
<div className="mt-4 overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead>
<tr className="border-b border-slate-100 text-slate-500">
<th className="pb-3 pr-4 font-medium"></th>
<th className="pb-3 pr-4 font-medium"></th>
<th className="pb-3 pr-4 font-medium"></th>
<th className="pb-3 pr-4 font-medium"></th>
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{authorBreakdown.map((item) => (
<tr key={`${item.ownerId || "legacy"}-${item.author}`} className="border-b border-slate-50">
<td className="py-3 pr-4 text-slate-800">{item.author}</td>
<td className="py-3 pr-4 text-slate-600">{item.count}</td>
<td className="py-3 pr-4 text-slate-600">{item.totalViews}</td>
<td className="py-3 pr-4 text-slate-600">{item.avgViews}</td>
<td className="py-3 text-slate-600">{formatTime(item.latestCreatedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
) : null}
</div>
);
}

View File

@@ -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<string, unknown>);
}
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 (
<div className="space-y-6">
<div className="rounded-2xl bg-white/80 p-6 shadow-sm ring-1 ring-slate-100">
<h1 className="text-2xl font-semibold"> · {tag}</h1>
<h1 className="text-2xl font-semibold"> / {tag}</h1>
<p className="mt-2 text-sm text-slate-500"> {total} </p>
</div>
@@ -76,7 +75,7 @@ export default async function TagDetailPage({
<input
name="q"
defaultValue={q || ""}
placeholder="在当前标签搜索"
placeholder="在当前标签搜索"
className="min-w-[200px] flex-1 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
/>
<button
@@ -96,9 +95,7 @@ export default async function TagDetailPage({
</form>
{posts.length === 0 ? (
<p className="rounded-xl bg-white/70 p-4 text-sm text-slate-500 ring-1 ring-slate-100">
</p>
<p className="rounded-xl bg-white/70 p-4 text-sm text-slate-500 ring-1 ring-slate-100"></p>
) : (
<div className="grid gap-4">
{posts.map((post) => (
@@ -114,13 +111,21 @@ export default async function TagDetailPage({
<div className="flex items-center gap-2">
<a
href={buildHref(Math.max(1, currentPage - 1))}
className={`rounded-full px-3 py-1 ${currentPage <= 1 ? "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 <= 1
? "pointer-events-none text-slate-300"
: "bg-white/80 ring-1 ring-slate-200 hover:text-brand-600"
}`}
>
</a>
<a
href={buildHref(Math.min(totalPages, currentPage + 1))}
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"}`}
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"
}`}
>
</a>

View File

@@ -19,13 +19,11 @@ export default async function TagsPage() {
<div className="space-y-6">
<div className="rounded-2xl bg-white/80 p-6 shadow-sm ring-1 ring-slate-100">
<h1 className="text-2xl font-semibold"></h1>
<p className="mt-2 text-sm text-slate-500"></p>
<p className="mt-2 text-sm text-slate-500">访</p>
</div>
{tags.length === 0 ? (
<p className="rounded-xl bg-white/70 p-4 text-sm text-slate-500 ring-1 ring-slate-100">
</p>
<p className="rounded-xl bg-white/70 p-4 text-sm text-slate-500 ring-1 ring-slate-100"></p>
) : (
<div className="flex flex-wrap gap-3">
{tags.map((tag) => (