|
|
@@ -1,19 +1,48 @@
|
|
|
<script setup lang="ts">
|
|
|
-import { ref, watch, nextTick } from 'vue'
|
|
|
+import { ref, watch, nextTick, onUnmounted } from 'vue'
|
|
|
|
|
|
const props = defineProps<{
|
|
|
visible: boolean
|
|
|
serviceName: string
|
|
|
logContent: string
|
|
|
loading: boolean
|
|
|
+ /** 自动刷新间隔(毫秒),由父组件从后端配置加载后传入 */
|
|
|
+ refreshInterval?: number
|
|
|
}>()
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
'update:visible': [value: boolean]
|
|
|
- refresh: []
|
|
|
+ /** silent=true 表示自动刷新触发,父组件不显示 loading 遮罩 */
|
|
|
+ refresh: [silent?: boolean]
|
|
|
}>()
|
|
|
|
|
|
const logPre = ref<HTMLPreElement | null>(null)
|
|
|
+/** 用户当前是否处于底部(决定自动刷新时是否追尾滚动) */
|
|
|
+const isAtBottom = ref(true)
|
|
|
+/** 自动刷新开关,默认开启 */
|
|
|
+const autoRefresh = ref(true)
|
|
|
+
|
|
|
+let refreshTimer: ReturnType<typeof setInterval> | null = null
|
|
|
+
|
|
|
+function stopAutoRefresh() {
|
|
|
+ if (refreshTimer) {
|
|
|
+ clearInterval(refreshTimer)
|
|
|
+ refreshTimer = null
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function startAutoRefresh() {
|
|
|
+ stopAutoRefresh()
|
|
|
+ const interval = props.refreshInterval ?? 1000
|
|
|
+ if (interval < 100) return // 防御过小值导致疯狂请求
|
|
|
+ refreshTimer = setInterval(() => emit('refresh', true), interval)
|
|
|
+}
|
|
|
+
|
|
|
+function handleScroll() {
|
|
|
+ if (!logPre.value) return
|
|
|
+ const { scrollTop, scrollHeight, clientHeight } = logPre.value
|
|
|
+ isAtBottom.value = scrollHeight - scrollTop - clientHeight < 30
|
|
|
+}
|
|
|
|
|
|
function scrollToBottom() {
|
|
|
// 双重保障:nextTick 等 Vue DOM 更新 + setTimeout 等浏览器完成布局
|
|
|
@@ -21,17 +50,42 @@ function scrollToBottom() {
|
|
|
setTimeout(() => {
|
|
|
if (logPre.value) {
|
|
|
logPre.value.scrollTop = logPre.value.scrollHeight
|
|
|
+ isAtBottom.value = true
|
|
|
}
|
|
|
}, 50)
|
|
|
})
|
|
|
}
|
|
|
|
|
|
-// loading 从 true 变 false 时,<pre> 刚挂载,滚到底部
|
|
|
+// 对话框打开:重置滚动状态并追尾
|
|
|
+watch(() => props.visible, (val) => {
|
|
|
+ if (val) {
|
|
|
+ isAtBottom.value = true
|
|
|
+ scrollToBottom()
|
|
|
+ } else {
|
|
|
+ stopAutoRefresh()
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// 首次加载完成(loading: true->false)强制滚到底部
|
|
|
watch(() => props.loading, (newVal, oldVal) => {
|
|
|
if (oldVal && !newVal) scrollToBottom()
|
|
|
})
|
|
|
-// 对话框打开时滚到底部
|
|
|
-watch(() => props.visible, (val) => { if (val) scrollToBottom() })
|
|
|
+
|
|
|
+// 日志内容变化时,仅在用户处于底部时自动追尾
|
|
|
+watch(() => props.logContent, () => {
|
|
|
+ if (isAtBottom.value) scrollToBottom()
|
|
|
+})
|
|
|
+
|
|
|
+// 管理 auto-refresh timer:响应 visible / autoRefresh / refreshInterval 变化
|
|
|
+watch(
|
|
|
+ [() => props.visible, autoRefresh, () => props.refreshInterval],
|
|
|
+ ([visible, auto]) => {
|
|
|
+ stopAutoRefresh()
|
|
|
+ if (visible && auto) startAutoRefresh()
|
|
|
+ },
|
|
|
+)
|
|
|
+
|
|
|
+onUnmounted(() => stopAutoRefresh())
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
|
@@ -40,7 +94,11 @@ watch(() => props.visible, (val) => { if (val) scrollToBottom() })
|
|
|
<div class="dialog-header">
|
|
|
<h2>{{ serviceName }} - 运行日志</h2>
|
|
|
<div class="header-actions">
|
|
|
- <button class="refresh-btn" @click="emit('refresh')">
|
|
|
+ <label class="auto-refresh-toggle" :title="`每 ${((refreshInterval ?? 1000) / 1000).toFixed(1)}s 自动刷新一次`">
|
|
|
+ <input type="checkbox" v-model="autoRefresh" />
|
|
|
+ 自动刷新
|
|
|
+ </label>
|
|
|
+ <button class="refresh-btn" @click="emit('refresh', false)">
|
|
|
<i class="pi pi-refresh"></i> 刷新
|
|
|
</button>
|
|
|
<button class="close-btn" @click="emit('update:visible', false)">
|
|
|
@@ -50,7 +108,7 @@ watch(() => props.visible, (val) => { if (val) scrollToBottom() })
|
|
|
</div>
|
|
|
<div class="dialog-body">
|
|
|
<div v-if="loading" class="loading">加载中...</div>
|
|
|
- <pre v-else ref="logPre" class="log-content">{{ logContent || '暂无日志' }}</pre>
|
|
|
+ <pre v-else ref="logPre" class="log-content" @scroll="handleScroll">{{ logContent || '暂无日志' }}</pre>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -72,7 +130,12 @@ watch(() => props.visible, (val) => { if (val) scrollToBottom() })
|
|
|
padding: 16px 24px; border-bottom: 1px solid #e5e7eb;
|
|
|
}
|
|
|
.dialog-header h2 { font-size: 16px; font-weight: 600; margin: 0; }
|
|
|
-.header-actions { display: flex; gap: 8px; }
|
|
|
+.header-actions { display: flex; gap: 12px; align-items: center; }
|
|
|
+.auto-refresh-toggle {
|
|
|
+ display: inline-flex; align-items: center; gap: 4px;
|
|
|
+ font-size: 13px; color: #6b7280; cursor: pointer; user-select: none;
|
|
|
+}
|
|
|
+.auto-refresh-toggle input { cursor: pointer; }
|
|
|
.refresh-btn {
|
|
|
background: none; border: 1px solid #d1d5db; padding: 4px 12px;
|
|
|
border-radius: 6px; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 4px;
|