// Filename: frontend/js/pages/logs/index.js import { apiFetchJson } from '../../services/api.js'; import LogList from './logList.js'; import CustomSelectV2 from '../../components/customSelectV2.js'; import { debounce } from '../../utils/utils.js'; import FilterPopover from '../../components/filterPopover.js'; import { STATIC_ERROR_MAP, STATUS_CODE_MAP } from './logList.js'; import SystemLogTerminal from './systemLog.js'; const dataStore = { groups: new Map(), keys: new Map(), }; class LogsPage { constructor() { this.state = { logs: [], pagination: { page: 1, pages: 1, total: 0, page_size: 20 }, isLoading: true, filters: { page: 1, page_size: 20, q: '', key_ids: new Set(), group_ids: new Set(), error_types: new Set(), status_codes: new Set(), }, selectedLogIds: new Set(), currentView: 'error', }; this.elements = { tabsContainer: document.querySelector('[data-sliding-tabs-container]'), contentContainer: document.getElementById('log-content-container'), errorFilters: document.getElementById('error-logs-filters'), systemControls: document.getElementById('system-logs-controls'), errorTemplate: document.getElementById('error-logs-template'), systemTemplate: document.getElementById('system-logs-template'), }; this.initialized = !!this.elements.contentContainer; if (this.initialized) { this.logList = null; this.systemLogTerminal = null; this.debouncedLoadAndRender = debounce(() => this.loadAndRenderLogs(), 300); } } async init() { if (!this.initialized) return; this._initPermanentEventListeners(); await this.loadGroupsOnce(); this.state.currentView = null; this.switchToView('error'); } _initPermanentEventListeners() { this.elements.tabsContainer.addEventListener('click', (event) => { const tabItem = event.target.closest('[data-tab-target]'); if (!tabItem) return; event.preventDefault(); const viewName = tabItem.dataset.tabTarget; if (viewName) { this.switchToView(viewName); } }); } switchToView(viewName) { if (this.state.currentView === viewName && this.elements.contentContainer.innerHTML !== '') return; if (this.systemLogTerminal) { this.systemLogTerminal.disconnect(); this.systemLogTerminal = null; } this.state.currentView = viewName; this.elements.contentContainer.innerHTML = ''; if (viewName === 'error') { this.elements.errorFilters.classList.remove('hidden'); this.elements.systemControls.classList.add('hidden'); const template = this.elements.errorTemplate.content.cloneNode(true); this.elements.contentContainer.appendChild(template); requestAnimationFrame(() => { this._initErrorLogView(); }); } else if (viewName === 'system') { this.elements.errorFilters.classList.add('hidden'); this.elements.systemControls.classList.remove('hidden'); const template = this.elements.systemTemplate.content.cloneNode(true); this.elements.contentContainer.appendChild(template); requestAnimationFrame(() => { this._initSystemLogView(); }); } } _initErrorLogView() { this.elements.tableBody = document.getElementById('logs-table-body'); this.elements.selectedCount = document.querySelector('.flex-1.text-sm span.font-semibold:nth-child(1)'); this.elements.totalCount = document.querySelector('.flex-1.text-sm span:last-child'); this.elements.pageSizeSelect = document.querySelector('[data-component="custom-select-v2"] select'); this.elements.pageInfo = document.querySelector('.flex.w-\\[100px\\]'); this.elements.paginationBtns = document.querySelectorAll('[data-pagination-controls] button'); this.elements.selectAllCheckbox = document.querySelector('thead .table-head-cell input[type="checkbox"]'); this.elements.searchInput = document.getElementById('log-search-input'); this.elements.errorTypeFilterBtn = document.getElementById('filter-error-type-btn'); this.elements.errorCodeFilterBtn = document.getElementById('filter-error-code-btn'); this.logList = new LogList(this.elements.tableBody, dataStore); const selectContainer = document.querySelector('[data-component="custom-select-v2"]'); if (selectContainer) { new CustomSelectV2(selectContainer); } this.initFilterPopovers(); this.initEventListeners(); this.loadAndRenderLogs(); } _initSystemLogView() { this.systemLogTerminal = new SystemLogTerminal( this.elements.contentContainer, this.elements.systemControls ); Swal.fire({ title: '实时系统日志', text: '您即将连接到实时日志流。这会与服务器建立一个持续的连接。', icon: 'info', confirmButtonText: '我明白了,开始连接', showCancelButton: true, cancelButtonText: '取消', target: '#main-content-wrapper', }).then((result) => { if (result.isConfirmed) { this.systemLogTerminal.connect(); } else { const errorLogTab = Array.from(this.elements.tabsContainer.querySelectorAll('[data-tab-target="error"]'))[0]; if (errorLogTab) errorLogTab.click(); } }); } initFilterPopovers() { const errorTypeOptions = [ ...Object.values(STATUS_CODE_MAP).map(v => ({ value: v.type, label: v.type })), ...Object.values(STATIC_ERROR_MAP).map(v => ({ value: v.type, label: v.type })) ]; const uniqueErrorTypeOptions = Array.from(new Map(errorTypeOptions.map(item => [item.value, item])).values()); if (this.elements.errorTypeFilterBtn) { new FilterPopover(this.elements.errorTypeFilterBtn, uniqueErrorTypeOptions, '筛选错误类型'); } const statusCodeOptions = Object.keys(STATUS_CODE_MAP).map(code => ({ value: code, label: code })); if (this.elements.errorCodeFilterBtn) { new FilterPopover(this.elements.errorCodeFilterBtn, statusCodeOptions, '筛选状态码'); } } initEventListeners() { if (this.elements.pageSizeSelect) { this.elements.pageSizeSelect.addEventListener('change', (e) => this.changePageSize(parseInt(e.target.value, 10))); } if (this.elements.paginationBtns.length >= 4) { this.elements.paginationBtns[0].addEventListener('click', () => this.goToPage(1)); this.elements.paginationBtns[1].addEventListener('click', () => this.goToPage(this.state.pagination.page - 1)); this.elements.paginationBtns[2].addEventListener('click', () => this.goToPage(this.state.pagination.page + 1)); this.elements.paginationBtns[3].addEventListener('click', () => this.goToPage(this.state.pagination.pages)); } if (this.elements.selectAllCheckbox) { this.elements.selectAllCheckbox.addEventListener('change', (event) => this.handleSelectAllChange(event)); } if (this.elements.tableBody) { this.elements.tableBody.addEventListener('change', (event) => { if (event.target.type === 'checkbox') this.handleSelectionChange(event.target); }); } if (this.elements.searchInput) { this.elements.searchInput.addEventListener('input', (event) => this.handleSearchInput(event)); } if (this.elements.errorTypeFilterBtn) { this.elements.errorTypeFilterBtn.addEventListener('filter-change', (e) => this.handleFilterChange(e)); } if (this.elements.errorCodeFilterBtn) { this.elements.errorCodeFilterBtn.addEventListener('filter-change', (e) => this.handleFilterChange(e)); } } handleFilterChange(event) { const { filterKey, selected } = event.detail; if (filterKey === 'filter-error-type-btn') { this.state.filters.error_types = selected; } else if (filterKey === 'filter-error-code-btn') { this.state.filters.status_codes = selected; } this.state.filters.page = 1; this.loadAndRenderLogs(); } handleSearchInput(event) { const searchTerm = event.target.value.trim().toLowerCase(); // 重置分页和与本次搜索相关的筛选条件 this.state.filters.page = 1; this.state.filters.q = ''; this.state.filters.key_ids = new Set(); this.state.filters.group_ids = new Set(); if (searchTerm === '') { this.debouncedLoadAndRender(); return; } const matchedGroupIds = new Set(); dataStore.groups.forEach(group => { if (group.display_name.toLowerCase().includes(searchTerm)) { matchedGroupIds.add(group.id); } }); const matchedKeyIds = new Set(); dataStore.keys.forEach(key => { if (key.APIKey && key.APIKey.toLowerCase().includes(searchTerm)) { matchedKeyIds.add(key.ID); } }); if (matchedGroupIds.size > 0) this.state.filters.group_ids = matchedGroupIds; if (matchedKeyIds.size > 0) this.state.filters.key_ids = matchedKeyIds; // 如果没有找到任何匹配的ID,则回退到原始的全局模糊搜索 if (matchedGroupIds.size === 0 && matchedKeyIds.size === 0) { this.state.filters.q = searchTerm; } this.debouncedLoadAndRender(); } handleSelectionChange(checkbox) { const row = checkbox.closest('.table-row'); if (!row) return; const logId = parseInt(row.dataset.logId, 10); if (isNaN(logId)) return; if (checkbox.checked) { this.state.selectedLogIds.add(logId); } else { this.state.selectedLogIds.delete(logId); } this.syncSelectionUI(); } handleSelectAllChange(event) { const isChecked = event.target.checked; this.state.logs.forEach(log => { if (isChecked) { this.state.selectedLogIds.add(log.ID); } else { this.state.selectedLogIds.delete(log.ID); } }); this.syncRowCheckboxes(); this.syncSelectionUI(); } syncRowCheckboxes() { const isAllChecked = this.elements.selectAllCheckbox.checked; this.elements.tableBody.querySelectorAll('input[type="checkbox"]').forEach(cb => { cb.checked = isAllChecked; }); } syncSelectionUI() { if (!this.elements.selectAllCheckbox || !this.elements.selectedCount) return; const selectedCount = this.state.selectedLogIds.size; const visibleLogsCount = this.state.logs.length; // 1. 更新表头“全选”复选框的状态 if (selectedCount === 0) { this.elements.selectAllCheckbox.checked = false; this.elements.selectAllCheckbox.indeterminate = false; } else if (selectedCount < visibleLogsCount) { this.elements.selectAllCheckbox.checked = false; this.elements.selectAllCheckbox.indeterminate = true; // 半选状态 } else if (selectedCount === visibleLogsCount && visibleLogsCount > 0) { this.elements.selectAllCheckbox.checked = true; this.elements.selectAllCheckbox.indeterminate = false; } // 2. 更新“已选择”计数 this.elements.selectedCount.textContent = selectedCount; // 3. (未来扩展) 更新批量操作按钮的状态 // const batchButton = document.querySelector('.batch-action-button'); // if (batchButton) batchButton.disabled = selectedCount === 0; } changePageSize(newSize) { this.state.filters.page_size = newSize; this.state.filters.page = 1; this.loadAndRenderLogs(); } goToPage(page) { if (page < 1 || page > this.state.pagination.pages || this.state.isLoading) return; this.state.filters.page = page; this.loadAndRenderLogs(); } updatePaginationUI() { const { page, pages, total } = this.state.pagination; if (this.elements.pageInfo) { this.elements.pageInfo.textContent = `第 ${page} / ${pages} 页`; } if (this.elements.totalCount) { this.elements.totalCount.textContent = total; } if (this.elements.paginationBtns.length >= 4) { const isFirstPage = page === 1; const isLastPage = page === pages || pages === 0; this.elements.paginationBtns[0].disabled = isFirstPage; this.elements.paginationBtns[1].disabled = isFirstPage; this.elements.paginationBtns[2].disabled = isLastPage; this.elements.paginationBtns[3].disabled = isLastPage; } } async loadGroupsOnce() { if (dataStore.groups.size > 0) return; try { const { success, data } = await apiFetchJson("/admin/keygroups"); if (success && Array.isArray(data)) { data.forEach(group => dataStore.groups.set(group.id, group)); } } catch (error) { console.error("Failed to load key groups:", error); } } async loadAndRenderLogs() { this.state.isLoading = true; this.state.selectedLogIds.clear(); this.logList.renderLoading(); this.updatePaginationUI(); this.syncSelectionUI(); try { // --- 查询参数准备阶段 --- const finalParams = {}; const { filters } = this.state; // 1. 复制所有非 Set 类型的参数 Object.keys(filters).forEach(key => { if (!(filters[key] instanceof Set)) { finalParams[key] = filters[key]; } }); // 2. 翻译 'error_types' const translatedErrorCodes = new Set(); // 从用户直接选择的状态码开始初始化 const translatedStatusCodes = new Set(filters.status_codes); if (filters.error_types.size > 0) { filters.error_types.forEach(type => { for (const [code, obj] of Object.entries(STATUS_CODE_MAP)) { if (obj.type === type) translatedStatusCodes.add(code); } for (const [code, obj] of Object.entries(STATIC_ERROR_MAP)) { if (obj.type === type) translatedErrorCodes.add(code); } }); } // 3. 统一处理所有 Set 类型的参数,转换为字符串 if (filters.key_ids.size > 0) finalParams.key_ids = [...filters.key_ids].join(','); if (filters.group_ids.size > 0) finalParams.group_ids = [...filters.group_ids].join(','); if (translatedErrorCodes.size > 0) finalParams.error_codes = [...translatedErrorCodes].join(','); if (translatedStatusCodes.size > 0) finalParams.status_codes = [...translatedStatusCodes].join(','); // 4. 清理空值 Object.keys(finalParams).forEach(key => { if (finalParams[key] === '' || finalParams[key] === null || finalParams[key] === undefined) { delete finalParams[key]; } }); const query = new URLSearchParams(finalParams); const { success, data } = await apiFetchJson(`/admin/logs?${query.toString()}`); if (success && typeof data === 'object' && data.items) { const { items, total, page, page_size } = data; this.state.logs = items; const totalPages = Math.ceil(total / page_size); this.state.pagination = { page, page_size, total, pages: totalPages > 0 ? totalPages : 1 }; await this.enrichLogsWithKeyNames(items); this.logList.render(this.state.logs, this.state.pagination, this.state.selectedLogIds); } else { this.state.logs = []; this.state.pagination = { ...this.state.pagination, total: 0, pages: 1, page: 1 }; this.logList.render([], this.state.pagination); } } catch (error) { console.error("Failed to load logs:", error); this.state.logs = []; this.state.pagination = { ...this.state.pagination, total: 0, pages: 1, page: 1 }; this.logList.render([], this.state.pagination); } finally { this.state.isLoading = false; this.updatePaginationUI(); this.syncSelectionUI(); } } async enrichLogsWithKeyNames(logs) { const missingKeyIds = [...new Set( logs.filter(log => log.KeyID && !dataStore.keys.has(log.KeyID)).map(log => log.KeyID) )]; if (missingKeyIds.length === 0) return; try { const idsQuery = missingKeyIds.join(','); const { success, data } = await apiFetchJson(`/admin/apikeys?ids=${idsQuery}`); if (success && Array.isArray(data)) { data.forEach(key => dataStore.keys.set(key.ID, key)); } } catch (error) { console.error(`Failed to fetch key details:`, error); } } } export default function() { const page = new LogsPage(); page.init(); }