feat: 合并长安巡检和艾玛

This commit is contained in:
zhanghao 2026-07-28 09:22:22 +08:00
parent f5711a1212
commit 998d7d3826
47 changed files with 7238 additions and 19 deletions

View File

@ -50,6 +50,7 @@
"vue3-ace-editor": "^2.2.4", "vue3-ace-editor": "^2.2.4",
"vue3-json-viewer": "2.3.0", "vue3-json-viewer": "2.3.0",
"vuedraggable": "4.1.0", "vuedraggable": "4.1.0",
"xacro-parser": "^0.3.11",
"xgplayer": "^3.0.22" "xgplayer": "^3.0.22"
}, },
"devDependencies": { "devDependencies": {

View File

@ -0,0 +1,465 @@
<?xml version="1.0"?>
<robot name="elfin10" xmlns:xacro="http://wiki.ros.org/xacro">
<xacro:arg name="use_real_hardware" default="true" />
<xacro:arg name="use_fake_hardware" default="false" />
<!-- Load TRANSMISSION file: -->
<xacro:include filename="file://$(find elfin_description)/elfin10_transmission.xacro" />
<!-- Load GAZEBO/ROS2_CONTROL file: -->
<xacro:include filename="file://$(find elfin_description)/elfin10_gazebo.xacro" />
<xacro:property name="PI" value="3.1415926535897931"/>
<xacro:property name="density" value="3.09"/>
<!-- <xacro:macro name="elfin10"> -->
<!-- TRANSMISSION: -->
<xacro:elfin10_transmission />
<!-- GAZEBO - ROS2_CONTROL: -->
<xacro:elfin10_gazebo
use_real_hardware="$(arg use_real_hardware)"
use_fake_hardware="$(arg use_fake_hardware)"/>
<link name="world"/>
<joint name="world_base" type="fixed">
<origin
xyz="0 0 0"
rpy="0 0 -1.5708" />
<parent
link="world" />
<child
link="elfin_base_link" />
</joint>
<link name="elfin_base_link"/>
<joint name="elfin_base_joint" type="fixed">
<origin
xyz="0 0 0"
rpy="0 0 ${PI/2}" />
<parent
link="elfin_base_link" />
<child
link="elfin_base" />
</joint>
<link
name="elfin_base">
<inertial>
<origin
xyz="-0.00226511349377665 4.35718066723588E-06 0.0325128562537015"
rpy="0 0 0" />
<mass
value="1.66727740473724" />
<inertia
ixx="0.004269288474386"
ixy="6.02518130623876E-08"
ixz="-6.92087963593712E-05"
iyy="0.00385585336301628"
iyz="-5.51423740998761E-08"
izz="0.0068204473327682" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="file://$(find elfin_description)/meshes/elfin10/elfin_base.STL" />
</geometry>
<material
name="">
<color
rgba="0.752941176470588 0.752941176470588 0.752941176470588 1" />
</material>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="file://$(find elfin_description)/meshes/elfin10/elfin_base.STL" />
</geometry>
</collision>
</link>
<link
name="elfin_link1">
<inertial>
<origin
xyz="7.61162863471406E-07 -0.00300107309756385 -0.0432743915921636"
rpy="0 0 0" />
<mass
value="6.3589066662676" />
<inertia
ixx="0.0391771076676677"
ixy="5.46819690041203E-08"
ixz="4.18972540516346E-07"
iyy="0.0381555138034643"
iyz="0.00661206821297882"
izz="0.0211340101406971" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link1.STL" />
</geometry>
<material
name="">
<color
rgba="0.752941176470588 0.752941176470588 0.752941176470588 1" />
</material>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link1.STL" />
</geometry>
</collision>
</link>
<joint
name="elfin_joint1"
type="revolute">
<origin
xyz="0 0 0.26"
rpy="0 0 0" />
<parent
link="elfin_base" />
<child
link="elfin_link1" />
<axis
xyz="0 0 1" />
<limit
lower="-3.14"
upper="3.14"
effort="420"
velocity="1.57" />
</joint>
<link
name="elfin_link2">
<inertial>
<origin
xyz="-1.29217317440473E-07 0.204406209458582 0.145205671136993"
rpy="0 0 0" />
<mass
value="5.51072381215317" />
<inertia
ixx="0.140886684844275"
ixy="-1.98882818733046E-05"
ixz="1.28793862225363E-06"
iyy="0.00904523320332792"
iyz="0.00556623571013066"
izz="0.143882970137024" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link2.STL" />
</geometry>
<material
name="">
<color
rgba="0.752941176470588 0.752941176470588 0.752941176470588 1" />
</material>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link2.STL" />
</geometry>
</collision>
</link>
<joint
name="elfin_joint2"
type="revolute">
<origin
xyz="0 0 0"
rpy="1.5708 0 0" />
<parent
link="elfin_link1" />
<child
link="elfin_link2" />
<axis
xyz="0 0 1" />
<limit
lower="-2.35"
upper="2.35"
effort="420"
velocity="1.57" />
</joint>
<link
name="elfin_link3">
<inertial>
<origin
xyz="0.0357493010718184 -2.74303992683623E-06 -0.00441107040289549"
rpy="0 0 0" />
<mass
value="3.25128088148268" />
<inertia
ixx="0.00712273850158261"
ixy="4.14573354600121E-07"
ixz="-0.00240614658407673"
iyy="0.0131139013038093"
iyz="-1.53858261863385E-07"
izz="0.0123861174875263" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link3.STL" />
</geometry>
<material
name="">
<color
rgba="0.752941176470588 0.752941176470588 0.752941176470588 1" />
</material>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link3.STL" />
</geometry>
</collision>
</link>
<joint
name="elfin_joint3"
type="revolute">
<origin
xyz="0 0.48 0"
rpy="-3.1416 0 1.5708" />
<parent
link="elfin_link2" />
<child
link="elfin_link3" />
<axis
xyz="0 0 1" />
<limit
lower="-2.61"
upper="2.61"
effort="200"
velocity="1.57" />
</joint>
<link
name="elfin_link4">
<inertial>
<origin
xyz="3.8972274666868E-06 -0.065883038527668 0.300149807420176"
rpy="0 0 0" />
<mass
value="2.45076028364132" />
<inertia
ixx="0.0366043963749035"
ixy="2.14481800816891E-07"
ixz="1.74370308297691E-06"
iyy="0.0331005868546169"
iyz="0.00876542795872678"
izz="0.00689303506560623" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link4.STL" />
</geometry>
<material
name="">
<color
rgba="0.752941176470588 0.752941176470588 0.752941176470588 1" />
</material>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link4.STL" />
</geometry>
</collision>
</link>
<joint
name="elfin_joint4"
type="revolute">
<origin
xyz="0 0 0"
rpy="1.5708 0 1.5708" />
<parent
link="elfin_link3" />
<child
link="elfin_link4" />
<axis
xyz="0 0 1" />
<limit
lower="-3.14"
upper="3.14"
effort="200"
velocity="1.57" />
</joint>
<link
name="elfin_link5">
<inertial>
<origin
xyz="5.37084413168812E-06 -0.0262437975997385 -0.00353971828905618"
rpy="0 0 0" />
<mass
value="1.39234539037872" />
<inertia
ixx="0.0033625821599978"
ixy="2.21090591006111E-07"
ixz="-5.5969383984273E-07"
iyy="0.00182229285631246"
iyz="0.000639319386824375"
izz="0.00292731816323284" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link5.STL" />
</geometry>
<material
name="">
<color
rgba="0.752941176470588 0.752941176470588 0.752941176470588 1" />
</material>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link5.STL" />
</geometry>
</collision>
</link>
<joint
name="elfin_joint5"
type="revolute">
<origin
xyz="0 0 0.52"
rpy="-1.5708 0 0" />
<parent
link="elfin_link4" />
<child
link="elfin_link5" />
<axis
xyz="0 0 1" />
<limit
lower="-2.56"
upper="2.56"
effort="69"
velocity="1.57" />
</joint>
<link
name="elfin_link6">
<inertial>
<origin
xyz="-8.45231478862452E-06 -0.000388392932234965 0.144636177508013"
rpy="0 0 0" />
<mass
value="0.297803050744826" />
<inertia
ixx="0.000151919382929214"
ixy="-1.8598001023495E-09"
ixz="6.95653480101813E-08"
iyy="0.000155217204136347"
iyz="-6.63866631888914E-08"
izz="0.00027009779924127" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link6.STL" />
</geometry>
<material
name="">
<color
rgba="0.752941176470588 0.752941176470588 0.752941176470588 1" />
</material>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link6.STL" />
</geometry>
</collision>
</link>
<joint
name="elfin_joint6"
type="revolute">
<origin
xyz="0 0 0"
rpy="1.5708 0 0" />
<parent
link="elfin_link5" />
<child
link="elfin_link6" />
<axis
xyz="0 0 1" />
<limit
lower="-3.14"
upper="3.14"
effort="69"
velocity="1.57" />
</joint>
<link
name="elfin_end_link"/>
<joint
name="elfin_end_joint"
type="fixed">
<origin
xyz="0 0.0 0.1735"
rpy="0 0 0" />
<parent
link="elfin_link6" />
<child
link="elfin_end_link" />
<axis
xyz="0 0 0" />
</joint>
<link name="elfin_dummy_gripper"/>
<joint name="elfin_dummy_joint" type="fixed">
<origin
xyz="0 0.109 0"
rpy="${-PI/2} ${-PI/2} 0" />
<parent
link="elfin_link6" />
<child
link="elfin_dummy_gripper" />
</joint>
</robot>

View File

@ -0,0 +1,134 @@
<?xml version="1.0"?>
<robot xmlns:xacro="http://www.ros.org/wiki/xacro">
<xacro:macro name="elfin10_gazebo"
params="use_fake_hardware:=false
use_real_hardware:=true">
<gazebo>
<plugin filename="libgazebo_ros2_control.so" name="gazebo_ros2_control">
<robot_sim_type>gazebo_ros2_control/GazeboSystem</robot_sim_type>
<parameters>$(find elfin10_ros2_gazebo)/config/elfin_arm_controller.yaml</parameters>
</plugin>
</gazebo>
<ros2_control name="GazeboSystem" type="system">
<hardware>
<xacro:if value="${use_real_hardware}">
<plugin>elfin_hardware_interface/ElfinHWInterface</plugin>
</xacro:if>
<xacro:unless value="${use_real_hardware}">
<xacro:if value="${use_fake_hardware}">
<plugin>fake_components/GenericSystem</plugin>
</xacro:if>
<xacro:unless value="${use_fake_hardware}">
<plugin>gazebo_ros2_control/GazeboSystem</plugin>
</xacro:unless>
</xacro:unless>
</hardware>
<joint name="elfin_joint1">
<command_interface name="position">
<param name="min">-3.14</param>
<param name="max">3.14</param>
</command_interface>
<command_interface name="velocity">
<param name="min">-1.57</param>
<param name="max">1.57</param>
</command_interface>
<state_interface name="position"/>
<state_interface name="velocity"/>
</joint>
<joint name="elfin_joint2">
<command_interface name="position">
<param name="min">-2.35</param>
<param name="max">2.35</param>
</command_interface>
<command_interface name="velocity">
<param name="min">-1.57</param>
<param name="max">1.57</param>
</command_interface>
<state_interface name="position"/>
<state_interface name="velocity"/>
</joint>
<joint name="elfin_joint3">
<command_interface name="position">
<param name="min">-2.61</param>
<param name="max">2.61</param>
</command_interface>
<command_interface name="velocity">
<param name="min">-1.57</param>
<param name="max">1.57</param>
</command_interface>
<state_interface name="position"/>
<state_interface name="velocity"/>
</joint>
<joint name="elfin_joint4">
<command_interface name="position">
<param name="min">-3.14</param>
<param name="max">3.14</param>
</command_interface>
<command_interface name="velocity">
<param name="min">-1.57</param>
<param name="max">1.57</param>
</command_interface>
<state_interface name="position"/>
<state_interface name="velocity"/>
</joint>
<joint name="elfin_joint5">
<command_interface name="position">
<param name="min">-2.56</param>
<param name="max">2.56</param>
</command_interface>
<command_interface name="velocity">
<param name="min">-1.57</param>
<param name="max">1.57</param>
</command_interface>
<state_interface name="position"/>
<state_interface name="velocity"/>
</joint>
<joint name="elfin_joint6">
<command_interface name="position">
<param name="min">-3.14</param>
<param name="max">3.14</param>
</command_interface>
<command_interface name="velocity">
<param name="min">-1.57</param>
<param name="max">1.57</param>
</command_interface>
<state_interface name="position"/>
<state_interface name="velocity"/>
</joint>
</ros2_control>
<gazebo reference="elfin_link2">
<visual name='visual'>
<material>
<script>
<uri>file://media/materials/scripts/gazebo.material</uri>
<name>Gazebo/Grey</name>
</script>
</material>
</visual>
</gazebo>
<gazebo reference="elfin_link4">
<visual name='visual'>
<material>
<script>
<uri>file://media/materials/scripts/gazebo.material</uri>
<name>Gazebo/Grey</name>
</script>
</material>
</visual>
</gazebo>
</xacro:macro>
</robot>

View File

@ -0,0 +1,69 @@
<?xml version="1.0"?>
<robot xmlns:xacro="http://wiki.ros.org/xacro">
<xacro:macro name="elfin10_transmission" >
<transmission name="elfin_joint1_trans">
<type>transmission_interface/SimpleTransmission</type>
<joint name="elfin_joint1">
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
</joint>
<actuator name="elfin_joint1_motor">
<mechanicalReduction>1</mechanicalReduction>
</actuator>
</transmission>
<transmission name="elfin_joint2_trans">
<type>transmission_interface/SimpleTransmission</type>
<joint name="elfin_joint2">
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
</joint>
<actuator name="elfin_joint2_motor">
<mechanicalReduction>1</mechanicalReduction>
</actuator>
</transmission>
<transmission name="elfin_joint3_trans">
<type>transmission_interface/SimpleTransmission</type>
<joint name="elfin_joint3">
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
</joint>
<actuator name="elfin_joint3_motor">
<mechanicalReduction>1</mechanicalReduction>
</actuator>
</transmission>
<transmission name="elfin_joint4_trans">
<type>transmission_interface/SimpleTransmission</type>
<joint name="elfin_joint4">
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
</joint>
<actuator name="elfin_joint4_motor">
<mechanicalReduction>1</mechanicalReduction>
</actuator>
</transmission>
<transmission name="elfin_joint5_trans">
<type>transmission_interface/SimpleTransmission</type>
<joint name="elfin_joint5">
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
</joint>
<actuator name="elfin_joint5_motor">
<mechanicalReduction>1</mechanicalReduction>
</actuator>
</transmission>
<transmission name="elfin_joint6_trans">
<type>transmission_interface/SimpleTransmission</type>
<joint name="elfin_joint6">
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
</joint>
<actuator name="elfin_joint6_motor">
<mechanicalReduction>1</mechanicalReduction>
</actuator>
</transmission>
</xacro:macro>
</robot>

View File

@ -3,7 +3,7 @@
* Converts urdf-loaders result to unified model * Converts urdf-loaders result to unified model
*/ */
import * as THREE from 'three'; import * as THREE from 'three';
import { UnifiedRobotModel, Link, Joint, JointLimits, VisualGeometry, CollisionGeometry, InertialProperties } from '../models/UnifiedRobotModel.js'; import { UnifiedRobotModel, Link, Joint, JointLimits, VisualGeometry, CollisionGeometry, InertialProperties } from '@/models/UnifiedRobotModel.js';
export class URDFAdapter { export class URDFAdapter {
/** /**

View File

@ -76,6 +76,7 @@ export class XacroAdapter {
// Parse xacro to URDF XML // Parse xacro to URDF XML
const urdfXML = await parser.parse(xacroContent); const urdfXML = await parser.parse(xacroContent);
// Convert XMLDocument to string // Convert XMLDocument to string
const serializer = new XMLSerializer(); const serializer = new XMLSerializer();
let urdfString = serializer.serializeToString(urdfXML); let urdfString = serializer.serializeToString(urdfXML);
@ -91,7 +92,6 @@ export class XacroAdapter {
// Check for parsing errors // Check for parsing errors
const parseError = cleanUrdfXML.querySelector('parsererror'); const parseError = cleanUrdfXML.querySelector('parsererror');
if (parseError) { if (parseError) {
console.error('[XacroAdapter] XML parsing error:', parseError.textContent);
throw new Error('Generated URDF has invalid XML: ' + parseError.textContent); throw new Error('Generated URDF has invalid XML: ' + parseError.textContent);
} }
@ -258,6 +258,8 @@ export class XacroAdapter {
done(null, err); done(null, err);
}); });
} else { } else {
let stlPath = path
stlPath = stlPath.replace('file:///', 'inspection/elfin10')
originalLoadMeshCb(path, manager, done); originalLoadMeshCb(path, manager, done);
} }
}).catch(error => { }).catch(error => {
@ -304,13 +306,13 @@ export class XacroAdapter {
static async loadFileFromMap(path, fileMap, workingPath) { static async loadFileFromMap(path, fileMap, workingPath) {
// Clean path - remove leading slash if present // Clean path - remove leading slash if present
let cleanPath = path; let cleanPath = path;
if (cleanPath.startsWith('/')) { // if (cleanPath.startsWith('/')) {
cleanPath = cleanPath.substring(1); // cleanPath = cleanPath.substring(1);
} // }
// Remove leading ./
cleanPath = cleanPath.replace(/^\.\//, '');
// // Remove leading ./
// cleanPath = cleanPath.replace(/^\.\//, '');
cleanPath = cleanPath.split('///')[1];
// Try different path combinations // Try different path combinations
const possiblePaths = [ const possiblePaths = [
cleanPath, cleanPath,

View File

@ -0,0 +1,97 @@
import request from '@/utils/request'
export function getPoseApi(params) {
return request({
url: '/api/arm/getPose',
method: 'get',
params: params
})
}
export function getJointStateApi(params) {
return request({
url: '/api/arm/getJointState',
method: 'get',
params: params
})
}
export function speedLApi(data) {
return request({
url: '/api/arm/speedL',
method: 'post',
data: data
})
}
export function moveJApi(data) {
return request({
url: '/api/arm/moveJ',
method: 'post',
data: data
})
}
export function stopMotionApi(params) {
return request({
url: '/api/arm/stopMotion',
method: 'get',
params: params
})
}
export function torqueOnApi(params) {
return request({
url: '/api/arm/torqueOn',
method: 'get',
params: params
})
}
export function speedJApi(data) {
return request({
url: '/api/arm/speedJ',
method: 'post',
data: data
})
}
export function clearFault(params) {
return request({
url: '/api/arm/clearFault',
method: 'get',
params: params
})
}
export function getMicVolume(params) {
return request({
url: '/api/Microphone/volume',
method: 'get',
params: params
})
}
export function setMicVolume(data) {
return request({
url: '/api/Microphone/volume',
method: 'post',
data: data
})
}
export function getSpeakerVolume(params) {
return request({
url: '/api/Speaker/volume',
method: 'get',
params: params
})
}
export function setSpeakerVolume(data) {
return request({
url: '/api/Speaker/volume',
method: 'post',
data: data
})
}

View File

@ -0,0 +1,23 @@
import request from '@/utils/request'
/**
* 查询巡检结果列表
*/
export function getInspectionResultList(params) {
return request({
url: '/inspection/result/list',
method: 'get',
params
})
}
/**
* 提交人工复核结果
*/
export function reviewInspectionResult(id, data) {
return request({
url: `/inspection/result/${id}/review`,
method: 'put',
data
})
}

View File

@ -55,3 +55,53 @@ export function getMapJson(params) {
params params
}) })
} }
/**
* 获取机器人位置
* @param {*} robotId
* @returns
*/
export function getRobotLocation(robotId) {
return request({
url: `/inspection/robot/${robotId}/location`,
method: 'get'
})
}
/**
* 获取机器人状态
* @param {*} robotId
* @returns
*/
export function getRobotStatus(robotId) {
return request({
url: `/inspection/robot/{robotId}/sync-status`,
method: 'get'
})
}
/**
* 机器人运动控制
* @param {*} data
* @returns
*/
export function moveRobot(data) {
return request({
url: '/api/agv/setVelocity',
method: 'post',
data: data
})
}
/**
* 机器人停止运动
* @param {*} data
* @returns
*/
export function stopRobot(data) {
return request({
url: '/api/agv/stopVelocityControl',
method: 'post',
data: data
})
}

