ScriptArtifactViewer.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. "use client";
  2. import { useEffect, useRef, useState } from "react";
  3. import { FileText, RefreshCw, X } from "lucide-react";
  4. import { errorMessage, getArtifactDetail } from "@/lib/api";
  5. import type { ArtifactDetailProjection } from "@/lib/types";
  6. import { TabList } from "@/components/ui/TabList";
  7. import { ScriptTable } from "@/components/artifact/ScriptTable";
  8. import { ScriptElementTable } from "@/components/artifact/ScriptElementTable";
  9. import { StatusBadge } from "@/components/ui/Badges";
  10. type ViewerTab = "script" | "elements";
  11. interface Props {
  12. buildId: string;
  13. snapshotRef: string;
  14. onClose: () => void;
  15. }
  16. export function ScriptArtifactViewer({ buildId, snapshotRef, onClose }: Props) {
  17. const dialog = useRef<HTMLDialogElement>(null);
  18. const [detail, setDetail] = useState<ArtifactDetailProjection>();
  19. const [loading, setLoading] = useState(true);
  20. const [error, setError] = useState<string>();
  21. const [tab, setTab] = useState<ViewerTab>("script");
  22. const [selectedElement, setSelectedElement] = useState<number | string>();
  23. const [reloadKey, setReloadKey] = useState(0);
  24. useEffect(() => {
  25. const node = dialog.current;
  26. if (node && !node.open && typeof node.showModal === "function") node.showModal();
  27. const cancel = (event: Event) => { event.preventDefault(); onClose(); };
  28. node?.addEventListener("cancel", cancel);
  29. const previousOverflow = document.body.style.overflow;
  30. document.body.style.overflow = "hidden";
  31. return () => {
  32. node?.removeEventListener("cancel", cancel);
  33. document.body.style.overflow = previousOverflow;
  34. };
  35. }, [onClose]);
  36. useEffect(() => {
  37. const controller = new AbortController();
  38. setLoading(true);
  39. setError(undefined);
  40. void getArtifactDetail(buildId, snapshotRef, controller.signal)
  41. .then(setDetail)
  42. .catch((reason) => {
  43. if (!controller.signal.aborted) setError(errorMessage(reason));
  44. })
  45. .finally(() => {
  46. if (!controller.signal.aborted) setLoading(false);
  47. });
  48. return () => controller.abort();
  49. }, [buildId, snapshotRef, reloadKey]);
  50. const table = detail?.scriptTable;
  51. const context = detail?.artifactContext;
  52. const candidate = context?.type === "candidate" || snapshotRef.startsWith("artifact:branch:");
  53. const roundOutput = context?.type === "round" || snapshotRef.startsWith("artifact:round:");
  54. const title = candidate
  55. ? context?.roundIndex
  56. ? `第 ${context.roundIndex} 轮 · 方案 ${context.branchId ?? "-"}`
  57. : `候选方案 ${context?.branchId ?? snapshotRef.split(":").at(-1) ?? "-"}`
  58. : roundOutput
  59. ? `第 ${context?.roundIndex ?? snapshotRef.split(":").at(-1) ?? "-"} 轮 · 本轮产出`
  60. : `Run #${buildId} · 当前保存版本`;
  61. const versionNotice = candidate ? context ? candidateVersionNotice(context) : "正在读取候选版本信息" : roundOutput ? context?.note || "正在确认本轮产出版本" : "当前保存的最终主脚本";
  62. const canRefresh = candidate && ["open", "parked"].includes(String(context?.branchStatus || "").toLowerCase());
  63. const openElement = (id: number | string) => {
  64. setSelectedElement(id);
  65. setTab("elements");
  66. };
  67. return <dialog ref={dialog} className={`scriptViewer ${candidate ? "isCandidate" : "isFinal"}`} aria-labelledby="script-viewer-title" onWheelCapture={(event) => event.stopPropagation()}>
  68. <header className="scriptViewerHeader">
  69. <div className="scriptViewerTitle">
  70. <span><FileText size={17} />{candidate ? "完整候选脚本" : roundOutput ? "本轮产出脚本表" : "当前最终主脚本"}{candidate && context?.branchStatus ? <StatusBadge status={context.branchStatus} /> : null}</span>
  71. <h2 id="script-viewer-title">{title}</h2>
  72. {table ? <p>{table.counts.paragraphs} 个段落 · {table.counts.elements} 个元素 · {table.counts.links} 条关联</p> : null}
  73. </div>
  74. <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>
  75. </header>
  76. <div className="scriptViewerToolbar">
  77. <TabList
  78. className="scriptViewerTabs"
  79. tabs={[{ id: "script", label: "创作脚本" }, { id: "elements", label: `脚本元素${table ? ` ${table.counts.elements}` : ""}` }]}
  80. active={tab}
  81. onChange={setTab}
  82. ariaLabel={candidate ? "候选脚本内容分类" : "主脚本内容分类"}
  83. />
  84. <p className={`scriptViewerVersion ${context?.exactness === "historical-snapshot" ? "isHistorical" : "isCurrent"}`}>{versionNotice}</p>
  85. </div>
  86. <div className="scriptViewerBody">
  87. {loading ? <div className="scriptViewerState"><div className="skeletonLines" aria-label="正在加载完整脚本表"><i /><i /><i /></div><p>{candidate ? "正在读取候选脚本…" : "正在读取当前主脚本…"}</p></div> : null}
  88. {!loading && error ? <div className="scriptViewerState isError"><strong>{candidate ? "完整候选脚本暂时无法读取" : "完整主脚本暂时无法读取"}</strong><p>{error}</p></div> : null}
  89. {!loading && !error && table && tab === "script" ? table.paragraphs.length
  90. ? <ScriptTable paragraphs={table.paragraphs} elements={table.elements} onOpenElement={openElement} />
  91. : <div className="scriptViewerState"><strong>{candidate ? "当前候选版本还没有段落" : "当前主脚本还没有段落"}</strong><p>可以切换到“脚本元素”查看已经保存的元素。</p></div>
  92. : null}
  93. {!loading && !error && table && tab === "elements" ? table.elements.length
  94. ? <ScriptElementTable elements={table.elements} selectedId={selectedElement} />
  95. : <div className="scriptViewerState"><strong>{candidate ? "当前候选版本还没有元素" : "当前主脚本还没有元素"}</strong><p>创作脚本段落仍可在另一个页签查看。</p></div>
  96. : null}
  97. </div>
  98. </dialog>;
  99. }
  100. export function candidateVersionNotice(context: ArtifactDetailProjection["artifactContext"]) {
  101. const status = String(context.branchStatus || "").toLowerCase();
  102. if (context.exactness === "historical-snapshot") {
  103. if (status === "discarded") return "未采用前冻结保存的候选版本";
  104. if (status === "merged") return "采用前冻结保存的候选版本";
  105. return "处置前冻结保存的候选版本";
  106. }
  107. if (status === "parked") return "根据当前主脚本和暂存改动组合;不是暂存当时的冻结历史";
  108. if (status === "open") return "显示截至本次读取已写入的内容;方案仍可能继续更新";
  109. return context.note || "根据当前主脚本和分支改动组合的候选版本";
  110. }