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:
abingyydsandClaude Opus 4.6 committed 2026-03-26 21:34:56 +09:00
1 parent 23920eb635
commit f087c0f978
53 files changed
+518 -284

No files matched your search

+2
View File
@@ -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>
+1
View File
@@ -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');
+23 -1
View File
@@ -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"
}
}
+23 -1
View File
@@ -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": "下一页"
}
}
+4 -4
View File
@@ -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>
+179
View File
@@ -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>
);
}
+1
View File
@@ -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 }] : []),
];
+1
View File
@@ -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 }] : []),
];
+1
View File
@@ -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 }] : []),
];
+1
View File
@@ -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 }] : []),
];
+1
View File
@@ -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 }] : []),
];
+1
View File
@@ -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 }] : []),
];
+1
View File
@@ -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 }] : []),
];