3 Commits

Author SHA1 Message Date
boos 499dba6104 fix: 完善用户路由和注册回调
- App.tsx 导入 hasValidUserSession 修复 TS 编译错误
- UserRegister 增加 onLogin 回调,注册成功自动跳转
2026-05-19 02:47:23 +08:00
boos d432ded68f feat: 用户管理页面增加用量统计标签
- 用户列表新增总请求、总消耗($)、7天消耗列
- 点击用户行展开用量详情(趋势+最近日志)
- 添加"用量统计"标签页切换
2026-05-19 02:47:19 +08:00
boos 1cef28169c feat: 管理员创建用户自动生成密钥 & 用量统计 API
- create_user() 自动生成 sk-user-* 密钥并关联用户
  (使用事务确保用户+密钥原子创建)
- 新增 GET /api/admin/users/{id}/usage 返回用量统计
  (总请求/成本、7天成本、30天趋势、最近50条日志)
- api.ts 新增 getUserUsage(id) 方法
- mod.rs 导出 stats 模块供 user 路由复用
2026-05-19 02:47:15 +08:00
7 changed files with 338 additions and 59 deletions
+2 -1
View File
@@ -131,7 +131,8 @@ pub fn routes(state: AppState) -> Router<AppState> {
.put(users::update_user) .put(users::update_user)
.patch(users::update_user) .patch(users::update_user)
.delete(users::delete_user), .delete(users::delete_user),
); )
.route("/api/admin/users/{id}/usage", get(users::get_user_usage));
Router::new() Router::new()
.merge(sites_routes) .merge(sites_routes)
+133 -10
View File
@@ -49,6 +49,10 @@ pub struct UserResponse {
pub status: String, pub status: String,
pub created_at: Option<String>, pub created_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
pub default_api_key: Option<String>,
pub total_requests: Option<i64>,
pub total_cost: Option<f64>,
pub last_7_days_cost: Option<f64>,
} }
impl From<User> for UserResponse { impl From<User> for UserResponse {
@@ -61,6 +65,10 @@ impl From<User> for UserResponse {
status: u.status, status: u.status,
created_at: u.created_at, created_at: u.created_at,
updated_at: u.updated_at, updated_at: u.updated_at,
default_api_key: None,
total_requests: None,
total_cost: None,
last_7_days_cost: None,
} }
} }
} }
@@ -89,12 +97,12 @@ pub async fn list_users(
let users: Vec<User> = sqlx::query_as::<_, User>( let users: Vec<User> = sqlx::query_as::<_, User>(
r#" r#"
SELECT * SELECT u.*
FROM users FROM users u
WHERE (? = '' OR username LIKE ? OR email LIKE ? OR CAST(id AS TEXT) = ?) WHERE (? = '' OR u.username LIKE ? OR u.email LIKE ? OR CAST(u.id AS TEXT) = ?)
AND (? IS NULL OR role = ?) AND (? IS NULL OR u.role = ?)
AND (? IS NULL OR status = ?) AND (? IS NULL OR u.status = ?)
ORDER BY id DESC ORDER BY u.id DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
"#, "#,
) )
@@ -144,7 +152,7 @@ pub async fn list_users(
.fetch_one(&*state.db) .fetch_one(&*state.db)
.await?; .await?;
let items: Vec<UserResponse> = users.into_iter().map(Into::into).collect(); let items: Vec<UserResponse> = users.into_iter().map(|u| UserResponse::from(u)).collect();
Ok(Json(json!({ Ok(Json(json!({
"data": items, "data": items,
"users": items, "users": items,
@@ -203,26 +211,141 @@ pub async fn create_user(
let password_hash = hash_password(&payload.password) let password_hash = hash_password(&payload.password)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?; .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
let mut tx = state.db.begin().await?;
let result = let result =
sqlx::query("INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)") sqlx::query("INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)")
.bind(payload.username.trim()) .bind(payload.username.trim())
.bind(payload.email.trim()) .bind(payload.email.trim())
.bind(&password_hash) .bind(&password_hash)
.bind(&role) .bind(&role)
.execute(&*state.db) .execute(&mut *tx)
.await?; .await?;
let user_id = result.last_insert_rowid();
// Auto-generate downstream API key
let key = format!("sk-user-{}", uuid::Uuid::new_v4());
let key_name = format!("{} 的默认密钥", payload.username.trim());
sqlx::query("INSERT INTO downstream_api_keys (name, key, user_id, enabled) VALUES (?, ?, ?, ?)")
.bind(&key_name)
.bind(&key)
.bind(user_id)
.bind(true)
.execute(&mut *tx)
.await?;
tx.commit().await?;
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?") let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(result.last_insert_rowid()) .bind(user_id)
.fetch_one(&*state.db) .fetch_one(&*state.db)
.await?; .await?;
let mut resp = UserResponse::from(user);
resp.default_api_key = Some(key);
Ok(( Ok((
axum::http::StatusCode::CREATED, axum::http::StatusCode::CREATED,
Json(json!({ "data": UserResponse::from(user) })), Json(json!({ "data": resp })),
)) ))
} }
/// GET /api/admin/users/{id}/usage
pub async fn get_user_usage(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Getting usage for user: {}", id);
// Total requests and cost
let stats: (i64, f64) = sqlx::query_as(
r#"
SELECT
COUNT(l.id) as total_requests,
SUM(l.cost) as total_cost
FROM usage_logs l
JOIN downstream_api_keys k ON l.downstream_key_id = k.id
WHERE k.user_id = ?
"#
)
.bind(id)
.fetch_one(&*state.db)
.await?;
// Last 7 days cost
let last_7_days_cost: f64 = sqlx::query_scalar(
r#"
SELECT SUM(cost)
FROM usage_logs l
JOIN downstream_api_keys k ON l.downstream_key_id = k.id
WHERE k.user_id = ? AND l.created_at >= datetime('now', '-7 days')
"#
)
.bind(id)
.fetch_one(&*state.db)
.await?;
// Daily trend (last 30 days)
let trend: Vec<(String, i64, f64)> = sqlx::query_as(
r#"
SELECT
strftime('%Y-%m-%d', created_at) as day,
COUNT(*) as request_count,
SUM(cost) as cost
FROM usage_logs l
JOIN downstream_api_keys k ON l.downstream_key_id = k.id
WHERE k.user_id = ? AND l.created_at >= datetime('now', '-30 days')
GROUP BY day
ORDER BY day ASC
"#
)
.bind(id)
.fetch_all(&*state.db)
.await?;
// Recent logs
let recent_logs: Vec<Value> = sqlx::query(
r#"
SELECT l.*
FROM usage_logs l
JOIN downstream_api_keys k ON l.downstream_key_id = k.id
WHERE k.user_id = ?
ORDER BY l.id DESC
LIMIT 50
"#
)
.bind(id)
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|row| {
use sqlx::Row;
json!({
"id": row.get::<i64, _>("id"),
"model": row.get::<Option<String>, _>("model"),
"prompt_tokens": row.get::<i64, _>("prompt_tokens"),
"completion_tokens": row.get::<i64, _>("completion_tokens"),
"total_tokens": row.get::<i64, _>("total_tokens"),
"cost": row.get::<f64, _>("cost"),
"created_at": row.get::<Option<String>, _>("created_at"),
})
})
.collect();
Ok(Json(json!({
"data": {
"total_requests": stats.0,
"total_cost": stats.1,
"last_7_days_cost": last_7_days_cost,
"trend": trend.into_iter().map(|t| json!({ "day": t.0, "requests": t.1, "cost": t.2 })).collect::<Vec<_>>(),
"recent_logs": recent_logs,
}
})))
}
/// PUT /api/admin/users/{id} /// PUT /api/admin/users/{id}
pub async fn update_user( pub async fn update_user(
State(state): State<AppState>, State(state): State<AppState>,
+1 -1
View File
@@ -23,7 +23,7 @@ mod organizations;
mod proxy; mod proxy;
mod search; mod search;
mod site_announcements; mod site_announcements;
mod stats; pub(crate) mod stats;
mod tasks; mod tasks;
mod update_center; mod update_center;
mod user; mod user;
+6 -2
View File
@@ -5,7 +5,7 @@ import SearchModal from './components/SearchModal.js';
import NotificationPanel from './components/NotificationPanel.js'; import NotificationPanel from './components/NotificationPanel.js';
import TooltipLayer from './components/TooltipLayer.js'; import TooltipLayer from './components/TooltipLayer.js';
import { api } from './api.js'; import { api } from './api.js';
import { clearAuthSession, hasValidAuthSession, persistAuthSession } from './authSession.js'; import { clearAuthSession, hasValidAuthSession, hasValidUserSession, persistAuthSession } from './authSession.js';
import { import {
FIRST_USE_DOC_REMINDER_KEY, FIRST_USE_DOC_REMINDER_KEY,
LEGACY_THEME_STORAGE_KEY, LEGACY_THEME_STORAGE_KEY,
@@ -735,6 +735,10 @@ function AppShell() {
const isUserPage = location.pathname.startsWith('/user/'); const isUserPage = location.pathname.startsWith('/user/');
const isPublicUserPage = ['/user/login', '/user/register'].includes(location.pathname); const isPublicUserPage = ['/user/login', '/user/register'].includes(location.pathname);
if (isUserPage && isPublicUserPage && userAuthed) {
return <Navigate to="/user/dashboard" />;
}
if (!authed && !isUserPage) { if (!authed && !isUserPage) {
return <Login t={t} onLogin={(token) => { return <Login t={t} onLogin={(token) => {
persistAuthSession(localStorage, token); persistAuthSession(localStorage, token);
@@ -751,7 +755,7 @@ function AppShell() {
<Suspense fallback={<RouteLoadingFallback />}> <Suspense fallback={<RouteLoadingFallback />}>
<Routes> <Routes>
<Route path="/user/login" element={<UserLogin onLogin={() => setUserAuthed(true)} />} /> <Route path="/user/login" element={<UserLogin onLogin={() => setUserAuthed(true)} />} />
<Route path="/user/register" element={<UserRegister />} /> <Route path="/user/register" element={<UserRegister onLogin={() => setUserAuthed(true)} />} />
</Routes> </Routes>
</Suspense> </Suspense>
); );
+1
View File
@@ -1815,6 +1815,7 @@ export const api = {
getUsers: (params?: URLSearchParams) => getUsers: (params?: URLSearchParams) =>
request(`/api/admin/users${params ? `?${params.toString()}` : ""}`), request(`/api/admin/users${params ? `?${params.toString()}` : ""}`),
getUserById: (id: number) => request(`/api/admin/users/${id}`), getUserById: (id: number) => request(`/api/admin/users/${id}`),
getUserUsage: (id: number) => request(`/api/admin/users/${id}/usage`),
createUser: (data: { username: string; email: string; password: string; role?: string }) => createUser: (data: { username: string; email: string; password: string; role?: string }) =>
request("/api/admin/users", { method: "POST", body: JSON.stringify(data) }), request("/api/admin/users", { method: "POST", body: JSON.stringify(data) }),
updateUser: (id: number, data: { role?: string; status?: string; username?: string; email?: string; password?: string }) => updateUser: (id: number, data: { role?: string; status?: string; username?: string; email?: string; password?: string }) =>
+154 -5
View File
@@ -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,15 +448,22 @@ 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
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 <input
type="checkbox" type="checkbox"
checked={selectedIds.includes(user.id)} checked={selectedIds.includes(user.id)}
@@ -436,9 +493,17 @@ export default function UserManagement() {
{statusLabel(user.status)} {statusLabel(user.status)}
</span> </span>
</td> </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.createdAt || user.created_at)}</td>
<td>{formatDate(user.updatedAt || user.updated_at)}</td> <td className="user-management-actions-col" onClick={(e) => e.stopPropagation()}>
<td className="user-management-actions-col">
<div className="user-management-row-actions"> <div className="user-management-row-actions">
<button type="button" className="btn btn-link btn-link-primary" onClick={() => openEditModal(user)}>编辑</button> <button type="button" className="btn btn-link btn-link-primary" onClick={() => openEditModal(user)}>编辑</button>
{user.status === 'active' ? ( {user.status === 'active' ? (
@@ -450,6 +515,90 @@ export default function UserManagement() {
</div> </div>
</td> </td>
</tr> </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>
+2 -1
View File
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { persistUserSession } from '../authSession.js'; import { persistUserSession } from '../authSession.js';
export default function UserRegister() { export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
const navigate = useNavigate(); const navigate = useNavigate();
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@@ -36,6 +36,7 @@ export default function UserRegister() {
} }
const result = await res.json(); const result = await res.json();
persistUserSession(localStorage, result.token); persistUserSession(localStorage, result.token);
onLogin?.();
navigate('/user/dashboard'); navigate('/user/dashboard');
} catch (err: any) { } catch (err: any) {
setError(err?.message || '注册失败'); setError(err?.message || '注册失败');