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 ||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} /> };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
124
frontend/components/StaffDeletePostModal.tsx
Normal file
124
frontend/components/StaffDeletePostModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user