- 新增子流程节点类型,支持在同一项目中执行已发布工作流 - 添加子流程节点UI组件和相关配置选项 - 重构参数引用逻辑,使用getUpstreamInput替代getInput方法 - 优化cascader组件属性设置,统一使用props配置checkStrictly - 修正多个参数组件中的handleTypeChange和cascaderChange函数参数传递 - 添加流程输出参数管理功能,支持引用来源选择和类型识别 - 实现子流程运行时状态管理和迭代输出处理 - 增加循环引用检测和资源验证功能 - 更新API接口,添加子流程选项获取方法 - 修复流程引用和依赖关系处理逻辑
890 lines
32 KiB
Vue
890 lines
32 KiB
Vue
<template>
|
||
<div class="task-log" :style="{ height: `${height}px` }">
|
||
<!-- 独立滚动层负责监听用户位置;外层仅提供边框和悬浮按钮的定位上下文。 -->
|
||
<div ref="messageContainer" class="task-log__scroll" @scroll.passive="handleScroll">
|
||
<!-- 每条日志由固定宽度的时间列和自适应内容列组成;没有后端 ID 时使用数组下标兜底。 -->
|
||
<template v-for="(log, index) in displayLogs" :key="log.__displayKey || log.id || index">
|
||
<button
|
||
v-if="log.__iterationHeader"
|
||
type="button"
|
||
class="task-log__iteration-header"
|
||
@click="toggleIteration(log.__iterationKey)"
|
||
>
|
||
<el-icon :class="{ 'is-expanded': isIterationExpanded(log.__iterationKey) }"><ArrowRight /></el-icon>
|
||
<span class="task-log__iteration-title">{{ log.__iterationLabel }}</span>
|
||
<el-tag :type="log.__statusType" size="small">{{ log.__statusLabel }}</el-tag>
|
||
<el-tag v-if="log.__analysisLabel" :type="log.__analysisType" size="small">
|
||
{{ log.__analysisLabel }}
|
||
</el-tag>
|
||
<span class="task-log__iteration-count">{{ log.__count }} 条日志</span>
|
||
</button>
|
||
<div v-else class="task-log__item" :class="{ 'is-iteration-child': iterationPathOf(log).length }">
|
||
<div class="task-log__time">{{ formatDate(logTimeOf(log)) }}</div>
|
||
<div class="task-log__body">
|
||
<div class="task-log__content">{{ logContentOf(log) }}</div>
|
||
|
||
<div v-if="displayErrorOf(log)" class="task-log__error">
|
||
<strong>失败原因</strong>
|
||
<span>{{ displayErrorOf(log) }}</span>
|
||
</div>
|
||
|
||
<!-- 只有 analysisOf 能确认是音频或视频分析日志时,才展示结构化分析结果。 -->
|
||
<div v-if="analysisOf(log)" class="task-log__analysis">
|
||
<!-- 标题行展示分析类型、执行状态、模型名称和耗时;缺失字段不会留下空占位。 -->
|
||
<div class="task-log__analysis-heading">
|
||
<el-tag :type="analysisOf(log).tagType" size="small">
|
||
{{ analysisOf(log).title }} · {{ analysisOf(log).statusLabel }}
|
||
</el-tag>
|
||
<span v-if="isDebugView && analysisOf(log).modelName" class="task-log__analysis-meta">
|
||
模型:{{ analysisOf(log).modelName }}
|
||
</span>
|
||
<span v-if="analysisOf(log).timingMs !== null" class="task-log__analysis-meta">
|
||
耗时:{{ analysisOf(log).timingMs }}ms
|
||
</span>
|
||
</div>
|
||
|
||
<!-- 常用结果采用四列摘要,便于快速比较;详细扩展字段放到下方 JSON 区域。 -->
|
||
<div class="task-log__analysis-grid">
|
||
<div v-if="analysisOf(log).targetLabel" class="task-log__analysis-field">
|
||
<span>目标声音</span>
|
||
<strong>{{ analysisOf(log).targetLabel }}</strong>
|
||
</div>
|
||
<div v-if="analysisOf(log).label" class="task-log__analysis-field">
|
||
<span>{{ analysisOf(log).isAudio ? '实际声音' : '识别结果' }}</span>
|
||
<strong>{{ analysisOf(log).label }}</strong>
|
||
</div>
|
||
<div v-if="analysisOf(log).matchLabel" class="task-log__analysis-field">
|
||
<span>匹配状态</span>
|
||
<strong>{{ analysisOf(log).matchLabel }}</strong>
|
||
</div>
|
||
<div v-if="analysisOf(log).confidence !== null" class="task-log__analysis-field">
|
||
<span>置信度</span>
|
||
<strong>{{ analysisOf(log).confidence }}%</strong>
|
||
</div>
|
||
<div v-if="analysisOf(log).passedLabel" class="task-log__analysis-field">
|
||
<span>判断结果</span>
|
||
<strong>{{ analysisOf(log).passedLabel }}</strong>
|
||
</div>
|
||
</div>
|
||
<div v-if="analysisOf(log).summary" class="task-log__analysis-summary">
|
||
{{ analysisOf(log).summary }}
|
||
</div>
|
||
|
||
<!-- 关键事件可能来自模型输出数组,analysisOf 已限制最多展示前 20 条。 -->
|
||
<div v-if="analysisOf(log).events.length" class="task-log__analysis-events">
|
||
<div class="task-log__analysis-label">关键事件</div>
|
||
<div v-for="(event, eventIndex) in analysisOf(log).events" :key="eventIndex" class="task-log__analysis-event">
|
||
{{ event }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 未被结构化消费的字段以格式化 JSON 展示,保留排查模型输出的能力。 -->
|
||
<pre v-if="isDebugView && analysisOf(log).details" class="task-log__analysis-details">{{ analysisOf(log).details }}</pre>
|
||
</div>
|
||
|
||
<pre v-if="isDebugView && genericOutputOf(log)" class="task-log__output">{{ genericOutputOf(log) }}</pre>
|
||
<div v-else-if="!isDebugView && businessSummaryOf(log)" class="task-log__business-summary">
|
||
{{ businessSummaryOf(log) }}
|
||
</div>
|
||
|
||
<!-- 同一日志可同时携带多种媒体;mediaOf 负责从新旧字段提取并按“类型+URL”去重。 -->
|
||
<div v-if="mediaOf(log).length" class="task-log__media">
|
||
<template v-for="media in mediaOf(log)" :key="`${media.type}:${media.url}`">
|
||
<el-image
|
||
v-if="media.type === 'image'"
|
||
class="task-log__image"
|
||
:src="media.url"
|
||
:preview-src-list="imageUrlsOf(log)"
|
||
preview-teleported
|
||
fit="cover"
|
||
/>
|
||
<!-- 视频和音频只预加载元数据,避免日志列表初次渲染时下载完整媒体文件。 -->
|
||
<video v-else-if="media.type === 'video'" class="task-log__video" :src="media.url" controls preload="metadata" />
|
||
<audio v-else class="task-log__audio" :src="media.url" controls preload="metadata" />
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 用户离开底部后显示悬浮入口;点击后回到最新日志并恢复自动跟随。 -->
|
||
<el-tooltip v-if="!isAtBottom" content="跳到最新日志" placement="left">
|
||
<el-button
|
||
class="task-log__jump-latest"
|
||
type="primary"
|
||
:icon="Bottom"
|
||
circle
|
||
aria-label="跳到最新日志"
|
||
@click.stop="scrollToBottom"
|
||
/>
|
||
</el-tooltip>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||
import { ArrowRight, Bottom } from '@element-plus/icons-vue'
|
||
import { formatDate } from '@/utils/index'
|
||
|
||
/**
|
||
* list: 任务或流程节点产生的日志数组。组件只负责展示,不修改日志对象。
|
||
* height: 日志容器固定高度;内容超出后在容器内滚动,默认 150px。
|
||
*/
|
||
const props = defineProps({
|
||
list: { type: Array, default: () => [] },
|
||
height: { type: Number, default: 150 },
|
||
mode: { type: String, default: 'debug' },
|
||
})
|
||
|
||
const isDebugView = computed(() => props.mode !== 'user')
|
||
|
||
// 保存日志滚动容器 DOM,用于首次渲染和日志更新后滚动到最新一条。
|
||
const messageContainer = ref(null)
|
||
|
||
// 使用少量像素容差吸收浏览器对子像素和缩放比例的取整误差,避免视觉上已到底部却仍显示按钮。
|
||
const BOTTOM_THRESHOLD = 8
|
||
|
||
// 初始状态视为位于底部;组件挂载后会根据真实滚动尺寸完成第一次定位。
|
||
const isAtBottom = ref(true)
|
||
|
||
// 以日志对象本身作为弱引用键缓存媒体解析结果;日志对象回收后缓存会自动释放,不产生长期内存占用。
|
||
const mediaCache = new WeakMap()
|
||
|
||
/**
|
||
* 将扩展结果安全转换为普通对象。
|
||
* 后端历史数据可能返回 JSON 字符串,新版接口可能直接返回对象;空值、基础类型或非法 JSON 均降级为空对象,
|
||
* 避免一条格式异常的日志阻断整个列表渲染。
|
||
*/
|
||
const parseObject = (value) => {
|
||
if (!value) return {}
|
||
if (typeof value === 'object') return value
|
||
try {
|
||
const parsed = JSON.parse(value)
|
||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||
} catch {
|
||
return {}
|
||
}
|
||
}
|
||
|
||
const outputOf = (log) => ({
|
||
...parseObject(log?.paramsOut),
|
||
...parseObject(log?.actualResult),
|
||
...parseObject(log?.extraInfo),
|
||
})
|
||
|
||
const executionContextOf = (log) => outputOf(log)._executionContext || {}
|
||
|
||
const rawIterationPathOf = (log) => {
|
||
const values = [log?.iterations, executionContextOf(log).iterations, log?.iteration]
|
||
for (const value of values) {
|
||
const path = Array.isArray(value)
|
||
? value.filter(item => item !== null && item !== '')
|
||
: value === null || value === undefined || value === ''
|
||
? []
|
||
: String(value).split(/[-.,/]/).map(item => item.trim()).filter(Boolean)
|
||
if (path.length) return path
|
||
}
|
||
return []
|
||
}
|
||
|
||
// Historical NODE_STARTED records were persisted before the loop path was copied
|
||
// into the event. Pair them with the next terminal event of the same node so old
|
||
// task logs can be folded without rewriting database rows.
|
||
const inferredIterationPaths = computed(() => {
|
||
const result = new WeakMap()
|
||
const pendingStarts = new Map()
|
||
;(Array.isArray(props.list) ? props.list : []).forEach((log) => {
|
||
const eventType = String(executionContextOf(log).eventType || '').toUpperCase()
|
||
const nodeKey = String(log?.nodeId || log?.nodeName || log?.executionStep || '')
|
||
if (!nodeKey) return
|
||
if (eventType === 'NODE_STARTED') {
|
||
if (!rawIterationPathOf(log).length) {
|
||
if (!pendingStarts.has(nodeKey)) pendingStarts.set(nodeKey, [])
|
||
pendingStarts.get(nodeKey).push(log)
|
||
}
|
||
return
|
||
}
|
||
if (!['NODE_COMPLETED', 'NODE_FAILED'].includes(eventType)) return
|
||
const pending = pendingStarts.get(nodeKey)
|
||
if (!pending?.length) return
|
||
const startedLog = pending.shift()
|
||
const path = rawIterationPathOf(log)
|
||
if (path.length) result.set(startedLog, path)
|
||
})
|
||
return result
|
||
})
|
||
|
||
const iterationPathOf = (log) => {
|
||
const path = rawIterationPathOf(log)
|
||
return path.length ? path : inferredIterationPaths.value.get(log) || []
|
||
}
|
||
|
||
const executionKeyOf = (log) => {
|
||
const context = executionContextOf(log)
|
||
const nodeKey = log?.nodeId || log?.executionStep || log?.nodeName || log?.logContent || ''
|
||
return `${nodeKey}:${iterationPathOf(log).join('.')}:${context.itemOccurrence || ''}`
|
||
}
|
||
|
||
// User-facing task logs keep an unmatched start event while a node is running, then
|
||
// replace it with the terminal event. Structural wrapper nodes do not add business value.
|
||
const visibleLogs = computed(() => {
|
||
const source = Array.isArray(props.list) ? props.list : []
|
||
if (isDebugView.value) return source
|
||
const terminalKeys = new Set(source
|
||
.filter((log) => ['NODE_COMPLETED', 'NODE_FAILED'].includes(
|
||
String(executionContextOf(log).eventType || '').toUpperCase(),
|
||
))
|
||
.map(executionKeyOf))
|
||
const structuralTypes = new Set(['START', 'END', 'SUB_START', 'SUB_END', 'SUB_FLOW', 'LOOP'])
|
||
return source.filter((log) => {
|
||
const context = executionContextOf(log)
|
||
const eventType = String(context.eventType || '').toUpperCase()
|
||
const nodeType = String(context.nodeType || log?.nodeType || '').toUpperCase()
|
||
if (eventType === 'NODE_STARTED' && terminalKeys.has(executionKeyOf(log))) return false
|
||
if (structuralTypes.has(nodeType) && !errorOf(log)) return false
|
||
return true
|
||
})
|
||
})
|
||
|
||
const logTimeOf = (log) => log?.logTime || log?.createTime || log?.startTime || log?.endTime
|
||
const logContentOf = (log) => log?.logContent || log?.message
|
||
|| [log?.action || log?.nodeType, log?.status].filter(Boolean).join(' - ')
|
||
|| '节点执行记录'
|
||
|
||
const analysisPassedOf = (log) => {
|
||
const output = outputOf(log)
|
||
const analysisType = String(output.analysisType || '').toUpperCase()
|
||
const profileCode = String(output.profileCode || '').toLowerCase()
|
||
const isDecisionAnalysis = analysisType.includes('AUDIO') || analysisType.includes('VIDEO')
|
||
|| profileCode.includes('audio') || profileCode.includes('video')
|
||
|| [3, 4].includes(Number(log?.logType))
|
||
if (!isDecisionAnalysis) return null
|
||
if (['FAILED', 'ERROR'].includes(String(output.analysisStatus || '').toUpperCase())) return false
|
||
if (output.passed === undefined || output.passed === null) return null
|
||
return output.passed === true || String(output.passed).toLowerCase() === 'true'
|
||
}
|
||
|
||
const expandedIterations = ref(new Set())
|
||
const isIterationExpanded = key => expandedIterations.value.has(key)
|
||
const toggleIteration = (key) => {
|
||
const next = new Set(expandedIterations.value)
|
||
next.has(key) ? next.delete(key) : next.add(key)
|
||
expandedIterations.value = next
|
||
}
|
||
|
||
const statusOfIteration = (logs) => {
|
||
const statuses = logs.map(log => String(log?.status ?? log?.logStatus ?? '').toUpperCase())
|
||
const contexts = logs.map(log => String(executionContextOf(log).eventType || '').toUpperCase())
|
||
if (statuses.some(status => ['2', 'FAILED', 'FAILURE', 'ERROR'].includes(status))
|
||
|| contexts.includes('NODE_FAILED')) {
|
||
return { label: '执行失败', type: 'danger' }
|
||
}
|
||
|
||
const latestEventByNode = new Map()
|
||
logs.forEach((log, index) => {
|
||
const eventType = String(executionContextOf(log).eventType || '').toUpperCase()
|
||
if (!eventType) return
|
||
const nodeKey = String(log?.nodeId || log?.nodeName || log?.executionStep || index)
|
||
latestEventByNode.set(nodeKey, eventType)
|
||
})
|
||
if ([...latestEventByNode.values()].some(eventType => eventType === 'NODE_STARTED')) {
|
||
return { label: '执行中', type: 'warning' }
|
||
}
|
||
if (latestEventByNode.size) return { label: '执行完成', type: 'success' }
|
||
if (statuses.some(status => ['RUNNING', '1'].includes(status))) {
|
||
return { label: '执行中', type: 'warning' }
|
||
}
|
||
return { label: '执行完成', type: 'success' }
|
||
}
|
||
|
||
const displayLogs = computed(() => {
|
||
const source = visibleLogs.value
|
||
const groups = new Map()
|
||
source.forEach((log) => {
|
||
const path = iterationPathOf(log)
|
||
if (!path.length) return
|
||
const key = path.join('.')
|
||
if (!groups.has(key)) groups.set(key, [])
|
||
groups.get(key).push(log)
|
||
})
|
||
|
||
const emitted = new Set()
|
||
const result = []
|
||
source.forEach((log, sourceIndex) => {
|
||
const path = iterationPathOf(log)
|
||
if (!path.length) {
|
||
result.push(log)
|
||
return
|
||
}
|
||
const key = path.join('.')
|
||
if (emitted.has(key)) return
|
||
emitted.add(key)
|
||
const logs = groups.get(key) || []
|
||
const status = statusOfIteration(logs)
|
||
const decisions = logs.map(analysisPassedOf).filter(value => value !== null)
|
||
const analysisPassed = decisions.length ? decisions.every(Boolean) : null
|
||
result.push({
|
||
__iterationHeader: true,
|
||
__displayKey: `iteration:${key}:${sourceIndex}`,
|
||
__iterationKey: key,
|
||
__iterationLabel: `第 ${path.join('.')} 次循环`,
|
||
__count: logs.length,
|
||
__statusLabel: status.label,
|
||
__statusType: status.type,
|
||
__analysisLabel: analysisPassed === null ? '' : analysisPassed ? '分析通过' : '分析未通过',
|
||
__analysisType: analysisPassed ? 'success' : 'danger',
|
||
})
|
||
if (isIterationExpanded(key)) result.push(...logs)
|
||
})
|
||
return result
|
||
})
|
||
|
||
const genericOutputOf = (log) => {
|
||
if (analysisOf(log)) return ''
|
||
const output = { ...outputOf(log) }
|
||
delete output._executionContext
|
||
delete output.errorMessage
|
||
if (!Object.keys(output).length) return ''
|
||
return JSON.stringify(output, null, 2)
|
||
}
|
||
|
||
const businessSummaryOf = (log) => {
|
||
if (analysisOf(log) || mediaOf(log).length) return ''
|
||
const output = { ...outputOf(log) }
|
||
delete output._executionContext
|
||
if (typeof output.result === 'string' && output.result.trim()) return output.result.trim()
|
||
if (['number', 'boolean'].includes(typeof output.result)) return `结果:${output.result}`
|
||
if (Array.isArray(output.coordinates) && output.coordinates.length) {
|
||
return `坐标:${output.coordinates.join(', ')}`
|
||
}
|
||
if (Number.isFinite(Number(output.completedSteps))) return `已完成 ${output.completedSteps} 个步骤`
|
||
if (output.success === true) return '操作成功'
|
||
return ''
|
||
}
|
||
|
||
/**
|
||
* 把一个或多个媒体 URL 追加到结果数组。
|
||
* seen 使用“媒体类型:URL”作为去重键,因此同一 URL 以图片和视频两种类型出现时仍会分别保留;
|
||
* 非字符串、空白字符串和已经添加过的条目会被忽略。
|
||
*/
|
||
const addMedia = (result, seen, type, value) => {
|
||
const values = Array.isArray(value) ? value : [value]
|
||
values.forEach((item) => {
|
||
const url = typeof item === 'string' ? item.trim() : ''
|
||
const key = `${type}:${url}`
|
||
if (url && !seen.has(key)) {
|
||
seen.add(key)
|
||
result.push({ type, url })
|
||
}
|
||
})
|
||
}
|
||
|
||
// 分析结果的计算相对复杂,使用 WeakMap 保证模板多次调用 analysisOf(log) 时只解析一次。
|
||
const analysisCache = new WeakMap()
|
||
|
||
const errorCache = new WeakMap()
|
||
const errorOf = (log) => {
|
||
if (!log || typeof log !== 'object') return ''
|
||
if (errorCache.has(log)) return errorCache.get(log)
|
||
const actualResult = parseObject(log.actualResult)
|
||
const extraInfo = parseObject(log.extraInfo)
|
||
const paramsOut = parseObject(log.paramsOut)
|
||
const status = String(log.status ?? log.logStatus ?? '').trim().toLowerCase()
|
||
const failed = ['2', 'failed', 'failure', 'error'].includes(status)
|
||
const candidates = [
|
||
log.errorMessage,
|
||
log.errorStack,
|
||
actualResult.errorMessage,
|
||
actualResult.error,
|
||
extraInfo.errorMessage,
|
||
extraInfo.error,
|
||
paramsOut.errorMessage,
|
||
paramsOut.error,
|
||
failed ? log.message : '',
|
||
]
|
||
const result = candidates.find(value => typeof value === 'string' && value.trim())?.trim() || ''
|
||
errorCache.set(log, result)
|
||
return result
|
||
}
|
||
|
||
const displayErrorOf = (log) => {
|
||
const error = errorOf(log)
|
||
if (!error || isDebugView.value) return error
|
||
return error
|
||
.split(/\r?\n/)[0]
|
||
.replace(/^(?:[\w$.]+(?:Exception|Error):\s*)+/, '')
|
||
.replace(/^执行异常[::]\s*/, '')
|
||
.trim()
|
||
}
|
||
|
||
/**
|
||
* 把日志中的音视频分析结果转换成模板直接使用的展示模型。
|
||
* 返回 null 表示该日志不是可展示的音视频分析结果;否则返回状态、模型、置信度、结论和扩展详情等字段。
|
||
*/
|
||
const analysisOf = (log) => {
|
||
// 防御非对象输入,同时优先读取缓存,避免模板中多个字段重复做 JSON.parse 和格式化工作。
|
||
if (!log || typeof log !== 'object') return null
|
||
if (analysisCache.has(log)) return analysisCache.get(log)
|
||
|
||
// extraInfo 后展开,因此同名字段会覆盖 actualResult;这是为了让后续补充/修正的信息优先显示。
|
||
const output = {
|
||
...parseObject(log.paramsOut),
|
||
...parseObject(log.actualResult),
|
||
...parseObject(log.extraInfo),
|
||
}
|
||
|
||
// 新日志通过 analysisType/profileCode 标识媒体类型,logType=3/4 用于兼容旧版视频/音频日志。
|
||
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
|
||
// 图片分析没有历史 logType 枚举,只依赖新协议中的 analysisType/profileCode 识别。
|
||
const isImage = analysisType.includes('IMAGE') || profileCode.includes('image')
|
||
|
||
// 仅有媒体类型还不足以展示分析面板,至少要存在一个分析状态或业务结果字段。
|
||
const hasResult = Boolean(
|
||
output.analysisStatus
|
||
|| output.analysisType
|
||
|| output.profileCode
|
||
|| output.label
|
||
|| output.labelName
|
||
|| output.summary
|
||
|| output.conclusion
|
||
|| output.resultText
|
||
|| output.result !== undefined
|
||
|| output.passed !== undefined
|
||
)
|
||
|
||
// 非分析日志也缓存 null,避免之后每次渲染都重复执行同一套识别逻辑。
|
||
if (!hasResult || (!isAudio && !isVideo && !isImage)) {
|
||
analysisCache.set(log, null)
|
||
return null
|
||
}
|
||
|
||
const status = String(output.analysisStatus || '').toUpperCase()
|
||
const matched = output.matched
|
||
const passed = output.passed
|
||
const confidenceValue = Number(output.confidence)
|
||
|
||
// 置信度兼容 0~1 和 0~100 两种协议,统一限制到 0~100 并保留一位小数。
|
||
// 非数字值返回 null,模板据此隐藏置信度字段,而不是展示 NaN。
|
||
const confidence = Number.isFinite(confidenceValue)
|
||
? Math.max(0, Math.min(100, confidenceValue <= 1 ? confidenceValue * 100 : confidenceValue)).toFixed(1)
|
||
: null
|
||
|
||
// 常见成功/失败枚举转换为中文;未知状态保留原值,完全缺失时使用中性的“已返回结果”。
|
||
const statusLabel = status === 'SUCCEEDED' || status === 'SUCCESS'
|
||
|| status === 'COMPLETED'
|
||
? '分析完成'
|
||
: status === 'FAILED' || status === 'ERROR'
|
||
? '分析失败'
|
||
: status || '已返回结果'
|
||
|
||
// 复制一份原始输出用于生成扩展详情,后续删除操作不会修改日志本身或合并后的 output。
|
||
const detailSource = { ...output }
|
||
|
||
// _executionContext 只用于前端把日志归属到任务中的检测项和执行次数,属于内部路由元数据。
|
||
// 它与已经结构化展示的分析字段一起从“详情”中移除,避免用户看到重复且难以理解的底层上下文。
|
||
;[
|
||
'analysisStatus', 'analysisType', 'profileCode', 'analysisModel', 'analysisTimingMs',
|
||
'mediaUrl', 'mediaUrls', 'audioUrl', 'videoUrl', 'imageUrl', 'label', 'labelName', 'matched',
|
||
'confidence', 'passed', 'summary', 'conclusion', 'events', '_executionContext',
|
||
'expectedLabel', 'expectedLabelName', 'decisionReason', 'resultText', 'result',
|
||
].forEach((key) => delete detailSource[key])
|
||
|
||
// 所有已知字段删除后还有剩余内容时,才生成详情 JSON,避免显示空对象。
|
||
const hasDetails = Object.keys(detailSource).length > 0
|
||
|
||
// 统一生成展示模型,模板不需要了解各版本日志字段和状态枚举的差异。
|
||
const result = {
|
||
title: isAudio ? '声音类型检测' : isImage ? '通用图片分析' : '视频分析',
|
||
statusLabel,
|
||
|
||
// 分析失败或业务判断未通过使用红色;仅匹配失败使用警告色,其余使用成功色。
|
||
tagType: status === 'FAILED' || status === 'ERROR' || passed === false
|
||
? 'danger'
|
||
: matched === false ? 'warning' : 'success',
|
||
modelName: typeof output.analysisModel === 'string'
|
||
? output.analysisModel
|
||
: output.analysisModel?.name || '',
|
||
timingMs: Number.isFinite(Number(output.analysisTimingMs)) ? Number(output.analysisTimingMs) : null,
|
||
label: output.labelName || output.label || '',
|
||
targetLabel: output.expectedLabelName || output.expectedLabel || '',
|
||
matchLabel: isAudio ? '' : matched === true ? '匹配' : matched === false ? '未匹配' : '',
|
||
confidence,
|
||
passedLabel: passed === true ? '通过' : passed === false ? '未通过' : '',
|
||
// 图片分析由提示词决定输出形态,优先展示模型文本或字符串结果,避免把枚举型原因当摘要。
|
||
summary: output.summary || output.conclusion || output.resultText
|
||
|| (typeof output.result === 'string' ? output.result : '')
|
||
|| (/^[A-Z_]+$/.test(String(output.decisionReason || '')) ? '' : output.decisionReason)
|
||
|| '',
|
||
isAudio,
|
||
events: Array.isArray(output.events)
|
||
? output.events.slice(0, 20).map((event) => typeof event === 'string' ? event : JSON.stringify(event))
|
||
: [],
|
||
details: hasDetails ? JSON.stringify(detailSource, null, 2) : '',
|
||
}
|
||
|
||
// 正常分析结果也写入缓存,后续模板读取状态、模型、摘要等字段时直接复用同一对象。
|
||
analysisCache.set(log, result)
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* 从日志的新旧字段中提取所有可播放/预览的媒体资源。
|
||
* 返回值格式统一为 { type, url },模板无需关心 URL 原来位于 actualResult、extraInfo 还是日志顶层。
|
||
*/
|
||
const mediaOf = (log) => {
|
||
if (!log || typeof log !== 'object') return []
|
||
if (mediaCache.has(log)) return mediaCache.get(log)
|
||
|
||
const result = []
|
||
const seen = new Set()
|
||
|
||
// 与分析结果相同,extraInfo 中的同名字段优先于 actualResult。
|
||
const output = {
|
||
...parseObject(log.paramsOut),
|
||
...parseObject(log.actualResult),
|
||
...parseObject(log.extraInfo),
|
||
}
|
||
|
||
// 优先读取类型明确的新字段,它们不需要额外推断媒体类型。
|
||
addMedia(result, seen, 'image', output.imageUrl)
|
||
addMedia(result, seen, 'video', output.videoUrl)
|
||
addMedia(result, seen, 'audio', output.audioUrl)
|
||
const analysisType = String(output.analysisType || '').toUpperCase()
|
||
const profileCode = String(output.profileCode || '').toLowerCase()
|
||
|
||
// mediaUrl 本身没有类型,必须结合分析类型或 profileCode 后才能作为视频/音频加入。
|
||
if (analysisType.includes('VIDEO') || profileCode.includes('video')) addMedia(result, seen, 'video', output.mediaUrl)
|
||
if (analysisType.includes('AUDIO') || profileCode.includes('audio')) addMedia(result, seen, 'audio', output.mediaUrl)
|
||
if (analysisType.includes('IMAGE') || profileCode.includes('image')) {
|
||
addMedia(result, seen, 'image', output.mediaUrls)
|
||
addMedia(result, seen, 'image', output.mediaUrl)
|
||
}
|
||
addMedia(result, seen, 'image', log.screenshotUrl)
|
||
addMedia(result, seen, 'video', log.videoUrl)
|
||
|
||
// 旧版日志把媒体统一放在顶层 mediaUrl,并通过 logType=2/3/4 区分图片、视频和音频。
|
||
const legacyType = Number(log.logType) === 2 ? 'image'
|
||
: Number(log.logType) === 3 ? 'video'
|
||
: Number(log.logType) === 4 ? 'audio' : ''
|
||
if (legacyType) addMedia(result, seen, legacyType, log.mediaUrl)
|
||
|
||
// 即使没有找到媒体也缓存空数组,避免无媒体日志在每次渲染时重复扫描全部字段。
|
||
mediaCache.set(log, result)
|
||
return result
|
||
}
|
||
|
||
// Element Plus 图片预览需要同一条日志下的纯图片 URL 数组,视频和音频不会进入预览列表。
|
||
const imageUrlsOf = (log) => mediaOf(log)
|
||
.filter((item) => item.type === 'image')
|
||
.map((item) => item.url)
|
||
|
||
/**
|
||
* 根据滚动层的剩余距离更新“是否位于底部”。
|
||
* 用户拖动滚动条、滚轮滚动以及程序修改 scrollTop 都会触发 scroll 事件,因此只需要维护一个状态源。
|
||
*/
|
||
const updateBottomState = () => {
|
||
const container = messageContainer.value
|
||
if (!container) {
|
||
isAtBottom.value = true
|
||
return
|
||
}
|
||
|
||
const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight
|
||
isAtBottom.value = distanceToBottom <= BOTTOM_THRESHOLD
|
||
}
|
||
|
||
// 滚动事件只做轻量尺寸计算,不写入 scrollTop,避免干扰用户正在进行的历史日志浏览。
|
||
const handleScroll = () => updateBottomState()
|
||
|
||
/**
|
||
* 将滚动位置移动到最新日志,并恢复自动跟随状态。
|
||
* nextTick 确保新日志已经渲染,scrollHeight 是更新后的完整内容高度。
|
||
*/
|
||
const scrollToBottom = async () => {
|
||
await nextTick()
|
||
const container = messageContainer.value
|
||
if (!container) return
|
||
container.scrollTop = container.scrollHeight
|
||
isAtBottom.value = true
|
||
}
|
||
|
||
/**
|
||
* 日志变化时先读取更新前的滚动状态:
|
||
* - 原本在底部,说明用户正在追踪最新日志,DOM 更新后继续滚到底部;
|
||
* - 原本不在底部,说明用户正在查看历史内容,只更新按钮状态,不改变其 scrollTop。
|
||
* deep 监听同时覆盖追加日志和现有日志内容更新。
|
||
*/
|
||
watch(() => props.list, () => {
|
||
if (isAtBottom.value) {
|
||
scrollToBottom()
|
||
} else {
|
||
nextTick(updateBottomState)
|
||
}
|
||
}, { deep: true })
|
||
|
||
// 首次进入日志视图仍默认展示最新一条,此时尚不存在用户主动选择的历史位置。
|
||
onMounted(scrollToBottom)
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
/* 外层容器固定高度并裁剪内容,同时作为“跳到最新日志”悬浮按钮的定位上下文。 */
|
||
.task-log {
|
||
position: relative;
|
||
box-sizing: border-box;
|
||
margin-top: 12px;
|
||
overflow: hidden;
|
||
background: #fff;
|
||
border: 1px solid #eee;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
/* 只有内部区域负责滚动,悬浮按钮不会随日志内容一起移出可视区域。 */
|
||
.task-log__scroll {
|
||
box-sizing: border-box;
|
||
height: 100%;
|
||
padding: 12px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
/* 固定在滚动视口右下角,不参与日志排版;稳定尺寸避免按钮出现时挤压或移动内容。 */
|
||
.task-log__jump-latest {
|
||
position: absolute;
|
||
right: 16px;
|
||
bottom: 16px;
|
||
z-index: 2;
|
||
width: 36px;
|
||
height: 36px;
|
||
box-shadow: 0 4px 12px rgb(0 0 0 / 18%);
|
||
}
|
||
|
||
/* 桌面端使用“时间 + 内容”两列布局;内容列 minmax(0, 1fr) 允许长文本正确收缩。 */
|
||
.task-log__item {
|
||
display: grid;
|
||
grid-template-columns: 145px minmax(0, 1fr);
|
||
gap: 12px;
|
||
align-items: start;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.task-log__iteration-header {
|
||
display: flex;
|
||
width: 100%;
|
||
min-height: 38px;
|
||
gap: 8px;
|
||
align-items: center;
|
||
margin: 8px 0;
|
||
padding: 7px 10px;
|
||
border: 1px solid #dcdfe6;
|
||
border-radius: 6px;
|
||
background: #f7f8fa;
|
||
color: #303133;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
}
|
||
|
||
.task-log__iteration-header .el-icon {
|
||
flex: 0 0 auto;
|
||
transition: transform 0.18s ease;
|
||
}
|
||
|
||
.task-log__iteration-header .el-icon.is-expanded {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.task-log__iteration-title {
|
||
min-width: 0;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.task-log__iteration-count {
|
||
margin-left: auto;
|
||
color: #909399;
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.task-log__item.is-iteration-child {
|
||
margin-left: 18px;
|
||
padding-left: 10px;
|
||
border-left: 2px solid #e4e7ed;
|
||
}
|
||
|
||
.task-log__time {
|
||
color: #999;
|
||
font-size: 13px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.task-log__body {
|
||
min-width: 0;
|
||
}
|
||
|
||
.task-log__content {
|
||
color: #333;
|
||
font-size: 14px;
|
||
line-height: 20px;
|
||
}
|
||
|
||
.task-log__error {
|
||
display: grid;
|
||
grid-template-columns: max-content minmax(0, 1fr);
|
||
gap: 8px;
|
||
margin-top: 7px;
|
||
padding: 8px 10px;
|
||
border-left: 3px solid var(--el-color-danger);
|
||
background: var(--el-color-danger-light-9);
|
||
color: var(--el-color-danger-dark-2);
|
||
font-size: 13px;
|
||
line-height: 19px;
|
||
}
|
||
|
||
.task-log__error span {
|
||
min-width: 0;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
/* 分析结果使用左侧强调线与普通日志正文区分,但保持在同一条日志的内容列中。 */
|
||
.task-log__analysis {
|
||
margin-top: 9px;
|
||
padding: 10px 12px;
|
||
border-left: 3px solid #5b8ff9;
|
||
background: #f6f8fc;
|
||
color: #344054;
|
||
}
|
||
|
||
.task-log__analysis-heading {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.task-log__analysis-meta,
|
||
.task-log__analysis-field span,
|
||
.task-log__analysis-label {
|
||
color: #667085;
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* 四列摘要分别承载识别结果、匹配状态、置信度和判断结果。 */
|
||
.task-log__analysis-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 8px;
|
||
margin-top: 9px;
|
||
}
|
||
|
||
.task-log__analysis-field {
|
||
display: flex;
|
||
min-width: 0;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.task-log__analysis-field strong {
|
||
overflow: hidden;
|
||
color: #1f2937;
|
||
font-size: 13px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.task-log__analysis-summary {
|
||
margin-top: 9px;
|
||
color: #344054;
|
||
line-height: 20px;
|
||
}
|
||
|
||
.task-log__analysis-events {
|
||
margin-top: 9px;
|
||
}
|
||
|
||
.task-log__analysis-event {
|
||
margin-top: 3px;
|
||
color: #475467;
|
||
font-size: 12px;
|
||
line-height: 18px;
|
||
}
|
||
|
||
/* 原始扩展详情限制最大高度并允许内部滚动,长 JSON 不会无限撑高单条日志。 */
|
||
.task-log__analysis-details {
|
||
max-height: 180px;
|
||
margin: 9px 0 0;
|
||
overflow: auto;
|
||
padding: 8px;
|
||
background: #fff;
|
||
color: #475467;
|
||
font-size: 11px;
|
||
line-height: 16px;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.task-log__output {
|
||
max-height: 180px;
|
||
margin: 8px 0 0;
|
||
overflow: auto;
|
||
padding: 8px 10px;
|
||
border-left: 3px solid #67c23a;
|
||
background: #f5f7fa;
|
||
color: #475467;
|
||
font-size: 12px;
|
||
line-height: 18px;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.task-log__business-summary {
|
||
margin-top: 8px;
|
||
padding: 8px 10px;
|
||
border-left: 3px solid #67c23a;
|
||
background: #f3faf0;
|
||
color: #3f5f39;
|
||
line-height: 20px;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
/* 媒体资源允许自动换行;每种播放器使用稳定尺寸,资源加载后不会造成明显布局跳动。 */
|
||
.task-log__media {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.task-log__image {
|
||
width: 96px;
|
||
height: 72px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.task-log__video {
|
||
width: min(320px, 100%);
|
||
max-height: 220px;
|
||
background: #111;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.task-log__audio {
|
||
width: min(340px, 100%);
|
||
height: 40px;
|
||
}
|
||
|
||
/* 窄屏改为上下布局,并把分析摘要缩减为两列,保证字段名称和结果仍有可读宽度。 */
|
||
@media (max-width: 640px) {
|
||
.task-log__item {
|
||
grid-template-columns: 1fr;
|
||
gap: 3px;
|
||
}
|
||
|
||
.task-log__analysis-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
</style>
|