407 lines
18 KiB
JavaScript
407 lines
18 KiB
JavaScript
// 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();
|
||
}
|