2026-08-25 10:15:37 +08:00
|
|
|
|
<!--
|
|
|
|
|
|
* 文件说明:媒体分析节点的参数编辑器,同时服务于“声音类型检测”和“视频智能分析”节点。
|
|
|
|
|
|
* 数据流向:从 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"
|
|
|
|
|
|
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>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 音频检测需要一个期望声音;视频分析改由 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"
|
|
|
|
|
|
v-model="formData.mediaQuote"
|
|
|
|
|
|
:options="quoteOptions"
|
|
|
|
|
|
:props="{ checkStrictly: true }"
|
|
|
|
|
|
placeholder="选择上游录音或录像输出"
|
|
|
|
|
|
style="width: 100%"
|
|
|
|
|
|
@visible-change="loadQuoteOptions"
|
|
|
|
|
|
@change="handleQuoteChange"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
|
|
<el-form-item label="异步执行">
|
|
|
|
|
|
<el-switch v-model="formData.asynchronous" />
|
|
|
|
|
|
<div class="field-hint">开启后分析在后台并行执行,流程结束前统一等待分析结果。</div>
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 视频模型依赖自然语言标准判断是否通过;音频节点使用 expectedLabel,不需要 instruction。 -->
|
|
|
|
|
|
<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-form-item v-if="isVideo" label="输出内容">
|
|
|
|
|
|
<!-- detailedOutput 只影响视频节点:默认精简,需要事件证据和时间范围时再让后端返回完整结果。 -->
|
|
|
|
|
|
<el-switch
|
|
|
|
|
|
v-model="formData.detailedOutput"
|
|
|
|
|
|
inline-prompt
|
|
|
|
|
|
active-text="详细"
|
|
|
|
|
|
inactive-text="精简"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div class="field-hint">精简结果响应更快;需要逐步事件和时间范围时再启用详细输出。</div>
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
|
|
|
|
|
|
<!--
|
|
|
|
|
|
高级参数只对视频抽帧分析生效。默认折叠以降低普通用户的配置负担,isTuned 用于提示
|
|
|
|
|
|
当前值已经偏离模式预设;“恢复默认”只重置当前模式的参数,不切换分析模式。
|
|
|
|
|
|
-->
|
|
|
|
|
|
<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.sampleFps" :min="0.25" :max="6" :step="0.25" 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>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 输出字段只作契约说明,实际 outputParams 由节点配置定义,不能在这里编辑。 -->
|
|
|
|
|
|
<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 { getUpstreamInput } from '@/utils/flow'
|
2026-08-26 11:41:23 +08:00
|
|
|
|
import { soundTypeOptions } from '../../audioTypes.js'
|
2026-08-25 10:15:37 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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: { sampleFps: 1, maxWidth: 896, confidenceThreshold: 0.7, fallbackToAccurate: false },
|
|
|
|
|
|
AUTO: { sampleFps: 2, maxWidth: 960, confidenceThreshold: 0.75, fallbackToAccurate: true },
|
|
|
|
|
|
ACCURATE: { sampleFps: 3, maxWidth: 1120, confidenceThreshold: 0.8, fallbackToAccurate: false },
|
|
|
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 表单使用一个稳定的 reactive 对象,initData 只更新字段而不替换对象,保证 Element Plus
|
|
|
|
|
|
* 的表单项、校验状态和 v-model 引用始终指向同一个实例。
|
|
|
|
|
|
*/
|
|
|
|
|
|
const formData = reactive({
|
|
|
|
|
|
profileCode: '',
|
|
|
|
|
|
expectedLabel: '',
|
|
|
|
|
|
expectedLabelName: '',
|
|
|
|
|
|
mediaMode: 'quote',
|
|
|
|
|
|
mediaQuote: [],
|
|
|
|
|
|
mediaQuoteType: 'output',
|
|
|
|
|
|
mediaUrl: '',
|
|
|
|
|
|
instruction: '',
|
|
|
|
|
|
analysisMode: 'AUTO',
|
|
|
|
|
|
detailedOutput: false,
|
|
|
|
|
|
asynchronous: false,
|
|
|
|
|
|
analysisTuning: { ...analysisPresets.AUTO },
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// action 是区分音频节点和视频节点的唯一业务判别字段,后续文案、参数名和校验都由它派生。
|
|
|
|
|
|
const isVideo = computed(() => props.data?.properties?.action === 'VIDEO_ANALYZE')
|
|
|
|
|
|
|
|
|
|
|
|
// 以下计算属性统一收敛音频/视频差异,模板不需要重复判断具体 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' },
|
|
|
|
|
|
]
|
|
|
|
|
|
const analysisModes = [
|
|
|
|
|
|
{ label: '快速', value: 'FAST' },
|
|
|
|
|
|
{ label: '均衡', value: 'AUTO' },
|
|
|
|
|
|
{ label: '精确', value: 'ACCURATE' },
|
|
|
|
|
|
]
|
|
|
|
|
|
// 声音类型同时保存机器可读编码和中文名称;allow-create 允许现场扩展未预置类型。
|
|
|
|
|
|
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' }])
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 输出摘要与运行时返回对象保持一致,仅负责向流程设计用户解释后续节点可引用的字段。
|
|
|
|
|
|
* 音频结果强调目标声音与实际声音的比较;视频结果强调证据、结论和实际分析模式。
|
|
|
|
|
|
*/
|
|
|
|
|
|
const outputSummary = computed(() => isVideo.value
|
|
|
|
|
|
? [
|
|
|
|
|
|
{ name: 'result', desc: '分析结果' },
|
|
|
|
|
|
{ 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: 'detailedOutput', 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()
|
|
|
|
|
|
: 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',
|
|
|
|
|
|
}],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 使用 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 {}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsed = JSON.parse(value)
|
|
|
|
|
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 将旧配置或外部写入值限制在当前 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 {
|
|
|
|
|
|
sampleFps: Math.round(clamp(stored.sampleFps, 0.25, 6, preset.sampleFps) * 100) / 100,
|
|
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// getInput 根据当前节点 ID 返回可引用的上游输出,只在级联面板打开时刷新以减少无效计算。
|
|
|
|
|
|
const loadQuoteOptions = (visible) => {
|
|
|
|
|
|
if (visible) quoteOptions.value = getUpstreamInput(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 = () => {
|
|
|
|
|
|
loadQuoteOptions(true)
|
|
|
|
|
|
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 detailedOutput = params.find(item => item.name === 'detailedOutput')
|
|
|
|
|
|
const analysisTuning = params.find(item => item.name === 'analysisTuning')
|
|
|
|
|
|
const asynchronous = params.find(item => item.name === 'asynchronous')
|
|
|
|
|
|
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'
|
|
|
|
|
|
// 兼容旧流程或外部写入的字符串值,表单内部统一使用布尔值驱动开关。
|
|
|
|
|
|
formData.detailedOutput = detailedOutput?.input === true || detailedOutput?.input === 'true'
|
|
|
|
|
|
formData.asynchronous = asynchronous?.input === true || asynchronous?.input === 'true'
|
|
|
|
|
|
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 })
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 校验并保存当前媒体分析节点。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 保存过程重新构造 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',
|
|
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
nodeParams.push({
|
|
|
|
|
|
name: 'asynchronous',
|
|
|
|
|
|
type: 'input',
|
|
|
|
|
|
input: formData.asynchronous,
|
|
|
|
|
|
required: false,
|
|
|
|
|
|
disabled: true,
|
|
|
|
|
|
componentType: 'switch',
|
|
|
|
|
|
})
|
|
|
|
|
|
// 音频节点把目标类型插入 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',
|
|
|
|
|
|
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,
|
|
|
|
|
|
})
|
|
|
|
|
|
// 该开关控制后端是否返回 events 等较重字段,默认 false 可降低单节点执行响应成本。
|
|
|
|
|
|
nodeParams.push({
|
|
|
|
|
|
name: 'detailedOutput',
|
|
|
|
|
|
type: 'input',
|
|
|
|
|
|
input: formData.detailedOutput,
|
|
|
|
|
|
required: false,
|
|
|
|
|
|
disabled: true,
|
|
|
|
|
|
componentType: 'switch',
|
|
|
|
|
|
})
|
|
|
|
|
|
nodeParams.push({
|
|
|
|
|
|
name: 'analysisTuning',
|
|
|
|
|
|
type: 'input',
|
|
|
|
|
|
input: { ...formData.analysisTuning },
|
|
|
|
|
|
required: false,
|
|
|
|
|
|
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) {
|
|
|
|
|
|
emit('save-error')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ParamsDrawer 在关闭前通过组件 ref 调用该方法,因此必须显式暴露。
|
|
|
|
|
|
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>
|