CMVR-IOT-UI/src/views/flow/components/params/MediaAnalysisParams.vue
zhanghao d733ea8525 feat: 增强流资源管理和节点执行组件
- 增加了检测流资源中引用循环的功能,以防止循环依赖。
- 改进了流引用的验证,以确保节点输出中不存在循环依赖。
- 增强了资源定义清理功能,以便在执行前移除过时的设备ID。
- 已实现复制资源角色的合并,以优化现有资源的优先级并避免重复。
- 更新了LoopIterationResults组件,以管理运行中迭代的自动扩展。
- 增强了NodeState和NodeTitle组件,以支持循环和子流结果的更宽弹出框显示。
- 优化了ParamsDrawer和SubFlowExecutionResults组件,以处理动态参数更新和执行结果。
- 改进了TaskExecutionLog,以统一输出处理并增强用户对日志的可见性。
- 更新了各种参数组件,以简化输入处理并提升用户体验。
- 在代码中添加了全面的注释和文档,以提高可维护性和易理解性。
2026-08-21 15:01:16 +08:00

568 lines
26 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.

<!--
* 文件说明:媒体分析节点的参数编辑器,同时服务于“声音类型检测”和“视频智能分析”节点。
* 数据流向:从 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'
/**
* 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 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' }])
/**
* 输出摘要与运行时返回对象保持一致,仅负责向流程设计用户解释后续节点可引用的字段。
* 音频结果强调目标声音与实际声音的比较;视频结果强调证据、结论和实际分析模式。
*/
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,
}
}
// getUpstreamInput 根据当前节点 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>