feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
@@ -2,11 +2,38 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Ban, ExternalLink, Loader2, RotateCcw, ShieldCheck, Users as UsersIcon } from "lucide-react";
|
||||
import { apiAdminSetUserBan, apiAdminSetUserRole, apiAdminSetUserMessages, type Board } from "@/lib/api";
|
||||
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 { ROLES, ROLE_META, roleMeta } from "@/lib/roles";
|
||||
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";
|
||||
@@ -23,12 +50,22 @@ export interface RoleAccountTarget {
|
||||
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;
|
||||
@@ -80,6 +117,7 @@ export default function RoleAccountModal({
|
||||
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(() => {
|
||||
@@ -88,6 +126,7 @@ export default function RoleAccountModal({
|
||||
setBoardIds(target.board_ids ?? []);
|
||||
setBanned(!!target.banned);
|
||||
setManageMessages(!!target.can_manage_messages);
|
||||
setPermSel(target.perm_overrides ?? []);
|
||||
setErr("");
|
||||
}
|
||||
}, [target]);
|
||||
@@ -98,6 +137,10 @@ export default function RoleAccountModal({
|
||||
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));
|
||||
@@ -108,6 +151,13 @@ export default function RoleAccountModal({
|
||||
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;
|
||||
|
||||
@@ -116,7 +166,7 @@ export default function RoleAccountModal({
|
||||
setErr("请至少选择一个授权板块");
|
||||
return;
|
||||
}
|
||||
if (!roleChanged && !banChanged && !messagesChanged) {
|
||||
if (!roleChanged && !banChanged && !messagesChanged && !permsChanged) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
@@ -126,6 +176,7 @@ export default function RoleAccountModal({
|
||||
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,
|
||||
@@ -135,6 +186,14 @@ export default function RoleAccountModal({
|
||||
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;
|
||||
@@ -145,6 +204,7 @@ export default function RoleAccountModal({
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (roleChanged) parts.push("角色已更新");
|
||||
if (permsChanged) parts.push("渠道权限已更新");
|
||||
if (willBan) parts.push("已封禁");
|
||||
if (willUnban) parts.push("已解封");
|
||||
if (messagesChanged) parts.push(manageMessages ? "已授予消息管理" : "已撤销消息管理");
|
||||
@@ -155,6 +215,7 @@ export default function RoleAccountModal({
|
||||
board_ids: nextBoards,
|
||||
banned: nextBanned,
|
||||
can_manage_messages: nextMessages,
|
||||
perm_overrides: nextPerms,
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
@@ -291,7 +352,7 @@ export default function RoleAccountModal({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{err && (
|
||||
{err && role === ROLES.BOARD_ADMIN && (
|
||||
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>
|
||||
{err}
|
||||
</p>
|
||||
@@ -299,6 +360,108 @@ export default function RoleAccountModal({
|
||||
</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 && (
|
||||
@@ -396,6 +559,12 @@ export default function RoleAccountModal({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{err && role !== ROLES.BOARD_ADMIN && (
|
||||
<p className="mt-3 text-[12px]" style={{ color: "var(--danger)" }}>
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user