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

@@ -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());
}