You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

580 lines
19 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import React, { useEffect, useState, useRef } from 'react';
import { Button, Space, Tree, Collapse, Divider, Message, Modal } from '@arco-design/web-react';
import { IconLeft, IconPlus, IconEdit, IconDelete, IconLink, IconSend } from '@arco-design/web-react/icon';
import styles from './style/testInstance.module.less';
import { getComponentDesign } from '@/api/componentDevelopProcess';
import {
getComponentTestCaseList,
submitTestCase,
deleteTestCase,
exportTemplate,
exportTestCases,
importTestCases,
startTestCase,
sendTestCase,
generateTestCase
} from '@/api/componentTestCase';
import TestCaseModal from './testCaseModal';
import useWebSocket from '@/hooks/useWebSocket';
import { getToken } from '@/utils/auth';
const CollapseItem = Collapse.Item;
const TestInstance = ({ instance, parentId, onBack }: { instance: any; parentId: string; onBack: () => void }) => {
const [activeTab, setActiveTab] = useState('link');
const [testCaseList, setTestCaseList] = useState([]);
const [design, setDesign] = useState(null);
const [modalVisible, setModalVisible] = useState(false);
const [selectedOperationIdent, setSelectedOperationIdent] = useState('');
const [activeOperationIdent, setActiveOperationIdent] = useState('');
const [editingTestCase, setEditingTestCase] = useState(null);
const [logs, setLogs] = useState<string[]>([]);
const [isSocketConnected, setIsSocketConnected] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// WebSocket hook
const { connect, disconnect, isConnected, sendMessage } = useWebSocket({
onOpen: () => {
console.log('链接成功');
setIsSocketConnected(true);
addLog('连接信息:连接成功!');
},
onClose: () => {
console.log('链接关闭');
setIsSocketConnected(false);
addLog('连接信息:连接已断开');
},
onError: () => {
console.log('链接错误');
addLog('连接错误:连接失败');
},
onMessage: (event) => {
try {
const data = JSON.parse(event.data);
addLog(`收到消息: ${JSON.stringify(data)}`);
} catch (e) {
addLog(`收到消息: ${event.data}`);
}
}
});
// 添加日志
const addLog = (message: string) => {
const timestamp = new Date().toLocaleTimeString();
setLogs(prev => [...prev, `[${timestamp}] ${message}`]);
};
// 清空日志
const clearLogs = () => {
setLogs([]);
};
const getDesign = async () => {
const res: any = await getComponentDesign(parentId);
if (res.code === 200) {
setDesign(res.data);
// 默认激活第一个 operation
if (res.data?.operates?.length > 0) {
setActiveOperationIdent(res.data.operates[0].ident);
}
}
};
const getTestCaseList = async () => {
const res: any = await getComponentTestCaseList({ componentBaseId: parentId, identifier: instance.identifier });
if (res.code === 200) setTestCaseList(res.data);
};
useEffect(() => {
getTestCaseList();
getDesign();
}, [parentId, instance]);
// 组件卸载时断开连接
useEffect(() => {
return () => {
disconnect();
};
}, [disconnect]);
const cryptoRandom = () => {
return new Date().getTime().toString(16) + Math.random().toString(16).substring(2);
};
// 链接实例
const handleLinkInstance = async () => {
if (isSocketConnected) {
disconnect();
Message.info('已断开连接');
}
else {
// WebSocket连接前置
const res: any = await startTestCase(instance.id);
console.log('res:', res);
// 构建WebSocket URL根据你的实际后端配置调整
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
let wsUrl = '';
if (window.location.host.includes('localhost')) {
wsUrl = `${process.env.NEXT_PUBLIC_DEV_SOCKET_HOST}/ws/v1/bpms-workbench/test-case/${instance.id}/${cryptoRandom()}?Authorization=Bearer ${getToken()}`;
}
else {
wsUrl = `${protocol}//${host}/ws/v1/bpms-workbench/test-case/${instance.id}/${cryptoRandom()}?Authorization=Bearer ${getToken()}`;
}
connect(wsUrl);
addLog('正在连接测试实例...');
}
};
const handleSubmitTestCase = async (testCase: any, operationIdent: string) => {
const res: any = await sendTestCase(testCase);
if (res.code === 200) {
Message.success('测试用例发送成功');
}
else {
Message.error(res.msg || '测试用例发送失败');
}
};
const handleAddTestCase = (operationIdent: string) => {
setSelectedOperationIdent(operationIdent);
setEditingTestCase(null);
setModalVisible(true);
};
const handleEditTestCase = (testCase: any, operationIdent: string) => {
setSelectedOperationIdent(operationIdent);
setEditingTestCase(testCase);
setModalVisible(true);
};
const handleDeleteTestCase = (testCase: any) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除测试用例"${testCase.testCaseName}"吗?`,
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
const res: any = await deleteTestCase(testCase.id);
if (res.code === 200) {
Message.success('删除成功');
getTestCaseList();
}
else {
Message.error(res.msg || '删除失败');
}
} catch (error) {
Message.error('删除失败');
}
}
});
};
// 导出测试用例模板
const handleExportTemplate = async () => {
try {
Message.loading('正在生成模板文件...');
const res: any = await exportTemplate(parentId);
// 创建下载链接
const blob = new Blob([res], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
// 设置文件名
const fileName = `测试用例模板_${instance.identifier}_${new Date().getTime()}.xlsx`;
link.setAttribute('download', fileName);
// 触发下载
document.body.appendChild(link);
link.click();
// 清理
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
Message.success('模板下载成功');
} catch (error) {
console.error('导出模板失败:', error);
Message.error('导出模板失败');
}
};
// 导出当前测试用例
const handleExportTestCases = async () => {
try {
// 检查是否有测试用例
if (!testCaseList || testCaseList.length === 0) {
Message.warning('当前没有测试用例可导出');
return;
}
Message.loading('正在生成测试用例文件...');
const res: any = await exportTestCases(parentId);
// 创建下载链接
const blob = new Blob([res], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
// 设置文件名
const fileName = `测试用例_${instance.identifier}_${new Date().getTime()}.xlsx`;
link.setAttribute('download', fileName);
// 触发下载
document.body.appendChild(link);
link.click();
// 清理
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
Message.success('测试用例导出成功');
} catch (error) {
console.error('导出测试用例失败:', error);
Message.error('导出测试用例失败');
}
};
// 触发文件选择
const handleImportClick = () => {
fileInputRef.current?.click();
};
// 处理文件上传
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// 验证文件类型
const allowedTypes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
'application/vnd.ms-excel' // .xls
];
if (!allowedTypes.includes(file.type) && !file.name.match(/\.(xlsx|xls)$/i)) {
Message.error('请选择Excel文件.xlsx 或 .xls');
event.target.value = ''; // 清空input
return;
}
try {
Message.loading('正在导入测试用例...');
const res: any = await importTestCases({
componentBaseId: parentId,
file: file
});
if (res.code === 200) {
Message.success(`导入成功!`);
getTestCaseList(); // 刷新列表
}
else {
Message.error(res.msg || '导入失败');
}
} catch (error) {
console.error('导入测试用例失败:', error);
Message.error('导入测试用例失败');
} finally {
// 清空input允许重复选择同一文件
event.target.value = '';
}
};
const handleModalOk = async (values: any) => {
const params = {
...values,
componentBaseId: parentId,
identifier: instance.identifier
};
const res: any = await submitTestCase(params);
if (res.code === 200) {
Message.success('添加测试用例成功');
getTestCaseList();
}
else Message.error(res.msg);
setModalVisible(false);
};
const handleModalCancel = () => {
setModalVisible(false);
setEditingTestCase(null);
};
const handleGenerateTestCases = async () => {
const res: any = await generateTestCase({ id: design?.baseInfo.id, identifier: instance.identifier });
if (res.code === 200) {
Message.success('生成测试用例成功');
getTestCaseList();
}
else {
Message.error(res.msg);
}
};
return (
<div className={styles['test-instance']}>
{/* 隐藏的文件input */}
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
{/* 顶部标签栏 */}
<div className={styles['tabs']}>
<div className={styles['tab-left']}>
<Button
type="primary"
status={isSocketConnected ? 'danger' : 'default'}
icon={<IconLink />}
onClick={handleLinkInstance}
style={{ minWidth: 250 }}
>
{isSocketConnected ? '断开实例' : '链接实例'}
</Button>
</div>
<div className={styles['tab-center']}>
<Button
type="outline"
status="success"
onClick={handleGenerateTestCases}
>
</Button>
<Button
type="outline"
onClick={handleExportTestCases}
>
</Button>
<Button
type="outline"
onClick={handleImportClick}
>
</Button>
<Button
type="outline"
onClick={handleExportTemplate}
>
</Button>
</div>
<div className={styles['tab-right']}></div>
</div>
<div className={styles['main-content']}>
{/* 左侧测试用例列表 */}
<div className={styles['left-panel']}>
<div className={styles['panel-header']}>
<span></span>
</div>
<div className={styles['tree-container']}>
<Collapse defaultActiveKey={['1']} bordered={false} accordion>
{testCaseList.map((item: any, index: number) => (
<CollapseItem
header={
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center'
}}
onClick={() => setActiveOperationIdent(item.operationIdent)}
>
{item.operationIdent}
</div>
}
name={index.toString()}
key={index}
extra={
<IconPlus
onClick={(e) => {
e.stopPropagation();
handleAddTestCase(item.operationIdent);
}}
style={{ cursor: 'pointer' }}
/>
}
>
<div className={styles['tree-list']}>
{item.children.map((child: any, index1: number) => (
<div
className={styles['tree-item']}
key={index1}
onClick={() => setActiveOperationIdent(item.operationIdent)}
>
<span className={styles['item-text']}>{child.testCaseName}</span>
<div className={styles['item-actions']}>
<IconSend
onClick={e => {
e.stopPropagation();
isSocketConnected && handleSubmitTestCase(child, item.operationIdent);
}}
/>
<IconEdit
onClick={(e) => {
e.stopPropagation();
handleEditTestCase(child, item.operationIdent);
}}
/>
<IconDelete
onClick={(e) => {
e.stopPropagation();
handleDeleteTestCase(child);
}}
/>
</div>
</div>
))}
</div>
</CollapseItem>
))}
</Collapse>
</div>
</div>
{/* 中间流程图区域 */}
<div className={styles['center-panel']}>
<div className={styles['flow-wrapper']}>
{/* 开始节点 */}
<div className={styles['flow-node']}>
<div className={`${styles['node-box']} ${styles['node-start']}`}>
<div className={styles['node-header']}>
<span className={styles['node-title']}></span>
</div>
<div className={styles['node-label']}></div>
</div>
<div className={styles['node-connector']}>
<div className={styles['connector-line']}></div>
<div className={styles['connector-dot']}></div>
</div>
</div>
{/* 组件节点 - 单个节点展示所有接口 */}
<div className={styles['flow-node']}>
<div className={`${styles['node-box']} ${styles['node-component']}`}>
<div className={styles['node-header']}>
<span className={styles['node-title']}>{instance.identifier}</span>
</div>
<div className={styles['node-body']}>
{/* 显示所有接口函数 - 固定在上方 */}
{design?.operates?.map((operation: any, index: number) => (
<div
key={index}
className={`${styles['node-function']} ${
activeOperationIdent === operation.ident ? styles['active'] : ''
}`}
>
<span className={styles['function-dot']}></span>
{operation.ident}
</div>
))}
{/* 分割线 */}
<Divider />
{/* 显示激活接口的参数 */}
{design?.operates?.map((operation: any, index: number) => (
activeOperationIdent === operation.ident && (
<div key={index}>
<div className={styles['node-params']}>
{/* Input 参数 - 显示 parameters */}
{operation.parameters?.map((param: any, paramIndex: number) => (
<div className={`${styles['param-item']} ${styles['active']}`} key={paramIndex}>
<span className={styles['param-dot']}></span>
<span className={styles['param-label']}>
{param.ident} {param.type}
</span>
</div>
))}
</div>
{/* Output 参数 - 显示 responses */}
{operation.responses?.map((response: any, respIndex: number) => (
<div className={`${styles['param-item-right']} ${styles['active']}`} key={respIndex}>
<span className={styles['param-label']}>
{response.ident} {response.type}
</span>
<span className={styles['param-dot']}></span>
</div>
))}
</div>
)
))}
</div>
</div>
<div className={styles['node-connector']}>
<div className={styles['connector-line']}></div>
<div className={styles['connector-dot']}></div>
</div>
</div>
{/* 结束节点 */}
<div className={styles['flow-node']}>
<div className={`${styles['node-box']} ${styles['node-end']}`}>
<div className={styles['node-header']}>
<span className={styles['node-title']}></span>
</div>
<div className={styles['node-label']}></div>
</div>
</div>
</div>
</div>
{/* 右侧运行日志 */}
<div className={styles['right-panel']}>
<div className={styles['panel-header']}>
<span></span>
<div className={styles['header-actions']}>
<span className={styles['icon-btn']} onClick={clearLogs} title="清空日志">
<IconDelete />
</span>
</div>
</div>
<div className={styles['log-content']}>
{logs.length === 0 ? (
<div className={styles['log-empty']}>
<p></p>
<p>&#34;&#34;</p>
</div>
) : (
<div className={styles['log-list']}>
{logs.map((log, index) => (
<div key={index} className={styles['log-item']}>
{log}
</div>
))}
</div>
)}
</div>
</div>
</div>
{modalVisible && <TestCaseModal
design={design.operates}
visible={modalVisible}
operationIdent={selectedOperationIdent}
editingData={editingTestCase}
onCancel={handleModalCancel}
onOk={handleModalOk}
/>}
</div>
);
};
export default TestInstance;