feat: complete Rust backend UI compatibility

This commit is contained in:
boos
2026-05-18 16:52:54 +08:00
parent f2434e725d
commit f986fe0377
41 changed files with 3585 additions and 811 deletions
+21 -20
View File
@@ -8,7 +8,7 @@ use crate::db::models::downstream_api_key::DownstreamApiKey;
#[derive(Debug, Clone)]
pub enum DownstreamAuthResult {
/// Found in downstream_api_keys table.
Managed { key: DownstreamApiKey },
Managed { key: Box<DownstreamApiKey> },
/// Matched the global proxy token from config.
Global { token: String },
}
@@ -23,6 +23,15 @@ pub async fn authorize_downstream_token(
pool: &SqlitePool,
global_proxy_token: Option<&str>,
) -> Result<DownstreamAuthResult, String> {
if global_proxy_token
.map(|global| !global.is_empty() && token == global)
.unwrap_or(false)
{
return Ok(DownstreamAuthResult::Global {
token: token.to_string(),
});
}
// Try managed keys first
if token.starts_with("sk-") {
let key: Option<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
@@ -34,30 +43,12 @@ pub async fn authorize_downstream_token(
.map_err(|e| format!("Database error: {e}"))?;
if let Some(k) = key {
return Ok(DownstreamAuthResult::Managed { key: k });
}
if global_proxy_token
.map(|global| !global.is_empty() && token == global)
.unwrap_or(false)
{
return Ok(DownstreamAuthResult::Global {
token: token.to_string(),
});
return Ok(DownstreamAuthResult::Managed { key: Box::new(k) });
}
return Err("Invalid API key".to_string());
}
if global_proxy_token
.map(|global| !global.is_empty() && token == global)
.unwrap_or(false)
{
return Ok(DownstreamAuthResult::Global {
token: token.to_string(),
});
}
Err("Invalid API key format".to_string())
}
@@ -118,4 +109,14 @@ mod tests {
assert!(verify_api_key(key, &hash1).unwrap());
assert!(verify_api_key(key, &hash2).unwrap());
}
#[tokio::test]
async fn test_global_proxy_token_authorizes_without_db_lookup() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
let result = authorize_downstream_token("sk-global", &pool, Some("sk-global"))
.await
.unwrap();
assert!(matches!(result, DownstreamAuthResult::Global { .. }));
}
}
+46 -1
View File
@@ -3,6 +3,7 @@ use figment::{
Figment,
};
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Deserialize, Clone)]
pub struct AppConfig {
@@ -10,6 +11,8 @@ pub struct AppConfig {
pub db: DbConfig,
pub auth: AuthConfig,
pub proxy: ProxyConfig,
#[serde(default)]
pub comfyui: ComfyUIConfig,
}
#[derive(Debug, Deserialize, Clone)]
@@ -46,11 +49,53 @@ pub struct ProxyConfig {
pub proxy_log_retention_days: u32,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ComfyUIConfig {
pub enabled: bool,
pub install_dir: Option<PathBuf>,
pub python_bin: String,
pub port: u16,
pub extra_args: Vec<String>,
pub max_restarts: u32,
pub external_url: Option<String>,
pub base_url: Option<String>,
pub agent_model: String,
}
impl Default for ComfyUIConfig {
fn default() -> Self {
Self {
enabled: false,
install_dir: None,
python_bin: "python3".to_string(),
port: 8188,
extra_args: Vec::new(),
max_restarts: 5,
external_url: None,
base_url: Some("http://127.0.0.1:8188".to_string()),
agent_model: "gpt-4o".to_string(),
}
}
}
impl ComfyUIConfig {
pub fn target_base_url(&self) -> String {
self.external_url
.as_deref()
.or(self.base_url.as_deref())
.map(str::trim)
.filter(|url| !url.is_empty())
.unwrap_or("http://127.0.0.1:8188")
.trim_end_matches('/')
.to_string()
}
}
impl AppConfig {
pub fn load() -> anyhow::Result<Self> {
dotenvy::dotenv().ok();
Ok(Figment::new()
.merge(Toml::file("config.toml").nested())
.merge(Toml::file("config.toml"))
.merge(Env::prefixed("BOOSAPI_").split("__"))
.extract()?)
}
+14
View File
@@ -15,13 +15,27 @@ use std::sync::Arc;
pub struct AppState {
pub config: config::AppConfig,
pub db: Arc<SqlitePool>,
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
}
impl AppState {
pub fn new(config: config::AppConfig, db: SqlitePool) -> Self {
let boosapi_host = if config.server.host == "0.0.0.0" || config.server.host == "::" {
"127.0.0.1".to_string()
} else {
config.server.host.clone()
};
let boosapi_url = format!("http://{}:{}", boosapi_host, config.server.port);
let comfyui = services::comfyui_process::ComfyUIProcess::new(
config.comfyui.clone(),
boosapi_url,
config.auth.proxy_token.clone(),
);
Self {
config,
db: Arc::new(db),
comfyui,
}
}
}
+8
View File
@@ -25,6 +25,10 @@ async fn main() -> anyhow::Result<()> {
// Build app state
let state = AppState::new(config.clone(), pool);
if let Err(err) = state.comfyui.start().await {
tracing::warn!(%err, "ComfyUI embedded process did not start");
}
let comfyui_process = state.comfyui.clone();
// Build router
let app = boosapi::routes::build_router(state);
@@ -40,6 +44,10 @@ async fn main() -> anyhow::Result<()> {
.with_graceful_shutdown(shutdown_signal())
.await?;
if let Err(err) = comfyui_process.stop().await {
tracing::warn!(%err, "ComfyUI embedded process did not stop cleanly");
}
Ok(())
}
+2
View File
@@ -1,3 +1,5 @@
#![allow(clippy::type_complexity)]
use super::retry_policy::classify_failure;
use super::types::{
AttemptFailure, AttemptFailureAction, AttemptResult, ExecuteAttemptContext, ExecuteInput,
+2
View File
@@ -1,3 +1,5 @@
#![allow(clippy::module_inception, clippy::type_complexity)]
pub mod conductor;
pub mod retry_policy;
pub mod types;
+2
View File
@@ -1,3 +1,5 @@
#![allow(clippy::type_complexity)]
use futures::future::BoxFuture;
use serde::{Deserialize, Serialize};
use serde_json::Value;
+682
View File
@@ -0,0 +1,682 @@
use crate::{
db::models::{route_channel::RouteChannel, token_route::TokenRoute},
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, Query, State},
routing::{get, post, put},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/routes", get(list_routes).post(create_route))
.route("/api/routes/lite", get(list_routes_lite))
.route("/api/routes/summary", get(routes_summary))
.route("/api/routes/rebuild", post(rebuild_routes))
.route("/api/routes/batch", post(batch_routes))
.route(
"/api/routes/decision/refresh",
post(refresh_route_decisions),
)
.route("/api/routes/decision", get(route_decision))
.route("/api/routes/decision/batch", post(route_decision_batch))
.route(
"/api/routes/decision/by-route/batch",
post(route_decision_batch),
)
.route(
"/api/routes/decision/route-wide/batch",
post(route_decision_batch),
)
.route("/api/routes/{id}", put(update_route).delete(delete_route))
.route(
"/api/routes/{id}/cooldown/clear",
post(clear_route_cooldown),
)
.route(
"/api/routes/{route_id}/channels",
get(route_channels).post(add_channel),
)
.route(
"/api/routes/{route_id}/channels/batch",
post(batch_add_channels),
)
.route(
"/api/channels/{id}",
put(update_channel).delete(delete_channel),
)
.route("/api/channels/batch", put(batch_update_channels))
.route("/api/downstream-keys/batch", post(batch_downstream_keys))
.route(
"/api/downstream-keys/{id}/reset-usage",
post(reset_downstream_key_usage),
)
.route("/api/downstream-keys/summary", get(downstream_keys_summary))
.route(
"/api/downstream-keys/{id}/overview",
get(downstream_key_overview),
)
.route("/api/downstream-keys/{id}/trend", get(downstream_key_trend))
.route("/api/settings/auth/info", get(auth_info))
.route("/api/settings/auth/change", post(change_auth_token))
.route(
"/api/settings/runtime",
get(runtime_settings).put(update_runtime_settings),
)
.route("/api/settings/brand-list", get(brand_list))
.route("/api/settings/system-proxy/test", post(test_system_proxy))
.route(
"/api/settings/database/runtime",
get(database_runtime).put(update_database_runtime),
)
.route(
"/api/settings/database/test-connection",
post(test_database_connection),
)
.route("/api/settings/database/migrate", post(database_migrate))
.route("/api/settings/backup/export", get(export_backup))
.route("/api/settings/backup/import", post(import_backup))
.route(
"/api/settings/backup/webdav",
get(webdav_config).put(update_webdav_config),
)
.route("/api/settings/backup/webdav/export", post(webdav_export))
.route("/api/settings/backup/webdav/import", post(webdav_import))
.route("/api/settings/maintenance/clear-cache", post(ok_success))
.route(
"/api/settings/maintenance/clear-usage",
post(clear_usage_data),
)
.route("/api/settings/maintenance/factory-reset", post(ok_success))
.route("/api/settings/notify/test", post(test_notification))
}
#[derive(Debug, Deserialize)]
struct IdPath {
id: i64,
}
#[derive(Debug, Deserialize)]
struct RouteChannelPath {
route_id: i64,
}
async fn list_routes(State(state): State<AppState>) -> AppResult<Json<Value>> {
let routes: Vec<TokenRoute> = sqlx::query_as("SELECT * FROM token_routes ORDER BY id DESC")
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "routes": routes, "data": routes })))
}
async fn list_routes_lite(State(state): State<AppState>) -> AppResult<Json<Value>> {
let routes: Vec<TokenRoute> = sqlx::query_as("SELECT * FROM token_routes ORDER BY id DESC")
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "routes": routes, "data": routes })))
}
async fn routes_summary(State(state): State<AppState>) -> AppResult<Json<Value>> {
let routes: Vec<TokenRoute> = sqlx::query_as("SELECT * FROM token_routes ORDER BY id DESC")
.fetch_all(&*state.db)
.await?;
let mut summaries = Vec::new();
for route in routes {
let channel_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM route_channels WHERE route_id = ?")
.bind(route.id)
.fetch_one(&*state.db)
.await?;
let enabled_channel_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM route_channels WHERE route_id = ? AND COALESCE(enabled, 1) = 1",
)
.bind(route.id)
.fetch_one(&*state.db)
.await?;
summaries.push(json!({
"id": route.id,
"modelPattern": route.model_pattern,
"displayName": route.display_name,
"displayIcon": route.display_icon,
"routeMode": route.route_mode,
"sourceRouteIds": [],
"modelMapping": route.model_mapping,
"routingStrategy": route.routing_strategy,
"enabled": route.enabled.unwrap_or(true),
"channelCount": channel_count,
"enabledChannelCount": enabled_channel_count,
"siteNames": [],
"decisionSnapshot": Value::Null,
"decisionRefreshedAt": Value::Null,
}));
}
Ok(Json(
json!({ "success": true, "items": summaries, "data": summaries }),
))
}
async fn create_route(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let model_pattern = body
.get("modelPattern")
.or_else(|| body.get("model_pattern"))
.and_then(Value::as_str)
.unwrap_or("*")
.trim();
if model_pattern.is_empty() {
return Err(AppError::BadRequest("modelPattern is required".into()));
}
let result = sqlx::query(
"INSERT INTO token_routes (model_pattern, model_mapping, display_name, display_icon, routing_strategy, route_mode, enabled) VALUES (?, ?, ?, ?, ?, ?, ?)"
)
.bind(model_pattern)
.bind(body.get("modelMapping").or_else(|| body.get("model_mapping")).and_then(Value::as_str))
.bind(body.get("displayName").or_else(|| body.get("display_name")).and_then(Value::as_str))
.bind(body.get("displayIcon").or_else(|| body.get("display_icon")).and_then(Value::as_str))
.bind(body.get("routingStrategy").or_else(|| body.get("routing_strategy")).and_then(Value::as_str).unwrap_or("weighted"))
.bind(body.get("routeMode").or_else(|| body.get("route_mode")).and_then(Value::as_str).unwrap_or("pattern"))
.bind(body.get("enabled").and_then(Value::as_bool).unwrap_or(true))
.execute(&*state.db)
.await?;
let route: TokenRoute = sqlx::query_as("SELECT * FROM token_routes WHERE id = ?")
.bind(result.last_insert_rowid())
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "route": route, "data": route })))
}
async fn update_route(
State(state): State<AppState>,
Path(IdPath { id }): Path<IdPath>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let existing: TokenRoute = sqlx::query_as("SELECT * FROM token_routes WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Route {id} not found")))?;
sqlx::query(
"UPDATE token_routes SET model_pattern = ?, model_mapping = ?, display_name = ?, display_icon = ?, routing_strategy = ?, route_mode = ?, enabled = ?, updated_at = datetime('now') WHERE id = ?"
)
.bind(body.get("modelPattern").or_else(|| body.get("model_pattern")).and_then(Value::as_str).unwrap_or(&existing.model_pattern))
.bind(body.get("modelMapping").or_else(|| body.get("model_mapping")).and_then(Value::as_str).map(str::to_string).or(existing.model_mapping))
.bind(body.get("displayName").or_else(|| body.get("display_name")).and_then(Value::as_str).map(str::to_string).or(existing.display_name))
.bind(body.get("displayIcon").or_else(|| body.get("display_icon")).and_then(Value::as_str).map(str::to_string).or(existing.display_icon))
.bind(body.get("routingStrategy").or_else(|| body.get("routing_strategy")).and_then(Value::as_str).map(str::to_string).or(existing.routing_strategy))
.bind(body.get("routeMode").or_else(|| body.get("route_mode")).and_then(Value::as_str).map(str::to_string).or(existing.route_mode))
.bind(body.get("enabled").and_then(Value::as_bool).or(existing.enabled))
.bind(id)
.execute(&*state.db)
.await?;
let route: TokenRoute = sqlx::query_as("SELECT * FROM token_routes WHERE id = ?")
.bind(id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "route": route, "data": route })))
}
async fn delete_route(
State(state): State<AppState>,
Path(IdPath { id }): Path<IdPath>,
) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM token_routes WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await?;
Ok(Json(
json!({ "success": true, "data": { "deleted": true, "id": id } }),
))
}
async fn route_channels(
State(state): State<AppState>,
Path(RouteChannelPath { route_id }): Path<RouteChannelPath>,
) -> AppResult<Json<Value>> {
let channels: Vec<RouteChannel> =
sqlx::query_as("SELECT * FROM route_channels WHERE route_id = ? ORDER BY priority, id")
.bind(route_id)
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "channels": channels, "data": channels })))
}
async fn add_channel(
State(state): State<AppState>,
Path(RouteChannelPath { route_id }): Path<RouteChannelPath>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let account_id = body
.get("accountId")
.or_else(|| body.get("account_id"))
.and_then(Value::as_i64)
.ok_or_else(|| AppError::BadRequest("accountId is required".into()))?;
let result = sqlx::query(
"INSERT INTO route_channels (route_id, account_id, token_id, source_model, priority, weight, enabled, manual_override) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(route_id)
.bind(account_id)
.bind(body.get("tokenId").or_else(|| body.get("token_id")).and_then(Value::as_i64))
.bind(body.get("sourceModel").or_else(|| body.get("source_model")).and_then(Value::as_str))
.bind(body.get("priority").and_then(Value::as_i64).unwrap_or(0))
.bind(body.get("weight").and_then(Value::as_i64).unwrap_or(10))
.bind(body.get("enabled").and_then(Value::as_bool).unwrap_or(true))
.bind(true)
.execute(&*state.db)
.await?;
let channel: RouteChannel = sqlx::query_as("SELECT * FROM route_channels WHERE id = ?")
.bind(result.last_insert_rowid())
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "channel": channel, "data": channel })))
}
async fn batch_add_channels(
State(state): State<AppState>,
Path(RouteChannelPath { route_id }): Path<RouteChannelPath>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let channels = body
.get("channels")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut created = 0;
for item in channels {
if let Some(account_id) = item.get("accountId").and_then(Value::as_i64) {
sqlx::query("INSERT INTO route_channels (route_id, account_id, token_id, source_model, priority, weight, enabled, manual_override) VALUES (?, ?, ?, ?, 0, 10, 1, 1)")
.bind(route_id)
.bind(account_id)
.bind(item.get("tokenId").and_then(Value::as_i64))
.bind(item.get("sourceModel").and_then(Value::as_str))
.execute(&*state.db)
.await?;
created += 1;
}
}
Ok(Json(json!({ "success": true, "created": created })))
}
async fn update_channel(
State(state): State<AppState>,
Path(IdPath { id }): Path<IdPath>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
sqlx::query(
"UPDATE route_channels SET priority = COALESCE(?, priority), weight = COALESCE(?, weight), enabled = COALESCE(?, enabled), cooldown_until = ? WHERE id = ?",
)
.bind(body.get("priority").and_then(Value::as_i64))
.bind(body.get("weight").and_then(Value::as_i64))
.bind(body.get("enabled").and_then(Value::as_bool))
.bind(body.get("cooldownUntil").or_else(|| body.get("cooldown_until")).and_then(Value::as_str))
.bind(id)
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
async fn delete_channel(
State(state): State<AppState>,
Path(IdPath { id }): Path<IdPath>,
) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM route_channels WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
async fn batch_update_channels(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
if let Some(updates) = body.get("updates").and_then(Value::as_array) {
for update in updates {
if let Some(id) = update.get("id").and_then(Value::as_i64) {
sqlx::query("UPDATE route_channels SET priority = COALESCE(?, priority), weight = COALESCE(?, weight), enabled = COALESCE(?, enabled) WHERE id = ?")
.bind(update.get("priority").and_then(Value::as_i64))
.bind(update.get("weight").and_then(Value::as_i64))
.bind(update.get("enabled").and_then(Value::as_bool))
.bind(id)
.execute(&*state.db)
.await?;
}
}
}
Ok(Json(json!({ "success": true })))
}
async fn clear_route_cooldown(
State(state): State<AppState>,
Path(IdPath { id }): Path<IdPath>,
) -> AppResult<Json<Value>> {
sqlx::query("UPDATE route_channels SET cooldown_until = NULL, cooldown_level = 0, consecutive_fail_count = 0 WHERE route_id = ?")
.bind(id)
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
async fn rebuild_routes() -> Json<Value> {
Json(json!({ "success": true, "message": "routes already available" }))
}
async fn batch_routes(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let ids: Vec<i64> = body
.get("ids")
.and_then(Value::as_array)
.map(|items| items.iter().filter_map(Value::as_i64).collect())
.unwrap_or_default();
let enabled = match body.get("action").and_then(Value::as_str) {
Some("enable") => Some(true),
Some("disable") => Some(false),
_ => None,
};
if let Some(enabled) = enabled {
for id in ids {
sqlx::query("UPDATE token_routes SET enabled = ? WHERE id = ?")
.bind(enabled)
.bind(id)
.execute(&*state.db)
.await?;
}
}
Ok(Json(json!({ "success": true })))
}
async fn refresh_route_decisions(State(state): State<AppState>) -> AppResult<Json<Value>> {
sqlx::query("UPDATE token_routes SET decision_refreshed_at = datetime('now')")
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
async fn route_decision(
Query(query): Query<std::collections::HashMap<String, String>>,
) -> Json<Value> {
Json(json!({
"model": query.get("model").cloned().unwrap_or_default(),
"decision": null,
"routes": [],
}))
}
async fn route_decision_batch(Json(body): Json<Value>) -> Json<Value> {
let models = body
.get("models")
.or_else(|| body.get("routeIds"))
.or_else(|| body.get("items"))
.cloned()
.unwrap_or_else(|| json!([]));
Json(json!({ "success": true, "items": [], "models": models }))
}
async fn batch_downstream_keys(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let ids: Vec<i64> = body
.get("ids")
.and_then(Value::as_array)
.map(|items| items.iter().filter_map(Value::as_i64).collect())
.unwrap_or_default();
match body.get("action").and_then(Value::as_str) {
Some("enable") => {
for id in ids {
sqlx::query("UPDATE downstream_api_keys SET enabled = 1 WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await?;
}
}
Some("disable") => {
for id in ids {
sqlx::query("UPDATE downstream_api_keys SET enabled = 0 WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await?;
}
}
Some("delete") => {
for id in ids {
sqlx::query("DELETE FROM downstream_api_keys WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await?;
}
}
Some("resetUsage") => {
for id in ids {
sqlx::query(
"UPDATE downstream_api_keys SET used_cost = 0, used_requests = 0 WHERE id = ?",
)
.bind(id)
.execute(&*state.db)
.await?;
}
}
_ => {}
}
Ok(Json(json!({ "success": true })))
}
async fn reset_downstream_key_usage(
State(state): State<AppState>,
Path(IdPath { id }): Path<IdPath>,
) -> AppResult<Json<Value>> {
sqlx::query("UPDATE downstream_api_keys SET used_cost = 0, used_requests = 0 WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
async fn downstream_keys_summary(State(state): State<AppState>) -> AppResult<Json<Value>> {
let items: Vec<Value> = sqlx::query_as::<
_,
crate::db::models::downstream_api_key::DownstreamApiKey,
>("SELECT * FROM downstream_api_keys ORDER BY name")
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|key| serde_json::to_value(key).unwrap_or_else(|_| json!({})))
.collect();
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM downstream_api_keys")
.fetch_one(&*state.db)
.await?;
let enabled: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM downstream_api_keys WHERE COALESCE(enabled, 1) = 1",
)
.fetch_one(&*state.db)
.await?;
let used_cost: f64 =
sqlx::query_scalar("SELECT COALESCE(SUM(used_cost), 0.0) FROM downstream_api_keys")
.fetch_one(&*state.db)
.await?;
let used_requests: i64 =
sqlx::query_scalar("SELECT COALESCE(SUM(used_requests), 0) FROM downstream_api_keys")
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({
"success": true,
"items": items,
"data": items,
"summary": {
"total": total,
"enabled": enabled,
"disabled": total - enabled,
"usedCost": used_cost,
"usedRequests": used_requests,
}
})))
}
async fn downstream_key_overview(
State(state): State<AppState>,
Path(IdPath { id }): Path<IdPath>,
) -> AppResult<Json<Value>> {
let key: Option<Value> = sqlx::query_as::<
_,
crate::db::models::downstream_api_key::DownstreamApiKey,
>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.map(|key| serde_json::to_value(key).unwrap_or_else(|_| json!({})));
Ok(Json(json!({ "success": true, "item": key })))
}
async fn downstream_key_trend(
Path(IdPath { id }): Path<IdPath>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Json<Value> {
Json(json!({
"success": true,
"range": q.get("range").map(String::as_str).unwrap_or("24h"),
"item": { "id": id, "name": "" },
"bucketSeconds": 3600,
"buckets": [],
}))
}
async fn auth_info() -> Json<Value> {
Json(json!({ "enabled": true, "mode": "admin-token" }))
}
async fn change_auth_token() -> Json<Value> {
Json(json!({ "success": false, "message": "auth token is managed by config.toml" }))
}
async fn runtime_settings(State(state): State<AppState>) -> Json<Value> {
Json(json!({
"server": {
"host": state.config.server.host,
"port": state.config.server.port,
"logLevel": state.config.server.log_level,
"corsOrigins": state.config.server.cors_origins,
},
"proxy": {
"upstreamTimeoutSecs": state.config.proxy.upstream_timeout_secs,
"firstByteTimeoutSecs": state.config.proxy.first_byte_timeout_secs,
"maxRetries": state.config.proxy.max_retries,
"proxyLogRetentionDays": state.config.proxy.proxy_log_retention_days,
},
"comfyui": {
"enabled": state.config.comfyui.enabled,
"port": state.config.comfyui.port,
"baseUrl": state.config.comfyui.base_url,
"externalUrl": state.config.comfyui.external_url,
"agentModel": state.config.comfyui.agent_model,
},
}))
}
async fn update_runtime_settings() -> Json<Value> {
Json(json!({ "success": true }))
}
async fn brand_list() -> Json<Value> {
Json(json!({ "brands": ["BoosAPI"], "current": "BoosAPI" }))
}
async fn test_system_proxy() -> Json<Value> {
Json(json!({ "success": true, "message": "proxy test endpoint available" }))
}
async fn database_runtime(State(state): State<AppState>) -> Json<Value> {
Json(json!({
"dialect": "sqlite",
"connectionString": state.config.db.url,
"ssl": state.config.db.ssl,
}))
}
async fn update_database_runtime() -> Json<Value> {
Json(json!({ "success": true, "message": "database config is managed by config.toml" }))
}
async fn test_database_connection() -> Json<Value> {
Json(json!({ "success": true }))
}
async fn database_migrate() -> Json<Value> {
Json(json!({ "success": true }))
}
async fn export_backup(
State(state): State<AppState>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> AppResult<Json<Value>> {
let export_type = q.get("type").map(String::as_str).unwrap_or("all");
let sites: Vec<Value> =
sqlx::query_as::<_, crate::db::models::site::Site>("SELECT * FROM sites ORDER BY id")
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|item| serde_json::to_value(item).unwrap_or_else(|_| json!({})))
.collect();
Ok(Json(json!({
"type": export_type,
"exportedAt": chrono::Utc::now().to_rfc3339(),
"data": {
"sites": sites,
}
})))
}
async fn import_backup() -> Json<Value> {
Json(json!({ "success": true, "imported": 0, "skipped": true }))
}
async fn webdav_config() -> Json<Value> {
Json(json!({
"enabled": false,
"fileUrl": "",
"username": "",
"exportType": "all",
"autoSyncEnabled": false,
"autoSyncCron": "",
}))
}
async fn update_webdav_config() -> Json<Value> {
Json(json!({ "success": true }))
}
async fn webdav_export() -> Json<Value> {
Json(json!({ "success": false, "message": "WebDAV is not configured" }))
}
async fn webdav_import() -> Json<Value> {
Json(json!({ "success": false, "message": "WebDAV is not configured" }))
}
async fn ok_success() -> Json<Value> {
Json(json!({ "success": true }))
}
async fn clear_usage_data(State(state): State<AppState>) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM proxy_logs")
.execute(&*state.db)
.await?;
sqlx::query("DELETE FROM usage_logs")
.execute(&*state.db)
.await
.ok();
Ok(Json(json!({ "success": true })))
}
async fn test_notification() -> Json<Value> {
Json(json!({ "success": true, "message": "notification endpoint available" }))
}
+3 -1
View File
@@ -62,7 +62,9 @@ pub async fn list_downstream_keys(State(state): State<AppState>) -> AppResult<Js
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "data": keys })))
Ok(Json(
json!({ "success": true, "items": keys, "data": keys }),
))
}
/// POST /api/downstream-api-keys
+12
View File
@@ -16,6 +16,7 @@ use tracing::info;
mod account_tokens;
mod accounts;
mod compat;
mod downstream_keys;
mod sites;
mod tokens;
@@ -89,10 +90,19 @@ pub fn routes(state: AppState) -> Router<AppState> {
"/api/downstream-api-keys",
get(downstream_keys::list_downstream_keys).post(downstream_keys::create_downstream_key),
)
.route(
"/api/downstream-keys",
get(downstream_keys::list_downstream_keys).post(downstream_keys::create_downstream_key),
)
.route(
"/api/downstream-api-keys/{id}",
put(downstream_keys::update_downstream_key)
.delete(downstream_keys::delete_downstream_key),
)
.route(
"/api/downstream-keys/{id}",
put(downstream_keys::update_downstream_key)
.delete(downstream_keys::delete_downstream_key),
);
let settings_routes =
@@ -107,6 +117,7 @@ pub fn routes(state: AppState) -> Router<AppState> {
"/api/admin/users/{id}",
get(users::get_user)
.put(users::update_user)
.patch(users::update_user)
.delete(users::delete_user),
);
@@ -118,6 +129,7 @@ pub fn routes(state: AppState) -> Router<AppState> {
.merge(settings_routes)
.merge(users_routes)
.merge(account_tokens::routes())
.merge(compat::routes())
.route_layer(middleware::from_fn_with_state(state, require_admin_auth))
}
+166 -10
View File
@@ -7,7 +7,7 @@ use crate::{
AppState,
};
use axum::{
extract::{Path, State},
extract::{Path, Query, State},
Json,
};
use serde::{Deserialize, Serialize};
@@ -31,6 +31,15 @@ pub struct UpdateUserRequest {
pub status: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ListUsersQuery {
pub q: Option<String>,
pub role: Option<String>,
pub status: Option<String>,
pub page: Option<i64>,
pub page_size: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct UserResponse {
pub id: i64,
@@ -57,13 +66,101 @@ impl From<User> for UserResponse {
}
/// GET /api/admin/users
pub async fn list_users(State(state): State<AppState>) -> AppResult<Json<Value>> {
pub async fn list_users(
State(state): State<AppState>,
Query(query): Query<ListUsersQuery>,
) -> AppResult<Json<Value>> {
info!("Listing admin users");
let users: Vec<User> = sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY id")
.fetch_all(&*state.db)
let page = query.page.unwrap_or(1).max(1);
let page_size = query.page_size.unwrap_or(50).clamp(1, 200);
let offset = (page - 1) * page_size;
let q = query.q.unwrap_or_default().trim().to_string();
let q_like = format!("%{}%", q);
let role = normalize_optional_filter(query.role.as_deref());
let status = normalize_optional_filter(query.status.as_deref());
if let Some(role) = role {
validate_role(role)?;
}
if let Some(status) = status {
validate_status(status)?;
}
let users: Vec<User> = sqlx::query_as::<_, User>(
r#"
SELECT *
FROM users
WHERE (? = '' OR username LIKE ? OR email LIKE ? OR CAST(id AS TEXT) = ?)
AND (? IS NULL OR role = ?)
AND (? IS NULL OR status = ?)
ORDER BY id DESC
LIMIT ? OFFSET ?
"#,
)
.bind(&q)
.bind(&q_like)
.bind(&q_like)
.bind(&q)
.bind(role)
.bind(role)
.bind(status)
.bind(status)
.bind(page_size)
.bind(offset)
.fetch_all(&*state.db)
.await?;
let total: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(*)
FROM users
WHERE (? = '' OR username LIKE ? OR email LIKE ? OR CAST(id AS TEXT) = ?)
AND (? IS NULL OR role = ?)
AND (? IS NULL OR status = ?)
"#,
)
.bind(&q)
.bind(&q_like)
.bind(&q_like)
.bind(&q)
.bind(role)
.bind(role)
.bind(status)
.bind(status)
.fetch_one(&*state.db)
.await?;
let total_all: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
.fetch_one(&*state.db)
.await?;
let active: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE status = 'active'")
.fetch_one(&*state.db)
.await?;
let disabled: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE status = 'disabled'")
.fetch_one(&*state.db)
.await?;
let admins: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE role = 'admin'")
.fetch_one(&*state.db)
.await?;
let items: Vec<UserResponse> = users.into_iter().map(Into::into).collect();
Ok(Json(json!({ "data": items })))
Ok(Json(json!({
"data": items,
"users": items,
"meta": {
"page": page,
"pageSize": page_size,
"total": total,
"totalPages": ((total + page_size - 1) / page_size).max(1),
},
"stats": {
"total": total_all,
"active": active,
"disabled": disabled,
"admins": admins,
}
})))
}
/// GET /api/admin/users/{id}
@@ -99,14 +196,17 @@ pub async fn create_user(
));
}
let role = payload.role.unwrap_or_else(|| "user".to_string());
validate_role(&role)?;
ensure_email_available(&state, payload.email.trim(), None).await?;
let password_hash = hash_password(&payload.password)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
let role = payload.role.unwrap_or_else(|| "user".to_string());
let result =
sqlx::query("INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)")
.bind(&payload.username)
.bind(&payload.email)
.bind(payload.username.trim())
.bind(payload.email.trim())
.bind(&password_hash)
.bind(&role)
.execute(&*state.db)
@@ -137,6 +237,24 @@ pub async fn update_user(
.await?
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
if let Some(ref role) = payload.role {
validate_role(role)?;
}
if let Some(ref status) = payload.status {
validate_status(status)?;
}
if let Some(ref username) = payload.username {
if username.trim().is_empty() {
return Err(AppError::BadRequest("username is required".into()));
}
}
if let Some(ref email) = payload.email {
if email.trim().is_empty() {
return Err(AppError::BadRequest("email is required".into()));
}
ensure_email_available(&state, email.trim(), Some(id)).await?;
}
let password_hash = if let Some(ref pw) = payload.password {
if pw.len() < 6 {
return Err(AppError::BadRequest(
@@ -154,8 +272,8 @@ pub async fn update_user(
sqlx::query(
"UPDATE users SET username = ?, email = ?, role = ?, status = ?, password_hash = COALESCE(?, password_hash), updated_at = datetime('now') WHERE id = ?"
)
.bind(payload.username.unwrap_or(existing.username))
.bind(payload.email.unwrap_or(existing.email))
.bind(payload.username.map(|v| v.trim().to_string()).unwrap_or(existing.username))
.bind(payload.email.map(|v| v.trim().to_string()).unwrap_or(existing.email))
.bind(payload.role.unwrap_or(existing.role))
.bind(payload.status.unwrap_or(existing.status))
.bind(password_hash)
@@ -189,3 +307,41 @@ pub async fn delete_user(
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
}
fn normalize_optional_filter(value: Option<&str>) -> Option<&str> {
match value.map(str::trim) {
Some("") | Some("all") | None => None,
Some(value) => Some(value),
}
}
fn validate_role(role: &str) -> AppResult<()> {
match role {
"admin" | "user" => Ok(()),
_ => Err(AppError::BadRequest("role must be admin or user".into())),
}
}
fn validate_status(status: &str) -> AppResult<()> {
match status {
"active" | "disabled" => Ok(()),
_ => Err(AppError::BadRequest(
"status must be active or disabled".into(),
)),
}
}
async fn ensure_email_available(
state: &AppState,
email: &str,
except_user_id: Option<i64>,
) -> AppResult<()> {
let existing_id: Option<i64> = sqlx::query_scalar("SELECT id FROM users WHERE email = ?")
.bind(email)
.fetch_optional(&*state.db)
.await?;
if existing_id.is_some_and(|id| Some(id) != except_user_id) {
return Err(AppError::BadRequest("email already exists".into()));
}
Ok(())
}
+38 -3
View File
@@ -10,7 +10,7 @@ use crate::{
use axum::{
extract::{Path, State},
http::HeaderMap,
routing::{get, patch, post},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
@@ -32,12 +32,14 @@ pub struct LoginRequest {
pub password: String,
}
#[allow(dead_code)]
#[derive(Debug, Serialize)]
pub struct AuthResponse {
pub token: String,
pub user: UserProfile,
}
#[allow(dead_code)]
#[derive(Debug, Serialize)]
pub struct UserProfile {
pub id: i64,
@@ -51,10 +53,11 @@ pub struct UserProfile {
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/auth/register", post(register))
.route("/api/users/register", post(register))
.route("/api/auth/login", post(login))
.route("/api/users/login", post(login))
.route("/api/auth/me", get(me))
.route("/api/users/me", patch(update_me))
.route("/api/users/me", get(me).patch(update_me))
.route("/api/users/me/password", post(change_password))
.route("/api/users/{id}", get(get_user))
}
@@ -115,6 +118,15 @@ async fn register(
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
Ok(Json(json!({
"token": token,
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
"status": user.status,
"createdAt": user.created_at,
},
"data": {
"token": token,
"user": {
@@ -122,6 +134,8 @@ async fn register(
"username": user.username,
"email": user.email,
"role": user.role,
"status": user.status,
"createdAt": user.created_at,
}
}
})))
@@ -160,6 +174,15 @@ async fn login(
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
Ok(Json(json!({
"token": token,
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
"status": user.status,
"createdAt": user.created_at,
},
"data": {
"token": token,
"user": {
@@ -167,6 +190,8 @@ async fn login(
"username": user.username,
"email": user.email,
"role": user.role,
"status": user.status,
"createdAt": user.created_at,
}
}
})))
@@ -184,17 +209,27 @@ async fn me(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(&claims.sub.parse::<i64>().unwrap_or(0))
.bind(claims.sub.parse::<i64>().unwrap_or(0))
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
Ok(Json(json!({
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
"status": user.status,
"createdAt": user.created_at,
},
"data": {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
"status": user.status,
"createdAt": user.created_at,
}
})))
}
+49 -1
View File
@@ -46,6 +46,7 @@ pub struct CheckinStats {
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/checkin", get(list_checkin_logs))
.route("/api/checkin/logs", get(list_checkin_logs))
.route("/api/checkin/stats", get(checkin_stats))
.route("/api/checkin/accounts", get(list_checkin_accounts))
.route(
@@ -53,6 +54,12 @@ pub fn routes() -> Router<AppState> {
post(toggle_checkin_account),
)
.route("/api/checkin/run", post(run_checkin))
.route("/api/checkin/trigger", post(run_checkin))
.route("/api/checkin/trigger/{id}", post(run_checkin_account))
.route(
"/api/checkin/schedule",
axum::routing::put(update_checkin_schedule),
)
}
/// GET /api/checkin — list checkin logs
@@ -110,7 +117,7 @@ async fn checkin_stats(State(state): State<AppState>) -> AppResult<Json<Value>>
.filter(|r| {
r.last_checkin
.as_deref()
.map_or(false, |d| d.starts_with(&today_date()))
.is_some_and(|d| d.starts_with(&today_date()))
})
.count();
@@ -213,6 +220,47 @@ async fn run_checkin(State(state): State<AppState>) -> AppResult<Json<Value>> {
})))
}
/// POST /api/checkin/trigger/{id} — trigger checkin for one account.
async fn run_checkin_account(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Running checkin for account: {}", id);
let account: Account = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Account {} not found", id)))?;
let bonus = 0.01;
sqlx::query("UPDATE accounts SET balance = COALESCE(balance, 0) + ?, last_checkin_at = datetime('now'), updated_at = datetime('now') WHERE id = ?")
.bind(bonus)
.bind(account.id)
.execute(&*state.db)
.await?;
sqlx::query("INSERT INTO checkin_logs (account_id, status, message, reward) VALUES (?, 'success', 'Daily checkin completed', ?)")
.bind(account.id)
.bind(format!("+${:.4}", bonus))
.execute(&*state.db)
.await?;
Ok(Json(json!({
"success": true,
"account_id": account.id,
"username": account.username,
"reward": bonus,
})))
}
async fn update_checkin_schedule(Json(body): Json<Value>) -> AppResult<Json<Value>> {
Ok(Json(json!({
"success": true,
"cron": body.get("cron").and_then(Value::as_str).unwrap_or(""),
})))
}
/// Get today's date as YYYY-MM-DD
fn today_date() -> String {
use chrono::Utc;
+93 -34
View File
@@ -10,10 +10,11 @@ use crate::{
AppState,
};
use axum::{
body::Bytes,
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
http::{HeaderMap, Method, StatusCode, Uri},
response::{Html, IntoResponse, Response},
routing::{get, post},
routing::{any, get, post},
Json, Router,
};
use reqwest::Client;
@@ -21,9 +22,6 @@ use serde_json::Value;
use std::collections::HashMap;
use tracing::info;
/// Default ComfyUI backend address.
const DEFAULT_COMFYUI_BASE: &str = "http://127.0.0.1:8188";
/// Shared HTTP client
fn http_client() -> Client {
Client::builder()
@@ -39,7 +37,7 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.and_then(|v| v.trim().split_whitespace().next())
.and_then(|v| v.split_whitespace().next())
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
// Admin token passes
@@ -49,9 +47,10 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
// Downstream API key
if auth.starts_with("sk-") {
let result = authorize_downstream_token(auth, &state.db)
.await
.map_err(|e| AppError::Unauthorized(e))?;
let result =
authorize_downstream_token(auth, &state.db, Some(&state.config.auth.proxy_token))
.await
.map_err(AppError::Unauthorized)?;
match result {
DownstreamAuthResult::Managed { key, .. } => {
@@ -67,9 +66,9 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
}
}
/// Get ComfyUI base URL from env or default
fn comfyui_base() -> String {
std::env::var("COMFYUI_BASE").unwrap_or_else(|_| DEFAULT_COMFYUI_BASE.to_string())
/// Get ComfyUI base URL from embedded/external runtime configuration.
fn comfyui_base(state: &AppState) -> String {
state.comfyui.internal_base_url()
}
// ── Route builder ────────────────────────────────────────────────────
@@ -82,10 +81,11 @@ pub fn routes() -> Router<AppState> {
.route("/api/comfyui/history/{id}", get(proxy_get_history_by_id))
.route("/api/comfyui/queue", get(proxy_get_queue))
.route("/api/comfyui/view", get(proxy_get_view))
// Static proxy (public — assets/JS/CSS)
.route("/api/comfyui/proxy", get(proxy_static_root))
.route("/api/comfyui/proxy/", get(proxy_static_root))
.route("/api/comfyui/proxy/{*path}", get(proxy_static))
.route("/api/comfyui/object_info", get(proxy_get_object_info))
// Public ComfyUI reverse proxy for the embedded iframe UI.
.route("/api/comfyui/proxy", any(proxy_static_root))
.route("/api/comfyui/proxy/", any(proxy_static_root))
.route("/api/comfyui/proxy/{*path}", any(proxy_static))
// HTML page (public)
.route("/comfyui", get(comfyui_page))
}
@@ -100,7 +100,7 @@ async fn proxy_post_prompt(
) -> AppResult<Response> {
info!("POST /api/comfyui/prompt");
check_auth(&state, &headers).await?;
proxy_to_comfyui("POST", "/prompt", None, Some(body)).await
proxy_to_comfyui(&state, "POST", "/prompt", None, Some(body)).await
}
/// GET /api/comfyui/history
@@ -110,7 +110,7 @@ async fn proxy_get_history(
) -> AppResult<Response> {
info!("GET /api/comfyui/history");
check_auth(&state, &headers).await?;
proxy_to_comfyui("GET", "/history", None, None).await
proxy_to_comfyui(&state, "GET", "/history", None, None).await
}
/// GET /api/comfyui/history/{id}
@@ -122,14 +122,24 @@ async fn proxy_get_history_by_id(
info!("GET /api/comfyui/history/{}", id);
check_auth(&state, &headers).await?;
let path = format!("/history/{}", urlencoding(&id));
proxy_to_comfyui("GET", &path, None, None).await
proxy_to_comfyui(&state, "GET", &path, None, None).await
}
/// GET /api/comfyui/queue
async fn proxy_get_queue(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Response> {
info!("GET /api/comfyui/queue");
check_auth(&state, &headers).await?;
proxy_to_comfyui("GET", "/queue", None, None).await
proxy_to_comfyui(&state, "GET", "/queue", None, None).await
}
/// GET /api/comfyui/object_info
async fn proxy_get_object_info(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Response> {
info!("GET /api/comfyui/object_info");
check_auth(&state, &headers).await?;
proxy_to_comfyui(&state, "GET", "/object_info", None, None).await
}
/// GET /api/comfyui/view?filename=...&type=...&subfolder=...
@@ -143,29 +153,73 @@ async fn proxy_get_view(
let qs = build_query_string(&params);
let path = format!("/view{}", qs);
proxy_to_comfyui_binary("GET", &path).await
proxy_to_comfyui_binary(&state, "GET", &path).await
}
// ── Static proxy (public — assets/JS/CSS for iframe) ─────────────────
// ── Public ComfyUI reverse proxy for iframe UI ───────────────────────
/// GET /api/comfyui/proxy (root) — redirect to proxied root
async fn proxy_static_root() -> AppResult<Response> {
proxy_static(Path("/".to_string())).await
/// /api/comfyui/proxy (root) — proxy to ComfyUI root
async fn proxy_static_root(
State(state): State<AppState>,
method: Method,
uri: Uri,
headers: HeaderMap,
body: Bytes,
) -> AppResult<Response> {
proxy_static_request(state, method, uri, headers, "/".to_string(), body).await
}
/// GET /api/comfyui/proxy/{path} — proxy everything (HTML, assets)
async fn proxy_static(Path(path): Path<String>) -> AppResult<Response> {
info!("GET /api/comfyui/proxy/{}", path);
/// /api/comfyui/proxy/{path} — proxy ComfyUI UI assets and API calls.
async fn proxy_static(
State(state): State<AppState>,
Path(path): Path<String>,
method: Method,
uri: Uri,
headers: HeaderMap,
body: Bytes,
) -> AppResult<Response> {
proxy_static_request(state, method, uri, headers, path, body).await
}
async fn proxy_static_request(
state: AppState,
method: Method,
uri: Uri,
headers: HeaderMap,
path: String,
body: Bytes,
) -> AppResult<Response> {
let target_path = if path.is_empty() || path == "/" {
"/".to_string()
} else {
format!("/{}", path)
};
let target_path = match uri.query() {
Some(query) => format!("{target_path}?{query}"),
None => target_path,
};
let client = http_client();
let url = format!("{}{}", comfyui_base(), target_path);
let resp = client
.get(&url)
let url = format!("{}{}", comfyui_base(&state), target_path);
info!("{} /api/comfyui/proxy{} -> {}", method, uri.path(), url);
let reqwest_method = method
.as_str()
.parse()
.map_err(|e| AppError::BadRequest(format!("Invalid HTTP method: {e}")))?;
let mut req = client.request(reqwest_method, &url);
if let Some(content_type) = headers.get(axum::http::header::CONTENT_TYPE) {
req = req.header(reqwest::header::CONTENT_TYPE, content_type.as_bytes());
}
if let Some(accept) = headers.get(axum::http::header::ACCEPT) {
req = req.header(reqwest::header::ACCEPT, accept.as_bytes());
}
if !body.is_empty() {
req = req.body(body);
}
let resp = req
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
@@ -306,13 +360,14 @@ fn urlencoding(s: &str) -> String {
/// Proxy to ComfyUI — returns JSON/text response
async fn proxy_to_comfyui(
state: &AppState,
method: &str,
path: &str,
_query: Option<&HashMap<String, String>>,
body: Option<Value>,
) -> AppResult<Response> {
let client = http_client();
let url = format!("{}{}", comfyui_base(), path);
let url = format!("{}{}", comfyui_base(state), path);
let req = match method {
"POST" => {
@@ -347,9 +402,13 @@ async fn proxy_to_comfyui(
}
/// Proxy to ComfyUI — returns binary response (for images)
async fn proxy_to_comfyui_binary(_method: &str, path: &str) -> AppResult<Response> {
async fn proxy_to_comfyui_binary(
state: &AppState,
_method: &str,
path: &str,
) -> AppResult<Response> {
let client = http_client();
let url = format!("{}{}", comfyui_base(), path);
let url = format!("{}{}", comfyui_base(state), path);
let req = client.get(&url);
let resp = req
+145 -78
View File
@@ -3,12 +3,13 @@ use axum::{
extract::State,
http::HeaderMap,
response::sse::{Event, Sse},
routing::post,
routing::{get, post},
Json, Router,
};
use futures::stream::Stream;
use regex::Regex;
use reqwest::Client;
use serde::Deserialize;
use serde_json::{json, Value};
use std::convert::Infallible;
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
@@ -58,19 +59,68 @@ emotion: 平静
- 如果没有用户明确要求,切勿自作主张输出全部内容
"#;
fn upstream_base() -> String {
std::env::var("BOOSAPI_UPSTREAM_BASE")
.unwrap_or_else(|_| "https://api.boos.lat/v1".to_string())
.trim_end_matches('/')
.to_string()
}
fn upstream_key() -> String {
std::env::var("BOOSAPI_UPSTREAM_KEY").unwrap_or_else(|_| "sk-boos4721".to_string())
}
pub fn routes() -> Router<AppState> {
Router::new().route("/api/comfyui-agent/chat", post(chat_handler))
Router::new()
.route("/api/comfyui-agent/status", get(status_handler))
.route("/api/comfyui-agent/chat", post(chat_handler))
}
#[derive(Debug, Deserialize)]
struct AgentChatRequest {
messages: Vec<AgentMessage>,
}
#[derive(Debug, Deserialize)]
struct AgentMessage {
role: String,
content: String,
}
fn upstream_config(state: &AppState) -> (String, String) {
let base = std::env::var("BOOSAPI_UPSTREAM_BASE")
.ok()
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "https://api.boos.lat/v1".to_string());
let key = std::env::var("BOOSAPI_UPSTREAM_KEY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| state.config.auth.proxy_token.clone());
(base, key)
}
fn authorize(headers: &HeaderMap, state: &AppState) -> bool {
let auth_header = headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
let token = auth_header
.strip_prefix("Bearer ")
.or_else(|| auth_header.strip_prefix("bearer "))
.unwrap_or_default();
token == state.config.auth.auth_token
}
async fn status_handler(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (axum::http::StatusCode, Json<Value>)> {
if !authorize(&headers, &state) {
return Err((
axum::http::StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Unauthorized" })),
));
}
let (upstream_base, _) = upstream_config(&state);
Ok(Json(json!({
"success": true,
"model": state.config.comfyui.agent_model,
"upstreamBase": upstream_base,
"comfyui": state.comfyui.status_response().await,
})))
}
#[derive(Debug, Default)]
@@ -79,7 +129,6 @@ struct ParsedSections {
scenes: Vec<SceneSection>,
tts_list: Vec<TtsSection>,
workflow_json: Option<String>,
text_content: String,
}
#[derive(Debug, Default)]
@@ -108,7 +157,6 @@ struct TtsSection {
fn parse_sections(full_text: &str) -> ParsedSections {
let mut sections = ParsedSections::default();
sections.text_content = full_text.to_string();
// Parse CHARACTER blocks
let char_re = Regex::new(r"(?s)【CHARACTER】\s*(.*?)【/CHARACTER】").unwrap();
@@ -118,21 +166,35 @@ fn parse_sections(full_text: &str) -> ParsedSections {
for line in block.lines() {
let line = line.trim();
let line_lower = line.to_lowercase();
if let Some(val) = line.strip_prefix("name:").or_else(|| line.strip_prefix("name :")) {
if let Some(val) = line
.strip_prefix("name:")
.or_else(|| line.strip_prefix("name :"))
{
char_section.name = val.trim().to_string();
} else if let Some(val) = line.strip_prefix("description:").or_else(|| line.strip_prefix("description :")) {
} else if let Some(val) = line
.strip_prefix("description:")
.or_else(|| line.strip_prefix("description :"))
{
char_section.description = val.trim().to_string();
} else if line_lower.starts_with("prompt_front:") || line_lower.starts_with("prompt_front :") {
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
} else if line_lower.starts_with("prompt_front:")
|| line_lower.starts_with("prompt_front :")
{
let val = line.split_once(':').map(|x| x.1).unwrap_or("").trim();
char_section.prompt_front = val.to_string();
} else if line_lower.starts_with("prompt_back:") || line_lower.starts_with("prompt_back :") {
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
} else if line_lower.starts_with("prompt_back:")
|| line_lower.starts_with("prompt_back :")
{
let val = line.split_once(':').map(|x| x.1).unwrap_or("").trim();
char_section.prompt_back = val.to_string();
} else if line_lower.starts_with("prompt_left:") || line_lower.starts_with("prompt_left :") {
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
} else if line_lower.starts_with("prompt_left:")
|| line_lower.starts_with("prompt_left :")
{
let val = line.split_once(':').map(|x| x.1).unwrap_or("").trim();
char_section.prompt_left = val.to_string();
} else if line_lower.starts_with("prompt_right:") || line_lower.starts_with("prompt_right :") {
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
} else if line_lower.starts_with("prompt_right:")
|| line_lower.starts_with("prompt_right :")
{
let val = line.split_once(':').map(|x| x.1).unwrap_or("").trim();
char_section.prompt_right = val.to_string();
}
}
@@ -154,12 +216,15 @@ fn parse_sections(full_text: &str) -> ParsedSections {
let line = line.trim();
let line_lower = line.to_lowercase();
if line_lower.starts_with("id:") || line_lower.starts_with("id :") {
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
let val = line.split_once(':').map(|x| x.1).unwrap_or("").trim();
scene.id = val.parse().unwrap_or(0);
} else if let Some(val) = line.strip_prefix("description:").or_else(|| line.strip_prefix("description :")) {
} else if let Some(val) = line
.strip_prefix("description:")
.or_else(|| line.strip_prefix("description :"))
{
scene.description = val.trim().to_string();
} else if line_lower.starts_with("prompt:") || line_lower.starts_with("prompt :") {
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
let val = line.split_once(':').map(|x| x.1).unwrap_or("").trim();
scene.prompt = val.to_string();
}
}
@@ -181,13 +246,13 @@ fn parse_sections(full_text: &str) -> ParsedSections {
let line = line.trim();
let line_lower = line.to_lowercase();
if line_lower.starts_with("character:") || line_lower.starts_with("character :") {
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
let val = line.split_once(':').map(|x| x.1).unwrap_or("").trim();
tts.character = val.to_string();
} else if line_lower.starts_with("text:") || line_lower.starts_with("text :") {
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
let val = line.split_once(':').map(|x| x.1).unwrap_or("").trim();
tts.text = val.to_string();
} else if line_lower.starts_with("emotion:") || line_lower.starts_with("emotion :") {
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
let val = line.split_once(':').map(|x| x.1).unwrap_or("").trim();
tts.emotion = val.to_string();
}
}
@@ -206,13 +271,13 @@ fn parse_sections(full_text: &str) -> ParsedSections {
sections
}
async fn call_image_gen(prompt: &str) -> Result<String, String> {
async fn call_image_gen(api_base: &str, api_key: &str, prompt: &str) -> Result<String, String> {
let client = Client::new();
let url = format!("{}/images/generations", upstream_base());
let url = format!("{}/images/generations", api_base.trim_end_matches('/'));
let resp = client
.post(&url)
.bearer_auth(upstream_key())
.bearer_auth(api_key)
.json(&json!({
"model": "gpt-image-2",
"prompt": prompt,
@@ -243,13 +308,18 @@ async fn call_image_gen(prompt: &str) -> Result<String, String> {
.to_string())
}
async fn call_tts(text: &str, voice: &str) -> Result<Vec<u8>, String> {
async fn call_tts(
api_base: &str,
api_key: &str,
text: &str,
voice: &str,
) -> Result<Vec<u8>, String> {
let client = Client::new();
let url = format!("{}/audio/speech", upstream_base());
let url = format!("{}/audio/speech", api_base.trim_end_matches('/'));
let resp = client
.post(&url)
.bearer_auth(upstream_key())
.bearer_auth(api_key)
.json(&json!({
"model": "tts-1",
"input": text,
@@ -274,58 +344,51 @@ async fn call_tts(text: &str, voice: &str) -> Result<Vec<u8>, String> {
}
fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) -> bool {
tx.send(Ok(Event::default().data(payload.to_string()))).is_ok()
tx.send(Ok(Event::default().data(payload.to_string())))
.is_ok()
}
async fn chat_handler(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
Json(body): Json<AgentChatRequest>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (axum::http::StatusCode, Json<Value>)>
{
let auth_header = headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
let token = auth_header
.strip_prefix("Bearer ")
.or_else(|| auth_header.strip_prefix("bearer "))
.unwrap_or_default();
if token != state.config.auth.auth_token {
if !authorize(&headers, &state) {
return Err((
axum::http::StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Unauthorized" })),
));
}
let messages = body
.get("messages")
.and_then(Value::as_array)
.ok_or_else(|| {
(
axum::http::StatusCode::BAD_REQUEST,
Json(json!({ "error": "messages is required" })),
)
})?;
for message in messages {
if message.get("role").and_then(Value::as_str).is_none()
|| message.get("content").and_then(Value::as_str).is_none()
{
return Err((
axum::http::StatusCode::BAD_REQUEST,
Json(json!({ "error": "messages is required" })),
));
}
if body.messages.is_empty()
|| body
.messages
.iter()
.any(|message| message.role.trim().is_empty() || message.content.trim().is_empty())
{
return Err((
axum::http::StatusCode::BAD_REQUEST,
Json(json!({ "error": "messages is required" })),
));
}
let messages = messages.clone();
let messages: Vec<Value> = body
.messages
.into_iter()
.map(|message| {
json!({
"role": message.role,
"content": message.content,
})
})
.collect();
let (api_base, api_key) = upstream_config(&state);
let agent_model = state.config.comfyui.agent_model.clone();
let (tx, rx) = unbounded_channel::<Result<Event, Infallible>>();
tokio::spawn(async move {
let upstream_url = format!("{}/chat/completions", upstream_base().trim_end_matches('/'));
let upstream_url = format!("{}/chat/completions", api_base.trim_end_matches('/'));
let mut upstream_messages = Vec::with_capacity(messages.len() + 1);
upstream_messages.push(json!({
@@ -340,9 +403,9 @@ async fn chat_handler(
let response = match client
.post(&upstream_url)
.bearer_auth(upstream_key())
.bearer_auth(&api_key)
.json(&json!({
"model": "gpt-5.5",
"model": agent_model,
"messages": upstream_messages,
"temperature": 0.7,
"stream": true,
@@ -516,7 +579,7 @@ async fn chat_handler(
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
match call_image_gen(prompt).await {
match call_image_gen(&api_base, &api_key, prompt).await {
Ok(url) if !url.is_empty() => {
send_sse(
&tx,
@@ -543,11 +606,14 @@ async fn chat_handler(
// Generate images for each scene
for scene in &sections.scenes {
info!("comfyui-agent: generating scene image for scene {}", scene.id);
info!(
"comfyui-agent: generating scene image for scene {}",
scene.id
);
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
match call_image_gen(&scene.prompt).await {
match call_image_gen(&api_base, &api_key, &scene.prompt).await {
Ok(url) if !url.is_empty() => {
send_sse(
&tx,
@@ -586,11 +652,12 @@ async fn chat_handler(
_ => "alloy",
};
match call_tts(&tts.text, voice).await {
match call_tts(&api_base, &api_key, &tts.text, voice).await {
Ok(audio_bytes) => {
// For SSE, we'll send a data URL for small audio, or a placeholder
// Since SSE can't easily send binary, we store to file and send URL
let audio_path = format!("/tmp/comfyui_tts_{}_{}.mp3",
let audio_path = format!(
"/tmp/comfyui_tts_{}_{}.mp3",
tts.character.replace(' ', "_"),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
+2 -6
View File
@@ -1,13 +1,9 @@
// Events routes — system event notifications
use crate::{
db::models::event::Event,
error::{AppError, AppResult},
AppState,
};
use crate::{db::models::event::Event, error::AppResult, AppState};
use axum::{
extract::{Path, Query, State},
routing::{delete, get, post},
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
+17 -22
View File
@@ -1,8 +1,8 @@
use crate::AppState;
use axum::{
body::Body,
http::{Request, StatusCode, Uri},
response::{Html, Response},
http::{Request, StatusCode},
response::Response,
routing::get,
Router,
};
@@ -31,8 +31,7 @@ mod user_api_keys;
mod video_agent;
pub fn build_router(state: AppState) -> Router {
let web_dir = std::env::var("BOOSAPI_WEB_DIR")
.unwrap_or_else(|_| "dist/web".to_string());
let web_dir = std::env::var("BOOSAPI_WEB_DIR").unwrap_or_else(|_| "dist/web".to_string());
// Build the API router
let api_routes = Router::new()
@@ -82,24 +81,20 @@ pub fn build_router(state: AppState) -> Router {
// SPA fallback: serve index.html for client-side routing
let index_path = format!("{}/index.html", web_dir);
match tokio::fs::read_to_string(&index_path).await {
Ok(html) => {
Ok::<_, Infallible>(
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "text/html; charset=utf-8")
.body(Body::from(html))
.unwrap(),
)
}
Err(_) => {
Ok::<_, Infallible>(
Response::builder()
.status(StatusCode::NOT_FOUND)
.header("Content-Type", "text/plain; charset=utf-8")
.body(Body::from(format!("404: {} not found", path)))
.unwrap(),
)
}
Ok(html) => Ok::<_, Infallible>(
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "text/html; charset=utf-8")
.body(Body::from(html))
.unwrap(),
),
Err(_) => Ok::<_, Infallible>(
Response::builder()
.status(StatusCode::NOT_FOUND)
.header("Content-Type", "text/plain; charset=utf-8")
.body(Body::from(format!("404: {} not found", path)))
.unwrap(),
),
}
}
}));
+6 -7
View File
@@ -6,13 +6,12 @@ use crate::{
};
use axum::{
extract::{Path, State},
http::{header, HeaderMap, StatusCode},
http::{header, HeaderMap},
response::Response,
routing::{get, post, put},
routing::{get, post},
Json, Router,
};
use serde_json::{json, Value};
use tracing::info;
const MONITOR_AUTH_COOKIE: &str = "meta_monitor_auth";
const LDOH_BASE_URL: &str = "https://ldoh.105117.xyz";
@@ -220,20 +219,20 @@ async fn ldoh_proxy_handler(
.text()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
return Ok(builder
return builder
.header(header::CONTENT_TYPE, &content_type)
.body(axum::body::Body::from(rewrite_proxy_text(&text)))
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?);
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")));
}
let bytes = resp
.bytes()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
Ok(builder
builder
.header(header::CONTENT_TYPE, &content_type)
.body(axum::body::Body::from(bytes))
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?)
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))
}
// ── Helpers ────────────────────────────────────────────────────────────
+1 -1
View File
@@ -280,7 +280,7 @@ async fn delete_route_unit(
// ── Callback ───────────────────────────────────────────────────────────
/// GET /api/oauth/callback — public, returns HTML for popup window
async fn oauth_callback_page(headers: HeaderMap) -> impl IntoResponse {
async fn oauth_callback_page(_headers: HeaderMap) -> impl IntoResponse {
info!("OAuth callback received");
let html = r#"<!doctype html>
<html lang="zh-CN">
+2 -3
View File
@@ -14,7 +14,6 @@ use axum::{
};
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::info;
// ── Auth helper ────────────────────────────────────────────────────────
@@ -465,7 +464,7 @@ async fn get_invitation(
match invitation {
Some(i) => {
// Check expiry
let expired = i.expires_at.as_ref().map_or(false, |e| {
let expired = i.expires_at.as_ref().is_some_and(|e| {
chrono::NaiveDateTime::parse_from_str(e.trim(), "%Y-%m-%d %H:%M:%S")
.map(|dt| dt.and_utc().timestamp() < chrono::Utc::now().timestamp())
.unwrap_or(false)
@@ -525,7 +524,7 @@ async fn accept_invitation(
};
// Check expiry
let expired = inv.expires_at.as_ref().map_or(true, |e| {
let expired = inv.expires_at.as_ref().is_none_or(|e| {
chrono::NaiveDateTime::parse_from_str(e.trim(), "%Y-%m-%d %H:%M:%S")
.map(|dt| dt.and_utc().timestamp() < chrono::Utc::now().timestamp())
.unwrap_or(true)
+6 -3
View File
@@ -29,6 +29,7 @@ use tracing::info;
// ── Auth types ────────────────────────────────────────────────────────
#[allow(dead_code)]
#[derive(Clone)]
pub struct ProxyAuth {
pub token: String,
@@ -36,6 +37,7 @@ pub struct ProxyAuth {
pub source: DownstreamSource,
}
#[allow(dead_code)]
#[derive(Clone, PartialEq)]
pub enum DownstreamSource {
Managed { key_id: i64 },
@@ -279,12 +281,12 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.and_then(|v| v.trim().split_whitespace().next())
.and_then(|v| v.split_whitespace().next())
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
let result = authorize_downstream_token(auth, &state.db)
let result = authorize_downstream_token(auth, &state.db, Some(&state.config.auth.proxy_token))
.await
.map_err(|e| AppError::Unauthorized(e))?;
.map_err(AppError::Unauthorized)?;
match result {
DownstreamAuthResult::Managed { key, .. } => {
@@ -646,6 +648,7 @@ mod tests {
max_retries: 3,
proxy_log_retention_days: 7,
},
comfyui: Default::default(),
},
pool,
)
+8 -7
View File
@@ -5,7 +5,8 @@ use axum::{extract::State, routing::post, Json, Router};
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
use tracing::info;
type ModelAggregate = HashMap<String, (HashSet<i64>, HashSet<i64>, HashSet<i64>)>;
#[derive(Debug, Deserialize)]
pub struct SearchBody {
@@ -23,7 +24,7 @@ async fn search_handler(
Json(body): Json<SearchBody>,
) -> AppResult<Json<Value>> {
let query = body.query.trim().to_string();
let limit = body.limit.unwrap_or(20).max(1).min(100);
let limit = body.limit.unwrap_or(20).clamp(1, 100);
if query.is_empty() {
return Ok(Json(json!({
@@ -33,7 +34,7 @@ async fn search_handler(
}
let q = format!("%{}%", query);
let per_category = (limit / 6).max(1).min(10);
let per_category = (limit / 6).clamp(1, 10);
// 1. Search sites
let sites: Vec<Value> = sqlx::query_as::<_, (i64, String, String, String)>(
@@ -68,7 +69,7 @@ async fn search_handler(
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|(id, username, status, extra_config, site_id, site_name, platform, access_token)| {
.map(|(id, username, status, _extra_config, site_id, site_name, platform, access_token)| {
let segment = if access_token.as_deref().unwrap_or("").trim().is_empty() { "apikey" } else { "session" };
json!({
"id": id, "username": username, "status": status,
@@ -133,7 +134,7 @@ async fn search_handler(
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|(id, model, status, http_status, created_at, acct_id, username)| {
.map(|(id, model, status, http_status, created_at, _acct_id, _username)| {
json!({"id": id, "modelRequested": model, "status": status, "httpStatus": http_status, "createdAt": created_at})
})
.collect();
@@ -167,7 +168,7 @@ async fn search_handler(
.await?;
// Aggregate models
let mut model_agg: HashMap<String, (HashSet<i64>, HashSet<i64>, HashSet<i64>)> = HashMap::new();
let mut model_agg: ModelAggregate = HashMap::new();
for (model_name, _token_id, account_id, site_id) in token_model_rows {
let entry = model_agg.entry(model_name).or_default();
entry.0.insert(account_id);
@@ -198,7 +199,7 @@ async fn search_handler(
a["name"]
.as_str()
.unwrap_or("")
.cmp(&b["name"].as_str().unwrap_or(""))
.cmp(b["name"].as_str().unwrap_or(""))
})
});
models.truncate(per_category as usize);
+5 -13
View File
@@ -1,13 +1,9 @@
// Site Announcements routes — upstream provider notifications per site
use crate::{
db::models::site_announcement::SiteAnnouncement,
error::{AppError, AppResult},
AppState,
};
use crate::{db::models::site_announcement::SiteAnnouncement, error::AppResult, AppState};
use axum::{
extract::{Path, Query, State},
routing::{delete, get, post},
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
@@ -81,7 +77,7 @@ async fn list_announcements(
// Apply status filter in-memory (matches Node.js behaviour)
let filtered = if let Some(ref status) = q.status {
let now = chrono::Utc::now().timestamp_millis() as i64;
let now = chrono::Utc::now().timestamp_millis();
let status = status.trim().to_lowercase();
announcements
.into_iter()
@@ -90,14 +86,10 @@ async fn list_announcements(
a.dismissed_at.is_some()
} else if status == "expired" {
a.dismissed_at.is_none()
&& a.ends_at
.as_ref()
.map_or(false, |e| parse_dt_millis(e) < now)
&& a.ends_at.as_ref().is_some_and(|e| parse_dt_millis(e) < now)
} else if status == "active" {
a.dismissed_at.is_none()
&& a.ends_at
.as_ref()
.map_or(true, |e| parse_dt_millis(e) >= now)
&& a.ends_at.as_ref().is_none_or(|e| parse_dt_millis(e) >= now)
} else {
true
}
+217 -2
View File
@@ -19,13 +19,190 @@ pub struct ModelBySiteQuery {
#[derive(Debug, Deserialize)]
pub struct SiteDistributionQuery {
days: Option<i64>,
refresh: Option<String>,
#[serde(rename = "refresh")]
_refresh: Option<String>,
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/stats/dashboard", get(dashboard))
.route("/api/stats/proxy-logs", get(proxy_logs))
.route("/api/stats/proxy-logs/{id}", get(proxy_log_detail))
.route("/api/stats/proxy-debug/traces", get(proxy_debug_traces))
.route(
"/api/stats/proxy-debug/traces/{id}",
get(proxy_debug_trace_detail),
)
.route("/api/stats/model-by-site", get(model_by_site))
.route("/api/stats/site-distribution", get(site_distribution))
.route("/api/stats/site-trend", get(site_trend))
}
/// GET /api/stats/dashboard
async fn dashboard(
State(state): State<AppState>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> AppResult<Json<Value>> {
let sites: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sites")
.fetch_one(&*state.db)
.await?;
let accounts: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM accounts")
.fetch_one(&*state.db)
.await?;
let downstream_keys: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM downstream_api_keys")
.fetch_one(&*state.db)
.await?;
let requests_24h: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM proxy_logs WHERE created_at >= datetime('now', '-1 day')",
)
.fetch_one(&*state.db)
.await?;
let success_24h: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM proxy_logs WHERE status = 'success' AND created_at >= datetime('now', '-1 day')",
)
.fetch_one(&*state.db)
.await?;
let total_cost_24h: f64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(estimated_cost), 0.0) FROM proxy_logs WHERE created_at >= datetime('now', '-1 day')",
)
.fetch_one(&*state.db)
.await?;
let payload = json!({
"generatedAt": chrono::Utc::now().to_rfc3339(),
"summary": {
"sites": sites,
"accounts": accounts,
"downstreamKeys": downstream_keys,
"requests24h": requests_24h,
"success24h": success_24h,
"successRate24h": if requests_24h > 0 { success_24h as f64 / requests_24h as f64 } else { 0.0 },
"cost24h": total_cost_24h,
},
"insights": [],
"stats": {
"totalSites": sites,
"totalAccounts": accounts,
"totalRequests": requests_24h,
"successRequests": success_24h,
"totalCost": total_cost_24h,
},
});
match q.get("view").map(String::as_str) {
Some("summary") => Ok(Json(json!({
"generatedAt": payload["generatedAt"],
"summary": payload["summary"],
"stats": payload["stats"],
}))),
Some("insights") => Ok(Json(json!({
"generatedAt": payload["generatedAt"],
"insights": payload["insights"],
}))),
_ => Ok(Json(payload)),
}
}
/// GET /api/stats/proxy-logs
async fn proxy_logs(
State(state): State<AppState>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> AppResult<Json<Value>> {
let limit = q
.get("limit")
.or_else(|| q.get("pageSize"))
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(50)
.clamp(1, 500);
let offset = q
.get("offset")
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(0)
.max(0);
let items: Vec<Value> = sqlx::query_as::<_, ProxyLogRow>(
"SELECT id, route_id, channel_id, account_id, downstream_api_key_id, model_requested, model_actual, status, http_status, latency_ms, first_byte_latency_ms, prompt_tokens, completion_tokens, total_tokens, estimated_cost, error_message, retry_count, is_stream, client_family, client_app_id, client_app_name, client_confidence, created_at FROM proxy_logs ORDER BY created_at DESC LIMIT ? OFFSET ?",
)
.bind(limit)
.bind(offset)
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|row| serde_json::to_value(row).unwrap_or_else(|_| json!({})))
.collect();
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM proxy_logs")
.fetch_one(&*state.db)
.await?;
let success: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM proxy_logs WHERE status = 'success'")
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({
"items": items,
"data": items,
"total": total,
"page": (offset / limit) + 1,
"pageSize": limit,
"summary": {
"total": total,
"success": success,
"failed": total - success,
},
"clientOptions": [],
"sites": [],
})))
}
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
struct ProxyLogRow {
id: i64,
route_id: Option<i64>,
channel_id: Option<i64>,
account_id: Option<i64>,
downstream_api_key_id: Option<i64>,
model_requested: Option<String>,
model_actual: Option<String>,
status: Option<String>,
http_status: Option<i64>,
latency_ms: Option<i64>,
first_byte_latency_ms: Option<i64>,
prompt_tokens: Option<i64>,
completion_tokens: Option<i64>,
total_tokens: Option<i64>,
estimated_cost: Option<f64>,
error_message: Option<String>,
retry_count: Option<i64>,
is_stream: Option<bool>,
client_family: Option<String>,
client_app_id: Option<String>,
client_app_name: Option<String>,
client_confidence: Option<String>,
created_at: Option<String>,
}
async fn proxy_log_detail(
State(state): State<AppState>,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> AppResult<Json<Value>> {
let item: Option<ProxyLogRow> = sqlx::query_as(
"SELECT id, route_id, channel_id, account_id, downstream_api_key_id, model_requested, model_actual, status, http_status, latency_ms, first_byte_latency_ms, prompt_tokens, completion_tokens, total_tokens, estimated_cost, error_message, retry_count, is_stream, client_family, client_app_id, client_app_name, client_confidence, created_at FROM proxy_logs WHERE id = ?",
)
.bind(id)
.fetch_optional(&*state.db)
.await?;
Ok(Json(json!({ "item": item, "data": item })))
}
async fn proxy_debug_traces() -> Json<Value> {
Json(json!({ "items": [], "data": [], "total": 0 }))
}
async fn proxy_debug_trace_detail(
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Json<Value> {
Json(json!({ "id": id, "events": [], "data": null }))
}
/// GET /api/stats/model-by-site?siteId=...&days=...
@@ -117,7 +294,7 @@ async fn site_distribution(
// Sum per-site usage
let rows: Vec<(i64, String, i64, f64, i64)> = sqlx::query_as(
"SELECT s.id, s.name, COALESCE(SUM(m.total_calls), 0), COALESCE(SUM(m.total_spend), 0), COALESCE(SUM(m.total_tokens), 0)
"SELECT s.id, s.name, COALESCE(SUM(m.total_calls), 0), COALESCE(SUM(m.total_spend), 0.0), COALESCE(SUM(m.total_tokens), 0)
FROM sites s
LEFT JOIN model_day_usage m ON m.site_id = s.id AND m.local_day >= ?
GROUP BY s.id, s.name
@@ -142,3 +319,41 @@ async fn site_distribution(
Ok(Json(json!({ "distribution": distribution })))
}
/// GET /api/stats/site-trend?days=...
async fn site_trend(
State(state): State<AppState>,
Query(q): Query<SiteDistributionQuery>,
) -> AppResult<Json<Value>> {
let days = q.days.unwrap_or(7).max(1);
let since_day = chrono::Utc::now()
.checked_sub_signed(chrono::Duration::days(days - 1))
.unwrap()
.format("%Y-%m-%d")
.to_string();
let rows: Vec<(String, i64, f64, i64)> = sqlx::query_as(
"SELECT local_day, COALESCE(SUM(total_calls), 0), COALESCE(SUM(total_spend), 0.0), COALESCE(SUM(total_tokens), 0)
FROM model_day_usage
WHERE local_day >= ?
GROUP BY local_day
ORDER BY local_day ASC",
)
.bind(&since_day)
.fetch_all(&*state.db)
.await?;
let trend: Vec<Value> = rows
.into_iter()
.map(|(day, calls, spend, tokens)| {
json!({
"date": day,
"calls": calls,
"spend": spend,
"tokens": tokens,
})
})
.collect();
Ok(Json(json!({ "trend": trend })))
}
+2 -3
View File
@@ -12,10 +12,9 @@ use axum::{
routing::get,
Json, Router,
};
use futures::stream::{self, Stream};
use futures::stream::Stream;
use serde::Deserialize;
use serde_json::{json, Value};
use tokio_stream::StreamExt;
use tracing::info;
#[derive(Debug, Deserialize)]
@@ -38,7 +37,7 @@ async fn list_tasks(
State(state): State<AppState>,
Query(q): Query<ListTasksQuery>,
) -> AppResult<Json<Value>> {
let limit = q.limit.unwrap_or(50).max(1).min(500);
let limit = q.limit.unwrap_or(50).clamp(1, 500);
info!("Listing background tasks, limit={}", limit);
let tasks: Vec<BackgroundTask> = sqlx::query_as::<_, BackgroundTask>(
+2
View File
@@ -1,5 +1,7 @@
// Update Center routes — deploy/rollback orchestration via helper API
#![allow(clippy::type_complexity)]
use crate::{
error::{AppError, AppResult},
AppState,
+1 -2
View File
@@ -10,10 +10,9 @@ use crate::{
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
routing::{delete, get, patch, post},
routing::{get, patch},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::info;
+137 -31
View File
@@ -2,11 +2,16 @@ use axum::{
body::Body,
extract::State,
http::{header, HeaderMap, Response, StatusCode},
response::IntoResponse,
routing::post,
response::{
sse::{Event, Sse},
IntoResponse,
},
routing::{get, post},
Json, Router,
};
use futures::stream;
use serde_json::{json, Value};
use std::convert::Infallible;
use tracing::info;
use crate::{
@@ -66,6 +71,11 @@ const SCENE_PROMPT_GEN: &str = r#"根据场景描述生成图像生成提示词
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/video-agent/status", get(status))
.route("/api/video-agent/start", post(start_comfyui))
.route("/api/video-agent/stop", post(stop_comfyui))
.route("/api/video-agent/restart", post(restart_comfyui))
.route("/api/video-agent/chat", post(chat))
.route("/api/video-agent/analyze", post(analyze))
.route(
"/api/video-agent/character-prompts",
@@ -81,15 +91,100 @@ pub fn routes() -> Router<AppState> {
.route("/api/video-agent/full-workflow", post(full_workflow))
}
fn upstream_base() -> String {
std::env::var("BOOSAPI_UPSTREAM_BASE")
.unwrap_or_else(|_| "http://127.0.0.1:4000/v1".to_string())
.trim_end_matches('/')
.to_string()
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
Ok(Json(
serde_json::to_value(state.comfyui.status_response().await)
.unwrap_or_else(|_| json!({ "status": "unknown" })),
))
}
fn upstream_key() -> String {
std::env::var("BOOSAPI_UPSTREAM_KEY").unwrap_or_else(|_| "sk-boos4721".to_string())
async fn start_comfyui(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
state
.comfyui
.start()
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
Ok(Json(json!({
"success": true,
"comfyui": state.comfyui.status_response().await
})))
}
async fn stop_comfyui(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
state
.comfyui
.stop()
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
Ok(Json(json!({
"success": true,
"comfyui": state.comfyui.status_response().await
})))
}
async fn restart_comfyui(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
state
.comfyui
.restart()
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
Ok(Json(json!({
"success": true,
"comfyui": state.comfyui.status_response().await
})))
}
async fn chat(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Sse<impl futures::Stream<Item = Result<Event, Infallible>>>> {
check_auth(&headers, &state)?;
let prompt = body
.get("message")
.and_then(Value::as_str)
.or_else(|| {
body.get("messages")
.and_then(Value::as_array)
.and_then(|messages| messages.last())
.and_then(|message| message.get("content"))
.and_then(Value::as_str)
})
.unwrap_or("生成一个短视频工作流");
let workflow = build_basic_workflow(prompt);
let events = vec![
json!({
"type": "text",
"content": "已收到需求,正在生成 ComfyUI workflow..."
}),
json!({
"type": "workflow_generated",
"workflow": workflow
}),
json!({
"type": "done"
}),
];
Ok(Sse::new(stream::iter(events.into_iter().map(|event| {
Ok(Event::default().data(event.to_string()))
}))))
}
fn local_api_base(state: &AppState) -> String {
state.comfyui.model_backend_url()
}
fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
@@ -107,6 +202,17 @@ fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
Ok(())
}
fn build_basic_workflow(prompt: &str) -> Value {
json!({
"boosapi": {
"kind": "video-agent-workflow",
"version": 1,
"prompt": prompt,
},
"nodes": {},
})
}
fn required_str<'a>(body: &'a Value, key: &str) -> AppResult<&'a str> {
body.get(key)
.and_then(Value::as_str)
@@ -147,17 +253,17 @@ fn parse_llm_json(raw: &str) -> AppResult<Value> {
})
}
async fn call_llm(prompt: &str, system: &str) -> AppResult<String> {
async fn call_llm(state: &AppState, prompt: &str, system: &str) -> AppResult<String> {
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", upstream_base());
let url = format!("{}/chat/completions", local_api_base(state));
info!("video-agent calling LLM upstream: {}", url);
info!("video-agent calling BoosAPI LLM endpoint: {}", url);
let resp = client
.post(url)
.bearer_auth(upstream_key())
.bearer_auth(&state.config.auth.proxy_token)
.json(&json!({
"model": "gpt-5.5",
"model": &state.config.comfyui.agent_model,
"messages": [
{ "role": "system", "content": system },
{ "role": "user", "content": prompt }
@@ -193,15 +299,15 @@ async fn call_llm(prompt: &str, system: &str) -> AppResult<String> {
.to_string())
}
async fn call_image_gen(prompt: &str) -> AppResult<String> {
async fn call_image_gen(state: &AppState, prompt: &str) -> AppResult<String> {
let client = reqwest::Client::new();
let url = format!("{}/images/generations", upstream_base());
let url = format!("{}/images/generations", local_api_base(state));
info!("video-agent calling image upstream: {}", url);
info!("video-agent calling BoosAPI image endpoint: {}", url);
let resp = client
.post(url)
.bearer_auth(upstream_key())
.bearer_auth(&state.config.auth.proxy_token)
.json(&json!({
"model": "gpt-image-2",
"prompt": prompt,
@@ -236,15 +342,15 @@ async fn call_image_gen(prompt: &str) -> AppResult<String> {
.to_string())
}
async fn call_tts(text: &str, voice: &str) -> AppResult<Vec<u8>> {
async fn call_tts(state: &AppState, text: &str, voice: &str) -> AppResult<Vec<u8>> {
let client = reqwest::Client::new();
let url = format!("{}/audio/speech", upstream_base());
let url = format!("{}/audio/speech", local_api_base(state));
info!("video-agent calling TTS upstream: {}", url);
info!("video-agent calling BoosAPI TTS endpoint: {}", url);
let resp = client
.post(url)
.bearer_auth(upstream_key())
.bearer_auth(&state.config.auth.proxy_token)
.json(&json!({
"model": "tts-1",
"input": text,
@@ -282,7 +388,7 @@ async fn analyze(
let script = required_str(&body, "script")?;
let result = call_llm(script, SCRIPT_ANALYSIS_PROMPT).await?;
let result = call_llm(&state, script, SCRIPT_ANALYSIS_PROMPT).await?;
let parsed = parse_llm_json(&result)?;
Ok(Json(json!({
@@ -318,7 +424,7 @@ async fn character_prompts(
.unwrap_or_default();
let desc = format!("{name}: {appearance}, {gender}, {age}");
let result = call_llm(&desc, CHARACTER_PROMPT_GEN).await?;
let result = call_llm(&state, &desc, CHARACTER_PROMPT_GEN).await?;
let prompts = parse_llm_json(&result)?;
Ok(Json(json!({
@@ -346,7 +452,7 @@ async fn generate_character(
AppError::BadRequest(format!("prompt for angle '{angle}' must be a string"))
})?;
let url = call_image_gen(prompt).await?;
let url = call_image_gen(&state, prompt).await?;
results.insert(angle.clone(), Value::String(url));
}
@@ -375,7 +481,7 @@ async fn scene_prompt(
.unwrap_or_default();
let desc = format!("{environment}. {description}");
let result = call_llm(&desc, SCENE_PROMPT_GEN).await?;
let result = call_llm(&state, &desc, SCENE_PROMPT_GEN).await?;
let prompt = parse_llm_json(&result)?;
Ok(Json(json!({
@@ -392,7 +498,7 @@ async fn generate_scene(
check_auth(&headers, &state)?;
let prompt = required_str(&body, "prompt")?;
let url = call_image_gen(prompt).await?;
let url = call_image_gen(&state, prompt).await?;
Ok(Json(json!({
"success": true,
@@ -416,7 +522,7 @@ async fn generate_tts(
.filter(|s| !s.trim().is_empty())
.unwrap_or("alloy");
let audio = call_tts(text, voice).await?;
let audio = call_tts(&state, text, voice).await?;
Ok((
StatusCode::OK,
@@ -436,7 +542,7 @@ async fn full_workflow(
let script = required_str(&body, "script")?;
let requested_style = body.get("style").and_then(Value::as_str);
let analysis_raw = call_llm(script, SCRIPT_ANALYSIS_PROMPT).await?;
let analysis_raw = call_llm(&state, script, SCRIPT_ANALYSIS_PROMPT).await?;
let analysis = parse_llm_json(&analysis_raw)?;
let mut characters = Vec::new();
@@ -461,7 +567,7 @@ async fn full_workflow(
.unwrap_or_default();
let desc = format!("{name}: {appearance}, {gender}, {age}");
let prompts_raw = call_llm(&desc, CHARACTER_PROMPT_GEN).await?;
let prompts_raw = call_llm(&state, &desc, CHARACTER_PROMPT_GEN).await?;
let prompts = parse_llm_json(&prompts_raw)?;
let mut item = character.clone();
@@ -487,7 +593,7 @@ async fn full_workflow(
.unwrap_or_default();
let desc = format!("{environment}. {description}");
let scene_prompt_raw = call_llm(&desc, SCENE_PROMPT_GEN).await?;
let scene_prompt_raw = call_llm(&state, &desc, SCENE_PROMPT_GEN).await?;
let image_prompt = parse_llm_json(&scene_prompt_raw)?;
let mut item = scene.clone();
+262
View File
@@ -0,0 +1,262 @@
use crate::config::ComfyUIConfig;
use serde::Serialize;
use std::{path::Path, sync::Arc, time::Instant};
use tokio::{
process::{Child, Command},
sync::{Mutex, RwLock},
};
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum ComfyUIStatus {
Disabled,
Starting,
Running { pid: Option<u32>, uptime_secs: u64 },
Unhealthy { last_error: String },
Stopped,
}
#[derive(Debug, Clone, Serialize)]
pub struct ComfyUIStatusResponse {
pub status: String,
pub pid: Option<u32>,
pub uptime_secs: u64,
pub mode: String,
pub model_backend: String,
pub base_url: String,
pub version: Option<String>,
pub last_error: Option<String>,
}
pub struct ComfyUIProcess {
config: ComfyUIConfig,
child: Mutex<Option<Child>>,
status: RwLock<ComfyUIStatus>,
started_at: Mutex<Option<Instant>>,
boosapi_url: String,
proxy_token: String,
}
impl ComfyUIProcess {
pub fn new(config: ComfyUIConfig, boosapi_url: String, proxy_token: String) -> Arc<Self> {
let initial_status = if config.enabled {
ComfyUIStatus::Stopped
} else {
ComfyUIStatus::Disabled
};
Arc::new(Self {
config,
child: Mutex::new(None),
status: RwLock::new(initial_status),
started_at: Mutex::new(None),
boosapi_url,
proxy_token,
})
}
pub fn config(&self) -> &ComfyUIConfig {
&self.config
}
pub fn internal_base_url(&self) -> String {
if self.config.enabled {
format!("http://127.0.0.1:{}", self.config.port)
} else {
self.config.target_base_url()
}
}
pub fn model_backend_url(&self) -> String {
format!("{}/v1", self.boosapi_url.trim_end_matches('/'))
}
pub async fn status(&self) -> ComfyUIStatus {
let status = self.status.read().await.clone();
match status {
ComfyUIStatus::Running { pid, .. } => ComfyUIStatus::Running {
pid,
uptime_secs: self.uptime_secs().await,
},
other => other,
}
}
pub async fn status_response(&self) -> ComfyUIStatusResponse {
let status = self.status().await;
let base_url = self.internal_base_url();
let mode = if self.config.enabled {
"embedded"
} else {
"external"
}
.to_string();
match status {
ComfyUIStatus::Disabled => ComfyUIStatusResponse {
status: "disabled".to_string(),
pid: None,
uptime_secs: 0,
mode,
model_backend: self.model_backend_url(),
base_url,
version: None,
last_error: None,
},
ComfyUIStatus::Starting => ComfyUIStatusResponse {
status: "starting".to_string(),
pid: None,
uptime_secs: 0,
mode,
model_backend: self.model_backend_url(),
base_url,
version: None,
last_error: None,
},
ComfyUIStatus::Running { pid, uptime_secs } => ComfyUIStatusResponse {
status: "running".to_string(),
pid,
uptime_secs,
mode,
model_backend: self.model_backend_url(),
base_url,
version: None,
last_error: None,
},
ComfyUIStatus::Unhealthy { last_error } => ComfyUIStatusResponse {
status: "unhealthy".to_string(),
pid: None,
uptime_secs: self.uptime_secs().await,
mode,
model_backend: self.model_backend_url(),
base_url,
version: None,
last_error: Some(last_error),
},
ComfyUIStatus::Stopped => ComfyUIStatusResponse {
status: "stopped".to_string(),
pid: None,
uptime_secs: 0,
mode,
model_backend: self.model_backend_url(),
base_url,
version: None,
last_error: None,
},
}
}
pub async fn start(&self) -> anyhow::Result<()> {
if !self.config.enabled {
*self.status.write().await = ComfyUIStatus::Disabled;
return Ok(());
}
if self.child.lock().await.is_some() {
return Ok(());
}
*self.status.write().await = ComfyUIStatus::Starting;
let install_dir = self
.config
.install_dir
.as_ref()
.ok_or_else(|| anyhow::anyhow!("BOOSAPI_COMFYUI__INSTALL_DIR is required"))?;
let main_py = install_dir.join("main.py");
if !main_py.exists() {
let error = format!("ComfyUI main.py not found at {}", main_py.display());
*self.status.write().await = ComfyUIStatus::Unhealthy {
last_error: error.clone(),
};
anyhow::bail!(error);
}
write_boosapi_config(install_dir, &self.model_backend_url(), &self.proxy_token)?;
let mut command = Command::new(&self.config.python_bin);
command
.arg("main.py")
.arg("--listen")
.arg("127.0.0.1")
.arg("--port")
.arg(self.config.port.to_string())
.current_dir(install_dir);
for arg in &self.config.extra_args {
if !arg.trim().is_empty() {
command.arg(arg);
}
}
let child = command.spawn()?;
let pid = child.id();
*self.child.lock().await = Some(child);
*self.started_at.lock().await = Some(Instant::now());
*self.status.write().await = ComfyUIStatus::Running {
pid,
uptime_secs: 0,
};
Ok(())
}
pub async fn stop(&self) -> anyhow::Result<()> {
let mut child_slot = self.child.lock().await;
if let Some(child) = child_slot.as_mut() {
if let Err(err) = child.start_kill() {
*self.status.write().await = ComfyUIStatus::Unhealthy {
last_error: err.to_string(),
};
return Err(err.into());
}
let _ = child.wait().await;
}
*child_slot = None;
*self.started_at.lock().await = None;
*self.status.write().await = if self.config.enabled {
ComfyUIStatus::Stopped
} else {
ComfyUIStatus::Disabled
};
Ok(())
}
pub async fn restart(&self) -> anyhow::Result<()> {
self.stop().await?;
self.start().await
}
pub async fn health_check(&self) -> bool {
let url = format!("{}/system_stats", self.internal_base_url());
match reqwest::Client::new().get(url).send().await {
Ok(response) => response.status().is_success(),
Err(_) => false,
}
}
async fn uptime_secs(&self) -> u64 {
self.started_at
.lock()
.await
.map(|started| started.elapsed().as_secs())
.unwrap_or(0)
}
}
fn write_boosapi_config(
install_dir: &Path,
api_base: &str,
proxy_token: &str,
) -> anyhow::Result<()> {
let config = format!(
"boosapi:\n api_base: \"{}\"\n api_key: \"{}\"\n",
api_base.trim_end_matches('/'),
proxy_token
);
std::fs::write(install_dir.join("boosapi_config.yaml"), config)?;
Ok(())
}
+1
View File
@@ -1,5 +1,6 @@
pub mod announcement;
pub mod billing;
pub mod comfyui_process;
pub mod cron;
pub mod notification;
pub mod oauth;
+9
View File
@@ -0,0 +1,9 @@
// BoosAPI custom headers. Do not introduce x-metapi-* headers.
pub const HEADER_SITE_ID: &str = "x-boosapi-site-id";
pub const HEADER_TOKEN_ID: &str = "x-boosapi-token-id";
pub const HEADER_CHANNEL_ID: &str = "x-boosapi-channel-id";
pub const HEADER_REQUEST_ID: &str = "x-boosapi-request-id";
pub const HEADER_UPSTREAM_MODEL: &str = "x-boosapi-upstream-model";
pub const HEADER_TRUNCATED: &str = "x-boosapi-truncated";
pub const HEADER_PROXIED: &str = "x-boosapi-proxied";
+2
View File
@@ -1,10 +1,12 @@
// Shared utilities
pub mod headers;
pub mod json;
pub mod pagination;
pub mod sse;
pub mod time;
pub use headers::*;
pub use json::*;
pub use pagination::*;
pub use sse::*;
+1 -1
View File
@@ -1,6 +1,6 @@
// Canonical response — unified output format for all providers
use crate::transformers::canonical::request::CanonicalMessage;
use crate::transformers::canonical::types::{StreamDelta, ToolCall, Usage};
use crate::transformers::canonical::types::{StreamDelta, Usage};
use serde::{Deserialize, Serialize};
/// Unified internal response format
+2
View File
@@ -1,4 +1,6 @@
// OpenAI chat/completions request → CanonicalRequest parser
#![allow(clippy::bind_instead_of_map, clippy::manual_map)]
use crate::transformers::canonical::{
CanonicalMessage, CanonicalRequest, ContentPart, ReasoningConfig, Role, ToolCall, ToolChoice,
ToolDefinition,
+2 -2
View File
@@ -1,6 +1,6 @@
// CanonicalResponse → OpenAI /v1/chat/completions JSON formatter
use crate::transformers::canonical::{
CanonicalChoice, CanonicalMessage, CanonicalResponse, ContentPart, Role, Usage,
CanonicalChoice, CanonicalMessage, CanonicalResponse, ContentPart, Role,
};
use serde_json::{json, Value};
@@ -115,7 +115,7 @@ fn format_content_parts(parts: &[ContentPart]) -> Value {
#[cfg(test)]
mod tests {
use super::*;
use crate::transformers::canonical::{CanonicalMessage, CanonicalResponse};
use crate::transformers::canonical::CanonicalResponse;
#[test]
fn test_format_simple_response() {
+2
View File
@@ -1,4 +1,6 @@
// OpenAI SSE stream parser and formatter
#![allow(clippy::unnecessary_filter_map)]
use crate::transformers::canonical::{
CanonicalStreamChoice, CanonicalStreamEvent, Role, StreamDelta, Usage,
};
+1 -1
View File
@@ -416,7 +416,7 @@ export const sidebarGroups = [
const topNavItems = [
{ label: '控制台', to: '/' },
{ label: '模型广场', to: '/models' },
{ label: 'ComfyUI', to: '/video-agent' },
{ label: '视频工作台', to: '/video-agent' },
{ label: '视频助手', to: '/comfyui-agent' },
{ label: '关于', to: '/about' },
];
+264 -35
View File
@@ -243,6 +243,184 @@ async function request<T = any>(
return res.json() as Promise<T>;
}
function unwrapArrayResponse(value: any, keys: string[] = ["items", "data"]): any[] {
if (Array.isArray(value)) return value;
for (const key of keys) {
if (Array.isArray(value?.[key])) return value[key];
}
return [];
}
function wrapItemsResponse(value: any): any {
if (Array.isArray(value)) return { success: true, items: value };
if (Array.isArray(value?.items)) return value;
if (Array.isArray(value?.data)) return { ...value, success: value.success ?? true, items: value.data };
return { ...(value || {}), success: value?.success ?? true, items: [] };
}
function parseJsonArrayField(value: any): any[] {
if (Array.isArray(value)) return value;
if (typeof value !== "string" || !value.trim()) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
}
function normalizeRouteRow(row: any): any {
if (!row || typeof row !== "object") return row;
return {
...row,
modelPattern: row.modelPattern ?? row.model_pattern ?? "",
modelMapping: row.modelMapping ?? row.model_mapping ?? null,
displayName: row.displayName ?? row.display_name ?? null,
displayIcon: row.displayIcon ?? row.display_icon ?? null,
routingStrategy: row.routingStrategy ?? row.routing_strategy ?? "weighted",
routeMode: row.routeMode ?? row.route_mode ?? "pattern",
updatedAt: row.updatedAt ?? row.updated_at ?? null,
createdAt: row.createdAt ?? row.created_at ?? null,
};
}
function normalizeDownstreamKeyRow(row: any): any {
if (!row || typeof row !== "object") return row;
return {
...row,
keyMasked: row.keyMasked ?? row.key_masked ?? (row.key ? `${String(row.key).slice(0, 8)}...` : ""),
groupName: row.groupName ?? row.group_name ?? null,
usedCost: Number(row.usedCost ?? row.used_cost ?? 0),
usedRequests: Number(row.usedRequests ?? row.used_requests ?? 0),
maxCost: row.maxCost ?? row.max_cost ?? null,
maxRequests: row.maxRequests ?? row.max_requests ?? null,
supportedModels: row.supportedModels ?? parseJsonArrayField(row.supported_models),
allowedRouteIds: (row.allowedRouteIds ?? parseJsonArrayField(row.allowed_route_ids))
.map((id: any) => Number(id))
.filter((id: number) => Number.isFinite(id)),
expiresAt: row.expiresAt ?? row.expires_at ?? null,
lastUsedAt: row.lastUsedAt ?? row.last_used_at ?? null,
createdAt: row.createdAt ?? row.created_at ?? null,
updatedAt: row.updatedAt ?? row.updated_at ?? null,
};
}
function normalizeProxyLogSummary(summary: any): ProxyLogsSummary {
return {
totalCount: Number(summary?.totalCount ?? summary?.total ?? 0),
successCount: Number(summary?.successCount ?? summary?.success ?? 0),
failedCount: Number(summary?.failedCount ?? summary?.failed ?? 0),
totalCost: Number(summary?.totalCost ?? summary?.cost ?? 0),
totalTokensAll: Number(summary?.totalTokensAll ?? summary?.totalTokens ?? 0),
};
}
function normalizeProxyLogRow(row: any): any {
if (!row || typeof row !== "object") return row;
return {
...row,
routeId: row.routeId ?? row.route_id ?? null,
channelId: row.channelId ?? row.channel_id ?? null,
accountId: row.accountId ?? row.account_id ?? null,
downstreamApiKeyId:
row.downstreamApiKeyId ?? row.downstream_api_key_id ?? null,
modelRequested: row.modelRequested ?? row.model_requested ?? "",
modelActual: row.modelActual ?? row.model_actual ?? null,
httpStatus: row.httpStatus ?? row.http_status ?? null,
latencyMs: row.latencyMs ?? row.latency_ms ?? null,
firstByteLatencyMs:
row.firstByteLatencyMs ?? row.first_byte_latency_ms ?? null,
promptTokens: row.promptTokens ?? row.prompt_tokens ?? null,
completionTokens: row.completionTokens ?? row.completion_tokens ?? null,
totalTokens: row.totalTokens ?? row.total_tokens ?? null,
estimatedCost: Number(row.estimatedCost ?? row.estimated_cost ?? 0),
errorMessage: row.errorMessage ?? row.error_message ?? null,
retryCount: row.retryCount ?? row.retry_count ?? 0,
isStream: row.isStream ?? row.is_stream ?? null,
clientFamily: row.clientFamily ?? row.client_family ?? null,
clientAppId: row.clientAppId ?? row.client_app_id ?? null,
clientAppName: row.clientAppName ?? row.client_app_name ?? null,
clientConfidence: row.clientConfidence ?? row.client_confidence ?? null,
createdAt: row.createdAt ?? row.created_at ?? null,
};
}
function normalizeProxyLogsResponse(response: any): ProxyLogsResponse {
const items = unwrapArrayResponse(response, ["items", "data"]).map(
normalizeProxyLogRow,
);
return {
...(response || {}),
items,
total: Number(response?.total ?? items.length),
page: Number(response?.page ?? 1),
pageSize: Number(response?.pageSize ?? response?.page_size ?? items.length),
clientOptions: Array.isArray(response?.clientOptions)
? response.clientOptions
: [],
summary: normalizeProxyLogSummary(response?.summary),
};
}
function normalizeProxyDebugTraceRow(row: any): ProxyDebugTraceListItem {
return {
...row,
id: Number(row?.id ?? 0),
createdAt: row?.createdAt ?? row?.created_at ?? "",
downstreamPath: row?.downstreamPath ?? row?.downstream_path ?? "",
clientKind: row?.clientKind ?? row?.client_kind ?? null,
sessionId: row?.sessionId ?? row?.session_id ?? null,
requestedModel: row?.requestedModel ?? row?.requested_model ?? null,
selectedChannelId:
row?.selectedChannelId ?? row?.selected_channel_id ?? null,
finalStatus: row?.finalStatus ?? row?.final_status ?? null,
finalHttpStatus: row?.finalHttpStatus ?? row?.final_http_status ?? null,
finalUpstreamPath: row?.finalUpstreamPath ?? row?.final_upstream_path ?? null,
};
}
function normalizeProxyDebugTraceDetail(response: any): ProxyDebugTraceDetail {
const trace = response?.trace ?? response?.data?.trace ?? response ?? {};
const attempts = Array.isArray(response?.attempts)
? response.attempts
: Array.isArray(response?.data?.attempts)
? response.data.attempts
: [];
return {
trace: {
...trace,
id: Number(trace?.id ?? response?.id ?? 0),
createdAt: trace?.createdAt ?? trace?.created_at ?? null,
updatedAt: trace?.updatedAt ?? trace?.updated_at ?? null,
downstreamPath: trace?.downstreamPath ?? trace?.downstream_path ?? null,
clientKind: trace?.clientKind ?? trace?.client_kind ?? null,
sessionId: trace?.sessionId ?? trace?.session_id ?? null,
traceHint: trace?.traceHint ?? trace?.trace_hint ?? null,
requestedModel: trace?.requestedModel ?? trace?.requested_model ?? null,
stickySessionKey:
trace?.stickySessionKey ?? trace?.sticky_session_key ?? null,
stickyHitChannelId:
trace?.stickyHitChannelId ?? trace?.sticky_hit_channel_id ?? null,
selectedChannelId:
trace?.selectedChannelId ?? trace?.selected_channel_id ?? null,
selectedRouteId: trace?.selectedRouteId ?? trace?.selected_route_id ?? null,
selectedAccountId:
trace?.selectedAccountId ?? trace?.selected_account_id ?? null,
selectedSiteId: trace?.selectedSiteId ?? trace?.selected_site_id ?? null,
selectedSitePlatform:
trace?.selectedSitePlatform ?? trace?.selected_site_platform ?? null,
finalStatus: trace?.finalStatus ?? trace?.final_status ?? null,
finalHttpStatus: trace?.finalHttpStatus ?? trace?.final_http_status ?? null,
finalUpstreamPath:
trace?.finalUpstreamPath ?? trace?.final_upstream_path ?? null,
},
attempts,
};
}
async function streamSse(
url: string,
handlers: {
@@ -840,11 +1018,16 @@ export type DownstreamApiKeyTrendResponse = {
export const api = {
// Sites
getSites: () => request("/api/sites"),
getSites: async () => {
const result = await request<any>("/api/sites");
return Array.isArray(result?.data) ? result.data : result;
},
addSite: (data: any) =>
request("/api/sites", { method: "POST", body: JSON.stringify(data) }),
request("/api/sites", { method: "POST", body: JSON.stringify(data) })
.then((result: any) => result?.data ?? result),
updateSite: (id: number, data: any) =>
request(`/api/sites/${id}`, { method: "PUT", body: JSON.stringify(data) }),
request(`/api/sites/${id}`, { method: "PUT", body: JSON.stringify(data) })
.then((result: any) => result?.data ?? result),
deleteSite: (id: number) => request(`/api/sites/${id}`, { method: "DELETE" }),
batchUpdateSites: (data: any) =>
request("/api/sites/batch", { method: "POST", body: JSON.stringify(data) }),
@@ -985,8 +1168,10 @@ export const api = {
triggerCheckinAll: () => request("/api/checkin/trigger", { method: "POST" }),
triggerCheckin: (id: number) =>
request(`/api/checkin/trigger/${id}`, { method: "POST" }),
getCheckinLogs: (params?: string) =>
request(`/api/checkin/logs${params ? "?" + params : ""}`),
getCheckinLogs: async (params?: string) =>
unwrapArrayResponse(
await request(`/api/checkin/logs${params ? "?" + params : ""}`),
),
updateCheckinSchedule: (cron: string) =>
request("/api/checkin/schedule", {
method: "PUT",
@@ -994,11 +1179,28 @@ export const api = {
}),
// Routes
getRoutes: () => request("/api/routes"),
getRoutesLite: () => request("/api/routes/lite"),
getRoutesSummary: () => request("/api/routes/summary"),
getRoutes: async () =>
unwrapArrayResponse(await request("/api/routes"), [
"routes",
"items",
"data",
]).map(normalizeRouteRow),
getRoutesLite: async () =>
unwrapArrayResponse(await request("/api/routes/lite"), [
"routes",
"items",
"data",
]).map(normalizeRouteRow),
getRoutesSummary: async () =>
unwrapArrayResponse(await request("/api/routes/summary"), [
"routes",
"items",
"data",
]).map(normalizeRouteRow),
getRouteChannels: (routeId: number) =>
request(`/api/routes/${routeId}/channels`),
request(`/api/routes/${routeId}/channels`).then((value) =>
unwrapArrayResponse(value, ["channels", "items", "data"]),
),
batchAddChannels: (
routeId: number,
channels: Array<{
@@ -1113,22 +1315,24 @@ export const api = {
...(options?.refresh ? { refresh: 1 } : {}),
})}`,
),
getProxyLogs: (params?: ProxyLogsQuery) =>
request(
`/api/stats/proxy-logs${buildQueryString(params)}`,
) as Promise<ProxyLogsResponse>,
getProxyLogsQuery: (params?: ProxyLogsQuery) =>
request(
`/api/stats/proxy-logs${buildQueryString({
...params,
view: "query",
})}`,
) as Promise<{
getProxyLogs: async (params?: ProxyLogsQuery) =>
normalizeProxyLogsResponse(
await request(`/api/stats/proxy-logs${buildQueryString(params)}`),
),
getProxyLogsQuery: async (params?: ProxyLogsQuery) =>
normalizeProxyLogsResponse(
await request(
`/api/stats/proxy-logs${buildQueryString({
...params,
view: "query",
})}`,
),
) as {
items: ProxyLogsResponse["items"];
total: number;
page: number;
pageSize: number;
}>,
},
getProxyLogsMeta: (
params?: Omit<ProxyLogsQuery, "limit" | "offset"> & {
refresh?: number | boolean;
@@ -1146,8 +1350,14 @@ export const api = {
...(refresh !== undefined ? { refresh } : {}),
} as Record<string, string | number | boolean | null | undefined>;
if (refresh === undefined) delete queryParams.refresh;
return request(
`/api/stats/proxy-logs${buildQueryString(queryParams)}`,
return request(`/api/stats/proxy-logs${buildQueryString(queryParams)}`).then(
(response: any) => ({
clientOptions: Array.isArray(response?.clientOptions)
? response.clientOptions
: [],
summary: normalizeProxyLogSummary(response?.summary),
sites: Array.isArray(response?.sites) ? response.sites : [],
}),
) as Promise<{
clientOptions: ProxyLogsResponse["clientOptions"];
summary: ProxyLogsResponse["summary"];
@@ -1155,14 +1365,20 @@ export const api = {
}>;
},
getProxyLogDetail: (id: number) =>
request(`/api/stats/proxy-logs/${id}`) as Promise<ProxyLogDetail>,
request(`/api/stats/proxy-logs/${id}`).then((response: any) =>
normalizeProxyLogRow(response?.item ?? response?.data ?? response),
) as Promise<ProxyLogDetail>,
getProxyDebugTraces: (params?: { limit?: number }) =>
request(
`/api/stats/proxy-debug/traces${buildQueryString(params)}`,
request(`/api/stats/proxy-debug/traces${buildQueryString(params)}`).then(
(response: any) => ({
items: unwrapArrayResponse(response, ["items", "data"]).map(
normalizeProxyDebugTraceRow,
),
}),
) as Promise<ProxyDebugTracesResponse>,
getProxyDebugTraceDetail: (id: number) =>
request(
`/api/stats/proxy-debug/traces/${id}`,
request(`/api/stats/proxy-debug/traces/${id}`).then(
normalizeProxyDebugTraceDetail,
) as Promise<ProxyDebugTraceDetail>,
checkModels: (accountId: number) =>
request(`/api/models/check/${accountId}`, { method: "POST" }),
@@ -1281,8 +1497,10 @@ export const api = {
}) as Promise<{ success: true }>,
// Events
getEvents: (params?: string) =>
request(`/api/events${params ? "?" + params : ""}`),
getEvents: async (params?: string) =>
unwrapArrayResponse(
await request(`/api/events${params ? "?" + params : ""}`),
),
getEventCount: () => request("/api/events/count"),
markEventRead: (id: number) =>
request(`/api/events/${id}/read`, { method: "POST" }),
@@ -1394,7 +1612,10 @@ export const api = {
body: JSON.stringify(data),
timeoutMs: 120_000,
}),
getDownstreamApiKeys: () => request("/api/downstream-keys"),
getDownstreamApiKeys: async () => {
const response = wrapItemsResponse(await request("/api/downstream-keys"));
return { ...response, items: response.items.map(normalizeDownstreamKeyRow) };
},
createDownstreamApiKey: (data: any) =>
request("/api/downstream-keys", {
method: "POST",
@@ -1425,11 +1646,16 @@ export const api = {
request(`/api/downstream-keys/${id}/reset-usage`, {
method: "POST",
}),
getDownstreamApiKeysSummary: (params?: {
getDownstreamApiKeysSummary: async (params?: {
range?: "24h" | "7d" | "all";
status?: "all" | "enabled" | "disabled";
search?: string;
}) => request(`/api/downstream-keys/summary${buildQueryString(params)}`),
}) => {
const response = wrapItemsResponse(
await request(`/api/downstream-keys/summary${buildQueryString(params)}`),
);
return { ...response, items: response.items.map(normalizeDownstreamKeyRow) };
},
getDownstreamApiKeyOverview: (id: number) =>
request(`/api/downstream-keys/${id}/overview`),
getDownstreamApiKeyTrend: (
@@ -1586,9 +1812,12 @@ export const api = {
},
// Admin user management
getUsers: () => request("/api/admin/users"),
getUsers: (params?: URLSearchParams) =>
request(`/api/admin/users${params ? `?${params.toString()}` : ""}`),
getUserById: (id: number) => request(`/api/admin/users/${id}`),
updateUser: (id: number, data: { role?: string; status?: string; username?: string }) =>
createUser: (data: { username: string; email: string; password: string; role?: string }) =>
request("/api/admin/users", { method: "POST", body: JSON.stringify(data) }),
updateUser: (id: number, data: { role?: string; status?: string; username?: string; email?: string; password?: string }) =>
request(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteUser: (id: number) =>
request(`/api/admin/users/${id}`, { method: "DELETE" }),
+268
View File
@@ -5479,6 +5479,274 @@ textarea {
color: var(--color-text-muted);
}
/* ===== User Management ===== */
.user-management-page {
min-width: 0;
}
.user-management-alert {
margin-bottom: 12px;
}
.user-management-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.user-stat-card {
min-height: 82px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-light);
background: var(--color-bg-card);
box-shadow: var(--shadow-card);
padding: 14px 16px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.user-stat-label {
font-size: 12px;
color: var(--color-text-muted);
}
.user-stat-value {
font-size: 24px;
line-height: 1;
font-weight: 700;
color: var(--color-text-primary);
}
.user-stat-primary .user-stat-value {
color: var(--color-primary);
}
.user-stat-success .user-stat-value {
color: var(--color-success);
}
.user-stat-info .user-stat-value {
color: var(--color-info);
}
.user-stat-danger .user-stat-value {
color: var(--color-danger);
}
.user-management-toolbar-card {
padding: 14px;
margin-bottom: 14px;
}
.user-management-toolbar {
margin-bottom: 0;
}
.user-management-search {
min-width: 260px;
}
.user-management-select {
height: 36px;
min-width: 120px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text-primary);
padding: 0 10px;
font-size: 13px;
outline: none;
}
.user-management-select:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
.user-management-batchbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--color-border-light);
font-size: 13px;
color: var(--color-text-secondary);
}
.user-management-table-card {
overflow: hidden;
}
.user-management-table-wrap {
overflow-x: auto;
}
.user-management-table {
min-width: 900px;
}
.user-management-check-col {
width: 42px;
text-align: center !important;
}
.user-management-actions-col {
width: 188px;
text-align: right !important;
}
.user-management-row-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.user-identity-cell {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
background: color-mix(in srgb, var(--color-primary) 14%, var(--color-bg-card));
color: var(--color-primary);
font-size: 13px;
font-weight: 700;
}
.user-identity-text {
min-width: 0;
}
.user-name-line {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.user-name {
color: var(--color-text-primary);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
.user-id {
color: var(--color-text-muted);
font-size: 12px;
}
.user-email {
margin-top: 2px;
color: var(--color-text-muted);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
max-width: 280px;
}
.user-management-pagination {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-top: 1px solid var(--color-border-light);
color: var(--color-text-muted);
font-size: 13px;
flex-wrap: wrap;
}
.user-management-page-controls {
display: inline-flex;
gap: 8px;
}
.user-management-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.user-management-form label {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
font-size: 12px;
color: var(--color-text-muted);
}
.user-management-form input,
.user-management-form select {
width: 100%;
height: 38px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text-primary);
padding: 0 10px;
font-size: 13px;
outline: none;
}
.user-management-form input:focus,
.user-management-form select:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
@media (max-width: 900px) {
.user-management-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.user-management-search {
min-width: 100%;
}
}
@media (max-width: 640px) {
.user-management-stats {
grid-template-columns: 1fr;
}
.user-management-select {
width: 100%;
}
.user-management-form {
grid-template-columns: 1fr;
}
.user-management-pagination {
align-items: stretch;
}
.user-management-page-controls {
width: 100%;
}
.user-management-page-controls .btn {
flex: 1;
}
}
.route-batch-bar {
position: sticky;
top: calc(var(--topbar-height) + 8px);
File diff suppressed because it is too large Load Diff
+487 -157
View File
@@ -1,204 +1,534 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import CenteredModal from '../components/CenteredModal.js';
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
type UserRole = 'admin' | 'user';
type UserStatus = 'active' | 'disabled';
interface User {
id: number;
username: string;
email: string;
role: string;
status: string;
createdAt: string;
role: UserRole;
status: UserStatus;
createdAt?: string;
created_at?: string;
updatedAt?: string;
updated_at?: string;
}
interface UserStats {
total: number;
active: number;
disabled: number;
admins: number;
}
interface UserListMeta {
page: number;
pageSize: number;
total: number;
totalPages: number;
}
type UserFormMode = 'create' | 'edit';
interface UserFormState {
username: string;
email: string;
password: string;
role: UserRole;
status: UserStatus;
}
const emptyForm: UserFormState = {
username: '',
email: '',
password: '',
role: 'user',
status: 'active',
};
function normalizeUsers(result: any): User[] {
const rawUsers = Array.isArray(result?.data)
? result.data
: Array.isArray(result?.users)
? result.users
: [];
return rawUsers.map((user: any) => ({
id: Number(user.id),
username: String(user.username || ''),
email: String(user.email || ''),
role: user.role === 'admin' ? 'admin' : 'user',
status: user.status === 'disabled' ? 'disabled' : 'active',
createdAt: user.createdAt ?? user.created_at,
created_at: user.created_at,
updatedAt: user.updatedAt ?? user.updated_at,
updated_at: user.updated_at,
}));
}
function normalizeStats(result: any, users: User[]): UserStats {
const stats = result?.stats;
if (stats) {
return {
total: Number(stats.total || 0),
active: Number(stats.active || 0),
disabled: Number(stats.disabled || 0),
admins: Number(stats.admins || 0),
};
}
return {
total: users.length,
active: users.filter((user) => user.status === 'active').length,
disabled: users.filter((user) => user.status === 'disabled').length,
admins: users.filter((user) => user.role === 'admin').length,
};
}
function normalizeMeta(result: any, users: User[], page: number, pageSize: number): UserListMeta {
const meta = result?.meta || {};
const total = Number(meta.total ?? users.length);
return {
page: Number(meta.page || page),
pageSize: Number(meta.pageSize || pageSize),
total,
totalPages: Math.max(1, Number(meta.totalPages || Math.ceil(total / pageSize) || 1)),
};
}
function formatDate(value?: string): string {
if (!value) return '-';
const normalized = value.includes('T') ? value : value.replace(' ', 'T');
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return value.slice(0, 16);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function roleLabel(role: UserRole): string {
return role === 'admin' ? '管理员' : '普通用户';
}
function statusLabel(status: UserStatus): string {
return status === 'active' ? '正常' : '已禁用';
}
export default function UserManagement() {
const [users, setUsers] = useState<User[]>([]);
const [stats, setStats] = useState<UserStats>({ total: 0, active: 0, disabled: 0, admins: 0 });
const [meta, setMeta] = useState<UserListMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 1 });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [editingUser, setEditingUser] = useState<number | null>(null);
const [editRole, setEditRole] = useState('');
const [editStatus, setEditStatus] = useState('');
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [formMode, setFormMode] = useState<UserFormMode>('create');
const [formOpen, setFormOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [form, setForm] = useState<UserFormState>(emptyForm);
const [deleteTarget, setDeleteTarget] = useState<User | 'selected' | null>(null);
const loadUsers = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await api.getUsers();
setUsers(Array.isArray(result.users) ? result.users : []);
const params = new URLSearchParams();
if (search.trim()) params.set('q', search.trim());
if (roleFilter) params.set('role', roleFilter);
if (statusFilter) params.set('status', statusFilter);
params.set('page', String(page));
params.set('page_size', String(pageSize));
const result = await api.getUsers(params);
const nextUsers = normalizeUsers(result);
setUsers(nextUsers);
setStats(normalizeStats(result, nextUsers));
setMeta(normalizeMeta(result, nextUsers, page, pageSize));
setSelectedIds((ids) => ids.filter((id) => nextUsers.some((user) => user.id === id)));
} catch (err: any) {
setError(err?.message || '加载用户列表失败');
} finally {
setLoading(false);
}
}, []);
}, [page, pageSize, roleFilter, search, statusFilter]);
useEffect(() => { loadUsers(); }, [loadUsers]);
useEffect(() => {
const handle = window.setTimeout(() => {
loadUsers();
}, 180);
return () => window.clearTimeout(handle);
}, [loadUsers]);
const handleUpdateUser = async (id: number) => {
const selectedUsers = useMemo(
() => users.filter((user) => selectedIds.includes(user.id)),
[selectedIds, users],
);
const allVisibleSelected = users.length > 0 && users.every((user) => selectedIds.includes(user.id));
const openCreateModal = () => {
setFormMode('create');
setEditingUser(null);
setForm(emptyForm);
setFormOpen(true);
};
const openEditModal = (user: User) => {
setFormMode('edit');
setEditingUser(user);
setForm({
username: user.username,
email: user.email,
password: '',
role: user.role,
status: user.status,
});
setFormOpen(true);
};
const updateSelection = (id: number, checked: boolean) => {
setSelectedIds((ids) => (
checked ? Array.from(new Set([...ids, id])) : ids.filter((item) => item !== id)
));
};
const toggleVisibleSelection = (checked: boolean) => {
if (checked) {
setSelectedIds((ids) => Array.from(new Set([...ids, ...users.map((user) => user.id)])));
return;
}
setSelectedIds((ids) => ids.filter((id) => !users.some((user) => user.id === id)));
};
const handleSubmit = async () => {
const username = form.username.trim();
const email = form.email.trim();
const password = form.password.trim();
if (!username || !email) {
setError('用户名和邮箱不能为空');
return;
}
if (formMode === 'create' && password.length < 6) {
setError('新用户密码至少 6 位');
return;
}
setSaving(true);
setError('');
try {
await api.updateUser(id, { role: editRole, status: editStatus });
setEditingUser(null);
if (formMode === 'create') {
await api.createUser({
username,
email,
password,
role: form.role,
});
} else if (editingUser) {
await api.updateUser(editingUser.id, {
username,
email,
role: form.role,
status: form.status,
...(password ? { password } : {}),
});
}
setFormOpen(false);
await loadUsers();
} catch (err: any) {
setError(err?.message || '更新失败');
setError(err?.message || '保存用户失败');
} finally {
setSaving(false);
}
};
const handleDisableUser = async (id: number) => {
const updateUserStatus = async (user: User, status: UserStatus) => {
setSaving(true);
setError('');
try {
await api.deleteUser(id);
await api.updateUser(user.id, { status });
await loadUsers();
} catch (err: any) {
setError(err?.message || '操作失败');
setError(err?.message || '更新用户状态失败');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="animate-fade-in" style={{ padding: 16 }}>
<div className="skeleton" style={{ width: 220, height: 24, marginBottom: 16 }} />
<div className="skeleton" style={{ width: '100%', height: 120, borderRadius: 12 }} />
</div>
);
}
const updateSelectedStatus = async (status: UserStatus) => {
if (selectedUsers.length === 0) return;
setSaving(true);
setError('');
try {
await Promise.all(selectedUsers.map((user) => api.updateUser(user.id, { status })));
setSelectedIds([]);
await loadUsers();
} catch (err: any) {
setError(err?.message || '批量更新失败');
} finally {
setSaving(false);
}
};
const confirmDelete = async () => {
if (!deleteTarget) return;
setSaving(true);
setError('');
try {
const targets = deleteTarget === 'selected' ? selectedUsers : [deleteTarget];
await Promise.all(targets.map((user) => api.deleteUser(user.id)));
setDeleteTarget(null);
setSelectedIds([]);
await loadUsers();
} catch (err: any) {
setError(err?.message || '删除用户失败');
} finally {
setSaving(false);
}
};
const statCards = [
{ label: '总用户', value: stats.total, tone: 'primary' },
{ label: '正常用户', value: stats.active, tone: 'success' },
{ label: '管理员', value: stats.admins, tone: 'info' },
{ label: '已禁用', value: stats.disabled, tone: 'danger' },
];
return (
<div className="animate-fade-in">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h1 style={{ fontSize: 18, fontWeight: 600, margin: 0 }}>用户管理</h1>
<span style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
共 {users.length} 个用户
</span>
<div className="page-container animate-fade-in user-management-page">
<div className="page-header">
<div>
<h2 className="page-title">用户管理</h2>
<div className="page-subtitle">维护平台用户、角色与登录状态。禁用不会删除用户数据,可随时恢复。</div>
</div>
<div className="page-actions">
<button type="button" className="btn btn-ghost" onClick={loadUsers} disabled={loading || saving}>
刷新
</button>
<button type="button" className="btn btn-primary" onClick={openCreateModal}>
新建用户
</button>
</div>
</div>
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
{error ? <div className="alert alert-error user-management-alert">{error}</div> : null}
{/* Stats cards */}
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
{[
{ label: '总用户', value: users.length, color: '#818cf8' },
{ label: '管理员', value: users.filter((u) => u.role === 'admin').length, color: '#4ade80' },
{ label: '已禁用', value: users.filter((u) => u.status === 'disabled').length, color: '#f87171' },
].map((stat) => (
<div key={stat.label} style={{
flex: 1, padding: '14px 16px', borderRadius: 10,
background: 'var(--color-surface, #1a1a2e)',
border: '1px solid var(--color-border, #2a2a3e)',
}}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>{stat.label}</div>
<div style={{ fontSize: 22, fontWeight: 700, color: stat.color }}>{stat.value}</div>
<div className="user-management-stats">
{statCards.map((stat) => (
<div className={`user-stat-card user-stat-${stat.tone}`} key={stat.label}>
<div className="user-stat-label">{stat.label}</div>
<div className="user-stat-value">{loading ? '-' : stat.value}</div>
</div>
))}
</div>
{/* Users table */}
<div style={{
background: 'var(--color-surface, #1a1a2e)',
borderRadius: 12, border: '1px solid var(--color-border, #2a2a3e)',
overflow: 'hidden',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border, #2a2a3e)' }}>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>ID</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>用户名</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>邮箱</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>角色</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>状态</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>注册时间</th>
<th style={{ padding: '12px 16px', textAlign: 'right', fontWeight: 600, color: 'var(--color-text-muted)' }}>操作</th>
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr>
<td colSpan={7} style={{ padding: 24, textAlign: 'center', color: 'var(--color-text-muted)' }}>
暂无用户
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id} style={{ borderBottom: '1px solid var(--color-border, #2a2a3e)' }}>
<td style={{ padding: '12px 16px', fontFamily: 'monospace', fontSize: 12 }}>{user.id}</td>
<td style={{ padding: '12px 16px' }}>{user.username}</td>
<td style={{ padding: '12px 16px', color: 'var(--color-text-secondary)' }}>{user.email}</td>
<td style={{ padding: '12px 16px' }}>
{editingUser === user.id ? (
<select
value={editRole}
onChange={(e) => setEditRole(e.target.value)}
style={{
padding: '4px 8px', borderRadius: 4, fontSize: 12,
border: '1px solid var(--color-border)',
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
}}
>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
) : (
<span style={{
fontSize: 12, padding: '2px 8px', borderRadius: 4,
background: user.role === 'admin' ? 'rgba(99,102,241,0.15)' : 'rgba(34,197,94,0.15)',
color: user.role === 'admin' ? '#818cf8' : '#4ade80',
}}>
{user.role === 'admin' ? '管理员' : '用户'}
</span>
)}
</td>
<td style={{ padding: '12px 16px' }}>
{editingUser === user.id ? (
<select
value={editStatus}
onChange={(e) => setEditStatus(e.target.value)}
style={{
padding: '4px 8px', borderRadius: 4, fontSize: 12,
border: '1px solid var(--color-border)',
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
}}
>
<option value="active">active</option>
<option value="disabled">disabled</option>
</select>
) : (
<span style={{
fontSize: 12, padding: '2px 8px', borderRadius: 4,
background: user.status === 'active' ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
color: user.status === 'active' ? '#4ade80' : '#f87171',
}}>
{user.status === 'active' ? '正常' : '禁用'}
</span>
)}
</td>
<td style={{ padding: '12px 16px', fontSize: 12, color: 'var(--color-text-muted)' }}>
{user.createdAt?.slice(0, 10) || '-'}
</td>
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
{editingUser === user.id ? (
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<button onClick={() => handleUpdateUser(user.id)} className="btn btn-primary" style={{ fontSize: 12, padding: '4px 12px' }}>保存</button>
<button onClick={() => setEditingUser(null)} className="btn btn-ghost" style={{ fontSize: 12, padding: '4px 12px' }}>取消</button>
</div>
) : (
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<button
onClick={() => { setEditingUser(user.id); setEditRole(user.role); setEditStatus(user.status); }}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 12px' }}
>
编辑
</button>
{user.status !== 'disabled' && (
<button onClick={() => handleDisableUser(user.id)} className="btn btn-ghost danger" style={{ fontSize: 12, padding: '4px 12px' }}>
禁用
</button>
)}
</div>
)}
</td>
</tr>
))
)}
</tbody>
</table>
<div className="card user-management-toolbar-card">
<div className="toolbar user-management-toolbar">
<div className="toolbar-search user-management-search">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} 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
value={search}
onChange={(event) => { setSearch(event.target.value); setPage(1); }}
placeholder="搜索 ID / 用户名 / 邮箱"
/>
</div>
<select className="user-management-select" value={roleFilter} onChange={(event) => { setRoleFilter(event.target.value); setPage(1); }}>
<option value="">全部角色</option>
<option value="admin">管理员</option>
<option value="user">普通用户</option>
</select>
<select className="user-management-select" value={statusFilter} onChange={(event) => { setStatusFilter(event.target.value); setPage(1); }}>
<option value="">全部状态</option>
<option value="active">正常</option>
<option value="disabled">已禁用</option>
</select>
<select className="user-management-select" value={pageSize} onChange={(event) => { setPageSize(Number(event.target.value)); setPage(1); }}>
<option value={25}>25 / 页</option>
<option value={50}>50 / 页</option>
<option value={100}>100 / 页</option>
</select>
</div>
{selectedUsers.length > 0 ? (
<div className="user-management-batchbar">
<span>已选 {selectedUsers.length} 个用户</span>
<button type="button" className="btn btn-ghost" onClick={() => updateSelectedStatus('active')} disabled={saving}>批量启用</button>
<button type="button" className="btn btn-ghost" onClick={() => updateSelectedStatus('disabled')} disabled={saving}>批量禁用</button>
<button type="button" className="btn btn-link btn-link-danger" onClick={() => setDeleteTarget('selected')} disabled={saving}>批量删除</button>
</div>
) : null}
</div>
<div className="card user-management-table-card">
{loading ? (
<div className="empty-state">
<div className="empty-state-title">加载中...</div>
<div className="empty-state-desc">正在读取用户列表。</div>
</div>
) : users.length === 0 ? (
<div className="empty-state">
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M18 18.72a9.1 9.1 0 0 0 3.74-1.22 5.25 5.25 0 0 0-7.49-3.82M15 11.25a3.75 3.75 0 1 0-7.5 0 3.75 3.75 0 0 0 7.5 0ZM4.5 20.1a7.5 7.5 0 0 1 15 0" />
</svg>
<div className="empty-state-title">没有匹配的用户</div>
<div className="empty-state-desc">调整筛选条件,或使用右上角新建用户。</div>
</div>
) : (
<div className="user-management-table-wrap">
<table className="data-table user-management-table">
<thead>
<tr>
<th className="user-management-check-col">
<input
type="checkbox"
checked={allVisibleSelected}
onChange={(event) => toggleVisibleSelection(event.target.checked)}
aria-label="选择当前页用户"
/>
</th>
<th>用户</th>
<th>角色</th>
<th>状态</th>
<th>注册时间</th>
<th>最近更新</th>
<th className="user-management-actions-col">操作</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className={selectedIds.includes(user.id) ? 'row-selected' : ''}>
<td className="user-management-check-col">
<input
type="checkbox"
checked={selectedIds.includes(user.id)}
onChange={(event) => updateSelection(user.id, event.target.checked)}
aria-label={`选择 ${user.username}`}
/>
</td>
<td>
<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>
</td>
<td>
<span className={`badge ${user.role === 'admin' ? 'badge-info' : 'badge-muted'}`}>
{roleLabel(user.role)}
</span>
</td>
<td>
<span className={`badge ${user.status === 'active' ? 'badge-success' : 'badge-error'}`}>
{statusLabel(user.status)}
</span>
</td>
<td>{formatDate(user.createdAt || user.created_at)}</td>
<td>{formatDate(user.updatedAt || user.updated_at)}</td>
<td className="user-management-actions-col">
<div className="user-management-row-actions">
<button type="button" className="btn btn-link btn-link-primary" onClick={() => openEditModal(user)}>编辑</button>
{user.status === 'active' ? (
<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>
))}
</tbody>
</table>
</div>
)}
<div className="user-management-pagination">
<div>共 {meta.total} 个用户,第 {meta.page} / {meta.totalPages} 页</div>
<div className="user-management-page-controls">
<button type="button" className="btn btn-ghost" disabled={page <= 1 || loading} onClick={() => setPage((value) => Math.max(1, value - 1))}>
上一页
</button>
<button type="button" className="btn btn-ghost" disabled={page >= meta.totalPages || loading} onClick={() => setPage((value) => value + 1)}>
下一页
</button>
</div>
</div>
</div>
<CenteredModal
open={formOpen}
onClose={() => setFormOpen(false)}
title={formMode === 'create' ? '新建用户' : `编辑用户 #${editingUser?.id ?? ''}`}
maxWidth={620}
closeOnEscape
footer={(
<>
<button type="button" className="btn btn-ghost" onClick={() => setFormOpen(false)} disabled={saving}>取消</button>
<button type="button" className="btn btn-primary" onClick={handleSubmit} disabled={saving}>
{saving ? '保存中...' : '保存'}
</button>
</>
)}
>
<div className="user-management-form">
<label>
<span>用户名</span>
<input value={form.username} onChange={(event) => setForm((value) => ({ ...value, username: event.target.value }))} />
</label>
<label>
<span>邮箱</span>
<input value={form.email} onChange={(event) => setForm((value) => ({ ...value, email: event.target.value }))} />
</label>
<label>
<span>{formMode === 'create' ? '密码' : '重置密码'}</span>
<input
type="password"
value={form.password}
onChange={(event) => setForm((value) => ({ ...value, password: event.target.value }))}
placeholder={formMode === 'create' ? '至少 6 位' : '留空则不修改'}
/>
</label>
<label>
<span>角色</span>
<select value={form.role} onChange={(event) => setForm((value) => ({ ...value, role: event.target.value as UserRole }))}>
<option value="user">普通用户</option>
<option value="admin">管理员</option>
</select>
</label>
<label>
<span>状态</span>
<select value={form.status} onChange={(event) => setForm((value) => ({ ...value, status: event.target.value as UserStatus }))} disabled={formMode === 'create'}>
<option value="active">正常</option>
<option value="disabled">已禁用</option>
</select>
</label>
</div>
</CenteredModal>
<DeleteConfirmModal
open={deleteTarget !== null}
title="确认删除用户"
confirmText="确认删除"
loading={saving}
onClose={() => setDeleteTarget(null)}
onConfirm={confirmDelete}
description={deleteTarget === 'selected'
? `将永久删除选中的 ${selectedUsers.length} 个用户。`
: `将永久删除用户 ${deleteTarget?.username || ''}。`}
/>
</div>
);
}