feat: complete user login admin keys and deployment docs

This commit is contained in:
boos
2026-05-19 10:06:52 +02:00
parent 812a7c6c07
commit 4fe84707e5
16 changed files with 1384 additions and 187 deletions
+13 -4
View File
@@ -5,7 +5,7 @@ import SearchModal from './components/SearchModal.js';
import NotificationPanel from './components/NotificationPanel.js';
import TooltipLayer from './components/TooltipLayer.js';
import { api } from './api.js';
import { clearAuthSession, hasValidAuthSession, hasValidUserSession, persistAuthSession, persistUserSession } from './authSession.js';
import { clearAuthSession, clearUserSession, hasValidAuthSession, hasValidUserSession, persistAuthSession, persistUserSession } from './authSession.js';
import {
FIRST_USE_DOC_REMINDER_KEY,
LEGACY_THEME_STORAGE_KEY,
@@ -207,7 +207,10 @@ export function Login({
});
const data = await res.json().catch(() => null);
if (!res.ok) {
const message = data?.message || data?.error || t('登录失败,请检查邮箱和密码');
const rawErr = data?.error;
const message = data?.message
|| (typeof rawErr === 'string' ? rawErr : rawErr?.message)
|| t('登录失败,请检查邮箱和密码');
setError(t(String(message)));
setLoading(false);
return;
@@ -798,11 +801,15 @@ function AppShell() {
const isUserPage = location.pathname.startsWith('/user/');
const isPublicUserPage = ['/user/login', '/user/register'].includes(location.pathname);
if (isUserPage && isPublicUserPage && userAuthed) {
return <Navigate to="/user/dashboard" />;
if (isUserPage && isPublicUserPage) {
if (userAuthed) return <Navigate to="/user/dashboard" />;
if (authed) return <Navigate to="/" />;
}
if (!authed && !isUserPage) {
if (userAuthed) {
return <Navigate to="/user/dashboard" />;
}
return (
<Login
t={t}
@@ -978,7 +985,9 @@ function AppShell() {
</button>
<button onClick={() => {
clearAuthSession(localStorage);
clearUserSession(localStorage);
setAuthed(false);
setUserAuthed(false);
}} className="user-dropdown-item danger">
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
{t('退出登录')}
+11 -3
View File
@@ -1822,8 +1822,16 @@ export const api = {
request(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteUser: (id: number) =>
request(`/api/admin/users/${id}`, { method: "DELETE" }),
};
getUserKeys: (id: number) => request(`/api/admin/users/${id}/keys`),
createUserKey: (id: number, data: { name: string }) =>
request(`/api/admin/users/${id}/keys`, { method: "POST", body: JSON.stringify(data) }),
deleteUserKey: (userId: number, keyId: number) =>
request(`/api/admin/users/${userId}/keys/${keyId}`, { method: "DELETE" }),
resetUserKey: (userId: number, keyId: number) =>
request(`/api/admin/users/${userId}/keys/${keyId}/reset`, { method: "POST" }),
updateUserKey: (userId: number, keyId: number, data: any) =>
request(`/api/admin/users/${userId}/keys/${keyId}`, { method: "PATCH", body: JSON.stringify(data) }),
};
export const userApi = {
register: (data: { username: string; email: string; password: string }) =>
userRequest("/api/users/register", { method: "POST", body: JSON.stringify(data) }),
@@ -1841,7 +1849,7 @@ export const userApi = {
userRequest(`/api/user-api-keys/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteApiKey: (id: number) =>
userRequest(`/api/user-api-keys/${id}`, { method: "DELETE" }),
getLogs: (params?: { limit?: number; offset?: number }) =>
getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; status?: string; date_from?: string; date_to?: string }) =>
userRequest(`/api/user/logs${buildQueryString(params)}`),
getSummary: () => userRequest("/api/user/summary"),
};
+247 -75
View File
@@ -1,142 +1,314 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { userApi } from '../api.js';
import { useToast } from '../components/Toast.js';
interface LogItem {
id: number;
model_requested: string;
model_actual: string;
status: string;
http_status: number;
latency_ms: number;
model_requested: string | null;
model_actual: string | null;
status: string | null;
http_status: number | null;
latency_ms: number | null;
prompt_tokens: number | null;
completion_tokens: number | null;
total_tokens: number | null;
estimated_cost: number | null;
error_message: string | null;
is_stream: boolean | null;
created_at: string | null;
}
interface Summary {
total: number;
success: number;
failed: number;
total_cost: number;
total_tokens: number;
estimated_cost: number;
error_message?: string;
created_at: string;
}
const PAGE_SIZE = 50;
function formatDate(value: string | null): string {
if (!value) return '-';
const d = new Date(value.includes('T') ? value : value.replace(' ', 'T'));
if (Number.isNaN(d.getTime())) return value.slice(0, 16);
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function latencyColor(ms: number | null): string {
if (!ms) return 'var(--color-text-muted)';
if (ms < 1000) return 'var(--color-success)';
if (ms < 3000) return 'var(--color-warning)';
return 'var(--color-danger)';
}
export default function UsageRecords() {
const [logs, setLogs] = useState<LogItem[]>([]);
const [summary, setSummary] = useState<Summary | null>(null);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const pageSize = 20;
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [expandedId, setExpandedId] = useState<number | null>(null);
const toast = useToast();
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadLogs = useCallback(async () => {
const load = useCallback(async (p: number, q: string, status: string, from: string, to: string) => {
setLoading(true);
try {
const res = await userApi.getLogs({
limit: pageSize,
offset: (page - 1) * pageSize,
page: p,
page_size: PAGE_SIZE,
q: q || undefined,
status: status || undefined,
date_from: from || undefined,
date_to: to || undefined,
});
setLogs(res.items || []);
setTotal(res.total || 0);
setSummary(res.summary || null);
} catch (err: any) {
toast.error(err?.message || '加载记录失败');
} finally {
setLoading(false);
}
}, [page, toast]);
}, [toast]);
useEffect(() => {
loadLogs();
}, [loadLogs]);
load(page, search, statusFilter, dateFrom, dateTo);
}, [load, page, statusFilter, dateFrom, dateTo]);
const totalPages = Math.ceil(total / pageSize);
const handleSearchChange = (value: string) => {
setSearch(value);
if (searchTimer.current) clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => {
setPage(1);
load(1, value, statusFilter, dateFrom, dateTo);
}, 300);
};
const handleFilterChange = (status: string, from: string, to: string) => {
setStatusFilter(status);
setDateFrom(from);
setDateTo(to);
setPage(1);
};
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const successRate = summary && summary.total > 0
? ((summary.success / summary.total) * 100).toFixed(1)
: '0.0';
return (
<div className="page-container" style={{ padding: '24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h1 style={{ fontSize: '20px', fontWeight: 600, margin: 0 }}>使用记录</h1>
<button className="btn btn-ghost" onClick={loadLogs} disabled={loading}>
<div className="page-container animate-fade-in">
<div className="page-header">
<div>
<h2 className="page-title">使用记录</h2>
<div className="page-subtitle">当前账号下所有 API 调用日志</div>
</div>
<button
type="button"
className="btn btn-ghost"
onClick={() => load(page, search, statusFilter, dateFrom, dateTo)}
disabled={loading}
>
刷新
</button>
</div>
{/* Summary cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 20 }}>
{[
{ label: '总请求', value: summary?.total.toLocaleString() ?? '-', color: 'var(--color-text-primary)' },
{ label: '成功率', value: `${successRate}%`, color: 'var(--color-success)' },
{ label: '失败', value: summary?.failed.toLocaleString() ?? '-', color: summary?.failed ? 'var(--color-danger)' : 'var(--color-text-muted)' },
{ label: 'Tokens', value: summary ? (summary.total_tokens >= 1000 ? `${(summary.total_tokens / 1000).toFixed(1)}K` : summary.total_tokens.toString()) : '-', color: 'var(--color-text-primary)' },
{ label: '预估费用', value: summary ? `$${summary.total_cost.toFixed(4)}` : '-', color: 'var(--color-primary)' },
].map((card) => (
<div key={card.label} className="card" style={{ padding: '14px 16px' }}>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 6 }}>{card.label}</div>
<div style={{ fontSize: 20, fontWeight: 700, color: card.color }}>{card.value}</div>
</div>
))}
</div>
{/* Filters */}
<div className="card" style={{ padding: '12px 16px', marginBottom: 16 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center' }}>
<div style={{ position: 'relative', flex: '1 1 200px', minWidth: 160 }}>
<svg style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="m21 21-4.35-4.35M10.5 18a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15Z" />
</svg>
<input
style={{ width: '100%', paddingLeft: 30, paddingRight: 10, height: 34, border: '1px solid var(--color-border)', borderRadius: 6, background: 'var(--color-bg)', color: 'var(--color-text-primary)', fontSize: 13, boxSizing: 'border-box' }}
placeholder="搜索模型名称..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
/>
</div>
<select
style={{ height: 34, border: '1px solid var(--color-border)', borderRadius: 6, background: 'var(--color-bg)', color: 'var(--color-text-primary)', fontSize: 13, padding: '0 10px' }}
value={statusFilter}
onChange={(e) => handleFilterChange(e.target.value, dateFrom, dateTo)}
>
<option value="">全部状态</option>
<option value="success">成功</option>
<option value="failed">失败</option>
</select>
<input
type="date"
style={{ height: 34, border: '1px solid var(--color-border)', borderRadius: 6, background: 'var(--color-bg)', color: 'var(--color-text-primary)', fontSize: 13, padding: '0 10px' }}
value={dateFrom}
onChange={(e) => handleFilterChange(statusFilter, e.target.value, dateTo)}
/>
<span style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>至</span>
<input
type="date"
style={{ height: 34, border: '1px solid var(--color-border)', borderRadius: 6, background: 'var(--color-bg)', color: 'var(--color-text-primary)', fontSize: 13, padding: '0 10px' }}
value={dateTo}
onChange={(e) => handleFilterChange(statusFilter, dateFrom, e.target.value)}
/>
{(search || statusFilter || dateFrom || dateTo) && (
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => { setSearch(''); handleFilterChange('', '', ''); }}
>
清除
</button>
)}
</div>
</div>
{/* Table */}
<div className="card" style={{ overflow: 'hidden' }}>
<div style={{ overflowX: 'auto' }}>
<table className="table">
<table className="data-table">
<thead>
<tr>
<th>时间</th>
<th>请求模型</th>
<th>模型</th>
<th>状态</th>
<th>耗时</th>
<th>Tokens</th>
<th>预估成本</th>
<th>费用</th>
<th style={{ width: 40 }} />
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '40px' }}>
<td colSpan={7} style={{ textAlign: 'center', padding: '40px' }}>
<div className="spinner" style={{ margin: '0 auto' }} />
</td>
</tr>
) : logs.length === 0 ? (
<tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-muted)' }}>
暂无使用记录
<td colSpan={7} style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-muted)' }}>
暂无记录
</td>
</tr>
) : (
logs.map((log) => (
<tr key={log.id}>
<td style={{ fontSize: '12px' }}>{new Date(log.created_at).toLocaleString()}</td>
<td>
<div style={{ fontWeight: 500 }}>{log.model_requested}</div>
{log.model_actual && log.model_actual !== log.model_requested && (
<div style={{ fontSize: '11px', color: 'var(--color-text-muted)' }}>→ {log.model_actual}</div>
)}
</td>
<td>
<span className={`badge badge-${log.status === 'success' ? 'success' : 'danger'}`}>
{log.status === 'success' ? '成功' : '失败'}
{log.http_status && ` (${log.http_status})`}
</span>
{log.error_message && (
<div style={{ fontSize: '11px', color: 'var(--color-danger)', marginTop: '2px', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={log.error_message}>
{log.error_message}
</div>
)}
</td>
<td>{log.latency_ms}ms</td>
<td>{log.total_tokens || 0}</td>
<td>${log.estimated_cost ? log.estimated_cost.toFixed(6) : '0.000000'}</td>
</tr>
<React.Fragment key={log.id}>
<tr
style={{ cursor: 'pointer' }}
onClick={() => setExpandedId(expandedId === log.id ? null : log.id)}
>
<td style={{ fontSize: 12, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
{formatDate(log.created_at)}
</td>
<td>
<div style={{ fontWeight: 500, fontSize: 13 }}>{log.model_requested || '-'}</div>
{log.model_actual && log.model_actual !== log.model_requested && (
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>→ {log.model_actual}</div>
)}
</td>
<td>
<span className={`badge badge-${log.status === 'success' ? 'success' : 'error'}`} style={{ fontSize: 11 }}>
{log.status === 'success' ? '成功' : '失败'}
{log.http_status ? ` ${log.http_status}` : ''}
</span>
{log.is_stream && (
<span style={{ marginLeft: 4, fontSize: 10, color: 'var(--color-text-muted)' }}>流式</span>
)}
</td>
<td style={{ fontSize: 13, color: latencyColor(log.latency_ms), fontFamily: 'var(--font-mono)' }}>
{log.latency_ms != null ? `${log.latency_ms}ms` : '-'}
</td>
<td style={{ fontSize: 12, fontFamily: 'var(--font-mono)' }}>
{log.total_tokens != null ? log.total_tokens.toLocaleString() : '-'}
</td>
<td style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--color-primary)' }}>
{log.estimated_cost != null ? `$${log.estimated_cost.toFixed(5)}` : '-'}
</td>
<td>
<svg
style={{ display: 'block', color: 'var(--color-text-muted)', transition: 'transform 0.15s', transform: expandedId === log.id ? 'rotate(180deg)' : 'none' }}
width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</td>
</tr>
{expandedId === log.id && (
<tr>
<td colSpan={7} style={{ padding: 0, background: 'var(--color-bg-alt)' }}>
<div style={{ padding: '14px 20px', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, borderBottom: '1px solid var(--color-border)' }}>
{[
{ label: 'Prompt Tokens', value: log.prompt_tokens?.toLocaleString() ?? '-' },
{ label: 'Completion Tokens', value: log.completion_tokens?.toLocaleString() ?? '-' },
{ label: 'Total Tokens', value: log.total_tokens?.toLocaleString() ?? '-' },
{ label: 'HTTP Status', value: log.http_status?.toString() ?? '-' },
].map((item) => (
<div key={item.label}>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 2 }}>{item.label}</div>
<div style={{ fontSize: 13, fontFamily: 'var(--font-mono)' }}>{item.value}</div>
</div>
))}
{log.error_message && (
<div style={{ gridColumn: '1 / -1' }}>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 2 }}>错误信息</div>
<div style={{ fontSize: 12, color: 'var(--color-danger)', wordBreak: 'break-all' }}>{log.error_message}</div>
</div>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ fontSize: '13px', color: 'var(--color-text-muted)' }}>
共 {total} 条记录
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
className="btn btn-ghost btn-sm"
disabled={page === 1 || loading}
onClick={() => setPage(page - 1)}
>
上一页
</button>
<span style={{ fontSize: '13px', display: 'flex', alignItems: 'center' }}>
{page} / {totalPages}
</span>
<button
className="btn btn-ghost btn-sm"
disabled={page === totalPages || loading}
onClick={() => setPage(page + 1)}
>
下一页
</button>
</div>
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
共 {total} 条记录,第 {page} / {totalPages} 页
</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="btn btn-ghost btn-sm"
disabled={page === 1 || loading}
onClick={() => setPage((p) => p - 1)}
>
上一页
</button>
<button
type="button"
className="btn btn-ghost btn-sm"
disabled={page >= totalPages || loading}
onClick={() => setPage((p) => p + 1)}
>
下一页
</button>
</div>
</div>
</div>
</div>
);
+1 -1
View File
@@ -210,7 +210,7 @@ export default function UserDashboard() {
</div>
<div style={{ marginTop: '20px', display: 'flex', gap: '10px' }}>
<button className="btn btn-ghost btn-sm" style={{ flex: 1, border: '1px solid var(--color-border)' }} onClick={() => navigate('/user/dashboard/profile')}>修改资料</button>
<button className="btn btn-ghost btn-sm danger" style={{ flex: 1, border: '1px solid var(--color-border)' }} onClick={() => { clearUserSession(localStorage); navigate('/user/login'); }}>退出登录</button>
<button className="btn btn-ghost btn-sm danger" style={{ flex: 1, border: '1px solid var(--color-border)' }} onClick={() => { clearUserSession(localStorage); window.location.href = '/user/login'; }}>退出登录</button>
</div>
</section>
</div>
+2 -1
View File
@@ -22,7 +22,8 @@ export default function UserLogin({ onLogin }: { onLogin?: () => void }) {
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || `登录失败 (${res.status})`);
const errMsg = typeof data?.error === 'string' ? data.error : (data?.error?.message || data?.message || `登录失败 (${res.status})`);
throw new Error(errMsg);
}
const result = await res.json();
persistUserSession(localStorage, result.token);
+216 -59
View File
@@ -147,6 +147,9 @@ export default function UserManagement() {
const [expandedUserId, setExpandedUserId] = useState<number | null>(null);
const [usageData, setUsageData] = useState<any>(null);
const [usageLoading, setUsageLoading] = useState(false);
const [keysData, setKeysData] = useState<any[]>([]);
const [keysLoading, setKeysLoading] = useState(false);
const [expandedTab, setExpandedTab] = useState<'usage' | 'keys'>('usage');
const [activeTab, setActiveTab] = useState<'list' | 'usage'>('list');
const loadUsage = async (userId: number) => {
@@ -161,14 +164,92 @@ export default function UserManagement() {
}
};
const loadKeys = async (userId: number) => {
setKeysLoading(true);
try {
const result = await api.getUserKeys(userId);
setKeysData(result.data || result.keys || []);
} catch (err: any) {
console.error('Failed to load keys:', err);
} finally {
setKeysLoading(false);
}
};
const handleAddKey = async (userId: number) => {
const name = window.prompt('请输入密钥名称', '默认密钥');
if (!name) return;
try {
await api.createUserKey(userId, { name });
void loadKeys(userId);
} catch (err: any) {
alert(err.message || '添加失败');
}
};
const handleResetKey = async (userId: number, keyId: number) => {
if (!window.confirm('重置将生成新密钥并使旧密钥失效,确定吗?')) return;
try {
await api.resetUserKey(userId, keyId);
void loadKeys(userId);
} catch (err: any) {
alert(err.message || '重置失败');
}
};
const handleDeleteKey = async (userId: number, keyId: number) => {
if (!window.confirm('确定删除该密钥吗?')) return;
try {
await api.deleteUserKey(userId, keyId);
void loadKeys(userId);
} catch (err: any) {
alert(err.message || '删除失败');
}
};
const handleEditKeyName = async (userId: number, keyId: number, currentName: string) => {
const name = window.prompt('修改密钥名称', currentName);
if (!name || name === currentName) return;
try {
await api.updateUserKey(userId, keyId, { name });
void loadKeys(userId);
} catch (err: any) {
alert(err.message || '修改失败');
}
};
const handleToggleKey = async (userId: number, keyId: number, enabled: boolean) => {
try {
await api.updateUserKey(userId, keyId, { enabled });
void loadKeys(userId);
} catch (err: any) {
alert(err.message || '更新失败');
}
};
const toggleExpand = (userId: number) => {
if (expandedUserId === userId) {
setExpandedUserId(null);
setUsageData(null);
setKeysData([]);
} else {
setExpandedUserId(userId);
setUsageData(null);
setKeysData([]);
if (expandedTab === 'usage') {
void loadUsage(userId);
} else {
void loadKeys(userId);
}
}
};
const handleExpandedTabChange = (tab: 'usage' | 'keys', userId: number) => {
setExpandedTab(tab);
if (tab === 'usage' && !usageData) {
void loadUsage(userId);
} else if (tab === 'keys' && keysData.length === 0) {
void loadKeys(userId);
}
};
@@ -519,80 +600,156 @@ export default function UserManagement() {
<tr className="usage-detail-row">
<td colSpan={9}>
<div className="usage-detail-container animate-fade-in" style={{ padding: '16px 20px', background: 'var(--color-bg-alt)', borderBottom: '1px solid var(--color-border)' }}>
<h4 style={{ margin: '0 0 12px 0', fontSize: 14 }}>用量详情 - {user.username}</h4>
{usageLoading ? (
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--color-text-muted)' }}>加载中...</div>
) : usageData ? (
<div className="usage-detail-content">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 20 }}>
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>总请求数</div>
<div style={{ fontSize: 20, fontWeight: 600 }}>{usageData.total_requests.toLocaleString()}</div>
</div>
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>总消耗</div>
<div style={{ fontSize: 20, fontWeight: 600, color: 'var(--color-primary)' }}>${usageData.total_cost.toFixed(4)}</div>
</div>
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>近7天消耗</div>
<div style={{ fontSize: 20, fontWeight: 600 }}>${usageData.last_7_days_cost.toFixed(4)}</div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 20 }}>
<button
type="button"
className={`btn-link ${expandedTab === 'usage' ? 'btn-link-primary' : ''}`}
style={{ fontWeight: expandedTab === 'usage' ? 600 : 400, padding: 0, fontSize: 14 }}
onClick={(e) => { e.stopPropagation(); handleExpandedTabChange('usage', user.id); }}
>
用量详情
</button>
<button
type="button"
className={`btn-link ${expandedTab === 'keys' ? 'btn-link-primary' : ''}`}
style={{ fontWeight: expandedTab === 'keys' ? 600 : 400, padding: 0, fontSize: 14 }}
onClick={(e) => { e.stopPropagation(); handleExpandedTabChange('keys', user.id); }}
>
API 密钥管理
</button>
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>用户: {user.username} (ID: {user.id})</div>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 10 }}>最近 30 天趋势</div>
{usageData.trend.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无趋势数据</div>
) : (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: 100, borderBottom: '1px solid var(--color-border)', paddingBottom: 4 }}>
{usageData.trend.map((t: any) => (
<div
key={t.day}
title={`${t.day}: $${t.cost.toFixed(4)} (${t.requests} reqs)`}
style={{
flex: 1,
background: 'var(--color-primary)',
height: `${Math.max(4, (t.cost / (Math.max(...usageData.trend.map((x: any) => x.cost)) || 1)) * 100)}%`,
borderRadius: '2px 2px 0 0',
minWidth: 4
}}
/>
))}
{expandedTab === 'usage' ? (
usageLoading ? (
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--color-text-muted)' }}>加载中...</div>
) : usageData ? (
<div className="usage-detail-content">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 20 }}>
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>总请求数</div>
<div style={{ fontSize: 20, fontWeight: 600 }}>{usageData.total_requests.toLocaleString()}</div>
</div>
)}
</div>
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>总消耗</div>
<div style={{ fontSize: 20, fontWeight: 600, color: 'var(--color-primary)' }}>${usageData.total_cost.toFixed(4)}</div>
</div>
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>近7天消耗</div>
<div style={{ fontSize: 20, fontWeight: 600 }}>${usageData.last_7_days_cost.toFixed(4)}</div>
</div>
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 10 }}>最近使用日志</div>
<div className="table-responsive" style={{ maxHeight: 300, overflowY: 'auto', border: '1px solid var(--color-border)', borderRadius: 8 }}>
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 10 }}>最近 30 天趋势</div>
{usageData.trend.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无趋势数据</div>
) : (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: 100, borderBottom: '1px solid var(--color-border)', paddingBottom: 4 }}>
{usageData.trend.map((t: any) => (
<div
key={t.day}
title={`${t.day}: $${t.cost.toFixed(4)} (${t.requests} reqs)`}
style={{
flex: 1,
background: 'var(--color-primary)',
height: `${Math.max(4, (t.cost / (Math.max(...usageData.trend.map((x: any) => x.cost)) || 1)) * 100)}%`,
borderRadius: '2px 2px 0 0',
minWidth: 4
}}
/>
))}
</div>
)}
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 10 }}>最近使用日志</div>
<div className="table-responsive" style={{ maxHeight: 300, overflowY: 'auto', border: '1px solid var(--color-border)', borderRadius: 8 }}>
<table className="data-table" style={{ fontSize: 12 }}>
<thead style={{ position: 'sticky', top: 0, zIndex: 1 }}>
<tr>
<th>时间</th>
<th>模型</th>
<th>消耗 ($)</th>
<th>Token (P/C/T)</th>
</tr>
</thead>
<tbody>
{usageData.recent_logs.map((log: any) => (
<tr key={log.id}>
<td>{formatDate(log.created_at)}</td>
<td><code style={{ fontSize: 11 }}>{log.model}</code></td>
<td style={{ color: 'var(--color-primary)' }}>${log.cost.toFixed(5)}</td>
<td>{log.prompt_tokens}/{log.completion_tokens}/{log.total_tokens}</td>
</tr>
))}
{usageData.recent_logs.length === 0 && (
<tr><td colSpan={4} style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>暂无日志</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
) : (
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--color-text-muted)' }}>获取数据失败</div>
)
) : (
<div className="keys-management-content">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>API 密钥列表</div>
<button type="button" className="btn btn-primary btn-sm" onClick={(e) => { e.stopPropagation(); handleAddKey(user.id); }}>新建密钥</button>
</div>
{keysLoading ? (
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--color-text-muted)' }}>加载中...</div>
) : (
<div className="table-responsive" style={{ border: '1px solid var(--color-border)', borderRadius: 8 }}>
<table className="data-table" style={{ fontSize: 12 }}>
<thead style={{ position: 'sticky', top: 0, zIndex: 1 }}>
<thead>
<tr>
<th>时间</th>
<th>模型</th>
<th>消耗 ($)</th>
<th>Token (P/C/T)</th>
<th>名称</th>
<th>密钥内容</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{usageData.recent_logs.map((log: any) => (
<tr key={log.id}>
<td>{formatDate(log.created_at)}</td>
<td><code style={{ fontSize: 11 }}>{log.model}</code></td>
<td style={{ color: 'var(--color-primary)' }}>${log.cost.toFixed(5)}</td>
<td>{log.prompt_tokens}/{log.completion_tokens}/{log.total_tokens}</td>
{keysData.map((k: any) => (
<tr key={k.id}>
<td style={{ cursor: 'pointer' }} onClick={(e) => { e.stopPropagation(); handleEditKeyName(user.id, k.id, k.name); }}>
{k.name} <span style={{ fontSize: 10, color: 'var(--color-primary)', marginLeft: 4 }}>✎</span>
</td>
<td><code style={{ fontSize: 11, background: 'var(--color-bg)', padding: '2px 4px' }}>{k.key}</code></td>
<td>
<span
className={`badge ${k.enabled ? 'badge-success' : 'badge-muted'}`}
style={{ fontSize: 10, cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); handleToggleKey(user.id, k.id, !k.enabled); }}
>
{k.enabled ? '已启用' : '已禁用'}
</span>
</td>
<td>{formatDate(k.createdAt || k.created_at)}</td>
<td>
<div style={{ display: 'flex', gap: 10 }}>
<button type="button" className="btn btn-link btn-link-primary" style={{ fontSize: 12 }} onClick={(e) => { e.stopPropagation(); handleResetKey(user.id, k.id); }}>重置</button>
<button type="button" className="btn btn-link btn-link-danger" style={{ fontSize: 12 }} onClick={(e) => { e.stopPropagation(); handleDeleteKey(user.id, k.id); }}>删除</button>
</div>
</td>
</tr>
))}
{usageData.recent_logs.length === 0 && (
<tr><td colSpan={4} style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>暂无日志</td></tr>
{keysData.length === 0 && (
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--color-text-muted)', padding: '20px 0' }}>暂无密钥</td></tr>
)}
</tbody>
</table>
</div>
</div>
)}
</div>
) : (
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--color-text-muted)' }}>获取数据失败</div>
)}
</div>
</td>