feat(comments): 软删占位、理由与恢复,管理删仅对访客打码
自删/管理删差异文案与理由可见性,评论者与管理员免马赛克,并支持恢复与处理人链接。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,15 +11,18 @@ import {
|
||||
ChevronDown,
|
||||
BadgeCheck,
|
||||
XCircle,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
apiCreateComment,
|
||||
apiDeleteComment,
|
||||
apiRestoreComment,
|
||||
apiPurgeComment,
|
||||
apiAcceptBounty,
|
||||
apiAcceptQuestion,
|
||||
apiAdminApproveComment,
|
||||
apiAdminRejectComment,
|
||||
postDeleteTypeLabel,
|
||||
type CommentNode,
|
||||
type User,
|
||||
} from "@/lib/api";
|
||||
@@ -30,6 +33,7 @@ import Avatar from "./Avatar";
|
||||
import Pagination from "./Pagination";
|
||||
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/DeleteCommentModal";
|
||||
|
||||
interface CommentSectionProps {
|
||||
postId: string;
|
||||
@@ -51,14 +55,48 @@ interface CommentSectionProps {
|
||||
allowComments?: boolean;
|
||||
}
|
||||
|
||||
// 删除按钮的两段式确认:点一下进入确认态,3 秒未确认自动复原
|
||||
const CONFIRM_RESET_MS = 3000;
|
||||
|
||||
// 递归统计子树回复数(删除级联提示用)
|
||||
// 递归统计子树回复数(彻底删除级联提示用)
|
||||
function countReplies(node: CommentNode): number {
|
||||
return node.replies?.reduce((acc, r) => acc + 1 + countReplies(r), 0) ?? 0;
|
||||
}
|
||||
|
||||
function findComment(nodes: CommentNode[], id: number): CommentNode | null {
|
||||
for (const n of nodes) {
|
||||
if (n.id === id) return n;
|
||||
if (n.replies?.length) {
|
||||
const hit = findComment(n.replies, id);
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 马赛克头像:保留轮廓,不可辨认 */
|
||||
function MosaicAvatar({
|
||||
name,
|
||||
src,
|
||||
size,
|
||||
}: {
|
||||
name: string;
|
||||
src?: string;
|
||||
size: number;
|
||||
}) {
|
||||
return (
|
||||
<span className="cmt-mosaic-avatar" style={{ width: size, height: size }} aria-hidden>
|
||||
<Avatar name={name || "用户"} src={src || undefined} size={size} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 马赛克昵称 */
|
||||
function MosaicName({ text, className = "" }: { text: string; className?: string }) {
|
||||
return (
|
||||
<span className={`cmt-mosaic-name ${className}`} style={{ color: "var(--ink-2)" }} aria-hidden>
|
||||
{text || "用户"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CommentSection({
|
||||
postId,
|
||||
boardId,
|
||||
@@ -79,14 +117,16 @@ export default function CommentSection({
|
||||
const [replyTarget, setReplyTarget] = useState<{ id: number; nickname: string } | null>(null);
|
||||
const [replyContent, setReplyContent] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
|
||||
const [acceptingId, setAcceptingId] = useState<number | null>(null);
|
||||
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);
|
||||
const [restoreTargetId, setRestoreTargetId] = useState<number | null>(null);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
|
||||
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
|
||||
const [commentsOpen, setCommentsOpen] = useState(allowCommentsProp !== false);
|
||||
@@ -164,19 +204,20 @@ export default function CommentSection({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (commentId: number) => {
|
||||
if (confirmingId !== commentId) {
|
||||
setConfirmingId(commentId);
|
||||
setTimeout(() => {
|
||||
setConfirmingId((cur) => (cur === commentId ? null : cur));
|
||||
}, CONFIRM_RESET_MS);
|
||||
return;
|
||||
}
|
||||
setConfirmingId(null);
|
||||
setDeletingId(commentId);
|
||||
const deleteTarget = deleteTargetId != null ? findComment(comments, deleteTargetId) : null;
|
||||
const deleteMode: "self" | "staff" =
|
||||
deleteTarget && user && user.id === deleteTarget.user_id ? "self" : "staff";
|
||||
const purgeTarget = purgeTargetId != null ? findComment(comments, purgeTargetId) : null;
|
||||
const purgeReplyCount = purgeTarget ? countReplies(purgeTarget) : 0;
|
||||
|
||||
const handleDeleteConfirm = async (opts: DeleteCommentPayload) => {
|
||||
if (deleteTargetId == null) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await apiDeleteComment(postId, commentId);
|
||||
const res = await apiDeleteComment(postId, deleteTargetId, opts);
|
||||
if (res.message) {
|
||||
toast(deleteMode === "self" ? "已删除评论" : "已删除该评论", "ok");
|
||||
setDeleteTargetId(null);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast(res.error || "删除失败");
|
||||
@@ -184,7 +225,7 @@ export default function CommentSection({
|
||||
} catch {
|
||||
toast("网络错误,删除失败");
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -256,6 +297,25 @@ export default function CommentSection({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreConfirm = async () => {
|
||||
if (restoreTargetId == null) return;
|
||||
setRestoring(true);
|
||||
try {
|
||||
const res = await apiRestoreComment(postId, restoreTargetId);
|
||||
if (res.message) {
|
||||
toast("评论已恢复", "ok");
|
||||
setRestoreTargetId(null);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast(res.error || "恢复失败");
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "恢复失败");
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 引用功能已移除:与"回复"功能重叠,且会割裂讨论上下文(抖音风格无此功能)
|
||||
|
||||
const canSoftDelete = (comment: CommentNode) =>
|
||||
@@ -266,6 +326,13 @@ export default function CommentSection({
|
||||
const canPurge = (comment: CommentNode) =>
|
||||
!!user && canModerateBoard(user, boardId);
|
||||
|
||||
/** 作者可恢复自删;版主可恢复本板任意软删(含管理删除) */
|
||||
const canRestore = (comment: CommentNode) => {
|
||||
if (!comment.deleted || !user) return false;
|
||||
if (canModerateBoard(user, boardId)) return true;
|
||||
return user.id === comment.user_id && !comment.staff_deleted;
|
||||
};
|
||||
|
||||
const canApprove = (comment: CommentNode) =>
|
||||
!comment.deleted &&
|
||||
!!user &&
|
||||
@@ -348,42 +415,135 @@ export default function CommentSection({
|
||||
const isExpanded = isFloor ? expandedFloors.has(c.id) : true;
|
||||
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)",
|
||||
}}
|
||||
const displayName = c.user?.nickname || c.user?.username || "用户";
|
||||
const typeLabel = postDeleteTypeLabel(c.delete_type);
|
||||
const isMod = !!user && canModerateBoard(user, boardId);
|
||||
const isCommenter = !!user && user.id === c.user_id;
|
||||
// 管理删:仅普通访客打码;管理员与评论者本人看真实身份
|
||||
const showMosaic = !!c.staff_deleted && !isMod && !isCommenter;
|
||||
const selfReasonForStaff = !c.staff_deleted && !!c.delete_reason && isMod;
|
||||
const staffPublicMeta = c.staff_deleted && (!!typeLabel || !!c.delete_reason);
|
||||
const deleter = c.deleted_by_user;
|
||||
const deleterName = deleter
|
||||
? deleter.nickname || deleter.username || "管理员"
|
||||
: "";
|
||||
|
||||
const identity = showMosaic ? (
|
||||
<MosaicAvatar name={displayName} src={c.user?.avatar} size={isFloor ? 38 : 28} />
|
||||
) : (
|
||||
<Link
|
||||
href={`/u/${c.user_id}`}
|
||||
className={isFloor ? "shrink-0 mt-0.5" : "shrink-0"}
|
||||
aria-label={displayName}
|
||||
>
|
||||
此评论已删除
|
||||
<Avatar name={displayName} src={c.user?.avatar || undefined} size={isFloor ? 38 : 28} />
|
||||
</Link>
|
||||
);
|
||||
|
||||
const nameRow = showMosaic ? (
|
||||
<MosaicName text={displayName} className={isFloor ? "text-sm" : "cmt-reply-name"} />
|
||||
) : (
|
||||
<Link href={`/u/${c.user_id}`} className="group">
|
||||
<span
|
||||
className={`truncate group-hover:text-[var(--accent)] transition-colors ${
|
||||
isFloor ? "text-sm" : "cmt-reply-name"
|
||||
}`}
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
const tombstoneBody = (
|
||||
<div className="cmt-tombstone">
|
||||
<p className="cmt-tombstone-title">
|
||||
{c.staff_deleted ? "管理员已删除此评论" : "评论者已删除此评论"}
|
||||
</p>
|
||||
{staffPublicMeta ? (
|
||||
<p className="cmt-tombstone-reason">
|
||||
{typeLabel ? (
|
||||
<>
|
||||
<span style={{ color: "var(--ink-2)", fontWeight: 600 }}>{typeLabel}</span>
|
||||
{c.delete_reason ? " · " : null}
|
||||
</>
|
||||
) : null}
|
||||
{c.delete_reason || null}
|
||||
</p>
|
||||
) : null}
|
||||
{c.staff_deleted && deleter ? (
|
||||
<div className="cmt-tombstone-mod">
|
||||
<span>处理人</span>
|
||||
<Link href={`/u/${deleter.id}`} aria-label={`查看管理员 ${deleterName}`}>
|
||||
<Avatar name={deleterName} src={deleter.avatar || undefined} size={20} />
|
||||
{deleterName}
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
{selfReasonForStaff ? (
|
||||
<div className="cmt-tombstone-audit">
|
||||
<span className="cmt-tombstone-audit-label">自删理由 · 仅管理可见</span>
|
||||
<p className="cmt-tombstone-reason !mt-1">{c.delete_reason}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{isMod && c.content ? (
|
||||
<div className="cmt-tombstone-orig">
|
||||
<span className="cmt-tombstone-audit-label">原文 · 仅管理可见</span>
|
||||
<div className="mt-1.5">
|
||||
<MarkdownBodyClient content={c.content} className="prose-content prose-comment" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{c.staff_deleted ? (
|
||||
<span className="cmt-tombstone-tag">管理删除</span>
|
||||
) : (
|
||||
<span className="cmt-tombstone-tag cmt-tombstone-tag-self">评论者删除</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const purgeBtn = canPurge(c) && (
|
||||
<button
|
||||
type="button"
|
||||
className="cmt-reply-btn !text-[var(--danger)]"
|
||||
disabled={purging}
|
||||
className="cmt-mod-btn cmt-mod-btn-danger"
|
||||
disabled={purging || restoring}
|
||||
onClick={() => setPurgeTargetId(c.id)}
|
||||
data-tip="彻底删除,不占位"
|
||||
>
|
||||
彻底删除
|
||||
清除
|
||||
</button>
|
||||
);
|
||||
const restoreBtn = canRestore(c) && (
|
||||
<button
|
||||
type="button"
|
||||
className="cmt-mod-btn"
|
||||
style={{ color: "var(--accent)" }}
|
||||
disabled={restoring || purging}
|
||||
onClick={() => setRestoreTargetId(c.id)}
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
恢复
|
||||
</button>
|
||||
);
|
||||
const tombstoneActions =
|
||||
restoreBtn || purgeBtn ? (
|
||||
<div className="cmt-mod-actions">
|
||||
{restoreBtn}
|
||||
{purgeBtn}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (!isFloor) {
|
||||
return (
|
||||
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24 opacity-80">
|
||||
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24">
|
||||
<div className="flex gap-2.5">
|
||||
<span className="shrink-0 w-7 h-7 rounded-full" style={{ background: "var(--line)" }} aria-hidden />
|
||||
{identity}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="mb-1">{nameRow}</div>
|
||||
{tombstoneBody}
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
<div className="flex items-center justify-between gap-3 mt-1.5 flex-wrap">
|
||||
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
|
||||
{purgeBtn}
|
||||
{tombstoneActions}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -400,16 +560,14 @@ export default function CommentSection({
|
||||
<div
|
||||
key={c.id}
|
||||
id={`comment-${c.id}`}
|
||||
className="py-5 border-t first:border-t-0 scroll-mt-24 opacity-90"
|
||||
className="py-5 border-t first:border-t-0 scroll-mt-24"
|
||||
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 />
|
||||
{identity}
|
||||
<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>
|
||||
{nameRow}
|
||||
{floor && (
|
||||
<a href={`#comment-${c.id}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
|
||||
#{floor}
|
||||
@@ -417,9 +575,9 @@ export default function CommentSection({
|
||||
)}
|
||||
</div>
|
||||
{tombstoneBody}
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<div className="flex items-center justify-between gap-3 mt-2 flex-wrap">
|
||||
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
|
||||
{purgeBtn}
|
||||
{tombstoneActions}
|
||||
</div>
|
||||
{node.replies && node.replies.length > 0 && (
|
||||
<div className="mt-3">
|
||||
@@ -532,34 +690,32 @@ export default function CommentSection({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{canSoftDelete(c) && (
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
disabled={deletingId === c.id}
|
||||
className={`meta transition-colors disabled:opacity-50 p-1 ${
|
||||
confirmingId === c.id ? "!text-[var(--danger)]" : "hover:text-[var(--danger)]"
|
||||
}`}
|
||||
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
|
||||
data-tip={confirmingId === c.id ? "再点一次确认删除(占位)" : "删除(占位)"}
|
||||
>
|
||||
{confirmingId === c.id ? (
|
||||
<span className="text-[11.5px] whitespace-nowrap">确认删除?</span>
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
{(canSoftDelete(c) || (canPurge(c) && !c.deleted)) && (
|
||||
<div className="cmt-mod-actions" role="group" aria-label="评论管理">
|
||||
{canSoftDelete(c) && (
|
||||
<button
|
||||
type="button"
|
||||
className="cmt-mod-btn"
|
||||
onClick={() => setDeleteTargetId(c.id)}
|
||||
disabled={deleting}
|
||||
aria-label="删除评论"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
{canPurge(c) && (
|
||||
<button
|
||||
type="button"
|
||||
className="cmt-mod-btn cmt-mod-btn-danger"
|
||||
onClick={() => setPurgeTargetId(c.id)}
|
||||
disabled={purging}
|
||||
aria-label="彻底删除"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{replyTarget?.id === c.id && inlineReplyForm}
|
||||
@@ -659,34 +815,32 @@ export default function CommentSection({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{canSoftDelete(c) && (
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
disabled={deletingId === c.id}
|
||||
className={`meta transition-colors disabled:opacity-50 p-1 ${
|
||||
confirmingId === c.id ? "!text-[var(--danger)]" : "hover:text-[var(--danger)]"
|
||||
}`}
|
||||
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
|
||||
data-tip={confirmingId === c.id ? "再点一次确认删除(占位)" : "删除(占位)"}
|
||||
>
|
||||
{confirmingId === c.id ? (
|
||||
<span className="text-[11.5px] whitespace-nowrap">确认删除?</span>
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
{(canSoftDelete(c) || canPurge(c)) && (
|
||||
<div className="cmt-mod-actions" role="group" aria-label="评论管理">
|
||||
{canSoftDelete(c) && (
|
||||
<button
|
||||
type="button"
|
||||
className="cmt-mod-btn"
|
||||
onClick={() => setDeleteTargetId(c.id)}
|
||||
disabled={deleting}
|
||||
aria-label="删除评论"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
{canPurge(c) && (
|
||||
<button
|
||||
type="button"
|
||||
className="cmt-mod-btn cmt-mod-btn-danger"
|
||||
onClick={() => setPurgeTargetId(c.id)}
|
||||
disabled={purging}
|
||||
aria-label="彻底删除"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{replyTarget?.id === c.id && inlineReplyForm}
|
||||
@@ -829,11 +983,37 @@ export default function CommentSection({
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteCommentModal
|
||||
open={deleteTargetId != null}
|
||||
mode={deleteMode}
|
||||
busy={deleting}
|
||||
onClose={() => {
|
||||
if (!deleting) setDeleteTargetId(null);
|
||||
}}
|
||||
onConfirm={(opts) => void handleDeleteConfirm(opts)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={restoreTargetId != null}
|
||||
title="恢复评论"
|
||||
message="确定恢复这条评论?正文与评论者信息将重新公开显示。"
|
||||
confirmLabel="确认恢复"
|
||||
busy={restoring}
|
||||
onConfirm={handleRestoreConfirm}
|
||||
onCancel={() => {
|
||||
if (!restoring) setRestoreTargetId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={purgeTargetId != null}
|
||||
title="彻底删除评论"
|
||||
message="将从数据库永久删除此评论及其全部子回复,不保留占位、不可恢复。刷屏垃圾请用此操作。"
|
||||
confirmLabel="彻底删除"
|
||||
message={
|
||||
purgeReplyCount > 0
|
||||
? `将永久清除此评论及其 ${purgeReplyCount} 条回复,不保留占位、不可恢复。刷屏/垃圾内容请用此操作;一般违规请用「删除」保留占位。`
|
||||
: "将永久清除此评论,不保留占位、不可恢复。刷屏/垃圾内容请用此操作;一般违规请用「删除」保留占位。"
|
||||
}
|
||||
confirmLabel="永久清除"
|
||||
danger
|
||||
busy={purging}
|
||||
onConfirm={handlePurgeConfirm}
|
||||
|
||||
172
frontend/components/DeleteCommentModal.tsx
Normal file
172
frontend/components/DeleteCommentModal.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Modal from "@/components/Modal";
|
||||
import { POST_DELETE_TYPES } from "@/lib/api";
|
||||
|
||||
export type DeleteCommentPayload = {
|
||||
delete_type: string;
|
||||
delete_reason: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 评论软删弹窗:
|
||||
* - 评论者自删:必填理由(仅管理可见)
|
||||
* - 管理删:必选类型;仅「其他」须填理由
|
||||
*/
|
||||
export default function DeleteCommentModal({
|
||||
open,
|
||||
mode,
|
||||
busy,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** self=评论者自删;staff=版主/后台删除 */
|
||||
mode: "self" | "staff";
|
||||
busy?: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (opts: DeleteCommentPayload) => 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 isStaff = mode === "staff";
|
||||
const reasonRequired = !isStaff || deleteType === "other";
|
||||
|
||||
const submit = () => {
|
||||
const r = reason.trim();
|
||||
if (reasonRequired) {
|
||||
if (!r) {
|
||||
setErr("请填写删除理由");
|
||||
return;
|
||||
}
|
||||
if ([...r].length > 200) {
|
||||
setErr("删除理由不能超过 200 字");
|
||||
return;
|
||||
}
|
||||
}
|
||||
onConfirm({
|
||||
delete_type: isStaff ? deleteType : "",
|
||||
delete_reason: reasonRequired ? r : "",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={isStaff ? "删除评论" : "删除我的评论"}
|
||||
description={
|
||||
isStaff
|
||||
? "评论区保留马赛克占位;读者可见删除类型,「其他」时还会展示补充理由。子回复不受影响。"
|
||||
: "评论区保留马赛克占位,读者仅见「评论者已删除此评论」。你填写的理由仅管理员可见。"
|
||||
}
|
||||
onClose={onClose}
|
||||
busy={busy}
|
||||
maxWidthClass="max-w-[440px]"
|
||||
panelClassName="p-5"
|
||||
ariaLabel={isStaff ? "删除评论" : "删除我的评论"}
|
||||
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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{isStaff ? (
|
||||
<>
|
||||
<p className="mb-2 text-[12.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
删除类型
|
||||
</p>
|
||||
<div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-1.5 mb-1"
|
||||
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);
|
||||
setErr("");
|
||||
if (t.value !== "other") setReason("");
|
||||
}}
|
||||
className="text-left rounded-lg px-3 py-2.5 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>
|
||||
<p className="meta text-[11.5px] mb-4 leading-relaxed">
|
||||
选「其他」时需补充理由;其余类型直接按标签展示给读者。
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{reasonRequired ? (
|
||||
<>
|
||||
<label
|
||||
className="block mb-1.5 text-[12.5px] font-medium"
|
||||
style={{ color: "var(--ink)" }}
|
||||
htmlFor="cmt-delete-reason"
|
||||
>
|
||||
{isStaff ? "补充理由" : "删除理由"}{" "}
|
||||
<span style={{ color: "var(--danger)" }}>*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="cmt-delete-reason"
|
||||
className="field"
|
||||
rows={3}
|
||||
value={reason}
|
||||
onChange={(e) => {
|
||||
setReason(e.target.value);
|
||||
setErr("");
|
||||
}}
|
||||
placeholder={
|
||||
isStaff
|
||||
? "向读者说明删除原因(将展示在占位中)"
|
||||
: "例如:发错了 / 不想保留(仅管理员可见)"
|
||||
}
|
||||
maxLength={200}
|
||||
autoFocus={!isStaff}
|
||||
/>
|
||||
<p className="meta text-[11.5px] mt-1.5">
|
||||
{isStaff ? "将公开显示在评论占位旁。" : "普通读者看不到这条理由。"}
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{err ? (
|
||||
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>
|
||||
{err}
|
||||
</p>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user