feat: 调整流程节点
This commit is contained in:
parent
6af1118a3c
commit
eebd0dc6b7
@ -7,8 +7,8 @@ VITE_APP_ENV = 'development'
|
|||||||
# 招商车研物联网平台/开发环境
|
# 招商车研物联网平台/开发环境
|
||||||
VITE_APP_BASE_API = '/dev-api'
|
VITE_APP_BASE_API = '/dev-api'
|
||||||
|
|
||||||
VITE_API_URL = http://192.168.28.10:13080
|
VITE_API_URL = http://192.168.1.101:13080
|
||||||
VITE_WS_URL = ws://192.168.0.201:13080/ws
|
VITE_WS_URL = ws://192.168.1.101:13080/ws
|
||||||
|
|
||||||
# explanation
|
# explanation
|
||||||
VITE_INSPECTION_TYPE = "inspection"
|
VITE_INSPECTION_TYPE = "inspection"
|
||||||
@ -61,5 +61,6 @@
|
|||||||
"vite-plugin-compression": "0.5.1",
|
"vite-plugin-compression": "0.5.1",
|
||||||
"vite-plugin-monaco-editor": "^1.1.0",
|
"vite-plugin-monaco-editor": "^1.1.0",
|
||||||
"vite-plugin-svg-icons": "2.0.1"
|
"vite-plugin-svg-icons": "2.0.1"
|
||||||
}
|
},
|
||||||
|
"packageManager": "pnpm@9.15.9+sha512.68046141893c66fad01c079231128e9afb89ef87e2691d69e4d40eee228988295fd4682181bae55b58418c3a253bde65a505ec7c5f9403ece5cc3cd37dcf2531"
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
461
src/adapters/URDFAdapter.js
Normal file
461
src/adapters/URDFAdapter.js
Normal file
@ -0,0 +1,461 @@
|
|||||||
|
/**
|
||||||
|
* URDF Adapter
|
||||||
|
* Converts urdf-loaders result to unified model
|
||||||
|
*/
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { UnifiedRobotModel, Link, Joint, JointLimits, VisualGeometry, CollisionGeometry, InertialProperties } from '../models/UnifiedRobotModel.js';
|
||||||
|
|
||||||
|
export class URDFAdapter {
|
||||||
|
/**
|
||||||
|
* Convert urdf-loaders robot object to unified model
|
||||||
|
* @param {THREE.Group} robot - Robot object returned by urdf-loaders
|
||||||
|
* @param {string} urdfXML - Original URDF XML content (optional, for extracting inertial data)
|
||||||
|
* @returns {UnifiedRobotModel}
|
||||||
|
*/
|
||||||
|
static convert(robot, urdfXML = null) {
|
||||||
|
const model = new UnifiedRobotModel();
|
||||||
|
model.name = robot.name || 'robot';
|
||||||
|
model.threeObject = robot;
|
||||||
|
|
||||||
|
// Mark model type as URDF
|
||||||
|
if (!robot.userData) robot.userData = {};
|
||||||
|
robot.userData.type = 'urdf';
|
||||||
|
|
||||||
|
if (!robot.links || !robot.joints) {
|
||||||
|
console.warn('URDF model missing links or joints information');
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If XML provided, parse inertial data
|
||||||
|
let inertialData = {};
|
||||||
|
if (urdfXML) {
|
||||||
|
inertialData = this.parseInertialFromXML(urdfXML);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert links
|
||||||
|
Object.values(robot.links).forEach(urdfLink => {
|
||||||
|
const link = this.convertLink(urdfLink);
|
||||||
|
|
||||||
|
// If urdf-loader didn't parse inertial, get from XML
|
||||||
|
if (!link.inertial && inertialData[urdfLink.name]) {
|
||||||
|
link.inertial = inertialData[urdfLink.name];
|
||||||
|
}
|
||||||
|
|
||||||
|
model.addLink(link);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert joints
|
||||||
|
Object.values(robot.joints).forEach(urdfJoint => {
|
||||||
|
const joint = this.convertJoint(urdfJoint);
|
||||||
|
model.addJoint(joint);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If XML provided, supplement effort and velocity from XML (urdf-loaders may not have parsed)
|
||||||
|
if (urdfXML) {
|
||||||
|
this.supplementJointLimitsFromXML(model, urdfXML);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find root link (link that is not a child of any joint)
|
||||||
|
const allChildren = new Set(
|
||||||
|
Array.from(model.joints.values()).map(j => j.child).filter(c => c)
|
||||||
|
);
|
||||||
|
const rootLinks = Array.from(model.links.keys()).filter(
|
||||||
|
name => !allChildren.has(name)
|
||||||
|
);
|
||||||
|
if (rootLinks.length > 0) {
|
||||||
|
model.rootLink = rootLinks[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhance materials for better lighting (MuJoCo style)
|
||||||
|
this.enhanceMaterials(robot);
|
||||||
|
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhance materials for better lighting (MuJoCo style)
|
||||||
|
* Applies consistent shininess and specular properties to all materials
|
||||||
|
* Directly modifies materials in place to ensure changes persist
|
||||||
|
*/
|
||||||
|
static enhanceMaterials(robotObject) {
|
||||||
|
robotObject.traverse((child) => {
|
||||||
|
if (child.isMesh && child.material) {
|
||||||
|
// Handle material arrays
|
||||||
|
if (Array.isArray(child.material)) {
|
||||||
|
child.material = child.material.map(mat => {
|
||||||
|
return this.enhanceSingleMaterial(mat);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
child.material = this.enhanceSingleMaterial(child.material);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhance a single material with better lighting properties
|
||||||
|
* Returns enhanced material (may be cloned or modified in place)
|
||||||
|
* Saves original properties for lighting toggle
|
||||||
|
*/
|
||||||
|
static enhanceSingleMaterial(material) {
|
||||||
|
if (material.isMeshPhongMaterial || material.isMeshStandardMaterial) {
|
||||||
|
// Save original properties if not already saved (for lighting toggle)
|
||||||
|
if (material.userData.originalShininess === undefined) {
|
||||||
|
material.userData.originalShininess = material.shininess !== undefined ? material.shininess : 30;
|
||||||
|
// IMPORTANT: Save original color to preserve URDF material colors
|
||||||
|
if (!material.userData.originalColor && material.color) {
|
||||||
|
material.userData.originalColor = material.color.clone();
|
||||||
|
}
|
||||||
|
// Save original specular - if material had no specular, save null
|
||||||
|
if (!material.specular) {
|
||||||
|
material.userData.originalSpecular = null;
|
||||||
|
} else if (material.specular.isColor) {
|
||||||
|
const spec = material.specular;
|
||||||
|
if (spec.r < 0.1 && spec.g < 0.1 && spec.b < 0.1) {
|
||||||
|
material.userData.originalSpecular = null; // Likely default
|
||||||
|
} else {
|
||||||
|
material.userData.originalSpecular = spec.clone();
|
||||||
|
}
|
||||||
|
} else if (typeof material.specular === 'number') {
|
||||||
|
if (material.specular === 0x111111 || material.specular < 0x111111) {
|
||||||
|
material.userData.originalSpecular = null;
|
||||||
|
} else {
|
||||||
|
material.userData.originalSpecular = new THREE.Color(material.specular);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
material.userData.originalSpecular = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMPORTANT: Preserve original color from URDF material definitions
|
||||||
|
// Do not modify material.color - urdf-loaders already sets the correct color
|
||||||
|
|
||||||
|
// Apply enhanced lighting (default enabled)
|
||||||
|
// Increase shininess for better highlights
|
||||||
|
if (material.shininess === undefined || material.shininess < 50) {
|
||||||
|
material.shininess = 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhance specular reflection - ensure it's a Color object with proper values
|
||||||
|
if (!material.specular) {
|
||||||
|
material.specular = new THREE.Color(0.3, 0.3, 0.3);
|
||||||
|
} else if (material.specular.isColor) {
|
||||||
|
// If it's already a Color object, update values
|
||||||
|
if (material.specular.r < 0.2 || material.specular.g < 0.2 || material.specular.b < 0.2) {
|
||||||
|
material.specular.setRGB(0.3, 0.3, 0.3);
|
||||||
|
}
|
||||||
|
} else if (typeof material.specular === 'number') {
|
||||||
|
// Convert number to Color object
|
||||||
|
if (material.specular < 0x333333) {
|
||||||
|
material.specular = new THREE.Color(0.3, 0.3, 0.3);
|
||||||
|
} else {
|
||||||
|
// Convert hex to Color
|
||||||
|
material.specular = new THREE.Color(material.specular);
|
||||||
|
if (material.specular.r < 0.2) {
|
||||||
|
material.specular.setRGB(0.3, 0.3, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark material as needing update
|
||||||
|
material.needsUpdate = true;
|
||||||
|
return material;
|
||||||
|
} else if (material.type === 'MeshBasicMaterial') {
|
||||||
|
// Convert MeshBasicMaterial to MeshPhongMaterial
|
||||||
|
const oldMaterial = material;
|
||||||
|
const envMap = typeof window !== 'undefined' && window.app?.sceneManager?.environmentManager?.getEnvironmentMap();
|
||||||
|
const newMaterial = new THREE.MeshPhongMaterial({
|
||||||
|
color: oldMaterial.color,
|
||||||
|
map: oldMaterial.map,
|
||||||
|
transparent: oldMaterial.transparent,
|
||||||
|
opacity: oldMaterial.opacity,
|
||||||
|
side: oldMaterial.side,
|
||||||
|
shininess: 50,
|
||||||
|
specular: new THREE.Color(0.3, 0.3, 0.3),
|
||||||
|
envMap: envMap || null,
|
||||||
|
reflectivity: envMap ? 0.3 : 0
|
||||||
|
});
|
||||||
|
// Save original properties for lighting toggle
|
||||||
|
newMaterial.userData.originalShininess = 30;
|
||||||
|
newMaterial.userData.originalSpecular = null; // MeshBasicMaterial had no specular
|
||||||
|
return newMaterial;
|
||||||
|
}
|
||||||
|
|
||||||
|
return material;
|
||||||
|
}
|
||||||
|
|
||||||
|
static convertLink(urdfLink) {
|
||||||
|
const link = new Link(urdfLink.name);
|
||||||
|
link.threeObject = urdfLink;
|
||||||
|
|
||||||
|
// Convert inertial properties
|
||||||
|
if (urdfLink.inertial) {
|
||||||
|
link.inertial = this.convertInertial(urdfLink.inertial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: urdf-loaders has already converted visual and collision to Three.js objects
|
||||||
|
// We mainly extract metadata, actual meshes are in threeObject
|
||||||
|
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
static convertJoint(urdfJoint) {
|
||||||
|
// URDF joint object has jointType property (not type)
|
||||||
|
const jointType = urdfJoint.jointType || urdfJoint.type || 'fixed';
|
||||||
|
const joint = new Joint(urdfJoint.name, jointType);
|
||||||
|
|
||||||
|
// Extract parent name (urdf-loader returns Three.js object reference)
|
||||||
|
joint.parent = urdfJoint.parent?.name || null;
|
||||||
|
|
||||||
|
// child may not be in urdfJoint.child, but in Three.js children array
|
||||||
|
// In Three.js scene graph, Joint is parent node, Child Link is Joint's child node
|
||||||
|
if (urdfJoint.child && urdfJoint.child.name) {
|
||||||
|
joint.child = urdfJoint.child.name;
|
||||||
|
} else if (urdfJoint.children && urdfJoint.children.length > 0) {
|
||||||
|
// Find Link object from children array
|
||||||
|
const childLink = urdfJoint.children.find(child =>
|
||||||
|
child.isURDFLink || child.type === 'URDFLink'
|
||||||
|
);
|
||||||
|
if (childLink) {
|
||||||
|
joint.child = childLink.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
joint.threeObject = urdfJoint;
|
||||||
|
|
||||||
|
// Convert origin
|
||||||
|
if (urdfJoint.origin) {
|
||||||
|
joint.origin = {
|
||||||
|
xyz: urdfJoint.origin.xyz || [0, 0, 0],
|
||||||
|
rpy: urdfJoint.origin.rpy || [0, 0, 0]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert axis
|
||||||
|
if (urdfJoint.axis) {
|
||||||
|
joint.axis = { xyz: urdfJoint.axis.xyz || [0, 0, 1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert limits
|
||||||
|
if (urdfJoint.limit) {
|
||||||
|
joint.limits = this.convertLimits(urdfJoint.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current value
|
||||||
|
if (urdfJoint.angle !== undefined) {
|
||||||
|
joint.currentValue = urdfJoint.angle;
|
||||||
|
} else if (urdfJoint.jointValue !== undefined) {
|
||||||
|
joint.currentValue = urdfJoint.jointValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return joint;
|
||||||
|
}
|
||||||
|
|
||||||
|
static convertLimits(limit) {
|
||||||
|
const limits = new JointLimits();
|
||||||
|
if (limit.lower !== undefined) limits.lower = limit.lower;
|
||||||
|
if (limit.upper !== undefined) limits.upper = limit.upper;
|
||||||
|
if (limit.effort !== undefined) limits.effort = limit.effort;
|
||||||
|
if (limit.velocity !== undefined) limits.velocity = limit.velocity;
|
||||||
|
return limits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supplement joint effort and velocity information from URDF XML
|
||||||
|
* (because urdf-loaders may not have parsed these attributes)
|
||||||
|
*/
|
||||||
|
static supplementJointLimitsFromXML(model, urdfXML) {
|
||||||
|
try {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(urdfXML, 'text/xml');
|
||||||
|
|
||||||
|
const jointElements = doc.querySelectorAll('joint');
|
||||||
|
jointElements.forEach(jointEl => {
|
||||||
|
const jointName = jointEl.getAttribute('name');
|
||||||
|
const joint = model.joints.get(jointName);
|
||||||
|
|
||||||
|
if (joint) {
|
||||||
|
const limitEl = jointEl.querySelector('limit');
|
||||||
|
if (limitEl) {
|
||||||
|
if (!joint.limits) {
|
||||||
|
joint.limits = new JointLimits();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read effort and velocity
|
||||||
|
const effort = limitEl.getAttribute('effort');
|
||||||
|
const velocity = limitEl.getAttribute('velocity');
|
||||||
|
|
||||||
|
if (effort !== null) {
|
||||||
|
joint.limits.effort = parseFloat(effort);
|
||||||
|
}
|
||||||
|
if (velocity !== null) {
|
||||||
|
joint.limits.velocity = parseFloat(velocity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also ensure lower and upper are correctly set
|
||||||
|
const lower = limitEl.getAttribute('lower');
|
||||||
|
const upper = limitEl.getAttribute('upper');
|
||||||
|
if (lower !== null) {
|
||||||
|
joint.limits.lower = parseFloat(lower);
|
||||||
|
}
|
||||||
|
if (upper !== null) {
|
||||||
|
joint.limits.upper = parseFloat(upper);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to supplement joint limit information from XML:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static convertInertial(inertial) {
|
||||||
|
const props = new InertialProperties();
|
||||||
|
if (inertial.mass !== undefined) props.mass = inertial.mass;
|
||||||
|
if (inertial.origin) {
|
||||||
|
props.origin = {
|
||||||
|
xyz: inertial.origin.xyz || [0, 0, 0],
|
||||||
|
rpy: inertial.origin.rpy || [0, 0, 0]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (inertial.ixx !== undefined) props.ixx = inertial.ixx;
|
||||||
|
if (inertial.iyy !== undefined) props.iyy = inertial.iyy;
|
||||||
|
if (inertial.izz !== undefined) props.izz = inertial.izz;
|
||||||
|
if (inertial.ixy !== undefined) props.ixy = inertial.ixy;
|
||||||
|
if (inertial.ixz !== undefined) props.ixz = inertial.ixz;
|
||||||
|
if (inertial.iyz !== undefined) props.iyz = inertial.iyz;
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse inertial data from URDF XML
|
||||||
|
* @param {string} xmlContent - URDF XML content
|
||||||
|
* @returns {Object} Mapping from link names to inertial data
|
||||||
|
*/
|
||||||
|
static parseInertialFromXML(xmlContent) {
|
||||||
|
const inertialData = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const xmlDoc = parser.parseFromString(xmlContent, 'text/xml');
|
||||||
|
|
||||||
|
// Get all link elements
|
||||||
|
const links = xmlDoc.getElementsByTagName('link');
|
||||||
|
|
||||||
|
for (let i = 0; i < links.length; i++) {
|
||||||
|
const linkElement = links[i];
|
||||||
|
const linkName = linkElement.getAttribute('name');
|
||||||
|
|
||||||
|
// Find inertial element
|
||||||
|
const inertialElement = linkElement.getElementsByTagName('inertial')[0];
|
||||||
|
if (!inertialElement) continue;
|
||||||
|
|
||||||
|
const inertial = new InertialProperties();
|
||||||
|
|
||||||
|
// Parse mass
|
||||||
|
const massElement = inertialElement.getElementsByTagName('mass')[0];
|
||||||
|
if (massElement) {
|
||||||
|
inertial.mass = parseFloat(massElement.getAttribute('value'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse origin
|
||||||
|
const originElement = inertialElement.getElementsByTagName('origin')[0];
|
||||||
|
if (originElement) {
|
||||||
|
const xyz = originElement.getAttribute('xyz');
|
||||||
|
const rpy = originElement.getAttribute('rpy');
|
||||||
|
inertial.origin = {
|
||||||
|
xyz: xyz ? xyz.split(' ').map(parseFloat) : [0, 0, 0],
|
||||||
|
rpy: rpy ? rpy.split(' ').map(parseFloat) : [0, 0, 0]
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
inertial.origin = { xyz: [0, 0, 0], rpy: [0, 0, 0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse inertia
|
||||||
|
const inertiaElement = inertialElement.getElementsByTagName('inertia')[0];
|
||||||
|
if (inertiaElement) {
|
||||||
|
inertial.ixx = parseFloat(inertiaElement.getAttribute('ixx')) || 0;
|
||||||
|
inertial.iyy = parseFloat(inertiaElement.getAttribute('iyy')) || 0;
|
||||||
|
inertial.izz = parseFloat(inertiaElement.getAttribute('izz')) || 0;
|
||||||
|
inertial.ixy = parseFloat(inertiaElement.getAttribute('ixy')) || 0;
|
||||||
|
inertial.ixz = parseFloat(inertiaElement.getAttribute('ixz')) || 0;
|
||||||
|
inertial.iyz = parseFloat(inertiaElement.getAttribute('iyz')) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
inertialData[linkName] = inertial;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse URDF XML inertial data:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return inertialData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set joint angle (using urdf-loaders' setJointValue method)
|
||||||
|
* Reference URDFClasses.js setJointValue implementation
|
||||||
|
*/
|
||||||
|
static setJointAngle(joint, angle, ignoreLimits = false) {
|
||||||
|
joint.currentValue = angle;
|
||||||
|
|
||||||
|
// URDF format: use urdf-loader's setJointValue method
|
||||||
|
if (joint.threeObject) {
|
||||||
|
// If ignoring limits, temporarily modify URDF joint object's limit values
|
||||||
|
let originalLimits = null;
|
||||||
|
if (ignoreLimits && joint.threeObject.limit) {
|
||||||
|
originalLimits = {
|
||||||
|
lower: joint.threeObject.limit.lower,
|
||||||
|
upper: joint.threeObject.limit.upper
|
||||||
|
};
|
||||||
|
joint.threeObject.limit.lower = -Math.PI * 2;
|
||||||
|
joint.threeObject.limit.upper = Math.PI * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer setJointValue method (urdf-loader's standard method)
|
||||||
|
if (typeof joint.threeObject.setJointValue === 'function') {
|
||||||
|
joint.threeObject.setJointValue(angle);
|
||||||
|
|
||||||
|
// Restore original limits
|
||||||
|
if (originalLimits && joint.threeObject.limit) {
|
||||||
|
joint.threeObject.limit.lower = originalLimits.lower;
|
||||||
|
joint.threeObject.limit.upper = originalLimits.upper;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else if (typeof joint.threeObject.setAngle === 'function') {
|
||||||
|
joint.threeObject.setAngle(angle);
|
||||||
|
|
||||||
|
// Restore original limits
|
||||||
|
if (originalLimits && joint.threeObject.limit) {
|
||||||
|
joint.threeObject.limit.lower = originalLimits.lower;
|
||||||
|
joint.threeObject.limit.upper = originalLimits.upper;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore original limits (if none of the above methods executed)
|
||||||
|
if (originalLimits && joint.threeObject.limit) {
|
||||||
|
joint.threeObject.limit.lower = originalLimits.lower;
|
||||||
|
joint.threeObject.limit.upper = originalLimits.upper;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If none available, manually set rotation
|
||||||
|
// Note: For URDF, we usually shouldn't reach here, as urdf-loader should provide setJointValue
|
||||||
|
console.warn(`Joint ${joint.name} has no setJointValue or setAngle method, attempting manual setup`);
|
||||||
|
if (joint.type === 'revolute' || joint.type === 'continuous') {
|
||||||
|
const axis = joint.axis ? new THREE.Vector3(...joint.axis.xyz).normalize() : new THREE.Vector3(0, 0, 1);
|
||||||
|
// Note: joint.threeObject may not be a Three.js object, cannot directly set rotation
|
||||||
|
if (joint.threeObject.rotation) {
|
||||||
|
joint.threeObject.rotation.setFromAxisAngle(axis, angle);
|
||||||
|
}
|
||||||
|
} else if (joint.type === 'prismatic') {
|
||||||
|
const axis = joint.axis ? new THREE.Vector3(...joint.axis.xyz).normalize() : new THREE.Vector3(1, 0, 0);
|
||||||
|
if (joint.threeObject.position) {
|
||||||
|
joint.threeObject.position.copy(axis.multiplyScalar(angle));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`Joint ${joint.name} has no threeObject`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
614
src/adapters/XacroAdapter.js
Normal file
614
src/adapters/XacroAdapter.js
Normal file
@ -0,0 +1,614 @@
|
|||||||
|
/**
|
||||||
|
* Xacro Adapter
|
||||||
|
*
|
||||||
|
* Parses ROS Xacro files and converts them to URDF format using xacro-parser.
|
||||||
|
*
|
||||||
|
* Supported Features:
|
||||||
|
* - Property definitions and substitutions (xacro:property)
|
||||||
|
* - Macro definitions with parameters (xacro:macro)
|
||||||
|
* - Conditional blocks (xacro:if, xacro:unless)
|
||||||
|
* - File inclusions (xacro:include) with package:// and relative paths
|
||||||
|
* - Python-style boolean constants (True/False)
|
||||||
|
* - Arithmetic expressions and property evaluation
|
||||||
|
*
|
||||||
|
* Compatibility:
|
||||||
|
* - ROS Jade and later (inOrder=true, requirePrefix=true, localProperties=true)
|
||||||
|
* - Automatically injects True/False constants for compatibility with ROS xacro files
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* - Upload all related xacro files (main file and included files)
|
||||||
|
* - The adapter automatically resolves file inclusions from the uploaded file map
|
||||||
|
* - Mesh files are resolved using package:// paths or relative paths
|
||||||
|
*/
|
||||||
|
import { XacroParser } from 'xacro-parser';
|
||||||
|
import { URDFAdapter } from './URDFAdapter.js';
|
||||||
|
|
||||||
|
export class XacroAdapter {
|
||||||
|
/**
|
||||||
|
* Parse xacro content and convert to unified model
|
||||||
|
* @param {string} xacroContent - Xacro file content
|
||||||
|
* @param {string} fileName - Xacro file name (for working path)
|
||||||
|
* @param {Map} fileMap - File map (path -> File object)
|
||||||
|
* @param {File} file - Original file object (optional)
|
||||||
|
* @returns {Promise<UnifiedRobotModel>}
|
||||||
|
*/
|
||||||
|
static async parse(xacroContent, fileName, fileMap = null, file = null) {
|
||||||
|
try {
|
||||||
|
// Create xacro parser
|
||||||
|
const parser = new XacroParser();
|
||||||
|
|
||||||
|
// Configure parser for ROS Jade and later (default settings)
|
||||||
|
parser.inOrder = true;
|
||||||
|
parser.requirePrefix = true;
|
||||||
|
parser.localProperties = true;
|
||||||
|
|
||||||
|
// Set working path (directory where xacro file is located)
|
||||||
|
const workingPath = fileName.includes('/')
|
||||||
|
? fileName.substring(0, fileName.lastIndexOf('/') + 1)
|
||||||
|
: '';
|
||||||
|
parser.workingPath = workingPath;
|
||||||
|
|
||||||
|
// Inject Python-style boolean constants as xacro properties
|
||||||
|
// Some xacro files use True/False (capitalized) in conditions
|
||||||
|
xacroContent = this.injectBooleanConstants(xacroContent);
|
||||||
|
|
||||||
|
// Extract and set xacro arguments with their default values
|
||||||
|
// Also add common ROS arguments that might be used without definition
|
||||||
|
const xacroArgs = this.extractXacroArguments(xacroContent);
|
||||||
|
|
||||||
|
// Add common ROS arguments with sensible defaults if not already defined
|
||||||
|
if (!xacroArgs.hasOwnProperty('DEBUG')) {
|
||||||
|
xacroArgs.DEBUG = 'false';
|
||||||
|
}
|
||||||
|
if (!xacroArgs.hasOwnProperty('SELF_COLLIDE')) {
|
||||||
|
xacroArgs.SELF_COLLIDE = 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
parser.arguments = xacroArgs;
|
||||||
|
|
||||||
|
// If fileMap provided, setup custom file loader
|
||||||
|
if (fileMap) {
|
||||||
|
parser.getFileContents = async (path) => {
|
||||||
|
return await this.loadFileFromMap(path, fileMap, workingPath);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse xacro to URDF XML
|
||||||
|
const urdfXML = await parser.parse(xacroContent);
|
||||||
|
|
||||||
|
// Convert XMLDocument to string
|
||||||
|
const serializer = new XMLSerializer();
|
||||||
|
let urdfString = serializer.serializeToString(urdfXML);
|
||||||
|
|
||||||
|
// Clean up the XML string - remove empty xmlns attributes that might cause issues
|
||||||
|
urdfString = urdfString.replace(/\sxmlns=""/g, '');
|
||||||
|
|
||||||
|
// Re-parse the URDF string with DOMParser to create a clean XMLDocument
|
||||||
|
// This removes any xacro-specific nodes that urdf-loader might not handle
|
||||||
|
const domParser = new DOMParser();
|
||||||
|
const cleanUrdfXML = domParser.parseFromString(urdfString, 'text/xml');
|
||||||
|
|
||||||
|
// Check for parsing errors
|
||||||
|
const parseError = cleanUrdfXML.querySelector('parsererror');
|
||||||
|
if (parseError) {
|
||||||
|
console.error('[XacroAdapter] XML parsing error:', parseError.textContent);
|
||||||
|
throw new Error('Generated URDF has invalid XML: ' + parseError.textContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove problematic elements that urdf-loader might not handle well
|
||||||
|
// Remove gazebo elements (plugins, sensors, etc.)
|
||||||
|
const gazeboElements = cleanUrdfXML.querySelectorAll('gazebo');
|
||||||
|
gazeboElements.forEach(el => el.parentNode?.removeChild(el));
|
||||||
|
|
||||||
|
// Remove transmission elements (these are for Gazebo/ROS control)
|
||||||
|
const transmissionElements = cleanUrdfXML.querySelectorAll('transmission');
|
||||||
|
transmissionElements.forEach(el => el.parentNode?.removeChild(el));
|
||||||
|
|
||||||
|
// Remove visual/collision elements with empty geometry tags
|
||||||
|
this.removeEmptyGeometry(cleanUrdfXML);
|
||||||
|
|
||||||
|
// Clean up empty text nodes and comments that might cause issues
|
||||||
|
this.cleanXMLNodes(cleanUrdfXML.documentElement);
|
||||||
|
|
||||||
|
// Convert the clean XMLDocument back to string for URDFLoader
|
||||||
|
const finalUrdfString = serializer.serializeToString(cleanUrdfXML);
|
||||||
|
|
||||||
|
// Now use existing URDF loading infrastructure
|
||||||
|
// Import URDFLoader dynamically
|
||||||
|
const urdfModule = await import('urdf-loader');
|
||||||
|
const URDFLoader = urdfModule.URDFLoader || urdfModule.default || urdfModule;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const loader = new URDFLoader();
|
||||||
|
loader.parseCollision = true;
|
||||||
|
|
||||||
|
// Extract directory where URDF file is located
|
||||||
|
const urdfDir = workingPath;
|
||||||
|
|
||||||
|
// If file map provided, setup resource loader (same as URDF loading)
|
||||||
|
if (fileMap) {
|
||||||
|
// Parse URDF content, find all used package names
|
||||||
|
const packages = this.extractPackagesFromURDF(finalUrdfString);
|
||||||
|
|
||||||
|
// Build package map
|
||||||
|
const packageMap = {};
|
||||||
|
packages.forEach(pkg => {
|
||||||
|
packageMap[pkg] = pkg;
|
||||||
|
});
|
||||||
|
packageMap[''] = '';
|
||||||
|
|
||||||
|
loader.packages = packageMap;
|
||||||
|
|
||||||
|
// Set URL Modifier (same as URDF loading)
|
||||||
|
const urlModifier = (url) => {
|
||||||
|
// Handle blob URLs
|
||||||
|
if (url.startsWith('blob:')) {
|
||||||
|
const blobMatch = url.match(/^blob:https?:\/\/[^\/]+\/(.+)$/);
|
||||||
|
if (blobMatch && blobMatch[1]) {
|
||||||
|
const fileName = blobMatch[1];
|
||||||
|
if (/\.(jpg|jpeg|png|gif|bmp|tga|tiff|webp|dae|stl|obj|gltf|glb)$/i.test(fileName)) {
|
||||||
|
url = fileName;
|
||||||
|
} else {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTextureFile = /\.(jpg|jpeg|png|gif|bmp|tga|tiff|webp)$/i.test(url);
|
||||||
|
const isMeshFile = /\.(dae|stl|obj|gltf|glb)$/i.test(url);
|
||||||
|
|
||||||
|
let meshPath = url;
|
||||||
|
|
||||||
|
// Remove http:// or https:// prefix
|
||||||
|
if (meshPath.startsWith('http://') || meshPath.startsWith('https://')) {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(meshPath);
|
||||||
|
meshPath = urlObj.pathname;
|
||||||
|
if (meshPath.startsWith('/')) {
|
||||||
|
meshPath = meshPath.substring(1);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Invalid URL, use as-is
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove package:// prefix
|
||||||
|
if (meshPath.startsWith('package://')) {
|
||||||
|
meshPath = meshPath.replace(/^package:\/\//, '');
|
||||||
|
const parts = meshPath.split('/');
|
||||||
|
if (parts.length > 1) {
|
||||||
|
meshPath = parts.slice(1).join('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove leading ./
|
||||||
|
meshPath = meshPath.replace(/^\.\//, '');
|
||||||
|
|
||||||
|
// Handle relative paths
|
||||||
|
let normalizedPath = meshPath;
|
||||||
|
if (meshPath.includes('../')) {
|
||||||
|
const parts = meshPath.split('/');
|
||||||
|
const resolvedParts = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part === '..') {
|
||||||
|
resolvedParts.pop();
|
||||||
|
} else if (part !== '.' && part !== '') {
|
||||||
|
resolvedParts.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalizedPath = resolvedParts.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build full path
|
||||||
|
const fullPath = urdfDir + normalizedPath;
|
||||||
|
const altPath = urdfDir + meshPath;
|
||||||
|
|
||||||
|
// Find file in fileMap
|
||||||
|
let matchedFile = fileMap.get(fullPath);
|
||||||
|
|
||||||
|
if (!matchedFile && altPath !== fullPath) {
|
||||||
|
matchedFile = fileMap.get(altPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchedFile) {
|
||||||
|
matchedFile = fileMap.get(normalizedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchedFile) {
|
||||||
|
matchedFile = fileMap.get(meshPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchedFile) {
|
||||||
|
const targetFileName = normalizedPath.split('/').pop() || meshPath.split('/').pop();
|
||||||
|
for (const [key, file] of fileMap.entries()) {
|
||||||
|
const keyFileName = key.split('/').pop();
|
||||||
|
if (keyFileName === targetFileName) {
|
||||||
|
matchedFile = file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedFile) {
|
||||||
|
const bloburl = URL.createObjectURL(matchedFile);
|
||||||
|
return bloburl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
loader.manager.setURLModifier(urlModifier);
|
||||||
|
|
||||||
|
// Custom loadMeshCb (same as URDF loading)
|
||||||
|
const originalLoadMeshCb = loader.loadMeshCb || loader.defaultMeshLoader.bind(loader);
|
||||||
|
loader.loadMeshCb = (path, manager, done) => {
|
||||||
|
this.findFileInMapByPath(path, fileMap, urdfDir).then(file => {
|
||||||
|
if (file) {
|
||||||
|
const ext = (file.name || path).toLowerCase().split('.').pop();
|
||||||
|
this.loadMeshFileAsync(file, ext, manager).then(meshObject => {
|
||||||
|
if (meshObject) {
|
||||||
|
done(meshObject, null);
|
||||||
|
} else {
|
||||||
|
done(null, new Error(`Failed to load mesh file: ${path}`));
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(`Failed to load mesh: ${path}`, err);
|
||||||
|
done(null, err);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
originalLoadMeshCb(path, manager, done);
|
||||||
|
}
|
||||||
|
}).catch(error => {
|
||||||
|
console.error(`Failed to find file: ${path}`, error);
|
||||||
|
originalLoadMeshCb(path, manager, done);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temporary URL for URDF content
|
||||||
|
const blob = new Blob([finalUrdfString], { type: 'text/xml' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
loader.load(url, (robot) => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
// Convert to unified model using URDFAdapter
|
||||||
|
try {
|
||||||
|
const model = URDFAdapter.convert(robot, finalUrdfString);
|
||||||
|
resolve(model);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[XacroAdapter] URDF conversion error:', error);
|
||||||
|
reject(new Error('URDF conversion failed: ' + error.message));
|
||||||
|
}
|
||||||
|
}, undefined, (error) => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
console.error('[XacroAdapter] URDF loading error:', error);
|
||||||
|
reject(new Error('URDF loading failed: ' + (error.message || error)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[XacroAdapter] Xacro parsing error:', error);
|
||||||
|
throw new Error('Xacro parsing failed: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load file from fileMap for xacro includes
|
||||||
|
* @param {string} path - File path
|
||||||
|
* @param {Map} fileMap - File map
|
||||||
|
* @param {string} workingPath - Working directory
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
static async loadFileFromMap(path, fileMap, workingPath) {
|
||||||
|
// Clean path - remove leading slash if present
|
||||||
|
let cleanPath = path;
|
||||||
|
if (cleanPath.startsWith('/')) {
|
||||||
|
cleanPath = cleanPath.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove leading ./
|
||||||
|
cleanPath = cleanPath.replace(/^\.\//, '');
|
||||||
|
|
||||||
|
// Try different path combinations
|
||||||
|
const possiblePaths = [
|
||||||
|
cleanPath,
|
||||||
|
workingPath + cleanPath,
|
||||||
|
path,
|
||||||
|
workingPath + path.replace(/^\/+/, ''),
|
||||||
|
// Also try without the first directory component
|
||||||
|
cleanPath.includes('/') ? cleanPath.substring(cleanPath.indexOf('/') + 1) : cleanPath,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Try each path
|
||||||
|
for (const tryPath of possiblePaths) {
|
||||||
|
const file = fileMap.get(tryPath);
|
||||||
|
if (file) {
|
||||||
|
const content = await file.text();
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try filename only match
|
||||||
|
const fileName = cleanPath.split('/').pop();
|
||||||
|
for (const [key, file] of fileMap.entries()) {
|
||||||
|
const keyFileName = key.split('/').pop();
|
||||||
|
if (keyFileName === fileName) {
|
||||||
|
const content = await file.text();
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('[XacroAdapter] Cannot find included file:', path);
|
||||||
|
throw new Error(`Cannot find included file: ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove visual/collision elements with empty geometry tags
|
||||||
|
* xacro-parser may not fully expand conditional geometry (xacro:if)
|
||||||
|
* We'll just remove these empty visual elements rather than try to fix them
|
||||||
|
* @param {XMLDocument} xmlDoc - XML document to clean
|
||||||
|
*/
|
||||||
|
static removeEmptyGeometry(xmlDoc) {
|
||||||
|
// Find all geometry elements
|
||||||
|
const geometryElements = xmlDoc.querySelectorAll('geometry');
|
||||||
|
|
||||||
|
geometryElements.forEach(geom => {
|
||||||
|
// Check if geometry is empty (no child elements)
|
||||||
|
if (!geom.children || geom.children.length === 0) {
|
||||||
|
// Find the parent visual or collision element
|
||||||
|
let parent = geom.parentNode;
|
||||||
|
if (parent) {
|
||||||
|
const grandParent = parent.parentNode;
|
||||||
|
if (grandParent) {
|
||||||
|
if (parent.nodeName === 'visual' || parent.nodeName === 'collision') {
|
||||||
|
// Remove empty visual/collision elements
|
||||||
|
grandParent.removeChild(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively clean XML nodes - remove empty text nodes and comments
|
||||||
|
* @param {Element} element - Element to clean
|
||||||
|
*/
|
||||||
|
static cleanXMLNodes(element) {
|
||||||
|
if (!element || !element.childNodes) return;
|
||||||
|
|
||||||
|
const nodesToRemove = [];
|
||||||
|
|
||||||
|
// Collect nodes to remove
|
||||||
|
for (let i = 0; i < element.childNodes.length; i++) {
|
||||||
|
const node = element.childNodes[i];
|
||||||
|
|
||||||
|
// Remove comments
|
||||||
|
if (node.nodeType === Node.COMMENT_NODE) {
|
||||||
|
nodesToRemove.push(node);
|
||||||
|
}
|
||||||
|
// Remove empty or whitespace-only text nodes
|
||||||
|
else if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
if (!node.nodeValue || node.nodeValue.trim() === '') {
|
||||||
|
nodesToRemove.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Recursively clean element nodes
|
||||||
|
else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
this.cleanXMLNodes(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove collected nodes
|
||||||
|
nodesToRemove.forEach(node => {
|
||||||
|
element.removeChild(node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract package names from URDF content
|
||||||
|
* @param {string} urdfContent - URDF content
|
||||||
|
* @returns {Set<string>}
|
||||||
|
*/
|
||||||
|
static extractPackagesFromURDF(urdfContent) {
|
||||||
|
const packages = new Set();
|
||||||
|
const packageRegex = /package:\/\/([^\/]+)/g;
|
||||||
|
let match;
|
||||||
|
while ((match = packageRegex.exec(urdfContent)) !== null) {
|
||||||
|
packages.add(match[1]);
|
||||||
|
}
|
||||||
|
return packages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find file in fileMap by path
|
||||||
|
* @param {string} path - File path
|
||||||
|
* @param {Map} fileMap - File map
|
||||||
|
* @param {string} urdfDir - URDF directory
|
||||||
|
* @returns {Promise<File|null>}
|
||||||
|
*/
|
||||||
|
static async findFileInMapByPath(path, fileMap, urdfDir) {
|
||||||
|
let meshPath = path;
|
||||||
|
|
||||||
|
// Remove blob: prefix
|
||||||
|
meshPath = meshPath.replace(/^blob:[^\/]+\//, '');
|
||||||
|
|
||||||
|
// Remove package:// prefix
|
||||||
|
if (meshPath.startsWith('package://')) {
|
||||||
|
meshPath = meshPath.replace(/^package:\/\//, '');
|
||||||
|
const parts = meshPath.split('/');
|
||||||
|
if (parts.length > 1) {
|
||||||
|
meshPath = parts.slice(1).join('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove leading ./
|
||||||
|
meshPath = meshPath.replace(/^\.\//, '');
|
||||||
|
|
||||||
|
// Build full path
|
||||||
|
const fullPath = urdfDir + meshPath;
|
||||||
|
|
||||||
|
// Try full path
|
||||||
|
let file = fileMap.get(fullPath);
|
||||||
|
if (file) return file;
|
||||||
|
|
||||||
|
// Try path without directory
|
||||||
|
file = fileMap.get(meshPath);
|
||||||
|
if (file) return file;
|
||||||
|
|
||||||
|
// Try filename match
|
||||||
|
const targetFileName = meshPath.split('/').pop();
|
||||||
|
for (const [key, f] of fileMap.entries()) {
|
||||||
|
const keyFileName = key.split('/').pop();
|
||||||
|
if (keyFileName === targetFileName) {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load mesh file asynchronously
|
||||||
|
* @param {File} file - File object
|
||||||
|
* @param {string} ext - File extension
|
||||||
|
* @param {THREE.LoadingManager} manager - Loading manager
|
||||||
|
* @returns {Promise<THREE.Object3D>}
|
||||||
|
*/
|
||||||
|
static async loadMeshFileAsync(file, ext, manager) {
|
||||||
|
const THREE = await import('three');
|
||||||
|
const blobUrl = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let meshObject = null;
|
||||||
|
|
||||||
|
switch (ext) {
|
||||||
|
case 'stl': {
|
||||||
|
const { STLLoader } = await import('three/examples/jsm/loaders/STLLoader.js');
|
||||||
|
const stlLoader = new STLLoader(manager);
|
||||||
|
const stlGeometry = await new Promise((resolve, reject) => {
|
||||||
|
stlLoader.load(blobUrl, resolve, undefined, reject);
|
||||||
|
});
|
||||||
|
const stlMaterial = new THREE.MeshPhongMaterial();
|
||||||
|
meshObject = new THREE.Mesh(stlGeometry, stlMaterial);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'dae': {
|
||||||
|
const { ColladaLoader } = await import('three/examples/jsm/loaders/ColladaLoader.js');
|
||||||
|
const colladaLoader = new ColladaLoader(manager);
|
||||||
|
const colladaModel = await new Promise((resolve, reject) => {
|
||||||
|
colladaLoader.load(blobUrl, resolve, undefined, reject);
|
||||||
|
});
|
||||||
|
meshObject = colladaModel.scene;
|
||||||
|
|
||||||
|
// Remove lights
|
||||||
|
if (meshObject && meshObject.traverse) {
|
||||||
|
const lightsToRemove = [];
|
||||||
|
meshObject.traverse(child => {
|
||||||
|
if (child.isLight) {
|
||||||
|
lightsToRemove.push(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
lightsToRemove.forEach(light => {
|
||||||
|
if (light.parent) {
|
||||||
|
light.parent.remove(light);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'obj': {
|
||||||
|
const { OBJLoader } = await import('three/examples/jsm/loaders/OBJLoader.js');
|
||||||
|
const objLoader = new OBJLoader(manager);
|
||||||
|
meshObject = await new Promise((resolve, reject) => {
|
||||||
|
objLoader.load(blobUrl, resolve, undefined, reject);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'gltf':
|
||||||
|
case 'glb': {
|
||||||
|
const { GLTFLoader } = await import('three/examples/jsm/loaders/GLTFLoader.js');
|
||||||
|
const gltfLoader = new GLTFLoader(manager);
|
||||||
|
const gltfModel = await new Promise((resolve, reject) => {
|
||||||
|
gltfLoader.load(blobUrl, resolve, undefined, reject);
|
||||||
|
});
|
||||||
|
meshObject = gltfModel.scene;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(`Unsupported file format: ${ext}`);
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
return meshObject;
|
||||||
|
} catch (error) {
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
console.error(`Failed to load mesh file: ${file.name}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject Python-style boolean constants (True/False) into xacro content
|
||||||
|
* Some xacro files use capitalized True/False in conditional expressions
|
||||||
|
* The parameters are passed as strings ("True", "False") but compared to constants
|
||||||
|
* We define the constants to match the string values for comparison to work
|
||||||
|
* @param {string} xacroContent - Original xacro content
|
||||||
|
* @returns {string} - Modified xacro content with boolean constants defined
|
||||||
|
*/
|
||||||
|
static injectBooleanConstants(xacroContent) {
|
||||||
|
// Find the robot tag opening
|
||||||
|
const robotTagMatch = xacroContent.match(/(<robot[^>]*>)/);
|
||||||
|
if (!robotTagMatch) {
|
||||||
|
return xacroContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define True/False as string properties for comparison
|
||||||
|
// When mirror_dae="True" is passed, it becomes the string "True"
|
||||||
|
// We want ${mirror_dae == True} to work, so True must also be "True"
|
||||||
|
const booleanProperties = `
|
||||||
|
<!-- Injected by XacroAdapter: Python-style boolean constants -->
|
||||||
|
<xacro:property name="True" value="True"/>
|
||||||
|
<xacro:property name="False" value="False"/>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const insertPosition = robotTagMatch.index + robotTagMatch[0].length;
|
||||||
|
return xacroContent.substring(0, insertPosition) +
|
||||||
|
booleanProperties +
|
||||||
|
xacroContent.substring(insertPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract xacro arguments and their default values from xacro content
|
||||||
|
* Xacro files can define arguments using <xacro:arg name="..." default="..."/>
|
||||||
|
* These need to be provided to the parser via parser.arguments
|
||||||
|
* @param {string} xacroContent - Xacro file content
|
||||||
|
* @returns {Object} - Object with argument names as keys and default values
|
||||||
|
*/
|
||||||
|
static extractXacroArguments(xacroContent) {
|
||||||
|
const args = {};
|
||||||
|
|
||||||
|
// Match <xacro:arg name="NAME" default="VALUE"/>
|
||||||
|
// Also match <arg name="NAME" default="VALUE"/> (without xacro: prefix)
|
||||||
|
const argPattern = /<(?:xacro:)?arg\s+name=["']([^"']+)["'](?:\s+default=["']([^"']*)["'])?/g;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = argPattern.exec(xacroContent)) !== null) {
|
||||||
|
const argName = match[1];
|
||||||
|
const defaultValue = match[2] || 'false'; // Default to 'false' if no default specified
|
||||||
|
args[argName] = defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -174,7 +174,6 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
console.log(34566)
|
|
||||||
emitter.off("deleteNode");
|
emitter.off("deleteNode");
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
348
src/views/flow/components/params/BasicNodeParams.vue
Normal file
348
src/views/flow/components/params/BasicNodeParams.vue
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
<template>
|
||||||
|
<el-collapse v-model="activeNames">
|
||||||
|
<!-- 输入参数 -->
|
||||||
|
<el-collapse-item name="1" icon-position="left">
|
||||||
|
<template #title>
|
||||||
|
输入参数
|
||||||
|
<el-button :circle="true" size="small" @click="(e) => addFormItem(e, 'nodeParams')" class="addFormItem">
|
||||||
|
<el-icon :size="14">
|
||||||
|
<Plus />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<div class="form__container">
|
||||||
|
<el-button type="primary" v-if="['AGV_MOVE_TO_POINT', 'ALM'].includes(props.data.properties.action)" size="small" @click="openRobotForm(props.data.properties.action)">获取位置</el-button>
|
||||||
|
<el-form :inline="true" :model="formData" :rules="rules" ref="dynamicFormRef" label-position="top"
|
||||||
|
label-width="auto">
|
||||||
|
<div v-for="(property, index) in formData.nodeParams" :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, formData, property.type)">
|
||||||
|
<el-option label="引用" value="quote" />
|
||||||
|
<el-option label="输入" value="input" />
|
||||||
|
</el-select>
|
||||||
|
<el-form-item v-if="property.type === 'input'"
|
||||||
|
:rules="testRule(property)"
|
||||||
|
: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, formData, 'nodeParams')" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form-item>
|
||||||
|
<el-button :icon="Minus" circle size="small" :disabled="property?.disabled || false"
|
||||||
|
@click="handleDelete(index)" class="deleteBtn" />
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</el-collapse-item>
|
||||||
|
|
||||||
|
<!-- 输出参数 -->
|
||||||
|
<el-collapse-item name="2" icon-position="left">
|
||||||
|
<template #title>
|
||||||
|
输出参数
|
||||||
|
<el-button :circle="true" size="small" @click="(e) => addFormItem(e, 'outputParams')"
|
||||||
|
class="addFormItem">
|
||||||
|
<el-icon :size="14">
|
||||||
|
<Plus />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<div class="form__container">
|
||||||
|
<el-form :inline="true" :model="formData" label-position="top" label-width="auto" :rules="outputRules"
|
||||||
|
ref="outputFormRef">
|
||||||
|
<FormItemRecursive formType="output" :current-list="formData.outputParams" prop-path="outputParams"
|
||||||
|
:depth="0" :is-first-level="true" :endDepth="2" :parent-path="[]"
|
||||||
|
@delete-item="(path) => deleteTopLevelItem(path, 'outputParams')" />
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
title="获取位置信息"
|
||||||
|
width="360"
|
||||||
|
>
|
||||||
|
<el-form ref="robotFormRef" :model="robotForm" :rules="robotFormRules" label-width="auto">
|
||||||
|
<el-form-item label="终端ID" prop="terminalId">
|
||||||
|
<el-input v-model="robotForm.terminalId" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="设备ID" prop="deviceId">
|
||||||
|
<el-input v-model="robotForm.deviceId" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitRobotForm">确认</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch, nextTick } from 'vue'
|
||||||
|
import { Plus, Minus } from '@element-plus/icons-vue'
|
||||||
|
import FormItemRecursive from '../FormItemRecursive.vue'
|
||||||
|
import { getInput } from '@/utils/flow'
|
||||||
|
import { useQuote } from './useQuote.js'
|
||||||
|
import { getArmStatus } from '@/api/device/flow'
|
||||||
|
import { de } from 'element-plus/es/locale/index.mjs'
|
||||||
|
|
||||||
|
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 handleDelete = (index) => {
|
||||||
|
formData.nodeParams.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除顶层输出参数(递归删除通过 FormItemRecursive 的 emit 处理)
|
||||||
|
const deleteTopLevelItem = (fullPath, propPath) => {
|
||||||
|
let currentLevel = formData[propPath]
|
||||||
|
for (let i = 0; i < fullPath.length - 1; i++) {
|
||||||
|
currentLevel = currentLevel[fullPath[i]].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 || []))
|
||||||
|
formData.nodeParams = nodeParams;
|
||||||
|
formData.outputParams = outputParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到 lf
|
||||||
|
lf.setProperties(props.data.id, {
|
||||||
|
...props.data.properties,
|
||||||
|
...formData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (inputValid && outputValid) {
|
||||||
|
emit('save-success')
|
||||||
|
} else {
|
||||||
|
emit('save-error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const robotFormRef = ref(null)
|
||||||
|
const robotFormRules = reactive({
|
||||||
|
terminalId: [{ required: true, message: '终端ID不能为空', trigger: 'blur' }],
|
||||||
|
deviceId: [{ required: true, message: '设备ID不能为空', trigger: 'blur' }]
|
||||||
|
})
|
||||||
|
const robotForm = reactive({
|
||||||
|
deviceId: '',
|
||||||
|
terminalId: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const testRule = (property) => {
|
||||||
|
if (property?.name === 'acceleration') {
|
||||||
|
return [
|
||||||
|
{ required: true, message: '请输入参数值', trigger: 'blur' },
|
||||||
|
{ validator: () => {
|
||||||
|
if (formData.nodeParams.find(param => param.name === 'acceleration')?.input > formData.nodeParams.find(param => param.name === 'velocity')?.input) {
|
||||||
|
return Promise.resolve()
|
||||||
|
} else {
|
||||||
|
return Promise.reject(new Error('加速度必须大于速度'))
|
||||||
|
}
|
||||||
|
}, trigger: 'blur' }
|
||||||
|
]
|
||||||
|
|
||||||
|
}
|
||||||
|
return [{ required: property?.required ?? true, message: '请输入参数值', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const robotFormAction = ref()
|
||||||
|
|
||||||
|
const submitRobotForm = () => {
|
||||||
|
if (robotFormAction.value === 'AGV_MOVE_TO_POINT') {
|
||||||
|
|
||||||
|
} else if (robotFormAction.value === 'ALM') {
|
||||||
|
robotFormRef.value.validate(async (valid) => {
|
||||||
|
if (valid) {
|
||||||
|
try {
|
||||||
|
const response = await getArmStatus({ deviceId: robotForm.deviceId, terminalId: robotForm.terminalId })
|
||||||
|
if (response && response.data) {
|
||||||
|
dialogVisible.value = false
|
||||||
|
const { x, y, z, rx, ry, rz } = response.data
|
||||||
|
const testMap = {
|
||||||
|
x: x,
|
||||||
|
y: y,
|
||||||
|
z: z,
|
||||||
|
rx: rx,
|
||||||
|
ry: ry,
|
||||||
|
rz: rz,
|
||||||
|
deviceId: robotForm.deviceId
|
||||||
|
}
|
||||||
|
// 将获取到的位置信息设置到 formData 中
|
||||||
|
formData.nodeParams.forEach(param => {
|
||||||
|
if (testMap.hasOwnProperty(param.name)) {
|
||||||
|
param.input = testMap[param.name]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取位置信息失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openRobotForm = (action) => {
|
||||||
|
dialogVisible.value = true
|
||||||
|
robotFormAction.value = action
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ validateAndSave })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
463
src/views/flow/components/params/CodeNodeParams.vue
Normal file
463
src/views/flow/components/params/CodeNodeParams.vue
Normal file
@ -0,0 +1,463 @@
|
|||||||
|
<template>
|
||||||
|
<el-collapse v-model="activeNames">
|
||||||
|
<el-collapse v-model="activeNames">
|
||||||
|
<el-collapse-item name="1" 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>
|
||||||
|
</template>
|
||||||
|
<div class="form__container">
|
||||||
|
<el-form
|
||||||
|
:inline="true"
|
||||||
|
:model="formData"
|
||||||
|
:rules="rules"
|
||||||
|
ref="dynamicFormRef"
|
||||||
|
label-position="top"
|
||||||
|
label-width="auto"
|
||||||
|
>
|
||||||
|
<div v-for="(property, index) in formData.nodeParams" :key="index">
|
||||||
|
<el-row v-if="property.name !== 'code'">
|
||||||
|
<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)"
|
||||||
|
>
|
||||||
|
<el-option label="引用" value="quote" />
|
||||||
|
<el-option label="输入" value="input" />
|
||||||
|
</el-select>
|
||||||
|
<el-form-item
|
||||||
|
v-if="property.type === 'input'"
|
||||||
|
:rules="[
|
||||||
|
{
|
||||||
|
required: property?.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="false"
|
||||||
|
:step-strictly="true"
|
||||||
|
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)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form-item>
|
||||||
|
<el-button
|
||||||
|
:icon="Minus"
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
@click="handleDelete(index)"
|
||||||
|
:disabled="property?.disabled || false"
|
||||||
|
class="deleteBtn"
|
||||||
|
/>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</el-collapse-item>
|
||||||
|
<el-collapse-item title="代码" name="2" icon-position="left">
|
||||||
|
<div class="code__container">
|
||||||
|
<div ref="codeRef" style="width: 100%; height: 100%"></div>
|
||||||
|
</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) => addFormItem(e, 'outputParams')"
|
||||||
|
class="addFormItem"
|
||||||
|
>
|
||||||
|
<el-icon :size="14"><Plus /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<div class="form__container">
|
||||||
|
<el-form
|
||||||
|
:inline="true"
|
||||||
|
:model="formData"
|
||||||
|
label-position="top"
|
||||||
|
label-width="auto"
|
||||||
|
:rules="outputRules"
|
||||||
|
ref="outputFormRef"
|
||||||
|
>
|
||||||
|
<FormItemRecursive
|
||||||
|
formType="output"
|
||||||
|
:current-list="formData.outputParams"
|
||||||
|
prop-path="outputParams"
|
||||||
|
:depth="0"
|
||||||
|
:is-first-level="true"
|
||||||
|
:endDepth="2"
|
||||||
|
:parent-path="[]"
|
||||||
|
@delete-item="(path) => deleteTopLevelItem(path, 'outputParams')"
|
||||||
|
/>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
</el-collapse>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch, nextTick, onBeforeUnmount, onMounted } from 'vue'
|
||||||
|
import { Plus, Minus } from '@element-plus/icons-vue'
|
||||||
|
import FormItemRecursive from '../FormItemRecursive.vue'
|
||||||
|
import * as monaco from 'monaco-editor';
|
||||||
|
import { getInput } 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 codeRef = ref()
|
||||||
|
let editorInstance
|
||||||
|
|
||||||
|
function hasErrors() {
|
||||||
|
const model = editorInstance.getModel();
|
||||||
|
if (!model) return false;
|
||||||
|
|
||||||
|
// 获取当前模型的所有标记
|
||||||
|
const markers = monaco.editor.getModelMarkers({ resource: model.uri });
|
||||||
|
// 检查是否存在错误级别标记
|
||||||
|
return markers.some(marker => marker.severity === monaco.MarkerSeverity.Error);
|
||||||
|
}
|
||||||
|
const initEditor = (type) => {
|
||||||
|
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
|
||||||
|
target: monaco.languages.typescript.ScriptTarget.ES2020,
|
||||||
|
allowNonTsExtensions: true, // 允许非 ts 扩展名(.js)
|
||||||
|
checkJs: true, // 对 .js 文件进行类型检查
|
||||||
|
strict: true, // 启用所有严格检查
|
||||||
|
noImplicitAny: false, // 禁止隐式 any
|
||||||
|
noUnusedLocals: true, // 未使用的局部变量报错(可选)
|
||||||
|
noUnusedParameters: true, // 未使用的参数报错(可选)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 确保语义验证开启(默认就是开启的,但可以显式设置)
|
||||||
|
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
|
||||||
|
noSemanticValidation: false, // 开启语义检查
|
||||||
|
noSyntaxValidation: false, // 开启语法检查
|
||||||
|
});
|
||||||
|
|
||||||
|
editorInstance = monaco.editor.create(codeRef.value, {
|
||||||
|
value: ['{}'].join('\n'),
|
||||||
|
language: type === 'code' ? 'javascript' : 'json',
|
||||||
|
fontSize: 14,
|
||||||
|
lineNumbers: 'on',
|
||||||
|
roundedSelection: true,
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
formatOnPaste: true,
|
||||||
|
formatOnType: true,
|
||||||
|
quickSuggestions: true,
|
||||||
|
suggestOnTriggerCharacters: true,
|
||||||
|
// 自动完成
|
||||||
|
suggest: {
|
||||||
|
showWords: true,
|
||||||
|
showFunctions: true,
|
||||||
|
showVariables: true,
|
||||||
|
showClasses: true,
|
||||||
|
showModules: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置代码值
|
||||||
|
const setValue = (value) => {
|
||||||
|
if (editorInstance) {
|
||||||
|
editorInstance.setValue(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 销毁编辑器
|
||||||
|
const disposeEditor = () => {
|
||||||
|
if (editorInstance) {
|
||||||
|
editorInstance.dispose()
|
||||||
|
editorInstance = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 handleDelete = (index) => {
|
||||||
|
formData.nodeParams.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除顶层输出参数(递归删除通过 FormItemRecursive 的 emit 处理)
|
||||||
|
const deleteTopLevelItem = (fullPath, propPath) => {
|
||||||
|
let currentLevel = formData[propPath]
|
||||||
|
for (let i = 0; i < fullPath.length - 1; i++) {
|
||||||
|
currentLevel = currentLevel[fullPath[i]].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 || []))
|
||||||
|
formData.nodeParams = nodeParams;
|
||||||
|
formData.outputParams = outputParams;
|
||||||
|
const codeParams = props.data.properties.nodeParams.find(item => item.name === 'code')
|
||||||
|
if (codeParams) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (editorInstance) {
|
||||||
|
setValue(codeParams.input)
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存验证
|
||||||
|
const validateAndSave = async () => {
|
||||||
|
const targetObj = formData.nodeParams.find(item => item.name === 'code');
|
||||||
|
if (targetObj) {
|
||||||
|
// 存在:修改该对象的 input 属性
|
||||||
|
targetObj.input = editorInstance.getValue();
|
||||||
|
} else {
|
||||||
|
formData.nodeParams.push(
|
||||||
|
{ name: "code", type: "input", input: editorInstance.getValue() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到 lf
|
||||||
|
lf.setProperties(props.data.id, {
|
||||||
|
...props.data.properties,
|
||||||
|
...formData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hasErrors()) {
|
||||||
|
emit('save-error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputValid && outputValid) {
|
||||||
|
emit('save-success')
|
||||||
|
} else {
|
||||||
|
emit('save-error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
initEditor('code')
|
||||||
|
initData()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ validateAndSave })
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disposeEditor()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.code__container {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
text-align: initial;
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
654
src/views/flow/components/params/HttpNodeParams.vue
Normal file
654
src/views/flow/components/params/HttpNodeParams.vue
Normal file
@ -0,0 +1,654 @@
|
|||||||
|
<template>
|
||||||
|
<el-form :inline="true" :model="httpNodeData" :rules="rules" class="http-form" ref="dynamicFormRef"
|
||||||
|
label-position="top" label-width="auto">
|
||||||
|
<el-collapse v-model="activeNames">
|
||||||
|
<el-collapse-item name="1" icon-position="left">
|
||||||
|
<template #title>
|
||||||
|
请求配置
|
||||||
|
<el-tooltip class="box-item" effect="dark" content="支持HTTP/HTTPS协议" placement="top">
|
||||||
|
<el-icon class="header-icon" style="margin-left: 6px">
|
||||||
|
<info-filled />
|
||||||
|
</el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<div class="form__container">
|
||||||
|
<div v-for="(property, index) in httpNodeData.config" :key="index">
|
||||||
|
<el-row>
|
||||||
|
<el-form-item :label="index === 0 ? '参数名' : ''" :prop="`config.${index}.name`" :rules="[
|
||||||
|
{ required: true, message: '请输入参数名', trigger: 'blur' },
|
||||||
|
]">
|
||||||
|
<el-input :disabled="true" class="param-name" v-model="property.name" placeholder="请输入"
|
||||||
|
clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`config.${index}.input`">
|
||||||
|
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(index)">
|
||||||
|
<el-option label="引用" value="quote" />
|
||||||
|
<el-option label="输入" value="input" />
|
||||||
|
</el-select>
|
||||||
|
<el-form-item v-if="property.type === 'input'" :rules="[
|
||||||
|
{
|
||||||
|
required: property?.required ?? true,
|
||||||
|
message: '请输入参数值',
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
]" :prop="`config.${index}.input`">
|
||||||
|
<el-input-number class="param-value" v-if="property.componentType === 'number'"
|
||||||
|
v-model="property.input" :min="0" :max="property.max || Infinity"
|
||||||
|
:controls="false" :step-strictly="true" 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="`config.${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)" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form-item>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-collapse-item>
|
||||||
|
<el-collapse-item name="2" icon-position="left">
|
||||||
|
<template #title>
|
||||||
|
Header配置
|
||||||
|
<el-button :circle="true" size="small" @click="(e) => addHttpNodeFormItem(e, 'headers')"
|
||||||
|
class="addFormItem">
|
||||||
|
<el-icon :size="14">
|
||||||
|
<Plus />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<div class="form__container">
|
||||||
|
<div v-for="(property, index) in httpNodeData.headers" :key="index">
|
||||||
|
<el-row>
|
||||||
|
<el-form-item :label="index === 0 ? '参数名' : ''"
|
||||||
|
:prop="httpNodeData.headers.length > index ? `headers.${index}.name` : undefined"
|
||||||
|
:rules="[
|
||||||
|
{ required: true, message: '请输入参数名', trigger: 'blur' },
|
||||||
|
]">
|
||||||
|
<el-input class="param-name" v-model="property.name" placeholder="请输入" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="index === 0 ? '参数值' : ''"
|
||||||
|
:prop="httpNodeData.headers.length > index ? `headers.${index}.input` : undefined">
|
||||||
|
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(index)">
|
||||||
|
<el-option label="引用" value="quote" />
|
||||||
|
<el-option label="输入" value="input" />
|
||||||
|
</el-select>
|
||||||
|
<el-form-item v-if="property.type === 'input'" :rules="[
|
||||||
|
{
|
||||||
|
required: property?.required ?? true,
|
||||||
|
message: '请输入参数值',
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
]" :prop="`headers.${index}.input`">
|
||||||
|
<el-input-number class="param-value" v-if="property.componentType === 'number'"
|
||||||
|
v-model="property.input" :min="0" :max="property.max || Infinity"
|
||||||
|
:controls="false" :step-strictly="true" 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="`headers.${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)" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form-item>
|
||||||
|
<el-button :icon="Minus" circle size="small" @click="handleHttpNodeDelete('headers', index)"
|
||||||
|
class="deleteBtn" />
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-collapse-item>
|
||||||
|
<el-collapse-item name="3" icon-position="left">
|
||||||
|
<template #title>
|
||||||
|
Param配置
|
||||||
|
<el-button :circle="true" size="small" @click="(e) => addHttpNodeFormItem(e, 'params')"
|
||||||
|
class="addFormItem">
|
||||||
|
<el-icon :size="14">
|
||||||
|
<Plus />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<div class="form__container">
|
||||||
|
<div v-for="(property, index) in httpNodeData.params" :key="index">
|
||||||
|
<el-row>
|
||||||
|
<el-form-item :label="index === 0 ? '参数名' : ''"
|
||||||
|
:prop="httpNodeData.params.length > index ? `params.${index}.name` : undefined" :rules="[
|
||||||
|
{ required: true, message: '请输入参数名123', trigger: 'blur' },
|
||||||
|
]">
|
||||||
|
<el-input class="param-name" v-model="property.name" placeholder="请输入" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="index === 0 ? '参数值' : ''"
|
||||||
|
:prop="httpNodeData.params.length > index ? `params.${index}.input` : undefined">
|
||||||
|
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(index)">
|
||||||
|
<el-option label="引用" value="quote" />
|
||||||
|
<el-option label="输入" value="input" />
|
||||||
|
</el-select>
|
||||||
|
<el-form-item v-if="property.type === 'input'" :rules="[
|
||||||
|
{
|
||||||
|
required: property?.required ?? true,
|
||||||
|
message: '请输入参数值',
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
]" :prop="httpNodeData.params.length > index ? `params.${index}.input` : undefined">
|
||||||
|
<el-input-number class="param-value" v-if="property.componentType === 'number'"
|
||||||
|
v-model="property.input" :min="0" :max="property.max || Infinity"
|
||||||
|
:controls="false" :step-strictly="true" 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="`params.${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)" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form-item>
|
||||||
|
<el-button :icon="Minus" circle size="small" @click="handleHttpNodeDelete('params', index)"
|
||||||
|
class="deleteBtn" />
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-collapse-item>
|
||||||
|
<el-collapse-item name="4" title="Body配置" icon-position="left">
|
||||||
|
<template #title>
|
||||||
|
Body配置
|
||||||
|
<el-button
|
||||||
|
:circle="true"
|
||||||
|
size="small"
|
||||||
|
@click="(e) => addHttpNodeFormItem(e, 'body')"
|
||||||
|
class="addFormItem"
|
||||||
|
v-if="httpNodeData?.body?.type === 'form-data'"
|
||||||
|
>
|
||||||
|
<el-icon :size="14"><Plus /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<div class="form__container">
|
||||||
|
<el-select v-model="httpNodeData.body.type">
|
||||||
|
<el-option label="JSON" value="json" />
|
||||||
|
<el-option label="form-data" value="form-data" />
|
||||||
|
</el-select>
|
||||||
|
<div class="code__container" v-show="httpNodeData?.body?.type === 'json'">
|
||||||
|
<div ref="codeRef" style="width: 100%; height: 100%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="test" v-if="httpNodeData?.body?.type === 'form-data'">
|
||||||
|
<div v-for="(property, index) in httpNodeData.body.formData" :key="index">
|
||||||
|
<el-row>
|
||||||
|
<el-form-item
|
||||||
|
:label="index === 0 ? '参数名' : ''"
|
||||||
|
:prop="`body.formData.${index}.name`"
|
||||||
|
:rules="[
|
||||||
|
{ required: true, message: '请输入参数名', trigger: 'blur' },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
class="param-name"
|
||||||
|
v-model="property.name"
|
||||||
|
placeholder="请输入"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
:label="index === 0 ? '参数值' : ''"
|
||||||
|
:prop="`body.formData.${index}.input`"
|
||||||
|
>
|
||||||
|
<el-select
|
||||||
|
v-model="property.type"
|
||||||
|
class="param-type"
|
||||||
|
@change="handleTypeChange(index, 'httpBody')"
|
||||||
|
>
|
||||||
|
<el-option label="引用" value="quote" />
|
||||||
|
<el-option label="输入" value="input" />
|
||||||
|
</el-select>
|
||||||
|
<el-form-item
|
||||||
|
v-if="property.type === 'input'"
|
||||||
|
:rules="[
|
||||||
|
{
|
||||||
|
required: property?.required ?? true,
|
||||||
|
message: '请输入参数值',
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:prop="`body.formData.${index}.input`"
|
||||||
|
>
|
||||||
|
<el-input-number
|
||||||
|
class="param-value"
|
||||||
|
v-if="property.componentType === 'number'"
|
||||||
|
v-model="property.input"
|
||||||
|
:min="0"
|
||||||
|
:max="property.max || Infinity"
|
||||||
|
:controls="false"
|
||||||
|
:step-strictly="true"
|
||||||
|
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="`body.formData.${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, 'httpBody')
|
||||||
|
"
|
||||||
|
@change="(value) => cascaderChange(value, index, 'httpBody')"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form-item>
|
||||||
|
<el-button
|
||||||
|
:icon="Minus"
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
@click="handleHttpNodeDelete('body', index)"
|
||||||
|
class="deleteBtn"
|
||||||
|
/>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
</el-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { Plus, Minus } from '@element-plus/icons-vue'
|
||||||
|
import FormItemRecursive from '../FormItemRecursive.vue'
|
||||||
|
import * as monaco from 'monaco-editor';
|
||||||
|
import { getInput, transformHttpNodeData } 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 codeRef = ref()
|
||||||
|
let editorInstance
|
||||||
|
|
||||||
|
function hasErrors() {
|
||||||
|
const model = editorInstance.getModel();
|
||||||
|
if (!model) return false;
|
||||||
|
|
||||||
|
// 获取当前模型的所有标记
|
||||||
|
const markers = monaco.editor.getModelMarkers({ resource: model.uri });
|
||||||
|
// 检查是否存在错误级别标记
|
||||||
|
return markers.some(marker => marker.severity === monaco.MarkerSeverity.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initEditor = (type) => {
|
||||||
|
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
|
||||||
|
target: monaco.languages.typescript.ScriptTarget.ES2020,
|
||||||
|
allowNonTsExtensions: true, // 允许非 ts 扩展名(.js)
|
||||||
|
checkJs: true, // 对 .js 文件进行类型检查
|
||||||
|
strict: true, // 启用所有严格检查
|
||||||
|
noImplicitAny: false, // 禁止隐式 any
|
||||||
|
noUnusedLocals: true, // 未使用的局部变量报错(可选)
|
||||||
|
noUnusedParameters: true, // 未使用的参数报错(可选)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 确保语义验证开启(默认就是开启的,但可以显式设置)
|
||||||
|
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
|
||||||
|
noSemanticValidation: false, // 开启语义检查
|
||||||
|
noSyntaxValidation: false, // 开启语法检查
|
||||||
|
});
|
||||||
|
|
||||||
|
editorInstance = monaco.editor.create(codeRef.value, {
|
||||||
|
value: ['{}'].join('\n'),
|
||||||
|
language: 'json',
|
||||||
|
fontSize: 14,
|
||||||
|
lineNumbers: 'on',
|
||||||
|
roundedSelection: true,
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
formatOnPaste: true,
|
||||||
|
formatOnType: true,
|
||||||
|
quickSuggestions: true,
|
||||||
|
suggestOnTriggerCharacters: true,
|
||||||
|
// 自动完成
|
||||||
|
suggest: {
|
||||||
|
showWords: true,
|
||||||
|
showFunctions: true,
|
||||||
|
showVariables: true,
|
||||||
|
showClasses: true,
|
||||||
|
showModules: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置代码值
|
||||||
|
const setValue = (value) => {
|
||||||
|
if (editorInstance) {
|
||||||
|
editorInstance.setValue(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 销毁编辑器
|
||||||
|
const disposeEditor = () => {
|
||||||
|
if (editorInstance) {
|
||||||
|
editorInstance.dispose()
|
||||||
|
editorInstance = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = reactive({})
|
||||||
|
const dynamicFormRef = ref()
|
||||||
|
const activeNames = ref(['1', '2', '3', '4'])
|
||||||
|
|
||||||
|
// 使用共享的 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 handleDelete = (index) => {
|
||||||
|
formData.nodeParams.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除顶层输出参数(递归删除通过 FormItemRecursive 的 emit 处理)
|
||||||
|
const deleteTopLevelItem = (fullPath, propPath) => {
|
||||||
|
let currentLevel = formData[propPath]
|
||||||
|
for (let i = 0; i < fullPath.length - 1; i++) {
|
||||||
|
currentLevel = currentLevel[fullPath[i]].children
|
||||||
|
}
|
||||||
|
const lastIndex = fullPath[fullPath.length - 1]
|
||||||
|
currentLevel.splice(lastIndex, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP 节点数据
|
||||||
|
const httpNodeData = ref({
|
||||||
|
body: {
|
||||||
|
bodyType: 'json',
|
||||||
|
json: '{}',
|
||||||
|
formData: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const addHttpNodeFormItem = (e, type) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (type === 'body') {
|
||||||
|
if (!httpNodeData.value.body.formData) {
|
||||||
|
httpNodeData.value.body.formData = [];
|
||||||
|
}
|
||||||
|
httpNodeData.value.body.formData.push({ name: "", type: "input", input: ""});
|
||||||
|
} else {
|
||||||
|
if (!httpNodeData.value[type]) {
|
||||||
|
httpNodeData.value[type] = [];
|
||||||
|
}
|
||||||
|
httpNodeData.value[type].push({ name: "", type: "input", input: ""});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHttpNodeDelete = (type, index) => {
|
||||||
|
if (type === 'body') {
|
||||||
|
httpNodeData.value.body.formData.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
httpNodeData.value[type].splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化数据
|
||||||
|
const initData = () => {
|
||||||
|
const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || []))
|
||||||
|
const transformedData = transformHttpNodeData(nodeParams);
|
||||||
|
|
||||||
|
httpNodeData.value = transformedData;
|
||||||
|
if (httpNodeData.value.body.type === 'json') {
|
||||||
|
nextTick(() => {
|
||||||
|
initEditor('http');
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
if (editorInstance) {
|
||||||
|
setValue(httpNodeData.value.body.json || '{}')
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存验证
|
||||||
|
const validateAndSave = async () => {
|
||||||
|
let inputValid = true
|
||||||
|
if (dynamicFormRef.value) {
|
||||||
|
await dynamicFormRef.value.validate((valid) => { if (!valid) inputValid = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const nodeParams = []
|
||||||
|
nodeParams.push({ name: "config", type: "input", input: "", children: httpNodeData.value.config, disabled: true, required: true })
|
||||||
|
if (httpNodeData.value?.headers?.length > 0) {
|
||||||
|
nodeParams.push({ name: "headers", type: "input", input: "", children: httpNodeData.value.headers, disabled: true, required: true })
|
||||||
|
}
|
||||||
|
if (httpNodeData.value?.params?.length > 0) {
|
||||||
|
nodeParams.push({ name: "params", type: "input", input: "", children: httpNodeData.value.params, disabled: true, required: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyObj = {
|
||||||
|
name: "body", type: "input", input: "", children: [
|
||||||
|
{ name: 'bodyType', type: "input", input: httpNodeData.value.body.type, disabled: true },
|
||||||
|
{ name: 'json', type: "input", input: httpNodeData.value.body.type === 'json' ? editorInstance.getValue() : '' },
|
||||||
|
{ name: 'formData', type: "input", input: "", children: httpNodeData.value.body.formData }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
nodeParams.push(bodyObj)
|
||||||
|
|
||||||
|
// 保存到 lf
|
||||||
|
lf.setProperties(props.data.id, {
|
||||||
|
...props.data.properties,
|
||||||
|
nodeParams
|
||||||
|
})
|
||||||
|
|
||||||
|
if (httpNodeData.value.body.type === 'json') {
|
||||||
|
if (hasErrors()) {
|
||||||
|
emit('save-error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputValid) {
|
||||||
|
emit('save-success')
|
||||||
|
} else {
|
||||||
|
emit('save-error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
initData()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ validateAndSave })
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disposeEditor()
|
||||||
|
})
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.code__container {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
text-align: initial;
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
359
src/views/flow/components/params/SdAgentNodeParams.vue
Normal file
359
src/views/flow/components/params/SdAgentNodeParams.vue
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
<template>
|
||||||
|
<el-form :inline="true" :model="sdAgentNodeData" :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 class="form__container">
|
||||||
|
<div v-for="(property, index) in sdAgentNodeData.config" :key="index">
|
||||||
|
<el-row>
|
||||||
|
<el-form-item :label="index === 0 ? '参数名' : ''" :prop="`config.${index}.name`" :rules="[
|
||||||
|
{ required: true, message: '请输入参数名', trigger: 'blur' },
|
||||||
|
]">
|
||||||
|
<el-input :disabled="true" class="param-name" v-model="property.name" placeholder="请输入" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`config.${index}.input`">
|
||||||
|
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(index)">
|
||||||
|
<el-option label="引用" value="quote" />
|
||||||
|
<el-option label="输入" value="input" />
|
||||||
|
</el-select>
|
||||||
|
<el-form-item v-if="property.type === 'input'" :rules="[
|
||||||
|
{
|
||||||
|
required: property?.required ?? true,
|
||||||
|
message: '请输入参数值',
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
]" :prop="`config.${index}.input`">
|
||||||
|
<el-input-number class="param-value" v-if="property.componentType === 'number'"
|
||||||
|
v-model="property.input" :min="0" :max="property.max || Infinity" :controls="false"
|
||||||
|
:step-strictly="true" 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="`config.${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, 'sdAgentConfig')" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form-item>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
<el-row>
|
||||||
|
<span style="margin-right: 24px">是否调用TTS接口</span>
|
||||||
|
<el-switch size="small" v-model="sdAgentNodeData.invokeTts" @change="handleTtsChange" />
|
||||||
|
</el-row>
|
||||||
|
<div v-if="sdAgentNodeData.invokeTts">
|
||||||
|
<div style="margin: 10px 0">TTS参数设置</div>
|
||||||
|
<div v-for="(property, index) in sdAgentNodeData.tts" :key="index">
|
||||||
|
<el-row>
|
||||||
|
<el-form-item :label="index === 0 ? '参数名' : ''" :prop="`tts.${index}.name`" :rules="[
|
||||||
|
{ required: true, message: '请输入参数名', trigger: 'blur' },
|
||||||
|
]">
|
||||||
|
<el-input :disabled="true" class="param-name" v-model="property.name" placeholder="请输入" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`tts.${index}.input`">
|
||||||
|
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(index)">
|
||||||
|
<el-option label="引用" value="quote" />
|
||||||
|
<el-option label="输入" value="input" />
|
||||||
|
</el-select>
|
||||||
|
<el-form-item v-if="property.type === 'input'" :rules="[
|
||||||
|
{
|
||||||
|
required: property?.required ?? true,
|
||||||
|
message: '请输入参数值',
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
]" :prop="`tts.${index}.input`">
|
||||||
|
<el-input-number class="param-value" v-if="property.componentType === 'number'"
|
||||||
|
v-model="property.input" :min="0" :max="property.max || Infinity" :controls="false"
|
||||||
|
:step-strictly="true" 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="`tts.${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, 'sdAgentTts')" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form-item>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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) => addSdAgentItem(e)" class="addFormItem">
|
||||||
|
<el-icon :size="14">
|
||||||
|
<Plus />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<div class="form__container">
|
||||||
|
<el-form :inline="true" :model="sdAgentNodeData" label-position="top" label-width="auto" :rules="outputRules"
|
||||||
|
ref="outputFormRef">
|
||||||
|
<FormItemRecursive formType="output" :current-list="sdAgentNodeData.outputParams" prop-path="outputParams"
|
||||||
|
:depth="0" :is-first-level="true" :endDepth="2" :parent-path="[]"
|
||||||
|
@delete-item="(path) => deleteSdAgentItem(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 { getInput, transformSdAgentNodeData } 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 sdAgentNodeData = ref({})
|
||||||
|
|
||||||
|
const voiceOptions = () => {
|
||||||
|
return [{
|
||||||
|
value: 'x4_xiaoyan',
|
||||||
|
label: 'x4_xiaoyan'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTtsChange = (val) => {
|
||||||
|
if (val) {
|
||||||
|
sdAgentNodeData.value.tts = []
|
||||||
|
if (!sdAgentNodeData.value.tts || sdAgentNodeData.value.tts.length === 0) {
|
||||||
|
sdAgentNodeData.value.tts = [
|
||||||
|
{ name: 'url', type: "input", input: "", disabled: true },
|
||||||
|
{ name: 'text', type: "input", input: "", disabled: true },
|
||||||
|
{ name: 'speed', type: "input", input: "", max: 100, disabled: true },
|
||||||
|
{ name: 'voice', type: "input", input: "", componentType: 'select', selectOptions: voiceOptions(), disabled: true },
|
||||||
|
{ name: 'volume', type: "input", input: "", componentType: 'number', max: 100, disabled: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSdAgentItem = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
sdAgentNodeData.value.outputParams.push({ name: "", type: "string", input: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSdAgentItem = (fullPath, propPath) => {
|
||||||
|
// 从顶层数据开始查找
|
||||||
|
let currentLevel = sdAgentNodeData.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 = transformSdAgentNodeData(nodeParams);
|
||||||
|
sdAgentNodeData.value = {
|
||||||
|
...transformedData,
|
||||||
|
outputParams
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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({ name: "config", type: "input", input: "", children: sdAgentNodeData.value.config, required: true })
|
||||||
|
nodeParams.push({ name: "invokeTts", type: "input", input: sdAgentNodeData.value.invokeTts })
|
||||||
|
if (sdAgentNodeData.value.invokeTts) {
|
||||||
|
nodeParams.push({ name: "tts", type: "input", input: "", children: sdAgentNodeData.value.tts })
|
||||||
|
}
|
||||||
|
lf.setProperties(props.data.id, {
|
||||||
|
...props.data.properties,
|
||||||
|
nodeParams,
|
||||||
|
outputParams: sdAgentNodeData.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
183
src/views/flow/components/params/StartParams.vue
Normal file
183
src/views/flow/components/params/StartParams.vue
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
<template>
|
||||||
|
<el-collapse v-model="activeNames">
|
||||||
|
<el-collapse-item name="1" icon-position="left">
|
||||||
|
<template #title>
|
||||||
|
输入参数
|
||||||
|
<el-button :circle="true" size="small" @click="(e) => addFormItem(e, 'inputParams')" class="addFormItem">
|
||||||
|
<el-icon :size="14">
|
||||||
|
<Plus />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<el-form :inline="true" :model="formData" :rules="rules" ref="dynamicFormRef" label-position="top"
|
||||||
|
label-width="auto">
|
||||||
|
<FormItemRecursive formType="input" :current-list="formData.inputParams" prop-path="inputParams"
|
||||||
|
:depth="0" :is-first-level="true" :endDepth="2" :parent-path="[]"
|
||||||
|
@delete-item="(path) => deleteTopLevelItem(path, 'inputParams')" />
|
||||||
|
</el-form>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch, nextTick } from 'vue'
|
||||||
|
import { Plus, Minus } from '@element-plus/icons-vue'
|
||||||
|
import FormItemRecursive from '../FormItemRecursive.vue'
|
||||||
|
import { getInput } from '@/utils/flow'
|
||||||
|
import { useQuote } from './useQuote.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['save-success', 'save-error'])
|
||||||
|
|
||||||
|
const formData = reactive({
|
||||||
|
inputParams: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules = reactive({})
|
||||||
|
const dynamicFormRef = ref()
|
||||||
|
|
||||||
|
const activeNames = ref(['1', '2'])
|
||||||
|
|
||||||
|
// 添加表单项
|
||||||
|
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 handleDelete = (index) => {
|
||||||
|
formData.nodeParams.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除顶层输出参数(递归删除通过 FormItemRecursive 的 emit 处理)
|
||||||
|
const deleteTopLevelItem = (fullPath, propPath) => {
|
||||||
|
let currentLevel = formData[propPath]
|
||||||
|
for (let i = 0; i < fullPath.length - 1; i++) {
|
||||||
|
currentLevel = currentLevel[fullPath[i]].children
|
||||||
|
}
|
||||||
|
const lastIndex = fullPath[fullPath.length - 1]
|
||||||
|
currentLevel.splice(lastIndex, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化数据
|
||||||
|
const initData = () => {
|
||||||
|
const inputParams = JSON.parse(JSON.stringify(props.data.properties.inputParams || []))
|
||||||
|
formData.inputParams = inputParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.data,
|
||||||
|
() => {
|
||||||
|
initData()
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// 保存验证
|
||||||
|
const validateAndSave = async () => {
|
||||||
|
let inputValid = true
|
||||||
|
if (dynamicFormRef.value) {
|
||||||
|
await dynamicFormRef.value.validate((valid) => { if (!valid) inputValid = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到 lf
|
||||||
|
lf.setProperties(props.data.id, {
|
||||||
|
...props.data.properties,
|
||||||
|
...formData
|
||||||
|
})
|
||||||
|
if (inputValid) {
|
||||||
|
emit('save-success')
|
||||||
|
} else {
|
||||||
|
emit('save-error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ validateAndSave })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
53
src/views/flow/components/params/useQuote.js
Normal file
53
src/views/flow/components/params/useQuote.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { getInput } from '@/utils/flow'
|
||||||
|
|
||||||
|
export function useQuote(nodeId) {
|
||||||
|
const quoteOptions = ref([])
|
||||||
|
const cascaderRefs = ref([])
|
||||||
|
|
||||||
|
const handleTypeChange = (index, formData, type = 'default') => {
|
||||||
|
// 根据节点类型处理不同数据源
|
||||||
|
// 这里简化为通用逻辑,具体实现需根据实际数据结构调整
|
||||||
|
if (formData.nodeParams[index].type === 'input') {
|
||||||
|
formData.nodeParams[index].quote = ''
|
||||||
|
} else {
|
||||||
|
formData.nodeParams[index].input = ''
|
||||||
|
const option = getInput(nodeId)
|
||||||
|
if (option) quoteOptions.value = option
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cascaderChange = (value, index, formData, field = 'nodeParams') => {
|
||||||
|
const selectedOptions = cascaderRefs.value[index]?.getCheckedNodes(true)
|
||||||
|
if (selectedOptions && selectedOptions.length) {
|
||||||
|
formData[field][index].quote = value
|
||||||
|
formData[field][index].quoteType = selectedOptions[0].data.type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleChange = (visible, index, currentQuote, formData, field = 'nodeParams') => {
|
||||||
|
if (visible) {
|
||||||
|
const option = getInput(nodeId)
|
||||||
|
if (option) {
|
||||||
|
quoteOptions.value = option
|
||||||
|
const currentValue = [...currentQuote]
|
||||||
|
if (cascaderRefs.value[index] && currentValue.length > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
formData[field][index].quote = []
|
||||||
|
setTimeout(() => {
|
||||||
|
formData[field][index].quote = currentValue
|
||||||
|
}, 0)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
quoteOptions,
|
||||||
|
cascaderRefs,
|
||||||
|
handleTypeChange,
|
||||||
|
cascaderChange,
|
||||||
|
visibleChange
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -307,7 +307,7 @@ export const collapseList = [
|
|||||||
], disabled: true, required: true },
|
], disabled: true, required: true },
|
||||||
{ name: "body", type: "input", input: "", children: [
|
{ name: "body", type: "input", input: "", children: [
|
||||||
{ name: 'bodyType', type: "input", input: "json", disabled: true },
|
{ name: 'bodyType', type: "input", input: "json", disabled: true },
|
||||||
{ name: 'json', type: "input", input: '{}' },
|
{ name: 'json', type: "input", input: '{"a": 123}' },
|
||||||
{ name: 'formData', type: "input", input: "", children: []}
|
{ name: 'formData', type: "input", input: "", children: []}
|
||||||
], required: true },
|
], required: true },
|
||||||
],
|
],
|
||||||
@ -363,14 +363,38 @@ function handler(params) {
|
|||||||
{
|
{
|
||||||
collapseTitle: "机器人",
|
collapseTitle: "机器人",
|
||||||
nodeList: [
|
nodeList: [
|
||||||
|
{
|
||||||
|
icon: videoSvg,
|
||||||
|
name: "AGV控制",
|
||||||
|
type: "serviceNode",
|
||||||
|
desc: "控制机器人的底盘",
|
||||||
|
action: 'AGV_MOVE_TO_POINT',
|
||||||
|
nodeType: 'EDGE',
|
||||||
|
nodeParams: [
|
||||||
|
{ name: "deviceId", type: "input", input: "", disabled: true },
|
||||||
|
{ name: "x", type: "input", input: "", disabled: true },
|
||||||
|
{ name: "y", type: "input", input: "", disabled: true },
|
||||||
|
{ name: "theta", type: "input", input: "", disabled: true },
|
||||||
|
],
|
||||||
|
outputType: 'json'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: videoSvg,
|
icon: videoSvg,
|
||||||
name: "机械臂",
|
name: "机械臂",
|
||||||
type: "serviceNode",
|
type: "serviceNode",
|
||||||
desc: "控制机器人的手臂",
|
desc: "控制机器人的手臂",
|
||||||
action: 'ALM',
|
action: 'ALM',
|
||||||
|
nodeType: 'EDGE',
|
||||||
nodeParams: [
|
nodeParams: [
|
||||||
{ name: "deviceId", type: "input", input: "", disabled: true }
|
{ name: "deviceId", type: "input", input: "", disabled: true },
|
||||||
|
{ name: "x", type: "input", input: "", disabled: true },
|
||||||
|
{ name: "y", type: "input", input: "", disabled: true },
|
||||||
|
{ name: "z", type: "input", input: "", disabled: true },
|
||||||
|
{ name: "rx", type: "input", input: "", disabled: true },
|
||||||
|
{ name: "ry", type: "input", input: "", disabled: true },
|
||||||
|
{ name: "rz", type: "input", input: "", disabled: true },
|
||||||
|
{ name: "velocity", type: "input", componentType: 'number', max: 5, step: 0.1, input: 0, disabled: true },
|
||||||
|
{ name: "acceleration", type: "input", componentType: 'number', max: 5, step: 0.1, input: 0, disabled: true },
|
||||||
],
|
],
|
||||||
outputType: 'json'
|
outputType: 'json'
|
||||||
},
|
},
|
||||||
|
|||||||
@ -54,7 +54,7 @@ import NodeTitle from "../../components/NodeTitle.vue";
|
|||||||
import NodeState from "../../components/NodeState.vue";
|
import NodeState from "../../components/NodeState.vue";
|
||||||
import "vue3-json-viewer/dist/index.css";
|
import "vue3-json-viewer/dist/index.css";
|
||||||
import { emitter } from "@/utils/eventBus";
|
import { emitter } from "@/utils/eventBus";
|
||||||
import IPlayer from "@/views/device/register/components/LogicFlow/IPlayer/index.vue";
|
import IPlayer from "@/components/IPlayer/index.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
model: Object,
|
model: Object,
|
||||||
|
|||||||
@ -50,7 +50,7 @@ import { ref, onMounted, onUnmounted } from "vue";
|
|||||||
import NodeTitle from "../../components/NodeTitle.vue";
|
import NodeTitle from "../../components/NodeTitle.vue";
|
||||||
import NodeState from "../../components/NodeState.vue";
|
import NodeState from "../../components/NodeState.vue";
|
||||||
import { emitter } from "@/utils/eventBus";
|
import { emitter } from "@/utils/eventBus";
|
||||||
import IPlayer from "@/views/device/register/components/LogicFlow/IPlayer/index.vue";
|
import IPlayer from "@/components/IPlayer/index.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
model: Object,
|
model: Object,
|
||||||
|
|||||||
@ -37,7 +37,7 @@ import NodeTitle from "../../components/NodeTitle.vue";
|
|||||||
import NodeState from "../../components/NodeState.vue";
|
import NodeState from "../../components/NodeState.vue";
|
||||||
import "vue3-json-viewer/dist/index.css";
|
import "vue3-json-viewer/dist/index.css";
|
||||||
import { emitter } from "@/utils/eventBus";
|
import { emitter } from "@/utils/eventBus";
|
||||||
import IPlayer from "@/views/device/register/components/LogicFlow/IPlayer/index.vue";
|
import IPlayer from "@/components/IPlayer/index.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
model: Object,
|
model: Object,
|
||||||
|
|||||||
@ -53,7 +53,7 @@ import NodeTitle from "../../components/NodeTitle.vue";
|
|||||||
import NodeState from "../../components/NodeState.vue";
|
import NodeState from "../../components/NodeState.vue";
|
||||||
import "vue3-json-viewer/dist/index.css";
|
import "vue3-json-viewer/dist/index.css";
|
||||||
import { emitter } from "@/utils/eventBus";
|
import { emitter } from "@/utils/eventBus";
|
||||||
import IPlayer from "@/views/device/register/components/LogicFlow/IPlayer/index.vue";
|
import IPlayer from "@/components/IPlayer/index.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
model: Object,
|
model: Object,
|
||||||
|
|||||||
@ -37,7 +37,7 @@ import NodeTitle from "../../components/NodeTitle.vue";
|
|||||||
import NodeState from "../../components/NodeState.vue";
|
import NodeState from "../../components/NodeState.vue";
|
||||||
import "vue3-json-viewer/dist/index.css";
|
import "vue3-json-viewer/dist/index.css";
|
||||||
import { emitter } from "@/utils/eventBus";
|
import { emitter } from "@/utils/eventBus";
|
||||||
import IPlayer from "@/views/device/register/components/LogicFlow/IPlayer/index.vue";
|
import IPlayer from "@/components/IPlayer/index.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
model: Object,
|
model: Object,
|
||||||
|
|||||||
@ -38,7 +38,7 @@ import NodeTitle from "../../components/NodeTitle.vue";
|
|||||||
import NodeState from "../../components/NodeState.vue";
|
import NodeState from "../../components/NodeState.vue";
|
||||||
import "vue3-json-viewer/dist/index.css";
|
import "vue3-json-viewer/dist/index.css";
|
||||||
import { emitter } from "@/utils/eventBus";
|
import { emitter } from "@/utils/eventBus";
|
||||||
import IPlayer from "@/views/device/register/components/LogicFlow/IPlayer/index.vue";
|
import IPlayer from "@/components/IPlayer/index.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
model: Object,
|
model: Object,
|
||||||
|
|||||||
@ -146,7 +146,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="video-container">
|
<div class="video-container">
|
||||||
<div v-if="activeMode === 'auto' || activeController === 1" class="map-container">地图</div>
|
<div v-if="activeMode === 'auto' || activeController === 1" class="map-container">地图</div>
|
||||||
<div v-if="activeMode === 'artificial' && activeController === 2" class="model-container">机械臂模型</div>
|
<div v-if="activeMode === 'artificial' && activeController === 2" class="model-container">
|
||||||
|
<UrdfViewer
|
||||||
|
ref="viewerRef"
|
||||||
|
base-path="/inspection/elfin10/"
|
||||||
|
model-color="#cbcbcb"
|
||||||
|
:show-controls="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div v-if="activeMode === 'artificial' && activeController === 3" class="monitor-container">监控</div>
|
<div v-if="activeMode === 'artificial' && activeController === 3" class="monitor-container">监控</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="right-container">
|
<div class="right-container">
|
||||||
@ -160,6 +167,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import SvgIcon from "@/components/SvgIcon";
|
import SvgIcon from "@/components/SvgIcon";
|
||||||
|
import UrdfViewer from './UrdfView.vue'
|
||||||
|
|
||||||
const activeBot = ref(0)
|
const activeBot = ref(0)
|
||||||
const changeBot = (index) => {
|
const changeBot = (index) => {
|
||||||
@ -191,6 +199,13 @@ const handlerArm = (value) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onLoaded(robot) {
|
||||||
|
console.log('已加载关节:', Object.keys(robot.joints))
|
||||||
|
}
|
||||||
|
function onError(err) {
|
||||||
|
console.error('加载失败:', err)
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, onUnmounted } from 'vue';
|
import { onMounted, onUnmounted, watch } from 'vue';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
worldToScreen,
|
worldToScreen,
|
||||||
@ -25,8 +25,17 @@ import {
|
|||||||
buildOccupancyGridImage,
|
buildOccupancyGridImage,
|
||||||
MobileRobot
|
MobileRobot
|
||||||
} from '../canvasUtils';
|
} from '../canvasUtils';
|
||||||
|
import { getMapJson } from '@/api/inspection/robot'
|
||||||
import { useInspectionStore } from "@/store/modules/inspection";
|
import { useInspectionStore } from "@/store/modules/inspection";
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
robotList: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const inspectionStore = useInspectionStore();
|
const inspectionStore = useInspectionStore();
|
||||||
|
|
||||||
|
|
||||||
@ -339,32 +348,35 @@ const centerCanvasView = () => {
|
|||||||
inspectionStore.camera.centerY = (map.originY + map.maxY) / 2;
|
inspectionStore.camera.centerY = (map.originY + map.maxY) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadSourceMap = async (url) => {
|
const loadSourceMap = async () => {
|
||||||
const response = await fetch(url);
|
const res = await getMapJson({
|
||||||
if (!response.ok) throw new Error(`加载失败:${response.status}`);
|
robotId: props.robotList[0].id,
|
||||||
return response.text();
|
mapName: props.robotList[0].robotMapName
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
try {
|
||||||
|
const rawJsonObject = JSON.parse(res.data); // 解析 JSON
|
||||||
|
const parsedMap = parseSmapJson(rawJsonObject); // 转换为内部结构
|
||||||
|
await loadMapIntoApplication(parsedMap); // 加载到应用
|
||||||
|
} catch (parseError) {
|
||||||
|
ElMessage.error('Parse error: ' + parseError.message); // 显示错误信息
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载 smap JSON 文件
|
*
|
||||||
|
* @param id 机器人编号
|
||||||
|
* @param name 机器人名称
|
||||||
|
* @param angleRad 角度
|
||||||
|
* @param color 颜色
|
||||||
*/
|
*/
|
||||||
const loadUserFile = async () => {
|
const addRobot = (id, name,x, y, angleRad, color) => {
|
||||||
try {
|
|
||||||
const fileTextContent = await loadSourceMap('/3.smap');
|
|
||||||
const rawJsonObject = JSON.parse(fileTextContent); // 解析 JSON
|
|
||||||
const parsedMap = parseSmapJson(rawJsonObject); // 转换为内部结构
|
|
||||||
await loadMapIntoApplication(parsedMap); // 加载到应用
|
|
||||||
} catch (parseError) {
|
|
||||||
alert('Parse error: ' + parseError.message); // 显示错误信息
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addRobot = (id, name, angleRad, color) => {
|
|
||||||
const map = applicationState.parsedMap;
|
const map = applicationState.parsedMap;
|
||||||
// 放置于地图中央附近
|
// 放置于地图中央附近
|
||||||
const centerX = (map.originX + map.maxX) / 2;
|
// const centerX = (map.originX + map.maxX) / 2;
|
||||||
const centerY = (map.originY + map.maxY) / 2;
|
// const centerY = (map.originY + map.maxY) / 2;
|
||||||
const robot = new MobileRobot(id, name, centerX + (Math.random() - 0.5) * 10, centerY + (Math.random() - 0.5) * 10, angleRad, color);
|
const robot = new MobileRobot(id, name, x, y, angleRad, color);
|
||||||
applicationState.robots.push(robot);
|
applicationState.robots.push(robot);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -376,19 +388,26 @@ const moveRobot = (robot, dx, dy, dtheta = 0) => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
init()
|
init()
|
||||||
// 启动渲染循环
|
|
||||||
loadUserFile()
|
|
||||||
requestAnimationFrame(renderFrame);
|
|
||||||
window.addEventListener('resize', resizeCanvasToContainer); // 监听窗口大小变化
|
|
||||||
resizeCanvasToContainer(); // 初始调用一次
|
|
||||||
setTimeout(() => {
|
|
||||||
bindEvent()
|
|
||||||
addRobot(1, '巡检机器人#1', 0, '#00D4FF')
|
|
||||||
addRobot(2, '巡检机器人#2', 0, '#FFB300')
|
|
||||||
addRobot(3, '巡检机器人#3', 0, '#00FF88')
|
|
||||||
}, 300)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(() => props.robotList,
|
||||||
|
(newVal) => {
|
||||||
|
if (props.robotList.length > 0) {
|
||||||
|
loadSourceMap()
|
||||||
|
requestAnimationFrame(renderFrame);
|
||||||
|
window.addEventListener('resize', resizeCanvasToContainer); // 监听窗口大小变化
|
||||||
|
resizeCanvasToContainer(); // 初始调用一次
|
||||||
|
setTimeout(() => {
|
||||||
|
bindEvent()
|
||||||
|
props.robotList.forEach(item => {
|
||||||
|
const [x, y, angle] = item.currentPosition.split(',')
|
||||||
|
addRobot(item.id, item.robotName, x, y, angle, '#00D4FF')
|
||||||
|
})
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
zoomCanvas,
|
zoomCanvas,
|
||||||
centerCanvasView
|
centerCanvasView
|
||||||
|
|||||||
@ -3,28 +3,28 @@
|
|||||||
<div class="left-container">
|
<div class="left-container">
|
||||||
<div class="robot-container">
|
<div class="robot-container">
|
||||||
<div class="robot-state">机器人状态</div>
|
<div class="robot-state">机器人状态</div>
|
||||||
<div class="robot-info" v-for="item, index in 2">
|
<div class="robot-info" v-for="item, index in robotList">
|
||||||
<div class="title-box">
|
<div class="title-box">
|
||||||
<div><SvgIcon name="bot" :color="index === 0 ? '#00D4FF' : '#FFB300'" /></div>
|
<div><SvgIcon name="bot" :color="index === 0 ? '#00D4FF' : '#FFB300'" /></div>
|
||||||
<div class="title">巡检机器人#{{ index }}</div>
|
<div class="title">{{ item.robotName }}</div>
|
||||||
<div class="state" :class="robotState">在线</div>
|
<div class="state online">{{ robotStatus[item.status] }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<div class="item-box">
|
<div class="item-box">
|
||||||
<div class="item-title">编号</div>
|
<div class="item-title">编号</div>
|
||||||
<div class="item-detail">RB-2024-001</div>
|
<div class="item-detail">{{ item.robotCode }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-box">
|
<div class="item-box">
|
||||||
<div class="item-title">当前任务</div>
|
<div class="item-title">当前任务</div>
|
||||||
<div class="item-detail">厂区A-定时巡检</div>
|
<div class="item-detail">{{ getRunningTaskByRobotId(item.id) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-box">
|
<div class="item-box">
|
||||||
<div class="item-title">电量</div>
|
<div class="item-title">电量</div>
|
||||||
<div class="item-detail">78%</div>
|
<div class="item-detail">{{ item.batteryLevel }}%</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-box">
|
<div class="item-box">
|
||||||
<div class="item-title">速度</div>
|
<div class="item-title">IP端口</div>
|
||||||
<div class="item-detail">0.8m/s</div>
|
<div class="item-detail">{{ `${item.ipAddress}: ${item.port}` }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -32,11 +32,11 @@
|
|||||||
<div class="split-line"></div>
|
<div class="split-line"></div>
|
||||||
<div class="task-container">
|
<div class="task-container">
|
||||||
<div class="container-title">当前任务</div>
|
<div class="container-title">当前任务</div>
|
||||||
<div class="task-box" v-for="item, index in 2">
|
<div class="task-box" v-for="item, index in taskList">
|
||||||
<div class="title">巡检机器人#{{ index }}</div>
|
<div class="title">{{ item.taskName }}</div>
|
||||||
<div class="task-info">
|
<div class="task-info">
|
||||||
<div>执行中</div>
|
<div>{{ taskStatus[item.status] }}</div>
|
||||||
<div>厂区A定时巡检</div>
|
<div>{{ item.remark }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -61,7 +61,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="map-box">
|
<div class="map-box">
|
||||||
<MapCanvas ref="mapCanvasRef" />
|
<MapCanvas :robotList="robotList" ref="mapCanvasRef" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="right-container">
|
<div class="right-container">
|
||||||
@ -84,10 +84,66 @@
|
|||||||
import SvgIcon from "@/components/SvgIcon";
|
import SvgIcon from "@/components/SvgIcon";
|
||||||
import MapCanvas from "./MapCanvas.vue";
|
import MapCanvas from "./MapCanvas.vue";
|
||||||
import { onMounted, onUnmounted } from "vue";
|
import { onMounted, onUnmounted } from "vue";
|
||||||
|
import { getRobotList } from '@/api/inspection/robot'
|
||||||
|
import { getRunTaskList } from '@/api/inspection/runTask'
|
||||||
|
|
||||||
console.log('sss', import.meta.env.VITE_INSPECTION_TYPE)
|
console.log('sss', import.meta.env.VITE_INSPECTION_TYPE)
|
||||||
|
|
||||||
const robotState = ref('online')
|
const robotList = ref([])
|
||||||
|
const robotStatus = {
|
||||||
|
"0": "在线",
|
||||||
|
"1": "离线",
|
||||||
|
"2": "充电中",
|
||||||
|
"3": "巡检中"
|
||||||
|
}
|
||||||
|
const getRobot = async () => {
|
||||||
|
let robotType
|
||||||
|
if (import.meta.env.VITE_INSPECTION_TYPE = 'inspection') {
|
||||||
|
robotType = '2'
|
||||||
|
} else {
|
||||||
|
robotType = '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await getRobotList({
|
||||||
|
robotType
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.code === 200) {
|
||||||
|
robotList.value = res.rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskList = ref([])
|
||||||
|
const taskStatus = {
|
||||||
|
"0": "已完成",
|
||||||
|
"1": "执行中",
|
||||||
|
"2": "失败",
|
||||||
|
"3": "已暂停",
|
||||||
|
"4": "已终止",
|
||||||
|
"5": "未执行"
|
||||||
|
}
|
||||||
|
const getRunningTask = async () => {
|
||||||
|
let taskType
|
||||||
|
if (import.meta.env.VITE_INSPECTION_TYPE = 'inspection') {
|
||||||
|
taskType = '1'
|
||||||
|
} else {
|
||||||
|
taskType = '2'
|
||||||
|
}
|
||||||
|
const res = await getRunTaskList({
|
||||||
|
taskType
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
taskList.value = res.rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRunningTaskByRobotId = (robotId) => {
|
||||||
|
const task = taskList.value.find(item => item.robotId === robotId)
|
||||||
|
if (task) {
|
||||||
|
return task.taskName
|
||||||
|
}
|
||||||
|
return '暂无'
|
||||||
|
}
|
||||||
|
|
||||||
const mapCanvasRef = ref(null)
|
const mapCanvasRef = ref(null)
|
||||||
|
|
||||||
@ -102,6 +158,11 @@ const centerCanvasView = () => {
|
|||||||
mapCanvasRef.value.centerCanvasView();
|
mapCanvasRef.value.centerCanvasView();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
getRobot()
|
||||||
|
getRunningTask()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|||||||
588
src/views/inspection/cockpit/components/UrdfView.vue
Normal file
588
src/views/inspection/cockpit/components/UrdfView.vue
Normal file
@ -0,0 +1,588 @@
|
|||||||
|
<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 class="control-panel" v-if="!isLoading && jointControls.length > 0">
|
||||||
|
<h4>关节控制</h4>
|
||||||
|
<div class="joint-list">
|
||||||
|
<div v-for="joint in jointControls" :key="joint.name" class="joint-item">
|
||||||
|
<label :for="joint.name">{{ joint.displayName }}</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
:id="joint.name"
|
||||||
|
:min="joint.min"
|
||||||
|
:max="joint.max"
|
||||||
|
:step="0.01"
|
||||||
|
v-model.number="joint.value"
|
||||||
|
@input="updateJoint(joint.name, joint.value)"
|
||||||
|
/>
|
||||||
|
<span class="angle">{{ (joint.value * 180 / Math.PI).toFixed(1) }}°</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button @click="resetJoints" class="reset-btn">重置关节</button>
|
||||||
|
</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';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
basePath: { type: String, default: '/inspection/elfin10' },
|
||||||
|
modelColor: { type: String, default: '#cbcbcb' },
|
||||||
|
showControls: { type: Boolean, default: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewerContainer = ref(null);
|
||||||
|
const isLoading = ref(true);
|
||||||
|
const loadingMessage = ref('初始化...');
|
||||||
|
|
||||||
|
let scene, camera, renderer, controls;
|
||||||
|
let robotGroup = null;
|
||||||
|
const jointMap = new Map();
|
||||||
|
const animationId = ref(null);
|
||||||
|
const jointControls = reactive([]);
|
||||||
|
|
||||||
|
// ============ XACRO 解析器 ============
|
||||||
|
class XacroParser {
|
||||||
|
constructor() {
|
||||||
|
this.variables = new Map();
|
||||||
|
this.macros = new Map();
|
||||||
|
this.includes = [];
|
||||||
|
this.robotXml = null;
|
||||||
|
}
|
||||||
|
parse(xacroContent) {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const xmlDoc = parser.parseFromString(xacroContent, 'text/xml');
|
||||||
|
const properties = xmlDoc.getElementsByTagName('xacro:property');
|
||||||
|
for (const prop of properties) {
|
||||||
|
const name = prop.getAttribute('name');
|
||||||
|
const value = prop.getAttribute('value');
|
||||||
|
if (name && value) this.variables.set(name, this.evaluateExpression(value));
|
||||||
|
}
|
||||||
|
const includes = xmlDoc.getElementsByTagName('xacro:include');
|
||||||
|
for (const inc of includes) {
|
||||||
|
const filename = inc.getAttribute('filename');
|
||||||
|
if (filename) this.includes.push(filename);
|
||||||
|
}
|
||||||
|
const macros = xmlDoc.getElementsByTagName('xacro:macro');
|
||||||
|
for (const macro of macros) {
|
||||||
|
const name = macro.getAttribute('name');
|
||||||
|
const params = macro.getAttribute('params') || '';
|
||||||
|
if (name) {
|
||||||
|
this.macros.set(name, {
|
||||||
|
params: params.split(/\s+/).filter(p => p),
|
||||||
|
content: macro.innerHTML
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.robotXml = this.processRobotXml(xmlDoc);
|
||||||
|
return this.robotXml;
|
||||||
|
}
|
||||||
|
processRobotXml(xmlDoc) {
|
||||||
|
const doc = xmlDoc.cloneNode(true);
|
||||||
|
const allElements = Array.from(doc.getElementsByTagName('*'));
|
||||||
|
for (const elem of allElements) {
|
||||||
|
if (elem.tagName.toLowerCase().startsWith('xacro:')) {
|
||||||
|
elem.parentNode?.removeChild(elem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const remaining = Array.from(doc.getElementsByTagName('*'));
|
||||||
|
for (const elem of remaining) {
|
||||||
|
for (const attr of elem.attributes) {
|
||||||
|
if (attr.value?.includes('${')) attr.value = this.replaceVariables(attr.value);
|
||||||
|
}
|
||||||
|
if (elem.textContent?.includes('${')) elem.textContent = this.replaceVariables(elem.textContent);
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
replaceVariables(text) {
|
||||||
|
return text.replace(/\$\{([^}]+)\}/g, (match, expr) => this.evaluateExpression(expr));
|
||||||
|
}
|
||||||
|
evaluateExpression(expr) {
|
||||||
|
expr = expr.trim();
|
||||||
|
if (!expr) return '0';
|
||||||
|
try {
|
||||||
|
for (const [key, value] of this.variables) {
|
||||||
|
if (key.length === 0) continue;
|
||||||
|
const regex = new RegExp('\\b' + this.escapeRegex(key) + '\\b', 'g');
|
||||||
|
expr = expr.replace(regex, '(' + value + ')');
|
||||||
|
}
|
||||||
|
expr = expr.replace(/\bPI\b/g, '(' + Math.PI + ')');
|
||||||
|
expr = expr.replace(/\bpi\b/g, '(' + Math.PI + ')');
|
||||||
|
const sanitized = expr.replace(/\s+/g, '');
|
||||||
|
if (/^[\d+\-*/().eE]+$/.test(sanitized)) {
|
||||||
|
const result = Function('"use strict"; return (' + sanitized + ')')();
|
||||||
|
if (typeof result === 'number' && isFinite(result)) return result.toString();
|
||||||
|
}
|
||||||
|
console.warn('表达式求值失败,返回原值:', expr);
|
||||||
|
return expr;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('表达式求值异常:', expr, e);
|
||||||
|
return expr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
escapeRegex(str) {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
getRobotXml() { return this.robotXml; }
|
||||||
|
getIncludes() { return this.includes; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 工具函数 ============
|
||||||
|
const cleanMeshPath = (path) => {
|
||||||
|
if (!path) return '';
|
||||||
|
path = path.replace(/^file:\/\//, '');
|
||||||
|
path = path.replace(/^package:\/\/[^/]+\//, '');
|
||||||
|
const findMatch = path.match(/\$\(find\s+([^)]+)\)/);
|
||||||
|
if (findMatch) path = path.replace(/\$\(find\s+[^)]+\)/, '');
|
||||||
|
path = path.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseXYZ = (str) => {
|
||||||
|
if (!str) return { x: 0, y: 0, z: 0 };
|
||||||
|
const [x, y, z] = str.trim().split(/\s+/).map(Number);
|
||||||
|
return { x: x || 0, y: y || 0, z: z || 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseRPY = (str) => {
|
||||||
|
if (!str) return { roll: 0, pitch: 0, yaw: 0 };
|
||||||
|
const [r, p, y] = str.trim().split(/\s+/).map(Number);
|
||||||
|
return { roll: r || 0, pitch: p || 0, yaw: y || 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ★ 正确转换 RPY → 四元数(URDF 固定轴 XYZ = Three.js 局部轴 ZYX)
|
||||||
|
const rpyToQuaternion = (rpy) => {
|
||||||
|
const euler = new THREE.Euler(rpy.roll, rpy.pitch, rpy.yaw, 'ZYX');
|
||||||
|
return new THREE.Quaternion().setFromEuler(euler);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ STL 加载 ============
|
||||||
|
const loadSTLWithRetry = (url, group, visualPos, visualQuat) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const loader = new STLLoader();
|
||||||
|
const loadMesh = (attemptUrl) => {
|
||||||
|
loader.load(
|
||||||
|
attemptUrl,
|
||||||
|
(geometry) => {
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
const material = new THREE.MeshStandardMaterial({
|
||||||
|
color: props.modelColor,
|
||||||
|
roughness: 0.4,
|
||||||
|
metalness: 0.3,
|
||||||
|
});
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
mesh.position.set(visualPos.x, visualPos.y, visualPos.z);
|
||||||
|
mesh.quaternion.copy(visualQuat); // 使用四元数
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
while (group.children.length > 0) {
|
||||||
|
const child = group.children[0];
|
||||||
|
if (child.isMesh) {
|
||||||
|
child.geometry?.dispose();
|
||||||
|
child.material?.dispose();
|
||||||
|
}
|
||||||
|
group.remove(child);
|
||||||
|
}
|
||||||
|
group.add(mesh);
|
||||||
|
resolve(mesh);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
(error) => {
|
||||||
|
console.warn(`加载STL失败: ${attemptUrl}`, error);
|
||||||
|
if (!attemptUrl.startsWith('/') && !attemptUrl.startsWith('http')) {
|
||||||
|
loadMesh('/' + attemptUrl);
|
||||||
|
} else {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
loadMesh(url);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ URDF 解析与构建(严格修复) ============
|
||||||
|
const parseAndBuildRobot = async (urdfDoc) => {
|
||||||
|
const links = urdfDoc.getElementsByTagName('link');
|
||||||
|
const jointsRaw = urdfDoc.getElementsByTagName('joint');
|
||||||
|
const joints = sortJointsTopologically(jointsRaw, links);
|
||||||
|
const linkGroupMap = new Map();
|
||||||
|
const mainGroup = new THREE.Group();
|
||||||
|
mainGroup.name = 'robot';
|
||||||
|
|
||||||
|
loadingMessage.value = '创建链接结构...';
|
||||||
|
for (const link of links) {
|
||||||
|
const name = link.getAttribute('name');
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = name;
|
||||||
|
const visual = link.getElementsByTagName('visual')[0];
|
||||||
|
if (visual) {
|
||||||
|
const origin = visual.getElementsByTagName('origin')[0];
|
||||||
|
const geometry = visual.getElementsByTagName('geometry')[0];
|
||||||
|
const meshTag = geometry?.getElementsByTagName('mesh')[0];
|
||||||
|
if (meshTag) {
|
||||||
|
let meshPath = cleanMeshPath(meshTag.getAttribute('filename'));
|
||||||
|
let visualPos = { x: 0, y: 0, z: 0 };
|
||||||
|
let visualQuat = new THREE.Quaternion();
|
||||||
|
if (origin) {
|
||||||
|
const rawXyz = parseXYZ(origin.getAttribute('xyz') || '0 0 0');
|
||||||
|
const rawRpy = parseRPY(origin.getAttribute('rpy') || '0 0 0');
|
||||||
|
visualPos = rawXyz;
|
||||||
|
visualQuat = rpyToQuaternion(rawRpy); // ★ 使用正确顺序
|
||||||
|
}
|
||||||
|
group.userData = {
|
||||||
|
meshPath: `${props.basePath}${meshPath}`,
|
||||||
|
visualPos,
|
||||||
|
visualQuat
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
linkGroupMap.set(name, group);
|
||||||
|
mainGroup.add(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingMessage.value = '加载3D模型文件...';
|
||||||
|
const loadPromises = [];
|
||||||
|
for (const [name, group] of linkGroupMap) {
|
||||||
|
if (group.userData.meshPath) {
|
||||||
|
loadPromises.push(
|
||||||
|
loadSTLWithRetry(group.userData.meshPath, group, group.userData.visualPos, group.userData.visualQuat)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.allSettled(loadPromises);
|
||||||
|
|
||||||
|
loadingMessage.value = '构建关节结构...';
|
||||||
|
const jointDataList = [];
|
||||||
|
for (const joint of joints) {
|
||||||
|
const type = joint.getAttribute('type');
|
||||||
|
const name = joint.getAttribute('name');
|
||||||
|
const parent = joint.getElementsByTagName('parent')[0]?.getAttribute('link');
|
||||||
|
const child = joint.getElementsByTagName('child')[0]?.getAttribute('link');
|
||||||
|
const origin = joint.getElementsByTagName('origin')[0];
|
||||||
|
let xyz = { x: 0, y: 0, z: 0 };
|
||||||
|
let rpy = { roll: 0, pitch: 0, yaw: 0 };
|
||||||
|
if (origin) {
|
||||||
|
xyz = parseXYZ(origin.getAttribute('xyz') || '0 0 0');
|
||||||
|
rpy = parseRPY(origin.getAttribute('rpy') || '0 0 0');
|
||||||
|
}
|
||||||
|
const axis = joint.getElementsByTagName('axis')[0];
|
||||||
|
const axisVec = parseXYZ(axis?.getAttribute('xyz') || '0 0 1');
|
||||||
|
const limit = joint.getElementsByTagName('limit')[0];
|
||||||
|
const min = limit ? parseFloat(limit.getAttribute('lower')) : -Math.PI;
|
||||||
|
const max = limit ? parseFloat(limit.getAttribute('upper')) : Math.PI;
|
||||||
|
jointDataList.push({
|
||||||
|
name, type, parent, child, xyz, rpy,
|
||||||
|
axis: new THREE.Vector3(axisVec.x, axisVec.y, axisVec.z),
|
||||||
|
min, max
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建层级(Euler 顺序 ZYX)
|
||||||
|
for (const jointData of jointDataList) {
|
||||||
|
const { name, type, parent, child, xyz, rpy, axis, min, max } = jointData;
|
||||||
|
const parentGroup = linkGroupMap.get(parent);
|
||||||
|
const childGroup = linkGroupMap.get(child);
|
||||||
|
if (!parentGroup || !childGroup) {
|
||||||
|
console.warn(`关节 ${name} 的父或子链接不存在`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainGroup.children.includes(childGroup)) mainGroup.remove(childGroup);
|
||||||
|
if (parentGroup.children.includes(childGroup)) parentGroup.remove(childGroup);
|
||||||
|
|
||||||
|
if (type === 'revolute' || type === 'continuous') {
|
||||||
|
const varGroup = new THREE.Group();
|
||||||
|
varGroup.name = name + '_var';
|
||||||
|
|
||||||
|
const originGroup = new THREE.Group();
|
||||||
|
originGroup.name = name + '_origin';
|
||||||
|
originGroup.position.set(xyz.x, xyz.y, xyz.z);
|
||||||
|
// ★ 使用正确顺序
|
||||||
|
const euler = new THREE.Euler(rpy.roll, rpy.pitch, rpy.yaw, 'ZYX');
|
||||||
|
originGroup.quaternion.setFromEuler(euler);
|
||||||
|
|
||||||
|
originGroup.add(childGroup);
|
||||||
|
varGroup.add(originGroup);
|
||||||
|
jointMap.set(name, {
|
||||||
|
name,
|
||||||
|
group: varGroup,
|
||||||
|
axis: axis.clone().normalize(), // 仍为 Z-up 轴,稍后在 loadRobot 中统一变换
|
||||||
|
min, max,
|
||||||
|
value: 0,
|
||||||
|
displayName: name.replace(/_/g, ' ').replace('elfin ', '')
|
||||||
|
});
|
||||||
|
parentGroup.add(varGroup);
|
||||||
|
} else {
|
||||||
|
// 固定关节
|
||||||
|
childGroup.position.set(xyz.x, xyz.y, xyz.z);
|
||||||
|
const euler = new THREE.Euler(rpy.roll, rpy.pitch, rpy.yaw, 'ZYX');
|
||||||
|
childGroup.quaternion.setFromEuler(euler);
|
||||||
|
parentGroup.add(childGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mainGroup;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ 拓扑排序 ============
|
||||||
|
const sortJointsTopologically = (joints, links) => {
|
||||||
|
const childToJoint = new Map();
|
||||||
|
const allLinkNames = new Set();
|
||||||
|
for (const link of links) allLinkNames.add(link.getAttribute('name'));
|
||||||
|
const jointArray = Array.from(joints);
|
||||||
|
for (const joint of jointArray) {
|
||||||
|
const child = joint.getElementsByTagName('child')[0]?.getAttribute('link');
|
||||||
|
if (child) childToJoint.set(child, joint);
|
||||||
|
}
|
||||||
|
const childLinks = new Set(childToJoint.keys());
|
||||||
|
const rootLinks = [...allLinkNames].filter(name => !childLinks.has(name));
|
||||||
|
const sorted = [];
|
||||||
|
const queue = [...rootLinks];
|
||||||
|
const visited = new Set();
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const linkName = queue.shift();
|
||||||
|
for (const joint of jointArray) {
|
||||||
|
const parent = joint.getElementsByTagName('parent')[0]?.getAttribute('link');
|
||||||
|
const child = joint.getElementsByTagName('child')[0]?.getAttribute('link');
|
||||||
|
if (parent === linkName && !visited.has(joint.getAttribute('name'))) {
|
||||||
|
visited.add(joint.getAttribute('name'));
|
||||||
|
sorted.push(joint);
|
||||||
|
if (child) queue.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const joint of jointArray) {
|
||||||
|
if (!visited.has(joint.getAttribute('name'))) sorted.push(joint);
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ 场景初始化 ============
|
||||||
|
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(0x1a1a2e);
|
||||||
|
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
|
||||||
|
camera.position.set(2, 2, 3);
|
||||||
|
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||||
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
renderer.toneMappingExposure = 1.2;
|
||||||
|
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(0x404060, 0.6);
|
||||||
|
scene.add(ambient);
|
||||||
|
const main = new THREE.DirectionalLight(0xffffff, 1.0);
|
||||||
|
main.position.set(5, 10, 7);
|
||||||
|
main.castShadow = true;
|
||||||
|
scene.add(main);
|
||||||
|
const fill = new THREE.DirectionalLight(0x8888ff, 0.4);
|
||||||
|
fill.position.set(-5, 0, 5);
|
||||||
|
scene.add(fill);
|
||||||
|
const rim = new THREE.DirectionalLight(0xffffff, 0.3);
|
||||||
|
rim.position.set(0, -5, 5);
|
||||||
|
scene.add(rim);
|
||||||
|
const grid = new THREE.GridHelper(3, 20, 0x444466, 0x333355);
|
||||||
|
grid.position.y = -0.5;
|
||||||
|
scene.add(grid);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ 关节控制(无需修改) ============
|
||||||
|
const updateJoint = (jointName, angle) => {
|
||||||
|
const joint = jointMap.get(jointName);
|
||||||
|
if (!joint) return;
|
||||||
|
const clampedAngle = Math.max(joint.min, Math.min(joint.max, angle));
|
||||||
|
joint.value = clampedAngle;
|
||||||
|
|
||||||
|
joint.group.quaternion.identity();
|
||||||
|
const axis = joint.axis.clone().normalize();
|
||||||
|
joint.group.quaternion.setFromAxisAngle(axis, clampedAngle); // 正负根据实际情况调整
|
||||||
|
|
||||||
|
const control = jointControls.find(j => j.name === jointName);
|
||||||
|
if (control) control.value = clampedAngle;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetJoints = () => {
|
||||||
|
for (const joint of jointControls) updateJoint(joint.name, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ 加载机器人(轴变换加入) ============
|
||||||
|
const loadRobot = async () => {
|
||||||
|
try {
|
||||||
|
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();
|
||||||
|
loadingMessage.value = '解析XACRO...';
|
||||||
|
const parser = new XacroParser();
|
||||||
|
const robotDoc = parser.parse(xacroContent);
|
||||||
|
loadingMessage.value = '构建机器人模型...';
|
||||||
|
robotGroup = await parseAndBuildRobot(robotDoc);
|
||||||
|
if (robotGroup) {
|
||||||
|
scene.add(robotGroup);
|
||||||
|
// ★ 全局旋转:Z-up → Y-up
|
||||||
|
robotGroup.rotation.x = -Math.PI / 2;
|
||||||
|
|
||||||
|
// ★ 关键:将所有关节轴从 Z-up 坐标系变换到 Y-up 坐标系
|
||||||
|
const globalQuat = new THREE.Quaternion().setFromEuler(
|
||||||
|
new THREE.Euler(-Math.PI / 2, 0, 0, 'XYZ')
|
||||||
|
);
|
||||||
|
const globalQuat1 = new THREE.Quaternion().setFromEuler(
|
||||||
|
new THREE.Euler(-Math.PI / 2, 0, 0, 'ZXY')
|
||||||
|
);
|
||||||
|
for (const joint of jointMap.values()) {
|
||||||
|
if (['joint2', 'joint6'].includes(joint.displayName)) {
|
||||||
|
joint.axis.applyQuaternion(globalQuat).normalize();
|
||||||
|
}
|
||||||
|
if (['joint3', 'joint4', 'joint5'].includes(joint.displayName)) {
|
||||||
|
joint.axis.applyQuaternion(globalQuat1).normalize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jointControls.length = 0;
|
||||||
|
for (const [name, joint] of jointMap) {
|
||||||
|
jointControls.push({
|
||||||
|
name,
|
||||||
|
displayName: joint.displayName,
|
||||||
|
value: 0,
|
||||||
|
min: joint.min,
|
||||||
|
max: joint.max
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fitCamera();
|
||||||
|
}
|
||||||
|
isLoading.value = false;
|
||||||
|
loadingMessage.value = '加载完成';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载机器人失败:', error);
|
||||||
|
isLoading.value = false;
|
||||||
|
loadingMessage.value = `加载失败: ${error.message}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fitCamera = () => {
|
||||||
|
if (!robotGroup) return;
|
||||||
|
const box = new THREE.Box3().setFromObject(robotGroup);
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z);
|
||||||
|
if (maxDim === 0) return;
|
||||||
|
const distance = maxDim / (2 * Math.tan((camera.fov * Math.PI) / 360));
|
||||||
|
const direction = new THREE.Vector3(1, 1, 1).normalize();
|
||||||
|
camera.position.copy(center).add(direction.multiplyScalar(distance * 1.5));
|
||||||
|
controls.target.copy(center);
|
||||||
|
controls.update();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ 动画循环 ============
|
||||||
|
const animate = () => {
|
||||||
|
animationId.value = requestAnimationFrame(animate);
|
||||||
|
controls?.update();
|
||||||
|
renderer?.render(scene, camera);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ 生命周期 ============
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick();
|
||||||
|
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, jointMap });
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
.control-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: rgba(20, 20, 40, 0.85);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.joint-item label {
|
||||||
|
width: 120px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.joint-item input[type=range] {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.angle {
|
||||||
|
width: 50px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.reset-btn {
|
||||||
|
margin-top: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -128,6 +128,9 @@
|
|||||||
:value="robot.id" />
|
:value="robot.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="备注" prop="remark">
|
||||||
|
<el-input type="textarea" v-model="form.remark" />
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
@ -442,6 +445,7 @@ const subscribeDebounced = debounce(async (sub) => {
|
|||||||
delete taskRunningInfo.value[data.taskInstanceId]
|
delete taskRunningInfo.value[data.taskInstanceId]
|
||||||
delete taskLogList.value[data.taskInstanceId]
|
delete taskLogList.value[data.taskInstanceId]
|
||||||
getList()
|
getList()
|
||||||
|
getHistoryList()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 错误处理:取消订阅后重新订阅
|
// 错误处理:取消订阅后重新订阅
|
||||||
|
|||||||
@ -49,8 +49,8 @@ export default defineConfig(({mode, command}) => {
|
|||||||
'/dev-api': {
|
'/dev-api': {
|
||||||
//李小龙 http://192.168.0.201:13080
|
//李小龙 http://192.168.0.201:13080
|
||||||
// dev http://10.148.20.34:13080
|
// dev http://10.148.20.34:13080
|
||||||
// target: VITE_API_URL,
|
target: VITE_API_URL,
|
||||||
target: command === 'build' ? VITE_API_URL : 'http://192.168.0.201:13080',
|
// target: command === 'build' ? VITE_API_URL : 'http://192.168.0.201:13080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (p) => p.replace(/^\/dev-api/, '')
|
rewrite: (p) => p.replace(/^\/dev-api/, '')
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user