feat: 友链/书库/移动端导航/排行榜等多模块功能

新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
2026-09-30 16:30:07 +08:00
parent e487bc2e92
commit 4f3f3a265c
105 changed files with 12371 additions and 852 deletions

View File

@@ -14,6 +14,8 @@ import {
} from "./brand";
import { DEFAULT_LEVEL_DEFS, LEVEL_COLOR_KEYS, MAX_LEVEL_COUNT, type LevelDef } from "./levels";
import { normalizeUrlStyle, type UrlStyle } from "./urlStyle";
import { DEFAULT_MOBILE_TABS, normalizeMobileTabs, type MobileTabKey } from "./mobileTabs";
import { DEFAULT_HEADER_NAV, normalizeHeaderNav, type HeaderNavItem } from "./headerNav";
import { CSRF_COOKIE, hasAuthCookieHint } from "./cookies";
import { publishPublicMetaSnapshot } from "./publicMetaSnapshot";
import { emitForceLogout } from "./userEvents";
@@ -243,6 +245,7 @@ export interface SitePage {
allow_comments: boolean;
created_at: string;
updated_at: string;
deleted?: boolean; // 软删标记(仅管理端列表)
}
export interface SitePageInput {
@@ -255,6 +258,72 @@ export interface SitePageInput {
sort_order?: number;
}
/** 书库文件(epub/pdf/html 等;StoredName 不出后端) */
export interface LibraryFileItem {
id: number;
doc_id: number;
user_id: number;
name: string;
ext: string;
mime: string;
size: number;
download_count: number;
sort_order: number;
created_at: string;
}
/** 书库章节(两级树:parent_id 空=章,非空=节;正文 markdown) */
export interface LibrarySectionItem {
id: number;
doc_id: number;
parent_id: number | null;
title: string;
content: string;
sort_order: number;
created_at: string;
updated_at: string;
}
/** 书库条目(详情/管理端,含文件与章节) */
export interface LibraryDoc {
id: number;
slug: string;
title: string;
description: string;
cover_url: string;
published: boolean;
sort_order: number;
entries_auto?: boolean; // 全章节条目卡模式(章节内容免写 [entries] 壳)
created_at: string;
updated_at: string;
deleted?: boolean; // 软删标记(仅管理端列表)
files: LibraryFileItem[];
sections: LibrarySectionItem[];
}
/** 书库公开目录条目(不含 description 全文) */
export interface LibraryDocListItem {
id: number;
slug: string;
title: string;
cover_url: string;
sort_order: number;
file_count: number;
files: LibraryFileItem[];
created_at: string;
updated_at: string;
}
export interface LibraryInput {
slug: string;
title: string;
description?: string;
cover_url?: string;
published: boolean;
sort_order?: number;
entries_auto?: boolean;
}
export interface NewUser {
id: number;
username: string;
@@ -592,7 +661,7 @@ export interface UserProfile {
badges?: UserBadgeView[];
/** 访客(登录态)与该用户的关注关系;本人访问恒 false */
is_following?: boolean;
/** 仅本人访问时下发的打码邮箱(j***@163.com) */
/** 仅本人访问时下发的邮箱明文(账号信息展示与资料表单回填) */
email?: string;
};
stats: {
@@ -1012,6 +1081,10 @@ export interface PublicSettings {
levels_fx: boolean;
/** 详情页伪静态风格;缺省 default(标准 /post/123) */
url_style: UrlStyle;
/** 移动端底栏显示的 tab(固定顺序子集);空/非法回落默认全量 */
mobile_tabs: MobileTabKey[];
/** 桌面端 Header 主导航(数组顺序即展示顺序);空数组=全部隐藏;缺省=内置三项 */
header_nav: HeaderNavItem[];
}
/** 与后端 DefaultAttachmentExts 保持同步 */
@@ -1077,6 +1150,8 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
levels: [...DEFAULT_LEVEL_DEFS],
levels_fx: true,
url_style: "default",
mobile_tabs: [...DEFAULT_MOBILE_TABS],
header_nav: DEFAULT_HEADER_NAV.map((item) => ({ ...item })),
};
function normalizeAttachmentExts(raw: unknown): string[] {
@@ -1167,6 +1242,9 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
url_style: normalizeUrlStyle(data?.url_style),
levels: normalizeLevels(data?.levels),
levels_fx: data?.levels_fx !== false,
mobile_tabs: normalizeMobileTabs(data?.mobile_tabs),
// undefined(字段缺失)回落默认;[] 为空配置(全部隐藏),语义在 normalize 内区分
header_nav: normalizeHeaderNav(data?.header_nav),
};
}
@@ -2686,6 +2764,19 @@ export async function apiTimelineFromGit(input: {
return data as { items: TimelineGitItem[]; warning?: string; error?: string };
}
export async function apiTimelineFromReleases(input: {
urls: string[];
}): Promise<{ items: TimelineGitItem[]; warning?: string; error?: string }> {
const res = await fetchWithRefresh("/api/timeline/from-releases", {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "导入失败");
return data as { items: TimelineGitItem[]; warning?: string; error?: string };
}
/** SSR / 公开:已发布单页目录 */
export async function fetchPublishedSitePages(): Promise<{ pages: SitePageSummary[] }> {
const res = await fetch(`${API_BASE}/api/pages`, ssrInit());
@@ -2753,6 +2844,215 @@ export async function apiAdminDeleteSitePage(id: number): Promise<void> {
}
}
/** 彻底删除单页(仅限已软删;删除后 slug 释放可复用) */
export async function apiAdminPurgeSitePage(id: number): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/pages/${id}/purge`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "彻底删除失败");
}
}
// ===== 书库(公开 + 管理) =====
/** SSR / 公开:已发布书库目录 */
export async function fetchLibraryList(): Promise<{ docs: LibraryDocListItem[] }> {
const res = await fetch(`${API_BASE}/api/library`, ssrInit());
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取书库失败");
return { docs: data.docs ?? [] };
}
/** SSR / 公开:书库条目详情(404 返 null) */
export async function fetchLibraryDocBySlug(slug: string): Promise<{ doc: LibraryDoc } | null> {
const res = await fetch(`${API_BASE}/api/library/${encodeURIComponent(slug)}`, ssrInit());
if (res.status === 404) return null;
if (!res.ok) throw new Error("获取条目失败");
return res.json();
}
/** SSR:管理端书库目录(含未发布) */
export async function fetchAdminLibraryDocs(
cookieHeader?: string
): Promise<{ docs: LibraryDoc[] }> {
const res = await fetch(`${API_BASE}/api/admin/library/docs`, ssrInit(cookieHeaders(cookieHeader)));
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取书库失败");
return data as { docs: LibraryDoc[] };
}
export async function apiAdminListLibraryDocs(): Promise<{ docs: LibraryDoc[] }> {
const res = await fetchWithRefresh("/api/admin/library/docs", { headers: clientHeaders() });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取书库失败");
return data as { docs: LibraryDoc[] };
}
async function saveLibraryDoc(
method: "POST" | "PUT",
url: string,
input: LibraryInput
): Promise<{ doc: LibraryDoc }> {
const res = await fetchWithRefresh(url, {
method,
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "保存条目失败");
return data as { doc: LibraryDoc };
}
export function apiAdminCreateLibraryDoc(input: LibraryInput) {
return saveLibraryDoc("POST", "/api/admin/library/docs", input);
}
export function apiAdminUpdateLibraryDoc(id: number, input: LibraryInput) {
return saveLibraryDoc("PUT", `/api/admin/library/docs/${id}`, input);
}
export async function apiAdminDeleteLibraryDoc(id: number): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/library/docs/${id}`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "删除条目失败");
}
}
/** 彻底删除条目(仅限已软删;清除章节/文件行与磁盘文件,slug 释放可复用) */
export async function apiAdminPurgeLibraryDoc(id: number): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/library/docs/${id}/purge`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "彻底删除失败");
}
}
/** 上传文件到书库条目(multipart;扩展名/大小由后端白名单校验) */
export async function apiAdminUploadLibraryFile(
docId: number,
file: File
): Promise<{ file: LibraryFileItem; error?: string }> {
const form = new FormData();
form.append("file", file, file.name);
const res = await fetchWithRefresh(`/api/admin/library/docs/${docId}/files`, {
method: "POST",
headers: clientHeaders(),
body: form,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) return { file: null as unknown as LibraryFileItem, error: data.error || "上传失败" };
return data as { file: LibraryFileItem };
}
export async function apiAdminDeleteLibraryFile(fid: number): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/library/files/${fid}`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "删除文件失败");
}
}
// ===== 书库章节(两级树)=====
export interface LibrarySectionInput {
parent_id?: number | null;
title: string;
content?: string;
}
/** 批量导入的单个 md 文件 */
export interface LibraryImportFileInput {
filename: string;
content: string;
}
/** 导入 md 拆章入参:多文件按传入顺序依次拆章;split_level h1/h2/none 对每个文件生效 */
export interface LibraryImportMdInput {
files: LibraryImportFileInput[];
split_level: "h1" | "h2" | "none";
}
export async function apiAdminCreateLibrarySection(
docId: number,
input: LibrarySectionInput
): Promise<{ section: LibrarySectionItem }> {
const res = await fetchWithRefresh(`/api/admin/library/docs/${docId}/sections`, {
method: "POST",
headers: clientHeaders(),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "创建章节失败");
return data as { section: LibrarySectionItem };
}
export async function apiAdminUpdateLibrarySection(
sid: number,
input: Pick<LibrarySectionInput, "title" | "content">
): Promise<{ section: LibrarySectionItem }> {
const res = await fetchWithRefresh(`/api/admin/library/sections/${sid}`, {
method: "PUT",
headers: clientHeaders(),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "保存章节失败");
return data as { section: LibrarySectionItem };
}
export async function apiAdminDeleteLibrarySection(sid: number): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/library/sections/${sid}`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "删除章节失败");
}
}
export async function apiAdminMoveLibrarySection(
sid: number,
direction: "up" | "down"
): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/library/sections/${sid}/move`, {
method: "POST",
headers: clientHeaders(),
body: JSON.stringify({ direction }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "移动章节失败");
}
}
export async function apiAdminImportLibraryMarkdownBatch(
docId: number,
input: LibraryImportMdInput
): Promise<{ created: number; sections: LibrarySectionItem[] }> {
const res = await fetchWithRefresh(`/api/admin/library/docs/${docId}/import-md-batch`, {
method: "POST",
headers: clientHeaders(),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "导入失败");
return data as { created: number; sections: LibrarySectionItem[] };
}
// ===== 用户管理(管理员) =====
// 后台用户列表项:email/最近活跃/登录 IP 仅管理员接口返回
@@ -3367,6 +3667,10 @@ export type UpdateSiteSettingsBody = {
allow_register?: boolean;
allow_comments?: boolean;
url_style?: UrlStyle;
/** 移动端底栏显示的 tab(固定顺序子集,整体替换保存;空数组会被后端拒绝) */
mobile_tabs?: MobileTabKey[];
/** 桌面端 Header 主导航(整体替换保存;空数组表示全部隐藏) */
header_nav?: HeaderNavItem[];
comments_require_login?: boolean;
allow_messages?: boolean;
post_cooldown_hours?: number;
@@ -3484,6 +3788,20 @@ export async function apiBackgroundFromMedia(
return url;
}
/** 把本人媒体库图片复制一份作书籍封面素材,返回副本 URL(与原图解耦) */
export async function apiLibraryCoverFromMedia(attachmentId: number): Promise<string> {
const res = await fetchWithRefresh("/api/admin/library/cover/from-media", {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ attachment_id: attachmentId }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "选用媒体库图片失败");
const url = String(data.url || "");
if (!url) throw new Error("选用媒体库图片失败");
return url;
}
// ===== 板块(客户端,供角色授权弹窗勾选) =====
export async function apiListBoards(): Promise<{ boards: Board[] }> {
@@ -4443,3 +4761,101 @@ export async function apiAdminUpdateSidebarWidget(
export async function apiAdminDeleteSidebarWidget(id: number): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/sidebar/widgets/${id}`, "DELETE");
}
/* ---------- 友情链接(申请审批制) ---------- */
export type FriendLinkStatus = "pending" | "approved" | "rejected";
export interface FriendLink {
id: number;
name: string;
url: string;
description?: string;
logo_url?: string;
category?: string;
reciprocal_url?: string;
sort_order: number;
status: FriendLinkStatus;
applicant_user_id?: number | null;
reject_reason?: string;
created_at: string;
updated_at: string;
}
export interface FriendLinkApplyInput {
name: string;
url: string;
description?: string;
logo_url?: string;
category?: string;
reciprocal_url?: string;
}
export interface FriendLinkAdminInput {
name: string;
url: string;
description?: string;
logo_url?: string;
category?: string;
reciprocal_url?: string;
sort_order?: number;
}
/** SSR 公开友情链接列表(仅 approved) */
export async function fetchFriendLinks(cookie?: string): Promise<{ links: FriendLink[] }> {
const res = await fetch(`${API_BASE}/api/friend-links`, ssrInit(cookieHeaders(cookie)));
if (!res.ok) throw new Error("获取友情链接失败");
return res.json();
}
/** 用户提交友情链接申请(登录) */
export async function apiApplyFriendLink(body: FriendLinkApplyInput): Promise<{ link: FriendLink }> {
return apiOperations("/api/friend-links/apply", "POST", body);
}
/** 我的申请记录(含待审/拒绝与拒绝理由) */
export async function apiMyFriendLinks(): Promise<{ links: FriendLink[] }> {
return apiOperations("/api/friend-links/mine");
}
/** 申请人修改自己的申请;已通过/已驳回的修改后回退待审 */
export async function apiUpdateMyFriendLink(
id: number,
body: FriendLinkApplyInput
): Promise<{ ok: boolean }> {
return apiOperations(`/api/friend-links/${id}`, "PUT", body);
}
/** SSR 管理端全量列表(含 pending/rejected) */
export async function fetchAdminFriendLinks(cookie?: string): Promise<{ links: FriendLink[] }> {
const res = await fetch(`${API_BASE}/api/admin/friend-links`, {
...ssrInit(cookie ? { Cookie: cookie } : undefined),
credentials: "include",
});
if (!res.ok) throw new Error("加载友情链接失败");
return res.json();
}
export async function apiAdminListFriendLinks(): Promise<{ links: FriendLink[] }> {
return apiOperations("/api/admin/friend-links");
}
export async function apiAdminCreateFriendLink(body: FriendLinkAdminInput): Promise<{ link: FriendLink }> {
return apiOperations("/api/admin/friend-links", "POST", body);
}
export async function apiAdminUpdateFriendLink(id: number, body: FriendLinkAdminInput): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/friend-links/${id}`, "PUT", body);
}
export async function apiAdminSetFriendLinkStatus(
id: number,
status: FriendLinkStatus,
reject_reason?: string
): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/friend-links/${id}/status`, "POST", reject_reason ? { status, reject_reason } : { status });
}
export async function apiAdminDeleteFriendLink(id: number): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/friend-links/${id}`, "DELETE");
}