feat(canvas): expose native import progress and controls

This commit is contained in:
2026-11-08 15:40:10 +08:00
parent 72d6105e7c
commit ce02e43958
4 changed files with 66 additions and 2 deletions
+9
View File
@@ -192,3 +192,12 @@
.memberSync { padding: 5px 9px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #796e63; font-size: 11px; box-shadow: 0 2px 8px #372f2410; }
.memberSync button { margin-left: 6px; border: 0; background: transparent; color: #78523f; text-decoration: underline; cursor: pointer; }
.importPanel { position: absolute; top: 84px; right: 12px; z-index: 12; width: max-content; max-width: min(310px, calc(100vw - 28px)); max-height: 45vh; overflow: auto; border: 1px solid #ded7ce; border-radius: 10px; padding: 6px 9px; background: #fffefb; box-shadow: 0 4px 18px #32251a10; color: #49372e; font-size: 11px; }
.importPanel summary { cursor: pointer; padding: 5px 0; }
.importPanel ul { padding-left: 16px; overflow-wrap: anywhere; }
.importPanel p { margin: 5px 0; }
.importPanel small { color: #92877c; }
.importJob { border-top: 1px solid #eee7de; padding-top: 5px; margin-top: 5px; }
.importActions { display: flex; align-items: center; gap: 8px; }
.importActions > button:not(:first-child), .importJob button { color: #78523f; padding: 4px 0; }
@@ -16,6 +16,7 @@ import { guardEditingLease } from "./editing-lease";
import { NodeEditing } from "./transformable-node";
import { EdgeEditing } from "./edge-editing";
import { HeadlessAIPanel } from "./headless-ai-panel";
import { HeadlessImports } from "./headless-imports";
import { HeadlessRecoveryDialog } from "./headless-recovery-dialog";
import { CanvasEdge } from "./canvas-edge";
import { MaterialNode } from "./material-node";
@@ -202,6 +203,7 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
<IconButton icon={Type} label="添加纯文本" onClick={() => add('plain-text')} /><IconButton icon={StickyNote} label="添加文本卡片" onClick={() => add('text')} /><IconButton icon={ImagePlus} label="添加图片" onClick={() => imageInput.current?.click()} /><IconButton icon={Frame} label="添加框选区" onClick={() => add('frame')} />
</div>
<input ref={imageInput} type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden onChange={event => { const files = [...event.target.files ?? []]; event.target.value = ''; try { report(engine.stageFiles(files.map(file => ({ name: file.name, bytes: file })), (refs, tx) => { for (const ref of refs) add('image', ref, tx); })); } catch (e) { setError(errorText(e)); } }} />
<HeadlessImports engine={engine} canvasId={view.canvasId} />
{showAI && aiElement && <HeadlessAIPanel key={`${aiElement.id}:${aiElement.incarnation}:${aiElement.ownerCanvasId}`} engine={engine} element={aiElement} close={() => setShowAI(false)} />}
{showRecovery && <HeadlessRecoveryDialog engine={engine} close={() => setShowRecovery(false)} />}
{error && <div className={styles.error} role="alert">{error} <button onClick={() => setError('')}>关闭</button></div>}
@@ -0,0 +1,41 @@
"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) => ({ 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_unavailable: "导入源文件不可用", import_source_changed: "导入源或解析规则已变化", import_target_missing: "目标分区已不存在", import_target_changed: "目标分区已改变", import_dependency_changed: "引用对象已删除或恢复为新版本", dependency_capacity: "等待引用过多,请调整文件顺序", invalid_import_source: "文件过大或格式不支持" } as Record<string,string>)[code] ?? "这条记录暂时无法加入";
export function HeadlessImports({ engine, canvasId }: { engine: ProjectEngine; canvasId: string }) {
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("");
const scope = useRef({ engine, canvasId }); scope.current = { engine, canvasId };
const jobs = [...(snapshot.runtime.imports?.values() ?? [])].filter(job => job.source?.targetCanvasId === canvasId).slice(-3).reverse();
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)); }); };
return <aside className={styles.importPanel} aria-label="画布导入">
<div className={styles.importActions}><IconButton icon={FolderInput} label="导入 NDJSON 画布数据" onClick={() => input.current?.click()} /><span>逐行画布数据</span></div>
<input data-canvas-import ref={input} type="file" accept=".ndjson,.jsonl,application/x-ndjson" hidden onChange={event => {
const file = event.target.files?.[0]; event.target.value = ""; if (!file) return;
try { engine.imports.startNdjson(file, canvasId); setOpen(true); setError(""); } catch (error) { setError(errorText(error)); }
}} />
{jobs.length > 0 && <details open={open} onToggle={event => setOpen(event.currentTarget.open)}><summary>导入任务 · {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">{job.status === "running" ? "正在加入" : job.status === "paused" ? "已暂停" : job.status === "cancelled" ? "已停止" : job.status === "partial" ? "部分加入" : "已加入画布"} · {job.committedSourceKeys.length} 项{job.pending.length > 0 && `,${job.pending.length} 项等待`}</p>
{job.error && <p>{reason(job.error)}</p>}
<div className={styles.importActions}>
{job.status === "running" && <button onClick={() => control(engine.imports.pause(job.id))}>暂停导入</button>}
{job.status === "paused" && <button onClick={() => control(engine.imports.resume(job.id))}>继续导入</button>}
{["running","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>;
}