CMVR-BID/src/views/flow/components/TaskExecutionLog.vue

357 lines
11 KiB
Vue
Raw Normal View History

<template>
<div ref="messageContainer" class="task-log" :style="{ height: `${height}px` }">
<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>
<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>
<div class="task-log__analysis-grid">
<div v-if="analysisOf(log).label" class="task-log__analysis-field">
<span>识别结果</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>
<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>
<pre v-if="analysisOf(log).details" class="task-log__analysis-details">{{ analysisOf(log).details }}</pre>
</div>
<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>
</template>
<script setup>
import { nextTick, onMounted, ref, watch } from 'vue'
import { formatDate } from '@/utils/index'
const props = defineProps({
list: { type: Array, default: () => [] },
height: { type: Number, default: 150 },
})
const messageContainer = ref(null)
const mediaCache = new WeakMap()
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 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 })
}
})
}
const analysisCache = new WeakMap()
const analysisOf = (log) => {
if (!log || typeof log !== 'object') return null
if (analysisCache.has(log)) return analysisCache.get(log)
const output = {
...parseObject(log.actualResult),
...parseObject(log.extraInfo),
}
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 hasResult = Boolean(
output.analysisStatus
|| output.analysisType
|| output.profileCode
|| output.label
|| output.labelName
|| output.summary
|| output.conclusion
|| output.passed !== undefined
)
if (!hasResult || (!isAudio && !isVideo)) {
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)
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 || '已返回结果'
const detailSource = { ...output }
;[
'analysisStatus', 'analysisType', 'profileCode', 'analysisModel', 'analysisTimingMs',
'mediaUrl', 'audioUrl', 'videoUrl', 'imageUrl', 'label', 'labelName', 'matched',
'confidence', 'passed', 'summary', 'conclusion', 'events',
].forEach((key) => delete detailSource[key])
const hasDetails = Object.keys(detailSource).length > 0
const result = {
title: isAudio ? '声音分析' : '视频分析',
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 || '',
matchLabel: matched === true ? '匹配' : matched === false ? '未匹配' : '',
confidence,
passedLabel: passed === true ? '通过' : passed === false ? '未通过' : '',
summary: output.summary || output.conclusion || '',
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
}
const mediaOf = (log) => {
if (!log || typeof log !== 'object') return []
if (mediaCache.has(log)) return mediaCache.get(log)
const result = []
const seen = new Set()
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()
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)
addMedia(result, seen, 'image', log.screenshotUrl)
addMedia(result, seen, 'video', log.videoUrl)
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
}
const imageUrlsOf = (log) => mediaOf(log)
.filter((item) => item.type === 'image')
.map((item) => item.url)
const scrollToBottom = () => {
nextTick(() => {
if (messageContainer.value) messageContainer.value.scrollTop = messageContainer.value.scrollHeight
})
}
watch(() => props.list, scrollToBottom, { deep: true })
onMounted(scrollToBottom)
</script>
<style scoped lang="scss">
.task-log {
margin-top: 12px;
padding: 12px;
overflow-y: auto;
background: #fff;
border: 1px solid #eee;
border-radius: 8px;
}
.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;
}
.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>