feat(moderation): 帖评删除占位感知与硬删清场

管理删帖须填类型与理由并展示专用页;评论软删占位、版主可硬删不占楼。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 07:01:04 +08:00
parent 2b27703e6c
commit bcb4f6a603
15 changed files with 825 additions and 116 deletions

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,21 @@
"use client";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Trash2, MessagesSquare, ChevronRight, ChevronDown, BadgeCheck, XCircle } from "lucide-react";
import {
Trash2,
MessagesSquare,
MessageSquareOff,
ChevronRight,
ChevronDown,
BadgeCheck,
XCircle,
} from "lucide-react";
import {
apiCreateComment,
apiDeleteComment,
apiPurgeComment,
apiAcceptBounty,
apiAcceptQuestion,
apiAdminApproveComment,
@@ -16,6 +25,7 @@ import {
} from "@/lib/api";
import { canModerateBoard } from "@/lib/roles";
import { toast } from "@/lib/toast";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
import Avatar from "./Avatar";
import Pagination from "./Pagination";
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
@@ -37,6 +47,8 @@ interface CommentSectionProps {
/** 悬赏或问答:可采纳时在评论旁显示采纳按钮(二者按帖子类型二选一) */
acceptMode?: "bounty" | "question" | null;
acceptedCommentId?: number;
/** 全站是否开放评论;缺省 true。关闭时隐藏发评/回复入口 */
allowComments?: boolean;
}
// 删除按钮的两段式确认:点一下进入确认态,3 秒未确认自动复原
@@ -59,6 +71,7 @@ export default function CommentSection({
user,
acceptMode = null,
acceptedCommentId = 0,
allowComments: allowCommentsProp = true,
}: CommentSectionProps) {
const router = useRouter();
const [content, setContent] = useState("");
@@ -72,13 +85,33 @@ export default function CommentSection({
const [approvingId, setApprovingId] = useState<number | null>(null);
const [rejectTargetId, setRejectTargetId] = useState<number | null>(null);
const [rejecting, setRejecting] = useState(false);
const [purgeTargetId, setPurgeTargetId] = useState<number | null>(null);
const [purging, setPurging] = useState(false);
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
const [commentsOpen, setCommentsOpen] = useState(allowCommentsProp !== false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setCommentsOpen(allowCommentsProp !== false);
}, [allowCommentsProp]);
// 管理后台开关变更后即时隐藏发评入口(后端仍硬拒绝)
useEffect(() => {
return realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
if (typeof data?.allow_comments === "boolean") {
setCommentsOpen(data.allow_comments);
if (!data.allow_comments) {
setReplyTarget(null);
setReplyContent("");
}
}
});
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim()) return;
if (!commentsOpen || !content.trim()) return;
setSubmitting(true);
try {
const res = await apiCreateComment(postId, content);
@@ -105,7 +138,7 @@ export default function CommentSection({
const handleSubmitReply = async (e: React.FormEvent) => {
e.preventDefault();
if (!replyTarget || !replyContent.trim()) return;
if (!commentsOpen || !replyTarget || !replyContent.trim()) return;
setSubmitting(true);
try {
const res = await apiCreateComment(postId, replyContent, replyTarget.id);
@@ -204,19 +237,47 @@ export default function CommentSection({
}
};
const handlePurgeConfirm = async () => {
if (purgeTargetId == null) return;
setPurging(true);
try {
const res = await apiPurgeComment(postId, purgeTargetId);
if (res.message) {
toast("已彻底删除", "ok");
setPurgeTargetId(null);
router.refresh();
} else {
toast(res.error || "彻底删除失败");
}
} catch (e) {
toast(e instanceof Error ? e.message : "彻底删除失败");
} finally {
setPurging(false);
}
};
// 引用功能已移除:与"回复"功能重叠,且会割裂讨论上下文(抖音风格无此功能)
const canDelete = (comment: CommentNode) =>
!!user && (user.id === comment.user.id || canModerateBoard(user, boardId));
const canSoftDelete = (comment: CommentNode) =>
!comment.deleted &&
!!user &&
(user.id === comment.user?.id || canModerateBoard(user, boardId));
const canPurge = (comment: CommentNode) =>
!!user && canModerateBoard(user, boardId);
const canApprove = (comment: CommentNode) =>
!!user && comment.status === "pending" && canModerateBoard(user, boardId);
!comment.deleted &&
!!user &&
comment.status === "pending" &&
canModerateBoard(user, boardId);
const canAccept = (comment: CommentNode) =>
!comment.deleted &&
!!acceptMode &&
!!user &&
comment.status === "published" &&
comment.user.id !== authorId;
comment.user?.id !== authorId;
const openReply = (c: CommentNode) => {
setReplyTarget({ id: c.id, nickname: c.user.nickname || c.user.username });
@@ -235,8 +296,8 @@ export default function CommentSection({
const totalPages = Math.max(1, Math.ceil(total / size));
// 内联回复表单(出现在目标评论正下方)
const inlineReplyForm = replyTarget && (
// 内联回复表单(出现在目标评论正下方;全站关评时不渲染)
const inlineReplyForm = commentsOpen && replyTarget && (
<form onSubmit={handleSubmitReply} className="cmt-reply-form mt-3">
<div className="text-[12px] mb-2" style={{ color: "var(--ink-3)" }}>
回复 <span style={{ color: "var(--accent)" }}>@{replyTarget.nickname}</span>
@@ -285,7 +346,104 @@ export default function CommentSection({
const replyCount = countReplies(node);
const isFloor = depth === 0;
const isExpanded = isFloor ? expandedFloors.has(c.id) : true;
const parentAuthor = parent?.user.nickname || parent?.user.username;
const parentAuthor = parent?.user?.nickname || parent?.user?.username;
// 软删占位:保留楼层/回复位,不展示正文;子回复仍可展开
if (c.deleted) {
const tombstoneBody = (
<div
className="rounded-lg px-3 py-2.5 text-[13px]"
style={{
background: "var(--panel-2)",
color: "var(--ink-3)",
border: "1px dashed var(--line)",
}}
>
此评论已删除
</div>
);
const purgeBtn = canPurge(c) && (
<button
type="button"
className="cmt-reply-btn !text-[var(--danger)]"
disabled={purging}
onClick={() => setPurgeTargetId(c.id)}
data-tip="彻底删除,不占位"
>
彻底删除
</button>
);
if (!isFloor) {
return (
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24 opacity-80">
<div className="flex gap-2.5">
<span className="shrink-0 w-7 h-7 rounded-full" style={{ background: "var(--line)" }} aria-hidden />
<div className="flex-1 min-w-0">
{tombstoneBody}
<div className="flex items-center gap-3 mt-1.5">
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
{purgeBtn}
</div>
</div>
</div>
{node.replies && node.replies.length > 0 && (
<div className="cmt-children">
{node.replies.map((r) => renderComment(r, depth + 1, undefined, node))}
</div>
)}
</div>
);
}
return (
<div
key={c.id}
id={`comment-${c.id}`}
className="py-5 border-t first:border-t-0 scroll-mt-24 opacity-90"
style={{ borderColor: "var(--line)" }}
>
<div className="flex gap-3">
<span className="shrink-0 w-[38px] h-[38px] rounded-full mt-0.5" style={{ background: "var(--line)" }} aria-hidden />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 mb-1.5">
<span className="text-sm" style={{ color: "var(--ink-3)" }}>
已删除
</span>
{floor && (
<a href={`#comment-${c.id}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
#{floor}
</a>
)}
</div>
{tombstoneBody}
<div className="flex items-center gap-3 mt-2">
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
{purgeBtn}
</div>
{node.replies && node.replies.length > 0 && (
<div className="mt-3">
<button
type="button"
onClick={() => toggleFloor(c.id)}
className="cmt-fold-btn"
aria-expanded={isExpanded}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span>{isExpanded ? "收起回复" : `展开 ${replyCount} 条回复`}</span>
</button>
{isExpanded && (
<div className="cmt-replies-wrap">
{node.replies.map((r) => renderComment(r, 1, undefined, node))}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
if (!isFloor) {
// 子回复:紧凑行式布局(抖音风格)
@@ -323,7 +481,7 @@ export default function CommentSection({
<div className="flex items-center justify-between mt-1.5">
<div className="flex items-center gap-3">
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
{user && (
{commentsOpen && user && (
<button
onClick={() => openReply(c)}
className={`cmt-reply-btn transition-colors ${
@@ -374,7 +532,7 @@ export default function CommentSection({
</>
)}
</div>
{canDelete(c) && (
{canSoftDelete(c) && (
<button
onClick={() => handleDelete(c.id)}
disabled={deletingId === c.id}
@@ -382,17 +540,27 @@ export default function CommentSection({
confirmingId === c.id ? "!text-[var(--danger)]" : "hover:text-[var(--danger)]"
}`}
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
data-tip={confirmingId === c.id ? "再点一次确认删除" : "删除"}
data-tip={confirmingId === c.id ? "再点一次确认删除(占位)" : "删除(占位)"}
>
{confirmingId === c.id ? (
<span className="text-[11.5px] whitespace-nowrap">
确认删除?{countReplies(c) > 0 && `(含 ${countReplies(c)} 条回复)`}
</span>
<span className="text-[11.5px] whitespace-nowrap">确认删除?</span>
) : (
<Trash2 size={14} />
)}
</button>
)}
{canPurge(c) && (
<button
type="button"
onClick={() => setPurgeTargetId(c.id)}
disabled={purging}
className="meta p-1 hover:text-[var(--danger)] transition-colors"
data-tip="彻底删除,不占位"
aria-label="彻底删除"
>
<XCircle size={14} />
</button>
)}
</div>
{replyTarget?.id === c.id && inlineReplyForm}
</div>
@@ -440,7 +608,7 @@ export default function CommentSection({
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-3">
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
{user && (
{commentsOpen && user && (
<button
onClick={() => openReply(c)}
className={`cmt-reply-btn transition-colors ${
@@ -491,7 +659,7 @@ export default function CommentSection({
</>
)}
</div>
{canDelete(c) && (
{canSoftDelete(c) && (
<button
onClick={() => handleDelete(c.id)}
disabled={deletingId === c.id}
@@ -499,17 +667,27 @@ export default function CommentSection({
confirmingId === c.id ? "!text-[var(--danger)]" : "hover:text-[var(--danger)]"
}`}
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
data-tip={confirmingId === c.id ? "再点一次确认删除" : "删除"}
data-tip={confirmingId === c.id ? "再点一次确认删除(占位)" : "删除(占位)"}
>
{confirmingId === c.id ? (
<span className="text-[11.5px] whitespace-nowrap">
确认删除?{countReplies(c) > 0 && `(含 ${countReplies(c)} 条回复)`}
</span>
<span className="text-[11.5px] whitespace-nowrap">确认删除?</span>
) : (
<Trash2 size={14} />
)}
</button>
)}
{canPurge(c) && (
<button
type="button"
onClick={() => setPurgeTargetId(c.id)}
disabled={purging}
className="meta p-1 hover:text-[var(--danger)] transition-colors"
data-tip="彻底删除,不占位"
aria-label="彻底删除"
>
<XCircle size={14} />
</button>
)}
</div>
{replyTarget?.id === c.id && inlineReplyForm}
@@ -555,7 +733,34 @@ export default function CommentSection({
<span className="meta ml-auto text-[11.5px]">共 {totalPages} 页楼层</span>
</div>
{user ? (
{!commentsOpen ? (
<div
className="mb-8 rounded-2xl border px-5 py-6 text-center sm:px-6"
style={{
borderColor: "color-mix(in srgb, var(--ink-3) 35%, var(--line))",
background:
"repeating-linear-gradient(-45deg, transparent, transparent 6px, color-mix(in srgb, var(--ink-3) 8%, transparent) 6px, color-mix(in srgb, var(--ink-3) 8%, transparent) 7px), var(--panel-2)",
}}
role="status"
>
<span
className="mx-auto mb-3 flex h-11 w-11 items-center justify-center rounded-xl"
style={{
background: "color-mix(in srgb, var(--ink-3) 14%, transparent)",
color: "var(--ink-3)",
}}
aria-hidden
>
<MessageSquareOff size={22} />
</span>
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
评论功能暂时关闭
</p>
<p className="meta mt-1.5 text-[12.5px] leading-relaxed">
站点当前暂停发表新评论与回复,历史评论仍可浏览。
</p>
</div>
) : user ? (
<form onSubmit={handleSubmit} className="mb-8">
<textarea
ref={textareaRef}
@@ -602,7 +807,9 @@ export default function CommentSection({
<p className="text-sm" style={{ color: "var(--ink-2)" }}>
暂无评论
</p>
<p className="meta text-[12.5px] mt-1">来说两句,抢占沙发</p>
<p className="meta text-[12.5px] mt-1">
{commentsOpen ? "来说两句,抢占沙发" : "评论开放后即可参与讨论"}
</p>
</div>
)}
</div>
@@ -621,6 +828,19 @@ export default function CommentSection({
if (!rejecting) setRejectTargetId(null);
}}
/>
<ConfirmDialog
open={purgeTargetId != null}
title="彻底删除评论"
message="将从数据库永久删除此评论及其全部子回复,不保留占位、不可恢复。刷屏垃圾请用此操作。"
confirmLabel="彻底删除"
danger
busy={purging}
onConfirm={handlePurgeConfirm}
onCancel={() => {
if (!purging) setPurgeTargetId(null);
}}
/>
</section>
);
}

View File

@@ -133,15 +133,17 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
if (n.type === "rejected") return "未通过审核";
if (n.type === "mention") return "在消息中提到了你";
if (n.type === "pending_review") return "有新内容待审核";
if (n.type === "deleted") return "删除了你的帖子";
return "点赞了你的帖子";
};
// 审核结果类通知:无互动对象语义,使用专用图标与语义色
const isApproval = (n: NotificationItem) => n.type === "approved" || n.type === "rejected";
const isApproval = (n: NotificationItem) =>
n.type === "approved" || n.type === "rejected" || n.type === "deleted";
const iconStyle = (n: NotificationItem): { color: string; bg: string; node: React.ReactNode } => {
if (n.type === "approved")
return { color: "var(--ok)", bg: "var(--ok-soft)", node: <CheckCircle2 size={15} /> };
if (n.type === "rejected")
if (n.type === "rejected" || n.type === "deleted")
return { color: "var(--danger)", bg: "var(--danger-soft)", node: <XCircle size={15} /> };
if (n.type === "like")
return { color: "var(--clay)", bg: "var(--clay-soft)", node: <Heart size={15} /> };

View File

@@ -8,6 +8,7 @@ import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, apiAdminApprove
import { canModerateBoard, isAdminOrAbove } from "@/lib/roles";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
interface PostActionsProps {
postId: string;
@@ -17,6 +18,8 @@ interface PostActionsProps {
recommended: boolean;
/** 帖子审核状态:pending 时对可审者显示「通过评审」 */
status: string;
/** 供删除确认展示 */
title?: string;
}
export default function PostActions({
@@ -26,6 +29,7 @@ export default function PostActions({
pinned,
recommended,
status,
title,
}: PostActionsProps) {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
@@ -35,6 +39,7 @@ export default function PostActions({
const [isRecommended, setIsRecommended] = useState(recommended);
const [acting, setActing] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [staffDeleteOpen, setStaffDeleteOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -43,7 +48,6 @@ export default function PostActions({
.catch(() => setUser(null));
}, []);
// 点击外部 / Esc 关闭菜单(与通知铃铛同一交互模式)
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
@@ -60,17 +64,20 @@ export default function PostActions({
};
}, [open]);
// 作者本人或有该板块审核权的管理团队可管理;置顶/加精仅管理员及以上
const canManage = !!user && (user.id === authorId || canModerateBoard(user, boardId));
const isAdmin = !!user && isAdminOrAbove(user.role);
const canApprove = !!user && status === "pending" && canModerateBoard(user, boardId);
// staff 删他人帖:走类型+理由;作者自删:简单确认
const staffDeletingOther =
!!user && user.id !== authorId && canModerateBoard(user, boardId);
const handleDelete = async () => {
const runDelete = async (opts?: { delete_type?: string; delete_reason?: string }) => {
setDeleting(true);
try {
const res = await apiDeletePost(postId);
const res = await apiDeletePost(postId, opts);
if (res.message) {
setConfirmDelete(false);
setStaffDeleteOpen(false);
router.push("/");
toast("帖子已删除", "ok");
} else {
@@ -217,7 +224,8 @@ export default function PostActions({
role="menuitem"
onClick={() => {
setOpen(false);
setConfirmDelete(true);
if (staffDeletingOther) setStaffDeleteOpen(true);
else setConfirmDelete(true);
}}
disabled={deleting}
className={`${itemCls} items-center`}
@@ -232,13 +240,21 @@ export default function PostActions({
<ConfirmDialog
open={confirmDelete}
title="删除帖子"
message="确定要删除这篇帖子吗?此操作不可恢复。"
message="确定要删除这篇帖子吗?列表将不再展示,打开原链接会提示已删除。"
confirmLabel="确认删除"
danger
busy={deleting}
onConfirm={handleDelete}
onConfirm={() => void runDelete()}
onCancel={() => setConfirmDelete(false)}
/>
<StaffDeletePostModal
open={staffDeleteOpen}
title={title}
busy={deleting}
onClose={() => setStaffDeleteOpen(false)}
onConfirm={(opts) => void runDelete(opts)}
/>
</div>
);
}

View File

@@ -0,0 +1,124 @@
"use client";
import { useEffect, useState } from "react";
import Modal from "@/components/Modal";
import { POST_DELETE_TYPES } from "@/lib/api";
/**
* Staff 软删帖:必填删除类型 + 理由。
* 作者自删仍用 ConfirmDialog,不走此组件。
*/
export default function StaffDeletePostModal({
open,
title,
busy,
onClose,
onConfirm,
}: {
open: boolean;
/** 帖子标题,展示在说明里 */
title?: string;
busy?: boolean;
onClose: () => void;
onConfirm: (opts: { delete_type: string; delete_reason: string }) => void;
}) {
const [deleteType, setDeleteType] = useState("spam");
const [reason, setReason] = useState("");
const [err, setErr] = useState("");
useEffect(() => {
if (open) {
setDeleteType("spam");
setReason("");
setErr("");
}
}, [open]);
if (!open) return null;
const submit = () => {
const r = reason.trim();
if (!r) {
setErr("请填写删除理由");
return;
}
if ([...r].length > 200) {
setErr("删除理由不能超过 200 字");
return;
}
onConfirm({ delete_type: deleteType, delete_reason: r });
};
return (
<Modal
open
title="删除帖子"
description={
title
? `将删除「${title}」。列表不再展示;原链接会显示删除类型与理由。`
: "列表不再展示;原链接会显示删除类型与理由。"
}
onClose={onClose}
busy={busy}
maxWidthClass="max-w-[440px]"
panelClassName="p-5"
ariaLabel="删除帖子"
footer={
<>
<button type="button" className="btn btn-line" onClick={onClose} disabled={busy}>
取消
</button>
<button type="button" className="btn btn-danger" onClick={submit} disabled={busy}>
{busy ? "删除中…" : "确认删除"}
</button>
</>
}
>
<p className="mb-2 text-[12.5px]" style={{ color: "var(--ink)" }}>
删除类型
</p>
<div className="flex flex-col gap-1.5 mb-4" role="radiogroup" aria-label="删除类型">
{POST_DELETE_TYPES.map((t) => {
const active = deleteType === t.value;
return (
<button
key={t.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => setDeleteType(t.value)}
className="text-left rounded-lg px-3 py-2 text-[13px] transition-colors"
style={{
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
background: active ? "var(--accent-soft)" : "var(--panel-2)",
color: active ? "var(--accent)" : "var(--ink)",
}}
>
{t.label}
</button>
);
})}
</div>
<label className="block mb-1.5 text-[12.5px]" style={{ color: "var(--ink)" }} htmlFor="delete-reason">
删除理由 <span style={{ color: "var(--danger)" }}>*</span>
</label>
<textarea
id="delete-reason"
className="field"
rows={3}
value={reason}
onChange={(e) => {
setReason(e.target.value);
setErr("");
}}
placeholder="将展示在帖子专用页与作者通知中"
maxLength={200}
/>
{err && (
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>
{err}
</p>
)}
</Modal>
);
}