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

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

View File

@ -1,5 +1,7 @@
<template> <template>
<div ref="messageContainer" class="task-log" :style="{ height: `${height}px` }"> <div class="task-log" :style="{ height: `${height}px` }">
<!-- 独立滚动层负责监听用户位置;外层仅提供边框和悬浮按钮的定位上下文。 -->
<div ref="messageContainer" class="task-log__scroll" @scroll.passive="handleScroll">
<!-- 每条日志由固定宽度的时间列和自适应内容列组成;没有后端 ID 时使用数组下标兜底。 --> <!-- 每条日志由固定宽度的时间列和自适应内容列组成;没有后端 ID 时使用数组下标兜底。 -->
<div v-for="(log, index) in list" :key="log.id || index" class="task-log__item"> <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__time">{{ formatDate(log.logTime) }}</div>
@ -79,10 +81,24 @@
</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>
</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
} }
// deep 监听既覆盖追加/删除日志,也覆盖现有日志对象字段更新;组件挂载时同样执行一次初始定位。 const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight
watch(() => props.list, scrollToBottom, { deep: true }) 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) 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;