Update: Js 4 Log.html

This commit is contained in:
XOF
2025-11-26 01:46:47 +08:00
parent 04d36e4d9e
commit 01c9b34600
13 changed files with 3675 additions and 1459 deletions

View File

@@ -0,0 +1,151 @@
// Filename: frontend/js/components/BatchActions.js
import { apiFetchJson } from '../../services/api.js';
// 存储对 LogsPage 实例的引用
let logsPageInstance = null;
// 存储对 DOM 元素的引用
const elements = {
batchActionsBtn: null,
batchActionsMenu: null,
deleteSelectedLogsBtn: null,
clearAllLogsBtn: null,
};
// 用于处理页面点击以关闭菜单的绑定函数
const handleDocumentClick = (event) => {
if (!elements.batchActionsMenu.contains(event.target) && !elements.batchActionsBtn.contains(event.target)) {
closeBatchActionsMenu();
}
};
// 关闭菜单的逻辑
function closeBatchActionsMenu() {
if (elements.batchActionsMenu && !elements.batchActionsMenu.classList.contains('hidden')) {
elements.batchActionsMenu.classList.remove('opacity-100', 'scale-100');
elements.batchActionsMenu.classList.add('opacity-0', 'scale-95');
setTimeout(() => {
elements.batchActionsMenu.classList.add('hidden');
}, 100);
document.removeEventListener('click', handleDocumentClick);
}
}
// 切换菜单显示/隐藏
function handleBatchActionsToggle(event) {
event.stopPropagation();
const isHidden = elements.batchActionsMenu.classList.contains('hidden');
if (isHidden) {
elements.batchActionsMenu.classList.remove('hidden', 'opacity-0', 'scale-95');
elements.batchActionsMenu.classList.add('opacity-100', 'scale-100');
document.addEventListener('click', handleDocumentClick);
} else {
closeBatchActionsMenu();
}
}
// 处理删除选中日志的逻辑
async function handleDeleteSelectedLogs() {
closeBatchActionsMenu();
const selectedIds = Array.from(logsPageInstance.state.selectedLogIds);
if (selectedIds.length === 0) return;
Swal.fire({
width: '20rem',
backdrop: `rgba(0,0,0,0.5)`,
heightAuto: false,
customClass: { popup: `swal2-custom-style ${document.documentElement.classList.contains('dark') ? 'swal2-dark' : ''}` },
title: '确认删除',
text: `您确定要删除选定的 ${selectedIds.length} 条日志吗?此操作不可撤销。`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: '确认删除',
cancelButtonText: '取消',
reverseButtons: false,
confirmButtonColor: '#ef4444',
cancelButtonColor: '#6b7280',
focusCancel: true,
target: '#main-content-wrapper',
}).then(async (result) => {
if (result.isConfirmed) {
try {
const idsQueryString = selectedIds.join(',');
const url = `/admin/logs?ids=${idsQueryString}`;
const { success, message } = await apiFetchJson(url, { method: 'DELETE' });
if (success) {
Swal.fire({ toast: true, position: 'top-end', icon: 'success', title: '删除成功', showConfirmButton: false, timer: 2000, timerProgressBar: true });
logsPageInstance.loadAndRenderLogs(); // 使用实例刷新列表
} else {
throw new Error(message || '删除失败,请稍后重试。');
}
} catch (error) {
Swal.fire({ icon: 'error', title: '操作失败', text: error.message, target: '#main-content-wrapper' });
}
}
});
}
// [NEW] 处理清空所有日志的逻辑
async function handleClearAllLogs() {
closeBatchActionsMenu();
Swal.fire({
width: '20rem',
backdrop: `rgba(0,0,0,0.5)`,
heightAuto: false,
customClass: { popup: `swal2-custom-style ${document.documentElement.classList.contains('dark') ? 'swal2-dark' : ''}` },
title: '危险操作确认',
html: `您确定要<strong class="text-red-500">清空全部</strong>日志吗?<br>此操作不可撤销!`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: '确认清空',
cancelButtonText: '取消',
reverseButtons: false,
confirmButtonColor: '#ef4444',
cancelButtonColor: '#6b7280',
focusCancel: true,
target: '#main-content-wrapper',
}).then(async (result) => {
if (result.isConfirmed) {
try {
const url = `/admin/logs/all`; // 后端清空所有日志的接口
const { success, message } = await apiFetchJson(url, { method: 'DELETE' });
if (success) {
Swal.fire({ toast: true, position: 'top-end', icon: 'success', title: '清空成功', showConfirmButton: false, timer: 2000, timerProgressBar: true });
logsPageInstance.loadAndRenderLogs(); // 刷新列表
} else {
throw new Error(message || '清空失败,请稍后重试。');
}
} catch (error) {
Swal.fire({ icon: 'error', title: '操作失败', text: error.message, target: '#main-content-wrapper' });
}
}
});
}
/**
* 初始化批量操作模块
* @param {object} logsPage - LogsPage 类的实例
*/
export function initBatchActions(logsPage) {
logsPageInstance = logsPage;
// 查询所有需要的 DOM 元素
elements.batchActionsBtn = document.getElementById('batch-actions-btn');
elements.batchActionsMenu = document.getElementById('batch-actions-menu');
elements.deleteSelectedLogsBtn = document.getElementById('delete-selected-logs-btn');
elements.clearAllLogsBtn = document.getElementById('clear-all-logs-btn'); // [NEW] 查询新按钮
if (!elements.batchActionsBtn) return; // 如果找不到主按钮,则不进行任何操作
// 绑定事件监听器
elements.batchActionsBtn.addEventListener('click', handleBatchActionsToggle);
if (elements.deleteSelectedLogsBtn) {
elements.deleteSelectedLogsBtn.addEventListener('click', handleDeleteSelectedLogs);
}
if (elements.clearAllLogsBtn) { // [NEW] 绑定新按钮的事件
elements.clearAllLogsBtn.addEventListener('click', handleClearAllLogs);
}
}
// [NEW] - END

