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,364 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
ArrowDown,
ArrowUp,
GitBranch,
Loader2,
Plus,
Trash2,
} from "lucide-react";
import Modal from "@/components/Modal";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
isLikelyMergeCommit,
parseTimelineBody,
sanitizeTimelineText,
sanitizeTimelineTitle,
serializeTimelineBlock,
type TimelineItem,
} from "@/lib/timelineBlocks";
import { apiTimelineFromGit, type TimelineGitItem } from "@/lib/api";
import { toast } from "@/lib/toast";
type ImportMode = "replace" | "append";
type TimelineEditorProps = {
open: boolean;
/** 编辑已有块时传入 body;新建为空 */
initialBody?: string;
onInsert: (block: string) => void;
onClose: () => void;
};
function todayISO(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function fromGitItem(it: TimelineGitItem): TimelineItem {
const title = sanitizeTimelineTitle(it.title || "提交");
return {
date: /^\d{4}-\d{2}-\d{2}$/.test(it.date) ? it.date : todayISO(),
title,
body: sanitizeTimelineText(it.body || "", 2000),
sourceUrl: it.source_url,
selected: !isLikelyMergeCommit(title),
};
}
function emptyItem(): TimelineItem {
return {
date: todayISO(),
title: "",
body: "",
selected: true,
};
}
function seedItems(initialBody: string): TimelineItem[] {
const parsed = parseTimelineBody(initialBody);
return parsed.length > 0 ? parsed : [emptyItem()];
}
export default function TimelineEditor({
open,
initialBody = "",
onInsert,
onClose,
}: TimelineEditorProps) {
const [items, setItems] = useState<TimelineItem[]>(() => seedItems(initialBody));
const [urlsText, setUrlsText] = useState("");
const [followPages, setFollowPages] = useState(true);
const [importMode, setImportMode] = useState<ImportMode>("replace");
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [deleteIdx, setDeleteIdx] = useState<number | null>(null);
useEffect(() => {
if (!open) return;
setItems(seedItems(initialBody));
setUrlsText("");
setImportError(null);
setFollowPages(true);
setImportMode(initialBody.trim() ? "append" : "replace");
}, [open, initialBody]);
const updateItem = useCallback((idx: number, patch: Partial<TimelineItem>) => {
setItems((prev) => prev.map((it, i) => (i === idx ? { ...it, ...patch } : it)));
}, []);
const moveItem = (idx: number, dir: -1 | 1) => {
setItems((prev) => {
const j = idx + dir;
if (j < 0 || j >= prev.length) return prev;
const next = [...prev];
[next[idx], next[j]] = [next[j], next[idx]];
return next;
});
};
const sortByDate = () => {
setItems((prev) =>
[...prev].sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))
);
};
const runImport = async () => {
const urls = urlsText
.split(/\n/)
.map((l) => l.trim())
.filter(Boolean);
if (urls.length === 0) {
setImportError("请粘贴至少一条提交记录页面 URL");
return;
}
setImporting(true);
setImportError(null);
try {
const res = await apiTimelineFromGit({ urls, follow_pages: followPages });
const mapped = (res.items || []).map(fromGitItem);
if (mapped.length === 0) {
setImportError(res.error || "未能解析出提交;请检查 URL 或改为手填");
if (res.warning) toast(res.warning);
return;
}
setItems((prev) => {
if (importMode === "append") {
const seen = new Set(
prev.map((p) => p.sourceUrl || `${p.date}|${p.title}`).filter(Boolean)
);
const extra = mapped.filter(
(m) => !m.sourceUrl || !seen.has(m.sourceUrl)
);
return [...prev.filter((p) => p.title.trim() || p.body.trim()), ...extra];
}
return mapped;
});
if (res.warning) toast(res.warning, "ok");
else toast(`已导入 ${mapped.length} 条,请点「插入/更新时间线」写入正文`, "ok");
if (res.error) setImportError(res.error);
} catch (e) {
setImportError(e instanceof Error ? e.message : "导入失败,请手填时间线");
} finally {
setImporting(false);
}
};
const handleInsert = () => {
const kept = items.filter((it) => it.selected !== false);
if (kept.length === 0) {
toast("请至少保留一条时间线项目", "error");
return;
}
for (const it of kept) {
if (!it.title.trim()) {
toast("每条时间线需要标题", "error");
return;
}
}
onInsert(serializeTimelineBlock(kept));
onClose();
};
return (
<>
<Modal
open={open}
title="编辑时间线"
onClose={onClose}
zIndexClass="z-[1200]"
maxWidthClass="max-w-2xl"
panelClassName="p-0 overflow-hidden"
footer={
<>
<button type="button" className="btn btn-line btn-sm" onClick={onClose}>
取消
</button>
<button type="button" className="btn btn-primary btn-sm" onClick={handleInsert}>
{initialBody.trim() ? "更新时间线" : "插入时间线"}
</button>
</>
}
>
<div className="max-h-[min(70vh,640px)] overflow-y-auto px-5 py-4 space-y-4">
{/* 导入区 */}
<section className="j13-tl-import" aria-label="从提交记录导入">
<div className="flex items-center gap-2 mb-2">
<GitBranch size={15} style={{ color: "var(--accent)" }} />
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
从提交记录导入
</p>
</div>
<p className="meta text-[12px] mb-2 leading-relaxed">
粘贴 Gitea / GitHub 的 commits 列表页(一行一个)。默认自动拉取后续页;也可只贴某几页。
</p>
<textarea
value={urlsText}
onChange={(e) => setUrlsText(e.target.value)}
rows={3}
placeholder={"https://github.com/org/repo/commits/main\nhttps://git.example.com/user/repo/commits/branch/main"}
className="j13-tl-urls"
disabled={importing}
/>
<div className="mt-2 flex flex-wrap items-center gap-3">
<label className="inline-flex items-center gap-1.5 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
<input
type="checkbox"
checked={followPages}
onChange={(e) => setFollowPages(e.target.checked)}
disabled={importing}
/>
自动拉取后续页
</label>
<div className="flex items-center gap-1 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
<span>导入方式</span>
<select
className="j13-tl-select"
value={importMode}
onChange={(e) => setImportMode(e.target.value as ImportMode)}
disabled={importing}
>
<option value="replace">替换当前</option>
<option value="append">追加</option>
</select>
</div>
<button
type="button"
className="btn btn-line btn-sm ml-auto"
disabled={importing}
onClick={() => void runImport()}
>
{importing ? (
<>
<Loader2 size={14} className="animate-spin" /> 导入中…
</>
) : (
"导入"
)}
</button>
</div>
{importError ? (
<p className="mt-2 text-[12.5px] leading-relaxed" style={{ color: "var(--danger)" }}>
{importError}
</p>
) : null}
</section>
{/* 条目列表 */}
<div className="flex items-center justify-between gap-2">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
时间线条目
</p>
<div className="flex items-center gap-1.5">
<button type="button" className="btn btn-line btn-sm" onClick={sortByDate}>
按日期整理
</button>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setItems((prev) => [...prev, emptyItem()])}
>
<Plus size={14} /> 添加
</button>
</div>
</div>
<ul className="space-y-3">
{items.map((it, idx) => (
<li key={idx} className="j13-tl-edit-card">
<div className="flex items-start gap-2">
<label className="mt-2 shrink-0">
<input
type="checkbox"
checked={it.selected !== false}
onChange={(e) => updateItem(idx, { selected: e.target.checked })}
aria-label="保留此条"
/>
</label>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap gap-2">
<input
type="date"
value={it.date}
onChange={(e) => updateItem(idx, { date: e.target.value })}
className="j13-tl-field w-[9.5rem]"
/>
<input
type="text"
value={it.title}
maxLength={200}
placeholder="标题"
onChange={(e) => updateItem(idx, { title: e.target.value })}
className="j13-tl-field flex-1 min-w-[10rem]"
/>
</div>
<textarea
value={it.body}
rows={2}
placeholder="说明(可选,支持 Markdown)"
onChange={(e) => updateItem(idx, { body: e.target.value })}
className="j13-tl-field w-full"
/>
{it.sourceUrl ? (
<p className="meta text-[11.5px] truncate" title={it.sourceUrl}>
来自提交 · {it.sourceUrl}
</p>
) : null}
</div>
<div className="flex flex-col gap-0.5 shrink-0">
<button
type="button"
className="j13-md-tool"
aria-label="上移"
disabled={idx === 0}
onClick={() => moveItem(idx, -1)}
>
<ArrowUp size={14} />
</button>
<button
type="button"
className="j13-md-tool"
aria-label="下移"
disabled={idx === items.length - 1}
onClick={() => moveItem(idx, 1)}
>
<ArrowDown size={14} />
</button>
<button
type="button"
className="j13-md-tool"
aria-label="删除"
onClick={() => setDeleteIdx(idx)}
>
<Trash2 size={14} />
</button>
</div>
</div>
</li>
))}
</ul>
</div>
</Modal>
<ConfirmDialog
open={deleteIdx != null}
title="删除条目"
message="确定删除这条时间线项目?"
danger
confirmLabel="删除"
onCancel={() => setDeleteIdx(null)}
onConfirm={() => {
if (deleteIdx != null) {
setItems((prev) => prev.filter((_, i) => i !== deleteIdx));
}
setDeleteIdx(null);
}}
/>
</>
);
}