feat: 站点单页、时间线导入与行级 BBCode 短代码

首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 19:52:06 +08:00
parent 2aacf99a49
commit a83276060f
52 changed files with 5073 additions and 648 deletions

View File

@@ -86,7 +86,13 @@ export type AnnouncementColor =
| "orange"
| "red"
| "purple"
| "gray";
| "gray"
| "teal"
| "cyan"
| "pink"
| "amber"
| "indigo"
| "rose";
// 右栏站点公告摘要(标签/日期/标题)
export interface AnnouncementSummary {
@@ -95,6 +101,7 @@ export interface AnnouncementSummary {
tag: string;
tag_color: AnnouncementColor;
created_at: string;
pinned?: boolean;
}
// 公告详情/后台管理完整对象
@@ -105,10 +112,42 @@ export interface Announcement {
tag: string;
tag_color: AnnouncementColor;
published: boolean;
pinned: boolean;
created_at: string;
updated_at: string;
}
export interface SidebarPageItem {
id: number;
slug: string;
title: string;
excerpt: string;
}
export interface SitePage {
id: number;
slug: string;
title: string;
content: string;
excerpt: string;
published: boolean;
show_in_sidebar: boolean;
sort_order: number;
allow_comments: boolean;
created_at: string;
updated_at: string;
}
export interface SitePageInput {
slug: string;
title: string;
content: string;
excerpt?: string;
published: boolean;
show_in_sidebar: boolean;
sort_order?: number;
}
export interface NewUser {
id: number;
username: string;
@@ -130,6 +169,8 @@ export interface OverviewResponse {
active_users: ActiveUser[];
boards: BoardWithCount[];
announcements: AnnouncementSummary[];
announcements_total?: number;
sidebar_pages?: SidebarPageItem[];
new_users: NewUser[];
checkin?: CheckinStatus | null;
}
@@ -1509,11 +1550,15 @@ export async function apiDoCheckin(): Promise<CheckinStatus> {
// ===== 站点公告 =====
// SSR 公告详情(仅已发布;草稿/不存在由调用方按 404 处理)
// SSR 公告详情(仅已发布;转发 cookie 以便按登录态脱敏 hide)
export async function fetchAnnouncementDetail(
id: string
id: string,
cookieHeader?: string
): Promise<{ announcement: Announcement } | null> {
const res = await fetch(`${API_BASE}/api/announcements/${id}`, ssrInit());
const res = await fetch(
`${API_BASE}/api/announcements/${id}`,
ssrInit(cookieHeaders(cookieHeader))
);
if (res.status === 404) return null;
if (!res.ok) throw new Error("获取公告失败");
return res.json();
@@ -1550,6 +1595,7 @@ export interface AnnouncementInput {
tag: string;
tag_color: AnnouncementColor;
published: boolean;
pinned?: boolean;
}
async function saveAnnouncement(
@@ -1586,6 +1632,126 @@ export async function apiAdminDeleteAnnouncement(id: number): Promise<void> {
}
}
export async function apiAdminToggleAnnouncementPin(
id: number
): Promise<{ announcement: Announcement }> {
const res = await fetchWithRefresh(`/api/admin/announcements/${id}/pin`, {
method: "POST",
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "置顶失败");
return data as { announcement: Announcement };
}
/** SSR / 公开:分页公告列表 */
export async function fetchAnnouncementsPage(
page = 1,
size = 20
): Promise<{ announcements: Announcement[]; total: number; page: number }> {
const res = await fetch(
`${API_BASE}/api/announcements?page=${page}&size=${size}`,
ssrInit()
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取公告失败");
return data as { announcements: Announcement[]; total: number; page: number };
}
export type TimelineGitItem = {
date: string;
title: string;
body: string;
source_url?: string;
sha?: string;
};
export async function apiTimelineFromGit(input: {
urls: string[];
follow_pages?: boolean;
}): Promise<{ items: TimelineGitItem[]; warning?: string; error?: string }> {
const res = await fetchWithRefresh("/api/timeline/from-git", {
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 };
}
export async function fetchSitePageBySlug(
slug: string
): Promise<{ page: SitePage } | null> {
const res = await fetch(`${API_BASE}/api/pages/${encodeURIComponent(slug)}`, ssrInit());
if (res.status === 404) return null;
if (!res.ok) throw new Error("获取页面失败");
return res.json();
}
export async function apiAdminListSitePages(): Promise<{ pages: SitePage[] }> {
const res = await fetchWithRefresh("/api/admin/pages", { headers: clientHeaders() });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取单页失败");
return data as { pages: SitePage[] };
}
export async function fetchAdminSitePages(
cookieHeader?: string
): Promise<{ pages: SitePage[] }> {
const res = await fetch(`${API_BASE}/api/admin/pages`, ssrInit(cookieHeaders(cookieHeader)));
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取单页失败");
return data as { pages: SitePage[] };
}
async function saveSitePage(
method: "POST" | "PUT",
url: string,
input: SitePageInput
): Promise<{ page: SitePage }> {
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 { page: SitePage };
}
export function apiAdminCreateSitePage(input: SitePageInput) {
return saveSitePage("POST", "/api/admin/pages", input);
}
export function apiAdminUpdateSitePage(id: number, input: SitePageInput) {
return saveSitePage("PUT", `/api/admin/pages/${id}`, input);
}
export async function apiAdminDeleteSitePage(id: number): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/pages/${id}`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "删除页面失败");
}
}
export async function apiGetAdminSettings(): Promise<
PublicSettings & { timeline_git_import?: string }
> {
const res = await fetchWithRefresh("/api/admin/settings", { headers: clientHeaders() });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取设置失败");
return {
...normalizePublicSettings(data as Partial<PublicSettings>),
timeline_git_import:
typeof data.timeline_git_import === "string" ? data.timeline_git_import : undefined,
};
}
// ===== 用户管理(管理员) =====
// 后台用户列表项:email/最近活跃/登录 IP 仅管理员接口返回
@@ -1901,6 +2067,7 @@ export type UpdateSiteSettingsBody = {
attachment_max_mb?: number;
attachment_max_count?: number;
image_max_mb?: number;
timeline_git_import?: string;
};
// 更新站点设置(字段可选);accent 传空串恢复默认主题色