Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 499dba6104 | |||
| d432ded68f | |||
| 1cef28169c |
@@ -131,7 +131,8 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
.put(users::update_user)
|
||||
.patch(users::update_user)
|
||||
.delete(users::delete_user),
|
||||
);
|
||||
)
|
||||
.route("/api/admin/users/{id}/usage", get(users::get_user_usage));
|
||||
|
||||
Router::new()
|
||||
.merge(sites_routes)
|
||||
|
||||
+133
-10
@@ -49,6 +49,10 @@ pub struct UserResponse {
|
||||
pub status: String,
|
||||
pub created_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 {
|
||||
@@ -61,6 +65,10 @@ impl From<User> for UserResponse {
|
||||
status: u.status,
|
||||
created_at: u.created_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>(
|
||||
r#"
|
||||
SELECT *
|
||||
FROM users
|
||||
WHERE (? = '' OR username LIKE ? OR email LIKE ? OR CAST(id AS TEXT) = ?)
|
||||
AND (? IS NULL OR role = ?)
|
||||
AND (? IS NULL OR status = ?)
|
||||
ORDER BY id DESC
|
||||
SELECT u.*
|
||||
FROM users u
|
||||
WHERE (? = '' OR u.username LIKE ? OR u.email LIKE ? OR CAST(u.id AS TEXT) = ?)
|
||||
AND (? IS NULL OR u.role = ?)
|
||||
AND (? IS NULL OR u.status = ?)
|
||||
ORDER BY u.id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"#,
|
||||
)
|
||||
@@ -144,7 +152,7 @@ pub async fn list_users(
|
||||
.fetch_one(&*state.db)
|
||||
.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!({
|
||||
"data": items,
|
||||
"users": items,
|
||||
@@ -203,26 +211,141 @@ pub async fn create_user(
|
||||
let password_hash = hash_password(&payload.password)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
|
||||
|
||||
let mut tx = state.db.begin().await?;
|
||||
|
||||
let result =
|
||||
sqlx::query("INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)")
|
||||
.bind(payload.username.trim())
|
||||
.bind(payload.email.trim())
|
||||
.bind(&password_hash)
|
||||
.bind(&role)
|
||||
.execute(&*state.db)
|
||||
.execute(&mut *tx)
|
||||
.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 = ?")
|
||||
.bind(result.last_insert_rowid())
|
||||
.bind(user_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
let mut resp = UserResponse::from(user);
|
||||
resp.default_api_key = Some(key);
|
||||
|
||||
Ok((
|
||||
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}
|
||||
pub async fn update_user(
|
||||
State(state): State<AppState>,
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ mod organizations;
|
||||
mod proxy;
|
||||
mod search;
|
||||
mod site_announcements;
|
||||
mod stats;
|
||||
pub(crate) mod stats;
|
||||
mod tasks;
|
||||
mod update_center;
|
||||
mod user;
|
||||
|
||||
+6
-2
@@ -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, persistAuthSession } from './authSession.js';
|
||||
import { clearAuthSession, hasValidAuthSession, hasValidUserSession, persistAuthSession } from './authSession.js';
|
||||
import {
|
||||
FIRST_USE_DOC_REMINDER_KEY,
|
||||
LEGACY_THEME_STORAGE_KEY,
|
||||
@@ -735,6 +735,10 @@ 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 (!authed && !isUserPage) {
|
||||
return <Login t={t} onLogin={(token) => {
|
||||
persistAuthSession(localStorage, token);
|
||||
@@ -751,7 +755,7 @@ function AppShell() {
|
||||
<Suspense fallback={<RouteLoadingFallback />}>
|
||||
<Routes>
|
||||
<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>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -1815,6 +1815,7 @@ export const api = {
|
||||
getUsers: (params?: URLSearchParams) =>
|
||||
request(`/api/admin/users${params ? `?${params.toString()}` : ""}`),
|
||||
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 }) =>
|
||||
request("/api/admin/users", { method: "POST", body: JSON.stringify(data) }),
|
||||
updateUser: (id: number, data: { role?: string; status?: string; username?: string; email?: string; password?: string }) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { persistUserSession } from '../authSession.js';
|
||||
|
||||
export default function UserRegister() {
|
||||
export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
@@ -36,6 +36,7 @@ export default function UserRegister() {
|
||||
}
|
||||
const result = await res.json();
|
||||
persistUserSession(localStorage, result.token);
|
||||
onLogin?.();
|
||||
navigate('/user/dashboard');
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '注册失败');
|
||||
|
||||
Reference in New Issue
Block a user