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:
LIghtJUNction
2026-03-29 16:54:56 +08:00
parent b79e252100
commit 86e6d08aef
2 changed files with 264 additions and 152 deletions

View File

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

View File

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