feat: v1.3.0 - 添加完整的日志系统和错误处理
- 新增日志记录系统,自动记录用户核心操作和错误 - 新增日志查看器,支持筛选、搜索和导出 - 增强错误处理,显示具体错误信息 - 修复所有LLM adapter的URL双斜杠问题 - 优化审计失败提示,引导用户查看日志 - 更新版本号到v1.3.0
This commit is contained in:
parent
094677028a
commit
950325850c
32
README.md
32
README.md
|
|
@ -347,6 +347,38 @@ VITE_QWEN_API_KEY=key3
|
|||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>如何查看系统日志和调试信息?</b></summary>
|
||||
|
||||
XCodeReviewer 内置了日志系统,记录核心操作和错误:
|
||||
|
||||
**查看日志**:
|
||||
- 导航栏 -> 系统日志
|
||||
- 或访问:`http://localhost:5173/logs` (开发) / `http://localhost:8888/logs` (生产)
|
||||
|
||||
**记录内容**:
|
||||
- ✅ 用户核心操作(创建项目、审计任务、修改配置等)
|
||||
- ✅ API 请求失败和错误
|
||||
- ✅ 控制台错误(自动捕获)
|
||||
- ✅ 未处理的异常
|
||||
|
||||
**功能特性**:
|
||||
- 日志筛选、搜索
|
||||
- 导出日志(JSON/CSV)
|
||||
- 错误详情查看
|
||||
|
||||
**手动记录用户操作**:
|
||||
```typescript
|
||||
import { logger, LogCategory } from '@/shared/utils/logger';
|
||||
|
||||
// 记录用户操作
|
||||
logger.logUserAction('创建项目', { projectName, projectType });
|
||||
logger.logUserAction('开始审计', { taskId, fileCount });
|
||||
```
|
||||
|
||||
详细说明请参考:[LOGGING_README.md](LOGGING_README.md)
|
||||
</details>
|
||||
|
||||
### 🔑 获取 API Key
|
||||
|
||||
#### 支持的 LLM 平台
|
||||
|
|
|
|||
33
README_EN.md
33
README_EN.md
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/lintsinghua/XCodeReviewer/releases)
|
||||
[](https://github.com/lintsinghua/XCodeReviewer/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
|
|
@ -347,6 +347,37 @@ VITE_QWEN_API_KEY=key3
|
|||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>How to view system logs and debug information?</b></summary>
|
||||
|
||||
XCodeReviewer has a built-in logging system that records core operations and errors:
|
||||
|
||||
**View Logs**:
|
||||
- Navigation bar -> System Logs
|
||||
- Or visit: `http://localhost:5173/logs` (dev) / `http://localhost:8888/logs` (prod)
|
||||
|
||||
**Recorded Content**:
|
||||
- ✅ Core user operations (create project, audit tasks, config changes, etc.)
|
||||
- ✅ Failed API requests and errors
|
||||
- ✅ Console errors (auto-captured)
|
||||
- ✅ Unhandled exceptions
|
||||
|
||||
**Features**:
|
||||
- Log filtering and search
|
||||
- Export logs (JSON/CSV)
|
||||
- View error details
|
||||
|
||||
**Manual Logging**:
|
||||
```typescript
|
||||
import { logger, LogCategory } from '@/shared/utils/logger';
|
||||
|
||||
// Log user actions
|
||||
logger.logUserAction('Create Project', { projectName, projectType });
|
||||
logger.logUserAction('Start Audit', { taskId, fileCount });
|
||||
```
|
||||
|
||||
For details, see: [LOGGING_README.md](LOGGING_README.md)
|
||||
</details>
|
||||
|
||||
### 🔑 Getting API Keys
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "xcode-reviewer",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import App from "./App.tsx";
|
|||
import { AppWrapper } from "@/components/layout/PageMeta";
|
||||
import { isLocalMode } from "@/shared/config/database";
|
||||
import { initLocalDatabase } from "@/shared/utils/initLocalDB";
|
||||
import { ErrorBoundary } from "@/components/common/ErrorBoundary";
|
||||
import "@/shared/utils/fetchWrapper"; // 初始化fetch拦截器
|
||||
|
||||
// 初始化本地数据库
|
||||
if (isLocalMode) {
|
||||
|
|
@ -13,8 +15,10 @@ if (isLocalMode) {
|
|||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<AppWrapper>
|
||||
<App />
|
||||
</AppWrapper>
|
||||
<ErrorBoundary>
|
||||
<AppWrapper>
|
||||
<App />
|
||||
</AppWrapper>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import InstantAnalysis from "@/pages/InstantAnalysis";
|
|||
import AuditTasks from "@/pages/AuditTasks";
|
||||
import TaskDetail from "@/pages/TaskDetail";
|
||||
import AdminDashboard from "@/pages/AdminDashboard";
|
||||
import LogsPage from "@/pages/LogsPage";
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface RouteConfig {
|
||||
|
|
@ -64,6 +65,12 @@ const routes: RouteConfig[] = [
|
|||
element: <RecycleBin />,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: "系统日志",
|
||||
path: "/logs",
|
||||
element: <LogsPage />,
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
|
|
@ -195,6 +195,18 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
|||
|
||||
console.log('✅ 任务创建成功:', taskId);
|
||||
|
||||
// 记录用户操作
|
||||
import('@/shared/utils/logger').then(({ logger, LogCategory }) => {
|
||||
logger.logUserAction('创建审计任务', {
|
||||
taskId,
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
taskType: taskForm.task_type,
|
||||
branch: taskForm.branch_name,
|
||||
hasZipFile: !!zipFile,
|
||||
});
|
||||
});
|
||||
|
||||
// 关闭创建对话框
|
||||
onOpenChange(false);
|
||||
resetForm();
|
||||
|
|
@ -207,7 +219,14 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
|
|||
toast.success("审计任务已创建并启动");
|
||||
} catch (error) {
|
||||
console.error('❌ 创建任务失败:', error);
|
||||
toast.error("创建任务失败: " + (error as Error).message);
|
||||
|
||||
// 记录错误并显示详细信息
|
||||
import('@/shared/utils/errorHandler').then(({ handleError }) => {
|
||||
handleError(error, '创建审计任务失败');
|
||||
});
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||
toast.error(`创建任务失败: ${errorMessage}`);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,16 +49,16 @@ export default function TerminalProgressDialog({
|
|||
// 取消任务处理
|
||||
const handleCancel = async () => {
|
||||
if (!taskId) return;
|
||||
|
||||
|
||||
if (!confirm('确定要取消此任务吗?已分析的结果将被保留。')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 1. 标记任务为取消状态
|
||||
taskControl.cancelTask(taskId);
|
||||
setIsCancelled(true);
|
||||
addLog("🛑 用户取消任务,正在停止...", "error");
|
||||
|
||||
|
||||
// 2. 立即更新数据库状态
|
||||
try {
|
||||
const { api } = await import("@/shared/config/database");
|
||||
|
|
@ -301,13 +301,55 @@ export default function TerminalProgressDialog({
|
|||
addLog("", "info"); // 空行分隔
|
||||
addLog("❌ 审计任务执行失败", "error");
|
||||
addLog("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "error");
|
||||
addLog("可能的原因:", "error");
|
||||
addLog(" • 网络连接问题", "error");
|
||||
addLog(" • 仓库访问权限不足(私有仓库需配置 Token)", "error");
|
||||
addLog(" • GitHub/GitLab API 限流", "error");
|
||||
addLog(" • 代码文件格式错误", "error");
|
||||
|
||||
// 尝试从日志系统获取具体错误信息
|
||||
try {
|
||||
const { logger } = await import("@/shared/utils/logger");
|
||||
const recentLogs = logger.getLogs({
|
||||
startTime: Date.now() - 60000, // 最近1分钟
|
||||
});
|
||||
|
||||
// 查找与当前任务相关的错误
|
||||
const taskErrors = recentLogs
|
||||
.filter(log =>
|
||||
log.level === 'ERROR' &&
|
||||
(log.message.includes(taskId) ||
|
||||
log.message.includes('审计') ||
|
||||
log.message.includes('API'))
|
||||
)
|
||||
.slice(-3); // 最近3条错误
|
||||
|
||||
if (taskErrors.length > 0) {
|
||||
addLog("具体错误信息:", "error");
|
||||
taskErrors.forEach(log => {
|
||||
addLog(` • ${log.message}`, "error");
|
||||
if (log.data?.error) {
|
||||
const errorMsg = typeof log.data.error === 'string'
|
||||
? log.data.error
|
||||
: log.data.error.message || JSON.stringify(log.data.error);
|
||||
addLog(` ${errorMsg}`, "error");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 如果没有找到具体错误,显示常见原因
|
||||
addLog("可能的原因:", "error");
|
||||
addLog(" • 网络连接问题", "error");
|
||||
addLog(" • 仓库访问权限不足(私有仓库需配置 Token)", "error");
|
||||
addLog(" • GitHub/GitLab API 限流", "error");
|
||||
addLog(" • LLM API 配置错误或额度不足", "error");
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果获取日志失败,显示常见原因
|
||||
addLog("可能的原因:", "error");
|
||||
addLog(" • 网络连接问题", "error");
|
||||
addLog(" • 仓库访问权限不足(私有仓库需配置 Token)", "error");
|
||||
addLog(" • GitHub/GitLab API 限流", "error");
|
||||
addLog(" • LLM API 配置错误或额度不足", "error");
|
||||
}
|
||||
|
||||
addLog("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "error");
|
||||
addLog("💡 建议: 检查网络连接、仓库配置和 Token 设置后重试", "warning");
|
||||
addLog("💡 建议: 检查系统配置和网络连接后重试", "warning");
|
||||
addLog("📋 查看完整日志: 导航栏 -> 系统日志", "warning");
|
||||
|
||||
setIsFailed(true);
|
||||
if (pollIntervalRef.current) {
|
||||
|
|
@ -485,10 +527,10 @@ export default function TerminalProgressDialog({
|
|||
<span className="text-gray-300">
|
||||
{isCancelled ? "🛑 任务已取消,已分析的结果已保存" :
|
||||
isCompleted ? "✅ 任务已完成,可以关闭此窗口" :
|
||||
isFailed ? "❌ 任务失败,请检查配置后重试" :
|
||||
"⏳ 审计进行中,请勿关闭窗口,过程可能较慢,请耐心等待......"}
|
||||
isFailed ? "❌ 任务失败,请检查配置后重试" :
|
||||
"⏳ 审计进行中,请勿关闭窗口,过程可能较慢,请耐心等待......"}
|
||||
</span>
|
||||
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* 运行中显示取消按钮 */}
|
||||
{!isCompleted && !isFailed && !isCancelled && (
|
||||
|
|
@ -502,7 +544,19 @@ export default function TerminalProgressDialog({
|
|||
取消任务
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
{/* 失败时显示查看日志按钮 */}
|
||||
{isFailed && (
|
||||
<button
|
||||
onClick={() => {
|
||||
window.open('/logs', '_blank');
|
||||
}}
|
||||
className="px-4 py-1.5 bg-gradient-to-r from-yellow-600 to-orange-600 hover:from-yellow-500 hover:to-orange-500 text-white rounded text-xs transition-all shadow-lg shadow-yellow-900/50 font-medium"
|
||||
>
|
||||
📋 查看日志
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 已完成/失败/取消显示关闭按钮 */}
|
||||
{(isCompleted || isFailed || isCancelled) && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* React错误边界组件
|
||||
* 捕获组件树中的JavaScript错误并记录
|
||||
*/
|
||||
|
||||
import React, { Component, ReactNode } from 'react';
|
||||
import { logger, LogCategory } from '@/shared/utils/logger';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: React.ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
// 记录错误到日志系统
|
||||
logger.error(
|
||||
LogCategory.CONSOLE_ERROR,
|
||||
`React组件错误: ${error.message}`,
|
||||
{
|
||||
error: error.toString(),
|
||||
componentStack: errorInfo.componentStack,
|
||||
},
|
||||
error.stack
|
||||
);
|
||||
|
||||
this.setState({
|
||||
errorInfo,
|
||||
});
|
||||
|
||||
// 调用自定义错误处理
|
||||
this.props.onError?.(error, errorInfo);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
});
|
||||
};
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleGoHome = () => {
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// 如果提供了自定义fallback,使用它
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// 默认错误UI
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md space-y-6 rounded-lg border bg-card p-6 shadow-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-full bg-destructive/10 p-3">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">出错了</h2>
|
||||
<p className="text-sm text-muted-foreground">应用遇到了一个错误</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{this.state.error && (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-md bg-destructive/10 p-3">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{this.state.error.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{import.meta.env.DEV && this.state.error.stack && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer font-medium text-muted-foreground">
|
||||
查看错误堆栈
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-auto rounded bg-muted p-2 text-xs">
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{import.meta.env.DEV && this.state.errorInfo?.componentStack && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer font-medium text-muted-foreground">
|
||||
查看组件堆栈
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-auto rounded bg-muted p-2 text-xs">
|
||||
{this.state.errorInfo.componentStack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={this.handleReset} variant="outline" className="flex-1">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
重试
|
||||
</Button>
|
||||
<Button onClick={this.handleGoHome} variant="outline" className="flex-1">
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
返回首页
|
||||
</Button>
|
||||
<Button onClick={this.handleReload} className="flex-1">
|
||||
刷新页面
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
错误已被记录,我们会尽快修复
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 高阶组件:为组件添加错误边界
|
||||
*/
|
||||
export function withErrorBoundary<P extends object>(
|
||||
Component: React.ComponentType<P>,
|
||||
fallback?: ReactNode
|
||||
) {
|
||||
return function WithErrorBoundaryComponent(props: P) {
|
||||
return (
|
||||
<ErrorBoundary fallback={fallback}>
|
||||
<Component {...props} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
/**
|
||||
* 日志查看器组件
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { logger, LogLevel, LogCategory, LogEntry } from '@/shared/utils/logger';
|
||||
import { useLogs, useLogStats } from '@/shared/hooks/useLogger';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Trash2, Search, RefreshCw, FileJson, FileSpreadsheet } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function LogViewer() {
|
||||
const [levelFilter, setLevelFilter] = useState<LogLevel | 'ALL'>('ALL');
|
||||
const [categoryFilter, setCategoryFilter] = useState<LogCategory | 'ALL'>('ALL');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
|
||||
|
||||
const filter = useMemo(() => ({
|
||||
level: levelFilter !== 'ALL' ? levelFilter : undefined,
|
||||
category: categoryFilter !== 'ALL' ? categoryFilter : undefined,
|
||||
search: searchQuery || undefined,
|
||||
}), [levelFilter, categoryFilter, searchQuery]);
|
||||
|
||||
const rawLogs = useLogs(filter);
|
||||
// 反转日志顺序,最新的在最上面
|
||||
const logs = useMemo(() => [...rawLogs].reverse(), [rawLogs]);
|
||||
const stats = useLogStats();
|
||||
|
||||
const handleClearLogs = () => {
|
||||
if (confirm('确定要清空所有日志吗?')) {
|
||||
logger.clearLogs();
|
||||
toast.success('日志已清空');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadJson = () => {
|
||||
logger.downloadLogs('json');
|
||||
toast.success('日志已导出为JSON');
|
||||
};
|
||||
|
||||
const handleDownloadCsv = () => {
|
||||
logger.downloadLogs('csv');
|
||||
toast.success('日志已导出为CSV');
|
||||
};
|
||||
|
||||
const getLevelColor = (level: LogLevel) => {
|
||||
const colors = {
|
||||
[LogLevel.DEBUG]: 'bg-gray-500',
|
||||
[LogLevel.INFO]: 'bg-blue-500',
|
||||
[LogLevel.WARN]: 'bg-yellow-500',
|
||||
[LogLevel.ERROR]: 'bg-red-500',
|
||||
[LogLevel.FATAL]: 'bg-red-900',
|
||||
};
|
||||
return colors[level];
|
||||
};
|
||||
|
||||
const getCategoryColor = (category: LogCategory) => {
|
||||
const colors = {
|
||||
[LogCategory.USER_ACTION]: 'bg-green-500',
|
||||
[LogCategory.API_CALL]: 'bg-purple-500',
|
||||
[LogCategory.SYSTEM]: 'bg-blue-500',
|
||||
[LogCategory.CONSOLE_ERROR]: 'bg-red-500',
|
||||
};
|
||||
return colors[category];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4 p-4">
|
||||
{/* 统计信息 */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="text-sm text-muted-foreground">总日志数</div>
|
||||
<div className="text-2xl font-bold">{stats.total}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="text-sm text-muted-foreground">错误数</div>
|
||||
<div className="text-2xl font-bold text-red-500">{stats.errors}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="text-sm text-muted-foreground">当前显示</div>
|
||||
<div className="text-2xl font-bold">{logs.length}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="text-sm text-muted-foreground">最新日志</div>
|
||||
<div className="text-sm">
|
||||
{logs.length > 0 ? new Date(logs[logs.length - 1].timestamp).toLocaleTimeString() : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索日志..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={levelFilter} onValueChange={(v) => setLevelFilter(v as any)}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="所有级别" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">所有级别</SelectItem>
|
||||
{Object.values(LogLevel).map(level => (
|
||||
<SelectItem key={level} value={level}>{level}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={categoryFilter} onValueChange={(v) => setCategoryFilter(v as any)}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="所有分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">所有分类</SelectItem>
|
||||
{Object.values(LogCategory).map(category => (
|
||||
<SelectItem key={category} value={category}>{category}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="outline" size="icon" onClick={() => window.location.reload()} title="刷新页面">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="icon" onClick={handleDownloadJson} title="导出为JSON格式">
|
||||
<FileJson className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="icon" onClick={handleDownloadCsv} title="导出为CSV格式">
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button variant="destructive" size="icon" onClick={handleClearLogs} title="清空日志">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 日志列表 */}
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-hidden lg:flex-row">
|
||||
<div className="flex-1 flex flex-col rounded-lg border bg-card overflow-hidden">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-2">
|
||||
{logs.length === 0 ? (
|
||||
<div className="flex h-40 items-center justify-center text-muted-foreground">
|
||||
没有找到日志
|
||||
</div>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`mb-2 rounded-lg border p-3 transition-colors ${selectedLog?.id === log.id ? 'bg-accent' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<Badge className={`${getLevelColor(log.level)} text-white`}>
|
||||
{log.level}
|
||||
</Badge>
|
||||
<Badge className={`${getCategoryColor(log.category)} text-white`}>
|
||||
{log.category}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(log.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedLog(log)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 text-sm line-clamp-2">{log.message}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日志详情 */}
|
||||
{selectedLog && (
|
||||
<div className="flex w-full flex-col rounded-lg border bg-card lg:w-[600px] overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b p-4 flex-shrink-0">
|
||||
<h3 className="text-lg font-semibold">日志详情</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedLog(null)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-4 space-y-4 text-sm">
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-muted-foreground">ID</div>
|
||||
<div className="rounded bg-muted p-2 font-mono text-xs break-all">{selectedLog.id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-muted-foreground">时间</div>
|
||||
<div className="rounded bg-muted p-2">{new Date(selectedLog.timestamp).toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-muted-foreground">级别</div>
|
||||
<Badge className={`${getLevelColor(selectedLog.level)} text-white`}>
|
||||
{selectedLog.level}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-muted-foreground">分类</div>
|
||||
<Badge className={`${getCategoryColor(selectedLog.category)} text-white`}>
|
||||
{selectedLog.category}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-muted-foreground">消息</div>
|
||||
<div className="rounded bg-muted p-3 whitespace-pre-wrap break-words overflow-auto max-h-96">
|
||||
{selectedLog.message}
|
||||
</div>
|
||||
</div>
|
||||
{selectedLog.data && (
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-muted-foreground">数据</div>
|
||||
<pre className="overflow-auto rounded bg-muted p-3 text-xs max-h-96 whitespace-pre-wrap break-words">
|
||||
{JSON.stringify(selectedLog.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{selectedLog.stack && (
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-muted-foreground">堆栈跟踪</div>
|
||||
<pre className="overflow-auto rounded bg-muted p-3 text-xs max-h-96 whitespace-pre-wrap break-words">
|
||||
{selectedLog.stack}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{selectedLog.url && (
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-muted-foreground">URL</div>
|
||||
<div className="rounded bg-muted p-3 break-all text-xs">{selectedLog.url}</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedLog.userAgent && (
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-muted-foreground">User Agent</div>
|
||||
<div className="rounded bg-muted p-3 break-all text-xs">{selectedLog.userAgent}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ interface SystemConfigData {
|
|||
llmTemperature: number;
|
||||
llmMaxTokens: number;
|
||||
llmCustomHeaders: string;
|
||||
|
||||
|
||||
// 平台专用配置
|
||||
geminiApiKey: string;
|
||||
openaiApiKey: string;
|
||||
|
|
@ -76,13 +76,13 @@ interface SystemConfigData {
|
|||
minimaxApiKey: string;
|
||||
doubaoApiKey: string;
|
||||
ollamaBaseUrl: string;
|
||||
|
||||
|
||||
// GitHub 配置
|
||||
githubToken: string;
|
||||
|
||||
|
||||
// GitLab 配置
|
||||
gitlabToken: string;
|
||||
|
||||
|
||||
// 分析配置
|
||||
maxAnalyzeFiles: number;
|
||||
llmConcurrency: number;
|
||||
|
|
@ -134,7 +134,7 @@ export function SystemConfig() {
|
|||
try {
|
||||
// 尝试从 localStorage 加载运行时配置
|
||||
const savedConfig = localStorage.getItem(STORAGE_KEY);
|
||||
|
||||
|
||||
if (savedConfig) {
|
||||
const parsedConfig = JSON.parse(savedConfig);
|
||||
setConfig(parsedConfig);
|
||||
|
|
@ -188,8 +188,20 @@ export function SystemConfig() {
|
|||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
|
||||
setHasChanges(false);
|
||||
setConfigSource('runtime');
|
||||
|
||||
// 记录用户操作
|
||||
import('@/shared/utils/logger').then(({ logger, LogCategory }) => {
|
||||
logger.logUserAction('保存系统配置', {
|
||||
provider: config.llmProvider,
|
||||
hasApiKey: !!config.llmApiKey,
|
||||
maxFiles: config.maxAnalyzeFiles,
|
||||
concurrency: config.llmConcurrency,
|
||||
language: config.outputLanguage,
|
||||
});
|
||||
});
|
||||
|
||||
toast.success("配置已保存!刷新页面后生效");
|
||||
|
||||
|
||||
// 提示用户刷新页面
|
||||
setTimeout(() => {
|
||||
if (window.confirm("配置已保存。是否立即刷新页面使配置生效?")) {
|
||||
|
|
@ -198,17 +210,42 @@ export function SystemConfig() {
|
|||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Failed to save config:', error);
|
||||
toast.error("保存配置失败");
|
||||
|
||||
// 记录错误并显示详细信息
|
||||
import('@/shared/utils/errorHandler').then(({ handleError }) => {
|
||||
handleError(error, '保存系统配置失败');
|
||||
});
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||
toast.error(`保存配置失败: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
const resetConfig = () => {
|
||||
if (window.confirm("确定要重置为构建时配置吗?这将清除所有运行时配置。")) {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
loadFromEnv();
|
||||
setHasChanges(false);
|
||||
setConfigSource('build');
|
||||
toast.success("已重置为构建时配置");
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
loadFromEnv();
|
||||
setHasChanges(false);
|
||||
setConfigSource('build');
|
||||
|
||||
// 记录用户操作
|
||||
import('@/shared/utils/logger').then(({ logger, LogCategory }) => {
|
||||
logger.logUserAction('重置系统配置', { action: 'reset_to_build_config' });
|
||||
});
|
||||
|
||||
toast.success("已重置为构建时配置");
|
||||
} catch (error) {
|
||||
console.error('Failed to reset config:', error);
|
||||
|
||||
// 记录错误并显示详细信息
|
||||
import('@/shared/utils/errorHandler').then(({ handleError }) => {
|
||||
handleError(error, '重置系统配置失败');
|
||||
});
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||
toast.error(`重置配置失败: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -236,7 +273,7 @@ export function SystemConfig() {
|
|||
doubao: config.doubaoApiKey,
|
||||
ollama: 'ollama',
|
||||
};
|
||||
|
||||
|
||||
return config.llmApiKey || keyMap[provider] || '';
|
||||
};
|
||||
|
||||
|
|
@ -386,7 +423,7 @@ export function SystemConfig() {
|
|||
placeholder="例如:https://api.example.com/v1"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>💡 <strong>使用 API 中转站?</strong>在这里填入中转站地址</p>
|
||||
<p>💡 <strong>使用 API 中转站?</strong>在这里填入中转站地址。配置保存后会在实际使用时自动验证。</p>
|
||||
<details className="cursor-pointer">
|
||||
<summary className="text-primary hover:underline">查看常见 API 中转示例</summary>
|
||||
<div className="mt-2 p-3 bg-muted rounded space-y-1 text-xs">
|
||||
|
|
|
|||
|
|
@ -75,6 +75,17 @@ export async function runRepositoryAudit(params: {
|
|||
|
||||
console.log(`🚀 ${repoType}任务已创建: ${taskId},准备启动后台扫描...`);
|
||||
|
||||
// 记录审计任务开始
|
||||
import('@/shared/utils/logger').then(({ logger, LogCategory }) => {
|
||||
logger.info(LogCategory.SYSTEM, `开始审计任务: ${taskId}`, {
|
||||
taskId,
|
||||
projectId: params.projectId,
|
||||
repoUrl: params.repoUrl,
|
||||
branch,
|
||||
repoType,
|
||||
});
|
||||
});
|
||||
|
||||
// 启动后台审计任务,不阻塞返回
|
||||
(async () => {
|
||||
console.log(`🎬 后台扫描任务开始执行: ${taskId}`);
|
||||
|
|
@ -283,10 +294,29 @@ export async function runRepositoryAudit(params: {
|
|||
completed_at: new Date().toISOString()
|
||||
} as any);
|
||||
|
||||
// 记录审计完成
|
||||
import('@/shared/utils/logger').then(({ logger, LogCategory }) => {
|
||||
logger.info(LogCategory.SYSTEM, `审计任务完成: ${taskId}`, {
|
||||
taskId,
|
||||
totalFiles: files.length,
|
||||
scannedFiles: totalFiles,
|
||||
totalLines,
|
||||
issuesCount: createdIssues,
|
||||
qualityScore,
|
||||
failedCount,
|
||||
});
|
||||
});
|
||||
|
||||
taskControl.cleanupTask(taskId);
|
||||
} catch (e) {
|
||||
console.error('❌ GitHub审计任务执行失败:', e);
|
||||
console.error('错误详情:', e);
|
||||
|
||||
// 记录审计失败
|
||||
import('@/shared/utils/errorHandler').then(({ handleError }) => {
|
||||
handleError(e, `审计任务失败: ${taskId}`);
|
||||
});
|
||||
|
||||
try {
|
||||
await api.updateAuditTask(taskId, { status: "failed" } as any);
|
||||
} catch (updateError) {
|
||||
|
|
|
|||
|
|
@ -139,6 +139,16 @@ export async function scanZipFile(params: {
|
|||
|
||||
console.log(`🚀 ZIP任务已创建: ${taskId},准备启动后台扫描...`);
|
||||
|
||||
// 记录审计任务开始
|
||||
import('@/shared/utils/logger').then(({ logger, LogCategory }) => {
|
||||
logger.info(LogCategory.SYSTEM, `开始ZIP文件审计: ${taskId}`, {
|
||||
taskId,
|
||||
projectId,
|
||||
fileName: zipFile.name,
|
||||
fileSize: zipFile.size,
|
||||
});
|
||||
});
|
||||
|
||||
// 启动后台扫描任务,不阻塞返回
|
||||
(async () => {
|
||||
console.log(`🎬 后台扫描任务开始执行: ${taskId}`);
|
||||
|
|
@ -344,9 +354,30 @@ export async function scanZipFile(params: {
|
|||
completed_at: new Date().toISOString()
|
||||
} as any);
|
||||
|
||||
// 记录审计完成
|
||||
import('@/shared/utils/logger').then(({ logger, LogCategory }) => {
|
||||
logger.info(LogCategory.SYSTEM, `ZIP审计任务完成: ${taskId}`, {
|
||||
taskId,
|
||||
status: taskStatus,
|
||||
totalFiles,
|
||||
scannedFiles,
|
||||
failedFiles,
|
||||
totalLines,
|
||||
issuesCount: totalIssues,
|
||||
qualityScore: avgQualityScore,
|
||||
successRate: successRate.toFixed(1) + '%',
|
||||
});
|
||||
});
|
||||
|
||||
resolve();
|
||||
} catch (processingError) {
|
||||
await api.updateAuditTask(taskId, { status: "failed" } as any);
|
||||
|
||||
// 记录处理错误
|
||||
import('@/shared/utils/errorHandler').then(({ handleError }) => {
|
||||
handleError(processingError, `ZIP审计任务处理失败: ${taskId}`);
|
||||
});
|
||||
|
||||
reject(processingError);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -281,7 +281,7 @@ public class Example {
|
|||
// 构造临时任务和问题数据用于导出
|
||||
const getTempTaskAndIssues = () => {
|
||||
if (!result) return null;
|
||||
|
||||
|
||||
const tempTask: AuditTask = {
|
||||
id: 'instant-' + Date.now(),
|
||||
project_id: 'instant-analysis',
|
||||
|
|
@ -313,7 +313,7 @@ public class Example {
|
|||
updated_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const tempIssues: AuditIssue[] = result.issues.map((issue, index) => ({
|
||||
id: `instant-issue-${index}`,
|
||||
task_id: tempTask.id,
|
||||
|
|
@ -332,7 +332,7 @@ public class Example {
|
|||
resolved_at: undefined,
|
||||
created_at: new Date().toISOString()
|
||||
}));
|
||||
|
||||
|
||||
return { task: tempTask, issues: tempIssues };
|
||||
};
|
||||
|
||||
|
|
@ -618,10 +618,10 @@ public class Example {
|
|||
<Badge variant="outline" className="text-xs">
|
||||
{language.charAt(0).toUpperCase() + language.slice(1)}
|
||||
</Badge>
|
||||
|
||||
|
||||
{/* 导出按钮 */}
|
||||
<Button
|
||||
size="sm"
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setExportDialogOpen(true)}
|
||||
className="btn-primary"
|
||||
>
|
||||
|
|
@ -782,9 +782,9 @@ public class Example {
|
|||
|
||||
{/* 分析进行中状态 */}
|
||||
{analyzing && (
|
||||
<Card ref={loadingCardRef} className="card-modern">
|
||||
<Card className="card-modern">
|
||||
<CardContent className="py-16">
|
||||
<div className="text-center">
|
||||
<div ref={loadingCardRef} className="text-center">
|
||||
<div className="w-20 h-20 bg-red-50 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
|
|
@ -801,7 +801,7 @@ public class Example {
|
|||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
{/* 导出报告对话框 */}
|
||||
{result && (() => {
|
||||
const data = getTempTaskAndIssues();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* 日志查看页面
|
||||
*/
|
||||
|
||||
import { LogViewer } from '@/components/debug/LogViewer';
|
||||
|
||||
export default function LogsPage() {
|
||||
return (
|
||||
<div className="h-screen">
|
||||
<LogViewer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -100,13 +100,29 @@ export default function Projects() {
|
|||
// 无登录场景下不传 owner_id,由后端置为 null
|
||||
} as any);
|
||||
|
||||
// 记录用户操作
|
||||
import('@/shared/utils/logger').then(({ logger, LogCategory }) => {
|
||||
logger.logUserAction('创建项目', {
|
||||
projectName: createForm.name,
|
||||
repositoryType: createForm.repository_type,
|
||||
languages: createForm.programming_languages,
|
||||
});
|
||||
});
|
||||
|
||||
toast.success("项目创建成功");
|
||||
setShowCreateDialog(false);
|
||||
resetCreateForm();
|
||||
loadProjects();
|
||||
} catch (error) {
|
||||
console.error('Failed to create project:', error);
|
||||
toast.error("创建项目失败");
|
||||
|
||||
// 记录错误并显示详细信息
|
||||
import('@/shared/utils/errorHandler').then(({ handleError }) => {
|
||||
handleError(error, '创建项目失败');
|
||||
});
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||
toast.error(`创建项目失败: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -169,6 +185,15 @@ export default function Projects() {
|
|||
clearInterval(progressInterval);
|
||||
setUploadProgress(100);
|
||||
|
||||
// 记录用户操作
|
||||
import('@/shared/utils/logger').then(({ logger, LogCategory }) => {
|
||||
logger.logUserAction('上传ZIP文件创建项目', {
|
||||
projectName: project.name,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
});
|
||||
});
|
||||
|
||||
// 关闭创建对话框
|
||||
setShowCreateDialog(false);
|
||||
resetCreateForm();
|
||||
|
|
@ -181,7 +206,14 @@ export default function Projects() {
|
|||
|
||||
} catch (error: any) {
|
||||
console.error('Upload failed:', error);
|
||||
toast.error(error.message || "上传失败");
|
||||
|
||||
// 记录错误并显示详细信息
|
||||
import('@/shared/utils/errorHandler').then(({ handleError }) => {
|
||||
handleError(error, '上传ZIP文件失败');
|
||||
});
|
||||
|
||||
const errorMessage = error?.message || '未知错误';
|
||||
toast.error(`上传失败: ${errorMessage}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
|
|
@ -265,6 +297,15 @@ export default function Projects() {
|
|||
|
||||
try {
|
||||
await api.deleteProject(projectToDelete.id);
|
||||
|
||||
// 记录用户操作
|
||||
import('@/shared/utils/logger').then(({ logger, LogCategory }) => {
|
||||
logger.logUserAction('删除项目', {
|
||||
projectId: projectToDelete.id,
|
||||
projectName: projectToDelete.name,
|
||||
});
|
||||
});
|
||||
|
||||
toast.success(`项目 "${projectToDelete.name}" 已移到回收站`, {
|
||||
description: '您可以在回收站中恢复此项目',
|
||||
duration: 4000
|
||||
|
|
@ -274,7 +315,14 @@ export default function Projects() {
|
|||
loadProjects();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete project:', error);
|
||||
toast.error("删除项目失败");
|
||||
|
||||
// 记录错误并显示详细信息
|
||||
import('@/shared/utils/errorHandler').then(({ handleError }) => {
|
||||
handleError(error, '删除项目失败');
|
||||
});
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||
toast.error(`删除项目失败: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* 日志记录 Hook
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useState } from 'react';
|
||||
import { logger, LogEntry, LogLevel, LogCategory } from '../utils/logger';
|
||||
|
||||
export function useLogger() {
|
||||
const logUserAction = useCallback((action: string, details?: any) => {
|
||||
logger.logUserAction(action, details);
|
||||
}, []);
|
||||
|
||||
const logApiCall = useCallback((
|
||||
method: string,
|
||||
url: string,
|
||||
status?: number,
|
||||
duration?: number,
|
||||
error?: any
|
||||
) => {
|
||||
logger.logApiCall(method, url, status, duration, error);
|
||||
}, []);
|
||||
|
||||
const logPerformance = useCallback((metric: string, value: number, unit?: string) => {
|
||||
logger.logPerformance(metric, value, unit);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
debug: logger.debug.bind(logger),
|
||||
info: logger.info.bind(logger),
|
||||
warn: logger.warn.bind(logger),
|
||||
error: logger.error.bind(logger),
|
||||
fatal: logger.fatal.bind(logger),
|
||||
logUserAction,
|
||||
logApiCall,
|
||||
logPerformance,
|
||||
};
|
||||
}
|
||||
|
||||
export function useLogListener(callback: (log: LogEntry) => void) {
|
||||
useEffect(() => {
|
||||
const unsubscribe = logger.addListener(callback);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [callback]);
|
||||
}
|
||||
|
||||
export function useLogs(filter?: {
|
||||
level?: LogLevel;
|
||||
category?: LogCategory;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
search?: string;
|
||||
}) {
|
||||
const [logs, setLogs] = useState<LogEntry[]>(() => logger.getLogs(filter));
|
||||
|
||||
// 将filter转换为字符串作为依赖,避免对象引用问题
|
||||
const filterKey = JSON.stringify(filter);
|
||||
|
||||
useEffect(() => {
|
||||
// 立即更新一次
|
||||
setLogs(logger.getLogs(filter));
|
||||
|
||||
const updateLogs = () => {
|
||||
setLogs(logger.getLogs(filter));
|
||||
};
|
||||
|
||||
const unsubscribe = logger.addListener(updateLogs);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [filterKey]); // 使用filterKey而不是filter对象
|
||||
|
||||
return logs;
|
||||
}
|
||||
|
||||
export function useLogStats() {
|
||||
const [stats, setStats] = useState(() => logger.getStats());
|
||||
|
||||
useEffect(() => {
|
||||
const updateStats = () => {
|
||||
setStats(logger.getStats());
|
||||
};
|
||||
|
||||
const unsubscribe = logger.addListener(updateStats);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ export class ClaudeAdapter extends BaseLLMAdapter {
|
|||
Object.assign(headers, this.config.customHeaders);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/messages`, {
|
||||
const response = await fetch(`${this.baseUrl.replace(/\/$/, '')}/messages`, {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(headers),
|
||||
body: JSON.stringify(requestBody),
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export class DeepSeekAdapter extends BaseLLMAdapter {
|
|||
Object.assign(headers, this.config.customHeaders);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/v1/chat/completions`, {
|
||||
const response = await fetch(`${this.baseUrl.replace(/\/$/, '')}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(headers),
|
||||
body: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export class DoubaoAdapter extends BaseLLMAdapter {
|
|||
};
|
||||
if (this.config.customHeaders) Object.assign(headers, this.config.customHeaders);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
const response = await fetch(`${this.baseUrl.replace(/\/$/, '')}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(headers),
|
||||
body: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export class MinimaxAdapter extends BaseLLMAdapter {
|
|||
};
|
||||
if (this.config.customHeaders) Object.assign(headers, this.config.customHeaders);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/text/chatcompletion_v2`, {
|
||||
const response = await fetch(`${this.baseUrl.replace(/\/$/, '')}/text/chatcompletion_v2`, {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(headers),
|
||||
body: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export class MoonshotAdapter extends BaseLLMAdapter {
|
|||
};
|
||||
if (this.config.customHeaders) Object.assign(headers, this.config.customHeaders);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
const response = await fetch(`${this.baseUrl.replace(/\/$/, '')}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(headers),
|
||||
body: JSON.stringify({
|
||||
|
|
@ -73,11 +73,11 @@ export class MoonshotAdapter extends BaseLLMAdapter {
|
|||
|
||||
async validateConfig(): Promise<boolean> {
|
||||
await super.validateConfig();
|
||||
|
||||
|
||||
if (!this.config.model.startsWith('moonshot-')) {
|
||||
throw new Error(`无效的Moonshot模型: ${this.config.model}`);
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export class OllamaAdapter extends BaseLLMAdapter {
|
|||
Object.assign(headers, this.config.customHeaders);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
const response = await fetch(`${this.baseUrl.replace(/\/$/, '')}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(headers),
|
||||
body: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export class OpenAIAdapter extends BaseLLMAdapter {
|
|||
requestBody.max_tokens = request.maxTokens ?? this.config.maxTokens;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
const response = await fetch(`${this.baseUrl.replace(/\/$/, '')}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(headers),
|
||||
body: JSON.stringify(requestBody),
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export class QwenAdapter extends BaseLLMAdapter {
|
|||
};
|
||||
if (this.config.customHeaders) Object.assign(headers, this.config.customHeaders);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/services/aigc/text-generation/generation`, {
|
||||
const response = await fetch(`${this.baseUrl.replace(/\/$/, '')}/services/aigc/text-generation/generation`, {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(headers),
|
||||
body: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export class ZhipuAdapter extends BaseLLMAdapter {
|
|||
};
|
||||
if (this.config.customHeaders) Object.assign(headers, this.config.customHeaders);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
const response = await fetch(`${this.baseUrl.replace(/\/$/, '')}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(headers),
|
||||
body: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,189 @@
|
|||
/**
|
||||
* API拦截器
|
||||
* 自动记录所有API调用和响应
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
|
||||
import { logger, LogCategory } from './logger';
|
||||
import { errorHandler } from './errorHandler';
|
||||
|
||||
interface RequestMetadata {
|
||||
startTime: number;
|
||||
url: string;
|
||||
method: string;
|
||||
}
|
||||
|
||||
const requestMetadataMap = new WeakMap<any, RequestMetadata>();
|
||||
|
||||
/**
|
||||
* 设置Axios拦截器
|
||||
*/
|
||||
export function setupAxiosInterceptors(instance: AxiosInstance) {
|
||||
// 请求拦截器
|
||||
instance.interceptors.request.use(
|
||||
(config) => {
|
||||
const metadata: RequestMetadata = {
|
||||
startTime: Date.now(),
|
||||
url: config.url || '',
|
||||
method: (config.method || 'GET').toUpperCase(),
|
||||
};
|
||||
requestMetadataMap.set(config, metadata);
|
||||
|
||||
logger.debug(
|
||||
LogCategory.API_CALL,
|
||||
`→ ${metadata.method} ${metadata.url}`,
|
||||
{
|
||||
method: metadata.method,
|
||||
url: metadata.url,
|
||||
params: config.params,
|
||||
data: config.data,
|
||||
}
|
||||
);
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
logger.error(LogCategory.API_CALL, 'Request interceptor error', { error });
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
instance.interceptors.response.use(
|
||||
(response) => {
|
||||
const metadata = requestMetadataMap.get(response.config);
|
||||
if (metadata) {
|
||||
const duration = Date.now() - metadata.startTime;
|
||||
|
||||
logger.info(
|
||||
LogCategory.API_CALL,
|
||||
`← ${metadata.method} ${metadata.url} (${response.status}) - ${duration}ms`,
|
||||
{
|
||||
method: metadata.method,
|
||||
url: metadata.url,
|
||||
status: response.status,
|
||||
duration,
|
||||
data: response.data,
|
||||
}
|
||||
);
|
||||
|
||||
// 记录性能
|
||||
if (duration > 1000) {
|
||||
logger.warn(
|
||||
LogCategory.PERFORMANCE,
|
||||
`Slow API call: ${metadata.method} ${metadata.url} took ${duration}ms`,
|
||||
{ method: metadata.method, url: metadata.url, duration }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
const metadata = requestMetadataMap.get(error.config);
|
||||
if (metadata) {
|
||||
const duration = Date.now() - metadata.startTime;
|
||||
|
||||
logger.error(
|
||||
LogCategory.API_CALL,
|
||||
`✗ ${metadata.method} ${metadata.url} - ${duration}ms`,
|
||||
{
|
||||
method: metadata.method,
|
||||
url: metadata.url,
|
||||
status: error.response?.status,
|
||||
duration,
|
||||
error: error.response?.data || error.message,
|
||||
},
|
||||
error.stack
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带拦截器的Axios实例
|
||||
*/
|
||||
export function createApiClient(config?: AxiosRequestConfig): AxiosInstance {
|
||||
const instance = axios.create(config);
|
||||
return setupAxiosInterceptors(instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch API包装器
|
||||
*/
|
||||
export async function fetchWithLogging(
|
||||
url: string,
|
||||
options?: RequestInit
|
||||
): Promise<Response> {
|
||||
const method = options?.method || 'GET';
|
||||
const startTime = Date.now();
|
||||
|
||||
logger.debug(LogCategory.API_CALL, `→ ${method} ${url}`, {
|
||||
method,
|
||||
url,
|
||||
options,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
logger.info(
|
||||
LogCategory.API_CALL,
|
||||
`← ${method} ${url} (${response.status}) - ${duration}ms`,
|
||||
{
|
||||
method,
|
||||
url,
|
||||
status: response.status,
|
||||
duration,
|
||||
}
|
||||
);
|
||||
|
||||
if (duration > 1000) {
|
||||
logger.warn(
|
||||
LogCategory.PERFORMANCE,
|
||||
`Slow fetch call: ${method} ${url} took ${duration}ms`,
|
||||
{ method, url, duration }
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
logger.error(
|
||||
LogCategory.API_CALL,
|
||||
`✗ ${method} ${url} - ${duration}ms`,
|
||||
{
|
||||
method,
|
||||
url,
|
||||
duration,
|
||||
error,
|
||||
}
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装异步函数以自动记录错误
|
||||
*/
|
||||
export function withErrorLogging<T extends (...args: any[]) => Promise<any>>(
|
||||
fn: T,
|
||||
context?: string
|
||||
): T {
|
||||
return (async (...args: any[]) => {
|
||||
try {
|
||||
return await fn(...args);
|
||||
} catch (error) {
|
||||
errorHandler.handle(error, context);
|
||||
throw error;
|
||||
}
|
||||
}) as T;
|
||||
}
|
||||
|
|
@ -0,0 +1,308 @@
|
|||
/**
|
||||
* 错误处理模块
|
||||
* 提供统一的错误处理和用户反馈机制
|
||||
*/
|
||||
|
||||
import { toast } from 'sonner';
|
||||
import { logger, LogCategory } from './logger';
|
||||
|
||||
export enum ErrorType {
|
||||
NETWORK = 'NETWORK',
|
||||
API = 'API',
|
||||
VALIDATION = 'VALIDATION',
|
||||
AUTHENTICATION = 'AUTHENTICATION',
|
||||
AUTHORIZATION = 'AUTHORIZATION',
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
TIMEOUT = 'TIMEOUT',
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
}
|
||||
|
||||
export interface AppError {
|
||||
type: ErrorType;
|
||||
message: string;
|
||||
originalError?: any;
|
||||
code?: string | number;
|
||||
details?: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
class ErrorHandler {
|
||||
/**
|
||||
* 处理错误并显示用户友好的提示
|
||||
*/
|
||||
handle(error: any, context?: string): AppError {
|
||||
const appError = this.parseError(error);
|
||||
|
||||
// 记录错误日志
|
||||
logger.error(
|
||||
LogCategory.SYSTEM,
|
||||
`${context ? `[${context}] ` : ''}${appError.message}`,
|
||||
{
|
||||
type: appError.type,
|
||||
code: appError.code,
|
||||
details: appError.details,
|
||||
originalError: error,
|
||||
},
|
||||
error?.stack
|
||||
);
|
||||
|
||||
// 显示用户提示
|
||||
this.showErrorToast(appError, context);
|
||||
|
||||
return appError;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析错误对象
|
||||
*/
|
||||
private parseError(error: any): AppError {
|
||||
const timestamp = Date.now();
|
||||
|
||||
// Axios错误
|
||||
if (error?.isAxiosError || error?.response) {
|
||||
return this.parseAxiosError(error, timestamp);
|
||||
}
|
||||
|
||||
// Fetch错误
|
||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||
return {
|
||||
type: ErrorType.NETWORK,
|
||||
message: '网络连接失败,请检查网络设置',
|
||||
originalError: error,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// 自定义AppError
|
||||
if (error?.type && Object.values(ErrorType).includes(error.type)) {
|
||||
return { ...error, timestamp };
|
||||
}
|
||||
|
||||
// 标准Error对象
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
type: ErrorType.UNKNOWN,
|
||||
message: error.message || '发生未知错误',
|
||||
originalError: error,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// 其他类型
|
||||
return {
|
||||
type: ErrorType.UNKNOWN,
|
||||
message: typeof error === 'string' ? error : '发生未知错误',
|
||||
originalError: error,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析Axios错误
|
||||
*/
|
||||
private parseAxiosError(error: any, timestamp: number): AppError {
|
||||
const response = error.response;
|
||||
const status = response?.status;
|
||||
|
||||
// 网络错误
|
||||
if (!response) {
|
||||
return {
|
||||
type: ErrorType.NETWORK,
|
||||
message: '网络请求失败,请检查网络连接',
|
||||
originalError: error,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// 根据状态码分类
|
||||
switch (status) {
|
||||
case 400:
|
||||
return {
|
||||
type: ErrorType.VALIDATION,
|
||||
message: response.data?.message || '请求参数错误',
|
||||
code: status,
|
||||
details: response.data,
|
||||
originalError: error,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
case 401:
|
||||
return {
|
||||
type: ErrorType.AUTHENTICATION,
|
||||
message: '未登录或登录已过期,请重新登录',
|
||||
code: status,
|
||||
originalError: error,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
case 403:
|
||||
return {
|
||||
type: ErrorType.AUTHORIZATION,
|
||||
message: '没有权限执行此操作',
|
||||
code: status,
|
||||
originalError: error,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
case 404:
|
||||
return {
|
||||
type: ErrorType.NOT_FOUND,
|
||||
message: '请求的资源不存在',
|
||||
code: status,
|
||||
originalError: error,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
case 408:
|
||||
case 504:
|
||||
return {
|
||||
type: ErrorType.TIMEOUT,
|
||||
message: '请求超时,请稍后重试',
|
||||
code: status,
|
||||
originalError: error,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
return {
|
||||
type: ErrorType.API,
|
||||
message: '服务器错误,请稍后重试',
|
||||
code: status,
|
||||
details: response.data,
|
||||
originalError: error,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
type: ErrorType.API,
|
||||
message: response.data?.message || `请求失败 (${status})`,
|
||||
code: status,
|
||||
details: response.data,
|
||||
originalError: error,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示错误提示
|
||||
*/
|
||||
private showErrorToast(error: AppError, context?: string) {
|
||||
const title = context || this.getErrorTitle(error.type);
|
||||
|
||||
toast.error(title, {
|
||||
description: error.message,
|
||||
duration: 5000,
|
||||
action: error.code ? {
|
||||
label: '查看详情',
|
||||
onClick: () => this.showErrorDetails(error),
|
||||
} : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误标题
|
||||
*/
|
||||
private getErrorTitle(type: ErrorType): string {
|
||||
const titles = {
|
||||
[ErrorType.NETWORK]: '网络错误',
|
||||
[ErrorType.API]: 'API错误',
|
||||
[ErrorType.VALIDATION]: '验证错误',
|
||||
[ErrorType.AUTHENTICATION]: '认证错误',
|
||||
[ErrorType.AUTHORIZATION]: '权限错误',
|
||||
[ErrorType.NOT_FOUND]: '资源不存在',
|
||||
[ErrorType.TIMEOUT]: '请求超时',
|
||||
[ErrorType.UNKNOWN]: '未知错误',
|
||||
};
|
||||
return titles[type];
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示错误详情
|
||||
*/
|
||||
private showErrorDetails(error: AppError) {
|
||||
console.group('错误详情');
|
||||
console.log('类型:', error.type);
|
||||
console.log('消息:', error.message);
|
||||
console.log('代码:', error.code);
|
||||
console.log('时间:', new Date(error.timestamp).toLocaleString());
|
||||
if (error.details) {
|
||||
console.log('详情:', error.details);
|
||||
}
|
||||
if (error.originalError) {
|
||||
console.log('原始错误:', error.originalError);
|
||||
}
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建错误
|
||||
*/
|
||||
createError(type: ErrorType, message: string, details?: any): AppError {
|
||||
return {
|
||||
type,
|
||||
message,
|
||||
details,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步函数错误包装器
|
||||
*/
|
||||
async wrap<T>(
|
||||
fn: () => Promise<T>,
|
||||
context?: string,
|
||||
options?: {
|
||||
silent?: boolean;
|
||||
fallback?: T;
|
||||
}
|
||||
): Promise<T | undefined> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (!options?.silent) {
|
||||
this.handle(error, context);
|
||||
} else {
|
||||
logger.error(LogCategory.SYSTEM, `${context || 'Error'}: ${error}`, { error });
|
||||
}
|
||||
return options?.fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步函数错误包装器
|
||||
*/
|
||||
wrapSync<T>(
|
||||
fn: () => T,
|
||||
context?: string,
|
||||
options?: {
|
||||
silent?: boolean;
|
||||
fallback?: T;
|
||||
}
|
||||
): T | undefined {
|
||||
try {
|
||||
return fn();
|
||||
} catch (error) {
|
||||
if (!options?.silent) {
|
||||
this.handle(error, context);
|
||||
} else {
|
||||
logger.error(LogCategory.SYSTEM, `${context || 'Error'}: ${error}`, { error });
|
||||
}
|
||||
return options?.fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const errorHandler = new ErrorHandler();
|
||||
|
||||
// 便捷函数
|
||||
export const handleError = (error: any, context?: string) => errorHandler.handle(error, context);
|
||||
export const wrapAsync = <T>(fn: () => Promise<T>, context?: string, options?: any) =>
|
||||
errorHandler.wrap(fn, context, options);
|
||||
export const wrapSync = <T>(fn: () => T, context?: string, options?: any) =>
|
||||
errorHandler.wrapSync(fn, context, options);
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Fetch包装器 - 只记录失败的API调用
|
||||
*/
|
||||
|
||||
import { logger, LogCategory } from './logger';
|
||||
|
||||
const originalFetch = window.fetch;
|
||||
|
||||
/**
|
||||
* 判断是否应该记录该URL
|
||||
*/
|
||||
function shouldLogUrl(url: string): boolean {
|
||||
// 过滤掉静态资源和某些不需要记录的请求
|
||||
const skipPatterns = [
|
||||
/\.(png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)$/i,
|
||||
/\/assets\//,
|
||||
/chrome-extension:/,
|
||||
/localhost:.*\/node_modules/,
|
||||
];
|
||||
|
||||
return !skipPatterns.some(pattern => pattern.test(url));
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装fetch - 只记录错误
|
||||
*/
|
||||
window.fetch = async function (...args: Parameters<typeof fetch>): Promise<Response> {
|
||||
const [url, options] = args;
|
||||
const method = options?.method || 'GET';
|
||||
const urlString = typeof url === 'string' ? url : url.toString();
|
||||
|
||||
// 跳过不需要记录的URL
|
||||
if (!shouldLogUrl(urlString)) {
|
||||
return originalFetch(...args);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await originalFetch(...args);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// 只记录失败的请求
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
LogCategory.API_CALL,
|
||||
`API请求失败: ${method} ${urlString} (${response.status})`,
|
||||
{ method, url: urlString, status: response.status, statusText: response.statusText, duration }
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// 记录网络错误
|
||||
logger.error(
|
||||
LogCategory.API_CALL,
|
||||
`API请求异常: ${method} ${urlString}`,
|
||||
{
|
||||
method,
|
||||
url: urlString,
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export { originalFetch };
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* 工具函数统一导出
|
||||
*/
|
||||
|
||||
export * from './logger';
|
||||
export * from './errorHandler';
|
||||
export * from './performanceMonitor';
|
||||
|
|
@ -0,0 +1,382 @@
|
|||
/**
|
||||
* 日志记录模块
|
||||
* 提供统一的日志记录、存储和导出功能
|
||||
*/
|
||||
|
||||
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();
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* 性能监控模块
|
||||
*/
|
||||
|
||||
import { logger } from './logger';
|
||||
|
||||
class PerformanceMonitor {
|
||||
private marks = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* 开始计时
|
||||
*/
|
||||
start(label: string) {
|
||||
this.marks.set(label, performance.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束计时并记录
|
||||
*/
|
||||
end(label: string, logToConsole = false) {
|
||||
const startTime = this.marks.get(label);
|
||||
if (!startTime) {
|
||||
console.warn(`Performance mark "${label}" not found`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
this.marks.delete(label);
|
||||
|
||||
logger.logPerformance(label, Math.round(duration));
|
||||
|
||||
if (logToConsole) {
|
||||
console.log(`⏱️ ${label}: ${duration.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
return duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测量函数执行时间
|
||||
*/
|
||||
async measure<T>(label: string, fn: () => T | Promise<T>): Promise<T> {
|
||||
this.start(label);
|
||||
try {
|
||||
const result = await fn();
|
||||
this.end(label);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.end(label);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 监控页面性能指标 - 禁用自动监控
|
||||
*/
|
||||
monitorPagePerformance() {
|
||||
// 不自动记录页面性能,只在需要时手动调用
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 监控资源加载 - 禁用
|
||||
*/
|
||||
monitorResourceLoading() {
|
||||
// 不记录资源加载
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 监控内存使用 - 禁用
|
||||
*/
|
||||
monitorMemory() {
|
||||
// 不记录内存使用
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 监控长任务 - 禁用
|
||||
*/
|
||||
monitorLongTasks() {
|
||||
// 不记录长任务
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化所有监控
|
||||
*/
|
||||
initAll() {
|
||||
this.monitorPagePerformance();
|
||||
this.monitorResourceLoading();
|
||||
this.monitorMemory();
|
||||
this.monitorLongTasks();
|
||||
}
|
||||
}
|
||||
|
||||
export const performanceMonitor = new PerformanceMonitor();
|
||||
Loading…
Reference in New Issue