Files
jiang13-bbs/frontend/components/PostActions.tsx
freefire f3145b24ff feat: 就地通过评审,并为已过审用户提供默认可关的免审发布
帖/评管理入口可直接通过;站点默认信任已有 published 内容的用户后续直发。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 05:37:57 +08:00

245 lines
7.8 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";
interface PostActionsProps {
postId: string;
authorId: number;
boardId: number;
pinned: number;
recommended: boolean;
/** 帖子审核状态:pending 时对可审者显示「通过评审」 */
status: string;
}
export default function PostActions({
postId,
authorId,
boardId,
pinned,
recommended,
status,
}: 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 canApprove = !!user && status === "pending" && canModerateBoard(user, boardId);
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 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);
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={handleDelete}
onCancel={() => setConfirmDelete(false)}
/>
</div>
);
}