@lokvis/ui-react 0.2.0 → 0.2.2

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 (97) hide show
  1. package/dist/components/AssetPanel.d.ts.map +1 -1
  2. package/dist/components/AssetPanel.js +14 -14
  3. package/dist/components/AssetPanel.js.map +1 -1
  4. package/dist/components/Canvas.d.ts +4 -4
  5. package/dist/components/Canvas.d.ts.map +1 -1
  6. package/dist/components/Canvas.js +8 -8
  7. package/dist/components/Canvas.js.map +1 -1
  8. package/dist/components/CommandPalette.d.ts +7 -7
  9. package/dist/components/CommandPalette.d.ts.map +1 -1
  10. package/dist/components/CommandPalette.js +10 -10
  11. package/dist/components/CommandPalette.js.map +1 -1
  12. package/dist/components/CompareSlider.d.ts +6 -6
  13. package/dist/components/CompareSlider.d.ts.map +1 -1
  14. package/dist/components/CompareSlider.js +7 -7
  15. package/dist/components/CompareSlider.js.map +1 -1
  16. package/dist/components/DownloadPanel.d.ts +3 -3
  17. package/dist/components/DownloadPanel.d.ts.map +1 -1
  18. package/dist/components/DownloadPanel.js +9 -9
  19. package/dist/components/DownloadPanel.js.map +1 -1
  20. package/dist/components/ErrorBanner.d.ts +2 -2
  21. package/dist/components/ErrorBanner.d.ts.map +1 -1
  22. package/dist/components/ErrorBanner.js +3 -3
  23. package/dist/components/ErrorBanner.js.map +1 -1
  24. package/dist/components/ExifPanel.d.ts +1 -1
  25. package/dist/components/ExifPanel.d.ts.map +1 -1
  26. package/dist/components/ExifPanel.js +2 -2
  27. package/dist/components/ExifPanel.js.map +1 -1
  28. package/dist/components/GlobalDropzone.d.ts +5 -5
  29. package/dist/components/GlobalDropzone.d.ts.map +1 -1
  30. package/dist/components/GlobalDropzone.js +6 -6
  31. package/dist/components/GlobalDropzone.js.map +1 -1
  32. package/dist/components/HistoryPanel.d.ts +2 -2
  33. package/dist/components/HistoryPanel.d.ts.map +1 -1
  34. package/dist/components/HistoryPanel.js +23 -23
  35. package/dist/components/HistoryPanel.js.map +1 -1
  36. package/dist/components/Inspector.d.ts.map +1 -1
  37. package/dist/components/Inspector.js +6 -6
  38. package/dist/components/Inspector.js.map +1 -1
  39. package/dist/components/ParamForm.d.ts.map +1 -1
  40. package/dist/components/ParamForm.js +7 -7
  41. package/dist/components/ParamForm.js.map +1 -1
  42. package/dist/components/PipelineBar.d.ts.map +1 -1
  43. package/dist/components/PipelineBar.js +13 -13
  44. package/dist/components/PipelineBar.js.map +1 -1
  45. package/dist/components/ProgressBar.d.ts +4 -4
  46. package/dist/components/ProgressBar.d.ts.map +1 -1
  47. package/dist/components/ProgressBar.js +8 -8
  48. package/dist/components/ProgressBar.js.map +1 -1
  49. package/dist/components/StatusBar.d.ts +3 -3
  50. package/dist/components/StatusBar.d.ts.map +1 -1
  51. package/dist/components/StatusBar.js +11 -11
  52. package/dist/components/StatusBar.js.map +1 -1
  53. package/dist/components/ThemeToggle.d.ts +4 -4
  54. package/dist/components/ThemeToggle.d.ts.map +1 -1
  55. package/dist/components/ThemeToggle.js +7 -7
  56. package/dist/components/ThemeToggle.js.map +1 -1
  57. package/dist/components/Toolbar.d.ts.map +1 -1
  58. package/dist/components/Toolbar.js +2 -2
  59. package/dist/components/Toolbar.js.map +1 -1
  60. package/dist/components/WorkflowEditor.d.ts +6 -6
  61. package/dist/components/WorkflowEditor.d.ts.map +1 -1
  62. package/dist/components/WorkflowEditor.js +25 -25
  63. package/dist/components/WorkflowEditor.js.map +1 -1
  64. package/dist/components/WorkflowTemplates.d.ts +2 -2
  65. package/dist/components/WorkflowTemplates.d.ts.map +1 -1
  66. package/dist/components/WorkflowTemplates.js +3 -3
  67. package/dist/components/WorkflowTemplates.js.map +1 -1
  68. package/dist/components/Workspace.d.ts +22 -22
  69. package/dist/components/Workspace.d.ts.map +1 -1
  70. package/dist/components/Workspace.js +30 -30
  71. package/dist/components/Workspace.js.map +1 -1
  72. package/dist/index.d.ts +1 -0
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +3 -0
  75. package/dist/index.js.map +1 -1
  76. package/package.json +6 -6
  77. package/src/components/AssetPanel.tsx +263 -263
  78. package/src/components/Canvas.tsx +174 -174
  79. package/src/components/CommandPalette.tsx +242 -242
  80. package/src/components/CompareSlider.tsx +163 -163
  81. package/src/components/DownloadPanel.tsx +203 -203
  82. package/src/components/ErrorBanner.tsx +64 -64
  83. package/src/components/ExifPanel.tsx +154 -154
  84. package/src/components/GlobalDropzone.tsx +170 -170
  85. package/src/components/HistoryPanel.tsx +219 -219
  86. package/src/components/Inspector.tsx +136 -136
  87. package/src/components/ParamForm.tsx +136 -136
  88. package/src/components/PipelineBar.tsx +98 -98
  89. package/src/components/ProgressBar.tsx +78 -78
  90. package/src/components/StatusBar.tsx +148 -148
  91. package/src/components/ThemeToggle.tsx +98 -98
  92. package/src/components/Toolbar.tsx +65 -65
  93. package/src/components/WorkflowEditor.tsx +321 -321
  94. package/src/components/WorkflowTemplates.tsx +77 -77
  95. package/src/components/Workspace.tsx +223 -223
  96. package/src/css-modules.d.ts +3 -0
  97. package/src/index.ts +4 -0
