"use client"; import { useEffect, useState } from "react"; import Link from "next/link"; import type { LucideIcon } from "lucide-react"; import { Ban, BookOpen, CircleDollarSign, ExternalLink, FileText, Loader2, Megaphone, RotateCcw, ShieldCheck, Users as UsersIcon, } from "lucide-react"; import { apiAdminSetUserBan, apiAdminSetUserPerms, apiAdminSetUserRole, apiAdminSetUserMessages, type Board, } from "@/lib/api"; import { useUrlStyle } from "@/components/SiteBrandProvider"; import { userPath } from "@/lib/urlStyle"; import { CHANNEL_PERMS, PERM_META, PERMS, ROLE_LEVEL, ROLE_META, ROLES, roleLevel, roleMeta, } from "@/lib/roles"; import { formatDate } from "@/lib/format"; import { toast } from "@/lib/toast"; import Avatar from "@/components/Avatar"; import Modal from "@/components/Modal"; import RoleBadge from "@/components/RoleBadge"; import BoardIcon from "@/components/BoardIcon"; /** 角色与账号弹窗所需的最小用户形状(后台列表项 / 公开资料均可) */ export interface RoleAccountTarget { id: number; username: string; nickname: string; role: string; board_ids?: number[]; banned: boolean; can_manage_messages?: boolean; /** 账号级渠道权限(公告/单页/书库/广告);undefined 表示尚未加载完成 */ perm_overrides?: string[]; avatar?: string; email?: string; points?: number; created_at?: string; } // 渠道权限图标(与后台导航一致) const CHANNEL_ICONS: Record<(typeof CHANNEL_PERMS)[number], LucideIcon> = { [PERMS.ANNOUNCEMENTS]: Megaphone, [PERMS.PAGES]: FileText, [PERMS.LIBRARY]: BookOpen, [PERMS.ADS]: CircleDollarSign, }; interface RoleOption { value: string; title: string; desc: string; token: string; } /** * 角色授权 + 封禁状态弹窗。 * 后台用户管理与前台 StaffUserMenu 共用,避免两套表单。 */ export default function RoleAccountModal({ target, boards, canGrantSuper, acting, onClose, onSaved, setActing, }: { target: RoleAccountTarget | null; boards: Board[]; canGrantSuper: boolean; acting: boolean; onClose: () => void; onSaved: (next: RoleAccountTarget) => void; setActing: (v: boolean) => void; }) { const grantKeys = [ ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.BOARD_ADMIN, ROLES.USER, ] as const; const options: RoleOption[] = grantKeys .filter((k) => canGrantSuper || k !== ROLES.SUPER_ADMIN) .map((k) => { const meta = ROLE_META[k]; return { value: k, title: meta.label, desc: meta.grantDesc || "", token: meta.colorToken, }; }); const [role, setRole] = useState(ROLES.USER); const [boardIds, setBoardIds] = useState([]); const [banned, setBanned] = useState(false); const [manageMessages, setManageMessages] = useState(false); const [permSel, setPermSel] = useState([]); const [err, setErr] = useState(""); useEffect(() => { if (target) { setRole(target.role === ROLES.OWNER ? ROLES.USER : target.role); setBoardIds(target.board_ids ?? []); setBanned(!!target.banned); setManageMessages(!!target.can_manage_messages); setPermSel(target.perm_overrides ?? []); setErr(""); } }, [target]); if (!target) return null; const toggleBoard = (id: number) => { setBoardIds((prev) => (prev.includes(id) ? prev.filter((b) => b !== id) : [...prev, id])); }; const togglePerm = (perm: string) => { setPermSel((prev) => (prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm])); }; const sameBoards = JSON.stringify([...(target.board_ids ?? [])].sort((a, b) => a - b)) === JSON.stringify([...boardIds].sort((a, b) => a - b)); const roleChanged = role !== target.role || (role === ROLES.BOARD_ADMIN && !sameBoards); const banChanged = banned !== !!target.banned; const messagesChanged = canGrantSuper && target.role !== ROLES.OWNER && target.role !== ROLES.SUPER_ADMIN && manageMessages !== !!target.can_manage_messages; /** 渠道权限可编辑性跟随弹窗中选中的角色:管理员及以上自带全部渠道 */ const channelEditable = roleLevel(role) < ROLE_LEVEL[ROLES.ADMIN]; const targetPerms = target.perm_overrides ?? []; const permsChanged = target.perm_overrides !== undefined && channelEditable && JSON.stringify([...targetPerms].sort()) !== JSON.stringify([...permSel].sort()); const willBan = banChanged && banned; const willUnban = banChanged && !banned; const submit = async () => { if (role === ROLES.BOARD_ADMIN && boardIds.length === 0) { setErr("请至少选择一个授权板块"); return; } if (!roleChanged && !banChanged && !messagesChanged && !permsChanged) { onClose(); return; } setActing(true); try { let nextRole = target.role; let nextBoards = target.board_ids ?? []; let nextBanned = !!target.banned; let nextMessages = !!target.can_manage_messages; let nextPerms = target.perm_overrides ?? []; if (roleChanged) { const res = await apiAdminSetUserRole( target.id, role, role === ROLES.BOARD_ADMIN ? boardIds : [] ); nextRole = res.user.role; nextBoards = res.user.board_ids ?? []; } if (permsChanged) { const res = await apiAdminSetUserPerms(target.id, permSel); nextPerms = res.user.perm_overrides ?? [...permSel].sort(); } if (roleChanged && roleLevel(nextRole) >= ROLE_LEVEL[ROLES.ADMIN]) { // 升至管理员及以上:后端已清空账号级渠道授权 nextPerms = []; } if (banChanged) { const res = await apiAdminSetUserBan(target.id, banned); nextBanned = !!res.user.banned; } if (messagesChanged) { const res = await apiAdminSetUserMessages(target.id, manageMessages); nextMessages = !!res.user.can_manage_messages; } const parts: string[] = []; if (roleChanged) parts.push("角色已更新"); if (permsChanged) parts.push("渠道权限已更新"); if (willBan) parts.push("已封禁"); if (willUnban) parts.push("已解封"); if (messagesChanged) parts.push(manageMessages ? "已授予消息管理" : "已撤销消息管理"); toast(parts.join(" · ") || "已保存", "ok"); onSaved({ ...target, role: nextRole, board_ids: nextBoards, banned: nextBanned, can_manage_messages: nextMessages, perm_overrides: nextPerms, }); onClose(); } catch (e) { setErr(e instanceof Error ? e.message : "保存失败"); } finally { setActing(false); } }; const saveLabel = willBan ? "保存并封禁" : willUnban ? "保存并解封" : "保存"; const displayName = target.nickname || target.username; const currentMeta = roleMeta(target.role); return ( } onClose={onClose} busy={acting} maxWidthClass="max-w-[540px]" panelClassName="p-6 max-h-[88vh] flex flex-col overflow-hidden" bodyClassName="overflow-y-auto flex-1 min-h-0 -mx-1 px-1" ariaLabel={`设置 ${displayName} 的角色与账号`} footer={ <> } >

