Compare commits

...

3 Commits

Author SHA1 Message Date
f18af176f5 Merge remote-tracking branch 'origin/feature/unified-resource-center' into feature/unified-resource-center
# Conflicts:
#	.env.development
#	src/views/flow/components/ParamsDrawer.vue
#	src/views/flow/components/params/ImageAnalysisParams.vue
#	src/views/flow/components/params/MediaAnalysisParams.vue
#	src/views/flow/config.js
#	src/views/flow/vue-flow/FlowNode.vue
#	src/views/resource/device/index.vue
2026-09-16 11:42:33 +08:00
e82dc48483 feat(flow): 新增聊天参数组件和视觉标定管理功能
- 添加 ChatParams.vue 组件用于配置聊天节点参数
- 实现文本来源选择(直接输入或引用上游输出)
- 集成提示词模式设置(固定或自定义)
- 添加 TTS 语音播放开关功能
- 创建视觉标定管理界面(手眼标定和工具 TCP)
- 实现标定方案列表展示和状态管理
- 添加标定会话实时监控和进度显示
- 支持标定结果验证和误差分析
- 提供六自由度人工微调功能
2026-09-16 11:40:47 +08:00
275aeb5f4f feat(flow): 添加视觉定位和闲聊节点功能
- 新增视觉定位执行节点,支持RGBD图像分析和目标定位
- 添加闲聊节点,集成大模型自然语言对话功能
- 配置视觉定位相关的设备绑定和参数设置
- 更新设备资源管理界面,增加机器人筛选功能
- 优化图像分析参数,提高定位精度
- 添加车机发声检测功能到声音分析模块
2026-09-16 11:39:59 +08:00
13 changed files with 3715 additions and 9 deletions

View File

@ -7,8 +7,8 @@ VITE_APP_ENV = 'development'
# 招商车研物联网平台/开发环境 # 招商车研物联网平台/开发环境
VITE_APP_BASE_API = '/dev-api' VITE_APP_BASE_API = '/dev-api'
VITE_API_URL = http://192.168.0.199:14080 VITE_API_URL = http://192.168.0.222:14080
VITE_WS_URL = ws://192.168.0.199:14080/ws VITE_WS_URL = ws://192.168.0.222:14080/ws
VITE_PORT = 5174 VITE_PORT = 80
VITE_INSPECTION_TYPE = "inspection" VITE_INSPECTION_TYPE = "inspection"
VITE_MINIO_URL = "http://192.168.28.10:9000/cmvr-iot/" VITE_MINIO_URL = "http://192.168.28.10:9000/cmvr-iot/"

View 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' })
}

View File

@ -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)

