feat: 用户管理页面增加用量统计标签
- 用户列表新增总请求、总消耗($)、7天消耗列 - 点击用户行展开用量详情(趋势+最近日志) - 添加"用量统计"标签页切换
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user