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

890 lines
32 KiB
Vue
Raw Normal View History

<template>
2026-08-18 15:49:53 +08:00
<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>
2026-08-18 15:49:53 +08:00
<div class="task-log__body">
<div class="task-log__content">{{ logContentOf(log) }}</div>
2026-08-18 15:49:53 +08:00
<div v-if="displayErrorOf(log)" class="task-log__error">
<strong>失败原因</strong>
<span>{{ displayErrorOf(log) }}</span>
</div>
2026-08-18 15:49:53 +08:00
<!-- 只有 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">
2026-08-18 15:49:53 +08:00
模型:{{ analysisOf(log).modelName }}
</span>
<span v-if="analysisOf(log).timingMs !== null" class="task-log__analysis-meta">
耗时:{{ analysisOf(log).timingMs }}ms
</span>
</div>
2026-08-18 15:49:53 +08:00
<!-- 常用结果采用四列摘要,便于快速比较;详细扩展字段放到下方 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>
2026-08-18 15:49:53 +08:00
<div v-if="analysisOf(log).summary" class="task-log__analysis-summary">
{{ analysisOf(log).summary }}
</div>
2026-08-18 15:49:53 +08:00
<!-- 关键事件可能来自模型输出数组,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>
2026-08-18 15:49:53 +08:00
<!-- 未被结构化消费的字段以格式化 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) }}
2026-08-18 15:49:53 +08:00
</div>
2026-08-18 15:49:53 +08:00
<!-- 同一日志可同时携带多种媒体;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>
2026-08-18 15:49:53 +08:00
<!-- 用户离开底部后显示悬浮入口;点击后回到最新日志并恢复自动跟随。 -->
<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)
2026-08-18 15:49:53 +08:00
// 使用少量像素容差吸收浏览器对子像素和缩放比例的取整误差,避免视觉上已到底部却仍显示按钮。
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)
/**
2026-08-18 15:49:53 +08:00
* 根据滚动层的剩余距离更新“是否位于底部”。
* 用户拖动滚动条、滚轮滚动以及程序修改 scrollTop 都会触发 scroll 事件,因此只需要维护一个状态源。
*/
2026-08-18 15:49:53 +08:00
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
}
2026-08-18 15:49:53 +08:00
// 滚动事件只做轻量尺寸计算,不写入 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">
2026-08-18 15:49:53 +08:00
/* 外层容器固定高度并裁剪内容,同时作为“跳到最新日志”悬浮按钮的定位上下文。 */
.task-log {
2026-08-18 15:49:53 +08:00
position: relative;
box-sizing: border-box;
margin-top: 12px;
2026-08-18 15:49:53 +08:00
overflow: hidden;
background: #fff;
border: 1px solid #eee;
border-radius: 8px;
}
2026-08-18 15:49:53 +08:00
/* 只有内部区域负责滚动,悬浮按钮不会随日志内容一起移出可视区域。 */
.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>