View 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. 禁止:生硬模板、过度搞笑、油腻、网络烂梗、夸张情绪化。
【可聊范围】
- 车间日常、机器人工作、巡检、物流、设备状态
- 工厂技术、智能制造、自动化、数字工厂
- 员工解压、日常闲聊、心情陪伴、轻松对话
- 参观讲解、点位介绍、路线答疑
- 科普、生活、工作、放松对话全覆盖
【输出要求】
• 闲聊短句为主,不超长段落
• 专业提问自动切换正式讲解
• 始终保持:智能、温柔、靠谱、车间专属机器人人设`

View File

@ -0,0 +1,165 @@
<!--
* 文件说明:节点参数抽屉,统一触发参数校验和保存。
* 作用范围:仅服务于流程设计器模块。
-->
<template>
<el-drawer
:model-value="props.drawer"
custom-class="flow-params-drawer"
:title="props.data.properties?.name || '参数配置'"
class="flow-params-drawer"
:size="drawerSize"
append-to-body
destroy-on-close
:close-on-click-modal="false"
:before-close="confirmSave"
>
<component
:is="currentComponent"
ref="paramsComponentRef"
:data="props.data"
v-bind="componentExtraProps"
@save-success="handleSaveSuccess"
@save-error="handleSaveError"
@extract-step="handleExtractStep"
/>
</el-drawer>
</template>
<script setup>
import { ref, computed } from 'vue'
import { emitter } from '@/utils/eventBus'
// 导入各子组件
import StartParams from './params/StartParams.vue'
import BasicNodeParams from './params/BasicNodeParams.vue'
import CodeNodeParams from './params/CodeNodeParams.vue'
import HttpNodeParams from './params/HttpNodeParams.vue'
import SdAgentNodeParams from './params/SdAgentNodeParams.vue'
import RecognizeNodeParams from './params/RecognizeNodeParams.vue'
import DeviceUniversal from './params/DeviceUniversal.vue'
import SwitchNodeParams from './params/SwitchNodeParams.vue'
import ActionQueueParams from './params/ActionQueueParams.vue'
import MediaAnalysisParams from './params/MediaAnalysisParams.vue'
import ImageAnalysisParams from './params/ImageAnalysisParams.vue'
import VisionLocateParams from './params/VisionLocateParams.vue'
import ChatParams from './params/ChatParams.vue'
import EndParams from './params/EndParams.vue'
import SubFlowParams from './params/SubFlowParams.vue'
const props = defineProps({
drawer: Boolean,
data: Object,
flowId: String
})
const emits = defineEmits(['close', 'extract-step'])
const paramsComponentRef = ref(null)
/**
* 分支条件包含左值、运算符和右值三列,仅为分支节点提供更宽的编辑空间。
*/
const drawerSize = computed(() => (
props.data?.type === 'branch' ? 'min(920px, 96vw)' : 'min(560px, 92vw)'
))
// 根据节点类型决定渲染哪个子组件
const currentComponent = computed(() => {
const type = props.data?.type
if (type === 'start') return StartParams
if (type === 'end') return EndParams
if (type === 'subFlow') return SubFlowParams
if (['currentLoop', 'serviceNode', 'sleep', 'loop'].includes(type)) return BasicNodeParams
if (type === 'code') return CodeNodeParams
if (type === 'http') return HttpNodeParams
if (type === 'sdAgent') return SdAgentNodeParams
if (type === 'recognize') return RecognizeNodeParams
if (['deviceUniversal', 'device_universal'].includes(type)) return DeviceUniversal
if (type === 'actionQueue') return ActionQueueParams
if (type === 'mediaAnalysis') return MediaAnalysisParams
// 图片分析支持多图、参考图和自由提示词输出,使用独立面板保持媒体分析面板聚焦音视频。
if (type === 'imageAnalysis') return ImageAnalysisParams
if (type === 'visionLocate') return VisionLocateParams
if (type === 'chat') return ChatParams
if (type === 'branch') return SwitchNodeParams
return null
})
const componentExtraProps = computed(() => (
props.data?.type === 'subFlow' ? { flowId: props.flowId } : {}
))
// 这里提供一个公开方法供父组件调用
/**
* 确认 confirmSave 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const confirmSave = async () => {
if (paramsComponentRef.value && typeof paramsComponentRef.value.validateAndSave === 'function') {
await paramsComponentRef.value.validateAndSave()
} else {
emits('close')
}
}
// 暴露给父组件使用
defineExpose({ confirmSave })
// 保存成功 / 失败处理
/**
* 处理 handleSaveSuccess 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const handleSaveSuccess = () => {
emitter.emit('setProperties', { id: props.data.id })
emitter.emit("throwAnError", { id: props.data.id, hasError: false, type: props.data.type });
// 无错误,关闭抽屉
emits('close')
}
/**
* 处理 handleSaveError 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const handleSaveError = () => {
emitter.emit('setProperties', { id: props.data.id })
emitter.emit("throwAnError", { id: props.data.id, hasError: true, type: props.data.type });
emits('close')
}
const handleExtractStep = (payload) => emits('extract-step', payload)
// 组件卸载时清理事件监听
// 注意:emitter.off 应在各子组件中自行清理,父组件不再重复
</script>
<style lang="scss">
.flow-params-drawer {
.el-drawer__header {
min-height: 62px;
margin-bottom: 0 !important;
padding: 0 20px;
border-bottom: 1px solid #e4e8ef;
color: #1f2937;
font-size: 16px;
font-weight: 650;
}
.el-drawer__body {
padding: 0 20px 24px;
overflow-x: hidden;
}
.el-collapse {
border-top: 0;
}
.el-collapse-item__header {
height: 50px;
color: #344054;
font-size: 13px;
font-weight: 650;
}
.form__container {
padding: 4px 0 10px;
}
}
</style>

View 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>

View File

@ -0,0 +1,433 @@
<!--
* 文件说明:通用图片分析节点参数编辑器,支持引用上游图片或直接填写 HTTP/HTTPS 图片地址。
* 数据流向:从 nodeParams 回填表单,保存时写回 IMAGE_ANALYZE 执行协议和统一输出字段。
-->
<template>
<div class="image-analysis-params">
<div class="intro">
<div class="intro__title">通用图片分析</div>
<div class="intro__desc">根据提示词分析一张或多张图片,模型结果保持提示词要求的内容和格式。</div>
</div>
<el-form ref="formRef" :model="formData" :rules="rules" label-position="top">
<el-form-item label="分析方式">
<el-segmented v-model="formData.analysisMethod" :options="methodOptions" block />
</el-form-item>
<el-form-item :label="isReferenceMatch ? '大图来源' : (isTextLocate ? '目标图片来源' : '图片来源')" prop="imageMode">
<el-segmented v-model="formData.imageMode" :options="sourceOptions" />
</el-form-item>
<el-form-item v-if="formData.imageMode === 'quote'" label="引用上游图片" prop="imageQuote">
<el-cascader
ref="imageCascaderRef"
v-model="formData.imageQuote"
:options="quoteOptions"
:props="{ checkStrictly: true }"
placeholder="选择图片节点的 imageUrl 输出"
style="width: 100%"
@visible-change="loadQuoteOptions"
@change="handleQuoteChange"
/>
<div class="field-hint" v-if="isVisionAnalysis">上游输出为图片数组时,将按原顺序全部分析,最多 12 张。</div>
</el-form-item>
<el-form-item v-else label="图片地址" prop="imageUrlsText">
<el-input
v-model="formData.imageUrlsText"
type="textarea"
:rows="4"
:placeholder="isLocationMethod ? '输入一张目标图片的 HTTP/HTTPS 地址' : '每行一个 HTTP/HTTPS 图片地址,最多 12 张'"
/>
</el-form-item>
<el-form-item v-if="!isReferenceMatch" :label="isTextLocate ? '目标描述' : '分析提示词'" prop="prompt">
<el-input
v-model="formData.prompt"
type="textarea"
:rows="5"
maxlength="4000"
show-word-limit
:placeholder="isTextLocate ? '例如:底部应用栏中的设置按钮' : '例如:读取图片中的仪表盘数值,只输出数字。或:识别所有仪表,按 JSON 输出名称、数值和单位。'"
/>
</el-form-item>
<el-form-item v-if="!isReferenceMatch" label="分析模式">
<el-segmented v-model="formData.analysisMode" :options="modeOptions" block />
</el-form-item>
<el-form-item label="异步执行">
<el-switch v-model="formData.asynchronous" />
<div class="field-hint">开启后分析在后台并行执行,流程结束前统一等待分析结果。</div>
</el-form-item>
<el-form-item v-if="isTextLocate" label="最低定位置信度">
<el-slider v-model="formData.analysisTuning.locateConfidenceThreshold" :min="0.2" :max="0.95" :step="0.01" show-input />
</el-form-item>
<template v-if="isReferenceMatch">
<el-form-item label="小图来源">
<el-segmented v-model="formData.referenceMode" :options="sourceOptions" />
</el-form-item>
<el-form-item v-if="formData.referenceMode === 'quote'" label="引用待查小图" prop="referenceQuote">
<el-cascader
ref="referenceCascaderRef"
v-model="formData.referenceQuote"
:options="quoteOptions"
:props="{ checkStrictly: true }"
placeholder="选择待查小图输出"
style="width: 100%"
@visible-change="loadQuoteOptions"
@change="handleReferenceQuoteChange"
/>
</el-form-item>
<el-form-item v-else label="小图地址" prop="referenceImageUrl">
<el-input v-model="formData.referenceImageUrl" clearable placeholder="输入待查小图的 HTTP/HTTPS 地址" />
</el-form-item>
<el-form-item label="最低匹配分数">
<el-slider v-model="formData.analysisTuning.matchThreshold" :min="0.2" :max="0.95" :step="0.01" show-input />
</el-form-item>
<el-form-item label="选择匹配结果">
<el-select v-model="formData.analysisTuning.matchSelector" style="width: 100%">
<el-option v-for="item in matchSelectorOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<template v-if="formData.analysisTuning.matchSelector === 'INDEX'">
<el-form-item label="排序方式">
<el-segmented v-model="formData.analysisTuning.matchSort" :options="matchSortOptions" block />
</el-form-item>
<el-form-item label="选择第几个">
<el-input-number v-model="formData.analysisTuning.matchIndex" :min="1" :max="20" />
</el-form-item>
</template>
</template>
<el-collapse v-if="!isReferenceMatch" v-model="advancedSections" class="advanced-settings">
<el-collapse-item v-if="isVisionAnalysis" title="参考图片(可选)" name="reference">
<el-form-item label="参考图片来源">
<el-segmented v-model="formData.referenceMode" :options="sourceOptions" />
</el-form-item>
<el-form-item v-if="formData.referenceMode === 'quote'" label="引用参考图片" prop="referenceQuote">
<el-cascader
ref="referenceCascaderRef"
v-model="formData.referenceQuote"
:options="quoteOptions"
:props="{ checkStrictly: true }"
placeholder="选择参考图片输出"
style="width: 100%"
clearable
@visible-change="loadQuoteOptions"
@change="handleReferenceQuoteChange"
/>
</el-form-item>
<el-form-item v-else label="参考图片地址" prop="referenceImageUrl">
<el-input v-model="formData.referenceImageUrl" clearable placeholder="HTTP/HTTPS 图片地址" />
</el-form-item>
<div class="field-hint">参考图片会作为最后一张图片传入;请在提示词中说明它的用途。</div>
</el-collapse-item>
<el-collapse-item title="高级设置" name="advanced">
<el-form-item label="图片最大宽度">
<el-slider v-model="formData.analysisTuning.maxWidth" :min="640" :max="1600" :step="64" show-input />
</el-form-item>
<el-form-item v-if="isVisionAnalysis" label="最多分析图片">
<el-input-number v-model="formData.analysisTuning.maxImages" :min="1" :max="12" />
</el-form-item>
<el-form-item label="最大输出长度">
<el-input-number v-model="formData.analysisTuning.maxOutputTokens" :min="32" :max="4096" :step="32" />
</el-form-item>
</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 { getUpstreamInput } from '@/utils/flow'
import { IMAGE_ANALYSIS_OUTPUT_PARAMS } from '@/utils/flowResources'
const props = defineProps({ data: Object })
const emit = defineEmits(['save-success', 'save-error'])
const formRef = ref()
const imageCascaderRef = ref()
const referenceCascaderRef = ref()
const quoteOptions = ref([])
const advancedSections = ref([])
// 这里的 value 会直接写入 nodeParams.type,需与流程参数协议中的 quote/input 保持一致。
const sourceOptions = [
{ label: '引用节点输出', value: 'quote' },
{ label: '输入图片地址', value: 'input' },
]
const modeOptions = [
{ label: '快速', value: 'FAST' },
{ label: '精准', value: 'ACCURATE' },
]
const methodOptions = [
{ label: '智能分析', value: 'VISION_MODEL' },
{ label: '描述找图', value: 'TEXT_LOCATE' },
{ label: '精确找图', value: 'REFERENCE_MATCH' },
]
const matchSelectorOptions = [
{ label: '最佳匹配', value: 'BEST' },
{ label: '按序号选择', value: 'INDEX' },
{ label: '左上', value: 'TOP_LEFT' },
{ label: '右上', value: 'TOP_RIGHT' },
{ label: '左下', value: 'BOTTOM_LEFT' },
{ label: '右下', value: 'BOTTOM_RIGHT' },
]
const matchSortOptions = [
{ label: '先行后列', value: 'ROW_MAJOR' },
{ label: '先列后行', value: 'COLUMN_MAJOR' },
]
/**
* 使用稳定的 reactive 对象承载表单状态;initData 只覆盖字段,避免 Element Plus 校验引用失效。
*/
const formData = reactive({
analysisMethod: 'VISION_MODEL',
imageMode: 'quote',
imageQuote: [],
imageQuoteType: 'output',
imageUrlsText: '',
referenceMode: 'input',
referenceQuote: [],
referenceQuoteType: 'output',
referenceImageUrl: '',
prompt: '',
analysisMode: 'FAST',
asynchronous: false,
analysisTuning: {
// Location needs enough pixels to distinguish small dashboard icons.
maxWidth: 1600,
maxImages: 12,
maxOutputTokens: 384,
matchThreshold: 0.48,
matchSelector: 'BEST',
matchIndex: 1,
matchSort: 'ROW_MAJOR',
locateConfidenceThreshold: 0.45,
},
})
const isReferenceMatch = computed(() => formData.analysisMethod === 'REFERENCE_MATCH')
const isTextLocate = computed(() => formData.analysisMethod === 'TEXT_LOCATE')
const isVisionAnalysis = computed(() => formData.analysisMethod === 'VISION_MODEL')
const isLocationMethod = computed(() => isReferenceMatch.value || isTextLocate.value)
const outputSummary = [
{ name: 'result', desc: '按提示词返回的结果;JSON、数组和数字会自动解析' },
{ name: 'resultText', desc: '模型返回的原始文本' },
{ name: 'matched', desc: '找图结果是否达到最低分数' },
{ name: 'matchScore', desc: '找图的匹配分数或定位置信度' },
{ name: 'coordinates', desc: '大图原始尺寸坐标 [x1,y1,x2,y2]' },
{ name: 'center', desc: '目标中心点坐标 [x,y]' },
{ name: 'matchedDescription', desc: '模型实际定位的目标描述' },
{ name: 'refinementMethod', desc: '坐标精修方式' },
{ name: 'matchCount', desc: '找到的匹配项数量' },
{ name: 'matches', desc: '全部匹配项及其序号、坐标和分数' },
{ name: 'selectedMatchIndex', desc: '当前选择的匹配序号' },
]
// 文本输入允许换行或逗号分隔,保存前统一转成执行端需要的 URL 数组。
const parseUrls = value => String(value || '').split(/\r?\n|,/).map(item => item.trim()).filter(Boolean)
const validUrl = value => /^https?:\/\//i.test(value)
const hasReference = () => formData.referenceMode === 'quote'
? formData.referenceQuote.length > 0
: Boolean(formData.referenceImageUrl.trim())
/**
* 条件校验只约束当前可见的图片来源,隐藏模式不阻塞保存;直接输入仅接受 HTTP/HTTPS。
*/
const rules = {
imageQuote: [{
validator: (_, value) => formData.imageMode !== 'quote' || value?.length
? Promise.resolve()
: Promise.reject(new Error('请选择图片输出')),
trigger: 'change',
}],
imageUrlsText: [{
validator: (_, value) => {
if (formData.imageMode !== 'input') return Promise.resolve()
const urls = parseUrls(value)
if (!urls.length) return Promise.reject(new Error('请至少输入一个图片地址'))
const maximum = isLocationMethod.value ? 1 : 12
if (urls.length > maximum) return Promise.reject(new Error(isLocationMethod.value ? '找图只能输入一张目标图片' : '最多输入 12 张图片'))
return urls.every(validUrl)
? Promise.resolve()
: Promise.reject(new Error('图片地址必须是 HTTP/HTTPS 地址'))
},
trigger: 'blur',
}],
referenceImageUrl: [{
validator: (_, value) => {
if (formData.referenceMode !== 'input') return Promise.resolve()
if (isReferenceMatch.value && !String(value || '').trim()) return Promise.reject(new Error('请输入待查小图地址'))
return !value || validUrl(String(value).trim())
? Promise.resolve()
: Promise.reject(new Error('请输入有效的参考图片地址'))
},
trigger: 'blur',
}],
referenceQuote: [{
validator: (_, value) => !isReferenceMatch.value || formData.referenceMode !== 'quote' || value?.length
? Promise.resolve()
: Promise.reject(new Error('请选择待查小图输出')),
trigger: 'change',
}],
prompt: [{
validator: (_, value) => isReferenceMatch.value || String(value || '').trim()
? Promise.resolve()
: Promise.reject(new Error('请输入分析提示词')),
trigger: 'blur',
}],
}
// 引用选项按需加载,上游流程较大时避免每次渲染都遍历画布。
const loadQuoteOptions = visible => { if (visible) quoteOptions.value = getUpstreamInput(props.data.id) || [] }
const handleQuoteChange = () => {
formData.imageQuoteType = imageCascaderRef.value?.getCheckedNodes?.(true)?.[0]?.data?.type || 'output'
}
const handleReferenceQuoteChange = () => {
formData.referenceQuoteType = referenceCascaderRef.value?.getCheckedNodes?.(true)?.[0]?.data?.type || 'output'
}
const getParam = (params, name) => params.find(item => item.name === name)
/**
* 兼容旧版图片识别节点:把 taskType、targetDescription、instruction 合成新的自由提示词。
*/
const legacyPrompt = (params) => {
const target = String(getParam(params, 'targetDescription')?.input || '').trim()
const instruction = String(getParam(params, 'instruction')?.input || '').trim()
const taskType = String(getParam(params, 'taskType')?.input || '').toUpperCase()
const parts = [target || instruction]
if (taskType === 'ICON_TEMPLATE_LOCATE') {
parts.push('最后一张图片是参考图片,请在其他图片中查找对应目标并返回坐标。')
}
parts.push('只返回分析结果,不输出通过、未通过或置信度。')
return parts.filter(Boolean).join('\n')
}
/**
* 将持久化 nodeParams 回填到表单,并为缺少新字段的旧节点补默认值。
*/
const initData = () => {
loadQuoteOptions(true)
const params = props.data?.properties?.nodeParams || []
const images = getParam(params, 'imageUrl')
const reference = getParam(params, 'referenceImageUrl')
const tuning = getParam(params, 'analysisTuning')?.input || {}
const asynchronous = getParam(params, 'asynchronous')
formData.imageMode = images?.type === 'input' ? 'input' : 'quote'
formData.imageQuote = Array.isArray(images?.quote) ? [...images.quote] : []
formData.imageQuoteType = images?.quoteType || 'output'
const staticImages = Array.isArray(images?.input) ? [...images.input] : parseUrls(images?.input || '')
const savedMethod = String(getParam(params, 'analysisMethod')?.input || '').toUpperCase()
const savedPrompt = getParam(params, 'prompt')?.input || legacyPrompt(params)
const coordinatePrompt = /(坐标|位置|小图|参考图|图标|coordinate|position|bbox|template)/i.test(savedPrompt)
const legacyReferenceMatch = !savedMethod && coordinatePrompt
&& ((reference && staticImages.length === 1) || (!reference && staticImages.length === 2))
const legacyTextLocate = !savedMethod && coordinatePrompt && !reference && staticImages.length === 1
formData.analysisMethod = savedMethod === 'REFERENCE_MATCH' || legacyReferenceMatch
? 'REFERENCE_MATCH'
: (savedMethod === 'TEXT_LOCATE' || legacyTextLocate ? 'TEXT_LOCATE' : 'VISION_MODEL')
formData.imageUrlsText = (legacyReferenceMatch ? staticImages.slice(0, 1) : staticImages).join('\n')
formData.referenceMode = reference?.type === 'quote' ? 'quote' : 'input'
formData.referenceQuote = Array.isArray(reference?.quote) ? [...reference.quote] : []
formData.referenceQuoteType = reference?.quoteType || 'output'
formData.referenceImageUrl = reference?.input || (legacyReferenceMatch ? staticImages[1] : '')
formData.prompt = savedPrompt
const savedMode = String(getParam(params, 'analysisMode')?.input || '').toUpperCase()
formData.analysisMode = savedMode === 'ACCURATE' ? 'ACCURATE' : 'FAST'
formData.asynchronous = asynchronous?.input === true || asynchronous?.input === 'true'
formData.analysisTuning.maxWidth = Number(tuning.maxWidth)
|| (isLocationMethod.value ? 1600 : (formData.analysisMode === 'FAST' ? 896 : 1280))
formData.analysisTuning.maxImages = Number(tuning.maxImages) || 12
formData.analysisTuning.maxOutputTokens = Number(tuning.maxOutputTokens)
|| (formData.analysisMode === 'FAST' ? 384 : 512)
formData.analysisTuning.matchThreshold = Number(tuning.matchThreshold) || 0.48
formData.analysisTuning.matchSelector = matchSelectorOptions.some(item => item.value === tuning.matchSelector)
? tuning.matchSelector
: 'BEST'
formData.analysisTuning.matchIndex = Math.max(1, Math.min(20, Number(tuning.matchIndex) || 1))
formData.analysisTuning.matchSort = tuning.matchSort === 'COLUMN_MAJOR' ? 'COLUMN_MAJOR' : 'ROW_MAJOR'
formData.analysisTuning.locateConfidenceThreshold = Number(tuning.locateConfidenceThreshold) || 0.45
advancedSections.value = isVisionAnalysis.value && hasReference() ? ['reference'] : []
}
watch(() => props.data, initData, { immediate: true, deep: true })
/**
* 媒体参数统一保存为 quote/input 二选一结构;未使用的一侧清空,避免执行端误读。
*/
const buildMediaParam = (name, mode, input, quote, quoteType, required = true) => ({
name,
type: mode,
input: mode === 'input' ? input : '',
quote: mode === 'quote' ? [...quote] : '',
quoteType,
required,
disabled: true,
})
/**
* 校验通过后重建 IMAGE_ANALYZE 节点属性,确保 action、类型和输出契约同步升级。
*/
const validateAndSave = async () => {
try {
await formRef.value.validate()
const nodeParams = [
{ name: 'profileCode', type: 'input', input: 'common.image_analysis.v1', required: true, disabled: true },
buildMediaParam('imageUrl', formData.imageMode, parseUrls(formData.imageUrlsText), formData.imageQuote, formData.imageQuoteType),
{ name: 'analysisMethod', type: 'input', input: formData.analysisMethod, required: false, disabled: true },
{ name: 'prompt', type: 'input', input: isReferenceMatch.value ? '在大图中查找小图,返回原图像素坐标 [x1,y1,x2,y2]' : formData.prompt.trim(), required: true, disabled: true, componentType: 'textarea' },
{ name: 'analysisMode', type: 'input', input: formData.analysisMode, required: false, disabled: true, componentType: 'select', selectOptions: modeOptions },
{ name: 'analysisTuning', type: 'input', input: { ...formData.analysisTuning }, required: false, disabled: true },
{ name: 'asynchronous', type: 'input', input: formData.asynchronous, required: false, disabled: true, componentType: 'switch' },
]
if (!isTextLocate.value && hasReference()) {
// 参考图存在时插入到图片来源和提示词之间,保持执行端按固定顺序读取。
nodeParams.splice(3, 0, buildMediaParam(
'referenceImageUrl', formData.referenceMode, formData.referenceImageUrl.trim(),
formData.referenceQuote, formData.referenceQuoteType, false,
))
}
const properties = {
...props.data.properties,
name: '通用图片分析',
action: 'IMAGE_ANALYZE',
nodeType: 'LLM',
nodeParams,
outputType: 'json',
outputParams: JSON.parse(JSON.stringify(IMAGE_ANALYSIS_OUTPUT_PARAMS)),
}
// 图片分析由 LLM 服务执行,不绑定机器人或设备资源。
delete properties.executionTarget
lf.setProperties(props.data.id, properties)
emit('save-success')
} catch {
emit('save-error')
}
}
defineExpose({ validateAndSave })
</script>
<style scoped lang="scss">
.image-analysis-params { padding-top: 16px; }
.intro { margin-bottom: 22px; 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 { margin-top: 5px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 18px; }
.advanced-settings { margin-bottom: 18px; }
.output-list { display: grid; gap: 8px; }
.output-item { display: flex; align-items: baseline; gap: 12px; color: var(--el-text-color-regular); font-size: 13px; }
.output-item code { min-width: 92px; color: var(--el-color-primary); }
</style>

View File

@ -0,0 +1,569 @@
<!--
* 文件说明:媒体分析节点的参数编辑器,同时服务于“声音类型检测”和“视频智能分析”节点。
* 数据流向:从 props.data.properties.nodeParams 回填表单,校验通过后再组装为标准 nodeParams
* 并写回 LogicFlow 节点属性;本组件不直接调用媒体分析接口。
* 作用范围:仅服务于流程设计器参数抽屉,由 ParamsDrawer 调用 validateAndSave 完成保存。
-->
<template>
<div class="media-analysis-params">
<!-- 标题和说明由节点 action 决定,同一组件在音频、视频节点下呈现不同业务语义。 -->
<div class="media-analysis-intro">
<div class="media-analysis-intro__title">{{ title }}</div>
<div class="media-analysis-intro__desc">{{ description }}</div>
</div>
<el-form ref="formRef" :model="formData" :rules="rules" label-position="top">
<!--
profileCode 是后端选择分析配置和模型策略的场景编码。
allow-create 用于兼容后端临时新增、前端尚未内置的场景,不把可用值限制在下拉选项内。
-->
<el-form-item label="分析场景" prop="profileCode">
<el-select
v-model="formData.profileCode"
filterable
allow-create
default-first-option
placeholder="选择或输入场景编码"
style="width: 100%"
>
<el-option
v-for="item in profileOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<div class="field-hint">场景决定识别标签、判断阈值和分析要求。</div>
</el-form-item>
<!-- 音频检测需要一个期望声音;视频分析改由 instruction 描述通过标准,因此不显示该字段。 -->
<el-form-item v-if="!isVideo" label="目标声音类型" prop="expectedLabel">
<el-select
v-model="formData.expectedLabel"
filterable
allow-create
default-first-option
placeholder="请选择目标声音类型"
style="width: 100%"
@change="handleExpectedLabelChange"
>
<el-option
v-for="item in soundTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<div class="field-hint">只有实际声音与目标类型一致时才通过;新增类型可直接输入编码或名称。</div>
</el-form-item>
<!-- 媒体既可以引用上游录音/录像节点的输出,也可以直接填写一个可访问的网络地址。 -->
<el-form-item :label="mediaLabel" prop="mediaMode">
<el-segmented v-model="formData.mediaMode" :options="mediaModes" />
</el-form-item>
<!-- 引用模式保存级联路径和输出类型;打开下拉时才查询上游节点,避免每次渲染都遍历流程图。 -->
<el-form-item v-if="formData.mediaMode === 'quote'" label="引用节点输出" prop="mediaQuote">
<el-cascader
ref="mediaCascaderRef"
v-model="formData.mediaQuote"
:options="quoteOptions"
:props="{ checkStrictly: true }"
placeholder="选择上游录音或录像输出"
style="width: 100%"
@visible-change="loadQuoteOptions"
@change="handleQuoteChange"
/>
</el-form-item>
<!-- 直接输入模式只接受 HTTP/HTTPS 地址,具体格式在 rules.mediaUrl 中按当前模式条件校验。 -->
<el-form-item v-else :label="`${mediaLabel}地址`" prop="mediaUrl">
<el-input v-model="formData.mediaUrl" type="textarea" :rows="3" placeholder="请输入 HTTP/HTTPS 媒体地址" />
</el-form-item>
<el-form-item label="异步执行">
<el-switch v-model="formData.asynchronous" />
<div class="field-hint">开启后分析在后台并行执行,流程结束前统一等待分析结果。</div>
</el-form-item>
<!-- 视频模型依赖自然语言标准判断是否通过;音频节点使用 expectedLabel,不需要 instruction。 -->
<el-form-item v-if="isVideo" label="通过标准" prop="instruction" required>
<el-input
v-model="formData.instruction"
type="textarea"
:rows="4"
maxlength="2000"
show-word-limit
placeholder="例如:车辆启动后,仪表必须在3秒内点亮"
/>
<div class="field-hint">未观察到完整证据或置信度不足时,结果按未通过处理。</div>
</el-form-item>
<!-- 切换分析模式时同步恢复该模式预设,避免旧模式的微调参数意外带入新模式。 -->
<el-form-item v-if="isVideo" label="分析模式">
<el-segmented v-model="formData.analysisMode" :options="analysisModes" block @change="handleAnalysisModeChange" />
<div class="field-hint">{{ modeDescription }}</div>
</el-form-item>
<el-form-item v-if="isVideo" label="输出内容">
<!-- detailedOutput 只影响视频节点:默认精简,需要事件证据和时间范围时再让后端返回完整结果。 -->
<el-switch
v-model="formData.detailedOutput"
inline-prompt
active-text="详细"
inactive-text="精简"
/>
<div class="field-hint">精简结果响应更快;需要逐步事件和时间范围时再启用详细输出。</div>
</el-form-item>
<!--
高级参数只对视频抽帧分析生效。默认折叠以降低普通用户的配置负担,isTuned 用于提示
当前值已经偏离模式预设;“恢复默认”只重置当前模式的参数,不切换分析模式。
-->
<el-collapse v-if="isVideo" v-model="advancedSections" class="analysis-tuning">
<el-collapse-item name="tuning">
<template #title>
<div class="analysis-tuning__title">
<span>高级设置</span>
<el-tag v-if="isTuned" size="small" type="warning" effect="plain">已微调</el-tag>
</div>
</template>
<div class="analysis-tuning__body">
<div class="analysis-tuning__toolbar">
<span>当前模式参数</span>
<el-button :icon="Refresh" text type="primary" @click.stop="restorePreset">恢复默认</el-button>
</div>
<el-form-item label="采样帧率(帧/秒)">
<el-slider v-model="formData.analysisTuning.sampleFps" :min="0.25" :max="6" :step="0.25" show-input />
</el-form-item>
<el-form-item label="图片最大宽度">
<el-slider v-model="formData.analysisTuning.maxWidth" :min="640" :max="1280" :step="64" show-input />
</el-form-item>
<el-form-item label="通过置信度">
<el-slider v-model="formData.analysisTuning.confidenceThreshold" :min="0.5" :max="0.95" :step="0.05" show-input />
</el-form-item>
<el-form-item v-if="formData.analysisMode === 'AUTO'" label="低置信度转精确分析">
<el-switch v-model="formData.analysisTuning.fallbackToAccurate" />
</el-form-item>
</div>
</el-collapse-item>
</el-collapse>
</el-form>
<!-- 输出字段只作契约说明,实际 outputParams 由节点配置定义,不能在这里编辑。 -->
<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 { Refresh } from '@element-plus/icons-vue'
import { getUpstreamInput } from '@/utils/flow'
/**
* data 是流程画布当前节点模型,核心字段包括 id、properties.action 和 properties.nodeParams。
* 保存成功/失败事件由 ParamsDrawer 接收,用于更新节点错误状态并关闭参数抽屉。
*/
const props = defineProps({ data: Object })
const emit = defineEmits(['save-success', 'save-error'])
// formRef 触发表单校验;mediaCascaderRef 用于读取级联选择器最终选中节点的元数据。
const formRef = ref()
const mediaCascaderRef = ref()
// 上游节点输出在级联选择器展开时按需加载;高级设置默认保持折叠。
const quoteOptions = ref([])
const advancedSections = ref([])
/**
* 三种视频分析模式的推荐参数:
* maxFrames 控制抽取帧数,maxWidth 控制送入模型前的缩放宽度,confidenceThreshold 是通过阈值;
* fallbackToAccurate 仅 AUTO 模式有意义,表示快速分析置信度不足时是否升级为精确分析。
*/
const analysisPresets = {
FAST: { sampleFps: 1, maxWidth: 896, confidenceThreshold: 0.7, fallbackToAccurate: false },
AUTO: { sampleFps: 2, maxWidth: 960, confidenceThreshold: 0.75, fallbackToAccurate: true },
ACCURATE: { sampleFps: 3, maxWidth: 1120, confidenceThreshold: 0.8, fallbackToAccurate: false },
}
/**
* 表单使用一个稳定的 reactive 对象,initData 只更新字段而不替换对象,保证 Element Plus
* 的表单项、校验状态和 v-model 引用始终指向同一个实例。
*/
const formData = reactive({
profileCode: '',
expectedLabel: '',
expectedLabelName: '',
mediaMode: 'quote',
mediaQuote: [],
mediaQuoteType: 'output',
mediaUrl: '',
instruction: '',
analysisMode: 'AUTO',
detailedOutput: false,
asynchronous: false,
analysisTuning: { ...analysisPresets.AUTO },
})
// action 是区分音频节点和视频节点的唯一业务判别字段,后续文案、参数名和校验都由它派生。
const isVideo = computed(() => props.data?.properties?.action === 'VIDEO_ANALYZE')
// 以下计算属性统一收敛音频/视频差异,模板不需要重复判断具体 action 字符串。
const title = computed(() => isVideo.value ? '视频智能分析' : '声音类型检测')
const description = computed(() => isVideo.value
? '对录像抽帧并按所选场景生成结构化分析结果。'
: '判断录音是否属于指定的目标声音类型,并输出通过或不通过。')
const mediaLabel = computed(() => isVideo.value ? '视频来源' : '音频来源')
// nodeParams 中的媒体参数名属于执行协议:视频使用 videoUrl,音频使用 audioUrl。
const mediaName = computed(() => isVideo.value ? 'videoUrl' : 'audioUrl')
// mediaMode 的值会直接写入媒体参数 type,因此必须与流程参数协议中的 quote/input 保持一致。
const mediaModes = [
{ label: '引用节点输出', value: 'quote' },
{ label: '直接输入地址', value: 'input' },
]
const analysisModes = [
{ label: '快速', value: 'FAST' },
{ label: '均衡', value: 'AUTO' },
{ label: '精确', value: 'ACCURATE' },
]
// 声音类型同时保存机器可读编码和中文名称;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 = {
FAST: '使用轻量模型直接判断,适合动作明显、批量执行的场景。',
AUTO: '先快速判断,低置信度时自动切换精确分析。',
ACCURATE: '使用高精度模型分析更多画面细节,耗时较长。',
}
const modeDescription = computed(() => modeDescriptions[formData.analysisMode] || '')
// 任意高级参数偏离当前模式预设即视为“已微调”,用于控制折叠标题中的提示标签。
const isTuned = computed(() => {
const preset = analysisPresets[formData.analysisMode] || analysisPresets.AUTO
return Object.keys(preset).some((key) => formData.analysisTuning[key] !== preset[key])
})
// 不同媒体类型使用不同场景编码,默认值也从这里取得,避免音视频节点互相沿用 profileCode。
const profileOptions = computed(() => isVideo.value
? [{ label: '爱玛测试视频分析', value: 'aima.power_video.v1' }]
: [{ label: '爱玛车辆声音类型检测', value: 'aima.power_state.v1' }])
/**
* 输出摘要与运行时返回对象保持一致,仅负责向流程设计用户解释后续节点可引用的字段。
* 音频结果强调目标声音与实际声音的比较;视频结果强调证据、结论和实际分析模式。
*/
const outputSummary = computed(() => isVideo.value
? [
{ name: 'result', desc: '分析结果' },
{ name: 'passed', desc: '是否通过' },
{ name: 'confidence', desc: '判定置信度' },
{ name: 'evidenceSufficient', desc: '证据是否充分' },
{ name: 'conclusion', desc: '分析结论' },
{ name: 'summary', desc: '分析摘要' },
{ name: 'events', desc: '关键事件列表' },
{ name: 'analysisMode', desc: '实际使用的分析模式' },
{ name: 'fallback', desc: '是否切换到精确分析' },
{ name: 'detailedOutput', desc: '是否返回详细事件证据' },
]
: [
{ name: 'passed', desc: '是否检测到目标声音' },
{ name: 'expectedLabelName', desc: '目标声音类型' },
{ name: 'labelName', desc: '实际声音类型' },
{ name: 'matched', desc: '是否识别到已知声音' },
{ name: 'vehicleSoundDetected', desc: '是否检测到车机发声' },
{ name: 'confidence', desc: '最高匹配度' },
{ name: 'decisionReason', desc: '检测结论' },
{ name: 'scores', desc: '各分类匹配度' },
])
/**
* 校验规则包含多个条件字段:
* - expectedLabel 只在音频节点必填;instruction 只在视频节点必填;
* - mediaQuote/mediaUrl 根据 mediaMode 二选一校验,隐藏字段不会阻塞保存;
* - 直接地址限制为 HTTP/HTTPS,避免执行端收到本地路径或无法识别的协议。
*/
const rules = {
profileCode: [{ required: true, message: '请选择或输入分析场景', trigger: 'change' }],
expectedLabel: [{
validator: (_, value) => isVideo.value || String(value || '').trim()
? Promise.resolve()
: Promise.reject(new Error('请选择目标声音类型')),
trigger: 'change',
}],
mediaQuote: [{
validator: (_, value) => formData.mediaMode !== 'quote' || (Array.isArray(value) && value.length)
? Promise.resolve()
: Promise.reject(new Error('请选择媒体输出')),
trigger: 'change',
}],
mediaUrl: [{
validator: (_, value) => formData.mediaMode !== 'input' || /^https?:\/\//i.test(String(value || '').trim())
? Promise.resolve()
: Promise.reject(new Error('请输入有效的 HTTP/HTTPS 地址')),
trigger: 'blur',
}],
instruction: [{
validator: (_, value) => !isVideo.value || String(value || '').trim()
? Promise.resolve()
: Promise.reject(new Error('请输入明确的通过标准')),
trigger: 'blur',
}],
}
// 使用 Object.assign 保留 analysisTuning 的响应式引用,只覆盖当前模式定义的预设字段。
const restorePreset = () => {
Object.assign(formData.analysisTuning, analysisPresets[formData.analysisMode] || analysisPresets.AUTO)
}
// 模式切换后直接采用新模式预设,用户仍可在高级设置中继续微调。
const handleAnalysisModeChange = () => restorePreset()
// 同时维护编码和展示名称;用户创建自定义类型时,输入值本身作为可读名称兜底。
const handleExpectedLabelChange = (value) => {
const option = soundTypeOptions.find(item => item.value === value)
formData.expectedLabelName = option?.label || String(value || '').trim()
}
/**
* analysisTuning 的历史数据可能是对象或 JSON 字符串。这里统一解析为普通对象;
* 非法 JSON 返回空对象,后续 normalizeStoredTuning 会用模式预设补齐全部字段。
*/
const parseStoredTuning = (value) => {
if (value && typeof value === 'object' && !Array.isArray(value)) return value
if (typeof value !== 'string' || !value.trim()) return {}
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
// 将旧配置或外部写入值限制在当前 UI 支持范围内,NaN 等非法值回退到模式默认值。
const clamp = (value, minimum, maximum, fallback) => {
const number = Number(value)
return Number.isFinite(number) ? Math.min(maximum, Math.max(minimum, number)) : fallback
}
/**
* 标准化持久化的高级参数,保证滑块始终接收到合法数字,并兼容缺少新字段的旧流程数据。
* 帧数和宽度必须为整数;fallbackToAccurate 只接受真正的布尔值,不把字符串误判为 true。
*/
const normalizeStoredTuning = (value, preset) => {
const stored = parseStoredTuning(value)
return {
sampleFps: Math.round(clamp(stored.sampleFps, 0.25, 6, preset.sampleFps) * 100) / 100,
maxWidth: Math.round(clamp(stored.maxWidth, 640, 1280, preset.maxWidth)),
confidenceThreshold: clamp(stored.confidenceThreshold, 0.5, 0.95, preset.confidenceThreshold),
fallbackToAccurate: typeof stored.fallbackToAccurate === 'boolean'
? stored.fallbackToAccurate
: preset.fallbackToAccurate,
}
}
// getInput 根据当前节点 ID 返回可引用的上游输出,只在级联面板打开时刷新以减少无效计算。
const loadQuoteOptions = (visible) => {
if (visible) quoteOptions.value = getUpstreamInput(props.data.id) || []
}
/**
* 级联路径只记录选中位置,quoteType 需要从叶子节点元数据单独取得;
* 读取不到元数据时回退为 output,兼容旧版上游输出选项。
*/
const handleQuoteChange = () => {
const checked = mediaCascaderRef.value?.getCheckedNodes?.(true)?.[0]
formData.mediaQuoteType = checked?.data?.type || 'output'
}
/**
* 将节点已保存的 nodeParams 回填到表单。
*
* nodeParams 是按 name 存储的数组而不是对象,因此先逐项定位;缺失字段使用当前节点类型的默认值。
* 媒体参数的 type 决定“引用/输入”模式,quote、quoteType 和 input 分别恢复对应控件。
* 旧版音频节点可能只有 expectedLabel 没有 expectedLabelName,此时通过选项表补齐中文名称。
* 视频高级配置经过 normalizeStoredTuning 清洗,非 AUTO 模式强制关闭仅 AUTO 支持的精确分析回退。
*/
const initData = () => {
loadQuoteOptions(true)
const params = props.data?.properties?.nodeParams || []
const profile = params.find(item => item.name === 'profileCode')
const expectedLabel = params.find(item => item.name === 'expectedLabel')
const expectedLabelName = params.find(item => item.name === 'expectedLabelName')
const media = params.find(item => item.name === mediaName.value)
const instruction = params.find(item => item.name === 'instruction')
const analysisMode = params.find(item => item.name === 'analysisMode')
const detailedOutput = params.find(item => item.name === 'detailedOutput')
const analysisTuning = params.find(item => item.name === 'analysisTuning')
const asynchronous = params.find(item => item.name === 'asynchronous')
formData.profileCode = profile?.input || profileOptions.value[0]?.value || ''
formData.expectedLabel = expectedLabel?.input || ''
formData.expectedLabelName = expectedLabelName?.input || ''
if (formData.expectedLabel && !formData.expectedLabelName) {
handleExpectedLabelChange(formData.expectedLabel)
}
formData.mediaMode = media?.type === 'input' ? 'input' : 'quote'
formData.mediaQuote = Array.isArray(media?.quote) ? [...media.quote] : []
formData.mediaQuoteType = media?.quoteType || 'output'
formData.mediaUrl = media?.input || ''
formData.instruction = instruction?.input || ''
formData.analysisMode = analysisPresets[analysisMode?.input] ? analysisMode.input : 'AUTO'
// 兼容旧流程或外部写入的字符串值,表单内部统一使用布尔值驱动开关。
formData.detailedOutput = detailedOutput?.input === true || detailedOutput?.input === 'true'
formData.asynchronous = asynchronous?.input === true || asynchronous?.input === 'true'
const preset = analysisPresets[formData.analysisMode] || analysisPresets.AUTO
Object.assign(formData.analysisTuning, normalizeStoredTuning(analysisTuning?.input, preset))
if (formData.analysisMode !== 'AUTO') formData.analysisTuning.fallbackToAccurate = false
}
// 抽屉可能在不销毁组件的情况下切换节点;深度监听保证节点属性更新后表单立即重新回填。
watch(() => props.data, initData, { immediate: true, deep: true })
/**
* 校验并保存当前媒体分析节点。
*
* 保存过程重新构造 nodeParams,而不是修改原数组,避免残留另一种媒体类型的专属字段:
* 1. 所有节点保存 profileCode 和当前媒体参数;
* 2. 音频节点额外保存 expectedLabel/expectedLabelName;
* 3. 视频节点额外保存 instruction、analysisMode 和 analysisTuning;
* 4. 删除旧的 executionTarget 后,通过 LogicFlow 全局实例 lf 一次性写回 properties。
*
* ParamsDrawer 只依赖 save-success/save-error,不需要了解 Element Plus 的校验异常细节。
*/
const validateAndSave = async () => {
try {
await formRef.value.validate()
// 公共参数始终位于数组前部,保持与 config.js 中节点初始定义一致,便于调试和序列化比较。
const nodeParams = [
{
name: 'profileCode',
type: 'input',
input: formData.profileCode.trim(),
required: true,
disabled: true,
componentType: 'select',
selectOptions: profileOptions.value,
},
{
name: mediaName.value,
type: formData.mediaMode,
input: formData.mediaMode === 'input' ? formData.mediaUrl.trim() : '',
quote: formData.mediaMode === 'quote' ? [...formData.mediaQuote] : '',
quoteType: formData.mediaQuoteType,
required: true,
disabled: true,
},
]
nodeParams.push({
name: 'asynchronous',
type: 'input',
input: formData.asynchronous,
required: false,
disabled: true,
componentType: 'switch',
})
// 音频节点把目标类型插入 profileCode 与媒体参数之间,不保存任何视频专属参数。
if (!isVideo.value) {
nodeParams.splice(1, 0,
{
name: 'expectedLabel',
type: 'input',
input: formData.expectedLabel.trim(),
required: true,
disabled: true,
componentType: 'select',
selectOptions: soundTypeOptions,
},
{
name: 'expectedLabelName',
type: 'input',
input: formData.expectedLabelName.trim() || formData.expectedLabel.trim(),
required: false,
disabled: true,
},
)
}
// 视频节点保留自然语言标准和分析策略;高级参数复制为普通对象,避免泄漏 Vue 响应式代理。
if (isVideo.value) {
nodeParams.push({
name: 'instruction',
type: 'input',
input: formData.instruction.trim(),
required: true,
disabled: true,
})
nodeParams.push({
name: 'analysisMode',
type: 'input',
input: formData.analysisMode,
required: false,
disabled: true,
componentType: 'select',
selectOptions: analysisModes,
})
// 该开关控制后端是否返回 events 等较重字段,默认 false 可降低单节点执行响应成本。
nodeParams.push({
name: 'detailedOutput',
type: 'input',
input: formData.detailedOutput,
required: false,
disabled: true,
componentType: 'switch',
})
nodeParams.push({
name: 'analysisTuning',
type: 'input',
input: { ...formData.analysisTuning },
required: false,
disabled: true,
})
}
// 保留节点名称、action 等其他属性,仅替换本组件负责的 nodeParams。
const properties = { ...props.data.properties, nodeParams }
// executionTarget 属于旧执行模型字段,媒体分析节点统一由流程运行时调度,保存时主动移除。
delete properties.executionTarget
// lf 由流程设计器页面提供为全局 LogicFlow 实例;setProperties 会触发画布节点属性更新。
lf.setProperties(props.data.id, properties)
emit('save-success')
} catch (error) {
emit('save-error')
}
}
// ParamsDrawer 在关闭前通过组件 ref 调用该方法,因此必须显式暴露。
defineExpose({ validateAndSave })
</script>
<style scoped lang="scss">
.media-analysis-params { padding-top: 16px; }
.media-analysis-intro {
margin-bottom: 22px;
padding: 14px 16px;
border-left: 3px solid var(--el-color-primary);
background: var(--el-fill-color-light);
}
.media-analysis-intro__title { color: var(--el-text-color-primary); font-weight: 650; }
.media-analysis-intro__desc, .field-hint { margin-top: 5px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 18px; }
.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: 92px; color: var(--el-color-primary); }
.analysis-tuning { margin-bottom: 18px; border-top: 1px solid var(--el-border-color-lighter); }
.analysis-tuning :deep(.el-collapse-item__header) { min-height: 48px; height: auto; }
.analysis-tuning :deep(.el-collapse-item__content) { padding-bottom: 4px; }
.analysis-tuning__title { display: flex; min-width: 0; align-items: center; gap: 8px; font-weight: 650; }
.analysis-tuning__body { padding: 4px 2px 0; }
.analysis-tuning__toolbar { display: flex; min-height: 32px; margin-bottom: 10px; align-items: center; justify-content: space-between; color: var(--el-text-color-secondary); font-size: 12px; }
.analysis-tuning :deep(.el-slider) { --el-slider-button-size: 16px; }
.analysis-tuning :deep(.el-slider__runway.show-input) { margin-right: 18px; }
</style>

View 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>

897
src/views/flow/config.js Normal file
View File

@ -0,0 +1,897 @@
/**
* 文件说明:定义流程设计器可用的节点、表单字段及下拉选项等静态配置。
* 作用范围:仅服务于流程设计器模块。
*/
import cameraSvg from './icon/camera.svg'
import videoSvg from './icon/video.svg'
import loopSvg from './icon/loop.svg'
import stopLoopSvg from './icon/stopLoop.svg'
import switchSvg from './icon/switch.svg'
import microphoneSvg from './icon/microphone.svg'
import sleepSvg from './icon/sleep.svg'
import planSvg from './icon/plan.svg'
import awakenSvg from './icon/awaken.svg'
import dialogueSvg from './icon/dialogue.svg'
import audioSvg from './icon/audio.svg'
import touchSvg from './icon/touch.svg'
import expressionSvg from './icon/expression.svg'
/**
* 处理与 http method options 相关的状态或数据,该影响范围仅限当前模块。
*/
const httpMethodOptions = () => {
return [{
value: 'POST',
label: 'POST'
}, {
value: 'GET',
label: 'GET'
}, {
value: 'PUT',
label: 'PUT'
}, {
value: 'DELETE',
label: 'DELETE'
}]
}
/**
* 处理与 express options 相关的状态或数据,该影响范围仅限当前模块。
*/
const expressOptions = () => {
return [{
value: 1,
label: '高兴'
}, {
value: 2,
label: '惊讶'
}, {
value: 3,
label: '愤怒'
}, {
value: 4,
label: '悲伤'
}, {
value: 5,
label: '睡觉'
}, {
value: 6,
label: '打哈欠'
}]
}
/**
* 处理与 task type options 相关的状态或数据,该影响范围仅限当前模块。
*/
const taskTypeOptions = () => {
return [{
value: 'voice',
label: '语音交互'
}, {
value: 'touch',
label: '触控交互'
}]
}
/**
* 处理与 voice options 相关的状态或数据,该影响范围仅限当前模块。
*/
const voiceOptions = () => {
return [{
value: 'x4_xiaoyan',
label: 'x4_xiaoyan'
}]
}
/**
* 处理与 inspection event types 相关的状态或数据,该影响范围仅限当前模块。
*/
const inspectionEventTypes = () => {
return [{
value: 'No-Glove',
label: '没戴手套'
}, {
value: 'No-Helmet-Glove',
label: '没戴头盔'
}]
}
export const collapseList = [
{
collapseTitle: "相机",
nodeList: [
{
icon: cameraSvg,
name: "开启相机",
type: "serviceNode",
desc: "在获取相机照片时,需要先启动相机",
action: 'CAMERA_START',
nodeType: 'EDGE',
nodeParams: [],
outputParams: [],
outputType: 'json'
},
{
icon: cameraSvg,
name: "获取图片",
type: "serviceNode",
desc: "获取相机拍摄的照片",
action: 'CAMERA_GETRGBIMAGE',
nodeType: 'EDGE',
nodeParams: [],
outputType: 'img',
outputParams: [{ name: 'imageUrl', type: 'array<string>', desc: '图片地址数组', children: [], disabled: true }]
},
{
icon: cameraSvg,
name: "关闭相机",
type: "serviceNode",
desc: "获取相机照片后,关闭该相机",
action: 'CAMERA_STOP',
nodeType: 'EDGE',
nodeParams: [],
outputParams: [],
outputType: 'json'
},
],
},
{
collapseTitle: "录像",
nodeList: [
{
icon: videoSvg,
name: "开启录像",
type: "serviceNode",
desc: "开启摄像头,并开始录像",
action: 'CAMERA_RECORDING_START',
nodeType: 'EDGE',
nodeParams: [],
outputParams: [],
outputType: 'json'
},
{
icon: videoSvg,
name: "结束录像",
type: "serviceNode",
desc: "停止录像,并关闭摄像头",
action: 'CAMERA_RECORDING_STOP',
nodeType: 'EDGE',
nodeParams: [],
outputType: 'video',
outputParams: [{ name: 'videoUrl', type: 'array<string>', children: [], desc: '视频播放地址数组', disabled: true}]
}
],
},
{
collapseTitle: "功能",
nodeList: [
{
icon: loopSvg,
name: "循环",
type: "loop",
action: 'START_LOOP',
desc: "实现对列表对象循环执行一系列任务",
nodeParams: [{ name: "loopNum", type: "input", input: null, quote: "" }],
outputParams: [],
outputType: 'json'
},
{
icon: planSvg,
name: "子流程",
type: "subFlow",
action: 'SUB_FLOW',
nodeType: 'NONE',
desc: "执行同一项目中已发布的工作流",
subFlow: {},
nodeParams: [],
outputParams: [],
outputType: 'json'
},
{
icon: stopLoopSvg,
name: "结束循环",
type: "stopLoop",
action: 'STOP_LOOP',
desc: "用于立即终止当前所在的循环,跳出循环体",
},
// {
// icon: startSvg,
// name: "单次循环开始",
// type: "subStart",
// action: 'SUB_START',
// desc: "循环体内部工作流的开始节点,开始循环体内部的单次流程",
// },
// {
// icon: endSvg,
// name: "单次循环结束",
// type: "subEnd",
// action: 'SUB_END',
// desc: "循环体内部工作流的终止节点,结束循环体内部的单次流程",
// },
{
icon: switchSvg,
name: "分支",
type: "branch",
action: 'BRANCH',
desc: "连接多个下游分支,根据设定的条件按照顺序查找的方式来匹配运行的分支,如果匹配到某条件则只运行该条件对应的分支,否则继续匹配下一条件直至结束",
},
{
icon: sleepSvg,
name: "睡眠",
type: "sleep",
desc: "用于睡眠整个流程,表示延迟多少毫秒",
action: 'sleep',
nodeParams: [
{ name: "delayMs", type: "input", componentType: 'number', input: 0, disabled: true }
],
outputParams: [],
outputType: 'json'
},
{
icon: expressionSvg,
name: "HTTP请求",
type: "http",
desc: "HTTP请求",
action: 'HTTP',
nodeType: 'HTTP',
nodeParams: [
{ name: "config", type: "input", input: "", children: [
{ name: "url", type: "input", input: "", disabled: true, required: true },
{ name: "method", type: "input", input: "POST", componentType: 'select', selectOptions: httpMethodOptions(), disabled: true },
{ name: "timeout", type: "input", input: "10000", componentType: 'number', required: false, disabled: true }
], disabled: true, required: true },
{ name: "headers", type: "input", input: "", children: [
{ name: "", type: "input", input: ""}
], disabled: true, required: true },
{ name: "params", type: "input", input: "", children: [
{ name: "", type: "input", input: ""}
], disabled: true, required: true },
{ name: "body", type: "input", input: "", children: [
{ name: 'bodyType', type: "input", input: "json", disabled: true },
{ name: 'json', type: "input", input: '{"a": 123}' },
{ name: 'formData', type: "input", input: "", children: []}
], required: true },
],
outputType: 'json',
outputParams: [{ name: 'result', type: 'Object', children: [], desc: 'HTTP请求结果'}]
},
{
icon: expressionSvg,
name: "代码",
type: "code",
desc: "js代码",
action: 'CODE',
canAddFormItem: true,
nodeParams: [
{ name: "input", type: "input", input: "", required: true },
{ name: "code", type: "input", input: `
// 方法定义不能修改
/**
* 处理与 r 相关的状态或数据,该影响范围仅限当前模块。
* @param {*} params 待处理的参数集合
*/
function handler(params) {
// 返回值是一个可序列化成 json 的 dict 或 object
const result ={
type: 2,
message: params.input
}
return result
}
`, required: true }
],
outputType: 'json',
outputParams: [{ name: 'result', type: 'object', children: [
{name: 'type', type: 'number', desc: '占位属性'},
{name: 'message', type: 'string', desc: '占位属性'}
], desc: 'js节点返回'}]
},
{
icon: microphoneSvg,
name: "获取当前循环对象",
type: "currentLoop",
desc: "获取当前循环对象",
action: 'GET_CURRENT_OBJECT',
nodeParams: [
{ name: "array", type: "input", input: "" }
],
outputType: 'json',
outputParams: [
{ name: 'index', type: 'number', desc: '索引'},
{ name: 'object', type: 'object', children: [
{ name: 'test', type: 'string', desc: '占位属性' }
], desc: '当前循环对象', }
]
},
],
},
{
collapseTitle: "机器人",
nodeList: [
{
icon: videoSvg,
name: "AGV控制",
type: "serviceNode",
desc: "控制机器人的底盘",
action: 'AGV_MOVE_TO_POINT',
nodeType: 'EDGE',
nodeParams: [
{ name: "x", type: "input", input: "", disabled: true },
{ name: "y", type: "input", input: "", disabled: true },
{ name: "theta", type: "input", input: "", disabled: true },
{ name: "blockedTimeoutMs", type: "input", componentType: "number", input: 60000, min: 1, required: true, disabled: true },
],
outputType: 'json'
},
{
icon: videoSvg,
name: "AGV站点导航",
type: "serviceNode",
desc: "从AGV站点列表选择目标站点并导航",
action: 'AGV_MOVE_TO_STATION',
nodeType: 'EDGE',
nodeParams: [
{ name: "stationId", type: "input", input: "", componentType: 'select', selectOptions: [], forceSelect: true, disabled: true },
{ name: "blockedTimeoutMs", type: "input", componentType: "number", input: 60000, min: 1, required: true, disabled: true }
],
outputType: 'json'
},
{
icon: cameraSvg,
name: "通用指令",
type: "deviceUniversal",
desc: "执行设备通用指令",
action: 'DEVICE_EXECUTE_JSON_COMMAND',
nodeType: 'EDGE',
nodeParams: [
{ name: "requestJson", type: "input", input: `{
"a":123
}`, required: false, disabled: true },
],
outputParams: [],
outputType: 'json'
},
{
icon: planSvg,
name: "动作队列",
type: "actionQueue",
desc: "在边缘端按顺序连续执行多个动作",
action: 'SYSTEM_EXECUTE_ACTION_QUEUE',
nodeType: 'EDGE',
nodeParams: [
{ name: "actionId", type: "input", input: "", required: false },
{ name: "totalTimeoutMs", type: "input", componentType: 'number', input: 60000, required: true },
{ name: "steps", type: "input", input: [], required: true },
],
outputParams: [
{ name: 'actionId', type: 'string', desc: '动作队列标识' },
{ name: 'completedSteps', type: 'number', desc: '已完成步骤数' },
{ name: 'result', type: 'string', desc: '执行结果' },
],
outputType: 'json'
},
{
icon: videoSvg,
name: "机械臂",
type: "serviceNode",
desc: "控制机器人的手臂",
action: 'ARM_MOVE_TO_POINT',
nodeType: 'EDGE',
nodeParams: [
{ name: "x", type: "input", input: "", disabled: true },
{ name: "y", type: "input", input: "", disabled: true },
{ name: "z", type: "input", input: "", disabled: true },
{ name: "rx", type: "input", input: "", disabled: true },
{ name: "ry", type: "input", input: "", disabled: true },
{ name: "rz", type: "input", input: "", disabled: true },
{ name: "velocity", type: "input", componentType: 'number', max: 5, step: 0.01, input: 0.1, disabled: true },
{ name: "acceleration", type: "input", componentType: 'number', max: 5, step: 0.01, input: 0.2, disabled: true },
],
outputType: 'json'
},
{
icon: videoSvg,
name: "机械臂关节运动",
type: "serviceNode",
desc: "通过关节空间插值运动到目标关节位置",
action: 'ARM_MOVE_TO_J',
nodeType: 'EDGE',
nodeParams: [
{ name: "target", type: "input", input: "[0, 0, 0, 0, 0, 0]", disabled: true },
{ name: "velocity", type: "input", componentType: 'number', max: 5, step: 0.01, input: 0.1, disabled: true },
{ name: "acceleration", type: "input", componentType: 'number', max: 5, step: 0.01, input: 0.2, disabled: true },
],
outputType: 'json'
},
// 使能状态由两个独立节点显式控制。节点本身不接收参数,也不依赖前一运动节点的隐式使能,
// 因而可在流程开始、异常恢复或任务收尾位置按业务需要自由编排。
{
icon: videoSvg,
name: "开启机械臂使能",
type: "serviceNode",
desc: "开启机械臂使能,后续运动节点不会自动使能",
action: 'ARM_ENABLE',
nodeType: 'EDGE',
nodeParams: [],
outputParams: [],
outputType: 'json'
},
// 关闭节点与开启节点使用不同 action,边缘执行器据此下发去使能指令;
// 空 outputParams 表示该节点只负责设备状态切换,不向后续节点暴露业务输出。
{
icon: videoSvg,
name: "关闭机械臂使能",
type: "serviceNode",
desc: "关闭机械臂使能",
action: 'ARM_DISABLE',
nodeType: 'EDGE',
nodeParams: [],
outputParams: [],
outputType: 'json'
},
{
icon: cameraSvg,
name: "开始监听报警事件",
type: "serviceNode",
desc: "开始监听报警事件",
action: 'INSPECTION_ALERT_LISTEN_START',
nodeType: 'EDGE',
nodeParams: [
{ name: "robotId", type: "input", input: "", disabled: true },
{ name: "eventTypes", type: "input", input: "", componentType: 'select', selectOptions: inspectionEventTypes(), disabled: true }
],
outputParams: [],
outputType: 'json'
},
{
icon: cameraSvg,
name: "结束监听报警事件",
type: "serviceNode",
desc: "结束监听报警事件",
action: 'INSPECTION_ALERT_LISTEN_STOP',
nodeType: 'EDGE',
nodeParams: [
{ name: "robotId", type: "input", input: "", disabled: true },
{ name: "eventTypes", type: "input", input: "", componentType: 'select', selectOptions: inspectionEventTypes(), disabled: true }
],
outputParams: [],
outputType: 'json'
},
{
icon: dialogueSvg,
name: "巡检仪表读数识别",
type: "recognize",
desc: "巡检仪表读数识别",
action: 'INSPECTION_METER_RECOGNIZE',
nodeType: "LLM",
outputType: 'json',
nodeParams: [
{ name: 'imageUrl', type: "input", input: "", disabled: true },
{ name: 'resultName', type: "input", input: "", disabled: true },
{ name: "alarmRules", type: "input", input: [], disabled: true }
],
outputParams: []
},
{
icon: cameraSvg,
name: "人工巡检判断",
type: "serviceNode",
desc: "创建人工巡检判断任务",
action: 'INSPECTION_MANUAL_REVIEW_CREATE',
nodeType: 'EDGE',
nodeParams: [
{ name: "resultName", type: "input", input: "", disabled: true },
{ name: "videoUrl", type: "input", input: "", required: false, disabled: true },
{ name: "imageUrl", type: "input", input: "", required: false, disabled: true },
{ name: "reviewCriteria", type: "input", input: "", disabled: true },
{ name: "defaultAlarmLevel", type: "input", componentType: 'number', max: 3, step: 1, input: 1, disabled: true },
],
outputParams: [],
outputType: 'json'
},
],
},
{
collapseTitle: "音频",
nodeList: [
{
icon: microphoneSvg,
name: "启动麦克风",
type: "serviceNode",
desc: "用于启动麦克风的节点",
action: 'MICROPHONE_START',
nodeType: 'EDGE',
nodeParams: [],
outputType: 'json'
},
{
icon: microphoneSvg,
name: "停止麦克风",
type: "serviceNode",
desc: "用于停止麦克风的节点",
action: 'MICROPHONE_STOP',
nodeType: 'EDGE',
nodeParams: [],
outputType: 'audio',
outputParams: [{ name: 'audioUrl', type: 'array<string>', children: [], desc: '录音播放地址数组', disabled: true }]
},
{
icon: audioSvg,
name: "播放音频",
type: "serviceNode",
desc: "用于播放音频的节点",
action: 'SPEAKER_PLAYAUDIO',
nodeType: 'EDGE',
nodeParams: [
{ name: "audioPath", type: "input", input: "", disabled: true }
],
outputType: 'json'
},
],
},
{
collapseTitle: "大模型",
nodeList: [
{
icon: microphoneSvg,
name: "声音类型检测",
type: "mediaAnalysis",
desc: "判断录音是否属于指定的声音类型",
action: "AUDIO_EVENT_CLASSIFY",
nodeType: "LLM",
nodeParams: [
{ name: "profileCode", type: "input", input: "aima.power_state.v1", required: true, disabled: true },
{ name: "expectedLabel", type: "input", input: "", required: true, disabled: true },
{ name: "expectedLabelName", type: "input", input: "", required: false, disabled: true },
{ name: "audioUrl", type: "quote", input: "", quote: "", required: true, disabled: true },
{ name: "asynchronous", type: "input", input: false, required: false, disabled: true, componentType: "switch" },
],
outputType: "json",
outputParams: [
{ name: "passed", type: "boolean", desc: "是否检测到目标声音", disabled: true },
{ name: "expectedLabel", type: "string", desc: "目标声音类型编码", disabled: true },
{ name: "expectedLabelName", type: "string", desc: "目标声音类型名称", disabled: true },
{ name: "label", type: "string", desc: "实际声音类型编码,未匹配时为UNKNOWN", disabled: true },
{ name: "labelName", type: "string", desc: "实际声音类型名称", disabled: true },
{ name: "matched", type: "boolean", desc: "是否识别到已知声音", disabled: true },
{ name: "confidence", type: "number", desc: "最高匹配度", disabled: true },
{ name: "decisionReason", type: "string", desc: "检测结论", disabled: true },
{ name: "scores", type: "object", desc: "各分类匹配度", children: [], disabled: true },
],
},
{
icon: videoSvg,
name: "视频智能分析",
type: "mediaAnalysis",
desc: "按分析场景理解测试录像并输出结构化结论",
action: "VIDEO_ANALYZE",
nodeType: "LLM",
nodeParams: [
{ name: "profileCode", type: "input", input: "aima.power_video.v1", required: true, disabled: true },
{ name: "videoUrl", type: "quote", input: "", quote: "", required: true, disabled: true },
{ name: "instruction", type: "input", input: "", required: true, disabled: true },
{ name: "analysisMode", type: "input", input: "AUTO", required: false, disabled: true },
// 默认返回精简结论;用户开启详细输出时,执行端才补充事件证据。
{ name: "detailedOutput", type: "input", input: false, required: false, disabled: true, componentType: "switch" },
{ name: "analysisTuning", type: "input", input: { sampleFps: 2, maxWidth: 960, confidenceThreshold: 0.75, fallbackToAccurate: true }, required: false, disabled: true },
{ name: "asynchronous", type: "input", input: false, required: false, disabled: true, componentType: "switch" },
],
outputType: "json",
outputParams: [
{ name: "result", type: "string", desc: "分析结果", disabled: true },
{ name: "passed", type: "boolean", desc: "是否通过", disabled: true },
{ name: "confidence", type: "number", desc: "判定置信度", disabled: true },
{ name: "evidenceSufficient", type: "boolean", desc: "证据是否充分", disabled: true },
{ name: "decisionReason", type: "string", desc: "判定原因", disabled: true },
{ name: "conclusion", type: "string", desc: "分析结论", disabled: true },
{ name: "summary", type: "string", desc: "分析摘要", disabled: true },
{ name: "events", type: "array<object>", desc: "关键事件", children: [], disabled: true },
{ name: "analysisMode", type: "string", desc: "实际分析模式", disabled: true },
{ name: "fallback", type: "boolean", desc: "是否切换精确分析", disabled: true },
{ name: "detailedOutput", type: "boolean", desc: "是否返回详细事件证据", disabled: true },
],
},
{
// 通用图片分析不内置通过/不通过判定,返回结构完全由用户提示词约定。
icon: cameraSvg,
name: "通用图片分析",
type: "imageAnalysis",
desc: "按照提示词分析一张或多张图片并返回指定内容",
action: 'IMAGE_ANALYZE',
nodeType: "LLM",
nodeParams: [
{ name: "profileCode", type: "input", input: "common.image_analysis.v1", required: true, disabled: true },
{ name: "imageUrl", type: "quote", input: "", quote: "", required: true, disabled: true },
{ name: "analysisMethod", type: "input", input: "VISION_MODEL", required: false, disabled: true },
{ name: "prompt", type: "input", input: "", required: true, disabled: true, componentType: "textarea" },
{ name: "analysisMode", type: "input", input: "FAST", required: false, disabled: true },
{ name: "analysisTuning", type: "input", input: { maxWidth: 896, maxImages: 12, maxOutputTokens: 384, matchThreshold: 0.48, matchSelector: "BEST", matchIndex: 1, matchSort: "ROW_MAJOR", locateConfidenceThreshold: 0.45 }, required: false, disabled: true },
{ name: "asynchronous", type: "input", input: false, required: false, disabled: true, componentType: "switch" },
],
outputType: "json",
outputParams: [
{ name: "result", type: "object", desc: "按提示词返回的结果", children: [], disabled: true },
{ name: "resultText", type: "string", desc: "模型返回的原始文本", disabled: true },
{ name: "matched", type: "boolean", desc: "找图是否匹配", disabled: true },
{ name: "matchScore", type: "number", desc: "匹配分数或定位置信度", disabled: true },
{ name: "coordinates", type: "array<number>", desc: "原图坐标 [x1,y1,x2,y2]", children: [], disabled: true },
{ name: "center", type: "array<number>", desc: "目标中心点 [x,y]", children: [], disabled: true },
{ name: "matchedDescription", type: "string", desc: "模型实际定位的目标描述", disabled: true },
{ name: "refinementMethod", type: "string", desc: "坐标精修方式", disabled: true },
{ name: "matchCount", type: "number", desc: "匹配项数量", disabled: true },
{ name: "matches", type: "array<object>", desc: "全部匹配项", children: [], disabled: true },
{ name: "selectedMatchIndex", type: "number", desc: "当前选择的匹配序号", disabled: true },
],
},
{
icon: microphoneSvg,
name: "获取触控坐标",
type: "serviceNode",
desc: "用于获取触控坐标的节点",
action: 'TOUCH_COORDINATES',
nodeType: "LLM",
nodeParams: [
{ name: "targetFeature", type: "input", input: "运动模式", disabled: true },
{ name: "manufacturer", type: "input", input: "xiaomi", disabled: true },
{ name: "vehType", type: "input", input: "su7", disabled: true },
],
outputType: 'json',
outputParams: [{ name: 'coordinates', type: 'object', children: [
{ name: 'x', type: 'number', desc: 'x坐标' },
{ name: 'y', type: 'number', desc: 'y坐标' }
], desc: '坐标对象', }]
}, {
icon: dialogueSvg,
name: "tts语音合成",
type: "serviceNode",
desc: "将指令文本(text)处理合成语音,输出语音路径(audioPath)",
action: 'GENERATE_ADVANCED_AUDIO',
nodeType: "LLM",
outputType: 'json',
nodeParams: [{ name: 'text', type: "input", input: "", disabled: true }],
outputParams: [{ name: 'audioPath', type: 'string', desc: '语音路径', disabled: true}]
}, {
icon: dialogueSvg,
name: "意图识别",
type: "serviceNode",
desc: "意图识别,输出类型(type) 1: 语音交互 2: 触控交互 3: 闲聊 4: 知识问答",
action: 'INTENT_RECOGNITION',
nodeType: "LLM",
outputType: 'json',
nodeParams: [{ name: 'text', type: "input", input: "", disabled: true }],
outputParams: [{ name: 'type', type: 'string', desc: '1:语音交互 2:触控交互 3: 闲聊 4:知识问答', disabled: true}]
}, {
icon: dialogueSvg,
name: "商道智能体",
type: "sdAgent",
desc: "通过apiKey调用商道大模型",
action: 'AI_AGENT_PLATFORM',
nodeType: "LLM",
outputType: 'json',
nodeParams: [
{ name: "config", type: "input", input: "", children: [
{ name: 'apiKey', type: "input", input: "", disabled: true },
{ name: 'text', type: "input", input: "", disabled: true },
]},
{ name: 'invokeTts', type: "input", input: false },
{ name: 'tts', type: "input", input: "", children: [
{ name: 'url', type: "input", input: "", disabled: true },
{ name: 'text', type: "input", input: "", disabled: true },
{ name: 'speed', type: "input", input: "", max: 100, disabled: true },
{ name: 'voice', type: "input", input: "", componentType: 'select', selectOptions: voiceOptions(), disabled: true },
{ name: 'volume', type: "input", input: "", componentType: 'number', max: 100, disabled: true }
], disabled: true }
],
outputParams: [{ name: 'result', type: 'string', desc: '商道大模型的返回文案', disabled: true}]
}, {
icon: dialogueSvg,
name: "tts语音播放",
type: "serviceNode",
desc: "调用tts将文案转换为语音播放",
action: 'AI_TTS',
nodeType: "LLM",
outputType: 'json',
nodeParams: [
{ name: 'url', type: "input", input: "", disabled: true },
{ name: 'text', type: "input", input: "", disabled: true },
{ name: 'speed', type: "input", input: "", max: 100, disabled: true },
{ name: 'voice', type: "input", input: "", componentType: 'select', selectOptions: voiceOptions(), disabled: true },
{ name: 'volume', type: "input", input: "", componentType: 'number', max: 100, disabled: true }
]
}, {
icon: dialogueSvg,
name: "获取当前页面名称",
type: "serviceNode",
desc: "获取当前页面名称",
action: 'GET_CURRENT_PAGE',
nodeType: "LLM",
outputType: 'json',
nodeParams: [
{ name: 'url', type: "input", input: "", disabled: true },
],
outputParams: [{ name: 'pageName', type: 'string', desc: '当前页面名称', disabled: true}]
}
],
},
{
collapseTitle: "语音交互",
nodeList: [
{
icon: planSvg,
name: "方案处理",
type: "serviceNode",
desc: "语音交互-方案处理,输入方案id(schemeId),输出唤醒语料(wakeCorpus)和测试语料数组(testCorpus)",
action: 'VI_SCHEME',
nodeParams: [{ name: 'schemeId', type: "input", input: "", disabled: true }],
outputType: 'json',
outputParams: [
{ name: 'wakeCorpus', type: 'object', desc: '唤醒语料', children: []},
{ name: 'testCorpus', type: 'array<object>', desc: '测试语料数组', children: []},
{ name: 'sceneType', type: 'number', desc: '1:唤醒测试,2:单次对话测试,3:连续对话测试', disabled: true }
]
},
{
icon: awakenSvg,
name: "唤醒语料",
type: "serviceNode",
desc: "唤醒语料处理,输入唤醒语料(wakeCorpus),输出语音路径(audioPath)",
action: 'VI_CORPUS_WAKE',
nodeType: "EDGE",
outputType: 'json',
nodeParams: [{ name: 'wakeCorpus', type: "input", input: "", disabled: true }],
outputParams: [{ name: 'audioPath', type: 'string', desc: '语音路径', disabled: true}]
},
{
icon: dialogueSvg,
name: "单次对话语料",
type: "serviceNode",
desc: "测试语料-单次对话语料处理,输入单次对话语料(testCorpus),输出语音路径(audioPath)",
action: 'VI_CORPUS_SINGLE',
nodeType: "EDGE",
outputType: 'json',
nodeParams: [{ name: 'testCorpus', type: "input", input: "", disabled: true }],
outputParams: [{ name: 'audioPath', type: 'string', desc: '语音路径', disabled: true}]
},
{
icon: dialogueSvg,
name: "连续对话语料",
type: "serviceNode",
desc: "测试语料-连续对话语料处理,输入连续对话语料(testCorpus),输出语音路径(audioPath)",
action: 'VI_CORPUS_CONTINUOUS',
nodeType: "EDGE",
outputType: 'json',
nodeParams: [{ name: 'testCorpus', type: "input", input: "", disabled: true }],
outputParams: [{ name: 'audioPath', type: 'string', desc: '语音路径', disabled: true}]
},
{
icon: audioSvg,
name: "播放语料",
type: "serviceNode",
desc: "用于播放语料的节点,需要输入语音路径,执行设备由流程角色配置",
action: 'VI_PLAY_CORPUS',
nodeType: "EDGE",
outputType: 'json',
nodeParams: [
{ name: 'audioPath', type: "input", input: "", disabled: true },
{ name: 'bioId', type: "input", input: "", disabled: true, required: false }
],
}
],
},
{
collapseTitle: "触控交互",
nodeList: [
{
icon: touchSvg,
name: "触控",
type: "serviceNode",
desc: "去触控坐标的点位",
action: 'TOUCH',
nodeType: "EDGE",
nodeParams: [
{ name: "touchParams", type: "input", input: "", disabled: true }
],
outputType: 'json',
},
{
icon: expressionSvg,
name: "开始说话",
type: "serviceNode",
desc: "控制机器人说话",
action: 'BIO_HEAD_SPEAK_START',
nodeType: "EDGE",
nodeParams: [],
outputType: 'json',
},
{
icon: expressionSvg,
name: "停止说话",
type: "serviceNode",
desc: "停止机器人说话",
action: 'BIO_HEAD_SPEAK_STOP',
nodeType: "EDGE",
nodeParams: [],
outputType: 'json',
},
{
icon: expressionSvg,
name: "表情",
type: "serviceNode",
desc: "控制机器人的面部表情",
action: 'BIO_HEAD_SPECIAL_EXPRESSION',
nodeType: "EDGE",
nodeParams: [
{ name: "expressKey", type: "input",componentType: 'select', selectOptions: expressOptions(), disabled: true },
],
outputType: 'json',
},
{
icon: touchSvg,
name: "路径搜索",
type: "serviceNode",
desc: "路径搜索",
action: 'TI_PATH_SEARCH',
nodeType: "EDGE",
nodeParams: [
{ name: "currentLocation", type: "input", input: "", disabled: true },
{ name: "funcId", type: "input", input: "", disabled: true },
{ name: "vehicleConfigId", type: "input", input: "", disabled: true },
],
outputType: 'json',
outputParams: [
{ name: 'nextLocation', type: 'string', desc: '下一路径位置', disabled: true},
{ name: 'isEnd', type: 'string', desc: '是否结束', disabled: true},
{ name: 'iconUrl', type: 'string', desc: '图标路径', disabled: true}
]
},
{
icon: touchSvg,
name: "获取触控二维坐标",
type: "serviceNode",
desc: "获取触控二维坐标",
action: 'TI_TOUCH_COORDINATES',
nodeType: "EDGE",
nodeParams: [
{ name: "imageUrl", type: "input", input: "", disabled: true },
{ name: "iconUrl", type: "input", input: "", disabled: true }
],
outputType: 'json',
outputParams: [
{ name: 'touchCoordinates', type: 'string', desc: '触控二维坐标', disabled: true},
]
}
],
},
{
collapseTitle: "AI评估",
nodeList: [
{
icon: expressionSvg,
name: "AI评估预处理",
type: "serviceNode",
desc: "AI评估预处理",
action: 'AI_EVALUATION_PRE',
nodeType: "AE",
nodeParams: [
{ name: "taskType", type: "input", input: "", componentType: 'select', selectOptions: taskTypeOptions(), disabled: true },
{ name: "wakeCorpus", type: "input", input: "", disabled: true, required: false },
{ name: "testCorpus", type: "input", input: "", disabled: true, required: false },
{ name: "sceneType", type: "input", input: "", disabled: true, required: false },
],
outputType: 'json',
},
{
icon: touchSvg,
name: "AI评估执行",
type: "serviceNode",
desc: "AI评估执行",
action: 'AI_EVALUATION_END',
nodeType: "AE",
nodeParams: [
{ name: "videoUrl", type: "input", input: "", disabled: true }
],
outputType: 'json',
}
],
},
]

View File

@ -0,0 +1,419 @@
<!--
* 文件说明:Vue Flow 节点壳组件,负责锚点、连接规则、分支位置及分组交互适配。
* 作用范围:仅服务于流程设计器模块。
-->
<template>
<div
class="flow-node-shell"
:class="[`flow-node-shell--${flowType}`, {
'flow-node-shell--selected': selected,
'flow-node-shell--queue-drop-active': queueDropActive,
}]"
>
<Handle
v-if="showTarget"
:id="targetHandleId"
type="target"
:position="Position.Left"
:connectable="connectable && !flowStore.disableForm"
:is-valid-connection="isValidLoopConnection"
class="flow-handle flow-handle--target"
/>
<div class="flow-node-content" :class="{ 'flow-node-card': !isGroup }">
<component
:is="nodeComponent"
ref="componentRef"
:model="model"
:graph-model="model"
:properties="properties"
:child-count="isGroup ? childNodeCount : undefined"
@content-change="contentChanged"
@bind-ref="bindComponent"
@add-anchor="addAnchor"
@remove-anchor="removeAnchor"
@change-anchor="changeAnchor"
@sync-anchors="syncBranchAnchors"
@add-to-group="addToGroup"
/>
<div v-if="executionLabel" class="flow-node-executor">{{ executionLabel }}</div>
</div>
<template v-if="flowType === 'branch'">
<template v-for="anchor in branchAnchors" :key="anchor.id">
<Handle
:id="anchor.id"
type="source"
:position="Position.Right"
:connectable="connectable && !flowStore.disableForm"
:is-valid-connection="isValidLoopConnection"
class="flow-handle flow-handle--source flow-handle--branch"
:style="{ top: `${Math.max(40, Number(anchor.height || 0))}px` }"
/>
</template>
</template>
<Handle
v-else-if="showSource"
:id="`${id}_1`"
type="source"
:position="Position.Right"
:connectable="connectable && !flowStore.disableForm"
:is-valid-connection="isValidLoopConnection"
class="flow-handle flow-handle--source"
/>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from "vue";
import { Handle, Position, useVueFlow } from "@vue-flow/core";
import { useFlowStore } from "@/store/modules/flow";
import StartNode from "../nodes/common/startNode.vue";
import EndNode from "../nodes/common/endNode.vue";
import ServiceNode from "../nodes/common/serviceNode.vue";
import SelectAreaNode from "../nodes/common/selectAreaNode.vue";
import LoopNode from "../nodes/function/loop.vue";
import BranchNode from "../nodes/function/switch.vue";
import StopLoopNode from "../nodes/function/stopLoop.vue";
import SubStartNode from "../nodes/function/subStart.vue";
import SubEndNode from "../nodes/function/subEnd.vue";
import SleepNode from "../nodes/function/sleep.vue";
import HttpNode from "../nodes/function/httpNode.vue";
import CodeNode from "../nodes/function/codeNode.vue";
import CurrentLoopNode from "../nodes/function/currentLoop.vue";
import SdAgentNode from "../nodes/function/sdAgent.vue";
import RecognizeNode from "../nodes/function/recognize.vue";
import DeviceUniversal from "../nodes/function/deviceUniversal.vue";
import ActionQueue from "../nodes/function/actionQueue.vue";
import SubFlowNode from "../nodes/function/subFlow.vue";
const props = defineProps({
id: String,
data: Object,
selected: Boolean,
queueDropActive: Boolean,
connectable: [Boolean, Number, String, Function],
});
const componentMap = {
start: StartNode,
end: EndNode,
serviceNode: ServiceNode,
selectArea: SelectAreaNode,
loop: LoopNode,
branch: BranchNode,
stopLoop: StopLoopNode,
subStart: SubStartNode,
subEnd: SubEndNode,
sleep: SleepNode,
http: HttpNode,
code: CodeNode,
currentLoop: CurrentLoopNode,
sdAgent: SdAgentNode,
recognize: RecognizeNode,
deviceUniversal: DeviceUniversal,
device_universal: DeviceUniversal,
actionQueue: ActionQueue,
subFlow: SubFlowNode,
};
const flowStore = useFlowStore();
const vueFlow = useVueFlow("main-flow");
const componentRef = ref();
const flowType = computed(() => props.data.flowType);
const properties = computed(() => props.data.properties || {});
const executionLabel = computed(() => {
const roleKey = properties.value.executionTarget?.roleKey;
if (!roleKey) return "";
const graph = window.lf?.getGraphData?.();
const start = graph?.nodes?.find((node) => node.type === "start");
const role = start?.properties?.resourceRoles?.find((item) => item.roleKey === roleKey);
if (!role) return "未配置机器人角色";
const bindings = properties.value.executionTarget?.deviceBindings || [];
if (bindings.length > 1) return `${role.displayName} · ${bindings.length} 个设备用途`;
const slotKey = properties.value.executionTarget?.deviceSlotKey
|| properties.value.executionTarget?.deviceBindings?.[0]?.slotKey;
const slot = role.deviceSlots?.find((item) => item.slotKey === slotKey);
return slot ? `${role.displayName} · ${slot.displayName}` : role.displayName;
});
const nodeComponent = computed(() => componentMap[flowType.value] || ServiceNode);
const isGroup = computed(() => ["loop", "selectArea"].includes(flowType.value));
const childNodeCount = computed(() => (
(vueFlow.getNodes.value || []).filter((node) => node.parentNode === props.id).length
));
const showTarget = computed(() => !["start", "subStart", "selectArea"].includes(flowType.value));
const showSource = computed(() => !["end", "stopLoop", "subEnd", "selectArea", "branch"].includes(flowType.value));
const targetHandleId = computed(() => flowType.value === "branch" ? `${props.id}_entry` : `${props.id}_3`);
const branchAnchors = computed(() => {
const reservedIds = new Set([`${props.id}_1`, `${props.id}_3`, `${props.id}_entry`]);
const conditionDefs = properties.value.nodeParams || properties.value.conditions || [];
const conditionIds = new Set(conditionDefs.map((condition) => condition?.id).filter(Boolean));
const elseId = `${props.id}_else`;
const seen = new Set();
return (properties.value.anchor || []).filter((anchor) => {
if (!anchor?.id || reservedIds.has(anchor.id) || seen.has(anchor.id)) return false;
if (conditionIds.size && anchor.id !== elseId && !conditionIds.has(anchor.id)) return false;
seen.add(anchor.id);
return true;
});
});
const model = computed(() => window.lf?.getNodeModelById(props.id));
/**
* 获取与 loop scope 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} nodeId 流程节点 ID
*/
const getLoopScope = (nodeId) => {
const node = vueFlow.findNode(nodeId);
const parent = node?.parentNode ? vueFlow.findNode(node.parentNode) : null;
return parent?.data?.flowType === "loop" ? parent.id : null;
};
/**
* 判断与 valid loop connection 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} connection 待校验或创建的连接信息
*/
const isValidLoopConnection = (connection) => {
if (!connection.source || !connection.target || connection.source === connection.target) return false;
return getLoopScope(connection.source) === getLoopScope(connection.target);
};
/**
* 同步与 anchors 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} anchors 需要同步的锚点集合
*/
const syncAnchors = (anchors) => {
window.lf?.setProperties(props.id, { ...window.lf.getProperties(props.id), anchor: anchors });
vueFlow.updateNodeInternals([props.id]);
};
/**
* 新增与 anchor 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} height 锚点纵向位置
* @param {*} anchorId 分支锚点 ID
*/
const addAnchor = (height, anchorId) => {
const anchors = [...branchAnchors.value];
if (!anchors.some((anchor) => anchor.id === anchorId)) {
anchors.push({ id: anchorId, name: "right", height });
syncAnchors(anchors);
}
};
/**
* 移除与 anchor 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} anchorId 分支锚点 ID
*/
const removeAnchor = (anchorId) => syncAnchors(branchAnchors.value.filter((anchor) => anchor.id !== anchorId));
/**
* 更新与 anchor 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} height 锚点纵向位置
* @param {*} anchorId 分支锚点 ID
*/
const changeAnchor = (height, anchorId) => syncAnchors(branchAnchors.value.map((anchor) => anchor.id === anchorId ? { ...anchor, height } : anchor));
/**
* 同步与 branch anchors 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} positions 分支锚点位置集合
*/
const syncBranchAnchors = (positions = []) => {
if (!positions.length) return;
const positionMap = new Map(positions.map((item) => [item.id, Number(item.height || 0)]));
const current = [...(properties.value.anchor || [])];
let changed = false;
const anchors = current.map((anchor) => {
if (!positionMap.has(anchor.id)) return anchor;
const height = positionMap.get(anchor.id);
positionMap.delete(anchor.id);
if (Math.abs(Number(anchor.height || 0) - height) < 0.5) return anchor;
changed = true;
return { ...anchor, height };
});
positionMap.forEach((height, id) => {
anchors.push({ id, name: "right", height });
changed = true;
});
if (changed) syncAnchors(anchors);
else vueFlow.updateNodeInternals([props.id]);
};
/**
* 处理与 content changed 相关的状态或数据,该影响范围仅限当前组件。
*/
const contentChanged = () => {
vueFlow.updateNodeInternals([props.id]);
window.lf?.markChanged();
};
/**
* 绑定与 component 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} instance 节点组件实例
*/
const bindComponent = (instance) => window.lf?.registerComponentInstance(props.id, instance);
/**
* 新增与 to group 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} childId 子节点 ID
* @param {*} dropCenter 拖放中心点坐标
*/
const addToGroup = (childId, dropCenter) => window.lf?.addToGroup(props.id, childId, dropCenter);
onMounted(() => bindComponent(componentRef.value));
</script>
<style scoped lang="scss">
.flow-node-shell {
--node-accent: #4f6b8a;
width: 100%;
min-height: 100%;
position: relative;
box-sizing: border-box;
&--queue-drop-active .flow-node-content {
border-radius: 8px;
box-shadow: 0 0 0 3px rgba(47, 128, 237, 0.35), 0 10px 24px rgba(47, 128, 237, 0.18);
}
&--start,
&--subStart { --node-accent: #239565; }
&--end,
&--subEnd,
&--stopLoop { --node-accent: #d45252; }
&--branch { --node-accent: #b7791f; }
&--loop,
&--currentLoop { --node-accent: #df6b35; }
&--http,
&--code { --node-accent: #3978c5; }
&--sdAgent,
&--recognize { --node-accent: #7a5ab8; }
&--loop,
&--selectArea {
height: 100%;
}
&:hover .flow-node-card {
border-color: #cbd3df;
box-shadow: 0 10px 26px rgba(31, 41, 55, 0.11);
}
&--selected .flow-node-card {
border-color: #2f80ed;
box-shadow: 0 0 0 2px rgba(47, 128, 237, 0.2), 0 10px 26px rgba(31, 41, 55, 0.12);
}
&--selected.flow-node-shell--branch :deep(.node__container),
&--selected.flow-node-shell--loop :deep(.group__container),
&--selected.flow-node-shell--selectArea :deep(.group__container) {
border-color: #2f80ed;
box-shadow: 0 0 0 2px rgba(47, 128, 237, 0.18);
}
}
.flow-node-content {
width: 100%;
height: 100%;
box-sizing: border-box;
}
.flow-node-executor {
overflow: hidden;
margin-top: 8px;
color: #667085;
font-size: 11px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.flow-node-card {
width: 100%;
height: auto;
padding: 13px 14px 13px 16px;
position: relative;
background: #fff;
border: 1px solid #dfe4ec;
border-left: 4px solid var(--node-accent);
border-radius: 8px;
box-shadow: 0 7px 20px rgba(31, 41, 55, 0.08);
box-sizing: border-box;
transition: border-color 0.16s ease, box-shadow 0.16s ease;
:deep(.node__container) {
width: 100%;
height: auto;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
box-sizing: border-box;
}
:deep(.terminal-node) {
display: flex;
min-width: 0;
align-items: center;
gap: 11px;
}
:deep(.terminal-node__icon) {
display: grid;
width: 38px;
height: 38px;
flex: 0 0 38px;
place-items: center;
border-radius: 50%;
background: #f1f5f9;
}
:deep(.terminal-node__icon img) {
width: 22px;
height: 22px;
}
:deep(.terminal-node__content) { min-width: 0; }
:deep(.terminal-node__title) {
color: #1f2937;
font-size: 15px;
font-weight: 650;
line-height: 21px;
}
:deep(.terminal-node__description) {
overflow: hidden;
margin-top: 3px;
color: #7a8494;
font-size: 11px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.flow-handle {
width: 12px;
height: 12px;
border: 2px solid #fff;
background: var(--node-accent);
box-shadow: 0 0 0 1px var(--node-accent), 0 2px 5px rgba(31, 41, 55, 0.18);
z-index: 10;
transition: transform 0.14s ease, box-shadow 0.14s ease;
&:hover {
box-shadow: 0 0 0 3px rgba(47, 128, 237, 0.18), 0 2px 6px rgba(31, 41, 55, 0.2);
}
&--target {
left: -6px;
background: #fff;
border-color: var(--node-accent);
}
&--source { right: -6px; }
&--branch { transform: translate(50%, -50%); }
&--branch:hover { transform: translate(50%, -50%) scale(1.18); }
}
</style>

View File

@ -0,0 +1,137 @@
<template>
<div class="app-container">
<el-form :inline="true" :model="query">
<el-form-item label="机器人">
<el-select
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-form-item>
<el-form-item label="设备用途"><el-input v-model="query.deviceRole" clearable @keyup.enter="search" /></el-form-item>
<el-form-item label="在线状态">
<el-select v-model="onlineFilter" clearable style="width: 120px" @change="search">
<el-option label="在线" value="online" /><el-option label="离线" value="offline" />
</el-select>
</el-form-item>
<el-form-item><el-button type="primary" icon="Search" @click="search">查询</el-button><el-button icon="Refresh" @click="reset">重置</el-button></el-form-item>
</el-form>
<el-table v-loading="loading" :data="rows" row-key="id">
<el-table-column label="机器人" prop="robotId" min-width="200" show-overflow-tooltip />
<el-table-column label="设备" min-width="210">
<template #default="{ row }"><strong>{{ row.deviceName || row.deviceId }}</strong><div class="secondary">{{ row.deviceId }}</div></template>
</el-table-column>
<el-table-column label="类型" width="120"><template #default="{ row }">{{ kindName(row.deviceKind) }}</template></el-table-column>
<el-table-column label="用途" prop="deviceRole" min-width="150"><template #default="{ row }">{{ row.deviceRole || '未设置' }}</template></el-table-column>
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.onlineStatus === '1' ? 'success' : 'info'">{{ row.onlineStatus === '1' ? '在线' : '离线' }}</el-tag></template></el-table-column>
<el-table-column label="启用" width="90"><template #default="{ row }">{{ row.enabled === '0' ? '否' : '是' }}</template></el-table-column>
<el-table-column label="健康" width="100"><template #default="{ row }"><el-tag :type="row.hasError === '1' ? 'danger' : 'success'">{{ row.hasError === '1' ? '故障' : '正常' }}</el-tag></template></el-table-column>
<el-table-column label="最后发现" prop="lastSeenTime" width="175" />
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<el-button v-if="controlPath(row)" link type="primary" icon="VideoPlay" :disabled="row.onlineStatus !== '1'" @click="openTeach(row)">示教</el-button>
<el-button link type="primary" icon="Setting" @click="openConfig(row)">配置</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" v-model:page="query.pageNum" v-model:limit="query.pageSize" @pagination="load" />
<el-drawer v-model="configOpen" title="设备配置" size="min(720px, 94vw)" destroy-on-close>
<el-form :model="active" label-width="90px">
<el-form-item label="设备ID"><span class="identity">{{ active.deviceId }}</span></el-form-item>
<el-form-item label="设备名称"><el-input v-model="active.deviceName" /></el-form-item>
<el-form-item label="设备用途"><el-input v-model="active.deviceRole" placeholder="例如:前摄像头、左机械臂、环境麦克风" /></el-form-item>
<el-form-item label="启用"><el-switch v-model="active.enabled" active-value="1" inactive-value="0" /></el-form-item>
<el-form-item label="备注"><el-input v-model="active.remark" type="textarea" /></el-form-item>
</el-form>
<template #footer><el-button @click="configOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveConfig">保存</el-button></template>
</el-drawer>
</div>
</template>
<script setup name="ResourceDevice">
import { onActivated, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { listResourceDevices, updateResourceDevice } from '@/api/resource/device'
import { listResourceRobots } from '@/api/resource/robot'
const kindNames = { 1: '移动底盘', 2: '机械臂', 3: '电池', 4: '仿生头', 5: '摄像头', 6: 'CAN总线', 7: '灵巧手', 8: '夹爪', 9: '麦克风', 10: '电机', 11: '电机系统', 12: '机器人本体', 13: '扬声器' }
const router = useRouter()
const loading = ref(false), robotLoading = ref(false), saving = ref(false), rows = ref([]), robots = ref([]), total = ref(0), configOpen = ref(false), onlineFilter = ref('')
const query = reactive({ pageNum: 1, pageSize: 20, robotId: '', deviceRole: '', onlineOnly: false })
const active = reactive({})
const kindName = value => kindNames[value] || `未知(${value ?? '-'})`
const robotOptionLabel = robot => robot.robotName
? `${robot.robotName}(${robot.robotId})`
: robot.robotId
const controlPath = device => ({
2: 'mechanical_arm', 4: 'head', 5: 'camera', 7: 'hand', 9: 'microphone', 13: 'speaker',
})[device.deviceKind]
function openTeach(device) {
const path = controlPath(device)
if (!path) return
router.push({
path: `/device/${path}/${device.id}`,
query: { robotId: device.robotId, deviceId: device.deviceId },
})
}
async function load() {
loading.value = true
try {
const params = { ...query, onlineOnly: onlineFilter.value === 'online' }
const res = await listResourceDevices(params)
const data = res.rows || res.data || []
rows.value = onlineFilter.value === 'offline' ? data.filter(item => item.onlineStatus !== '1') : data
total.value = onlineFilter.value === 'offline' ? rows.value.length : (res.total ?? rows.value.length)
} finally { loading.value = false }
}
async function loadRobots() {
robotLoading.value = true
try {
const res = await listResourceRobots({ pageNum: 1, pageSize: 10000 })
robots.value = (res.rows || res.data || []).filter(robot => robot.robotId)
} finally {
robotLoading.value = false
}
}
function search() { query.pageNum = 1; load() }
function handleRobotChange() { search() }
function reset() { Object.assign(query, { pageNum: 1, robotId: '', deviceRole: '', onlineOnly: false }); onlineFilter.value = ''; load() }
function openConfig(row) {
Object.assign(active, JSON.parse(JSON.stringify(row)))
configOpen.value = true
}
async function saveConfig() {
saving.value = true
try {
await updateResourceDevice(active.id, active)
ElMessage.success('设备配置已保存')
configOpen.value = false
load()
} finally { saving.value = false }
}
const refresh = () => Promise.all([loadRobots(), load()])
onMounted(refresh)
onActivated(refresh)
</script>
<style scoped>
.secondary { color: var(--el-text-color-secondary); font-size: 12px; margin-top: 3px; }
.identity { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
</style>

View 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>