Update Js for logs.html

This commit is contained in:
XOF
2025-11-24 20:47:12 +08:00
parent f2706d6fc8
commit e026d8f324
23 changed files with 1884 additions and 396 deletions

View File

@@ -1,46 +1,235 @@
// Filename: frontend/js/pages/logs/index.js
import { apiFetchJson } from '../../services/api.js';
import LogList from './logList.js';
// [最终版] 创建一个共享的数据仓库,用于缓存 Groups 和 Keys
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';
const dataStore = {
groups: new Map(),
keys: new Map(),
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 }
// [优化] 统一将所有可筛选字段在此处初始化,便于管理
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(),
};
this.elements = {
tableBody: document.getElementById('logs-table-body'),
selectedCount: document.querySelector('.flex-1.text-sm span.font-semibold:nth-child(1)'),
totalCount: document.querySelector('.flex-1.text-sm span:last-child'),
pageSizeSelect: document.querySelector('[data-component="custom-select-v2"] select'),
pageInfo: document.querySelector('.flex.w-\\[100px\\]'),
paginationBtns: document.querySelectorAll('[data-pagination-controls] button'),
selectAllCheckbox: document.querySelector('thead .table-head-cell input[type="checkbox"]'),
searchInput: document.getElementById('log-search-input'),
errorTypeFilterBtn: document.getElementById('filter-error-type-btn'),
errorCodeFilterBtn: document.getElementById('filter-error-code-btn'),
};
this.initialized = !!this.elements.tableBody;
if (this.initialized) {
this.logList = new LogList(this.elements.tableBody, dataStore);
const selectContainer = document.querySelector('[data-component="custom-select-v2"]');
if (selectContainer) { new CustomSelectV2(selectContainer); }
this.debouncedLoadAndRender = debounce(() => this.loadAndRenderLogs(), 300);
}
}
async init() {
if (!this.initialized) return;
this.initFilterPopovers();
this.initEventListeners();
// 页面初始化:先加载群组,再加载日志
await this.loadGroupsOnce();
await this.loadAndRenderLogs();
}
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();
}
initEventListeners() { /* 分页和筛选的事件监听器 */ }
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; // 防止重复加载
if (dataStore.groups.size > 0) return;
try {
const { success, data } = await apiFetchJson("/admin/keygroups");
if (success && Array.isArray(data)) {
@@ -53,30 +242,69 @@ class LogsPage {
async loadAndRenderLogs() {
this.state.isLoading = true;
this.logList.renderLoading();
this.state.selectedLogIds.clear();
this.logList.renderLoading();
this.updatePaginationUI();
this.syncSelectionUI();
try {
const query = new URLSearchParams(this.state.filters);
const { success, data } = await apiFetchJson(`/admin/logs?${query.toString()}`);
// --- 查询参数准备阶段 ---
const finalParams = {};
const { filters } = this.state;
if (success && typeof data === 'object') {
// 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;
this.state.pagination = { page, page_size, total, pages: Math.ceil(total / page_size) };
// [核心] 在渲染前按需批量加载本页日志所需的、尚未缓存的Key信息
const totalPages = Math.ceil(total / page_size);
this.state.pagination = { page, page_size, total, pages: totalPages > 0 ? totalPages : 1 };
await this.enrichLogsWithKeyNames(items);
// 调用 render此时 dataStore 中已包含所有需要的数据
this.logList.render(this.state.logs, this.state.pagination);
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();
}
}

View File

@@ -1,7 +1,7 @@
// Filename: frontend/js/pages/logs/logList.js
import { escapeHTML } from '../../utils/utils.js';
const STATIC_ERROR_MAP = {
export const STATIC_ERROR_MAP = {
'API_KEY_INVALID': { type: '密钥无效', style: 'red' },
'INVALID_ARGUMENT': { type: '参数无效', style: 'red' },
'PERMISSION_DENIED': { type: '权限不足', style: 'red' },
@@ -13,8 +13,8 @@ const STATIC_ERROR_MAP = {
'INTERNAL': { type: 'Google内部错误', style: 'yellow' },
'UNAVAILABLE': { type: '服务不可用', style: 'yellow' },
};
// --- [更新] HTTP状态码到类型和样式的动态映射表 ---
const STATUS_CODE_MAP = {
// --- HTTP状态码到类型和样式的动态映射表 ---
export const STATUS_CODE_MAP = {
400: { type: '错误请求', style: 'red' },
401: { type: '认证失败', style: 'red' },
403: { type: '禁止访问', style: 'red' },
@@ -55,7 +55,7 @@ class LogList {
this.container.innerHTML = `<tr><td colspan="9" class="p-8 text-center text-muted-foreground"><i class="fas fa-spinner fa-spin mr-2"></i> 加载日志中...</td></tr>`;
}
render(logs, pagination) {
render(logs, pagination, selectedLogIds) {
if (!this.container) return;
if (!logs || logs.length === 0) {
this.container.innerHTML = `<tr><td colspan="9" class="p-8 text-center text-muted-foreground">没有找到相关的日志记录。</td></tr>`;
@@ -63,7 +63,10 @@ class LogList {
}
const { page, page_size } = pagination;
const startIndex = (page - 1) * page_size;
const logsHtml = logs.map((log, index) => this.createLogRowHtml(log, startIndex + index + 1)).join('');
const logsHtml = logs.map((log, index) => {
const isChecked = selectedLogIds.has(log.ID);
return this.createLogRowHtml(log, startIndex + index + 1, isChecked);
}).join('');
this.container.innerHTML = logsHtml;
}
@@ -125,7 +128,7 @@ class LogList {
return `<div class="inline-block rounded bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5"><span class="font-quinquefive text-xs tracking-wider ${styleClass}">${modelName}</span></div>`;
}
createLogRowHtml(log, index) {
createLogRowHtml(log, index, isChecked) {
const group = this.dataStore.groups.get(log.GroupID);
const groupName = group ? group.display_name : (log.GroupID ? `Group #${log.GroupID}` : 'N/A');
const key = this.dataStore.keys.get(log.KeyID);
@@ -140,9 +143,13 @@ class LogList {
const modelNameFormatted = this._formatModelName(log.ModelName);
const errorMessageAttr = log.ErrorMessage ? `data-error-message="${escape(log.ErrorMessage)}"` : '';
const requestTime = new Date(log.RequestTime).toLocaleString();
const checkedAttr = isChecked ? 'checked' : '';
return `
<tr class="table-row" data-log-id="${log.ID}" ${errorMessageAttr}>
<td class="table-cell"><input type="checkbox" class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500"></td>
<td class="table-cell">
<input type="checkbox" class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500" ${checkedAttr}>
</td>
<td class="table-cell font-mono text-muted-foreground">${index}</td>
<td class="table-cell font-medium font-mono">${apiKeyDisplay}</td>
<td class="table-cell">${groupName}</td>
@@ -159,5 +166,4 @@ class LogList {
`;
}
}
export default LogList;