feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
// 客户端:站长任命/撤销群管理员
|
||||
|
||||
Reference in New Issue
Block a user