Jelajahi Sumber

feat(frontend): 添加日志页面

Signed-off-by: Myon <[email protected]>
Myon 3 tahun lalu
induk
melakukan
8ce8a39f21

+ 2 - 3
frontend/package-lock.json

@@ -12,6 +12,7 @@
         "@vueuse/core": "^4.11.2",
         "axios": "^0.25.0",
         "core-js": "^3.6.5",
+        "events": "^3.3.0",
         "github-markdown-css": "^5.1.0",
         "markdown": "^0.5.0",
         "quasar": "^2.0.0-beta.1",
@@ -6147,7 +6148,6 @@
       "version": "3.3.0",
       "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
       "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
-      "dev": true,
       "engines": {
         "node": ">=0.8.x"
       }
@@ -18142,8 +18142,7 @@
     "events": {
       "version": "3.3.0",
       "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
-      "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
-      "dev": true
+      "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="
     },
     "execa": {
       "version": "5.1.0",

+ 1 - 0
frontend/package.json

@@ -23,6 +23,7 @@
     "@vueuse/core": "^4.11.2",
     "axios": "^0.25.0",
     "core-js": "^3.6.5",
+    "events": "^3.3.0",
     "github-markdown-css": "^5.1.0",
     "markdown": "^0.5.0",
     "quasar": "^2.0.0-beta.1",

+ 1 - 0
frontend/quasar.conf.js

@@ -77,6 +77,7 @@ module.exports = configure((ctx) => ({
       '/api': {
         target: 'http://127.0.0.1:19035',
         changeOrigin: true,
+        ws: true,
         pathRewrite: {
           '^/api': '',
         },

+ 6 - 0
frontend/src/api/LogApi.js

@@ -0,0 +1,6 @@
+import BaseApi from './BaseApi';
+
+class LogApi extends BaseApi {
+  getList = () => this.http('/running-log', {}, 'POST');
+}
+export default new LogApi();

+ 7 - 0
frontend/src/components/FixHeightQPage.vue

@@ -0,0 +1,7 @@
+<template>
+  <q-page class="no-wrap" :style-fn="(offset) => ({
+        height: offset ? `calc(100vh - ${offset}px)` : '100vh',
+      })">
+    <slot></slot>
+  </q-page>
+</template>

+ 117 - 0
frontend/src/composables/useWebSocketApi.js

@@ -0,0 +1,117 @@
+import EventEmitter from 'events';
+import { LocalStorage } from 'quasar';
+import { SystemMessage } from 'src/utils/Message';
+import { onBeforeUnmount, onMounted } from 'vue';
+
+class WSManager extends EventEmitter {
+  ws = null;
+
+  url = null;
+
+  autoConnect = true;
+
+  connected = false;
+
+  constructor(url) {
+    super();
+    this.url = url;
+  }
+
+  send(type, data) {
+    this.ws.send(
+      JSON.stringify({
+        type,
+        data: JSON.stringify(data),
+      })
+    );
+  }
+
+  connect() {
+    const ws = new WebSocket(this.url);
+    ws.onopen = () => {
+      // 连接成功后自动发送验证信息
+      this.send('auth', {
+        token: LocalStorage.getItem('token'),
+      });
+    };
+
+    ws.onmessage = (e) => {
+      try {
+        const { type, data } = JSON.parse(e.data);
+        // console.log(type, data);
+        this.emit(type, JSON.parse(data));
+      } catch (error) {
+        // eslint-disable-next-line no-console
+        console.error(error);
+      }
+    };
+
+    ws.onclose = (e) => {
+      this.connected = false;
+      if (this.autoConnect) {
+        // eslint-disable-next-line no-console
+        console.log('Socket is closed. Reconnect will be attempted in 2 second.', e.reason);
+        setTimeout(() => {
+          this.connect();
+        }, 2000);
+      }
+    };
+
+    ws.onerror = (err) => {
+      // eslint-disable-next-line no-console
+      console.error('Socket encountered error: ', err.message, 'Closing socket');
+      ws.close();
+    };
+
+    this.ws = ws;
+
+    this.connected = true;
+  }
+
+  // 强制关闭连接
+  close() {
+    this.autoConnect = false;
+    this.ws?.close();
+  }
+}
+
+// 根据BACKEND_URL配置计算ws地址
+export const getWsBaseUrl = () => {
+  let result = '';
+  const backendUrl = process.env.BACKEND_URL;
+  if (!backendUrl) {
+    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+    const { host } = window.location;
+    result = `${protocol}//${host}`;
+  } else if (backendUrl.startsWith('http')) {
+    const protocol = backendUrl.startsWith('https') ? 'wss:' : 'ws:';
+    result = `${protocol}//${backendUrl.split('//')[1]}`;
+  } else if (backendUrl.startsWith('/')) {
+    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+    result = `${protocol}//${window.location.host}${backendUrl}`;
+  }
+
+  return 'ws://localhost:8080' || result;
+};
+
+export const wsManager = new WSManager(`${getWsBaseUrl()}/ws`);
+
+// 处理认证信息
+wsManager.on('common_reply', (data) => {
+  if (data.message === 'auth error') {
+    SystemMessage.error('Websocket验证失败,请重新登录');
+  }
+});
+
+export const useWebSocketApi = (eventType, eventHandler) => {
+  if (wsManager.connected === false) {
+    wsManager.connect();
+  }
+  onMounted(() => {
+    wsManager.on(eventType, eventHandler);
+  });
+
+  onBeforeUnmount(() => {
+    wsManager.off(eventType, eventHandler);
+  });
+};

+ 118 - 0
frontend/src/pages/logs/index.vue

@@ -0,0 +1,118 @@
+<template>
+  <fix-height-q-page class="flex row q-pa-md">
+    <q-card class="row col" flat bordered>
+      <div class="full-height q-pa-md">
+        <q-list style="width: 220px">
+          <q-item clickable @click="logType = 'rt'" :active="logType === 'rt'" active-class="text-primary text-bold">
+            <q-item-section>实时日志</q-item-section>
+
+            <q-item-section side>
+              <q-btn
+                flat
+                round
+                icon="file_download"
+                size="sm"
+                @click.stop="downloadLog(rtLogLines)"
+                title="下载日志"
+              ></q-btn>
+            </q-item-section>
+          </q-item>
+
+          <q-separator class="q-my-xs" />
+
+          <q-item-label header>历史日志</q-item-label>
+          <q-item
+            v-for="item in logList"
+            :key="item.index"
+            :active="logType === 'history' && item.index === currentIndex"
+            active-class="text-primary text-bold"
+            @click="handleHistoryItemClick(item)"
+            clickable
+          >
+            <q-item-section>{{ item.log_lines[0]?.date_time }}</q-item-section>
+            <q-item-section side>
+              <q-btn
+                flat
+                round
+                icon="file_download"
+                size="sm"
+                @click.stop="downloadLog(item.log_lines)"
+                title="下载日志"
+              ></q-btn>
+            </q-item-section>
+          </q-item>
+        </q-list>
+      </div>
+      <q-separator vertical />
+      <div class="full-height col bg-grey-2 overflow-auto" :key="logType + currentItem?.log_lines[0]?.date_time">
+        <q-virtual-scroll
+          v-model.number="virtualListIndex"
+          ref="logArea"
+          class="full-height q-pa-sm"
+          :items="currentLogLines"
+          :items-size="1000"
+        >
+          <template v-slot="{ item, index }">
+            <div :key="index" style="white-space: nowrap; line-height: 2">
+              {{ getTexLogLine(item) }}
+            </div>
+          </template>
+        </q-virtual-scroll>
+      </div>
+    </q-card>
+  </fix-height-q-page>
+</template>
+
+<script setup>
+import { useLogList } from 'pages/logs/useLogList';
+import FixHeightQPage from 'components/FixHeightQPage';
+import { saveText } from 'src/utils/FileDownload';
+import { useRealTimeLog } from 'pages/logs/useRealTimeLog';
+import { computed, nextTick, ref, watch } from 'vue';
+import { templateRef } from '@vueuse/core';
+
+const { logList, currentIndex, currentItem } = useLogList();
+const logType = ref('rt'); // rt or history
+
+const { logLines: rtLogLines } = useRealTimeLog();
+
+const handleHistoryItemClick = (item) => {
+  currentIndex.value = item.index;
+  logType.value = 'history';
+};
+
+// eslint-disable-next-line camelcase
+const getTexLogLine = ({ level, date_time, content }) => `[${level}]: ${date_time} - ${content}`;
+
+const getTextLogLines = (logLines = []) => logLines.map(getTexLogLine);
+
+const getTextLogContent = (logLines = []) => getTextLogLines(logLines).join('\n');
+
+const currentLogLines = computed(() => {
+  const lines = logType.value === 'rt' ? rtLogLines.value : currentItem.value?.log_lines;
+  return lines || [];
+});
+
+const logArea = templateRef('logArea');
+
+// 自动滚动到底部
+watch(
+  () => rtLogLines.value.length,
+  () => {
+    if (logType.value !== 'rt') return;
+    const element = logArea.value.$el;
+    // console.log(element.scrollTop, element.clientHeight, element.scrollHeight);
+    // 如果当前正处于底部,则自动滚动
+    if (element.scrollTop + element.clientHeight >= element.scrollHeight - 10) {
+      nextTick(() => {
+        logArea.value.scrollTo(rtLogLines.value.length - 1);
+      });
+    }
+  }
+);
+
+const downloadLog = (logLines) => {
+  const filename = `${logLines[0]?.date_time || 'output'}.log`;
+  saveText(filename, getTextLogContent(logLines));
+};
+</script>

+ 28 - 0
frontend/src/pages/logs/useLogList.js

@@ -0,0 +1,28 @@
+import LogApi from 'src/api/LogApi';
+import { SystemMessage } from 'src/utils/Message';
+import { computed, onMounted, ref } from 'vue';
+
+export const useLogList = () => {
+  const logList = ref([]);
+  const currentIndex = ref(null);
+  const currentItem = computed(() => logList.value.find((item) => item.index === currentIndex.value));
+
+  const getData = async () => {
+    const [res, err] = await LogApi.getList();
+    if (err !== null) {
+      SystemMessage.error(err.message);
+      return;
+    }
+    logList.value = res.recent_logs;
+  };
+
+  onMounted(() => {
+    getData();
+  });
+
+  return {
+    logList,
+    currentIndex,
+    currentItem,
+  };
+};

+ 18 - 0
frontend/src/pages/logs/useRealTimeLog.js

@@ -0,0 +1,18 @@
+import { ref } from 'vue';
+import { useWebSocketApi, wsManager } from 'src/composables/useWebSocketApi';
+
+export const useRealTimeLog = () => {
+  const logLines = ref([]);
+
+  useWebSocketApi('running_log', (data) => {
+    logLines.value.push(...(data.log_lines ?? []));
+    wsManager.send({
+      type: 'common_reply',
+      message: 'running log recv ok',
+    });
+  });
+
+  return {
+    logLines,
+  };
+};

+ 2 - 10
frontend/src/pages/settings/exportSettingBtnDialog.vue

@@ -43,6 +43,7 @@ import { settingsState } from 'pages/settings/useSettings';
 import { copyToClipboard } from 'quasar';
 import { SystemMessage } from 'src/utils/Message';
 import {deepCopy} from 'src/utils/CommonUtils';
+import {saveText} from 'src/utils/FileDownload';
 
 const visible = ref(false);
 const hideSensitive = ref(false);
@@ -59,16 +60,7 @@ const settingsString = computed(() => {
 });
 
 const exportSettings = () => {
-  const element = document.createElement('a');
-  element.setAttribute('href', `data:text/plain;charset=utf-8,${encodeURIComponent(settingsString.value)}`);
-  element.setAttribute('download', 'ChineseSubFinderSettings.json');
-
-  element.style.display = 'none';
-  document.body.appendChild(element);
-
-  element.click();
-
-  document.body.removeChild(element);
+  saveText(settingsString.value, 'ChineseSubFinderSettings.json');
 };
 
 const copy = (str) =>

+ 6 - 0
frontend/src/router/routes.js

@@ -30,6 +30,12 @@ const routes = [
         component: () => import('pages/settings/index.vue'),
         meta: { title: '配置中心', icon: 'settings' },
       },
+      {
+        name: 'logs',
+        path: 'logs',
+        component: () => import('pages/logs/index.vue'),
+        meta: { title: '日志', icon: 'receipt_long' },
+      },
     ],
   },
 

+ 20 - 0
frontend/src/utils/FileDownload.js

@@ -0,0 +1,20 @@
+export const saveAs = (filename, blob) => {
+  const element = document.createElement('a');
+  element.href = URL.createObjectURL(blob);
+  element.download = filename;
+
+  element.style.display = 'none';
+  document.body.appendChild(element);
+
+  element.click();
+
+  document.body.removeChild(element);
+};
+
+export const saveText = (filename, content) => {
+  const blob = new Blob([content], {
+    type: 'text/plain;charset=utf-8',
+  });
+
+  saveAs(filename, blob);
+};