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)
|
.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
@@ -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
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 }) =>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 || '注册失败');
|
||||||
|
|||||||
Reference in New Issue
Block a user