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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/LICENSE +21 -0
  2. package/dist/components/AssetPanel.d.ts +2 -2
  3. package/dist/components/AssetPanel.d.ts.map +1 -1
  4. package/dist/components/AssetPanel.js +79 -7
  5. package/dist/components/AssetPanel.js.map +1 -1
  6. package/dist/components/Canvas.d.ts +9 -1
  7. package/dist/components/Canvas.d.ts.map +1 -1
  8. package/dist/components/Canvas.js +39 -2
  9. package/dist/components/Canvas.js.map +1 -1
  10. package/dist/components/CommandPalette.d.ts +48 -0
  11. package/dist/components/CommandPalette.d.ts.map +1 -0
  12. package/dist/components/CommandPalette.js +154 -0
  13. package/dist/components/CommandPalette.js.map +1 -0
  14. package/dist/components/CompareSlider.d.ts +25 -0
  15. package/dist/components/CompareSlider.d.ts.map +1 -0
  16. package/dist/components/CompareSlider.js +106 -0
  17. package/dist/components/CompareSlider.js.map +1 -0
  18. package/dist/components/DownloadPanel.d.ts +20 -0
  19. package/dist/components/DownloadPanel.d.ts.map +1 -0
  20. package/dist/components/DownloadPanel.js +169 -0
  21. package/dist/components/DownloadPanel.js.map +1 -0
  22. package/dist/components/ErrorBanner.d.ts +22 -0
  23. package/dist/components/ErrorBanner.d.ts.map +1 -0
  24. package/dist/components/ErrorBanner.js +47 -0
  25. package/dist/components/ErrorBanner.js.map +1 -0
  26. package/dist/components/ExifPanel.d.ts +26 -0
  27. package/dist/components/ExifPanel.d.ts.map +1 -0
  28. package/dist/components/ExifPanel.js +104 -0
  29. package/dist/components/ExifPanel.js.map +1 -0
  30. package/dist/components/GlobalDropzone.d.ts +37 -0
  31. package/dist/components/GlobalDropzone.d.ts.map +1 -0
  32. package/dist/components/GlobalDropzone.js +154 -0
  33. package/dist/components/GlobalDropzone.js.map +1 -0
  34. package/dist/components/HistoryPanel.d.ts +17 -7
  35. package/dist/components/HistoryPanel.d.ts.map +1 -1
  36. package/dist/components/HistoryPanel.js +74 -62
  37. package/dist/components/HistoryPanel.js.map +1 -1
  38. package/dist/components/Inspector.d.ts.map +1 -1
  39. package/dist/components/Inspector.js +2 -1
  40. package/dist/components/Inspector.js.map +1 -1
  41. package/dist/components/ProgressBar.d.ts +18 -0
  42. package/dist/components/ProgressBar.d.ts.map +1 -0
  43. package/dist/components/ProgressBar.js +35 -0
  44. package/dist/components/ProgressBar.js.map +1 -0
  45. package/dist/components/StatusBar.d.ts +8 -2
  46. package/dist/components/StatusBar.d.ts.map +1 -1
  47. package/dist/components/StatusBar.js +59 -4
  48. package/dist/components/StatusBar.js.map +1 -1
  49. package/dist/components/ThemeToggle.d.ts +20 -0
  50. package/dist/components/ThemeToggle.d.ts.map +1 -0
  51. package/dist/components/ThemeToggle.js +60 -0
  52. package/dist/components/ThemeToggle.js.map +1 -0
  53. package/dist/components/WorkflowEditor.d.ts +24 -0
  54. package/dist/components/WorkflowEditor.d.ts.map +1 -0
  55. package/dist/components/WorkflowEditor.js +160 -0
  56. package/dist/components/WorkflowEditor.js.map +1 -0
  57. package/dist/components/WorkflowTemplates.d.ts +23 -0
  58. package/dist/components/WorkflowTemplates.d.ts.map +1 -0
  59. package/dist/components/WorkflowTemplates.js +32 -0
  60. package/dist/components/WorkflowTemplates.js.map +1 -0
  61. package/dist/components/Workspace.d.ts +48 -2
  62. package/dist/components/Workspace.d.ts.map +1 -1
  63. package/dist/components/Workspace.js +78 -4
  64. package/dist/components/Workspace.js.map +1 -1
  65. package/dist/data/workflow-templates.d.ts +42 -0
  66. package/dist/data/workflow-templates.d.ts.map +1 -0
  67. package/dist/data/workflow-templates.js +112 -0
  68. package/dist/data/workflow-templates.js.map +1 -0
  69. package/dist/hooks/useDebouncedRun.d.ts +49 -0
  70. package/dist/hooks/useDebouncedRun.d.ts.map +1 -0
  71. package/dist/hooks/useDebouncedRun.js +117 -0
  72. package/dist/hooks/useDebouncedRun.js.map +1 -0
  73. package/dist/hooks/useLokvis.js +1 -1
  74. package/dist/hooks/useMediaQuery.d.ts +27 -0
  75. package/dist/hooks/useMediaQuery.d.ts.map +1 -0
  76. package/dist/hooks/useMediaQuery.js +41 -0
  77. package/dist/hooks/useMediaQuery.js.map +1 -0
  78. package/dist/hooks/useShareLink.d.ts +58 -0
  79. package/dist/hooks/useShareLink.d.ts.map +1 -0
  80. package/dist/hooks/useShareLink.js +120 -0
  81. package/dist/hooks/useShareLink.js.map +1 -0
  82. package/dist/hooks/useTheme.d.ts +30 -0
  83. package/dist/hooks/useTheme.d.ts.map +1 -0
  84. package/dist/hooks/useTheme.js +105 -0
  85. package/dist/hooks/useTheme.js.map +1 -0
  86. package/dist/hooks/useWorkflowProgress.d.ts +28 -0
  87. package/dist/hooks/useWorkflowProgress.d.ts.map +1 -0
  88. package/dist/hooks/useWorkflowProgress.js +25 -0
  89. package/dist/hooks/useWorkflowProgress.js.map +1 -0
  90. package/dist/hooks/useWorkflows.d.ts +86 -0
  91. package/dist/hooks/useWorkflows.d.ts.map +1 -0
  92. package/dist/hooks/useWorkflows.js +247 -0
  93. package/dist/hooks/useWorkflows.js.map +1 -0
  94. package/dist/index.d.ts +26 -0
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +26 -0
  97. package/dist/index.js.map +1 -1
  98. package/dist/store/assets-slice.d.ts.map +1 -1
  99. package/dist/store/assets-slice.js +2 -0
  100. package/dist/store/assets-slice.js.map +1 -1
  101. package/dist/store/history-slice.d.ts +18 -0
  102. package/dist/store/history-slice.d.ts.map +1 -0
  103. package/dist/store/history-slice.js +42 -0
  104. package/dist/store/history-slice.js.map +1 -0
  105. package/dist/store/index.d.ts +1 -0
  106. package/dist/store/index.d.ts.map +1 -1
  107. package/dist/store/index.js +3 -0
  108. package/dist/store/index.js.map +1 -1
  109. package/dist/store/runtime-slice.d.ts +10 -2
  110. package/dist/store/runtime-slice.d.ts.map +1 -1
  111. package/dist/store/runtime-slice.js +77 -28
  112. package/dist/store/runtime-slice.js.map +1 -1
  113. package/dist/store/types.d.ts +70 -1
  114. package/dist/store/types.d.ts.map +1 -1
  115. package/dist/store/types.js +10 -0
  116. package/dist/store/types.js.map +1 -1
  117. package/dist/store/workflow-slice.d.ts +1 -1
  118. package/dist/store/workflow-slice.d.ts.map +1 -1
  119. package/dist/store/workflow-slice.js +128 -9
  120. package/dist/store/workflow-slice.js.map +1 -1
  121. package/package.json +9 -6
  122. package/src/components/AssetPanel.tsx +149 -8
  123. package/src/components/Canvas.tsx +79 -2
  124. package/src/components/CommandPalette.tsx +300 -0
  125. package/src/components/CompareSlider.tsx +186 -0
  126. package/src/components/DownloadPanel.tsx +252 -0
  127. package/src/components/ErrorBanner.tsx +95 -0
  128. package/src/components/ExifPanel.tsx +187 -0
  129. package/src/components/GlobalDropzone.tsx +220 -0
  130. package/src/components/HistoryPanel.tsx +231 -104
  131. package/src/components/Inspector.tsx +4 -0
  132. package/src/components/ProgressBar.tsx +104 -0
  133. package/src/components/StatusBar.tsx +124 -5
  134. package/src/components/ThemeToggle.tsx +122 -0
  135. package/src/components/WorkflowEditor.tsx +382 -0
  136. package/src/components/WorkflowTemplates.tsx +101 -0
  137. package/src/components/Workspace.tsx +205 -8
  138. package/src/data/workflow-templates.ts +137 -0
  139. package/src/hooks/useDebouncedRun.ts +151 -0
  140. package/src/hooks/useLokvis.ts +1 -1
  141. package/src/hooks/useMediaQuery.ts +53 -0
  142. package/src/hooks/useShareLink.ts +158 -0
  143. package/src/hooks/useTheme.ts +116 -0
  144. package/src/hooks/useWorkflowProgress.ts +41 -0
  145. package/src/hooks/useWorkflows.ts +326 -0
  146. package/src/index.ts +32 -0
  147. package/src/store/assets-slice.ts +2 -0
  148. package/src/store/history-slice.ts +65 -0
  149. package/src/store/index.ts +3 -0
  150. package/src/store/runtime-slice.ts +89 -32
  151. package/src/store/types.ts +71 -1
  152. package/src/store/workflow-slice.ts +139 -10
