Files
Endoleap/src/components/canvas/headless-imports.tsx
T

63 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 新版画布的导入/复制任务显示入口。订阅engine已发布状态,选择文件后启动runtime任务,并展示已加入前缀、等待项与失败原因。
* 面板不保存独立任务事实;暂停/继续/停止由imports实现,停止后已加入内容仍保留,不能把它当成整批回滚。
*/
"use client";
import { useCallback, useRef, useState, useSyncExternalStore } from "react";
import { FolderInput } from "lucide-react";
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
import { IconButton } from "@/components/ui/icon-button";
import { errorText } from "@/lib/api";
import styles from "./canvas.module.css";
/** 将记录级错误码转成可读说明,未识别的码使用通用提示;原始错误仍保存在任务记录中。 */
const reason = (code: string) => ({ import_asset_authorization_failed: "资源访问授权失败,此记录未加入", import_asset_capacity: "本组资源过大,无法一次准备", asset_depot_unavailable: "资源仓库暂不可用", import_resource_authorization_required: "资源需要重新授权,此记录尚未加入", invalid_document_import: "导出文档结构不合法", invalid_json_array: "JSON 记录数组格式错误", incomplete_json_array: "JSON 数组尾部不完整", json_nesting_limit: "JSON 嵌套层数过多", invalid_json: "记录格式错误", invalid_json_value: "记录包含不支持的数据", invalid_utf8: "文件编码不是 UTF-8", record_too_large: "单条记录过大", unresolved_dependency: "引用的记录未加入", invalid_native_import_record: "不是画布记录", import_component_unavailable: "元素类型暂不支持", invalid_geometry: "元素尺寸不合法", invalid_endpoint: "连线端点已不存在", duplicate_import_source_key: "记录标识重复", import_source_read_failed: "暂时无法读取源文件,可重试;已加入内容保留", import_source_verification_failed: "源文件校验失败,可重试;已加入内容保留", invalid_import_read_proof: "源文件读取证据不一致,任务已暂停", import_source_unavailable: "导入源文件不可用", import_source_changed: "源文件内容与保存时不一致,任务已暂停", import_source_integrity_unavailable: "旧任务缺少源内容校验,无法安全继续;已加入内容保留", import_source_integrity_missing: "源校验记录缺失,任务已暂停", import_source_integrity_interpreter_unavailable: "源校验版本暂不支持", import_target_missing: "目标分区已不存在", import_target_changed: "目标分区已改变", import_dependency_changed: "引用对象已删除或恢复为新版本", dependency_capacity: "等待引用过多,请调整文件顺序", copy_external_changed: "原图引用已删除、移动或恢复为新版本", invalid_duplicate_source: "复制源数据不可用", invalid_copy_snapshot: "复制快照不可用", invalid_copy_preparation: "复制准备记录不可用", copy_interpreter_changed: "复制规则已变化,需要重新复制", invalid_import_source: "文件过大或格式不支持" } as Record<string,string>)[code] ?? "这条记录暂时无法加入";
/** 只显示目标canvas最近三个任务,倒序展示;任务数量与汇总仅覆盖这个显示窗口,不代表项目全部导入。 */
export function HeadlessImports({ engine, canvasId, viewId }: { engine: ProjectEngine; canvasId: string; viewId:string }) {
// React订阅唯一发布根,不在组件里复制任务状态;同一个getSnapshot也作为服务端快照入口。
const subscribe = useCallback((notify: () => void) => engine.editor.subscribe(notify), [engine]);
const snapshot = useSyncExternalStore(subscribe, engine.getSnapshot, engine.getSnapshot);
const input = useRef<HTMLInputElement>(null), [open, setOpen] = useState(false), [error, setError] = useState("");
// 每次渲染更新engine/canvas身份,用于异步文件探测和控制回调完成后避免更新另一画布的错误显示。
const scope = useRef({ engine, canvasId }); scope.current = { engine, canvasId };
// scope 只比较 engine/canvas,不带挂载代次;同一目标卸载后的已兑现回调并非由此 ref 自动取消。
const jobs = [...(snapshot.runtime.imports?.values() ?? [])].filter(job => job.source?.targetCanvasId === canvasId).slice(-3).reverse();
/** 统一承接runtime控制Promise的成功/失败;这里只更新面板错误,不自行重排或重启任务。 */
const control = (work: Promise<void>) => { void work.then(() => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(""); }).catch(error => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(errorText(error)); }); };
// control 接收已经开始的 Promise:同步调用抛错不在它的 catch 内;这里也不阻止多次点任务按钮。
// canStart决定文件入口可用性;input每次先清value,允许再次选择同一个文件。
// .json读取前64KiB探测首个非空白字符:对象走文档导入,数组走记录数组;其它后缀走NDJSON。
// 探测前捕获canvas父关系及入口生命周期,await后重新核对,防止向已被替换/移走的目标启动任务。
// 64KiB 只是后缀为 .json 时的格式探测;真正解析、校验、文件持久保存和逐步加入由 runtime 负责。
// integrityVersion=1源校验未完成显示校验中;=2允许内容继续编辑,但提示校验完成后同步。
// “重试源文件校验/保存”调用 engine.retry,作用于项目待重试工作,不是只重试当前展示的 job。
// 任务按钮按runtime状态显示;失败详情最多列前10项,停止提示明确已加入内容继续同步。
return <aside data-canvas-ui-view={viewId} className={styles.importPanel} aria-label="画布导入">
<div className={styles.importActions}><IconButton icon={FolderInput} label="导入画布记录" disabled={!engine.imports.canStart(canvasId).enabled} onClick={() => input.current?.click()} /><span>NDJSON / JSON 记录数组</span></div>
<input data-canvas-import ref={input} type="file" accept=".ndjson,.jsonl,.json,application/json,application/x-ndjson" hidden onChange={async event => {
const file = event.target.files?.[0]; event.target.value = ""; if (!file) return;
try { if(file.name.toLowerCase().endsWith('.json')){const before=engine.getSnapshot().document,target=before.canvases.get(canvasId),parent=JSON.stringify(target?.parent),entry=target?.parent?before.elements.get(target.parent.entryElementId)?.incarnation:undefined;const prefix=(await file.slice(0,65536).text()).trimStart()[0];if(scope.current.engine!==engine||scope.current.canvasId!==canvasId)return;const current=engine.getSnapshot().document,now=current.canvases.get(canvasId);if(!target||!now||JSON.stringify(now.parent)!==parent||(now.parent?current.elements.get(now.parent.entryElementId)?.incarnation:undefined)!==entry)throw Error('import_target_changed');if(prefix==='{')engine.imports.startDocument(file,canvasId);else if(prefix==='[')engine.imports.startJsonArray(file,canvasId);else throw Error('invalid_import_source');}else engine.imports.startNdjson(file,canvasId); setOpen(true); setError(""); } catch (error) { if(scope.current.engine===engine&&scope.current.canvasId===canvasId)setError(errorText(error)); }
}} />
{jobs.length > 0 && <details open={open} onToggle={event => setOpen(event.currentTarget.open)}><summary>导入与复制任务 · {jobs.some(job => job.status === "preparing") && "正在准备复制 · "}{jobs.reduce((count, job) => count + job.committedSourceKeys.length, 0)} 项已加入</summary>
{jobs.map((job, index) => <section className={styles.importJob} key={job.id} aria-label={`导入任务 ${index + 1}`}>
<strong>{job.source?.name || `导入任务 ${index + 1}`}</strong>
<p role="status">{["running","preparing"].includes(job.status) && job.source?.integrityVersion === 1 && !job.source.sha256 ? "正在校验源文件" : job.status === "preparing" ? "正在准备复制" : job.status === "running" ? "正在加入" : job.status === "paused" ? "已暂停" : job.status === "cancelled" ? "已停止" : job.status === "partial" ? "部分加入" : "已加入画布"} · {job.committedSourceKeys.length} 项{job.pending.length > 0 && `,${job.pending.length} 项等待`}</p>
{!(job.status==='cancelled'&&!job.committedSourceKeys.length)&&job.source?.integrityVersion===2&&!job.source.sha256&&!job.source.integrityError&&<small>内容可继续编辑,源文件校验完成后同步。</small>}
{job.source?.retirement?.purgedAt !== undefined && <small>本地源文件已清理;画布内容和撤销记录保留。</small>}
{job.error && <p>{reason(job.error)}</p>}
<div className={styles.importActions}>
{job.source?.integrityError&&<button onClick={()=>control(engine.retry())}>重试源文件校验</button>}
{["running","preparing"].includes(job.status) && <button onClick={() => control(engine.imports.pause(job.id))}>暂停导入</button>}
{job.status === "paused" && <button onClick={() => control(engine.imports.resume(job.id))}>继续导入</button>}
{["running","preparing","paused"].includes(job.status) && <button onClick={() => control(engine.imports.cancel(job.id))}>停止导入</button>}
</div>
{job.status === "cancelled" && <small>已加入的内容保留并继续同步。</small>}
{job.failures.length > 0 && <details><summary>{job.failures.length} 条未加入记录</summary><ul>{job.failures.slice(0, 10).map((failure, item) => <li key={`${failure.key}:${item}`}>{failure.key.startsWith("byte:") ? `文件位置 ${failure.cursor}` : failure.key}:{reason(failure.code)}</li>)}</ul></details>}
</section>)}
</details>}
{error && <p role="alert">{error}{jobs.length > 0 && <button onClick={() => control(engine.retry())}>重试保存</button>}</p>}
</aside>;
}