View File

@ -0,0 +1,32 @@
import request from '@/utils/request'
export function addVehicle(data) {
return request({
url: '/aima/vehicle',
method: 'post',
data: data
})
}
export function getVehicleList(params) {
return request({
url: '/aima/vehicle/list',
method: 'get',
params: params
})
}
export function updateVehicle(data) {
return request({
url: '/aima/vehicle',
method: 'put',
data: data
})
}
export function deleteVehicle(ids) {
return request({
url: `/aima/vehicle/${ids}`,
method: 'delete'
})
}

View File

@ -0,0 +1,32 @@
import request from '@/utils/request'
export function addPhone(data) {
return request({
url: '/aima/phone',
method: 'post',
data: data
})
}
export function getPhoneList(params) {
return request({
url: '/aima/phone/list',
method: 'get',
params: params
})
}
export function updatePhone(data) {
return request({
url: '/aima/phone',
method: 'put',
data: data
})
}
export function deletePhone(ids) {
return request({
url: `/aima/phone/${ids}`,
method: 'delete'
})
}

View File

@ -0,0 +1,69 @@
import request from '@/utils/request'
import { method } from 'lodash'
export function addTaskinstance(data) {
return request({
url: '/aima/taskinstance',
method: 'post',
data: data
})
}
export function getTaskinstanceList(params) {
return request({
url: '/aima/taskinstance/list',
method: 'get',
params: params
})
}
export function updateTaskinstance(data) {
return request({
url: '/aima/taskinstance',
method: 'put',
data: data
})
}
export function deleteTaskinstance(ids) {
return request({
url: `/aima/taskinstance/{ids}`,
method: 'delete'
})
}
export function startTaskinstance(id) {
return request({
url: `/aima/taskinstance/start/${id}`,
method: 'put'
})
}
export function pauseTaskinstance(id) {
return request({
url: `/aima/taskinstance/pause/${id}`,
method: 'put'
})
}
export function stopTaskinstance(id) {
return request({
url: `/aima/taskinstance/stop/${id}`,
method: 'put'
})
}
export function resumeTaskinstance(id) {
return request({
url: `/aima/taskinstance/resume/${id}`,
method: 'put'
})
}
export function getTaskLogListApi(params) {
return request({
url: '/aima/testlog/list',
method: 'get',
params: params
})
}

View File

@ -0,0 +1,47 @@
import request from '@/utils/request'
export function addTask(data) {
return request({
url: '/aima/task',
method: 'post',
data: data
})
}
export function getTaskList(params) {
return request({
url: '/aima/task/list',
method: 'get',
params: params
})
}
export function updateTask(data) {
return request({
url: '/aima/task',
method: 'put',
data: data
})
}
export function deleteTask(ids) {
return request({
url: `/aima/task/${ids}`,
method: 'delete'
})
}
export function getTestCaseByTaskId(taskId) {
return request({
url: `/aima/task/testCases/${taskId}`,
method: 'get',
})
}
export function bindTastCase(taskId, ids) {
return request({
url: `/aima/task/bindTestCases/${taskId}`,
method: 'post',
data: ids
})
}

View File

@ -0,0 +1,32 @@
import request from '@/utils/request'
export function addTestCase(data) {
return request({
url: '/aima/testcase',
method: 'post',
data: data
})
}
export function getTestCaseList(params) {
return request({
url: '/aima/testcase/list',
method: 'get',
params: params
})
}
export function updateTestCase(data) {
return request({
url: '/aima/testcase',
method: 'put',
data: data
})
}
export function deleteTestCase(ids) {
return request({
url: `/aima/testcase/${ids}`,
method: 'delete'
})
}

View File

@ -0,0 +1,181 @@
/**
* Unified robot model data interface
* All formats (URDF, MJCF, USD) are converted to this unified format
*/
export class UnifiedRobotModel {
constructor() {
this.name = '';
this.links = new Map(); // Map<name, Link>
this.joints = new Map(); // Map<name, Joint>
this.materials = new Map(); // Map<name, Material>
this.constraints = new Map(); // Map<name, Constraint> - for parallel mechanism constraints
this.rootLink = null; // Root link name
this.threeObject = null; // Three.js object (if available)
}
addLink(link) {
this.links.set(link.name, link);
}
addJoint(joint) {
this.joints.set(joint.name, joint);
}
addConstraint(constraint) {
this.constraints.set(constraint.name, constraint);
}
getLink(name) {
return this.links.get(name);
}
getJoint(name) {
return this.joints.get(name);
}
getConstraint(name) {
return this.constraints.get(name);
}
}
/**
* Link interface
*/
export class Link {
constructor(name) {
this.name = name;
this.visuals = []; // VisualGeometry[]
this.collisions = []; // CollisionGeometry[]
this.inertial = null; // InertialProperties
this.threeObject = null; // Three.js object
this.userData = {}; // User-defined data (for adapters to store additional information)
}
}
/**
* VisualGeometry interface
*/
export class VisualGeometry {
constructor() {
this.name = '';
this.origin = { xyz: [0, 0, 0], rpy: [0, 0, 0] };
this.geometry = null; // GeometryType
this.material = null; // Material
this.threeObject = null; // Three.js Mesh
}
}
/**
* CollisionGeometry interface
*/
export class CollisionGeometry {
constructor() {
this.name = '';
this.origin = { xyz: [0, 0, 0], rpy: [0, 0, 0] };
this.geometry = null; // GeometryType
this.threeObject = null; // Three.js Mesh
}
}
/**
* GeometryType interface
*/
export class GeometryType {
constructor(type) {
this.type = type; // 'box' | 'sphere' | 'cylinder' | 'mesh'
this.size = null; // Size parameters (varies by type)
this.filename = null; // Mesh file path (if mesh type)
}
clone() {
const cloned = new GeometryType(this.type);
cloned.size = this.size ? { ...this.size } : null;
cloned.filename = this.filename;
return cloned;
}
}
/**
* InertialProperties interface
*/
export class InertialProperties {
constructor() {
this.mass = 0;
this.origin = { xyz: [0, 0, 0], rpy: [0, 0, 0] };
this.ixx = 0;
this.iyy = 0;
this.izz = 0;
this.ixy = 0;
this.ixz = 0;
this.iyz = 0;
}
}
/**
* Joint interface
*/
export class Joint {
constructor(name, type) {
this.name = name;
this.type = type; // 'revolute' | 'prismatic' | 'fixed' | 'continuous'
this.parent = null; // Parent link name
this.child = null; // Child link name
this.origin = { xyz: [0, 0, 0], rpy: [0, 0, 0] };
this.axis = { xyz: [0, 0, 1] }; // Default z-axis
this.limits = null; // JointLimits
this.currentValue = 0; // Current joint value
this.threeObject = null; // Three.js object (if available)
}
}
/**
* JointLimits interface
*/
export class JointLimits {
constructor() {
this.lower = -Math.PI;
this.upper = Math.PI;
this.effort = null;
this.velocity = null;
}
}
/**
* Material interface
*/
export class Material {
constructor(name) {
this.name = name;
this.color = { r: 0.8, g: 0.8, b: 0.8 };
this.texture = null;
}
}
/**
* Constraint interface - for describing closed-chain constraints of parallel mechanisms
* Supports MuJoCo equality constraint types
*/
export class Constraint {
constructor(name, type) {
this.name = name;
this.type = type; // 'connect' | 'weld' | 'joint' | 'tendon' | 'distance'
// Constraint objects (may be body, geom, joint, etc. depending on type)
this.body1 = null;
this.body2 = null;
this.anchor = null; // Connection point coordinates
this.torquescale = null; // Torque scale
// Joint constraint specific properties
this.joint1 = null;
this.joint2 = null;
this.polycoef = null; // Polynomial coefficients [a0, a1, a2, a3, a4]
// Visualization object
this.threeObject = null; // Three.js object for displaying constraint
// Original data (for debugging)
this.userData = {};
}
}

View File

@ -17,7 +17,7 @@ const service = axios.create({
// axios中请求配置有baseURL选项表示请求URL公共部分 // axios中请求配置有baseURL选项表示请求URL公共部分
baseURL: import.meta.env.VITE_APP_BASE_API, baseURL: import.meta.env.VITE_APP_BASE_API,
// 超时 // 超时
timeout: 100000 timeout: 10000
}) })
// request拦截器 // request拦截器
@ -95,7 +95,7 @@ service.interceptors.response.use(res => {
} }
return Promise.reject('无效的会话,或者会话已过期,请重新登录。') return Promise.reject('无效的会话,或者会话已过期,请重新登录。')
} else if (code === 500) { } else if (code === 500) {
ElMessage({ message: msg, type: 'error' }) ElMessage({ message: msg, grouping: true, type: 'error' })
return Promise.reject(new Error(msg)) return Promise.reject(new Error(msg))
} else if (code === 601) { } else if (code === 601) {
ElMessage({ message: msg, type: 'warning' }) ElMessage({ message: msg, type: 'warning' })
@ -117,7 +117,7 @@ service.interceptors.response.use(res => {
} else if (message.includes("Request failed with status code")) { } else if (message.includes("Request failed with status code")) {
message = "系统接口" + message.substr(message.length - 3) + "异常"; message = "系统接口" + message.substr(message.length - 3) + "异常";
} }
ElMessage({ message: message, type: 'error', duration: 5 * 1000 }) ElMessage({ message: message, type: 'error', grouping: true, duration: 5 * 1000 })
return Promise.reject(error) return Promise.reject(error)
} }
) )

View File

@ -26,6 +26,7 @@ import BasicNodeParams from './params/BasicNodeParams.vue'
import CodeNodeParams from './params/CodeNodeParams.vue' import CodeNodeParams from './params/CodeNodeParams.vue'
import HttpNodeParams from './params/HttpNodeParams.vue' import HttpNodeParams from './params/HttpNodeParams.vue'
import SdAgentNodeParams from './params/SdAgentNodeParams.vue' import SdAgentNodeParams from './params/SdAgentNodeParams.vue'
import RecognizeNodeParams from './params/RecognizeNodeParams.vue'
const props = defineProps({ const props = defineProps({
drawer: Boolean, drawer: Boolean,
@ -43,6 +44,7 @@ const currentComponent = computed(() => {
if (type === 'code') return CodeNodeParams if (type === 'code') return CodeNodeParams
if (type === 'http') return HttpNodeParams if (type === 'http') return HttpNodeParams
if (type === 'sdAgent') return SdAgentNodeParams if (type === 'sdAgent') return SdAgentNodeParams
if (type === 'recognize') return RecognizeNodeParams
return null return null
}) })

View File

@ -0,0 +1,356 @@
<template>
<el-form :inline="true" :model="RecognizeNodeData" :rules="rules" ref="dynamicFormRef" class="http-form"
label-position="top" label-width="auto">
<el-collapse v-model="activeNames">
<el-collapse-item name="1" title="识别配置" icon-position="left">
<div v-for="(property, index) in RecognizeNodeData.nodeParams" class="form__container" :key="index">
<el-row>
<el-form-item :label="index === 0 ? '参数名' : ''" :prop="`nodeParams.${index}.name`"
:rules="[{ required: true, message: '请输入参数名', trigger: 'blur' }]">
<el-input :disabled="property?.disabled || false" class="param-name" v-model="property.name"
placeholder="请输入" clearable />
</el-form-item>
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`nodeParams.${index}.type`">
<el-select v-model="property.type" class="param-type"
@change="handleTypeChange(index, RecognizeNodeData, property.type)">
<el-option label="引用" value="quote" />
<el-option label="输入" value="input" />
</el-select>
<el-form-item v-if="property.type === 'input'" :rules="{ required: true, message: '请输入参数值', trigger: 'blur' }"
:prop="`nodeParams.${index}.input`">
<el-input-number class="param-value" v-if="property.componentType === 'number'"
v-model="property.input" :min="0" :max="property.max || Infinity"
:controls="property?.controls || true" :step-strictly="true"
:step="property.step || 1" placeholder="请输入" clearable />
<el-select v-model="property.input" class="param-value"
v-else-if="property.componentType === 'select'">
<el-option v-for="item in property.selectOptions" :key="item.value"
:label="item.label" :value="item.value" />
</el-select>
<el-input class="param-value" v-else v-model="property.input" placeholder="请输入"
clearable />
</el-form-item>
<el-form-item v-if="property.type === 'quote'"
:rules="[{ required: true, message: '请选择参数值', trigger: 'blur' }]"
:prop="`nodeParams.${index}.quote`">
<el-cascader :ref="(el) => { if (el) cascaderRefs[index] = el }"
v-model="property.quote" :checkStrictly="true" :options="quoteOptions"
placeholder="请选择"
@visible-change="(visible) => visibleChange(visible, index, property.quote)"
@change="(value) => cascaderChange(value, index, RecognizeNodeData, 'nodeParams')" />
</el-form-item>
</el-form-item>
</el-row>
</div>
</el-collapse-item>
<el-collapse-item name="2" title="告警级别" icon-position="left">
<template #title>
告警级别
<el-button :circle="true" size="small" @click="addAlarmRule" class="addFormItem">
<el-icon :size="14"><Plus /></el-icon>
</el-button>
</template>
<div class="form__container alarm-rules">
<el-row v-for="(rule, index) in RecognizeNodeData.alarmRules" :key="index">
<el-form-item label="告警级别" :prop="`alarmRules.${index}.level`"
:rules="[{ required: true, message: '请输入告警级别', trigger: 'change' }]">
<el-input-number v-model="rule.level" :min="1" :max="3" :step="1"
:step-strictly="true" />
</el-form-item>
<el-form-item label="比较符" :prop="`alarmRules.${index}.operator`"
:rules="[{ required: true, message: '请选择比较符', trigger: 'change' }]">
<el-select v-model="rule.operator" placeholder="请选择">
<el-option v-for="operator in alarmOperators" :key="operator.value"
:label="operator.label" :value="operator.value" />
</el-select>
</el-form-item>
<el-form-item label="阈值" :prop="`alarmRules.${index}.threshold`"
:rules="[{ required: true, message: '请输入阈值', trigger: 'change' }]">
<el-input-number v-model="rule.threshold" :controls="false" />
</el-form-item>
<el-form-item label="告警信息" :prop="`alarmRules.${index}.message`"
:rules="[{ required: true, message: '请输入告警信息', trigger: 'blur' }]">
<el-input v-model="rule.message" placeholder="请输入告警信息" clearable />
</el-form-item>
<el-button type="danger" link class="deleteBtn" @click="deleteAlarmRule(index)">
<el-icon><Minus /></el-icon>
</el-button>
</el-row>
<el-empty v-if="!RecognizeNodeData.alarmRules.length" description="暂无告警规则" :image-size="60" />
</div>
</el-collapse-item>
<el-collapse-item name="3" icon-position="left">
<template #title>
输出参数
<el-tooltip class="box-item" effect="dark" content="这里定义的输出变量可以被后续节点引用。" placement="top">
<el-icon class="header-icon" style="margin-left: 6px">
<info-filled />
</el-icon>
</el-tooltip>
<el-button :circle="true" size="small" @click="(e) => addRecognizeItem(e)" class="addFormItem">
<el-icon :size="14">
<Plus />
</el-icon>
</el-button>
</template>
<div class="form__container">
<el-form :inline="true" :model="RecognizeNodeData" label-position="top" label-width="auto"
:rules="outputRules" ref="outputFormRef">
<FormItemRecursive formType="output" :current-list="RecognizeNodeData.outputParams"
prop-path="outputParams" :depth="0" :is-first-level="true" :endDepth="2" :parent-path="[]"
@delete-item="(path) => deleteRecognizeItem(path, 'outputParams')" />
</el-form>
</div>
</el-collapse-item>
</el-collapse>
</el-form>
</template>
<script setup>
import { ref, reactive, watch, nextTick } from 'vue'
import { Plus, Minus } from '@element-plus/icons-vue'
import FormItemRecursive from '../FormItemRecursive.vue'
import { transformRecognizeNodeData } from '@/utils/flow'
import { useQuote } from './useQuote.js'
const props = defineProps({
data: Object
})
const emit = defineEmits(['save-success', 'save-error'])
const formData = reactive({
nodeParams: [],
outputParams: []
})
const rules = reactive({})
const outputRules = reactive({})
const dynamicFormRef = ref()
const outputFormRef = ref()
const activeNames = ref(['1', '2'])
// 使 quote
const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
//
const addFormItem = (e, type) => {
e.stopPropagation()
const obj = {
name: '',
type: type === 'nodeParams' ? 'input' : 'string',
required: false,
children: [],
input: ''
}
if (!formData[type]) formData[type] = []
formData[type].push(obj)
}
//
const RecognizeNodeData = ref({})
const alarmOperators = [
{ label: '大于GT', value: 'GT' },
{ label: '大于等于GTE', value: 'GTE' },
{ label: '小于LT', value: 'LT' },
{ label: '小于等于LTE', value: 'LTE' },
{ label: '等于EQ', value: 'EQ' },
{ label: '不等于NE', value: 'NE' }
]
const addAlarmRule = (e) => {
e.stopPropagation()
RecognizeNodeData.value.alarmRules.push({
level: 1,
operator: 'GTE',
threshold: 0,
message: ''
})
}
const deleteAlarmRule = (index) => {
RecognizeNodeData.value.alarmRules.splice(index, 1)
}
const addRecognizeItem = (e) => {
e.stopPropagation();
RecognizeNodeData.value.outputParams.push({ name: "", type: "string", input: "" });
}
const deleteRecognizeItem = (fullPath, propPath) => {
//
let currentLevel = RecognizeNodeData.value[propPath];
//
for (let i = 0; i < fullPath.length - 1; i++) {
const index = fullPath[i];
//
currentLevel = currentLevel[index].children;
}
//
const lastIndex = fullPath[fullPath.length - 1];
currentLevel.splice(lastIndex, 1);
}
//
const initData = () => {
const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || []))
const outputParams = JSON.parse(JSON.stringify(props.data.properties.outputParams || []))
const transformedData = transformRecognizeNodeData(nodeParams);
RecognizeNodeData.value = {
...transformedData,
outputParams
};
console.log('RecognizeNodeData', JSON.stringify(RecognizeNodeData.value))
}
watch(
() => props.data,
() => {
initData()
},
{ immediate: true, deep: true }
)
//
const validateAndSave = async () => {
let inputValid = true
let outputValid = true
if (dynamicFormRef.value) {
await dynamicFormRef.value.validate((valid) => { if (!valid) inputValid = false })
}
if (outputFormRef.value) {
await outputFormRef.value.validate((valid) => { if (!valid) outputValid = false })
}
const nodeParams = []
nodeParams.push(...RecognizeNodeData.value.nodeParams)
nodeParams.push({
name: "alarmRules",
type: "input",
input: RecognizeNodeData.value.alarmRules.map(({ level, operator, threshold, message }) => ({
level,
operator,
threshold,
message
})),
disabled: true
})
lf.setProperties(props.data.id, {
...props.data.properties,
nodeParams,
outputParams: RecognizeNodeData.value.outputParams
});
if (inputValid && outputValid) {
emit('save-success')
} else {
emit('save-error')
}
}
defineExpose({ validateAndSave })
</script>
<style lang="scss" scoped>
.http-form {
width: 100%;
.el-collapse {
width: 100%;
}
}
.form__container {
margin: 12px 0;
:deep(.el-row) {
align-items: end;
.el-form-item {
margin-right: 12px;
}
.param-name {
width: 160px;
}
.param-type {
width: 80px;
}
.param-value {
width: 160px;
}
}
.sub-properties {
margin-left: 10px;
.zw {
margin-left: 15px;
&::before {
content: "";
position: absolute;
left: 0;
top: -4px;
width: 10px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-bottom-left-radius: 4px;
background-color: transparent;
height: 24px;
}
}
}
.gSon-properties {
margin-left: 10px;
.zw {
margin-left: 15px;
&::before {
content: "";
position: absolute;
left: 0;
top: -4px;
width: 10px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-bottom-left-radius: 4px;
background-color: transparent;
height: 24px;
}
}
}
.deleteBtn {
margin-bottom: 22px;
cursor: pointer;
}
}
.alarm-rules {
:deep(.el-row) {
flex-wrap: nowrap;
.el-form-item {
flex: 1;
}
.el-select,
.el-input,
.el-input-number {
width: 100%;
}
}
}
</style>

