193 lines
6.8 KiB
TypeScript
193 lines
6.8 KiB
TypeScript
// @ts-nocheck
|
||
|
||
|
||
import { getAll, clear, exportJson, subscribe, formatTs, formatType } from '../utils/audit-log.js';
|
||
|
||
let _badgeCountEl = null;
|
||
let _filter = 'all';
|
||
let _expanded = new Set();
|
||
let _renderFn = null;
|
||
|
||
export function initBadge(sidebarEl) {
|
||
_badgeCountEl = sidebarEl.querySelector('#audit-count');
|
||
|
||
sidebarEl.querySelector('#btn-open-audit').addEventListener('click', () => {
|
||
showAuditDialog();
|
||
});
|
||
|
||
subscribe(() => updateBadge());
|
||
updateBadge();
|
||
}
|
||
|
||
function updateBadge() {
|
||
if (_badgeCountEl) {
|
||
_badgeCountEl.textContent = getAll().length;
|
||
}
|
||
}
|
||
|
||
export function showAuditDialog() {
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'dialog-overlay';
|
||
overlay.innerHTML = `
|
||
<div class="dialog audit-dialog" role="dialog" aria-modal="true">
|
||
<div class="dialog-header">
|
||
<h2>审计日志</h2>
|
||
<button class="dialog-close" type="button" aria-label="关闭">×</button>
|
||
</div>
|
||
<div class="audit-toolbar">
|
||
<div class="audit-filters">
|
||
<button class="filter-btn active" data-filter="all" type="button">全部</button>
|
||
<button class="filter-btn" data-filter="user" type="button">用户</button>
|
||
<button class="filter-btn" data-filter="assistant" type="button">模型</button>
|
||
<button class="filter-btn" data-filter="tool" type="button">工具</button>
|
||
<button class="filter-btn" data-filter="system" type="button">系统</button>
|
||
<button class="filter-btn" data-filter="error" type="button">错误</button>
|
||
</div>
|
||
<div class="audit-toolbar-actions">
|
||
<button class="mini-btn" id="audit-export" type="button">导出 JSON</button>
|
||
<button class="mini-btn danger-btn" id="audit-clear" type="button">清空</button>
|
||
</div>
|
||
</div>
|
||
<div class="audit-table-wrapper">
|
||
<div class="audit-table-head">
|
||
<div class="col-time">时间</div>
|
||
<div class="col-type">类型</div>
|
||
<div class="col-summary">摘要</div>
|
||
<div class="col-action"></div>
|
||
</div>
|
||
<div class="audit-table-body" id="audit-tbody"></div>
|
||
</div>
|
||
<div class="audit-stats" id="audit-stats"></div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(overlay);
|
||
document.body.classList.add('modal-open');
|
||
|
||
const tbody = overlay.querySelector('#audit-tbody');
|
||
const statsEl = overlay.querySelector('#audit-stats');
|
||
|
||
const renderTable = () => {
|
||
const all = getAll();
|
||
const filtered = _filter === 'all'
|
||
? all
|
||
: all.filter(e => e.type === _filter || e.type.startsWith(_filter + ':'));
|
||
|
||
|
||
const counts = {
|
||
user: all.filter(e => e.type === 'user').length,
|
||
assistant: all.filter(e => e.type === 'assistant').length,
|
||
tool: all.filter(e => e.type.startsWith('tool')).length,
|
||
system: all.filter(e => e.type === 'system').length,
|
||
error: all.filter(e => e.type === 'error').length,
|
||
};
|
||
statsEl.innerHTML = `共 <b>${all.length}</b> 条 · 用户 ${counts.user} / 模型 ${counts.assistant} / 工具 ${counts.tool} / 系统 ${counts.system} / 错误 ${counts.error}${_filter !== 'all' ? ` · 当前筛选 ${filtered.length}` : ''}`;
|
||
|
||
if (filtered.length === 0) {
|
||
tbody.innerHTML = '<div class="audit-empty">暂无日志</div>';
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = '';
|
||
for (const entry of filtered) {
|
||
const expanded = _expanded.has(entry.id);
|
||
const hasDetails = !!entry.details;
|
||
|
||
const row = document.createElement('div');
|
||
row.className = `audit-row audit-${entry.type.replace(':', '-')} ${expanded ? 'expanded' : ''}`;
|
||
|
||
row.innerHTML = `
|
||
<div class="col-time">${formatTs(entry.ts)}</div>
|
||
<div class="col-type">${formatType(entry.type)}</div>
|
||
<div class="col-summary">${escapeHtml(entry.summary || '(空)')}</div>
|
||
<div class="col-action">${hasDetails ? `<span class="expand-icon">${expanded ? '▼' : '▶'}</span>` : ''}</div>
|
||
`;
|
||
|
||
if (hasDetails) {
|
||
row.addEventListener('click', () => {
|
||
if (_expanded.has(entry.id)) _expanded.delete(entry.id);
|
||
else _expanded.add(entry.id);
|
||
renderTable();
|
||
});
|
||
}
|
||
|
||
tbody.appendChild(row);
|
||
|
||
if (expanded && hasDetails) {
|
||
const detail = document.createElement('div');
|
||
detail.className = 'audit-detail';
|
||
|
||
const json = JSON.stringify(entry.details, null, 2);
|
||
const display = json.replace(/\\n/g, '\n').replace(/\\t/g, ' ');
|
||
detail.innerHTML = `<pre>${escapeHtml(display)}</pre>`;
|
||
tbody.appendChild(detail);
|
||
}
|
||
}
|
||
};
|
||
|
||
_renderFn = renderTable;
|
||
renderTable();
|
||
|
||
overlay.querySelectorAll('.filter-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
_filter = btn.dataset.filter;
|
||
overlay.querySelectorAll('.filter-btn').forEach(b => b.classList.toggle('active', b === btn));
|
||
renderTable();
|
||
});
|
||
});
|
||
|
||
overlay.querySelector('#audit-clear').addEventListener('click', () => {
|
||
if (!confirm('确定清空所有日志?此操作不可撤销。')) return;
|
||
clear();
|
||
_expanded.clear();
|
||
});
|
||
|
||
overlay.querySelector('#audit-export').addEventListener('click', () => {
|
||
exportJson();
|
||
});
|
||
|
||
const close = () => {
|
||
const unsub = subscribe(() => {
|
||
if (document.body.contains(overlay)) {
|
||
renderTable();
|
||
} else {
|
||
unsub();
|
||
}
|
||
});
|
||
overlay.remove();
|
||
document.body.classList.remove('modal-open');
|
||
};
|
||
|
||
overlay.querySelector('.dialog-close').addEventListener('click', close);
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) close();
|
||
});
|
||
|
||
|
||
const escHandler = (e) => {
|
||
if (e.key === 'Escape') {
|
||
close();
|
||
document.removeEventListener('keydown', escHandler);
|
||
}
|
||
};
|
||
document.addEventListener('keydown', escHandler);
|
||
|
||
|
||
const unsubOnLog = subscribe(() => {
|
||
if (document.body.contains(overlay)) {
|
||
renderTable();
|
||
} else {
|
||
unsubOnLog();
|
||
}
|
||
});
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
if (s == null) return '';
|
||
return String(s)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|