feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等

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

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
This commit is contained in:
2026-10-01 03:06:05 +08:00
parent 7fbd6ba699
commit ff2ab286fb
141 changed files with 7527 additions and 1407 deletions

View File

@@ -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>
);
}