feat: 添加音频校正功能,新增音频校正对话框和相关逻辑
This commit is contained in:
parent
c07c776f3a
commit
7eaec22372
@ -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) {
|
||||
return request({
|
||||
url: '/api/arm/getPose',
|
||||
|
||||
8
src/views/flow/audioTypes.js
Normal file
8
src/views/flow/audioTypes.js
Normal 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' },
|
||||
]
|
||||
162
src/views/flow/components/AudioCorrectionDialog.vue
Normal file
162
src/views/flow/components/AudioCorrectionDialog.vue
Normal 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>
|
||||
@ -13,6 +13,9 @@
|
||||
</el-icon>
|
||||
<span class="node-state__text">{{ stateText }}</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>
|
||||
|
||||
<el-popover
|
||||
@ -117,6 +120,13 @@ defineExpose({ closePopover });
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.node-state__actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.node-state__result {
|
||||
display: flex;
|
||||
height: 26px;
|
||||
|
||||
@ -35,6 +35,16 @@
|
||||
<el-tag :type="analysisOf(log).tagType" size="small">
|
||||
{{ analysisOf(log).title }} · {{ analysisOf(log).statusLabel }}
|
||||
</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">
|
||||
模型:{{ analysisOf(log).modelName }}
|
||||
</span>
|
||||
@ -120,12 +130,20 @@
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<AudioCorrectionDialog
|
||||
v-model="audioCorrectionVisible"
|
||||
:node-inst-id="audioCorrectionTarget ? nodeInstIdOf(audioCorrectionTarget) : ''"
|
||||
:value="audioCorrectionTarget ? outputOf(audioCorrectionTarget) : {}"
|
||||
@saved="handleAudioCorrectionSaved"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<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 { formatDate } from '@/utils/index'
|
||||
import AudioCorrectionDialog from './AudioCorrectionDialog.vue'
|
||||
|
||||
/**
|
||||
* list: 任务或流程节点产生的日志数组。组件只负责展示,不修改日志对象。
|
||||
@ -167,12 +185,65 @@ const parseObject = (value) => {
|
||||
}
|
||||
}
|
||||
|
||||
const correctedOutputs = reactive(new Map())
|
||||
|
||||
const outputOf = (log) => ({
|
||||
...parseObject(log?.paramsOut),
|
||||
...parseObject(log?.actualResult),
|
||||
...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 rawIterationPathOf = (log) => {
|
||||
@ -428,19 +499,18 @@ const analysisOf = (log) => {
|
||||
if (analysisCache.has(log)) return analysisCache.get(log)
|
||||
|
||||
// extraInfo 后展开,因此同名字段会覆盖 actualResult;这是为了让后续补充/修正的信息优先显示。
|
||||
const output = {
|
||||
...parseObject(log.paramsOut),
|
||||
...parseObject(log.actualResult),
|
||||
...parseObject(log.extraInfo),
|
||||
}
|
||||
const output = outputOf(log)
|
||||
|
||||
// 新日志通过 analysisType/profileCode 标识媒体类型,logType=3/4 用于兼容旧版视频/音频日志。
|
||||
const action = String(log.action || '').toUpperCase()
|
||||
const analysisType = String(output.analysisType || '').toUpperCase()
|
||||
const profileCode = String(output.profileCode || '').toLowerCase()
|
||||
const isAudio = analysisType.includes('AUDIO') || profileCode.includes('audio') || Number(log.logType) === 4
|
||||
const isVideo = analysisType.includes('VIDEO') || profileCode.includes('video') || Number(log.logType) === 3
|
||||
const isAudio = action === 'AUDIO_EVENT_CLASSIFY'
|
||||
|| 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 识别。
|
||||
const isImage = analysisType.includes('IMAGE') || profileCode.includes('image')
|
||||
const isImage = action === 'IMAGE_ANALYZE' || analysisType.includes('IMAGE') || profileCode.includes('image')
|
||||
|
||||
// 仅有媒体类型还不足以展示分析面板,至少要存在一个分析状态或业务结果字段。
|
||||
const hasResult = Boolean(
|
||||
@ -764,6 +834,10 @@ onMounted(scrollToBottom)
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task-log__correction-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.task-log__analysis-meta,
|
||||
.task-log__analysis-field span,
|
||||
.task-log__analysis-label {
|
||||
|
||||
@ -165,6 +165,7 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { getUpstreamInput } from '@/utils/flow'
|
||||
import { soundTypeOptions } from '../../audioTypes.js'
|
||||
|
||||
/**
|
||||
* data 是流程画布当前节点模型,核心字段包括 id、properties.action 和 properties.nodeParams。
|
||||
@ -234,13 +235,6 @@ const analysisModes = [
|
||||
{ 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: '先快速判断,低置信度时自动切换精确分析。',
|
||||
|
||||
@ -5,6 +5,18 @@
|
||||
<template>
|
||||
<div class="node__container" :class="props.model.id">
|
||||
<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>
|
||||
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
|
||||
</template>
|
||||
@ -45,6 +57,12 @@
|
||||
<div v-else>{{ errorInfoData }}</div>
|
||||
</template>
|
||||
</NodeState>
|
||||
<AudioCorrectionDialog
|
||||
v-model="audioCorrectionVisible"
|
||||
:node-inst-id="nodeInstId"
|
||||
:value="outputJsonData"
|
||||
@saved="applyAudioCorrection"
|
||||
/>
|
||||
<NodeTitle
|
||||
:icon="props.properties.icon"
|
||||
:nodeId="props.model.id"
|
||||
@ -65,6 +83,7 @@ import NodeState from "../../components/NodeState.vue";
|
||||
import "vue3-json-viewer/dist/index.css";
|
||||
import { useFlowEvents } from "../../composables/useFlowEvents";
|
||||
import IPlayer from "@/components/IPlayer/index.vue";
|
||||
import AudioCorrectionDialog from "../../components/AudioCorrectionDialog.vue";
|
||||
|
||||
const { onFlowEvent } = useFlowEvents();
|
||||
|
||||
@ -93,6 +112,39 @@ const runtimes = ref(0);
|
||||
const inputJsonData = ref({});
|
||||
const outputJsonData = 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 value = outputJsonData.value?.audioUrl;
|
||||
@ -116,6 +168,7 @@ const closePopover = () => {
|
||||
onMounted(() => {
|
||||
onFlowEvent("changeNodeState", (data) => {
|
||||
if (data.nodeId === props.model.id) {
|
||||
executionLog.value = data;
|
||||
nodeOperatingStatus.value = data.status;
|
||||
if (data.endTime && data.startTime) {
|
||||
runtimes.value = data.endTime - data.startTime;
|
||||
@ -141,6 +194,7 @@ onMounted(() => {
|
||||
nodeOperatingStatus.value = "NORMAL";
|
||||
inputJsonData.value = {};
|
||||
outputJsonData.value = {};
|
||||
executionLog.value = null;
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
@ -166,4 +220,8 @@ defineExpose({ closePopover })
|
||||
width: min(320px, 100%);
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.node-audio-correction-button {
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user