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