@lokvis/ui-react 0.1.1-beta.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/components/AssetPanel.d.ts +2 -2
- package/dist/components/AssetPanel.d.ts.map +1 -1
- package/dist/components/AssetPanel.js +79 -7
- package/dist/components/AssetPanel.js.map +1 -1
- package/dist/components/Canvas.d.ts +9 -1
- package/dist/components/Canvas.d.ts.map +1 -1
- package/dist/components/Canvas.js +39 -2
- package/dist/components/Canvas.js.map +1 -1
- package/dist/components/CommandPalette.d.ts +48 -0
- package/dist/components/CommandPalette.d.ts.map +1 -0
- package/dist/components/CommandPalette.js +154 -0
- package/dist/components/CommandPalette.js.map +1 -0
- package/dist/components/CompareSlider.d.ts +25 -0
- package/dist/components/CompareSlider.d.ts.map +1 -0
- package/dist/components/CompareSlider.js +106 -0
- package/dist/components/CompareSlider.js.map +1 -0
- package/dist/components/DownloadPanel.d.ts +20 -0
- package/dist/components/DownloadPanel.d.ts.map +1 -0
- package/dist/components/DownloadPanel.js +169 -0
- package/dist/components/DownloadPanel.js.map +1 -0
- package/dist/components/ErrorBanner.d.ts +22 -0
- package/dist/components/ErrorBanner.d.ts.map +1 -0
- package/dist/components/ErrorBanner.js +47 -0
- package/dist/components/ErrorBanner.js.map +1 -0
- package/dist/components/ExifPanel.d.ts +26 -0
- package/dist/components/ExifPanel.d.ts.map +1 -0
- package/dist/components/ExifPanel.js +104 -0
- package/dist/components/ExifPanel.js.map +1 -0
- package/dist/components/GlobalDropzone.d.ts +37 -0
- package/dist/components/GlobalDropzone.d.ts.map +1 -0
- package/dist/components/GlobalDropzone.js +154 -0
- package/dist/components/GlobalDropzone.js.map +1 -0
- package/dist/components/HistoryPanel.d.ts +17 -7
- package/dist/components/HistoryPanel.d.ts.map +1 -1
- package/dist/components/HistoryPanel.js +74 -62
- package/dist/components/HistoryPanel.js.map +1 -1
- package/dist/components/Inspector.d.ts.map +1 -1
- package/dist/components/Inspector.js +2 -1
- package/dist/components/Inspector.js.map +1 -1
- package/dist/components/ProgressBar.d.ts +18 -0
- package/dist/components/ProgressBar.d.ts.map +1 -0
- package/dist/components/ProgressBar.js +35 -0
- package/dist/components/ProgressBar.js.map +1 -0
- package/dist/components/StatusBar.d.ts +8 -2
- package/dist/components/StatusBar.d.ts.map +1 -1
- package/dist/components/StatusBar.js +59 -4
- package/dist/components/StatusBar.js.map +1 -1
- package/dist/components/ThemeToggle.d.ts +20 -0
- package/dist/components/ThemeToggle.d.ts.map +1 -0
- package/dist/components/ThemeToggle.js +60 -0
- package/dist/components/ThemeToggle.js.map +1 -0
- package/dist/components/WorkflowEditor.d.ts +24 -0
- package/dist/components/WorkflowEditor.d.ts.map +1 -0
- package/dist/components/WorkflowEditor.js +160 -0
- package/dist/components/WorkflowEditor.js.map +1 -0
- package/dist/components/WorkflowTemplates.d.ts +23 -0
- package/dist/components/WorkflowTemplates.d.ts.map +1 -0
- package/dist/components/WorkflowTemplates.js +32 -0
- package/dist/components/WorkflowTemplates.js.map +1 -0
- package/dist/components/Workspace.d.ts +48 -2
- package/dist/components/Workspace.d.ts.map +1 -1
- package/dist/components/Workspace.js +78 -4
- package/dist/components/Workspace.js.map +1 -1
- package/dist/data/workflow-templates.d.ts +42 -0
- package/dist/data/workflow-templates.d.ts.map +1 -0
- package/dist/data/workflow-templates.js +112 -0
- package/dist/data/workflow-templates.js.map +1 -0
- package/dist/hooks/useDebouncedRun.d.ts +49 -0
- package/dist/hooks/useDebouncedRun.d.ts.map +1 -0
- package/dist/hooks/useDebouncedRun.js +117 -0
- package/dist/hooks/useDebouncedRun.js.map +1 -0
- package/dist/hooks/useLokvis.js +1 -1
- package/dist/hooks/useMediaQuery.d.ts +27 -0
- package/dist/hooks/useMediaQuery.d.ts.map +1 -0
- package/dist/hooks/useMediaQuery.js +41 -0
- package/dist/hooks/useMediaQuery.js.map +1 -0
- package/dist/hooks/useShareLink.d.ts +58 -0
- package/dist/hooks/useShareLink.d.ts.map +1 -0
- package/dist/hooks/useShareLink.js +120 -0
- package/dist/hooks/useShareLink.js.map +1 -0
- package/dist/hooks/useTheme.d.ts +30 -0
- package/dist/hooks/useTheme.d.ts.map +1 -0
- package/dist/hooks/useTheme.js +105 -0
- package/dist/hooks/useTheme.js.map +1 -0
- package/dist/hooks/useWorkflowProgress.d.ts +28 -0
- package/dist/hooks/useWorkflowProgress.d.ts.map +1 -0
- package/dist/hooks/useWorkflowProgress.js +25 -0
- package/dist/hooks/useWorkflowProgress.js.map +1 -0
- package/dist/hooks/useWorkflows.d.ts +86 -0
- package/dist/hooks/useWorkflows.d.ts.map +1 -0
- package/dist/hooks/useWorkflows.js +247 -0
- package/dist/hooks/useWorkflows.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -1
- package/dist/store/assets-slice.d.ts.map +1 -1
- package/dist/store/assets-slice.js +2 -0
- package/dist/store/assets-slice.js.map +1 -1
- package/dist/store/history-slice.d.ts +18 -0
- package/dist/store/history-slice.d.ts.map +1 -0
- package/dist/store/history-slice.js +42 -0
- package/dist/store/history-slice.js.map +1 -0
- package/dist/store/index.d.ts +1 -0
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +3 -0
- package/dist/store/index.js.map +1 -1
- package/dist/store/runtime-slice.d.ts +10 -2
- package/dist/store/runtime-slice.d.ts.map +1 -1
- package/dist/store/runtime-slice.js +77 -28
- package/dist/store/runtime-slice.js.map +1 -1
- package/dist/store/types.d.ts +70 -1
- package/dist/store/types.d.ts.map +1 -1
- package/dist/store/types.js +10 -0
- 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 +128 -9
- package/dist/store/workflow-slice.js.map +1 -1
- package/package.json +9 -6
- package/src/components/AssetPanel.tsx +149 -8
- package/src/components/Canvas.tsx +79 -2
- package/src/components/CommandPalette.tsx +300 -0
- package/src/components/CompareSlider.tsx +186 -0
- package/src/components/DownloadPanel.tsx +252 -0
- package/src/components/ErrorBanner.tsx +95 -0
- package/src/components/ExifPanel.tsx +187 -0
- package/src/components/GlobalDropzone.tsx +220 -0
- package/src/components/HistoryPanel.tsx +231 -104
- package/src/components/Inspector.tsx +4 -0
- package/src/components/ProgressBar.tsx +104 -0
- package/src/components/StatusBar.tsx +124 -5
- package/src/components/ThemeToggle.tsx +122 -0
- package/src/components/WorkflowEditor.tsx +382 -0
- package/src/components/WorkflowTemplates.tsx +101 -0
- package/src/components/Workspace.tsx +205 -8
- package/src/data/workflow-templates.ts +137 -0
- package/src/hooks/useDebouncedRun.ts +151 -0
- package/src/hooks/useLokvis.ts +1 -1
- package/src/hooks/useMediaQuery.ts +53 -0
- package/src/hooks/useShareLink.ts +158 -0
- package/src/hooks/useTheme.ts +116 -0
- package/src/hooks/useWorkflowProgress.ts +41 -0
- package/src/hooks/useWorkflows.ts +326 -0
- package/src/index.ts +32 -0
- package/src/store/assets-slice.ts +2 -0
- package/src/store/history-slice.ts +65 -0
- package/src/store/index.ts +3 -0
- package/src/store/runtime-slice.ts +89 -32
- package/src/store/types.ts +71 -1
- 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
|
+
空工作流 — 从右侧 <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
|
+
}
|