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

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