feat: add log search functionality with UI input, filtering logic, and internationalization support

This commit is contained in:
Supra4E8C
2025-12-04 23:42:13 +08:00
parent d235cfde81
commit 0e01ee0456
5 changed files with 181 additions and 34 deletions

11
app.js
View File

@@ -77,7 +77,9 @@ class CLIProxyManager {
this.logsRefreshTimer = null; this.logsRefreshTimer = null;
// 当前展示的日志行 // 当前展示的日志行
this.allLogLines = [];
this.displayedLogLines = []; this.displayedLogLines = [];
this.logSearchQuery = '';
this.maxDisplayLogLines = MAX_LOG_LINES; this.maxDisplayLogLines = MAX_LOG_LINES;
this.logFetchLimit = LOG_FETCH_LIMIT; this.logFetchLimit = LOG_FETCH_LIMIT;
@@ -333,6 +335,7 @@ class CLIProxyManager {
const downloadLogs = document.getElementById('download-logs'); const downloadLogs = document.getElementById('download-logs');
const clearLogs = document.getElementById('clear-logs'); const clearLogs = document.getElementById('clear-logs');
const logsAutoRefreshToggle = document.getElementById('logs-auto-refresh-toggle'); const logsAutoRefreshToggle = document.getElementById('logs-auto-refresh-toggle');
const logsSearchInput = document.getElementById('logs-search-input');
if (refreshLogs) { if (refreshLogs) {
refreshLogs.addEventListener('click', () => this.refreshLogs()); refreshLogs.addEventListener('click', () => this.refreshLogs());
@@ -349,6 +352,14 @@ class CLIProxyManager {
if (logsAutoRefreshToggle) { if (logsAutoRefreshToggle) {
logsAutoRefreshToggle.addEventListener('change', (e) => this.toggleLogsAutoRefresh(e.target.checked)); logsAutoRefreshToggle.addEventListener('change', (e) => this.toggleLogsAutoRefresh(e.target.checked));
} }
if (logsSearchInput) {
const debouncedLogSearch = this.debounce((value) => {
this.updateLogSearchQuery(value);
}, 200);
logsSearchInput.addEventListener('input', (e) => {
debouncedLogSearch(e?.target?.value ?? '');
});
}
// API 密钥管理 // API 密钥管理
const addApiKey = document.getElementById('add-api-key'); const addApiKey = document.getElementById('add-api-key');

View File

@@ -529,6 +529,9 @@ const i18n = {
'logs.auto_refresh': '自动刷新', 'logs.auto_refresh': '自动刷新',
'logs.auto_refresh_enabled': '自动刷新已开启', 'logs.auto_refresh_enabled': '自动刷新已开启',
'logs.auto_refresh_disabled': '自动刷新已关闭', 'logs.auto_refresh_disabled': '自动刷新已关闭',
'logs.search_placeholder': '搜索日志内容或关键字',
'logs.search_empty_title': '未找到匹配的日志',
'logs.search_empty_desc': '尝试更换关键字或清空搜索条件。',
'logs.lines': '行', 'logs.lines': '行',
'logs.removed': '已删除', 'logs.removed': '已删除',
'logs.upgrade_required_title': '需要升级 CLI Proxy API', 'logs.upgrade_required_title': '需要升级 CLI Proxy API',
@@ -1161,6 +1164,9 @@ const i18n = {
'logs.auto_refresh': 'Auto Refresh', 'logs.auto_refresh': 'Auto Refresh',
'logs.auto_refresh_enabled': 'Auto refresh enabled', 'logs.auto_refresh_enabled': 'Auto refresh enabled',
'logs.auto_refresh_disabled': 'Auto refresh disabled', 'logs.auto_refresh_disabled': 'Auto refresh disabled',
'logs.search_placeholder': 'Search logs by content or keyword',
'logs.search_empty_title': 'No matching logs found',
'logs.search_empty_desc': 'Try a different keyword or clear the search filter.',
'logs.lines': 'lines', 'logs.lines': 'lines',
'logs.removed': 'Removed', 'logs.removed': 'Removed',
'logs.upgrade_required_title': 'Please Upgrade CLI Proxy API', 'logs.upgrade_required_title': 'Please Upgrade CLI Proxy API',

View File

@@ -839,27 +839,33 @@
<h2 data-i18n="logs.title">日志查看</h2> <h2 data-i18n="logs.title">日志查看</h2>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header logs-header">
<h3><i class="fas fa-scroll"></i> <span data-i18n="logs.log_content">日志内容</span></h3> <div class="logs-header-main">
<h3><i class="fas fa-scroll"></i> <span data-i18n="logs.log_content">日志内容</span></h3>
<div class="logs-search">
<i class="fas fa-search"></i>
<input type="text" id="logs-search-input" aria-label="搜索日志" data-i18n-placeholder="logs.search_placeholder" placeholder="搜索日志...">
</div>
</div>
<div class="header-actions"> <div class="header-actions">
<div class="toggle-group" style="margin-right: 15px;"> <div class="toggle-group" style="margin-right: 15px;">
<label class="toggle-switch" style="margin-right: 5px;"> <label class="toggle-switch" style="margin-right: 5px;">
<input type="checkbox" id="logs-auto-refresh-toggle"> <input type="checkbox" id="logs-auto-refresh-toggle">
<span class="slider"></span> <span class="slider"></span>
</label> </label>
<span class="toggle-label" data-i18n="logs.auto_refresh" style="font-size: 0.9em;">自动刷新</span> <span class="toggle-label" data-i18n="logs.auto_refresh" style="font-size: 0.9em;">自动刷新</span>
</div> </div>
<button id="refresh-logs" class="btn btn-primary"> <button id="refresh-logs" class="btn btn-primary">
<i class="fas fa-sync-alt"></i> <span data-i18n="logs.refresh_button">刷新日志</span> <i class="fas fa-sync-alt"></i> <span data-i18n="logs.refresh_button">刷新日志</span>
</button> </button>
<button id="select-error-log" class="btn btn-secondary"> <button id="select-error-log" class="btn btn-secondary">
<i class="fas fa-file-circle-exclamation"></i> <span data-i18n="logs.error_log_button">选择错误日志</span> <i class="fas fa-file-circle-exclamation"></i> <span data-i18n="logs.error_log_button">选择错误日志</span>
</button> </button>
<button id="download-logs" class="btn btn-secondary"> <button id="download-logs" class="btn btn-secondary">
<i class="fas fa-download"></i> <span data-i18n="logs.download_button">下载日志</span> <i class="fas fa-download"></i> <span data-i18n="logs.download_button">下载日志</span>
</button> </button>
<button id="clear-logs" class="btn btn-danger"> <button id="clear-logs" class="btn btn-danger">
<i class="fas fa-trash"></i> <span data-i18n="logs.clear_button">清空日志</span> <i class="fas fa-trash"></i> <span data-i18n="logs.clear_button">清空日志</span>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -50,20 +50,19 @@ export const logsModule = {
} else if (!incremental && response.lines.length > 0) { } else if (!incremental && response.lines.length > 0) {
this.renderLogs(response.lines, response['line-count'] || response.lines.length, true); this.renderLogs(response.lines, response['line-count'] || response.lines.length, true);
} else if (!incremental) { } else if (!incremental) {
logsContent.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i><p data-i18n="logs.empty_title">' +
i18n.t('logs.empty_title') + '</p><p data-i18n="logs.empty_desc">' +
i18n.t('logs.empty_desc') + '</p></div>';
this.latestLogTimestamp = null; this.latestLogTimestamp = null;
this.renderLogs([], 0, false);
} }
} else if (!incremental) { } else if (!incremental) {
logsContent.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i><p data-i18n="logs.empty_title">' +
i18n.t('logs.empty_title') + '</p><p data-i18n="logs.empty_desc">' +
i18n.t('logs.empty_desc') + '</p></div>';
this.latestLogTimestamp = null; this.latestLogTimestamp = null;
this.renderLogs([], 0, false);
} }
} catch (error) { } catch (error) {
console.error('加载日志失败:', error); console.error('加载日志失败:', error);
if (!incremental) { if (!incremental) {
this.allLogLines = [];
this.displayedLogLines = [];
this.latestLogTimestamp = null;
const is404 = error.message && (error.message.includes('404') || error.message.includes('Not Found')); const is404 = error.message && (error.message.includes('404') || error.message.includes('Not Found'));
if (is404) { if (is404) {
@@ -82,7 +81,17 @@ export const logsModule = {
const logsContent = document.getElementById('logs-content'); const logsContent = document.getElementById('logs-content');
if (!logsContent) return; if (!logsContent) return;
if (!lines || lines.length === 0) { const sourceLines = Array.isArray(lines) ? lines : [];
const filteredLines = sourceLines.filter(line => !line.includes('/v0/management/'));
let displayedLines = filteredLines;
if (filteredLines.length > this.maxDisplayLogLines) {
const linesToRemove = filteredLines.length - this.maxDisplayLogLines;
displayedLines = filteredLines.slice(linesToRemove);
}
this.allLogLines = displayedLines.slice();
if (displayedLines.length === 0) {
this.displayedLogLines = []; this.displayedLogLines = [];
logsContent.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i><p data-i18n="logs.empty_title">' + logsContent.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i><p data-i18n="logs.empty_title">' +
i18n.t('logs.empty_title') + '</p><p data-i18n="logs.empty_desc">' + i18n.t('logs.empty_title') + '</p><p data-i18n="logs.empty_desc">' +
@@ -90,14 +99,15 @@ export const logsModule = {
return; return;
} }
const filteredLines = lines.filter(line => !line.includes('/v0/management/')); const visibleLines = this.filterLogLinesBySearch(displayedLines);
let displayedLines = filteredLines; this.displayedLogLines = visibleLines.slice();
if (filteredLines.length > this.maxDisplayLogLines) {
const linesToRemove = filteredLines.length - this.maxDisplayLogLines;
displayedLines = filteredLines.slice(linesToRemove);
}
this.displayedLogLines = displayedLines.slice(); if (visibleLines.length === 0) {
logsContent.innerHTML = '<div class="empty-state"><i class="fas fa-search"></i><p data-i18n="logs.search_empty_title">' +
i18n.t('logs.search_empty_title') + '</p><p data-i18n="logs.search_empty_desc">' +
i18n.t('logs.search_empty_desc') + '</p></div>';
return;
}
const displayedLineCount = this.displayedLogLines.length; const displayedLineCount = this.displayedLogLines.length;
logsContent.innerHTML = ` logsContent.innerHTML = `
@@ -107,7 +117,7 @@ export const logsModule = {
<pre class="logs-text">${this.buildLogsHtml(this.displayedLogLines)}</pre> <pre class="logs-text">${this.buildLogsHtml(this.displayedLogLines)}</pre>
`; `;
if (scrollToBottom) { if (scrollToBottom && !this.logSearchQuery) {
const logsTextElement = logsContent.querySelector('.logs-text'); const logsTextElement = logsContent.querySelector('.logs-text');
if (logsTextElement) { if (logsTextElement) {
logsTextElement.scrollTop = logsTextElement.scrollHeight; logsTextElement.scrollTop = logsTextElement.scrollHeight;
@@ -138,9 +148,21 @@ export const logsModule = {
const isAtBottom = logsTextElement.scrollHeight - logsTextElement.scrollTop - logsTextElement.clientHeight < 50; const isAtBottom = logsTextElement.scrollHeight - logsTextElement.scrollTop - logsTextElement.clientHeight < 50;
this.displayedLogLines = this.displayedLogLines.concat(filteredNewLines); const baseLines = Array.isArray(this.allLogLines) && this.allLogLines.length > 0
if (this.displayedLogLines.length > this.maxDisplayLogLines) { ? this.allLogLines
this.displayedLogLines = this.displayedLogLines.slice(this.displayedLogLines.length - this.maxDisplayLogLines); : (Array.isArray(this.displayedLogLines) ? this.displayedLogLines : []);
this.allLogLines = baseLines.concat(filteredNewLines);
if (this.allLogLines.length > this.maxDisplayLogLines) {
this.allLogLines = this.allLogLines.slice(this.allLogLines.length - this.maxDisplayLogLines);
}
const visibleLines = this.filterLogLinesBySearch(this.allLogLines);
this.displayedLogLines = visibleLines.slice();
if (visibleLines.length === 0) {
this.renderLogs(this.allLogLines, this.allLogLines.length, false);
return;
} }
logsTextElement.innerHTML = this.buildLogsHtml(this.displayedLogLines); logsTextElement.innerHTML = this.buildLogsHtml(this.displayedLogLines);
@@ -150,11 +172,44 @@ export const logsModule = {
logsInfoElement.innerHTML = `<span><i class="fas fa-list-ol"></i> ${displayedLines} ${i18n.t('logs.lines')}</span>`; logsInfoElement.innerHTML = `<span><i class="fas fa-list-ol"></i> ${displayedLines} ${i18n.t('logs.lines')}</span>`;
} }
if (isAtBottom) { if (isAtBottom && !this.logSearchQuery) {
logsTextElement.scrollTop = logsTextElement.scrollHeight; logsTextElement.scrollTop = logsTextElement.scrollHeight;
} }
}, },
filterLogLinesBySearch(lines) {
const keyword = (this.logSearchQuery || '').toLowerCase();
if (!keyword) {
return Array.isArray(lines) ? lines.slice() : [];
}
if (!Array.isArray(lines) || lines.length === 0) {
return [];
}
return lines.filter(line => (line || '').toLowerCase().includes(keyword));
},
updateLogSearchQuery(value = '') {
const normalized = (value || '').trim();
if (this.logSearchQuery === normalized) {
return;
}
this.logSearchQuery = normalized;
this.applyLogSearchFilter();
},
applyLogSearchFilter() {
const logsContent = document.getElementById('logs-content');
if (!logsContent) return;
if (logsContent.querySelector('.upgrade-notice') || logsContent.querySelector('.error-state')) {
return;
}
const baseLines = Array.isArray(this.allLogLines) ? this.allLogLines : [];
if (baseLines.length === 0 && logsContent.querySelector('.loading-placeholder')) {
return;
}
this.renderLogs(baseLines, baseLines.length, false);
},
buildLogsHtml(lines) { buildLogsHtml(lines) {
if (!lines || lines.length === 0) { if (!lines || lines.length === 0) {
return ''; return '';

View File

@@ -4331,6 +4331,60 @@ input:checked+.slider:before {
line-height: 1.6; line-height: 1.6;
} }
/* 日志页面头部布局 */
#logs .card-header.logs-header {
flex-wrap: wrap;
gap: 12px;
}
.logs-header-main {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 280px;
}
.logs-header-main h3 {
margin: 0;
}
.logs-search {
display: flex;
align-items: center;
border: 1px solid var(--border-primary);
border-radius: 10px;
padding: 0 12px;
background: var(--bg-secondary);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.03);
min-width: 240px;
max-width: 420px;
flex: 1;
}
[data-theme="dark"] .logs-search {
background: var(--bg-tertiary);
box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.04);
}
.logs-search i {
color: var(--text-tertiary);
margin-right: 8px;
}
.logs-search input {
border: none;
background: transparent;
color: var(--text-primary);
width: 100%;
padding: 10px 0;
font-size: 0.95rem;
}
.logs-search input:focus {
outline: none;
}
/* 日志页面头部操作区域 */ /* 日志页面头部操作区域 */
#logs .card-header .header-actions { #logs .card-header .header-actions {
display: flex; display: flex;
@@ -4362,6 +4416,21 @@ input:checked+.slider:before {
font-size: 12px; font-size: 12px;
} }
#logs .card-header.logs-header {
align-items: flex-start;
}
.logs-header-main {
width: 100%;
flex-direction: column;
align-items: flex-start;
}
.logs-search {
width: 100%;
max-width: none;
}
#logs .card-header .header-actions { #logs .card-header .header-actions {
width: 100%; width: 100%;
justify-content: flex-start; justify-content: flex-start;