315 lines
7.8 KiB
Vue
315 lines
7.8 KiB
Vue
<script setup>
|
|
import { useCommonStore } from '@/stores/common';
|
|
import axios from 'axios';
|
|
import { EventSourcePolyfill } from 'event-source-polyfill';
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<div class="filter-controls mb-2" v-if="showLevelBtns">
|
|
<v-chip-group v-model="selectedLevels" column multiple>
|
|
<v-chip v-for="level in logLevels" :key="level" :color="getLevelColor(level)" filter variant="flat" size="small"
|
|
:text-color="level === 'DEBUG' || level === 'INFO' ? 'black' : 'white'" class="font-weight-medium">
|
|
{{ level }}
|
|
</v-chip>
|
|
</v-chip-group>
|
|
</div>
|
|
|
|
<div id="term" style="background-color: #1e1e1e; padding: 16px; border-radius: 8px; overflow-y:auto; height: 100%">
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
export default {
|
|
name: 'ConsoleDisplayer',
|
|
data() {
|
|
return {
|
|
autoScroll: true,
|
|
logColorAnsiMap: {
|
|
'\u001b[1;34m': 'color: #39C5BB; font-weight: bold;',
|
|
'\u001b[1;36m': 'color: #00FFFF; font-weight: bold;',
|
|
'\u001b[1;33m': 'color: #FFFF00; font-weight: bold;',
|
|
'\u001b[31m': 'color: #FF0000;',
|
|
'\u001b[1;31m': 'color: #FF0000; font-weight: bold;',
|
|
'\u001b[0m': 'color: inherit; font-weight: normal;',
|
|
'\u001b[32m': 'color: #00FF00;',
|
|
'default': 'color: #FFFFFF;'
|
|
},
|
|
logLevels: ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
|
|
selectedLevels: [0, 1, 2, 3, 4],
|
|
levelColors: {
|
|
'DEBUG': 'grey',
|
|
'INFO': 'blue-lighten-3',
|
|
'WARNING': 'amber',
|
|
'ERROR': 'red',
|
|
'CRITICAL': 'purple'
|
|
},
|
|
localLogCache: [],
|
|
eventSource: null,
|
|
retryTimer: null,
|
|
retryAttempts: 0,
|
|
maxRetryAttempts: 10,
|
|
baseRetryDelay: 1000,
|
|
lastEventId: null,
|
|
}
|
|
},
|
|
computed: {
|
|
commonStore() {
|
|
return useCommonStore();
|
|
},
|
|
},
|
|
props: {
|
|
historyNum: {
|
|
type: String,
|
|
default: "-1"
|
|
},
|
|
showLevelBtns: {
|
|
type: Boolean,
|
|
default: true
|
|
}
|
|
},
|
|
watch: {
|
|
selectedLevels: {
|
|
handler() {
|
|
this.refreshDisplay();
|
|
},
|
|
deep: true
|
|
}
|
|
},
|
|
async mounted() {
|
|
await this.fetchLogHistory();
|
|
this.connectSSE();
|
|
},
|
|
beforeUnmount() {
|
|
if (this.eventSource) {
|
|
this.eventSource.close();
|
|
this.eventSource = null;
|
|
}
|
|
if (this.retryTimer) {
|
|
clearTimeout(this.retryTimer);
|
|
this.retryTimer = null;
|
|
}
|
|
this.retryAttempts = 0;
|
|
},
|
|
methods: {
|
|
connectSSE() {
|
|
if (this.eventSource) {
|
|
this.eventSource.close();
|
|
this.eventSource = null;
|
|
}
|
|
|
|
console.log(`正在连接日志流... (尝试次数: ${this.retryAttempts})`);
|
|
|
|
const token = localStorage.getItem('token');
|
|
|
|
this.eventSource = new EventSourcePolyfill('/api/live-log', {
|
|
headers: {
|
|
'Authorization': token ? `Bearer ${token}` : ''
|
|
},
|
|
heartbeatTimeout: 300000,
|
|
withCredentials: true
|
|
});
|
|
|
|
this.eventSource.onopen = () => {
|
|
console.log('日志流连接成功!');
|
|
this.retryAttempts = 0;
|
|
|
|
if (!this.lastEventId) {
|
|
this.fetchLogHistory();
|
|
}
|
|
};
|
|
|
|
this.eventSource.onmessage = (event) => {
|
|
try {
|
|
if (event.lastEventId) {
|
|
this.lastEventId = event.lastEventId;
|
|
}
|
|
|
|
const payload = JSON.parse(event.data);
|
|
this.processNewLogs([payload]);
|
|
} catch (e) {
|
|
console.error('解析日志失败:', e);
|
|
}
|
|
};
|
|
|
|
this.eventSource.onerror = (err) => {
|
|
|
|
if (err.status === 401) {
|
|
console.error('鉴权失败 (401),可能是 Token 过期了。');
|
|
|
|
} else {
|
|
console.warn('日志流连接错误:', err);
|
|
}
|
|
|
|
if (this.eventSource) {
|
|
this.eventSource.close();
|
|
this.eventSource = null;
|
|
}
|
|
|
|
if (this.retryAttempts >= this.maxRetryAttempts) {
|
|
console.error('❌ 已达到最大重试次数,停止重连。请刷新页面重试。');
|
|
return;
|
|
}
|
|
|
|
const delay = Math.min(
|
|
this.baseRetryDelay * Math.pow(2, this.retryAttempts),
|
|
30000
|
|
);
|
|
|
|
console.log(`⏳ ${delay}ms 后尝试第 ${this.retryAttempts + 1} 次重连...`);
|
|
|
|
if (this.retryTimer) {
|
|
clearTimeout(this.retryTimer);
|
|
this.retryTimer = null;
|
|
}
|
|
|
|
this.retryTimer = setTimeout(async () => {
|
|
this.retryAttempts++;
|
|
|
|
if (!this.lastEventId) {
|
|
await this.fetchLogHistory();
|
|
}
|
|
|
|
this.connectSSE();
|
|
}, delay);
|
|
};
|
|
},
|
|
|
|
processNewLogs(newLogs) {
|
|
if (!newLogs || newLogs.length === 0) return;
|
|
|
|
let hasUpdate = false;
|
|
|
|
newLogs.forEach(log => {
|
|
|
|
const exists = this.localLogCache.some(existing =>
|
|
existing.time === log.time &&
|
|
existing.data === log.data &&
|
|
existing.level === log.level
|
|
);
|
|
|
|
if (!exists) {
|
|
this.localLogCache.push(log);
|
|
hasUpdate = true;
|
|
|
|
if (this.isLevelSelected(log.level)) {
|
|
this.printLog(log.data);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (hasUpdate) {
|
|
this.localLogCache.sort((a, b) => a.time - b.time);
|
|
|
|
const maxSize = this.commonStore.log_cache_max_len || 200;
|
|
if (this.localLogCache.length > maxSize) {
|
|
this.localLogCache.splice(0, this.localLogCache.length - maxSize);
|
|
}
|
|
}
|
|
},
|
|
|
|
async fetchLogHistory() {
|
|
try {
|
|
const res = await axios.get('/api/log-history');
|
|
if (res.data.data.logs && res.data.data.logs.length > 0) {
|
|
this.processNewLogs(res.data.data.logs);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch log history:', err);
|
|
}
|
|
},
|
|
|
|
getLevelColor(level) {
|
|
return this.levelColors[level] || 'grey';
|
|
},
|
|
|
|
isLevelSelected(level) {
|
|
for (let i = 0; i < this.selectedLevels.length; ++i) {
|
|
let level_ = this.logLevels[this.selectedLevels[i]]
|
|
if (level_ === level) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
|
|
refreshDisplay() {
|
|
const termElement = document.getElementById('term');
|
|
if (termElement) {
|
|
termElement.innerHTML = '';
|
|
|
|
if (this.localLogCache && this.localLogCache.length > 0) {
|
|
this.localLogCache.forEach(logItem => {
|
|
if (this.isLevelSelected(logItem.level)) {
|
|
this.printLog(logItem.data);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
},
|
|
|
|
toggleAutoScroll() {
|
|
this.autoScroll = !this.autoScroll;
|
|
},
|
|
|
|
printLog(log) {
|
|
let ele = document.getElementById('term')
|
|
if (!ele) {
|
|
return;
|
|
}
|
|
|
|
let span = document.createElement('pre')
|
|
let style = this.logColorAnsiMap['default']
|
|
for (let key in this.logColorAnsiMap) {
|
|
if (log.startsWith(key)) {
|
|
style = this.logColorAnsiMap[key]
|
|
log = log.replace(key, '').replace('\u001b[0m', '')
|
|
break
|
|
}
|
|
}
|
|
|
|
span.style = style
|
|
span.classList.add('console-log-line', 'fade-in')
|
|
span.innerText = `${log}`;
|
|
ele.appendChild(span)
|
|
if (this.autoScroll) {
|
|
ele.scrollTop = ele.scrollHeight
|
|
}
|
|
}
|
|
},
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.filter-controls {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
margin-left: 20px;
|
|
}
|
|
|
|
:deep(.console-log-line) {
|
|
display: block;
|
|
margin-bottom: 2px;
|
|
font-family: SFMono-Regular, Menlo, Monaco, Consolas, var(--astrbot-font-cjk-mono), monospace;
|
|
font-size: 12px;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
:deep(.fade-in) {
|
|
animation: fadeIn 0.3s;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
}
|
|
|
|
to {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
</style>
|