修复日志详细页面,新增json格式查看器

This commit is contained in:
2025-11-11 14:22:45 +08:00
parent 24cfa54869
commit 0f55de3e9b
5 changed files with 217 additions and 60 deletions

View File

@@ -53,15 +53,15 @@ type BackendModel struct {
// RequestLog 记录每次API请求的详细信息
type RequestLog struct {
gorm.Model
APIKeyID uint `gorm:"index"` // API密钥ID
ProviderName string `gorm:"index"` // 服务商名称
VirtualModelName string `gorm:"index"` // 虚拟模型名称
BackendModelName string `gorm:"index"` // 后端模型名称
RequestTimestamp time.Time `gorm:"index;not null"` // 请求时间戳
ResponseTimestamp time.Time `gorm:"not null"` // 响应时间戳
RequestTokens int `gorm:"default:0"` // 请求token数
ResponseTokens int `gorm:"default:0"` // 响应token数
Cost float64 `gorm:"type:decimal(10,6)"` // 成本
RequestBody string `gorm:"type:text"` // 请求体
ResponseBody string `gorm:"type:text"` // 响应体
APIKeyID uint `gorm:"index" json:"api_key_id"` // API密钥ID
ProviderName string `gorm:"index" json:"provider_name"` // 服务商名称
VirtualModelName string `gorm:"index" json:"virtual_model_name"` // 虚拟模型名称
BackendModelName string `gorm:"index" json:"backend_model_name"` // 后端模型名称
RequestTimestamp time.Time `gorm:"index;not null" json:"request_timestamp"` // 请求时间戳
ResponseTimestamp time.Time `gorm:"not null" json:"response_timestamp"` // 响应时间戳
RequestTokens int `gorm:"default:0" json:"request_tokens"` // 请求token数
ResponseTokens int `gorm:"default:0" json:"response_tokens"` // 响应token数
Cost float64 `gorm:"type:decimal(10,6)" json:"cost"` // 成本
RequestBody string `gorm:"type:text" json:"request_body"` // 请求体
ResponseBody string `gorm:"type:text" json:"response_body"` // 响应体
}

View File

@@ -0,0 +1,69 @@
/* JsonViewer 自定义样式 */
/* 针对 dark 主题的样式覆盖 */
.json-viewer-dark ._1MGIk {
color: rgb(253, 246, 227) !important;
}
.json-viewer-dark ._3eOF8 {
color: rgb(253, 246, 227) !important;
}
.json-viewer-dark ._3uHL6 {
color: rgb(253, 246, 227) !important;
}
.json-viewer-dark ._1Gho6 {
color: rgb(129, 181, 172) !important;
}
.json-viewer-dark ._2T6PJ {
color: rgb(129, 181, 172) !important;
}
.json-viewer-dark ._vGjyY {
color: rgb(203, 75, 22) !important;
}
.json-viewer-dark ._1bQdo {
color: rgb(211, 54, 130) !important;
}
.json-viewer-dark ._3zQKs {
color: rgb(174, 129, 255) !important;
}
.json-viewer-dark ._1xvuR {
color: rgb(38, 139, 210) !important;
}
.json-viewer-dark ._oLqym {
color: rgb(253, 246, 227) !important;
}
.json-viewer-dark ._2AXVT {
color: rgb(253, 246, 227) !important;
}
.json-viewer-dark ._2KJWg {
color: rgb(253, 246, 227) !important;
}
.json-viewer-dark ._2bSDX {
color: rgb(253, 246, 227) !important;
}
.json-viewer-dark ._gsbQL {
color: rgb(253, 246, 227) !important;
}
/* 确保背景色在 dark 主题下正确 */
.json-viewer-dark {
background-color: transparent !important;
}
/* 确保整体文本颜色在 dark 主题下正确 */
.json-viewer-dark,
.json-viewer-dark * {
color-scheme: dark;
}

View File

@@ -0,0 +1,84 @@
import React, { useMemo } from 'react';
import { JsonView } from 'react-json-view-lite';
import 'react-json-view-lite/dist/index.css';
import './JsonViewer.css';
const JsonViewer = ({
data,
theme = 'dark',
collapsed = 1,
collapseAll = true,
enableClipboard = false,
className = '',
style = {},
fallbackMessage = '无内容'
}) => {
// 根据主题设置容器类名
const themeClass = theme === 'dark' ? 'json-viewer-dark' : '';
// 判断数据是否为有效的JSON字符串或对象
const { isValid, parsedData } = useMemo(() => {
if (!data) {
return { isValid: false, parsedData: null };
}
// 如果已经是对象,直接使用
if (typeof data === 'object' && data !== null) {
return { isValid: true, parsedData: data };
}
// 如果是字符串,尝试解析
if (typeof data === 'string') {
const trimmed = data.trim();
if (trimmed === '') {
return { isValid: false, parsedData: null };
}
try {
const parsed = JSON.parse(trimmed);
return { isValid: true, parsedData: parsed };
} catch (e) {
return { isValid: false, parsedData: data };
}
}
return { isValid: false, parsedData: data };
}, [data]);
// 默认样式设置
const defaultStyle = {
fontFamily: "'Courier New', monospace",
fontSize: '0.875rem',
color: theme === 'dark' ? 'white' : 'black',
...style
};
// 如果是有效的JSON使用JsonView显示
if (isValid && parsedData !== null) {
return (
<div className={`${className} ${themeClass}`}>
<JsonView
data={parsedData}
style={defaultStyle}
collapsed={collapsed}
collapseAll={collapseAll}
enableClipboard={enableClipboard}
shouldLogOnChange={false}
/>
</div>
);
}
// 如果不是JSON或为空使用纯文本显示
return (
<div className={className}>
<pre
className={`text-sm ${theme === 'dark' ? 'text-gray-100' : 'text-gray-900'} text-left`}
style={defaultStyle}
>
<code>{parsedData || fallbackMessage}</code>
</pre>
</div>
);
};
export default JsonViewer;

View File

@@ -1,8 +1,7 @@
import React, { useState, useEffect, useMemo } from 'react';
import Modal from '../../../components/ui/Modal';
import JsonViewer from '../../../components/ui/JsonViewer';
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);
@@ -13,12 +12,12 @@ const RequestLogDetailModal = ({ log, isOpen, onClose }) => {
// 按需加载日志详情
useEffect(() => {
if (isOpen && log && log.ID) {
if (isOpen && log && log.id) {
const fetchLogDetail = async () => {
try {
setLoading(true);
setError(null);
const detailData = await getLogDetail(log.ID);
const detailData = await getLogDetail(log.id);
setDetailLog(detailData);
} catch (err) {
setError(err.message || '获取日志详情失败');
@@ -57,8 +56,8 @@ const RequestLogDetailModal = ({ log, isOpen, onClose }) => {
return `¥${parseFloat(cost).toFixed(6)}`;
};
// 使用 useMemo 优化 JSON 格式化
const formatJSON = useMemo(() => {
// 格式化JSON用于复制
const formatJSONForCopy = useMemo(() => {
return (jsonString) => {
if (!jsonString || jsonString.trim() === '') {
return '无数据';
@@ -77,7 +76,10 @@ const RequestLogDetailModal = ({ log, isOpen, onClose }) => {
// 复制到剪贴板
const copyToClipboard = async (text, type) => {
try {
await navigator.clipboard.writeText(text);
// 格式化JSON内容用于复制
const formattedText = formatJSONForCopy(text);
await navigator.clipboard.writeText(formattedText);
if (type === 'input') {
setCopiedInput(true);
setTimeout(() => setCopiedInput(false), 2000);
@@ -118,35 +120,35 @@ const RequestLogDetailModal = ({ log, isOpen, onClose }) => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="text-sm text-gray-500">请求时间</div>
<div className="text-sm font-medium text-gray-900">{formatDate(displayLog.RequestTimestamp)}</div>
<div className="text-sm font-medium text-gray-900">{formatDate(displayLog.request_timestamp)}</div>
</div>
<div>
<div className="text-sm text-gray-500">响应时间</div>
<div className="text-sm font-medium text-gray-900">{formatDate(displayLog.ResponseTimestamp)}</div>
<div className="text-sm font-medium text-gray-900">{formatDate(displayLog.response_timestamp)}</div>
</div>
<div>
<div className="text-sm text-gray-500">虚拟模型</div>
<div className="text-sm font-medium text-gray-900">{displayLog.VirtualModelName || 'N/A'}</div>
<div className="text-sm font-medium text-gray-900">{displayLog.virtual_model_name || 'N/A'}</div>
</div>
<div>
<div className="text-sm text-gray-500">后端模型</div>
<div className="text-sm font-medium text-gray-900">{displayLog.BackendModelName || 'N/A'}</div>
<div className="text-sm font-medium text-gray-900">{displayLog.backend_model_name || 'N/A'}</div>
</div>
<div>
<div className="text-sm text-gray-500">服务商</div>
<div className="text-sm font-medium text-gray-900">{displayLog.ProviderName || 'N/A'}</div>
<div className="text-sm font-medium text-gray-900">{displayLog.provider_name || 'N/A'}</div>
</div>
<div>
<div className="text-sm text-gray-500">成本</div>
<div className="text-sm font-medium text-gray-900">{formatCost(displayLog.Cost)}</div>
<div className="text-sm font-medium text-gray-900">{formatCost(displayLog.cost)}</div>
</div>
<div>
<div className="text-sm text-gray-500">请求Tokens</div>
<div className="text-sm font-medium text-gray-900">{displayLog.RequestTokens || 0}</div>
<div className="text-sm font-medium text-gray-900">{displayLog.request_tokens || 0}</div>
</div>
<div>
<div className="text-sm text-gray-500">响应Tokens</div>
<div className="text-sm font-medium text-gray-900">{displayLog.ResponseTokens || 0}</div>
<div className="text-sm font-medium text-gray-900">{displayLog.response_tokens || 0}</div>
</div>
</div>
</div>
@@ -156,7 +158,7 @@ const RequestLogDetailModal = ({ log, isOpen, onClose }) => {
<div className="flex justify-between items-center mb-2">
<h3 className="text-lg font-semibold text-gray-900">请求内容 (Input)</h3>
<button
onClick={() => copyToClipboard(displayLog.RequestBody || '', 'input')}
onClick={() => copyToClipboard(displayLog.request_body || '', 'input')}
className={`px-3 py-1 text-sm rounded transition-colors ${copiedInput
? 'bg-gray-400 text-white cursor-default'
: 'bg-green-500 text-white hover:bg-green-600'
@@ -166,26 +168,19 @@ const RequestLogDetailModal = ({ log, isOpen, onClose }) => {
{copiedInput ? '已复制' : '复制'}
</button>
</div>
{/* <div className="bg-gray-800 rounded-lg p-3 overflow-auto" style={{ maxHeight: '300px' }}>
<pre className="text-sm text-gray-100 text-left" style={{ fontFamily: "'Courier New', monospace" }}>
<code>{loading ? '加载中...' : formatJSON(displayLog.RequestBody)}</code>
</pre>
</div> */}
<div className="bg-gray-800 rounded-lg p-3 overflow-auto" style={{ maxHeight: '300px' }}>
{loading ? '加载中' :
log.RequestBody ? (
<JsonView
data={JSON.parse(log.RequestBody)} // 解析字符串为对象
style={{ fontFamily: "'Courier New', monospace", fontSize: '0.875rem' }} // 匹配你的text-sm
collapsed={1} // 默认折叠1级深度0=全展开false=不折叠
collapseAll={true} // 初始全折叠
enableClipboard={false} // 禁用内置复制用你的按钮
shouldLogOnChange={false} // 禁用编辑只读模式
/>
) : (
<p className="text-gray-400 text-sm">无内容</p>
)
}
<div className="bg-gray-800 rounded-lg p-3 overflow-auto text-left text-white" style={{ maxHeight: '300px' }}>
{loading ? (
<div className="text-gray-400 text-sm">加载中...</div>
) : (
<JsonViewer
data={displayLog.request_body}
theme="dark"
collapsed={1}
collapseAll={true}
enableClipboard={false}
fallbackMessage="无内容"
/>
)}
</div>
</div>
@@ -194,7 +189,7 @@ const RequestLogDetailModal = ({ log, isOpen, onClose }) => {
<div className="flex justify-between items-center mb-2">
<h3 className="text-lg font-semibold text-gray-900">响应内容 (Response)</h3>
<button
onClick={() => copyToClipboard(displayLog.ResponseBody || '', 'output')}
onClick={() => copyToClipboard(displayLog.response_body || '', 'output')}
className={`px-3 py-1 text-sm rounded transition-colors ${copiedOutput
? 'bg-gray-400 text-white cursor-default'
: 'bg-green-500 text-white hover:bg-green-600'
@@ -204,10 +199,19 @@ const RequestLogDetailModal = ({ log, isOpen, onClose }) => {
{copiedOutput ? '已复制' : '复制'}
</button>
</div>
<div className="bg-gray-800 rounded-lg p-3 overflow-auto" style={{ maxHeight: '300px' }}>
<pre className="text-sm text-gray-100 text-left" style={{ fontFamily: "'Courier New', monospace" }}>
<code>{loading ? '加载中...' : formatJSON(displayLog.ResponseBody)}</code>
</pre>
<div className="bg-gray-800 rounded-lg p-3 overflow-auto text-left text-white" style={{ maxHeight: '300px' }}>
{loading ? (
<div className="text-gray-400 text-sm">加载中...</div>
) : (
<JsonViewer
data={displayLog.response_body}
theme="dark"
collapsed={1}
collapseAll={true}
enableClipboard={false}
fallbackMessage="无内容"
/>
)}
</div>
</div>
</div>

View File

@@ -243,27 +243,27 @@ const RequestLogList = () => {
</tr>
) : (
logs.map((log, index) => (
<tr key={log.ID || index} className="hover:bg-gray-50">
<tr key={log.id || index} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatDate(log.RequestTimestamp)}
{formatDate(log.request_timestamp)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{log.VirtualModelName || 'N/A'}
{log.virtual_model_name || 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{log.BackendModelName || 'N/A'}
{log.backend_model_name || 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{log.ProviderName || 'N/A'}
{log.provider_name || 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{log.RequestTokens || 0}
{log.request_tokens || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{log.ResponseTokens || 0}
{log.response_tokens || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatCost(log.Cost)}
{formatCost(log.cost)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<button