doc: 添加注释
This commit is contained in:
commit
36016684a3
@ -15,4 +15,12 @@ export function status(data) {
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function clearFault(params) {
|
||||
return request({
|
||||
url: '/api/arm/clearFault',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
17
src/api/device/system.js
Normal file
17
src/api/device/system.js
Normal file
@ -0,0 +1,17 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function getSafetyState(robotId) {
|
||||
return request({
|
||||
url: '/api/system/safetyState',
|
||||
method: 'get',
|
||||
params: { robotId },
|
||||
})
|
||||
}
|
||||
|
||||
export function recoverSafetyState(data) {
|
||||
return request({
|
||||
url: '/api/system/recoverSafetyState',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
@ -1,8 +1,33 @@
|
||||
<script setup>
|
||||
import { reactive, toRefs, onUnmounted, ref, onMounted } from "vue";
|
||||
import { computed, reactive, toRefs, onUnmounted, ref, onMounted } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { CircleClose } from "@element-plus/icons-vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { clearFault as clearArmFault } from "@/api/device/arm";
|
||||
import UrdfViewer from "./components/UrdfViewer.vue";
|
||||
import JointControl from "./components/JointControl.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const robotId = computed(() => String(route.query.robotId || ""));
|
||||
const deviceId = computed(() => String(route.query.deviceId || ""));
|
||||
const clearingFault = ref(false);
|
||||
|
||||
const handleClearFault = async () => {
|
||||
if (!robotId.value || !deviceId.value) {
|
||||
ElMessage.warning("缺少机器人或机械臂设备信息");
|
||||
return;
|
||||
}
|
||||
clearingFault.value = true;
|
||||
try {
|
||||
await clearArmFault({ robotId: robotId.value, deviceId: deviceId.value });
|
||||
ElMessage.success("机械臂故障状态已清除");
|
||||
} catch (error) {
|
||||
console.error("清除机械臂故障失败:", error);
|
||||
} finally {
|
||||
clearingFault.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const data = reactive({
|
||||
form: {
|
||||
x: 11,
|
||||
@ -160,6 +185,15 @@ onUnmounted(() => {
|
||||
>零位姿态</el-button
|
||||
>
|
||||
<el-button @click="handleResets('init')">初始位置</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
:loading="clearingFault"
|
||||
:disabled="!robotId || !deviceId"
|
||||
@click="handleClearFault"
|
||||
>
|
||||
<el-icon><CircleClose /></el-icon>清除故障
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="pose-info flex align-center">
|
||||
<div class="pose-item">X: {{ form.x.toFixed(6) }} m</div>
|
||||
|
||||
@ -23,8 +23,12 @@
|
||||
|
||||
<!-- 常用结果采用四列摘要,便于快速比较;详细扩展字段放到下方 JSON 区域。 -->
|
||||
<div class="task-log__analysis-grid">
|
||||
<div v-if="analysisOf(log).targetLabel" class="task-log__analysis-field">
|
||||
<span>目标声音</span>
|
||||
<strong>{{ analysisOf(log).targetLabel }}</strong>
|
||||
</div>
|
||||
<div v-if="analysisOf(log).label" class="task-log__analysis-field">
|
||||
<span>识别结果</span>
|
||||
<span>{{ analysisOf(log).isAudio ? '实际声音' : '识别结果' }}</span>
|
||||
<strong>{{ analysisOf(log).label }}</strong>
|
||||
</div>
|
||||
<div v-if="analysisOf(log).matchLabel" class="task-log__analysis-field">
|
||||
@ -199,6 +203,7 @@ const analysisOf = (log) => {
|
||||
'analysisStatus', 'analysisType', 'profileCode', 'analysisModel', 'analysisTimingMs',
|
||||
'mediaUrl', 'audioUrl', 'videoUrl', 'imageUrl', 'label', 'labelName', 'matched',
|
||||
'confidence', 'passed', 'summary', 'conclusion', 'events', '_executionContext',
|
||||
'expectedLabel', 'expectedLabelName', 'decisionReason',
|
||||
].forEach((key) => delete detailSource[key])
|
||||
|
||||
// 所有已知字段删除后还有剩余内容时,才生成详情 JSON,避免显示空对象。
|
||||
@ -206,7 +211,7 @@ const analysisOf = (log) => {
|
||||
|
||||
// 统一生成展示模型,模板不需要了解各版本日志字段和状态枚举的差异。
|
||||
const result = {
|
||||
title: isAudio ? '声音分析' : '视频分析',
|
||||
title: isAudio ? '声音类型检测' : '视频分析',
|
||||
statusLabel,
|
||||
|
||||
// 分析失败或业务判断未通过使用红色;仅匹配失败使用警告色,其余使用成功色。
|
||||
@ -218,13 +223,12 @@ const analysisOf = (log) => {
|
||||
: output.analysisModel?.name || '',
|
||||
timingMs: Number.isFinite(Number(output.analysisTimingMs)) ? Number(output.analysisTimingMs) : null,
|
||||
label: output.labelName || output.label || '',
|
||||
matchLabel: matched === true ? '匹配' : matched === false ? '未匹配' : '',
|
||||
targetLabel: output.expectedLabelName || output.expectedLabel || '',
|
||||
matchLabel: isAudio ? '' : matched === true ? '匹配' : matched === false ? '未匹配' : '',
|
||||
confidence,
|
||||
passedLabel: passed === true ? '通过' : passed === false ? '未通过' : '',
|
||||
summary: output.summary || output.conclusion || '',
|
||||
|
||||
// 限制最多 20 条关键事件,防止模型返回超大数组导致单条日志撑满页面。
|
||||
// 对象事件转成紧凑 JSON,字符串事件保持原样。
|
||||
summary: output.decisionReason || output.summary || output.conclusion || '',
|
||||
isAudio,
|
||||
events: Array.isArray(output.events)
|
||||
? output.events.slice(0, 20).map((event) => typeof event === 'string' ? event : JSON.stringify(event))
|
||||
: [],
|
||||
|
||||
@ -1,15 +1,22 @@
|
||||
<!--
|
||||
* 文件说明:通用媒体分析节点配置,支持音频分类和视频分析场景。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
* 文件说明:媒体分析节点的参数编辑器,同时服务于“声音类型检测”和“视频智能分析”节点。
|
||||
* 数据流向:从 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"
|
||||
@ -29,10 +36,33 @@
|
||||
<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"
|
||||
@ -46,10 +76,12 @@
|
||||
/>
|
||||
</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>
|
||||
|
||||
<!-- 视频模型依赖自然语言标准判断是否通过;音频节点使用 expectedLabel,不需要 instruction。 -->
|
||||
<el-form-item v-if="isVideo" label="通过标准" prop="instruction" required>
|
||||
<el-input
|
||||
v-model="formData.instruction"
|
||||
@ -62,11 +94,16 @@
|
||||
<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>
|
||||
|
||||
<!--
|
||||
高级参数只对视频抽帧分析生效。默认折叠以降低普通用户的配置负担,isTuned 用于提示
|
||||
当前值已经偏离模式预设;“恢复默认”只重置当前模式的参数,不切换分析模式。
|
||||
-->
|
||||
<el-collapse v-if="isVideo" v-model="advancedSections" class="analysis-tuning">
|
||||
<el-collapse-item name="tuning">
|
||||
<template #title>
|
||||
@ -97,6 +134,7 @@
|
||||
</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">
|
||||
@ -112,20 +150,39 @@ import { computed, reactive, ref, watch } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { getInput } 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: { maxFrames: 6, maxWidth: 896, confidenceThreshold: 0.7, fallbackToAccurate: false },
|
||||
AUTO: { maxFrames: 8, maxWidth: 960, confidenceThreshold: 0.75, fallbackToAccurate: true },
|
||||
ACCURATE: { maxFrames: 10, maxWidth: 1120, confidenceThreshold: 0.8, fallbackToAccurate: false },
|
||||
}
|
||||
/**
|
||||
* 表单使用一个稳定的 reactive 对象,initData 只更新字段而不替换对象,保证 Element Plus
|
||||
* 的表单项、校验状态和 v-model 引用始终指向同一个实例。
|
||||
*/
|
||||
const formData = reactive({
|
||||
profileCode: '',
|
||||
expectedLabel: '',
|
||||
expectedLabelName: '',
|
||||
mediaMode: 'quote',
|
||||
mediaQuote: [],
|
||||
mediaQuoteType: 'output',
|
||||
@ -135,13 +192,20 @@ const formData = reactive({
|
||||
analysisTuning: { ...analysisPresets.AUTO },
|
||||
})
|
||||
|
||||
// action 是区分音频节点和视频节点的唯一业务判别字段,后续文案、参数名和校验都由它派生。
|
||||
const isVideo = computed(() => props.data?.properties?.action === 'VIDEO_ANALYZE')
|
||||
const title = computed(() => isVideo.value ? '视频智能分析' : '声音事件识别')
|
||||
|
||||
// 以下计算属性统一收敛音频/视频差异,模板不需要重复判断具体 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' },
|
||||
@ -151,19 +215,34 @@ const analysisModes = [
|
||||
{ label: '均衡', value: 'AUTO' },
|
||||
{ label: '精确', value: 'ACCURATE' },
|
||||
]
|
||||
// 声音类型同时保存机器可读编码和中文名称;allow-create 允许现场扩展未预置类型。
|
||||
const soundTypeOptions = [
|
||||
{ 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' }])
|
||||
: [{ label: '爱玛车辆声音类型检测', value: 'aima.power_state.v1' }])
|
||||
/**
|
||||
* 输出摘要与运行时返回对象保持一致,仅负责向流程设计用户解释后续节点可引用的字段。
|
||||
* 音频结果强调目标声音与实际声音的比较;视频结果强调证据、结论和实际分析模式。
|
||||
*/
|
||||
const outputSummary = computed(() => isVideo.value
|
||||
? [
|
||||
{ name: 'passed', desc: '是否通过' },
|
||||
@ -176,15 +255,29 @@ const outputSummary = computed(() => isVideo.value
|
||||
{ name: 'fallback', desc: '是否切换到精确分析' },
|
||||
]
|
||||
: [
|
||||
{ name: 'label', desc: '场景分类编码,未匹配时为 UNKNOWN' },
|
||||
{ name: 'labelName', desc: '结果名称' },
|
||||
{ name: 'matched', desc: '是否达到匹配阈值' },
|
||||
{ name: 'passed', desc: '是否检测到目标声音' },
|
||||
{ name: 'expectedLabelName', desc: '目标声音类型' },
|
||||
{ name: 'labelName', desc: '实际声音类型' },
|
||||
{ name: 'matched', 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()
|
||||
@ -205,12 +298,24 @@ const rules = {
|
||||
}],
|
||||
}
|
||||
|
||||
// 使用 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 {}
|
||||
@ -222,11 +327,16 @@ const parseStoredTuning = (value) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 将旧配置或外部写入值限制在当前 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 {
|
||||
@ -239,23 +349,43 @@ const normalizeStoredTuning = (value, preset) => {
|
||||
}
|
||||
}
|
||||
|
||||
// getInput 根据当前节点 ID 返回可引用的上游输出,只在级联面板打开时刷新以减少无效计算。
|
||||
const loadQuoteOptions = (visible) => {
|
||||
if (visible) quoteOptions.value = getInput(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 = () => {
|
||||
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 analysisTuning = params.find(item => item.name === 'analysisTuning')
|
||||
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'
|
||||
@ -267,11 +397,24 @@ const initData = () => {
|
||||
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',
|
||||
@ -292,6 +435,28 @@ const validateAndSave = async () => {
|
||||
disabled: true,
|
||||
},
|
||||
]
|
||||
// 音频节点把目标类型插入 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',
|
||||
@ -317,8 +482,13 @@ const validateAndSave = async () => {
|
||||
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) {
|
||||
@ -326,6 +496,7 @@ const validateAndSave = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// ParamsDrawer 在关闭前通过组件 ref 调用该方法,因此必须显式暴露。
|
||||
defineExpose({ validateAndSave })
|
||||
</script>
|
||||
|
||||
|
||||
@ -520,21 +520,27 @@ function handler(params) {
|
||||
nodeList: [
|
||||
{
|
||||
icon: microphoneSvg,
|
||||
name: "声音事件识别",
|
||||
name: "声音类型检测",
|
||||
type: "mediaAnalysis",
|
||||
desc: "将录音与分析场景中的多类参考声音进行匹配",
|
||||
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 },
|
||||
],
|
||||
outputType: "json",
|
||||
outputParams: [
|
||||
{ name: "label", type: "string", desc: "场景分类编码,未匹配时为UNKNOWN", disabled: true },
|
||||
{ name: "labelName", type: "string", desc: "分类名称", disabled: true },
|
||||
{ name: "matched", type: "boolean", desc: "是否达到匹配阈值", disabled: true },
|
||||
{ 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 },
|
||||
],
|
||||
},
|
||||
|
||||
@ -21,6 +21,15 @@
|
||||
<el-button v-if="hasRunResult" @click="clearRun">
|
||||
<el-icon><Delete /></el-icon>清除结果
|
||||
</el-button>
|
||||
<el-button
|
||||
type="warning"
|
||||
plain
|
||||
:loading="recoverSafetyLoading"
|
||||
:disabled="flowState === 'testRunning'"
|
||||
@click="openRecoverSafetyDialog"
|
||||
>
|
||||
<el-icon><Warning /></el-icon>清除错误状态
|
||||
</el-button>
|
||||
<el-button :type="groupSelectionPending ? 'primary' : 'default'" @click="group">
|
||||
<el-icon><CollectionTag /></el-icon>{{ groupSelectionPending ? "取消分组" : "分组" }}
|
||||
</el-button>
|
||||
@ -163,6 +172,39 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="recoverSafetyDialogVisible" title="清除错误状态" width="480" append-to-body>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="机器人">
|
||||
<el-select
|
||||
v-model="recoverSafetyRobotIds"
|
||||
multiple
|
||||
filterable
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
:loading="recoverRobotLoading"
|
||||
placeholder="请选择需要恢复的在线机器人"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in recoverRobotOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="recoverSafetyDialogVisible = false">取消</el-button>
|
||||
<el-button
|
||||
type="warning"
|
||||
:loading="recoverSafetyLoading"
|
||||
:disabled="recoverSafetyRobotIds.length === 0"
|
||||
@click="confirmRecoverSafetyState"
|
||||
>确认清除</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="visible" :width="nodeAction === 'SYSTEM_EXECUTE_ACTION_QUEUE' ? '620' : '500'" class="single-node-dialog" append-to-body>
|
||||
<template #header="{ close, titleId, titleClass }">
|
||||
<div class="my-header">
|
||||
@ -279,7 +321,7 @@ import TestRun from "./components/TestRun.vue";
|
||||
import ParamsDrawer from "./components/ParamsDrawer.vue";
|
||||
import Aside from "./components/Aside.vue";
|
||||
import ActionQueueSingleRun from "./components/ActionQueueSingleRun.vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { useFlowStore } from "@/store/modules/flow";
|
||||
import { emitter } from "@/utils/eventBus";
|
||||
import { convertToTree } from "@/utils/flow";
|
||||
@ -308,6 +350,7 @@ import {
|
||||
} from "@/api/flow/flow";
|
||||
import { getDetect } from "@/api/test/detect";
|
||||
import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot'
|
||||
import { recoverSafetyState } from '@/api/device/system'
|
||||
import { getDeviceKindForAction, toDeviceOptions } from '@/utils/robotDevice'
|
||||
import {
|
||||
CircleClose,
|
||||
@ -321,6 +364,7 @@ import {
|
||||
Upload,
|
||||
VideoPause,
|
||||
VideoPlay,
|
||||
Warning,
|
||||
} from "@element-plus/icons-vue";
|
||||
|
||||
const flowContainerRef = ref(null);
|
||||
@ -333,6 +377,76 @@ const queueDropTargetId = ref("");
|
||||
const { onFlowEvent } = useFlowEvents();
|
||||
window.lf = lf;
|
||||
|
||||
const recoverSafetyDialogVisible = ref(false);
|
||||
const recoverRobotLoading = ref(false);
|
||||
const recoverSafetyLoading = ref(false);
|
||||
const recoverRobotOptions = ref([]);
|
||||
const recoverSafetyRobotIds = ref([]);
|
||||
|
||||
const openRecoverSafetyDialog = async () => {
|
||||
recoverSafetyDialogVisible.value = true;
|
||||
recoverRobotLoading.value = true;
|
||||
try {
|
||||
const response = await getRobotList({ pageNum: 1, pageSize: 1000, connectStatus: '1' });
|
||||
recoverRobotOptions.value = (response.rows || []).filter((robot) => robot.robotId).map((robot) => ({
|
||||
value: robot.robotId,
|
||||
label: `${robot.robotName || robot.robotId} (${robot.robotId})`,
|
||||
}));
|
||||
recoverSafetyRobotIds.value = recoverSafetyRobotIds.value.filter((robotId) => (
|
||||
recoverRobotOptions.value.some((option) => option.value === robotId)
|
||||
));
|
||||
if (!recoverSafetyRobotIds.value.length && recoverRobotOptions.value.length === 1) {
|
||||
recoverSafetyRobotIds.value = [recoverRobotOptions.value[0].value];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载在线机器人失败:", error);
|
||||
recoverRobotOptions.value = [];
|
||||
} finally {
|
||||
recoverRobotLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmRecoverSafetyState = async () => {
|
||||
if (!recoverSafetyRobotIds.value.length) {
|
||||
ElMessage.warning("请选择需要恢复的机器人");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将清除 ${recoverSafetyRobotIds.value.length} 台机器人的可恢复错误状态,确认继续?`,
|
||||
"安全状态确认",
|
||||
{ type: "warning", confirmButtonText: "确认清除", cancelButtonText: "取消" },
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
recoverSafetyLoading.value = true;
|
||||
const robotIds = [...recoverSafetyRobotIds.value];
|
||||
try {
|
||||
const results = await Promise.allSettled(robotIds.map((robotId) => recoverSafetyState({
|
||||
robotId,
|
||||
reason: "工作流设计器手动清除错误状态",
|
||||
timeoutMs: 15000,
|
||||
})));
|
||||
const failedRobotIds = results.flatMap((result, index) => (
|
||||
result.status === "rejected" ? [robotIds[index]] : []
|
||||
));
|
||||
if (!failedRobotIds.length) {
|
||||
ElMessage.success(`已清除 ${robotIds.length} 台机器人的可恢复错误状态`);
|
||||
recoverSafetyDialogVisible.value = false;
|
||||
recoverSafetyRobotIds.value = [];
|
||||
} else {
|
||||
const successCount = robotIds.length - failedRobotIds.length;
|
||||
recoverSafetyRobotIds.value = failedRobotIds;
|
||||
ElMessage.error(successCount
|
||||
? `${successCount} 台成功,${failedRobotIds.length} 台失败,请重试失败项`
|
||||
: "清除错误状态失败,请检查机器人状态");
|
||||
}
|
||||
} finally {
|
||||
recoverSafetyLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const defaultEdgeOptions = {
|
||||
type: "flow-edge",
|
||||
animated: true,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user