CMVR-IOT-UI/src/views/flow/components/params/MediaAnalysisParams.vue
lixiaolong eb056f097c feat(flow): 更新声音类型检测功能并添加系统错误状态清除
- 修改声音事件识别为声音类型检测,调整相关参数配置
- 添加目标声音类型选择功能和预期标签匹配逻辑
- 新增机械臂故障清除API接口和操作按钮
- 实现工作流中清除机器人错误状态的功能和对话框
- 更新任务执行日志中声音分析结果显示逻辑
- 优化声音类型检测的输出参数和验证规则
2026-08-18 14:42:33 +08:00

423 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
* 文件说明:通用媒体分析节点配置,支持音频分类和视频分析场景。
* 作用范围:仅服务于流程设计器模块。
-->
<template>
<div class="media-analysis-params">
<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">
<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>
<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>
<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 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-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.maxFrames" :min="2" :max="16" :step="1" 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>
<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 { getInput } from '@/utils/flow'
const props = defineProps({ data: Object })
const emit = defineEmits(['save-success', 'save-error'])
const formRef = ref()
const mediaCascaderRef = ref()
const quoteOptions = ref([])
const advancedSections = ref([])
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 },
}
const formData = reactive({
profileCode: '',
expectedLabel: '',
expectedLabelName: '',
mediaMode: 'quote',
mediaQuote: [],
mediaQuoteType: 'output',
mediaUrl: '',
instruction: '',
analysisMode: 'AUTO',
analysisTuning: { ...analysisPresets.AUTO },
})
const isVideo = computed(() => props.data?.properties?.action === 'VIDEO_ANALYZE')
const title = computed(() => isVideo.value ? '视频智能分析' : '声音类型检测')
const description = computed(() => isVideo.value
? '对录像抽帧并按所选场景生成结构化分析结果。'
: '判断录音是否属于指定的目标声音类型,并输出通过或不通过。')
const mediaLabel = computed(() => isVideo.value ? '视频来源' : '音频来源')
const mediaName = computed(() => isVideo.value ? 'videoUrl' : 'audioUrl')
const mediaModes = [
{ label: '引用节点输出', value: 'quote' },
{ label: '直接输入地址', value: 'input' },
]
const analysisModes = [
{ label: '快速', value: 'FAST' },
{ label: '均衡', value: 'AUTO' },
{ label: '精确', value: 'ACCURATE' },
]
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])
})
const profileOptions = computed(() => isVideo.value
? [{ label: '爱玛测试视频分析', value: 'aima.power_video.v1' }]
: [{ label: '爱玛车辆声音类型检测', value: 'aima.power_state.v1' }])
const outputSummary = computed(() => isVideo.value
? [
{ 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: 'passed', desc: '是否检测到目标声音' },
{ name: 'expectedLabelName', desc: '目标声音类型' },
{ name: 'labelName', desc: '实际声音类型' },
{ name: 'matched', desc: '是否识别到已知声音' },
{ name: 'confidence', desc: '最高匹配度' },
{ name: 'decisionReason', desc: '检测结论' },
{ name: 'scores', desc: '各分类匹配度' },
])
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',
}],
}
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()
}
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 {}
}
}
const clamp = (value, minimum, maximum, fallback) => {
const number = Number(value)
return Number.isFinite(number) ? Math.min(maximum, Math.max(minimum, number)) : fallback
}
const normalizeStoredTuning = (value, preset) => {
const stored = parseStoredTuning(value)
return {
maxFrames: Math.round(clamp(stored.maxFrames, 2, 16, preset.maxFrames)),
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,
}
}
const loadQuoteOptions = (visible) => {
if (visible) quoteOptions.value = getInput(props.data.id) || []
}
const handleQuoteChange = () => {
const checked = mediaCascaderRef.value?.getCheckedNodes?.(true)?.[0]
formData.mediaQuoteType = checked?.data?.type || 'output'
}
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'
formData.mediaUrl = media?.input || ''
formData.instruction = instruction?.input || ''
formData.analysisMode = analysisPresets[analysisMode?.input] ? analysisMode.input : 'AUTO'
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 })
const validateAndSave = async () => {
try {
await formRef.value.validate()
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,
},
]
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,
},
)
}
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,
})
nodeParams.push({
name: 'analysisTuning',
type: 'input',
input: { ...formData.analysisTuning },
required: false,
disabled: true,
})
}
const properties = { ...props.data.properties, nodeParams }
delete properties.executionTarget
lf.setProperties(props.data.id, properties)
emit('save-success')
} catch (error) {
emit('save-error')
}
}
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>