feat: 分站用户调用日志页面
- 新增 DistUserGetLogs 后端接口,支持按模型/令牌筛选和分页 - 新增 dist-site Logs 页面,desktop 表格 + mobile 卡片布局 - 所有 7 个主题 Layout 添加日志导航项 - Dashboard 快捷入口替换为调用日志链接 - 添加中英文 i18n 翻译 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
23920eb635
commit
f087c0f978
53 files changed
+518
-284
No files matched your search
@@ -10,6 +10,7 @@ const Tokens = lazy(() => import('./pages/Tokens'));
|
||||
const Packages = lazy(() => import('./pages/Packages'));
|
||||
const Pricing = lazy(() => import('./pages/Pricing'));
|
||||
const Topup = lazy(() => import('./pages/Topup'));
|
||||
const Logs = lazy(() => import('./pages/Logs'));
|
||||
|
||||
const Loading = () => (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
@@ -35,6 +36,7 @@ function ThemedRoutes() {
|
||||
<Route element={<AuthGuard />}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/tokens" element={<Tokens />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="/topup" element={<Topup />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -56,6 +56,7 @@ export const logout = () => api.post('/api/dist/user/logout');
|
||||
// ===== User =====
|
||||
export const getUserSelf = () => api.get('/api/dist/user/self');
|
||||
export const getUserUsage = () => api.get('/api/dist/user/usage');
|
||||
export const getUserLogs = (params) => api.get('/api/dist/user/logs', { params });
|
||||
|
||||
// ===== Tokens =====
|
||||
export const getTokens = () => api.get('/api/dist/token/list');
|
||||
|
||||
@@ -227,6 +227,28 @@
|
||||
"topup.payWith": "Pay with",
|
||||
"topup.payNow": "Pay",
|
||||
"topup.quotaIncluded": "{{count}} quota included",
|
||||
"topup.creemPayment": "Creem Payment"
|
||||
"topup.creemPayment": "Creem Payment",
|
||||
|
||||
"nav.logs": "Logs",
|
||||
|
||||
"dashboard.logs": "Call Logs",
|
||||
"dashboard.viewLogs": "View usage details",
|
||||
|
||||
"logs.title": "Call Logs",
|
||||
"logs.subtitle": "View detailed records of every API call",
|
||||
"logs.filterModel": "Filter by model...",
|
||||
"logs.filterToken": "Filter by token...",
|
||||
"logs.clearFilter": "Clear",
|
||||
"logs.noLogs": "No call records yet",
|
||||
"logs.time": "Time",
|
||||
"logs.model": "Model",
|
||||
"logs.token": "Token",
|
||||
"logs.promptTokens": "Input",
|
||||
"logs.completionTokens": "Output",
|
||||
"logs.cost": "Cost",
|
||||
"logs.duration": "Duration",
|
||||
"logs.showing": "{{from}}-{{to}} of {{total}}",
|
||||
"logs.prev": "Prev",
|
||||
"logs.next": "Next"
|
||||
}
|
||||
}
|
||||
@@ -227,6 +227,28 @@
|
||||
"topup.payWith": "使用",
|
||||
"topup.payNow": "支付",
|
||||
"topup.quotaIncluded": "含 {{count}} 额度",
|
||||
"topup.creemPayment": "Creem 支付"
|
||||
"topup.creemPayment": "Creem 支付",
|
||||
|
||||
"nav.logs": "调用日志",
|
||||
|
||||
"dashboard.logs": "调用日志",
|
||||
"dashboard.viewLogs": "查看使用明细",
|
||||
|
||||
"logs.title": "调用日志",
|
||||
"logs.subtitle": "查看每次 API 调用的详细记录",
|
||||
"logs.filterModel": "按模型筛选...",
|
||||
"logs.filterToken": "按令牌筛选...",
|
||||
"logs.clearFilter": "清除",
|
||||
"logs.noLogs": "暂无调用记录",
|
||||
"logs.time": "时间",
|
||||
"logs.model": "模型",
|
||||
"logs.token": "令牌",
|
||||
"logs.promptTokens": "输入",
|
||||
"logs.completionTokens": "输出",
|
||||
"logs.cost": "消费",
|
||||
"logs.duration": "耗时",
|
||||
"logs.showing": "第 {{from}}-{{to}} 条,共 {{total}} 条",
|
||||
"logs.prev": "上一页",
|
||||
"logs.next": "下一页"
|
||||
}
|
||||
}
|
||||
@@ -115,10 +115,10 @@ export default function Dashboard() {
|
||||
<p className="text-sm font-medium text-page group-hover:text-page-link transition-colors">{t('dashboard.pricing')}</p>
|
||||
<p className="text-xs text-page-muted">{t('dashboard.modelPrices')}</p>
|
||||
</Link>
|
||||
<div className="glass-sm !rounded-xl px-4 py-3 opacity-50">
|
||||
<p className="text-sm font-medium text-page">{t('dashboard.apiDocs')}</p>
|
||||
<p className="text-xs text-page-muted">{t('dashboard.comingSoon')}</p>
|
||||
</div>
|
||||
<Link to="/logs" className="glass-sm !rounded-xl px-4 py-3 hover:bg-page-surface-hover transition-colors group">
|
||||
<p className="text-sm font-medium text-page group-hover:text-page-link transition-colors">{t('dashboard.logs')}</p>
|
||||
<p className="text-xs text-page-muted">{t('dashboard.viewLogs')}</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getUserLogs, Q } from '../api';
|
||||
import { useCurrency } from '../context/SiteContext';
|
||||
|
||||
function formatTime(unix) {
|
||||
if (!unix) return '-';
|
||||
const d = new Date(unix * 1000);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
export default function Logs() {
|
||||
const { t } = useTranslation();
|
||||
const { symbol, rate } = useCurrency();
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modelFilter, setModelFilter] = useState('');
|
||||
const [tokenFilter, setTokenFilter] = useState('');
|
||||
const pageSize = 20;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = { p: page, page_size: pageSize, type: 2 }; // type=2 consume only
|
||||
if (modelFilter) params.model_name = modelFilter;
|
||||
if (tokenFilter) params.token_name = tokenFilter;
|
||||
const res = await getUserLogs(params);
|
||||
if (res.data.success) {
|
||||
setLogs(res.data.data?.items || []);
|
||||
setTotal(res.data.data?.total || 0);
|
||||
}
|
||||
} catch (e) { /* interceptor */ }
|
||||
setLoading(false);
|
||||
}, [page, modelFilter, tokenFilter]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-10">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-heading font-bold text-page mb-1">{t('logs.title')}</h1>
|
||||
<p className="text-sm text-page-secondary">{t('logs.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="glass rounded-2xl p-4 mb-6">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex-1 min-w-[180px]">
|
||||
<input
|
||||
type="text"
|
||||
value={modelFilter}
|
||||
onChange={(e) => { setModelFilter(e.target.value); setPage(1); }}
|
||||
className="input w-full"
|
||||
placeholder={t('logs.filterModel')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[180px]">
|
||||
<input
|
||||
type="text"
|
||||
value={tokenFilter}
|
||||
onChange={(e) => { setTokenFilter(e.target.value); setPage(1); }}
|
||||
className="input w-full"
|
||||
placeholder={t('logs.filterToken')}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={() => { setModelFilter(''); setTokenFilter(''); setPage(1); }} className="btn-secondary text-sm">
|
||||
{t('logs.clearFilter')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="glass rounded-2xl overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="w-8 h-8 border-2 border-brand-500/30 border-t-brand-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="text-center py-16 text-page-secondary">
|
||||
<p>{t('logs.noLogs')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-page-divider">
|
||||
<th className="text-left px-4 py-3 font-medium text-page-secondary">{t('logs.time')}</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-page-secondary">{t('logs.model')}</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-page-secondary">{t('logs.token')}</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-page-secondary">{t('logs.promptTokens')}</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-page-secondary">{t('logs.completionTokens')}</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-page-secondary">{t('logs.cost')}</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-page-secondary">{t('logs.duration')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log, i) => (
|
||||
<tr key={i} className="border-b border-page-divider last:border-0 hover:bg-page-surface transition-colors">
|
||||
<td className="px-4 py-3 text-page-secondary text-xs whitespace-nowrap">{formatTime(log.created_at)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-xs text-page">{log.model_name || '-'}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-page-secondary">{log.token_name || '-'}</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs text-page-label">{log.prompt_tokens?.toLocaleString() || '0'}</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs text-page-label">{log.completion_tokens?.toLocaleString() || '0'}</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs text-page-warning">
|
||||
{log.quota > 0 ? `${symbol}${(log.quota / Q * rate).toFixed(6)}` : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-xs text-page-secondary">
|
||||
{log.use_time > 0 ? `${(log.use_time / 1000).toFixed(1)}s` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="md:hidden divide-y divide-page-divider">
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className="px-4 py-3 space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-xs text-page font-medium">{log.model_name || '-'}</span>
|
||||
<span className="font-mono text-xs text-page-warning">
|
||||
{log.quota > 0 ? `${symbol}${(log.quota / Q * rate).toFixed(6)}` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] text-page-secondary">
|
||||
<span>{formatTime(log.created_at)}</span>
|
||||
<span>{log.prompt_tokens || 0} / {log.completion_tokens || 0} tokens</span>
|
||||
</div>
|
||||
{log.token_name && (
|
||||
<div className="text-[11px] text-page-muted">{log.token_name}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > pageSize && (
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-page-secondary">
|
||||
{t('logs.showing', { from: (page - 1) * pageSize + 1, to: Math.min(page * pageSize, total), total })}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="btn-secondary text-sm px-3 py-1.5 disabled:opacity-30"
|
||||
>
|
||||
{t('logs.prev')}
|
||||
</button>
|
||||
<span className="text-sm text-page-secondary px-2">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="btn-secondary text-sm px-3 py-1.5 disabled:opacity-30"
|
||||
>
|
||||
{t('logs.next')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export default function ClaudeLayout() {
|
||||
{ to: '/packages', label: t('nav.packages'), auth: false },
|
||||
{ to: '/dashboard', label: t('nav.dashboard'), auth: true },
|
||||
{ to: '/tokens', label: t('nav.apiKeys'), auth: true },
|
||||
{ to: '/logs', label: t('nav.logs'), auth: true },
|
||||
...(site?.enable_topup ? [{ to: '/topup', label: t('nav.topup'), auth: true }] : []),
|
||||
];
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export default function CleanLayout() {
|
||||
{ to: '/packages', label: t('nav.packages'), auth: false },
|
||||
{ to: '/dashboard', label: t('nav.dashboard'), auth: true },
|
||||
{ to: '/tokens', label: t('nav.apiKeys'), auth: true },
|
||||
{ to: '/logs', label: t('nav.logs'), auth: true },
|
||||
...(site?.enable_topup ? [{ to: '/topup', label: t('nav.topup'), auth: true }] : []),
|
||||
];
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export default function CorporateLayout() {
|
||||
{ to: '/packages', label: t('nav.packages'), auth: false },
|
||||
{ to: '/dashboard', label: t('nav.dashboard'), auth: true },
|
||||
{ to: '/tokens', label: t('nav.apiKeys'), auth: true },
|
||||
{ to: '/logs', label: t('nav.logs'), auth: true },
|
||||
...(site?.enable_topup ? [{ to: '/topup', label: t('nav.topup'), auth: true }] : []),
|
||||
];
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export default function DarkLayout() {
|
||||
{ to: '/packages', label: t('nav.packages'), auth: false },
|
||||
{ to: '/dashboard', label: t('nav.dashboard'), auth: true },
|
||||
{ to: '/tokens', label: t('nav.apiKeys'), auth: true },
|
||||
{ to: '/logs', label: t('nav.logs'), auth: true },
|
||||
...(site?.enable_topup ? [{ to: '/topup', label: t('nav.topup'), auth: true }] : []),
|
||||
];
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export default function DefaultLayout() {
|
||||
{ to: '/packages', label: t('nav.packages'), auth: false },
|
||||
{ to: '/dashboard', label: t('nav.dashboard'), auth: true },
|
||||
{ to: '/tokens', label: t('nav.apiKeys'), auth: true },
|
||||
{ to: '/logs', label: t('nav.logs'), auth: true },
|
||||
...(site?.enable_topup ? [{ to: '/topup', label: t('nav.topup'), auth: true }] : []),
|
||||
];
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export default function MinimalLayout() {
|
||||
{ to: '/packages', label: t('nav.packages'), auth: false },
|
||||
{ to: '/dashboard', label: t('nav.dashboard'), auth: true },
|
||||
{ to: '/tokens', label: t('nav.apiKeys'), auth: true },
|
||||
{ to: '/logs', label: t('nav.logs'), auth: true },
|
||||
...(site?.enable_topup ? [{ to: '/topup', label: t('nav.topup'), auth: true }] : []),
|
||||
];
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export default function StarterLayout() {
|
||||
{ to: '/packages', label: t('nav.packages'), auth: false },
|
||||
{ to: '/dashboard', label: t('nav.dashboard'), auth: true },
|
||||
{ to: '/tokens', label: t('nav.apiKeys'), auth: true },
|
||||
{ to: '/logs', label: t('nav.logs'), auth: true },
|
||||
...(site?.enable_topup ? [{ to: '/topup', label: t('nav.topup'), auth: true }] : []),
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user