Files
jiang13-bbs/frontend/components/PostActions.tsx
freefire a7b6421840 feat: 实现完整的板块级RBAC内容审核系统
- 新增站长/超级管理员/管理员/板块管理员四级角色体系
- 实现实时权限快照加载与细粒度权限校验
- 新增内容审核队列与前后端页面
- 重构用户权限管理与站点管理逻辑
- 新增评论/帖子审核状态与通知推送
- 优化前端权限控制与角色徽章展示
- 修正数据库迁移与默认值问题
2026-09-15 04:47:34 +08:00

247 lines
8.3 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, Loader2, X } from "lucide-react";
import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, type User } from "@/lib/api";
import { canModerateBoard, isAdminOrAbove } from "@/lib/roles";
import { toast } from "@/lib/toast";
interface PostActionsProps {
postId: string;
authorId: number;
boardId: number;
pinned: number;
recommended: boolean;
}
export default function PostActions({ postId, authorId, boardId, pinned, recommended }: 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 menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
apiMe()
.then((res) => setUser(res.user || null))
.catch(() => setUser(null));
}, []);
// 点击外部 / Esc 关闭菜单(与通知铃铛同一交互模式)
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 handleDelete = async () => {
setDeleting(true);
try {
const res = await apiDeletePost(postId);
if (res.message) {
setConfirmDelete(false);
router.push("/");
toast("帖子已删除", "ok");
} else {
toast(res.error || "删除失败");
}
} catch {
toast("网络错误,删除失败");
} finally {
setDeleting(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)",
}}
>
{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);
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>
)}
{confirmDelete && (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center p-4"
style={{ background: "color-mix(in srgb, var(--ink) 55%, transparent)" }}
onClick={() => !deleting && setConfirmDelete(false)}
>
<div
className="j13-toast-in panel w-full max-w-[400px] rounded-2xl p-6"
style={{ boxShadow: "var(--shadow-lg)" }}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<div className="flex items-start gap-4">
<span
className="w-12 h-12 rounded-full flex items-center justify-center shrink-0"
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
aria-hidden="true"
>
<Trash2 size={22} />
</span>
<div className="min-w-0 flex-1">
<h3 className="text-[16px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
删除帖子
</h3>
<p className="meta mt-1.5 text-[13px] leading-relaxed">
确定要删除这篇帖子吗?此操作不可恢复。
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-5">
<button
type="button"
className="btn btn-line"
onClick={() => setConfirmDelete(false)}
disabled={deleting}
>
<X size={14} /> 取消
</button>
<button
type="button"
className="btn btn-danger"
onClick={handleDelete}
disabled={deleting}
>
{deleting && <Loader2 size={15} className="animate-spin" />}
确认删除
</button>
</div>
</div>
</div>
)}
</div>
);
}