"use client"; import { useCallback, useEffect, useState } from "react"; import { ArrowDown, ArrowUp, GitBranch, Loader2, Plus, Tag, 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, apiTimelineFromReleases, 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(() => seedItems(initialBody)); const [urlsText, setUrlsText] = useState(""); const [followPages, setFollowPages] = useState(true); const [importMode, setImportMode] = useState("replace"); const [importing, setImporting] = useState(false); const [importError, setImportError] = useState(null); const [deleteIdx, setDeleteIdx] = useState(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) => { 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 collectURLs = () => urlsText .split(/\n/) .map((l) => l.trim()) .filter(Boolean); const applyImported = (mapped: TimelineItem[], errorHint: string) => { if (mapped.length === 0) { setImportError(errorHint); return false; } 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; }); return true; }; const runImportCommits = async () => { const urls = collectURLs(); 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 (!applyImported(mapped, res.error || "未能解析出提交;请检查 URL 或改为手填")) { if (res.warning) toast(res.warning); return; } 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 runImportReleases = async () => { const urls = collectURLs(); if (urls.length === 0) { setImportError("请粘贴至少一条 Release 页面 URL"); return; } setImporting(true); setImportError(null); try { const res = await apiTimelineFromReleases({ urls }); const mapped = (res.items || []).map(fromGitItem); if (!applyImported(mapped, res.error || "未能解析出 Release;请检查 URL 或改为手填")) { if (res.warning) toast(res.warning); return; } if (res.warning) toast(res.warning, "ok"); else toast(`已从 Release 导入 ${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(); }; const keptCount = items.filter((it) => it.selected !== false).length; const urlCount = urlsText .split(/\n/) .map((l) => l.trim()) .filter(Boolean).length; const hasExistingContent = items.some( (it) => it.title.trim() || it.body.trim() ); const showReplaceWarning = importMode === "replace" && hasExistingContent; return ( <> 0 ? "var(--ink-3)" : "var(--danger)" }} > {keptCount > 0 ? `将${initialBody.trim() ? "更新" : "插入"} ${keptCount} 条` : "没有可保留的条目"}
} >
{/* 导入区 */}

从提交记录导入

粘贴 Gitea / GitHub 的 commits 或 releases 列表页 URL,每行一条。点「导入提交」拉取提交记录,点「导入 Release」拉取发布日志。