From 24cfa5486931e100cd0f735f079603a834f6f3f3 Mon Sep 17 00:00:00 2001 From: nanako <469449812@qq.com> Date: Tue, 11 Nov 2025 13:18:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=97=A5=E5=BF=97=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/handlers.go | 65 +++++- backend/main.go | 1 + frontend/package-lock.json | 23 +- frontend/package.json | 3 +- frontend/src/features/logs/api/index.js | 11 + .../logs/components/RequestLogDetailModal.jsx | 218 ++++++++++++++++++ .../logs/components/RequestLogList.jsx | 56 ++++- push_docker_image.sh | 8 + 8 files changed, 366 insertions(+), 19 deletions(-) create mode 100644 frontend/src/features/logs/components/RequestLogDetailModal.jsx create mode 100755 push_docker_image.sh 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 ( + +
+ {/* 加载状态 */} + {loading && ( +
+
加载中...
+
+ )} + + {/* 错误提示 */} + {error && ( +
+ {error} +
+ )} + + {/* 基本信息区域 */} +
+

基本信息

+
+
+
请求时间
+
{formatDate(displayLog.RequestTimestamp)}
+
+
+
响应时间
+
{formatDate(displayLog.ResponseTimestamp)}
+
+
+
虚拟模型
+
{displayLog.VirtualModelName || 'N/A'}
+
+
+
后端模型
+
{displayLog.BackendModelName || 'N/A'}
+
+
+
服务商
+
{displayLog.ProviderName || 'N/A'}
+
+
+
成本
+
{formatCost(displayLog.Cost)}
+
+
+
请求Tokens
+
{displayLog.RequestTokens || 0}
+
+
+
响应Tokens
+
{displayLog.ResponseTokens || 0}
+
+
+
+ + {/* 请求内容 (Input) 区域 */} +
+
+

请求内容 (Input)

+ +
+ {/*
+
+              {loading ? '加载中...' : formatJSON(displayLog.RequestBody)}
+            
+
*/} +
+ {loading ? '加载中' : + log.RequestBody ? ( + + ) : ( +

无内容

+ ) + } +
+
+ + {/* 响应内容 (Response) 区域 */} +
+
+

响应内容 (Response)

+ +
+
+
+              {loading ? '加载中...' : formatJSON(displayLog.ResponseBody)}
+            
+
+
+
+
+ ); +}; + +export default RequestLogDetailModal; \ No newline at end of file diff --git a/frontend/src/features/logs/components/RequestLogList.jsx b/frontend/src/features/logs/components/RequestLogList.jsx index d87c42d..3e54439 100644 --- a/frontend/src/features/logs/components/RequestLogList.jsx +++ b/frontend/src/features/logs/components/RequestLogList.jsx @@ -1,5 +1,23 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { getRequestLogs, getRequestLogStats } from '../api'; +import RequestLogDetailModal from './RequestLogDetailModal'; + +// 自定义防抖 Hook +const useDebounce = (value, delay) => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +}; const RequestLogList = () => { const [logs, setLogs] = useState([]); @@ -9,6 +27,8 @@ const RequestLogList = () => { const [page, setPage] = useState(1); const [pageSize] = useState(20); const [totalPages, setTotalPages] = useState(1); + const [selectedLog, setSelectedLog] = useState(null); + const [showDetailModal, setShowDetailModal] = useState(false); // 筛选条件 const [filters, setFilters] = useState({ @@ -18,13 +38,16 @@ const RequestLogList = () => { endDate: '' }); + // 使用防抖的筛选条件 + const debouncedFilters = useDebounce(filters, 500); + const fetchLogs = async () => { try { setLoading(true); const params = { page, page_size: pageSize, - ...filters + ...debouncedFilters }; // 移除空值 @@ -46,7 +69,7 @@ const RequestLogList = () => { const fetchStats = async () => { try { - const data = await getRequestLogStats(filters); + const data = await getRequestLogStats(debouncedFilters); setStats(data); } catch (err) { console.error('Error fetching stats:', err); @@ -56,13 +79,18 @@ const RequestLogList = () => { useEffect(() => { fetchLogs(); fetchStats(); - }, [page, filters]); + }, [page, debouncedFilters]); const handleFilterChange = (key, value) => { setFilters(prev => ({ ...prev, [key]: value })); setPage(1); // 重置到第一页 }; + const handleViewDetail = (log) => { + setSelectedLog(log); + setShowDetailModal(true); + }; + const formatDate = (dateString) => { if (!dateString) return 'N/A'; const date = new Date(dateString); @@ -201,12 +229,15 @@ const RequestLogList = () => { 成本 + + 操作 + {logs.length === 0 ? ( - + 暂无日志记录 @@ -234,6 +265,14 @@ const RequestLogList = () => { {formatCost(log.Cost)} + + + )) )} @@ -264,6 +303,13 @@ const RequestLogList = () => { )} + + {/* 详情查看模态框 */} + setShowDetailModal(false)} + /> ); }; diff --git a/push_docker_image.sh b/push_docker_image.sh new file mode 100755 index 0000000..76ee460 --- /dev/null +++ b/push_docker_image.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +docker compose -f docker-compose.cn.yml build + +docker tag airouter-backend:latest nanako1092/airouter-backend:latest +docker tag airouter-frontend:latest nanako1092/airouter-frontend:latest +docker push nanako1092/airouter-backend:latest +docker push nanako1092/airouter-frontend:latest \ No newline at end of file