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 传空串恢复默认主题色

View File

@@ -0,0 +1,205 @@
/** 统一拆分正文中的 [hide] / [timeline] 指令块(忽略代码围栏) */
import {
splitHideSegments,
type HideKind,
type HideSegment,
} from "@/lib/hideBlocks";
import { parseTimelineBody, type TimelineItem } from "@/lib/timelineBlocks";
export type ContentSegment =
| { type: "md"; text: string }
| {
type: "hide";
kind: HideKind;
points: number;
locked: boolean;
body: string;
}
| { type: "timeline"; items: TimelineItem[]; rawBody: string };
function isTimelineClose(line: string): boolean {
return line.trim().toLowerCase() === "[/timeline]";
}
function isTimelineOpen(trimmed: string): boolean {
return trimmed.toLowerCase() === "[timeline]";
}
/**
* 先拆时间线,再对普通 md/hide 段做 hide 拆分。
* 时间线与 hide 互不嵌套;解析失败时整段当普通 Markdown。
*/
export function splitContentSegments(content: string): ContentSegment[] {
if (!content) return [{ type: "md", text: "" }];
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const raw: Array<{ type: "md"; text: string } | { type: "timeline"; body: string }> = [];
let buf: string[] = [];
let inCode = false;
let i = 0;
const flushMd = () => {
if (buf.length === 0) return;
raw.push({ type: "md", text: buf.join("\n") });
buf = [];
};
while (i < lines.length) {
const trimmed = lines[i].trim();
if (trimmed.startsWith("```")) {
inCode = !inCode;
buf.push(lines[i]);
i++;
continue;
}
if (inCode) {
buf.push(lines[i]);
i++;
continue;
}
if (isTimelineOpen(trimmed)) {
flushMd();
const bodyLines: string[] = [];
let j = i + 1;
let innerCode = false;
let found = false;
while (j < lines.length) {
const inner = lines[j].trim();
if (inner.startsWith("```")) {
innerCode = !innerCode;
bodyLines.push(lines[j]);
j++;
continue;
}
if (innerCode) {
bodyLines.push(lines[j]);
j++;
continue;
}
if (isTimelineOpen(inner)) {
// 嵌套视为失败:整段当 md
buf.push(lines[i]);
i++;
found = false;
break;
}
if (isTimelineClose(inner)) {
found = true;
break;
}
bodyLines.push(lines[j]);
j++;
}
if (!found) {
buf.push(lines[i]);
i++;
continue;
}
raw.push({ type: "timeline", body: bodyLines.join("\n") });
i = j + 1;
continue;
}
buf.push(lines[i]);
i++;
}
flushMd();
const out: ContentSegment[] = [];
for (const part of raw) {
if (part.type === "timeline") {
out.push({
type: "timeline",
items: parseTimelineBody(part.body),
rawBody: part.body,
});
continue;
}
const hideSegs = splitHideSegments(part.text) as HideSegment[];
for (const s of hideSegs) {
if (s.type === "md") {
out.push({ type: "md", text: s.text });
} else {
out.push({
type: "hide",
kind: s.kind,
points: s.points,
locked: s.locked,
body: s.body,
});
}
}
}
return out.length > 0 ? out : [{ type: "md", text: content }];
}
/** 光标是否落在某个 [timeline]…[/timeline] 块内;返回块起止与正文 */
export function findTimelineBlockAt(
content: string,
cursor: number
): { start: number; end: number; body: string } | null {
const blocks = listTimelineBlocks(content);
for (const b of blocks) {
if (cursor >= b.start && cursor <= b.end) return b;
}
return null;
}
/**
* 编辑器打开时间线面板时的目标块:
* 1) 光标落在某块内 → 该块
* 2) 正文恰有一块 → 该块(changelog 页常见:光标在标题区仍应更新已有时间线)
* 3) 否则 null(新建插入)
*/
export function resolveTimelineBlockForEdit(
content: string,
cursor: number
): { start: number; end: number; body: string } | null {
const at = findTimelineBlockAt(content, cursor);
if (at) return at;
const blocks = listTimelineBlocks(content);
if (blocks.length === 1) return blocks[0];
return null;
}
function listTimelineBlocks(
content: string
): Array<{ start: number; end: number; body: string }> {
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const out: Array<{ start: number; end: number; body: string }> = [];
let offset = 0;
let inCode = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineStart = offset;
const lineEnd = offset + line.length;
const trimmed = line.trim();
if (trimmed.startsWith("```")) inCode = !inCode;
if (!inCode && isTimelineOpen(trimmed)) {
const openStart = lineStart;
let j = i + 1;
let innerCode = false;
let closeEnd = -1;
const bodyLines: string[] = [];
let pos = lineEnd + 1;
while (j < lines.length) {
const l = lines[j];
const t = l.trim();
if (t.startsWith("```")) innerCode = !innerCode;
if (!innerCode && isTimelineClose(t)) {
closeEnd = pos + l.length;
break;
}
bodyLines.push(l);
pos += l.length + 1;
j++;
}
if (closeEnd >= 0) {
out.push({ start: openStart, end: closeEnd, body: bodyLines.join("\n") });
}
}
offset = lineEnd + 1;
}
return out;
}

