Explorar el Código

1. 实现了日志界面自动刷新功能,默认每秒拉取一次最新日志,频率通过后端 application.yml 配置(app.log.refresh-interval);
2. 新增了刷新频率查询接口(GET /api/services/log-refresh-interval),前端启动时动态加载,调整频率无需重新构建前端;
3. 优化了日志滚动行为,用户上滚查看历史时自动刷新不再强行追尾,且自动刷新走静默拉取避免每次刷新闪烁 loading 遮罩。

weisijie hace 2 semanas
padre
commit
5e10907ae3

+ 10 - 0
backend/src/main/java/com/svcman/controller/ServiceController.java

@@ -7,6 +7,7 @@ import com.svcman.service.ProcessManager;
 import com.svcman.service.ServiceConfigService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.*;
 
@@ -27,6 +28,10 @@ public class ServiceController {
     private final CommandParser commandParser;
     private final PortService portService;
 
+    /** 日志自动刷新间隔(毫秒),来自 application.yml: app.log.refresh-interval */
+    @Value("${app.log.refresh-interval:1000}")
+    private long logRefreshInterval;
+
     // ===== CRUD =====
 
     @GetMapping
@@ -137,6 +142,11 @@ public class ServiceController {
         return ResponseEntity.ok(Map.of("encoding", ProcessManager.getSystemDefaultEncoding()));
     }
 
+    @GetMapping("/log-refresh-interval")
+    public ResponseEntity<Map<String, Object>> getLogRefreshInterval() {
+        return ResponseEntity.ok(Map.of("refreshInterval", logRefreshInterval));
+    }
+
     // ===== 异常处理 =====
 
     @ExceptionHandler(IllegalArgumentException.class)

+ 3 - 0
backend/src/main/resources/application.yml

@@ -3,6 +3,9 @@ server:
 
 app:
   data-dir: ${user.dir}/../data
+  log:
+    # 日志自动刷新间隔(毫秒),前端日志界面按此频率拉取最新日志
+    refresh-interval: 1000
 
 spring:
   jackson:

+ 12 - 4
frontend/src/App.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import { ref, computed, onMounted, onUnmounted } from 'vue'
-import { serviceApi } from './api/service'
+import { serviceApi, systemApi } from './api/service'
 import type { ServiceConfig, Category } from './api/types'
 import ServiceCard from './components/ServiceCard.vue'
 import ServiceFormDialog from './components/ServiceFormDialog.vue'
@@ -22,6 +22,8 @@ const logServiceId = ref<string>('')
 const logServiceName = ref<string>('')
 const logContent = ref('')
 const logLoading = ref(false)
+/** 日志自动刷新间隔(毫秒),由后端配置加载 */
+const logRefreshInterval = ref(1000)
 const showPortDialog = ref(false)
 const portServiceId = ref<string>('')
 const portServiceName = ref<string>('')
@@ -171,16 +173,17 @@ const handleFormSave = async (data: ServiceConfig) => {
   }
 }
 
-const refreshLog = async () => {
+const refreshLog = async (silent?: boolean) => {
   if (!logServiceId.value) return
-  logLoading.value = true
+  // 自动刷新触发时静默,避免每次刷新都闪 loading 遮罩
+  if (!silent) logLoading.value = true
   try {
     const res = await serviceApi.getLog(logServiceId.value)
     logContent.value = res.log
   } catch {
     // ignore
   } finally {
-    logLoading.value = false
+    if (!silent) logLoading.value = false
   }
 }
 
@@ -205,6 +208,10 @@ const categoriesForSelect = computed<Category[]>(() => {
 onMounted(() => {
   fetchServices()
   refreshTimer = setInterval(fetchServices, 5000)
+  // 加载日志自动刷新间隔配置
+  systemApi.getLogRefreshInterval()
+    .then((res) => { logRefreshInterval.value = res.refreshInterval })
+    .catch(() => { /* 加载失败沿用默认值 */ })
 })
 
 onUnmounted(() => {
@@ -280,6 +287,7 @@ onUnmounted(() => {
       :service-name="logServiceName"
       :log-content="logContent"
       :loading="logLoading"
+      :refresh-interval="logRefreshInterval"
       @refresh="refreshLog"
     />
 

+ 4 - 0
frontend/src/api/service.ts

@@ -15,6 +15,10 @@ export const systemApi = {
   getDefaultEncoding(): Promise<{ encoding: string }> {
     return api.get('/system-default-encoding').then((r) => r.data)
   },
+
+  getLogRefreshInterval(): Promise<{ refreshInterval: number }> {
+    return api.get('/log-refresh-interval').then((r) => r.data)
+  },
 }
 
 export const serviceApi = {

+ 71 - 8
frontend/src/components/LogDialog.vue

@@ -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;

+ 1 - 0
prompt.md

@@ -102,3 +102,4 @@ skill要包含few-shot示例。
 
 ---
 
+日志界面,修改为自动刷新吧,1次/s,频率做成配置项,写到后端application.yml中,提供接口供前端读取。