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); }; 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 (
); } /** 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; onSecondary?: () => void | Promise; } 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(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) => { 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 (
{editingName ? ( <> 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" /> ) : ( { 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 && {t('app.template')}} {!presetReadOnly && ✎} )} {msg && {msg}}
{isAdmin && workflowId && ( )} {workflowId && !presetReadOnly && ( )} {confirmDialog && (
{confirmDialog.danger ? '!' : '?'}

{confirmDialog.title}

{confirmDialog.message}

{confirmDialog.secondaryLabel && ( )}
)} {templateDialog && (

{t('app.startFromTemplate')}

Templates contain the processing chain only — input device cards appear as your collectors report data. Applying a template replaces the current canvas.

{workflows.filter((w) => w.is_preset).map((t) => ( ))} {!workflows.some((w) => w.is_preset) && (
{t('app.noTemplates')}
)}
{t('app.templateHint')}
)}
); }