diff --git a/backend/api/handlers.go b/backend/api/handlers.go
index ab99a4e..de8afb6 100644
--- a/backend/api/handlers.go
+++ b/backend/api/handlers.go
@@ -18,6 +18,23 @@ type APIHandler struct {
DB *gorm.DB
}
+// RequestLogListItem 精简的请求日志列表项(不包含RequestBody和ResponseBody)
+type RequestLogListItem struct {
+ ID uint `json:"id"`
+ APIKeyID uint `json:"api_key_id"`
+ ProviderName string `json:"provider_name"`
+ VirtualModelName string `json:"virtual_model_name"`
+ BackendModelName string `json:"backend_model_name"`
+ RequestTimestamp time.Time `json:"request_timestamp"`
+ ResponseTimestamp time.Time `json:"response_timestamp"`
+ StatusCode int `json:"status_code,omitempty"`
+ ErrorMessage string `json:"error_message,omitempty"`
+ RequestTokens int `json:"request_tokens"`
+ ResponseTokens int `json:"response_tokens"`
+ Cost float64 `json:"cost"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
// HealthCheckHandler 健康检查端点(无需认证)
func (h *APIHandler) HealthCheckHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
@@ -182,7 +199,7 @@ func copyResponseHeaders(c *gin.Context, resp *http.Response) {
}
}
-// GetRequestLogsHandler 获取请求日志列表
+// GetRequestLogsHandler 获取请求日志列表(精简版,不包含RequestBody和ResponseBody)
func (h *APIHandler) GetRequestLogsHandler(c *gin.Context) {
// 获取分页参数
page := 1
@@ -232,7 +249,7 @@ func (h *APIHandler) GetRequestLogsHandler(c *gin.Context) {
return
}
- // 获取日志列表
+ // 获取日志列表(完整数据)
var logs []models.RequestLog
offset := (page - 1) * pageSize
if err := query.Order("request_timestamp DESC").Limit(pageSize).Offset(offset).Find(&logs).Error; err != nil {
@@ -240,8 +257,26 @@ func (h *APIHandler) GetRequestLogsHandler(c *gin.Context) {
return
}
+ // 转换为精简DTO
+ logItems := make([]RequestLogListItem, len(logs))
+ for i, log := range logs {
+ logItems[i] = RequestLogListItem{
+ ID: log.ID,
+ APIKeyID: log.APIKeyID,
+ ProviderName: log.ProviderName,
+ VirtualModelName: log.VirtualModelName,
+ BackendModelName: log.BackendModelName,
+ RequestTimestamp: log.RequestTimestamp,
+ ResponseTimestamp: log.ResponseTimestamp,
+ RequestTokens: log.RequestTokens,
+ ResponseTokens: log.ResponseTokens,
+ Cost: log.Cost,
+ CreatedAt: log.CreatedAt,
+ }
+ }
+
c.JSON(http.StatusOK, gin.H{
- "logs": logs,
+ "logs": logItems,
"total": total,
"page": page,
"page_size": pageSize,
@@ -249,6 +284,30 @@ func (h *APIHandler) GetRequestLogsHandler(c *gin.Context) {
})
}
+// GetRequestLogDetailHandler 获取单个请求日志的完整详情(包含RequestBody和ResponseBody)
+func (h *APIHandler) GetRequestLogDetailHandler(c *gin.Context) {
+ // 获取日志ID
+ logID := c.Param("id")
+ if logID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Log ID is required"})
+ return
+ }
+
+ // 查询日志
+ var log models.RequestLog
+ if err := h.DB.First(&log, logID).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Log not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch log detail"})
+ return
+ }
+
+ // 返回完整的日志信息
+ c.JSON(http.StatusOK, log)
+}
+
// GetRequestLogStatsHandler 获取请求日志统计信息
func (h *APIHandler) GetRequestLogStatsHandler(c *gin.Context) {
// 获取时间范围参数
diff --git a/backend/main.go b/backend/main.go
index 9df5ece..548a49b 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -81,6 +81,7 @@ func main() {
// Request Logs
api_.GET("/logs", handler.GetRequestLogsHandler)
api_.GET("/logs/stats", handler.GetRequestLogStatsHandler)
+ api_.GET("/logs/:id", handler.GetRequestLogDetailHandler)
}
// 启动HTTP服务器
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 531a361..ec6a6cd 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -11,7 +11,8 @@
"@tailwindcss/vite": "^4.1.17",
"axios": "^1.13.2",
"react": "^19.1.1",
- "react-dom": "^19.1.1"
+ "react-dom": "^19.1.1",
+ "react-json-view-lite": "^2.5.0"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
@@ -59,7 +60,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1595,7 +1595,6 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -1637,7 +1636,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1798,7 +1796,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -2134,7 +2131,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3252,7 +3248,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -3279,7 +3274,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -3327,7 +3321,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3344,6 +3337,17 @@
"react": "^19.2.0"
}
},
+ "node_modules/react-json-view-lite": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/react-json-view-lite/-/react-json-view-lite-2.5.0.tgz",
+ "integrity": "sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g==",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -3573,7 +3577,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
diff --git a/frontend/package.json b/frontend/package.json
index af7b991..71b741b 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -13,7 +13,8 @@
"@tailwindcss/vite": "^4.1.17",
"axios": "^1.13.2",
"react": "^19.1.1",
- "react-dom": "^19.1.1"
+ "react-dom": "^19.1.1",
+ "react-json-view-lite": "^2.5.0"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
diff --git a/frontend/src/features/logs/api/index.js b/frontend/src/features/logs/api/index.js
index bf19c83..f994826 100644
--- a/frontend/src/features/logs/api/index.js
+++ b/frontend/src/features/logs/api/index.js
@@ -20,4 +20,15 @@ export const getRequestLogStats = async (params = {}) => {
console.error('获取日志统计信息失败:', error);
throw new Error(error.response?.data?.error || '获取日志统计信息失败');
}
+};
+
+// 获取日志详情
+export const getLogDetail = async (id) => {
+ try {
+ const response = await apiClient.get(`/api/logs/${id}`);
+ return response.data;
+ } catch (error) {
+ console.error('获取日志详情失败:', error);
+ throw new Error(error.response?.data?.error || '获取日志详情失败');
+ }
};
\ No newline at end of file
diff --git a/frontend/src/features/logs/components/RequestLogDetailModal.jsx b/frontend/src/features/logs/components/RequestLogDetailModal.jsx
new file mode 100644
index 0000000..6a550ca
--- /dev/null
+++ b/frontend/src/features/logs/components/RequestLogDetailModal.jsx
@@ -0,0 +1,218 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import Modal from '../../../components/ui/Modal';
+import { getLogDetail } from '../api';
+import { JsonView } from 'react-json-view-lite';
+import 'react-json-view-lite/dist/index.css';
+
+const RequestLogDetailModal = ({ log, isOpen, onClose }) => {
+ const [copiedInput, setCopiedInput] = useState(false);
+ const [copiedOutput, setCopiedOutput] = useState(false);
+ const [detailLog, setDetailLog] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // 按需加载日志详情
+ useEffect(() => {
+ if (isOpen && log && log.ID) {
+ const fetchLogDetail = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const detailData = await getLogDetail(log.ID);
+ setDetailLog(detailData);
+ } catch (err) {
+ setError(err.message || '获取日志详情失败');
+ console.error('Error fetching log detail:', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchLogDetail();
+ } else if (!isOpen) {
+ // 弹窗关闭时重置状态
+ setDetailLog(null);
+ setError(null);
+ setLoading(false);
+ }
+ }, [isOpen, log]);
+
+ // 格式化日期时间
+ const formatDate = (dateString) => {
+ if (!dateString) return 'N/A';
+ const date = new Date(dateString);
+ return date.toLocaleString('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit'
+ });
+ };
+
+ // 格式化成本
+ const formatCost = (cost) => {
+ if (cost === null || cost === undefined) return 'N/A';
+ return `¥${parseFloat(cost).toFixed(6)}`;
+ };
+
+ // 使用 useMemo 优化 JSON 格式化
+ const formatJSON = useMemo(() => {
+ return (jsonString) => {
+ if (!jsonString || jsonString.trim() === '') {
+ return '无数据';
+ }
+
+ try {
+ const parsed = JSON.parse(jsonString);
+ return JSON.stringify(parsed, null, 2);
+ } catch (e) {
+ // 如果解析失败,返回原始文本
+ return jsonString;
+ }
+ };
+ }, []);
+
+ // 复制到剪贴板
+ const copyToClipboard = async (text, type) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ if (type === 'input') {
+ setCopiedInput(true);
+ setTimeout(() => setCopiedInput(false), 2000);
+ } else {
+ setCopiedOutput(true);
+ setTimeout(() => setCopiedOutput(false), 2000);
+ }
+ } catch (err) {
+ console.error('复制失败:', err);
+ }
+ };
+
+ if (!log) return null;
+
+ // 使用详情数据或基本日志数据
+ const displayLog = detailLog || log;
+
+ return (
+ 无内容基本信息
+ 请求内容 (Input)
+
+
+
+ {loading ? '加载中...' : formatJSON(displayLog.RequestBody)}
+ 响应内容 (Response)
+
+
+
+ {loading ? '加载中...' : formatJSON(displayLog.ResponseBody)}
+