View File

@@ -6,12 +6,13 @@ 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';
import { initBatchActions } from './batchActions.js';
import flatpickr from '../../vendor/flatpickr.js';
const dataStore = {
groups: new Map(),
keys: new Map(),
};
};
class LogsPage {
constructor() {
this.state = {
@@ -25,7 +26,9 @@ class LogsPage {
key_ids: new Set(),
group_ids: new Set(),
error_types: new Set(),
status_codes: new Set(),
status_codes: new Set(),
start_date: null,
end_date: null,
},
selectedLogIds: new Set(),
currentView: 'error',
@@ -43,6 +46,8 @@ class LogsPage {
this.logList = null;
this.systemLogTerminal = null;
this.debouncedLoadAndRender = debounce(() => this.loadAndRenderLogs(), 300);
this.fp = null;
this.themeObserver = null;
}
}
async init() {
@@ -69,6 +74,16 @@ class LogsPage {
this.systemLogTerminal.disconnect();
this.systemLogTerminal = null;
}
if (this.fp) {
this.fp.destroy();
this.fp = null;
}
if (this.themeObserver) {
this.themeObserver.disconnect();
this.themeObserver = null;
}
this.state.currentView = viewName;
this.elements.contentContainer.innerHTML = '';
if (viewName === 'error') {
@@ -100,13 +115,42 @@ class LogsPage {
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.elements.dateRangeFilterBtn = document.getElementById('filter-date-range-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.initDateRangePicker();
this.initEventListeners();
this._observeThemeChanges();
initBatchActions(this);
this.loadAndRenderLogs();
}
_observeThemeChanges() {
const applyTheme = () => {
if (!this.fp || !this.fp.calendarContainer) return;
if (document.documentElement.classList.contains('dark')) {
this.fp.calendarContainer.classList.add('dark');
} else {
this.fp.calendarContainer.classList.remove('dark');
}
};
this.themeObserver = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
applyTheme();
}
}
});
this.themeObserver.observe(document.documentElement, { attributes: true });
// 确保初始状态正确
applyTheme();
}
_initSystemLogView() {
this.systemLogTerminal = new SystemLogTerminal(
this.elements.contentContainer,
@@ -116,9 +160,7 @@ class LogsPage {
width: '20rem',
backdrop: `rgba(0,0,0,0.5)`,
heightAuto: false,
customClass: {
popup: `swal2-custom-style ${document.documentElement.classList.contains('dark') ? 'swal2-dark' : ''}`
},
customClass: { popup: `swal2-custom-style ${document.documentElement.classList.contains('dark') ? 'swal2-dark' : ''}` },
title: '系统终端日志',
text: '您即将连接到实时系统日志流窗口。',
showCancelButton: true,
@@ -153,6 +195,83 @@ class LogsPage {
new FilterPopover(this.elements.errorCodeFilterBtn, statusCodeOptions, '筛选状态码');
}
}
initDateRangePicker() {
if (!this.elements.dateRangeFilterBtn) return;
const buttonTextSpan = this.elements.dateRangeFilterBtn.querySelector('span');
const originalText = buttonTextSpan.textContent;
this.fp = flatpickr(this.elements.dateRangeFilterBtn, {
mode: 'range',
dateFormat: 'Y-m-d',
onClose: (selectedDates) => {
if (selectedDates.length === 2) {
const [start, end] = selectedDates;
end.setHours(23, 59, 59, 999);
this.state.filters.start_date = start.toISOString();
this.state.filters.end_date = end.toISOString();
const startDateStr = start.toISOString().split('T')[0];
const endDateStr = end.toISOString().split('T')[0];
buttonTextSpan.textContent = `${startDateStr} ~ ${endDateStr}`;
this.elements.dateRangeFilterBtn.classList.add('!border-primary', '!text-primary');
this.state.filters.page = 1;
this.loadAndRenderLogs();
}
},
onReady: (selectedDates, dateStr, instance) => {
// 暗黑模式和清除按钮的现有逻辑保持不变
if (document.documentElement.classList.contains('dark')) {
instance.calendarContainer.classList.add('dark');
}
const clearButton = document.createElement('button');
clearButton.textContent = '清除';
clearButton.className = 'button flatpickr-button flatpickr-clear-button';
clearButton.addEventListener('click', (e) => {
e.preventDefault();
instance.clear();
this.state.filters.start_date = null;
this.state.filters.end_date = null;
buttonTextSpan.textContent = originalText;
this.elements.dateRangeFilterBtn.classList.remove('!border-primary', '!text-primary');
this.state.filters.page = 1;
this.loadAndRenderLogs();
instance.close();
});
instance.calendarContainer.appendChild(clearButton);
const nativeMonthSelect = instance.monthsDropdownContainer;
if (!nativeMonthSelect) return;
const monthYearContainer = nativeMonthSelect.parentElement;
const wrapper = document.createElement('div');
wrapper.className = 'custom-select-v2-container relative inline-block text-left';
wrapper.innerHTML = `
<button type="button" class="custom-select-trigger inline-flex justify-center items-center w-22 gap-x-1.5 rounded-md bg-transparent px-1 py-1 text-xs font-semibold text-foreground shadow-sm ring-0 ring-inset ring-input hover:bg-accent focus:outline-none focus:ring-1 focus:ring-offset-1 focus:ring-offset-background focus:ring-primary" aria-haspopup="true">
<span class="truncate"></span>
</button>
`;
const template = document.createElement('template');
template.className = 'custom-select-panel-template';
template.innerHTML = `
<div class="custom-select-panel absolute z-[1000] my-2 w-24 origin-top-right rounded-md bg-popover dark:bg-zinc-900 shadow-lg ring-1 ring-zinc-500/30 ring-opacity-5 focus:outline-none" role="menu" aria-orientation="vertical" tabindex="-1">
</div>
`;
nativeMonthSelect.classList.add('hidden');
wrapper.appendChild(nativeMonthSelect);
wrapper.appendChild(template);
monthYearContainer.prepend(wrapper);
const customSelect = new CustomSelectV2(wrapper);
instance.customMonthSelect = customSelect;
},
onMonthChange: (selectedDates, dateStr, instance) => {
if (instance.customMonthSelect) {
instance.customMonthSelect.updateTriggerText();
}
},
});
}
initEventListeners() {
if (this.elements.pageSizeSelect) {
this.elements.pageSizeSelect.addEventListener('change', (e) => this.changePageSize(parseInt(e.target.value, 10)));
@@ -191,16 +310,12 @@ class LogsPage {
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;
@@ -219,13 +334,11 @@ class LogsPage {
});
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;
@@ -238,7 +351,6 @@ class LogsPage {
}
this.syncSelectionUI();
}
handleSelectAllChange(event) {
const isChecked = event.target.checked;
this.state.logs.forEach(log => {
@@ -251,36 +363,35 @@ class LogsPage {
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; // 半选状态
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;
const hasSelection = selectedCount > 0;
const deleteSelectedBtn = document.getElementById('delete-selected-logs-btn');
if (deleteSelectedBtn) {
deleteSelectedBtn.disabled = !hasSelection;
}
}
changePageSize(newSize) {
this.state.filters.page_size = newSize;
this.state.filters.page = 1;
@@ -291,18 +402,15 @@ class LogsPage {
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;
@@ -312,7 +420,6 @@ class LogsPage {
this.elements.paginationBtns[3].disabled = isLastPage;
}
}
async loadGroupsOnce() {
if (dataStore.groups.size > 0) return;
try {
@@ -324,7 +431,6 @@ class LogsPage {
console.error("Failed to load key groups:", error);
}
}
async loadAndRenderLogs() {
this.state.isLoading = true;
this.state.selectedLogIds.clear();
@@ -332,19 +438,15 @@ class LogsPage {
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 => {
@@ -356,19 +458,23 @@ class LogsPage {
}
});
}
// 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()}`);
const { success, data } = await apiFetchJson(
`/admin/logs?${query.toString()}`,
{ cache: 'no-cache', noCache: true }
);
if (success && typeof data === 'object' && data.items) {
const { items, total, page, page_size } = data;
this.state.logs = items;
@@ -392,10 +498,9 @@ class LogsPage {
this.syncSelectionUI();
}
}
async enrichLogsWithKeyNames(logs) {
const missingKeyIds = [...new Set(
logs.filter(log => log.KeyID && !dataStore.keys.has(log.KeyID)).map(log => log.KeyID)
logs.filter(log => log.KeyID && !dataStore.keys.has(log.KeyID)).map(log => log.ID)
)];
if (missingKeyIds.length === 0) return;
try {
@@ -409,8 +514,7 @@ class LogsPage {
}
}
}
export default function() {
const page = new LogsPage();
page.init();
}
}

2
frontend/js/vendor/flatpickr.js vendored Normal file

File diff suppressed because one or more lines are too long