File size: 28,411 Bytes
d646dd7
 
 
 
 
 
 
 
 
 
 
bb59211
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb59211
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb59211
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb59211
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb59211
d646dd7
 
 
 
 
 
 
 
 
bb59211
d646dd7
 
 
bb59211
d646dd7
 
bb59211
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
bb59211
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
bb59211
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb59211
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb59211
d646dd7
 
bb59211
d646dd7
 
 
 
bb59211
d646dd7
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
import { useEffect, useRef, useState } from 'react';
import { ReactFlowProvider } from '@xyflow/react';
import { WorkflowCanvas } from './components/WorkflowCanvas/WorkflowCanvas';
import { NodePalette } from './components/WorkflowCanvas/NodePalette';
import { IconifyIcon } from './components/WorkflowCanvas/IconifyIcon';
import { clearDraft, normalizeWorkflowGraphForEditor, useWorkflowStore } from './store/workflowStore';
import { useUiStore } from './store/uiStore';
import { ToastContainer, GlobalDialog } from './components/ui/AppDialogs';
import { deleteWorkflow, getWorkflow, getWorkflowUsage, listModules, updateWorkflow } from './api/workflows';
import { getCurrentUser } from './api/auth';
import { hydrateNodeTypes } from './components/WorkflowCanvas/nodes/registry';
import { useT } from './i18n';
import type { WorkflowListItem } from './types/workflow';

/**
 * Compare only persisted workflow semantics. React Flow adds presentation
 * metadata (labels, handles, measured dimensions, selection state, etc.)
 * while the registry also hydrates old nodes on load. Those fields must not
 * turn an identical saved graph into a dirty draft.
 */
function comparableGraph(graph: any) {
  const stableValue = (value: any): any => {
    if (!value || typeof value !== 'object') return value;
    if (Array.isArray(value)) return value.map(stableValue);
    return Object.keys(value).sort().reduce((result, key) => {
      result[key] = stableValue(value[key]);
      return result;
    }, {} as Record<string, any>);
  };
  const nodes = (graph?.nodes || []).map((node: any) => ({
    id: node.id,
    type: node.type || 'workflowNode',
    position: {
      x: Number(node.position?.x || 0),
      y: Number(node.position?.y || 0),
    },
    parentId: node.parentId || null,
    data: {
      nodeType: node.data?.nodeType || null,
      config: stableValue(node.data?.config || {}),
    },
  })).sort((a: any, b: any) => String(a.id).localeCompare(String(b.id)));
  const edges = (graph?.edges || []).map((edge: any) => ({
    id: edge.id || null,
    source: edge.source,
    target: edge.target,
    sourceHandle: edge.sourceHandle || null,
    targetHandle: edge.targetHandle || null,
    type: edge.type || null,
  })).sort((a: any, b: any) => String(a.id).localeCompare(String(b.id)));
  return JSON.stringify({ nodes, edges });
}

function graphsMatch(left: any, right: any) {
  return comparableGraph(left) === comparableGraph(right);
}