@@ -1,127 +1,254 @@
1
1
  /**
2
- * HistoryPanel - 处理历史面板
2
+ * HistoryPanel - 历史栈面板(W7.1 / W9.1)
3
3
  *
4
- * 监听 Runtime 事件,记录最近处理过的资产与工作流执行。
5
- * MVP 仅做只读展示,撤销/重做通过 Runtime API(已留接口)。
4
+ * 展示当前工作流的执行历史,支持:
5
+ * - Undo / Redo 按钮(根据 cursor 禁用)
6
+ * - 点击条目直接跳转(jumpTo)
7
+ * - cursor 当前位置高亮,cursor 之后的条目标灰(redo 分支)
8
+ *
9
+ * W9.1: 支持 `variant` 切换布局方向
10
+ * - `vertical`(默认):侧栏垂直布局,适合嵌入左/右栏
11
+ * - `horizontal`:底部横条布局,作为底部 history bar 使用
12
+ *
13
+ * 数据来自 history-slice(store 单一来源),由 runtime-slice 的
14
+ * history:changed 订阅自动更新,组件无需自行订阅事件。
15
+ *
16
+ * 历史条目渲染:把 HistoryEntry 的 capability + params 摘要为可读标签。
6
17
  */
7
18
 
8
- import * as React from 'react';
9
- import type { LokvisEvent } from '@lokvis/schema';
19
+ import type { HistoryEntry } from '@lokvis/schema';
20
+ import { Icon } from '@lokvis/ui-core';
10
21
  import { useWorkspaceStore } from '../store/index.js';
