Files
jiang13-bbs/frontend/components/RoleAccountModal.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

658 lines
24 KiB
TypeScript
Raw Permalink 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, 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<string>(ROLES.USER);
const [boardIds, setBoardIds] = useState<number[]>([]);
const [banned, setBanned] = useState(false);
const [manageMessages, setManageMessages] = useState(false);
const [permSel, setPermSel] = useState<string[]>([]);
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 (
<Modal
open
title="角色与账号"
header={<IdentityHeader target={target} displayName={displayName} currentMetaLabel={currentMeta.label} />}
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={
<>
<button type="button" className="btn btn-line" onClick={onClose} disabled={acting}>
取消
</button>
<button
type="button"
className={willBan ? "btn btn-danger" : "btn btn-primary"}
onClick={submit}
disabled={acting}
>
{acting && <Loader2 size={15} className="animate-spin" />}
{saveLabel}
</button>
</>
}
>
<p className="mb-2.5 text-[12px] font-medium tracking-wide" style={{ color: "var(--ink-3)" }}>
管理角色
</p>
<div className="flex flex-col gap-2" role="radiogroup" aria-label="管理角色">
{options.map((o) => {
const active = role === o.value;
return (
<button
key={o.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => {
setRole(o.value);
setErr("");
}}
className="text-left rounded-xl p-3.5 flex items-start gap-3 transition-colors"
style={{
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
background: active ? "var(--accent-soft)" : "var(--panel-2)",
}}
>
<span
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 mt-0.5"
style={{
background: active ? "var(--accent)" : "var(--panel)",
color: active ? "var(--accent-on)" : `var(${o.token})`,
}}
aria-hidden="true"
>
{o.value === ROLES.USER ? <UsersIcon size={18} /> : <ShieldCheck size={18} />}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
{o.title}
</span>
<span className="block mt-1 text-[12px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
{o.desc}
</span>
</span>
<span
className="mt-1 w-4 h-4 rounded-full shrink-0 flex items-center justify-center"
style={{ border: `2px solid ${active ? "var(--accent)" : "var(--line-2)"}` }}
aria-hidden="true"
>
{active && <span className="w-2 h-2 rounded-full" style={{ background: "var(--accent)" }} />}
</span>
</button>
);
})}
</div>
{role === ROLES.BOARD_ADMIN && (
<div className="mt-4">
<p className="text-[12px] font-medium tracking-wide mb-2" style={{ color: "var(--ink-3)" }}>
授权板块 <span style={{ color: "var(--danger)" }}>*</span>
</p>
{boards.length === 0 ? (
<p className="meta text-[12px]">板块加载中…</p>
) : (
<div className="grid grid-cols-2 gap-2">
{boards.map((b) => {
const checked = boardIds.includes(b.id);
return (
<button
key={b.id}
type="button"
role="checkbox"
aria-checked={checked}
onClick={() => toggleBoard(b.id)}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-[12.5px] transition-colors"
style={{
border: `1.5px solid ${checked ? "var(--accent)" : "var(--line)"}`,
background: checked ? "var(--accent-soft)" : "var(--panel-2)",
color: checked ? "var(--accent)" : "var(--ink-2)",
}}
>
<span
className="w-4 h-4 rounded shrink-0 flex items-center justify-center"
style={{ border: `2px solid ${checked ? "var(--accent)" : "var(--line-2)"}` }}
aria-hidden="true"
>
{checked && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
)}
</span>
<BoardIcon icon={b.icon} name={b.name} size={13} />
<span className="truncate">{b.name}</span>
</button>
);
})}
</div>
)}
{err && role === ROLES.BOARD_ADMIN && (
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>
{err}
</p>
)}
</div>
)}
{target.role !== ROLES.OWNER && (
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<p className="text-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>
渠道权限
</p>
{target.perm_overrides === undefined ? (
<p
className="text-[12px] flex items-center gap-1.5"
style={{ color: "var(--ink-3)" }}
aria-live="polite"
>
<Loader2 size={13} className="animate-spin" aria-hidden /> 渠道权限加载中…
</p>
) : (
<>
<div className="flex flex-col gap-2">
{CHANNEL_PERMS.map((perm) => {
const Icon = CHANNEL_ICONS[perm];
const meta = PERM_META[perm];
const on = channelEditable && permSel.includes(perm);
const body = (
<>
<span className="flex items-center gap-2.5 min-w-0">
<span
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{
background: on || !channelEditable ? "var(--accent-soft)" : "var(--panel)",
color: on || !channelEditable ? "var(--accent)" : "var(--ink-3)",
}}
aria-hidden
>
<Icon size={15} />
</span>
<span className="min-w-0">
<span
className="block text-[13.5px] font-medium"
style={{ color: on ? "var(--accent)" : "var(--ink)" }}
>
{meta.label}
</span>
<span className="block mt-0.5 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
{meta.desc}
</span>
</span>
</span>
{channelEditable ? (
<span
className="shrink-0 w-10 h-6 rounded-full relative transition-colors"
style={{ background: on ? "var(--accent)" : "var(--line)" }}
aria-hidden
>
<span
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform"
style={{ left: on ? "1.125rem" : "0.125rem" }}
/>
</span>
) : (
<span
className="shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium leading-[1.6]"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
角色自带
</span>
)}
</>
);
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 ? (
<button
key={perm}
type="button"
role="switch"
aria-checked={on}
disabled={acting}
onClick={() => togglePerm(perm)}
className={rowCls}
style={rowStyle}
>
{body}
</button>
) : (
<div key={perm} className={rowCls} style={rowStyle} aria-disabled="true">
{body}
</div>
);
})}
</div>
<p className="mt-2 text-[11.5px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
{channelEditable
? "授予后该账号即可进入后台管理对应渠道,立即生效;可随时收回。"
: "管理员及以上角色自带全部渠道权限,无需单独授予。"}
</p>
</>
)}
</div>
)}
{canGrantSuper &&
target.role !== ROLES.OWNER &&
target.role !== ROLES.SUPER_ADMIN && (
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<p className="text-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>
消息管理
</p>
<button
type="button"
role="switch"
aria-checked={manageMessages}
onClick={() => setManageMessages((v) => !v)}
className="w-full rounded-xl px-3.5 py-3 text-left transition-colors flex items-center justify-between gap-3"
style={{
border: `1.5px solid ${manageMessages ? "var(--accent)" : "var(--line)"}`,
background: manageMessages ? "var(--accent-soft)" : "var(--panel-2)",
}}
>
<span>
<span
className="block text-[13.5px] font-medium"
style={{ color: manageMessages ? "var(--accent)" : "var(--ink)" }}
>
站点群消息管理
</span>
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
可在后台管理全部群聊消息(不含私聊);站长/超管固有全站监管无需此项。
</span>
</span>
<span
className="shrink-0 w-10 h-6 rounded-full relative transition-colors"
style={{ background: manageMessages ? "var(--accent)" : "var(--line)" }}
aria-hidden
>
<span
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform"
style={{ left: manageMessages ? "1.125rem" : "0.125rem" }}
/>
</span>
</button>
</div>
)}
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<p className="text-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>
账号状态
</p>
<div className="grid grid-cols-2 gap-2" role="radiogroup" aria-label="账号状态">
<button
type="button"
role="radio"
aria-checked={!banned}
onClick={() => setBanned(false)}
className="rounded-xl px-3.5 py-3 text-left transition-colors"
style={{
border: `1.5px solid ${!banned ? "var(--ok)" : "var(--line)"}`,
background: !banned ? "var(--ok-soft)" : "var(--panel-2)",
}}
>
<span
className="flex items-center gap-1.5 text-[13.5px] font-medium"
style={{ color: !banned ? "var(--ok)" : "var(--ink)" }}
>
<RotateCcw size={15} /> 正常
</span>
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
可登录与互动
</span>
</button>
<button
type="button"
role="radio"
aria-checked={banned}
onClick={() => setBanned(true)}
className="rounded-xl px-3.5 py-3 text-left transition-colors"
style={{
border: `1.5px solid ${banned ? "var(--danger)" : "var(--line)"}`,
background: banned ? "var(--danger-soft)" : "var(--panel-2)",
}}
>
<span
className="flex items-center gap-1.5 text-[13.5px] font-medium"
style={{ color: banned ? "var(--danger)" : "var(--ink)" }}
>
<Ban size={15} /> 封禁
</span>
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
强制下线,禁止登录
</span>
</button>
</div>
{willBan && (
<p className="mt-2.5 text-[12px] leading-relaxed" style={{ color: "var(--danger)" }}>
封禁后立即强制下线,无法登录、发帖与回复;已发布内容保留。角色变更与封禁将一并保存。
</p>
)}
</div>
{err && role !== ROLES.BOARD_ADMIN && (
<p className="mt-3 text-[12px]" style={{ color: "var(--danger)" }}>
{err}
</p>
)}
</Modal>
);
}
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 (
<div>
<p
className="text-[11px] font-medium uppercase tracking-[0.14em] mb-3"
style={{ color: "var(--ink-3)" }}
>
角色与账号
</p>
<div
className="flex items-start gap-4 min-w-0 rounded-2xl p-3.5"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
{/* 描边用 box-shadow,勿把实色垫在默认头像后面:.av-* 底是半透明,会透出垫色把字母对比打翻 */}
<span
className="inline-flex items-center justify-center shrink-0 rounded-full"
style={{
boxShadow: target.banned
? "0 0 0 3px var(--panel-2), 0 0 0 4.5px var(--danger)"
: "0 0 0 3px var(--panel-2), 0 0 0 4.5px color-mix(in srgb, var(--accent) 38%, transparent)",
}}
aria-hidden
>
<Avatar
name={displayName}
src={target.avatar || undefined}
size={68}
/>
</span>
<div className="min-w-0 flex-1 pt-0.5">
<p
className="text-[18px] sm:text-[20px] font-extrabold tracking-tight leading-tight truncate"
style={{ color: "var(--ink)" }}
>
{displayName}
</p>
<p className="mt-0.5 text-[13px] truncate" style={{ color: "var(--ink-2)" }}>
@{target.username}
{target.email ? ` · ${target.email}` : ""}
</p>
<div className="mt-2 flex items-center gap-1.5 flex-wrap">
<RoleBadge role={target.role} boardIds={target.board_ids} />
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium leading-[1.6]"
style={
target.banned
? { background: "var(--danger-soft)", color: "var(--danger)" }
: { background: "var(--ok-soft)", color: "var(--ok)" }
}
>
{target.banned ? "已封禁" : "正常"}
</span>
</div>
<p className="mt-2 text-[11.5px] tabular-nums leading-relaxed" style={{ color: "var(--ink-3)" }}>
{metaBits.join(" · ")}
<span className="sr-only">当前角色 {currentMetaLabel}</span>
</p>
<Link
href={userPath(urlStyle, target.id)}
className="mt-2.5 inline-flex items-center gap-1 text-[12.5px] font-medium hover:underline"
style={{ color: "var(--accent)" }}
>
查看个人主页 <ExternalLink size={12} aria-hidden />
</Link>
</div>
</div>
</div>
);
}