2026-08-12 14:39:36 +08:00
|
|
|
|
<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>
|
2026-08-17 16:05:42 +08:00
|
|
|
|
<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>
|
2026-08-12 14:39:36 +08:00
|
|
|
|
<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 })
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 16:05:42 +08:00
|
|
|
|
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',
|
2026-08-18 11:19:52 +08:00
|
|
|
|
'confidence', 'passed', 'summary', 'conclusion', 'events', '_executionContext',
|
2026-08-17 16:05:42 +08:00
|
|
|
|
].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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 14:39:36 +08:00
|
|
|
|
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)
|
2026-08-17 16:05:42 +08:00
|
|
|
|
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)
|
2026-08-12 14:39:36 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 16:05:42 +08:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 14:39:36 +08:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-08-17 16:05:42 +08:00
|
|
|
|
|
|
|
|
|
|
.task-log__analysis-grid {
|
|
|
|
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
|
|
}
|
2026-08-12 14:39:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
</style>
|