CMVR-IOT-UI/src/views/flow/components/TaskExecutionLog.vue

542 lines
20 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.

<template>
<div class="task-log" :style="{ height: `${height}px` }">
<!-- 独立滚动层负责监听用户位置;外层仅提供边框和悬浮按钮的定位上下文。 -->
<div ref="messageContainer" class="task-log__scroll" @scroll.passive="handleScroll">
<!-- 每条日志由固定宽度的时间列和自适应内容列组成;没有后端 ID 时使用数组下标兜底。 -->
<div v-for="(log, index) in list" :key="log.id || index" class="task-log__item">
<div class="task-log__time">{{ formatDate(log.logTime) }}</div>
<div class="task-log__body">
<div class="task-log__content">{{ log.logContent }}</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="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="analysisOf(log).details" class="task-log__analysis-details">{{ analysisOf(log).details }}</pre>
</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>
</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 { nextTick, onMounted, ref, watch } from 'vue'
import { 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 },
})
// 保存日志滚动容器 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 {}
}
}
/**
* 把一个或多个媒体 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()
/**
* 把日志中的音视频分析结果转换成模板直接使用的展示模型。
* 返回 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.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.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__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__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__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>