重构前端逻辑

This commit is contained in:
dengqichen 2025-11-10 13:56:26 +08:00
parent 11c44bc95d
commit fcdf005e22
5 changed files with 351 additions and 143 deletions

View File

@ -1,4 +1,4 @@
import React, { useCallback } from 'react'; import React, { useCallback, useEffect } from 'react';
import { import {
ReactFlow, ReactFlow,
Background, Background,
@ -80,24 +80,26 @@ const FlowCanvas: React.FC<FlowCanvasProps> = ({
// 节点变化处理 // 节点变化处理
const handleNodesChange = useCallback((changes: any) => { const handleNodesChange = useCallback((changes: any) => {
onNodesStateChange(changes); onNodesStateChange(changes);
if (onNodesChange) { }, [onNodesStateChange]);
// 延迟获取最新状态 - 在实际项目中可以使用useEffect监听nodes变化
setTimeout(() => {
onNodesChange(nodes as FlowNode[]);
}, 0);
}
}, [onNodesStateChange, onNodesChange, nodes]);
// 边变化处理 // 边变化处理
const handleEdgesChange = useCallback((changes: any) => { const handleEdgesChange = useCallback((changes: any) => {
onEdgesStateChange(changes); onEdgesStateChange(changes);
if (onEdgesChange) { }, [onEdgesStateChange]);
// 延迟获取最新状态 - 在实际项目中可以使用useEffect监听edges变化
setTimeout(() => { // 使用 useEffect 监听 nodes 变化并通知父组件
onEdgesChange(edges as FlowEdge[]); useEffect(() => {
}, 0); if (onNodesChange && nodes.length > 0) {
onNodesChange(nodes as FlowNode[]);
} }
}, [onEdgesStateChange, onEdgesChange, edges]); }, [nodes, onNodesChange]);
// 使用 useEffect 监听 edges 变化并通知父组件
useEffect(() => {
if (onEdgesChange) {
onEdgesChange(edges as FlowEdge[]);
}
}, [edges, onEdgesChange]);
// 连接验证 - 利用 React Flow 的实时验证功能 // 连接验证 - 利用 React Flow 的实时验证功能
const isValidConnection = useCallback((connection: Connection | Edge) => { const isValidConnection = useCallback((connection: Connection | Edge) => {

View File

@ -0,0 +1,103 @@
import React from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogBody } from '@/components/ui/dialog';
import { Keyboard } from 'lucide-react';
interface KeyboardShortcutsPanelProps {
open: boolean;
onClose: () => void;
}
interface Shortcut {
keys: string[];
description: string;
category: string;
}
const shortcuts: Shortcut[] = [
// 基本操作
{ keys: ['Ctrl', 'S'], description: '保存工作流', category: '基本操作' },
{ keys: ['Ctrl', 'Z'], description: '撤销', category: '基本操作' },
{ keys: ['Ctrl', 'Y'], description: '重做', category: '基本操作' },
{ keys: ['Ctrl', 'Shift', 'Z'], description: '重做', category: '基本操作' },
{ keys: ['?'], description: '显示快捷键', category: '基本操作' },
// 编辑操作
{ keys: ['Ctrl', 'C'], description: '复制选中节点', category: '编辑操作' },
{ keys: ['Ctrl', 'V'], description: '粘贴节点', category: '编辑操作' },
{ keys: ['Ctrl', 'A'], description: '全选节点', category: '编辑操作' },
{ keys: ['Delete'], description: '删除选中节点', category: '编辑操作' },
{ keys: ['Backspace'], description: '删除选中节点', category: '编辑操作' },
// 视图操作
{ keys: ['Ctrl', '+'], description: '放大画布', category: '视图操作' },
{ keys: ['Ctrl', '-'], description: '缩小画布', category: '视图操作' },
{ keys: ['Ctrl', '0'], description: '适应视图', category: '视图操作' },
{ keys: ['Space', '+', '拖动'], description: '平移画布', category: '视图操作' },
// 节点操作
{ keys: ['双击节点'], description: '配置节点', category: '节点操作' },
{ keys: ['双击边'], description: '配置连接条件', category: '节点操作' },
{ keys: ['Ctrl', '+', '点击'], description: '多选节点', category: '节点操作' },
];
const KeyboardShortcutsPanel: React.FC<KeyboardShortcutsPanelProps> = ({ open, onClose }) => {
// 按分类分组快捷键
const groupedShortcuts = shortcuts.reduce((acc, shortcut) => {
if (!acc[shortcut.category]) {
acc[shortcut.category] = [];
}
acc[shortcut.category].push(shortcut);
return acc;
}, {} as Record<string, Shortcut[]>);
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Keyboard className="h-5 w-5" />
</DialogTitle>
</DialogHeader>
<DialogBody>
<div className="grid grid-cols-2 gap-6">
{Object.entries(groupedShortcuts).map(([category, items]) => (
<div key={category}>
<h3 className="text-sm font-semibold text-gray-700 mb-3 pb-2 border-b">
{category}
</h3>
<div className="space-y-2">
{items.map((shortcut, index) => (
<div key={index} className="flex items-center justify-between text-sm">
<span className="text-gray-600">{shortcut.description}</span>
<div className="flex items-center gap-1">
{shortcut.keys.map((key, keyIndex) => (
<React.Fragment key={keyIndex}>
{keyIndex > 0 && (
<span className="text-gray-400 text-xs mx-0.5">+</span>
)}
<kbd className="px-2 py-1 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-300 rounded shadow-sm">
{key}
</kbd>
</React.Fragment>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
<div className="text-xs text-gray-500 text-center pt-4 mt-4 border-t">
<kbd className="px-1.5 py-0.5 text-xs font-semibold bg-gray-100 border border-gray-300 rounded">?</kbd>
</div>
</DialogBody>
</DialogContent>
</Dialog>
);
};
export default KeyboardShortcutsPanel;

View File

@ -0,0 +1,106 @@
.nodePanel {
width: 260px;
height: 100%;
background: #f8fafc;
border-right: 1px solid #e5e7eb;
overflow: hidden;
display: flex;
flex-direction: column;
}
.header {
padding: 16px;
background: white;
border-bottom: 1px solid #e5e7eb;
}
.title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #374151;
}
.subtitle {
margin: 4px 0 0 0;
font-size: 12px;
color: #6b7280;
}
.content {
flex: 1;
overflow: auto;
padding: 12px;
}
.category {
margin-bottom: 16px;
}
.categoryTitle {
font-size: 12px;
font-weight: 600;
color: #4b5563;
margin-bottom: 8px;
padding: 4px 0;
border-bottom: 1px solid #e5e7eb;
}
.nodeItem {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid #e5e7eb;
background: white;
cursor: grab;
transition: all 0.2s ease-in-out;
margin-bottom: 6px;
}
.nodeItem:hover {
background: #f9fafb;
transform: translateX(2px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.nodeItem:active {
cursor: grabbing;
}
.nodeIcon {
font-size: 16px;
width: 20px;
text-align: center;
flex-shrink: 0;
}
.nodeInfo {
flex: 1;
min-width: 0;
}
.nodeName {
font-size: 13px;
font-weight: 500;
color: #374151;
line-height: 1.2;
}
.nodeDesc {
font-size: 11px;
color: #6b7280;
line-height: 1.2;
margin-top: 2px;
}
.tips {
padding: 12px;
background: #f1f5f9;
border-top: 1px solid #e5e7eb;
font-size: 11px;
color: #64748b;
line-height: 1.4;
}

View File

@ -2,6 +2,7 @@ import React from 'react';
import { NODE_DEFINITIONS } from '../nodes'; import { NODE_DEFINITIONS } from '../nodes';
import { NodeCategory } from '../nodes/types'; import { NodeCategory } from '../nodes/types';
import type { WorkflowNodeDefinition } from '../nodes/types'; import type { WorkflowNodeDefinition } from '../nodes/types';
import styles from './NodePanel.module.css';
interface NodePanelProps { interface NodePanelProps {
className?: string; className?: string;
@ -31,59 +32,26 @@ const NodePanel: React.FC<NodePanelProps> = ({ className = '' }) => {
<div <div
key={nodeDefinition.nodeCode} key={nodeDefinition.nodeCode}
draggable draggable
style={{ className={styles.nodeItem}
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #e5e7eb',
background: 'white',
cursor: 'grab',
transition: 'all 0.2s ease-in-out',
marginBottom: '6px'
}}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.background = '#f9fafb';
e.currentTarget.style.borderColor = nodeDefinition.renderConfig.theme.primary; e.currentTarget.style.borderColor = nodeDefinition.renderConfig.theme.primary;
e.currentTarget.style.transform = 'translateX(2px)';
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.background = 'white';
e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.borderColor = '#e5e7eb';
e.currentTarget.style.transform = 'translateX(0)';
}}
onDragStart={(e) => {
e.currentTarget.style.cursor = 'grabbing';
handleDragStart(e, nodeDefinition);
}}
onDragEnd={(e) => {
e.currentTarget.style.cursor = 'grab';
}} }}
onDragStart={(e) => handleDragStart(e, nodeDefinition)}
>
<div
className={styles.nodeIcon}
style={{ color: nodeDefinition.renderConfig.theme.primary }}
> >
<div style={{
fontSize: '16px',
width: '20px',
textAlign: 'center',
color: nodeDefinition.renderConfig.theme.primary
}}>
{nodeDefinition.renderConfig.icon.content} {nodeDefinition.renderConfig.icon.content}
</div> </div>
<div> <div className={styles.nodeInfo}>
<div style={{ <div className={styles.nodeName}>
fontSize: '13px',
fontWeight: '500',
color: '#374151',
lineHeight: '1.2'
}}>
{nodeDefinition.nodeName} {nodeDefinition.nodeName}
</div> </div>
<div style={{ <div className={styles.nodeDesc}>
fontSize: '11px',
color: '#6b7280',
lineHeight: '1.2',
marginTop: '2px'
}}>
{nodeDefinition.description} {nodeDefinition.description}
</div> </div>
</div> </div>
@ -99,52 +67,16 @@ const NodePanel: React.FC<NodePanelProps> = ({ className = '' }) => {
}; };
return ( return (
<div className={`node-panel ${className}`} style={{ <div className={`${styles.nodePanel} ${className}`}>
width: '260px', <div className={styles.header}>
height: '100%', <h3 className={styles.title}></h3>
background: '#f8fafc', <p className={styles.subtitle}></p>
borderRight: '1px solid #e5e7eb',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}>
<div style={{
padding: '16px',
background: 'white',
borderBottom: '1px solid #e5e7eb'
}}>
<h3 style={{
margin: 0,
fontSize: '14px',
fontWeight: '600',
color: '#374151'
}}>
</h3>
<p style={{
margin: '4px 0 0 0',
fontSize: '12px',
color: '#6b7280'
}}>
</p>
</div> </div>
<div style={{ <div className={styles.content}>
flex: '1',
overflow: 'auto',
padding: '12px'
}}>
{Object.entries(nodesByCategory).map(([category, nodes]) => ( {Object.entries(nodesByCategory).map(([category, nodes]) => (
<div key={category} style={{ marginBottom: '16px' }}> <div key={category} className={styles.category}>
<div style={{ <div className={styles.categoryTitle}>
fontSize: '12px',
fontWeight: '600',
color: '#4b5563',
marginBottom: '8px',
padding: '4px 0',
borderBottom: '1px solid #e5e7eb'
}}>
{categoryTitles[category as NodeCategory]} {categoryTitles[category as NodeCategory]}
</div> </div>
{nodes.map(renderNodeItem)} {nodes.map(renderNodeItem)}
@ -153,18 +85,12 @@ const NodePanel: React.FC<NodePanelProps> = ({ className = '' }) => {
</div> </div>
{/* 使用提示 */} {/* 使用提示 */}
<div style={{ <div className={styles.tips}>
padding: '12px',
background: '#f1f5f9',
borderTop: '1px solid #e5e7eb',
fontSize: '11px',
color: '#64748b',
lineHeight: '1.4'
}}>
💡 💡
<br /> <br />
<br /> <br />
<br /> <br />
<br /> ?
</div> </div>
</div> </div>
); );

View File

@ -2,6 +2,7 @@ import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { message } from 'antd'; import { message } from 'antd';
import { ReactFlowProvider, useReactFlow } from '@xyflow/react'; import { ReactFlowProvider, useReactFlow } from '@xyflow/react';
import { Loader2 } from 'lucide-react';
import WorkflowToolbar from './components/WorkflowToolbar'; import WorkflowToolbar from './components/WorkflowToolbar';
import NodePanel from './components/NodePanel'; import NodePanel from './components/NodePanel';
@ -9,6 +10,7 @@ import FlowCanvas from './components/FlowCanvas';
import NodeConfigModal from './components/NodeConfigModal'; import NodeConfigModal from './components/NodeConfigModal';
import EdgeConfigModal, { type EdgeCondition } from './components/EdgeConfigModal'; import EdgeConfigModal, { type EdgeCondition } from './components/EdgeConfigModal';
import FormPreviewModal from './components/FormPreviewModal'; import FormPreviewModal from './components/FormPreviewModal';
import KeyboardShortcutsPanel from './components/KeyboardShortcutsPanel';
import type { FlowNode, FlowEdge, FlowNodeData } from './types'; import type { FlowNode, FlowEdge, FlowNodeData } from './types';
import type { WorkflowNodeDefinition } from './nodes/types'; import type { WorkflowNodeDefinition } from './nodes/types';
import { isConfigurableNode } from './nodes/types'; import { isConfigurableNode } from './nodes/types';
@ -40,7 +42,10 @@ const WorkflowDesignInner: React.FC = () => {
const [workflowTitle, setWorkflowTitle] = useState('新建工作流'); const [workflowTitle, setWorkflowTitle] = useState('新建工作流');
const [currentZoom, setCurrentZoom] = useState(1); // 当前缩放比例 const [currentZoom, setCurrentZoom] = useState(1); // 当前缩放比例
const [loading, setLoading] = useState(false); // 加载状态
const [lastSaveTime, setLastSaveTime] = useState<Date | null>(null); // 最后保存时间
const reactFlowWrapper = useRef<HTMLDivElement>(null); const reactFlowWrapper = useRef<HTMLDivElement>(null);
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
// 当前工作流ID // 当前工作流ID
const currentWorkflowId = id ? parseInt(id) : undefined; const currentWorkflowId = id ? parseInt(id) : undefined;
@ -57,6 +62,9 @@ const WorkflowDesignInner: React.FC = () => {
const [formDefinition, setFormDefinition] = useState<FormDefinitionResponse | null>(null); const [formDefinition, setFormDefinition] = useState<FormDefinitionResponse | null>(null);
const [formPreviewVisible, setFormPreviewVisible] = useState(false); const [formPreviewVisible, setFormPreviewVisible] = useState(false);
// 快捷键面板状态
const [shortcutsPanelOpen, setShortcutsPanelOpen] = useState(false);
// 提取表单字段(用于变量引用) // 提取表单字段(用于变量引用)
const formFields = useMemo(() => { const formFields = useMemo(() => {
if (!formDefinition?.schema?.fields) return []; if (!formDefinition?.schema?.fields) return [];
@ -109,6 +117,8 @@ const WorkflowDesignInner: React.FC = () => {
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
if (currentWorkflowId) { if (currentWorkflowId) {
setLoading(true);
try {
const data = await loadWorkflow(currentWorkflowId); const data = await loadWorkflow(currentWorkflowId);
if (data) { if (data) {
setNodes(data.nodes); setNodes(data.nodes);
@ -128,6 +138,9 @@ const WorkflowDesignInner: React.FC = () => {
setFormDefinition(null); setFormDefinition(null);
} }
} }
} finally {
setLoading(false);
}
} }
}; };
@ -161,7 +174,7 @@ const WorkflowDesignInner: React.FC = () => {
const initialEdges: FlowEdge[] = []; const initialEdges: FlowEdge[] = [];
// 工具栏事件处理 // 工具栏事件处理
const handleSave = useCallback(async () => { const handleSave = useCallback(async (isAutoSave = false) => {
const nodes = getNodes() as FlowNode[]; const nodes = getNodes() as FlowNode[];
const edges = getEdges() as FlowEdge[]; const edges = getEdges() as FlowEdge[];
@ -175,10 +188,35 @@ const WorkflowDesignInner: React.FC = () => {
}); });
if (success) { if (success) {
setLastSaveTime(new Date());
if (!isAutoSave) {
console.log('保存工作流成功:', { nodes, edges }); console.log('保存工作流成功:', { nodes, edges });
} }
}
}, [getNodes, getEdges, saveWorkflow, currentWorkflowId, workflowTitle, workflowDefinition]); }, [getNodes, getEdges, saveWorkflow, currentWorkflowId, workflowTitle, workflowDefinition]);
// 自动保存
useEffect(() => {
if (!currentWorkflowId || !hasUnsavedChanges) return;
// 清除之前的定时器
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
// 30秒后自动保存
autoSaveTimerRef.current = setTimeout(() => {
handleSave(true);
message.success('已自动保存', 1);
}, 30000);
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
};
}, [currentWorkflowId, hasUnsavedChanges, handleSave]);
const handleBack = useCallback(() => { const handleBack = useCallback(() => {
navigate(-1); // 返回上一页,避免硬编码路径 navigate(-1); // 返回上一页,避免硬编码路径
}, [navigate]); }, [navigate]);
@ -543,11 +581,21 @@ const WorkflowDesignInner: React.FC = () => {
handleDelete(); handleDelete();
} }
} }
// ? - 显示快捷键面板
else if (e.key === '?' && !shouldSkipShortcut) {
e.preventDefault();
setShortcutsPanelOpen(true);
}
// Ctrl+S / Cmd+S - 保存
else if (ctrlKey && e.key === 's') {
e.preventDefault();
handleSave(false);
}
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleUndo, handleRedo, handleCopy, handlePaste, handleSelectAll, handleDelete]); }, [handleUndo, handleRedo, handleCopy, handlePaste, handleSelectAll, handleDelete, handleSave]);
return ( return (
<div <div
@ -557,10 +605,20 @@ const WorkflowDesignInner: React.FC = () => {
overflow: 'hidden' overflow: 'hidden'
}} }}
> >
{/* 加载状态 */}
{loading && (
<div className="absolute inset-0 bg-white/80 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
<p className="text-sm text-muted-foreground">...</p>
</div>
</div>
)}
{/* 工具栏 */} {/* 工具栏 */}
<WorkflowToolbar <WorkflowToolbar
title={`${workflowTitle}${hasUnsavedChanges ? ' *' : ''}`} title={`${workflowTitle}${hasUnsavedChanges ? ' *' : ''}`}
onSave={handleSave} onSave={() => handleSave(false)}
onBack={handleBack} onBack={handleBack}
onPreviewForm={handlePreviewForm} onPreviewForm={handlePreviewForm}
hasFormDefinition={!!formDefinition} hasFormDefinition={!!formDefinition}
@ -624,6 +682,19 @@ const WorkflowDesignInner: React.FC = () => {
onClose={() => setFormPreviewVisible(false)} onClose={() => setFormPreviewVisible(false)}
formDefinition={formDefinition} formDefinition={formDefinition}
/> />
{/* 快捷键面板 */}
<KeyboardShortcutsPanel
open={shortcutsPanelOpen}
onClose={() => setShortcutsPanelOpen(false)}
/>
{/* 最后保存时间提示 */}
{lastSaveTime && (
<div className="fixed bottom-4 right-4 bg-white border border-gray-200 rounded-lg shadow-sm px-3 py-2 text-xs text-gray-600 z-10">
: {lastSaveTime.toLocaleTimeString()}
</div>
)}
</div> </div>
); );
}; };