doc: 添加注释

This commit is contained in:
zhanghao 2026-08-18 15:32:41 +08:00
commit 36016684a3
7 changed files with 377 additions and 23 deletions

View File

@ -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
View 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,
})
}

View File

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

View File

@ -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))
: [],

View File

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

View File

@ -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 },
],
},

View File

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