feat: 用户管理页面增加用量统计标签
- 用户列表新增总请求、总消耗($)、7天消耗列 - 点击用户行展开用量详情(趋势+最近日志) - 添加"用量统计"标签页切换
This commit is contained in:
@@ -16,6 +16,9 @@ interface User {
|
|||||||
created_at?: string;
|
created_at?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
|
total_requests?: number;
|
||||||
|
total_cost?: number;
|
||||||
|
last_7_days_cost?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserStats {
|
interface UserStats {
|
||||||
@@ -66,6 +69,9 @@ function normalizeUsers(result: any): User[] {
|
|||||||
created_at: user.created_at,
|
created_at: user.created_at,
|
||||||
updatedAt: user.updatedAt ?? user.updated_at,
|
updatedAt: user.updatedAt ?? user.updated_at,
|
||||||
updated_at: 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 [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
const [form, setForm] = useState<UserFormState>(emptyForm);
|
const [form, setForm] = useState<UserFormState>(emptyForm);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<User | 'selected' | null>(null);
|
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 () => {
|
const loadUsers = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -321,6 +354,23 @@ export default function UserManagement() {
|
|||||||
|
|
||||||
{error ? <div className="alert alert-error user-management-alert">{error}</div> : null}
|
{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">
|
<div className="user-management-stats">
|
||||||
{statCards.map((stat) => (
|
{statCards.map((stat) => (
|
||||||
<div className={`user-stat-card user-stat-${stat.tone}`} key={stat.label}>
|
<div className={`user-stat-card user-stat-${stat.tone}`} key={stat.label}>
|
||||||
@@ -398,58 +448,157 @@ export default function UserManagement() {
|
|||||||
<th>用户</th>
|
<th>用户</th>
|
||||||
<th>角色</th>
|
<th>角色</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
|
<th>总请求</th>
|
||||||
|
<th>总消耗 ($)</th>
|
||||||
|
<th>7天消耗</th>
|
||||||
<th>注册时间</th>
|
<th>注册时间</th>
|
||||||
<th>最近更新</th>
|
|
||||||
<th className="user-management-actions-col">操作</th>
|
<th className="user-management-actions-col">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<tr key={user.id} className={selectedIds.includes(user.id) ? 'row-selected' : ''}>
|
<React.Fragment key={user.id}>
|
||||||
<td className="user-management-check-col">
|
<tr
|
||||||
<input
|
className={`${selectedIds.includes(user.id) ? 'row-selected' : ''} ${expandedUserId === user.id ? 'row-expanded' : ''}`}
|
||||||
type="checkbox"
|
onClick={() => toggleExpand(user.id)}
|
||||||
checked={selectedIds.includes(user.id)}
|
style={{ cursor: 'pointer' }}
|
||||||
onChange={(event) => updateSelection(user.id, event.target.checked)}
|
>
|
||||||
aria-label={`选择 ${user.username}`}
|
<td className="user-management-check-col" onClick={(e) => e.stopPropagation()}>
|
||||||
/>
|
<input
|
||||||
</td>
|
type="checkbox"
|
||||||
<td>
|
checked={selectedIds.includes(user.id)}
|
||||||
<div className="user-identity-cell">
|
onChange={(event) => updateSelection(user.id, event.target.checked)}
|
||||||
<div className="user-avatar">{user.username.slice(0, 1).toUpperCase() || 'U'}</div>
|
aria-label={`选择 ${user.username}`}
|
||||||
<div className="user-identity-text">
|
/>
|
||||||
<div className="user-name-line">
|
</td>
|
||||||
<span className="user-name">{user.username}</span>
|
<td>
|
||||||
<span className="user-id">#{user.id}</span>
|
<div className="user-identity-cell">
|
||||||
|
<div className="user-avatar">{user.username.slice(0, 1).toUpperCase() || 'U'}</div>
|
||||||
|
<div className="user-identity-text">
|
||||||
|
<div className="user-name-line">
|
||||||
|
<span className="user-name">{user.username}</span>
|
||||||
|
<span className="user-id">#{user.id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="user-email">{user.email}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="user-email">{user.email}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
<td>
|
||||||
<td>
|
<span className={`badge ${user.role === 'admin' ? 'badge-info' : 'badge-muted'}`}>
|
||||||
<span className={`badge ${user.role === 'admin' ? 'badge-info' : 'badge-muted'}`}>
|
{roleLabel(user.role)}
|
||||||
{roleLabel(user.role)}
|
</span>
|
||||||
</span>
|
</td>
|
||||||
</td>
|
<td>
|
||||||
<td>
|
<span className={`badge ${user.status === 'active' ? 'badge-success' : 'badge-error'}`}>
|
||||||
<span className={`badge ${user.status === 'active' ? 'badge-success' : 'badge-error'}`}>
|
{statusLabel(user.status)}
|
||||||
{statusLabel(user.status)}
|
</span>
|
||||||
</span>
|
</td>
|
||||||
</td>
|
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 13 }}>
|
||||||
<td>{formatDate(user.createdAt || user.created_at)}</td>
|
{user.total_requests?.toLocaleString() || 0}
|
||||||
<td>{formatDate(user.updatedAt || user.updated_at)}</td>
|
</td>
|
||||||
<td className="user-management-actions-col">
|
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 13 }}>
|
||||||
<div className="user-management-row-actions">
|
${user.total_cost?.toFixed(4) || '0.0000'}
|
||||||
<button type="button" className="btn btn-link btn-link-primary" onClick={() => openEditModal(user)}>编辑</button>
|
</td>
|
||||||
{user.status === 'active' ? (
|
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--color-primary)' }}>
|
||||||
<button type="button" className="btn btn-link btn-link-warning" onClick={() => updateUserStatus(user, 'disabled')} disabled={saving}>禁用</button>
|
${user.last_7_days_cost?.toFixed(4) || '0.0000'}
|
||||||
) : (
|
</td>
|
||||||
<button type="button" className="btn btn-link btn-link-info" onClick={() => updateUserStatus(user, 'active')} disabled={saving}>启用</button>
|
<td>{formatDate(user.createdAt || user.created_at)}</td>
|
||||||
)}
|
<td className="user-management-actions-col" onClick={(e) => e.stopPropagation()}>
|
||||||
<button type="button" className="btn btn-link btn-link-danger" onClick={() => setDeleteTarget(user)} disabled={saving}>删除</button>
|
<div className="user-management-row-actions">
|
||||||
</div>
|
<button type="button" className="btn btn-link btn-link-primary" onClick={() => openEditModal(user)}>编辑</button>
|
||||||
</td>
|
{user.status === 'active' ? (
|
||||||
</tr>
|
<button type="button" className="btn btn-link btn-link-warning" onClick={() => updateUserStatus(user, 'disabled')} disabled={saving}>禁用</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="btn btn-link btn-link-info" onClick={() => updateUserStatus(user, 'active')} disabled={saving}>启用</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-link btn-link-danger" onClick={() => setDeleteTarget(user)} disabled={saving}>删除</button>
|
||||||
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Reference in New Issue
Block a user