管理角色

{options.map((o) => { const active = role === o.value; return ( ); })}
{role === ROLES.BOARD_ADMIN && (

授权板块 *

{boards.length === 0 ? (

板块加载中…

) : (
{boards.map((b) => { const checked = boardIds.includes(b.id); return ( ); })}
)} {err && role === ROLES.BOARD_ADMIN && (

{err}

)}
)} {target.role !== ROLES.OWNER && (

渠道权限

{target.perm_overrides === undefined ? (

渠道权限加载中…

) : ( <>
{CHANNEL_PERMS.map((perm) => { const Icon = CHANNEL_ICONS[perm]; const meta = PERM_META[perm]; const on = channelEditable && permSel.includes(perm); const body = ( <> {meta.label} {meta.desc} {channelEditable ? ( ) : ( 角色自带 )} ); const rowStyle = { border: `1.5px solid ${on ? "var(--accent)" : "var(--line)"}`, background: on ? "var(--accent-soft)" : "var(--panel-2)", }; const rowCls = "w-full rounded-xl px-3.5 py-3 text-left transition-colors flex items-center justify-between gap-3"; return channelEditable ? ( ) : (
{body}
); })}

{channelEditable ? "授予后该账号即可进入后台管理对应渠道,立即生效;可随时收回。" : "管理员及以上角色自带全部渠道权限,无需单独授予。"}

)}
)} {canGrantSuper && target.role !== ROLES.OWNER && target.role !== ROLES.SUPER_ADMIN && (

消息管理

)}

账号状态

{willBan && (

封禁后立即强制下线,无法登录、发帖与回复;已发布内容保留。角色变更与封禁将一并保存。

)}
{err && role !== ROLES.BOARD_ADMIN && (

{err}

)}
); } function IdentityHeader({ target, displayName, currentMetaLabel, }: { target: RoleAccountTarget; displayName: string; currentMetaLabel: string; }) { const urlStyle = useUrlStyle(); const metaBits: string[] = [`ID ${target.id}`]; if (typeof target.points === "number") { metaBits.push(`积分 ${target.points.toLocaleString("zh-CN")}`); } if (target.created_at) { metaBits.push(`注册 ${formatDate(target.created_at)}`); } return (

角色与账号

{/* 描边用 box-shadow,勿把实色垫在默认头像后面:.av-* 底是半透明,会透出垫色把字母对比打翻 */}

{displayName}

@{target.username} {target.email ? ` · ${target.email}` : ""}

{target.banned ? "已封禁" : "正常"}

{metaBits.join(" · ")} 当前角色 {currentMetaLabel}

查看个人主页
); }