Files
gemini-banlancer/frontend/js/pages/logs/index.js
2025-11-25 16:58:15 +08:00

407 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
}