Implement per-user post permissions and move stats into dedicated pages
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)]";
|
||||
|
||||
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
|
||||
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 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 }
|
||||
])
|
||||
.toArray();
|
||||
return authors.map((a: any) => ({ author: a._id, count: a.count }));
|
||||
const { startIso, endIso } = getShanghaiDayRange();
|
||||
const users = await db
|
||||
.collection("users")
|
||||
.find(
|
||||
{},
|
||||
{
|
||||
projection: {
|
||||
username: 1,
|
||||
displayName: 1,
|
||||
role: 1,
|
||||
dailyPostLimit: 1
|
||||
}
|
||||
}
|
||||
)
|
||||
.sort({ createdAt: 1 })
|
||||
.toArray();
|
||||
|
||||
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>
|
||||
|
||||
<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}%` }}
|
||||
<CreatePostForm
|
||||
availableTags={availableTags}
|
||||
publishLimit={publishQuota.limit}
|
||||
todayCount={publishQuota.todayCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<AdminPostList initialPosts={recentPosts} canDelete={false} />
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
86
app/api/admin/users/[userId]/route.ts
Normal file
86
app/api/admin/users/[userId]/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
35
app/page.tsx
35
app/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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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, "<")
|
||||
@@ -12,12 +12,12 @@ function escapeXml(input: string): string {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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
248
app/stats/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -6,24 +6,32 @@ import { Post } from "@/types/post";
|
||||
|
||||
type AdminPost = Post & { createdAtText?: string };
|
||||
|
||||
export function AdminPostList({ initialPosts }: { initialPosts: AdminPost[] }) {
|
||||
export function AdminPostList({
|
||||
initialPosts,
|
||||
canDelete = false
|
||||
}: {
|
||||
initialPosts: AdminPost[];
|
||||
canDelete?: boolean;
|
||||
}) {
|
||||
const [posts, setPosts] = useState<AdminPost[]>(initialPosts);
|
||||
const [tagQuery, setTagQuery] = useState("");
|
||||
|
||||
const visiblePosts = useMemo(() => {
|
||||
const q = tagQuery.trim().toLowerCase();
|
||||
if (!q) return posts;
|
||||
return posts.filter((post) => post.tags?.some((tag) => tag.toLowerCase().includes(q)));
|
||||
const query = tagQuery.trim().toLowerCase();
|
||||
if (!query) return posts;
|
||||
return posts.filter((post) => post.tags?.some((tag) => tag.toLowerCase().includes(query)));
|
||||
}, [posts, tagQuery]);
|
||||
|
||||
async function handleDelete(slug: string) {
|
||||
if (!window.confirm("确定要删除这条内容吗?此操作不可恢复。")) return;
|
||||
|
||||
const res = await fetch(`/api/posts/${slug}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
alert(data.error || "删除失败");
|
||||
return;
|
||||
}
|
||||
|
||||
setPosts((prev) => prev.filter((post) => post.slug !== slug));
|
||||
}
|
||||
|
||||
@@ -35,7 +43,9 @@ export function AdminPostList({ initialPosts }: { initialPosts: AdminPost[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
const summary = tagQuery ? `匹配 ${visiblePosts.length} / 总 ${posts.length}` : `共 ${posts.length} 条`;
|
||||
const summary = tagQuery
|
||||
? `匹配 ${visiblePosts.length} / 共 ${posts.length} 条`
|
||||
: `共 ${posts.length} 条`;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100">
|
||||
@@ -74,8 +84,8 @@ export function AdminPostList({ initialPosts }: { initialPosts: AdminPost[] }) {
|
||||
{post.title}
|
||||
</Link>
|
||||
<p className="text-xs text-slate-500">
|
||||
{(post.author || "佚名") +
|
||||
" · " +
|
||||
{(post.author || "匿名") +
|
||||
" | " +
|
||||
(post.createdAtText ||
|
||||
new Date(post.createdAt).toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
@@ -99,6 +109,7 @@ export function AdminPostList({ initialPosts }: { initialPosts: AdminPost[] }) {
|
||||
>
|
||||
编辑
|
||||
</Link>
|
||||
{canDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(post.slug)}
|
||||
@@ -106,6 +117,7 @@ export function AdminPostList({ initialPosts }: { initialPosts: AdminPost[] }) {
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
239
components/AdminUserManager.tsx
Normal file
239
components/AdminUserManager.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<section className="space-y-4 rounded-2xl bg-white/80 p-5 shadow-sm ring-1 ring-slate-100">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">用户管理</h3>
|
||||
<p className="text-sm text-slate-500">按用户名搜索,删除指定内容、删除用户,并设置每日发布额度。</p>
|
||||
</div>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{visibleUsers.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">没有匹配的用户。</p>
|
||||
) : (
|
||||
visibleUsers.map((user) => (
|
||||
<AdminUserCard
|
||||
key={user.id}
|
||||
user={user}
|
||||
currentUserId={currentUserId}
|
||||
saving={savingId === user.id}
|
||||
onDeletePost={handleDeletePost}
|
||||
onDeleteUser={handleDeleteUser}
|
||||
onSaveLimit={handleSaveLimit}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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<void>;
|
||||
onDeleteUser: (userId: string) => Promise<void>;
|
||||
onSaveLimit: (userId: string, dailyPostLimit: number) => Promise<void>;
|
||||
}) {
|
||||
const [limit, setLimit] = useState(user.dailyPostLimit);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-100 bg-white/70 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-slate-900">
|
||||
{user.displayName} <span className="text-sm font-normal text-slate-500">(@{user.username})</span>
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
角色:{user.role === "admin" ? "管理员" : "用户"} | 总发布:{user.postCount} | 今日发布:{user.todayPostCount}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={limit}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => onSaveLimit(user.id, limit)}
|
||||
className="rounded-full bg-brand-50 px-3 py-2 text-xs font-medium text-brand-700 ring-1 ring-brand-100 hover:bg-brand-100 disabled:opacity-60"
|
||||
>
|
||||
保存额度
|
||||
</button>
|
||||
{user.id !== currentUserId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteUser(user.id)}
|
||||
className="rounded-full bg-red-50 px-3 py-2 text-xs font-medium text-red-600 ring-1 ring-red-100 hover:bg-red-100"
|
||||
>
|
||||
删除用户
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
{user.posts.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">该用户暂无内容。</p>
|
||||
) : (
|
||||
user.posts.map((post) => (
|
||||
<div
|
||||
key={post.slug}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-100 bg-white px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<a href={`/p/${post.slug}`} className="text-sm font-medium text-slate-900 hover:text-brand-600">
|
||||
{post.title}
|
||||
</a>
|
||||
<p className="text-xs text-slate-500">
|
||||
{new Date(post.createdAt).toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
timeZone: "Asia/Shanghai"
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeletePost(post.slug)}
|
||||
className="rounded-full bg-red-50 px-3 py-1 text-xs font-medium text-red-600 ring-1 ring-red-100 hover:bg-red-100"
|
||||
>
|
||||
删除内容
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
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[
|
||||
<form onSubmit={handleSubmit} className="space-y-4 rounded-2xl bg-white/80 p-5 shadow-sm ring-1 ring-slate-100">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">发布新内容</h3>
|
||||
<p className="text-sm text-slate-500">以信息流的方式记录进展、沉淀经验。</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreview((prev) => !prev)}
|
||||
className="rounded-full bg-slate-100 px-4 py-2 text-sm font-medium text-slate-700 ring-1 ring-slate-200 hover:text-brand-600"
|
||||
>
|
||||
{preview ? "编辑" : "预览"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-full bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? "发布中..." : "发布"}
|
||||
</button>
|
||||
<h3 className="text-lg font-semibold">发布内容</h3>
|
||||
<p className="text-sm text-slate-500">用信息流的形式记录今天的进展。</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-600 ring-1 ring-slate-200">
|
||||
今日已发 {todayCount} / {publishLimit}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-1">
|
||||
@@ -102,12 +97,12 @@ export function CreatePostForm({ availableTags = [] }: { availableTags?: string[
|
||||
value={title}
|
||||
onChange={(e) => setTitle(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>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">封面图片 URL(可选)</span>
|
||||
<span className="text-sm font-medium text-slate-700">封面地址(可选)</span>
|
||||
<input
|
||||
value={cover}
|
||||
onChange={(e) => setCover(e.target.value)}
|
||||
@@ -141,7 +136,26 @@ export function CreatePostForm({ availableTags = [] }: { availableTags?: string[
|
||||
</label>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">Markdown 内容</span>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-slate-700">正文</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreview((prev) => !prev)}
|
||||
className="rounded-full bg-slate-100 px-4 py-2 text-sm font-medium text-slate-700 ring-1 ring-slate-200 hover:text-brand-600"
|
||||
>
|
||||
{preview ? "继续编辑" : "预览"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-full bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? "发布中..." : "发布"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!preview ? (
|
||||
<textarea
|
||||
required
|
||||
@@ -152,7 +166,7 @@ export function CreatePostForm({ availableTags = [] }: { availableTags?: string[
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<MarkdownPreview markdown={markdown || "(暂无内容)"} />
|
||||
<MarkdownPreview markdown={markdown || "(空内容)"} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { MarkdownPreview } from "@/components/MarkdownPreview";
|
||||
import { normalizeImageUrl } from "@/lib/normalize";
|
||||
import { Post } from "@/types/post";
|
||||
import { MarkdownPreview } from "@/components/MarkdownPreview";
|
||||
|
||||
export function EditPostForm({ post }: { post: Post }) {
|
||||
const router = useRouter();
|
||||
@@ -31,7 +31,7 @@ export function EditPostForm({ post }: { post: Post }) {
|
||||
new Set(
|
||||
tags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
)
|
||||
@@ -49,41 +49,11 @@ export function EditPostForm({ post }: { post: Post }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!window.confirm("确定要删除这条内容吗?此操作不可恢复。")) return;
|
||||
const res = await fetch(`/api/posts/${post.slug}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
alert(data.error || "删除失败");
|
||||
return;
|
||||
}
|
||||
router.push("/admin");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave} className="space-y-4 rounded-2xl bg-white/80 p-5 shadow-sm ring-1 ring-slate-100">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold">编辑内容</h3>
|
||||
<p className="text-sm text-slate-500">更新信息流内容与标签。</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="rounded-full bg-red-50 px-4 py-2 text-sm font-medium text-red-600 ring-1 ring-red-100 hover:bg-red-100"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-full bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? "保存中..." : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">你只能修改自己发布的内容。</p>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-1">
|
||||
@@ -97,7 +67,7 @@ export function EditPostForm({ post }: { post: Post }) {
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">封面图片 URL(可选)</span>
|
||||
<span className="text-sm font-medium text-slate-700">封面地址(可选)</span>
|
||||
<input
|
||||
value={cover}
|
||||
onChange={(e) => setCover(e.target.value)}
|
||||
@@ -117,15 +87,24 @@ export function EditPostForm({ post }: { post: Post }) {
|
||||
</label>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">Markdown 内容</span>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-slate-700">正文</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreview((prev) => !prev)}
|
||||
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700 ring-1 ring-slate-200 hover:text-brand-600"
|
||||
>
|
||||
{preview ? "编辑" : "预览"}
|
||||
{preview ? "继续编辑" : "预览"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-full bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? "保存中..." : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!preview ? (
|
||||
<textarea
|
||||
@@ -137,7 +116,7 @@ export function EditPostForm({ post }: { post: Post }) {
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<MarkdownPreview markdown={markdown || "(暂无内容)"} />
|
||||
<MarkdownPreview markdown={markdown || "(空内容)"} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,8 @@ type Props = {
|
||||
|
||||
export function PostCard({ post }: Props) {
|
||||
const coverUrl = normalizeImageUrl(post.cover);
|
||||
const author = post.author || "佚名";
|
||||
const author = post.author || "匿名";
|
||||
|
||||
return (
|
||||
<article className="group 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)]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -20,7 +21,11 @@ export function PostCard({ post }: Props) {
|
||||
{post.title}
|
||||
</Link>
|
||||
<p className="text-sm text-slate-500">
|
||||
{author} · {new Date(post.createdAt).toLocaleString("zh-CN", { hour12: false })}
|
||||
{author} |{" "}
|
||||
{new Date(post.createdAt).toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
timeZone: "Asia/Shanghai"
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{coverUrl ? (
|
||||
|
||||
19
lib/auth.ts
19
lib/auth.ts
@@ -15,17 +15,28 @@ function getSecret() {
|
||||
}
|
||||
|
||||
export type SessionPayload = {
|
||||
role: "admin";
|
||||
role: "admin" | "user";
|
||||
iat: number;
|
||||
exp?: number;
|
||||
uid?: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export function getAdminName() {
|
||||
return process.env.ADMIN_NAME?.trim() || "Admin";
|
||||
}
|
||||
|
||||
export function isAdminName(name?: string | null) {
|
||||
const adminName = getAdminName().trim().toLowerCase();
|
||||
const value = name?.trim().toLowerCase();
|
||||
return Boolean(adminName && value && adminName === value);
|
||||
}
|
||||
|
||||
export function isAdminSession(session?: SessionPayload | null) {
|
||||
return session?.role === "admin";
|
||||
}
|
||||
|
||||
async function getHmacKey(secret: string) {
|
||||
if (cachedKey && cachedSecret === secret) {
|
||||
return cachedKey;
|
||||
@@ -63,6 +74,9 @@ export async function verifySession(token?: string): Promise<SessionPayload | nu
|
||||
if (check !== sig) return null;
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(base, "base64url").toString());
|
||||
if (payload?.role !== "admin" && payload?.role !== "user") {
|
||||
return null;
|
||||
}
|
||||
if (typeof payload?.exp !== "number") {
|
||||
return null;
|
||||
}
|
||||
@@ -77,7 +91,8 @@ export async function verifySession(token?: string): Promise<SessionPayload | nu
|
||||
|
||||
export async function requireAdminFromRequest(req: NextRequest): Promise<boolean> {
|
||||
const token = req.cookies.get(COOKIE_NAME)?.value;
|
||||
return Boolean(await verifySession(token));
|
||||
const session = await verifySession(token);
|
||||
return isAdminSession(session);
|
||||
}
|
||||
|
||||
export function setAdminCookie(token: string) {
|
||||
|
||||
96
lib/posts.ts
Normal file
96
lib/posts.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { SessionPayload, isAdminSession } from "@/lib/auth";
|
||||
import { DEFAULT_OPC_SIGNAL } from "@/lib/opc";
|
||||
import { Post } from "@/types/post";
|
||||
|
||||
function buildLegacyAuthorFilter(session: SessionPayload) {
|
||||
if (!session.name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
$and: [
|
||||
{
|
||||
$or: [{ ownerId: { $exists: false } }, { ownerId: null }]
|
||||
},
|
||||
{ author: session.name }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOwnedPostFilter(session?: SessionPayload | null) {
|
||||
if (!session?.uid) {
|
||||
return { _id: { $exists: false } };
|
||||
}
|
||||
|
||||
const filters: Record<string, unknown>[] = [{ ownerId: session.uid }];
|
||||
const legacyAuthorFilter = buildLegacyAuthorFilter(session);
|
||||
if (legacyAuthorFilter) {
|
||||
filters.push(legacyAuthorFilter);
|
||||
}
|
||||
|
||||
return { $or: filters };
|
||||
}
|
||||
|
||||
export function buildPostScopeFilter(session?: SessionPayload | null) {
|
||||
return buildOwnedPostFilter(session);
|
||||
}
|
||||
|
||||
export function isPostOwner(doc: any, session?: SessionPayload | null) {
|
||||
if (!session?.uid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (doc?.ownerId === session.uid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !doc?.ownerId && Boolean(session.name) && doc?.author === session.name;
|
||||
}
|
||||
|
||||
export function buildScopedPostFilter(
|
||||
session: SessionPayload | null | undefined,
|
||||
filters: Record<string, unknown>[] = []
|
||||
) {
|
||||
const scopedFilters = [buildPostScopeFilter(session), ...filters].filter(
|
||||
(item) => Object.keys(item).length > 0
|
||||
);
|
||||
|
||||
if (scopedFilters.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (scopedFilters.length === 1) {
|
||||
return scopedFilters[0];
|
||||
}
|
||||
|
||||
return { $and: scopedFilters };
|
||||
}
|
||||
|
||||
export function canAccessPost() {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function canEditPost(doc: any, session?: SessionPayload | null) {
|
||||
return isPostOwner(doc, session);
|
||||
}
|
||||
|
||||
export function canDeletePost(doc: any, session?: SessionPayload | null) {
|
||||
return Boolean(doc && isAdminSession(session));
|
||||
}
|
||||
|
||||
export function serializePost(doc: any): Post {
|
||||
return {
|
||||
_id: doc._id?.toString(),
|
||||
title: doc.title ?? "",
|
||||
slug: doc.slug ?? "",
|
||||
markdown: doc.markdown ?? "",
|
||||
cover: doc.cover,
|
||||
tags: doc.tags ?? [],
|
||||
signal: doc.signal ?? DEFAULT_OPC_SIGNAL,
|
||||
author: doc.author ?? "匿名",
|
||||
ownerId: doc.ownerId,
|
||||
createdAt: doc.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: doc.updatedAt ?? doc.createdAt ?? new Date().toISOString(),
|
||||
views: doc.views ?? 0
|
||||
};
|
||||
}
|
||||
212
lib/stats.ts
Normal file
212
lib/stats.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { getDb } from "@/lib/mongo";
|
||||
|
||||
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||
|
||||
export type StatsSummary = {
|
||||
total: number;
|
||||
totalViews: number;
|
||||
avgViews: number;
|
||||
tagCount: number;
|
||||
authorCount: number;
|
||||
latest: { title: string; createdAt: string } | null;
|
||||
top: Array<{ title: string; slug: string; views: number }>;
|
||||
};
|
||||
|
||||
function appendMatch(
|
||||
pipeline: Record<string, unknown>[],
|
||||
filter: Record<string, unknown>,
|
||||
extraMatch?: Record<string, unknown>
|
||||
) {
|
||||
const matchFilters = [filter, extraMatch].filter(
|
||||
(item) => item && Object.keys(item).length > 0
|
||||
) as Record<string, unknown>[];
|
||||
|
||||
if (matchFilters.length === 1) {
|
||||
pipeline.push({ $match: matchFilters[0] });
|
||||
} else if (matchFilters.length > 1) {
|
||||
pipeline.push({ $match: { $and: matchFilters } });
|
||||
}
|
||||
}
|
||||
|
||||
function toShanghaiDateKey(date: Date) {
|
||||
return new Date(date.getTime() + SHANGHAI_OFFSET_MS).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function buildShanghaiBucketLabel(key: string) {
|
||||
const [, month, day] = key.split("-").map(Number);
|
||||
return `${month}/${day}`;
|
||||
}
|
||||
|
||||
export async function fetchStatsSummary(filter: Record<string, unknown>): Promise<StatsSummary> {
|
||||
const db = await getDb();
|
||||
const collection = db.collection("posts");
|
||||
const total = await collection.countDocuments(filter);
|
||||
const latest = await collection
|
||||
.find(filter, { projection: { title: 1, createdAt: 1 } })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(1)
|
||||
.toArray();
|
||||
const top = await collection
|
||||
.find(filter, { projection: { title: 1, views: 1, slug: 1 } })
|
||||
.sort({ views: -1, createdAt: -1 })
|
||||
.limit(3)
|
||||
.toArray();
|
||||
|
||||
const viewsPipeline: Record<string, unknown>[] = [];
|
||||
appendMatch(viewsPipeline, filter);
|
||||
viewsPipeline.push({
|
||||
$group: {
|
||||
_id: null,
|
||||
totalViews: { $sum: { $ifNull: ["$views", 0] } }
|
||||
}
|
||||
});
|
||||
const viewsAgg = await collection.aggregate(viewsPipeline).toArray();
|
||||
const totalViews = viewsAgg[0]?.totalViews ?? 0;
|
||||
const avgViews = total > 0 ? Math.round(totalViews / total) : 0;
|
||||
|
||||
const tagPipeline: Record<string, unknown>[] = [];
|
||||
appendMatch(tagPipeline, filter, {
|
||||
tags: { $exists: true, $ne: [] }
|
||||
});
|
||||
tagPipeline.push(
|
||||
{ $unwind: "$tags" },
|
||||
{ $group: { _id: "$tags" } },
|
||||
{ $count: "count" }
|
||||
);
|
||||
const tagCount = (await collection.aggregate(tagPipeline).toArray())[0]?.count ?? 0;
|
||||
|
||||
const authorPipeline: Record<string, unknown>[] = [];
|
||||
appendMatch(authorPipeline, filter);
|
||||
authorPipeline.push(
|
||||
{
|
||||
$group: {
|
||||
_id: { $ifNull: ["$author", "匿名"] }
|
||||
}
|
||||
},
|
||||
{ $count: "count" }
|
||||
);
|
||||
const authorCount = (await collection.aggregate(authorPipeline).toArray())[0]?.count ?? 0;
|
||||
|
||||
return {
|
||||
total,
|
||||
totalViews,
|
||||
avgViews,
|
||||
tagCount,
|
||||
authorCount,
|
||||
latest: latest[0]
|
||||
? {
|
||||
title: latest[0].title ?? "未命名",
|
||||
createdAt: latest[0].createdAt ?? new Date().toISOString()
|
||||
}
|
||||
: null,
|
||||
top: top.map((item: any) => ({
|
||||
title: item.title ?? "未命名",
|
||||
slug: item.slug ?? "",
|
||||
views: item.views ?? 0
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchTagStats(filter: Record<string, unknown>, limit = 10) {
|
||||
const db = await getDb();
|
||||
const pipeline: Record<string, unknown>[] = [];
|
||||
appendMatch(pipeline, filter, {
|
||||
tags: { $exists: true, $ne: [] }
|
||||
});
|
||||
pipeline.push(
|
||||
{ $unwind: "$tags" },
|
||||
{ $group: { _id: "$tags", count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1, _id: 1 } },
|
||||
{ $limit: limit }
|
||||
);
|
||||
|
||||
const result = await db.collection("posts").aggregate(pipeline).toArray();
|
||||
return result.map((item: any) => ({
|
||||
tag: item._id,
|
||||
count: item.count
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchDailyStats(filter: Record<string, unknown>, days = 7) {
|
||||
const db = await getDb();
|
||||
const now = new Date();
|
||||
const dailyBuckets: { key: string; label: string }[] = [];
|
||||
|
||||
for (let index = days - 1; index >= 0; index -= 1) {
|
||||
const current = new Date(now);
|
||||
current.setUTCDate(current.getUTCDate() - index);
|
||||
const key = toShanghaiDateKey(current);
|
||||
dailyBuckets.push({
|
||||
key,
|
||||
label: buildShanghaiBucketLabel(key)
|
||||
});
|
||||
}
|
||||
|
||||
const firstKey = dailyBuckets[0]?.key;
|
||||
const pipeline: Record<string, unknown>[] = [];
|
||||
appendMatch(
|
||||
pipeline,
|
||||
filter,
|
||||
firstKey
|
||||
? {
|
||||
createdAt: {
|
||||
$gte: new Date(`${firstKey}T00:00:00+08:00`).toISOString()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
pipeline.push(
|
||||
{
|
||||
$addFields: {
|
||||
shanghaiDay: {
|
||||
$dateToString: {
|
||||
format: "%Y-%m-%d",
|
||||
date: { $toDate: "$createdAt" },
|
||||
timezone: "Asia/Shanghai"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ $group: { _id: "$shanghaiDay", count: { $sum: 1 } } }
|
||||
);
|
||||
|
||||
const raw = await db.collection("posts").aggregate(pipeline).toArray();
|
||||
const countMap = new Map<string, number>();
|
||||
raw.forEach((item: any) => countMap.set(item._id, item.count));
|
||||
|
||||
return dailyBuckets.map((item) => ({
|
||||
label: item.label,
|
||||
count: countMap.get(item.key) ?? 0
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchAuthorBreakdown(limit = 100) {
|
||||
const db = await getDb();
|
||||
const result = await db
|
||||
.collection("posts")
|
||||
.aggregate([
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
ownerId: { $ifNull: ["$ownerId", "legacy"] },
|
||||
author: { $ifNull: ["$author", "匿名"] }
|
||||
},
|
||||
count: { $sum: 1 },
|
||||
totalViews: { $sum: { $ifNull: ["$views", 0] } },
|
||||
latestCreatedAt: { $max: "$createdAt" }
|
||||
}
|
||||
},
|
||||
{ $sort: { count: -1, totalViews: -1, "_id.author": 1 } },
|
||||
{ $limit: limit }
|
||||
])
|
||||
.toArray();
|
||||
|
||||
return result.map((item: any) => ({
|
||||
ownerId: item._id.ownerId === "legacy" ? undefined : item._id.ownerId,
|
||||
author: item._id.author,
|
||||
count: item.count,
|
||||
totalViews: item.totalViews,
|
||||
avgViews: item.count > 0 ? Math.round(item.totalViews / item.count) : 0,
|
||||
latestCreatedAt: item.latestCreatedAt ?? null
|
||||
}));
|
||||
}
|
||||
38
lib/users.ts
Normal file
38
lib/users.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ObjectId } from "mongodb";
|
||||
import { getDb } from "@/lib/mongo";
|
||||
|
||||
export const DEFAULT_DAILY_POST_LIMIT = 10;
|
||||
const SHANGHAI_OFFSET_HOURS = 8;
|
||||
|
||||
export function getEffectiveDailyPostLimit(user?: { dailyPostLimit?: number | null }) {
|
||||
if (typeof user?.dailyPostLimit === "number" && user.dailyPostLimit >= 0) {
|
||||
return user.dailyPostLimit;
|
||||
}
|
||||
|
||||
return DEFAULT_DAILY_POST_LIMIT;
|
||||
}
|
||||
|
||||
export function getShanghaiDayRange(now = new Date()) {
|
||||
const shifted = new Date(now.getTime() + SHANGHAI_OFFSET_HOURS * 60 * 60 * 1000);
|
||||
const year = shifted.getUTCFullYear();
|
||||
const month = shifted.getUTCMonth();
|
||||
const day = shifted.getUTCDate();
|
||||
const start = new Date(Date.UTC(year, month, day, -SHANGHAI_OFFSET_HOURS, 0, 0, 0));
|
||||
const end = new Date(Date.UTC(year, month, day + 1, -SHANGHAI_OFFSET_HOURS, 0, 0, 0));
|
||||
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
startIso: start.toISOString(),
|
||||
endIso: end.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export async function findUserById(uid?: string | null) {
|
||||
if (!uid || !ObjectId.isValid(uid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
return db.collection("users").findOne({ _id: new ObjectId(uid) });
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export async function middleware(req: NextRequest) {
|
||||
|
||||
const isProtected =
|
||||
pathname.startsWith("/admin") ||
|
||||
pathname.startsWith("/stats") ||
|
||||
pathname.startsWith("/api/admin") ||
|
||||
pathname.startsWith("/api/posts") && req.method !== "GET";
|
||||
|
||||
@@ -30,5 +31,5 @@ export async function middleware(req: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/admin/:path*", "/api/admin/:path*", "/api/posts/:path*"]
|
||||
matcher: ["/admin/:path*", "/stats/:path*", "/api/admin/:path*", "/api/posts/:path*"]
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ export type Post = {
|
||||
tags?: string[];
|
||||
signal?: string;
|
||||
author: string;
|
||||
ownerId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
views?: number;
|
||||
|
||||
Reference in New Issue
Block a user