View File

@ -436,6 +436,38 @@ function handler(params) {
outputParams: [], outputParams: [],
outputType: 'json' outputType: 'json'
}, },
{
icon: dialogueSvg,
name: "巡检仪表读数识别",
type: "recognize",
desc: "巡检仪表读数识别",
action: 'INSPECTION_METER_RECOGNIZE',
nodeType: "LLM",
outputType: 'json',
nodeParams: [
{ name: 'imageUrl', type: "input", input: "", disabled: true },
{ name: 'resultName', type: "input", input: "", disabled: true },
{ name: "alarmRules", type: "input", input: [], disabled: true }
],
outputParams: []
},
{
icon: cameraSvg,
name: "人工巡检判断",
type: "serviceNode",
desc: "创建人工巡检判断任务",
action: 'INSPECTION_MANUAL_REVIEW_CREATE',
nodeType: 'EDGE',
nodeParams: [
{ name: "resultName", type: "input", input: "", disabled: true },
{ name: "videoUrl", type: "input", input: "", required: false, disabled: true },
{ name: "imageUrl", type: "input", input: "", required: false, disabled: true },
{ name: "reviewCriteria", type: "input", input: "", disabled: true },
{ name: "defaultAlarmLevel", type: "input", componentType: 'number', max: 3, step: 1, input: 1, disabled: true },
],
outputParams: [],
outputType: 'json'
},
], ],
}, },
{ {

View File

@ -8,6 +8,7 @@ import { registerHttpNode } from './httpNode'
import { registerCodeNode } from './codeNode' import { registerCodeNode } from './codeNode'
import { registerCurrentLoopNode } from './currentLoopNode' import { registerCurrentLoopNode } from './currentLoopNode'
import { registerSdAgentNode } from './sdAgent' import { registerSdAgentNode } from './sdAgent'
import { registerRecognizeNode } from './recognize'
export const registerFunction = (lf) => { export const registerFunction = (lf) => {
registerLoopBodyNode(lf) registerLoopBodyNode(lf)
@ -20,4 +21,5 @@ export const registerFunction = (lf) => {
registerCodeNode(lf) registerCodeNode(lf)
registerCurrentLoopNode(lf) registerCurrentLoopNode(lf)
registerSdAgentNode(lf) registerSdAgentNode(lf)
registerRecognizeNode(lf)
} }

View File

@ -0,0 +1,213 @@
// 导入 HtmlNode 及其模型,为后续继承做准备
import { HtmlNode, HtmlNodeModel } from "@logicflow/core";
// 导入 Vue 相关方法,用于渲染组件
import { createApp, h, nextTick } from "vue";
import ElementPlus from "element-plus";
// 导入 Vue 组件
// @ts-ignore
import Recognize from "./recognize.vue";
import OuterNode from "../../components/OuterNode.vue";
import JsonViewer from 'vue3-json-viewer'
/**
* 定义一个 元素的 HTML 节点类继承自 HtmlNode
* 该类负责在 HTML 中渲染 元素并处理其交互逻辑
*/
class RecognizeNodeHtmlNode extends HtmlNode {
resizeObserver = null;
isMounted; // 标记组件是否已挂载
r; // 渲染函数
app; // Vue 应用实例
container = null;
static reusePool = new Map(); // 节点复用池
/**
* 构造函数
* @param props 传递给节点的属性包括模型图模型等
*/
constructor(props) {
super(props);
this.initVueApp(props);
}
initVueApp(props) {
this.isMounted = false;
this.hideAnchor = false;
this.autoExpand = true; // 防止锚点被折叠
this.anchorsPreset = "default"; // 重置锚点预设
// 创建 元素的渲染函数
this.r = h(OuterNode, {
model: props.model,
component: Recognize,
properties: {
...props.model.getProperties(),
},
onContentChange: this.handleContentChange.bind(this),
onBindRef: this.handleComponentInstance.bind(this)
});
// 创建 Vue 应用实例,并指定渲染函数
this.app = createApp({
render: () => this.r,
});
}
/**
* HTML 内容设置到指定的根元素上
* @param rootEl 根元素
*/
async setHtml(rootEl) {
const nodeId = this.props.model.id;
if (RecognizeNodeHtmlNode.reusePool.has(nodeId)) {
rootEl.appendChild(RecognizeNodeHtmlNode.reusePool.get(nodeId));
return;
}
if (!this.isMounted) {
this.isMounted = true;
this.container = document.createElement("div");
this.container.style.display = "inline-block"; // 关键:确保容器自适应内容
rootEl.appendChild(this.container);
this.app.use(ElementPlus);
this.app.use(JsonViewer);
this.app.mount(this.container);
await nextTick();
this.setupSizeObserver();
RecognizeNodeHtmlNode.reusePool.set(nodeId, this.container);
} else {
this.r.component.props.properties = this.props.model.getProperties();
}
}
/**
* 获取节点文本内容
* 对于元素返回 null因为其内容由特定组件渲染
* @returns {null}
*/
getText() {
return null;
}
handleComponentInstance(data) {
// 将组件实例保存到节点模型
this.props.model.setComponentInstance(data)
}
handleContentChange() {
// 内容变化时强制更新尺寸
this.updateNodeSize();
}
// 渲染完成后获取实际尺寸
updateNodeSize() {
if (this.container) {
const { SCALE_X, SCALE_Y } = this.props.graphModel.transformModel;
const node = this.container.querySelector(".node__container");
const rect = node.getBoundingClientRect();
this.props.model.updateSize(rect.width / SCALE_X, rect.height / SCALE_Y);
}
}
setupSizeObserver() {
// 首次渲染立即检测
requestAnimationFrame(() => {
this.updateNodeSize();
// 持续监听变化
this.resizeObserver = new ResizeObserver(() => {
this.updateNodeSize();
});
this.resizeObserver.observe(this.container);
});
}
// 组件卸载时移除监听
onDestroy() {
this.resizeObserver?.disconnect();
}
}
/**
* 定义一个元素的 HTML 模型类继承自 HtmlNodeModel
* 该类主要设置节点的属性和样式
*/
class RecognizeNodeHtmlModel extends HtmlNodeModel {
initNodeData(data) {
super.initNodeData(data);
}
// 保存组件实例引用
setComponentInstance(instance) {
this.componentInstance = instance;
}
closePopover() {
this.componentInstance.closePopover()
}
/**
* 设置节点属性
* 包括宽度高度文本编辑属性等
*/
setAttributes() {
// 初始设置为0后续动态更新
this.width = 0;
this.height = 0;
this.text.editable = false;
}
updateSize(width, height) {
this.width = width + 24;
this.height = height + 50;
this.initNodeData(this); // 触发节点重绘
}
// 定义节点只有左右两个锚点. 锚点位置通过中心点和宽度算出来。
getDefaultAnchor() {
let _a = this,
x = _a.x,
y = _a.y,
width = _a.width,
height = _a.height;
return [
{
x: x + width / 2 + 4,
y: y,
name: "right",
id: "".concat(this.id, "_1"),
properties: { connectionType: "source" },
},
{
x: x - width / 2,
y: y,
name: "left",
id: "".concat(this.id, "_3"),
properties: { connectionType: "target" },
},
];
}
/**
* 获取节点轮廓样式
* 覆盖父类方法设置 stroke 属性为 none以适应特定的视觉效果
* @returns {object} 节点轮廓样式
*/
getOutlineStyle() {
const style = super.getOutlineStyle();
style.stroke = "none";
// style.hover.stroke = "none";
return style;
}
}
// 导出方法注册
export function registerRecognizeNode(lf) {
lf.register({
type: "recognize",
view: RecognizeNodeHtmlNode,
model: RecognizeNodeHtmlModel
});
}

View File

@ -0,0 +1,124 @@
<template>
<div class="node__container" :class="props.model.id">
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
<template #input>
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
</template>
<template #output>
<JsonViewer v-if="props.properties.outputType === 'json'" :value="outputJsonData" copyable boxed sort theme="light" />
<el-image
v-if="props.properties.outputType === 'img'"
v-for="item in outputJsonData.imageUrl"
style="width: 60px; height: 60px"
:src="item"
:preview-src-list="outputJsonData.imageUrl"
:preview-teleported="true"
show-progress
fit="fill"
/>
<IPlayer v-if="props.properties.outputType === 'video'" v-for="item in outputJsonData.videoUrl" :videoUrl="item" />
</template>
</NodeState>
<NodeTitle
:icon="props.properties.icon"
:nodeId="props.model.id"
:nodeProperties="props.properties"
:nodeName="props.properties.name"
:nodeType="props.properties.nodeType || 'NONE'"
:nodeDesc="props.properties.desc"
@setNodeName="setNodeName"
/>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue";
import "vue3-json-viewer/dist/index.css";
import { emitter } from "@/utils/eventBus";
import IPlayer from "@/components/IPlayer/index.vue";
const props = defineProps({
model: Object,
properties: Object,
});
const emits = defineEmits(["contentChange"]);
const setNodeName = (name) => {
const properties = lf.getProperties(props.model.id);
lf.setProperties(props.model.id, {
...properties,
name
});
emits("contentChange");
}
const nodeOperatingStatus = ref("NORMAL");
const runtimes = ref(0);
const inputJsonData = ref({});
const outputJsonData = ref({});
const nodeStateRef = ref(null);
const closePopover = () => {
if (nodeStateRef.value) {
nodeStateRef.value.closePopover();
}
}
onMounted(() => {
emitter.on("changeNodeState", (data) => {
if (data.nodeId === props.model.id) {
nodeOperatingStatus.value = data.status;
if (data.endTime && data.startTime) {
runtimes.value = data.endTime - data.startTime;
}
let inputData = {};
let output = {};
try {
inputData = JSON.parse(data.paramsIn) || {};
output = JSON.parse(data.paramsOut) || {};
} catch (error) {
inputData = data.paramsIn || {};
output = data.paramsOut || {};
}
inputJsonData.value = inputData;
outputJsonData.value = output;
emits("contentChange");
}
});
emitter.on("contentChange", (data) => {
if (data.id === props.model.id) {
nodeOperatingStatus.value = "NORMAL";
inputJsonData.value = {};
outputJsonData.value = {};
emits("contentChange");
}
});
emitter.on("setProperties", (data) => {
if (data.id === props.model.id) {
emits("contentChange");
}
});
});
onUnmounted(() => {
emitter.off("changeNodeState");
emitter.off("contentChange");
emitter.off("setProperties");
});
defineExpose({ closePopover })
</script>
<style lang="scss" scoped>
.node__container {
width: 100%;
height: auto;
}
</style>

View File

@ -1,3 +1,452 @@
<template> <template>
<div>告警记录</div> <div class="result-list">
<el-card shadow="never" class="search-card">
<el-form ref="queryFormRef" :model="queryParams" :inline="true" label-width="80px">
<el-form-item
label="所属任务"
prop="taskId"
>
<el-select
v-model="queryParams.taskId"
filterable
placeholder="请选择所属任务"
clearable
class="query-select"
>
<el-option
v-for="task in taskOptions"
:key="task.value"
:label="task.label"
:value="task.value"
/>
</el-select>
</el-form-item>
<el-form-item label="结果名称" prop="resultName">
<el-input
v-model="queryParams.resultName"
placeholder="请输入结果名称"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="结果类型" prop="resultType">
<el-select v-model="queryParams.resultType" clearable placeholder="全部" class="query-select">
<el-option
v-for="item in resultTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态" prop="resultStatus">
<el-select v-model="queryParams.resultStatus" clearable placeholder="全部" class="query-select">
<el-option
v-for="item in resultStatusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="告警级别" prop="alarmLevel">
<el-select v-model="queryParams.alarmLevel" clearable placeholder="全部" class="query-select">
<el-option
v-for="item in alarmLevelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">查询</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="table-card">
<el-table v-loading="loading" :data="tableData" border stripe>
<el-table-column prop="id" label="结果 ID" min-width="100" show-overflow-tooltip />
<el-table-column prop="resultName" label="结果名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="resultType" label="结果类型" min-width="110">
<template #default="{ row }">
{{ getOptionLabel(resultTypeOptions, row.resultType) }}
</template>
</el-table-column>
<el-table-column prop="resultStatus" label="状态" min-width="140">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.resultStatus)">
{{ getOptionLabel(resultStatusOptions, row.resultStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="alarmLevel" label="告警级别" min-width="110">
<template #default="{ row }">
{{ row.alarmLevel == null ? '-' : getOptionLabel(alarmLevelOptions, row.alarmLevel) }}
</template>
</el-table-column>
<el-table-column prop="alarmLevel" label="图片/视频" min-width="110">
<template #default="{ row }">
<el-button
type="primary"
link
@click="handleOpenMedia(row)"
>
查看
</el-button>
</template>
</el-table-column>
<el-table-column prop="taskName" label="所属任务" min-width="150" show-overflow-tooltip />
<el-table-column prop="createTime" label="创建时间" min-width="180" />
<el-table-column label="操作" fixed="right" width="120" align="center">
<template #default="{ row }">
<el-button
v-if="row.resultStatus === 'PENDING'"
type="primary"
link
@click="openReviewDialog(row)"
>
复核
</el-button>
<span v-else>-</span>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无巡检结果" />
</template>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="fetchList"
/>
</div>
</el-card>
<el-dialog
v-model="reviewDialogVisible"
title="人工复核"
width="600px"
:close-on-click-modal="false"
@closed="resetReviewForm"
>
<el-form
ref="reviewFormRef"
:model="reviewForm"
:rules="reviewRules"
label-width="100px"
>
<el-form-item label="复核结果" prop="resultStatus">
<el-select v-model="reviewForm.resultStatus" placeholder="请选择复核结果">
<el-option label="异常" value="ABNORMAL" />
<el-option label="正常" value="NORMAL" />
</el-select>
</el-form-item>
<el-form-item label="告警级别" prop="alarmLevel">
<el-select
v-model="reviewForm.alarmLevel"
:disabled="reviewForm.resultStatus !== 'ABNORMAL'"
clearable
placeholder="请选择告警级别"
>
<el-option
v-for="item in alarmLevelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="复核说明" prop="reviewRemark">
<el-input
v-model="reviewForm.reviewRemark"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入复核说明"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="reviewDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="reviewSubmitting" @click="submitReview">
提交
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="mediaDialogVisible"
title="图片/视频"
width="500px"
:close-on-click-modal="false"
>
<el-image
v-if="mediaData.evidenceType === 'IMAGE'"
:src="mediaData.evidenceUrl"
style="width: 100%; height: 100%"
/>
<IPlayer
v-if="mediaData.evidenceType === 'VIDEO'"
:videoUrl="mediaData.evidenceUrl"
/>
</el-dialog>
</div>
</template> </template>
<script setup>
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getTaskList } from '@/api/inspection/task'
import {
getInspectionResultList,
reviewInspectionResult
} from '@/api/inspection/result'
const queryFormRef = ref()
const reviewFormRef = ref()
const loading = ref(false)
const reviewSubmitting = ref(false)
const reviewDialogVisible = ref(false)
const tableData = ref([])
const taskOptions = ref([])
const total = ref(0)
const currentReviewId = ref(null)
const queryParams = reactive({
taskId: null,
resultName: '',
resultType: null,
resultStatus: null,
alarmLevel: null,
pageNum: 1,
pageSize: 10
})
const reviewForm = reactive({
resultStatus: '',
alarmLevel: null,
reviewRemark: '',
reviewVersion: 0
})
const resultTypeOptions = [
{ label: '劳保穿戴', value: 'PPE' },
{ label: '仪表识别', value: 'METER' },
{ label: '人工巡检', value: 'MANUAL' }
]
const resultStatusOptions = [
{ label: '待处理', value: 'PENDING' },
{ label: '正常', value: 'NORMAL' },
{ label: '异常', value: 'ABNORMAL' },
{ label: '识别失败', value: 'RECOGNIZE_FAILED' }
]
const alarmLevelOptions = [
{ label: '1提示', value: 1 },
{ label: '2警告', value: 2 },
{ label: '3严重', value: 3 }
]
const validateAlarmLevel = (_rule, value, callback) => {
if (reviewForm.resultStatus === 'ABNORMAL' && value == null) {
callback(new Error('异常结果必须选择告警级别'))
return
}
callback()
}
const reviewRules = {
resultStatus: [
{ required: true, message: '请选择复核结果', trigger: 'change' }
],
alarmLevel: [
{ validator: validateAlarmLevel, trigger: 'change' }
]
}
const getOptionLabel = (options, value) => {
return options.find(item => item.value === value)?.label || value || '-'
}
const getStatusTagType = (status) => {
if (status === 'ABNORMAL') return 'danger'
if (status === 'NORMAL') return 'success'
return 'info'
}
const buildQueryParams = () => {
return Object.fromEntries(
Object.entries(queryParams).filter(([, value]) => value !== '' && value != null)
)
}
//
const fetchList = async () => {
loading.value = true
try {
const response = await getInspectionResultList(buildQueryParams())
tableData.value = response.rows || response.data?.rows || []
total.value = response.total ?? response.data?.total ?? 0
} finally {
loading.value = false
}
}
const loadTaskOptions = async () => {
try {
const response = await getTaskList({ pageNum: 1, pageSize: 10000 })
const rows = response.rows || response.data?.rows || []
taskOptions.value = rows.map(task => ({
label: task.taskName || task.name || `任务 ${task.id ?? task.taskId}`,
value: task.id ?? task.taskId
}))
} catch {
//
taskOptions.value = []
}
}
const handleQuery = async () => {
try {
await queryFormRef.value?.validate()
queryParams.pageNum = 1
await fetchList()
} catch {
//
}
}
const resetQuery = () => {
queryFormRef.value?.resetFields()
Object.assign(queryParams, {
resultName: '',
resultType: null,
resultStatus: null,
alarmLevel: null,
pageNum: 1,
pageSize: 10
})
fetchList()
}
const handleSizeChange = () => {
queryParams.pageNum = 1
fetchList()
}
const openReviewDialog = (row) => {
currentReviewId.value = row.id
Object.assign(reviewForm, {
resultStatus: row.resultStatus || 'ABNORMAL',
alarmLevel: row.alarmLevel ?? null,
reviewRemark: '',
reviewVersion: row.reviewVersion ?? 0
})
reviewDialogVisible.value = true
nextTick(() => reviewFormRef.value?.clearValidate())
}
const resetReviewForm = () => {
currentReviewId.value = null
Object.assign(reviewForm, {
resultStatus: '',
alarmLevel: null,
reviewRemark: '',
reviewVersion: 0
})
reviewFormRef.value?.clearValidate()
}
const submitReview = async () => {
try {
await reviewFormRef.value.validate()
} catch {
return
}
reviewSubmitting.value = true
try {
await reviewInspectionResult(currentReviewId.value, {
alarmLevel: reviewForm.resultStatus === 'ABNORMAL' ? reviewForm.alarmLevel : null,
resultStatus: reviewForm.resultStatus,
reviewRemark: reviewForm.reviewRemark,
reviewVersion: reviewForm.reviewVersion
})
ElMessage.success('复核成功')
reviewDialogVisible.value = false
await fetchList()
} finally {
reviewSubmitting.value = false
}
}
const mediaDialogVisible = ref(false)
const mediaData = ref(null)
const handleOpenMedia = (row) => {
mediaDialogVisible.value = true
mediaData.value = row
}
watch(
() => reviewForm.resultStatus,
status => {
if (status !== 'ABNORMAL') {
reviewForm.alarmLevel = null
nextTick(() => reviewFormRef.value?.clearValidate('alarmLevel'))
}
}
)
onMounted(async () => {
await loadTaskOptions()
await fetchList()
})
</script>
<style scoped lang="scss">
.result-list {
width: 100%;
height: 100%;
.search-card {
margin-bottom: 16px;
:deep(.el-card__body) {
padding-bottom: 2px;
}
}
.query-select {
width: 180px;
}
.table-card {
min-width: 0;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
:deep(.el-dialog .el-select) {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,321 @@
<template>
<div class="panel">
<div class="card">
<div class="card__head">
<h3>方向控制</h3>
<el-tag size="small" effect="dark" type="info">长按持续移动</el-tag>
</div>
<div class="dpad">
<button class="dpad__btn dpad__btn--up" @mousedown="onPress('forward')" @mouseup="onRelease"
@mouseleave="onRelease" @touchstart.prevent="onPress('forward')" @touchend.prevent="onRelease">
<svg viewBox="0 0 14 14">
<path d="M7 1L13 11H1Z" fill="currentColor" />
</svg>
<small>前进</small>
</button>
<button class="dpad__btn dpad__btn--lt" @mousedown="onPress('left')" @mouseup="onRelease"
@touchstart.prevent="onPress('left')" @touchend.prevent="onRelease">
<svg viewBox="0 0 14 14">
<path d="M1 7L11 1V13Z" fill="currentColor" />
</svg>
<small>左转</small>
</button>
<div class="dpad__core"><span /></div>
<button class="dpad__btn dpad__btn--rt" @mousedown="onPress('right')" @mouseup="onRelease"
@touchstart.prevent="onPress('right')" @touchend.prevent="onRelease">
<svg viewBox="0 0 14 14">
<path d="M13 7L3 1V13Z" fill="currentColor" />
</svg>
<small>右转</small>
</button>
<button class="dpad__btn dpad__btn--dn" @mousedown="onPress('backward')" @mouseup="onRelease"
@touchstart.prevent="onPress('backward')" @touchend.prevent="onRelease">
<svg viewBox="0 0 14 14">
<path d="M7 13L1 3H13Z" fill="currentColor" />
</svg>
<small>后退</small>
</button>
</div>
</div>
<!-- Parameters -->
<div class="card">
<div class="card__head">
<h3>参数设置</h3>
</div>
<div class="param" v-for="p in paramList" :key="p.key">
<div class="param__head">
<span>{{ p.label }}</span>
<b class="param__val">{{ formatVal(p) }}</b>
</div>
<el-slider v-model="p.model" :min="p.min" :max="p.max" :step="p.step" :show-tooltip="false" />
</div>
</div>
</div>
</template>
<script setup>
import { moveRobot, stopRobot } from '@/api/inspection/robot.js'
const props = defineProps({
robot: {
type: Object,
default: () => ({})
}
})
const maxSpeed = ref(0.6)
const rotationAngle = ref(0)
const paramList = reactive([
{ key: 'speed', label: '速度', model: maxSpeed, min: 0, max: 1, step: 0.1, unit: 'm/s', digits: 1 },
{ key: 'rot', label: '旋转角速度', model: rotationAngle, min: -1, max: 1, step: 0.1, unit: 'm/s', digits: 0 },
])
function formatVal(p) {
const v = typeof p.model === 'object' ? p.model.value : p.model
return (typeof v === 'number' ? v.toFixed(p.digits) : v) + ' ' + p.unit
}
const moveRobotData = ref({
vx: 0,
vy: 0,
vz: 0
})
/* long-press stubs */
const onPress = async (_dir) => {
if (_dir === 'forward') {
moveRobotData.value.vx = maxSpeed.value
} else if (_dir === 'backward') {
moveRobotData.value.vx = -maxSpeed.value
} else if (_dir === 'left') {
moveRobotData.value.vy = maxSpeed.value
} else {
moveRobotData.value.vy = -maxSpeed.value
}
moveRobotData.value.vz = rotationAngle.value
const res = await moveRobot({
terminalId: props.robot.terminalId,
deviceId: props.robot.robotCode,
...moveRobotData.value
})
}
function onRelease() {
moveRobotData.value.vx = 0;
moveRobotData.value.vy = 0;
stopRobot({
terminalId: props.robot.terminalId,
deviceId: props.robot.robotCode,
})
}
</script>
<style lang="scss" scoped>
.panel {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0;
overflow-y: auto;
// background: #0F1E38;
.card {
padding: 18px 20px;
border-bottom: 1px solid rgba(0, 195, 255, 0.08);
&__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h3 {
font-family: 'Rajdhani', sans-serif;
font-size: 14px;
font-weight: 700;
letter-spacing: 1.2px;
color: #475a72;
margin: 0;
text-transform: uppercase;
}
}
:deep(.el-tag) {
font-size: 10px;
height: 20px;
background: rgba(0, 195, 255, 0.08);
border-color: rgba(0,195,255,0.08);
color: #00c3ff;
}
.dpad {
display: grid;
grid-template-columns: repeat(3, 76px);
grid-template-rows: repeat(3, 64px);
gap: 6px;
justify-content: center;
margin-bottom: 18px;
&__btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
border: 1px solid rgba(0, 195, 255, 0.08);
border-radius: 8px;
background: #0d1a3b;
color: #c0cfdf;
cursor: pointer;
transition: all .15s ease;
svg {
width: 14px;
height: 14px;
}
small {
font-size: 10px;
letter-spacing: .5px;
color: #475a72;
}
&:hover {
border-color: rgba(0, 195, 255, 0.08);
background: #101930;
color: #00c3ff;
}
&:active {
background: #00c3ff;
color: #060a12;
border-color: #00c3ff;
box-shadow: 0 0 24px rgba(0,195,255,0.14);
transform: scale(.94);
small {
color: #060a12;
}
}
&--up {
grid-column: 2;
grid-row: 1;
}
&--lt {
grid-column: 1;
grid-row: 2;
}
&--rt {
grid-column: 3;
grid-row: 2;
}
&--dn {
grid-column: 2;
grid-row: 3;
}
}
&__core {
grid-column: 2;
grid-row: 2;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(0,195,255,0.08);
border-radius: 8px;
background: rgba(0, 195, 255, 0.03);
span {
width: 10px;
height: 10px;
border-radius: 50%;
background: #00c3ff;
opacity: .3;
}
}
}
.estop {
width: 100%;
height: 42px !important;
font-size: 14px;
font-weight: 600;
letter-spacing: 1px;
border-color: #ff2d55 !important;
color: #ff2d55 !important;
background: rgba(255, 45, 85, 0.06) !important;
&:hover {
background: rgba(255, 45, 85, 0.14) !important;
}
}
.param {
margin-bottom: 18px;
&:last-child {
margin-bottom: 0;
}
&__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8px;
font-size: 13px;
color: #475a72;
}
&__val {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
font-weight: 500;
color: #00c3ff;
}
:deep(.el-slider) {
.el-slider__runway {
height: 4px;
background: rgba(255, 255, 255, 0.05);
border-radius: 2px;
}
.el-slider__bar {
height: 4px;
background: #00c3ff;
border-radius: 2px;
}
.el-slider__button-wrapper {
top: -16px;
}
.el-slider__button {
width: 14px;
height: 14px;
border: 2px solid #00c3ff;
background: #0f1628;
box-shadow: 0 0 8px rgba(0, 195, 255, 0.3);
transition: box-shadow .2s;
&:hover {
box-shadow: 0 0 14px rgba(0, 195, 255, 0.35);
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,409 @@
<template>
<div class="robotic-arm-box">
<div class="robotic-arm-status">
<div class="coord-display">
<div class="coord-item">
<div class="coord-key key-x">X</div>
<div class="coord-val" id="coordX">{{ pose.x?.toFixed(3) }}</div>
</div>
<div class="coord-item">
<div class="coord-key key-y">Y</div>
<div class="coord-val" id="coordY">{{ pose.y?.toFixed(3) }}</div>
</div>
<div class="coord-item">
<div class="coord-key key-z">Z</div>
<div class="coord-val" id="coordZ">{{ pose.z?.toFixed(3) }}</div>
</div>
<div class="coord-item">
<div class="coord-key key-x">RX</div>
<div class="coord-val" id="coordX">{{ pose.rx?.toFixed(3) }}</div>
</div>
<div class="coord-item">
<div class="coord-key key-y">RY</div>
<div class="coord-val" id="coordY">{{ pose.ry?.toFixed(3) }}</div>
</div>
<div class="coord-item">
<div class="coord-key key-z">RZ</div>
<div class="coord-val" id="coordZ">{{ pose.rz?.toFixed(3) }}</div>
</div>
</div>
</div>
<div class="axis-group">
<!-- X Axis -->
<div class="axis-row x">
<div class="axis-label x">X</div>
<div class="axis-slider-wrap">
<div class="axis-name">前后平移</div>
<div>
<el-button
type="primary"
circle
@mousedown="move('x', '-')"
@mouseup="stop"
@touchstart.prevent="move('x', '-')"
@touchend.prevent="stop"
>
<el-icon size="24">
<Remove />
</el-icon>
</el-button>
<el-button
type="primary"
circle
@mousedown="move('x', '+')"
@mouseup="stop"
@touchstart.prevent="move('x', '+')"
@touchend.prevent="stop"
>
<el-icon size="24">
<CirclePlus />
</el-icon>
</el-button>
</div>
</div>
</div>
<!-- Y Axis -->
<div class="axis-row y">
<div class="axis-label y">Y</div>
<div class="axis-slider-wrap">
<div class="axis-name">左右平移</div>
<div>
<el-button
type="primary"
circle
@mousedown="move('y', '-')"
@mouseup="stop"
@touchstart.prevent="move('y', '-')"
@touchend.prevent="stop"
>
<el-icon size="24">
<Remove />
</el-icon>
</el-button>
<el-button
type="primary"
circle
@mousedown="move('y', '+')"
@mouseup="stop"
@touchstart.prevent="move('y', '+')"
@touchend.prevent="stop"
>
<el-icon size="24">
<CirclePlus />
</el-icon>
</el-button>
</div>
</div>
</div>
<!-- Z Axis -->
<div class="axis-row z">
<div class="axis-label z">Z</div>
<div class="axis-slider-wrap">
<div class="axis-name">上下平移</div>
<div>
<el-button
type="primary"
circle
@mousedown="move('z', '-')"
@mouseup="stop"
@touchstart.prevent="move('z', '-')"
@touchend.prevent="stop"
>
<el-icon size="24">
<Remove />
</el-icon>
</el-button>
<el-button
type="primary"
circle
@mousedown="move('z', '+')"
@mouseup="stop"
@touchstart.prevent="move('z', '+')"
@touchend.prevent="stop"
>
<el-icon size="24">
<CirclePlus />
</el-icon>
</el-button>
</div>
</div>
</div>
</div>
<div class="speed-container">
<div class="label">速度</div>
<el-slider v-model="speed" :min="0.01" :step="0.01" :max="1" />
<div class="proportion">{{( speed / 1 * 100).toFixed(0) }} %</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { getPoseApi, speedLApi, stopMotionApi } from '@/api/inspection/cockpit';
import { reactive } from 'vue';
const props = defineProps({
robot: {
type: Object,
default: () => ({})
},
type: {
type: String,
default: 'end'
}
})
const pose = ref({
x: 0,
y: 0,
z: 0,
rx: 0,
ry: 0,
rz: 0
})
const speed = ref(1) // 0.1 - 5
const errorNum = ref(0)
/**
* 获取机械臂末端状态信息
*/
const getPose = async (robot) => {
try {
const res = await getPoseApi({
deviceId: import.meta.env.VITE_INSPECTION_ARM_DEVICE_ID,
terminalId: robot.terminalId
})
if (res.code === 200) {
const { x, y, z, rx, ry, rz } = res.data
pose.value = { x, y, z, rx, ry, rz }
} else {
console.error('获取姿态失败:', res.message)
errorNum.value++
if (errorNum.value > 3) {
clearInterval(timer.value)
}
}
} catch (error) {
errorNum.value++
if (errorNum.value > 3) {
clearInterval(timer.value)
}
console.error('获取姿态失败:', error)
}
}
const speedData = reactive({
vx: 0,
vy: 0,
vz: 0,
wx: 0,
wy: 0,
wz: 0
})
/**
* 机械臂移动
* @param axis 坐标轴
* @param direction 方向
*/
const move = async (axis, direction) => {
speedData.vx = 0
speedData.vy = 0
speedData.vz = 0
speedData.wx = 0
speedData.wy = 0
speedData.wz = 0
if (props.type === 'end') {
//
const speedValue = speed.value
switch (axis) {
case 'x':
speedData.vx = direction === '+' ? speedValue : -speedValue
break
case 'y':
speedData.vy = direction === '+' ? speedValue : -speedValue
break
case 'z':
speedData.vz = direction === '+' ? speedValue : -speedValue
break
}
} else if (props.type === 'posture') {
//
const jointSpeed = speed.value
switch (axis) {
case 'x':
speedData.wx = direction === '+' ? jointSpeed : -jointSpeed
break
case 'y':
speedData.wy = direction === '+' ? jointSpeed : -jointSpeed
break
case 'z':
speedData.wz = direction === '+' ? jointSpeed : -jointSpeed
break
}
}
speedLApi({
deviceId: import.meta.env.VITE_INSPECTION_ARM_DEVICE_ID,
terminalId: props.robot.terminalId,
duration: 60,
acceleration: speed.value + 0.1,
...speedData
})
}
/**
* 停止机械臂移动
*/
const stop = async () => {
await stopMotionApi({
deviceId: import.meta.env.VITE_INSPECTION_ARM_DEVICE_ID,
terminalId: props.robot.terminalId
})
}
const timer = ref(null)
watch(() => props.robot, (newRobot) => {
if (newRobot && newRobot.terminalId) {
if (timer.value) {
clearInterval(timer.value)
}
timer.value = setInterval(() => {
getPose(newRobot)
}, 100)
}
}, { immediate: true })
</script>
<style lang="scss" scoped>
.robotic-arm-box {
margin-top: 12px;
.robotic-arm-status {
.coord-display {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px;
background: #070b1a;
border: 1px solid #1c2a52;
border-radius: 8px;
margin-bottom: 16px;
.coord-item {
width: calc((100% - 24px) / 3);
text-align: center;
padding: 6px 0;
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
.coord-key {
font-size: 12px;
font-weight: bold;
margin-bottom: 3px;
}
.key-x {
color: #f87171;
}
.key-y {
color: #34d399;
;
}
.key-z {
color: #38bdf8;
;
}
.coord-val {
font-size: 14px;
font-weight: bold;
color: #e8edf8;
}
}
}
}
.axis-group {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
.axis-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #0c1228;
border: 1px solid #1c2a52;
border-radius: 10px;
transition: all 0.2s ease;
&:hover {
border-color: #2a3f7a;
background: #3b82f608;
}
.axis-label {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.x {
background: #f8717126;
color: #f87171;
border: 1px solid #f871714d;
}
.y {
background: #34d39926;
color: #34d399;
border: 1px solid #34d3994d;
}
.z {
background: #38bdf826;
color: #38bdf8;
border: 1px solid #38bdf84d;
}
.axis-slider-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
color: #fff;
}
}
}
.speed-container {
display: flex;
align-items: center;
justify-content: space-between;
.label,
.proportion {
width: 100px;
text-align: center;
color: #fff;
}
}
}
</style>

View File

@ -0,0 +1,240 @@
<template>
<div class="elfin-viewer">
<div ref="viewerContainer" class="viewer-container">
<!-- Three.js 渲染 -->
</div>
<div v-if="isLoading" class="loading-overlay">
<div class="spinner"></div>
<p>正在加载机器人模型...</p>
<p class="loading-detail">{{ loadingMessage }}</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick, reactive } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
import { XacroAdapter } from '@/adapters/XacroAdapter.js'
const props = defineProps({
basePath: { type: String, default: '/inspection/elfin10' },
modelColor: { type: String, default: '#cbcbcb' }
});
const viewerContainer = ref(null);
const isLoading = ref(true);
const loadingMessage = ref('初始化...');
let scene, camera, renderer, controls;
const animationId = ref(null);
const jointControls = reactive([]);
// ============ ============
const initScene = () => {
const container = viewerContainer.value;
const width = container.clientWidth || 800;
const height = container.clientHeight || 600;
scene = new THREE.Scene();
scene.background = new THREE.Color('#000d2e');
camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 1000);
camera.position.set(0, 0, 4);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(width, height);
container.appendChild(renderer.domElement);
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.1;
controls.target.set(0, 0.5, 0);
controls.update();
const ambient = new THREE.AmbientLight(0xffffff, 0.6)
scene.add(ambient);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
directionalLight.position.set(5, 10, 7);
directionalLight.castShadow = true;
scene.add(directionalLight);
};
// ============ ============
const updateJoint = (jointName, angle) => {
if (!robotInstance) return;
const joint = robotInstance.joints[jointName];
if (!joint) {
console.warn(`未找到关节: ${jointName}`);
return;
}
// 使 setJointValue
if (joint._jointType === 'revolute' || joint._jointType === 'continuous') {
joint.setJointValue(angle);
} else if (joint._jointType === 'prismatic') {
joint.setJointValue(angle);
}
//
const control = jointControls.find(c => c.name === jointName);
if (control) control.value = angle;
};
const resetJoints = () => {
for (const joint of jointControls) {
joint.value = 0;
updateJoint(joint.name, 0);
}
};
const fileMap = new Map()
const loadFiles = async () => {
const fileNames = [
'elfin10.urdf.xacro',
'elfin10_gazebo.xacro',
'elfin10_transmission.xacro',
'meshes/elfin10/elfin_base.STL',
'meshes/elfin10/elfin_end_link.STL',
'meshes/elfin10/elfin_link1.STL',
'meshes/elfin10/elfin_link2.STL',
'meshes/elfin10/elfin_link3.STL',
'meshes/elfin10/elfin_link4.STL',
'meshes/elfin10/elfin_link5.STL',
'meshes/elfin10/elfin_link6.STL'
]
const fetchPromises = fileNames.map(async (name) => {
const url = `/inspection/elfin10/${name}`
const response = await fetch(url)
const blob = await response.blob()
return new File([blob], name, { type: blob.type })
})
const fileList = await Promise.all(fetchPromises)
fileList.forEach(item => {
fileMap.set('elfin10/' + item.name, item);
})
}
const currentModel = ref()
let robotInstance = null; // URDFRobot
const loadRobot = async () => {
isLoading.value = true;
loadingMessage.value = '加载XACRO文件...';
const xacroUrl = `${props.basePath}elfin10.urdf.xacro`;
const response = await fetch(xacroUrl);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const xacroContent = await response.text();
const file = fileMap.get('elfin10/elfin10.urdf.xacro')
const model = await XacroAdapter.parse(xacroContent, file.name, fileMap, file);
const robot = model.threeObject; // URDFRobot
//
scene.add(robot); // robot.scene robot
robot.rotation.x = -Math.PI / 2;
isLoading.value = false;
currentModel.value = model
robotInstance = robot;
//
let jointEntries = [];
if (robot.joints instanceof Map) {
jointEntries = [...robot.joints.entries()];
} else if (typeof robot.joints === 'object' && robot.joints !== null) {
jointEntries = Object.entries(robot.joints);
} else if (Array.isArray(robot.joints)) {
jointEntries = robot.joints.map((j, i) => [i, j]); //
} else {
console.error('未知的 joints 结构:', robot.joints);
}
//
for (const [name, joint] of jointEntries) {
if (joint._jointType === 'fixed') continue;
const limits = joint.limits || {};
let lower = limits.lower !== undefined ? limits.lower : -Math.PI;
let upper = limits.upper !== undefined ? limits.upper : Math.PI;
// -PI ~ PI
if (joint._jointType === 'continuous') {
lower = -Math.PI;
upper = Math.PI;
}
// 0
jointControls.push({
name,
min: lower,
max: upper,
value: 0, // joint.angle 0
});
}
};
// ============ ============
const animate = () => {
animationId.value = requestAnimationFrame(animate);
controls?.update();
renderer?.render(scene, camera);
};
// ============ ============
onMounted(async () => {
await nextTick();
await loadFiles()
initScene();
await loadRobot();
animate();
window.addEventListener('resize', onResize);
});
onBeforeUnmount(() => {
if (animationId.value) cancelAnimationFrame(animationId.value);
window.removeEventListener('resize', onResize);
renderer?.dispose();
controls?.dispose();
});
const onResize = () => {
const container = viewerContainer.value;
if (!container || !camera || !renderer) return;
const width = container.clientWidth;
const height = container.clientHeight;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
};
defineExpose({ updateJoint, resetJoints, jointControls });
</script>
<style scoped>
.elfin-viewer {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
background: #0d0d1a;
}
.viewer-container {
width: 100%;
height: 100%;
}
.viewer-container canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,554 @@
<template>
<div class="monitor-container">
<div class="left-container">
<div class="robot-container">
<div class="title">选择机器人</div>
<div class="robot-box">
<div v-for="item, index in robotList" class="robot" :class="{ active: activeBot === index }"
@click="changeBot(index)">
<div>
<SvgIcon name="bot" :color="colorList[index]" />
</div>
<div>
<div class="bot-name">{{ item.robotName }}</div>
<div class="bot-local">{{ robotStatus[item.status] }}</div>
</div>
</div>
</div>
</div>
<div class="split-line"></div>
<div class="mode-container">
<div class="title">控制模式</div>
<div class="mode-box">
<div class="mode-item" :class="{ activeMode: activeMode === 'auto' }" @click="changeMode('auto')">
<SvgIcon name="cpu" :color="activeMode === 'auto' ? '#00FF88' : '#fff'" :size="16" />
<span class="label">自动巡检</span>
</div>
<div class="mode-item" :class="{ activeMode: activeMode === 'artificial' }"
@click="changeMode('artificial')">
<SvgIcon name="gamepad" :color="activeMode === 'artificial' ? '#00FF88' : '#fff'" :size="16" />
<span class="label">人工接管</span>
</div>
</div>
</div>
<div v-if="activeMode === 'artificial'" class="artificial-panel">
<el-radio-group v-model="activeController" size="small" @change="handlerController">
<el-radio :value="1">底盘</el-radio>
<el-radio :value="2">机械臂</el-radio>
<el-radio :value="3">相机</el-radio>
</el-radio-group>
<div class="controller-panel">人工控制面板</div>
<DirectionControl v-if="activeController === 1" :robot="activeBotData" />
<div v-if="activeController === 2" class="robotic-arm-controller-container">
<el-radio-group v-model="activeRoboticArm" size="small">
<el-radio :value="1">末端控制</el-radio>
<el-radio :value="2">关节控制</el-radio>
<el-radio :value="3">姿态控制</el-radio>
</el-radio-group>
<RoboticArm :type="activeRoboticArm === 1 ? 'end' : 'posture'" :robot="activeBotData"
v-if="[1, 3].includes(activeRoboticArm)" />
<div class="control-panel" v-if="activeRoboticArm === 2">
<div class="joint-list">
<div v-for="joint in jointControls" :key="joint.name" class="joint-item">
<label :for="joint.name">{{ joint.name }}</label>
<el-input
v-model="joint.value"
readonly
>
<template #prepend>
<el-button
@mousedown="updateJoint(joint.name, '-')"
@mouseup="stop"
@touchstart.prevent="updateJoint(joint.name, '-')"
@touchend.prevent="stop"
>
-
</el-button>
</template>
<template #append>
<el-button
@mousedown="updateJoint(joint.name, '+')"
@mouseup="stop"
@touchstart.prevent="updateJoint(joint.name, '+')"
@touchend.prevent="stop"
>
+
</el-button>
</template>
</el-input>
</div>
</div>
<div class="speed-container">
<div class="label">速度</div>
<el-slider v-model="jointSpeed" :min="0.01" :step="0.01" :max="1" />
<div class="proportion">{{( jointSpeed / 1 * 100).toFixed(0) }} %</div>
</div>
</div>
</div>
</div>
</div>
<div class="video-container">
<div v-if="activeMode === 'auto' || activeController === 1" class="map-container">
<MapCanvas :robotList="robotList" ref="mapCanvasRef" />
</div>
<div v-if="activeMode === 'artificial' && activeController === 2" class="model-container">
<UrdfViewer ref="urdfViewerRef" base-path="/inspection/elfin10/" />
</div>
<div v-if="activeMode === 'artificial' && activeController === 3" class="monitor-container">监控</div>
</div>
<div class="right-container">
<div class="container-title">车载监控</div>
<div class="monitor-container">
<IPlayer :isLive="true" :videoUrl="videoUrl" />
</div>
<div class="split-line"></div>
<div class="container-title">语音对话</div>
<div class="voice-container">
<VoiceConversation />
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, nextTick } from "vue";
import SvgIcon from "@/components/SvgIcon";
import UrdfViewer from './UrdfView.vue'
import DirectionControl from './DirectionControl.vue'
import RoboticArm from "./RoboticArm.vue";
import { getRobotList } from '@/api/inspection/robot'
import { getJointStateApi, moveJApi, torqueOnApi, speedJApi, stopMotionApi, clearFault } from '@/api/inspection/cockpit'
import { Remove, CirclePlus } from '@element-plus/icons-vue'
import VoiceConversation from "./VoiceConversation.vue";
import IPlayer from "@/components/IPlayer/index.vue";
const robotList = ref([])
const fetchRobotList = async () => {
try {
const res = await getRobotList({
robotType: 2,
})
if (res.code === 200) {
robotList.value = res.rows
activeBotData.value = robotList.value[0] || {}
} else {
console.error('获取机器人列表失败:', res.message)
}
} catch (error) {
console.error('获取机器人列表失败:', error)
}
}
const robotStatus = {
0: '在线',
1: '离线',
2: '充电中',
3: '巡检中'
}
const activeBot = ref(0)
const activeBotData = ref({})
const changeBot = (index) => {
activeBot.value = index
activeBotData.value = robotList.value[activeBot.value] || {}
}
const colorList = ['#00D4FF', '#FFB300', '#00FF88', '#fff']
const activeMode = ref('auto')
const changeMode = (mode) => {
activeMode.value = mode
}
const activeController = ref(1)
const speed = ref(0.8)
const activeRoboticArm = ref(1)
const urdfViewerRef = ref()
const jointControls = ref()
const animationId = ref(null)
const handlerController = (value) => {
if (value === 2) {
// 使
torqueOnApi({
deviceId: import.meta.env.VITE_INSPECTION_ARM_DEVICE_ID,
terminalId: activeBotData.value.terminalId
}).catch(error => {
console.error('开启机械臂使能失败:', error)
clearFault({
deviceId: import.meta.env.VITE_INSPECTION_ARM_DEVICE_ID,
terminalId: activeBotData.value.terminalId
})
})
jointControls.value = urdfViewerRef.value.jointControls
if (animationId.value) {
clearInterval(animationId.value)
}
animationId.value = setInterval(() => {
getJointState(activeBotData.value)
}, 100)
} else {
if (animationId.value) {
clearInterval(animationId.value)
}
}
}
const jointNamsMap = {
'joint_1' :'elfin_joint1',
'joint_2': 'elfin_joint2',
'joint_3': 'elfin_joint3',
'joint_4': 'elfin_joint4',
'joint_5': 'elfin_joint5',
'joint_6': 'elfin_joint6',
}
const errCount = ref(0)
/**
* 获取关节状态
* @param robot
*/
const getJointState = async (robot) => {
try {
const res = await getJointStateApi({
deviceId: import.meta.env.VITE_INSPECTION_ARM_DEVICE_ID,
terminalId: robot.terminalId
})
if (res.code === 200) {
setTimeout(() => {
const names = res.data.name
const positions = res.data.position
names.forEach((name, index) => {
urdfViewerRef.value.updateJoint(jointNamsMap[name] || name, positions[index].toFixed(3))
})
}, 500)
} else {
errCount.value += 1
if (errCount.value > 2) {
clearInterval(animationId.value)
errCount.value = 0
}
console.error('获取关节状态失败:', res.message)
}
} catch (error) {
errCount.value += 1
if (errCount.value > 2) {
clearInterval(animationId.value)
errCount.value = 0
}
console.error('获取关节状态失败:', error)
}
}
const jointSpeed = ref(0.8)
const updateJoint = async (jointName, _dir) => {
const velocities = jointControls.value.map(joint => {
return 0
})
const index = jointControls.value.findIndex(joint => joint.name === jointName)
velocities[index] = jointSpeed.value * (_dir === '+' ? 1 : -1)
const res = await speedJApi({
deviceId: import.meta.env.VITE_INSPECTION_ARM_DEVICE_ID,
terminalId: activeBotData.value.terminalId,
acceleration: jointSpeed.value + 0.1,
duration: 60,
velocities: velocities
})
}
/**
* 停止机械臂移动
*/
const stop = async () => {
await stopMotionApi({
deviceId: import.meta.env.VITE_INSPECTION_ARM_DEVICE_ID,
terminalId: activeBotData.value.terminalId
})
}
const videoUrl = ref('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8')
onMounted(() => {
fetchRobotList()
})
onUnmounted(() => {
if (animationId.value) {
clearInterval(animationId.value)
}
})
</script>
<style lang="scss" scoped>
.monitor-container {
width: 100%;
height: 100%;
display: flex;
.left-container {
width: 360px;
height: 100%;
padding: 16px;
.robot-container {
.title {
color: #8899BB;
font-size: 14px;
margin-bottom: 16px;
}
.robot-box {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
.robot {
flex: 0 0 calc(50% - 10px);
background: #0F1E38;
height: 56px;
border-radius: 8px;
padding: 16px;
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 16px;
cursor: pointer;
border: 1px solid transparent;
.bot-name {
color: #fff;
font-size: 12px;
}
.bot-local {
color: #00d4ff;
font-size: 11px;
margin-top: 4px;
}
}
.active {
border-color: #00c2ff;
color: #fff;
box-shadow: 0 0 10px rgba(0, 194, 255, 0.5);
}
}
}
.split-line {
width: 100%;
height: 1px;
margin: 16px 0;
background: #1E3A5F;
}
.mode-container {
.title {
color: #8899BB;
font-size: 14px;
margin-bottom: 16px;
}
.mode-box {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
.mode-item {
height: 36px;
flex: 0 0 calc(50% - 10px);
background: #0F1E38;
border: 1px solid transparent;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 16px;
cursor: pointer;
.label {
color: #8899BB;
font-size: 13px;
}
}
.activeMode {
border-color: #00FF88;
.label {
color: #00FF88;
}
}
}
}
.controller-panel {
padding: 16px;
color: #fff;
font-size: 16px;
}
.robotic-arm-controller-container {
:deep(.el-radio-group) {
display: flex;
align-items: center;
justify-content: space-between;
}
}
}
.video-container {
flex: 1;
border: 1px solid #1E3A5F;
border-top: 0;
.map-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #00c2ff;
font-size: 20px;
}
.model-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #00c2ff;
font-size: 20px;
}
.monitor-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #00c2ff;
font-size: 20px;
}
}
.right-container {
width: 360px;
height: 100%;
padding: 16px;
.container-title {
color: #fff;
font-size: 16px;
font-weight: bold;
margin-bottom: 16px;
}
.split-line {
width: 100%;
height: 1px;
margin: 16px 0;
background: #1E3A5F;
}
.monitor-container {
width: 100%;
height: 360px;
}
.voice-container {
width: 100%;
height: 400px;
}
}
}
.control-panel {
margin-top: 12px;
background: #0F1E38;
padding: 15px;
border-radius: 8px;
color: #ccc;
max-height: 80%;
overflow-y: auto;
.joint-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 10px;
border: 1px solid rgba(0, 195, 255, 0.08);
border-radius: 5px;
background: #0b182c;
&:hover {
border-color: #2a3f7a;
background: rgba(59, 130, 246, 0.031372549);
}
label {
width: 120px;
font-size: 16px;
font-weight: bold;
}
input[type=range] {
flex: 1;
}
.angle {
width: 50px;
text-align: right;
}
:deep(.el-input-group__prepend) {
background: #080b10;
border-right: 0px;
box-shadow: 0 0 0 1px #272727 inset;
}
:deep(.el-input-group__append) {
background: #080b10;
border-left: 0px;
box-shadow: 0 0 0 1px #272727 inset;
}
:deep(.el-input__wrapper) {
background-color: #0e0e0e;
box-shadow: 0 0 0 1px #272727 inset;
}
}
.speed-container {
display: flex;
align-items: center;
justify-content: space-between;
.label,
.proportion {
width: 100px;
text-align: center;
color: #fff;
}
}
}
</style>

View File

@ -181,7 +181,8 @@ function renderFrame(timestamp) {
// Canvas // Canvas
canvasCtx.setTransform(inspectionStore.devicePixelRatio, 0, 0, inspectionStore.devicePixelRatio, 0, 0); canvasCtx.setTransform(inspectionStore.devicePixelRatio, 0, 0, inspectionStore.devicePixelRatio, 0, 0);
canvasCtx.clearRect(0, 0, inspectionStore.canvasWidthPx, inspectionStore.canvasHeightPx); // // canvasCtx.clearRect(0, 0, inspectionStore.canvasWidthPx, inspectionStore.canvasHeightPx); //
canvasCtx.clearRect(0, 0, 10000, 10000); //
// //
if (!applicationState.isMapLoaded) { if (!applicationState.isMapLoaded) {

View File

@ -130,7 +130,8 @@ const getRunningTask = async () => {
taskType = '2' taskType = '2'
} }
const res = await getRunTaskList({ const res = await getRunTaskList({
taskType taskType,
statusList: ['1', '3']
}) })
if (res.code === 200) { if (res.code === 200) {
taskList.value = res.rows taskList.value = res.rows

View File

@ -20,7 +20,16 @@
</div> </div>
</div> </div>
</div> </div>
<div class="center-container">监控</div> <div class="center-container">
<div class="video-container" v-if="activeBot == 0">
<div class="ttt" v-for="value in 4">
<IPlayer :isLive="true" :videoUrl="videoUrl" />
</div>
</div>
<div class="single-video-container" v-else>
<IPlayer :isLive="true" :videoUrl="videoUrl" />
</div>
</div>
<div v-if="activeBot != 0" class="right-container"> <div v-if="activeBot != 0" class="right-container">
<div class="container-title">OCR识别</div> <div class="container-title">OCR识别</div>
<div class="ocr-container"></div> <div class="ocr-container"></div>
@ -31,6 +40,9 @@
</template> </template>
<script setup> <script setup>
import SvgIcon from "@/components/SvgIcon"; import SvgIcon from "@/components/SvgIcon";
import IPlayer from "@/components/IPlayer/index.vue";
const videoUrl = ref('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8')
const colorList = ['#00D4FF', '#FFB300', '#00FF88', '#fd00ff'] const colorList = ['#00D4FF', '#FFB300', '#00FF88', '#fd00ff']
@ -95,8 +107,38 @@ const changeBot = (index) => {
.center-container { .center-container {
flex: 1; flex: 1;
border: 1px solid #1E3A5F; min-width: 0;
min-height: 0;
overflow: hidden;
border: 1px solid #1e3a5f;
border-top: 0; border-top: 0;
.video-container {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 16px;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
.ttt {
width: auto;
height: auto;
min-width: 0;
min-height: 0;
overflow: hidden;
box-sizing: border-box;
}
}
.single-video-container {
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
} }
.right-container { .right-container {

View File

@ -13,9 +13,12 @@
<script setup> <script setup>
import Header from './components/Header.vue'; import Header from './components/Header.vue';
import MapOverview from './components/MapOverview.vue'; import MapOverview from './components/MapOverview.vue';
import ManualTakeover from './components/ManualTakeover.vue'; import ManualTakeover from './components/ManualTakeover/index.vue';
import Monitor from './components/Monitor.vue'; import Monitor from './components/Monitor.vue';
// import autofit from 'autofit.js'
// autofit.init()
const currentPage = ref('home') const currentPage = ref('home')
const activePage = (page) => { const activePage = (page) => {

View File

@ -0,0 +1,304 @@
<template>
<div class="app-container">
<div ref="topContainerRef">
<TableSearch
:queryParams="queryParams"
:showSearch="showSearch"
label-width="90px"
queryRef="queryRef"
@refresh="resetQuery"
@search="handleQuery"
>
<template #one>
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
<el-form-item label="车辆名称" prop="vehicleName">
<el-input
v-model="queryParams.vehicleName"
placeholder="请输入车辆名称"
clearable
style="width: 240px"
@keyup.enter="handleQuery"
/>
</el-form-item>
</el-col>
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
<el-form-item label="车型" prop="vehicleModel">
<el-input
v-model="queryParams.vehicleModel"
placeholder="请输入车型"
clearable
style="width: 240px"
@keyup.enter="handleQuery"
/>
</el-form-item>
</el-col>
</template>
</TableSearch>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="Plus"
@click="handleAdd"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="Edit"
:disabled="single"
@click="handleUpdate"
>修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="Delete"
:disabled="multiple"
@click="handleDelete"
>删除</el-button>
</el-col>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
</div>
<div :style="containerHeight">
<!-- 表格数据 -->
<el-table height="100%" v-loading="loading" :data="tableDataList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="车辆名称" prop="vehicleName" />
<el-table-column label="车型" prop="vehicleModel" />
<el-table-column label="车架号" prop="vin" />
<el-table-column label="颜色" prop="color"/>
<el-table-column label="状态" align="center" width="100">
<template #default="scope">
<el-switch
v-model="scope.row.status"
active-value="0"
inactive-value="1"
@change="handleStatusChange(scope.row)"
></el-switch>
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" width="180"/>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template #default="scope">
<el-tooltip content="修改" placement="top">
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)">修改</el-button>
</el-tooltip>
<el-tooltip content="删除" placement="top">
<el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
</el-tooltip>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</div>
<!-- 添加或修改配置对话框 -->
<el-dialog :title="title" v-model="open" width="500px" append-to-body>
<el-form ref="carRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="车辆名称" prop="vehicleName">
<el-input v-model="form.vehicleName" placeholder="请输入车辆名称" />
</el-form-item>
<el-form-item label="车型" prop="vehicleModel">
<el-input v-model="form.vehicleModel" placeholder="请输入车型" />
</el-form-item>
<el-form-item label="车架号" prop="vin">
<el-input v-model="form.vin" placeholder="请输入车架号" />
</el-form-item>
<el-form-item label="颜色" prop="vin">
<el-input v-model="form.color" placeholder="请输入颜色" />
</el-form-item>
<el-form-item label="生产日期" prop="productionDate">
<el-date-picker
v-model="form.productionDate"
type="date"
style="width: 100%;"
placeholder="Pick a day"
/>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" placeholder="请输入内容"></el-input>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitForm"> </el-button>
<el-button @click="cancel"> </el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup name="Role">
import TableSearch from "@/components/TableSearch/index.vue";
import { useContainerHeight } from "@/hooks/tableHeight";
import { addVehicle, getVehicleList, updateVehicle, deleteVehicle } from "@/api/intelligenceTest/car.js"
import { ElMessage, ElMessageBox } from 'element-plus'
const topContainerRef = ref();
const containerHeight = useContainerHeight(topContainerRef);
const tableDataList = ref([]);
const open = ref(false);
const loading = ref(true);
const showSearch = ref(true);
const ids = ref([]);
const single = ref(true);
const multiple = ref(true);
const total = ref(0);
const title = ref("");
const data = reactive({
form: {},
queryParams: {
pageNum: 1,
pageSize: 10,
vehicleName: undefined,
vehicleModel: undefined
},
rules: {
vehicleName: [{ required: true, message: "车辆名称不能为空", trigger: "blur" }],
vehicleModel: [{ required: true, message: "车型不能为空", trigger: "blur" }],
vin: [{ required: true, message: "车架号不能为空", trigger: "blur" }],
color: [{ required: true, message: "颜色不能为空", trigger: "blur" }],
productionDate: [{ required: true, message: "生产日期不能为空", trigger: "blur" }]
},
});
const { queryParams, form, rules } = toRefs(data);
/** 查询列表 */
const getList = async () => {
loading.value = true;
const res = await getVehicleList(queryParams.value)
if (res.code === 200) {
tableDataList.value = res.rows;
total.value = res.total;
}
loading.value = false;
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.value.pageNum = 1;
getList();
}
/** 重置按钮操作 */
function resetQuery() {
handleQuery();
}
/** 删除按钮操作 */
function handleDelete(row) {
const deleteIds = row.id || ids.value;
ElMessageBox.confirm(`确定要删除编号为${deleteIds}的车辆吗?`, "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(() => {
deleteVehicle(deleteIds).then(() => {
ElMessage.success("删除成功");
getList();
});
});
}
/** 多选框选中数据 */
function handleSelectionChange(selection) {
ids.value = selection.map(item => item.id);
single.value = selection.length != 1;
multiple.value = !selection.length;
}
/** 状态修改 */
function handleStatusChange(row) {
let text = row.status === "0" ? "启用" : "停用";
ElMessageBox.confirm('确认要"' + text + '""' + row.vehicleName + '"车辆吗?', "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(() => {
updateVehicle({
...row,
}).then(() => {
ElMessage.success(text + "成功");
getList();
});
}).catch(() => {
row.status = row.status === "0" ? "1" : "0";
})
}
const reset = () => {
carRef.value.resetFields()
};
const carRef = ref()
const isNew = ref(true)
/** 添加 */
function handleAdd() {
open.value = true;
isNew.value = true
title.value = "添加";
}
/** 修改 */
function handleUpdate(row) {
form.value = { ...row };
open.value = true;
isNew.value = false
title.value = "修改";
}
/** 提交按钮 */
function submitForm() {
carRef.value.validate((valid) => {
if (valid) {
if (isNew.value) {
addVehicle(form.value).then(() => {
ElMessage.success("添加成功");
getList();
open.value = false;
reset();
});
} else {
updateVehicle(form.value).then(() => {
ElMessage.success("修改成功");
getList();
open.value = false;
reset();
});
}
}
});
}
/** 取消按钮 */
function cancel() {
open.value = false;
reset();
}
getList();
</script>

View File

@ -0,0 +1,321 @@
<template>
<div class="app-container">
<div ref="topContainerRef">
<TableSearch
:queryParams="queryParams"
:showSearch="showSearch"
label-width="90px"
queryRef="queryRef"
@refresh="resetQuery"
@search="handleQuery"
>
<template #one>
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
<el-form-item label="手机名称" prop="phoneName">
<el-input
v-model="queryParams.phoneName"
placeholder="请输入手机名称"
clearable
style="width: 240px"
@keyup.enter="handleQuery"
/>
</el-form-item>
</el-col>
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
<el-form-item label="手机型号" prop="phoneModel">
<el-input
v-model="queryParams.phoneModel"
placeholder="请输入手机型号"
clearable
style="width: 240px"
@keyup.enter="handleQuery"
/>
</el-form-item>
</el-col>
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
<el-form-item label="厂商" prop="manufacturer">
<el-input
v-model="queryParams.manufacturer"
placeholder="请输入厂商"
clearable
style="width: 240px"
@keyup.enter="handleQuery"
/>
</el-form-item>
</el-col>
</template>
</TableSearch>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="Plus"
@click="handleAdd"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="Edit"
:disabled="single"
@click="handleUpdate"
>修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="Delete"
:disabled="multiple"
@click="handleDelete"
>删除</el-button>
</el-col>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
</div>
<div :style="containerHeight">
<!-- 表格数据 -->
<el-table height="100%" v-loading="loading" :data="tableDataList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="手机名称" prop="phoneName" :show-overflow-tooltip="true" width="180"/>
<el-table-column label="手机型号" prop="phoneModel" />
<el-table-column label="系统版本" prop="osVersion" />
<el-table-column label="操作系统" prop="osSystem" width="100" />
<el-table-column label="厂商" align="center" prop="manufacturer" />
<el-table-column label="额外参数" align="center" prop="specifications" :show-overflow-tooltip="true" width="180"/>
<el-table-column label="状态" align="center" width="100">
<template #default="scope">
<el-switch
v-model="scope.row.status"
active-value="0"
inactive-value="1"
@change="handleStatusChange(scope.row)"
></el-switch>
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" width="180"/>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template #default="scope">
<el-tooltip content="修改" placement="top">
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)">修改</el-button>
</el-tooltip>
<el-tooltip content="删除" placement="top">
<el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
</el-tooltip>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</div>
<!-- 添加或修改配置对话框 -->
<el-dialog :title="title" v-model="open" width="500px" append-to-body>
<el-form ref="phoneRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="手机名称" prop="phoneName">
<el-input v-model="form.phoneName" placeholder="请输入手机名称" />
</el-form-item>
<el-form-item label="手机型号" prop="phoneModel">
<el-input v-model="form.phoneModel" placeholder="请输入手机型号" />
</el-form-item>
<el-form-item label="操作系统" prop="osSystem">
<el-select v-model="form.osSystem">
<el-option label="Android" value="Android" />
<el-option label="iOS" value="iOS" />
</el-select>
</el-form-item>
<el-form-item label="系统版本" prop="osVersion">
<el-input v-model="form.osVersion" placeholder="请输入系统版本" />
</el-form-item>
<el-form-item label="厂商" prop="manufacturer">
<el-input v-model="form.manufacturer" />
</el-form-item>
<el-form-item label="规格参数" prop="specifications">
<el-input v-model="form.specifications" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" placeholder="请输入内容"></el-input>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitForm"> </el-button>
<el-button @click="cancel"> </el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup name="Role">
import TableSearch from "@/components/TableSearch/index.vue";
import { useContainerHeight } from "@/hooks/tableHeight";
import { addPhone, getPhoneList, updatePhone, deletePhone } from "@/api/intelligenceTest/mobilePhone.js"
import { ElMessage, ElMessageBox } from 'element-plus'
const topContainerRef = ref();
const containerHeight = useContainerHeight(topContainerRef);
const tableDataList = ref([]);
const open = ref(false);
const loading = ref(true);
const showSearch = ref(true);
const ids = ref([]);
const single = ref(true);
const multiple = ref(true);
const total = ref(0);
const title = ref("");
const data = reactive({
form: {},
queryParams: {
pageNum: 1,
pageSize: 10,
phoneName: undefined,
phoneModel: undefined,
manufacturer: undefined
},
rules: {
phoneName: [{ required: true, message: "手机名称不能为空", trigger: "blur" }],
phoneModel: [{ required: true, message: "手机型号不能为空", trigger: "blur" }],
manufacturer: [{ required: true, message: "厂商不能为空", trigger: "blur" }],
osSystem: [{ required: true, message: "操作系统不能为空", trigger: "blur" }],
osVersion: [{ required: true, message: "系统版本不能为空", trigger: "blur" }]
},
});
const { queryParams, form, rules } = toRefs(data);
/** 查询列表 */
const getList = async () => {
loading.value = true;
const res = await getPhoneList(queryParams.value)
if (res.code === 200) {
tableDataList.value = res.rows;
total.value = res.total;
}
loading.value = false;
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.value.pageNum = 1;
getList();
}
/** 重置按钮操作 */
function resetQuery() {
handleQuery();
}
/** 删除按钮操作 */
function handleDelete(row) {
const deleteIds = row.id || ids.value;
ElMessageBox.confirm(`确定要删除编号为${deleteIds}的手机吗?`, "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(() => {
deletePhone(deleteIds).then(() => {
ElMessage.success("删除成功");
getList();
});
});
}
/** 多选框选中数据 */
function handleSelectionChange(selection) {
ids.value = selection.map(item => item.id);
single.value = selection.length != 1;
multiple.value = !selection.length;
}
/** 状态修改 */
function handleStatusChange(row) {
let text = row.status === "0" ? "启用" : "停用";
ElMessageBox.confirm('确认要"' + text + '""' + row.phoneName + '"手机吗?', "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(() => {
updatePhone({
...row,
}).then(() => {
ElMessage.success(text + "成功");
getList();
});
}).catch(() => {
row.status = row.status === "0" ? "1" : "0";
})
}
const reset = () => {
phoneRef.value.resetFields()
};
const phoneRef = ref()
const isNew = ref(true)
/** 添加 */
function handleAdd() {
open.value = true;
isNew.value = true
title.value = "添加";
}
/** 修改 */
function handleUpdate(row) {
form.value = { ...row };
open.value = true;
isNew.value = false
title.value = "修改";
}
/** 提交按钮 */
function submitForm() {
phoneRef.value.validate((valid) => {
if (valid) {
if (isNew.value) {
addPhone(form.value).then(() => {
ElMessage.success("添加成功");
getList();
open.value = false;
reset();
});
} else {
deleteVehicle(form.value).then(() => {
ElMessage.success("修改成功");
getList();
open.value = false;
reset();
});
}
}
});
}
/** 取消按钮 */
function cancel() {
open.value = false;
reset();
}
getList();
</script>

View File

@ -0,0 +1,647 @@
<template>
<div class="page-container">
<div class="left">
<div class="header">
<div>任务队列</div>
<el-button type="primary" @click="addTask">添加任务</el-button>
</div>
<div class="taskList-container">
<div class="task-item" v-for="task in runTaskList">
<div class="task-content">
<div class="task-title">{{ task.taskName }}</div>
<div class="task-actions">
<SvgIcon class="icon" v-if="task.status == '1'" name="pause" :size="24" color="#FFB300"
@click="pauseTask(task)" />
<SvgIcon class="icon" v-if="task.status == '3' || task.status == '5'" name="start"
:size="24" color="#00FF88" @click="startTask(task)" />
<SvgIcon class="icon" v-if="task.status != '5'" name="stop" :size="24" color="#d81e06"
@click="stopTask(task)" />
<SvgIcon class="icon" v-if="task.status == '5'" name="remove" :size="24" color="#FFB300"
@click="removeTask(task)" />
</div>
</div>
<div class="robot">手机{{ task.phoneName }}</div>
<div class="robot">车辆{{ task.vehicleName }}</div>
<el-progress :color="customColorMethod"
:percentage="parseFloat(taskRunningInfo[task.id]?.progress?.toFixed(2)) || 0" />
</div>
</div>
</div>
<div class="right" ref="topContainerRef">
<div class="header">
<div class="label" :class="{ active: activePanel === 'current' }" @click="activePanel = 'current'">当前任务
</div>
<div class="label" :class="{ active: activePanel === 'history' }" @click="activePanel = 'history'">历史记录
</div>
</div>
<div class="context-container">
<div v-if="activePanel === 'current'">
<!-- 当前任务内容 -->
<div class="task" v-for="runTask in runingTaskList">
<div class="task-header">
<div class="header-container">
<div class="task-info">
<div class="title">{{ runTask.taskName }}</div>
<div class="id">ID: {{ runTask.taskId }}</div>
</div>
<div class="robot">
<div>手机{{ runTask.phoneName }}</div>
<div>车辆{{ runTask.vehicleName }}</div>
<div>开始时间: {{ formatDate(runTask.startTime) }}</div>
</div>
</div>
<div>
<el-tag :type="runTask.status === '3' ? 'primary' : 'warning'">
{{ taskStatusMap[runTask.status] || '未知状态' }}
</el-tag>
</div>
</div>
<div class="progress-label">执行进度</div>
<el-progress :color="customColorMethod"
:percentage="parseFloat(taskRunningInfo[runTask.id]?.progress?.toFixed(2)) || 0" />
<taskSteps :list="pointList_runningTask[runTask.taskId]"
:activeStep="taskRunningInfo[runTask.id]?.activeStep || 0" />
<div class="run-log-container">
<div>执行日志</div>
<taskLog :list="taskLogList[runTask.id]" />
</div>
</div>
</div>
<div v-else-if="activePanel === 'history'">
<div :style="containerHeight">
<el-table v-loading="loading" height="100%" :data="tableDataList">
<el-table-column align="center" label="任务名称" prop="taskName" show-overflow-tooltip />
<el-table-column align="center" label="任务状态" prop="status">
<template #default="scope">
<el-tag :type="scope.row.status === '0' ? 'success' : 'danger'">
{{ taskStatusMap[scope.row.status] || '未知状态' }}
</el-tag>
</template>
</el-table-column>
<el-table-column align="center" label="任务类型" prop="taskType">
<template #default="scope">
<el-tag :type="scope.row.taskType === '1' ? 'primary' : 'success'">
{{ scope.row.taskType === '1' ? '巡检任务' : '讲解任务' }}
</el-tag>
</template>
</el-table-column>
<el-table-column align="center" label="机器人" prop="robotName" show-overflow-tooltip />
<el-table-column align="center" label="开始时间" prop="startTime">
<template #default="scope">
{{ formatDate(scope.row.startTime) }}
</template>
</el-table-column>
<el-table-column align="center" label="结束时间" prop="endTime">
<template #default="scope">
{{ formatDate(scope.row.endTime) }}
</template>
</el-table-column>
<el-table-column align="center" class-name="small-padding fixed-width" label="操作"
width="260">
<template #default="scope">
<el-button icon="Edit" link type="primary" @click="handleOpenLog(scope.row)">
日志
</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:limit="queryParams.pageSize"
v-model:page="queryParams.pageNum" :total="total" @pagination="getHistoryList" />
</div>
</div>
</div>
</div>
<!-- 添加任务弹窗 -->
<el-dialog title="添加任务" v-model="open" width="500px" append-to-body>
<el-form ref="runTaskRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="选择任务" prop="taskId">
<el-select v-model="form.taskId" placeholder="请选择任务" clearable style="width: 100%">
<el-option v-for="task in taskList" :key="task.id" :label="task.taskName" :value="task.id" />
</el-select>
</el-form-item>
<el-form-item label="选择手机" prop="phoneId">
<el-select v-model="form.phoneId" placeholder="请选择手机" clearable style="width: 100%">
<el-option v-for="phone in phoneList" :key="phone.id" :label="phone.phoneName"
:value="phone.id" />
</el-select>
</el-form-item>
<el-form-item label="选择车辆" prop="vehicleId">
<el-select v-model="form.vehicleId" placeholder="请选择车辆" clearable style="width: 100%">
<el-option v-for="vehicle in carList" :key="vehicle.id" :label="vehicle.vehicleName"
:value="vehicle.id" />
</el-select>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input type="textarea" v-model="form.remark" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="confirmTaskDialog"> </el-button>
<el-button @click="cancelTaskDialog"> </el-button>
</div>
</template>
</el-dialog>
<el-dialog title="任务执行日志" v-model="logDialogVisible" width="500px" append-to-body>
<taskLog :list="logDialogData" :height="300" />
</el-dialog>
</div>
</template>
<script setup>
import SvgIcon from "@/components/SvgIcon";
import taskSteps from "./task-steps.vue";
import taskLog from "./task-log.vue";
import { useContainerHeight } from "@/hooks/tableHeight";
import { getTaskList, getTestCaseByTaskId } from "@/api/intelligenceTest/taskManage";
import { getPhoneList } from "@/api/intelligenceTest/mobilePhone";
import { getVehicleList } from "@/api/intelligenceTest/car";
import { onMounted, onUnmounted } from "vue";
import {
addTaskinstance,
getTaskinstanceList,
startTaskinstance,
pauseTaskinstance,
stopTaskinstance,
resumeTaskinstance,
deleteTaskinstance,
getTaskLogListApi
} from "@/api/intelligenceTest/runningTask";
import { ElMessage, ElMessageBox } from "element-plus";
import { formatDate } from "@/utils/index";
import { debounce } from 'lodash'; // lodash
const topContainerRef = ref();
const containerHeight = useContainerHeight(topContainerRef);
const socket = inject('ws');
const taskStatusMap = {
'0': '已完成',
'1': '执行中',
'2': '失败',
'3': '已暂停',
'4': '已终止',
'5': '未执行',
}
const form = reactive({
taskId: null,
});
const rules = reactive({
taskId: [{ required: true, message: '请选择任务', trigger: 'change' }],
phoneId: [{ required: true, message: '请选择手机', trigger: 'change' }],
vehicleId: [{ required: true, message: '请选择车辆', trigger: 'change' }],
});
const taskList = ref([]);
const phoneList = ref([]);
const runTaskList = ref([]);
const getTaskData = () => {
getTaskList({
pageNum: 1,
pageSize: 10000,
}).then((res) => {
taskList.value = res.rows;
})
}
const getPhoneData = (mapId = null) => {
getPhoneList({
pageNum: 1,
pageSize: 10000
}).then((res) => {
phoneList.value = res.rows;
})
}
const carList = ref([])
const getCarData = (mapId = null) => {
getVehicleList({
pageNum: 1,
pageSize: 10000
}).then((res) => {
carList.value = res.rows;
})
}
const open = ref(false);
const addTask = () => {
open.value = true;
}
const runTaskRef = ref();
/**
* 确定添加执行任务
*/
const confirmTaskDialog = () => {
runTaskRef.value.validate((valid) => {
if (valid) {
addTaskinstance(form).then(() => {
ElMessage.success("任务添加成功");
getList();
open.value = false;
runTaskRef.value.resetFields();
}).catch(() => {
ElMessage.error("任务添加失败");
})
}
});
}
const cancelTaskDialog = () => {
open.value = false;
}
const runingTaskList = ref([])
/**
* 获取任务队列
*/
const getList = () => {
getTaskinstanceList({
statusList: ['1', '3', '5'],
}).then((res) => {
if (res.code === 200) {
runTaskList.value = res.rows;
runingTaskList.value = res.rows.filter(item => item.status !== '5')
for (let i = 0; i < runTaskList.value.length; i++) {
getCaseByTaskId(runTaskList.value[i].taskId)
getTaskLogList(runTaskList.value[i].id)
}
}
})
}
const pointList_runningTask = ref({})
/**
* 获取正在执行任务的用例列表
* @param taskId
*/
const getCaseByTaskId = async (taskId) => {
const res = await getTestCaseByTaskId(taskId)
if (res.code === 200) {
pointList_runningTask.value[taskId] = res.data
}
}
const taskLogList = ref({})
/**
* 获取正在执行任务的执行日志
* @param taskInstanceId
*/
const getTaskLogList = async (taskInstanceId) => {
const res = await getTaskLogListApi({ taskInstanceId })
if (res.code === 200) {
taskLogList.value[taskInstanceId] = res.rows
}
}
/**
* 自定义进度条值不同区间所显示的颜色
* @param percentage
*/
const customColorMethod = (percentage) => {
if (percentage < 30) {
return '#6f7ad3'
}
if (percentage < 70) {
return '#1989fa'
}
return '#67c23a'
}
const activePanel = ref('current');
const tableDataList = ref([]);
const loading = ref(false);
const total = ref(0);
const queryParams = reactive({
pageNum: 1,
pageSize: 10,
});
/**
* 开始执行任务
* @param task
*/
const startTask = async (task) => {
if (task.status === '3') {
let res = await resumeTaskinstance(task.id);
if (res.code === 200) {
ElMessage.success("任务已恢复");
getList();
}
} else {
let res = await startTaskinstance(task.id);
if (res.code === 200) {
ElMessage.success("任务已开始");
getList();
}
}
}
/**
* 暂停执行任务
* @param task
*/
const pauseTask = async (task) => {
let res = await pauseTaskinstance(task.id);
if (res.code === 200) {
ElMessage.success("任务已暂停");
getList();
}
}
/**
* 停止执行任务
* @param task
*/
const stopTask = async (task) => {
let res = await stopTaskinstance(task.id);
if (res.code === 200) {
ElMessage.success("任务已停止");
getList();
}
}
/**
* 删除执行任务
* @param task
*/
const removeTask = async (task) => {
ElMessageBox.confirm('确定要删除该任务吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(async () => {
let res = await deleteRunTask(task.id);
if (res.code === 200) {
ElMessage.success("任务已删除");
getList();
}
}).catch(() => {
//
});
}
/**
* 获取历史执行任务
*/
const getHistoryList = () => {
loading.value = true;
getTaskinstanceList({
statusList: ['0', '2', '4'],
pageNum: queryParams.pageNum,
pageSize: queryParams.pageSize,
}).then((res) => {
if (res.code === 200) {
tableDataList.value = res.rows;
total.value = res.total;
}
loading.value = false;
}).catch(() => {
loading.value = false;
});
}
const taskRunningInfo = ref({})
const socketChannelCallbacks = ref({})
const channel = 'AimaTaskInstance';
/**
* 订阅websocket
*/
const subscribeDebounced = debounce(async (sub) => {
if (!socket) {
console.warn(`执行任务订阅失败: socket=${!!socket}`);
return;
}
socket.send({
type: 'channel_subscription',
action: sub ? 'subscribe' : 'unsubscribe',
channel,
});
if (sub) {
await nextTick();
//
if (socketChannelCallbacks.value[channel]) {
socket.off(channel, socketChannelCallbacks.value[channel]);
console.log('清理旧回调:', channel);
}
const callback = (data) => {
console.log('data', data)
if (data !== '500') {
if (data.status !== 0) {
let activeStep = pointList_runningTask.value[data.taskId].findIndex(item => item.detectItemId === data.itemId) || 0
if (data.progress == 100) {
activeStep += 1
}
taskRunningInfo.value[data.taskInstanceId] = {
...data,
activeStep
}
if (!taskLogList.value[data.taskInstanceId]) {
taskLogList.value[data.taskInstanceId] = []
}
taskLogList.value[data.taskInstanceId].push({
...data
})
} else {
delete taskRunningInfo.value[data.taskInstanceId]
delete taskLogList.value[data.taskInstanceId]
getList()
getHistoryList()
}
} else {
//
subscribeDebounced(false);
subscribeDebounced(true);
}
};
socket.on(channel, callback);
socketChannelCallbacks.value[channel] = callback;
} else {
if (socketChannelCallbacks.value[channel]) {
socket.off(channel, socketChannelCallbacks.value[channel]);
delete socketChannelCallbacks.value[channel];
}
}
}, 300);
const logDialogVisible = ref(false)
const logDialogData = ref([])
const handleOpenLog = async (row) => {
logDialogVisible.value = true
const res = await getTaskLogListApi({ taskInstanceId: row.id })
if (res.code === 200) {
logDialogData.value = res.rows
}
}
onMounted(() => {
getTaskData();
getPhoneData();
getCarData()
getList();
getHistoryList();
subscribeDebounced(true)
})
onUnmounted(() => {
subscribeDebounced(false)
})
</script>
<style lang="scss" scoped>
.page-container {
width: 100%;
height: 100%;
display: flex;
gap: 12px;
.left {
width: 25%;
height: 100%;
background: #fff;
padding: 16px;
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 16px;
border-bottom: 1px solid #eee;
}
.taskList-container {
margin-top: 16px;
height: calc(100% - 100px);
overflow-y: auto;
.task-item {
padding: 12px;
background: #fafafa;
box-shadow: inset 0 0 0 1px #eee;
border-radius: 8px;
margin-bottom: 16px;
.task-content {
display: flex;
justify-content: space-between;
align-items: center;
.task-title {
font-size: 16px;
font-weight: bold;
}
.task-actions {
display: flex;
gap: 8px;
.icon {
cursor: pointer;
}
}
}
.robot {
margin-top: 8px;
color: #666;
}
}
}
}
.right {
flex: 1;
padding: 16px;
background: #fff;
overflow: hidden;
.header {
display: flex;
margin-bottom: 16px;
border-bottom: 1px solid #eee;
.label {
font-size: 16px;
font-weight: bold;
color: #333;
cursor: pointer;
box-shadow: inset 0 0 0 1px #eee;
padding: 10px;
}
.active {
color: #409eff;
box-shadow: inset 0 0 0 1px #409eff;
}
}
.context-container {
height: calc(100% - 40px);
overflow: auto;
}
.task {
padding: 16px;
background: #fafafa;
border-radius: 20px;
margin-bottom: 12px;
.task-header {
display: flex;
justify-content: space-between;
align-items: center;
.task-info {
display: flex;
gap: 40px;
.title {
font-size: 18px;
font-weight: bold;
}
.id {
color: #666;
font-size: 14px;
margin: 8px 0;
}
}
.robot {
display: flex;
align-items: center;
gap: 50px;
color: #666;
font-size: 14px;
}
}
.progress-label {
margin-top: 12px;
color: #666;
font-size: 14px;
font-weight: bold;
}
}
}
}
</style>

View File

@ -0,0 +1,71 @@
<template>
<div class="log-container" :style="{ 'height': height + 'px' }" ref="messageContainer">
<div class="log" v-for="log, index in list">
<div class="time">{{ formatDate(log.logTime) }}</div>
<div class="context">{{ log.logContent }}</div>
</div>
</div>
</template>
<script setup>
import { formatDate } from "@/utils/index";
import { watch } from "vue";
const props = defineProps({
list: {
type: Array,
default: () => []
},
height: {
type: Number,
default: 150
}
})
const messageContainer = ref(null)
const SCROLL_DELAY = 100
const scrollToBottom = () => {
nextTick(() => {
setTimeout(() => {
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight
}
}, SCROLL_DELAY)
})
}
watch(() => props.list, () => {
scrollToBottom()
}, { deep: true })
onMounted(() => {
scrollToBottom()
})
</script>
<style lang="scss" scoped>
.log-container {
margin-top: 12px;
overflow-y: auto;
background: #fff;
border: 1px solid #eee;
border-radius: 8px;
padding: 12px;
.log {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
.time {
color: #999;
font-size: 14px;
}
.context {
color: #333;
font-size: 14px;
}
}
}
</style>

View File

@ -0,0 +1,88 @@
<template>
<el-scrollbar ref="scrollbarRef" style="width: 100%;" class="step-scrollbar">
<div class="step-wrapper" ref="stepWrapperRef">
<el-steps :active="activeStep" finish-status="success">
<el-step v-for="item in list" :key="value" :title="item.caseName" :description="item.remark" />
</el-steps>
</div>
</el-scrollbar>
</template>
<script setup>
const props = defineProps({
list: {
type: Array,
default: () => []
},
activeStep: {
type: Number,
default: 0
}
})
const scrollbarRef = ref(null)
const stepWrapperRef = ref(null)
/**
* 滚动到当前激活的步骤
*/
const scrollToActiveStep = () => {
nextTick(() => {
// el-scrollbar
const scrollbarEl = scrollbarRef.value?.$el
const wrapEl = scrollbarEl?.querySelector('.el-scrollbar__wrap')
if (!wrapEl) return
const viewWidth = wrapEl.clientWidth
// step
const steps = stepWrapperRef.value?.querySelectorAll('.el-step')
if (!steps || !steps[props.activeStep]) return
// step
const activeEl = steps[props.activeStep]
const stepLeft = activeEl.offsetLeft
const stepWidth = activeEl.offsetWidth
// step
const scrollLeft = stepLeft - viewWidth / 2 + stepWidth / 2
console.log('滚动位置:', scrollLeft)
// 使 el-scrollbar
scrollbarRef.value.setScrollLeft(Math.max(0, scrollLeft))
})
}
setTimeout(() => {
scrollToActiveStep()
}, 1000)
</script>
<style lang="scss" scoped>
.step-scrollbar {
margin-top: 12px;
white-space: nowrap;
.step-wrapper {
display: inline-block;
/* 让wrapper宽度由内容决定 */
width: 100%;
/* 至少和容器一样宽 */
white-space: nowrap;
/* 防止 flex 项换行 */
margin: 10px 0 20px 0;
:deep(.el-steps) {
flex-wrap: nowrap;
width: calc(100% - 150px);
.el-step {
flex-shrink: 0 !important;
/* 禁止步骤项被压缩 */
min-width: 150px;
/* 每个步骤的最小宽度,按需调整 */
}
}
}
}
</style>

View File

@ -0,0 +1,357 @@
<template>
<div class="app-container">
<div ref="topContainerRef">
<TableSearch :queryParams="queryParams" :showSearch="showSearch" label-width="90px" queryRef="queryRef"
@refresh="resetQuery" @search="handleQuery">
<template #one>
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
<el-form-item label="任务名称" prop="taskName">
<el-input v-model="queryParams.taskName" clearable placeholder="请输入任务名称" style="width: 100%"
@keyup.enter="handleQuery" />
</el-form-item>
</el-col>
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
<el-form-item label="任务类型" prop="taskType">
<el-select v-model="queryParams.taskType" clearable placeholder="请选择任务类型"
style="width: 100%">
<el-option label="常规测试" value="1" />
<el-option label="回归测试" value="2" />
<el-option label="专项测试" value="3" />
</el-select>
</el-form-item>
</el-col>
</template>
</TableSearch>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button icon="Plus" plain type="primary" @click="handleAdd">新增
</el-button>
</el-col>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
</div>
<div :style="containerHeight">
<el-table v-loading="loading" height="100%" :data="tableDataList">
<el-table-column align="center" label="id" prop="id" show-overflow-tooltip />
<el-table-column align="center" label="任务名称" prop="taskName" show-overflow-tooltip />
<el-table-column align="center" label="任务描述" prop="remark" show-overflow-tooltip />
<el-table-column align="center" label="任务类型" prop="taskType">
<template #default="scope">
<el-tag :type="scope.row.taskType === '1' ? 'primary' : 'success'">
{{ scope.row.taskType === '1' ? '巡检任务' : '讲解任务' }}
</el-tag>
</template>
</el-table-column>
<el-table-column align="center" label="创建人" prop="createBy" show-overflow-tooltip />
<el-table-column align="center" label="创建时间" prop="createTime" show-overflow-tooltip />
<el-table-column align="center" class-name="small-padding fixed-width" label="操作" width="260">
<template #default="scope">
<el-button icon="Edit" link type="primary" @click="handleUpdate(scope.row)">
修改
</el-button>
<el-button icon="Cpu" link type="primary" @click="handleBindCase(scope.row)">绑定用例
</el-button>
<el-button icon="Delete" link type="primary" @click="handleDelete(scope.row)">删除
</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:limit="queryParams.pageSize" v-model:page="queryParams.pageNum"
:total="total" @pagination="getList" />
</div>
<el-dialog v-model="open" :title="title" append-to-body width="500px">
<el-form ref="taskRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="任务名称" prop="taskName">
<el-input v-model="form.taskName" placeholder="请输入任务名称" />
</el-form-item>
<el-form-item label="任务类型" prop="taskType">
<el-select v-model="form.taskType" clearable placeholder="请选择任务类型" style="width: 100%">
<el-option label="常规测试" value="1" />
<el-option label="回归测试" value="2" />
<el-option label="专项测试" value="3" />
</el-select>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" :rows="2" placeholder="请输入备注" type="textarea" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitForm"> </el-button>
<el-button @click="cancel"> </el-button>
</div>
</template>
</el-dialog>
<!-- 点位绑定弹窗 -->
<el-dialog v-model="dialogVisible" title="测试用例绑定" width="800px">
<el-row>
<TableSearch :queryParams="caseQueryParams" :showSearch="showSearch" label-width="90px" @refresh="resetBindCaseQuery" @search="handleBindCaseQuery">
<template #one>
<el-col :lg="12" :md="12" :sm="24" :xl="12" :xs="24" :xxl="12">
<el-form-item label="用例名称" prop="caseName">
<el-input v-model="caseQueryParams.caseName" clearable placeholder="请输入用例名称"
style="width: 100%" @keyup.enter="handleBindCaseQuery" />
</el-form-item>
</el-col>
</template>
</TableSearch>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<div style="height: 55vh">
<div class="task-title">待选择用例</div>
<draggable :animation="340" :forceFallback="true" :list="taskLeftList" class="draggable-group"
ghostClass="dragClass" group="people" itemKey="name">
<template #item="{ element, index }">
<div class="list-group-item">
<el-icon>
<Tools />
</el-icon>
{{ element.caseName }}
</div>
</template>
</draggable>
</div>
</el-col>
<el-col :span="12">
<div style="height: 55vh">
<div class="task-title">用例排序</div>
<draggable :animation="340" :forceFallback="true" :list="taskRightList" class="draggable-group"
ghostClass="dragClass" group="people" itemKey="name">
<template #item="{ element, index }">
<div class="list-group-item">
{{ index + 1 }} {{ element.caseName }}
</div>
</template>
</draggable>
</div>
</el-col>
</el-row>
<div class="custom-block">
<div class="mb5">1将待选择用例拖拽至用例排序中</div>
<div>2用例排序中可拖拽自定义其顺序</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="bindCase"> </el-button>
<el-button @click="cancelCase"> </el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script name="Detect" setup>
import { addTask, updateTask, getTaskList, deleteTask, getTestCaseByTaskId, bindTastCase} from "../../../api/intelligenceTest/taskManage";
import { getTestCaseList } from "../../../api/intelligenceTest/testCase.js";
import TableSearch from "@/components/TableSearch/index.vue";
import { useContainerHeight } from "@/hooks/tableHeight";
import { ElMessage, ElMessageBox } from "element-plus";
import draggable from "vuedraggable";
import { reactive, ref } from "vue";
import { getMapList } from "@/api/inspection/map";
const topContainerRef = ref();
const containerHeight = useContainerHeight(topContainerRef);
const { proxy } = getCurrentInstance();
const tableDataList = ref([]);
const open = ref(false);
const dialogVisible = ref(false);
const loading = ref(false);
const showSearch = ref(true);
const total = ref(0);
const title = ref("");
const data = reactive({
form: {},
queryParams: {
pageNum: 1,
pageSize: 10,
taskName: null,
taskType: null,
},
rules: {
taskName: [
{ required: true, message: "任务名称不能为空", trigger: "blur" },
],
taskType: [
{ required: true, message: "请选择任务类型", trigger: "blur" },
],
mapId: [
{ required: true, message: "请选择所属地图", trigger: "blur" },
],
},
});
const { queryParams, form, rules } = toRefs(data);
/** 查询任务列表 */
function getList() {
loading.value = false;
getTaskList(queryParams.value).then((res) => {
tableDataList.value = res.rows;
total.value = res.total;
loading.value = false;
}).finally(() => {
loading.value = false;
});
}
//
function cancel() {
open.value = false;
reset();
}
//
function reset() {
taskRef.value.resetFields();
form.value = {};
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.value.pageNum = 1;
getList();
}
/** 重置按钮操作 */
function resetQuery() {
handleQuery();
}
/** 新增按钮操作 */
function handleAdd() {
isNew.value = true;
open.value = true;
title.value = "添加任务";
}
/** 修改按钮操作 */
function handleUpdate(row) {
form.value = { ...row };
isNew.value = false;
open.value = true;
title.value = "修改任务";
}
const taskRef = ref();
const isNew = ref(true);
/** 提交按钮 */
function submitForm() {
taskRef.value.validate((valid) => {
if (valid) {
if (isNew.value) {
addTask(form.value).then(() => {
ElMessage.success("添加成功");
cancel();
getList();
});
} else {
updateTask(form.value).then(() => {
ElMessage.success("修改成功");
cancel();
getList();
});
}
}
});
}
/** 删除按钮操作 */
function handleDelete(row) {
ElMessageBox.confirm("确定要删除该任务吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(() => {
deleteTask(row.id).then(() => {
ElMessage.success("删除成功");
getList();
});
});
}
const caseQueryParams = reactive({
pageNum: 1,
pageSize: 1000000,
caseName: null
})
/** 搜索按钮操作 */
function handleBindCaseQuery() {
queryParams.value.pageNum = 1;
getList();
}
/** 重置按钮操作 */
function resetBindCaseQuery() {
handleBindCaseQuery();
}
const taskId = ref(null);
const handleBindCase = async (row) => {
dialogVisible.value = true;
taskId.value = row.id;
getbindCaseByTaskId(row.id)
getTestCaseListData()
};
/** 查询点位列表 */
const getTestCaseListData = async () => {
const res = await getTestCaseList(caseQueryParams)
if (res.code === 200) {
let ids = []
if (taskRightList.value) {
ids = taskRightList.value.map(item => item.id)
}
taskLeftList.value = res.rows.filter(item => {
return !ids.includes(item.id)
})
}
}
const getbindCaseByTaskId = async (taskId) => {
const res = await getTestCaseByTaskId(taskId)
if (res.code === 200) {
taskRightList.value = res.data;
}
}
const taskLeftList = ref([])
const taskRightList = ref([])
const bindCase = async () => {
if (taskRightList.value.length === 0) {
ElMessage.warning("请选择用例")
return
}
const ids = taskRightList.value.map(item => item.id)
const res = await bindTastCase(taskId.value, ids)
if (res.code === 200) {
ElMessage.success("绑定用例成功")
cancelCase()
}
};
const cancelCase = () => {
dialogVisible.value = false;
};
onMounted(() => {
getList();
});
</script>

View File

@ -0,0 +1,330 @@
<template>
<div class="app-container">
<div ref="topContainerRef">
<TableSearch
:queryParams="queryParams"
:showSearch="showSearch"
label-width="90px"
queryRef="queryRef"
@refresh="resetQuery"
@search="handleQuery"
>
<template #one>
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
<el-form-item label="用例编码" prop="caseCode">
<el-input
v-model="queryParams.caseCode"
placeholder="请输入用例编码"
clearable
style="width: 240px"
@keyup.enter="handleQuery"
/>
</el-form-item>
</el-col>
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
<el-form-item label="测试步骤" prop="testSteps">
<el-input
v-model="queryParams.testSteps"
placeholder="请输入测试步骤"
clearable
style="width: 240px"
@keyup.enter="handleQuery"
/>
</el-form-item>
</el-col>
</template>
</TableSearch>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="Plus"
@click="handleAdd"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="Edit"
:disabled="single"
@click="handleUpdate"
>修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="Delete"
:disabled="multiple"
@click="handleDelete"
>删除</el-button>
</el-col>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
</div>
<div :style="containerHeight">
<!-- 表格数据 -->
<el-table height="100%" v-loading="loading" :data="tableDataList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="用例名称" prop="caseName" />
<el-table-column label="预期结果" prop="expectedResult" :show-overflow-tooltip="true" width="200" />
<el-table-column label="前置条件" prop="precondition" show-overflow-tooltip="true" width="200" />
<el-table-column label="测试步骤" align="center" prop="testSteps" show-overflow-tooltip="true" width="200"/>
<el-table-column label="测试描述" align="center" prop="testDescription" show-overflow-tooltip="true" width="200" />
<el-table-column label="状态" align="center" width="100">
<template #default="scope">
<el-switch
v-model="scope.row.enabled"
active-value="0"
inactive-value="1"
@change="handleStatusChange(scope.row)"
></el-switch>
</template>
</el-table-column>
<el-table-column align="center" label="配置状态" prop="configStatus">
<template #default="scope">
<TableTags
:options="[
{
label: '已配置',
value: '0',
type: 'success',
},
{
label: '未配置',
value: '1',
type: 'warning',
},
]"
:value="scope.row.configStatus"
></TableTags>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template #default="scope">
<el-tooltip content="修改" placement="top">
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)">修改</el-button>
</el-tooltip>
<el-button
icon="Cpu"
link
type="primary"
@click="handleDeploy(scope.row)"
>配置
</el-button>
<el-tooltip content="删除" placement="top">
<el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
</el-tooltip>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</div>
<!-- 添加或修改配置对话框 -->
<el-dialog :title="title" v-model="open" width="500px" append-to-body>
<el-form ref="testCaseRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="用例名称" prop="caseName">
<el-input v-model="form.caseName" placeholder="请输入用例名称" />
</el-form-item>
<el-form-item label="预期结果" prop="expectedResult">
<el-input type="textarea" v-model="form.expectedResult" placeholder="请输入预期结果" />
</el-form-item>
<el-form-item label="前置条件" prop="precondition">
<el-input type="textarea" v-model="form.precondition" placeholder="请输入前置条件" />
</el-form-item>
<el-form-item label="测试步骤" prop="testSteps">
<el-input type="textarea" v-model="form.testSteps" placeholder="请输入测试步骤" />
</el-form-item>
<el-form-item label="测试描述" prop="testDescription">
<el-input type="textarea" v-model="form.testDescription" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitForm"> </el-button>
<el-button @click="cancel"> </el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup name="Role">
import TableSearch from "@/components/TableSearch/index.vue";
import { useContainerHeight } from "@/hooks/tableHeight";
import { addTestCase, getTestCaseList, updateTestCase, deleteTestCase } from "@/api/intelligenceTest/testCase.js"
import { ElMessage, ElMessageBox } from 'element-plus'
import { appendParamsToPath } from "@/utils/fn.js";
import TableTags from "@/components/TableTags/index.vue";
const topContainerRef = ref();
const containerHeight = useContainerHeight(topContainerRef);
const tableDataList = ref([]);
const open = ref(false);
const loading = ref(true);
const showSearch = ref(true);
const ids = ref([]);
const single = ref(true);
const multiple = ref(true);
const total = ref(0);
const title = ref("");
const data = reactive({
form: {},
queryParams: {
pageNum: 1,
pageSize: 10,
caseName: undefined,
testSteps: undefined
},
rules: {
caseName: [{ required: true, message: "用例名称不能为空", trigger: "blur" }],
expectedResult: [{ required: true, message: "预期结果不能为空", trigger: "blur" }],
testSteps: [{ required: true, message: "测试步骤为空", trigger: "blur" }],
testDescription: [{ required: true, message: "测试描述不能为空", trigger: "blur" }]
},
});
const { queryParams, form, rules } = toRefs(data);
/** 查询列表 */
const getList = async () => {
loading.value = true;
const res = await getTestCaseList(queryParams.value)
if (res.code === 200) {
tableDataList.value = res.rows;
total.value = res.total;
}
loading.value = false;
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.value.pageNum = 1;
getList();
}
/** 重置按钮操作 */
function resetQuery() {
handleQuery();
}
/** 删除按钮操作 */
function handleDelete(row) {
const deleteIds = row.id || ids.value;
ElMessageBox.confirm(`确定要删除编号为${deleteIds}的测试用例吗?`, "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(() => {
deleteTestCase(deleteIds).then(() => {
ElMessage.success("删除成功");
getList();
});
});
}
/** 多选框选中数据 */
function handleSelectionChange(selection) {
ids.value = selection.map(item => item.id);
single.value = selection.length != 1;
multiple.value = !selection.length;
}
/** 状态修改 */
function handleStatusChange(row) {
let text = row.enabled === "0" ? "启用" : "停用";
ElMessageBox.confirm('确认要"' + text + '""' + row.caseName + '"测试用例吗?', "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(() => {
updateTestCase({
...row,
}).then(() => {
ElMessage.success(text + "成功");
getList();
});
}).catch(() => {
row.enabled = row.enabled === "0" ? "1" : "0";
})
}
const reset = () => {
testCaseRef.value.resetFields()
};
const testCaseRef = ref()
const isNew = ref(true)
/** 添加 */
function handleAdd() {
open.value = true;
isNew.value = true
title.value = "添加";
}
/** 修改 */
function handleUpdate(row) {
form.value = { ...row };
open.value = true;
isNew.value = false
title.value = "修改";
}
/** 提交按钮 */
function submitForm() {
testCaseRef.value.validate((valid) => {
if (valid) {
if (isNew.value) {
addTestCase(form.value).then(() => {
ElMessage.success("添加成功");
getList();
open.value = false;
reset();
});
} else {
updateTestCase(form.value).then(() => {
ElMessage.success("修改成功");
getList();
open.value = false;
reset();
});
}
}
});
}
/** 取消按钮 */
function cancel() {
open.value = false;
reset();
}
/** 配置按钮操作 */
function handleDeploy(row) {
const params = { itemId: row.detectItemId, name: row.caseName, state: row.status };
const fullPath = appendParamsToPath("/flow", params);
const p = window.open(fullPath, "_blank");
}
getList();
</script>