| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119 |
- "use client";
- import { useEffect, useRef, useState } from "react";
- import { FileText, RefreshCw, X } from "lucide-react";
- import { errorMessage, getArtifactDetail } from "@/lib/api";
- import type { ArtifactDetailProjection } from "@/lib/types";
- import { TabList } from "@/components/ui/TabList";
- import { ScriptTable } from "@/components/artifact/ScriptTable";
- import { ScriptElementTable } from "@/components/artifact/ScriptElementTable";
- import { StatusBadge } from "@/components/ui/Badges";
- type ViewerTab = "script" | "elements";
- interface Props {
- buildId: string;
- snapshotRef: string;
- onClose: () => void;
- }
- export function ScriptArtifactViewer({ buildId, snapshotRef, onClose }: Props) {
- const dialog = useRef<HTMLDialogElement>(null);
- const [detail, setDetail] = useState<ArtifactDetailProjection>();
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState<string>();
- const [tab, setTab] = useState<ViewerTab>("script");
- const [selectedElement, setSelectedElement] = useState<number | string>();
- const [reloadKey, setReloadKey] = useState(0);
- useEffect(() => {
- const node = dialog.current;
- if (node && !node.open && typeof node.showModal === "function") node.showModal();
- const cancel = (event: Event) => { event.preventDefault(); onClose(); };
- node?.addEventListener("cancel", cancel);
- const previousOverflow = document.body.style.overflow;
- document.body.style.overflow = "hidden";
- return () => {
- node?.removeEventListener("cancel", cancel);
- document.body.style.overflow = previousOverflow;
- };
- }, [onClose]);
- useEffect(() => {
- const controller = new AbortController();
- setLoading(true);
- setError(undefined);
- void getArtifactDetail(buildId, snapshotRef, controller.signal)
- .then(setDetail)
- .catch((reason) => {
- if (!controller.signal.aborted) setError(errorMessage(reason));
- })
- .finally(() => {
- if (!controller.signal.aborted) setLoading(false);
- });
- return () => controller.abort();
- }, [buildId, snapshotRef, reloadKey]);
- const table = detail?.scriptTable;
- const context = detail?.artifactContext;
- const candidate = context?.type === "candidate" || snapshotRef.startsWith("artifact:branch:");
- const roundOutput = context?.type === "round" || snapshotRef.startsWith("artifact:round:");
- const title = candidate
- ? context?.roundIndex
- ? `第 ${context.roundIndex} 轮 · 方案 ${context.branchId ?? "-"}`
- : `候选方案 ${context?.branchId ?? snapshotRef.split(":").at(-1) ?? "-"}`
- : roundOutput
- ? `第 ${context?.roundIndex ?? snapshotRef.split(":").at(-1) ?? "-"} 轮 · 本轮产出`
- : `Run #${buildId} · 当前保存版本`;
- const versionNotice = candidate ? context ? candidateVersionNotice(context) : "正在读取候选版本信息" : roundOutput ? context?.note || "正在确认本轮产出版本" : "当前保存的最终主脚本";
- const canRefresh = candidate && ["open", "parked"].includes(String(context?.branchStatus || "").toLowerCase());
- const openElement = (id: number | string) => {
- setSelectedElement(id);
- setTab("elements");
- };
- return <dialog ref={dialog} className={`scriptViewer ${candidate ? "isCandidate" : "isFinal"}`} aria-labelledby="script-viewer-title" onWheelCapture={(event) => event.stopPropagation()}>
- <header className="scriptViewerHeader">
- <div className="scriptViewerTitle">
- <span><FileText size={17} />{candidate ? "完整候选脚本" : roundOutput ? "本轮产出脚本表" : "当前最终主脚本"}{candidate && context?.branchStatus ? <StatusBadge status={context.branchStatus} /> : null}</span>
- <h2 id="script-viewer-title">{title}</h2>
- {table ? <p>{table.counts.paragraphs} 个段落 · {table.counts.elements} 个元素 · {table.counts.links} 条关联</p> : null}
- </div>
- <div className="scriptViewerHeaderActions">{canRefresh ? <button className="quietButton" type="button" aria-label="刷新当前候选脚本" onClick={() => setReloadKey((value) => value + 1)} disabled={loading}><RefreshCw size={15} /><span>刷新当前候选</span></button> : null}<button autoFocus className="iconButton" type="button" onClick={onClose} aria-label={candidate ? "关闭完整候选脚本" : "关闭完整主脚本"}><X size={20} /></button></div>
- </header>
- <div className="scriptViewerToolbar">
- <TabList
- className="scriptViewerTabs"
- tabs={[{ id: "script", label: "创作脚本" }, { id: "elements", label: `脚本元素${table ? ` ${table.counts.elements}` : ""}` }]}
- active={tab}
- onChange={setTab}
- ariaLabel={candidate ? "候选脚本内容分类" : "主脚本内容分类"}
- />
- <p className={`scriptViewerVersion ${context?.exactness === "historical-snapshot" ? "isHistorical" : "isCurrent"}`}>{versionNotice}</p>
- </div>
- <div className="scriptViewerBody">
- {loading ? <div className="scriptViewerState"><div className="skeletonLines" aria-label="正在加载完整脚本表"><i /><i /><i /></div><p>{candidate ? "正在读取候选脚本…" : "正在读取当前主脚本…"}</p></div> : null}
- {!loading && error ? <div className="scriptViewerState isError"><strong>{candidate ? "完整候选脚本暂时无法读取" : "完整主脚本暂时无法读取"}</strong><p>{error}</p></div> : null}
- {!loading && !error && table && tab === "script" ? table.paragraphs.length
- ? <ScriptTable paragraphs={table.paragraphs} elements={table.elements} onOpenElement={openElement} />
- : <div className="scriptViewerState"><strong>{candidate ? "当前候选版本还没有段落" : "当前主脚本还没有段落"}</strong><p>可以切换到“脚本元素”查看已经保存的元素。</p></div>
- : null}
- {!loading && !error && table && tab === "elements" ? table.elements.length
- ? <ScriptElementTable elements={table.elements} selectedId={selectedElement} />
- : <div className="scriptViewerState"><strong>{candidate ? "当前候选版本还没有元素" : "当前主脚本还没有元素"}</strong><p>创作脚本段落仍可在另一个页签查看。</p></div>
- : null}
- </div>
- </dialog>;
- }
- export function candidateVersionNotice(context: ArtifactDetailProjection["artifactContext"]) {
- const status = String(context.branchStatus || "").toLowerCase();
- if (context.exactness === "historical-snapshot") {
- if (status === "discarded") return "未采用前冻结保存的候选版本";
- if (status === "merged") return "采用前冻结保存的候选版本";
- return "处置前冻结保存的候选版本";
- }
- if (status === "parked") return "根据当前主脚本和暂存改动组合;不是暂存当时的冻结历史";
- if (status === "open") return "显示截至本次读取已写入的内容;方案仍可能继续更新";
- return context.note || "根据当前主脚本和分支改动组合的候选版本";
- }
|