Files
jiang13-bbs/frontend/components/PostActions.tsx
freefire bcb4f6a603 feat(moderation): 帖评删除占位感知与硬删清场
管理删帖须填类型与理由并展示专用页;评论软删占位、版主可硬删不占楼。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 07:01:04 +08:00

261 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Pencil, Trash2, Pin, Star, SlidersHorizontal, ChevronDown, Check, BadgeCheck } from "lucide-react";
import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, apiAdminApprovePost, type User } from "@/lib/api";
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;
authorId: number;
boardId: number;
pinned: number;
recommended: boolean;
/** 帖子审核状态:pending 时对可审者显示「通过评审」 */
status: string;
/** 供删除确认展示 */
title?: string;
}
export default function PostActions({
postId,
authorId,
boardId,
pinned,
recommended,
status,
title,
}: PostActionsProps) {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [open, setOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [isPinned, setIsPinned] = useState(pinned > 0);
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(() => {
apiMe()
.then((res) => setUser(res.user || null))
.catch(() => setUser(null));
}, []);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [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 runDelete = async (opts?: { delete_type?: string; delete_reason?: string }) => {
setDeleting(true);
try {
const res = await apiDeletePost(postId, opts);
if (res.message) {
setConfirmDelete(false);
setStaffDeleteOpen(false);
router.push("/");
toast("帖子已删除", "ok");
} else {
toast(res.error || "删除失败");
}
} catch {
toast("网络错误,删除失败");
} finally {
setDeleting(false);
}
};
const handleApprove = async () => {
setActing(true);
setOpen(false);
try {
await apiAdminApprovePost(Number(postId));
toast("已通过评审", "ok");
router.refresh();
} catch (e) {
toast(e instanceof Error ? e.message : "通过评审失败");
} finally {
setActing(false);
}
};
const handlePin = async () => {
setActing(true);
setOpen(false);
try {
const res = await apiTogglePin(postId);
setIsPinned(res.pinned > 0);
toast(res.pinned > 0 ? "已置顶" : "已取消置顶", "ok");
router.refresh();
} catch {
toast("操作失败,请稍后重试");
} finally {
setActing(false);
}
};
const handleRecommend = async () => {
setActing(true);
setOpen(false);
try {
const res = await apiToggleRecommend(postId);
setIsRecommended(res.recommended);
toast(res.recommended ? "已设为推荐" : "已取消推荐", "ok");
router.refresh();
} catch {
toast("操作失败,请稍后重试");
} finally {
setActing(false);
}
};
if (!canManage) return null;
const itemCls =
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13px] transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]";
return (
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
disabled={acting || deleting}
aria-haspopup="menu"
aria-expanded={open}
className="btn btn-line btn-sm"
style={open ? { borderColor: "var(--accent)", color: "var(--accent)" } : undefined}
>
<SlidersHorizontal size={13} /> 管理
<ChevronDown
size={13}
style={{ transition: "transform .15s", transform: open ? "rotate(180deg)" : "none" }}
/>
</button>
{open && (
<div
role="menu"
className="absolute right-0 mt-2 w-44 overflow-hidden z-50 j13-toast-in"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
borderRadius: "var(--r)",
boxShadow: "var(--shadow-lg)",
}}
>
{canApprove && (
<>
<button
type="button"
role="menuitem"
onClick={handleApprove}
className={itemCls}
style={{ color: "var(--accent)" }}
>
<BadgeCheck size={14} className="shrink-0" />
<span className="flex-1 text-left">通过评审</span>
</button>
<div style={{ height: 1, background: "var(--line)" }} />
</>
)}
{isAdmin && (
<>
<button
type="button"
role="menuitem"
onClick={handlePin}
className={itemCls}
style={isPinned ? { color: "var(--accent)" } : { color: "var(--ink)" }}
>
<Pin size={14} className="shrink-0" />
<span className="flex-1 text-left">{isPinned ? "取消置顶" : "置顶"}</span>
{isPinned && <Check size={14} className="shrink-0" />}
</button>
<button
type="button"
role="menuitem"
onClick={handleRecommend}
className={itemCls}
style={isRecommended ? { color: "var(--gold)" } : { color: "var(--ink)" }}
>
<Star size={14} className="shrink-0" />
<span className="flex-1 text-left">{isRecommended ? "取消推荐" : "推荐"}</span>
{isRecommended && <Check size={14} className="shrink-0" />}
</button>
<div style={{ height: 1, background: "var(--line)" }} />
</>
)}
<Link
href={`/post/${postId}/edit`}
role="menuitem"
className={`${itemCls} items-center`}
style={{ color: "var(--ink)" }}
>
<Pencil size={14} className="shrink-0" />
<span className="flex-1 text-left">编辑帖子</span>
</Link>
<button
type="button"
role="menuitem"
onClick={() => {
setOpen(false);
if (staffDeletingOther) setStaffDeleteOpen(true);
else setConfirmDelete(true);
}}
disabled={deleting}
className={`${itemCls} items-center`}
style={{ color: "var(--danger)" }}
>
<Trash2 size={14} className="shrink-0" />
<span className="flex-1 text-left">{deleting ? "删除中..." : "删除帖子"}</span>
</button>
</div>
)}
<ConfirmDialog
open={confirmDelete}
title="删除帖子"
message="确定要删除这篇帖子吗?列表将不再展示,打开原链接会提示已删除。"
confirmLabel="确认删除"
danger
busy={deleting}
onConfirm={() => void runDelete()}
onCancel={() => setConfirmDelete(false)}
/>
<StaffDeletePostModal
open={staffDeleteOpen}
title={title}
busy={deleting}
onClose={() => setStaffDeleteOpen(false)}
onConfirm={(opts) => void runDelete(opts)}
/>
</div>
);
}