@@ -2,14 +2,14 @@
2
2
  * WorkflowEditor - 拖拽式工作流编辑器(W10.4)
3
3
  *
4
4
  * 增强 PipelineBar,提供节点拖拽重排能力:
5
- * - 拖拽节点到任意位置重排线性链
6
- * - 拖拽时显示插入指示器
7
- * - 键盘支持:选中节点后 ← → 移动(无障碍)
8
- * - 节点数上限 MAX_WORKFLOW_STEPS=5(M1 MVP 约束,UI 层常量)
5
+ * - 拖拽节点到任意位置重排线性链
6
+ * - 拖拽时显示插入指示器
7
+ * - 键盘支持:选中节点后 ← → 移动(无障碍)
8
+ * - 节点数上限 MAX_WORKFLOW_STEPS=5(M1 MVP 约束,UI 层常量)
9
9
  *
10
10
  * 与 PipelineBar 区别:
11
- * - PipelineBar:只读 + 选中/删除(快速预览)
12
- * - WorkflowEditor:可编辑 + 拖拽重排(编辑模式)
11
+ * - PipelineBar:只读 + 选中/删除(快速预览)
12
+ * - WorkflowEditor:可编辑 + 拖拽重排(编辑模式)
13
13
  *
14
14
  * 使用原生 HTML5 Drag and Drop API,无额外依赖。
15
15
  * 触摸设备降级为按钮上下移动(HTML5 DnD 在移动端支持不佳)。
@@ -23,240 +23,240 @@ import type { Capability } from '@lokvis/schema';
23
23
  import { useWorkspaceStore, MAX_WORKFLOW_STEPS } from '../store/index.js';
24
24
 
25
25
  export interface WorkflowEditorProps {
26
- className?: string;
26
+ className?: string;
27
27
  }
28
28
 
