feat: 用户管理页面增加用量统计标签

- 用户列表新增总请求、总消耗($)、7天消耗列
- 点击用户行展开用量详情(趋势+最近日志)
- 添加"用量统计"标签页切换
This commit is contained in:
boos
2026-05-19 02:47:19 +08:00
parent 1cef28169c
commit d432ded68f
+154 -5
View File
@@ -16,6 +16,9 @@ interface User {
created_at?: string;
updatedAt?: string;
updated_at?: string;
total_requests?: number;
total_cost?: number;
last_7_days_cost?: number;
}
interface UserStats {
@@ -66,6 +69,9 @@ function normalizeUsers(result: any): User[] {
created_at: user.created_at,
updatedAt: user.updatedAt ?? user.updated_at,
updated_at: user.updated_at,
total_requests: user.total_requests,
total_cost: user.total_cost,
last_7_days_cost: user.last_7_days_cost,
}));
}
@@ -138,6 +144,33 @@ export default function UserManagement() {
const [editingUser, setEditingUser] = useState<User | null>(null);
const [form, setForm] = useState<UserFormState>(emptyForm);
const [deleteTarget, setDeleteTarget] = useState<User | 'selected' | null>(null);
const [expandedUserId, setExpandedUserId] = useState<number | null>(null);
const [usageData, setUsageData] = useState<any>(null);
const [usageLoading, setUsageLoading] = useState(false);
const [activeTab, setActiveTab] = useState<'list' | 'usage'>('list');
const loadUsage = async (userId: number) => {
setUsageLoading(true);
try {
const data = await api.getUserUsage(userId);
setUsageData(data.data);
} catch (err: any) {
console.error('Failed to load usage:', err);
} finally {
setUsageLoading(false);
}
};
const toggleExpand = (userId: number) => {
if (expandedUserId === userId) {
setExpandedUserId(null);
setUsageData(null);
} else {
setExpandedUserId(userId);
setUsageData(null);
void loadUsage(userId);
}
};
const loadUsers = useCallback(async () => {
setLoading(true);
@@ -321,6 +354,23 @@ export default function UserManagement() {
{error ? <div className="alert alert-error user-management-alert">{error}</div> : null}
<div className="tabs user-management-tabs" style={{ marginBottom: 20 }}>
<button
type="button"
className={`tab-item ${activeTab === 'list' ? 'active' : ''}`}
onClick={() => setActiveTab('list')}
>
用户列表
</button>
<button
type="button"
className={`tab-item ${activeTab === 'usage' ? 'active' : ''}`}
onClick={() => setActiveTab('usage')}
>
用量统计
</button>
</div>
<div className="user-management-stats">
{statCards.map((stat) => (
<div className={`user-stat-card user-stat-${stat.tone}`} key={stat.label}>
@@ -398,15 +448,22 @@ export default function UserManagement() {
<th>用户</th>
<th>角色</th>
<th>状态</th>
<th>总请求</th>
<th>总消耗 ($)</th>
<th>7天消耗</th>
<th>注册时间</th>
<th>最近更新</th>
<th className="user-management-actions-col">操作</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className={selectedIds.includes(user.id) ? 'row-selected' : ''}>
<td className="user-management-check-col">
<React.Fragment key={user.id}>
<tr
className={`${selectedIds.includes(user.id) ? 'row-selected' : ''} ${expandedUserId === user.id ? 'row-expanded' : ''}`}
onClick={() => toggleExpand(user.id)}
style={{ cursor: 'pointer' }}
>
<td className="user-management-check-col" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.includes(user.id)}
@@ -436,9 +493,17 @@ export default function UserManagement() {
{statusLabel(user.status)}
</span>
</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 13 }}>
{user.total_requests?.toLocaleString() || 0}
</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 13 }}>
${user.total_cost?.toFixed(4) || '0.0000'}
</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--color-primary)' }}>
${user.last_7_days_cost?.toFixed(4) || '0.0000'}
</td>
<td>{formatDate(user.createdAt || user.created_at)}</td>
<td>{formatDate(user.updatedAt || user.updated_at)}</td>
<td className="user-management-actions-col">
<td className="user-management-actions-col" onClick={(e) => e.stopPropagation()}>
<div className="user-management-row-actions">
<button type="button" className="btn btn-link btn-link-primary" onClick={() => openEditModal(user)}>编辑</button>
{user.status === 'active' ? (
@@ -450,6 +515,90 @@ export default function UserManagement() {
</div>
</td>
</tr>
{expandedUserId === user.id && (
<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={{ 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>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>