@lokvis/ui-react 0.1.1-beta.0 → 0.2.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.
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,122 @@
1
+ /**
2
+ * ThemeToggle - 暗色模式切换按钮(W9.7)
3
+ *
4
+ * 使用 `useTheme()` hook 读写 localStorage + 跟随系统偏好。
5
+ * 与 `tokens.css` 的 `.dark` / `.light` 类双触发机制对齐。
6
+ *
7
+ * 交互:
8
+ * - 左键单击:在 light / dark 之间切换(基于当前 resolvedTheme 决定下一态)
9
+ * - 右键单击:弹出菜单(light / dark / system 三态)
10
+ * - ESC:关闭菜单
11
+ * - 点击菜单外部:关闭菜单
12
+ */
13
+
14
+ import * as React from 'react';
15
+ import { Icon } from '@lokvis/ui-core';
16
+ import { useTheme, type ThemeMode } from '../hooks/useTheme.js';
17
+
18
+ export interface ThemeToggleProps {
19
+ className?: string;
20
+ /** 是否显示文字标签(默认仅图标) */
21
+ showLabel?: boolean;
22
+ }
23
+
24
+ export function ThemeToggle({ className = '', showLabel = false }: ThemeToggleProps) {
25
+ const { theme, resolvedTheme, toggleTheme, setTheme } = useTheme();
26
+ const [menuOpen, setMenuOpen] = React.useState(false);
27
+ const btnRef = React.useRef<HTMLButtonElement>(null);
28
+
29
+ // 点击外部关闭菜单 + ESC 关闭
30
+ React.useEffect(() => {
31
+ if (!menuOpen) return;
32
+ const onDown = (e: MouseEvent) => {
33
+ // e.target 可能是任意 EventTarget;用 instanceof Node 类型守卫收窄,
34
+ // 避免 as 断言(与 WorkflowEditor 等处统一)
35
+ if (e.target instanceof Node && btnRef.current && !btnRef.current.contains(e.target)) {
36
+ setMenuOpen(false);
37
+ }
38
+ };
39
+ const onKey = (e: KeyboardEvent) => {
40
+ if (e.key === 'Escape') setMenuOpen(false);
41
+ };
42
+ document.addEventListener('mousedown', onDown);
43
+ document.addEventListener('keydown', onKey);
44
+ return () => {
45
+ document.removeEventListener('mousedown', onDown);
46
+ document.removeEventListener('keydown', onKey);
47
+ };
48
+ }, [menuOpen]);
49
+
50
+ const isDark = resolvedTheme === 'dark';
51
+
52
+ const icon = isDark ? (
53
+ <Icon size={14} strokeWidth={2}>
54
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
55
+ </Icon>
56
+ ) : (
57
+ <Icon size={14} strokeWidth={2}>
58
+ <circle cx="12" cy="12" r="4" />
59
+ <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
60
+ </Icon>
61
+ );
62
+
63
+ const label = theme === null ? 'System' : theme === 'dark' ? 'Dark' : 'Light';
64
+
65
+ return (
66
+ <div className={`relative ${className}`}>
67
+ <button
68
+ ref={btnRef}
69
+ type="button"
70
+ aria-haspopup="menu"
71
+ aria-expanded={menuOpen}
72
+ onClick={() => toggleTheme()}
73
+ onContextMenu={(e) => {
74
+ e.preventDefault();
75
+ setMenuOpen((v) => !v);
76
+ }}
77
+ aria-label={`Theme: ${label}. Click to toggle, right-click for options.`}
78
+ title={`Theme: ${label} (right-click for options)`}
79
+ className="flex h-8 w-8 items-center justify-center rounded-md text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
80
+ >
81
+ {icon}
82
+ {showLabel && <span className="ml-1 text-xs">{label}</span>}
83
+ </button>
84
+
85
+ {menuOpen && (
86
+ <div
87
+ role="menu"
88
+ className="absolute right-0 top-full z-50 mt-1 w-32 overflow-hidden rounded-md border border-zinc-200 bg-white py-1 shadow-lg dark:border-zinc-800 dark:bg-zinc-900"
89
+ >
90
+ {(['light', 'dark', null] as Array<ThemeMode | null>).map((mode) => {
91
+ const isActive = theme === mode;
92
+ const text = mode === null ? 'System' : mode === 'dark' ? 'Dark' : 'Light';
93
+ return (
94
+ <button
95
+ key={mode ?? 'system'}
96
+ type="button"
97
+ role="menuitemradio"
98
+ aria-checked={isActive}
99
+ onClick={() => {
100
+ setTheme(mode);
101
+ setMenuOpen(false);
102
+ }}
103
+ className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-xs transition-colors ${
104
+ isActive
105
+ ? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-950/30 dark:text-indigo-300'
106
+ : 'text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800'
107
+ }`}
108
+ >
109
+ <span>{text}</span>
110
+ {isActive && (
111
+ <Icon size={12} strokeWidth={3}>
112
+ <path d="M5 13l4 4L19 7" />
113
+ </Icon>
114
+ )}
115
+ </button>
116
+ );
117
+ })}
118
+ </div>
119
+ )}
120
+ </div>
121
+ );
122
+ }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * WorkflowEditor - 拖拽式工作流编辑器(W10.4)
3
+ *
4
+ * 增强 PipelineBar,提供节点拖拽重排能力:
5
+ * - 拖拽节点到任意位置重排线性链
6
+ * - 拖拽时显示插入指示器
7
+ * - 键盘支持:选中节点后 ← → 移动(无障碍)
8
+ * - 节点数上限 MAX_WORKFLOW_STEPS=5(M1 MVP 约束,UI 层常量)
9
+ *
10
+ * 与 PipelineBar 区别:
11
+ * - PipelineBar:只读 + 选中/删除(快速预览)
12
+ * - WorkflowEditor:可编辑 + 拖拽重排(编辑模式)
13
+ *
14
+ * 使用原生 HTML5 Drag and Drop API,无额外依赖。
15
+ * 触摸设备降级为按钮上下移动(HTML5 DnD 在移动端支持不佳)。
16
+ *
17
+ * @module WorkflowEditor
18
+ */
19
+
20
+ import * as React from 'react';
21
+ import { Icon } from '@lokvis/ui-core';
22
+ import type { Capability } from '@lokvis/schema';
23
+ import { useWorkspaceStore, MAX_WORKFLOW_STEPS } from '../store/index.js';
24
+
25
+ export interface WorkflowEditorProps {
26
+ className?: string;
27
+ }
28
+
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);
38
+
39
+ // 拖拽状态:被拖拽的节点索引 + 当前 hover 的插入位置
40
+ const [dragIndex, setDragIndex] = React.useState<number | null>(null);
41
+ const [hoverIndex, setHoverIndex] = React.useState<number | null>(null);
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
+ };
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
+ };
57
+
58
+ const handleDragLeave = () => {
59
+ // 不立即清空 hoverIndex,因为 dragleave 在子元素间会误触发
60
+ // 由 handleDrop / handleDragEnd 统一清空
61
+ };
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
+ };
71
+
72
+ const handleDragEnd = () => {
73
+ setDragIndex(null);
74
+ setHoverIndex(null);
75
+ };
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
+ };
90
+
91
+ const canAddMore = nodes.length < MAX_WORKFLOW_STEPS;
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>
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>
146
+
147
+ {/* W11.1: 节点之间可插入的连接器(+ 按钮) */}
148
+ <InsertConnector
149
+ index={0}
150
+ capabilities={capabilities}
151
+ onInsert={insertNodeAt}
152
+ disabled={nodes.length >= MAX_WORKFLOW_STEPS}
153
+ />
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>
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
+ })}
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>
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
+ );
243
+ }
244
+
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';
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
+ /**
263
+ * InsertConnector - 节点之间的连接器 + 插入按钮(W11.1)
264
+ *
265
+ * 默认显示箭头;hover 时变为 + 按钮,点击弹出 capability 选择菜单。
266
+ * 选择后调用 onInsert(index, capability)。
267
+ */
268
+ function InsertConnector({
269
+ index,
270
+ capabilities,
271
+ onInsert,
272
+ disabled,
273
+ }: {
274
+ index: number;
275
+ capabilities: Capability[];
276
+ onInsert: (index: number, capability: string) => void;
277
+ disabled?: boolean;
278
+ }) {
279
+ const [open, setOpen] = React.useState(false);
280
+ const [filter, setFilter] = React.useState('');
281
+ const ref = React.useRef<HTMLDivElement>(null);
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]);
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
+ );
307
+
308
+ const handleSelect = (capName: string) => {
309
+ onInsert(index, capName);
310
+ setOpen(false);
311
+ setFilter('');
312
+ };
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
+ }
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>
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
+ );
381
+ }
382
+
@@ -0,0 +1,101 @@
1
+ /**
2
+ * WorkflowTemplates - 工作流模板选择器(W11.4)
3
+ *
4
+ * 展示 5 个内置模板,点击即应用到当前工作流(替换现有 nodes)。
5
+ * 适合新用户快速上手,无需从零编排。
6
+ *
7
+ * 集成位置:
8
+ * - 可作为独立组件渲染(如在 Inspector 底部或 Command Palette 中)
9
+ * - Workspace 默认不渲染;由消费方按需挂载
10
+ *
11
+ * @module WorkflowTemplates
12
+ */
13
+
14
+ import * as React from 'react';
15
+ import { Icon } from '@lokvis/ui-core';
16
+ import { useWorkspaceStore } from '../store/index.js';
17
+ import { WORKFLOW_TEMPLATES, type WorkflowTemplate } from '../data/workflow-templates.js';
18
+
19
+ export interface WorkflowTemplatesProps {
20
+ className?: string;
21
+ /** 应用模板前是否需要确认(当前有节点时默认 true) */
22
+ confirmIfNotEmpty?: boolean;
23
+ /** 模板应用后的回调 */
24
+ onApply?: (template: WorkflowTemplate) => void;
25
+ }
26
+
27
+ export function WorkflowTemplates({
28
+ className = '',
29
+ confirmIfNotEmpty = true,
30
+ onApply,
31
+ }: WorkflowTemplatesProps) {
32
+ const loadWorkflowTemplate = useWorkspaceStore((s) => s.loadWorkflowTemplate);
33
+ const nodes = useWorkspaceStore((s) => s.nodes);
34
+
35
+ const handleApply = (tpl: WorkflowTemplate) => {
36
+ if (confirmIfNotEmpty && nodes.length > 0) {
37
+ if (!window.confirm(`应用模板「${tpl.name}」将替换当前 ${nodes.length} 个节点,继续?`)) {
38
+ return;
39
+ }
40
+ }
41
+ loadWorkflowTemplate(
42
+ tpl.nodes.map((n) => ({ capability: n.capability, params: n.params }))
43
+ );
44
+ onApply?.(tpl);
45
+ };
46
+
47
+ return (
48
+ <div
49
+ className={`flex flex-col ${className}`}
50
+ role="region"
51
+ aria-label="工作流模板"
52
+ >
53
+ <div className="flex items-center justify-between px-3 py-2 border-b border-zinc-200 dark:border-zinc-800">
54
+ <span className="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
55
+ Templates
56
+ </span>
57
+ <span className="text-[10px] text-zinc-400">{WORKFLOW_TEMPLATES.length} 内置</span>
58
+ </div>
59
+ <div className="grid grid-cols-1 gap-1.5 p-2">
60
+ {WORKFLOW_TEMPLATES.map((tpl) => (
61
+ <button
62
+ key={tpl.id}
63
+ type="button"
64
+ onClick={() => handleApply(tpl)}
65
+ className="group flex items-start gap-2 rounded-md border border-zinc-200 bg-white px-2.5 py-2 text-left transition-all hover:border-indigo-300 hover:bg-indigo-50/50 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:border-indigo-700 dark:hover:bg-indigo-950/30"
66
+ >
67
+ <span className="mt-0.5 text-base leading-none">{tpl.icon}</span>
68
+ <div className="flex-1 min-w-0">
69
+ <div className="flex items-center justify-between gap-1">
70
+ <span className="truncate text-[11px] font-medium text-zinc-700 dark:text-zinc-300">
71
+ {tpl.name}
72
+ </span>
73
+ <span className="shrink-0 rounded bg-zinc-100 px-1 py-0.5 text-[9px] font-medium uppercase text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
74
+ {tpl.nodes.length} 步
75
+ </span>
76
+ </div>
77
+ <p className="mt-0.5 truncate text-[10px] text-zinc-500 dark:text-zinc-400">
78
+ {tpl.description}
79
+ </p>
80
+ {/* 节点链预览 */}
81
+ <div className="mt-1 flex flex-wrap items-center gap-0.5">
82
+ {tpl.nodes.map((n, i) => (
83
+ <React.Fragment key={i}>
84
+ {i > 0 && (
85
+ <Icon size={8} className="text-zinc-300 dark:text-zinc-600">
86
+ <path d="m5 5 5 5-5 5" />
87
+ </Icon>
88
+ )}
89
+ <code className="rounded bg-zinc-100 px-1 py-0.5 text-[9px] text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400">
90
+ {n.capability}
91
+ </code>
92
+ </React.Fragment>
93
+ ))}
94
+ </div>
95
+ </div>
96
+ </button>
97
+ ))}
98
+ </div>
99
+ </div>
100
+ );
101
+ }