29
29
  export function WorkflowEditor({ className = '' }: WorkflowEditorProps) {
30
- const nodes = useWorkspaceStore((s) => s.nodes);
31
- const selectedNodeId = useWorkspaceStore((s) => s.selectedNodeId);
32
- const selectNode = useWorkspaceStore((s) => s.selectNode);
33
- const removeNode = useWorkspaceStore((s) => s.removeNode);
34
- const moveNode = useWorkspaceStore((s) => s.moveNode);
35
- const insertNodeAt = useWorkspaceStore((s) => s.insertNodeAt);
36
- const capabilities = useWorkspaceStore((s) => s.capabilities);
37
- const clearWorkflow = useWorkspaceStore((s) => s.clearWorkflow);
30
+ const nodes = useWorkspaceStore((s) => s.nodes);
31
+ const selectedNodeId = useWorkspaceStore((s) => s.selectedNodeId);
32
+ const selectNode = useWorkspaceStore((s) => s.selectNode);
33
+ const removeNode = useWorkspaceStore((s) => s.removeNode);
34
+ const moveNode = useWorkspaceStore((s) => s.moveNode);
35
+ const insertNodeAt = useWorkspaceStore((s) => s.insertNodeAt);
36
+ const capabilities = useWorkspaceStore((s) => s.capabilities);
37
+ const clearWorkflow = useWorkspaceStore((s) => s.clearWorkflow);
38
38
 
39
- // 拖拽状态:被拖拽的节点索引 + 当前 hover 的插入位置
40
- const [dragIndex, setDragIndex] = React.useState<number | null>(null);
41
- const [hoverIndex, setHoverIndex] = React.useState<number | null>(null);
39
+ // 拖拽状态:被拖拽的节点索引 + 当前 hover 的插入位置
40
+ const [dragIndex, setDragIndex] = React.useState<number | null>(null);
41
+ const [hoverIndex, setHoverIndex] = React.useState<number | null>(null);
42
42
 
43
- const handleDragStart = (e: React.DragEvent, index: number) => {
44
- setDragIndex(index);
45
- e.dataTransfer.effectAllowed = 'move';
46
- // Firefox 需要设置 data 才能触发 dragstart
47
- e.dataTransfer.setData('text/plain', String(index));
48
- };
43
+ const handleDragStart = (e: React.DragEvent, index: number) => {
44
+ setDragIndex(index);
45
+ e.dataTransfer.effectAllowed = 'move';
46
+ // Firefox 需要设置 data 才能触发 dragstart
47
+ e.dataTransfer.setData('text/plain', String(index));
48
+ };
49
49
 
50
- const handleDragOver = (e: React.DragEvent, index: number) => {
51
- e.preventDefault();
52
- e.dataTransfer.dropEffect = 'move';
53
- if (dragIndex !== null && dragIndex !== index) {
54
- setHoverIndex(index);
55
- }
56
- };
50
+ const handleDragOver = (e: React.DragEvent, index: number) => {
51
+ e.preventDefault();
52
+ e.dataTransfer.dropEffect = 'move';
53
+ if (dragIndex !== null && dragIndex !== index) {
54
+ setHoverIndex(index);
55
+ }
56
+ };
57
57
 
58
- const handleDragLeave = () => {
59
- // 不立即清空 hoverIndex,因为 dragleave 在子元素间会误触发
60
- // 由 handleDrop / handleDragEnd 统一清空
61
- };
58
+ const handleDragLeave = () => {
59
+ // 不立即清空 hoverIndex,因为 dragleave 在子元素间会误触发
60
+ // 由 handleDrop / handleDragEnd 统一清空
61
+ };
62
62
 
63
- const handleDrop = (e: React.DragEvent, index: number) => {
64
- e.preventDefault();
65
- if (dragIndex !== null && dragIndex !== index) {
66
- moveNode(dragIndex, index);
67
- }
68
- setDragIndex(null);
69
- setHoverIndex(null);
70
- };
63
+ const handleDrop = (e: React.DragEvent, index: number) => {
64
+ e.preventDefault();
65
+ if (dragIndex !== null && dragIndex !== index) {
66
+ moveNode(dragIndex, index);
67
+ }
68
+ setDragIndex(null);
69
+ setHoverIndex(null);
70
+ };
71
71
 
72
- const handleDragEnd = () => {
73
- setDragIndex(null);
74
- setHoverIndex(null);
75
- };
72
+ const handleDragEnd = () => {
73
+ setDragIndex(null);
74
+ setHoverIndex(null);
75
+ };
76
76
 
77
- // 键盘支持:选中节点后用 ← → 移动。传 node.id 而非 index 避免异步操作后 stale index
78
- const handleKeyDown = (e: React.KeyboardEvent, nodeId: string, index: number) => {
79
- if (e.key === 'ArrowLeft' && index > 0) {
80
- e.preventDefault();
81
- moveNode(index, index - 1);
82
- } else if (e.key === 'ArrowRight' && index < nodes.length - 1) {
83
- e.preventDefault();
84
- moveNode(index, index + 1);
85
- } else if (e.key === 'Delete' || e.key === 'Backspace') {
86
- e.preventDefault();
87
- removeNode(nodeId);
88
- }
89
- };
77
+ // 键盘支持:选中节点后用 ← → 移动。传 node.id 而非 index 避免异步操作后 stale index
78
+ const handleKeyDown = (e: React.KeyboardEvent, nodeId: string, index: number) => {
79
+ if (e.key === 'ArrowLeft' && index > 0) {
80
+ e.preventDefault();
81
+ moveNode(index, index - 1);
82
+ } else if (e.key === 'ArrowRight' && index < nodes.length - 1) {
83
+ e.preventDefault();
84
+ moveNode(index, index + 1);
85
+ } else if (e.key === 'Delete' || e.key === 'Backspace') {
86
+ e.preventDefault();
87
+ removeNode(nodeId);
88
+ }
89
+ };
90
90
 
91
- const canAddMore = nodes.length < MAX_WORKFLOW_STEPS;
91
+ const canAddMore = nodes.length < MAX_WORKFLOW_STEPS;
92
92
 
93
- return (
94
- <div
95
- className={`flex flex-col border-b border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900 ${className}`}
96
- role="region"
97
- aria-label="工作流编辑器"
98
- >
99
- {/* 顶部:标题 + 步数 + 清空 */}
100
- <div className="flex h-9 shrink-0 items-center justify-between border-b border-zinc-100 px-3 dark:border-zinc-800/50">
101
- <div className="flex items-center gap-2">
102
- <span className="text-[10px] font-semibold uppercase tracking-wider text-zinc-400">
103
- Workflow Editor
104
- </span>
105
- <span
106
- className={`rounded px-1.5 py-0.5 text-[10px] tabular-nums ${
107
- canAddMore
108
- ? 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400'
109
- : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
110
- }`}
111
- >
112
- {nodes.length}/{MAX_WORKFLOW_STEPS}
113
- </span>
114
- </div>
115
- {nodes.length > 0 && (
116
- <button
117
- type="button"
118
- onClick={() => {
119
- if (window.confirm('清空当前工作流?所有节点将被移除。')) {
120
- clearWorkflow();
121
- }
122
- }}
123
- className="text-[10px] text-zinc-400 transition-colors hover:text-red-500"
124
- aria-label="清空工作流"
125
- >
126
- Clear
127
- </button>
128
- )}
129
- </div>
93
+ return (
94
+ <div
95
+ className={`flex flex-col border-b border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] ${className}`}
96
+ role="region"
97
+ aria-label="工作流编辑器"
98
+ >
99
+ {/* 顶部:标题 + 步数 + 清空 */}
100
+ <div className="flex h-9 shrink-0 items-center justify-between border-b border-[var(--lokvis-border)] px-3">
101
+ <div className="flex items-center gap-2">
102
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--lokvis-fg-subtle)]">
103
+ Workflow Editor
104
+ </span>
105
+ <span
106
+ className={`rounded px-1.5 py-0.5 text-[10px] tabular-nums ${
107
+ canAddMore
108
+ ? 'bg-[var(--lokvis-surface-muted)] text-[var(--lokvis-fg-muted)]'
109
+ : 'bg-[var(--lokvis-warning)]/15 text-[var(--lokvis-warning)]'
110
+ }`}
111
+ >
112
+ {nodes.length}/{MAX_WORKFLOW_STEPS}
113
+ </span>
114
+ </div>
115
+ {nodes.length > 0 && (
116
+ <button
117
+ type="button"
118
+ onClick={() => {
119
+ if (window.confirm('清空当前工作流?所有节点将被移除。')) {
120
+ clearWorkflow();
121
+ }
122
+ }}
123
+ className="text-[10px] text-[var(--lokvis-fg-subtle)] transition-colors hover:text-[var(--lokvis-danger)]"
124
+ aria-label="清空工作流"
125
+ >
126
+ Clear
127
+ </button>
128
+ )}
129
+ </div>
130
130
 
131
- {/* 节点链 */}
132
- <div className="flex min-h-[3rem] items-center gap-1 overflow-x-auto px-3 py-2">
133
- {nodes.length === 0 ? (
134
- <div className="flex flex-1 items-center justify-center py-2 text-center">
135
- <p className="text-[11px] text-zinc-400">
136
- 空工作流 &mdash; 从右侧 <span className="font-mono">Capabilities</span> 面板点击添加节点
137
- </p>
138
- </div>
139
- ) : (
140
- <>
141
- {/* Source indicator */}
142
- <div className="flex shrink-0 items-center gap-1 rounded-md bg-zinc-100 px-2 py-1 text-[10px] font-medium text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
143
- <Icon size={12}><path d="M12 4.5v15m7.5-7.5h-15" /></Icon>
144
- Source
145
- </div>
131
+ {/* 节点链 */}
132
+ <div className="flex min-h-[3rem] items-center gap-1 overflow-x-auto px-3 py-2">
133
+ {nodes.length === 0 ? (
134
+ <div className="flex flex-1 items-center justify-center py-2 text-center">
135
+ <p className="text-[11px] text-[var(--lokvis-fg-subtle)]">
136
+ 空工作流 &mdash; 从右侧 <span className="font-mono">Capabilities</span> 面板点击添加节点
137
+ </p>
138
+ </div>
139
+ ) : (
140
+ <>
141
+ {/* Source indicator */}
142
+ <div className="flex shrink-0 items-center gap-1 rounded-md bg-[var(--lokvis-surface-muted)] px-2 py-1 text-[10px] font-medium text-[var(--lokvis-fg-muted)]">
143
+ <Icon size={12}><path d="M12 4.5v15m7.5-7.5h-15" /></Icon>
144
+ Source
145
+ </div>
146
146
 
147
- {/* W11.1: 节点之间可插入的连接器(+ 按钮) */}
148
- <InsertConnector
149
- index={0}
150
- capabilities={capabilities}
151
- onInsert={insertNodeAt}
152
- disabled={nodes.length >= MAX_WORKFLOW_STEPS}
153
- />
147
+ {/* W11.1: 节点之间可插入的连接器(+ 按钮) */}
148
+ <InsertConnector
149
+ index={0}
150
+ capabilities={capabilities}
151
+ onInsert={insertNodeAt}
152
+ disabled={nodes.length >= MAX_WORKFLOW_STEPS}
153
+ />
154
154
 
155
- {/* 节点 */}
156
- {nodes.map((node, i) => {
157
- const selected = node.id === selectedNodeId;
158
- const isDragging = dragIndex === i;
159
- const isHoverTarget = hoverIndex === i && dragIndex !== null && dragIndex !== i;
160
- return (
161
- <React.Fragment key={node.id}>
162
- <button
163
- type="button"
164
- draggable
165
- onDragStart={(e) => handleDragStart(e, i)}
166
- onDragOver={(e) => handleDragOver(e, i)}
167
- onDragLeave={handleDragLeave}
168
- onDrop={(e) => handleDrop(e, i)}
169
- onDragEnd={handleDragEnd}
170
- onClick={() => selectNode(node.id)}
171
- onKeyDown={(e) => handleKeyDown(e, node.id, i)}
172
- aria-label={`节点 ${node.capability},位置 ${i + 1},拖拽或方向键重排,Delete 删除`}
173
- className={`group relative flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-[11px] font-medium transition-all ${
174
- isDragging
175
- ? 'opacity-40'
176
- : isHoverTarget
177
- ? 'ring-2 ring-indigo-400 ring-offset-1 dark:ring-offset-zinc-900'
178
- : selected
179
- ? 'bg-indigo-100 text-indigo-700 ring-1 ring-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:ring-indigo-700'
180
- : node.status === 'running'
181
- ? 'bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400'
182
- : node.status === 'success'
183
- ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400'
184
- : node.status === 'failed'
185
- ? 'bg-red-100 text-red-700 dark:bg-red-950/30 dark:text-red-400'
186
- : 'bg-zinc-100 text-zinc-700 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700'
187
- } cursor-grab active:cursor-grabbing`}
188
- >
189
- {/* 步骤序号 */}
190
- <span className="flex h-4 w-4 items-center justify-center rounded-full bg-black/5 text-[9px] tabular-nums dark:bg-white/10">
191
- {i + 1}
192
- </span>
193
- <span className="font-mono">{node.capability}</span>
194
- <StatusDot status={node.status} />
195
- {/* 删除按钮:作为兄弟 button 而非嵌套(HTML 规范禁止 button 嵌套)
196
- 使用 position absolute 浮在节点 button 之上,避免叠加在 capability 文字上;
197
- group-hover 显示;stopPropagation 避免触发 selectNode */}
198
- <button
199
- type="button"
200
- onClick={(e) => {
201
- e.stopPropagation();
202
- removeNode(node.id);
203
- }}
204
- aria-label="删除节点"
205
- tabIndex={-1}
206
- className="absolute -right-1 -top-1 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/40"
207
- >
208
- <Icon size={10} strokeWidth={3}><path d="M6 18L18 6M6 6l12 12" /></Icon>
209
- </button>
210
- </button>
155
+ {/* 节点 */}
156
+ {nodes.map((node, i) => {
157
+ const selected = node.id === selectedNodeId;
158
+ const isDragging = dragIndex === i;
159
+ const isHoverTarget = hoverIndex === i && dragIndex !== null && dragIndex !== i;
160
+ return (
161
+ <React.Fragment key={node.id}>
162
+ <button
163
+ type="button"
164
+ draggable
165
+ onDragStart={(e) => handleDragStart(e, i)}
166
+ onDragOver={(e) => handleDragOver(e, i)}
167
+ onDragLeave={handleDragLeave}
168
+ onDrop={(e) => handleDrop(e, i)}
169
+ onDragEnd={handleDragEnd}
170
+ onClick={() => selectNode(node.id)}
171
+ onKeyDown={(e) => handleKeyDown(e, node.id, i)}
172
+ aria-label={`节点 ${node.capability},位置 ${i + 1},拖拽或方向键重排,Delete 删除`}
173
+ className={`group relative flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-[11px] font-medium transition-all ${
174
+ isDragging
175
+ ? 'opacity-40'
176
+ : isHoverTarget
177
+ ? 'ring-2 ring-[var(--lokvis-primary)] ring-offset-1'
178
+ : selected
179
+ ? 'bg-[var(--lokvis-primary)]/15 text-[var(--lokvis-primary)] ring-1 ring-[var(--lokvis-primary)]/50'
180
+ : node.status === 'running'
181
+ ? 'bg-[var(--lokvis-warning)]/15 text-[var(--lokvis-warning)]'
182
+ : node.status === 'success'
183
+ ? 'bg-[var(--lokvis-success)]/15 text-[var(--lokvis-success)]'
184
+ : node.status === 'failed'
185
+ ? 'bg-[var(--lokvis-danger)]/15 text-[var(--lokvis-danger)]'
186
+ : 'bg-[var(--lokvis-surface-muted)] text-[var(--lokvis-fg-muted)] hover:bg-[var(--lokvis-border)]'
187
+ } cursor-grab active:cursor-grabbing`}
188
+ >
189
+ {/* 步骤序号 */}
190
+ <span className="flex h-4 w-4 items-center justify-center rounded-full bg-[var(--lokvis-fg)]/5 text-[9px] tabular-nums">
191
+ {i + 1}
192
+ </span>
193
+ <span className="font-mono">{node.capability}</span>
194
+ <StatusDot status={node.status} />
195
+ {/* 删除按钮:作为兄弟 button 而非嵌套(HTML 规范禁止 button 嵌套)
196
+ 使用 position absolute 浮在节点 button 之上,避免叠加在 capability 文字上;
197
+ group-hover 显示;stopPropagation 避免触发 selectNode */}
198
+ <button
199
+ type="button"
200
+ onClick={(e) => {
201
+ e.stopPropagation();
202
+ removeNode(node.id);
203
+ }}
204
+ aria-label="删除节点"
205
+ tabIndex={-1}
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
+ >
208
+ <Icon size={10} strokeWidth={3}><path d="M6 18L18 6M6 6l12 12" /></Icon>
209
+ </button>
210
+ </button>
211
211
 
212
- {/* W11.1: 每个节点后的插入连接器(最后一个用于追加到 Output 前) */}
213
- <InsertConnector
214
- index={i + 1}
215
- capabilities={capabilities}
216
- onInsert={insertNodeAt}
217
- disabled={nodes.length >= MAX_WORKFLOW_STEPS}
218
- />
219
- </React.Fragment>
220
- );
221
- })}
212
+ {/* W11.1: 每个节点后的插入连接器(最后一个用于追加到 Output 前) */}
213
+ <InsertConnector
214
+ index={i + 1}
215
+ capabilities={capabilities}
216
+ onInsert={insertNodeAt}
217
+ disabled={nodes.length >= MAX_WORKFLOW_STEPS}
218
+ />
219
+ </React.Fragment>
220
+ );
221
+ })}
222
222
 
223
- {/* Output indicator */}
224
- {nodes.length > 0 && (
225
- <div className="flex shrink-0 items-center gap-1 rounded-md bg-zinc-100 px-2 py-1 text-[10px] font-medium text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
226
- <Icon size={12}><path d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" /></Icon>
227
- Output
228
- </div>
229
- )}
230
- </>
231
- )}
232
- </div>
223
+ {/* Output indicator */}
224
+ {nodes.length > 0 && (
225
+ <div className="flex shrink-0 items-center gap-1 rounded-md bg-[var(--lokvis-surface-muted)] px-2 py-1 text-[10px] font-medium text-[var(--lokvis-fg-muted)]">
226
+ <Icon size={12}><path d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" /></Icon>
227
+ Output
228
+ </div>
229
+ )}
230
+ </>
231
+ )}
232
+ </div>
233
233
 
234
- {/* 提示行 */}
235
- {nodes.length > 0 && (
236
- <div className="flex shrink-0 items-center gap-2 border-t border-zinc-100 px-3 py-1 text-[10px] text-zinc-400 dark:border-zinc-800/50">
237
- <Icon size={10}><path d="M13 5.5a1 1 0 1 1 2 0 1-1 0 0 0-.5.86L16.5 9l1.5-.5a1 1 0 1 1 0 2l-1.5-.5-1 1.5a1 1 0 1 1-2 0l1-1.5-1-1.5a1 1 0 0 1 0-2z" /></Icon>
238
- <span>拖拽重排 · 方向键移动 · Delete 删除 · hover 箭头处插入节点</span>
239
- </div>
240
- )}
241
- </div>
242
- );
234
+ {/* 提示行 */}
235
+ {nodes.length > 0 && (
236
+ <div className="flex shrink-0 items-center gap-2 border-t border-[var(--lokvis-border)] px-3 py-1 text-[10px] text-[var(--lokvis-fg-subtle)]">
237
+ <Icon size={10}><path d="M13 5.5a1 1 0 1 1 2 0 1-1 0 0 0-.5.86L16.5 9l1.5-.5a1 1 0 1 1 0 2l-1.5-.5-1 1.5a1 1 0 1 1-2 0l1-1.5-1-1.5a1 1 0 0 1 0-2z" /></Icon>
238
+ <span>拖拽重排 · 方向键移动 · Delete 删除 · hover 箭头处插入节点</span>
239
+ </div>
240
+ )}
241
+ </div>
242
+ );
243
243
  }
