feat: 增加视觉校准管理页面,具备手眼和工具TCP功能
This commit is contained in:
parent
f8d1adc788
commit
e40a25c3e5
55
src/api/resource/visionCalibration.js
Normal file
55
src/api/resource/visionCalibration.js
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
const base = '/vision/calibration'
|
||||||
|
|
||||||
|
export function listCalibrationProfiles(params) {
|
||||||
|
return request({ url: `${base}/profiles`, method: 'get', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCalibrationProfile(id) {
|
||||||
|
return request({ url: `${base}/profiles/${id}`, method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCalibrationCorrection(id, data) {
|
||||||
|
return request({ url: `${base}/profiles/${id}/correction`, method: 'put', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activateCalibrationProfile(id, forced = false) {
|
||||||
|
return request({ url: `${base}/profiles/${id}/activate`, method: 'put', data: { forced } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateCalibrationProfile(id) {
|
||||||
|
return request({ url: `${base}/profiles/${id}`, method: 'delete' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startHandEyeCalibration(data) {
|
||||||
|
return request({ url: `${base}/sessions/hand-eye`, method: 'post', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startToolCalibration(data) {
|
||||||
|
return request({ url: `${base}/sessions/tool`, method: 'post', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startCalibrationVerification(id) {
|
||||||
|
return request({ url: `${base}/profiles/${id}/verify`, method: 'post' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calibrationHeartbeat(id) {
|
||||||
|
return request({ url: `${base}/sessions/${id}/heartbeat`, method: 'post' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCalibrationSession(id) {
|
||||||
|
return request({ url: `${base}/sessions/${id}`, method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopCalibrationSession(id) {
|
||||||
|
return request({ url: `${base}/sessions/${id}/stop`, method: 'post' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordToolPivot(id) {
|
||||||
|
return request({ url: `${base}/sessions/${id}/tool/pivot`, method: 'post' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordToolDirection(id) {
|
||||||
|
return request({ url: `${base}/sessions/${id}/tool/direction`, method: 'post' })
|
||||||
|
}
|
||||||
@ -77,6 +77,11 @@ export const getResourceRoles = (graph = globalThis.lf?.getGraphData?.()) => (
|
|||||||
|
|
||||||
const isRobotNode = (node) => node?.properties?.nodeType === 'EDGE'
|
const isRobotNode = (node) => node?.properties?.nodeType === 'EDGE'
|
||||||
export const ACTION_QUEUE_ACTION = 'SYSTEM_EXECUTE_ACTION_QUEUE'
|
export const ACTION_QUEUE_ACTION = 'SYSTEM_EXECUTE_ACTION_QUEUE'
|
||||||
|
const NAMED_DEVICE_BINDING_ACTIONS = new Set([ACTION_QUEUE_ACTION, 'VISION_LOCATE_TARGET'])
|
||||||
|
const VISION_DEVICE_BINDING_SPECS = Object.freeze([
|
||||||
|
{ parameterName: 'cameraDeviceId', deviceKind: 5, displayName: '视觉定位相机' },
|
||||||
|
{ parameterName: 'armDeviceId', deviceKind: 2, displayName: '视觉定位机械臂' },
|
||||||
|
])
|
||||||
export const actionRequiresDevice = (action) => (
|
export const actionRequiresDevice = (action) => (
|
||||||
getDeviceKindForAction(action) != null || action === 'DEVICE_EXECUTE_JSON_COMMAND'
|
getDeviceKindForAction(action) != null || action === 'DEVICE_EXECUTE_JSON_COMMAND'
|
||||||
)
|
)
|
||||||
@ -196,12 +201,31 @@ export const normalizeFlowResourceSchema = (sourceGraph) => {
|
|||||||
}
|
}
|
||||||
target.roleKey ||= defaultRole.roleKey
|
target.roleKey ||= defaultRole.roleKey
|
||||||
target.dispatchPolicy ||= 'ONE'
|
target.dispatchPolicy ||= 'ONE'
|
||||||
const isActionQueue = properties.action === ACTION_QUEUE_ACTION
|
const usesNamedDeviceBindings = NAMED_DEVICE_BINDING_ACTIONS.has(properties.action)
|
||||||
if (!isActionQueue) target.deviceSlotKey ||= target.deviceBindings?.[0]?.slotKey || ''
|
if (!usesNamedDeviceBindings) target.deviceSlotKey ||= target.deviceBindings?.[0]?.slotKey || ''
|
||||||
target.deviceBindings = Array.isArray(target.deviceBindings) ? target.deviceBindings : []
|
target.deviceBindings = Array.isArray(target.deviceBindings) ? target.deviceBindings : []
|
||||||
|
|
||||||
const targetRole = roles.find((role) => role.roleKey === target.roleKey) || defaultRole
|
const targetRole = roles.find((role) => role.roleKey === target.roleKey) || defaultRole
|
||||||
const deviceParam = properties.nodeParams?.find((param) => param.name === 'deviceId')
|
const deviceParam = properties.nodeParams?.find((param) => param.name === 'deviceId')
|
||||||
|
if (properties.action === 'VISION_LOCATE_TARGET') {
|
||||||
|
VISION_DEVICE_BINDING_SPECS.forEach((spec) => {
|
||||||
|
if (target.deviceBindings.some((binding) => binding?.parameterName === spec.parameterName)) return
|
||||||
|
const legacyDeviceId = normalizedText(properties.nodeParams
|
||||||
|
?.find((param) => param.name === spec.parameterName)?.input)
|
||||||
|
const matchingSlots = targetRole.deviceSlots.filter((slot) => (
|
||||||
|
Number(slot.deviceKind) === spec.deviceKind
|
||||||
|
&& (!legacyDeviceId || !slot.legacyDeviceId || slot.legacyDeviceId === legacyDeviceId)
|
||||||
|
))
|
||||||
|
let slot = matchingSlots.length === 1 ? matchingSlots[0] : null
|
||||||
|
if (!slot && matchingSlots.length === 0) {
|
||||||
|
slot = createDeviceSlot(spec.deviceKind, targetRole.deviceSlots, spec.displayName)
|
||||||
|
slot.managedBy = FLOW_NODE_RESOURCE_OWNER
|
||||||
|
if (legacyDeviceId) slot.legacyDeviceId = legacyDeviceId
|
||||||
|
targetRole.deviceSlots.push(slot)
|
||||||
|
}
|
||||||
|
if (slot) target.deviceBindings.push({ parameterName: spec.parameterName, slotKey: slot.slotKey })
|
||||||
|
})
|
||||||
|
}
|
||||||
if (nodeRequiresDevice(node) && !target.deviceSlotKey) {
|
if (nodeRequiresDevice(node) && !target.deviceSlotKey) {
|
||||||
const slot = findOrCreateLegacySlot(targetRole, node, legacySlotByIdentity, deviceParam?.input)
|
const slot = findOrCreateLegacySlot(targetRole, node, legacySlotByIdentity, deviceParam?.input)
|
||||||
if (slot) target.deviceSlotKey = slot.slotKey
|
if (slot) target.deviceSlotKey = slot.slotKey
|
||||||
@ -213,8 +237,8 @@ export const normalizeFlowResourceSchema = (sourceGraph) => {
|
|||||||
targetRole.deviceSlots.forEach((slot) => {
|
targetRole.deviceSlots.forEach((slot) => {
|
||||||
if (referencedSlotKeys.has(slot.slotKey) && !slot.managedBy) slot.managedBy = FLOW_NODE_RESOURCE_OWNER
|
if (referencedSlotKeys.has(slot.slotKey) && !slot.managedBy) slot.managedBy = FLOW_NODE_RESOURCE_OWNER
|
||||||
})
|
})
|
||||||
if (!isActionQueue) delete target.deviceBindings
|
if (!usesNamedDeviceBindings) delete target.deviceBindings
|
||||||
if (isActionQueue) delete target.deviceSlotKey
|
if (usesNamedDeviceBindings) delete target.deviceSlotKey
|
||||||
properties.nodeParams = (properties.nodeParams || []).filter((param) => param.name !== 'deviceId')
|
properties.nodeParams = (properties.nodeParams || []).filter((param) => param.name !== 'deviceId')
|
||||||
properties.executionTarget = target
|
properties.executionTarget = target
|
||||||
})
|
})
|
||||||
@ -317,9 +341,9 @@ export const validateFlowResources = (sourceGraph) => {
|
|||||||
}
|
}
|
||||||
;(target.deviceBindings || []).forEach((binding) => {
|
;(target.deviceBindings || []).forEach((binding) => {
|
||||||
if (!binding?.parameterName || !binding?.slotKey) {
|
if (!binding?.parameterName || !binding?.slotKey) {
|
||||||
errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}存在未配置的队列设备` })
|
errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}存在未配置的设备绑定` })
|
||||||
} else if (!(role.deviceSlots || []).some((slot) => slot.slotKey === binding.slotKey)) {
|
} else if (!(role.deviceSlots || []).some((slot) => slot.slotKey === binding.slotKey)) {
|
||||||
errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}引用的队列设备用途不存在` })
|
errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}引用的设备用途不存在` })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (nodeRequiresDevice(node)
|
if (nodeRequiresDevice(node)
|
||||||
|
|||||||
34
src/views/flow/chatPrompt.js
Normal file
34
src/views/flow/chatPrompt.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
export const DEFAULT_CHAT_SYSTEM_PROMPT = `【系统人设·固定设定】
|
||||||
|
你是长安汽车龙兴工厂总装车间智能巡检轮式人形机器人小安,常驻车间、负责巡检、物流辅助、智能讲解、员工日常闲聊陪伴。
|
||||||
|
你的人设:温柔、乖巧、智能、话不多余、活泼不油腻、专业但接地气。
|
||||||
|
你不是冰冷AI,是车间里的「智能小工友」,懂生产、懂设备、懂车间工艺、也会轻松闲聊。
|
||||||
|
【知识绑定·必须精通】
|
||||||
|
你100%熟记长安龙兴总装车间全部参观路线、所有点位工艺、技术、设备、党建、安全、智慧系统:
|
||||||
|
- 车间数字大屏、MES/SCADA系统、一屏观全局
|
||||||
|
- 核心零部件、模块化生产、轮式人形机器人应用场景
|
||||||
|
- 车间产能、柔性共线、生产节拍、红色车间、党建攻坚、SOS三级安全体系、EHS管理
|
||||||
|
- 自动化小分装、ECU自动刷写
|
||||||
|
- AI视觉检测、防错技术EP、螺栓拧紧大数据系统
|
||||||
|
- 智慧立体库、AGV配送、Kitting物流、5G追溯
|
||||||
|
- 班组文化、党员传帮带、员工人文关怀看板
|
||||||
|
- L2++自动驾驶、ICA智能领航、多传感器融合
|
||||||
|
- 自动车门/座椅装配、安辰云检整车检测
|
||||||
|
- 轮胎自动装配、高精度液体加注
|
||||||
|
- 智慧绿色照明、节能智造体系
|
||||||
|
用户问任何车间技术、工艺、设备、参观讲解、工厂知识,你都能精准、简洁、专业讲解。
|
||||||
|
【闲聊风格·核心规则】
|
||||||
|
1. 日常闲聊:轻松、简短、温柔、可爱、像车间小机器人工友,不官话、不论文、不啰嗦。
|
||||||
|
2. 可以聊天、唠嗑、安慰、解压、陪聊、开玩笑、温柔互动。
|
||||||
|
3. 被问专业问题:立刻切换工厂讲解员模式,清晰、标准、正式、贴合官方解说词。
|
||||||
|
4. 说话语气:干净、年轻化、自然、不机械、不AI味。
|
||||||
|
5. 禁止:生硬模板、过度搞笑、油腻、网络烂梗、夸张情绪化。
|
||||||
|
【可聊范围】
|
||||||
|
- 车间日常、机器人工作、巡检、物流、设备状态
|
||||||
|
- 工厂技术、智能制造、自动化、数字工厂
|
||||||
|
- 员工解压、日常闲聊、心情陪伴、轻松对话
|
||||||
|
- 参观讲解、点位介绍、路线答疑
|
||||||
|
- 科普、生活、工作、放松对话全覆盖
|
||||||
|
【输出要求】
|
||||||
|
• 闲聊短句为主,不超长段落
|
||||||
|
• 专业提问自动切换正式讲解
|
||||||
|
• 始终保持:智能、温柔、靠谱、车间专属机器人人设`
|
||||||
@ -27,9 +27,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, defineAsyncComponent, ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { emitter } from '@/utils/eventBus'
|
import { emitter } from '@/utils/eventBus'
|
||||||
|
|
||||||
|
// 导入各子组件
|
||||||
const StartParams = defineAsyncComponent(() => import('./params/StartParams.vue'))
|
const StartParams = defineAsyncComponent(() => import('./params/StartParams.vue'))
|
||||||
const BasicNodeParams = defineAsyncComponent(() => import('./params/BasicNodeParams.vue'))
|
const BasicNodeParams = defineAsyncComponent(() => import('./params/BasicNodeParams.vue'))
|
||||||
const CodeNodeParams = defineAsyncComponent(() => import('./params/CodeNodeParams.vue'))
|
const CodeNodeParams = defineAsyncComponent(() => import('./params/CodeNodeParams.vue'))
|
||||||
@ -43,6 +44,8 @@ const MediaAnalysisParams = defineAsyncComponent(() => import('./params/MediaAna
|
|||||||
const ImageAnalysisParams = defineAsyncComponent(() => import('./params/ImageAnalysisParams.vue'))
|
const ImageAnalysisParams = defineAsyncComponent(() => import('./params/ImageAnalysisParams.vue'))
|
||||||
const EndParams = defineAsyncComponent(() => import('./params/EndParams.vue'))
|
const EndParams = defineAsyncComponent(() => import('./params/EndParams.vue'))
|
||||||
const SubFlowParams = defineAsyncComponent(() => import('./params/SubFlowParams.vue'))
|
const SubFlowParams = defineAsyncComponent(() => import('./params/SubFlowParams.vue'))
|
||||||
|
const ChatParams = defineAsyncComponent(() => import('./params/ChatParams.vue'))
|
||||||
|
const VisionLocateParams = defineAsyncComponent(() => import('./params/VisionLocateParams.vue'))
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
drawer: Boolean,
|
drawer: Boolean,
|
||||||
@ -76,6 +79,8 @@ const currentComponent = computed(() => {
|
|||||||
if (type === 'mediaAnalysis') return MediaAnalysisParams
|
if (type === 'mediaAnalysis') return MediaAnalysisParams
|
||||||
// 图片分析支持多图、参考图和自由提示词输出,使用独立面板保持媒体分析面板聚焦音视频。
|
// 图片分析支持多图、参考图和自由提示词输出,使用独立面板保持媒体分析面板聚焦音视频。
|
||||||
if (type === 'imageAnalysis') return ImageAnalysisParams
|
if (type === 'imageAnalysis') return ImageAnalysisParams
|
||||||
|
if (type === 'visionLocate') return VisionLocateParams
|
||||||
|
if (type === 'chat') return ChatParams
|
||||||
if (type === 'branch') return SwitchNodeParams
|
if (type === 'branch') return SwitchNodeParams
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|||||||
165
src/views/flow/components/params/ChatParams.vue
Normal file
165
src/views/flow/components/params/ChatParams.vue
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chat-params">
|
||||||
|
<el-alert :title="labels.intro" type="info" :closable="false" show-icon />
|
||||||
|
<el-form ref="formRef" :model="form" label-position="top" class="chat-form">
|
||||||
|
<el-form-item :label="labels.textSource" required>
|
||||||
|
<el-radio-group v-model="textParam.type" @change="handleTextTypeChange">
|
||||||
|
<el-radio-button value="input">{{ labels.directInput }}</el-radio-button>
|
||||||
|
<el-radio-button value="quote">{{ labels.quoteOutput }}</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="textParam.type === 'input'" :label="labels.inputText" required>
|
||||||
|
<el-input
|
||||||
|
v-model="textParam.input"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="2000"
|
||||||
|
show-word-limit
|
||||||
|
:placeholder="labels.inputPlaceholder"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-else :label="labels.upstreamOutput" required>
|
||||||
|
<el-cascader
|
||||||
|
v-model="textParam.quote"
|
||||||
|
:options="quoteOptions"
|
||||||
|
:props="{ checkStrictly: true }"
|
||||||
|
:placeholder="labels.quotePlaceholder"
|
||||||
|
style="width: 100%"
|
||||||
|
@visible-change="visibleChange"
|
||||||
|
@change="value => cascaderChange(value, textParam)"
|
||||||
|
/>
|
||||||
|
<div class="field-hint">{{ labels.quoteHint }}</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item :label="labels.promptMode">
|
||||||
|
<el-radio-group v-model="form.promptMode">
|
||||||
|
<el-radio-button value="FIXED">{{ labels.fixedPrompt }}</el-radio-button>
|
||||||
|
<el-radio-button value="CUSTOM">{{ labels.customPrompt }}</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="form.promptMode === 'CUSTOM'" :label="labels.customPrompt" required>
|
||||||
|
<el-input
|
||||||
|
v-model="form.prompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="2000"
|
||||||
|
show-word-limit
|
||||||
|
:placeholder="labels.promptPlaceholder"
|
||||||
|
/>
|
||||||
|
<div class="field-hint">{{ labels.promptHint }}</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="labels.invokeTts">
|
||||||
|
<el-switch v-model="form.invokeTts" />
|
||||||
|
<div class="field-hint">{{ labels.ttsHint }}</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useQuote } from './useQuote.js'
|
||||||
|
import { DEFAULT_CHAT_SYSTEM_PROMPT } from '../../chatPrompt.js'
|
||||||
|
|
||||||
|
const props = defineProps({ data: Object })
|
||||||
|
const emit = defineEmits(['save-success', 'save-error'])
|
||||||
|
const formRef = ref()
|
||||||
|
const form = reactive({ promptMode: 'CUSTOM', prompt: DEFAULT_CHAT_SYSTEM_PROMPT, invokeTts: false })
|
||||||
|
const textParam = reactive({
|
||||||
|
name: 'text',
|
||||||
|
type: 'input',
|
||||||
|
input: '',
|
||||||
|
quote: [],
|
||||||
|
quoteType: 'output',
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
const { quoteOptions, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
intro: '\u8f93\u5165\u4e00\u53e5\u8bdd\u6216\u5f15\u7528\u4e0a\u6e38\u8282\u70b9\u8f93\u51fa\uff0c\u8c03\u7528\u5927\u6a21\u578b\u8fdb\u884c\u95f2\u804a\uff0c\u53ea\u8fd4\u56de\u7eaf\u6587\u672c\u3002',
|
||||||
|
textSource: '\u6587\u672c\u6765\u6e90',
|
||||||
|
directInput: '\u76f4\u63a5\u8f93\u5165',
|
||||||
|
quoteOutput: '\u5f15\u7528\u4e0a\u6e38\u8f93\u51fa',
|
||||||
|
inputText: '\u8f93\u5165\u6587\u672c',
|
||||||
|
inputPlaceholder: '\u8bf7\u8f93\u5165\u8981\u53d1\u9001\u7ed9\u5927\u6a21\u578b\u7684\u5185\u5bb9',
|
||||||
|
upstreamOutput: '\u4e0a\u6e38\u8282\u70b9\u8f93\u51fa',
|
||||||
|
quotePlaceholder: '\u8bf7\u9009\u62e9\u4e0a\u6e38\u8282\u70b9\u7684\u6587\u672c\u8f93\u51fa',
|
||||||
|
quoteHint: '\u6d41\u7a0b\u8fd0\u884c\u65f6\u4f1a\u81ea\u52a8\u5c06\u5f15\u7528\u503c\u4f5c\u4e3a\u95f2\u804a\u8f93\u5165\u3002',
|
||||||
|
promptMode: '\u63d0\u793a\u8bcd\u6a21\u5f0f',
|
||||||
|
fixedPrompt: '\u56fa\u5b9a\u63d0\u793a\u8bcd\uff08\u63a8\u8350\uff09',
|
||||||
|
customPrompt: '\u81ea\u5b9a\u4e49\u63d0\u793a\u8bcd',
|
||||||
|
promptPlaceholder: '\u4f8b\u5982\uff1a\u8bf7\u7528\u7b80\u6d01\u3001\u4e13\u4e1a\u7684\u8bed\u6c14\u56de\u7b54',
|
||||||
|
promptHint: '\u81ea\u5b9a\u4e49\u63d0\u793a\u8bcd\u53ef\u80fd\u589e\u52a0\u5904\u7406\u65f6\u95f4\uff0c\u7cfb\u7edf\u4ecd\u4f1a\u8981\u6c42\u6a21\u578b\u53ea\u8fd4\u56de\u7eaf\u6587\u672c\u3002',
|
||||||
|
invokeTts: '\u8fb9\u751f\u6210\u8fb9\u64ad\u653e\u8bed\u97f3',
|
||||||
|
ttsHint: '\u5f00\u542f\u540e\u6309\u53e5\u63a5\u6536\u6a21\u578b\u6d41\u5f0f\u8f93\u51fa\u5e76\u7acb\u5373\u8c03\u7528 TTS\uff0c\u8282\u70b9\u6700\u7ec8\u4ecd\u8f93\u51fa\u5b8c\u6574\u6587\u672c\u3002',
|
||||||
|
}
|
||||||
|
|
||||||
|
const init = () => {
|
||||||
|
const params = props.data?.properties?.nodeParams || []
|
||||||
|
const savedText = params.find(item => item?.name === 'text')
|
||||||
|
if (savedText) {
|
||||||
|
textParam.type = savedText.type === 'quote' ? 'quote' : 'input'
|
||||||
|
textParam.input = savedText.input ?? ''
|
||||||
|
textParam.quote = Array.isArray(savedText.quote) ? [...savedText.quote] : []
|
||||||
|
textParam.quoteType = savedText.quoteType || 'output'
|
||||||
|
}
|
||||||
|
form.promptMode = params.find(item => item?.name === 'promptMode')?.input || 'CUSTOM'
|
||||||
|
form.prompt = params.find(item => item?.name === 'prompt')?.input || DEFAULT_CHAT_SYSTEM_PROMPT
|
||||||
|
form.invokeTts = Boolean(params.find(item => item?.name === 'invokeTts')?.input)
|
||||||
|
}
|
||||||
|
watch(() => props.data, init, { immediate: true, deep: true })
|
||||||
|
|
||||||
|
const handleTextTypeChange = () => handleTypeChange(textParam)
|
||||||
|
|
||||||
|
const validateAndSave = async () => {
|
||||||
|
if (textParam.type === 'input' && !String(textParam.input || '').trim()) {
|
||||||
|
ElMessage.warning('\u8bf7\u8f93\u5165\u95f2\u804a\u6587\u672c')
|
||||||
|
emit('save-error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (textParam.type === 'quote' && (!Array.isArray(textParam.quote) || textParam.quote.length < 3)) {
|
||||||
|
ElMessage.warning('\u8bf7\u9009\u62e9\u4e0a\u6e38\u8282\u70b9\u8f93\u51fa')
|
||||||
|
emit('save-error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.promptMode === 'CUSTOM' && !String(form.prompt || '').trim()) {
|
||||||
|
ElMessage.warning('\u8bf7\u8f93\u5165\u81ea\u5b9a\u4e49\u63d0\u793a\u8bcd')
|
||||||
|
emit('save-error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedTextParam = {
|
||||||
|
name: 'text',
|
||||||
|
type: textParam.type,
|
||||||
|
required: true,
|
||||||
|
...(textParam.type === 'quote'
|
||||||
|
? { quote: [...textParam.quote], quoteType: textParam.quoteType || 'output' }
|
||||||
|
: { input: textParam.input }),
|
||||||
|
}
|
||||||
|
const properties = {
|
||||||
|
...props.data.properties,
|
||||||
|
nodeParams: [
|
||||||
|
savedTextParam,
|
||||||
|
{ name: 'promptMode', type: 'input', input: form.promptMode, required: true },
|
||||||
|
{ name: 'prompt', type: 'input', input: form.prompt, required: form.promptMode === 'CUSTOM' },
|
||||||
|
{ name: 'invokeTts', type: 'input', input: form.invokeTts, required: false },
|
||||||
|
],
|
||||||
|
outputType: 'json',
|
||||||
|
outputParams: [{ name: 'text', type: 'string', desc: '\u5927\u6a21\u578b\u8fd4\u56de\u7684\u95f2\u804a\u6587\u672c', disabled: true }],
|
||||||
|
}
|
||||||
|
globalThis.lf.setProperties(props.data.id, properties)
|
||||||
|
emit('save-success')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ validateAndSave })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-params { padding-bottom: 24px; }
|
||||||
|
.chat-form { padding-top: 8px; }
|
||||||
|
.chat-form :deep(.el-radio-group) { width: 100%; display: flex; }
|
||||||
|
.chat-form :deep(.el-radio-button) { flex: 1; }
|
||||||
|
.field-hint { margin-top: 4px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 18px; }
|
||||||
|
</style>
|
||||||
@ -207,7 +207,8 @@ const formData = reactive({
|
|||||||
analysisMode: 'FAST',
|
analysisMode: 'FAST',
|
||||||
asynchronous: false,
|
asynchronous: false,
|
||||||
analysisTuning: {
|
analysisTuning: {
|
||||||
maxWidth: 896,
|
// Location needs enough pixels to distinguish small dashboard icons.
|
||||||
|
maxWidth: 1600,
|
||||||
maxImages: 12,
|
maxImages: 12,
|
||||||
maxOutputTokens: 384,
|
maxOutputTokens: 384,
|
||||||
matchThreshold: 0.48,
|
matchThreshold: 0.48,
|
||||||
@ -348,7 +349,8 @@ const initData = () => {
|
|||||||
const savedMode = String(getParam(params, 'analysisMode')?.input || '').toUpperCase()
|
const savedMode = String(getParam(params, 'analysisMode')?.input || '').toUpperCase()
|
||||||
formData.analysisMode = savedMode === 'ACCURATE' ? 'ACCURATE' : 'FAST'
|
formData.analysisMode = savedMode === 'ACCURATE' ? 'ACCURATE' : 'FAST'
|
||||||
formData.asynchronous = asynchronous?.input === true || asynchronous?.input === 'true'
|
formData.asynchronous = asynchronous?.input === true || asynchronous?.input === 'true'
|
||||||
formData.analysisTuning.maxWidth = Number(tuning.maxWidth) || (formData.analysisMode === 'FAST' ? 896 : 1280)
|
formData.analysisTuning.maxWidth = Number(tuning.maxWidth)
|
||||||
|
|| (isLocationMethod.value ? 1600 : (formData.analysisMode === 'FAST' ? 896 : 1280))
|
||||||
formData.analysisTuning.maxImages = Number(tuning.maxImages) || 12
|
formData.analysisTuning.maxImages = Number(tuning.maxImages) || 12
|
||||||
formData.analysisTuning.maxOutputTokens = Number(tuning.maxOutputTokens)
|
formData.analysisTuning.maxOutputTokens = Number(tuning.maxOutputTokens)
|
||||||
|| (formData.analysisMode === 'FAST' ? 384 : 512)
|
|| (formData.analysisMode === 'FAST' ? 384 : 512)
|
||||||
|
|||||||
@ -165,7 +165,6 @@
|
|||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import { Refresh } from '@element-plus/icons-vue'
|
import { Refresh } from '@element-plus/icons-vue'
|
||||||
import { getUpstreamInput } from '@/utils/flow'
|
import { getUpstreamInput } from '@/utils/flow'
|
||||||
import { soundTypeOptions } from '../../audioTypes.js'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* data 是流程画布当前节点模型,核心字段包括 id、properties.action 和 properties.nodeParams。
|
* data 是流程画布当前节点模型,核心字段包括 id、properties.action 和 properties.nodeParams。
|
||||||
@ -235,6 +234,14 @@ const analysisModes = [
|
|||||||
{ label: '精确', value: 'ACCURATE' },
|
{ label: '精确', value: 'ACCURATE' },
|
||||||
]
|
]
|
||||||
// 声音类型同时保存机器可读编码和中文名称;allow-create 允许现场扩展未预置类型。
|
// 声音类型同时保存机器可读编码和中文名称;allow-create 允许现场扩展未预置类型。
|
||||||
|
const soundTypeOptions = [
|
||||||
|
{ label: '车机发声(不限类型)', value: 'ANY_VEHICLE_SOUND' },
|
||||||
|
{ label: '开机', value: 'POWER_ON' },
|
||||||
|
{ label: '关机', value: 'POWER_OFF' },
|
||||||
|
{ label: '设防', value: 'ARMED' },
|
||||||
|
{ label: '解防', value: 'DISARMED' },
|
||||||
|
{ label: '鸣笛寻车', value: 'FIND_VEHICLE_HORN' },
|
||||||
|
]
|
||||||
const modeDescriptions = {
|
const modeDescriptions = {
|
||||||
FAST: '使用轻量模型直接判断,适合动作明显、批量执行的场景。',
|
FAST: '使用轻量模型直接判断,适合动作明显、批量执行的场景。',
|
||||||
AUTO: '先快速判断,低置信度时自动切换精确分析。',
|
AUTO: '先快速判断,低置信度时自动切换精确分析。',
|
||||||
@ -273,6 +280,7 @@ const outputSummary = computed(() => isVideo.value
|
|||||||
{ name: 'expectedLabelName', desc: '目标声音类型' },
|
{ name: 'expectedLabelName', desc: '目标声音类型' },
|
||||||
{ name: 'labelName', desc: '实际声音类型' },
|
{ name: 'labelName', desc: '实际声音类型' },
|
||||||
{ name: 'matched', desc: '是否识别到已知声音' },
|
{ name: 'matched', desc: '是否识别到已知声音' },
|
||||||
|
{ name: 'vehicleSoundDetected', desc: '是否检测到车机发声' },
|
||||||
{ name: 'confidence', desc: '最高匹配度' },
|
{ name: 'confidence', desc: '最高匹配度' },
|
||||||
{ name: 'decisionReason', desc: '检测结论' },
|
{ name: 'decisionReason', desc: '检测结论' },
|
||||||
{ name: 'scores', desc: '各分类匹配度' },
|
{ name: 'scores', desc: '各分类匹配度' },
|
||||||
|
|||||||
437
src/views/flow/components/params/VisionLocateParams.vue
Normal file
437
src/views/flow/components/params/VisionLocateParams.vue
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
<template>
|
||||||
|
<div class="vision-locate-params">
|
||||||
|
<div class="intro">
|
||||||
|
<div class="intro__title">视觉定位执行</div>
|
||||||
|
<div class="intro__desc">拍摄彩色和深度图,识别目标位置,并换算为机械臂可以使用的末端坐标。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form ref="formRef" :model="form" label-position="top">
|
||||||
|
<el-form-item label="配置模式">
|
||||||
|
<el-segmented v-model="configMode" :options="configModeOptions" block />
|
||||||
|
<div class="field-hint">{{ configModeDescription }}</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<div class="section-heading">执行设备</div>
|
||||||
|
<el-form-item label="由谁执行" required>
|
||||||
|
<el-select v-model="resource.roleKey" placeholder="选择开始节点中配置的机器人角色" style="width: 100%">
|
||||||
|
<el-option v-for="role in roles" :key="role.roleKey" :label="role.displayName" :value="role.roleKey" />
|
||||||
|
</el-select>
|
||||||
|
<div v-if="!roles.length" class="field-warning">请先在开始节点添加一个机器人角色。</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="深度相机" required>
|
||||||
|
<el-select
|
||||||
|
v-if="cameraSlots.length"
|
||||||
|
v-model="resource.cameraSlotKey"
|
||||||
|
filterable
|
||||||
|
:placeholder="cameraSlots.length === 1 ? '已自动匹配相机' : '选择相机用途'"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option v-for="slot in cameraSlots" :key="slot.slotKey" :label="slotLabel(slot)" :value="slot.slotKey" />
|
||||||
|
</el-select>
|
||||||
|
<el-input v-else v-model="form.cameraDeviceId" clearable placeholder="输入相机设备 ID" />
|
||||||
|
<div v-if="!cameraSlots.length" class="field-warning">当前机器人角色没有相机用途,可暂时输入设备 ID,建议在开始节点统一配置。</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="机械臂" required>
|
||||||
|
<el-select
|
||||||
|
v-if="armSlots.length"
|
||||||
|
v-model="resource.armSlotKey"
|
||||||
|
filterable
|
||||||
|
:placeholder="armSlots.length === 1 ? '已自动匹配机械臂' : '选择机械臂用途'"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option v-for="slot in armSlots" :key="slot.slotKey" :label="slotLabel(slot)" :value="slot.slotKey" />
|
||||||
|
</el-select>
|
||||||
|
<el-input v-else v-model="form.armDeviceId" clearable placeholder="输入机械臂设备 ID" />
|
||||||
|
<div v-if="!armSlots.length" class="field-warning">当前机器人角色没有机械臂用途,可暂时输入设备 ID,建议在开始节点统一配置。</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="末端工具" required>
|
||||||
|
<el-select v-model="form.toolCode" filterable placeholder="选择已标定的笔、吸盘或其他工具" style="width: 100%">
|
||||||
|
<el-option v-for="tool in toolOptions" :key="tool.value" :label="tool.label" :value="tool.value" />
|
||||||
|
</el-select>
|
||||||
|
<div v-if="toolOptions.length" class="field-hint">运行时按实际机器人和机械臂自动使用该工具的当前启用 TCP。</div>
|
||||||
|
<div v-else class="field-warning">
|
||||||
|
尚无工具档案,请先到机器人管理中的视觉标定管理新增并启用工具 TCP。
|
||||||
|
<el-button text type="primary" @click="openCalibrationManager">打开标定管理</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<div class="section-heading">定位任务</div>
|
||||||
|
<el-form-item v-if="form.analysisMethod !== 'REFERENCE_MATCH'" label="要定位什么" required>
|
||||||
|
<el-input
|
||||||
|
v-model="form.prompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="1000"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="例如:定位手机屏幕右上角的蓝牙图标中心"
|
||||||
|
/>
|
||||||
|
<div class="field-hint">直接描述目标即可,系统会自动要求模型返回可计算的坐标。</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="识别方式">
|
||||||
|
<el-radio-group v-model="form.analysisMethod">
|
||||||
|
<el-radio-button value="TEXT_LOCATE">描述找图</el-radio-button>
|
||||||
|
<el-radio-button value="REFERENCE_MATCH">精确找图</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<div class="field-hint">精确找图需要一张目标小图,例如方形标记码;小图和 RGBD 彩色图在本次执行中一起匹配。</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="form.analysisMethod === 'REFERENCE_MATCH'" label="目标参考小图" required>
|
||||||
|
<el-input v-model="form.referenceImageUrl" clearable placeholder="输入方形码或目标小图的 HTTP/HTTPS 地址" />
|
||||||
|
</el-form-item>
|
||||||
|
<template v-if="form.analysisMethod === 'REFERENCE_MATCH'">
|
||||||
|
<el-form-item label="匹配选择">
|
||||||
|
<el-radio-group v-model="form.matchSelector">
|
||||||
|
<el-radio-button value="BEST">最佳匹配</el-radio-button>
|
||||||
|
<el-radio-button value="INDEX">指定序号</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="form.matchSelector === 'INDEX'" label="匹配序号">
|
||||||
|
<el-radio-group v-model="matchIndexMode" size="small">
|
||||||
|
<el-radio-button value="input">输入</el-radio-button>
|
||||||
|
<el-radio-button value="quote">引用</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<el-input-number v-if="matchIndexMode === 'input'" v-model="form.matchIndex" :min="1" :max="20" />
|
||||||
|
<el-cascader
|
||||||
|
v-else
|
||||||
|
v-model="matchIndexQuote"
|
||||||
|
:options="quoteOptions"
|
||||||
|
:props="{ checkStrictly: true }"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
style="width: 100%; margin-top: 8px"
|
||||||
|
placeholder="选择上游节点输出的序号"
|
||||||
|
@visible-change="refreshQuoteOptions"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="匹配排序">
|
||||||
|
<el-select v-model="form.matchSort" style="width: 100%">
|
||||||
|
<el-option label="从左到右、从上到下" value="ROW_MAJOR" />
|
||||||
|
<el-option label="从上到下、从左到右" value="COLUMN_MAJOR" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form-item label="定位精度">
|
||||||
|
<el-segmented v-model="form.analysisMode" :options="analysisModeOptions" block />
|
||||||
|
<div class="field-hint">{{ analysisModeDescription }}</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="安全接近距离">
|
||||||
|
<el-slider v-model="form.approachDistanceMm" :min="1" :max="20" :step="1" show-input />
|
||||||
|
<div class="field-hint">先到达手机表面法向外侧的安全位置,范围 1–20 mm。</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="按压距离">
|
||||||
|
<el-slider v-model="form.pressDistanceMm" :min="0" :max="20" :step="0.1" show-input />
|
||||||
|
<div class="field-hint">在理论接触面基础上继续前进的距离,默认 0 mm。</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-alert
|
||||||
|
title="本节点只计算位姿,不控制机械臂。请将输出的接近法兰位姿和接触法兰位姿分别引用到后续运动节点。"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="movement-warning"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="configMode === 'BASIC'" class="parameter-summary">
|
||||||
|
<div class="parameter-summary__head">
|
||||||
|
<span>当前高级参数</span>
|
||||||
|
<el-button text type="primary" @click="configMode = 'ADVANCED'">查看和修改</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row"><span>手眼标定</span><el-tag size="small" type="success" effect="plain">运行时自动选择</el-tag></div>
|
||||||
|
<div class="summary-row"><span>工具 TCP</span><strong>{{ selectedToolLabel || '尚未选择' }}</strong></div>
|
||||||
|
<div class="summary-row"><span>输出方式</span><strong>接近位姿 + 接触位姿</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-collapse v-else v-model="advancedSections" class="advanced-settings">
|
||||||
|
<el-collapse-item name="model" title="识别参数">
|
||||||
|
<el-form-item label="分析配置">
|
||||||
|
<el-select
|
||||||
|
v-model="form.profileCode"
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
placeholder="选择或输入分析配置编码"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option label="通用图片目标分析(推荐)" value="common.image_analysis.v1" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="最低识别置信度">
|
||||||
|
<el-slider v-model="form.confidenceThreshold" :min="0.2" :max="0.95" :step="0.01" show-input />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="平面拟合范围(像素)">
|
||||||
|
<el-slider v-model="form.normalSampleRadius" :min="3" :max="40" :step="1" show-input />
|
||||||
|
<div class="field-hint">使用目标周围深度点拟合手机平面和法向。目标靠近边缘时减小范围。</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="最小深度(米)"><el-input-number v-model="form.minDepth" :min="0.01" :step="0.05" /></el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="最大深度(米)"><el-input-number v-model="form.maxDepth" :min="0.1" :step="0.1" /></el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-collapse-item>
|
||||||
|
|
||||||
|
<el-collapse-item name="calibration">
|
||||||
|
<template #title>
|
||||||
|
<div class="collapse-title">
|
||||||
|
<span>标定与工具 TCP</span>
|
||||||
|
<el-tag size="small" type="success" effect="plain">自动选用</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-alert title="矩阵由视觉标定管理统一维护。运行时根据实际机器人、机械臂、相机和工具自动选择当前启用版本。" type="info" :closable="false" show-icon />
|
||||||
|
<el-button class="manager-button" type="primary" plain @click="openCalibrationManager">打开视觉标定管理</el-button>
|
||||||
|
</el-collapse-item>
|
||||||
|
|
||||||
|
<el-collapse-item name="motion" title="定位安全校验">
|
||||||
|
<el-form-item label="目标最大允许距离(米)">
|
||||||
|
<el-slider v-model="form.maxMovement" :min="0.01" :max="2" :step="0.01" show-input />
|
||||||
|
<div class="field-hint">计算结果相对当前法兰超过此距离时直接报错,防止错误识别产生异常位姿。</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-button :icon="Refresh" text type="primary" @click="restoreAdvancedDefaults">恢复推荐参数</el-button>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-divider content-position="left">节点输出</el-divider>
|
||||||
|
<div class="output-list">
|
||||||
|
<div v-for="item in outputSummary" :key="item.name" class="output-item">
|
||||||
|
<code>{{ item.name }}</code><span>{{ item.desc }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getResourceRoles } from '@/utils/flowResources'
|
||||||
|
import { DEVICE_KIND } from '@/utils/robotDevice'
|
||||||
|
import { listCalibrationProfiles } from '@/api/resource/visionCalibration'
|
||||||
|
import { useQuote } from './useQuote.js'
|
||||||
|
|
||||||
|
const props = defineProps({ data: Object })
|
||||||
|
const emit = defineEmits(['save-success', 'save-error'])
|
||||||
|
const router = useRouter()
|
||||||
|
const formRef = ref()
|
||||||
|
const roles = ref([])
|
||||||
|
const toolOptions = ref([])
|
||||||
|
const matchIndexMode = ref('input')
|
||||||
|
const matchIndexQuote = ref([])
|
||||||
|
const { quoteOptions, refreshQuoteOptions } = useQuote(props.data?.id)
|
||||||
|
const configMode = ref('BASIC')
|
||||||
|
const advancedSections = ref(['calibration'])
|
||||||
|
|
||||||
|
const configModeOptions = [{ label: '普通配置', value: 'BASIC' }, { label: '高级配置', value: 'ADVANCED' }]
|
||||||
|
const analysisModeOptions = [{ label: '快速', value: 'FAST' }, { label: '精准', value: 'ACCURATE' }]
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
cameraDeviceId: '', armDeviceId: '', profileCode: 'common.image_analysis.v1',
|
||||||
|
toolCode: '', prompt: '定位目标中心点', analysisMethod: 'TEXT_LOCATE', referenceImageUrl: '', matchSelector: 'BEST', matchIndex: 1, matchSort: 'ROW_MAJOR', analysisMode: 'FAST', confidenceThreshold: 0.7,
|
||||||
|
approachDistanceMm: 10, pressDistanceMm: 0, autoMove: false, maxMovement: 0.5,
|
||||||
|
normalSampleRadius: 12, minDepth: 0.05, maxDepth: 5,
|
||||||
|
})
|
||||||
|
const resource = reactive({ roleKey: '', cameraSlotKey: '', armSlotKey: '' })
|
||||||
|
const selectedRole = computed(() => roles.value.find(role => role.roleKey === resource.roleKey))
|
||||||
|
const cameraSlots = computed(() => (selectedRole.value?.deviceSlots || []).filter(slot => Number(slot.deviceKind) === DEVICE_KIND.CAMERA))
|
||||||
|
const armSlots = computed(() => (selectedRole.value?.deviceSlots || []).filter(slot => Number(slot.deviceKind) === DEVICE_KIND.ARM))
|
||||||
|
const configModeDescription = computed(() => configMode.value === 'BASIC'
|
||||||
|
? '选择设备、工具和目标即可,标定矩阵由系统自动匹配。'
|
||||||
|
: '用于调整识别、平面拟合和异常距离校验。')
|
||||||
|
const analysisModeDescription = computed(() => form.analysisMode === 'FAST'
|
||||||
|
? '优先响应速度,适合目标明显、环境稳定的场景。'
|
||||||
|
: '优先定位可靠性,适合目标较小或背景复杂的场景。')
|
||||||
|
const selectedToolLabel = computed(() => toolOptions.value.find(item => item.value === form.toolCode)?.label)
|
||||||
|
const pointChildren = [
|
||||||
|
{ name: 'x', type: 'number', desc: 'X坐标' },
|
||||||
|
{ name: 'y', type: 'number', desc: 'Y坐标' },
|
||||||
|
{ name: 'z', type: 'number', desc: 'Z坐标' },
|
||||||
|
]
|
||||||
|
const poseChildren = [
|
||||||
|
...pointChildren,
|
||||||
|
{ name: 'rx', type: 'number', desc: 'RX旋转(弧度)' },
|
||||||
|
{ name: 'ry', type: 'number', desc: 'RY旋转(弧度)' },
|
||||||
|
{ name: 'rz', type: 'number', desc: 'RZ旋转(弧度)' },
|
||||||
|
]
|
||||||
|
const outputSummary = [
|
||||||
|
{ name: 'objectPoint', desc: '目标在机器人基座坐标系中的位置' },
|
||||||
|
{ name: 'surfaceNormal', desc: '手机表面朝外法向' },
|
||||||
|
{ name: 'approachFlangePose', desc: '安全接近位置对应的法兰位姿' },
|
||||||
|
{ name: 'contactFlangePose', desc: '最终接触位置对应的法兰位姿' },
|
||||||
|
{ name: 'approachTcpPose', desc: '安全接近工具 TCP 位姿' },
|
||||||
|
{ name: 'contactTcpPose', desc: '最终接触工具 TCP 位姿' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const slotLabel = slot => `${slot.displayName} · ${Number(slot.deviceKind) === DEVICE_KIND.CAMERA ? '相机' : '机械臂'}`
|
||||||
|
const selectSingleSlots = () => {
|
||||||
|
if (cameraSlots.value.length === 1) resource.cameraSlotKey = cameraSlots.value[0].slotKey
|
||||||
|
if (armSlots.value.length === 1) resource.armSlotKey = armSlots.value[0].slotKey
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTools = async () => {
|
||||||
|
try {
|
||||||
|
const response = await listCalibrationProfiles({ profileType: 'TOOL_TCP' })
|
||||||
|
const byCode = new Map()
|
||||||
|
;(response.data || []).filter(item => item.status !== 'INVALID' && item.toolCode).forEach(item => {
|
||||||
|
if (!byCode.has(item.toolCode)) byCode.set(item.toolCode, { value: item.toolCode, label: `${item.profileName}(${item.toolCode})` })
|
||||||
|
})
|
||||||
|
toolOptions.value = [...byCode.values()]
|
||||||
|
} catch {
|
||||||
|
toolOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCalibrationManager = () => router.push('/resource/vision-calibration')
|
||||||
|
|
||||||
|
const init = () => {
|
||||||
|
const params = props.data?.properties?.nodeParams || []
|
||||||
|
params.forEach(item => {
|
||||||
|
if (item?.name && Object.prototype.hasOwnProperty.call(form, item.name) && item.input !== undefined) form[item.name] = item.input
|
||||||
|
})
|
||||||
|
// Legacy nodes sometimes retained a reference image while their method was
|
||||||
|
// still VISION_MODEL. A reference image unambiguously means precise matching
|
||||||
|
// and the backend applies the same migration at runtime.
|
||||||
|
if (String(form.referenceImageUrl || '').trim() && form.analysisMethod !== 'REFERENCE_MATCH') {
|
||||||
|
form.analysisMethod = 'REFERENCE_MATCH'
|
||||||
|
}
|
||||||
|
if (form.analysisMethod === 'REFERENCE_MATCH' && Number(form.confidenceThreshold) === 0.7) {
|
||||||
|
form.confidenceThreshold = 0.45
|
||||||
|
}
|
||||||
|
configMode.value = props.data?.properties?.visionConfigMode || 'BASIC'
|
||||||
|
roles.value = getResourceRoles()
|
||||||
|
const target = props.data?.properties?.executionTarget || {}
|
||||||
|
resource.roleKey = target.roleKey || roles.value[0]?.roleKey || ''
|
||||||
|
const bindings = target.deviceBindings || []
|
||||||
|
resource.cameraSlotKey = bindings.find(item => item?.parameterName === 'cameraDeviceId')?.slotKey || ''
|
||||||
|
resource.armSlotKey = bindings.find(item => item?.parameterName === 'armDeviceId')?.slotKey || ''
|
||||||
|
selectSingleSlots()
|
||||||
|
const matchIndexParam = params.find(item => item?.name === 'matchIndex')
|
||||||
|
matchIndexMode.value = matchIndexParam?.type === 'quote' ? 'quote' : 'input'
|
||||||
|
matchIndexQuote.value = Array.isArray(matchIndexParam?.quote) ? [...matchIndexParam.quote] : []
|
||||||
|
refreshQuoteOptions()
|
||||||
|
loadTools()
|
||||||
|
}
|
||||||
|
watch(() => props.data, init, { immediate: true, deep: true })
|
||||||
|
watch(() => resource.roleKey, () => {
|
||||||
|
if (!cameraSlots.value.some(slot => slot.slotKey === resource.cameraSlotKey)) resource.cameraSlotKey = ''
|
||||||
|
if (!armSlots.value.some(slot => slot.slotKey === resource.armSlotKey)) resource.armSlotKey = ''
|
||||||
|
selectSingleSlots()
|
||||||
|
})
|
||||||
|
watch(() => form.analysisMethod, method => {
|
||||||
|
if (method === 'REFERENCE_MATCH' && Number(form.confidenceThreshold) === 0.7) form.confidenceThreshold = 0.45
|
||||||
|
if (method !== 'REFERENCE_MATCH' && Number(form.confidenceThreshold) === 0.45) form.confidenceThreshold = 0.7
|
||||||
|
})
|
||||||
|
|
||||||
|
const restoreAdvancedDefaults = () => {
|
||||||
|
form.profileCode = 'common.image_analysis.v1'
|
||||||
|
form.analysisMode = 'FAST'
|
||||||
|
form.confidenceThreshold = 0.7
|
||||||
|
form.normalSampleRadius = 12
|
||||||
|
form.minDepth = 0.05
|
||||||
|
form.maxDepth = 5
|
||||||
|
form.maxMovement = 0.5
|
||||||
|
ElMessage.success('已恢复识别、平面拟合和距离校验推荐参数')
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateAndSave = async () => {
|
||||||
|
if (!resource.roleKey) {
|
||||||
|
ElMessage.warning('请选择执行机器人角色'); emit('save-error'); return
|
||||||
|
}
|
||||||
|
if ((!resource.cameraSlotKey && !String(form.cameraDeviceId || '').trim())
|
||||||
|
|| (!resource.armSlotKey && !String(form.armDeviceId || '').trim())) {
|
||||||
|
ElMessage.warning('请选择相机和机械臂,或填写对应设备 ID'); emit('save-error'); return
|
||||||
|
}
|
||||||
|
if (form.analysisMethod !== 'REFERENCE_MATCH' && !String(form.prompt || '').trim()) {
|
||||||
|
ElMessage.warning('请描述要定位的目标'); emit('save-error'); return
|
||||||
|
}
|
||||||
|
if (form.analysisMethod === 'REFERENCE_MATCH' && !String(form.referenceImageUrl || '').trim()) {
|
||||||
|
ElMessage.warning('精确找图需要填写目标参考小图地址'); emit('save-error'); return
|
||||||
|
}
|
||||||
|
if (!String(form.toolCode || '').trim()) {
|
||||||
|
ElMessage.warning('请选择已标定的末端工具'); emit('save-error'); return
|
||||||
|
}
|
||||||
|
if (!String(form.profileCode || '').trim()) {
|
||||||
|
ElMessage.warning('分析配置不能为空'); emit('save-error'); return
|
||||||
|
}
|
||||||
|
if (Number(form.minDepth) >= Number(form.maxDepth)) {
|
||||||
|
ElMessage.warning('最大深度必须大于最小深度'); emit('save-error'); return
|
||||||
|
}
|
||||||
|
form.autoMove = false
|
||||||
|
const requiredNames = new Set(['cameraDeviceId', 'armDeviceId', 'profileCode', 'toolCode'])
|
||||||
|
if (form.analysisMethod !== 'REFERENCE_MATCH') requiredNames.add('prompt')
|
||||||
|
if (form.matchSelector === 'INDEX' && matchIndexMode.value === 'quote' && !matchIndexQuote.value.length) {
|
||||||
|
ElMessage.warning('请选择用于匹配序号的上游输出'); emit('save-error'); return
|
||||||
|
}
|
||||||
|
const nodeParams = Object.entries(form)
|
||||||
|
.filter(([name]) => name !== 'matchIndex')
|
||||||
|
.map(([name, input]) => ({ name, type: 'input', input, required: requiredNames.has(name) }))
|
||||||
|
if (form.matchSelector === 'INDEX' && matchIndexMode.value === 'quote') {
|
||||||
|
nodeParams.push({ name: 'matchIndex', type: 'quote', input: '', quote: [...matchIndexQuote.value],
|
||||||
|
quoteType: matchIndexQuote.value[1] === 'input' ? 'input' : 'output', required: true })
|
||||||
|
} else {
|
||||||
|
nodeParams.push({ name: 'matchIndex', type: 'input', input: form.matchIndex, required: false })
|
||||||
|
}
|
||||||
|
const properties = {
|
||||||
|
...props.data.properties,
|
||||||
|
visionConfigMode: configMode.value,
|
||||||
|
nodeParams,
|
||||||
|
outputType: 'json',
|
||||||
|
outputParams: [
|
||||||
|
{ name: 'objectPoint', type: 'object', desc: '目标基座坐标', children: pointChildren },
|
||||||
|
{ name: 'surfaceNormal', type: 'object', desc: '手机表面法向', children: pointChildren },
|
||||||
|
{ name: 'approachTcpPose', type: 'object', desc: '安全接近工具位姿', children: poseChildren },
|
||||||
|
{ name: 'contactTcpPose', type: 'object', desc: '最终接触工具位姿', children: poseChildren },
|
||||||
|
{ name: 'approachFlangePose', type: 'object', desc: '安全接近法兰位姿', children: poseChildren },
|
||||||
|
{ name: 'contactFlangePose', type: 'object', desc: '最终接触法兰位姿', children: poseChildren },
|
||||||
|
{ name: 'pixel', type: 'object', desc: '目标像素坐标', children: [] },
|
||||||
|
{ name: 'depth', type: 'number', desc: '目标深度' },
|
||||||
|
{ name: 'depthUnitScale', type: 'number', desc: '深度单位比例' },
|
||||||
|
{ name: 'intrinsics', type: 'object', desc: '相机内参', children: [] },
|
||||||
|
],
|
||||||
|
executionTarget: {
|
||||||
|
...(props.data.properties.executionTarget || {}),
|
||||||
|
roleKey: resource.roleKey,
|
||||||
|
dispatchPolicy: 'ONE',
|
||||||
|
deviceBindings: [
|
||||||
|
resource.cameraSlotKey && { parameterName: 'cameraDeviceId', slotKey: resource.cameraSlotKey },
|
||||||
|
resource.armSlotKey && { parameterName: 'armDeviceId', slotKey: resource.armSlotKey },
|
||||||
|
].filter(Boolean),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
globalThis.lf.setProperties(props.data.id, properties)
|
||||||
|
emit('save-success')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ validateAndSave })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.vision-locate-params { padding-top: 16px; padding-bottom: 24px; }
|
||||||
|
.intro { margin-bottom: 20px; padding: 14px 16px; border-left: 3px solid var(--el-color-primary); background: var(--el-fill-color-light); }
|
||||||
|
.intro__title { color: var(--el-text-color-primary); font-weight: 650; }
|
||||||
|
.intro__desc, .field-hint, .field-warning { margin-top: 5px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 18px; }
|
||||||
|
.field-warning { color: var(--el-color-warning-dark-2); }
|
||||||
|
.section-heading { margin: 22px 0 14px; padding-bottom: 9px; border-bottom: 1px solid var(--el-border-color-lighter); color: var(--el-text-color-primary); font-size: 14px; font-weight: 650; }
|
||||||
|
.movement-warning { margin: 2px 0 18px; }
|
||||||
|
.parameter-summary { margin: 6px 0 20px; padding: 12px 14px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; background: var(--el-fill-color-extra-light); }
|
||||||
|
.parameter-summary__head, .summary-row { display: flex; min-height: 32px; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.parameter-summary__head { margin-bottom: 4px; color: var(--el-text-color-primary); font-weight: 650; }
|
||||||
|
.summary-row { color: var(--el-text-color-regular); font-size: 13px; }
|
||||||
|
.summary-row strong { font-weight: 500; }
|
||||||
|
.advanced-settings { margin: 6px 0 20px; border-top: 1px solid var(--el-border-color-lighter); }
|
||||||
|
.advanced-settings :deep(.el-collapse-item__header) { min-height: 50px; height: auto; }
|
||||||
|
.advanced-settings :deep(.el-collapse-item__content) { padding: 6px 2px 10px; }
|
||||||
|
.collapse-title { display: flex; min-width: 0; align-items: center; gap: 8px; }
|
||||||
|
.output-list { display: grid; gap: 8px; }
|
||||||
|
.output-item { display: flex; gap: 12px; align-items: baseline; color: var(--el-text-color-regular); font-size: 13px; }
|
||||||
|
.output-item code { min-width: 104px; color: var(--el-color-primary); }
|
||||||
|
:deep(.el-input-number) { width: 100%; }
|
||||||
|
:deep(.el-segmented) { width: 100%; }
|
||||||
|
</style>
|
||||||
@ -15,6 +15,19 @@ import dialogueSvg from './icon/dialogue.svg'
|
|||||||
import audioSvg from './icon/audio.svg'
|
import audioSvg from './icon/audio.svg'
|
||||||
import touchSvg from './icon/touch.svg'
|
import touchSvg from './icon/touch.svg'
|
||||||
import expressionSvg from './icon/expression.svg'
|
import expressionSvg from './icon/expression.svg'
|
||||||
|
import { DEFAULT_CHAT_SYSTEM_PROMPT } from './chatPrompt.js'
|
||||||
|
|
||||||
|
const pointOutputChildren = () => [
|
||||||
|
{ name: 'x', type: 'number', desc: 'X坐标' },
|
||||||
|
{ name: 'y', type: 'number', desc: 'Y坐标' },
|
||||||
|
{ name: 'z', type: 'number', desc: 'Z坐标' },
|
||||||
|
]
|
||||||
|
const poseOutputChildren = () => [
|
||||||
|
...pointOutputChildren(),
|
||||||
|
{ name: 'rx', type: 'number', desc: 'RX旋转(弧度)' },
|
||||||
|
{ name: 'ry', type: 'number', desc: 'RY旋转(弧度)' },
|
||||||
|
{ name: 'rz', type: 'number', desc: 'RZ旋转(弧度)' },
|
||||||
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理与 http method options 相关的状态或数据,该影响范围仅限当前模块。
|
* 处理与 http method options 相关的状态或数据,该影响范围仅限当前模块。
|
||||||
@ -313,6 +326,46 @@ function handler(params) {
|
|||||||
{
|
{
|
||||||
collapseTitle: "机器人",
|
collapseTitle: "机器人",
|
||||||
nodeList: [
|
nodeList: [
|
||||||
|
{
|
||||||
|
icon: cameraSvg,
|
||||||
|
name: "视觉定位执行",
|
||||||
|
type: "visionLocate",
|
||||||
|
desc: "同帧获取RGBD图像,分析目标并计算工具TCP和机械臂法兰位姿",
|
||||||
|
action: 'VISION_LOCATE_TARGET',
|
||||||
|
nodeType: 'EDGE',
|
||||||
|
nodeParams: [
|
||||||
|
{ name: "cameraDeviceId", type: "input", input: "", required: true },
|
||||||
|
{ name: "armDeviceId", type: "input", input: "", required: true },
|
||||||
|
{ name: "toolCode", type: "input", input: "", required: true },
|
||||||
|
{ name: "profileCode", type: "input", input: "common.image_analysis.v1", required: true },
|
||||||
|
{ name: "prompt", type: "input", input: "定位目标中心点", required: true },
|
||||||
|
{ name: "analysisMode", type: "input", input: "ACCURATE" },
|
||||||
|
{ name: "mountType", type: "input", input: "EYE_IN_HAND", componentType: "select", selectOptions: [
|
||||||
|
{ value: "EYE_IN_HAND", label: "末端相机" }, { value: "EYE_TO_HAND", label: "固定相机" }
|
||||||
|
] },
|
||||||
|
{ name: "approachDistanceMm", type: "input", componentType: "number", input: 10, min: 1, max: 20 },
|
||||||
|
{ name: "pressDistanceMm", type: "input", componentType: "number", input: 0, min: 0, max: 20, step: 0.1 },
|
||||||
|
{ name: "normalSampleRadius", type: "input", componentType: "number", input: 12, min: 3, max: 40 },
|
||||||
|
{ name: "confidenceThreshold", type: "input", componentType: "number", input: 0.7, min: 0, max: 1, step: 0.01 },
|
||||||
|
{ name: "minDepth", type: "input", componentType: "number", input: 0.05, min: 0.01, step: 0.05 },
|
||||||
|
{ name: "maxDepth", type: "input", componentType: "number", input: 5, min: 0.1, step: 0.1 },
|
||||||
|
{ name: "maxMovement", type: "input", componentType: "number", input: 0.5, min: 0.01, step: 0.01 },
|
||||||
|
{ name: "autoMove", type: "input", componentType: "switch", input: false, disabled: true }
|
||||||
|
],
|
||||||
|
outputType: 'json',
|
||||||
|
outputParams: [
|
||||||
|
{ name: 'objectPoint', type: 'object', children: pointOutputChildren() },
|
||||||
|
{ name: 'surfaceNormal', type: 'object', children: pointOutputChildren() },
|
||||||
|
{ name: 'approachTcpPose', type: 'object', children: poseOutputChildren() },
|
||||||
|
{ name: 'contactTcpPose', type: 'object', children: poseOutputChildren() },
|
||||||
|
{ name: 'approachFlangePose', type: 'object', children: poseOutputChildren() },
|
||||||
|
{ name: 'contactFlangePose', type: 'object', children: poseOutputChildren() },
|
||||||
|
{ name: 'pixel', type: 'object', children: [] },
|
||||||
|
{ name: 'depth', type: 'number' },
|
||||||
|
{ name: 'depthUnitScale', type: 'number' },
|
||||||
|
{ name: 'intrinsics', type: 'object', children: [] }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: videoSvg,
|
icon: videoSvg,
|
||||||
name: "AGV控制",
|
name: "AGV控制",
|
||||||
@ -904,4 +957,25 @@ function handler(params) {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
collapseTitle: "闲聊",
|
||||||
|
nodeList: [{
|
||||||
|
icon: dialogueSvg,
|
||||||
|
name: "闲聊",
|
||||||
|
type: "chat",
|
||||||
|
desc: "输入文本并调用大模型进行自然语言闲聊,只返回文本",
|
||||||
|
action: 'LLM_CHAT',
|
||||||
|
nodeType: "LLM",
|
||||||
|
outputType: 'json',
|
||||||
|
nodeParams: [
|
||||||
|
{ name: "text", type: "input", input: "", required: true },
|
||||||
|
{ name: "promptMode", type: "input", input: "CUSTOM", componentType: "select", selectOptions: [
|
||||||
|
{ value: "FIXED", label: "固定提示词(推荐)" }, { value: "CUSTOM", label: "自定义提示词" }
|
||||||
|
] },
|
||||||
|
{ name: "prompt", type: "input", input: DEFAULT_CHAT_SYSTEM_PROMPT, required: true },
|
||||||
|
{ name: "invokeTts", type: "input", input: false, componentType: "switch" },
|
||||||
|
],
|
||||||
|
outputParams: [{ name: 'text', type: 'string', desc: '大模型返回的闲聊文本', disabled: true }]
|
||||||
|
}]
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@ -114,6 +114,8 @@ const componentMap = {
|
|||||||
device_universal: DeviceUniversal,
|
device_universal: DeviceUniversal,
|
||||||
actionQueue: ActionQueue,
|
actionQueue: ActionQueue,
|
||||||
subFlow: SubFlowNode,
|
subFlow: SubFlowNode,
|
||||||
|
visionLocate: ServiceNode,
|
||||||
|
chat: ServiceNode,
|
||||||
};
|
};
|
||||||
|
|
||||||
const flowStore = useFlowStore();
|
const flowStore = useFlowStore();
|
||||||
|
|||||||
@ -1,14 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container resource-device-page">
|
<div class="app-container resource-device-page">
|
||||||
<el-form :inline="true" :model="query">
|
<el-form :inline="true" :model="query">
|
||||||
<el-form-item label="机器人ID">
|
<el-form-item label="机器人">
|
||||||
<el-select v-model="query.robotId" filterable clearable :loading="robotsLoading" placeholder="请选择机器人" style="width: 190px">
|
<el-select
|
||||||
<el-option v-for="robot in robots" :key="robot.robotId" :label="robotLabel(robot)" :value="robot.robotId" />
|
v-model="query.robotId"
|
||||||
|
filterable
|
||||||
|
clearable
|
||||||
|
:loading="robotLoading"
|
||||||
|
placeholder="全部机器人"
|
||||||
|
style="width: 280px"
|
||||||
|
@change="handleRobotChange"
|
||||||
|
>
|
||||||
|
<el-option label="全部机器人" value="" />
|
||||||
|
<el-option
|
||||||
|
v-for="robot in robots"
|
||||||
|
:key="robot.robotId"
|
||||||
|
:label="robotOptionLabel(robot)"
|
||||||
|
:value="robot.robotId"
|
||||||
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="设备用途"><el-input v-model="query.deviceRole" clearable @keyup.enter="search" /></el-form-item>
|
<el-form-item label="设备用途"><el-input v-model="query.deviceRole" clearable @keyup.enter="search" /></el-form-item>
|
||||||
<el-form-item label="在线状态">
|
<el-form-item label="在线状态">
|
||||||
<el-select v-model="onlineFilter" clearable style="width: 120px">
|
<el-select v-model="onlineFilter" clearable style="width: 120px" @change="search">
|
||||||
<el-option label="在线" value="online" /><el-option label="离线" value="offline" />
|
<el-option label="在线" value="online" /><el-option label="离线" value="offline" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@ -59,12 +73,14 @@ import { listResourceRobots } from '@/api/resource/robot'
|
|||||||
|
|
||||||
const kindNames = { 1: '移动底盘', 2: '机械臂', 3: '电池', 4: '仿生头', 5: '摄像头', 6: 'CAN总线', 7: '灵巧手', 8: '夹爪', 9: '麦克风', 10: '电机', 11: '电机系统', 12: '机器人本体', 13: '扬声器' }
|
const kindNames = { 1: '移动底盘', 2: '机械臂', 3: '电池', 4: '仿生头', 5: '摄像头', 6: 'CAN总线', 7: '灵巧手', 8: '夹爪', 9: '麦克风', 10: '电机', 11: '电机系统', 12: '机器人本体', 13: '扬声器' }
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const loading = ref(false), saving = ref(false), rows = ref([]), total = ref(0), configOpen = ref(false), onlineFilter = ref('')
|
const loading = ref(false), robotLoading = ref(false), saving = ref(false), rows = ref([]), robots = ref([]), total = ref(0), configOpen = ref(false), onlineFilter = ref('')
|
||||||
const robots = ref([]), robotsLoading = ref(false)
|
|
||||||
const query = reactive({ pageNum: 1, pageSize: 20, robotId: '', deviceRole: '', onlineOnly: false })
|
const query = reactive({ pageNum: 1, pageSize: 20, robotId: '', deviceRole: '', onlineOnly: false })
|
||||||
const active = reactive({})
|
const active = reactive({})
|
||||||
const kindName = value => kindNames[value] || `未知(${value ?? '-'})`
|
const kindName = value => kindNames[value] || `未知(${value ?? '-'})`
|
||||||
const robotLabel = robot => robot.robotName ? `${robot.robotName} (${robot.robotId})` : robot.robotId
|
const robotOptionLabel = robot => robot.robotName
|
||||||
|
? `${robot.robotName}(${robot.robotId})`
|
||||||
|
: robot.robotId
|
||||||
const controlPath = device => ({
|
const controlPath = device => ({
|
||||||
2: 'mechanical_arm', 4: 'head', 5: 'camera', 7: 'hand', 9: 'microphone', 13: 'speaker',
|
2: 'mechanical_arm', 4: 'head', 5: 'camera', 7: 'hand', 9: 'microphone', 13: 'speaker',
|
||||||
})[device.deviceKind]
|
})[device.deviceKind]
|
||||||
@ -101,6 +117,8 @@ async function loadRobots() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function search() { query.pageNum = 1; load() }
|
function search() { query.pageNum = 1; load() }
|
||||||
|
|
||||||
|
function handleRobotChange() { search() }
|
||||||
function reset() { Object.assign(query, { pageNum: 1, robotId: '', deviceRole: '', onlineOnly: false }); onlineFilter.value = ''; load() }
|
function reset() { Object.assign(query, { pageNum: 1, robotId: '', deviceRole: '', onlineOnly: false }); onlineFilter.value = ''; load() }
|
||||||
function openConfig(row) {
|
function openConfig(row) {
|
||||||
Object.assign(active, JSON.parse(JSON.stringify(row)))
|
Object.assign(active, JSON.parse(JSON.stringify(row)))
|
||||||
|
|||||||
371
src/views/resource/visionCalibration/index.vue
Normal file
371
src/views/resource/visionCalibration/index.vue
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container calibration-page">
|
||||||
|
<div class="page-heading">
|
||||||
|
<div>
|
||||||
|
<h2>视觉标定管理</h2>
|
||||||
|
<p>管理每台机器人的手眼标定和末端工具 TCP。工作流自动使用当前启用版本。</p>
|
||||||
|
</div>
|
||||||
|
<div class="heading-actions">
|
||||||
|
<el-button :icon="Camera" type="primary" @click="openHandEye">新建手眼标定</el-button>
|
||||||
|
<el-button :icon="Aim" @click="openTool">新增工具 TCP</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<el-segmented v-model="query.profileType" :options="profileTypeOptions" @change="loadProfiles" />
|
||||||
|
<el-select v-model="query.robotId" clearable filterable placeholder="全部机器人" @change="loadProfiles">
|
||||||
|
<el-option v-for="robot in robots" :key="robot.robotId" :label="robotLabel(robot)" :value="robot.robotId" />
|
||||||
|
</el-select>
|
||||||
|
<el-input v-model="query.keyword" clearable placeholder="搜索方案或工具" :prefix-icon="Search" @keyup.enter="loadProfiles" />
|
||||||
|
<el-button :icon="Refresh" @click="loadProfiles">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="profiles" row-key="id" empty-text="暂无标定方案">
|
||||||
|
<el-table-column label="方案" min-width="190">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<strong>{{ row.profileName }}</strong>
|
||||||
|
<div class="secondary">{{ row.profileType === 'HAND_EYE' ? '手眼标定' : `工具 · ${row.toolCode}` }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="机器人/设备" min-width="260">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>{{ robotName(row.robotId) }}</div>
|
||||||
|
<div class="secondary">机械臂 {{ row.armDeviceId }}</div>
|
||||||
|
<div v-if="row.cameraDeviceId" class="secondary">相机 {{ row.cameraDeviceId }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="版本" width="80" align="center"><template #default="{ row }">V{{ row.versionNo }}</template></el-table-column>
|
||||||
|
<el-table-column label="验证误差" width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.maxErrorMm != null" :class="Number(row.maxErrorMm) > 1 ? 'error-value' : 'ok-value'">
|
||||||
|
最大 {{ Number(row.maxErrorMm).toFixed(2) }} mm
|
||||||
|
</span>
|
||||||
|
<span v-else class="secondary">待验证</span>
|
||||||
|
<div v-if="row.meanErrorMm != null" class="secondary">平均 {{ Number(row.meanErrorMm).toFixed(2) }} mm</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusType(row.status)" effect="plain">{{ statusLabel(row.status) }}</el-tag>
|
||||||
|
<div v-if="row.forcedEnabled" class="forced-label">人工启用</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="验证时间" prop="verifiedTime" width="170"><template #default="{ row }">{{ row.verifiedTime || '-' }}</template></el-table-column>
|
||||||
|
<el-table-column label="操作" width="285" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="openCorrection(row)">微调</el-button>
|
||||||
|
<el-button link type="primary" :disabled="row.status === 'INVALID'" @click="verifyProfile(row)">验证</el-button>
|
||||||
|
<el-button v-if="row.status !== 'ACTIVE'" link type="success" :disabled="!row.verifiedTime" @click="activateProfile(row)">启用</el-button>
|
||||||
|
<el-button link type="danger" :disabled="row.status === 'INVALID'" @click="invalidateProfile(row)">失效</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-dialog v-model="setupOpen" :title="setupType === 'HAND_EYE' ? '新建手眼标定' : '新增工具 TCP'" width="620px" destroy-on-close>
|
||||||
|
<el-alert
|
||||||
|
:title="setupType === 'HAND_EYE' ? '先使用示教按钮将相机移动到能看清唯一方形标定码的位置,并确认周围无障碍。' : '先将相机移动到能看清该工具方向标定码的位置。'"
|
||||||
|
type="info" show-icon :closable="false" class="dialog-alert"
|
||||||
|
/>
|
||||||
|
<el-form ref="setupRef" :model="setup" :rules="setupRules" label-position="top">
|
||||||
|
<el-form-item label="机器人" prop="robotId">
|
||||||
|
<el-select v-model="setup.robotId" filterable style="width: 100%" @change="loadSetupDevices">
|
||||||
|
<el-option v-for="robot in robots" :key="robot.robotId" :label="robotLabel(robot)" :value="robot.robotId" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="机械臂" prop="armDeviceId">
|
||||||
|
<el-select v-model="setup.armDeviceId" filterable style="width: 100%"><el-option v-for="item in armDevices" :key="item.deviceId" :label="deviceLabel(item)" :value="item.deviceId" /></el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="深度相机" prop="cameraDeviceId">
|
||||||
|
<el-select v-model="setup.cameraDeviceId" filterable style="width: 100%"><el-option v-for="item in cameraDevices" :key="item.deviceId" :label="deviceLabel(item)" :value="item.deviceId" /></el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="方案名称" prop="profileName"><el-input v-model="setup.profileName" placeholder="例如:右臂末端相机" /></el-form-item>
|
||||||
|
<template v-if="setupType === 'TOOL_TCP'">
|
||||||
|
<el-form-item label="工具标识" prop="toolCode">
|
||||||
|
<el-input v-model="setup.toolCode" placeholder="稳定标识,例如 pen、suction,不建议后续修改" />
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :span="12"><el-form-item label="平移范围"><el-input-number v-model="setup.translationRangeMm" :min="5" :max="20" /><span class="unit">mm</span></el-form-item></el-col>
|
||||||
|
<el-col :span="12"><el-form-item label="旋转范围"><el-input-number v-model="setup.rotationRangeDeg" :min="3" :max="10" /><span class="unit">°</span></el-form-item></el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-form-item label="标定码外框边长"><el-input-number v-model="setup.markerSizeMm" :min="10" :max="100" /><span class="unit">mm</span></el-form-item>
|
||||||
|
</template>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="setupOpen = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="starting" @click="startSetup">开始标定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="sessionOpen" title="标定进行中" width="760px" :close-on-click-modal="false" :before-close="closeSession">
|
||||||
|
<div class="session-status">
|
||||||
|
<div class="session-status__top">
|
||||||
|
<strong>{{ session.message }}</strong>
|
||||||
|
<el-tag :type="sessionStatusType">{{ sessionStatusLabel }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<el-progress :percentage="sessionPercent" :status="session.status === 'FAILED' ? 'exception' : (session.status === 'COMPLETED' ? 'success' : '')" />
|
||||||
|
<div class="secondary">已记录 {{ session.sampleCount || 0 }} 组 · 步骤 {{ session.currentStep || 0 }}/{{ session.totalSteps || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="session.imageUrl" class="marker-preview">
|
||||||
|
<svg v-if="session.imageWidth && session.imageHeight" :viewBox="`0 0 ${session.imageWidth} ${session.imageHeight}`" role="img" aria-label="标定码识别画面">
|
||||||
|
<image :href="session.imageUrl" x="0" y="0" :width="session.imageWidth" :height="session.imageHeight" />
|
||||||
|
<polygon v-if="markerPolygon" :points="markerPolygon" class="marker-outline" />
|
||||||
|
<g v-if="targetCorner">
|
||||||
|
<circle :cx="targetCorner[0]" :cy="targetCorner[1]" r="10" class="target-corner" />
|
||||||
|
<line :x1="targetCorner[0] - 18" :y1="targetCorner[1]" :x2="targetCorner[0] + 18" :y2="targetCorner[1]" class="target-cross" />
|
||||||
|
<line :x1="targetCorner[0]" :y1="targetCorner[1] - 18" :x2="targetCorner[0]" :y2="targetCorner[1] + 18" class="target-cross" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<img v-else :src="session.imageUrl" alt="标定码识别画面" />
|
||||||
|
<div class="marker-preview__caption">绿色边框为识别结果;黄色十字是工具定点标定统一使用的规范角点。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isToolSession && session.status === 'RUNNING'" class="tool-actions">
|
||||||
|
<div class="tool-step">
|
||||||
|
<span class="tool-step__number">1</span>
|
||||||
|
<div><strong>定点旋转</strong><p>使用示教按钮让工具中心对准标定码规范角点,每次明显改变末端角度。</p></div>
|
||||||
|
<el-button type="primary" :loading="recording" @click="recordPivot">记录当前位姿</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="tool-step">
|
||||||
|
<span class="tool-step__number">2</span>
|
||||||
|
<div><strong>安全方向</strong><p>样本足够后,让工具垂直贴合标定码平面,再记录工具工作朝向。</p></div>
|
||||||
|
<el-button :disabled="!canRecordDirection" :loading="recording" @click="recordDirection">记录方向并完成</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="session.status === 'COMPLETED'" class="result-strip">
|
||||||
|
<div><span>平均误差</span><strong>{{ formatError(session.meanErrorMm) }}</strong></div>
|
||||||
|
<div><span>最大误差</span><strong :class="Number(session.maxErrorMm) > 1 ? 'error-value' : 'ok-value'">{{ formatError(session.maxErrorMm) }}</strong></div>
|
||||||
|
<div v-if="session.directionErrorDeg != null"><span>方向误差</span><strong>{{ Number(session.directionErrorDeg).toFixed(2) }}°</strong></div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button v-if="session.status === 'RUNNING'" type="danger" plain @click="stopSession">停止并保持当前位置</el-button>
|
||||||
|
<el-button v-else type="primary" @click="finishSessionDialog">完成</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="correctionOpen" title="六自由度人工微调" width="620px">
|
||||||
|
<el-alert title="修正不会覆盖算法原始矩阵。保存后方案进入待验证状态,必须完成快速验证才能重新启用。" type="warning" :closable="false" show-icon />
|
||||||
|
<el-form :model="correction" label-position="top" class="correction-form">
|
||||||
|
<el-divider content-position="left">位置修正(毫米)</el-divider>
|
||||||
|
<el-row :gutter="12"><el-col v-for="axis in ['xMm','yMm','zMm']" :key="axis" :span="8"><el-form-item :label="axis[0].toUpperCase()"><el-input-number v-model="correction[axis]" :step="0.1" :precision="2" /></el-form-item></el-col></el-row>
|
||||||
|
<el-divider content-position="left">角度修正(度)</el-divider>
|
||||||
|
<el-row :gutter="12"><el-col v-for="axis in ['rxDeg','ryDeg','rzDeg']" :key="axis" :span="8"><el-form-item :label="axis.slice(0,2).toUpperCase()"><el-input-number v-model="correction[axis]" :step="0.1" :precision="2" /></el-form-item></el-col></el-row>
|
||||||
|
</el-form>
|
||||||
|
<template #footer><el-button @click="correctionOpen = false">取消</el-button><el-button type="primary" @click="saveCorrection">保存并等待验证</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup name="VisionCalibration">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { Aim, Camera, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { getRobotDevicesByRobotId, getRobotList } from '@/api/resource/robot'
|
||||||
|
import {
|
||||||
|
activateCalibrationProfile, calibrationHeartbeat, getCalibrationSession, invalidateCalibrationProfile,
|
||||||
|
listCalibrationProfiles, recordToolDirection, recordToolPivot, startCalibrationVerification,
|
||||||
|
startHandEyeCalibration, startToolCalibration, stopCalibrationSession, updateCalibrationCorrection,
|
||||||
|
} from '@/api/resource/visionCalibration'
|
||||||
|
|
||||||
|
const profileTypeOptions = [{ label: '手眼标定', value: 'HAND_EYE' }, { label: '工具 TCP', value: 'TOOL_TCP' }]
|
||||||
|
const query = reactive({ profileType: 'HAND_EYE', robotId: '', keyword: '' })
|
||||||
|
const loading = ref(false)
|
||||||
|
const profiles = ref([])
|
||||||
|
const robots = ref([])
|
||||||
|
const setupOpen = ref(false)
|
||||||
|
const setupType = ref('HAND_EYE')
|
||||||
|
const setupRef = ref()
|
||||||
|
const starting = ref(false)
|
||||||
|
const armDevices = ref([])
|
||||||
|
const cameraDevices = ref([])
|
||||||
|
const sessionOpen = ref(false)
|
||||||
|
const session = reactive({})
|
||||||
|
const recording = ref(false)
|
||||||
|
const correctionOpen = ref(false)
|
||||||
|
const correctionProfile = ref(null)
|
||||||
|
const correction = reactive({ xMm: 0, yMm: 0, zMm: 0, rxDeg: 0, ryDeg: 0, rzDeg: 0 })
|
||||||
|
let pollTimer
|
||||||
|
let heartbeatTimer
|
||||||
|
|
||||||
|
const emptySetup = () => ({ profileName: '', toolCode: '', robotId: '', armDeviceId: '', cameraDeviceId: '', translationRangeMm: 10, rotationRangeDeg: 5, markerSizeMm: 20, velocity: 0.01, acceleration: 0.03, settleTimeMs: 500 })
|
||||||
|
const setup = reactive(emptySetup())
|
||||||
|
const setupRules = {
|
||||||
|
robotId: [{ required: true, message: '请选择机器人', trigger: 'change' }],
|
||||||
|
armDeviceId: [{ required: true, message: '请选择机械臂', trigger: 'change' }],
|
||||||
|
cameraDeviceId: [{ required: true, message: '请选择深度相机', trigger: 'change' }],
|
||||||
|
profileName: [{ required: true, message: '请输入方案名称', trigger: 'blur' }],
|
||||||
|
toolCode: [{ required: true, message: '请输入稳定的工具标识', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const robotLabel = robot => `${robot.robotName || robot.robotId}${robot.connectStatus === '1' ? ' · 在线' : ' · 离线'}`
|
||||||
|
const robotName = robotId => robots.value.find(item => item.robotId === robotId)?.robotName || robotId
|
||||||
|
const deviceLabel = device => device.deviceName && device.deviceName !== device.deviceId ? `${device.deviceName} (${device.deviceId})` : device.deviceId
|
||||||
|
const statusLabel = status => ({ ACTIVE: '已启用', DRAFT: '待验证', INVALID: '已失效' }[status] || status)
|
||||||
|
const statusType = status => ({ ACTIVE: 'success', DRAFT: 'warning', INVALID: 'info' }[status] || 'info')
|
||||||
|
const isToolSession = computed(() => ['TOOL_TCP', 'TOOL_VERIFY'].includes(session.sessionType))
|
||||||
|
const requiredToolSamples = computed(() => session.sessionType === 'TOOL_VERIFY' ? 3 : 5)
|
||||||
|
const canRecordDirection = computed(() => Number(session.sampleCount || 0) >= requiredToolSamples.value)
|
||||||
|
const sessionPercent = computed(() => session.totalSteps ? Math.min(100, Math.round((session.currentStep || 0) * 100 / session.totalSteps)) : 0)
|
||||||
|
const sessionStatusLabel = computed(() => ({ RUNNING: '进行中', COMPLETED: '已完成', FAILED: '失败', CANCELLED: '已停止' }[session.status] || session.status))
|
||||||
|
const sessionStatusType = computed(() => ({ RUNNING: 'primary', COMPLETED: 'success', FAILED: 'danger', CANCELLED: 'info' }[session.status] || 'info'))
|
||||||
|
const markerPolygon = computed(() => (session.markerCorners || []).map(point => point.join(',')).join(' '))
|
||||||
|
const targetCorner = computed(() => session.markerCorners?.[0])
|
||||||
|
const formatError = value => value == null ? '-' : `${Number(value).toFixed(2)} mm`
|
||||||
|
|
||||||
|
async function loadRobots() {
|
||||||
|
const response = await getRobotList({ pageNum: 1, pageSize: 1000 })
|
||||||
|
robots.value = response.rows || []
|
||||||
|
}
|
||||||
|
async function loadProfiles() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await listCalibrationProfiles(query)
|
||||||
|
profiles.value = response.data || []
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
function resetSetup(type) {
|
||||||
|
setupType.value = type
|
||||||
|
Object.assign(setup, emptySetup())
|
||||||
|
armDevices.value = []
|
||||||
|
cameraDevices.value = []
|
||||||
|
setupOpen.value = true
|
||||||
|
}
|
||||||
|
function openHandEye() { resetSetup('HAND_EYE') }
|
||||||
|
function openTool() { resetSetup('TOOL_TCP') }
|
||||||
|
async function loadSetupDevices(robotId) {
|
||||||
|
setup.armDeviceId = ''
|
||||||
|
setup.cameraDeviceId = ''
|
||||||
|
if (!robotId) return
|
||||||
|
const [arms, cameras] = await Promise.all([
|
||||||
|
getRobotDevicesByRobotId(robotId, { deviceKind: 2 }),
|
||||||
|
getRobotDevicesByRobotId(robotId, { deviceKind: 5 }),
|
||||||
|
])
|
||||||
|
armDevices.value = arms.data || []
|
||||||
|
cameraDevices.value = cameras.data || []
|
||||||
|
if (armDevices.value.length === 1) setup.armDeviceId = armDevices.value[0].deviceId
|
||||||
|
if (cameraDevices.value.length === 1) setup.cameraDeviceId = cameraDevices.value[0].deviceId
|
||||||
|
}
|
||||||
|
async function startSetup() {
|
||||||
|
await setupRef.value.validate()
|
||||||
|
starting.value = true
|
||||||
|
try {
|
||||||
|
const response = setupType.value === 'HAND_EYE' ? await startHandEyeCalibration(setup) : await startToolCalibration(setup)
|
||||||
|
setupOpen.value = false
|
||||||
|
openSession(response.data)
|
||||||
|
} finally { starting.value = false }
|
||||||
|
}
|
||||||
|
function openSession(value) {
|
||||||
|
Object.assign(session, value || {})
|
||||||
|
sessionOpen.value = true
|
||||||
|
startSessionTimers()
|
||||||
|
}
|
||||||
|
function startSessionTimers() {
|
||||||
|
clearSessionTimers()
|
||||||
|
if (!session.sessionId || session.status !== 'RUNNING') return
|
||||||
|
heartbeatTimer = setInterval(() => calibrationHeartbeat(session.sessionId).catch(() => {}), 2000)
|
||||||
|
pollTimer = setInterval(refreshSession, 1200)
|
||||||
|
}
|
||||||
|
async function refreshSession() {
|
||||||
|
if (!session.sessionId) return
|
||||||
|
const response = await getCalibrationSession(session.sessionId)
|
||||||
|
Object.assign(session, response.data || {})
|
||||||
|
if (session.status !== 'RUNNING') clearSessionTimers()
|
||||||
|
}
|
||||||
|
function clearSessionTimers() { clearInterval(pollTimer); clearInterval(heartbeatTimer); pollTimer = null; heartbeatTimer = null }
|
||||||
|
async function stopSession() {
|
||||||
|
await stopCalibrationSession(session.sessionId)
|
||||||
|
await refreshSession()
|
||||||
|
}
|
||||||
|
async function closeSession(done) {
|
||||||
|
if (session.status === 'RUNNING') {
|
||||||
|
await ElMessageBox.confirm('关闭页面将停止标定,机械臂保持当前位置。是否继续?', '停止标定', { type: 'warning' })
|
||||||
|
await stopSession()
|
||||||
|
}
|
||||||
|
clearSessionTimers()
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
function finishSessionDialog() { sessionOpen.value = false; clearSessionTimers(); loadProfiles() }
|
||||||
|
async function recordPivot() {
|
||||||
|
recording.value = true
|
||||||
|
try { Object.assign(session, (await recordToolPivot(session.sessionId)).data || {}) }
|
||||||
|
finally { recording.value = false }
|
||||||
|
}
|
||||||
|
async function recordDirection() {
|
||||||
|
recording.value = true
|
||||||
|
try { Object.assign(session, (await recordToolDirection(session.sessionId)).data || {}); clearSessionTimers(); loadProfiles() }
|
||||||
|
finally { recording.value = false }
|
||||||
|
}
|
||||||
|
function openCorrection(row) {
|
||||||
|
correctionProfile.value = row
|
||||||
|
let stored = {}
|
||||||
|
try { stored = JSON.parse(row.correctionJson || '{}') } catch { stored = {} }
|
||||||
|
Object.assign(correction, { xMm: 0, yMm: 0, zMm: 0, rxDeg: 0, ryDeg: 0, rzDeg: 0 }, stored)
|
||||||
|
correctionOpen.value = true
|
||||||
|
}
|
||||||
|
async function saveCorrection() {
|
||||||
|
await updateCalibrationCorrection(correctionProfile.value.id, correction)
|
||||||
|
correctionOpen.value = false
|
||||||
|
ElMessage.success('修正已保存,请执行快速验证')
|
||||||
|
loadProfiles()
|
||||||
|
}
|
||||||
|
async function verifyProfile(row) { openSession((await startCalibrationVerification(row.id)).data) }
|
||||||
|
async function activateProfile(row) {
|
||||||
|
const forced = Number(row.maxErrorMm) > 1
|
||||||
|
if (forced) await ElMessageBox.confirm('验证误差超过1毫米,启用后可能影响定位精度。确认人工启用?', '超差方案', { type: 'warning' })
|
||||||
|
await activateCalibrationProfile(row.id, forced)
|
||||||
|
ElMessage.success('当前版本已启用')
|
||||||
|
loadProfiles()
|
||||||
|
}
|
||||||
|
async function invalidateProfile(row) {
|
||||||
|
await ElMessageBox.confirm('失效后工作流不会再使用此版本,是否继续?', '标记失效', { type: 'warning' })
|
||||||
|
await invalidateCalibrationProfile(row.id)
|
||||||
|
loadProfiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => { await loadRobots(); await loadProfiles() })
|
||||||
|
onBeforeUnmount(() => clearSessionTimers())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.calibration-page { min-width: 0; }
|
||||||
|
.page-heading { display: flex; margin-bottom: 20px; align-items: flex-start; justify-content: space-between; gap: 20px; }
|
||||||
|
.page-heading h2 { margin: 0 0 6px; color: #1f2937; font-size: 20px; letter-spacing: 0; }
|
||||||
|
.page-heading p, .secondary { margin: 0; color: #667085; font-size: 12px; line-height: 18px; }
|
||||||
|
.heading-actions, .filters { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.filters { margin-bottom: 16px; }
|
||||||
|
.filters .el-select { width: 220px; }
|
||||||
|
.filters .el-input { width: 240px; }
|
||||||
|
.error-value { color: var(--el-color-danger); }
|
||||||
|
.ok-value { color: var(--el-color-success); }
|
||||||
|
.forced-label { margin-top: 3px; color: var(--el-color-warning-dark-2); font-size: 11px; }
|
||||||
|
.dialog-alert { margin-bottom: 18px; }
|
||||||
|
.unit { margin-left: 8px; color: #667085; font-size: 12px; }
|
||||||
|
.session-status { padding: 14px 16px; background: var(--el-fill-color-light); border-radius: 6px; }
|
||||||
|
.session-status__top { display: flex; margin-bottom: 12px; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.marker-preview { margin-top: 16px; text-align: center; }
|
||||||
|
.marker-preview img, .marker-preview svg { display: block; width: 100%; max-height: 320px; object-fit: contain; background: #111827; border-radius: 4px; }
|
||||||
|
.marker-outline { fill: none; stroke: #22c55e; stroke-width: 3; vector-effect: non-scaling-stroke; }
|
||||||
|
.target-corner { fill: rgba(250, 204, 21, .2); stroke: #facc15; stroke-width: 3; vector-effect: non-scaling-stroke; }
|
||||||
|
.target-cross { stroke: #facc15; stroke-width: 3; vector-effect: non-scaling-stroke; }
|
||||||
|
.marker-preview__caption { margin-top: 7px; color: #667085; font-size: 12px; }
|
||||||
|
.tool-actions { display: grid; gap: 10px; margin-top: 16px; }
|
||||||
|
.tool-step { display: grid; grid-template-columns: 32px 1fr auto; padding: 12px; align-items: center; gap: 12px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; }
|
||||||
|
.tool-step__number { display: grid; width: 28px; height: 28px; place-items: center; border-radius: 50%; background: var(--el-color-primary-light-9); color: var(--el-color-primary); font-weight: 700; }
|
||||||
|
.tool-step p { margin: 4px 0 0; color: #667085; font-size: 12px; }
|
||||||
|
.result-strip { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 16px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; }
|
||||||
|
.result-strip > div { display: flex; padding: 13px; flex-direction: column; gap: 4px; border-right: 1px solid var(--el-border-color-lighter); }
|
||||||
|
.result-strip > div:last-child { border-right: 0; }
|
||||||
|
.result-strip span { color: #667085; font-size: 12px; }
|
||||||
|
.correction-form { margin-top: 12px; }
|
||||||
|
.correction-form :deep(.el-input-number) { width: 100%; }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.page-heading, .filters { align-items: stretch; flex-direction: column; }
|
||||||
|
.heading-actions { display: grid; grid-template-columns: 1fr 1fr; }
|
||||||
|
.filters .el-select, .filters .el-input { width: 100%; }
|
||||||
|
.tool-step { grid-template-columns: 32px 1fr; }
|
||||||
|
.tool-step .el-button { grid-column: 2; justify-self: start; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in New Issue
Block a user