Files
jiang13-bbs/frontend/components/StaffUserMenu.tsx
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

262 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 { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
Ban,
ChevronDown,
FolderSearch,
RotateCcw,
Shield,
SlidersHorizontal,
Users,
} from "lucide-react";
import {
apiAdminGetUserPerms,
apiAdminSetUserBan,
apiListBoards,
apiMe,
type Board,
type User,
} from "@/lib/api";
import { isOwner, isSuperOrOwner, ROLES } from "@/lib/roles";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import RoleAccountModal, { type RoleAccountTarget } from "@/components/RoleAccountModal";
export interface StaffUserTarget {
id: number;
username: string;
nickname: string;
role: string;
board_ids?: number[];
/** 有权访客资料接口下发;未知时按未封禁处理 */
banned?: boolean;
avatar?: string;
created_at?: string;
points?: number;
}
/**
* 超管/站长对用户的前台快捷菜单:封禁、改角色、内容档案。
* 权限仅控显隐;写操作以后端为准。
*/
export default function StaffUserMenu({
target,
compact = false,
onBannedChange,
}: {
target: StaffUserTarget;
/** 作者栏等窄位用更小按钮 */
compact?: boolean;
/** 封禁态变化回调(主页徽章等) */
onBannedChange?: (banned: boolean) => void;
}) {
const router = useRouter();
const menuRef = useRef<HTMLDivElement>(null);
const [me, setMe] = useState<User | null>(null);
const [open, setOpen] = useState(false);
const [banned, setBanned] = useState(!!target.banned);
const [confirmBan, setConfirmBan] = useState(false);
const [roleOpen, setRoleOpen] = useState(false);
const [boards, setBoards] = useState<Board[]>([]);
const [acting, setActing] = useState(false);
/** 目标账号的渠道权限(打开弹窗时按需拉取;undefined = 加载中) */
const [rolePerms, setRolePerms] = useState<string[] | undefined>(undefined);
useEffect(() => {
apiMe()
.then((res) => setMe(res.user || null))
.catch(() => setMe(null));
}, []);
useEffect(() => {
setBanned(!!target.banned);
}, [target.banned, target.id]);
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 =
!!me &&
isSuperOrOwner(me.role) &&
me.id !== target.id &&
target.role !== ROLES.OWNER;
if (!canManage) return null;
const openRoleModal = () => {
setOpen(false);
setRoleOpen(true);
setRolePerms(undefined);
if (boards.length === 0) {
apiListBoards()
.then((res) => setBoards(res.boards ?? []))
.catch(() => toast("板块列表加载失败"));
}
apiAdminGetUserPerms(target.id)
.then((res) => setRolePerms(res.perm_overrides))
.catch(() => toast("渠道权限加载失败"));
};
const handleBanConfirm = async () => {
const next = !banned;
setActing(true);
try {
await apiAdminSetUserBan(target.id, next);
setBanned(next);
onBannedChange?.(next);
setConfirmBan(false);
toast(next ? "已封禁" : "已解封", "ok");
router.refresh();
} catch (e) {
toast(e instanceof Error ? e.message : "操作失败");
} finally {
setActing(false);
}
};
const roleTarget: RoleAccountTarget = {
id: target.id,
username: target.username,
nickname: target.nickname,
role: target.role,
board_ids: target.board_ids,
banned,
perm_overrides: rolePerms,
avatar: target.avatar,
created_at: target.created_at,
points: target.points,
};
const itemCls =
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13px] transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]";
return (
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
disabled={acting}
aria-haspopup="menu"
aria-expanded={open}
className={compact ? "btn btn-line btn-sm" : "btn btn-line btn-sm"}
style={open ? { borderColor: "var(--accent)", color: "var(--accent)" } : undefined}
data-tip="管理该用户"
>
<SlidersHorizontal size={13} />
{compact ? "管理" : "管理用户"}
<ChevronDown
size={13}
style={{ transition: "transform .15s", transform: open ? "rotate(180deg)" : "none" }}
/>
</button>
{open && (
<div
role="menu"
className="absolute right-0 mt-2 w-48 overflow-hidden z-50 j13-toast-in"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
borderRadius: "var(--r)",
boxShadow: "var(--shadow-lg)",
}}
>
<button
type="button"
role="menuitem"
onClick={() => {
setOpen(false);
setConfirmBan(true);
}}
className={itemCls}
style={{ color: banned ? "var(--ok)" : "var(--danger)" }}
>
{banned ? <RotateCcw size={14} className="shrink-0" /> : <Ban size={14} className="shrink-0" />}
<span className="flex-1 text-left">{banned ? "解封账号" : "封禁账号"}</span>
</button>
<button
type="button"
role="menuitem"
onClick={openRoleModal}
className={itemCls}
style={{ color: "var(--ink)" }}
>
<Shield size={14} className="shrink-0" />
<span className="flex-1 text-left">角色与账号</span>
</button>
<div style={{ height: 1, background: "var(--line)" }} />
{isOwner(me.role) && (
<Link
href={`/admin/users/${target.id}`}
role="menuitem"
className={itemCls}
style={{ color: "var(--ink)" }}
onClick={() => setOpen(false)}
>
<FolderSearch size={14} className="shrink-0" />
<span className="flex-1 text-left">内容档案</span>
</Link>
)}
<Link
href="/admin/users"
role="menuitem"
className={itemCls}
style={{ color: "var(--ink)" }}
onClick={() => setOpen(false)}
>
<Users size={14} className="shrink-0" />
<span className="flex-1 text-left">后台用户列表</span>
</Link>
</div>
)}
<ConfirmDialog
open={confirmBan}
title={banned ? "解封账号" : "封禁账号"}
message={
banned
? `确定解封 @${target.username}?解封后可立即登录与互动。`
: `确定封禁 @${target.username}?将立即强制下线,无法登录、发帖与回复;已发布内容保留。`
}
confirmLabel={banned ? "确认解封" : "确认封禁"}
danger={!banned}
busy={acting}
onConfirm={handleBanConfirm}
onCancel={() => setConfirmBan(false)}
/>
<RoleAccountModal
target={roleOpen ? roleTarget : null}
boards={boards}
canGrantSuper={isOwner(me.role)}
acting={acting}
onClose={() => setRoleOpen(false)}
onSaved={(next) => {
setBanned(next.banned);
setRolePerms(next.perm_overrides);
onBannedChange?.(next.banned);
setRoleOpen(false);
router.refresh();
}}
setActing={setActing}
/>
</div>
);
}