244
244
 
245
245
  function StatusDot({ status }: { status: string }) {
246
- const color =
247
- status === 'running'
248
- ? 'bg-amber-500'
249
- : status === 'success'
250
- ? 'bg-emerald-500'
251
- : status === 'failed'
252
- ? 'bg-red-500'
253
- : status === 'pending'
254
- ? 'bg-zinc-400 dark:bg-zinc-500'
255
- : 'bg-zinc-300 dark:bg-zinc-600';
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
256
 
257
- const animate = status === 'running' ? 'animate-pulse' : '';
257
+ const animate = status === 'running' ? 'animate-pulse' : '';
258
258
 
259
- return <span className={`h-1.5 w-1.5 rounded-full shrink-0 ${color} ${animate}`} />;
259
+ return <span className={`h-1.5 w-1.5 rounded-full shrink-0 ${color} ${animate}`} />;
260
260
  }
261
261
 
262
262
  /**
@@ -266,117 +266,117 @@ function StatusDot({ status }: { status: string }) {
266
266
  * 选择后调用 onInsert(index, capability)。
267
267
  */
268
268
  function InsertConnector({
269
- index,
270
- capabilities,
271
- onInsert,
272
- disabled,
269
+ index,
270
+ capabilities,
271
+ onInsert,
272
+ disabled,
273
273
  }: {
274
- index: number;
275
- capabilities: Capability[];
276
- onInsert: (index: number, capability: string) => void;
277
- disabled?: boolean;
274
+ index: number;
275
+ capabilities: Capability[];
276
+ onInsert: (index: number, capability: string) => void;
277
+ disabled?: boolean;
278
278
  }) {
279
- const [open, setOpen] = React.useState(false);
280
- const [filter, setFilter] = React.useState('');
281
- const ref = React.useRef<HTMLDivElement>(null);
279
+ const [open, setOpen] = React.useState(false);
280
+ const [filter, setFilter] = React.useState('');
281
+ const ref = React.useRef<HTMLDivElement>(null);
282
282
 
283
- // 点击外部关闭菜单
284
- React.useEffect(() => {
285
- if (!open) return;
286
- const handler = (e: MouseEvent) => {
287
- // 用 instanceof guard 替代 as Node 断言:运行时安全 + 类型安全
288
- if (!(e.target instanceof Node)) return;
289
- if (ref.current && !ref.current.contains(e.target)) {
290
- setOpen(false);
291
- setFilter('');
292
- }
293
- };
294
- document.addEventListener('mousedown', handler);
295
- return () => document.removeEventListener('mousedown', handler);
296
- }, [open]);
283
+ // 点击外部关闭菜单
284
+ React.useEffect(() => {
285
+ if (!open) return;
286
+ const handler = (e: MouseEvent) => {
287
+ // 用 instanceof guard 替代 as Node 断言:运行时安全 + 类型安全
288
+ if (!(e.target instanceof Node)) return;
289
+ if (ref.current && !ref.current.contains(e.target)) {
290
+ setOpen(false);
291
+ setFilter('');
292
+ }
293
+ };
294
+ document.addEventListener('mousedown', handler);
295
+ return () => document.removeEventListener('mousedown', handler);
296
+ }, [open]);
297
297
 
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
- ),
305
- [capabilities, filter]
306
- );
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
+ ),
305
+ [capabilities, filter]
306
+ );
307
307
 
