feat: 优化任务日志自动滚动

This commit is contained in:
zhanghao 2026-08-18 15:49:53 +08:00
parent 36016684a3
commit 1edb97f606

View File

@ -1,88 +1,104 @@
<template> <template>
<div ref="messageContainer" class="task-log" :style="{ height: `${height}px` }"> <div class="task-log" :style="{ height: `${height}px` }">
<!-- 每条日志由固定宽度的时间列和自适应内容列组成;没有后端 ID 时使用数组下标兜底。 --> <!-- 独立滚动层负责监听用户位置;外层仅提供边框和悬浮按钮的定位上下文。 -->
<div v-for="(log, index) in list" :key="log.id || index" class="task-log__item"> <div ref="messageContainer" class="task-log__scroll" @scroll.passive="handleScroll">
<div class="task-log__time">{{ formatDate(log.logTime) }}</div> <!-- 每条日志由固定宽度的时间列和自适应内容列组成;没有后端 ID 时使用数组下标兜底。 -->
<div class="task-log__body"> <div v-for="(log, index) in list" :key="log.id || index" class="task-log__item">
<div class="task-log__content">{{ log.logContent }}</div> <div class="task-log__time">{{ formatDate(log.logTime) }}</div>
<div class="task-log__body">
<div class="task-log__content">{{ log.logContent }}</div>
<!-- 只有 analysisOf 能确认是音频或视频分析日志时,才展示结构化分析结果。 --> <!-- 只有 analysisOf 能确认是音频或视频分析日志时,才展示结构化分析结果。 -->
<div v-if="analysisOf(log)" class="task-log__analysis"> <div v-if="analysisOf(log)" class="task-log__analysis">
<!-- 标题行展示分析类型、执行状态、模型名称和耗时;缺失字段不会留下空占位。 --> <!-- 标题行展示分析类型、执行状态、模型名称和耗时;缺失字段不会留下空占位。 -->
<div class="task-log__analysis-heading"> <div class="task-log__analysis-heading">
<el-tag :type="analysisOf(log).tagType" size="small"> <el-tag :type="analysisOf(log).tagType" size="small">
{{ analysisOf(log).title }} · {{ analysisOf(log).statusLabel }} {{ analysisOf(log).title }} · {{ analysisOf(log).statusLabel }}
</el-tag> </el-tag>
<span v-if="analysisOf(log).modelName" class="task-log__analysis-meta"> <span v-if="analysisOf(log).modelName" class="task-log__analysis-meta">
模型:{{ analysisOf(log).modelName }} 模型:{{ analysisOf(log).modelName }}
</span> </span>
<span v-if="analysisOf(log).timingMs !== null" class="task-log__analysis-meta"> <span v-if="analysisOf(log).timingMs !== null" class="task-log__analysis-meta">
耗时:{{ analysisOf(log).timingMs }}ms 耗时:{{ analysisOf(log).timingMs }}ms
</span> </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> </div>
<!-- 常用结果采用四列摘要,便于快速比较;详细扩展字段放到下方 JSON 区域。 --> <!-- 同一日志可同时携带多种媒体;mediaOf 负责从新旧字段提取并按“类型+URL”去重。 -->
<div class="task-log__analysis-grid"> <div v-if="mediaOf(log).length" class="task-log__media">
<div v-if="analysisOf(log).targetLabel" class="task-log__analysis-field"> <template v-for="media in mediaOf(log)" :key="`${media.type}:${media.url}`">
<span>目标声音</span> <el-image
<strong>{{ analysisOf(log).targetLabel }}</strong> v-if="media.type === 'image'"
</div> class="task-log__image"
<div v-if="analysisOf(log).label" class="task-log__analysis-field"> :src="media.url"
<span>{{ analysisOf(log).isAudio ? '实际声音' : '识别结果' }}</span> :preview-src-list="imageUrlsOf(log)"
<strong>{{ analysisOf(log).label }}</strong> preview-teleported
</div> fit="cover"
<div v-if="analysisOf(log).matchLabel" class="task-log__analysis-field"> />
<span>匹配状态</span> <!-- 视频和音频只预加载元数据,避免日志列表初次渲染时下载完整媒体文件。 -->
<strong>{{ analysisOf(log).matchLabel }}</strong> <video v-else-if="media.type === 'video'" class="task-log__video" :src="media.url" controls preload="metadata" />
</div> <audio v-else class="task-log__audio" :src="media.url" controls preload="metadata" />
<div v-if="analysisOf(log).confidence !== null" class="task-log__analysis-field"> </template>
<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>
<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>
</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> </div>
</template> </template>
<script setup> <script setup>
import { nextTick, onMounted, ref, watch } from 'vue' import { nextTick, onMounted, ref, watch } from 'vue'
import { Bottom } from '@element-plus/icons-vue'
import { formatDate } from '@/utils/index' import { formatDate } from '@/utils/index'
/** /**
@ -97,6 +113,12 @@ const props = defineProps({
// 保存日志滚动容器 DOM,用于首次渲染和日志更新后滚动到最新一条。 // 保存日志滚动容器 DOM,用于首次渲染和日志更新后滚动到最新一条。
const messageContainer = ref(null) const messageContainer = ref(null)
// 使用少量像素容差吸收浏览器对子像素和缩放比例的取整误差,避免视觉上已到底部却仍显示按钮。
const BOTTOM_THRESHOLD = 8
// 初始状态视为位于底部;组件挂载后会根据真实滚动尺寸完成第一次定位。
const isAtBottom = ref(true)
// 以日志对象本身作为弱引用键缓存媒体解析结果;日志对象回收后缓存会自动释放,不产生长期内存占用。 // 以日志对象本身作为弱引用键缓存媒体解析结果;日志对象回收后缓存会自动释放,不产生长期内存占用。
const mediaCache = new WeakMap() const mediaCache = new WeakMap()
@ -287,31 +309,84 @@ const imageUrlsOf = (log) => mediaOf(log)
.map((item) => item.url) .map((item) => item.url)
/** /**
* 将滚动位置移动到容器底部,让用户默认看到最新日志。 * 根据滚动层的剩余距离更新“是否位于底部”。
* nextTick 等待 Vue 完成 DOM 更新,否则新日志节点尚未插入时 scrollHeight 仍是旧值。 * 用户拖动滚动条、滚轮滚动以及程序修改 scrollTop 都会触发 scroll 事件,因此只需要维护一个状态源。
*/ */
const scrollToBottom = () => { const updateBottomState = () => {
nextTick(() => { const container = messageContainer.value
if (messageContainer.value) messageContainer.value.scrollTop = messageContainer.value.scrollHeight if (!container) {
}) isAtBottom.value = true
return
}
const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight
isAtBottom.value = distanceToBottom <= BOTTOM_THRESHOLD
} }
// deep 监听既覆盖追加/删除日志,也覆盖现有日志对象字段更新;组件挂载时同样执行一次初始定位。 // 滚动事件只做轻量尺寸计算,不写入 scrollTop,避免干扰用户正在进行的历史日志浏览。
watch(() => props.list, scrollToBottom, { deep: true }) 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) onMounted(scrollToBottom)
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
/* 外层容器固定高度并独立滚动,避免持续增长的日志改变父页面布局。 */ /* 外层容器固定高度并裁剪内容,同时作为“跳到最新日志”悬浮按钮的定位上下文。 */
.task-log { .task-log {
position: relative;
box-sizing: border-box;
margin-top: 12px; margin-top: 12px;
padding: 12px; overflow: hidden;
overflow-y: auto;
background: #fff; background: #fff;
border: 1px solid #eee; border: 1px solid #eee;
border-radius: 8px; 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) 允许长文本正确收缩。 */ /* 桌面端使用“时间 + 内容”两列布局;内容列 minmax(0, 1fr) 允许长文本正确收缩。 */
.task-log__item { .task-log__item {
display: grid; display: grid;