Skip to content

Commit 0924e53

Browse files
committed
feat(控制台): 增强控制台功能并优化用户体验
添加日志级别自动识别与彩色显示、搜索和筛选功能 实现日志统计计数、自动滚动和跳转按钮 支持单行日志复制和导出全部日志 优化空状态提示和长时间间隔的时间分隔线 更新多语言支持并改进整体 UI/UX
1 parent 3ec02f1 commit 0924e53

File tree

3 files changed

+239
-13
lines changed

3 files changed

+239
-13
lines changed

frontend/public/changelog.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
{
22
"changelog": [
3+
{
4+
"version": "3.1.6",
5+
"date": "2026-03-15",
6+
"changes": [
7+
"优化:控制台页面 UI/UX — 日志自动识别级别并彩色显示(红=错误/黄=警告/绿=成功/灰=普通)、新增搜索框和级别筛选标签页(全部/ERR/WARN/OK)、日志计数统计(总数+错误数+警告数)、自动滚动到底部+跳到底部浮动按钮、点击日志行一键复制、导出日志按钮、清除按钮改图标、空状态增加终端图标和操作提示、长时间间隔日志插入时间分隔线"
8+
]
9+
},
310
{
411
"version": "3.1.5",
512
"date": "2026-03-15",
Lines changed: 230 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,256 @@
11
<script>
2+
import { onMount } from 'svelte';
3+
24
let { logs = $bindable([]), t = {} } = $props();
35
6+
let searchQuery = $state('');
7+
let levelFilter = $state('all'); // 'all' | 'error' | 'warn' | 'success'
8+
let autoScroll = $state(true);
9+
let showScrollBtn = $state(false);
10+
let copiedIdx = $state(-1);
11+
let logContainer;
12+
413
function stripAnsi(value) {
514
if (!value) return '';
615
return value.replace(/\x1B\[[0-9;]*m/g, '');
716
}
817
18+
// Detect log level from message content
19+
function getLogLevel(message) {
20+
if (!message) return 'info';
21+
const msg = stripAnsi(message).toLowerCase();
22+
if (msg.includes('error') || msg.includes('failed') || msg.includes('failure') || msg.includes('fatal') || msg.includes('错误') || msg.includes('失败')) return 'error';
23+
if (msg.includes('warn') || msg.includes('warning') || msg.includes('警告')) return 'warn';
24+
if (msg.includes('success') || msg.includes('completed') || msg.includes('created') || msg.includes('成功') || msg.includes('完成') || msg.includes('apply complete')) return 'success';
25+
return 'info';
26+
}
27+
28+
function getLevelColor(level) {
29+
switch (level) {
30+
case 'error': return 'text-red-400';
31+
case 'warn': return 'text-yellow-400';
32+
case 'success': return 'text-emerald-400';
33+
default: return 'text-gray-300';
34+
}
35+
}
36+
37+
function getLevelDot(level) {
38+
switch (level) {
39+
case 'error': return 'bg-red-500';
40+
case 'warn': return 'bg-yellow-500';
41+
case 'success': return 'bg-emerald-500';
42+
default: return '';
43+
}
44+
}
45+
46+
// Stats
47+
let errorCount = $derived(logs.filter(l => getLogLevel(l.message) === 'error').length);
48+
let warnCount = $derived(logs.filter(l => getLogLevel(l.message) === 'warn').length);
49+
50+
// Filtered logs
51+
let filteredLogs = $derived.by(() => {
52+
let result = logs.map((log, idx) => ({ ...log, _idx: idx }));
53+
if (levelFilter !== 'all') {
54+
result = result.filter(l => getLogLevel(l.message) === levelFilter);
55+
}
56+
if (searchQuery.trim()) {
57+
const q = searchQuery.trim().toLowerCase();
58+
result = result.filter(l => stripAnsi(l.message).toLowerCase().includes(q));
59+
}
60+
return result;
61+
});
62+
63+
// Time gap detection: insert separator if gap > 5 minutes
64+
function shouldShowTimeSep(currentLog, prevLog) {
65+
if (!prevLog || !currentLog) return false;
66+
try {
67+
const parseTime = (t) => {
68+
const parts = t.split(':');
69+
if (parts.length < 3) return 0;
70+
return parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2]);
71+
};
72+
const diff = parseTime(currentLog.time) - parseTime(prevLog.time);
73+
return diff > 300 || diff < -300; // > 5 min or day wrap
74+
} catch { return false; }
75+
}
76+
77+
// Auto-scroll
78+
$effect(() => {
79+
if (logs.length && autoScroll && logContainer) {
80+
requestAnimationFrame(() => {
81+
logContainer.scrollTop = logContainer.scrollHeight;
82+
});
83+
}
84+
});
85+
86+
function handleScroll() {
87+
if (!logContainer) return;
88+
const { scrollTop, scrollHeight, clientHeight } = logContainer;
89+
const atBottom = scrollHeight - scrollTop - clientHeight < 40;
90+
autoScroll = atBottom;
91+
showScrollBtn = !atBottom && logs.length > 0;
92+
}
93+
94+
function scrollToBottom() {
95+
if (logContainer) {
96+
logContainer.scrollTop = logContainer.scrollHeight;
97+
autoScroll = true;
98+
showScrollBtn = false;
99+
}
100+
}
101+
102+
// Copy single log line
103+
function copyLog(log, idx) {
104+
const text = `[${log.time}] ${stripAnsi(log.message)}`;
105+
navigator.clipboard.writeText(text);
106+
copiedIdx = idx;
107+
setTimeout(() => { copiedIdx = -1; }, 1500);
108+
}
109+
110+
// Export all logs
111+
function exportLogs() {
112+
const text = logs.map(l => `[${l.time}] ${stripAnsi(l.message)}`).join('\n');
113+
const blob = new Blob([text], { type: 'text/plain' });
114+
const url = URL.createObjectURL(blob);
115+
const a = document.createElement('a');
116+
a.href = url;
117+
a.download = `redc-console-${new Date().toISOString().slice(0, 10)}.log`;
118+
a.click();
119+
URL.revokeObjectURL(url);
120+
}
121+
9122
export function clearLogs() {
10123
logs = [];
11124
}
12125
</script>
13126

14-
<div class="h-full flex flex-col bg-[#1e1e1e] rounded-xl overflow-hidden">
15-
<div class="flex items-center justify-between px-4 py-2.5 bg-[#252526] border-b border-[#3c3c3c]">
127+
<div class="h-full flex flex-col bg-[#1e1e1e] rounded-xl overflow-hidden relative">
128+
<!-- Header bar -->
129+
<div class="flex items-center justify-between px-4 py-2 bg-[#252526] border-b border-[#3c3c3c]">
16130
<div class="flex items-center gap-2">
17131
<div class="flex gap-1.5">
18132
<span class="w-3 h-3 rounded-full bg-[#ff5f56]"></span>
19133
<span class="w-3 h-3 rounded-full bg-[#ffbd2e]"></span>
20134
<span class="w-3 h-3 rounded-full bg-[#27ca40]"></span>
21135
</div>
22136
<span class="text-[12px] text-gray-500 ml-2">{t.terminal}</span>
137+
<!-- Log counts -->
138+
{#if logs.length > 0}
139+
<span class="text-[10px] text-gray-600 ml-1 tabular-nums">{logs.length}</span>
140+
{#if errorCount > 0}
141+
<span class="text-[10px] text-red-400 tabular-nums">{errorCount} {t.consoleErrors || 'err'}</span>
142+
{/if}
143+
{#if warnCount > 0}
144+
<span class="text-[10px] text-yellow-400 tabular-nums">{warnCount} {t.consoleWarns || 'warn'}</span>
145+
{/if}
146+
{/if}
147+
</div>
148+
<div class="flex items-center gap-2">
149+
<!-- Search -->
150+
<div class="relative">
151+
<input
152+
type="text"
153+
bind:value={searchQuery}
154+
placeholder={t.consoleSearch || '搜索日志...'}
155+
class="h-6 w-36 pl-6 pr-2 text-[11px] bg-[#3c3c3c] text-gray-300 rounded border border-[#4c4c4c] focus:border-gray-500 focus:outline-none placeholder-gray-600"
156+
/>
157+
<svg class="w-3 h-3 text-gray-600 absolute left-2 top-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
158+
</div>
159+
<!-- Level filter pills -->
160+
<div class="flex items-center gap-0.5 bg-[#1e1e1e] rounded p-0.5">
161+
<button class="h-5 px-1.5 text-[10px] rounded transition-colors cursor-pointer {levelFilter === 'all' ? 'bg-[#3c3c3c] text-gray-300' : 'text-gray-600 hover:text-gray-400'}" onclick={() => levelFilter = 'all'}>{t.filterAll || '全部'}</button>
162+
<button class="h-5 px-1.5 text-[10px] rounded transition-colors cursor-pointer {levelFilter === 'error' ? 'bg-[#3c3c3c] text-red-400' : 'text-gray-600 hover:text-red-400'}" onclick={() => levelFilter = levelFilter === 'error' ? 'all' : 'error'}>ERR</button>
163+
<button class="h-5 px-1.5 text-[10px] rounded transition-colors cursor-pointer {levelFilter === 'warn' ? 'bg-[#3c3c3c] text-yellow-400' : 'text-gray-600 hover:text-yellow-400'}" onclick={() => levelFilter = levelFilter === 'warn' ? 'all' : 'warn'}>WARN</button>
164+
<button class="h-5 px-1.5 text-[10px] rounded transition-colors cursor-pointer {levelFilter === 'success' ? 'bg-[#3c3c3c] text-emerald-400' : 'text-gray-600 hover:text-emerald-400'}" onclick={() => levelFilter = levelFilter === 'success' ? 'all' : 'success'}>OK</button>
165+
</div>
166+
<!-- Export -->
167+
<button
168+
class="h-6 w-6 flex items-center justify-center text-gray-600 hover:text-gray-300 transition-colors cursor-pointer rounded hover:bg-[#3c3c3c] disabled:opacity-30"
169+
onclick={exportLogs}
170+
disabled={logs.length === 0}
171+
title={t.consoleExport || '导出日志'}
172+
>
173+
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>
174+
</button>
175+
<!-- Clear -->
176+
<button
177+
class="h-6 w-6 flex items-center justify-center text-gray-600 hover:text-gray-300 transition-colors cursor-pointer rounded hover:bg-[#3c3c3c] disabled:opacity-30"
178+
onclick={clearLogs}
179+
disabled={logs.length === 0}
180+
title={t.clear}
181+
>
182+
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>
183+
</button>
23184
</div>
24-
<button
25-
class="text-[11px] text-gray-500 hover:text-gray-300 transition-colors"
26-
onclick={clearLogs}
27-
>{t.clear}</button>
28185
</div>
29-
<div class="flex-1 p-4 overflow-auto font-mono text-[12px] leading-5">
30-
{#each logs as log}
31-
<div class="flex">
32-
<span class="text-gray-600 select-none">[{log.time}]</span>
33-
<span class="text-gray-300 ml-2">{stripAnsi(log.message)}</span>
186+
187+
<!-- Log area -->
188+
<div
189+
class="flex-1 px-4 py-3 overflow-auto font-mono text-[12px] leading-5 relative"
190+
bind:this={logContainer}
191+
onscroll={handleScroll}
192+
>
193+
{#if filteredLogs.length === 0 && logs.length === 0}
194+
<!-- Empty state -->
195+
<div class="flex flex-col items-center justify-center h-full text-gray-600 gap-3">
196+
<svg class="w-10 h-10 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
197+
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z" />
198+
</svg>
199+
<div class="text-[12px]">$ {t.waitOutput}</div>
200+
<div class="text-[10px] text-gray-700">{t.consoleHint || '部署、启动、停止等操作的日志将在此显示'}</div>
201+
</div>
202+
{:else if filteredLogs.length === 0 && logs.length > 0}
203+
<!-- Filter empty -->
204+
<div class="flex flex-col items-center justify-center h-full text-gray-600 gap-2">
205+
<div class="text-[12px]">{t.consoleNoMatch || '无匹配日志'}</div>
206+
<button class="text-[11px] text-gray-500 hover:text-gray-300 cursor-pointer" onclick={() => { searchQuery = ''; levelFilter = 'all'; }}>{t.consoleClearFilter || '清除筛选'}</button>
34207
</div>
35208
{:else}
36-
<div class="text-gray-600">$ {t.waitOutput}</div>
37-
{/each}
209+
{#each filteredLogs as log, i}
210+
<!-- Time separator -->
211+
{#if i > 0 && shouldShowTimeSep(log, filteredLogs[i - 1])}
212+
<div class="flex items-center gap-2 my-2">
213+
<div class="flex-1 border-t border-[#3c3c3c]"></div>
214+
<span class="text-[9px] text-gray-700 flex-shrink-0">{log.time}</span>
215+
<div class="flex-1 border-t border-[#3c3c3c]"></div>
216+
</div>
217+
{/if}
218+
{@const level = getLogLevel(log.message)}
219+
{@const dotClass = getLevelDot(level)}
220+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
221+
<div
222+
class="flex items-start group hover:bg-[#2a2a2a] rounded px-1 -mx-1 cursor-pointer transition-colors"
223+
onclick={() => copyLog(log, log._idx)}
224+
title={t.consoleCopyLine || '点击复制'}
225+
>
226+
{#if dotClass}
227+
<span class="w-1.5 h-1.5 rounded-full {dotClass} mt-[7px] mr-1.5 flex-shrink-0"></span>
228+
{:else}
229+
<span class="w-1.5 mr-1.5 flex-shrink-0"></span>
230+
{/if}
231+
<span class="text-gray-600 select-none flex-shrink-0">[{log.time}]</span>
232+
<span class="{getLevelColor(level)} ml-2 break-all">{stripAnsi(log.message)}</span>
233+
<!-- Copy indicator -->
234+
<span class="ml-auto pl-2 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-gray-600">
235+
{#if copiedIdx === log._idx}
236+
<svg class="w-3 h-3 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /></svg>
237+
{:else}
238+
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9.75a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" /></svg>
239+
{/if}
240+
</span>
241+
</div>
242+
{/each}
243+
{/if}
38244
</div>
245+
246+
<!-- Scroll to bottom button -->
247+
{#if showScrollBtn}
248+
<button
249+
class="absolute bottom-4 right-6 w-8 h-8 bg-[#3c3c3c] hover:bg-[#4c4c4c] text-gray-400 hover:text-gray-200 rounded-full flex items-center justify-center shadow-lg transition-colors cursor-pointer z-10"
250+
onclick={scrollToBottom}
251+
title={t.consoleScrollBottom || '跳到底部'}
252+
>
253+
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /></svg>
254+
</button>
255+
{/if}
39256
</div>

frontend/src/lib/i18n.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const i18n = {
4242
pending: '等待中', starting: '启动中', stopping: '停止中', removing: '删除中',
4343
processing: '处理中', loadingOutputs: '正在加载输出信息...',
4444
copy: '复制', terminal: 'Terminal', clear: '清空', waitOutput: '等待输出...',
45+
consoleErrors: '错误', consoleWarns: '警告', consoleSearch: '搜索日志...', consoleExport: '导出日志', consoleHint: '部署、启动、停止等操作的日志将在此显示', consoleNoMatch: '无匹配日志', consoleClearFilter: '清除筛选', consoleCopyLine: '点击复制', consoleScrollBottom: '跳到底部',
4546
saveAsTemplate: '保存为模板',
4647
deploymentPreview: '部署预览', validationPassed: '验证通过', validationSuccess: '验证成功',
4748
redcPath: 'RedC 路径', projectPath: '项目路径', logPath: '日志路径',
@@ -435,6 +436,7 @@ export const i18n = {
435436
pending: 'Pending', starting: 'Starting', stopping: 'Stopping', removing: 'Removing',
436437
processing: 'Processing', loadingOutputs: 'Loading outputs...',
437438
copy: 'Copy', terminal: 'Terminal', clear: 'Clear', waitOutput: 'Waiting for output...',
439+
consoleErrors: 'err', consoleWarns: 'warn', consoleSearch: 'Search logs...', consoleExport: 'Export Logs', consoleHint: 'Logs from deploy, start, stop operations will appear here', consoleNoMatch: 'No matching logs', consoleClearFilter: 'Clear filter', consoleCopyLine: 'Click to copy', consoleScrollBottom: 'Scroll to bottom',
438440
copied: 'Copied', copyScript: 'Copy Script', copiedSuccess: 'Copied!',
439441
deploymentPreview: 'Deployment Preview', validationPassed: 'Validation Passed', validationSuccess: 'Validation Passed',
440442
completeConfigToPreview: 'Please complete config to view preview',

0 commit comments

Comments
 (0)