308
- const handleSelect = (capName: string) => {
309
- onInsert(index, capName);
310
- setOpen(false);
311
- setFilter('');
312
- };
308
+ const handleSelect = (capName: string) => {
309
+ onInsert(index, capName);
310
+ setOpen(false);
311
+ setFilter('');
312
+ };
313
313
 
314
- if (disabled) {
315
- // 5 步上限时只显示箭头,不显示 +
316
- return (
317
- <Icon size={12} className="shrink-0 text-zinc-300 dark:text-zinc-600">
318
- <path d="m9 5 7 7-7 7" />
319
- </Icon>
320
- );
321
- }
314
+ if (disabled) {
315
+ // 5 步上限时只显示箭头,不显示 +
316
+ return (
317
+ <Icon size={12} className="shrink-0 text-[var(--lokvis-fg-subtle)]">
318
+ <path d="m9 5 7 7-7 7" />
319
+ </Icon>
320
+ );
321
+ }
322
322
 
323
- return (
324
- <div ref={ref} className="relative flex shrink-0 items-center">
325
- {/* 箭头 / + 按钮切换 */}
326
- <button
327
- type="button"
328
- onClick={() => setOpen(!open)}
329
- aria-label={`在位置 ${index + 1} 插入节点`}
330
- title="插入节点"
331
- className="group flex h-5 w-5 items-center justify-center rounded text-zinc-300 transition-colors hover:bg-indigo-100 hover:text-indigo-500 dark:text-zinc-600 dark:hover:bg-indigo-950/40"
332
- >
333
- <Icon size={12} className="group-hover:hidden">
334
- <path d="m9 5 7 7-7 7" />
335
- </Icon>
336
- <Icon size={12} className="hidden group-hover:block" strokeWidth={2.5}>
337
- <path d="M12 5v14M5 12h14" />
338
- </Icon>
339
- </button>
323
+ return (
324
+ <div ref={ref} className="relative flex shrink-0 items-center">
325
+ {/* 箭头 / + 按钮切换 */}
326
+ <button
327
+ type="button"
328
+ onClick={() => setOpen(!open)}
329
+ aria-label={`在位置 ${index + 1} 插入节点`}
330
+ title="插入节点"
331
+ className="group flex h-5 w-5 items-center justify-center rounded text-[var(--lokvis-fg-subtle)] transition-colors hover:bg-[var(--lokvis-primary)]/15 hover:text-[var(--lokvis-primary)]"
332
+ >
333
+ <Icon size={12} className="group-hover:hidden">
334
+ <path d="m9 5 7 7-7 7" />
335
+ </Icon>
336
+ <Icon size={12} className="hidden group-hover:block" strokeWidth={2.5}>
337
+ <path d="M12 5v14M5 12h14" />
338
+ </Icon>
339
+ </button>
340
340
 
341
- {/* 弹出菜单 */}
342
- {open && (
343
- <div className="absolute top-full left-1/2 z-50 mt-1 w-56 -translate-x-1/2 rounded-lg border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
344
- {/* 搜索 */}
345
- <div className="border-b border-zinc-100 p-1.5 dark:border-zinc-800">
346
- <input
347
- type="text"
348
- autoFocus
349
- placeholder="搜索 capability..."
350
- value={filter}
351
- onChange={(e) => setFilter(e.target.value)}
352
- className="w-full rounded border border-zinc-200 bg-white px-2 py-1 text-[11px] placeholder-zinc-400 focus:border-indigo-400 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900"
353
- />
354
- </div>
355
- {/* 列表 */}
356
- <div className="max-h-48 overflow-y-auto p-1">
357
- {filtered.length === 0 ? (
358
- <p className="px-2 py-2 text-center text-[10px] text-zinc-400">无匹配 capability</p>
359
- ) : (
360
- filtered.map((cap) => (
361
- <button
362
- key={cap.name}
363
- type="button"
364
- onClick={() => handleSelect(cap.name)}
365
- className="block w-full rounded px-2 py-1 text-left transition-colors hover:bg-indigo-50 dark:hover:bg-indigo-950/30"
366
- >
367
- <span className="block font-mono text-[10px] font-medium text-zinc-700 dark:text-zinc-300">
368
- {cap.name}
369
- </span>
370
- <span className="block truncate text-[9px] text-zinc-500 dark:text-zinc-400">
371
- {cap.description}
372
- </span>
373
- </button>
374
- ))
375
- )}
376
- </div>
377
- </div>
378
- )}
379
- </div>
380
- );
341
+ {/* 弹出菜单 */}
342
+ {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-lg">
344
+ {/* 搜索 */}
345
+ <div className="border-b border-[var(--lokvis-border)] p-1.5">
346
+ <input
347
+ type="text"
348
+ autoFocus
349
+ placeholder="搜索 capability..."
350
+ value={filter}
351
+ 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
+ />
354
+ </div>
355
+ {/* 列表 */}
356
+ <div className="max-h-48 overflow-y-auto p-1">
357
+ {filtered.length === 0 ? (
358
+ <p className="px-2 py-2 text-center text-[10px] text-[var(--lokvis-fg-subtle)]">无匹配 capability</p>
359
+ ) : (
360
+ filtered.map((cap) => (
361
+ <button
362
+ key={cap.name}
363
+ type="button"
364
+ onClick={() => handleSelect(cap.name)}
365
+ className="block w-full rounded px-2 py-1 text-left transition-colors hover:bg-[var(--lokvis-primary)]/10"
366
+ >
367
+ <span className="block font-mono text-[10px] font-medium text-[var(--lokvis-fg-muted)]">
368
+ {cap.name}
369
+ </span>
370
+ <span className="block truncate text-[9px] text-[var(--lokvis-fg-muted)]">
371
+ {cap.description}
372
+ </span>
373
+ </button>
374
+ ))
375
+ )}
376
+ </div>
377
+ </div>
378
+ )}
379
+ </div>
380
+ );
381
381
  }
382
382