View File

@@ -1,4 +1,4 @@
/** 正文 :::hide 块解析(与 backend/markdown/hide.go 契约一致) */
/** 正文 [hide]…[/hide] 块解析(与 backend/markdown/hide.go 契约一致) */
export type HideKind = "login" | "reply" | "points" | "password";
@@ -13,25 +13,34 @@ export type HideSegment =
};
const HIDE_KINDS = new Set<HideKind>(["login", "reply", "points", "password"]);
const HIDE_OPEN_RE = /^\[hide(?:\s+(.+))?\]$/i;
function isClose(line: string): boolean {
return line.trim() === ":::";
return line.trim().toLowerCase() === "[/hide]";
}
/** 与 Go parseOpenMarker 对齐::::hide <kind> [points|password] [locked] */
/** 与 Go parseOpenMarker 对齐:[hide <kind>[=arg] [locked]] */
function parseOpen(trimmed: string): {
kind: HideKind;
points: number;
locked: boolean;
} | null {
if (!trimmed.startsWith(":::hide")) return null;
const parts = trimmed
.slice(":::hide".length)
.trim()
.split(/\s+/)
.filter(Boolean);
const m = trimmed.match(HIDE_OPEN_RE);
if (!m) return null;
const rest = (m[1] || "").trim();
if (!rest) return null;
const parts = rest.split(/\s+/).filter(Boolean);
if (parts.length === 0) return null;
const kind = parts[0] as HideKind;
let head = parts[0];
let kind = head;
let arg = "";
const eq = head.indexOf("=");
if (eq >= 0) {
kind = head.slice(0, eq);
arg = head.slice(eq + 1);
}
kind = kind.toLowerCase() as HideKind;
if (!HIDE_KINDS.has(kind)) return null;
let idx = 1;
@@ -39,20 +48,21 @@ function parseOpen(trimmed: string): {
let locked = false;
if (kind === "points") {
if (idx >= parts.length) return null;
const n = Number(parts[idx]);
if (!arg) return null;
const n = Number(arg);
if (!Number.isFinite(n) || n < 1 || n > 100000) return null;
points = n;
idx++;
} else if (kind === "password") {
// 脱敏::::hide password locked;原文::::hide password <pwd> [locked]
if (idx < parts.length && parts[idx] !== "locked") {
idx++;
// 脱敏:[hide password locked];原文密码不在前端解析保留
if (arg) {
/* skip plaintext */
}
} else if (arg) {
return null;
}
for (; idx < parts.length; idx++) {
if (parts[idx] === "locked") locked = true;
if (parts[idx].toLowerCase() === "locked") locked = true;
else return null;
}
return { kind, points, locked };

View File

@@ -0,0 +1,140 @@
/** 正文 [timeline]…[/timeline] 块解析与序列化 */
export type TimelineItem = {
date: string;
title: string;
body: string;
sourceUrl?: string;
/** 导入勾选;手填项默认 true */
selected?: boolean;
};
export type TimelineDayGroup = {
date: string;
items: TimelineItem[];
};
const GIT_COMMENT_RE = /^<!--\s*git:\s*(https?:\/\/\S+)\s*-->\s*$/i;
const HEADING_RE = /^##\s+(\d{4}-\d{2}-\d{2})\s+(.+)$/;
/** 去掉控制字符与 RTL 覆盖,截断长度 */
export function sanitizeTimelineText(raw: string, max: number): string {
let s = raw
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
.replace(/[\u202A-\u202E\u2066-\u2069]/g, "");
if ([...s].length > max) {
s = [...s].slice(0, max).join("");
}
return s;
}
function isDirectiveLine(t: string): boolean {
const lower = t.toLowerCase();
return (
lower === "[/hide]" ||
lower === "[/timeline]" ||
lower === "[timeline]" ||
lower.startsWith("[hide") ||
t.startsWith("<!-- git:")
);
}
/** 避免导入文本拆坏指令块:危险独立行加前缀空格 */
export function neutralizeDirectiveLines(text: string): string {
return text
.split("\n")
.map((line) => {
const t = line.trim();
if (isDirectiveLine(t)) {
return " " + line;
}
return line;
})
.join("\n");
}
/** 标题行:去掉换行,防止拆项 */
export function sanitizeTimelineTitle(title: string): string {
return sanitizeTimelineText(title.replace(/[\r\n]+/g, " ").trim(), 200);
}
export function parseTimelineBody(body: string): TimelineItem[] {
const lines = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const items: TimelineItem[] = [];
let cur: TimelineItem | null = null;
let bodyBuf: string[] = [];
const flush = () => {
if (!cur) return;
let sourceUrl: string | undefined;
const kept: string[] = [];
for (const line of bodyBuf) {
const m = line.trim().match(GIT_COMMENT_RE);
if (m) {
sourceUrl = m[1];
continue;
}
kept.push(line);
}
cur.body = kept.join("\n").replace(/^\n+/, "").replace(/\n+$/, "");
if (sourceUrl) cur.sourceUrl = sourceUrl;
items.push(cur);
cur = null;
bodyBuf = [];
};
for (const line of lines) {
const m = line.match(HEADING_RE);
if (m) {
flush();
cur = {
date: m[1],
title: sanitizeTimelineTitle(m[2]),
body: "",
selected: true,
};
continue;
}
if (cur) bodyBuf.push(line);
}
flush();
return items;
}
export function serializeTimelineBlock(items: TimelineItem[]): string {
const parts = items
.filter((it) => it.selected !== false)
.map((it) => {
const date = /^\d{4}-\d{2}-\d{2}$/.test(it.date)
? it.date
: new Date().toISOString().slice(0, 10);
const title = sanitizeTimelineTitle(it.title) || "未命名";
const body = neutralizeDirectiveLines(sanitizeTimelineText(it.body || "", 2000));
const lines = [`## ${date} ${title}`];
if (body.trim()) lines.push(body.trimEnd());
if (it.sourceUrl && /^https:\/\//i.test(it.sourceUrl)) {
lines.push(`<!-- git: ${it.sourceUrl} -->`);
}
return lines.join("\n");
});
return `[timeline]\n${parts.join("\n\n")}\n[/timeline]`;
}
export function groupTimelineByDate(items: TimelineItem[]): TimelineDayGroup[] {
const map = new Map<string, TimelineItem[]>();
const order: string[] = [];
for (const it of items) {
const d = it.date || "未知日期";
if (!map.has(d)) {
map.set(d, []);
order.push(d);
}
map.get(d)!.push(it);
}
order.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
return order.map((date) => ({ date, items: map.get(date)! }));
}
export function isLikelyMergeCommit(title: string): boolean {
return /^merge\b/i.test(title.trim()) || /^合并\b/.test(title.trim());
}