forked from SmartBench/CMVR-IOT-UI
feat: 优化任务日志自动滚动
This commit is contained in:
parent
36016684a3
commit
1edb97f606
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user