@lokvis/ui-react 0.1.1-beta.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/LICENSE +21 -0
  2. package/dist/components/AssetPanel.d.ts +2 -2
  3. package/dist/components/AssetPanel.d.ts.map +1 -1
  4. package/dist/components/AssetPanel.js +79 -7
  5. package/dist/components/AssetPanel.js.map +1 -1
  6. package/dist/components/Canvas.d.ts +9 -1
  7. package/dist/components/Canvas.d.ts.map +1 -1
  8. package/dist/components/Canvas.js +39 -2
  9. package/dist/components/Canvas.js.map +1 -1
  10. package/dist/components/CommandPalette.d.ts +48 -0
  11. package/dist/components/CommandPalette.d.ts.map +1 -0
  12. package/dist/components/CommandPalette.js +154 -0
  13. package/dist/components/CommandPalette.js.map +1 -0
  14. package/dist/components/CompareSlider.d.ts +25 -0
  15. package/dist/components/CompareSlider.d.ts.map +1 -0
  16. package/dist/components/CompareSlider.js +106 -0
  17. package/dist/components/CompareSlider.js.map +1 -0
  18. package/dist/components/DownloadPanel.d.ts +20 -0
  19. package/dist/components/DownloadPanel.d.ts.map +1 -0
  20. package/dist/components/DownloadPanel.js +169 -0
  21. package/dist/components/DownloadPanel.js.map +1 -0
  22. package/dist/components/ErrorBanner.d.ts +22 -0
  23. package/dist/components/ErrorBanner.d.ts.map +1 -0
  24. package/dist/components/ErrorBanner.js +47 -0
  25. package/dist/components/ErrorBanner.js.map +1 -0
  26. package/dist/components/ExifPanel.d.ts +26 -0
  27. package/dist/components/ExifPanel.d.ts.map +1 -0
  28. package/dist/components/ExifPanel.js +104 -0
  29. package/dist/components/ExifPanel.js.map +1 -0
  30. package/dist/components/GlobalDropzone.d.ts +37 -0
  31. package/dist/components/GlobalDropzone.d.ts.map +1 -0
  32. package/dist/components/GlobalDropzone.js +154 -0
  33. package/dist/components/GlobalDropzone.js.map +1 -0
  34. package/dist/components/HistoryPanel.d.ts +17 -7
  35. package/dist/components/HistoryPanel.d.ts.map +1 -1
  36. package/dist/components/HistoryPanel.js +74 -62
  37. package/dist/components/HistoryPanel.js.map +1 -1
  38. package/dist/components/Inspector.d.ts.map +1 -1
  39. package/dist/components/Inspector.js +2 -1
  40. package/dist/components/Inspector.js.map +1 -1
  41. package/dist/components/ProgressBar.d.ts +18 -0
  42. package/dist/components/ProgressBar.d.ts.map +1 -0
  43. package/dist/components/ProgressBar.js +35 -0
  44. package/dist/components/ProgressBar.js.map +1 -0
  45. package/dist/components/StatusBar.d.ts +8 -2
  46. package/dist/components/StatusBar.d.ts.map +1 -1
  47. package/dist/components/StatusBar.js +59 -4
  48. package/dist/components/StatusBar.js.map +1 -1
  49. package/dist/components/ThemeToggle.d.ts +20 -0
  50. package/dist/components/ThemeToggle.d.ts.map +1 -0
  51. package/dist/components/ThemeToggle.js +60 -0
  52. package/dist/components/ThemeToggle.js.map +1 -0
  53. package/dist/components/WorkflowEditor.d.ts +24 -0
  54. package/dist/components/WorkflowEditor.d.ts.map +1 -0
  55. package/dist/components/WorkflowEditor.js +160 -0
  56. package/dist/components/WorkflowEditor.js.map +1 -0
  57. package/dist/components/WorkflowTemplates.d.ts +23 -0
  58. package/dist/components/WorkflowTemplates.d.ts.map +1 -0
  59. package/dist/components/WorkflowTemplates.js +32 -0
  60. package/dist/components/WorkflowTemplates.js.map +1 -0
  61. package/dist/components/Workspace.d.ts +48 -2
  62. package/dist/components/Workspace.d.ts.map +1 -1
  63. package/dist/components/Workspace.js +78 -4
  64. package/dist/components/Workspace.js.map +1 -1
  65. package/dist/data/workflow-templates.d.ts +42 -0
  66. package/dist/data/workflow-templates.d.ts.map +1 -0
  67. package/dist/data/workflow-templates.js +112 -0
  68. package/dist/data/workflow-templates.js.map +1 -0
  69. package/dist/hooks/useDebouncedRun.d.ts +49 -0
  70. package/dist/hooks/useDebouncedRun.d.ts.map +1 -0
  71. package/dist/hooks/useDebouncedRun.js +117 -0
  72. package/dist/hooks/useDebouncedRun.js.map +1 -0
  73. package/dist/hooks/useLokvis.js +1 -1
  74. package/dist/hooks/useMediaQuery.d.ts +27 -0
  75. package/dist/hooks/useMediaQuery.d.ts.map +1 -0
  76. package/dist/hooks/useMediaQuery.js +41 -0
  77. package/dist/hooks/useMediaQuery.js.map +1 -0
  78. package/dist/hooks/useShareLink.d.ts +58 -0
  79. package/dist/hooks/useShareLink.d.ts.map +1 -0
  80. package/dist/hooks/useShareLink.js +120 -0
  81. package/dist/hooks/useShareLink.js.map +1 -0
  82. package/dist/hooks/useTheme.d.ts +30 -0
  83. package/dist/hooks/useTheme.d.ts.map +1 -0
  84. package/dist/hooks/useTheme.js +105 -0
  85. package/dist/hooks/useTheme.js.map +1 -0
  86. package/dist/hooks/useWorkflowProgress.d.ts +28 -0
  87. package/dist/hooks/useWorkflowProgress.d.ts.map +1 -0
  88. package/dist/hooks/useWorkflowProgress.js +25 -0
  89. package/dist/hooks/useWorkflowProgress.js.map +1 -0
  90. package/dist/hooks/useWorkflows.d.ts +86 -0
  91. package/dist/hooks/useWorkflows.d.ts.map +1 -0
  92. package/dist/hooks/useWorkflows.js +247 -0
  93. package/dist/hooks/useWorkflows.js.map +1 -0
  94. package/dist/index.d.ts +26 -0
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +26 -0
  97. package/dist/index.js.map +1 -1
  98. package/dist/store/assets-slice.d.ts.map +1 -1
  99. package/dist/store/assets-slice.js +2 -0
  100. package/dist/store/assets-slice.js.map +1 -1
  101. package/dist/store/history-slice.d.ts +18 -0
  102. package/dist/store/history-slice.d.ts.map +1 -0
  103. package/dist/store/history-slice.js +42 -0
  104. package/dist/store/history-slice.js.map +1 -0
  105. package/dist/store/index.d.ts +1 -0
  106. package/dist/store/index.d.ts.map +1 -1
  107. package/dist/store/index.js +3 -0
  108. package/dist/store/index.js.map +1 -1
  109. package/dist/store/runtime-slice.d.ts +10 -2
  110. package/dist/store/runtime-slice.d.ts.map +1 -1
  111. package/dist/store/runtime-slice.js +77 -28
  112. package/dist/store/runtime-slice.js.map +1 -1
  113. package/dist/store/types.d.ts +70 -1
  114. package/dist/store/types.d.ts.map +1 -1
  115. package/dist/store/types.js +10 -0
  116. package/dist/store/types.js.map +1 -1
  117. package/dist/store/workflow-slice.d.ts +1 -1
  118. package/dist/store/workflow-slice.d.ts.map +1 -1
  119. package/dist/store/workflow-slice.js +128 -9
  120. package/dist/store/workflow-slice.js.map +1 -1
  121. package/package.json +9 -6
  122. package/src/components/AssetPanel.tsx +149 -8
  123. package/src/components/Canvas.tsx +79 -2
  124. package/src/components/CommandPalette.tsx +300 -0
  125. package/src/components/CompareSlider.tsx +186 -0
  126. package/src/components/DownloadPanel.tsx +252 -0
  127. package/src/components/ErrorBanner.tsx +95 -0
  128. package/src/components/ExifPanel.tsx +187 -0
  129. package/src/components/GlobalDropzone.tsx +220 -0
  130. package/src/components/HistoryPanel.tsx +231 -104
  131. package/src/components/Inspector.tsx +4 -0
  132. package/src/components/ProgressBar.tsx +104 -0
  133. package/src/components/StatusBar.tsx +124 -5
  134. package/src/components/ThemeToggle.tsx +122 -0
  135. package/src/components/WorkflowEditor.tsx +382 -0
  136. package/src/components/WorkflowTemplates.tsx +101 -0
  137. package/src/components/Workspace.tsx +205 -8
  138. package/src/data/workflow-templates.ts +137 -0
  139. package/src/hooks/useDebouncedRun.ts +151 -0
  140. package/src/hooks/useLokvis.ts +1 -1
  141. package/src/hooks/useMediaQuery.ts +53 -0
  142. package/src/hooks/useShareLink.ts +158 -0
  143. package/src/hooks/useTheme.ts +116 -0
  144. package/src/hooks/useWorkflowProgress.ts +41 -0
  145. package/src/hooks/useWorkflows.ts +326 -0
  146. package/src/index.ts +32 -0
  147. package/src/store/assets-slice.ts +2 -0
  148. package/src/store/history-slice.ts +65 -0
  149. package/src/store/index.ts +3 -0
  150. package/src/store/runtime-slice.ts +89 -32
  151. package/src/store/types.ts +71 -1
  152. package/src/store/workflow-slice.ts +139 -10
