feat: 机械臂

This commit is contained in:
zhanghao 2026-07-03 09:49:29 +08:00
parent d5ce6ee297
commit 0ceb7cc233
11 changed files with 1309 additions and 713 deletions

View File

@ -7,8 +7,9 @@ VITE_APP_ENV = 'development'
# 招商车研物联网平台/开发环境
VITE_APP_BASE_API = '/dev-api'
VITE_API_URL = http://192.168.1.201:13080
VITE_WS_URL = ws://192.168.1.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"
VITE_INSPECTION_ALM_DEVICEID = "huayan_arm"

View File

@ -92,7 +92,6 @@ export class XacroAdapter {
// 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);
}
@ -261,7 +260,6 @@ export class XacroAdapter {
} else {
let stlPath = path
stlPath = stlPath.replace('file:///', 'inspection/elfin10')
console.log(stlPath, manager, done)
originalLoadMeshCb(path, manager, done);
}
}).catch(error => {
@ -306,9 +304,6 @@ export class XacroAdapter {
* @returns {Promise<string>}
*/
static async loadFileFromMap(path, fileMap, workingPath) {
console.log('path', path)
console.log('fileMap', fileMap)
console.log('workingPath', workingPath)
// Clean path - remove leading slash if present
let cleanPath = path;
// if (cleanPath.startsWith('/')) {
@ -318,7 +313,6 @@ export class XacroAdapter {
// // Remove leading ./
// cleanPath = cleanPath.replace(/^\.\//, '');
cleanPath = cleanPath.split('///')[1];
console.log('cleanPath', cleanPath)
// Try different path combinations
const possiblePaths = [
cleanPath,
@ -328,13 +322,11 @@ export class XacroAdapter {
// Also try without the first directory component
cleanPath.includes('/') ? cleanPath.substring(cleanPath.indexOf('/') + 1) : cleanPath,
];
console.log('possiblePaths', possiblePaths)
// Try each path
for (const tryPath of possiblePaths) {
console.log('tryPath', tryPath)
const file = fileMap.get(tryPath);
if (file) {
console.log(12345)
const content = await file.text();
return content;
}

View File

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

View File

@ -31,7 +31,7 @@
: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 />
: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"

View File

@ -393,8 +393,8 @@ function handler(params) {
{ 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', input: 0, disabled: true },
{ name: "acceleration", type: "input", componentType: 'number', input: 0, 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'
},

View File

@ -1,674 +0,0 @@
<template>
<div class="monitor-container">
<div class="left-container">
<div class="robot-container">
<div class="title">选择机器人</div>
<div class="robot-box">
<div v-for="item, index in 4" class="robot" :class="{ active: activeBot === index }"
@click="changeBot(index)">
<div>
<SvgIcon name="bot" :color="colorList[index]" />
</div>
<div>
<div class="bot-name">机器人#01</div>
<div class="bot-local">厂区A-在线</div>
</div>
</div>
</div>
</div>
<div class="split-line"></div>
<div class="mode-container">
<div class="title">控制模式</div>
<div class="mode-box">
<div class="mode-item" :class="{ activeMode: activeMode === 'auto' }" @click="changeMode('auto')">
<SvgIcon name="cpu" :color="activeMode === 'auto' ? '#00FF88' : '#fff'" :size="16" />
<span class="label">自动巡检</span>
</div>
<div class="mode-item" :class="{ activeMode: activeMode === 'artificial' }"
@click="changeMode('artificial')">
<SvgIcon name="gamepad" :color="activeMode === 'artificial' ? '#00FF88' : '#fff'" :size="16" />
<span class="label">人工接管</span>
</div>
</div>
</div>
<div v-if="activeMode === 'artificial'" class="artificial-panel">
<el-radio-group v-model="activeController" size="small" @change="handlerController">
<el-radio :value="1">底盘</el-radio>
<el-radio :value="2">机械臂</el-radio>
<el-radio :value="3">相机</el-radio>
</el-radio-group>
<div class="controller-panel">人工控制面板</div>
<div v-if="activeController === 1" class="direction-controller-container">
<div class="title">
<SvgIcon name="navigation" color="00D4FF" :size="16" />
<span class="label">方向控制器</span>
</div>
<div class="direction-box">
<div class="direction-container">
<div class="item"></div>
<div class="item">
<SvgIcon name="chevron-up" :size="24" />
</div>
<div class="item"></div>
<div class="item">
<SvgIcon name="chevron-left" :size="24" />
</div>
<div class="item">
<SvgIcon name="chevron-stop" :size="52" />
</div>
<div class="item">
<SvgIcon name="chevron-right" :size="24" />
</div>
<div class="item"></div>
<div class="item">
<SvgIcon name="chevron-down" :size="24" />
</div>
<div class="item"></div>
</div>
</div>
<div class="speed-box">
<div class="label">移动速度</div>
<div class="speed">{{ speed }}m/s</div>
</div>
<el-slider v-model="speed" :max="2" :step="0.1" size="small" />
</div>
<div v-if="activeController === 2" class="robotic-arm-controller-container">
<el-radio-group v-model="activeRoboticArm" @change="handlerArm" size="small">
<el-radio :value="1">末端控制</el-radio>
<el-radio :value="2">关节控制</el-radio>
<el-radio :value="3">姿态控制</el-radio>
</el-radio-group>
<div class="mode-tabs">
<div class="mode-tab active">左手</div>
<div class="mode-tab">右手</div>
</div>
<div v-if="[1, 3].includes(activeRoboticArm)" class="robotic-arm-box">
<div class="robotic-arm-status">
<div class="coord-display">
<div class="coord-item">
<div class="coord-key key-x">X</div>
<div class="coord-val" id="coordX">+124.50</div>
</div>
<div class="coord-item">
<div class="coord-key key-y">Y</div>
<div class="coord-val" id="coordY">38.20</div>
</div>
<div class="coord-item">
<div class="coord-key key-z">Z</div>
<div class="coord-val" id="coordZ">+502.00</div>
</div>
</div>
</div>
<div class="axis-group">
<!-- X Axis -->
<div class="axis-row x">
<div class="axis-label x">X</div>
<div class="axis-slider-wrap">
<div class="axis-name">前后平移</div>
<div>
<el-button type="primary" circle>
<el-icon size="24"><Remove /></el-icon>
</el-button>
<el-button type="primary" circle >
<el-icon size="24"><CirclePlus /></el-icon>
</el-button>
</div>
</div>
</div>
<!-- Y Axis -->
<div class="axis-row y">
<div class="axis-label y">Y</div>
<div class="axis-slider-wrap">
<div class="axis-name">左右平移</div>
<div>
<el-button type="primary" circle>
<el-icon size="24"><Remove /></el-icon>
</el-button>
<el-button type="primary" circle >
<el-icon size="24"><CirclePlus /></el-icon>
</el-button>
</div>
</div>
</div>
<!-- Z Axis -->
<div class="axis-row z">
<div class="axis-label z">Z</div>
<div class="axis-slider-wrap">
<div class="axis-name">上下平移</div>
<div>
<el-button type="primary" circle>
<el-icon size="24"><Remove /></el-icon>
</el-button>
<el-button type="primary" circle >
<el-icon size="24"><CirclePlus /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</div>
<div class="control-panel" v-if="activeRoboticArm === 2">
<div class="joint-list">
<div v-for="joint in jointControls" :key="joint.name" class="joint-item">
<label :for="joint.name">{{ joint.name }}</label>
<el-slider v-model="joint.value" :min="joint.min" :max="joint.max" :step="0.01" @input="updateJoint(joint.name, joint.value)"/>
<span class="angle">{{ (joint.value * 180 / Math.PI).toFixed(1) }}°</span>
</div>
</div>
</div>
</div>
</div>
</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">
<UrdfViewer ref="urdfViewerRef" base-path="/inspection/elfin10/" />
</div>
<div v-if="activeMode === 'artificial' && activeController === 3" class="monitor-container">监控</div>
</div>
<div class="right-container">
<div class="container-title">车载监控</div>
<div class="monitor-container"></div>
<div class="split-line"></div>
<div class="container-title">语音对话</div>
</div>
</div>
</template>
<script setup>
import SvgIcon from "@/components/SvgIcon";
import UrdfViewer from './UrdfView.vue'
import { Remove, CirclePlus } from '@element-plus/icons-vue'
const activeBot = ref(0)
const changeBot = (index) => {
activeBot.value = index
}
const colorList = ['#00D4FF', '#FFB300', '#00FF88', '#fff']
const activeMode = ref('auto')
const changeMode = (mode) => {
activeMode.value = mode
}
const activeController = ref(1)
const speed = ref(0.8)
const activeRoboticArm = ref(1)
const urdfViewerRef = ref()
const jointControls = ref()
const handlerController = (value) => {
if (value === 2 && [1, 3].includes(activeRoboticArm.value)) {
jointControls.value = urdfViewerRef.value.jointControls
}
}
const updateJoint = (jointName, angle) => {
urdfViewerRef.value.updateJoint(jointName, angle)
}
const handlerArm = (value) => {
if ([1, 3].includes(value)) {
}
}
function onLoaded(robot) {
console.log('已加载关节:', Object.keys(robot.joints))
}
function onError(err) {
console.error('加载失败:', err)
}
</script>
<style lang="scss" scoped>
.monitor-container {
width: 100%;
height: 100%;
display: flex;
.left-container {
width: 360px;
height: 100%;
padding: 16px;
.robot-container {
.title {
color: #8899BB;
font-size: 14px;
margin-bottom: 16px;
}
.robot-box {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
.robot {
flex: 0 0 calc(50% - 10px);
background: #0F1E38;
height: 56px;
border-radius: 8px;
padding: 16px;
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 16px;
cursor: pointer;
border: 1px solid transparent;
.bot-name {
color: #fff;
font-size: 13px;
}
.bot-local {
color: #00d4ff;
font-size: 11px;
margin-top: 4px;
}
}
.active {
border-color: #00c2ff;
color: #fff;
box-shadow: 0 0 10px rgba(0, 194, 255, 0.5);
}
}
}
.split-line {
width: 100%;
height: 1px;
margin: 16px 0;
background: #1E3A5F;
}
.mode-container {
.title {
color: #8899BB;
font-size: 14px;
margin-bottom: 16px;
}
.mode-box {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
.mode-item {
height: 36px;
flex: 0 0 calc(50% - 10px);
background: #0F1E38;
border: 1px solid transparent;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 16px;
cursor: pointer;
.label {
color: #8899BB;
font-size: 13px;
}
}
.activeMode {
border-color: #00FF88;
.label {
color: #00FF88;
}
}
}
}
.controller-panel {
padding: 16px;
color: #fff;
font-size: 16px;
}
.direction-controller-container {
padding: 16px;
height: 272px;
background: #0F1E38;
border-radius: 10px;
.title {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
.label {
color: #fff;
font-size: 14px;
}
}
.direction-box {
display: flex;
align-items: center;
justify-content: center;
.direction-container {
display: flex;
flex-wrap: wrap;
gap: 4px;
width: 164px;
height: 164px;
.item {
// flex: 0 0 calc(33.333% - 4px);
width: 52px;
height: 52px;
background: #1E3A5F;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
}
}
.speed-box {
display: flex;
align-items: center;
justify-content: space-between;
margin: 8px 0;
.label {
color: #8899BB;
font-size: 12px;
}
.speed {
color: #00D4FF;
font-size: 12px;
}
}
:deep(.el-slider__button) {
scale: 0.5;
}
}
.robotic-arm-controller-container {
:deep(.el-radio-group) {
display: flex;
align-items: center;
justify-content: space-between;
}
.mode-tabs {
display: flex;
padding: 12px 20px 0;
gap: 6px;
.mode-tab {
flex: 1;
padding: 8px 0;
text-align: center;
font-size: 12px;
font-weight: 500;
color: #4a5580;
background: transparent;
border: 1px solid transparent;
border-bottom: none;
border-radius: 8px 8px 0 0;
cursor: pointer;
transition: all 0.25s ease;
position: relative;
user-select: none;
&:hover {
color: #8b97bf;
background: #3b82f60d;
}
}
.active {
color: #3b82f6;
background: #111a36;
border-color: #1c2a52;
font-weight: 700;
&:after {
content: '';
position: absolute;
bottom: 0;
left: 20%;
right: 20%;
height: 2px;
background: var(--accent);
border-radius: 1px;
box-shadow: 0 0 8px var(--accent-glow);
}
}
}
.robotic-arm-box {
.robotic-arm-status {
.coord-display {
display: flex;
gap: 8px;
padding: 12px;
background: #070b1a;
border: 1px solid #1c2a52;
border-radius: 8px;
margin-bottom: 16px;
.coord-item {
flex: 1;
text-align: center;
padding: 6px 0;
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
.coord-key {
font-size: 12px;
font-weight: bold;
margin-bottom: 3px;
}
.key-x {
color: #f87171;
}
.key-y {
color: #34d399;
;
}
.key-z {
color: #38bdf8;
;
}
.coord-val {
font-size: 14px;
font-weight: bold;
color: #e8edf8;
}
}
}
}
.axis-group {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
.axis-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #0c1228;
border: 1px solid #1c2a52;
border-radius: 10px;
transition: all 0.2s ease;
&:hover {
border-color: #2a3f7a;
background: #3b82f608;
}
.axis-label {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.x {
background: #f8717126;
color: #f87171;
border: 1px solid #f871714d;
}
.y {
background: #34d39926;
color: #34d399;
border: 1px solid #34d3994d;
}
.z {
background: #38bdf826;
color: #38bdf8;
border: 1px solid #38bdf84d;
}
.axis-slider-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
color: #fff;
}
}
}
}
}
}
.video-container {
flex: 1;
border: 1px solid #1E3A5F;
border-top: 0;
.map-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #00c2ff;
font-size: 20px;
}
.model-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #00c2ff;
font-size: 20px;
}
.monitor-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #00c2ff;
font-size: 20px;
}
}
.right-container {
width: 360px;
height: 100%;
padding: 16px;
.container-title {
color: #fff;
font-size: 16px;
font-weight: bold;
margin-bottom: 16px;
}
.split-line {
width: 100%;
height: 1px;
margin: 16px 0;
background: #1E3A5F;
}
.monitor-container {
width: 100%;
height: 360px;
}
}
}
.control-panel {
margin-top: 12px;
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;
label {
width: 120px;
font-size: 0.9em;
}
input[type=range] {
flex: 1;
}
.angle {
width: 50px;
text-align: right;
}
}
}
</style>

View File

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

View File

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

View File

@ -41,46 +41,35 @@ const initScene = () => {
const height = container.clientHeight || 600;
scene = new THREE.Scene();
scene.background = new THREE.Color('#000d2e');
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
camera.position.set(2, 2, 3);
camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 1000);
camera.position.set(0, 0, 4);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(width, height);
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);
const ambient = new THREE.AmbientLight(0xffffff, 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 directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
directionalLight.position.set(5, 10, 7);
directionalLight.castShadow = true;
scene.add(directionalLight);
};
// ============ ============
const updateJoint = (jointName, angle) => {
if (!robotInstance) return;
const joint = robotInstance.joints[jointName];
if (!joint) {
console.warn(`未找到关节: ${jointName}`);
return;
@ -93,6 +82,7 @@ const updateJoint = (jointName, angle) => {
joint.setJointValue(angle);
}
//
const control = jointControls.find(c => c.name === jointName);
if (control) control.value = angle;

View File

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

View File

@ -13,7 +13,7 @@
<script setup>
import Header from './components/Header.vue';
import MapOverview from './components/MapOverview.vue';
import ManualTakeover from './components/ManualTakeover.vue';
import ManualTakeover from './components/ManualTakeover/index.vue';
import Monitor from './components/Monitor.vue';