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

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

View File

@@ -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,13 +109,15 @@ export function AdminPostList({ initialPosts }: { initialPosts: AdminPost[] }) {
>
</Link>
<button
type="button"
onClick={() => handleDelete(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>
{canDelete ? (
<button
type="button"
onClick={() => handleDelete(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>
) : null}
</div>
</div>
))}

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

View File

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

View File

@@ -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>
<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>
<div className="space-y-1">
<h3 className="text-lg font-semibold"></h3>
<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>
<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 ? "编辑" : "预览"}
</button>
<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 ? "继续编辑" : "预览"}
</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>

View File

@@ -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 ? (