feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user