feat: complete user login admin keys and deployment docs
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
# BoosAPI 部署与环境指南
|
||||
|
||||
本文档介绍如何配置和运行 BoosAPI 的开发环境与生产环境。
|
||||
|
||||
## 1. 快速参数参考 (CLI Args)
|
||||
|
||||
后端二进制文件支持以下命令行参数,优先级高于 `config.toml` 和环境变量:
|
||||
|
||||
- `-c, --config <PATH>`: 指定配置文件路径 (默认: `config.toml`)
|
||||
- `-p, --port <PORT>`: 覆盖监听端口
|
||||
- `--host <HOST>`: 覆盖监听地址 (如 `0.0.0.0`)
|
||||
- `--log-level <LEVEL>`: 设置日志级别 (`trace`, `debug`, `info`, `warn`, `error`)
|
||||
- `--migrate-only`: 仅运行数据库迁移后退出
|
||||
- `--check-config`: 加载并验证配置后退出
|
||||
- `-h, --help, -help`: 显示帮助信息
|
||||
- `-V, --version, -version`: 显示版本信息
|
||||
|
||||
示例:
|
||||
```bash
|
||||
./boosapi --config prod.toml --port 8080 --log-level warn
|
||||
# 仅执行迁移
|
||||
./boosapi --migrate-only
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 开发环境 (Development)
|
||||
|
||||
### 前提条件
|
||||
- **Rust**: 1.85+ (推荐使用 rustup)
|
||||
- **Node.js**: 20+ (仅用于前端构建)
|
||||
- **SQLite**: 系统自带或手动安装
|
||||
|
||||
### 步骤
|
||||
1. **克隆项目**:
|
||||
```bash
|
||||
git clone https://github.com/boos/boosapi.git
|
||||
cd boosapi
|
||||
```
|
||||
|
||||
2. **配置环境**:
|
||||
复制 `config.toml.example` 并根据需要修改:
|
||||
```bash
|
||||
cp config.toml.example config.toml
|
||||
```
|
||||
或者直接设置环境变量。
|
||||
|
||||
3. **运行前端 (热更新模式)**:
|
||||
```bash
|
||||
npm install
|
||||
# 启动 vite 开发服务器
|
||||
npm run dev:web
|
||||
```
|
||||
默认访问 `http://localhost:5173`。Vite 会将 `/api` 请求代理到后端 `localhost:4000`。
|
||||
|
||||
4. **运行后端**:
|
||||
```bash
|
||||
# 使用默认配置运行
|
||||
cargo run
|
||||
# 推荐:使用 cargo-watch 自动重载
|
||||
# cargo install cargo-watch
|
||||
# cargo watch -x run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 生产环境 (Production)
|
||||
|
||||
### 推荐:Docker Compose 部署
|
||||
|
||||
1. **准备 docker-compose.yml**:
|
||||
```yaml
|
||||
services:
|
||||
boosapi:
|
||||
image: ghcr.io/boos/boosapi:latest
|
||||
container_name: boosapi
|
||||
ports:
|
||||
- "4000:4000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./config.toml:/app/config.toml
|
||||
environment:
|
||||
- BOOSAPI_AUTH__AUTH_TOKEN=your-secure-admin-token
|
||||
- BOOSAPI_AUTH__JWT_SECRET=your-secure-jwt-secret
|
||||
- BOOSAPI_DB__URL=sqlite:/app/data/boosapi.db?mode=rwc
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
2. **启动**:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 传统 Linux 部署 (Systemd + Nginx)
|
||||
|
||||
1. **构建前端与后端**:
|
||||
```bash
|
||||
# 构建前端
|
||||
npm run build
|
||||
# 构建后端
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
2. **安装**:
|
||||
将生成的二进制文件 `target/release/boosapi` 和静态资源目录 `dist/web` 拷贝到部署目录 (如 `/opt/boosapi`)。
|
||||
|
||||
3. **初始化数据库**:
|
||||
```bash
|
||||
/opt/boosapi/boosapi --migrate-only
|
||||
```
|
||||
|
||||
4. **Systemd 配置** (`/etc/systemd/system/boosapi.service`):
|
||||
```ini
|
||||
[Unit]
|
||||
Description=BoosAPI Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/opt/boosapi
|
||||
ExecStart=/opt/boosapi/boosapi --config /opt/boosapi/config.toml
|
||||
Restart=always
|
||||
Environment=BOOSAPI_SERVER__LOG_LEVEL=info
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
5. **Nginx 反向代理配置**:
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.yourdomain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 支持 WebSockets 与 SSE
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键配置项
|
||||
|
||||
| 配置项 (config.toml) | 环境变量 (ENV) | 说明 |
|
||||
|-------------------|--------------|------|
|
||||
| `server.host` | `BOOSAPI_SERVER__HOST` | 后端监听地址 (默认 127.0.0.1) |
|
||||
| `server.port` | `BOOSAPI_SERVER__PORT` | 后端监听端口 (默认 4000) |
|
||||
| `auth.auth_token` | `BOOSAPI_AUTH__AUTH_TOKEN` | 管理员登录 Token (静态,必填) |
|
||||
| `auth.jwt_secret` | `BOOSAPI_AUTH__JWT_SECRET` | JWT 签名密钥 (普通用户,必填) |
|
||||
| `db.url` | `BOOSAPI_DB__URL` | SQLite 数据库路径 (如 `sqlite:data/boosapi.db`) |
|
||||
| `web_dir` | `BOOSAPI_WEB_DIR` | 前端静态资源目录 (默认 `dist/web`) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 运维操作 (Ops)
|
||||
|
||||
### 检查配置
|
||||
在启动前验证配置文件是否正确:
|
||||
```bash
|
||||
./boosapi --check-config
|
||||
```
|
||||
|
||||
### 数据库备份
|
||||
BoosAPI 使用 SQLite,只需备份数据库文件即可:
|
||||
```bash
|
||||
cp data/boosapi.db data/boosapi.db.bak
|
||||
```
|
||||
|
||||
### 查看日志
|
||||
如果使用 Systemd:
|
||||
```bash
|
||||
journalctl -u boosapi -f
|
||||
```
|
||||
如果使用 Docker:
|
||||
```bash
|
||||
docker logs -f boosapi
|
||||
```
|
||||
@@ -24,7 +24,8 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md"><strong>中文</strong></a> |
|
||||
<a href="README_EN.md">English</a>
|
||||
<a href="README_EN.md">English</a> |
|
||||
<a href="DEPLOY.md">部署指南</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# 编译前后端并启动服务
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
# ── 参数 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
RELEASE=0
|
||||
FRONTEND_ONLY=0
|
||||
BACKEND_ONLY=0
|
||||
NO_RUN=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法: ./build-and-run.sh [选项]
|
||||
|
||||
选项:
|
||||
--release 使用 release 模式编译后端(更慢但性能好)
|
||||
--frontend-only 只构建前端
|
||||
--backend-only 只构建后端
|
||||
--no-run 构建后不启动服务
|
||||
-h, --help 显示帮助
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--release) RELEASE=1 ;;
|
||||
--frontend-only) FRONTEND_ONLY=1 ;;
|
||||
--backend-only) BACKEND_ONLY=1 ;;
|
||||
--no-run) NO_RUN=1 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "未知参数: $1" >&2; usage >&2; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
log() { echo "[$(date '+%H:%M:%S')] $*"; }
|
||||
ok() { echo "[$(date '+%H:%M:%S')] ✓ $*"; }
|
||||
fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
|
||||
|
||||
# ── 构建前端 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
if [ "$BACKEND_ONLY" -eq 0 ]; then
|
||||
log "构建前端 (Vite)..."
|
||||
if ! command -v npx >/dev/null 2>&1; then
|
||||
fail "未找到 npx,请先安装 Node.js"
|
||||
fi
|
||||
# 直接调用 vite build,跳过 desktop:icons(脚本文件不存在)
|
||||
npx vite build
|
||||
ok "前端构建完成 → dist/web/"
|
||||
fi
|
||||
|
||||
# ── 构建后端 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
if [ "$FRONTEND_ONLY" -eq 0 ]; then
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
fail "未找到 cargo,请先安装 Rust 工具链"
|
||||
fi
|
||||
|
||||
if [ "$RELEASE" -eq 1 ]; then
|
||||
log "构建后端 (cargo build --release)..."
|
||||
cargo build --release
|
||||
BINARY="$ROOT_DIR/target/release/boosapi"
|
||||
ok "后端构建完成 → target/release/boosapi"
|
||||
else
|
||||
log "构建后端 (cargo build)..."
|
||||
cargo build
|
||||
BINARY="$ROOT_DIR/target/debug/boosapi"
|
||||
ok "后端构建完成 → target/debug/boosapi"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 启动服务 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
if [ "$NO_RUN" -eq 1 ] || [ "$FRONTEND_ONLY" -eq 1 ]; then
|
||||
log "跳过启动(--no-run 或 --frontend-only)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 停止旧进程
|
||||
OLD_PID=$(pgrep -f "target/(debug|release)/boosapi" || true)
|
||||
if [ -n "$OLD_PID" ]; then
|
||||
log "停止旧进程 (PID $OLD_PID)..."
|
||||
kill "$OLD_PID" 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
log "启动服务..."
|
||||
exec "$BINARY"
|
||||
+5
-1
@@ -93,9 +93,13 @@ impl ComfyUIConfig {
|
||||
|
||||
impl AppConfig {
|
||||
pub fn load() -> anyhow::Result<Self> {
|
||||
Self::load_from_file("config.toml")
|
||||
}
|
||||
|
||||
pub fn load_from_file(path: &str) -> anyhow::Result<Self> {
|
||||
dotenvy::dotenv().ok();
|
||||
Ok(Figment::new()
|
||||
.merge(Toml::file("config.toml"))
|
||||
.merge(Toml::file(path))
|
||||
.merge(Env::prefixed("BOOSAPI_").split("__"))
|
||||
.extract()?)
|
||||
}
|
||||
|
||||
+82
-6
@@ -1,19 +1,76 @@
|
||||
use axum::serve;
|
||||
use boosapi::{config::AppConfig, db, AppState};
|
||||
use clap::Parser;
|
||||
use std::ffi::OsString;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::signal;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "boosapi",
|
||||
version,
|
||||
author,
|
||||
about = "AI API Gateway — Rust rewrite of BoosAPI"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Config file path
|
||||
#[arg(short, long, default_value = "config.toml")]
|
||||
config: String,
|
||||
|
||||
/// Host to bind to
|
||||
#[arg(long)]
|
||||
host: Option<String>,
|
||||
|
||||
/// Port to listen on
|
||||
#[arg(short, long)]
|
||||
port: Option<u16>,
|
||||
|
||||
/// Log level (trace, debug, info, warn, error)
|
||||
#[arg(long)]
|
||||
log_level: Option<String>,
|
||||
|
||||
/// Only run database migrations and exit
|
||||
#[arg(long)]
|
||||
migrate_only: bool,
|
||||
|
||||
/// Load and validate configuration, then exit
|
||||
#[arg(long)]
|
||||
check_config: bool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Initialize tracing
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.init();
|
||||
let args = Cli::parse_from(normalize_legacy_args());
|
||||
|
||||
// Load config
|
||||
let config = AppConfig::load()?;
|
||||
tracing::info!("Loaded configuration");
|
||||
let mut config = AppConfig::load_from_file(&args.config)?;
|
||||
|
||||
// Apply CLI overrides
|
||||
if let Some(host) = args.host {
|
||||
config.server.host = host;
|
||||
}
|
||||
if let Some(port) = args.port {
|
||||
config.server.port = port;
|
||||
}
|
||||
if let Some(level) = args.log_level {
|
||||
config.server.log_level = level;
|
||||
}
|
||||
|
||||
if args.check_config {
|
||||
println!("Configuration is valid.");
|
||||
println!("Server: {}:{}", config.server.host, config.server.port);
|
||||
println!("Database URL: {}", config.db.url);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Initialize tracing
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new(&config.server.log_level));
|
||||
|
||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||
|
||||
tracing::info!("Loaded configuration from {}", args.config);
|
||||
|
||||
// Create database pool
|
||||
let pool = db::create_pool(&config.db.url, config.db.max_connections).await?;
|
||||
@@ -23,6 +80,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
tracing::info!("Database migrations applied");
|
||||
|
||||
if args.migrate_only {
|
||||
tracing::info!("Migration only mode requested. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Build app state
|
||||
let state = AppState::new(config.clone(), pool);
|
||||
if let Err(err) = state.comfyui.start().await {
|
||||
@@ -51,6 +113,20 @@ async fn main() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_legacy_args() -> Vec<OsString> {
|
||||
std::env::args_os()
|
||||
.map(|arg| {
|
||||
if arg == "-help" {
|
||||
OsString::from("--help")
|
||||
} else if arg == "-version" {
|
||||
OsString::from("--version")
|
||||
} else {
|
||||
arg
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
signal::ctrl_c()
|
||||
|
||||
@@ -8,7 +8,7 @@ use axum::{
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
@@ -501,3 +501,132 @@ fn build_account_extra_config(
|
||||
.map_err(|err| AppError::Internal(err.into()))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Account model availability ────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelItem {
|
||||
name: String,
|
||||
#[serde(rename = "latencyMs")]
|
||||
latency_ms: Option<i64>,
|
||||
disabled: bool,
|
||||
#[serde(rename = "isManual")]
|
||||
is_manual: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddManualModelsRequest {
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
/// GET /api/accounts/{id}/models
|
||||
pub async fn get_account_models(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Getting models for account: {}", id);
|
||||
|
||||
// Account + site info
|
||||
let row = sqlx::query(
|
||||
"SELECT a.id, a.site_id, s.name AS site_name
|
||||
FROM accounts a
|
||||
JOIN sites s ON s.id = a.site_id
|
||||
WHERE a.id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Account {} not found", id)))?;
|
||||
|
||||
use sqlx::Row;
|
||||
let site_id: i64 = row.get("site_id");
|
||||
let site_name: String = row.get("site_name");
|
||||
|
||||
// Available models for this account
|
||||
let availability: Vec<(String, Option<i64>, bool)> = sqlx::query_as(
|
||||
"SELECT model_name, latency_ms, is_manual
|
||||
FROM model_availability
|
||||
WHERE account_id = ? AND available = 1
|
||||
ORDER BY model_name ASC",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
// Disabled models for this site
|
||||
let disabled_names: std::collections::HashSet<String> =
|
||||
sqlx::query_scalar("SELECT model_name FROM site_disabled_models WHERE site_id = ?")
|
||||
.bind(site_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let models: Vec<ModelItem> = availability
|
||||
.into_iter()
|
||||
.map(|(name, latency_ms, is_manual)| {
|
||||
let disabled = disabled_names.contains(&name);
|
||||
ModelItem {
|
||||
name,
|
||||
latency_ms,
|
||||
disabled,
|
||||
is_manual,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total_count = models.len();
|
||||
let disabled_count = models.iter().filter(|m| m.disabled).count();
|
||||
|
||||
Ok(Json(json!({
|
||||
"siteId": site_id,
|
||||
"siteName": site_name,
|
||||
"models": models,
|
||||
"totalCount": total_count,
|
||||
"disabledCount": disabled_count,
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/accounts/{id}/models/manual
|
||||
pub async fn add_account_manual_models(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<AddManualModelsRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!(
|
||||
"Adding {} manual models for account: {}",
|
||||
payload.models.len(),
|
||||
id
|
||||
);
|
||||
|
||||
// Verify account exists
|
||||
sqlx::query_scalar::<_, i64>("SELECT 1 FROM accounts WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Account {} not found", id)))?;
|
||||
|
||||
let mut inserted = 0usize;
|
||||
for model_name in payload
|
||||
.models
|
||||
.iter()
|
||||
.map(|m| m.trim())
|
||||
.filter(|m| !m.is_empty())
|
||||
{
|
||||
sqlx::query(
|
||||
"INSERT INTO model_availability (account_id, model_name, available, is_manual, checked_at)
|
||||
VALUES (?, ?, 1, 1, datetime('now'))
|
||||
ON CONFLICT (account_id, model_name) DO UPDATE SET
|
||||
available = 1,
|
||||
is_manual = 1,
|
||||
checked_at = datetime('now')",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(model_name)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
inserted += 1;
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "inserted": inserted })))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::{
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
@@ -13,6 +13,12 @@ use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListKeysQuery {
|
||||
pub q: Option<String>,
|
||||
pub user_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateDownstreamKeyRequest {
|
||||
pub name: String,
|
||||
@@ -25,6 +31,7 @@ pub struct CreateDownstreamKeyRequest {
|
||||
pub max_requests: Option<i64>,
|
||||
pub supported_models: Option<Vec<String>>,
|
||||
pub allowed_route_ids: Option<String>,
|
||||
pub user_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -39,6 +46,7 @@ pub struct UpdateDownstreamKeyRequest {
|
||||
pub max_requests: Option<i64>,
|
||||
pub supported_models: Option<Vec<String>>,
|
||||
pub allowed_route_ids: Option<String>,
|
||||
pub user_id: Option<i64>,
|
||||
}
|
||||
|
||||
/// Helper to validate models from a JSON string against a Vec<String>
|
||||
@@ -54,13 +62,32 @@ fn validate_supported_models(models: &[String]) -> AppResult<()> {
|
||||
}
|
||||
|
||||
/// GET /api/downstream-api-keys
|
||||
pub async fn list_downstream_keys(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Listing downstream API keys");
|
||||
pub async fn list_downstream_keys(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListKeysQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Listing downstream API keys with filter: {:?}", query);
|
||||
|
||||
let keys: Vec<DownstreamApiKey> =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys ORDER BY name")
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
let q = query.q.unwrap_or_default().trim().to_string();
|
||||
let q_like = format!("%{}%", q);
|
||||
let user_id = query.user_id;
|
||||
|
||||
let keys: Vec<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
|
||||
r#"
|
||||
SELECT * FROM downstream_api_keys
|
||||
WHERE (? = '' OR name LIKE ? OR key LIKE ? OR tags LIKE ?)
|
||||
AND (? IS NULL OR user_id = ?)
|
||||
ORDER BY id DESC
|
||||
"#,
|
||||
)
|
||||
.bind(&q)
|
||||
.bind(&q_like)
|
||||
.bind(&q_like)
|
||||
.bind(&q_like)
|
||||
.bind(user_id)
|
||||
.bind(user_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(
|
||||
json!({ "success": true, "items": keys, "data": keys }),
|
||||
@@ -79,7 +106,11 @@ pub async fn create_downstream_key(
|
||||
}
|
||||
|
||||
// Auto-generate key
|
||||
let key = format!("sk-{}", Uuid::new_v4().to_string().replace('-', ""));
|
||||
let key = if payload.user_id.is_some() {
|
||||
format!("sk-user-{}", Uuid::new_v4().to_string().replace('-', ""))
|
||||
} else {
|
||||
format!("sk-{}", Uuid::new_v4().to_string().replace('-', ""))
|
||||
};
|
||||
|
||||
// Serialize supported_models to JSON string if provided
|
||||
let supported_models: Option<String> = if let Some(ref models) = payload.supported_models {
|
||||
@@ -95,7 +126,7 @@ pub async fn create_downstream_key(
|
||||
let enabled = payload.enabled.unwrap_or(true);
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO downstream_api_keys (name, key, description, group_name, tags, enabled, expires_at, max_cost, max_requests, supported_models, allowed_route_ids) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
"INSERT INTO downstream_api_keys (name, key, description, group_name, tags, enabled, expires_at, max_cost, max_requests, supported_models, allowed_route_ids, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&payload.name)
|
||||
.bind(&key)
|
||||
@@ -108,6 +139,7 @@ pub async fn create_downstream_key(
|
||||
.bind(payload.max_requests)
|
||||
.bind(&supported_models)
|
||||
.bind(&payload.allowed_route_ids)
|
||||
.bind(payload.user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
@@ -149,7 +181,7 @@ pub async fn update_downstream_key(
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE downstream_api_keys SET name = ?, description = ?, group_name = ?, tags = ?, enabled = ?, expires_at = ?, max_cost = ?, max_requests = ?, supported_models = ?, allowed_route_ids = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
"UPDATE downstream_api_keys SET name = ?, description = ?, group_name = ?, tags = ?, enabled = ?, expires_at = ?, max_cost = ?, max_requests = ?, supported_models = ?, allowed_route_ids = ?, user_id = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.bind(payload.name.unwrap_or(existing.name))
|
||||
.bind(payload.description.or(existing.description))
|
||||
@@ -161,6 +193,7 @@ pub async fn update_downstream_key(
|
||||
.bind(payload.max_requests.or(existing.max_requests))
|
||||
.bind(&supported_models)
|
||||
.bind(payload.allowed_route_ids.or(existing.allowed_route_ids))
|
||||
.bind(payload.user_id.or(existing.user_id))
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
@@ -196,3 +229,40 @@ pub async fn delete_downstream_key(
|
||||
|
||||
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
|
||||
}
|
||||
|
||||
/// POST /api/downstream-api-keys/{id}/reset
|
||||
pub async fn reset_downstream_key(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Resetting downstream API key: {}", id);
|
||||
|
||||
let existing: DownstreamApiKey =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Downstream key {} not found", id)))?;
|
||||
|
||||
let new_key = if existing.user_id.is_some() {
|
||||
format!("sk-user-{}", Uuid::new_v4().to_string().replace('-', ""))
|
||||
} else {
|
||||
format!("sk-{}", Uuid::new_v4().to_string().replace('-', ""))
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE downstream_api_keys SET key = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
)
|
||||
.bind(&new_key)
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let api_key: DownstreamApiKey =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": api_key })))
|
||||
}
|
||||
|
||||
+30
-2
@@ -8,7 +8,7 @@ use axum::{
|
||||
http::HeaderMap,
|
||||
middleware,
|
||||
response::Response,
|
||||
routing::{get, post, put},
|
||||
routing::{get, patch, post, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
@@ -79,6 +79,14 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
get(accounts::get_account)
|
||||
.put(accounts::update_account)
|
||||
.delete(accounts::delete_account),
|
||||
)
|
||||
.route(
|
||||
"/api/accounts/{id}/models",
|
||||
get(accounts::get_account_models),
|
||||
)
|
||||
.route(
|
||||
"/api/accounts/{id}/models/manual",
|
||||
post(accounts::add_account_manual_models),
|
||||
);
|
||||
|
||||
let tokens_routes = Router::new()
|
||||
@@ -112,10 +120,18 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
put(downstream_keys::update_downstream_key)
|
||||
.delete(downstream_keys::delete_downstream_key),
|
||||
)
|
||||
.route(
|
||||
"/api/downstream-api-keys/{id}/reset",
|
||||
post(downstream_keys::reset_downstream_key),
|
||||
)
|
||||
.route(
|
||||
"/api/downstream-keys/{id}",
|
||||
put(downstream_keys::update_downstream_key)
|
||||
.delete(downstream_keys::delete_downstream_key),
|
||||
)
|
||||
.route(
|
||||
"/api/downstream-keys/{id}/reset",
|
||||
post(downstream_keys::reset_downstream_key),
|
||||
);
|
||||
|
||||
let settings_routes =
|
||||
@@ -133,7 +149,19 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
.patch(users::update_user)
|
||||
.delete(users::delete_user),
|
||||
)
|
||||
.route("/api/admin/users/{id}/usage", get(users::get_user_usage));
|
||||
.route("/api/admin/users/{id}/usage", get(users::get_user_usage))
|
||||
.route(
|
||||
"/api/admin/users/{id}/keys",
|
||||
get(users::list_user_keys).post(users::create_user_key),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/users/{id}/keys/{key_id}",
|
||||
patch(users::update_user_key).delete(users::delete_user_key),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/users/{id}/keys/{key_id}/reset",
|
||||
post(users::reset_user_key),
|
||||
);
|
||||
|
||||
Router::new()
|
||||
.merge(sites_routes)
|
||||
|
||||
+199
-1
@@ -2,17 +2,24 @@
|
||||
|
||||
use crate::{
|
||||
auth::password::hash_password,
|
||||
db::models::user::User,
|
||||
db::models::{downstream_api_key::DownstreamApiKey, user::User},
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateUserApiKeyRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateUserRequest {
|
||||
@@ -501,3 +508,194 @@ async fn attach_usage_summary(state: &AppState, item: &mut UserResponse) -> AppR
|
||||
item.last_7_days_cost = Some(last_7_days_cost);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── User API Key Management for Admin ──────────────────────────────────
|
||||
|
||||
/// GET /api/admin/users/{id}/keys
|
||||
pub async fn list_user_keys(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Admin listing API keys for user: {}", id);
|
||||
|
||||
// Verify user exists
|
||||
let _exists = sqlx::query_scalar::<_, i64>("SELECT 1 FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
|
||||
|
||||
let keys: Vec<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
|
||||
"SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": keys, "keys": keys })))
|
||||
}
|
||||
|
||||
/// POST /api/admin/users/{id}/keys
|
||||
pub async fn create_user_key(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<CreateUserApiKeyRequest>,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
info!("Admin creating API key for user: {}", id);
|
||||
|
||||
let key = format!("sk-user-{}", Uuid::new_v4().to_string().replace('-', ""));
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO downstream_api_keys (name, key, user_id, enabled) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&payload.name)
|
||||
.bind(&key)
|
||||
.bind(id)
|
||||
.bind(true)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let key_id = result.last_insert_rowid();
|
||||
let api_key: DownstreamApiKey =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(key_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({ "data": api_key }))))
|
||||
}
|
||||
|
||||
/// DELETE /api/admin/users/{id}/keys/{key_id}
|
||||
pub async fn delete_user_key(
|
||||
State(state): State<AppState>,
|
||||
Path((user_id, key_id)): Path<(i64, i64)>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Admin deleting API key {} for user {}", key_id, user_id);
|
||||
|
||||
let affected = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ? AND user_id = ?")
|
||||
.bind(key_id)
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"Key {} for user {} not found",
|
||||
key_id, user_id
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "data": { "deleted": true, "id": key_id } })))
|
||||
}
|
||||
|
||||
/// POST /api/admin/users/{id}/keys/{key_id}/reset
|
||||
pub async fn reset_user_key(
|
||||
State(state): State<AppState>,
|
||||
Path((user_id, key_id)): Path<(i64, i64)>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Admin resetting API key {} for user {}", key_id, user_id);
|
||||
|
||||
let new_key = format!("sk-user-{}", Uuid::new_v4().to_string().replace('-', ""));
|
||||
|
||||
let affected = sqlx::query("UPDATE downstream_api_keys SET key = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
|
||||
.bind(&new_key)
|
||||
.bind(key_id)
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"Key {} for user {} not found",
|
||||
key_id, user_id
|
||||
)));
|
||||
}
|
||||
|
||||
let api_key: DownstreamApiKey =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(key_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": api_key })))
|
||||
}
|
||||
|
||||
/// PATCH /api/admin/users/{id}/keys/{key_id}
|
||||
pub async fn update_user_key(
|
||||
State(state): State<AppState>,
|
||||
Path((user_id, key_id)): Path<(i64, i64)>,
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Admin updating API key {} for user {}", key_id, user_id);
|
||||
|
||||
let _existing: DownstreamApiKey = sqlx::query_as::<_, DownstreamApiKey>(
|
||||
"SELECT * FROM downstream_api_keys WHERE id = ? AND user_id = ?",
|
||||
)
|
||||
.bind(key_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Key {} for user {} not found", key_id, user_id)))?;
|
||||
|
||||
// Build dynamic UPDATE
|
||||
let mut sets: Vec<String> = vec![];
|
||||
let mut params: Vec<serde_json::Value> = vec![];
|
||||
|
||||
if let Some(v) = payload.get("name").and_then(|v| v.as_str()) {
|
||||
sets.push("name = ?".to_string());
|
||||
params.push(json!(v));
|
||||
}
|
||||
if let Some(v) = payload.get("description") {
|
||||
sets.push("description = ?".to_string());
|
||||
params.push(v.clone());
|
||||
}
|
||||
if let Some(v) = payload.get("enabled").and_then(|v| v.as_bool()) {
|
||||
sets.push("enabled = ?".to_string());
|
||||
params.push(json!(v));
|
||||
}
|
||||
if let Some(v) = payload.get("expiresAt") {
|
||||
sets.push("expires_at = ?".to_string());
|
||||
params.push(v.clone());
|
||||
}
|
||||
if let Some(v) = payload.get("maxCost").and_then(|v| v.as_f64()) {
|
||||
sets.push("max_cost = ?".to_string());
|
||||
params.push(json!(v));
|
||||
}
|
||||
if let Some(v) = payload.get("maxRequests").and_then(|v| v.as_i64()) {
|
||||
sets.push("max_requests = ?".to_string());
|
||||
params.push(json!(v));
|
||||
}
|
||||
|
||||
if !sets.is_empty() {
|
||||
sets.push("updated_at = datetime('now')".to_string());
|
||||
let sql = format!(
|
||||
"UPDATE downstream_api_keys SET {} WHERE id = ? AND user_id = ?",
|
||||
sets.join(", ")
|
||||
);
|
||||
let mut q = sqlx::query(&sql);
|
||||
for p in ¶ms {
|
||||
if let Some(s) = p.as_str() {
|
||||
q = q.bind(s.to_owned());
|
||||
} else if let Some(b) = p.as_bool() {
|
||||
q = q.bind(b);
|
||||
} else if let Some(f) = p.as_f64() {
|
||||
q = q.bind(f);
|
||||
} else if let Some(i) = p.as_i64() {
|
||||
q = q.bind(i);
|
||||
} else if p.is_null() {
|
||||
q = q.bind(None::<String>);
|
||||
}
|
||||
}
|
||||
q.bind(key_id).bind(user_id).execute(&*state.db).await?;
|
||||
}
|
||||
|
||||
let updated: DownstreamApiKey =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(key_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": updated })))
|
||||
}
|
||||
|
||||
+81
-22
@@ -161,33 +161,66 @@ async fn delete_api_key(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserLogsQuery {
|
||||
q: Option<String>,
|
||||
status: Option<String>,
|
||||
date_from: Option<String>,
|
||||
date_to: Option<String>,
|
||||
page: Option<i64>,
|
||||
page_size: Option<i64>,
|
||||
// legacy compat
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
}
|
||||
|
||||
async fn get_user_logs(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
Query(params): Query<UserLogsQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let user_id = current_user_id(&headers, &state)?;
|
||||
|
||||
let limit = params
|
||||
.get("limit")
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
let page_size = params
|
||||
.page_size
|
||||
.or(params.limit)
|
||||
.unwrap_or(20)
|
||||
.clamp(1, 100);
|
||||
let offset = params
|
||||
.get("offset")
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
.clamp(1, 200);
|
||||
let page = params.page.unwrap_or(1).max(1);
|
||||
let offset = params.offset.unwrap_or((page - 1) * page_size).max(0);
|
||||
|
||||
let q = params.q.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
let q_like = q.map(|s| format!("%{s}%"));
|
||||
let status = params
|
||||
.status
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty() && *s != "all");
|
||||
let date_from = params.date_from.as_deref().filter(|s| !s.is_empty());
|
||||
let date_to = params.date_to.as_deref().filter(|s| !s.is_empty());
|
||||
|
||||
let items: Vec<Value> = sqlx::query_as::<_, crate::routes::stats::ProxyLogRow>(
|
||||
"SELECT l.* FROM proxy_logs l
|
||||
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
|
||||
WHERE k.user_id = ?
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT ? OFFSET ?",
|
||||
r#"
|
||||
SELECT l.* FROM proxy_logs l
|
||||
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
|
||||
WHERE k.user_id = ?
|
||||
AND (? IS NULL OR l.model_requested LIKE ?)
|
||||
AND (? IS NULL OR l.status = ?)
|
||||
AND (? IS NULL OR l.created_at >= ?)
|
||||
AND (? IS NULL OR l.created_at <= ?)
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit)
|
||||
.bind(q_like.as_deref())
|
||||
.bind(q_like.as_deref())
|
||||
.bind(status)
|
||||
.bind(status)
|
||||
.bind(date_from)
|
||||
.bind(date_from)
|
||||
.bind(date_to)
|
||||
.bind(date_to)
|
||||
.bind(page_size)
|
||||
.bind(offset)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
@@ -195,20 +228,46 @@ async fn get_user_logs(
|
||||
.map(|row| serde_json::to_value(row).unwrap_or(json!({})))
|
||||
.collect();
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM proxy_logs l
|
||||
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
|
||||
WHERE k.user_id = ?",
|
||||
let (total, success_count, total_cost, total_tokens): (i64, i64, f64, i64) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
COUNT(*),
|
||||
SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END),
|
||||
COALESCE(SUM(l.estimated_cost), 0.0),
|
||||
COALESCE(SUM(l.total_tokens), 0)
|
||||
FROM proxy_logs l
|
||||
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
|
||||
WHERE k.user_id = ?
|
||||
AND (? IS NULL OR l.model_requested LIKE ?)
|
||||
AND (? IS NULL OR l.status = ?)
|
||||
AND (? IS NULL OR l.created_at >= ?)
|
||||
AND (? IS NULL OR l.created_at <= ?)
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(q_like.as_deref())
|
||||
.bind(q_like.as_deref())
|
||||
.bind(status)
|
||||
.bind(status)
|
||||
.bind(date_from)
|
||||
.bind(date_from)
|
||||
.bind(date_to)
|
||||
.bind(date_to)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"items": items,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"summary": {
|
||||
"total": total,
|
||||
"success": success_count,
|
||||
"failed": total - success_count,
|
||||
"total_cost": total_cost,
|
||||
"total_tokens": total_tokens,
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
+13
-4
@@ -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, hasValidUserSession, persistAuthSession, persistUserSession } from './authSession.js';
|
||||
import { clearAuthSession, clearUserSession, hasValidAuthSession, hasValidUserSession, persistAuthSession, persistUserSession } from './authSession.js';
|
||||
import {
|
||||
FIRST_USE_DOC_REMINDER_KEY,
|
||||
LEGACY_THEME_STORAGE_KEY,
|
||||
@@ -207,7 +207,10 @@ export function Login({
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
const message = data?.message || data?.error || t('登录失败,请检查邮箱和密码');
|
||||
const rawErr = data?.error;
|
||||
const message = data?.message
|
||||
|| (typeof rawErr === 'string' ? rawErr : rawErr?.message)
|
||||
|| t('登录失败,请检查邮箱和密码');
|
||||
setError(t(String(message)));
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -798,11 +801,15 @@ 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 (isUserPage && isPublicUserPage) {
|
||||
if (userAuthed) return <Navigate to="/user/dashboard" />;
|
||||
if (authed) return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
if (!authed && !isUserPage) {
|
||||
if (userAuthed) {
|
||||
return <Navigate to="/user/dashboard" />;
|
||||
}
|
||||
return (
|
||||
<Login
|
||||
t={t}
|
||||
@@ -978,7 +985,9 @@ function AppShell() {
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
clearAuthSession(localStorage);
|
||||
clearUserSession(localStorage);
|
||||
setAuthed(false);
|
||||
setUserAuthed(false);
|
||||
}} className="user-dropdown-item danger">
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
|
||||
{t('退出登录')}
|
||||
|
||||
+11
-3
@@ -1822,8 +1822,16 @@ export const api = {
|
||||
request(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
deleteUser: (id: number) =>
|
||||
request(`/api/admin/users/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
getUserKeys: (id: number) => request(`/api/admin/users/${id}/keys`),
|
||||
createUserKey: (id: number, data: { name: string }) =>
|
||||
request(`/api/admin/users/${id}/keys`, { method: "POST", body: JSON.stringify(data) }),
|
||||
deleteUserKey: (userId: number, keyId: number) =>
|
||||
request(`/api/admin/users/${userId}/keys/${keyId}`, { method: "DELETE" }),
|
||||
resetUserKey: (userId: number, keyId: number) =>
|
||||
request(`/api/admin/users/${userId}/keys/${keyId}/reset`, { method: "POST" }),
|
||||
updateUserKey: (userId: number, keyId: number, data: any) =>
|
||||
request(`/api/admin/users/${userId}/keys/${keyId}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
};
|
||||
export const userApi = {
|
||||
register: (data: { username: string; email: string; password: string }) =>
|
||||
userRequest("/api/users/register", { method: "POST", body: JSON.stringify(data) }),
|
||||
@@ -1841,7 +1849,7 @@ export const userApi = {
|
||||
userRequest(`/api/user-api-keys/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
deleteApiKey: (id: number) =>
|
||||
userRequest(`/api/user-api-keys/${id}`, { method: "DELETE" }),
|
||||
getLogs: (params?: { limit?: number; offset?: number }) =>
|
||||
getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; status?: string; date_from?: string; date_to?: string }) =>
|
||||
userRequest(`/api/user/logs${buildQueryString(params)}`),
|
||||
getSummary: () => userRequest("/api/user/summary"),
|
||||
};
|
||||
|
||||
+247
-75
@@ -1,142 +1,314 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { userApi } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
|
||||
interface LogItem {
|
||||
id: number;
|
||||
model_requested: string;
|
||||
model_actual: string;
|
||||
status: string;
|
||||
http_status: number;
|
||||
latency_ms: number;
|
||||
model_requested: string | null;
|
||||
model_actual: string | null;
|
||||
status: string | null;
|
||||
http_status: number | null;
|
||||
latency_ms: number | null;
|
||||
prompt_tokens: number | null;
|
||||
completion_tokens: number | null;
|
||||
total_tokens: number | null;
|
||||
estimated_cost: number | null;
|
||||
error_message: string | null;
|
||||
is_stream: boolean | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
total_cost: number;
|
||||
total_tokens: number;
|
||||
estimated_cost: number;
|
||||
error_message?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return '-';
|
||||
const d = new Date(value.includes('T') ? value : value.replace(' ', 'T'));
|
||||
if (Number.isNaN(d.getTime())) return value.slice(0, 16);
|
||||
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
function latencyColor(ms: number | null): string {
|
||||
if (!ms) return 'var(--color-text-muted)';
|
||||
if (ms < 1000) return 'var(--color-success)';
|
||||
if (ms < 3000) return 'var(--color-warning)';
|
||||
return 'var(--color-danger)';
|
||||
}
|
||||
|
||||
export default function UsageRecords() {
|
||||
const [logs, setLogs] = useState<LogItem[]>([]);
|
||||
const [summary, setSummary] = useState<Summary | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const toast = useToast();
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const loadLogs = useCallback(async () => {
|
||||
const load = useCallback(async (p: number, q: string, status: string, from: string, to: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await userApi.getLogs({
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
page: p,
|
||||
page_size: PAGE_SIZE,
|
||||
q: q || undefined,
|
||||
status: status || undefined,
|
||||
date_from: from || undefined,
|
||||
date_to: to || undefined,
|
||||
});
|
||||
setLogs(res.items || []);
|
||||
setTotal(res.total || 0);
|
||||
setSummary(res.summary || null);
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '加载记录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, toast]);
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
}, [loadLogs]);
|
||||
load(page, search, statusFilter, dateFrom, dateTo);
|
||||
}, [load, page, statusFilter, dateFrom, dateTo]);
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
searchTimer.current = setTimeout(() => {
|
||||
setPage(1);
|
||||
load(1, value, statusFilter, dateFrom, dateTo);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleFilterChange = (status: string, from: string, to: string) => {
|
||||
setStatusFilter(status);
|
||||
setDateFrom(from);
|
||||
setDateTo(to);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const successRate = summary && summary.total > 0
|
||||
? ((summary.success / summary.total) * 100).toFixed(1)
|
||||
: '0.0';
|
||||
|
||||
return (
|
||||
<div className="page-container" style={{ padding: '24px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<h1 style={{ fontSize: '20px', fontWeight: 600, margin: 0 }}>使用记录</h1>
|
||||
<button className="btn btn-ghost" onClick={loadLogs} disabled={loading}>
|
||||
<div className="page-container animate-fade-in">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h2 className="page-title">使用记录</h2>
|
||||
<div className="page-subtitle">当前账号下所有 API 调用日志</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => load(page, search, statusFilter, dateFrom, dateTo)}
|
||||
disabled={loading}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 20 }}>
|
||||
{[
|
||||
{ label: '总请求', value: summary?.total.toLocaleString() ?? '-', color: 'var(--color-text-primary)' },
|
||||
{ label: '成功率', value: `${successRate}%`, color: 'var(--color-success)' },
|
||||
{ label: '失败', value: summary?.failed.toLocaleString() ?? '-', color: summary?.failed ? 'var(--color-danger)' : 'var(--color-text-muted)' },
|
||||
{ label: 'Tokens', value: summary ? (summary.total_tokens >= 1000 ? `${(summary.total_tokens / 1000).toFixed(1)}K` : summary.total_tokens.toString()) : '-', color: 'var(--color-text-primary)' },
|
||||
{ label: '预估费用', value: summary ? `$${summary.total_cost.toFixed(4)}` : '-', color: 'var(--color-primary)' },
|
||||
].map((card) => (
|
||||
<div key={card.label} className="card" style={{ padding: '14px 16px' }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 6 }}>{card.label}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: card.color }}>{card.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card" style={{ padding: '12px 16px', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center' }}>
|
||||
<div style={{ position: 'relative', flex: '1 1 200px', minWidth: 160 }}>
|
||||
<svg style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="m21 21-4.35-4.35M10.5 18a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15Z" />
|
||||
</svg>
|
||||
<input
|
||||
style={{ width: '100%', paddingLeft: 30, paddingRight: 10, height: 34, border: '1px solid var(--color-border)', borderRadius: 6, background: 'var(--color-bg)', color: 'var(--color-text-primary)', fontSize: 13, boxSizing: 'border-box' }}
|
||||
placeholder="搜索模型名称..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
style={{ height: 34, border: '1px solid var(--color-border)', borderRadius: 6, background: 'var(--color-bg)', color: 'var(--color-text-primary)', fontSize: 13, padding: '0 10px' }}
|
||||
value={statusFilter}
|
||||
onChange={(e) => handleFilterChange(e.target.value, dateFrom, dateTo)}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="success">成功</option>
|
||||
<option value="failed">失败</option>
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
style={{ height: 34, border: '1px solid var(--color-border)', borderRadius: 6, background: 'var(--color-bg)', color: 'var(--color-text-primary)', fontSize: 13, padding: '0 10px' }}
|
||||
value={dateFrom}
|
||||
onChange={(e) => handleFilterChange(statusFilter, e.target.value, dateTo)}
|
||||
/>
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>至</span>
|
||||
<input
|
||||
type="date"
|
||||
style={{ height: 34, border: '1px solid var(--color-border)', borderRadius: 6, background: 'var(--color-bg)', color: 'var(--color-text-primary)', fontSize: 13, padding: '0 10px' }}
|
||||
value={dateTo}
|
||||
onChange={(e) => handleFilterChange(statusFilter, dateFrom, e.target.value)}
|
||||
/>
|
||||
{(search || statusFilter || dateFrom || dateTo) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => { setSearch(''); handleFilterChange('', '', ''); }}
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card" style={{ overflow: 'hidden' }}>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="table">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>请求模型</th>
|
||||
<th>模型</th>
|
||||
<th>状态</th>
|
||||
<th>耗时</th>
|
||||
<th>Tokens</th>
|
||||
<th>预估成本</th>
|
||||
<th>费用</th>
|
||||
<th style={{ width: 40 }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<td colSpan={7} style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div className="spinner" style={{ margin: '0 auto' }} />
|
||||
</td>
|
||||
</tr>
|
||||
) : logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-muted)' }}>
|
||||
暂无使用记录
|
||||
<td colSpan={7} style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-muted)' }}>
|
||||
暂无记录
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td style={{ fontSize: '12px' }}>{new Date(log.created_at).toLocaleString()}</td>
|
||||
<td>
|
||||
<div style={{ fontWeight: 500 }}>{log.model_requested}</div>
|
||||
{log.model_actual && log.model_actual !== log.model_requested && (
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-muted)' }}>→ {log.model_actual}</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge badge-${log.status === 'success' ? 'success' : 'danger'}`}>
|
||||
{log.status === 'success' ? '成功' : '失败'}
|
||||
{log.http_status && ` (${log.http_status})`}
|
||||
</span>
|
||||
{log.error_message && (
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-danger)', marginTop: '2px', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={log.error_message}>
|
||||
{log.error_message}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>{log.latency_ms}ms</td>
|
||||
<td>{log.total_tokens || 0}</td>
|
||||
<td>${log.estimated_cost ? log.estimated_cost.toFixed(6) : '0.000000'}</td>
|
||||
</tr>
|
||||
<React.Fragment key={log.id}>
|
||||
<tr
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setExpandedId(expandedId === log.id ? null : log.id)}
|
||||
>
|
||||
<td style={{ fontSize: 12, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
|
||||
{formatDate(log.created_at)}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ fontWeight: 500, fontSize: 13 }}>{log.model_requested || '-'}</div>
|
||||
{log.model_actual && log.model_actual !== log.model_requested && (
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>→ {log.model_actual}</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge badge-${log.status === 'success' ? 'success' : 'error'}`} style={{ fontSize: 11 }}>
|
||||
{log.status === 'success' ? '成功' : '失败'}
|
||||
{log.http_status ? ` ${log.http_status}` : ''}
|
||||
</span>
|
||||
{log.is_stream && (
|
||||
<span style={{ marginLeft: 4, fontSize: 10, color: 'var(--color-text-muted)' }}>流式</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ fontSize: 13, color: latencyColor(log.latency_ms), fontFamily: 'var(--font-mono)' }}>
|
||||
{log.latency_ms != null ? `${log.latency_ms}ms` : '-'}
|
||||
</td>
|
||||
<td style={{ fontSize: 12, fontFamily: 'var(--font-mono)' }}>
|
||||
{log.total_tokens != null ? log.total_tokens.toLocaleString() : '-'}
|
||||
</td>
|
||||
<td style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--color-primary)' }}>
|
||||
{log.estimated_cost != null ? `$${log.estimated_cost.toFixed(5)}` : '-'}
|
||||
</td>
|
||||
<td>
|
||||
<svg
|
||||
style={{ display: 'block', color: 'var(--color-text-muted)', transition: 'transform 0.15s', transform: expandedId === log.id ? 'rotate(180deg)' : 'none' }}
|
||||
width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</td>
|
||||
</tr>
|
||||
{expandedId === log.id && (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ padding: 0, background: 'var(--color-bg-alt)' }}>
|
||||
<div style={{ padding: '14px 20px', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, borderBottom: '1px solid var(--color-border)' }}>
|
||||
{[
|
||||
{ label: 'Prompt Tokens', value: log.prompt_tokens?.toLocaleString() ?? '-' },
|
||||
{ label: 'Completion Tokens', value: log.completion_tokens?.toLocaleString() ?? '-' },
|
||||
{ label: 'Total Tokens', value: log.total_tokens?.toLocaleString() ?? '-' },
|
||||
{ label: 'HTTP Status', value: log.http_status?.toString() ?? '-' },
|
||||
].map((item) => (
|
||||
<div key={item.label}>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 2 }}>{item.label}</div>
|
||||
<div style={{ fontSize: 13, fontFamily: 'var(--font-mono)' }}>{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
{log.error_message && (
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 2 }}>错误信息</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-danger)', wordBreak: 'break-all' }}>{log.error_message}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: '13px', color: 'var(--color-text-muted)' }}>
|
||||
共 {total} 条记录
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={page === 1 || loading}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span style={{ fontSize: '13px', display: 'flex', alignItems: 'center' }}>
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={page === totalPages || loading}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||
共 {total} 条记录,第 {page} / {totalPages} 页
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={page === 1 || loading}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={page >= totalPages || loading}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -210,7 +210,7 @@ export default function UserDashboard() {
|
||||
</div>
|
||||
<div style={{ marginTop: '20px', display: 'flex', gap: '10px' }}>
|
||||
<button className="btn btn-ghost btn-sm" style={{ flex: 1, border: '1px solid var(--color-border)' }} onClick={() => navigate('/user/dashboard/profile')}>修改资料</button>
|
||||
<button className="btn btn-ghost btn-sm danger" style={{ flex: 1, border: '1px solid var(--color-border)' }} onClick={() => { clearUserSession(localStorage); navigate('/user/login'); }}>退出登录</button>
|
||||
<button className="btn btn-ghost btn-sm danger" style={{ flex: 1, border: '1px solid var(--color-border)' }} onClick={() => { clearUserSession(localStorage); window.location.href = '/user/login'; }}>退出登录</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,8 @@ export default function UserLogin({ onLogin }: { onLogin?: () => void }) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.error || `登录失败 (${res.status})`);
|
||||
const errMsg = typeof data?.error === 'string' ? data.error : (data?.error?.message || data?.message || `登录失败 (${res.status})`);
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
const result = await res.json();
|
||||
persistUserSession(localStorage, result.token);
|
||||
|
||||
@@ -147,6 +147,9 @@ export default function UserManagement() {
|
||||
const [expandedUserId, setExpandedUserId] = useState<number | null>(null);
|
||||
const [usageData, setUsageData] = useState<any>(null);
|
||||
const [usageLoading, setUsageLoading] = useState(false);
|
||||
const [keysData, setKeysData] = useState<any[]>([]);
|
||||
const [keysLoading, setKeysLoading] = useState(false);
|
||||
const [expandedTab, setExpandedTab] = useState<'usage' | 'keys'>('usage');
|
||||
const [activeTab, setActiveTab] = useState<'list' | 'usage'>('list');
|
||||
|
||||
const loadUsage = async (userId: number) => {
|
||||
@@ -161,14 +164,92 @@ export default function UserManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadKeys = async (userId: number) => {
|
||||
setKeysLoading(true);
|
||||
try {
|
||||
const result = await api.getUserKeys(userId);
|
||||
setKeysData(result.data || result.keys || []);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load keys:', err);
|
||||
} finally {
|
||||
setKeysLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddKey = async (userId: number) => {
|
||||
const name = window.prompt('请输入密钥名称', '默认密钥');
|
||||
if (!name) return;
|
||||
try {
|
||||
await api.createUserKey(userId, { name });
|
||||
void loadKeys(userId);
|
||||
} catch (err: any) {
|
||||
alert(err.message || '添加失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetKey = async (userId: number, keyId: number) => {
|
||||
if (!window.confirm('重置将生成新密钥并使旧密钥失效,确定吗?')) return;
|
||||
try {
|
||||
await api.resetUserKey(userId, keyId);
|
||||
void loadKeys(userId);
|
||||
} catch (err: any) {
|
||||
alert(err.message || '重置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteKey = async (userId: number, keyId: number) => {
|
||||
if (!window.confirm('确定删除该密钥吗?')) return;
|
||||
try {
|
||||
await api.deleteUserKey(userId, keyId);
|
||||
void loadKeys(userId);
|
||||
} catch (err: any) {
|
||||
alert(err.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditKeyName = async (userId: number, keyId: number, currentName: string) => {
|
||||
const name = window.prompt('修改密钥名称', currentName);
|
||||
if (!name || name === currentName) return;
|
||||
try {
|
||||
await api.updateUserKey(userId, keyId, { name });
|
||||
void loadKeys(userId);
|
||||
} catch (err: any) {
|
||||
alert(err.message || '修改失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleKey = async (userId: number, keyId: number, enabled: boolean) => {
|
||||
try {
|
||||
await api.updateUserKey(userId, keyId, { enabled });
|
||||
void loadKeys(userId);
|
||||
} catch (err: any) {
|
||||
alert(err.message || '更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExpand = (userId: number) => {
|
||||
if (expandedUserId === userId) {
|
||||
setExpandedUserId(null);
|
||||
setUsageData(null);
|
||||
setKeysData([]);
|
||||
} else {
|
||||
setExpandedUserId(userId);
|
||||
setUsageData(null);
|
||||
setKeysData([]);
|
||||
if (expandedTab === 'usage') {
|
||||
void loadUsage(userId);
|
||||
} else {
|
||||
void loadKeys(userId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpandedTabChange = (tab: 'usage' | 'keys', userId: number) => {
|
||||
setExpandedTab(tab);
|
||||
if (tab === 'usage' && !usageData) {
|
||||
void loadUsage(userId);
|
||||
} else if (tab === 'keys' && keysData.length === 0) {
|
||||
void loadKeys(userId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -519,80 +600,156 @@ export default function UserManagement() {
|
||||
<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={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 20 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-link ${expandedTab === 'usage' ? 'btn-link-primary' : ''}`}
|
||||
style={{ fontWeight: expandedTab === 'usage' ? 600 : 400, padding: 0, fontSize: 14 }}
|
||||
onClick={(e) => { e.stopPropagation(); handleExpandedTabChange('usage', user.id); }}
|
||||
>
|
||||
用量详情
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-link ${expandedTab === 'keys' ? 'btn-link-primary' : ''}`}
|
||||
style={{ fontWeight: expandedTab === 'keys' ? 600 : 400, padding: 0, fontSize: 14 }}
|
||||
onClick={(e) => { e.stopPropagation(); handleExpandedTabChange('keys', user.id); }}
|
||||
>
|
||||
API 密钥管理
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>用户: {user.username} (ID: {user.id})</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
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{expandedTab === 'usage' ? (
|
||||
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>
|
||||
<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>
|
||||
<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 }}>
|
||||
<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 className="keys-management-content">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>API 密钥列表</div>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={(e) => { e.stopPropagation(); handleAddKey(user.id); }}>新建密钥</button>
|
||||
</div>
|
||||
{keysLoading ? (
|
||||
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--color-text-muted)' }}>加载中...</div>
|
||||
) : (
|
||||
<div className="table-responsive" style={{ border: '1px solid var(--color-border)', borderRadius: 8 }}>
|
||||
<table className="data-table" style={{ fontSize: 12 }}>
|
||||
<thead style={{ position: 'sticky', top: 0, zIndex: 1 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>模型</th>
|
||||
<th>消耗 ($)</th>
|
||||
<th>Token (P/C/T)</th>
|
||||
<th>名称</th>
|
||||
<th>密钥内容</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</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>
|
||||
{keysData.map((k: any) => (
|
||||
<tr key={k.id}>
|
||||
<td style={{ cursor: 'pointer' }} onClick={(e) => { e.stopPropagation(); handleEditKeyName(user.id, k.id, k.name); }}>
|
||||
{k.name} <span style={{ fontSize: 10, color: 'var(--color-primary)', marginLeft: 4 }}>✎</span>
|
||||
</td>
|
||||
<td><code style={{ fontSize: 11, background: 'var(--color-bg)', padding: '2px 4px' }}>{k.key}</code></td>
|
||||
<td>
|
||||
<span
|
||||
className={`badge ${k.enabled ? 'badge-success' : 'badge-muted'}`}
|
||||
style={{ fontSize: 10, cursor: 'pointer' }}
|
||||
onClick={(e) => { e.stopPropagation(); handleToggleKey(user.id, k.id, !k.enabled); }}
|
||||
>
|
||||
{k.enabled ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatDate(k.createdAt || k.created_at)}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button type="button" className="btn btn-link btn-link-primary" style={{ fontSize: 12 }} onClick={(e) => { e.stopPropagation(); handleResetKey(user.id, k.id); }}>重置</button>
|
||||
<button type="button" className="btn btn-link btn-link-danger" style={{ fontSize: 12 }} onClick={(e) => { e.stopPropagation(); handleDeleteKey(user.id, k.id); }}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{usageData.recent_logs.length === 0 && (
|
||||
<tr><td colSpan={4} style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>暂无日志</td></tr>
|
||||
{keysData.length === 0 && (
|
||||
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--color-text-muted)', padding: '20px 0' }}>暂无密钥</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--color-text-muted)' }}>获取数据失败</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user