export default function App() {
  const t = useT();

  useEffect(() => {
    listModules().then((items) => hydrateNodeTypes(items as any)).catch(() => {
      // The built-in registry remains available when the API is offline.
    });
  }, []);

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const ctrl = e.ctrlKey || e.metaKey;
      // Ctrl+S 由 WorkflowToolbar 统一处理(与 Save 按钮同权限/确认流程)
      if (ctrl && e.key === 'z' && !e.shiftKey) {
        e.preventDefault();
        useWorkflowStore.getState().undo();
      }
      if (ctrl && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
        e.preventDefault();
        useWorkflowStore.getState().redo();
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

  // Auto-load most recent workflow or restore unsaved draft on mount.
  // 优先级:URL ?workflow= > localStorage draft > 最后创建的工作流。
  // 显式导航(?workflow=)必须跳过 draft 恢复,否则项目页跳转会被旧草稿顶掉。
  useEffect(() => {
    const store = useWorkflowStore.getState();
    const params = new URLSearchParams(window.location.search);
    const wfParam = params.get('workflow');
    const projectParam = params.get('project');

    // 当前用户角色(决定预设编辑权限);失败 → userRole 保持 null(按非 admin)
    getCurrentUser()
      .then((u) => useWorkflowStore.setState({ userRole: u.role }))
      .catch(() => {});
    // ?project= 是显式项目上下文。没有它时,等工作流加载完成后再用
    // workflow.project_id 解析输入源,避免直接从工作流模块进入时退回全局设备。
    if (projectParam) useWorkflowStore.setState({ workflowProjectId: projectParam });

    const restoreDraftOrFirst = async () => {
      // Check for unsaved draft first
      try {
        const raw = localStorage.getItem('workflow_draft');
        if (raw) {
          let draft = JSON.parse(raw);
          const normalizedDraft = draft?.nodes && Array.isArray(draft.nodes)
            ? normalizeWorkflowGraphForEditor({ nodes: draft.nodes, edges: draft.edges || [] })
            : null;
          // Older builds left the draft in localStorage after loading a saved
          // workflow. An identical draft is not an unsaved edit.
          if (draft.workflowId) {
            try {
              const saved = await getWorkflow(String(draft.workflowId));
              if (normalizedDraft && graphsMatch(normalizedDraft, saved.graph)) {
                clearDraft();
                draft = null;
              }
            } catch {
              // Keep a real draft when the API is temporarily unavailable.
            }
          }
          if (draft && draft.nodes && Array.isArray(draft.nodes) && draft.nodes.length > 0) {
            const normalized = normalizedDraft || normalizeWorkflowGraphForEditor(draft);
            useWorkflowStore.setState({
              nodes: normalized.nodes,
              edges: normalized.edges,
              workflowId: draft.workflowId || null,
              workflowProjectId: projectParam || draft.workflowProjectId || null,
              workflowName: draft.workflowName || 'Untitled',
              isDirty: true,
            });
            store.loadWorkflowList();
            const draftProjectId = projectParam || draft.workflowProjectId;
            store.loadInputsForWorkflow(draftProjectId);
            return;
          }
        }
      } catch {}

      // No draft — load the last created workflow by default
      // (workflows.json 追加顺序,末尾 = 最新创建)
      store.loadWorkflowList().then(() => {
        const { workflows } = useWorkflowStore.getState();
        if (workflows.length > 0) {
          store.loadWorkflow(workflows[workflows.length - 1].id).then(() => {
            const current = useWorkflowStore.getState();
            // A workflow's persisted project wins over a stale URL project.
            return current.loadInputsForWorkflow(current.workflowProjectId || projectParam);
          });
        } else if (projectParam) {
          store.loadInputsForWorkflow(projectParam);
        } else {
          // A new/direct workflow has no project input context. Keep the
          // palette's fixed device categories, but do not show global online
          // devices from another project in the node cards.
          store.loadInputsForWorkflow(null);
        }
      });
    };

    if (wfParam) {
      store.loadWorkflowList();
      store.loadWorkflow(wfParam).then(() => {
        const current = useWorkflowStore.getState();
        return current.loadInputsForWorkflow(current.workflowProjectId || projectParam);
      }).catch(() => {
        // URL 工作流不存在(被删)→ 回退 draft/首个工作流
        void restoreDraftOrFirst();
      });
      return;
    }
    void restoreDraftOrFirst();
  }, []);

  return (
    <ReactFlowProvider>
      <div className="h-full w-full flex flex-col">
        <WorkflowToolbar />
        <div className="flex-1 flex min-h-0">
          <WorkflowCanvas />
          <NodePalette />
        </div>
      </div>
      <ToastContainer />
      <GlobalDialog />
    </ReactFlowProvider>
  );
}

/** Inline toolbar — workflow switcher + rename + Save.
 *  运行是自动的:数据上传后按项目绑定的工作流自动入队执行,无手动 Run。
 *  预设权限:非 admin 对预设只读(保存 → 另存为);admin 可改/可新增/可取消预设。 */
interface ConfirmDialogState {
  title: string;
  message: string;
  confirmLabel: string;
  danger?: boolean;
  /** 次按钮(如"另存为副本");存在则渲染在 Cancel 与主按钮之间。 */
  secondaryLabel?: string;
  onConfirm: () => void | Promise<void>;
  onSecondary?: () => void | Promise<void>;
}

function WorkflowToolbar() {
  const t = useT();
  const { workflowName, workflowId, isDirty, isSaving, workflows, userRole, isPresetWorkflow, saveWorkflowSafe, saveWorkflowAs, loadWorkflow, loadWorkflowList } =
    useWorkflowStore();
  const [editingName, setEditingName] = useState(false);
  const [nameDraft, setNameDraft] = useState(workflowName);
  const [msg, setMsg] = useState('');
  const [confirmDialog, setConfirmDialog] = useState<ConfirmDialogState | null>(null);
  const [dialogBusy, setDialogBusy] = useState(false);
  // 新建工作流:选择 Blank / 模板(模板样式 = 只含后处理链,设备按实际采集端添加)
  const [templateDialog, setTemplateDialog] = useState(false);
  const [templateBusy, setTemplateBusy] = useState(false);

  const isAdmin = userRole === 'admin';
  const presetReadOnly = isPresetWorkflow && !isAdmin;

  const flash = (s: string) => { setMsg(s); setTimeout(() => setMsg(''), 2000); };

  const syncUrl = () => {
    const s = useWorkflowStore.getState();
    if (!s.workflowId) return;
    const params = new URLSearchParams(window.location.search);
    params.set('workflow', s.workflowId);
    if (s.workflowProjectId) params.set('project', s.workflowProjectId);
    else params.delete('project');
    const q = params.toString();
    window.history.replaceState(null, '', `/workflow-studio${q ? `?${q}` : ''}`);
  };

  /** 预设且非 admin:prompt 另存为(权限现状保持)。 */
  const runPresetSaveAs = async (s = useWorkflowStore.getState()) => {
    const name = await useUiStore.getState().prompt(
      'This is a template workflow and cannot be overwritten. Save a copy as:',
      { title: 'Save a copy', defaultValue: (s.workflowName || 'Untitled') + ' (copy)', confirmLabel: 'Save' },
    );
    if (!name || !name.trim()) return;
    try {
      await s.saveWorkflowAs(name.trim());
      await s.loadWorkflowList();
      syncUrl();
      flash('Saved as copy');
    } catch (err: any) { useUiStore.getState().pushToast(err.message || 'Save failed', 'error'); }
  };

  /** 直接保存(store 兜底 403 → 转另存为)。 */
  const runDirectSaveInner = async () => {
    const s = useWorkflowStore.getState();
    const r = await s.saveWorkflowSafe();
    if (r === 'needs-save-as') { await runPresetSaveAs(s); return; }
    if (r === 'saved') flash('Saved');
  };

  /** 共享工作流(被 ≥2 项目使用):改全局前提示影响范围。 */
  const runDirectSave = async () => {
    const s = useWorkflowStore.getState();
    if (s.workflowId) {
      try {
        const usage = await getWorkflowUsage(s.workflowId);
        if (usage.project_count >= 2) {
          const names = usage.projects.map((p) => p.name).join(', ');
          setConfirmDialog({
            title: 'Shared workflow',
            message: `This workflow is used by ${usage.project_count} projects (${names}). Saving directly will affect all of them.`,
            confirmLabel: 'Save directly',
            secondaryLabel: 'Save as copy',
            onConfirm: () => runDirectSaveInner(),
            onSecondary: () => runSaveAsCopy(),
          });
          return;
        }
      } catch (err) {
        // usage 查询失败不阻断保存(后端 403 仍由 saveWorkflowSafe 兜底)
        console.warn('usage check failed, saving directly', err);
      }
    }
    await runDirectSaveInner();
  };

  const runSaveAsCopy = async () => {
    const s = useWorkflowStore.getState();
    const name = await useUiStore.getState().prompt('Save a copy as:', {
      title: 'Save a copy', defaultValue: (s.workflowName || 'Untitled') + ' (copy)', confirmLabel: 'Save',
    });
    if (!name || !name.trim()) return;
    try {
      await s.saveWorkflowAs(name.trim());
      await s.loadWorkflowList();
      syncUrl();
      flash('Saved as copy');
    } catch (err: any) { useUiStore.getState().pushToast(err.message || 'Save failed', 'error'); }
  };

  const saveSafe = async () => {
    const s = useWorkflowStore.getState();
    // 新工作流(未保存):先命名再保存,不直接创建 "Untitled"
    if (!s.workflowId) {
      setNameDraft(s.workflowName);
      setEditingName(true);
      return;
    }
    // 预设 + 非 admin:只读,强制另存为(现状)
    if (presetReadOnly) { await runPresetSaveAs(s); return; }
    // 空工作流提醒:0 节点不会处理任何上传数据
    if (s.workflowId && s.nodes.length === 0) {
      setConfirmDialog({
        title: 'Save empty workflow?',
        message: 'This workflow has no nodes, so it will not process any uploaded data. Save anyway?',
        confirmLabel: 'Save anyway',
        onConfirm: () => runDirectSave(),
      });
      return;
    }
    await runDirectSave();
  };

  const commitName = async () => {
    const name = nameDraft.trim();
    setEditingName(false);
    if (!name) { setNameDraft(workflowName); return; }
    if (name === workflowName) return;
    if (presetReadOnly) {
      flash('Template workflows can only be renamed by admin. Save a copy instead.');
      return;
    }
    if (workflowId) {
      // 已有工作流:改 store 里的名称后保存(更新后端 name)
      useWorkflowStore.setState({ workflowName: name });
      try {
        await saveWorkflowSafe();
        await loadWorkflowList();   // 刷新下拉列表,让新名称立即显示
        flash('Renamed');
      } catch { /* saveWorkflowSafe rethrows non-403 errors */ }
    } else {
      // 未保存的新工作流:直接以新名称创建
      await saveWorkflowAs(name);
      await loadWorkflowList();
      flash('Saved');
    }
  };

  const applyWorkflowSwitch = async (id: string) => {
    const store = useWorkflowStore.getState();
    if (id === '__new__') {
      // 直接新建空白工作流(熟练用户手动拖节点);
      // 模板不在这里:工具栏 Template 按钮 = 应用模板到当前工作流
      store.newWorkflow();
      const params = new URLSearchParams(window.location.search);
      const projectId = params.get('project');
      if (projectId) {
        useWorkflowStore.setState({ workflowProjectId: projectId });
        await useWorkflowStore.getState().loadInputsForWorkflow(projectId);
      } else {
        await useWorkflowStore.getState().loadInputsForWorkflow(null);
      }
      params.delete('workflow');
      const q = params.toString();
      window.history.replaceState(null, '', `/workflow-studio${q ? `?${q}` : ''}`);
    } else if (id) {
      await loadWorkflow(id);
      const current = useWorkflowStore.getState();
      // If another switch completed later, this invocation is stale and must
      // not start a second request for the wrong project.
      if (current.workflowId !== id) return;
      const projectFromUrl = new URLSearchParams(window.location.search).get('project');
      const projectId = current.workflowProjectId || projectFromUrl;
      await current.loadInputsForWorkflow(projectId);
      if (useWorkflowStore.getState().workflowId !== id) return;
      syncUrl();
    }
  };

  /** 应用模板到当前工作流(替换画布,保留工作流 ID/名称)。
   *  清掉 URL 里的 workflow 参数,未保存刷新时走草稿恢复而非旧后端内容。 */
  const pickTemplate = async (t: WorkflowListItem | null) => {
    setTemplateBusy(true);
    try {
      const store = useWorkflowStore.getState();
      if (t) await store.newWorkflowFromTemplate(t);
      const params = new URLSearchParams(window.location.search);
      params.delete('workflow');
      const q = params.toString();
      window.history.replaceState(null, '', `/workflow-studio${q ? `?${q}` : ''}`);
    } catch (err: any) {
      flash(err?.message || 'Failed to load template');
    } finally {
      setTemplateBusy(false);
      setTemplateDialog(false);
    }
  };

  const handleSwitch = async (e: React.ChangeEvent<HTMLSelectElement>) => {
    const id = e.target.value;
    const store = useWorkflowStore.getState();
    if (store.isDirty) {
      setConfirmDialog({
        title: 'Discard unsaved changes?',
        message: 'Your current workflow has unsaved changes. Switching workflows will discard them.',
        confirmLabel: 'Discard and switch',
        danger: true,
        onConfirm: () => applyWorkflowSwitch(id),
      });
      return;
    }
    await applyWorkflowSwitch(id);
  };

  /** 打开模板选择弹窗:与 handleSwitch 一致的 isDirty 保护,
   *  防止未保存改动被"从模板新建"无声丢弃。 */
  const openTemplateDialog = () => {
    const store = useWorkflowStore.getState();
    if (store.isDirty) {
      setConfirmDialog({
        title: 'Discard unsaved changes?',
        message: 'Your current workflow has unsaved changes. Starting from a template or a blank workflow will discard them.',
        confirmLabel: 'Discard and continue',
        danger: true,
        onConfirm: () => setTemplateDialog(true),
      });
      return;
    }
    setTemplateDialog(true);
  };

  const togglePreset = () => {
    const s = useWorkflowStore.getState();
    if (!s.workflowId) return;
    const nextPreset = !isPresetWorkflow;
    setConfirmDialog({
      title: nextPreset ? 'Make this a template workflow?' : 'Remove template status?',
      message: nextPreset
        ? 'This workflow will become a shared template for all users.'
        : 'This workflow will become a regular workflow. Its nodes and connections will not change.',
      confirmLabel: nextPreset ? 'Make Template' : 'Remove template status',
      onConfirm: async () => {
        try {
          await updateWorkflow(s.workflowId as string, { is_preset: nextPreset });
          await loadWorkflowList();
          useWorkflowStore.setState({ isPresetWorkflow: nextPreset });
          flash(nextPreset ? 'Template created' : 'Template removed');
        } catch (err: any) { flash(err.message || 'Failed'); }
      },
    });
  };

  // 执行后若执行期间弹了新对话框(如"共享提醒"→"空工作流"链),保留新对话框
  const closeDialogIfCurrent = (dialog: ConfirmDialogState) => {
    setDialogBusy(false);
    setConfirmDialog((cur) => (cur === dialog ? null : cur));
  };

  const confirmDialogAction = async () => {
    const dialog = confirmDialog;
    if (!dialog || dialogBusy) return;
    setDialogBusy(true);
    try {
      await dialog.onConfirm();
    } catch (err: any) {
      flash(err?.message || 'Operation failed');
    } finally {
      closeDialogIfCurrent(dialog);
    }
  };

  const confirmDialogSecondary = async () => {
    const dialog = confirmDialog;
    if (!dialog || dialogBusy) return;
    setDialogBusy(true);
    try {
      await dialog.onSecondary?.();
    } catch (err: any) {
      flash(err?.message || 'Operation failed');
    } finally {
      closeDialogIfCurrent(dialog);
    }
  };

  // Ctrl+S 与 Save 按钮同一套确认流程(空工作流/共享提醒/预设另存为)
  const saveSafeRef = useRef(saveSafe);
  useEffect(() => { saveSafeRef.current = saveSafe; });
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if ((e.ctrlKey || e.metaKey) && e.key === 's') {
        e.preventDefault();
        saveSafeRef.current();
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

  const dot = isDirty ? 'bg-yellow-500' : msg ? 'bg-green-500' : 'bg-gray-600';

  return (
    <div className="relative flex items-center gap-2 px-4 py-1.5 bg-gray-900 border-b border-gray-800 shrink-0">
      <span className={`w-2 h-2 rounded-full shrink-0 ${dot}`} />
      <select
        value={workflowId || ''}
        onChange={handleSwitch}
        onClick={() => loadWorkflowList()}
        className="bg-transparent border border-gray-700 rounded px-1.5 py-0.5 text-xs font-semibold text-gray-200 focus:outline-none focus:border-blue-500 cursor-pointer max-w-[200px]"
      >
        {!workflowId && <option value="" disabled hidden>{t('app.newWorkflow')}</option>}
        {workflows.map((w) => (
          <option key={w.id} value={w.id} className="bg-gray-800 text-gray-200">{w.name}{w.is_preset ? ' (Template)' : ''}</option>
        ))}
        {workflowId && !workflows.find((w) => w.id === workflowId) && (
          <option value={workflowId} className="bg-gray-800 text-gray-200">{workflowName}</option>
        )}
        <option value="__new__" className="bg-gray-800 text-gray-400">+ New Workflow</option>
      </select>
      {editingName ? (
        <>
          <input autoFocus value={nameDraft} onChange={(e) => setNameDraft(e.target.value)}
            onKeyDown={(e) => { if (e.key === 'Enter') commitName(); if (e.key === 'Escape') { setNameDraft(workflowName); setEditingName(false); } }}
            onBlur={commitName}
            className="bg-gray-800 border border-gray-600 rounded px-1.5 py-0.5 text-xs text-white w-48 focus:outline-none focus:border-blue-500"
            title="Press Enter to save" />
        </>
      ) : (
        <span
          onClick={() => {
            if (presetReadOnly) { flash('Template workflows can only be renamed by admin. Save a copy instead.'); return; }
            setNameDraft(workflowName); setEditingName(true);
          }}
          title="Click to rename workflow"
          className="text-xs font-semibold text-gray-200 hover:text-blue-400 hover:underline cursor-pointer whitespace-nowrap">
          {workflowName} {isPresetWorkflow && <span className="text-[9px] text-yellow-400 border border-yellow-700 rounded px-1 ml-1 align-middle">{t('app.template')}</span>}
          {!presetReadOnly && <span className="text-[10px] text-gray-500 ml-1">✎</span>}
        </span>
      )}
      {msg && <span className="text-[11px] text-green-400">{msg}</span>}
      <div className="flex-1" />
      <button
        onClick={() => { loadWorkflowList(); openTemplateDialog(); }}
        title="Start a new workflow from a template (processing chain) or a blank canvas"
        className="text-[11px] text-blue-400 hover:text-blue-300 hover:bg-blue-900/30 border border-blue-900/60 px-1.5 py-0.5 rounded">
        <IconifyIcon icon="ant-design:project-outlined" className="text-[12px] align-[-2px] mr-0.5" />{' '}{t('app.template')}
      </button>
      {isAdmin && workflowId && (
        <button onClick={togglePreset} className="text-[11px] text-yellow-400 hover:text-yellow-300 hover:bg-yellow-900/30 px-1.5 py-0.5 rounded">
          {isPresetWorkflow ? t('app.unsetTemplate') : t('app.makeTemplate')}
        </button>
      )}
      <button onClick={saveSafe} disabled={isSaving} className="text-[11px] bg-blue-600 hover:bg-blue-500 text-white px-2 py-0.5 rounded disabled:opacity-50">{isSaving ? '...' : t('app.save')}</button>
      {workflowId && !presetReadOnly && (
        <button onClick={() => setConfirmDialog({
          title: 'Delete this workflow?',
          message: 'This action cannot be undone. The workflow definition will be removed.',
          confirmLabel: 'Delete workflow',
          danger: true,
          onConfirm: async () => {
            await deleteWorkflow(workflowId);
            await loadWorkflowList();
            const { workflows: updated } = useWorkflowStore.getState();
            if (updated.length > 0) await loadWorkflow(updated[0].id);
            else useWorkflowStore.getState().newWorkflow();
            syncUrl();
          },
        })} className="text-[11px] text-red-400 hover:text-red-300 hover:bg-red-900/30 px-1.5 py-0.5 rounded">{t('app.del')}</button>
      )}
      {confirmDialog && (
        <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4" role="presentation">
          <div className="w-full max-w-sm rounded-lg border border-gray-700 bg-gray-900 shadow-2xl" role="dialog" aria-modal="true" aria-labelledby="workflow-dialog-title">
            <div className="flex items-start gap-3 border-b border-gray-800 px-4 py-3">
              <span className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full ${confirmDialog.danger ? 'bg-red-900/50 text-red-300' : 'bg-blue-900/50 text-blue-300'}`}>
                <span className="text-sm font-bold">{confirmDialog.danger ? '!' : '?'}</span>
              </span>
              <div className="min-w-0">
                <h2 id="workflow-dialog-title" className="text-sm font-semibold text-gray-100">{confirmDialog.title}</h2>
                <p className="mt-1 text-xs leading-5 text-gray-400">{confirmDialog.message}</p>
              </div>
            </div>
            <div className="flex justify-end gap-2 px-4 py-3">
              <button type="button" disabled={dialogBusy} onClick={() => setConfirmDialog(null)} className="rounded border border-gray-700 px-3 py-1.5 text-xs text-gray-300 hover:bg-gray-800 disabled:opacity-50">{t('app.cancel')}</button>
              {confirmDialog.secondaryLabel && (
                <button type="button" disabled={dialogBusy} onClick={confirmDialogSecondary}
                        className="rounded border border-gray-700 px-3 py-1.5 text-xs text-gray-300 hover:bg-gray-800 disabled:opacity-50">
                  {dialogBusy ? 'Working...' : confirmDialog.secondaryLabel}
                </button>
              )}
              <button type="button" disabled={dialogBusy} onClick={confirmDialogAction} className={`rounded px-3 py-1.5 text-xs text-white disabled:opacity-50 ${confirmDialog.danger ? 'bg-red-700 hover:bg-red-600' : 'bg-blue-600 hover:bg-blue-500'}`}>
                {dialogBusy ? 'Working...' : confirmDialog.confirmLabel}
              </button>
            </div>
          </div>
        </div>
      )}
      {templateDialog && (
        <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4" role="presentation">
          <div className="w-full max-w-sm rounded-lg border border-gray-700 bg-gray-900 shadow-2xl" role="dialog" aria-modal="true" aria-labelledby="template-dialog-title">
            <div className="border-b border-gray-800 px-4 py-3">
              <h2 id="template-dialog-title" className="text-sm font-semibold text-gray-100">{t('app.startFromTemplate')}</h2>
              <p className="mt-1 text-xs leading-5 text-gray-400">
                Templates contain the processing chain only — input device cards appear as your
                collectors report data. Applying a template replaces the current canvas.
              </p>
            </div>
            <div className="px-4 py-3 space-y-1.5">
              {workflows.filter((w) => w.is_preset).map((t) => (
                <button type="button" key={t.id} disabled={templateBusy} onClick={() => pickTemplate(t)}
                  className="w-full text-left rounded border border-gray-700 px-3 py-2 hover:border-blue-600 hover:bg-gray-800 disabled:opacity-50">
                  <div className="flex items-center gap-2">
                    <IconifyIcon icon="ant-design:database-outlined" className="text-[14px] text-gray-500 shrink-0" />
                    <div className="min-w-0">
                      <div className="text-xs text-gray-200 truncate">{t.name}</div>
                      <div className="text-[10px] text-gray-500 mt-0.5">Template · {t.node_count ?? 0} nodes · processing chain only</div>
                    </div>
                  </div>
                </button>
              ))}
              {!workflows.some((w) => w.is_preset) && (
                <div className="text-[11px] text-gray-600 px-1 pt-1">{t('app.noTemplates')}</div>
              )}
              <div className="text-[10px] text-gray-600 pt-1">
                {t('app.templateHint')}
              </div>
            </div>
            <div className="flex justify-end gap-2 px-4 py-3">
              <button type="button" disabled={templateBusy} onClick={() => setTemplateDialog(false)}
                className="rounded border border-gray-700 px-3 py-1.5 text-xs text-gray-300 hover:bg-gray-800 disabled:opacity-50">{t('app.cancel')}</button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}