修复日志详细页面,新增json格式查看器
This commit is contained in:
@@ -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"` // 响应体
|
||||
}
|
||||
|
||||
69
frontend/src/components/ui/JsonViewer.css
Normal file
69
frontend/src/components/ui/JsonViewer.css
Normal 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;
|
||||
}
|
||||
84
frontend/src/components/ui/JsonViewer.jsx
Normal file
84
frontend/src/components/ui/JsonViewer.jsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user