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_API_URL = http://192.168.28.10:13080
|
||||
VITE_WS_URL = ws://192.168.0.201:13080/ws
|
||||
VITE_API_URL = http://192.168.1.101:13080
|
||||
VITE_WS_URL = ws://192.168.1.101:13080/ws
|
||||
|
||||
# explanation
|
||||
VITE_INSPECTION_TYPE = "inspection"
|
||||
@ -61,5 +61,6 @@
|
||||
"vite-plugin-compression": "0.5.1",
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"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(() => {
|
||||
console.log(34566)
|
||||
emitter.off("deleteNode");
|
||||
})
|
||||
</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 },
|
||||
{ name: "body", type: "input", input: "", children: [
|
||||
{ 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: []}
|
||||
], required: true },
|
||||
],
|
||||
@ -363,14 +363,38 @@ function handler(params) {
|
||||
{
|
||||
collapseTitle: "机器人",
|
||||
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,
|
||||
name: "机械臂",
|
||||
type: "serviceNode",
|
||||
desc: "控制机器人的手臂",
|
||||
action: 'ALM',
|
||||
nodeType: 'EDGE',
|
||||
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'
|
||||
},
|
||||
|
||||
@ -54,7 +54,7 @@ import NodeTitle from "../../components/NodeTitle.vue";
|
||||
import NodeState from "../../components/NodeState.vue";
|
||||
import "vue3-json-viewer/dist/index.css";
|
||||
import { emitter } from "@/utils/eventBus";
|
||||
import IPlayer from "@/views/device/register/components/LogicFlow/IPlayer/index.vue";
|
||||
import IPlayer from "@/components/IPlayer/index.vue";
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
|
||||
@ -50,7 +50,7 @@ import { ref, onMounted, onUnmounted } from "vue";
|
||||
import NodeTitle from "../../components/NodeTitle.vue";
|
||||
import NodeState from "../../components/NodeState.vue";
|
||||
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({
|
||||
model: Object,
|
||||
|
||||
@ -37,7 +37,7 @@ import NodeTitle from "../../components/NodeTitle.vue";
|
||||
import NodeState from "../../components/NodeState.vue";
|
||||
import "vue3-json-viewer/dist/index.css";
|
||||
import { emitter } from "@/utils/eventBus";
|
||||
import IPlayer from "@/views/device/register/components/LogicFlow/IPlayer/index.vue";
|
||||
import IPlayer from "@/components/IPlayer/index.vue";
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
|
||||
@ -53,7 +53,7 @@ import NodeTitle from "../../components/NodeTitle.vue";
|
||||
import NodeState from "../../components/NodeState.vue";
|
||||
import "vue3-json-viewer/dist/index.css";
|
||||
import { emitter } from "@/utils/eventBus";
|
||||
import IPlayer from "@/views/device/register/components/LogicFlow/IPlayer/index.vue";
|
||||
import IPlayer from "@/components/IPlayer/index.vue";
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
|
||||
@ -37,7 +37,7 @@ import NodeTitle from "../../components/NodeTitle.vue";
|
||||
import NodeState from "../../components/NodeState.vue";
|
||||
import "vue3-json-viewer/dist/index.css";
|
||||
import { emitter } from "@/utils/eventBus";
|
||||
import IPlayer from "@/views/device/register/components/LogicFlow/IPlayer/index.vue";
|
||||
import IPlayer from "@/components/IPlayer/index.vue";
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
|
||||
@ -38,7 +38,7 @@ import NodeTitle from "../../components/NodeTitle.vue";
|
||||
import NodeState from "../../components/NodeState.vue";
|
||||
import "vue3-json-viewer/dist/index.css";
|
||||
import { emitter } from "@/utils/eventBus";
|
||||
import IPlayer from "@/views/device/register/components/LogicFlow/IPlayer/index.vue";
|
||||
import IPlayer from "@/components/IPlayer/index.vue";
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
|
||||
@ -146,7 +146,14 @@
|
||||
</div>
|
||||
<div class="video-container">
|
||||
<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>
|
||||
<div class="right-container">
|
||||
@ -160,6 +167,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import SvgIcon from "@/components/SvgIcon";
|
||||
import UrdfViewer from './UrdfView.vue'
|
||||
|
||||
const activeBot = ref(0)
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
import { onMounted, onUnmounted, watch } from 'vue';
|
||||
|
||||
import {
|
||||
worldToScreen,
|
||||
@ -25,8 +25,17 @@ import {
|
||||
buildOccupancyGridImage,
|
||||
MobileRobot
|
||||
} from '../canvasUtils';
|
||||
|
||||
import { getMapJson } from '@/api/inspection/robot'
|
||||
import { useInspectionStore } from "@/store/modules/inspection";
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const props = defineProps({
|
||||
robotList: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const inspectionStore = useInspectionStore();
|
||||
|
||||
|
||||
@ -339,32 +348,35 @@ const centerCanvasView = () => {
|
||||
inspectionStore.camera.centerY = (map.originY + map.maxY) / 2;
|
||||
}
|
||||
|
||||
const loadSourceMap = async (url) => {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`加载失败:${response.status}`);
|
||||
return response.text();
|
||||
const loadSourceMap = async () => {
|
||||
const res = await getMapJson({
|
||||
robotId: props.robotList[0].id,
|
||||
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 () => {
|
||||
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 addRobot = (id, name,x, y, angleRad, color) => {
|
||||
const map = applicationState.parsedMap;
|
||||
// 放置于地图中央附近
|
||||
const centerX = (map.originX + map.maxX) / 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 centerX = (map.originX + map.maxX) / 2;
|
||||
// const centerY = (map.originY + map.maxY) / 2;
|
||||
const robot = new MobileRobot(id, name, x, y, angleRad, color);
|
||||
applicationState.robots.push(robot);
|
||||
}
|
||||
|
||||
@ -376,19 +388,26 @@ const moveRobot = (robot, dx, dy, dtheta = 0) => {
|
||||
|
||||
onMounted(() => {
|
||||
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({
|
||||
zoomCanvas,
|
||||
centerCanvasView
|
||||
|
||||
@ -3,28 +3,28 @@
|
||||
<div class="left-container">
|
||||
<div class="robot-container">
|
||||
<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><SvgIcon name="bot" :color="index === 0 ? '#00D4FF' : '#FFB300'" /></div>
|
||||
<div class="title">巡检机器人#{{ index }}</div>
|
||||
<div class="state" :class="robotState">在线</div>
|
||||
<div class="title">{{ item.robotName }}</div>
|
||||
<div class="state online">{{ robotStatus[item.status] }}</div>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<div class="item-box">
|
||||
<div class="item-title">编号</div>
|
||||
<div class="item-detail">RB-2024-001</div>
|
||||
<div class="item-detail">{{ item.robotCode }}</div>
|
||||
</div>
|
||||
<div class="item-box">
|
||||
<div class="item-title">当前任务</div>
|
||||
<div class="item-detail">厂区A-定时巡检</div>
|
||||
<div class="item-detail">{{ getRunningTaskByRobotId(item.id) }}</div>
|
||||
</div>
|
||||
<div class="item-box">
|
||||
<div class="item-title">电量</div>
|
||||
<div class="item-detail">78%</div>
|
||||
<div class="item-detail">{{ item.batteryLevel }}%</div>
|
||||
</div>
|
||||
<div class="item-box">
|
||||
<div class="item-title">速度</div>
|
||||
<div class="item-detail">0.8m/s</div>
|
||||
<div class="item-title">IP端口</div>
|
||||
<div class="item-detail">{{ `${item.ipAddress}: ${item.port}` }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -32,11 +32,11 @@
|
||||
<div class="split-line"></div>
|
||||
<div class="task-container">
|
||||
<div class="container-title">当前任务</div>
|
||||
<div class="task-box" v-for="item, index in 2">
|
||||
<div class="title">巡检机器人#{{ index }}</div>
|
||||
<div class="task-box" v-for="item, index in taskList">
|
||||
<div class="title">{{ item.taskName }}</div>
|
||||
<div class="task-info">
|
||||
<div>执行中</div>
|
||||
<div>厂区A定时巡检</div>
|
||||
<div>{{ taskStatus[item.status] }}</div>
|
||||
<div>{{ item.remark }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -61,7 +61,7 @@
|
||||
</div>
|
||||
|
||||
<div class="map-box">
|
||||
<MapCanvas ref="mapCanvasRef" />
|
||||
<MapCanvas :robotList="robotList" ref="mapCanvasRef" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-container">
|
||||
@ -84,10 +84,66 @@
|
||||
import SvgIcon from "@/components/SvgIcon";
|
||||
import MapCanvas from "./MapCanvas.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)
|
||||
|
||||
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)
|
||||
|
||||
@ -102,6 +158,11 @@ const centerCanvasView = () => {
|
||||
mapCanvasRef.value.centerCanvasView();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getRobot()
|
||||
getRunningTask()
|
||||
})
|
||||
</script>
|
||||
|
||||
<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" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input type="textarea" v-model="form.remark" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
@ -442,6 +445,7 @@ const subscribeDebounced = debounce(async (sub) => {
|
||||
delete taskRunningInfo.value[data.taskInstanceId]
|
||||
delete taskLogList.value[data.taskInstanceId]
|
||||
getList()
|
||||
getHistoryList()
|
||||
}
|
||||
} else {
|
||||
// 错误处理:取消订阅后重新订阅
|
||||
|
||||
@ -49,8 +49,8 @@ export default defineConfig(({mode, command}) => {
|
||||
'/dev-api': {
|
||||
//李小龙 http://192.168.0.201:13080
|
||||
// dev http://10.148.20.34:13080
|
||||
// target: VITE_API_URL,
|
||||
target: command === 'build' ? VITE_API_URL : 'http://192.168.0.201:13080',
|
||||
target: VITE_API_URL,
|
||||
// target: command === 'build' ? VITE_API_URL : 'http://192.168.0.201:13080',
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/dev-api/, '')
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user