11
22
 
12
- interface HistoryItem {
13
- id: string;
14
- type: string;
15
- message: string;
16
- timestamp: number;
17
- }
18
-
19
23
  export interface HistoryPanelProps {
20
24
  className?: string;
21
- /** 最大记录数 */
22
- maxItems?: number;
25
+ /** 布局方向(W9.1):vertical 侧栏,horizontal 底部横条 */
26
+ variant?: 'vertical' | 'horizontal';
23
27
  }
24
28
 
25
- export function HistoryPanel({ className = '', maxItems = 50 }: HistoryPanelProps) {
26
- const runtime = useWorkspaceStore((s) => s.runtime);
27
- const [items, setItems] = React.useState<HistoryItem[]>([]);
29
+ export function HistoryPanel({ className = '', variant = 'vertical' }: HistoryPanelProps) {
30
+ const entries = useWorkspaceStore((s) => s.historyEntries);
31
+ const cursor = useWorkspaceStore((s) => s.historyCursor);
32
+ const workflowId = useWorkspaceStore((s) => s.historyWorkflowId);
33
+ const undo = useWorkspaceStore((s) => s.undo);
34
+ const redo = useWorkspaceStore((s) => s.redo);
35
+ const jumpTo = useWorkspaceStore((s) => s.jumpToHistory);
36
+
37
+ const canUndo = cursor >= 0;
38
+ const canRedo = cursor < entries.length - 1;
39
+ const horizontal = variant === 'horizontal';
28
40
 
29
- React.useEffect(() => {
30
- if (!runtime) return;
31
- const push = (e: LokvisEvent) => {
32
- const item: HistoryItem = {
33
- id: `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
34
- type: e.type,
35
- message: formatEvent(e),
36
- timestamp: Date.now(),
37
- };
38
- setItems((prev) => [item, ...prev].slice(0, maxItems));
39
- };
40
- // 使用 onAny 订阅所有事件,避免枚举所有类型
41
- const off = runtime.eventBus.onAny(push);
42
- return () => {
43
- off();
44
- };
45
- // eslint-disable-next-line react-hooks/exhaustive-deps
46
- }, [runtime]);
41
+ const undoBtn = (
42
+ <button
43
+ type="button"
44
+ onClick={() => void undo()}
45
+ disabled={!canUndo}
46
+ aria-label="Undo"
47
+ className="rounded p-1 text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700 disabled:cursor-not-allowed disabled:opacity-30 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
48
+ >
49
+ <Icon size={13}>
50
+ <path d="M9 14L4 9l5-5" />
51
+ <path d="M4 9h11a4 4 0 0 1 0 8h-3" />
52
+ </Icon>
53
+ </button>
54
+ );
55
+ const redoBtn = (
56
+ <button
57
+ type="button"
58
+ onClick={() => void redo()}
59
+ disabled={!canRedo}
60
+ aria-label="Redo"
61
+ className="rounded p-1 text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700 disabled:cursor-not-allowed disabled:opacity-30 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
62
+ >
63
+ <Icon size={13}>
64
+ <path d="M15 14l5-5-5-5" />
65
+ <path d="M20 9H9a4 4 0 0 0 0 8h3" />
66
+ </Icon>
67
+ </button>
68
+ );
47
69
 
70
+ const headerEl = (
71
+ <div
72
+ className={`flex shrink-0 items-center justify-between px-3 ${
73
+ horizontal ? 'h-full border-r border-zinc-200 dark:border-zinc-800' : 'h-10 border-b border-zinc-200 dark:border-zinc-800'
74
+ }`}
75
+ >
76
+ <span className="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
77
+ History
78
+ </span>
79
+ <div className="flex items-center gap-0.5">
80
+ {undoBtn}
81
+ {redoBtn}
82
+ </div>
83
+ </div>
84
+ );
85
+
86
+ // 空状态
87
+ if (!workflowId || entries.length === 0) {
88
+ return (
89
+ <aside
90
+ className={`flex shrink-0 ${
91
+ horizontal
92
+ ? 'h-12 flex-row items-center border-t border-zinc-200 dark:border-zinc-800'
93
+ : 'w-48 flex-col border-r border-zinc-200 dark:border-zinc-800'
94
+ } ${className}`}
95
+ aria-label="History panel"
96
+ >
97
+ {headerEl}
98
+ <p className="px-3 text-[10px] text-zinc-400">No history yet</p>
99
+ </aside>
100
+ );
101
+ }
102
+
103
+ if (horizontal) {
104
+ // 横向布局:header | entries(横向滚动)
105
+ return (
106
+ <aside
107
+ className={`flex h-12 shrink-0 items-stretch border-t border-zinc-200 dark:border-zinc-800 ${className}`}
108
+ aria-label="History panel"
109
+ >
110
+ {headerEl}
111
+ <ol className="flex flex-1 items-center gap-1 overflow-x-auto px-2">
112
+ {/* 初始状态项 */}
113
+ <li className="shrink-0">
114
+ <button
115
+ type="button"
116
+ onClick={() => void jumpTo(-1)}
117
+ className={`flex items-center gap-1.5 rounded px-2 py-1 text-[10px] transition-colors ${
118
+ cursor === -1
119
+ ? 'bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-950/30 dark:text-indigo-300 dark:ring-indigo-800'
120
+ : 'text-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800'
121
+ }`}
122
+ >
123
+ <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-zinc-300 dark:bg-zinc-600" aria-hidden="true" />
124
+ <span className="truncate">Initial</span>
125
+ </button>
126
+ </li>
127
+ {entries.map((entry, i) => {
128
+ const isCurrent = i === cursor;
129
+ const isRedoBranch = i > cursor;
130
+ return (
131
+ <li key={entry.id} className="shrink-0">
132
+ <button
133
+ type="button"
134
+ onClick={() => void jumpTo(i)}
135
+ aria-current={isCurrent ? 'step' : undefined}
136
+ className={`flex items-center gap-1.5 rounded px-2 py-1 text-[10px] transition-colors ${
137
+ isCurrent
138
+ ? 'bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-950/30 dark:text-indigo-300 dark:ring-indigo-800'
139
+ : isRedoBranch
140
+ ? 'text-zinc-300 hover:bg-zinc-50 dark:text-zinc-600 dark:hover:bg-zinc-800/50'
141
+ : 'text-zinc-600 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-800'
142
+ }`}
143
+ >
144
+ <span
145
+ className={`h-1.5 w-1.5 shrink-0 rounded-full ${
146
+ isCurrent
147
+ ? 'bg-indigo-500'
148
+ : isRedoBranch
149
+ ? 'bg-zinc-200 dark:bg-zinc-700'
150
+ : 'bg-zinc-400 dark:bg-zinc-500'
151
+ }`}
152
+ aria-hidden="true"
153
+ />
154
+ <span className="truncate font-medium">{describeHistoryEntry(entry)}</span>
155
+ </button>
156
+ </li>
157
+ );
158
+ })}
159
+ </ol>
160
+ </aside>
161
+ );
162
+ }
163
+
164
+ // 垂直布局(原实现)
48
165
  return (
49
166
  <aside
50
- className={`flex h-32 flex-col border-t border-zinc-200 bg-zinc-50/50 dark:border-zinc-800 dark:bg-zinc-950/50 ${className}`}
167
+ className={`flex w-48 shrink-0 flex-col border-r border-zinc-200 dark:border-zinc-800 ${className}`}
168
+ aria-label="History panel"
51
169
  >
52
- <div className="flex items-center justify-between border-b border-zinc-200 px-4 py-1.5 dark:border-zinc-800">
53
- <h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
54
- History
55
- </h3>
56
- {items.length > 0 && (
57
- <button
58
- type="button"
59
- onClick={() => setItems([])}
60
- className="text-xs text-zinc-400 hover:text-zinc-600"
61
- >
62
- Clear
63
- </button>
64
- )}
65
- </div>
66
- <div className="flex-1 overflow-x-auto overflow-y-hidden px-2 py-1.5">
67
- {items.length === 0 ? (
68
- <p className="px-2 text-xs text-zinc-400">No history yet</p>
69
- ) : (
70
- <ul className="flex h-full items-stretch gap-1.5">
71
- {items.map((item) => (
72
- <li
73
- key={item.id}
74
- className="flex w-56 flex-none flex-col justify-between rounded border border-zinc-200 bg-white px-2 py-1 text-xs dark:border-zinc-800 dark:bg-zinc-900"
75
- >
76
- <div className="flex items-center justify-between gap-2">
77
- <span className="font-mono text-[10px] text-zinc-400">
78
- {new Date(item.timestamp).toLocaleTimeString()}
79
- </span>
80
- <span className="font-mono text-[10px] text-zinc-400">{item.type}</span>
81
- </div>
82
- <div className="truncate text-zinc-600 dark:text-zinc-300" title={item.message}>
83
- {item.message}
84
- </div>
170
+ {headerEl}
171
+ <div className="flex-1 overflow-y-auto px-2 py-2">
172
+ <ol className="space-y-0.5">
173
+ {/* 初始状态项(index=-1) */}
174
+ <li>
175
+ <button
176
+ type="button"
177
+ onClick={() => void jumpTo(-1)}
178
+ className={`flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-[10px] transition-colors ${
179
+ cursor === -1
180
+ ? 'bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-950/30 dark:text-indigo-300 dark:ring-indigo-800'
181
+ : 'text-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800'
182
+ }`}
183
+ >
184
+ <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-zinc-300 dark:bg-zinc-600" aria-hidden="true" />
185
+ <span className="truncate">Initial</span>
186
+ </button>
187
+ </li>
188
+ {entries.map((entry, i) => {
189
+ const isCurrent = i === cursor;
190
+ const isRedoBranch = i > cursor;
191
+ return (
192
+ <li key={entry.id}>
193
+ <button
194
+ type="button"
195
+ onClick={() => void jumpTo(i)}
196
+ aria-current={isCurrent ? 'step' : undefined}
197
+ className={`flex w-full items-start gap-1.5 rounded px-2 py-1 text-left text-[10px] transition-colors ${
198
+ isCurrent
199
+ ? 'bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-950/30 dark:text-indigo-300 dark:ring-indigo-800'
200
+ : isRedoBranch
201
+ ? 'text-zinc-300 hover:bg-zinc-50 dark:text-zinc-600 dark:hover:bg-zinc-800/50'
202
+ : 'text-zinc-600 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-800'
203
+ }`}
204
+ >
205
+ <span
206
+ className={`mt-0.5 h-1.5 w-1.5 shrink-0 rounded-full ${
207
+ isCurrent
208
+ ? 'bg-indigo-500'
209
+ : isRedoBranch
210
+ ? 'bg-zinc-200 dark:bg-zinc-700'
211
+ : 'bg-zinc-400 dark:bg-zinc-500'
212
+ }`}
213
+ aria-hidden="true"
214
+ />
215
+ <div className="min-w-0 flex-1">
216
+ <div className="truncate font-medium">
217
+ {describeHistoryEntry(entry)}
218
+ </div>
219
+ <div className="text-[9px] text-zinc-400">
220
+ {new Date(entry.timestamp).toLocaleTimeString()}
221
+ </div>
222
+ </div>
223
+ </button>
85
224
  </li>
86
- ))}
87
- </ul>
88
- )}
225
+ );
226
+ })}
227
+ </ol>
89
228
  </div>
90
229
  </aside>
91
230
  );
92
231
  }
93
232
 
94
- function formatEvent(e: LokvisEvent): string {
95
- switch (e.type) {
96
- case 'asset:imported':
97
- return `Imported ${e.metadata.mimeType}`;
98
- case 'asset:removed':
99
- return `Removed asset ${e.assetId.slice(0, 8)}`;
100
- case 'workflow:started':
101
- return `Workflow started: ${e.workflowId.slice(0, 8)}`;
102
- case 'workflow:completed':
103
- return `Workflow ${e.result.status} in ${e.result.duration}ms`;
104
- case 'workflow:cancelled':
105
- return 'Workflow cancelled';
106
- case 'workflow:paused':
107
- return 'Workflow paused';
108
- case 'workflow:resumed':
109
- return 'Workflow resumed';
110
- case 'node:started':
111
- return `Node started: ${e.nodeId.slice(0, 8)}`;
112
- case 'node:finished':
113
- return `Node finished: ${e.nodeId.slice(0, 8)} (${e.duration}ms)`;
114
- case 'node:failed':
115
- return `Node failed: ${e.error.message}`;
116
- case 'plugin:loaded':
117
- return `Plugin loaded: ${e.name}@${e.version}`;
118
- case 'export:completed':
119
- return `Exported ${e.format} (${(e.size / 1024).toFixed(1)} KB)`;
120
- case 'capability:registered':
121
- return `Capability registered: ${e.capability} (${e.engine})`;
122
- case 'history:changed':
123
- return `History changed (${e.entries.length} entries)`;
124
- default:
125
- return (e as { type: string }).type;
233
+ /** 把 HistoryEntry 摘要为可读标签(capability 简称 + 关键参数) */
234
+ function describeHistoryEntry(entry: HistoryEntry): string {
235
+ // image.resize → Resize;image.watermark → Watermark
236
+ const short = entry.capability.replace(/^image\./, '');
237
+ const cap = short.charAt(0).toUpperCase() + short.slice(1);
238
+ const params = entry.params ?? {};
239
+ // 摘取 1-2 个关键参数作为提示
240
+ const hints: string[] = [];
241
+ if (typeof params.width === 'number' || typeof params.width === 'string') {
242
+ hints.push(`${params.width}w`);
243
+ }
244
+ if (typeof params.quality === 'number') {
245
+ hints.push(`q${params.quality}`);
246
+ }
247
+ if (typeof params.format === 'string') {
248
+ hints.push(String(params.format));
249
+ }
250
+ if (typeof params.text === 'string' && params.text) {
251
+ hints.push(`"${String(params.text).slice(0, 8)}"`);
126
252
  }
253
+ return hints.length > 0 ? `${cap} (${hints.join(' ')})` : cap;
127
254
  }
@@ -9,6 +9,7 @@ import * as React from 'react';
9
9
  import { Icon } from '@lokvis/ui-core';
10
10
  import { useWorkspaceStore } from '../store/index.js';
11
11
  import { ParamForm } from './ParamForm.js';
12
+ import { ExifPanel } from './ExifPanel.js';
12
13
 
13
14
  export interface InspectorProps {
14
15
  className?: string;
@@ -49,6 +50,9 @@ export function Inspector({ className = '' }: InspectorProps) {
49
50
  <aside
50
51
  className={`flex w-72 shrink-0 flex-col border-l border-zinc-200 dark:border-zinc-800 ${className}`}
51
52
  >
53
+ {/* EXIF panel (selected image asset only, hides automatically for non-image) */}
54
+ <ExifPanel />
55
+
52
56
  {/* Configure section (when a node is selected) */}
53
57
  {showConfigure && (
54
58
  <div className="shrink-0 border-b border-zinc-200 dark:border-zinc-800">
@@ -0,0 +1,104 @@
1
+ /**
2
+ * ProgressBar - 工作流执行进度条 + 取消按钮(W11.6)
3
+ *
4
+ * 展示:
5
+ * - 节点完成进度(成功数 / 总数)
6
+ * - 横向进度条(running 时动画)
7
+ * - Cancel 按钮(调用 cancelRun)
8
+ * - 状态消息(statusMessage)
9
+ *
10
+ * 仅在 running 或最近一次执行有结果时显示。
11
+ *
12
+ * @module ProgressBar
13
+ */
14
+
15
+ import { Icon } from '@lokvis/ui-core';
16
+ import { useWorkspaceStore } from '../store/index.js';
17
+ import { useWorkflowProgress } from '../hooks/useWorkflowProgress.js';
18
+
19
+ export interface ProgressBarProps {
20
+ className?: string;
21
+ }
22
+
23
+ export function ProgressBar({ className = '' }: ProgressBarProps) {
24
+ const running = useWorkspaceStore((s) => s.running);
25
+ const statusMessage = useWorkspaceStore((s) => s.statusMessage);
26
+ const cancelRun = useWorkspaceStore((s) => s.cancelRun);
27
+
28
+ // 进度计算抽取到共享 hook(与 StatusBar 共用,避免重复逻辑)
29
+ const { total, done, failedCount, pct, hasFailure } = useWorkflowProgress();
30
+
31
+ // 没有节点或未运行且无最近结果时不渲染
32
+ if (total === 0) return null;
33
+ if (!running && done === 0) return null;
34
+
35
+ return (
36
+ <div
37
+ className={`flex items-center gap-2 border-b border-zinc-200 bg-white px-3 py-1.5 dark:border-zinc-800 dark:bg-zinc-900 ${className}`}
38
+ role="status"
39
+ aria-live="polite"
40
+ aria-label={`工作流进度:${done} / ${total}`}
41
+ >
42
+ {/* 进度条 */}
43
+ <div className="relative h-1.5 flex-1 overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-800">
44
+ <div
45
+ className={`absolute inset-y-0 left-0 transition-all duration-300 ${
46
+ hasFailure
47
+ ? 'bg-red-500'
48
+ : running
49
+ ? 'bg-indigo-500'
50
+ : 'bg-emerald-500'
51
+ } ${running ? 'animate-pulse' : ''}`}
52
+ style={{ width: `${pct}%` }}
53
+ />
54
+ </div>
55
+
56
+ {/* 计数 */}
57
+ <span className="shrink-0 text-[10px] font-medium tabular-nums text-zinc-500 dark:text-zinc-400">
58
+ {done}/{total}
59
+ {failedCount > 0 && (
60
+ <span className="ml-1 text-red-500">({failedCount} failed)</span>
61
+ )}
62
+ </span>
63
+
64
+ {/* 状态消息(running 时显示) */}
65
+ {running && statusMessage && (
66
+ <span className="hidden shrink-0 truncate text-[10px] text-zinc-400 sm:inline">
67
+ {statusMessage}
68
+ </span>
69
+ )}
70
+
71
+ {/* Cancel 按钮(仅 running 时) */}
72
+ {running && (
73
+ <button
74
+ type="button"
75
+ onClick={() => void cancelRun()}
76
+ aria-label="取消运行"
77
+ title="取消运行"
78
+ className="flex shrink-0 items-center gap-1 rounded-md bg-red-50 px-2 py-0.5 text-[10px] font-medium text-red-600 transition-colors hover:bg-red-100 dark:bg-red-950/30 dark:text-red-400 dark:hover:bg-red-900/40"
79
+ >
80
+ <Icon size={10} strokeWidth={3}><path d="M6 6l12 12M6 18L18 6" /></Icon>
81
+ Cancel
82
+ </button>
83
+ )}
84
+
85
+ {/* 完成图标(非 running 且有结果) */}
86
+ {!running && done === total && (
87
+ <span
88
+ className={`flex shrink-0 items-center gap-1 text-[10px] font-medium ${
89
+ hasFailure ? 'text-red-500' : 'text-emerald-500'
90
+ }`}
91
+ >
92
+ <Icon size={10} strokeWidth={3}>
93
+ {hasFailure ? (
94
+ <path d="M12 2L2 22h20L12 2zM12 9v5M12 17v.01" />
95
+ ) : (
96
+ <path d="M20 6L9 17l-5-5" />
97
+ )}
98
+ </Icon>
99
+ {hasFailure ? 'Failed' : 'Done'}
100
+ </span>
101
+ )}
102
+ </div>
103
+ );
104
+ }
@@ -1,15 +1,48 @@
1
1
  /**
2
2
  * StatusBar - 底部状态栏
3
3
  *
4
- * 紧凑单行:左侧状态指示,右侧资源统计。
4
+ * 紧凑单行:左侧状态指示 + 当前工具 + 进度,右侧在线状态 + 资源统计 + 存储配额。
5
+ *
6
+ * W9.6 新增:
7
+ * - 当前选中工具名(从 selectedNode 取)
8
+ * - 执行进度(已完成节点数 / 总节点数)
9
+ * - 在线状态(navigator.onLine + 事件监听)
5
10
  */
6
11
 
12
+ import * as React from 'react';
7
13
  import { useWorkspaceStore } from '../store/index.js';
14
+ import { useWorkflowProgress } from '../hooks/useWorkflowProgress.js';
8
15
 
9
16
  export interface StatusBarProps {
10
17
  className?: string;
11
18
  }
12
19
 
20
+ /** 格式化字节数为人类可读(KB/MB/GB) */
21
+ function formatBytes(bytes: number): string {
22
+ if (bytes < 1024) return `${bytes} B`;
23
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
24
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
25
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
26
+ }
27
+
28
+ /** useOnlineStatus - 监听 navigator.onLine + online/offline 事件(W9.6) */
29
+ function useOnlineStatus(): boolean {
30
+ const [online, setOnline] = React.useState<boolean>(() =>
31
+ typeof navigator !== 'undefined' ? navigator.onLine : true
32
+ );
33
+ React.useEffect(() => {
34
+ const onOnline = () => setOnline(true);
35
+ const onOffline = () => setOnline(false);
36
+ window.addEventListener('online', onOnline);
37
+ window.addEventListener('offline', onOffline);
38
+ return () => {
39
+ window.removeEventListener('online', onOnline);
40
+ window.removeEventListener('offline', onOffline);
41
+ };
42
+ }, []);
43
+ return online;
44
+ }
45
+
13
46
  export function StatusBar({ className = '' }: StatusBarProps) {
14
47
  const statusMessage = useWorkspaceStore((s) => s.statusMessage);
15
48
  const error = useWorkspaceStore((s) => s.error);
@@ -17,14 +50,32 @@ export function StatusBar({ className = '' }: StatusBarProps) {
17
50
  const assets = useWorkspaceStore((s) => s.assets);
18
51
  const capabilities = useWorkspaceStore((s) => s.capabilities);
19
52
  const nodes = useWorkspaceStore((s) => s.nodes);
53
+ const selectedNodeId = useWorkspaceStore((s) => s.selectedNodeId);
54
+ const storageUsage = useWorkspaceStore((s) => s.storageUsage);
20
55
  const setError = useWorkspaceStore((s) => s.setError);
56
+ const online = useOnlineStatus();
57
+
58
+ // W9.6 当前选中工具名
59
+ const selectedNode = nodes.find((n) => n.id === selectedNodeId);
60
+
61
+ // W9.6 执行进度(抽取到共享 hook,与 ProgressBar 共用,避免重复逻辑)
62
+ const { total: totalNodes, done: doneNodes, pct: progressPct } = useWorkflowProgress();
63
+
64
+ // W6.7 存储配额压力:>=95% 红(临界),>=80% 琥珀(警告),其余正常
65
+ const ratio = storageUsage ? storageUsage.usage / storageUsage.quota : 0;
66
+ const storageCritical = ratio >= 0.95;
67
+ const storageWarning = ratio >= 0.8 && !storageCritical;
21
68
 
22
69
  return (
23
70
  <footer
24
71
  className={`flex h-7 shrink-0 items-center justify-between border-t border-zinc-200 bg-zinc-50 px-3 dark:border-zinc-800 dark:bg-zinc-950 ${className}`}
25
72
  >
26
- {/* Left: Status */}
27
- <div className="flex items-center gap-2 min-w-0">
73
+ {/* Left: Status — m5 加 aria-live,屏幕阅读器播报状态变化 */}
74
+ <div
75
+ className="flex items-center gap-2 min-w-0"
76
+ role="status"
77
+ aria-live="polite"
78
+ >
28
79
  <span className="flex items-center gap-1.5">
29
80
  <span
30
81
  className={`h-1.5 w-1.5 rounded-full shrink-0 ${
@@ -34,11 +85,33 @@ export function StatusBar({ className = '' }: StatusBarProps) {
34
85
  ? 'bg-amber-500 animate-pulse'
35
86
  : 'bg-emerald-500'
36
87
  }`}
88
+ aria-hidden="true"
37
89
  />
38
90
  <span className={`truncate text-[10px] ${error ? 'text-red-500' : 'text-zinc-500'}`}>
39
91
  {statusMessage}
40
92
  </span>
41
93
  </span>
94
+
95
+ {/* W9.6 当前工具名 */}
96
+ {selectedNode && (
97
+ <>
98
+ <span className="text-zinc-300 dark:text-zinc-700" aria-hidden="true">·</span>
99
+ <span className="shrink-0 font-mono text-[10px] text-indigo-500 dark:text-indigo-400 truncate max-w-[120px]">
100
+ {selectedNode.capability}
101
+ </span>
102
+ </>
103
+ )}
104
+
105
+ {/* W9.6 执行进度 */}
106
+ {running && totalNodes > 0 && (
107
+ <>
108
+ <span className="text-zinc-300 dark:text-zinc-700" aria-hidden="true">·</span>
109
+ <span className="shrink-0 tabular-nums text-[10px] text-amber-600 dark:text-amber-400">
110
+ {doneNodes}/{totalNodes} ({progressPct}%)
111
+ </span>
112
+ </>
113
+ )}
114
+
42
115
  {error && (
43
116
  <button
44
117
  type="button"
@@ -52,10 +125,56 @@ export function StatusBar({ className = '' }: StatusBarProps) {
52
125
 
53
126
  {/* Right: Stats */}
54
127
  <div className="flex items-center gap-3 shrink-0 text-[10px] text-zinc-400 tabular-nums">
128
+ {/* W9.6 在线状态 */}
129
+ <span
130
+ className={`flex items-center gap-1 ${online ? 'text-emerald-500' : 'text-amber-500'}`}
131
+ title={online ? 'Online' : 'Offline — running locally, no upload needed'}
132
+ aria-label={online ? 'Online' : 'Offline — files still processed locally'}
133
+ >
134
+ <span className={`h-1.5 w-1.5 rounded-full ${online ? 'bg-emerald-500' : 'bg-amber-500'}`} aria-hidden="true" />
135
+ {online ? 'Online' : 'Offline'}
136
+ </span>
137
+ <span className="text-zinc-300 dark:text-zinc-700" aria-hidden="true">|</span>
138
+
139
+ {/* W6.7 存储配额:接近上限时变色警告;m5 加 aria-label 让屏幕阅读器播报告警 */}
140
+ {storageUsage && (
141
+ <>
142
+ <span
143
+ className={`flex items-center gap-1 ${
144
+ storageCritical
145
+ ? 'text-red-500 font-semibold'
146
+ : storageWarning
147
+ ? 'text-amber-500'
148
+ : 'text-zinc-400'
149
+ }`}
150
+ title={
151
+ storageCritical
152
+ ? `Storage almost full (${Math.round(ratio * 100)}%) — clean up to free space`
153
+ : storageWarning
154
+ ? `Storage nearing limit (${Math.round(ratio * 100)}%)`
155
+ : `Storage: ${formatBytes(storageUsage.usage)} of ${formatBytes(storageUsage.quota)}`
156
+ }
157
+ aria-label={
158
+ storageCritical
159
+ ? `Storage critical: ${formatBytes(storageUsage.usage)} of ${formatBytes(storageUsage.quota)} used, ${Math.round(ratio * 100)} percent. Clean up to free space.`
160
+ : storageWarning
161
+ ? `Storage warning: ${formatBytes(storageUsage.usage)} of ${formatBytes(storageUsage.quota)} used, ${Math.round(ratio * 100)} percent.`
162
+ : `Storage: ${formatBytes(storageUsage.usage)} of ${formatBytes(storageUsage.quota)} used.`
163
+ }
164
+ aria-live={storageCritical ? 'assertive' : 'polite'}
165
+ >
166
+ {(storageCritical || storageWarning) && (
167
+ <span className="text-[9px]" aria-hidden="true">⚠</span>
168
+ )}
169
+ {formatBytes(storageUsage.usage)} / {formatBytes(storageUsage.quota)}
170
+ </span>
171
+ <span className="text-zinc-300 dark:text-zinc-700" aria-hidden="true">|</span>
172
+ </>
173
+ )}
55
174
  <span>{assets.length} asset{assets.length !== 1 ? 's' : ''}</span>
56
- <span className="text-zinc-300 dark:text-zinc-700">|</span>
175
+ <span className="text-zinc-300 dark:text-zinc-700" aria-hidden="true">|</span>
57
176
  <span>{capabilities.length} cap{capabilities.length !== 1 ? 's' : ''}</span>
58
- <span className="text-zinc-300 dark:text-zinc-700">|</span>
177
+ <span className="text-zinc-300 dark:text-zinc-700" aria-hidden="true">|</span>
59
178
  <span>{nodes.length} step{nodes.length !== 1 ? 's' : ''}</span>
60
179
  </div>
61
180
  </footer>