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

@@ -82,6 +82,8 @@ export interface User {
board_ids?: number[];
// 站点消息管理(站长授予;站长/超管固有监管不必依赖此字段)
can_manage_messages?: boolean;
// 生效权限码(角色默认 ∪ 账号级渠道授予;仅 /api/me 返回,用于后台入口显隐)
perms?: string[];
// 作为群管理员的群数量(/me;用于后台消息入口)
chat_admin_room_count?: number;
// 仅 /api/me 与 /api/profile 响应中返回(用户自身敏感信息)
@@ -284,13 +286,26 @@ export interface LibrarySectionItem {
updated_at: string;
}
/** 书库条目创建者摘要(已注销用户时接口省略该字段) */
export interface LibraryCreator {
id: number;
username: string;
nickname: string;
avatar: string;
}
/** 书库条目(详情/管理端,含文件与章节) */
export interface LibraryDoc {
id: number;
slug: string;
title: string;
author: string;
creator_id: number;
creator?: LibraryCreator | null;
description: string;
cover_url: string;
cover_width: number;
cover_height: number;
published: boolean;
sort_order: number;
entries_auto?: boolean; // 全章节条目卡模式(章节内容免写 [entries] 壳)
@@ -301,11 +316,14 @@ export interface LibraryDoc {
sections: LibrarySectionItem[];
}
/** 书库公开目录条目(不含 description 全文) */
/** 书库公开目录条目(description 为 markdown 原文,卡片内按纯文本截断展示) */
export interface LibraryDocListItem {
id: number;
slug: string;
title: string;
author: string;
description: string;
creator?: LibraryCreator | null;
cover_url: string;
sort_order: number;
file_count: number;
@@ -317,8 +335,11 @@ export interface LibraryDocListItem {
export interface LibraryInput {
slug: string;
title: string;
author?: string;
description?: string;
cover_url?: string;
cover_width?: number;
cover_height?: number;
published: boolean;
sort_order?: number;
entries_auto?: boolean;
@@ -492,6 +513,8 @@ export interface Post {
export interface AcceptedAnswer {
id: number;
floor: number;
/** false = 楼中楼回复,锚点需 #comment-{floor}-r{id} */
is_root: boolean;
content: string;
created_at: string;
deleted?: boolean;
@@ -683,6 +706,10 @@ export interface UserCommentItem {
post_id: number;
post_title: string;
content: string;
/** 所属帖内相对楼层号(0 = 评论已不可见) */
floor: number;
/** 主楼/楼中楼:楼中楼锚点需 #comment-{floor}-r{id} */
is_root: boolean;
created_at: string;
}
@@ -728,6 +755,10 @@ export interface NotificationItem {
content: string;
is_read: boolean;
created_at: string;
/** 关联评论在所属帖内的相对楼层号(0/缺省 = 评论已彻底删除) */
comment_floor?: number;
/** 关联评论是否主楼;楼中楼锚点需 #comment-{floor}-r{comment_id} */
comment_is_root?: boolean;
actor: User;
post?: { id: number; title: string };
room?: { id: number; name: string } | null;
@@ -1407,18 +1438,6 @@ export async function fetchComments(
return res.json();
}
// 评论定位:返回楼层号(与楼层分页口径一致),0 = 不存在。
// 通知/主页评论深链 #comment-{id} 跨页时用它换到正确页码。
export async function fetchCommentLocation(
postId: string | number,
commentId: number
): Promise<number> {
const res = await fetch(`${API_BASE}/api/posts/${postId}/comments/${commentId}`);
if (!res.ok) return 0;
const data = (await res.json()) as { floor?: number };
return data.floor || 0;
}
export async function fetchUserProfile(
id: string,
page = 1,
@@ -2331,6 +2350,8 @@ export interface MediaAttachment {
id: number;
url: string;
kind: "avatar" | "image";
/** 图片来源:post=帖子插图,library_cover=书籍封面,library_content=书籍正文 */
source?: string;
size: number;
width: number;
height: number;
@@ -2354,7 +2375,8 @@ export async function apiUploadAvatar(
// 上传帖子插图(JPEG / PNG / WebP / GIF,≤5MB;服务端自动转存 WebP,GIF 除外)
export async function apiUploadPostImage(
file: File | Blob,
filename = "image.jpg"
filename = "image.jpg",
source?: string
): Promise<{ url?: string; attachment?: MediaAttachment; error?: string }> {
const form = new FormData();
const name =
@@ -2362,6 +2384,7 @@ export async function apiUploadPostImage(
? file.name
: filename;
form.append("file", file, name);
if (source) form.append("source", source);
const res = await fetchWithRefresh("/api/upload/image", {
method: "POST",
headers: clientHeaders(),
@@ -2965,6 +2988,48 @@ export async function apiAdminDeleteLibraryFile(fid: number): Promise<void> {
}
}
/** 书库导入报告(POST /api/admin/library/import) */
export interface LibraryImportItem {
slug: string;
title: string;
action: "created" | "overwritten" | string;
sections: number;
files: number;
}
export interface LibraryImportFailure {
slug: string;
title: string;
/** conflict=slug 被在用条目占用(可改 overwrite 重导);deleted=被软删条目占用;invalid/failed */
reason: "conflict" | "deleted" | "invalid" | "failed" | string;
error: string;
}
export interface LibraryImportReport {
mode: string;
total: number;
imported: LibraryImportItem[];
failed: LibraryImportFailure[];
}
/** 上传书库导出 ZIP 恢复书籍;mode=create(冲突跳过)/ overwrite(覆盖同名) */
export async function apiAdminImportLibraryZip(
file: File,
mode: "create" | "overwrite"
): Promise<LibraryImportReport> {
const form = new FormData();
form.append("file", file, file.name);
form.append("mode", mode);
const res = await fetchWithRefresh(`/api/admin/library/import`, {
method: "POST",
headers: clientHeaders(),
body: form,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "导入失败");
return (data as { report: LibraryImportReport }).report;
}
// ===== 书库章节(两级树)=====
export interface LibrarySectionInput {
@@ -3067,6 +3132,8 @@ export interface AdminUser {
board_ids: number[]; // 板块管理员的授权板块
banned: boolean;
can_manage_messages?: boolean;
// 账号级渠道权限(公告/单页/书库/广告;角色固有之外的增量授予)
perm_overrides?: string[];
points?: number;
total_points?: number;
level?: number;
@@ -3210,6 +3277,33 @@ export async function apiAdminSetUserMessages(
return data as { user: AdminUser };
}
// 读取账号级渠道权限(超管/站长;前台快捷菜单的授权弹窗回显用)
export async function apiAdminGetUserPerms(
id: number
): Promise<{ perm_overrides: string[] }> {
const res = await fetchWithRefresh(`/api/admin/users/${id}/perms`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "渠道权限加载失败");
return data as { perm_overrides: string[] };
}
// 超管/站长授予/撤销账号级渠道权限(公告/单页/书库/广告)
export async function apiAdminSetUserPerms(
id: number,
perms: string[]
): Promise<{ user: AdminUser }> {
const res = await fetchWithRefresh(`/api/admin/users/${id}/perms`, {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ perms }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "渠道权限更新失败");
return data as { user: AdminUser };
}
// 管理员手动调整用户积分(加分计累计可升级;扣分仅扣余额不降级),理由必填
export interface AdminAdjustPointsResult {
balance: number;
@@ -3547,6 +3641,9 @@ export interface AdminAuditCommentItem {
deleted: boolean;
created_at: string;
deleted_at?: string | null;
/** 所属帖内相对楼层号(0 = 评论已不可见) */
floor: number;
is_root: boolean;
}
export interface AdminAuditMessageItem {
@@ -3788,8 +3885,10 @@ export async function apiBackgroundFromMedia(
return url;
}
/** 把本人媒体库图片复制一份作书籍封面素材,返回副本 URL(与原图解耦) */
export async function apiLibraryCoverFromMedia(attachmentId: number): Promise<string> {
/** 把本人媒体库图片复制一份作书籍封面素材,返回副本 URL 与尺寸(与原图解耦) */
export async function apiLibraryCoverFromMedia(
attachmentId: number
): Promise<{ url: string; width: number; height: number }> {
const res = await fetchWithRefresh("/api/admin/library/cover/from-media", {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
@@ -3799,7 +3898,8 @@ export async function apiLibraryCoverFromMedia(attachmentId: number): Promise<st
if (!res.ok) throw new Error(data.error || "选用媒体库图片失败");
const url = String(data.url || "");
if (!url) throw new Error("选用媒体库图片失败");
return url;
const att = (data.attachment || {}) as { width?: number; height?: number };
return { url, width: Number(att.width) || 0, height: Number(att.height) || 0 };
}
// ===== 板块(客户端,供角色授权弹窗勾选) =====
@@ -3877,6 +3977,9 @@ export interface AdminContentComment {
deleted: boolean;
deleted_at?: string;
created_at: string;
/** 所属帖内相对楼层号(0 = 评论已不可见) */
floor: number;
is_root: boolean;
board: Board;
user: User;
}
@@ -4089,6 +4192,7 @@ export interface ChatRoomMember {
role: "owner" | "admin" | "member";
last_read_message_id: number;
muted: boolean;
muted_until?: string | null;
created_at: string;
updated_at: string;
user: User;
@@ -4106,6 +4210,8 @@ export interface ChatMessage {
recalled_at?: string | null;
recalled_by?: number;
sender: User;
/** 系统提示消息(如禁言通知):sender_id=0,居中灰字渲染 */
is_system?: boolean;
/** 撤回操作者(已撤回时后端 Preload;本人撤回时通常等于 sender) */
recaller?: User | null;
}
@@ -4376,23 +4482,24 @@ export async function apiKickChatMember(
return data as { ok: boolean };
}
// 客户端:禁言/解禁
// 客户端:禁言/解禁;durationMinutes 可选:0/缺省=永久,>0=指定分钟数
export async function apiMuteChatMember(
roomId: number,
userId: number,
muted: boolean
): Promise<{ ok: boolean; muted: boolean }> {
muted: boolean,
durationMinutes?: number
): Promise<{ ok: boolean; muted: boolean; duration_minutes?: number }> {
const res = await fetchWithRefresh(
`/api/chat/rooms/${roomId}/members/${userId}/mute`,
{
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ muted }),
body: JSON.stringify({ muted, duration_minutes: durationMinutes ?? 0 }),
}
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "禁言操作失败");
return data as { ok: boolean; muted: boolean };
return data as { ok: boolean; muted: boolean; duration_minutes?: number };
}
// 客户端:站长任命/撤销群管理员