@lokvis/ui-react 0.2.2 → 0.4.1
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.
- package/dist/components/AssetPanel.d.ts.map +1 -1
- package/dist/components/AssetPanel.js +10 -54
- package/dist/components/AssetPanel.js.map +1 -1
- package/dist/components/Canvas.d.ts.map +1 -1
- package/dist/components/Canvas.js +4 -4
- package/dist/components/Canvas.js.map +1 -1
- package/dist/components/CompareSlider.d.ts +4 -3
- package/dist/components/CompareSlider.d.ts.map +1 -1
- package/dist/components/CompareSlider.js +5 -4
- package/dist/components/CompareSlider.js.map +1 -1
- package/dist/components/DownloadPanel.d.ts.map +1 -1
- package/dist/components/DownloadPanel.js +1 -8
- package/dist/components/DownloadPanel.js.map +1 -1
- package/dist/components/ErrorBoundary.d.ts +40 -0
- package/dist/components/ErrorBoundary.d.ts.map +1 -0
- package/dist/components/ErrorBoundary.js +52 -0
- package/dist/components/ErrorBoundary.js.map +1 -0
- package/dist/components/ExifPanel.js +1 -1
- package/dist/components/ExifPanel.js.map +1 -1
- package/dist/components/GlobalDropzone.d.ts.map +1 -1
- package/dist/components/GlobalDropzone.js +10 -3
- package/dist/components/GlobalDropzone.js.map +1 -1
- package/dist/components/Inspector.d.ts.map +1 -1
- package/dist/components/Inspector.js +10 -7
- package/dist/components/Inspector.js.map +1 -1
- package/dist/components/ParamForm.d.ts.map +1 -1
- package/dist/components/ParamForm.js +8 -5
- package/dist/components/ParamForm.js.map +1 -1
- package/dist/components/PipelineBar.d.ts.map +1 -1
- package/dist/components/PipelineBar.js +17 -26
- package/dist/components/PipelineBar.js.map +1 -1
- package/dist/components/StatusBar.d.ts.map +1 -1
- package/dist/components/StatusBar.js +3 -12
- package/dist/components/StatusBar.js.map +1 -1
- package/dist/components/StatusDot.d.ts +12 -0
- package/dist/components/StatusDot.d.ts.map +1 -0
- package/dist/components/StatusDot.js +15 -0
- package/dist/components/StatusDot.js.map +1 -0
- package/dist/components/ThemeToggle.js +1 -1
- package/dist/components/ThemeToggle.js.map +1 -1
- package/dist/components/ToolRunner.d.ts +53 -0
- package/dist/components/ToolRunner.d.ts.map +1 -0
- package/dist/components/ToolRunner.js +142 -0
- package/dist/components/ToolRunner.js.map +1 -0
- package/dist/components/Toolbar.js +2 -2
- package/dist/components/Toolbar.js.map +1 -1
- package/dist/components/WorkflowEditor.d.ts.map +1 -1
- package/dist/components/WorkflowEditor.js +31 -40
- package/dist/components/WorkflowEditor.js.map +1 -1
- package/dist/components/WorkflowTemplates.d.ts.map +1 -1
- package/dist/components/WorkflowTemplates.js +15 -5
- package/dist/components/WorkflowTemplates.js.map +1 -1
- package/dist/components/Workspace.d.ts +23 -1
- package/dist/components/Workspace.d.ts.map +1 -1
- package/dist/components/Workspace.js +60 -13
- package/dist/components/Workspace.js.map +1 -1
- package/dist/hooks/useCustomPresets.d.ts +98 -0
- package/dist/hooks/useCustomPresets.d.ts.map +1 -0
- package/dist/hooks/useCustomPresets.js +210 -0
- package/dist/hooks/useCustomPresets.js.map +1 -0
- package/dist/hooks/useLokvis.d.ts +19 -1
- package/dist/hooks/useLokvis.d.ts.map +1 -1
- package/dist/hooks/useLokvis.js +30 -9
- package/dist/hooks/useLokvis.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/store/assets-slice.d.ts +8 -1
- package/dist/store/assets-slice.d.ts.map +1 -1
- package/dist/store/assets-slice.js +101 -54
- package/dist/store/assets-slice.js.map +1 -1
- package/dist/store/capabilities-slice.d.ts +1 -1
- package/dist/store/capabilities-slice.d.ts.map +1 -1
- package/dist/store/capabilities-slice.js +8 -1
- package/dist/store/capabilities-slice.js.map +1 -1
- package/dist/store/index.d.ts +1 -1
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +1 -1
- package/dist/store/index.js.map +1 -1
- package/dist/store/runtime-slice.d.ts.map +1 -1
- package/dist/store/runtime-slice.js +22 -16
- package/dist/store/runtime-slice.js.map +1 -1
- package/dist/store/subscribe-utils.d.ts +45 -0
- package/dist/store/subscribe-utils.d.ts.map +1 -0
- package/dist/store/subscribe-utils.js +69 -0
- package/dist/store/subscribe-utils.js.map +1 -0
- package/dist/store/types.d.ts +19 -11
- package/dist/store/types.d.ts.map +1 -1
- package/dist/store/types.js +0 -10
- package/dist/store/types.js.map +1 -1
- package/dist/store/workflow-slice.d.ts +1 -1
- package/dist/store/workflow-slice.d.ts.map +1 -1
- package/dist/store/workflow-slice.js +19 -51
- package/dist/store/workflow-slice.js.map +1 -1
- package/dist/utils.d.ts +24 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +31 -0
- package/dist/utils.js.map +1 -0
- package/package.json +7 -6
- package/src/components/AssetPanel.tsx +18 -58
- package/src/components/Canvas.tsx +11 -7
- package/src/components/CompareSlider.tsx +13 -10
- package/src/components/DownloadPanel.tsx +1 -7
- package/src/components/ErrorBoundary.tsx +101 -0
- package/src/components/ExifPanel.tsx +1 -1
- package/src/components/GlobalDropzone.tsx +8 -3
- package/src/components/Inspector.tsx +24 -16
- package/src/components/ParamForm.tsx +12 -9
- package/src/components/PipelineBar.tsx +20 -27
- package/src/components/StatusBar.tsx +3 -10
- package/src/components/StatusDot.tsx +27 -0
- package/src/components/ThemeToggle.tsx +1 -1
- package/src/components/ToolRunner.tsx +352 -0
- package/src/components/Toolbar.tsx +2 -2
- package/src/components/WorkflowEditor.tsx +50 -43
- package/src/components/WorkflowTemplates.tsx +27 -4
- package/src/components/Workspace.tsx +97 -10
- package/src/hooks/useCustomPresets.ts +284 -0
- package/src/hooks/useLokvis.ts +43 -10
- package/src/index.ts +3 -0
- package/src/store/assets-slice.ts +106 -52
- package/src/store/capabilities-slice.ts +10 -2
- package/src/store/index.ts +1 -1
- package/src/store/runtime-slice.ts +20 -15
- package/src/store/subscribe-utils.ts +82 -0
- package/src/store/types.ts +19 -12
- package/src/store/workflow-slice.ts +19 -53
- package/src/utils.ts +41 -0
- package/dist/store.d.ts +0 -76
- package/dist/store.d.ts.map +0 -1
- package/dist/store.js +0 -217
- package/dist/store.js.map +0 -1
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ToolRunner - 单工具页组合组件
|
|
3
|
+
*
|
|
4
|
+
* 为单工具操作场景(压缩、裁剪、格式转换等)提供一站式 UI:
|
|
5
|
+
* 上传 → 调参 → 处理 → 对比 → 下载。
|
|
6
|
+
*
|
|
7
|
+
* 不依赖 Workspace Store(zustand),通过 engine prop 注入处理函数,
|
|
8
|
+
* 可在任何 React 应用中独立使用。
|
|
9
|
+
*
|
|
10
|
+
* 五层架构:UI 层不直接依赖 Engine 层,engine 函数由消费方传入。
|
|
11
|
+
* 消费方需自行实现按 capability.name 路由到对应 Blob↔Blob 操作的 wrapper
|
|
12
|
+
* (engine-image 已不再提供 runTool 统一入口,各 operation 需直接组合调用)。
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* import { ToolRunner } from '@lokvis/ui-react';
|
|
17
|
+
* import { IMAGE_COMPRESS, compress } from '@lokvis/engine-image';
|
|
18
|
+
*
|
|
19
|
+
* <ToolRunner
|
|
20
|
+
* capability={IMAGE_COMPRESS}
|
|
21
|
+
* engine={(name, input, params, opts) => compress(input, params, opts?.signal)}
|
|
22
|
+
* onOpenInWorkspace={(state) => { ... }}
|
|
23
|
+
* />
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import * as React from 'react';
|
|
28
|
+
import type { Capability } from '@lokvis/schema';
|
|
29
|
+
import { Button, Card } from '@lokvis/ui-core';
|
|
30
|
+
import { ParamForm } from './ParamForm.js';
|
|
31
|
+
|
|
32
|
+
type EngineFn = (
|
|
33
|
+
toolName: string,
|
|
34
|
+
input: Blob,
|
|
35
|
+
params: Record<string, unknown>,
|
|
36
|
+
options?: { signal?: AbortSignal; onProgress?: (p: number) => void },
|
|
37
|
+
) => Promise<Blob>;
|
|
38
|
+
|
|
39
|
+
export interface ToolRunnerProps {
|
|
40
|
+
/** 工具能力定义(决定参数表单和输入类型) */
|
|
41
|
+
capability: Capability;
|
|
42
|
+
/** 处理函数(按 capability.name 路由到对应 Blob↔Blob 操作的 wrapper) */
|
|
43
|
+
engine: EngineFn;
|
|
44
|
+
/** 在 Workspace 中打开的回调(携带当前状态) */
|
|
45
|
+
onOpenInWorkspace?: (state: {
|
|
46
|
+
inputBlob: Blob;
|
|
47
|
+
outputBlob: Blob | null;
|
|
48
|
+
params: Record<string, unknown>;
|
|
49
|
+
}) => void;
|
|
50
|
+
className?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* ToolRunner - 单工具操作组件。
|
|
55
|
+
*
|
|
56
|
+
* 内部组合:上传区 + 参数表单 + 预览/对比 + 进度 + 下载。
|
|
57
|
+
* 所有状态自管理,不依赖外部 store。
|
|
58
|
+
*/
|
|
59
|
+
export function ToolRunner({
|
|
60
|
+
capability,
|
|
61
|
+
engine,
|
|
62
|
+
onOpenInWorkspace,
|
|
63
|
+
className = '',
|
|
64
|
+
}: ToolRunnerProps) {
|
|
65
|
+
const [inputBlob, setInputBlob] = React.useState<Blob | null>(null);
|
|
66
|
+
const [inputUrl, setInputUrl] = React.useState<string | null>(null);
|
|
67
|
+
const [outputBlob, setOutputBlob] = React.useState<Blob | null>(null);
|
|
68
|
+
const [outputUrl, setOutputUrl] = React.useState<string | null>(null);
|
|
69
|
+
const [params, setParams] = React.useState<Record<string, unknown>>(() => {
|
|
70
|
+
const defaults: Record<string, unknown> = {};
|
|
71
|
+
for (const p of capability.params) {
|
|
72
|
+
if (p.default !== undefined) defaults[p.name] = p.default;
|
|
73
|
+
}
|
|
74
|
+
return defaults;
|
|
75
|
+
});
|
|
76
|
+
const [progress, setProgress] = React.useState(0);
|
|
77
|
+
const [processing, setProcessing] = React.useState(false);
|
|
78
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
79
|
+
const [dragging, setDragging] = React.useState(false);
|
|
80
|
+
const abortRef = React.useRef<AbortController | null>(null);
|
|
81
|
+
|
|
82
|
+
// 清理 ObjectURL
|
|
83
|
+
React.useEffect(() => {
|
|
84
|
+
return () => {
|
|
85
|
+
if (inputUrl) URL.revokeObjectURL(inputUrl);
|
|
86
|
+
if (outputUrl) URL.revokeObjectURL(outputUrl);
|
|
87
|
+
};
|
|
88
|
+
}, [inputUrl, outputUrl]);
|
|
89
|
+
|
|
90
|
+
// W21.6: unmount 时 abort 进行中的处理,防止后台泄漏
|
|
91
|
+
// (engine 函数持有 Worker/Canvas 资源,卸载后继续运行是浪费 + 潜在内存泄漏)
|
|
92
|
+
React.useEffect(() => {
|
|
93
|
+
return () => {
|
|
94
|
+
if (abortRef.current) {
|
|
95
|
+
abortRef.current.abort();
|
|
96
|
+
abortRef.current = null;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
const accept = capability.inputTypes.join(',');
|
|
102
|
+
|
|
103
|
+
function handleFiles(files: FileList | File[] | null) {
|
|
104
|
+
if (!files || files.length === 0) return;
|
|
105
|
+
const file = files[0];
|
|
106
|
+
if (!file) return;
|
|
107
|
+
|
|
108
|
+
setInputBlob(file);
|
|
109
|
+
if (inputUrl) URL.revokeObjectURL(inputUrl);
|
|
110
|
+
setInputUrl(URL.createObjectURL(file));
|
|
111
|
+
|
|
112
|
+
// 重置输出
|
|
113
|
+
setOutputBlob(null);
|
|
114
|
+
if (outputUrl) {
|
|
115
|
+
URL.revokeObjectURL(outputUrl);
|
|
116
|
+
setOutputUrl(null);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function handleProcess() {
|
|
121
|
+
if (!inputBlob) return;
|
|
122
|
+
setProcessing(true);
|
|
123
|
+
setProgress(0);
|
|
124
|
+
setError(null);
|
|
125
|
+
|
|
126
|
+
abortRef.current = new AbortController();
|
|
127
|
+
try {
|
|
128
|
+
const result = await engine(capability.name, inputBlob, params, {
|
|
129
|
+
signal: abortRef.current.signal,
|
|
130
|
+
onProgress: setProgress,
|
|
131
|
+
});
|
|
132
|
+
setOutputBlob(result);
|
|
133
|
+
if (outputUrl) URL.revokeObjectURL(outputUrl);
|
|
134
|
+
setOutputUrl(URL.createObjectURL(result));
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (err instanceof Error && err.name === 'AbortError') return;
|
|
137
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
138
|
+
} finally {
|
|
139
|
+
setProcessing(false);
|
|
140
|
+
abortRef.current = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function handleCancel() {
|
|
145
|
+
abortRef.current?.abort();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const downloadName = outputBlob
|
|
149
|
+
? `output.${outputBlob.type.split('/')[1] ?? 'bin'}`
|
|
150
|
+
: 'output';
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div className={`flex flex-col gap-6 ${className}`}>
|
|
154
|
+
{/* Upload zone */}
|
|
155
|
+
{!inputBlob && (
|
|
156
|
+
<label
|
|
157
|
+
className={`block cursor-pointer rounded-xl border-2 border-dashed p-10 text-center transition-all ${
|
|
158
|
+
dragging
|
|
159
|
+
? 'border-[var(--lokvis-primary)] bg-[var(--lokvis-primary)]/10'
|
|
160
|
+
: 'border-[var(--lokvis-border)] hover:border-[var(--lokvis-primary)]/50 hover:bg-[var(--lokvis-surface-muted)]'
|
|
161
|
+
}`}
|
|
162
|
+
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
|
163
|
+
onDragLeave={() => setDragging(false)}
|
|
164
|
+
onDrop={(e) => {
|
|
165
|
+
e.preventDefault();
|
|
166
|
+
setDragging(false);
|
|
167
|
+
handleFiles(e.dataTransfer.files);
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
<input
|
|
171
|
+
type="file"
|
|
172
|
+
accept={accept}
|
|
173
|
+
className="hidden"
|
|
174
|
+
onChange={(e) => handleFiles(e.target.files)}
|
|
175
|
+
/>
|
|
176
|
+
<div className="flex flex-col items-center gap-3">
|
|
177
|
+
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--lokvis-surface-muted)]">
|
|
178
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-[var(--lokvis-fg-subtle)]">
|
|
179
|
+
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
|
180
|
+
<polyline points="17 8 12 3 7 8" />
|
|
181
|
+
<line x1="12" y1="3" x2="12" y2="15" />
|
|
182
|
+
</svg>
|
|
183
|
+
</div>
|
|
184
|
+
<div>
|
|
185
|
+
<p className="text-sm font-medium text-[var(--lokvis-fg)]">
|
|
186
|
+
Drop file here or click to upload
|
|
187
|
+
</p>
|
|
188
|
+
<p className="mt-1 text-xs text-[var(--lokvis-fg-subtle)]">
|
|
189
|
+
Accepted: {accept || 'any file'}
|
|
190
|
+
</p>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
</label>
|
|
194
|
+
)}
|
|
195
|
+
|
|
196
|
+
{/* Processing area */}
|
|
197
|
+
{inputBlob && (
|
|
198
|
+
<div className="grid gap-6 md:grid-cols-2">
|
|
199
|
+
{/* Left: Parameters */}
|
|
200
|
+
<div className="space-y-4">
|
|
201
|
+
<Card>
|
|
202
|
+
<div className="mb-3 flex items-center justify-between">
|
|
203
|
+
<h3 className="text-sm font-semibold text-[var(--lokvis-fg)]">
|
|
204
|
+
Parameters
|
|
205
|
+
</h3>
|
|
206
|
+
<span className="rounded bg-[var(--lokvis-surface-muted)] px-2 py-0.5 font-mono text-xs text-[var(--lokvis-primary)]">
|
|
207
|
+
{capability.name}
|
|
208
|
+
</span>
|
|
209
|
+
</div>
|
|
210
|
+
<ParamForm
|
|
211
|
+
capability={capability}
|
|
212
|
+
values={params}
|
|
213
|
+
onChange={setParams}
|
|
214
|
+
/>
|
|
215
|
+
</Card>
|
|
216
|
+
|
|
217
|
+
{/* Actions */}
|
|
218
|
+
<div className="flex gap-3">
|
|
219
|
+
<Button
|
|
220
|
+
variant="primary"
|
|
221
|
+
size="md"
|
|
222
|
+
onClick={handleProcess}
|
|
223
|
+
loading={processing}
|
|
224
|
+
disabled={!inputBlob}
|
|
225
|
+
>
|
|
226
|
+
{processing ? 'Processing...' : 'Process'}
|
|
227
|
+
</Button>
|
|
228
|
+
<Button
|
|
229
|
+
variant="secondary"
|
|
230
|
+
size="md"
|
|
231
|
+
onClick={() => {
|
|
232
|
+
setInputBlob(null);
|
|
233
|
+
if (inputUrl) URL.revokeObjectURL(inputUrl);
|
|
234
|
+
setInputUrl(null);
|
|
235
|
+
setOutputBlob(null);
|
|
236
|
+
if (outputUrl) {
|
|
237
|
+
URL.revokeObjectURL(outputUrl);
|
|
238
|
+
setOutputUrl(null);
|
|
239
|
+
}
|
|
240
|
+
}}
|
|
241
|
+
>
|
|
242
|
+
Change file
|
|
243
|
+
</Button>
|
|
244
|
+
{onOpenInWorkspace && (
|
|
245
|
+
<Button
|
|
246
|
+
variant="ghost"
|
|
247
|
+
size="md"
|
|
248
|
+
onClick={() => onOpenInWorkspace({ inputBlob, outputBlob, params })}
|
|
249
|
+
>
|
|
250
|
+
Open in Workspace →
|
|
251
|
+
</Button>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
{/* Progress */}
|
|
256
|
+
{processing && (
|
|
257
|
+
<div className="space-y-2">
|
|
258
|
+
<div className="flex items-center justify-between text-xs text-[var(--lokvis-fg-muted)]">
|
|
259
|
+
<span>Processing...</span>
|
|
260
|
+
<span className="tabular-nums">{Math.round(progress)}%</span>
|
|
261
|
+
</div>
|
|
262
|
+
<div className="h-1.5 overflow-hidden rounded-full bg-[var(--lokvis-border)]">
|
|
263
|
+
<div
|
|
264
|
+
className="h-full rounded-full bg-[var(--lokvis-primary)] transition-all duration-200"
|
|
265
|
+
style={{ width: `${progress}%` }}
|
|
266
|
+
/>
|
|
267
|
+
</div>
|
|
268
|
+
<button
|
|
269
|
+
type="button"
|
|
270
|
+
onClick={handleCancel}
|
|
271
|
+
className="text-xs text-[var(--lokvis-danger)] hover:underline"
|
|
272
|
+
>
|
|
273
|
+
Cancel
|
|
274
|
+
</button>
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
277
|
+
|
|
278
|
+
{/* Error */}
|
|
279
|
+
{error && (
|
|
280
|
+
<div className="rounded-lg border border-[var(--lokvis-danger)]/30 bg-[var(--lokvis-danger)]/10 px-4 py-3 text-sm text-[var(--lokvis-danger)]">
|
|
281
|
+
{error}
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
{/* Download */}
|
|
286
|
+
{outputBlob && outputUrl && (
|
|
287
|
+
<Card>
|
|
288
|
+
<div className="flex items-center justify-between">
|
|
289
|
+
<div>
|
|
290
|
+
<p className="text-sm font-medium text-[var(--lokvis-fg)]">Ready to download</p>
|
|
291
|
+
<p className="mt-1 text-xs text-[var(--lokvis-fg-muted)]">
|
|
292
|
+
{(outputBlob.size / 1024).toFixed(1)} KB · {outputBlob.type.split('/')[1] ?? 'file'}
|
|
293
|
+
</p>
|
|
294
|
+
</div>
|
|
295
|
+
<a
|
|
296
|
+
href={outputUrl}
|
|
297
|
+
download={downloadName}
|
|
298
|
+
className="inline-flex items-center gap-1.5 rounded-lg bg-[var(--lokvis-primary)] px-4 py-2 text-sm font-medium text-[var(--lokvis-primary-fg)] transition-colors hover:bg-[var(--lokvis-primary-hover)]"
|
|
299
|
+
>
|
|
300
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
301
|
+
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
|
302
|
+
<polyline points="7 10 12 15 17 10" />
|
|
303
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
304
|
+
</svg>
|
|
305
|
+
Download
|
|
306
|
+
</a>
|
|
307
|
+
</div>
|
|
308
|
+
</Card>
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
{/* Right: Preview */}
|
|
313
|
+
<div className="space-y-4">
|
|
314
|
+
{/* Input preview */}
|
|
315
|
+
{inputUrl && (
|
|
316
|
+
<Card>
|
|
317
|
+
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--lokvis-fg-subtle)]">
|
|
318
|
+
Input
|
|
319
|
+
</h4>
|
|
320
|
+
<img
|
|
321
|
+
src={inputUrl}
|
|
322
|
+
alt="Input preview"
|
|
323
|
+
className="max-h-80 w-full rounded-lg object-contain"
|
|
324
|
+
/>
|
|
325
|
+
<p className="mt-2 text-xs text-[var(--lokvis-fg-muted)]">
|
|
326
|
+
{(inputBlob.size / 1024).toFixed(1)} KB · {inputBlob.type.split('/')[1] ?? 'file'}
|
|
327
|
+
</p>
|
|
328
|
+
</Card>
|
|
329
|
+
)}
|
|
330
|
+
|
|
331
|
+
{/* Output preview */}
|
|
332
|
+
{outputUrl && (
|
|
333
|
+
<Card>
|
|
334
|
+
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--lokvis-fg-subtle)]">
|
|
335
|
+
Output
|
|
336
|
+
</h4>
|
|
337
|
+
<img
|
|
338
|
+
src={outputUrl}
|
|
339
|
+
alt="Output preview"
|
|
340
|
+
className="max-h-80 w-full rounded-lg object-contain"
|
|
341
|
+
/>
|
|
342
|
+
<p className="mt-2 text-xs text-[var(--lokvis-fg-muted)]">
|
|
343
|
+
{(outputBlob!.size / 1024).toFixed(1)} KB · {outputBlob!.type.split('/')[1] ?? 'file'}
|
|
344
|
+
</p>
|
|
345
|
+
</Card>
|
|
346
|
+
)}
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
)}
|
|
350
|
+
</div>
|
|
351
|
+
);
|
|
352
|
+
}
|
|
@@ -36,13 +36,13 @@ export function Toolbar({ title = 'Lokvis Workspace', rightExtra }: ToolbarProps
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
return (
|
|
39
|
-
<header className="flex h-
|
|
39
|
+
<header className="flex h-[var(--lokvis-toolbar-h)] shrink-0 items-center justify-between border-b border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] px-4">
|
|
40
40
|
{/* Left: Brand */}
|
|
41
41
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
42
42
|
<span
|
|
43
43
|
className="shrink-0 text-base font-bold leading-none"
|
|
44
44
|
style={{
|
|
45
|
-
background: 'linear-gradient(135deg,
|
|
45
|
+
background: 'linear-gradient(135deg, var(--lokvis-primary), var(--lokvis-info))',
|
|
46
46
|
WebkitBackgroundClip: 'text',
|
|
47
47
|
WebkitTextFillColor: 'transparent',
|
|
48
48
|
}}
|
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import * as React from 'react';
|
|
21
|
-
import { Icon } from '@lokvis/ui-core';
|
|
21
|
+
import { ConfirmDialog, Icon, Input } from '@lokvis/ui-core';
|
|
22
22
|
import type { Capability } from '@lokvis/schema';
|
|
23
23
|
import { useWorkspaceStore, MAX_WORKFLOW_STEPS } from '../store/index.js';
|
|
24
|
+
import { filterCapabilities } from '../utils.js';
|
|
25
|
+
import { StatusDot } from './StatusDot.js';
|
|
24
26
|
|
|
25
27
|
export interface WorkflowEditorProps {
|
|
26
28
|
className?: string;
|
|
@@ -34,11 +36,14 @@ export function WorkflowEditor({ className = '' }: WorkflowEditorProps) {
|
|
|
34
36
|
const moveNode = useWorkspaceStore((s) => s.moveNode);
|
|
35
37
|
const insertNodeAt = useWorkspaceStore((s) => s.insertNodeAt);
|
|
36
38
|
const capabilities = useWorkspaceStore((s) => s.capabilities);
|
|
39
|
+
const stubCapabilities = useWorkspaceStore((s) => s.stubCapabilities);
|
|
37
40
|
const clearWorkflow = useWorkspaceStore((s) => s.clearWorkflow);
|
|
38
41
|
|
|
39
42
|
// 拖拽状态:被拖拽的节点索引 + 当前 hover 的插入位置
|
|
40
43
|
const [dragIndex, setDragIndex] = React.useState<number | null>(null);
|
|
41
44
|
const [hoverIndex, setHoverIndex] = React.useState<number | null>(null);
|
|
45
|
+
// 清空确认对话框
|
|
46
|
+
const [clearOpen, setClearOpen] = React.useState(false);
|
|
42
47
|
|
|
43
48
|
const handleDragStart = (e: React.DragEvent, index: number) => {
|
|
44
49
|
setDragIndex(index);
|
|
@@ -115,11 +120,7 @@ export function WorkflowEditor({ className = '' }: WorkflowEditorProps) {
|
|
|
115
120
|
{nodes.length > 0 && (
|
|
116
121
|
<button
|
|
117
122
|
type="button"
|
|
118
|
-
onClick={() =>
|
|
119
|
-
if (window.confirm('清空当前工作流?所有节点将被移除。')) {
|
|
120
|
-
clearWorkflow();
|
|
121
|
-
}
|
|
122
|
-
}}
|
|
123
|
+
onClick={() => setClearOpen(true)}
|
|
123
124
|
className="text-[10px] text-[var(--lokvis-fg-subtle)] transition-colors hover:text-[var(--lokvis-danger)]"
|
|
124
125
|
aria-label="清空工作流"
|
|
125
126
|
>
|
|
@@ -148,6 +149,7 @@ export function WorkflowEditor({ className = '' }: WorkflowEditorProps) {
|
|
|
148
149
|
<InsertConnector
|
|
149
150
|
index={0}
|
|
150
151
|
capabilities={capabilities}
|
|
152
|
+
stubCapabilities={stubCapabilities}
|
|
151
153
|
onInsert={insertNodeAt}
|
|
152
154
|
disabled={nodes.length >= MAX_WORKFLOW_STEPS}
|
|
153
155
|
/>
|
|
@@ -159,6 +161,8 @@ export function WorkflowEditor({ className = '' }: WorkflowEditorProps) {
|
|
|
159
161
|
const isHoverTarget = hoverIndex === i && dragIndex !== null && dragIndex !== i;
|
|
160
162
|
return (
|
|
161
163
|
<React.Fragment key={node.id}>
|
|
164
|
+
{/* D7: remove button 作为 node button 的兄弟而非子元素(HTML 规范禁止 button 嵌套) */}
|
|
165
|
+
<div className="group relative flex shrink-0 items-center">
|
|
162
166
|
<button
|
|
163
167
|
type="button"
|
|
164
168
|
draggable
|
|
@@ -170,7 +174,7 @@ export function WorkflowEditor({ className = '' }: WorkflowEditorProps) {
|
|
|
170
174
|
onClick={() => selectNode(node.id)}
|
|
171
175
|
onKeyDown={(e) => handleKeyDown(e, node.id, i)}
|
|
172
176
|
aria-label={`节点 ${node.capability},位置 ${i + 1},拖拽或方向键重排,Delete 删除`}
|
|
173
|
-
className={`
|
|
177
|
+
className={`flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-[11px] font-medium transition-all ${
|
|
174
178
|
isDragging
|
|
175
179
|
? 'opacity-40'
|
|
176
180
|
: isHoverTarget
|
|
@@ -192,27 +196,24 @@ export function WorkflowEditor({ className = '' }: WorkflowEditorProps) {
|
|
|
192
196
|
</span>
|
|
193
197
|
<span className="font-mono">{node.capability}</span>
|
|
194
198
|
<StatusDot status={node.status} />
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
group-hover 显示;stopPropagation 避免触发 selectNode */}
|
|
199
|
+
</button>
|
|
200
|
+
{/* 删除按钮:兄弟 button(position absolute 浮在节点右上角),group-hover 显示 */}
|
|
198
201
|
<button
|
|
199
202
|
type="button"
|
|
200
|
-
onClick={(
|
|
201
|
-
e.stopPropagation();
|
|
202
|
-
removeNode(node.id);
|
|
203
|
-
}}
|
|
203
|
+
onClick={() => removeNode(node.id)}
|
|
204
204
|
aria-label="删除节点"
|
|
205
205
|
tabIndex={-1}
|
|
206
206
|
className="absolute -right-1 -top-1 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-[var(--lokvis-danger)]/15 hover:text-[var(--lokvis-danger)]"
|
|
207
207
|
>
|
|
208
208
|
<Icon size={10} strokeWidth={3}><path d="M6 18L18 6M6 6l12 12" /></Icon>
|
|
209
209
|
</button>
|
|
210
|
-
</
|
|
210
|
+
</div>
|
|
211
211
|
|
|
212
212
|
{/* W11.1: 每个节点后的插入连接器(最后一个用于追加到 Output 前) */}
|
|
213
213
|
<InsertConnector
|
|
214
214
|
index={i + 1}
|
|
215
215
|
capabilities={capabilities}
|
|
216
|
+
stubCapabilities={stubCapabilities}
|
|
216
217
|
onInsert={insertNodeAt}
|
|
217
218
|
disabled={nodes.length >= MAX_WORKFLOW_STEPS}
|
|
218
219
|
/>
|
|
@@ -238,27 +239,24 @@ export function WorkflowEditor({ className = '' }: WorkflowEditorProps) {
|
|
|
238
239
|
<span>拖拽重排 · 方向键移动 · Delete 删除 · hover 箭头处插入节点</span>
|
|
239
240
|
</div>
|
|
240
241
|
)}
|
|
242
|
+
|
|
243
|
+
{/* 清空确认对话框(替代 window.confirm) */}
|
|
244
|
+
<ConfirmDialog
|
|
245
|
+
open={clearOpen}
|
|
246
|
+
title="清空工作流"
|
|
247
|
+
message="清空当前工作流?所有节点将被移除。"
|
|
248
|
+
confirmText="清空"
|
|
249
|
+
variant="danger"
|
|
250
|
+
onConfirm={() => {
|
|
251
|
+
clearWorkflow();
|
|
252
|
+
setClearOpen(false);
|
|
253
|
+
}}
|
|
254
|
+
onClose={() => setClearOpen(false)}
|
|
255
|
+
/>
|
|
241
256
|
</div>
|
|
242
257
|
);
|
|
243
258
|
}
|
|
244
259
|
|
|
245
|
-
function StatusDot({ status }: { status: string }) {
|
|
246
|
-
const color =
|
|
247
|
-
status === 'running'
|
|
248
|
-
? 'bg-[var(--lokvis-warning)]'
|
|
249
|
-
: status === 'success'
|
|
250
|
-
? 'bg-[var(--lokvis-success)]'
|
|
251
|
-
: status === 'failed'
|
|
252
|
-
? 'bg-[var(--lokvis-danger)]'
|
|
253
|
-
: status === 'pending'
|
|
254
|
-
? 'bg-[var(--lokvis-fg-subtle)]'
|
|
255
|
-
: 'bg-[var(--lokvis-fg-subtle)]';
|
|
256
|
-
|
|
257
|
-
const animate = status === 'running' ? 'animate-pulse' : '';
|
|
258
|
-
|
|
259
|
-
return <span className={`h-1.5 w-1.5 rounded-full shrink-0 ${color} ${animate}`} />;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
260
|
/**
|
|
263
261
|
* InsertConnector - 节点之间的连接器 + 插入按钮(W11.1)
|
|
264
262
|
*
|
|
@@ -268,11 +266,13 @@ function StatusDot({ status }: { status: string }) {
|
|
|
268
266
|
function InsertConnector({
|
|
269
267
|
index,
|
|
270
268
|
capabilities,
|
|
269
|
+
stubCapabilities,
|
|
271
270
|
onInsert,
|
|
272
271
|
disabled,
|
|
273
272
|
}: {
|
|
274
273
|
index: number;
|
|
275
274
|
capabilities: Capability[];
|
|
275
|
+
stubCapabilities: Set<string>;
|
|
276
276
|
onInsert: (index: number, capability: string) => void;
|
|
277
277
|
disabled?: boolean;
|
|
278
278
|
}) {
|
|
@@ -296,12 +296,7 @@ function InsertConnector({
|
|
|
296
296
|
}, [open]);
|
|
297
297
|
|
|
298
298
|
const filtered = React.useMemo(
|
|
299
|
-
() =>
|
|
300
|
-
capabilities.filter(
|
|
301
|
-
(c) =>
|
|
302
|
-
c.name.toLowerCase().includes(filter.toLowerCase()) ||
|
|
303
|
-
c.description.toLowerCase().includes(filter.toLowerCase())
|
|
304
|
-
),
|
|
299
|
+
() => filterCapabilities(capabilities, filter),
|
|
305
300
|
[capabilities, filter]
|
|
306
301
|
);
|
|
307
302
|
|
|
@@ -340,16 +335,16 @@ function InsertConnector({
|
|
|
340
335
|
|
|
341
336
|
{/* 弹出菜单 */}
|
|
342
337
|
{open && (
|
|
343
|
-
<div className="absolute top-full left-1/2 z-50 mt-1 w-56 -translate-x-1/2 rounded-lg border border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] shadow-
|
|
338
|
+
<div className="absolute top-full left-1/2 z-50 mt-1 w-56 -translate-x-1/2 rounded-lg border border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] shadow-[var(--lokvis-elevation-2)]">
|
|
344
339
|
{/* 搜索 */}
|
|
345
340
|
<div className="border-b border-[var(--lokvis-border)] p-1.5">
|
|
346
|
-
<
|
|
341
|
+
<Input
|
|
347
342
|
type="text"
|
|
343
|
+
size="sm"
|
|
348
344
|
autoFocus
|
|
349
345
|
placeholder="搜索 capability..."
|
|
350
346
|
value={filter}
|
|
351
347
|
onChange={(e) => setFilter(e.target.value)}
|
|
352
|
-
className="w-full rounded border border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] px-2 py-1 text-[11px] placeholder-zinc-400 focus:border-[var(--lokvis-primary)] focus:outline-none"
|
|
353
348
|
/>
|
|
354
349
|
</div>
|
|
355
350
|
{/* 列表 */}
|
|
@@ -357,21 +352,33 @@ function InsertConnector({
|
|
|
357
352
|
{filtered.length === 0 ? (
|
|
358
353
|
<p className="px-2 py-2 text-center text-[10px] text-[var(--lokvis-fg-subtle)]">无匹配 capability</p>
|
|
359
354
|
) : (
|
|
360
|
-
filtered.map((cap) =>
|
|
355
|
+
filtered.map((cap) => {
|
|
356
|
+
// A7: stub-only 能力显示 "Coming Soon" 标记
|
|
357
|
+
const isStubOnly = stubCapabilities.has(cap.name);
|
|
358
|
+
return (
|
|
361
359
|
<button
|
|
362
360
|
key={cap.name}
|
|
363
361
|
type="button"
|
|
364
362
|
onClick={() => handleSelect(cap.name)}
|
|
363
|
+
title={isStubOnly ? 'Coming soon — no engine installed yet' : undefined}
|
|
365
364
|
className="block w-full rounded px-2 py-1 text-left transition-colors hover:bg-[var(--lokvis-primary)]/10"
|
|
366
365
|
>
|
|
366
|
+
<span className="flex items-center justify-between gap-1.5">
|
|
367
367
|
<span className="block font-mono text-[10px] font-medium text-[var(--lokvis-fg-muted)]">
|
|
368
368
|
{cap.name}
|
|
369
369
|
</span>
|
|
370
|
+
{isStubOnly && (
|
|
371
|
+
<span className="shrink-0 rounded px-1 py-0.5 text-[8px] font-medium uppercase bg-[var(--lokvis-warning)]/15 text-[var(--lokvis-warning)]">
|
|
372
|
+
Soon
|
|
373
|
+
</span>
|
|
374
|
+
)}
|
|
375
|
+
</span>
|
|
370
376
|
<span className="block truncate text-[9px] text-[var(--lokvis-fg-muted)]">
|
|
371
377
|
{cap.description}
|
|
372
378
|
</span>
|
|
373
379
|
</button>
|
|
374
|
-
)
|
|
380
|
+
);
|
|
381
|
+
})
|
|
375
382
|
)}
|
|
376
383
|
</div>
|
|
377
384
|
</div>
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import * as React from 'react';
|
|
15
|
-
import { Icon } from '@lokvis/ui-core';
|
|
15
|
+
import { ConfirmDialog, Icon } from '@lokvis/ui-core';
|
|
16
16
|
import { useWorkspaceStore } from '../store/index.js';
|
|
17
17
|
import { WORKFLOW_TEMPLATES, type WorkflowTemplate } from '../data/workflow-templates.js';
|
|
18
18
|
|
|
@@ -31,13 +31,18 @@ export function WorkflowTemplates({
|
|
|
31
31
|
}: WorkflowTemplatesProps) {
|
|
32
32
|
const loadWorkflowTemplate = useWorkspaceStore((s) => s.loadWorkflowTemplate);
|
|
33
33
|
const nodes = useWorkspaceStore((s) => s.nodes);
|
|
34
|
+
// 待确认应用的模板(非空工作流替换前需用户确认)
|
|
35
|
+
const [pendingTpl, setPendingTpl] = React.useState<WorkflowTemplate | null>(null);
|
|
34
36
|
|
|
35
37
|
const handleApply = (tpl: WorkflowTemplate) => {
|
|
36
38
|
if (confirmIfNotEmpty && nodes.length > 0) {
|
|
37
|
-
|
|
39
|
+
setPendingTpl(tpl);
|
|
38
40
|
return;
|
|
39
41
|
}
|
|
40
|
-
|
|
42
|
+
applyTemplate(tpl);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const applyTemplate = (tpl: WorkflowTemplate) => {
|
|
41
46
|
loadWorkflowTemplate(
|
|
42
47
|
tpl.nodes.map((n) => ({ capability: n.capability, params: n.params }))
|
|
43
48
|
);
|
|
@@ -62,7 +67,7 @@ export function WorkflowTemplates({
|
|
|
62
67
|
key={tpl.id}
|
|
63
68
|
type="button"
|
|
64
69
|
onClick={() => handleApply(tpl)}
|
|
65
|
-
className="group flex items-start gap-2 rounded-md border border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] px-2.5 py-2 text-left transition-all hover:border-[var(--lokvis-primary)]/50 hover:bg-[var(--lokvis-primary)]/10
|
|
70
|
+
className="group flex items-start gap-2 rounded-md border border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] px-2.5 py-2 text-left transition-all hover:border-[var(--lokvis-primary)]/50 hover:bg-[var(--lokvis-primary)]/10"
|
|
66
71
|
>
|
|
67
72
|
<span className="mt-0.5 text-base leading-none">{tpl.icon}</span>
|
|
68
73
|
<div className="flex-1 min-w-0">
|
|
@@ -96,6 +101,24 @@ export function WorkflowTemplates({
|
|
|
96
101
|
</button>
|
|
97
102
|
))}
|
|
98
103
|
</div>
|
|
104
|
+
|
|
105
|
+
{/* 替换确认对话框(替代 window.confirm) */}
|
|
106
|
+
<ConfirmDialog
|
|
107
|
+
open={pendingTpl !== null}
|
|
108
|
+
title="应用模板"
|
|
109
|
+
message={
|
|
110
|
+
pendingTpl
|
|
111
|
+
? `应用模板「${pendingTpl.name}」将替换当前 ${nodes.length} 个节点,继续?`
|
|
112
|
+
: ''
|
|
113
|
+
}
|
|
114
|
+
confirmText="替换"
|
|
115
|
+
variant="danger"
|
|
116
|
+
onConfirm={() => {
|
|
117
|
+
if (pendingTpl) applyTemplate(pendingTpl);
|
|
118
|
+
setPendingTpl(null);
|
|
119
|
+
}}
|
|
120
|
+
onClose={() => setPendingTpl(null)}
|
|
121
|
+
/>
|
|
99
122
|
</div>
|
|
100
123
|
);
|
|
101
124
|
}
|