@@ -0,0 +1,186 @@
1
+ /**
2
+ * CompareSlider - before/after 对比滑块(W9.4)
3
+ *
4
+ * 把输入资产(原)与输出资产(处理后)叠加展示,通过可拖拽的分隔条左右揭示
5
+ * before / after 两张图。用于直观展示工作流处理效果。
6
+ *
7
+ * 交互:
8
+ * - 鼠标拖拽分隔条(或点击容器任意位置)调整揭示比例
9
+ * - 触摸:touchmove 同步
10
+ * - 键盘:← → 调整 5% 步长(聚焦分隔条时)
11
+ *
12
+ * 数据来源:
13
+ * - before: `store.selectedAssetId` 对应的 thumbnails(由 AssetPanel 自动生成)
14
+ * - after: `store.selectedOutputId` 对应的 thumbnails
15
+ * - 任一缺失则不渲染(返回 null)
16
+ *
17
+ * 注:本组件只负责"展示对比",不负责生成缩略图。缩略图由 AssetPanel
18
+ * 的 effect 统一生成并存入 store.thumbnails,本组件按 assetId 取用。
19
+ */
20
+
21
+ import * as React from 'react';
22
+ import { Icon } from '@lokvis/ui-core';
23
+ import { useWorkspaceStore } from '../store/index.js';
24
+
25
+ export interface CompareSliderProps {
26
+ className?: string;
27
+ }
28
+
29
+ export function CompareSlider({ className = '' }: CompareSliderProps) {
30
+ const assets = useWorkspaceStore((s) => s.assets);
31
+ const thumbnails = useWorkspaceStore((s) => s.thumbnails);
32
+ const selectedAssetId = useWorkspaceStore((s) => s.selectedAssetId);
33
+ const selectedOutputId = useWorkspaceStore((s) => s.selectedOutputId);
34
+ const lastOutputIds = useWorkspaceStore((s) => s.lastOutputIds);
35
+
36
+ // before:用户选中的输入资产;after:用户选中的输出资产(默认第一个)
37
+ const beforeId = selectedAssetId;
38
+ const afterId = selectedOutputId ?? lastOutputIds[0] ?? null;
39
+
40
+ const beforeAsset = beforeId ? assets.find((a) => a.id === beforeId) : null;
41
+ const afterAsset = afterId ? assets.find((a) => a.id === afterId) : null;
42
+ const beforeUrl = beforeId ? thumbnails[beforeId] : undefined;
43
+ const afterUrl = afterId ? thumbnails[afterId] : undefined;
44
+
45
+ const containerRef = React.useRef<HTMLDivElement>(null);
46
+ const [pos, setPos] = React.useState(50); // 0-100,分隔条位置百分比
47
+ const [dragging, setDragging] = React.useState(false);
48
+
49
+ // 切换资产时重置位置到中央
50
+ React.useEffect(() => {
51
+ setPos(50);
52
+ }, [beforeId, afterId]);
53
+
54
+ const updatePos = React.useCallback((clientX: number) => {
55
+ const el = containerRef.current;
56
+ if (!el) return;
57
+ const rect = el.getBoundingClientRect();
58
+ const ratio = (clientX - rect.left) / rect.width;
59
+ setPos(Math.max(0, Math.min(100, ratio * 100)));
60
+ }, []);
61
+
62
+ // 鼠标拖拽
63
+ const handleMouseDown = (e: React.MouseEvent) => {
64
+ e.preventDefault();
65
+ setDragging(true);
66
+ updatePos(e.clientX);
67
+ };
68
+ React.useEffect(() => {
69
+ if (!dragging) return;
70
+ const onMove = (e: MouseEvent) => updatePos(e.clientX);
71
+ const onUp = () => setDragging(false);
72
+ document.addEventListener('mousemove', onMove);
73
+ document.addEventListener('mouseup', onUp);
74
+ return () => {
75
+ document.removeEventListener('mousemove', onMove);
76
+ document.removeEventListener('mouseup', onUp);
77
+ };
78
+ }, [dragging, updatePos]);
79
+
80
+ // 触摸拖拽
81
+ const handleTouchStart = (e: React.TouchEvent) => {
82
+ setDragging(true);
83
+ if (e.touches[0]) updatePos(e.touches[0].clientX);
84
+ };
85
+ React.useEffect(() => {
86
+ if (!dragging) return;
87
+ const onMove = (e: TouchEvent) => {
88
+ if (e.touches[0]) updatePos(e.touches[0].clientX);
89
+ };
90
+ const onEnd = () => setDragging(false);
91
+ document.addEventListener('touchmove', onMove, { passive: true });
92
+ document.addEventListener('touchend', onEnd);
93
+ return () => {
94
+ document.removeEventListener('touchmove', onMove);
95
+ document.removeEventListener('touchend', onEnd);
96
+ };
97
+ }, [dragging, updatePos]);
98
+
99
+ // 键盘:← → 调整 5%
100
+ const handleKeyDown = (e: React.KeyboardEvent) => {
101
+ if (e.key === 'ArrowLeft') {
102
+ e.preventDefault();
103
+ setPos((p) => Math.max(0, p - 5));
104
+ } else if (e.key === 'ArrowRight') {
105
+ e.preventDefault();
106
+ setPos((p) => Math.min(100, p + 5));
107
+ }
108
+ };
109
+
110
+ if (!beforeUrl || !afterUrl) return null;
111
+
112
+ return (
113
+ <div
114
+ ref={containerRef}
115
+ className={`relative select-none overflow-hidden rounded-lg ring-1 ring-black/5 dark:ring-black/30 ${className}`}
116
+ onMouseDown={handleMouseDown}
117
+ onTouchStart={handleTouchStart}
118
+ >
119
+ {/* after 全图(底层)——决定容器尺寸 */}
120
+ <img
121
+ src={afterUrl}
122
+ alt="After"
123
+ draggable={false}
124
+ className="block max-h-full max-w-full object-contain pointer-events-none"
125
+ />
126
+
127
+ {/* before 覆盖层:绝对定位同尺寸,用 clip-path 从右侧裁剪到 pos%
128
+ (避免读取 containerRef.current.clientWidth 这种"渲染期读 ref"反模式) */}
129
+ <div
130
+ className="absolute inset-0"
131
+ style={{ clipPath: `inset(0 ${100 - pos}% 0 0)` }}
132
+ >
133
+ <img
134
+ src={beforeUrl}
135
+ alt="Before"
136
+ draggable={false}
137
+ className="block max-h-full max-w-full object-contain pointer-events-none"
138
+ />
139
+ </div>
140
+
141
+ {/* before/after 标签 */}
142
+ <span className="absolute top-2 left-2 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white">
143
+ Before
144
+ </span>
145
+ <span className="absolute top-2 right-2 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white">
146
+ After
147
+ </span>
148
+
149
+ {/* 分隔条 */}
150
+ <div
151
+ className="absolute top-0 bottom-0 z-10 cursor-ew-resize"
152
+ style={{ left: `${pos}%`, transform: 'translateX(-50%)' }}
153
+ role="separator"
154
+ aria-valuenow={Math.round(pos)}
155
+ aria-valuemin={0}
156
+ aria-valuemax={100}
157
+ aria-label="Comparison slider position"
158
+ tabIndex={0}
159
+ onKeyDown={handleKeyDown}
160
+ >
161
+ <div className="absolute inset-y-0 left-1/2 w-0.5 -translate-x-1/2 bg-white shadow-md" />
162
+ <div className="absolute top-1/2 left-1/2 flex h-8 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-white shadow-lg ring-1 ring-black/10">
163
+ <Icon size={14} className="text-zinc-600">
164
+ <path d="M9 5l-5 7 5 7M15 5l5 7-5 7" />
165
+ </Icon>
166
+ </div>
167
+ </div>
168
+
169
+ {/* 元数据对比(底部叠加) */}
170
+ {(beforeAsset || afterAsset) && (
171
+ <div className="absolute bottom-2 inset-x-2 flex justify-between text-[10px] text-white">
172
+ {beforeAsset?.metadata.dimensions && (
173
+ <span className="rounded bg-black/60 px-1.5 py-0.5">
174
+ {beforeAsset.metadata.dimensions.width}×{beforeAsset.metadata.dimensions.height}
175
+ </span>
176
+ )}
177
+ {afterAsset?.metadata.dimensions && (
178
+ <span className="rounded bg-black/60 px-1.5 py-0.5">
179
+ {afterAsset.metadata.dimensions.width}×{afterAsset.metadata.dimensions.height}
180
+ </span>
181
+ )}
182
+ </div>
183
+ )}
184
+ </div>
185
+ );
186
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * DownloadPanel - 工作流输出下载面板(W9.5)
3
+ *
4
+ * 展示上次工作流执行的输出资产,提供:
5
+ * - 单项下载(从 runtime.exportAsset 取 blob,触发浏览器下载)
6
+ * - 全部下载(逐项触发,BATCH_DOWNLOAD_DELAY_MS 间隔,避免浏览器拦截;长期方案见该常量注释)
7
+ * - 清空输出列表
8
+ *
9
+ * 与 apps/playground 的 DownloadManager(独立工具页)不同:本组件是 Workspace
10
+ * 内嵌的轻量面板,数据来自 store.lastOutputIds(由 workflow-slice.run 写入)。
11
+ *
12
+ * 下载命名:`lokvis-output-{assetId前8位}.{ext}`,简洁且避免覆盖。
13
+ * 不引入 JSZip(需新增依赖),用浏览器原生 downloadBlob 实现。
14
+ */
15
+
16
+ import * as React from 'react';
17
+ import { Icon } from '@lokvis/ui-core';
18
+ import { useWorkspaceStore } from '../store/index.js';
19
+
20
+ export interface DownloadPanelProps {
21
+ className?: string;
22
+ }
23
+
24
+ /** 格式化字节数 */
25
+ function formatBytes(bytes: number): string {
26
+ if (bytes < 1024) return `${bytes} B`;
27
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
28
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
29
+ }
30
+
31
+ /**
32
+ * 批量下载时每项之间的延迟毫秒数。
33
+ *
34
+ * 短期方案:浏览器(尤其 Chrome)会拦截同一 tick 内触发的多个 programmatic
35
+ * `<a download>` 点击,把第一个之后的视为"非用户手势"弹窗拦截。两次下载之间
36
+ * 插入小延迟可绕过该启发式判定。该值是经验值,不是规范要求 —— 不同浏览器
37
+ * 版本行为可能变化。
38
+ *
39
+ * 长期方案:用 JSZip 将多个输出打包成单个 ZIP 下载(只需一次用户手势)。
40
+ * 本组件暂未引入 JSZip 依赖(见文件头注释),待 M2 引入 archive capability 后
41
+ * 一并迁移,届时可移除此常量与 sleep 调用。
42
+ */
43
+ const BATCH_DOWNLOAD_DELAY_MS = 200;
44
+
45
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
46
+
47
+ /** 从扩展名或 mime 推断下载扩展名 */
48
+ function extFromMime(mime: string): string {
49
+ const sub = mime.split('/')[1] ?? 'bin';
50
+ // image/svg+xml → svg+xml → svg(取 + 前部分)
51
+ return sub.split('+')[0] ?? sub;
52
+ }
53
+
54
+ /** 触发浏览器下载 */
55
+ function downloadBlob(blob: Blob, filename: string): void {
56
+ const url = URL.createObjectURL(blob);
57
+ const a = document.createElement('a');
58
+ a.href = url;
59
+ a.download = filename;
60
+ document.body.appendChild(a);
61
+ a.click();
62
+ document.body.removeChild(a);
63
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
64
+ }
65
+
66
+ export function DownloadPanel({ className = '' }: DownloadPanelProps) {
67
+ const assets = useWorkspaceStore((s) => s.assets);
68
+ const lastOutputIds = useWorkspaceStore((s) => s.lastOutputIds);
69
+ const runtime = useWorkspaceStore((s) => s.runtime);
70
+ const clearOutputs = useWorkspaceStore((s) => s.clearOutputs);
71
+ const setStatus = useWorkspaceStore((s) => s.setStatus);
72
+
73
+ const [downloaded, setDownloaded] = React.useState<Set<string>>(new Set());
74
+ const [batchDownloading, setBatchDownloading] = React.useState(false);
75
+ const [error, setError] = React.useState<string | null>(null);
76
+
77
+ // 卸载标志:批量下载是异步循环,卸载后不应再 setState
78
+ const mountedRef = React.useRef(true);
79
+ React.useEffect(() => {
80
+ mountedRef.current = true;
81
+ return () => {
82
+ mountedRef.current = false;
83
+ };
84
+ }, []);
85
+
86
+ // 输出资产列表(从 store.assets 中按 lastOutputIds 顺序取)
87
+ const outputAssets = React.useMemo(() => {
88
+ return lastOutputIds
89
+ .map((id) => assets.find((a) => a.id === id))
90
+ .filter((a): a is NonNullable<typeof a> => a !== undefined);
91
+ }, [lastOutputIds, assets]);
92
+
93
+ // outputs 变化时清空 downloaded 状态
94
+ React.useEffect(() => {
95
+ setDownloaded(new Set());
96
+ setError(null);
97
+ }, [lastOutputIds]);
98
+
99
+ const totalSize = outputAssets.reduce((sum, a) => sum + a.metadata.size, 0);
100
+ const downloadedCount = outputAssets.filter((a) => downloaded.has(a.id)).length;
101
+ const allDownloaded = outputAssets.length > 0 && downloadedCount === outputAssets.length;
102
+
103
+ async function handleDownloadOne(id: string) {
104
+ if (!runtime) return;
105
+ const asset = outputAssets.find((a) => a.id === id);
106
+ if (!asset) return;
107
+ try {
108
+ const blob = await runtime.exportAsset(id);
109
+ const ext = extFromMime(asset.metadata.mimeType);
110
+ const filename = `lokvis-output-${asset.id.slice(0, 8)}.${ext}`;
111
+ downloadBlob(blob, filename);
112
+ if (mountedRef.current) {
113
+ setDownloaded((prev) => new Set(prev).add(id));
114
+ }
115
+ } catch (err) {
116
+ if (mountedRef.current) {
117
+ setError(err instanceof Error ? err.message : String(err));
118
+ }
119
+ }
120
+ }
121
+
122
+ async function handleDownloadAll() {
123
+ if (!runtime || batchDownloading) return;
124
+ setBatchDownloading(true);
125
+ setError(null);
126
+ setStatus('Downloading outputs...');
127
+ // 用本地 Set 跟踪本次循环已下载 id,避免 downloaded 闭包快照与 state 不同步
128
+ // (review 反馈:循环中 setDownloaded 更新 state 但闭包 downloaded 不变,
129
+ // 虽有 batchDownloading guard 阻止并发,逻辑上仍应读取最新已下载集合)
130
+ const localDownloaded = new Set(downloaded);
131
+ try {
132
+ for (const asset of outputAssets) {
133
+ // 卸载后立即停止循环(避免对已卸载组件 setState)
134
+ if (!mountedRef.current) return;
135
+ if (localDownloaded.has(asset.id)) continue;
136
+ try {
137
+ const blob = await runtime.exportAsset(asset.id);
138
+ const ext = extFromMime(asset.metadata.mimeType);
139
+ const filename = `lokvis-output-${asset.id.slice(0, 8)}.${ext}`;
140
+ downloadBlob(blob, filename);
141
+ localDownloaded.add(asset.id);
142
+ if (mountedRef.current) {
143
+ setDownloaded((prev) => new Set(prev).add(asset.id));
144
+ }
145
+ await sleep(BATCH_DOWNLOAD_DELAY_MS);
146
+ } catch (err) {
147
+ if (mountedRef.current) {
148
+ setError(err instanceof Error ? err.message : String(err));
149
+ }
150
+ // 单项失败不阻断后续
151
+ }
152
+ }
153
+ if (mountedRef.current) {
154
+ setStatus('Downloads complete');
155
+ }
156
+ } finally {
157
+ if (mountedRef.current) {
158
+ setBatchDownloading(false);
159
+ }
160
+ }
161
+ }
162
+
163
+ function handleClear() {
164
+ clearOutputs();
165
+ setDownloaded(new Set());
166
+ }
167
+
168
+ if (outputAssets.length === 0) return null;
169
+
170
+ return (
171
+ <div
172
+ className={`flex flex-col border-t border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900 ${className}`}
173
+ aria-label="Outputs panel"
174
+ >
175
+ <div className="flex items-center justify-between px-3 h-9 shrink-0 border-b border-zinc-200 dark:border-zinc-800">
176
+ <span className="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
177
+ Outputs
178
+ </span>
179
+ <span className="text-[11px] tabular-nums text-zinc-400">
180
+ {downloadedCount}/{outputAssets.length} · {formatBytes(totalSize)}
181
+ </span>
182
+ </div>
183
+
184
+ <div className="flex items-center gap-2 px-3 py-2 overflow-x-auto">
185
+ {outputAssets.map((asset) => {
186
+ const isDownloaded = downloaded.has(asset.id);
187
+ const dims = asset.metadata.dimensions;
188
+ return (
189
+ <div
190
+ key={asset.id}
191
+ className="flex shrink-0 flex-col gap-1 rounded-md border border-zinc-200 p-2 dark:border-zinc-700"
192
+ >
193
+ <div className="flex items-center gap-2">
194
+ <span className="rounded bg-zinc-100 px-1 py-0.5 text-[9px] font-medium text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
195
+ {asset.metadata.format.toUpperCase()}
196
+ </span>
197
+ {dims && (
198
+ <span className="text-[10px] text-zinc-500 dark:text-zinc-400 tabular-nums">
199
+ {dims.width}×{dims.height}
200
+ </span>
201
+ )}
202
+ <span className="text-[10px] text-zinc-400 tabular-nums">
203
+ {formatBytes(asset.metadata.size)}
204
+ </span>
205
+ </div>
206
+ <div className="flex items-center gap-1">
207
+ <button
208
+ type="button"
209
+ onClick={() => void handleDownloadOne(asset.id)}
210
+ className={`flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-medium transition-colors ${
211
+ isDownloaded
212
+ ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400'
213
+ : 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-950/30 dark:text-indigo-300 dark:hover:bg-indigo-900/40'
214
+ }`}
215
+ >
216
+ <Icon size={10} strokeWidth={2}>
217
+ <path d="M12 3v12m0 0l-4-4m4 4l4-4M3 17v2a2 2 0 002 2h14a2 2 0 002-2v-2" />
218
+ </Icon>
219
+ {isDownloaded ? 'Downloaded' : 'Download'}
220
+ </button>
221
+ </div>
222
+ </div>
223
+ );
224
+ })}
225
+
226
+ <div className="flex shrink-0 items-center gap-1">
227
+ <button
228
+ type="button"
229
+ onClick={() => void handleDownloadAll()}
230
+ disabled={batchDownloading || allDownloaded}
231
+ className="rounded-md bg-indigo-600 px-2.5 py-1 text-[10px] font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-50"
232
+ >
233
+ {batchDownloading ? 'Downloading...' : 'Download All'}
234
+ </button>
235
+ <button
236
+ type="button"
237
+ onClick={handleClear}
238
+ className="rounded-md px-2 py-1 text-[10px] text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
239
+ >
240
+ Clear
241
+ </button>
242
+ </div>
243
+ </div>
244
+
245
+ {error && (
246
+ <p className="px-3 pb-2 text-[10px] text-red-500" role="alert">
247
+ {error}
248
+ </p>
249
+ )}
250
+ </div>
251
+ );
252
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * ErrorBanner - 错误信息横幅(W11.3)
3
+ *
4
+ * 当 store.error 非空时显示,展示错误信息并提供:
5
+ * - 关闭按钮(清除 error)
6
+ * - 重试按钮(重新执行工作流)
7
+ *
8
+ * 节点失败高亮由 WorkflowEditor 的节点状态颜色处理(已实现);
9
+ * 本组件负责全局错误信息的可见性。
10
+ *
11
+ * 显式错误计数器作为可见性派生依据:
12
+ * store.error 是字符串,相同错误多次设置时引用不变,useEffect 依赖 [error] 不会触发。
13
+ * 改为依赖 errorSeq(error 出现的序号),每次 setError(非 null)都递增,确保 banner 重新弹出。
14
+ *
15
+ * @module ErrorBanner
16
+ */
17
+
18
+ import * as React from 'react';
19
+ import { Icon } from '@lokvis/ui-core';
20
+ import { useWorkspaceStore } from '../store/index.js';
21
+
22
+ export interface ErrorBannerProps {
23
+ className?: string;
24
+ }
25
+
26
+ export function ErrorBanner({ className = '' }: ErrorBannerProps) {
27
+ const error = useWorkspaceStore((s) => s.error);
28
+ const errorSeq = useWorkspaceStore((s) => s.errorSeq);
29
+ const setError = useWorkspaceStore((s) => s.setError);
30
+ const run = useWorkspaceStore((s) => s.run);
31
+ const nodes = useWorkspaceStore((s) => s.nodes);
32
+ const running = useWorkspaceStore((s) => s.running);
33
+
34
+ // 用户关闭后隐藏;新错误(errorSeq 变化)时重新显示。
35
+ // 用 errorSeq 而非 error 字符串本身:store.run() 在 setError(null) 之后才设新 error,
36
+ // errorSeq 在 setError(非 null) 时递增,即使错误消息相同也能感知到"新的错误事件"。
37
+ const [dismissedSeq, setDismissedSeq] = React.useState<number | null>(null);
38
+
39
+ const visible = error !== null && dismissedSeq !== errorSeq;
40
+
41
+ const handleDismiss = React.useCallback(() => {
42
+ setDismissedSeq(errorSeq);
43
+ setError(null);
44
+ }, [errorSeq, setError]);
45
+
46
+ const handleRetry = React.useCallback(() => {
47
+ // 先清 error 再 run,避免 run 同步抛错时 banner 已隐藏且 errorSeq 未变化
48
+ setError(null);
49
+ void run();
50
+ }, [setError, run]);
51
+
52
+ if (!visible || !error) return null;
53
+
54
+ const canRetry = nodes.length > 0 && !running;
55
+
56
+ return (
57
+ <div
58
+ className={`flex items-start gap-2 border-b border-red-200 bg-red-50 px-3 py-2 dark:border-red-800/50 dark:bg-red-950/20 ${className}`}
59
+ role="alert"
60
+ >
61
+ <Icon size={14} className="mt-0.5 shrink-0 text-red-500">
62
+ <circle cx="12" cy="12" r="10" />
63
+ <line x1="12" y1="8" x2="12" y2="12" />
64
+ <line x1="12" y1="16" x2="12.01" y2="16" />
65
+ </Icon>
66
+ <div className="flex-1 min-w-0">
67
+ <p className="text-[11px] font-medium text-red-700 dark:text-red-400">
68
+ 执行出错
69
+ </p>
70
+ <p className="mt-0.5 break-words text-[10px] text-red-600/80 dark:text-red-400/70">
71
+ {error}
72
+ </p>
73
+ </div>
74
+ <div className="flex shrink-0 items-center gap-1">
75
+ {canRetry && (
76
+ <button
77
+ type="button"
78
+ onClick={handleRetry}
79
+ className="rounded bg-red-100 px-2 py-0.5 text-[10px] font-medium text-red-700 transition-colors hover:bg-red-200 dark:bg-red-900/40 dark:text-red-400 dark:hover:bg-red-900/60"
80
+ >
81
+ 重试
82
+ </button>
83
+ )}
84
+ <button
85
+ type="button"
86
+ onClick={handleDismiss}
87
+ aria-label="关闭错误信息"
88
+ className="rounded p-0.5 text-red-400 transition-colors hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/40"
89
+ >
90
+ <Icon size={12} strokeWidth={3}><path d="M6 18L18 6M6 6l12 12" /></Icon>
91
+ </button>
92
+ </div>
93
+ </div>
94
+ );
95
+ }