feat: 添加音频校正功能,新增音频校正对话框和相关逻辑

This commit is contained in:
zhanghao 2026-08-26 11:41:23 +08:00
parent c07c776f3a
commit 7eaec22372
7 changed files with 330 additions and 16 deletions

View File

@ -90,6 +90,14 @@ export function flowStop(instId) {
}) })
} }
export function submitAudioCorrection(nodeInstId, data) {
return request({
url: `/flow/audio-correction/${encodeURIComponent(nodeInstId)}`,
method: 'post',
data,
})
}
export function getArmStatus(params) { export function getArmStatus(params) {
return request({ return request({
url: '/api/arm/getPose', url: '/api/arm/getPose',

View File

@ -0,0 +1,8 @@
export const soundTypeOptions = [
{ label: '开机', value: 'POWER_ON' },
{ label: '关机', value: 'POWER_OFF' },
{ label: '设防', value: 'ARMED' },
{ label: '解防', value: 'DISARMED' },
{ label: '鸣笛寻车', value: 'FIND_VEHICLE_HORN' },
{ label: '未知', value: 'UNKNOWN' },
]

View File

@ -0,0 +1,162 @@
<template>
<el-dialog
:model-value="props.modelValue"
title="声音类型人工判断"
width="480px"
append-to-body
:close-on-click-modal="false"
destroy-on-close
@update:model-value="updateVisible"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="96px">
<el-form-item label="声音类型" prop="label">
<el-select
v-model="form.label"
class="audio-correction-dialog__field"
filterable
default-first-option
clearable
placeholder="请选择声音类型"
@change="handleTypeChange"
>
<el-option
v-for="item in soundTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="备注" prop="note">
<el-input
v-model="form.note"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入人工判断备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { nextTick, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { submitAudioCorrection } from '@/api/flow/flow'
import { soundTypeOptions } from '../audioTypes.js'
const props = defineProps({
modelValue: { type: Boolean, default: false },
nodeInstId: { type: [String, Number], default: '' },
value: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['update:modelValue', 'saved'])
const formRef = ref(null)
const submitting = ref(false)
const form = reactive({ label: '', labelName: '', note: '' })
const rules = {
label: [{ required: true, message: '请选择声音类型', trigger: 'change' }],
}
const parseObject = (value) => {
if (!value) return {}
if (typeof value === 'object' && !Array.isArray(value)) return value
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
const resetForm = () => {
const value = parseObject(props.value)
const option = soundTypeOptions.find(item => item.value === value.label)
Object.assign(form, {
label: value.label || '',
labelName: value.labelName || option?.label || value.label || '',
note: value.correctionNote || value.note || '',
})
}
watch(() => props.modelValue, (visible) => {
if (!visible) return
resetForm()
nextTick(() => formRef.value?.clearValidate())
})
const updateVisible = (value) => emit('update:modelValue', value)
const close = () => {
if (!submitting.value) updateVisible(false)
}
const handleTypeChange = (value) => {
const option = soundTypeOptions.find(item => item.value === value)
form.labelName = option?.label || value || ''
}
const responseCorrection = (response) => {
const root = parseObject(response?.data ?? response)
const data = parseObject(root.data)
const source = Object.keys(data).length ? data : root
const candidates = [
source.correction,
source.audioCorrection,
source.actualResult,
source.result,
source,
]
const fields = [
'label', 'labelName', 'note', 'correctionNote', 'confidence', 'matched',
'passed', 'decisionReason', 'analysisStatus', 'analysisTimingMs', 'summary',
'conclusion', 'manualCorrected', 'manualCorrection',
]
const candidate = candidates.map(parseObject).find(item => fields.some(field => item[field] !== undefined)) || {}
const correction = {}
fields.forEach((field) => {
if (candidate[field] !== undefined) correction[field] = candidate[field]
})
correction.label ??= form.label
correction.labelName ??= form.labelName
correction.note ??= form.note
correction.correctionNote ??= form.note
correction.manualCorrected = true
return correction
}
const submit = async () => {
if (submitting.value || !props.nodeInstId) return
let valid = true
try {
valid = await formRef.value?.validate()
} catch {
valid = false
}
if (!valid) return
submitting.value = true
try {
const response = await submitAudioCorrection(props.nodeInstId, {
label: form.label,
labelName: form.labelName,
note: form.note,
})
emit('saved', responseCorrection(response))
ElMessage.success('人工判断已保存')
updateVisible(false)
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.audio-correction-dialog__field { width: 100%; }
</style>

View File

@ -13,6 +13,9 @@
</el-icon> </el-icon>
<span class="node-state__text">{{ stateText }}</span> <span class="node-state__text">{{ stateText }}</span>
<span class="node-state__runtime">{{ props.runtimes }} ms</span> <span class="node-state__runtime">{{ props.runtimes }} ms</span>
<div v-if="slots.actions" class="node-state__actions" @mousedown.stop @click.stop>
<slot name="actions" />
</div>
</div> </div>
<el-popover <el-popover
@ -117,6 +120,13 @@ defineExpose({ closePopover });
white-space: nowrap; white-space: nowrap;
} }
.node-state__actions {
display: flex;
min-width: 0;
align-items: center;
margin-left: auto;
}
.node-state__result { .node-state__result {
display: flex; display: flex;
height: 26px; height: 26px;

View File

@ -35,6 +35,16 @@
<el-tag :type="analysisOf(log).tagType" size="small"> <el-tag :type="analysisOf(log).tagType" size="small">
{{ analysisOf(log).title }} · {{ analysisOf(log).statusLabel }} {{ analysisOf(log).title }} · {{ analysisOf(log).statusLabel }}
</el-tag> </el-tag>
<el-button
v-if="isAudioCorrectionAvailable(log)"
class="task-log__correction-button"
type="primary"
link
size="small"
@click="openAudioCorrection(log)"
>
{{ audioCorrectionStateOf(log) ? '重新判断' : '人工判断' }}
</el-button>
<span v-if="isDebugView && analysisOf(log).modelName" class="task-log__analysis-meta"> <span v-if="isDebugView && analysisOf(log).modelName" class="task-log__analysis-meta">
模型:{{ analysisOf(log).modelName }} 模型:{{ analysisOf(log).modelName }}
</span> </span>
@ -120,12 +130,20 @@
/> />
</el-tooltip> </el-tooltip>
</div> </div>
<AudioCorrectionDialog
v-model="audioCorrectionVisible"
:node-inst-id="audioCorrectionTarget ? nodeInstIdOf(audioCorrectionTarget) : ''"
:value="audioCorrectionTarget ? outputOf(audioCorrectionTarget) : {}"
@saved="handleAudioCorrectionSaved"
/>
</template> </template>
<script setup> <script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ArrowRight, Bottom } from '@element-plus/icons-vue' import { ArrowRight, Bottom } from '@element-plus/icons-vue'
import { formatDate } from '@/utils/index' import { formatDate } from '@/utils/index'
import AudioCorrectionDialog from './AudioCorrectionDialog.vue'
/** /**
* list: 任务或流程节点产生的日志数组。组件只负责展示,不修改日志对象。 * list: 任务或流程节点产生的日志数组。组件只负责展示,不修改日志对象。
@ -167,12 +185,65 @@ const parseObject = (value) => {
} }
} }
const correctedOutputs = reactive(new Map())
const outputOf = (log) => ({ const outputOf = (log) => ({
...parseObject(log?.paramsOut), ...parseObject(log?.paramsOut),
...parseObject(log?.actualResult), ...parseObject(log?.actualResult),
...parseObject(log?.extraInfo), ...parseObject(log?.extraInfo),
...(correctedOutputs.get(log) || {}),
}) })
const audioCorrectionVisible = ref(false)
const audioCorrectionTarget = ref(null)
const nodeInstIdOf = (log) => {
const output = outputOf(log)
return log?.nodeInstId
|| log?.nodeInstanceId
|| output.nodeInstId
|| output.nodeInstanceId
|| log?.nodeId
|| output.nodeId
|| log?.id
|| ''
}
const audioCorrectionStateOf = (log) => Boolean(
outputOf(log).manualCorrected
|| outputOf(log).manualCorrection
|| outputOf(log).correctionNote,
)
const isAudioCorrectionAvailable = (log) => {
const analysis = analysisOf(log)
return Boolean(analysis?.isAudio && nodeInstIdOf(log))
}
const openAudioCorrection = (log) => {
audioCorrectionTarget.value = log
audioCorrectionVisible.value = true
}
const applyAudioCorrection = (log, correction) => {
correctedOutputs.set(log, correction)
const currentActualResult = parseObject(log?.actualResult)
const mergedActualResult = { ...currentActualResult, ...correction }
if (log && typeof log.actualResult === 'object' && log.actualResult !== null) {
Object.assign(log.actualResult, correction)
} else if (log) {
log.actualResult = JSON.stringify(mergedActualResult)
}
analysisCache.delete(log)
}
const handleAudioCorrectionSaved = (correction) => {
if (audioCorrectionTarget.value) {
applyAudioCorrection(audioCorrectionTarget.value, correction)
}
audioCorrectionTarget.value = null
}
const executionContextOf = (log) => outputOf(log)._executionContext || {} const executionContextOf = (log) => outputOf(log)._executionContext || {}
const rawIterationPathOf = (log) => { const rawIterationPathOf = (log) => {
@ -428,19 +499,18 @@ const analysisOf = (log) => {
if (analysisCache.has(log)) return analysisCache.get(log) if (analysisCache.has(log)) return analysisCache.get(log)
// extraInfo 后展开,因此同名字段会覆盖 actualResult;这是为了让后续补充/修正的信息优先显示。 // extraInfo 后展开,因此同名字段会覆盖 actualResult;这是为了让后续补充/修正的信息优先显示。
const output = { const output = outputOf(log)
...parseObject(log.paramsOut),
...parseObject(log.actualResult),
...parseObject(log.extraInfo),
}
// 新日志通过 analysisType/profileCode 标识媒体类型,logType=3/4 用于兼容旧版视频/音频日志。 // 新日志通过 analysisType/profileCode 标识媒体类型,logType=3/4 用于兼容旧版视频/音频日志。
const action = String(log.action || '').toUpperCase()
const analysisType = String(output.analysisType || '').toUpperCase() const analysisType = String(output.analysisType || '').toUpperCase()
const profileCode = String(output.profileCode || '').toLowerCase() const profileCode = String(output.profileCode || '').toLowerCase()
const isAudio = analysisType.includes('AUDIO') || profileCode.includes('audio') || Number(log.logType) === 4 const isAudio = action === 'AUDIO_EVENT_CLASSIFY'
const isVideo = analysisType.includes('VIDEO') || profileCode.includes('video') || Number(log.logType) === 3 || analysisType.includes('AUDIO') || profileCode.includes('audio') || Number(log.logType) === 4
const isVideo = action === 'VIDEO_ANALYZE'
|| analysisType.includes('VIDEO') || profileCode.includes('video') || Number(log.logType) === 3
// 图片分析没有历史 logType 枚举,只依赖新协议中的 analysisType/profileCode 识别。 // 图片分析没有历史 logType 枚举,只依赖新协议中的 analysisType/profileCode 识别。
const isImage = analysisType.includes('IMAGE') || profileCode.includes('image') const isImage = action === 'IMAGE_ANALYZE' || analysisType.includes('IMAGE') || profileCode.includes('image')
// 仅有媒体类型还不足以展示分析面板,至少要存在一个分析状态或业务结果字段。 // 仅有媒体类型还不足以展示分析面板,至少要存在一个分析状态或业务结果字段。
const hasResult = Boolean( const hasResult = Boolean(
@ -764,6 +834,10 @@ onMounted(scrollToBottom)
align-items: center; align-items: center;
} }
.task-log__correction-button {
margin-left: auto;
}
.task-log__analysis-meta, .task-log__analysis-meta,
.task-log__analysis-field span, .task-log__analysis-field span,
.task-log__analysis-label { .task-log__analysis-label {

View File

@ -165,6 +165,7 @@
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { Refresh } from '@element-plus/icons-vue' import { Refresh } from '@element-plus/icons-vue'
import { getUpstreamInput } from '@/utils/flow' import { getUpstreamInput } from '@/utils/flow'
import { soundTypeOptions } from '../../audioTypes.js'
/** /**
* data 是流程画布当前节点模型,核心字段包括 id、properties.action 和 properties.nodeParams。 * data 是流程画布当前节点模型,核心字段包括 id、properties.action 和 properties.nodeParams。
@ -234,13 +235,6 @@ const analysisModes = [
{ label: '精确', value: 'ACCURATE' }, { label: '精确', value: 'ACCURATE' },
] ]
// 声音类型同时保存机器可读编码和中文名称;allow-create 允许现场扩展未预置类型。 // 声音类型同时保存机器可读编码和中文名称;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 = { const modeDescriptions = {
FAST: '使用轻量模型直接判断,适合动作明显、批量执行的场景。', FAST: '使用轻量模型直接判断,适合动作明显、批量执行的场景。',
AUTO: '先快速判断,低置信度时自动切换精确分析。', AUTO: '先快速判断,低置信度时自动切换精确分析。',

View File

@ -5,6 +5,18 @@
<template> <template>
<div class="node__container" :class="props.model.id"> <div class="node__container" :class="props.model.id">
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef"> <NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
<template #actions>
<el-button
v-if="audioCorrectionAvailable"
class="node-audio-correction-button nodrag nopan"
type="primary"
link
size="small"
@click="audioCorrectionVisible = true"
>
{{ outputJsonData.manualCorrected ? '重新判断' : '人工判断' }}
</el-button>
</template>
<template #input> <template #input>
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" /> <JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
</template> </template>
@ -45,6 +57,12 @@
<div v-else>{{ errorInfoData }}</div> <div v-else>{{ errorInfoData }}</div>
</template> </template>
</NodeState> </NodeState>
<AudioCorrectionDialog
v-model="audioCorrectionVisible"
:node-inst-id="nodeInstId"
:value="outputJsonData"
@saved="applyAudioCorrection"
/>
<NodeTitle <NodeTitle
:icon="props.properties.icon" :icon="props.properties.icon"
:nodeId="props.model.id" :nodeId="props.model.id"
@ -65,6 +83,7 @@ import NodeState from "../../components/NodeState.vue";
import "vue3-json-viewer/dist/index.css"; import "vue3-json-viewer/dist/index.css";
import { useFlowEvents } from "../../composables/useFlowEvents"; import { useFlowEvents } from "../../composables/useFlowEvents";
import IPlayer from "@/components/IPlayer/index.vue"; import IPlayer from "@/components/IPlayer/index.vue";
import AudioCorrectionDialog from "../../components/AudioCorrectionDialog.vue";
const { onFlowEvent } = useFlowEvents(); const { onFlowEvent } = useFlowEvents();
@ -93,6 +112,39 @@ const runtimes = ref(0);
const inputJsonData = ref({}); const inputJsonData = ref({});
const outputJsonData = ref({}); const outputJsonData = ref({});
const errorInfoData = ref(''); const errorInfoData = ref('');
const executionLog = ref(null);
const audioCorrectionVisible = ref(false);
const nodeInstIdOf = (log) => log?.nodeInstId
|| log?.nodeInstanceId
|| log?.executionNodeInstId
|| log?.id
|| '';
const nodeInstId = computed(() => nodeInstIdOf(executionLog.value));
const audioCorrectionAvailable = computed(() => (
String(props.properties?.action || '').toUpperCase() === 'AUDIO_EVENT_CLASSIFY'
&& nodeOperatingStatus.value !== 'NORMAL'
&& Boolean(nodeInstId.value)
));
const applyAudioCorrection = (correction) => {
outputJsonData.value = { ...outputJsonData.value, ...correction };
if (!executionLog.value) return;
const currentOutput = parseRuntimeValue(executionLog.value.paramsOut);
executionLog.value.paramsOut = JSON.stringify({ ...currentOutput, ...correction });
};
const parseRuntimeValue = (value) => {
if (!value) return {};
if (typeof value === 'object' && !Array.isArray(value)) return value;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
};
const audioUrls = computed(() => { const audioUrls = computed(() => {
const value = outputJsonData.value?.audioUrl; const value = outputJsonData.value?.audioUrl;
@ -116,6 +168,7 @@ const closePopover = () => {
onMounted(() => { onMounted(() => {
onFlowEvent("changeNodeState", (data) => { onFlowEvent("changeNodeState", (data) => {
if (data.nodeId === props.model.id) { if (data.nodeId === props.model.id) {
executionLog.value = data;
nodeOperatingStatus.value = data.status; nodeOperatingStatus.value = data.status;
if (data.endTime && data.startTime) { if (data.endTime && data.startTime) {
runtimes.value = data.endTime - data.startTime; runtimes.value = data.endTime - data.startTime;
@ -141,6 +194,7 @@ onMounted(() => {
nodeOperatingStatus.value = "NORMAL"; nodeOperatingStatus.value = "NORMAL";
inputJsonData.value = {}; inputJsonData.value = {};
outputJsonData.value = {}; outputJsonData.value = {};
executionLog.value = null;
emits("contentChange"); emits("contentChange");
} }
}); });
@ -166,4 +220,8 @@ defineExpose({ closePopover })
width: min(320px, 100%); width: min(320px, 100%);
height: 36px; height: 36px;
} }
.node-audio-correction-button {
margin-left: 4px;
}
</style> </style>