后端: - 添加 GET /api/v1/stats/dashboard 聚合统计端点 (账号数/活跃服务商/今日请求/今日Token用量等7项指标) - 需要 account:admin 权限 Admin 前端 (Next.js 14 + shadcn/ui + Tailwind + Recharts): - 设计系统: Dark Mode OLED (#020617 背景, #22C55E CTA) - 登录页: 双栏布局, 品牌区 + 表单 - Dashboard 布局: Sidebar 导航 + Header + 主内容区 - 仪表盘: 4 统计卡片 + AreaChart 请求趋势 + BarChart Token用量 - 8 个 CRUD 页面: - 账号管理 (搜索/角色/状态筛选, 编辑/启用禁用) - 服务商 (CRUD + API Key masked) - 模型管理 (Provider筛选, CRUD) - API 密钥 (创建/撤销, 一次性显示token) - 用量统计 (LineChart + BarChart) - 中转任务 (状态筛选, 展开详情) - 系统配置 (分类Tab, 编辑) - 操作日志 (Action筛选, 展开详情) - 14 个 shadcn 风格 UI 组件 (手写实现) - 类型化 API 客户端 (SaaSClient, 20+ 方法, 401 自动跳转) - AuthGuard 路由保护 + useAuth() hook 验证: tsc --noEmit 零 error, pnpm build 13 页面成功, cargo test 21 通过
169 lines
6.5 KiB
Rust
169 lines
6.5 KiB
Rust
//! 账号管理 HTTP 处理器
|
|
|
|
use axum::{
|
|
extract::{Extension, Path, Query, State},
|
|
Json,
|
|
};
|
|
use crate::state::AppState;
|
|
use crate::error::SaasResult;
|
|
use crate::auth::types::AuthContext;
|
|
use crate::auth::handlers::{log_operation, check_permission};
|
|
use super::{types::*, service};
|
|
|
|
fn require_admin(ctx: &AuthContext) -> SaasResult<()> {
|
|
check_permission(ctx, "account:admin")
|
|
}
|
|
|
|
/// GET /api/v1/accounts (admin only)
|
|
pub async fn list_accounts(
|
|
State(state): State<AppState>,
|
|
Query(query): Query<ListAccountsQuery>,
|
|
Extension(ctx): Extension<AuthContext>,
|
|
) -> SaasResult<Json<PaginatedResponse<serde_json::Value>>> {
|
|
require_admin(&ctx)?;
|
|
service::list_accounts(&state.db, &query).await.map(Json)
|
|
}
|
|
|
|
/// GET /api/v1/accounts/:id
|
|
pub async fn get_account(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Extension(ctx): Extension<AuthContext>,
|
|
) -> SaasResult<Json<serde_json::Value>> {
|
|
// 只能查看自己,或 admin 查看任何人
|
|
if id != ctx.account_id {
|
|
require_admin(&ctx)?;
|
|
}
|
|
service::get_account(&state.db, &id).await.map(Json)
|
|
}
|
|
|
|
/// PUT /api/v1/accounts/:id (admin or self for limited fields)
|
|
pub async fn update_account(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Extension(ctx): Extension<AuthContext>,
|
|
Json(req): Json<UpdateAccountRequest>,
|
|
) -> SaasResult<Json<serde_json::Value>> {
|
|
// 非管理员只能修改自己的资料
|
|
if id != ctx.account_id {
|
|
require_admin(&ctx)?;
|
|
}
|
|
let result = service::update_account(&state.db, &id, &req).await?;
|
|
log_operation(&state.db, &ctx.account_id, "account.update", "account", &id, None, None).await?;
|
|
Ok(Json(result))
|
|
}
|
|
|
|
/// PATCH /api/v1/accounts/:id/status (admin only)
|
|
pub async fn update_status(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Extension(ctx): Extension<AuthContext>,
|
|
Json(req): Json<UpdateStatusRequest>,
|
|
) -> SaasResult<Json<serde_json::Value>> {
|
|
require_admin(&ctx)?;
|
|
service::update_account_status(&state.db, &id, &req.status).await?;
|
|
log_operation(&state.db, &ctx.account_id, "account.update_status", "account", &id,
|
|
Some(serde_json::json!({"status": &req.status})), None).await?;
|
|
Ok(Json(serde_json::json!({"ok": true})))
|
|
}
|
|
|
|
/// GET /api/v1/tokens
|
|
pub async fn list_tokens(
|
|
State(state): State<AppState>,
|
|
Extension(ctx): Extension<AuthContext>,
|
|
) -> SaasResult<Json<Vec<TokenInfo>>> {
|
|
service::list_api_tokens(&state.db, &ctx.account_id).await.map(Json)
|
|
}
|
|
|
|
/// POST /api/v1/tokens
|
|
pub async fn create_token(
|
|
State(state): State<AppState>,
|
|
Extension(ctx): Extension<AuthContext>,
|
|
Json(req): Json<CreateTokenRequest>,
|
|
) -> SaasResult<Json<TokenInfo>> {
|
|
let token = service::create_api_token(&state.db, &ctx.account_id, &req).await?;
|
|
log_operation(&state.db, &ctx.account_id, "token.create", "api_token", &token.id,
|
|
Some(serde_json::json!({"name": &req.name})), None).await?;
|
|
Ok(Json(token))
|
|
}
|
|
|
|
/// DELETE /api/v1/tokens/:id
|
|
pub async fn revoke_token(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Extension(ctx): Extension<AuthContext>,
|
|
) -> SaasResult<Json<serde_json::Value>> {
|
|
service::revoke_api_token(&state.db, &id, &ctx.account_id).await?;
|
|
log_operation(&state.db, &ctx.account_id, "token.revoke", "api_token", &id, None, None).await?;
|
|
Ok(Json(serde_json::json!({"ok": true})))
|
|
}
|
|
|
|
/// GET /api/v1/logs/operations (admin only)
|
|
pub async fn list_operation_logs(
|
|
State(state): State<AppState>,
|
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
|
Extension(ctx): Extension<AuthContext>,
|
|
) -> SaasResult<Json<Vec<serde_json::Value>>> {
|
|
require_admin(&ctx)?;
|
|
let page: i64 = params.get("page").and_then(|v| v.parse().ok()).unwrap_or(1);
|
|
let page_size: i64 = params.get("page_size").and_then(|v| v.parse().ok()).unwrap_or(50);
|
|
let offset = (page - 1) * page_size;
|
|
|
|
let rows: Vec<(i64, Option<String>, String, Option<String>, Option<String>, Option<String>, Option<String>, String)> =
|
|
sqlx::query_as(
|
|
"SELECT id, account_id, action, target_type, target_id, details, ip_address, created_at
|
|
FROM operation_logs ORDER BY created_at DESC LIMIT ?1 OFFSET ?2"
|
|
)
|
|
.bind(page_size)
|
|
.bind(offset)
|
|
.fetch_all(&state.db)
|
|
.await?;
|
|
|
|
let items: Vec<serde_json::Value> = rows.into_iter().map(|(id, account_id, action, target_type, target_id, details, ip_address, created_at)| {
|
|
serde_json::json!({
|
|
"id": id, "account_id": account_id, "action": action,
|
|
"target_type": target_type, "target_id": target_id,
|
|
"details": details.and_then(|d| serde_json::from_str::<serde_json::Value>(&d).ok()),
|
|
"ip_address": ip_address, "created_at": created_at,
|
|
})
|
|
}).collect();
|
|
|
|
Ok(Json(items))
|
|
}
|
|
|
|
/// GET /api/v1/stats/dashboard — 仪表盘聚合统计 (需要 admin 权限)
|
|
pub async fn dashboard_stats(
|
|
State(state): State<AppState>,
|
|
Extension(ctx): Extension<AuthContext>,
|
|
) -> SaasResult<Json<serde_json::Value>> {
|
|
require_admin(&ctx)?;
|
|
|
|
let total_accounts: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM accounts")
|
|
.fetch_one(&state.db).await?;
|
|
let active_accounts: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM accounts WHERE status = 'active'")
|
|
.fetch_one(&state.db).await?;
|
|
let tasks_today: (i64,) = sqlx::query_as(
|
|
"SELECT COUNT(*) FROM relay_tasks WHERE date(created_at) = date('now')"
|
|
).fetch_one(&state.db).await?;
|
|
let active_providers: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM providers WHERE enabled = 1")
|
|
.fetch_one(&state.db).await?;
|
|
let active_models: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM models WHERE enabled = 1")
|
|
.fetch_one(&state.db).await?;
|
|
let tokens_today_input: (i64,) = sqlx::query_as(
|
|
"SELECT COALESCE(SUM(input_tokens), 0) FROM usage_records WHERE date(created_at) = date('now')"
|
|
).fetch_one(&state.db).await?;
|
|
let tokens_today_output: (i64,) = sqlx::query_as(
|
|
"SELECT COALESCE(SUM(output_tokens), 0) FROM usage_records WHERE date(created_at) = date('now')"
|
|
).fetch_one(&state.db).await?;
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"total_accounts": total_accounts.0,
|
|
"active_accounts": active_accounts.0,
|
|
"tasks_today": tasks_today.0,
|
|
"active_providers": active_providers.0,
|
|
"active_models": active_models.0,
|
|
"tokens_today_input": tokens_today_input.0,
|
|
"tokens_today_output": tokens_today_output.0,
|
|
})))
|
|
}
|