feat(moderation): 帖评删除占位感知与硬删清场
管理删帖须填类型与理由并展示专用页;评论软删占位、版主可硬删不占楼。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -34,6 +34,7 @@ import { formatRelative } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
@@ -139,6 +140,8 @@ export default function ContentAdmin({
|
||||
run: () => Promise<void>;
|
||||
} | null>(null);
|
||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||
const [staffDeletePost, setStaffDeletePost] = useState<AdminContentPost | null>(null);
|
||||
const [staffDeleteBusy, setStaffDeleteBusy] = useState(false);
|
||||
|
||||
const refreshCounts = useCallback(() => {
|
||||
apiAdminContentCounts().then(setCounts).catch(() => {});
|
||||
@@ -515,18 +518,7 @@ export default function ContentAdmin({
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
askConfirm(
|
||||
"移入回收站",
|
||||
`确定软删帖子「${p.title}」吗?可稍后在「已删除」中恢复。`,
|
||||
() =>
|
||||
apiAdminSoftDeletePost(p.id).then(() => {
|
||||
toast("已移入回收站", "ok");
|
||||
reload(postsPage);
|
||||
}),
|
||||
true
|
||||
)
|
||||
}
|
||||
onClick={() => setStaffDeletePost(p)}
|
||||
>
|
||||
<Trash2 size={14} /> 删除
|
||||
</button>
|
||||
@@ -691,6 +683,25 @@ export default function ContentAdmin({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<StaffDeletePostModal
|
||||
open={!!staffDeletePost}
|
||||
title={staffDeletePost?.title}
|
||||
busy={staffDeleteBusy}
|
||||
onClose={() => !staffDeleteBusy && setStaffDeletePost(null)}
|
||||
onConfirm={(opts) => {
|
||||
if (!staffDeletePost) return;
|
||||
setStaffDeleteBusy(true);
|
||||
apiAdminSoftDeletePost(staffDeletePost.id, opts)
|
||||
.then(() => {
|
||||
toast("已移入回收站", "ok");
|
||||
setStaffDeletePost(null);
|
||||
reload(postsPage);
|
||||
})
|
||||
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
|
||||
.finally(() => setStaffDeleteBusy(false));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
|
||||
const approval = n.type === "approved" || n.type === "rejected";
|
||||
const iconNode = n.type === "approved"
|
||||
? <CheckCircle2 size={16} />
|
||||
: n.type === "rejected"
|
||||
: n.type === "rejected" || n.type === "deleted"
|
||||
? <XCircle size={16} />
|
||||
: n.type === "like"
|
||||
? <Heart size={16} />
|
||||
@@ -87,14 +87,14 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
|
||||
: <MessageCircle size={16} />;
|
||||
const iconColor = n.type === "approved"
|
||||
? "var(--ok)"
|
||||
: n.type === "rejected"
|
||||
: n.type === "rejected" || n.type === "deleted"
|
||||
? "var(--danger)"
|
||||
: n.type === "like"
|
||||
? "var(--clay)"
|
||||
: "var(--accent)";
|
||||
const iconBg = n.type === "approved"
|
||||
? "var(--ok-soft)"
|
||||
: n.type === "rejected"
|
||||
: n.type === "rejected" || n.type === "deleted"
|
||||
? "var(--danger-soft)"
|
||||
: n.type === "like"
|
||||
? "var(--clay-soft)"
|
||||
@@ -143,7 +143,9 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
|
||||
? "在消息中提到了你"
|
||||
: n.type === "pending_review"
|
||||
? "有新内容待审核"
|
||||
: "点赞了你的帖子"}
|
||||
: n.type === "deleted"
|
||||
? "删除了你的帖子"
|
||||
: "点赞了你的帖子"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -18,12 +18,13 @@ import {
|
||||
fetchComments,
|
||||
fetchPosts,
|
||||
fetchUserProfile,
|
||||
postDeleteTypeLabel,
|
||||
type UserProfile,
|
||||
type PostListItem,
|
||||
type Board,
|
||||
} from "@/lib/api";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import CommentSection from "@/components/CommentSection";
|
||||
import PostActions from "@/components/PostActions";
|
||||
import LikeButton from "@/components/LikeButton";
|
||||
@@ -50,9 +51,16 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
try {
|
||||
// 带登录态:作者/管理团队看待审帖时标签标题也应是真实标题而非"帖子不存在"
|
||||
const { post } = await fetchPostDetail(id, authCookieHeader(await cookies()));
|
||||
if (post.tombstone) {
|
||||
return {
|
||||
title: post.title || "帖子已删除",
|
||||
description: "该帖子已被删除",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
}
|
||||
const desc = post.content_locked
|
||||
? post.access_hint || "部分内容需满足条件后可见"
|
||||
: post.content.slice(0, 160);
|
||||
: (post.content || "").slice(0, 160);
|
||||
return {
|
||||
title: post.title,
|
||||
description: desc,
|
||||
@@ -383,10 +391,63 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
notFound();
|
||||
}
|
||||
|
||||
// 评论 / 当前用户 / 作者资料 / 同板块热议并行拉取;侧栏数据失败一律静默降级,不阻塞正文
|
||||
const [commentsData, me, profile, relatedData] = await Promise.all([
|
||||
// 软删专用页:列表已不可见,原链接展示删除说明
|
||||
if (post.tombstone) {
|
||||
const typeLabel = postDeleteTypeLabel(post.delete_type);
|
||||
return (
|
||||
<div className="max-w-lg mx-auto py-16 px-4 text-center">
|
||||
<p
|
||||
className="inline-flex items-center rounded-full px-3 py-1 text-[12px] font-semibold mb-4"
|
||||
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
|
||||
>
|
||||
帖子已删除
|
||||
</p>
|
||||
<h1 className="text-[20px] sm:text-[22px] font-extrabold mb-3" style={{ color: "var(--ink)" }}>
|
||||
{post.title || "(无标题)"}
|
||||
</h1>
|
||||
{post.staff_deleted ? (
|
||||
<div
|
||||
className="panel text-left p-5 sm:p-6 mt-2"
|
||||
style={{ borderColor: "var(--danger-soft)" }}
|
||||
>
|
||||
<p className="text-[14px] font-medium mb-3" style={{ color: "var(--ink)" }}>
|
||||
此帖已被管理员删除
|
||||
</p>
|
||||
{typeLabel && (
|
||||
<p className="text-[13px] mb-2" style={{ color: "var(--ink-2)" }}>
|
||||
<span style={{ color: "var(--ink-3)" }}>类型:</span>
|
||||
{typeLabel}
|
||||
</p>
|
||||
)}
|
||||
{post.delete_reason && (
|
||||
<p className="text-[13px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
|
||||
<span style={{ color: "var(--ink-3)" }}>理由:</span>
|
||||
{post.delete_reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="meta text-[14px] mt-2">作者已删除此帖</p>
|
||||
)}
|
||||
<div className="mt-8 flex items-center justify-center gap-3">
|
||||
<Link href="/" className="btn btn-line btn-sm">
|
||||
返回首页
|
||||
</Link>
|
||||
{post.board?.id ? (
|
||||
<Link href={`/?board=${post.board_id}`} className="btn btn-primary btn-sm">
|
||||
浏览板块
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 评论 / 当前用户 / 站点设置 / 作者资料 / 同板块热议并行拉取;侧栏数据失败一律静默降级,不阻塞正文
|
||||
const [commentsData, me, siteSettings, profile, relatedData] = await Promise.all([
|
||||
fetchComments(id, commentPage, cookie),
|
||||
getMeCached(cookie || undefined),
|
||||
getPublicSettingsCached(),
|
||||
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
|
||||
fetchPosts(1, 8, post.board_id, "recommended", "", false, cookie).catch(() => null),
|
||||
]);
|
||||
@@ -554,6 +615,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
pinned={post.pinned}
|
||||
recommended={post.recommended}
|
||||
status={post.status}
|
||||
title={post.title}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -659,6 +721,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
page={commentsData.page}
|
||||
size={commentsData.size}
|
||||
user={me.user}
|
||||
allowComments={siteSettings.allow_comments}
|
||||
acceptMode={acceptMode}
|
||||
acceptedCommentId={
|
||||
post.question?.accepted_comment_id ||
|
||||
|
||||
Reference in New Issue
Block a user