Browse Source

flow

release/web-ui-v1.0.0
root 4 months ago
parent
commit
eab9eb6e4b
  1. 387
      src/X1.WebUI/src/components/testcases/TestCaseDetailDrawer.tsx
  2. 51
      src/X1.WebUI/src/pages/users/UserForm.tsx
  3. 43
      src/modify.md

387
src/X1.WebUI/src/components/testcases/TestCaseDetailDrawer.tsx

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect } from 'react';
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
import ReactFlow, {
Node,
@ -13,23 +13,7 @@ import ReactFlow, {
} from 'reactflow';
import { testcaseService, TestCaseFlowDetail } from '@/services/testcaseService';
import 'reactflow/dist/style.css';
import {
Play,
Square,
GitBranch,
Smartphone,
Wifi,
WifiOff,
Phone,
PhoneCall,
PhoneOff,
Network,
Activity,
Signal,
SignalHigh,
SignalLow,
Settings
} from 'lucide-react';
import { Settings } from 'lucide-react';
interface TestCaseDetailDrawerProps {
testCaseId: string | null;
@ -37,281 +21,45 @@ interface TestCaseDetailDrawerProps {
onOpenChange: (open: boolean) => void;
}
// 自定义节点组件 - 与 ReactFlowDesigner 完全一致
// 简化的自定义节点组件
const TestStepNode = ({ data, selected }: { data: any; selected?: boolean }) => {
console.log('TestStepNode 接收到的数据:', data);
console.log('TestStepNode selected:', selected);
const getIconComponent = (iconName: string) => {
// 根据图标名称返回对应的图标组件
switch (iconName) {
case 'play-circle': return <Play className="h-4 w-4" />;
case 'stop-circle': return <Square className="h-4 w-4" />;
case 'git-branch': return <GitBranch className="h-4 w-4" />;
case 'settings': return <Settings className="h-4 w-4" />;
case 'smartphone': return <Smartphone className="h-4 w-4" />;
case 'wifi': return <Wifi className="h-4 w-4" />;
case 'wifi-off': return <WifiOff className="h-4 w-4" />;
case 'phone': return <Phone className="h-4 w-4" />;
case 'phone-call': return <PhoneCall className="h-4 w-4" />;
case 'phone-off': return <PhoneOff className="h-4 w-4" />;
case 'network': return <Network className="h-4 w-4" />;
case 'activity': return <Activity className="h-4 w-4" />;
case 'signal': return <Signal className="h-4 w-4" />;
case 'signal-high': return <SignalHigh className="h-4 w-4" />;
case 'signal-low': return <SignalLow className="h-4 w-4" />;
default: return <Settings className="h-4 w-4" />;
}
};
const getNodeStyle = (stepType: number) => {
switch (stepType) {
case 1: // 开始步骤 - 圆形
return {
shape: 'rounded-full',
bgColor: 'bg-blue-50 dark:bg-blue-900/30',
textColor: 'text-blue-600 dark:text-blue-400',
borderColor: 'border-blue-200 dark:border-blue-600/50',
hoverBorderColor: 'hover:border-blue-300 dark:hover:border-blue-500'
};
case 2: // 结束步骤 - 圆形
return {
shape: 'rounded-full',
bgColor: 'bg-red-50 dark:bg-red-900/30',
textColor: 'text-red-600 dark:text-red-400',
borderColor: 'border-red-200 dark:border-red-600/50',
hoverBorderColor: 'hover:border-red-300 dark:hover:border-red-500'
};
case 3: // 处理步骤 - 矩形
return {
shape: 'rounded-md',
bgColor: 'bg-green-50 dark:bg-green-900/30',
textColor: 'text-green-600 dark:text-green-400',
borderColor: 'border-green-200 dark:border-green-600/50',
hoverBorderColor: 'hover:border-green-300 dark:hover:border-green-500'
};
case 4: // 判断步骤 - 菱形
return {
shape: 'transform rotate-45',
bgColor: 'bg-purple-50 dark:bg-purple-900/30',
textColor: 'text-purple-600 dark:text-purple-400',
borderColor: 'border-purple-200 dark:border-purple-600/50',
hoverBorderColor: 'hover:border-purple-300 dark:hover:border-purple-500'
};
default:
return {
shape: 'rounded-md',
bgColor: 'bg-gray-50 dark:bg-gray-800',
textColor: 'text-gray-600 dark:text-gray-400',
borderColor: 'border-gray-200 dark:border-gray-700',
hoverBorderColor: 'hover:border-gray-300 dark:hover:border-gray-600'
};
}
};
const getIconBgColor = (iconName: string) => {
// 设备相关图标 - 蓝色
const deviceIcons = ['smartphone', 'phone', 'phone-call', 'phone-off', 'wifi', 'wifi-off', 'signal', 'signal-high', 'signal-low'];
// 网络相关图标 - 绿色
const networkIcons = ['network', 'activity'];
// 控制相关图标 - 橙色
const controlIcons = ['play-circle', 'stop-circle'];
// 配置相关图标 - 紫色
const configIcons = ['settings', 'git-branch'];
if (deviceIcons.includes(iconName)) {
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400';
} else if (networkIcons.includes(iconName)) {
return 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400';
} else if (controlIcons.includes(iconName)) {
return 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400';
} else if (configIcons.includes(iconName)) {
return 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400';
case 1: return { bgColor: 'bg-blue-50', borderColor: 'border-blue-200' };
case 2: return { bgColor: 'bg-red-50', borderColor: 'border-red-200' };
case 3: return { bgColor: 'bg-green-50', borderColor: 'border-green-200' };
case 4: return { bgColor: 'bg-purple-50', borderColor: 'border-purple-200' };
default: return { bgColor: 'bg-gray-50', borderColor: 'border-gray-200' };
}
return 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400';
};
const nodeStyle = getNodeStyle(data.stepType);
return (
<div className={`group relative transition-all duration-200`}>
{/* 开始和结束步骤使用圆形 */}
{(data.stepType === 1 || data.stepType === 2) && (
<div
className={`px-2 py-1 ${nodeStyle.shape} ${nodeStyle.bgColor} border ${nodeStyle.borderColor} ${
selected ? 'ring-2 ring-blue-500' : ''
}`}
>
<div className="flex items-center space-x-1">
<div className={`flex-shrink-0 w-3 h-3 rounded-lg ${getIconBgColor(data.icon || 'settings')} flex items-center justify-center`}>
{getIconComponent(data.icon || 'settings')}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-gray-900 dark:text-gray-100 break-words">
{data.stepName}
</div>
</div>
</div>
</div>
)}
<div className={`px-3 py-2 rounded-md border ${nodeStyle.bgColor} ${nodeStyle.borderColor} ${selected ? 'ring-2 ring-blue-500' : ''}`}>
<div className="flex items-center space-x-2">
<Settings className="h-4 w-4 text-gray-600" />
<span className="text-sm font-medium text-gray-900">{data.stepName}</span>
</div>
{/* 处理步骤使用矩形 */}
{data.stepType === 3 && (
<div
className={`px-2 py-1 ${nodeStyle.shape} ${nodeStyle.bgColor} border ${nodeStyle.borderColor} ${
selected ? 'ring-2 ring-blue-500' : ''
}`}
>
<div className="flex items-center space-x-1">
<div className={`flex-shrink-0 w-3 h-3 rounded-lg ${getIconBgColor(data.icon || 'settings')} flex items-center justify-center`}>
{getIconComponent(data.icon || 'settings')}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-gray-900 dark:text-gray-100 break-words">
{data.stepName}
</div>
</div>
</div>
</div>
)}
{/* 判断步骤使用菱形 */}
{data.stepType === 4 && (
<div
className={`px-2 py-1 ${nodeStyle.shape} ${nodeStyle.bgColor} border ${nodeStyle.borderColor} ${
selected ? 'ring-2 ring-blue-500' : ''
}`}
>
<div className="flex items-center space-x-1">
<div className={`flex-shrink-0 w-3 h-3 rounded-lg ${getIconBgColor(data.icon || 'settings')} flex items-center justify-center`}>
{getIconComponent(data.icon || 'settings')}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-gray-900 dark:text-gray-100 break-words">
{data.stepName}
</div>
</div>
</div>
</div>
)}
{/* 连接点 - 根据节点类型显示不同的连接点 */}
{/* 开始节点 (type=1) - 只有输出连接点 */}
{data.stepType === 1 && (
<>
<Handle
type="source"
position={Position.Right}
id="right"
className="w-3 h-3 bg-green-500 border-2 border-white rounded-full hover:bg-green-600"
style={{ right: -6 }}
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
className="w-3 h-3 bg-green-500 border-2 border-white rounded-full hover:bg-green-600"
style={{ bottom: -6 }}
/>
</>
)}
{/* 结束节点 (type=2) - 只有输入连接点 */}
{data.stepType === 2 && (
<>
<Handle
type="target"
position={Position.Top}
id="top"
className="w-3 h-3 bg-blue-500 border-2 border-white rounded-full hover:bg-blue-600"
style={{ top: -6 }}
/>
<Handle
type="target"
position={Position.Left}
id="left"
className="w-3 h-3 bg-blue-500 border-2 border-white rounded-full hover:bg-blue-600"
style={{ left: -6 }}
/>
</>
)}
{/* 处理节点 (type=3) - 有输入和输出连接点 */}
{data.stepType === 3 && (
<>
<Handle
type="target"
position={Position.Top}
id="top"
className="w-3 h-3 bg-blue-500 border-2 border-white rounded-full hover:bg-blue-600"
style={{ top: -6 }}
/>
<Handle
type="source"
position={Position.Right}
id="right"
className="w-3 h-3 bg-green-500 border-2 border-white rounded-full hover:bg-green-600"
style={{ right: -6 }}
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
className="w-3 h-3 bg-green-500 border-2 border-white rounded-full hover:bg-green-600"
style={{ bottom: -6 }}
/>
<Handle
type="target"
position={Position.Left}
id="left"
className="w-3 h-3 bg-blue-500 border-2 border-white rounded-full hover:bg-blue-600"
style={{ left: -6 }}
/>
</>
)}
{/* 判断节点 (type=4) - 有输入和输出连接点 */}
{data.stepType === 4 && (
<>
<Handle
type="target"
position={Position.Top}
id="top"
className="w-3 h-3 bg-blue-500 border-2 border-white rounded-full hover:bg-blue-600"
style={{ top: -6 }}
/>
<Handle
type="source"
position={Position.Right}
id="right"
className="w-3 h-3 bg-green-500 border-2 border-white rounded-full hover:bg-green-600"
style={{ right: -6 }}
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
className="w-3 h-3 bg-green-500 border-2 border-white rounded-full hover:bg-green-600"
style={{ bottom: -6 }}
/>
<Handle
type="target"
position={Position.Left}
id="left"
className="w-3 h-3 bg-blue-500 border-2 border-white rounded-full hover:bg-blue-600"
style={{ left: -6 }}
/>
</>
)}
{/* 连接点 */}
<Handle
type="target"
position={Position.Top}
className="w-3 h-3 bg-blue-500 border-2 border-white rounded-full"
style={{ top: -6 }}
/>
<Handle
type="source"
position={Position.Bottom}
className="w-3 h-3 bg-green-500 border-2 border-white rounded-full"
style={{ bottom: -6 }}
/>
</div>
);
};
// 节点类型定义
const nodeTypes = {
testStep: TestStepNode,
};
const nodeTypes = { testStep: TestStepNode };
function TestCaseDetailDrawerInner({ testCaseId, open, onOpenChange }: TestCaseDetailDrawerProps) {
const [selectedTestCase, setSelectedTestCase] = useState<TestCaseFlowDetail | null>(null);
@ -325,7 +73,6 @@ function TestCaseDetailDrawerInner({ testCaseId, open, onOpenChange }: TestCaseD
if (open && testCaseId) {
loadTestCaseDetail(testCaseId);
} else {
// 关闭抽屉时清理状态
setSelectedTestCase(null);
setError(null);
setNodes([]);
@ -340,76 +87,40 @@ function TestCaseDetailDrawerInner({ testCaseId, open, onOpenChange }: TestCaseD
const result = await testcaseService.getTestCaseFlowById(id);
if (result.isSuccess && result.data) {
setSelectedTestCase(result.data.testCaseFlow);
// 转换节点和连线为ReactFlow格式
const flowData = getReactFlowData(result.data.testCaseFlow);
setNodes(flowData.nodes);
setEdges(flowData.edges);
// 延迟执行 fitView,确保节点已渲染
setTimeout(() => {
fitView({ padding: 0.1 });
}, 100);
setTimeout(() => fitView({ padding: 0.1 }), 100);
} else {
setError('加载测试用例详情失败');
console.error('加载测试用例详情失败:', result.errorMessages);
}
} catch (error) {
setError('加载测试用例详情出错');
console.error('加载测试用例详情出错:', error);
} finally {
setFlowLoading(false);
}
};
// 转换节点和连线为ReactFlow格式
const getReactFlowData = (testCase: TestCaseFlowDetail) => {
console.log('原始节点数据:', testCase.nodes);
console.log('原始连线数据:', testCase.edges);
const flowNodes: Node[] = testCase.nodes.map(node => {
console.log('处理节点:', node);
console.log('节点数据:', node.data);
console.log('节点位置:', node.position);
// 检查数据字段是否存在,如果不存在则使用默认值
const nodeData = {
const flowNodes: Node[] = testCase.nodes.map(node => ({
id: node.id,
type: 'testStep',
position: node.position,
data: {
stepId: node.data?.stepId || node.id,
stepName: node.data?.stepName || 'Unknown',
stepType: node.data?.stepType || 3,
stepTypeName: node.data?.stepTypeName || '处理步骤',
description: node.data?.description || '',
icon: node.data?.icon || 'settings'
};
console.log('处理后的节点数据:', nodeData);
return {
id: node.id,
type: 'testStep', // 使用自定义节点类型
position: node.position,
data: nodeData,
width: node.width || 150,
height: node.height || 50,
selected: node.selected || false,
positionAbsolute: node.positionAbsolute,
dragging: node.dragging || false
};
});
}
}));
const flowEdges: Edge[] = testCase.edges.map(edge => {
console.log('处理连线:', edge);
return {
id: edge.id,
source: edge.source,
sourceHandle: edge.sourceHandle || null,
target: edge.target,
targetHandle: edge.targetHandle || null,
type: edge.type || 'smoothstep',
animated: edge.animated || false,
style: edge.style || { stroke: '#333', strokeWidth: 2 },
data: edge.data || {}
};
});
const flowEdges: Edge[] = testCase.edges.map(edge => ({
id: edge.id,
source: edge.source,
target: edge.target,
type: 'smoothstep',
style: { stroke: '#3b82f6', strokeWidth: 2 }
}));
return { nodes: flowNodes, edges: flowEdges };
};
@ -434,11 +145,6 @@ function TestCaseDetailDrawerInner({ testCaseId, open, onOpenChange }: TestCaseD
) : error ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="text-red-500 mb-2">
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<p className="text-gray-600">{error}</p>
<button
onClick={() => testCaseId && loadTestCaseDetail(testCaseId)}
@ -449,7 +155,7 @@ function TestCaseDetailDrawerInner({ testCaseId, open, onOpenChange }: TestCaseD
</div>
</div>
) : selectedTestCase ? (
<div className="h-full border rounded-lg">
<div className="h-full border rounded-lg bg-gray-50">
<ReactFlow
nodes={nodes}
edges={edges}
@ -457,7 +163,7 @@ function TestCaseDetailDrawerInner({ testCaseId, open, onOpenChange }: TestCaseD
fitView
className="bg-gray-50"
defaultViewport={{ x: 0, y: 0, zoom: 1.5 }}
minZoom={1}
minZoom={0.5}
maxZoom={2}
style={{ width: '100%', height: '100%' }}
snapToGrid={true}
@ -465,6 +171,11 @@ function TestCaseDetailDrawerInner({ testCaseId, open, onOpenChange }: TestCaseD
>
<Controls />
<Background />
<MiniMap
nodeColor="#3b82f6"
maskColor="rgba(0, 0, 0, 0.1)"
style={{ width: 150, height: 100 }}
/>
</ReactFlow>
</div>
) : (

51
src/X1.WebUI/src/pages/users/UserForm.tsx

@ -1,8 +1,10 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { CreateUserRequest } from '@/services/userService';
import { roleService, Role } from '@/services/roleService';
interface UserFormProps {
onSubmit: (data: CreateUserRequest) => void;
@ -18,11 +20,35 @@ export default function UserForm({ onSubmit, initialData }: UserFormProps) {
roles: initialData?.roles || []
});
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchRoles = async () => {
setLoading(true);
const result = await roleService.getAllRoles();
if (result.isSuccess && result.data) {
setRoles(result.data.roles || []);
}
setLoading(false);
};
fetchRoles();
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(formData);
};
const handleRoleChange = (roleName: string, checked: boolean) => {
const currentRoles = formData.roles || [];
if (checked) {
setFormData({ ...formData, roles: [...currentRoles, roleName] });
} else {
setFormData({ ...formData, roles: currentRoles.filter(r => r !== roleName) });
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
@ -62,6 +88,29 @@ export default function UserForm({ onSubmit, initialData }: UserFormProps) {
required={!initialData}
/>
</div>
<div className="space-y-2">
<Label></Label>
{loading ? (
<div className="text-sm text-muted-foreground">...</div>
) : (
<div className="grid grid-cols-2 gap-4">
{roles.map((role) => (
<div key={role.id} className="flex items-center space-x-2">
<Checkbox
id={`role-${role.id}`}
checked={formData.roles?.includes(role.name) || false}
onCheckedChange={(checked: boolean) => handleRoleChange(role.name, checked)}
/>
<Label htmlFor={`role-${role.id}`} className="text-sm font-normal">
{role.name}
</Label>
</div>
))}
</div>
)}
</div>
<Button type="submit" className="w-full">
{initialData ? '更新用户' : '创建用户'}
</Button>

43
src/modify.md

@ -1,5 +1,48 @@
# 修改记录
## 2025-01-21 - UserForm 组件添加角色选择功能
#### 修改文件:
`X1.WebUI/src/pages/users/UserForm.tsx` - 为用户创建和编辑表单添加角色选择功能
#### 修改内容:
1. **角色选择功能添加**
- **导入依赖**:添加 `useEffect`, `useState``roleService`, `Role` 导入
- **状态管理**:添加 `roles``loading` 状态管理角色数据和加载状态
- **数据获取**:在组件挂载时自动获取所有可用角色列表
- **角色选择**:使用 `Checkbox` 组件实现多角色选择功能
2. **角色选择界面**
- **加载状态**:显示"加载角色中..."提示
- **网格布局**:使用 `grid grid-cols-2 gap-4` 布局,每行显示两个角色
- **复选框**:每个角色使用独立的复选框,支持多选
- **标签显示**:显示角色名称,使用 `text-sm font-normal` 样式
3. **角色数据处理**
- **初始值设置**:在 `formData` 中初始化 `roles` 字段为 `initialData?.roles || []`
- **角色变更处理**:实现 `handleRoleChange` 方法处理角色选择/取消选择
- **数据同步**:确保表单数据与角色选择状态保持同步
4. **技术特性**
- **异步加载**:使用 `useEffect` 异步加载角色数据
- **错误处理**:通过 `roleService.getAllRoles()` 的错误处理机制
- **用户体验**:加载状态提示,防止用户困惑
- **数据完整性**:确保角色数据正确传递给后端
5. **界面布局**
- **表单结构**:在密码字段后添加角色选择区域
- **响应式设计**:使用网格布局适应不同屏幕尺寸
- **视觉一致性**:与现有表单字段保持一致的样式和间距
#### 修改时间:
2025-01-21
#### 修改原因:
用户发现虽然 `userService.ts` 中的 `CreateUserRequest` 接口支持 `roles?: string[]` 字段,但 `UserForm.tsx` 组件中缺少角色选择功能。需要添加角色选择功能,使用户能够在创建和编辑用户时选择多个角色,确保前端界面与后端 `CreateUserCommand` 完全匹配。
---
## 2025-01-21 - NodeData 添加 stepId 必填字段和 TestCaseNode 实体修复
#### 修改文件:

Loading…
Cancel
Save