383 lines
10 KiB
TypeScript
383 lines
10 KiB
TypeScript
/**
|
|
* 日志记录模块
|
|
* 提供统一的日志记录、存储和导出功能
|
|
*/
|
|
|
|
export enum LogLevel {
|
|
DEBUG = 'DEBUG',
|
|
INFO = 'INFO',
|
|
WARN = 'WARN',
|
|
ERROR = 'ERROR',
|
|
FATAL = 'FATAL',
|
|
}
|
|
|
|
export enum LogCategory {
|
|
USER_ACTION = 'USER_ACTION', // 用户操作
|
|
API_CALL = 'API_CALL', // API调用
|
|
SYSTEM = 'SYSTEM', // 系统事件
|
|
CONSOLE_ERROR = 'CONSOLE_ERROR', // 控制台错误
|
|
}
|
|
|
|
export interface LogEntry {
|
|
id: string;
|
|
timestamp: number;
|
|
level: LogLevel;
|
|
category: LogCategory;
|
|
message: string;
|
|
data?: any;
|
|
stack?: string;
|
|
userAgent?: string;
|
|
url?: string;
|
|
userId?: string;
|
|
}
|
|
|
|
class Logger {
|
|
private logs: LogEntry[] = [];
|
|
private maxLogs = 1000; // 最多保存1000条日志
|
|
private storageKey = 'app_logs';
|
|
private listeners: Set<(log: LogEntry) => void> = new Set();
|
|
private isEnabled = true;
|
|
|
|
constructor() {
|
|
this.loadLogsFromStorage();
|
|
this.setupConsoleInterceptor();
|
|
this.setupErrorHandler();
|
|
this.setupUnhandledRejectionHandler();
|
|
}
|
|
|
|
/**
|
|
* 从localStorage加载历史日志
|
|
*/
|
|
private loadLogsFromStorage() {
|
|
try {
|
|
const stored = localStorage.getItem(this.storageKey);
|
|
if (stored) {
|
|
this.logs = JSON.parse(stored);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load logs from storage:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 保存日志到localStorage
|
|
*/
|
|
private saveLogsToStorage() {
|
|
try {
|
|
// 只保存最近的日志
|
|
const logsToSave = this.logs.slice(-this.maxLogs);
|
|
localStorage.setItem(this.storageKey, JSON.stringify(logsToSave));
|
|
} catch (error) {
|
|
console.error('Failed to save logs to storage:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 拦截console方法
|
|
*/
|
|
private setupConsoleInterceptor() {
|
|
const originalError = console.error;
|
|
|
|
// 只拦截错误,不拦截警告
|
|
console.error = (...args: any[]) => {
|
|
originalError.apply(console, args);
|
|
// 过滤掉一些常见的无关错误
|
|
const message = args.join(' ');
|
|
if (!message.includes('ResizeObserver') &&
|
|
!message.includes('favicon') &&
|
|
!message.includes('Download the React DevTools')) {
|
|
this.log(LogLevel.ERROR, LogCategory.CONSOLE_ERROR, message, { args });
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 设置全局错误处理
|
|
*/
|
|
private setupErrorHandler() {
|
|
window.addEventListener('error', (event) => {
|
|
this.log(
|
|
LogLevel.ERROR,
|
|
LogCategory.CONSOLE_ERROR,
|
|
event.message,
|
|
{
|
|
filename: event.filename,
|
|
lineno: event.lineno,
|
|
colno: event.colno,
|
|
error: event.error,
|
|
},
|
|
event.error?.stack
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 设置未处理的Promise拒绝处理
|
|
*/
|
|
private setupUnhandledRejectionHandler() {
|
|
window.addEventListener('unhandledrejection', (event) => {
|
|
this.log(
|
|
LogLevel.ERROR,
|
|
LogCategory.CONSOLE_ERROR,
|
|
`Unhandled Promise Rejection: ${event.reason}`,
|
|
{ reason: event.reason },
|
|
event.reason?.stack
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 记录日志
|
|
*/
|
|
log(
|
|
level: LogLevel,
|
|
category: LogCategory,
|
|
message: string,
|
|
data?: any,
|
|
stack?: string
|
|
) {
|
|
if (!this.isEnabled) return;
|
|
|
|
const entry: LogEntry = {
|
|
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
timestamp: Date.now(),
|
|
level,
|
|
category,
|
|
message,
|
|
data,
|
|
stack,
|
|
userAgent: navigator.userAgent,
|
|
url: window.location.href,
|
|
};
|
|
|
|
this.logs.push(entry);
|
|
|
|
// 限制日志数量
|
|
if (this.logs.length > this.maxLogs) {
|
|
this.logs = this.logs.slice(-this.maxLogs);
|
|
}
|
|
|
|
// 保存到localStorage
|
|
this.saveLogsToStorage();
|
|
|
|
// 通知监听器
|
|
this.listeners.forEach(listener => listener(entry));
|
|
|
|
// 在开发环境输出到控制台
|
|
if (import.meta.env.DEV) {
|
|
this.logToConsole(entry);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 输出到控制台
|
|
*/
|
|
private logToConsole(entry: LogEntry) {
|
|
// 生产环境只输出ERROR和FATAL级别
|
|
if (!import.meta.env.DEV && entry.level !== LogLevel.ERROR && entry.level !== LogLevel.FATAL) {
|
|
return;
|
|
}
|
|
|
|
const style = this.getConsoleStyle(entry.level);
|
|
console.log(
|
|
`%c[${entry.level}] [${entry.category}] ${new Date(entry.timestamp).toISOString()}`,
|
|
style,
|
|
entry.message,
|
|
entry.data || ''
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 获取控制台样式
|
|
*/
|
|
private getConsoleStyle(level: LogLevel): string {
|
|
const styles = {
|
|
[LogLevel.DEBUG]: 'color: #888',
|
|
[LogLevel.INFO]: 'color: #0066cc',
|
|
[LogLevel.WARN]: 'color: #ff9900',
|
|
[LogLevel.ERROR]: 'color: #cc0000; font-weight: bold',
|
|
[LogLevel.FATAL]: 'color: #fff; background: #cc0000; font-weight: bold',
|
|
};
|
|
return styles[level];
|
|
}
|
|
|
|
/**
|
|
* 便捷方法
|
|
*/
|
|
debug(category: LogCategory, message: string, data?: any) {
|
|
this.log(LogLevel.DEBUG, category, message, data);
|
|
}
|
|
|
|
info(category: LogCategory, message: string, data?: any) {
|
|
this.log(LogLevel.INFO, category, message, data);
|
|
}
|
|
|
|
warn(category: LogCategory, message: string, data?: any) {
|
|
this.log(LogLevel.WARN, category, message, data);
|
|
}
|
|
|
|
error(category: LogCategory, message: string, data?: any, stack?: string) {
|
|
this.log(LogLevel.ERROR, category, message, data, stack);
|
|
}
|
|
|
|
fatal(category: LogCategory, message: string, data?: any, stack?: string) {
|
|
this.log(LogLevel.FATAL, category, message, data, stack);
|
|
}
|
|
|
|
/**
|
|
* 记录用户操作
|
|
*/
|
|
logUserAction(action: string, details?: any) {
|
|
this.info(LogCategory.USER_ACTION, action, details);
|
|
}
|
|
|
|
/**
|
|
* 记录API调用
|
|
*/
|
|
logApiCall(method: string, url: string, status?: number, duration?: number, error?: any) {
|
|
const level = error ? LogLevel.ERROR : LogLevel.INFO;
|
|
this.log(level, LogCategory.API_CALL, `${method} ${url}`, {
|
|
method,
|
|
url,
|
|
status,
|
|
duration,
|
|
error,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 记录性能指标
|
|
*/
|
|
logPerformance(metric: string, value: number, unit = 'ms') {
|
|
this.info(LogCategory.SYSTEM, `${metric}: ${value}${unit}`, { metric, value, unit });
|
|
}
|
|
|
|
/**
|
|
* 获取所有日志
|
|
*/
|
|
getLogs(filter?: {
|
|
level?: LogLevel;
|
|
category?: LogCategory;
|
|
startTime?: number;
|
|
endTime?: number;
|
|
search?: string;
|
|
}): LogEntry[] {
|
|
let filtered = [...this.logs];
|
|
|
|
if (filter) {
|
|
if (filter.level) {
|
|
filtered = filtered.filter(log => log.level === filter.level);
|
|
}
|
|
if (filter.category) {
|
|
filtered = filtered.filter(log => log.category === filter.category);
|
|
}
|
|
if (filter.startTime) {
|
|
filtered = filtered.filter(log => log.timestamp >= filter.startTime!);
|
|
}
|
|
if (filter.endTime) {
|
|
filtered = filtered.filter(log => log.timestamp <= filter.endTime!);
|
|
}
|
|
if (filter.search) {
|
|
const search = filter.search.toLowerCase();
|
|
filtered = filtered.filter(log =>
|
|
log.message.toLowerCase().includes(search) ||
|
|
JSON.stringify(log.data).toLowerCase().includes(search)
|
|
);
|
|
}
|
|
}
|
|
|
|
return filtered;
|
|
}
|
|
|
|
/**
|
|
* 清空日志
|
|
*/
|
|
clearLogs() {
|
|
this.logs = [];
|
|
localStorage.removeItem(this.storageKey);
|
|
}
|
|
|
|
/**
|
|
* 导出日志为JSON
|
|
*/
|
|
exportLogsAsJson(): string {
|
|
return JSON.stringify(this.logs, null, 2);
|
|
}
|
|
|
|
/**
|
|
* 导出日志为CSV
|
|
*/
|
|
exportLogsAsCsv(): string {
|
|
const headers = ['Timestamp', 'Level', 'Category', 'Message', 'Data', 'URL'];
|
|
const rows = this.logs.map(log => [
|
|
new Date(log.timestamp).toISOString(),
|
|
log.level,
|
|
log.category,
|
|
log.message,
|
|
JSON.stringify(log.data || {}),
|
|
log.url || '',
|
|
]);
|
|
|
|
return [
|
|
headers.join(','),
|
|
...rows.map(row => row.map(cell => `"${cell}"`).join(',')),
|
|
].join('\n');
|
|
}
|
|
|
|
/**
|
|
* 下载日志文件
|
|
*/
|
|
downloadLogs(format: 'json' | 'csv' = 'json') {
|
|
const content = format === 'json' ? this.exportLogsAsJson() : this.exportLogsAsCsv();
|
|
const blob = new Blob([content], { type: format === 'json' ? 'application/json' : 'text/csv' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `logs-${new Date().toISOString()}.${format}`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
/**
|
|
* 添加日志监听器
|
|
*/
|
|
addListener(listener: (log: LogEntry) => void) {
|
|
this.listeners.add(listener);
|
|
return () => this.listeners.delete(listener);
|
|
}
|
|
|
|
/**
|
|
* 启用/禁用日志记录
|
|
*/
|
|
setEnabled(enabled: boolean) {
|
|
this.isEnabled = enabled;
|
|
}
|
|
|
|
/**
|
|
* 获取日志统计
|
|
*/
|
|
getStats() {
|
|
const stats = {
|
|
total: this.logs.length,
|
|
byLevel: {} as Record<LogLevel, number>,
|
|
byCategory: {} as Record<LogCategory, number>,
|
|
errors: 0,
|
|
};
|
|
|
|
this.logs.forEach(log => {
|
|
stats.byLevel[log.level] = (stats.byLevel[log.level] || 0) + 1;
|
|
stats.byCategory[log.category] = (stats.byCategory[log.category] || 0) + 1;
|
|
if (log.level === LogLevel.ERROR || log.level === LogLevel.FATAL) {
|
|
stats.errors++;
|
|
}
|
|
});
|
|
|
|
return stats;
|
|
}
|
|
}
|
|
|
|
// 导出单例
|
|
export const logger = new Logger();
|