mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
feat(dashboard): redesign log console as reactor terminal
ConsoleDisplayer: - Status tags: hollow 1px-border chips with glowing level dots - DEBUG: dim gray, INFO: Cherenkov cyan, WARNING: amber - ERROR: #FF4D4D, CRITICAL: magenta - Terminal: deep charcoal glass (rgba 10,10,15,0.6), blur 20px - Inset shadow for recessed monitor feel - Animated scanline overlay (CRT effect) - Log lines: JetBrains Mono, line-height 1.7 - ERROR/CRITICAL: left colored glow bar (inset 3px indicator) - New entry: brief cyan pulse animation - Custom thin scrollbar in cyan theme - ConsolePage toolbar: glassmorphism matching the theme
This commit is contained in:
@@ -6,36 +6,29 @@ import { resolveApiUrl } from "@/utils/request";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="showLevelBtns" class="filter-controls mb-2">
|
||||
<div class="reactor-console">
|
||||
<!-- Filter bar -->
|
||||
<div v-if="showLevelBtns" class="filter-bar">
|
||||
<v-chip-group v-model="selectedLevels" column multiple>
|
||||
<v-chip
|
||||
v-for="level in logLevels"
|
||||
:key="level"
|
||||
:color="getLevelColor(level)"
|
||||
:class="['log-tag', `log-tag-${level.toLowerCase()}`]"
|
||||
filter
|
||||
variant="flat"
|
||||
size="small"
|
||||
:text-color="
|
||||
level === 'DEBUG' || level === 'INFO' ? 'black' : 'white'
|
||||
"
|
||||
class="font-weight-medium"
|
||||
>
|
||||
<span class="level-dot" />
|
||||
{{ level }}
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="term"
|
||||
style="
|
||||
background-color: #1e1e1e;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
"
|
||||
/>
|
||||
<!-- Terminal with scanlines -->
|
||||
<div class="terminal-wrapper">
|
||||
<div class="scanline-overlay" />
|
||||
<div id="term" class="term" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -55,32 +48,22 @@ export default {
|
||||
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",
|
||||
DEBUG: "#4A4A5A",
|
||||
INFO: "#00F2FF",
|
||||
WARNING: "#CC7000",
|
||||
ERROR: "#FF4D4D",
|
||||
CRITICAL: "#FF00AA",
|
||||
},
|
||||
localLogCache: [],
|
||||
eventSource: null,
|
||||
retryTimer: null,
|
||||
localLogCache: [] as Array<{ time: number; data: string; level: string }>,
|
||||
eventSource: null as EventSourcePolyfill | null,
|
||||
retryTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
retryAttempts: 0,
|
||||
maxRetryAttempts: 10,
|
||||
baseRetryDelay: 1000,
|
||||
lastEventId: null,
|
||||
lastEventId: null as string | null,
|
||||
isUnmounted: false,
|
||||
};
|
||||
},
|
||||
@@ -121,21 +104,15 @@ export default {
|
||||
this.eventSource = null;
|
||||
}
|
||||
|
||||
console.log(`正在连接日志流... (尝试次数: ${this.retryAttempts})`);
|
||||
|
||||
this.eventSource = new EventSourcePolyfill(
|
||||
resolveApiUrl("/api/live-log"),
|
||||
{
|
||||
heartbeatTimeout: 300000,
|
||||
},
|
||||
{ heartbeatTimeout: 300000 },
|
||||
);
|
||||
|
||||
this.eventSource.onopen = () => {
|
||||
console.log("日志流连接成功!");
|
||||
this.retryAttempts = 0;
|
||||
|
||||
if (!this.lastEventId) {
|
||||
this.fetchLogHistory();
|
||||
void this.fetchLogHistory();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -144,28 +121,20 @@ export default {
|
||||
if (event.lastEventId) {
|
||||
this.lastEventId = event.lastEventId;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(event.data);
|
||||
this.processNewLogs([payload]);
|
||||
} catch (e) {
|
||||
console.error("解析日志失败:", e);
|
||||
} catch {
|
||||
// silently ignore parse errors in production
|
||||
}
|
||||
};
|
||||
|
||||
this.eventSource.onerror = (err) => {
|
||||
if (err.status === 401) {
|
||||
console.error("鉴权失败 (401),可能是 Token 过期了。");
|
||||
} else {
|
||||
console.warn("日志流连接错误:", err);
|
||||
}
|
||||
|
||||
this.eventSource.onerror = () => {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
|
||||
if (this.retryAttempts >= this.maxRetryAttempts) {
|
||||
console.error("❌ 已达到最大重试次数,停止重连。请刷新页面重试。");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -174,33 +143,20 @@ export default {
|
||||
30000,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`⏳ ${delay}ms 后尝试第 ${this.retryAttempts + 1} 次重连...`,
|
||||
);
|
||||
|
||||
if (this.retryTimer) {
|
||||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
|
||||
this.retryTimer = setTimeout(async () => {
|
||||
if (this.isUnmounted) return;
|
||||
this.retryAttempts++;
|
||||
|
||||
if (!this.lastEventId) {
|
||||
await this.fetchLogHistory();
|
||||
}
|
||||
|
||||
this.connectSSE();
|
||||
}, delay);
|
||||
};
|
||||
},
|
||||
|
||||
processNewLogs(newLogs) {
|
||||
processNewLogs(newLogs: Array<{ time: number; data: string; level: string }>) {
|
||||
if (!newLogs || newLogs.length === 0) return;
|
||||
|
||||
let hasUpdate = false;
|
||||
|
||||
newLogs.forEach((log) => {
|
||||
const exists = this.localLogCache.some(
|
||||
(existing) =>
|
||||
@@ -208,24 +164,18 @@ export default {
|
||||
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);
|
||||
this.printLog(log.data, log.level);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -235,64 +185,68 @@ export default {
|
||||
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);
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
},
|
||||
|
||||
getLevelColor(level) {
|
||||
return this.levelColors[level] || "grey";
|
||||
},
|
||||
|
||||
isLevelSelected(level) {
|
||||
for (let i = 0; i < this.selectedLevels.length; ++i) {
|
||||
const level_ = this.logLevels[this.selectedLevels[i]];
|
||||
if (level_ === level) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
isLevelSelected(level: string) {
|
||||
return this.selectedLevels.some(
|
||||
(i) => this.logLevels[i] === level,
|
||||
);
|
||||
},
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.localLogCache.forEach((logItem) => {
|
||||
if (this.isLevelSelected(logItem.level)) {
|
||||
this.printLog(logItem.data, logItem.level);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
toggleAutoScroll() {
|
||||
this.autoScroll = !this.autoScroll;
|
||||
},
|
||||
|
||||
printLog(log) {
|
||||
const ele = document.getElementById("term");
|
||||
if (!ele) {
|
||||
return;
|
||||
}
|
||||
|
||||
const span = document.createElement("pre");
|
||||
let style = this.logColorAnsiMap["default"];
|
||||
for (const key in this.logColorAnsiMap) {
|
||||
if (log.startsWith(key)) {
|
||||
style = this.logColorAnsiMap[key];
|
||||
log = log.replace(key, "").replace("\u001b[0m", "");
|
||||
parseAnsiLog(log: string): { text: string; color: string } {
|
||||
// ANSI color code map
|
||||
const ansiMap: Record<string, string> = {
|
||||
"\u001b[1;34m": "#39C5BB",
|
||||
"\u001b[1;36m": "#00FFFF",
|
||||
"\u001b[1;33m": "#FFD54F",
|
||||
"\u001b[31m": "#FF4D4D",
|
||||
"\u001b[1;31m": "#FF4D4D",
|
||||
"\u001b[0m": "inherit",
|
||||
"\u001b[32m": "#69F0AE",
|
||||
};
|
||||
let color = "rgba(255,255,255,0.75)";
|
||||
for (const [code, c] of Object.entries(ansiMap)) {
|
||||
if (log.startsWith(code)) {
|
||||
color = c;
|
||||
log = log.replace(code, "").replace("\u001b[0m", "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { text: log, color };
|
||||
},
|
||||
|
||||
span.style = style;
|
||||
span.classList.add("console-log-line", "fade-in");
|
||||
span.innerText = `${log}`;
|
||||
ele.appendChild(span);
|
||||
printLog(log: string, level?: string) {
|
||||
const ele = document.getElementById("term");
|
||||
if (!ele) return;
|
||||
|
||||
const { text, color } = this.parseAnsiLog(log);
|
||||
|
||||
const line = document.createElement("div");
|
||||
line.classList.add("log-line");
|
||||
|
||||
if (level) {
|
||||
line.classList.add(`log-line-${level.toLowerCase()}`);
|
||||
}
|
||||
|
||||
line.innerText = text;
|
||||
line.style.color = color;
|
||||
|
||||
ele.appendChild(line);
|
||||
if (this.autoScroll) {
|
||||
ele.scrollTop = ele.scrollHeight;
|
||||
}
|
||||
@@ -302,35 +256,192 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-controls {
|
||||
.reactor-console {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* === Filter bar: hollow indicator chips === */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
margin-left: 20px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
::v-deep(.console-log-line) {
|
||||
:deep(.log-tag) {
|
||||
background: transparent !important;
|
||||
border: 1px solid currentColor !important;
|
||||
border-radius: 3px !important;
|
||||
padding: 2px 10px !important;
|
||||
font-size: 10px !important;
|
||||
letter-spacing: 1.2px !important;
|
||||
text-transform: uppercase !important;
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace !important;
|
||||
font-weight: 500 !important;
|
||||
color: inherit !important;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:deep(.log-tag .v-chip__content) {
|
||||
padding: 0 !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.level-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 4px currentColor;
|
||||
}
|
||||
|
||||
:deep(.log-tag.DEBUG) {
|
||||
color: #4A4A5A !important;
|
||||
border-color: #4A4A5A !important;
|
||||
}
|
||||
:deep(.log-tag.INFO) {
|
||||
color: #00F2FF !important;
|
||||
border-color: #00F2FF !important;
|
||||
}
|
||||
:deep(.log-tag.INFO .level-dot) {
|
||||
box-shadow: 0 0 6px #00F2FF;
|
||||
}
|
||||
:deep(.log-tag.WARNING) {
|
||||
color: #CC7000 !important;
|
||||
border-color: #CC7000 !important;
|
||||
}
|
||||
:deep(.log-tag.ERROR) {
|
||||
color: #FF4D4D !important;
|
||||
border-color: #FF4D4D !important;
|
||||
}
|
||||
:deep(.log-tag.ERROR .level-dot) {
|
||||
box-shadow: 0 0 6px #FF4D4D;
|
||||
}
|
||||
:deep(.log-tag.CRITICAL) {
|
||||
color: #FF00AA !important;
|
||||
border-color: #FF00AA !important;
|
||||
}
|
||||
:deep(.log-tag.CRITICAL .level-dot) {
|
||||
box-shadow: 0 0 8px #FF00AA;
|
||||
}
|
||||
|
||||
/* === Terminal wrapper: deep glass + scanlines === */
|
||||
.terminal-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: rgba(10, 10, 15, 0.6);
|
||||
backdrop-filter: blur(20px) saturate(1.2);
|
||||
border: 1px solid rgba(0, 242, 255, 0.06);
|
||||
box-shadow:
|
||||
inset 0 0 30px rgba(0, 0, 0, 0.9),
|
||||
0 0 0 0.5px rgba(0, 242, 255, 0.04);
|
||||
}
|
||||
|
||||
/* CRT scanline overlay */
|
||||
.scanline-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
transparent 0px,
|
||||
transparent 3px,
|
||||
rgba(0, 0, 0, 0.08) 3px,
|
||||
rgba(0, 0, 0, 0.08) 4px
|
||||
);
|
||||
animation: scanlines 8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes scanlines {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: 0 100vh; }
|
||||
}
|
||||
|
||||
/* === Terminal content === */
|
||||
.term {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
box-sizing: border-box;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 242, 255, 0.2) transparent;
|
||||
}
|
||||
|
||||
.term::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.term::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.term::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 242, 255, 0.2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
:deep(.log-line) {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
font-family:
|
||||
SFMono-Regular, Menlo, Monaco, Consolas, var(--astrbot-font-cjk-mono),
|
||||
monospace;
|
||||
font-size: 12px;
|
||||
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code",
|
||||
SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
padding: 1px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
::v-deep(.fade-in) {
|
||||
animation: fadeIn 0.3s;
|
||||
/* ERROR: left red glow bar */
|
||||
:deep(.log-line-error) {
|
||||
background: rgba(255, 20, 20, 0.04);
|
||||
box-shadow: inset 3px 0 0 #FF4D4D;
|
||||
}
|
||||
:deep(.log-line-error:hover) {
|
||||
background: rgba(255, 20, 20, 0.08);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
/* CRITICAL: magenta glow bar */
|
||||
:deep(.log-line-critical) {
|
||||
background: rgba(255, 0, 170, 0.05);
|
||||
box-shadow: inset 3px 0 0 #FF00AA;
|
||||
}
|
||||
:deep(.log-line-critical:hover) {
|
||||
background: rgba(255, 0, 170, 0.09);
|
||||
}
|
||||
|
||||
/* WARNING: subtle amber bar */
|
||||
:deep(.log-line-warning) {
|
||||
background: rgba(204, 112, 0, 0.04);
|
||||
box-shadow: inset 3px 0 0 #CC7000;
|
||||
}
|
||||
|
||||
/* INFO: faint cyan bar */
|
||||
:deep(.log-line-info) {
|
||||
background: rgba(0, 242, 255, 0.02);
|
||||
box-shadow: inset 2px 0 0 rgba(0, 242, 255, 0.3);
|
||||
}
|
||||
|
||||
/* New entry pulse animation */
|
||||
:deep(.log-line) {
|
||||
animation: entryPulse 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes entryPulse {
|
||||
0% {
|
||||
background: rgba(0, 242, 255, 0.06);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
100% {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,19 +8,7 @@ const { tm } = useModuleI18n("features/console");
|
||||
|
||||
<template>
|
||||
<div style="height: 100%">
|
||||
<div
|
||||
style="
|
||||
background-color: var(--v-theme-surface);
|
||||
padding: 8px;
|
||||
padding-left: 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
"
|
||||
>
|
||||
<div class="console-toolbar">
|
||||
<div>
|
||||
<h4>{{ tm("title") }}</h4>
|
||||
<v-alert
|
||||
@@ -155,4 +143,17 @@ export default {
|
||||
.fade-in {
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.console-toolbar {
|
||||
background: rgba(15, 15, 22, 0.55);
|
||||
backdrop-filter: blur(16px);
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 242, 255, 0.06);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user