Compare commits

...

6 Commits

Author SHA1 Message Date
boos d697146941 feat(audit): login audit log with account info and IPv4
- 审计日志记录具体账号(用户名<邮箱>)
- 优先显示 IPv4,保留 IPv6
- Program Logs 页面新增 account/IPv4 列
2026-06-10 19:54:32 +08:00
boos d1f3f4e93d feat(payment): Stripe Checkout payment module
- payment_orders 建表迁移,支持多 provider(stripe)
- Stripe Checkout Session 创建、Webhook 验签、到账
- 支付成功自动为 API Key 增加额度
- payment.enabled 开关,关闭时不影响旧 Comfy Checkout
2026-06-10 19:54:27 +08:00
boos df97633300 fix(recharge): per-amount distinct checkout URLs
- 前端 /0/0/0 各自携带独立 checkoutUrl
- 后端按金额解析独立 Checkout URL 配置
- 同时校验 optionId 和 amountUsd 防止金额错配
- 移除旧单一固定 checkout URL 兜底
2026-06-10 19:54:20 +08:00
boos 3d54c7d529 fix: support user recharge record cancellation 2026-06-09 17:22:23 +08:00
boos 55b3d4d99a feat: enhance new website experiment motion 2026-06-09 16:59:04 +08:00
boos 59106e6a96 feat: refine BoosAPI console and user workspace 2026-06-09 16:48:18 +08:00
290 changed files with 62115 additions and 5596 deletions
+4
View File
@@ -27,3 +27,7 @@ openspec/
boosapi boosapi
bossai bossai
docker/*.tar docker/*.tar
boosapi.db-shm
boosapi.db-wal
.tours/
bun.lock
Generated
+3
View File
@@ -399,6 +399,8 @@ dependencies = [
"dotenvy", "dotenvy",
"figment", "figment",
"futures", "futures",
"hex",
"hmac",
"include_dir", "include_dir",
"jsonwebtoken", "jsonwebtoken",
"lettre", "lettre",
@@ -411,6 +413,7 @@ dependencies = [
"rstest", "rstest",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"sqlx", "sqlx",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
+3
View File
@@ -70,6 +70,9 @@ once_cell = "1"
parking_lot = "0.12" parking_lot = "0.12"
mime = "0.3" mime = "0.3"
base64 = "0.22" base64 = "0.22"
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
include_dir = "0.7" include_dir = "0.7"
mime_guess = "2" mime_guess = "2"
+29 -6
View File
@@ -57,6 +57,18 @@
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 | | 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 | | 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
## 2026-06 更新重点
- **后台登录收口**:管理员后台只允许使用 `BOOSAPI_AUTH__AUTH_TOKEN` 这类 API Token 登录;普通用户仍使用账号密码登录用户工作台。
- **日志边界清晰**:管理员「使用日志」保留在 `/logs`,系统/程序日志保留在 `/events`;`/api/events` 现在只接受管理员 API Token,普通用户不会再看到运行日志。
- **通知隔离**:普通用户顶部通知只读取用户公告;管理员通知面板继续聚合签到、余额、令牌、代理、状态与公告事件。
- **后台信息架构**:侧栏按接入管理、创作应用、系统管理、我的账户精简;外部监控入口已移除,系统日志入口保留在系统管理分组。
- **运维与 IP 管理**:新增运维监控视图与 IP 管理页面,支持代理质量检测、连接测试、批量操作与更清晰的状态展示。
- **用户自助工作台**:API Key、计费、账户充值、使用记录、工作空间和模型测试统一到用户侧;工作空间支持分页、一键选择和默认预览。
- **Comfy Checkout 充值**:用户先在系统内创建充值记录,再跳转到配置的 Comfy Checkout 链接付款;充值记录会保留用于后续对账。
- **Workflow UI 更新**:`npm run workflow-ui:install:cloud` 会安装当前 Comfy Cloud 前端资产并写入构建元数据,Rust 服务会对 JS/CSS 走 no-cache,避免继续显示旧版本。
- **本地化时间**:前端展示时间优先使用浏览器/UA 时区,后端无时区时间按 UTC 解析后再转成本地显示。
## ✨ 核心功能 ## ✨ 核心功能
### 🎨 Workflow Studio 与视频助手(Rust) ### 🎨 Workflow Studio 与视频助手(Rust)
@@ -249,7 +261,7 @@ curl http://127.0.0.1:4000/v1/videos \
返回的 `id` 是 Comfy `prompt_id`,可用 `GET /v1/videos/{id}?model=comfy-wan-video` 查询 Comfy history。 返回的 `id` 是 Comfy `prompt_id`,可用 `GET /v1/videos/{id}?model=comfy-wan-video` 查询 Comfy history。
> Workflow UI 有 cloud 与 external/runninghub 两套前端资产。开发环境可用 `npm run workflow-ui:install:cloud`、`npm run workflow-ui:install:external` 单独安装;生产构建会把可用资产嵌入 Rust 二进制。 > Workflow UI 有 cloud 与 external/runninghub 两套前端资产。开发环境可用 `npm run workflow-ui:install:cloud`、`npm run workflow-ui:install:external` 单独安装;安装脚本会写入 `boosapi-workflow-ui.json` 构建元数据,生产构建会把可用资产嵌入 Rust 二进制。若浏览器「关于」仍显示旧前端版本,请重启 Rust 服务并强刷浏览器缓存。
## 🚀 快速开始 ## 🚀 快速开始
@@ -275,14 +287,14 @@ services:
restart: unless-stopped restart: unless-stopped
EOF EOF
# AUTH_TOKEN = 管理后台登录令牌 # AUTH_TOKEN = 管理后台登录令牌(管理员只支持 API Token 登录)
# PROXY_TOKEN = 下游客户端调用 /v1/* 的 Token # PROXY_TOKEN = 下游客户端调用 /v1/* 的 Token
export AUTH_TOKEN=your-admin-token export AUTH_TOKEN=your-admin-token
export PROXY_TOKEN=sk-your-proxy-token export PROXY_TOKEN=sk-your-proxy-token
docker compose up -d docker compose up -d
``` ```
启动后访问 `http://localhost:4000`,用 `AUTH_TOKEN` 登录即可。 启动后访问 `http://localhost:4000/boos`,用 `AUTH_TOKEN` 登录管理员后台;普通用户从用户登录页使用账号密码进入用户工作台。
> [!IMPORTANT] > [!IMPORTANT]
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。 > 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。
@@ -329,7 +341,7 @@ docker compose up -d
│ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶) │ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶)
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage) │ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
│ ├── pages/ │ ├── pages/
│ │ ├── auth/ # 登录页拆分:LoginShell + AdminLoginPage + UserLoginPage(OAuthSlot 占位,行为由后续 change 接入) │ │ ├── auth/ # 登录页拆分:LoginShell + AdminLoginPage(token-only) + UserLoginPage(OAuthSlot 占位)
│ │ ├── ComfyUIAgent.tsx # 视频/工作流助手聊天界面 │ │ ├── ComfyUIAgent.tsx # 视频/工作流助手聊天界面
│ │ ├── Workflows.tsx # Workflow Studio 入口 │ │ ├── Workflows.tsx # Workflow Studio 入口
│ │ └── ... │ │ └── ...
@@ -454,11 +466,22 @@ npm install
# 开发模式(热更新,需要 Node.js) # 开发模式(热更新,需要 Node.js)
cd src/web && npx vite --host 0.0.0.0 cd src/web && npx vite --host 0.0.0.0
# 构建生产版本 # 类型检查与构建生产版本
npm run build npm run typecheck:web
npm run build:web
# 输出到 dist/web/,Rust 后端自动 serve # 输出到 dist/web/,Rust 后端自动 serve
``` ```
Workflow UI 前端资产单独更新:
```bash
# Comfy Cloud 前端
npm run workflow-ui:install:cloud
# External / RunningHub 前端
npm run workflow-ui:install:external
```
可选使用 Bun 加速主前端构建: 可选使用 Bun 加速主前端构建:
```bash ```bash
+31 -1
View File
@@ -25,6 +25,28 @@ upstream_timeout_secs = 300
first_byte_timeout_secs = 60 first_byte_timeout_secs = 60
max_retries = 3 max_retries = 3
proxy_log_retention_days = 30 proxy_log_retention_days = 30
# Optional proxy for external template galleries/workflow template assets.
# Supports http://, https:// and socks5:// proxy URLs.
# template_proxy_url = "http://127.0.0.1:7890"
[payment]
enabled = false
# Public HTTPS origin used to build Stripe success/cancel URLs and webhook docs.
# Example: "https://tybossai.com"
public_base_url = ""
product_name_prefix = "BossAI 账户充值"
currency = "usd"
min_amount_usd = 1.0
max_amount_usd = 100000.0
order_timeout_minutes = 30
max_pending_orders = 3
success_path = "/user/recharge?payment=success&order={ORDER_ID}"
cancel_path = "/user/recharge?payment=cancel&order={ORDER_ID}"
# Prefer environment variables for secrets:
# BOOSAPI_PAYMENT__STRIPE_SECRET_KEY=sk_live_...
# BOOSAPI_PAYMENT__STRIPE_WEBHOOK_SECRET=whsec_...
stripe_secret_key = ""
stripe_webhook_secret = ""
[comfyui] [comfyui]
enabled = false enabled = false
@@ -35,12 +57,20 @@ max_restarts = 5
# backend modes: # backend modes:
# - "boosapi": use BoosAPI's own Rust Workflow/API node executor. # - "boosapi": use BoosAPI's own Rust Workflow/API node executor.
# - "cloud": proxy to Comfy Cloud and use native ComfyUI /prompt and /ws. # - "cloud": proxy to Comfy Cloud and use native ComfyUI /prompt and /ws.
# - "runninghub": proxy to RunningHub's ComfyUI-compatible API with external-compatible frontend.
# - "external": proxy to your own full ComfyUI server. # - "external": proxy to your own full ComfyUI server.
# - "docker": legacy local Docker ComfyUI mode; not recommended for the current API Workflow path. # - "docker": legacy local Docker ComfyUI mode; not recommended for the current API Workflow path.
backend = "cloud" backend = "cloud"
# Native Comfy task costs are charged against the user's downstream API Key
# only after successful output archival. Keep 0 while pricing is not finalized.
money_printer_video_cost = 0.0
digital_human_video_cost = 0.0
# docker_image = "boosapi-comfyui:multi-tenant" # docker_image = "boosapi-comfyui:multi-tenant"
# container_name = "boosapi-comfyui-workflow" # container_name = "boosapi-comfyui-workflow"
# base_url = "https://www.runninghub.cn/proxy/94d1330ea62d4ae980ffb018ecdf82a2" # base_url = "https://www.runninghub.cn/proxy-plus/94d1330ea62d4ae980ffb018ecdf82a2"
# For RunningHub, set backend = "runninghub" and use your RunningHub proxy/API URL.
# RunningHub proxy URL already contains the API key. Keep this empty unless
# you switch to a backend that requires Authorization / X-API-Key headers.
base_url = "https://cloud.comfy.org" base_url = "https://cloud.comfy.org"
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f" api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
agent_model = "gpt-5.5" agent_model = "gpt-5.5"
@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS user_recharge_records (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
option_id TEXT,
label TEXT,
title TEXT,
amount_usd REAL,
provider TEXT NOT NULL DEFAULT 'comfy_checkout',
checkout_url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
note TEXT,
metadata TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
completed_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS user_recharge_records_public_id_unique
ON user_recharge_records (public_id);
CREATE INDEX IF NOT EXISTS user_recharge_records_user_created_idx
ON user_recharge_records (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS user_recharge_records_user_status_idx
ON user_recharge_records (user_id, status);
@@ -0,0 +1,39 @@
CREATE TABLE IF NOT EXISTS payment_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
recharge_record_id INTEGER,
provider TEXT NOT NULL,
payment_method TEXT NOT NULL DEFAULT 'stripe',
provider_session_id TEXT,
provider_payment_id TEXT,
amount_usd REAL NOT NULL,
amount_minor INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'usd',
status TEXT NOT NULL DEFAULT 'pending',
checkout_url TEXT,
success_url TEXT,
cancel_url TEXT,
note TEXT,
metadata TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
paid_at TEXT,
completed_at TEXT,
expires_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (recharge_record_id) REFERENCES user_recharge_records(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS payment_orders_public_id_unique
ON payment_orders (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS payment_orders_provider_session_unique
ON payment_orders (provider, provider_session_id)
WHERE provider_session_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS payment_orders_user_created_idx
ON payment_orders (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS payment_orders_status_idx
ON payment_orders (status);
+39 -3
View File
@@ -35,9 +35,18 @@ const variants = {
function run(command, args, options = {}) { function run(command, args, options = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const env = { ...process.env, ...(options.env || {}) };
if (options.cleanPackageEnv) {
Object.keys(env)
.filter((key) => key.startsWith('npm_package_'))
.forEach((key) => {
delete env[key];
});
Object.assign(env, options.env || {});
}
const child = spawn(command, args, { const child = spawn(command, args, {
cwd: options.cwd || ROOT, cwd: options.cwd || ROOT,
env: { ...process.env, ...(options.env || {}) }, env,
stdio: 'inherit', stdio: 'inherit',
shell: process.platform === 'win32', shell: process.platform === 'win32',
}); });
@@ -55,6 +64,11 @@ function selectedVariants() {
throw new Error(`Unknown workflow-ui variant: ${VARIANT}. Use external, cloud, or all.`); throw new Error(`Unknown workflow-ui variant: ${VARIANT}. Use external, cloud, or all.`);
} }
async function readPackageInfo(dir) {
const raw = await fs.readFile(path.join(dir, 'package.json'), 'utf8');
return JSON.parse(raw);
}
async function buildVariant(name, config) { async function buildVariant(name, config) {
const ref = config.ref; const ref = config.ref;
const dist = config.dist; const dist = config.dist;
@@ -77,17 +91,39 @@ async function buildVariant(name, config) {
await run('git', ['submodule', 'update', '--init', '--recursive'], { cwd: dir }); await run('git', ['submodule', 'update', '--init', '--recursive'], { cwd: dir });
} }
const packageInfo = await readPackageInfo(dir);
const packageEnv = {
npm_package_name: packageInfo.name || '',
npm_package_version: packageInfo.version || ref.replace(/^v/, ''),
};
const hasPnpmLock = await fs.access(path.join(dir, 'pnpm-lock.yaml')).then(() => true, () => false); const hasPnpmLock = await fs.access(path.join(dir, 'pnpm-lock.yaml')).then(() => true, () => false);
if (hasPnpmLock) { if (hasPnpmLock) {
await run('pnpm', ['install', '--frozen-lockfile'], { cwd: dir }); await run('pnpm', ['install', '--frozen-lockfile'], { cwd: dir });
await run('pnpm', ['exec', 'vite', 'build', '--config', 'vite.config.mts'], { cwd: dir }); await run('pnpm', ['exec', 'vite', 'build', '--config', 'vite.config.mts'], {
cwd: dir,
cleanPackageEnv: true,
env: packageEnv,
});
} else { } else {
await run('npm', ['ci'], { cwd: dir }); await run('npm', ['ci'], { cwd: dir });
await run('npm', ['run', 'build'], { cwd: dir }); await run('npm', ['run', 'build'], {
cwd: dir,
cleanPackageEnv: true,
env: packageEnv,
});
} }
await fs.rm(dist, { recursive: true, force: true }); await fs.rm(dist, { recursive: true, force: true });
await fs.cp(path.join(dir, 'dist'), dist, { recursive: true }); await fs.cp(path.join(dir, 'dist'), dist, { recursive: true });
await fs.writeFile(
path.join(dist, 'boosapi-workflow-ui.json'),
`${JSON.stringify({
variant: name,
ref,
packageName: packageInfo.name || null,
packageVersion: packageInfo.version || null,
}, null, 2)}\n`,
);
console.log(`workflow-ui:${name} installed to ${dist}`); console.log(`workflow-ui:${name} installed to ${dist}`);
if (name === 'cloud') { if (name === 'cloud') {
+45
View File
@@ -13,6 +13,8 @@ pub struct AppConfig {
pub proxy: ProxyConfig, pub proxy: ProxyConfig,
#[serde(default)] #[serde(default)]
pub comfyui: ComfyUIConfig, pub comfyui: ComfyUIConfig,
#[serde(default)]
pub payment: PaymentConfig,
} }
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
@@ -56,6 +58,10 @@ pub struct ComfyUIConfig {
pub enabled: bool, pub enabled: bool,
#[serde(default)] #[serde(default)]
pub backend: ComfyUIBackend, pub backend: ComfyUIBackend,
#[serde(default)]
pub money_printer_video_cost: f64,
#[serde(default)]
pub digital_human_video_cost: f64,
#[serde(default = "default_comfyui_docker_image")] #[serde(default = "default_comfyui_docker_image")]
pub docker_image: String, pub docker_image: String,
#[serde(default = "default_comfyui_container_name")] #[serde(default = "default_comfyui_container_name")]
@@ -71,6 +77,23 @@ pub struct ComfyUIConfig {
pub agent_model: String, pub agent_model: String,
} }
#[derive(Debug, Deserialize, Clone)]
pub struct PaymentConfig {
pub enabled: bool,
pub public_base_url: Option<String>,
pub product_name_prefix: String,
pub currency: String,
pub min_amount_usd: f64,
pub max_amount_usd: Option<f64>,
pub daily_limit_usd: Option<f64>,
pub order_timeout_minutes: i64,
pub max_pending_orders: i64,
pub stripe_secret_key: Option<String>,
pub stripe_webhook_secret: Option<String>,
pub success_path: String,
pub cancel_path: String,
}
#[derive(Debug, Deserialize, Clone, Default)] #[derive(Debug, Deserialize, Clone, Default)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ComfyUIBackend { pub enum ComfyUIBackend {
@@ -95,6 +118,8 @@ impl Default for ComfyUIConfig {
Self { Self {
enabled: false, enabled: false,
backend: ComfyUIBackend::Boosapi, backend: ComfyUIBackend::Boosapi,
money_printer_video_cost: 0.0,
digital_human_video_cost: 0.0,
docker_image: default_comfyui_docker_image(), docker_image: default_comfyui_docker_image(),
container_name: default_comfyui_container_name(), container_name: default_comfyui_container_name(),
install_dir: None, install_dir: None,
@@ -110,6 +135,26 @@ impl Default for ComfyUIConfig {
} }
} }
impl Default for PaymentConfig {
fn default() -> Self {
Self {
enabled: false,
public_base_url: None,
product_name_prefix: "BossAI 账户充值".to_string(),
currency: "usd".to_string(),
min_amount_usd: 1.0,
max_amount_usd: Some(100_000.0),
daily_limit_usd: None,
order_timeout_minutes: 30,
max_pending_orders: 3,
stripe_secret_key: None,
stripe_webhook_secret: None,
success_path: "/user/recharge?payment=success&order={ORDER_ID}".to_string(),
cancel_path: "/user/recharge?payment=cancel&order={ORDER_ID}".to_string(),
}
}
}
impl ComfyUIConfig { impl ComfyUIConfig {
pub fn uses_native_backend(&self) -> bool { pub fn uses_native_backend(&self) -> bool {
matches!( matches!(
+1
View File
@@ -42,6 +42,7 @@ pub async fn ensure_sqlite_upstream_compat(pool: &SqlitePool) -> anyhow::Result<
("client_confidence", "TEXT"), ("client_confidence", "TEXT"),
("is_stream", "INTEGER"), ("is_stream", "INTEGER"),
("first_byte_latency_ms", "INTEGER"), ("first_byte_latency_ms", "INTEGER"),
("billing_details", "TEXT"),
("content_summary", "TEXT"), ("content_summary", "TEXT"),
("request_content", "TEXT"), ("request_content", "TEXT"),
("response_content", "TEXT"), ("response_content", "TEXT"),
+9
View File
@@ -66,6 +66,14 @@ impl IntoResponse for AppError {
} => Some(*retry_after_secs), } => Some(*retry_after_secs),
_ => None, _ => None,
}; };
let billing = match &self {
AppError::RateLimited | AppError::RateLimitedWithRetry { .. } => Some(json!({
"reason": "insufficient_quota",
"rechargePath": "/user/recharge",
"usagePath": "/user/usage",
})),
_ => None,
};
( (
status, status,
Json(json!({ Json(json!({
@@ -74,6 +82,7 @@ impl IntoResponse for AppError {
"type": status.canonical_reason(), "type": status.canonical_reason(),
}, },
"retry_after_secs": retry_after_secs, "retry_after_secs": retry_after_secs,
"billing": billing,
})), })),
) )
.into_response() .into_response()
+734 -33
View File
@@ -2,21 +2,26 @@ use super::types::{RouteDecision, RouteMatch};
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::proxy::conductor::{FailureInfo, SelectedChannel, SuccessMetrics}; use crate::proxy::conductor::{FailureInfo, SelectedChannel, SuccessMetrics};
use crate::proxy::executors::UpstreamRoute; use crate::proxy::executors::UpstreamRoute;
use crate::services::oauth::OAuthService;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use parking_lot::RwLock; use parking_lot::RwLock;
use regex::Regex; use regex::Regex;
use sqlx::{FromRow, SqlitePool}; use sqlx::{FromRow, SqlitePool};
use std::{ use std::{
collections::HashMap, collections::{HashMap, HashSet},
sync::Arc, sync::Arc,
time::{Duration, Instant}, time::{Duration, Instant},
}; };
const ROUTE_CACHE_TTL: Duration = Duration::from_secs(2); const ROUTE_CACHE_TTL: Duration = Duration::from_secs(2);
const DEFAULT_FAILURE_COOLDOWN_MAX_SECONDS: i64 = 30 * 24 * 60 * 60;
const FAILURE_COOLDOWN_MAX_SETTING_KEY: &str = "runtime.token_router_failure_cooldown_max_sec";
static ROUTE_ROWS_CACHE: Lazy<RwLock<Option<TimedCache<Vec<RouteRow>>>>> = type DbCacheKey = usize;
Lazy::new(|| RwLock::new(None));
static CHANNEL_ROWS_CACHE: Lazy<RwLock<HashMap<i64, TimedCache<Vec<ChannelRow>>>>> = static ROUTE_ROWS_CACHE: Lazy<RwLock<HashMap<DbCacheKey, TimedCache<Vec<RouteRow>>>>> =
Lazy::new(|| RwLock::new(HashMap::new()));
static CHANNEL_ROWS_CACHE: Lazy<RwLock<HashMap<(DbCacheKey, i64), TimedCache<Vec<ChannelRow>>>>> =
Lazy::new(|| RwLock::new(HashMap::new())); Lazy::new(|| RwLock::new(HashMap::new()));
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -32,16 +37,10 @@ impl<T> TimedCache<T> {
} }
pub fn invalidate_route_cache() { pub fn invalidate_route_cache() {
*ROUTE_ROWS_CACHE.write() = None; ROUTE_ROWS_CACHE.write().clear();
CHANNEL_ROWS_CACHE.write().clear(); CHANNEL_ROWS_CACHE.write().clear();
} }
fn invalidate_channel_cache_for_channel(channel_id: i64) {
CHANNEL_ROWS_CACHE
.write()
.retain(|_, cache| !cache.value.iter().any(|row| row.channel_id == channel_id));
}
#[derive(Clone)] #[derive(Clone)]
pub struct TokenRouter { pub struct TokenRouter {
db: Arc<SqlitePool>, db: Arc<SqlitePool>,
@@ -60,15 +59,43 @@ struct ChannelRow {
channel_id: i64, channel_id: i64,
route_id: i64, route_id: i64,
account_id: i64, account_id: i64,
oauth_route_unit_id: Option<i64>,
oauth_route_unit_strategy: Option<String>,
site_id: i64, site_id: i64,
base_url: String, base_url: String,
site_api_key: Option<String>, site_api_key: Option<String>,
account_access_token: String, account_access_token: String,
account_api_token: Option<String>, account_api_token: Option<String>,
oauth_provider: Option<String>,
account_unit_cost: Option<f64>, account_unit_cost: Option<f64>,
token_name: Option<String>, token_name: Option<String>,
token_value: Option<String>, token_value: Option<String>,
source_model: Option<String>, source_model: Option<String>,
priority: i64,
weight: i64,
consecutive_fail_count: i64,
last_selected_at: Option<String>,
}
#[derive(Debug, Clone)]
struct RouteUnitCandidate {
strategy: RouteUnitStrategy,
rows: Vec<ChannelRow>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RouteUnitStrategy {
RoundRobin,
StickUntilUnavailable,
}
impl RouteUnitStrategy {
fn from_db(value: Option<&str>) -> Self {
match value.unwrap_or("round_robin") {
"stick_until_unavailable" => Self::StickUntilUnavailable,
_ => Self::RoundRobin,
}
}
} }
impl TokenRouter { impl TokenRouter {
@@ -76,6 +103,20 @@ impl TokenRouter {
Self { db } Self { db }
} }
fn db_cache_key(&self) -> DbCacheKey {
Arc::as_ptr(&self.db) as usize
}
fn invalidate_channel_cache_for_channel(&self, channel_id: i64) {
let db_cache_key = self.db_cache_key();
CHANNEL_ROWS_CACHE
.write()
.retain(|(cache_db_key, _), cache| {
*cache_db_key != db_cache_key
|| !cache.value.iter().any(|row| row.channel_id == channel_id)
});
}
pub async fn resolve(&self, model: &str) -> AppResult<UpstreamRoute> { pub async fn resolve(&self, model: &str) -> AppResult<UpstreamRoute> {
let decision = self.resolve_decision(model, &[]).await?; let decision = self.resolve_decision(model, &[]).await?;
Ok(UpstreamRoute { Ok(UpstreamRoute {
@@ -161,12 +202,27 @@ impl TokenRouter {
exclude_channel_ids: &[i64], exclude_channel_ids: &[i64],
) -> AppResult<Option<SelectedChannel>> { ) -> AppResult<Option<SelectedChannel>> {
let rows = self.channel_rows(route.id).await?; let rows = self.channel_rows(route.id).await?;
let row = select_row_with_route_units(rows, exclude_channel_ids);
let row = rows let Some(row) = row else {
.into_iter() return Ok(None);
.find(|row| !exclude_channel_ids.contains(&row.channel_id)); };
let oauth_access_token = if row
.oauth_provider
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
{
let service = OAuthService::new(self.db.clone());
let refreshed = service.refresh_token(row.account_id, row.site_id).await?;
self.invalidate_channel_cache_for_channel(row.channel_id);
Some(refreshed.access_token)
} else {
None
};
Ok(row.map(|row| { Ok(Some({
let actual_model = route let actual_model = route
.model_mapping .model_mapping
.clone() .clone()
@@ -177,11 +233,13 @@ impl TokenRouter {
.filter(|value| !value.trim().is_empty()) .filter(|value| !value.trim().is_empty())
}) })
.unwrap_or_else(|| requested_model.to_string()); .unwrap_or_else(|| requested_model.to_string());
let api_key = row let api_key = row.token_value.clone().unwrap_or_else(|| {
.token_value oauth_access_token.unwrap_or_else(|| {
.clone() row.account_api_token
.or(row.account_api_token.clone()) .clone()
.unwrap_or(row.account_access_token.clone()); .unwrap_or(row.account_access_token.clone())
})
});
SelectedChannel { SelectedChannel {
channel_id: row.channel_id, channel_id: row.channel_id,
@@ -203,7 +261,8 @@ impl TokenRouter {
} }
async fn route_rows(&self) -> AppResult<Vec<RouteRow>> { async fn route_rows(&self) -> AppResult<Vec<RouteRow>> {
if let Some(cache) = ROUTE_ROWS_CACHE.read().as_ref() { let db_cache_key = self.db_cache_key();
if let Some(cache) = ROUTE_ROWS_CACHE.read().get(&db_cache_key) {
if cache.fresh() { if cache.fresh() {
return Ok(cache.value.clone()); return Ok(cache.value.clone());
} }
@@ -218,15 +277,19 @@ impl TokenRouter {
.fetch_all(&*self.db) .fetch_all(&*self.db)
.await?; .await?;
*ROUTE_ROWS_CACHE.write() = Some(TimedCache { ROUTE_ROWS_CACHE.write().insert(
loaded_at: Instant::now(), db_cache_key,
value: rows.clone(), TimedCache {
}); loaded_at: Instant::now(),
value: rows.clone(),
},
);
Ok(rows) Ok(rows)
} }
async fn channel_rows(&self, route_id: i64) -> AppResult<Vec<ChannelRow>> { async fn channel_rows(&self, route_id: i64) -> AppResult<Vec<ChannelRow>> {
if let Some(cache) = CHANNEL_ROWS_CACHE.read().get(&route_id) { let db_cache_key = self.db_cache_key();
if let Some(cache) = CHANNEL_ROWS_CACHE.read().get(&(db_cache_key, route_id)) {
if cache.fresh() { if cache.fresh() {
return Ok(cache.value.clone()); return Ok(cache.value.clone());
} }
@@ -237,6 +300,8 @@ impl TokenRouter {
rc.id AS channel_id, rc.id AS channel_id,
rc.route_id AS route_id, rc.route_id AS route_id,
rc.account_id AS account_id, rc.account_id AS account_id,
rc.oauth_route_unit_id AS oauth_route_unit_id,
oru.strategy AS oauth_route_unit_strategy,
a.site_id AS site_id, a.site_id AS site_id,
COALESCE(( COALESCE((
SELECT sae.url SELECT sae.url
@@ -250,18 +315,25 @@ impl TokenRouter {
s.api_key AS site_api_key, s.api_key AS site_api_key,
a.access_token AS account_access_token, a.access_token AS account_access_token,
a.api_token AS account_api_token, a.api_token AS account_api_token,
a.oauth_provider AS oauth_provider,
a.unit_cost AS account_unit_cost, a.unit_cost AS account_unit_cost,
at.name AS token_name, at.name AS token_name,
at.token AS token_value, at.token AS token_value,
rc.source_model AS source_model rc.source_model AS source_model,
COALESCE(rc.priority, 0) AS priority,
COALESCE(rc.weight, 10) AS weight,
rc.consecutive_fail_count AS consecutive_fail_count,
rc.last_selected_at AS last_selected_at
FROM route_channels rc FROM route_channels rc
JOIN accounts a ON a.id = rc.account_id JOIN accounts a ON a.id = rc.account_id
JOIN sites s ON s.id = a.site_id JOIN sites s ON s.id = a.site_id
LEFT JOIN oauth_route_units oru ON oru.id = rc.oauth_route_unit_id AND COALESCE(oru.enabled, 1) = 1
LEFT JOIN account_tokens at ON at.id = rc.token_id AND at.enabled = 1 LEFT JOIN account_tokens at ON at.id = rc.token_id AND at.enabled = 1
WHERE rc.route_id = ? WHERE rc.route_id = ?
AND rc.enabled = 1 AND rc.enabled = 1
AND a.status = 'active' AND a.status = 'active'
AND s.status = 'active' AND s.status = 'active'
AND (rc.oauth_route_unit_id IS NULL OR oru.id IS NOT NULL)
AND (rc.cooldown_until IS NULL OR rc.cooldown_until <= datetime('now')) AND (rc.cooldown_until IS NULL OR rc.cooldown_until <= datetime('now'))
AND (rc.token_id IS NULL OR at.id IS NOT NULL) AND (rc.token_id IS NULL OR at.id IS NOT NULL)
ORDER BY ORDER BY
@@ -276,7 +348,7 @@ impl TokenRouter {
.await?; .await?;
CHANNEL_ROWS_CACHE.write().insert( CHANNEL_ROWS_CACHE.write().insert(
route_id, (db_cache_key, route_id),
TimedCache { TimedCache {
loaded_at: Instant::now(), loaded_at: Instant::now(),
value: rows.clone(), value: rows.clone(),
@@ -303,12 +375,14 @@ impl TokenRouter {
.bind(channel_id) .bind(channel_id)
.execute(&*self.db) .execute(&*self.db)
.await?; .await?;
invalidate_channel_cache_for_channel(channel_id); self.invalidate_channel_cache_for_channel(channel_id);
self.record_route_unit_member_success(channel_id, &metrics)
.await?;
Ok(()) Ok(())
} }
pub async fn record_failure(&self, channel_id: i64, info: FailureInfo) -> AppResult<()> { pub async fn record_failure(&self, channel_id: i64, info: FailureInfo) -> AppResult<()> {
let cooldown_seconds = cooldown_seconds_for_failure(&info); let cooldown_seconds = self.cooldown_seconds_for_failure(&info).await?;
sqlx::query( sqlx::query(
"UPDATE route_channels "UPDATE route_channels
SET fail_count = COALESCE(fail_count, 0) + 1, SET fail_count = COALESCE(fail_count, 0) + 1,
@@ -326,9 +400,156 @@ impl TokenRouter {
.bind(channel_id) .bind(channel_id)
.execute(&*self.db) .execute(&*self.db)
.await?; .await?;
invalidate_channel_cache_for_channel(channel_id); self.invalidate_channel_cache_for_channel(channel_id);
self.record_route_unit_member_failure(channel_id, info, cooldown_seconds)
.await?;
Ok(()) Ok(())
} }
async fn cooldown_seconds_for_failure(&self, info: &FailureInfo) -> AppResult<i64> {
let raw_max: Option<String> =
sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
.bind(FAILURE_COOLDOWN_MAX_SETTING_KEY)
.fetch_optional(&*self.db)
.await?;
let max_seconds = raw_max
.and_then(|value| value.trim().parse::<i64>().ok())
.unwrap_or(DEFAULT_FAILURE_COOLDOWN_MAX_SECONDS)
.max(0);
Ok(cooldown_seconds_for_failure(info).min(max_seconds))
}
async fn record_route_unit_member_success(
&self,
channel_id: i64,
metrics: &SuccessMetrics,
) -> AppResult<()> {
sqlx::query(
"UPDATE oauth_route_unit_members
SET success_count = COALESCE(success_count, 0) + 1,
total_latency_ms = COALESCE(total_latency_ms, 0) + ?,
total_cost = COALESCE(total_cost, 0) + ?,
consecutive_fail_count = 0,
cooldown_level = 0,
cooldown_until = NULL,
last_used_at = datetime('now'),
last_selected_at = datetime('now'),
updated_at = datetime('now')
WHERE account_id = (
SELECT account_id FROM route_channels WHERE id = ?
)
AND unit_id = (
SELECT oauth_route_unit_id FROM route_channels WHERE id = ?
)",
)
.bind(metrics.latency_ms.unwrap_or(0) as i64)
.bind(metrics.cost.unwrap_or(0.0))
.bind(channel_id)
.bind(channel_id)
.execute(&*self.db)
.await?;
Ok(())
}
async fn record_route_unit_member_failure(
&self,
channel_id: i64,
_info: FailureInfo,
cooldown_seconds: i64,
) -> AppResult<()> {
sqlx::query(
"UPDATE oauth_route_unit_members
SET fail_count = COALESCE(fail_count, 0) + 1,
consecutive_fail_count = consecutive_fail_count + 1,
cooldown_level = cooldown_level + 1,
cooldown_until = CASE
WHEN ? > 0 THEN datetime('now', '+' || ? || ' seconds')
ELSE cooldown_until
END,
last_fail_at = datetime('now'),
updated_at = datetime('now')
WHERE account_id = (
SELECT account_id FROM route_channels WHERE id = ?
)
AND unit_id = (
SELECT oauth_route_unit_id FROM route_channels WHERE id = ?
)",
)
.bind(cooldown_seconds)
.bind(cooldown_seconds)
.bind(channel_id)
.bind(channel_id)
.execute(&*self.db)
.await?;
Ok(())
}
}
fn select_row_with_route_units(
rows: Vec<ChannelRow>,
exclude_channel_ids: &[i64],
) -> Option<ChannelRow> {
let excluded: HashSet<i64> = exclude_channel_ids.iter().copied().collect();
let mut candidates: Vec<Result<ChannelRow, RouteUnitCandidate>> = Vec::new();
let mut unit_candidate_indexes: HashMap<i64, usize> = HashMap::new();
for row in rows {
if excluded.contains(&row.channel_id) {
continue;
}
let Some(unit_id) = row.oauth_route_unit_id else {
candidates.push(Ok(row));
continue;
};
if let Some(index) = unit_candidate_indexes.get(&unit_id).copied() {
if let Some(Err(candidate)) = candidates.get_mut(index) {
candidate.rows.push(row);
}
continue;
}
let strategy = RouteUnitStrategy::from_db(row.oauth_route_unit_strategy.as_deref());
let index = candidates.len();
candidates.push(Err(RouteUnitCandidate {
strategy,
rows: vec![row],
}));
unit_candidate_indexes.insert(unit_id, index);
}
candidates
.into_iter()
.find_map(|candidate| match candidate {
Ok(row) => Some(row),
Err(candidate) => select_route_unit_member(candidate),
})
}
fn select_route_unit_member(candidate: RouteUnitCandidate) -> Option<ChannelRow> {
let mut rows = candidate.rows;
match candidate.strategy {
RouteUnitStrategy::RoundRobin => {
rows.sort_by(|a, b| {
a.last_selected_at
.cmp(&b.last_selected_at)
.then_with(|| a.consecutive_fail_count.cmp(&b.consecutive_fail_count))
.then_with(|| b.weight.cmp(&a.weight))
.then_with(|| a.channel_id.cmp(&b.channel_id))
});
}
RouteUnitStrategy::StickUntilUnavailable => {
rows.sort_by(|a, b| {
b.priority
.cmp(&a.priority)
.then_with(|| b.weight.cmp(&a.weight))
.then_with(|| a.consecutive_fail_count.cmp(&b.consecutive_fail_count))
.then_with(|| a.channel_id.cmp(&b.channel_id))
});
}
}
rows.into_iter().next()
} }
fn cooldown_seconds_for_failure(info: &FailureInfo) -> i64 { fn cooldown_seconds_for_failure(info: &FailureInfo) -> i64 {
@@ -393,14 +614,27 @@ fn wildcard_matches(model: &str, pattern: &str) -> bool {
mod tests { mod tests {
use super::*; use super::*;
use sqlx::sqlite::SqlitePoolOptions; use sqlx::sqlite::SqlitePoolOptions;
use tokio::sync::Mutex;
static TEST_CACHE_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
async fn test_pool() -> SqlitePool { async fn test_pool() -> SqlitePool {
invalidate_route_cache();
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
.max_connections(1) .max_connections(1)
.connect("sqlite::memory:") .connect("sqlite::memory:")
.await .await
.unwrap(); .unwrap();
sqlx::query(
"CREATE TABLE settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query( sqlx::query(
"CREATE TABLE sites ( "CREATE TABLE sites (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
@@ -408,7 +642,8 @@ mod tests {
url TEXT NOT NULL, url TEXT NOT NULL,
platform TEXT NOT NULL DEFAULT 'openai', platform TEXT NOT NULL DEFAULT 'openai',
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
api_key TEXT api_key TEXT,
custom_headers TEXT
)", )",
) )
.execute(&pool) .execute(&pool)
@@ -432,8 +667,11 @@ mod tests {
site_id INTEGER NOT NULL, site_id INTEGER NOT NULL,
access_token TEXT NOT NULL, access_token TEXT NOT NULL,
api_token TEXT, api_token TEXT,
oauth_provider TEXT,
extra_config TEXT,
unit_cost REAL, unit_cost REAL,
status TEXT NOT NULL DEFAULT 'active' status TEXT NOT NULL DEFAULT 'active',
updated_at TEXT
)", )",
) )
.execute(&pool) .execute(&pool)
@@ -457,6 +695,7 @@ mod tests {
route_id INTEGER NOT NULL, route_id INTEGER NOT NULL,
account_id INTEGER NOT NULL, account_id INTEGER NOT NULL,
token_id INTEGER, token_id INTEGER,
oauth_route_unit_id INTEGER,
source_model TEXT, source_model TEXT,
priority INTEGER DEFAULT 0, priority INTEGER DEFAULT 0,
weight INTEGER DEFAULT 10, weight INTEGER DEFAULT 10,
@@ -476,6 +715,41 @@ mod tests {
.execute(&pool) .execute(&pool)
.await .await
.unwrap(); .unwrap();
sqlx::query(
"CREATE TABLE oauth_route_units (
id INTEGER PRIMARY KEY,
site_id INTEGER NOT NULL,
provider TEXT NOT NULL,
name TEXT NOT NULL,
strategy TEXT NOT NULL DEFAULT 'round_robin',
enabled INTEGER DEFAULT 1
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE oauth_route_unit_members (
id INTEGER PRIMARY KEY,
unit_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
sort_order INTEGER DEFAULT 0,
success_count INTEGER DEFAULT 0,
fail_count INTEGER DEFAULT 0,
total_latency_ms INTEGER DEFAULT 0,
total_cost REAL DEFAULT 0,
last_used_at TEXT,
last_selected_at TEXT,
last_fail_at TEXT,
consecutive_fail_count INTEGER NOT NULL DEFAULT 0,
cooldown_level INTEGER NOT NULL DEFAULT 0,
cooldown_until TEXT,
updated_at TEXT
)",
)
.execute(&pool)
.await
.unwrap();
// select_channel_for_route's COALESCE subquery joins through // select_channel_for_route's COALESCE subquery joins through
// site_api_endpoints. The table must exist or the SELECT errors // site_api_endpoints. The table must exist or the SELECT errors
// out — we leave it empty and the COALESCE falls back to sites.url. // out — we leave it empty and the COALESCE falls back to sites.url.
@@ -501,16 +775,25 @@ mod tests {
.execute(&pool) .execute(&pool)
.await .await
.unwrap(); .unwrap();
sqlx::query("INSERT INTO accounts (id, site_id, access_token, api_token) VALUES (2, 1, 'access-key-2', NULL)")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO account_tokens (id, account_id, name, token) VALUES (1, 1, 'primary', 'token-key')") sqlx::query("INSERT INTO account_tokens (id, account_id, name, token) VALUES (1, 1, 'primary', 'token-key')")
.execute(&pool) .execute(&pool)
.await .await
.unwrap(); .unwrap();
sqlx::query("INSERT INTO account_tokens (id, account_id, name, token) VALUES (2, 2, 'secondary', 'token-key-2')")
.execute(&pool)
.await
.unwrap();
pool pool
} }
#[tokio::test] #[tokio::test]
async fn resolves_exact_model_route() { async fn resolves_exact_model_route() {
let _guard = TEST_CACHE_LOCK.lock().await;
let pool = test_pool().await; let pool = test_pool().await;
sqlx::query("INSERT INTO token_routes (id, model_pattern, model_mapping, route_mode) VALUES (1, 'gpt-5.5', 'upstream-gpt-5.5', 'exact')") sqlx::query("INSERT INTO token_routes (id, model_pattern, model_mapping, route_mode) VALUES (1, 'gpt-5.5', 'upstream-gpt-5.5', 'exact')")
.execute(&pool) .execute(&pool)
@@ -530,8 +813,57 @@ mod tests {
assert_eq!(decision.selected.api_key, "token-key"); assert_eq!(decision.selected.api_key, "token-key");
} }
#[tokio::test]
async fn route_cache_is_scoped_to_database_instance() {
let _guard = TEST_CACHE_LOCK.lock().await;
let first_pool = test_pool().await;
let second_pool = test_pool().await;
sqlx::query("INSERT INTO token_routes (id, model_pattern, route_mode) VALUES (1, 'gpt-alpha', 'exact')")
.execute(&first_pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO route_channels (id, route_id, account_id, token_id) VALUES (1, 1, 1, 1)",
)
.execute(&first_pool)
.await
.unwrap();
sqlx::query("INSERT INTO token_routes (id, model_pattern, route_mode) VALUES (1, 'gpt-beta', 'exact')")
.execute(&second_pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO route_channels (id, route_id, account_id, token_id) VALUES (1, 1, 1, 1)",
)
.execute(&second_pool)
.await
.unwrap();
let first_router = TokenRouter::new(Arc::new(first_pool));
let second_router = TokenRouter::new(Arc::new(second_pool));
let first = first_router
.resolve_decision("gpt-alpha", &[])
.await
.unwrap();
assert_eq!(first.match_kind, RouteMatch::Exact);
let second = second_router
.resolve_decision("gpt-beta", &[])
.await
.unwrap();
assert_eq!(second.match_kind, RouteMatch::Exact);
assert!(second_router
.resolve_decision("gpt-alpha", &[])
.await
.is_err());
}
#[tokio::test] #[tokio::test]
async fn resolves_wildcard_route_and_skips_excluded_channel() { async fn resolves_wildcard_route_and_skips_excluded_channel() {
let _guard = TEST_CACHE_LOCK.lock().await;
let pool = test_pool().await; let pool = test_pool().await;
sqlx::query("INSERT INTO token_routes (id, model_pattern, route_mode) VALUES (1, 'gpt-*', 'pattern')") sqlx::query("INSERT INTO token_routes (id, model_pattern, route_mode) VALUES (1, 'gpt-*', 'pattern')")
.execute(&pool) .execute(&pool)
@@ -551,4 +883,373 @@ mod tests {
assert_eq!(decision.match_kind, RouteMatch::Wildcard); assert_eq!(decision.match_kind, RouteMatch::Wildcard);
assert_eq!(decision.selected.channel_id, 2); assert_eq!(decision.selected.channel_id, 2);
} }
#[tokio::test]
async fn oauth_channel_uses_access_token_instead_of_refresh_token() {
let _guard = TEST_CACHE_LOCK.lock().await;
let pool = test_pool().await;
sqlx::query(
"INSERT INTO token_routes (id, model_pattern, route_mode) VALUES (1, 'gpt-*', 'pattern')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"UPDATE accounts
SET access_token = 'oauth-access',
api_token = 'oauth-refresh',
oauth_provider = 'codex',
extra_config = ?
WHERE id = 1",
)
.bind(
serde_json::json!({
"credentialMode": "oauth",
"expires_at": (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339()
})
.to_string(),
)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO route_channels (id, route_id, account_id, token_id) VALUES (1, 1, 1, NULL)")
.execute(&pool)
.await
.unwrap();
let router = TokenRouter::new(Arc::new(pool));
let decision = router.resolve_decision("gpt-4o", &[]).await.unwrap();
assert_eq!(decision.selected.api_key, "oauth-access");
}
#[tokio::test]
async fn oauth_channel_refreshes_expired_access_token_before_selection() {
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let _guard = TEST_CACHE_LOCK.lock().await;
let pool = test_pool().await;
let mock = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.and(body_string_contains("refresh_token=oauth-refresh-old"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "oauth-access-new",
"refresh_token": "oauth-refresh-new",
"expires_in": 3600
})))
.mount(&mock)
.await;
sqlx::query(
"UPDATE sites
SET api_key = NULL,
platform = 'codex',
custom_headers = ?
WHERE id = 1",
)
.bind(
serde_json::json!({
"oauth_token_url": format!("{}/oauth/token", mock.uri())
})
.to_string(),
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"UPDATE accounts
SET access_token = 'oauth-access-old',
api_token = 'oauth-refresh-old',
oauth_provider = 'codex',
extra_config = ?
WHERE id = 1",
)
.bind(
serde_json::json!({
"credentialMode": "oauth",
"expires_at": "2000-01-01T00:00:00Z"
})
.to_string(),
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO token_routes (id, model_pattern, route_mode) VALUES (1, 'gpt-*', 'pattern')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO route_channels (id, route_id, account_id, token_id) VALUES (1, 1, 1, NULL)")
.execute(&pool)
.await
.unwrap();
let router = TokenRouter::new(Arc::new(pool.clone()));
let decision = router.resolve_decision("gpt-4o", &[]).await.unwrap();
assert_eq!(decision.selected.api_key, "oauth-access-new");
let row: (String, Option<String>, Option<String>) = sqlx::query_as(
"SELECT access_token, api_token, extra_config FROM accounts WHERE id = 1",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row.0, "oauth-access-new");
assert_eq!(row.1.as_deref(), Some("oauth-refresh-new"));
let extra: serde_json::Value = serde_json::from_str(row.2.as_deref().unwrap()).unwrap();
assert_eq!(
extra
.get("refreshToken")
.and_then(serde_json::Value::as_str),
Some("oauth-refresh-new")
);
}
#[tokio::test]
async fn route_unit_selects_one_member_and_failover_stays_inside_pool() {
let _guard = TEST_CACHE_LOCK.lock().await;
let pool = test_pool().await;
sqlx::query("INSERT INTO token_routes (id, model_pattern, route_mode) VALUES (1, 'gpt-*', 'pattern')")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO oauth_route_units (id, site_id, provider, name, strategy)
VALUES (9, 1, 'codex', 'Codex Pool', 'round_robin')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO oauth_route_unit_members (id, unit_id, account_id, sort_order) VALUES
(91, 9, 1, 0),
(92, 9, 2, 1)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO route_channels (id, route_id, account_id, token_id, oauth_route_unit_id, priority) VALUES
(1, 1, 1, 1, 9, 10),
(2, 1, 2, 2, 9, 10),
(3, 1, 1, 1, NULL, 1)",
)
.execute(&pool)
.await
.unwrap();
let router = TokenRouter::new(Arc::new(pool.clone()));
let first = router.resolve_decision("gpt-4o", &[]).await.unwrap();
assert_eq!(first.selected.channel_id, 1);
let failover = router
.resolve_next_selected("gpt-4o", &[first.selected.channel_id])
.await
.unwrap();
assert_eq!(failover.channel_id, 2);
assert_eq!(failover.api_key, "token-key-2");
router
.record_success(
failover.channel_id,
SuccessMetrics {
latency_ms: Some(123),
cost: Some(0.25),
metadata: HashMap::new(),
},
)
.await
.unwrap();
let member_success: (i64, i64, f64) = sqlx::query_as(
"SELECT success_count, total_latency_ms, total_cost
FROM oauth_route_unit_members
WHERE unit_id = 9 AND account_id = 2",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(member_success, (1, 123, 0.25));
router
.record_failure(
first.selected.channel_id,
FailureInfo {
action: "failover".to_string(),
status: Some(429),
raw_error_text: Some("rate limited".to_string()),
},
)
.await
.unwrap();
let member_failure: (i64, i64) = sqlx::query_as(
"SELECT fail_count, consecutive_fail_count
FROM oauth_route_unit_members
WHERE unit_id = 9 AND account_id = 1",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(member_failure, (1, 1));
}
#[tokio::test]
async fn route_unit_round_robin_prefers_least_recent_member() {
let _guard = TEST_CACHE_LOCK.lock().await;
let pool = test_pool().await;
sqlx::query("INSERT INTO token_routes (id, model_pattern, route_mode) VALUES (1, 'gpt-*', 'pattern')")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO oauth_route_units (id, site_id, provider, name, strategy)
VALUES (9, 1, 'codex', 'Codex Pool', 'round_robin')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO oauth_route_unit_members (id, unit_id, account_id, sort_order) VALUES
(91, 9, 1, 0),
(92, 9, 2, 1)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO route_channels (
id, route_id, account_id, token_id, oauth_route_unit_id, priority, last_selected_at
) VALUES
(1, 1, 1, 1, 9, 10, '2026-01-02 00:00:00'),
(2, 1, 2, 2, 9, 10, '2026-01-01 00:00:00')",
)
.execute(&pool)
.await
.unwrap();
let router = TokenRouter::new(Arc::new(pool.clone()));
let first = router.resolve_decision("gpt-4o", &[]).await.unwrap();
assert_eq!(first.selected.channel_id, 2);
router
.record_success(
first.selected.channel_id,
SuccessMetrics {
latency_ms: Some(88),
cost: Some(0.12),
metadata: HashMap::new(),
},
)
.await
.unwrap();
let next = router.resolve_decision("gpt-4o", &[]).await.unwrap();
assert_eq!(next.selected.channel_id, 1);
}
#[tokio::test]
async fn route_unit_stick_strategy_keeps_primary_until_it_cools_down() {
let _guard = TEST_CACHE_LOCK.lock().await;
let pool = test_pool().await;
sqlx::query("INSERT INTO token_routes (id, model_pattern, route_mode) VALUES (1, 'gpt-*', 'pattern')")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO oauth_route_units (id, site_id, provider, name, strategy)
VALUES (9, 1, 'codex', 'Codex Pool', 'stick_until_unavailable')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO oauth_route_unit_members (id, unit_id, account_id, sort_order) VALUES
(91, 9, 1, 0),
(92, 9, 2, 1)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO route_channels (
id, route_id, account_id, token_id, oauth_route_unit_id, priority, weight, last_selected_at
) VALUES
(1, 1, 1, 1, 9, 20, 10, '2026-01-02 00:00:00'),
(2, 1, 2, 2, 9, 10, 100, NULL)",
)
.execute(&pool)
.await
.unwrap();
let router = TokenRouter::new(Arc::new(pool.clone()));
let first = router.resolve_decision("gpt-4o", &[]).await.unwrap();
assert_eq!(first.selected.channel_id, 1);
let again = router.resolve_decision("gpt-4o", &[]).await.unwrap();
assert_eq!(again.selected.channel_id, 1);
router
.record_failure(
first.selected.channel_id,
FailureInfo {
action: "failover".to_string(),
status: Some(429),
raw_error_text: Some("rate limited".to_string()),
},
)
.await
.unwrap();
let failover = router.resolve_decision("gpt-4o", &[]).await.unwrap();
assert_eq!(failover.selected.channel_id, 2);
}
#[tokio::test]
async fn record_failure_clamps_cooldown_to_runtime_setting() {
let _guard = TEST_CACHE_LOCK.lock().await;
let pool = test_pool().await;
sqlx::query("INSERT INTO settings (key, value) VALUES (?, ?)")
.bind(FAILURE_COOLDOWN_MAX_SETTING_KEY)
.bind("30")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO token_routes (id, model_pattern, route_mode) VALUES (1, 'gpt-4o', 'exact')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO route_channels (id, route_id, account_id, token_id) VALUES (1, 1, 1, 1)",
)
.execute(&pool)
.await
.unwrap();
let router = TokenRouter::new(Arc::new(pool.clone()));
router
.record_failure(
1,
FailureInfo {
action: "failover".to_string(),
status: Some(429),
raw_error_text: Some("rate limited".to_string()),
},
)
.await
.unwrap();
let seconds_until: i64 = sqlx::query_scalar(
"SELECT CAST(strftime('%s', cooldown_until) AS INTEGER) - CAST(strftime('%s', 'now') AS INTEGER)
FROM route_channels
WHERE id = 1",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(
(0..=35).contains(&seconds_until),
"cooldown should be clamped near 30 seconds, got {seconds_until}"
);
}
} }
+255 -11
View File
@@ -13,6 +13,8 @@ use serde_json::{json, Value};
use std::time::Duration; use std::time::Duration;
use tracing::info; use tracing::info;
const RUNTIME_HEALTH_DEGRADED_LATENCY_MS: i64 = 5_000;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ListAccountsQuery { pub struct ListAccountsQuery {
pub site_id: Option<i64>, pub site_id: Option<i64>,
@@ -151,10 +153,23 @@ pub async fn list_accounts(
let site_by_id: std::collections::HashMap<i64, &Site> = let site_by_id: std::collections::HashMap<i64, &Site> =
sites.iter().map(|s| (s.id, s)).collect(); sites.iter().map(|s| (s.id, s)).collect();
let latest_health_rows: Vec<(i64, String, Option<i64>, Option<String>, Option<i64>, Option<String>)> = let latest_health_rows: Vec<(
i64,
String,
Option<i64>,
Option<String>,
Option<i64>,
Option<String>,
Option<String>,
Option<String>,
Option<i64>,
Option<i64>,
i64,
)> =
sqlx::query_as( sqlx::query_as(
r#" r#"
SELECT l.account_id, l.status, l.http_status, l.error_message, l.latency_ms, l.created_at SELECT l.account_id, l.status, l.http_status, l.error_message, l.latency_ms, l.created_at,
l.model_requested, l.model_actual, l.route_id, l.channel_id, l.id
FROM proxy_logs l FROM proxy_logs l
INNER JOIN ( INNER JOIN (
SELECT account_id, MAX(id) AS id SELECT account_id, MAX(id) AS id
@@ -176,14 +191,42 @@ pub async fn list_accounts(
Option<String>, Option<String>,
Option<i64>, Option<i64>,
Option<String>, Option<String>,
Option<String>,
Option<String>,
Option<i64>,
Option<i64>,
i64,
), ),
> = latest_health_rows > = latest_health_rows
.into_iter() .into_iter()
.map( .map(
|(account_id, status, http_status, error_message, latency_ms, created_at)| { |(
account_id,
status,
http_status,
error_message,
latency_ms,
created_at,
model_requested,
model_actual,
route_id,
channel_id,
log_id,
)| {
( (
account_id, account_id,
(status, http_status, error_message, latency_ms, created_at), (
status,
http_status,
error_message,
latency_ms,
created_at,
model_requested,
model_actual,
route_id,
channel_id,
log_id,
),
) )
}, },
) )
@@ -214,6 +257,11 @@ fn account_response_value(
Option<String>, Option<String>,
Option<i64>, Option<i64>,
Option<String>, Option<String>,
Option<String>,
Option<String>,
Option<i64>,
Option<i64>,
i64,
)>, )>,
) -> Value { ) -> Value {
let credential_mode = resolve_account_credential_mode(account); let credential_mode = resolve_account_credential_mode(account);
@@ -246,16 +294,46 @@ fn account_response_value(
"state": "unhealthy", "state": "unhealthy",
"reason": "连接凭证已过期,请更新凭证", "reason": "连接凭证已过期,请更新凭证",
}) })
} else if let Some((status, http_status, error_message, latency_ms, created_at)) = } else if let Some((
status,
http_status,
error_message,
latency_ms,
created_at,
model_requested,
model_actual,
route_id,
channel_id,
log_id,
)) =
runtime_health runtime_health
{ {
let ok = status == "success"; let ok = status == "success";
let is_degraded_latency =
ok && latency_ms.is_some_and(|ms| ms >= RUNTIME_HEALTH_DEGRADED_LATENCY_MS);
let model = model_actual
.as_ref()
.filter(|value| !value.trim().is_empty())
.or_else(|| model_requested.as_ref().filter(|value| !value.trim().is_empty()));
json!({ json!({
"state": if ok { "healthy" } else { "unhealthy" }, "state": if !ok {
"unhealthy"
} else if is_degraded_latency {
"degraded"
} else {
"healthy"
},
"reason": if ok { "reason": if ok {
match latency_ms { if is_degraded_latency {
Some(ms) => format!("最近代理请求成功,用时 {}ms", ms), match latency_ms {
None => "最近代理请求成功".to_string(), Some(ms) => format!("最近代理请求成功但延迟偏高,用时 {}ms", ms),
None => "最近代理请求成功但延迟偏高".to_string(),
}
} else {
match latency_ms {
Some(ms) => format!("最近代理请求成功,用时 {}ms", ms),
None => "最近代理请求成功".to_string(),
}
} }
} else { } else {
error_message error_message
@@ -265,11 +343,17 @@ fn account_response_value(
"latencyMs": latency_ms, "latencyMs": latency_ms,
"httpStatus": http_status, "httpStatus": http_status,
"checkedAt": created_at, "checkedAt": created_at,
"modelRequested": model_requested,
"modelActual": model_actual,
"recentModel": model,
"routeId": route_id,
"channelId": channel_id,
"logId": log_id,
}) })
} else if credential_mode == "apikey" || account.status == "active" { } else if credential_mode == "apikey" || account.status == "active" {
json!({ json!({
"state": "degraded", "state": "unknown",
"reason": "尚无代理请求记录,等待首次调用后更新", "reason": "尚无代理请求记录,首次调用后自动更新",
}) })
} else { } else {
json!({ json!({
@@ -1266,3 +1350,163 @@ pub async fn refresh_account_models(
} }
}))) })))
} }
#[cfg(test)]
mod tests {
use super::*;
fn account_fixture(status: &str, credential_mode: &str) -> Account {
Account {
id: 7,
site_id: 3,
username: Some("tester".to_string()),
access_token: "sk-test".to_string(),
api_token: None,
balance: Some(0.0),
balance_used: Some(0.0),
quota: None,
unit_cost: None,
value_score: None,
status: status.to_string(),
checkin_enabled: Some(false),
last_checkin_at: None,
last_balance_refresh: None,
extra_config: Some(json!({ "credentialMode": credential_mode }).to_string()),
is_pinned: Some(false),
sort_order: Some(0),
oauth_provider: None,
oauth_account_key: None,
oauth_project_id: None,
created_at: None,
updated_at: None,
}
}
fn site_fixture(status: &str) -> Site {
Site {
id: 3,
name: "Demo".to_string(),
platform: "openai".to_string(),
url: "https://example.com".to_string(),
status: status.to_string(),
api_key: None,
proxy_url: None,
use_system_proxy: None,
custom_headers: None,
external_checkin_url: None,
global_weight: None,
post_refresh_probe_enabled: None,
post_refresh_probe_model: None,
post_refresh_probe_scope: None,
post_refresh_probe_latency_threshold_ms: None,
is_pinned: Some(false),
sort_order: Some(0),
created_at: None,
updated_at: None,
}
}
#[test]
fn active_apikey_without_proxy_logs_is_pending_detection_not_degraded() {
let account = account_fixture("active", "apikey");
let site = site_fixture("active");
let value = account_response_value(&account, Some(&site), None);
let runtime_health = value
.get("runtimeHealth")
.expect("runtimeHealth should be present");
assert_eq!(
runtime_health.get("state").and_then(Value::as_str),
Some("unknown")
);
assert_eq!(
runtime_health.get("reason").and_then(Value::as_str),
Some("尚无代理请求记录,首次调用后自动更新")
);
}
#[test]
fn runtime_health_includes_recent_proxy_context_for_diagnostics() {
let account = account_fixture("active", "apikey");
let site = site_fixture("active");
let latest = (
"failed".to_string(),
Some(404),
Some("Not found".to_string()),
Some(1820),
Some("2026-06-08 10:00:00".to_string()),
Some("gpt-4o-mini".to_string()),
Some("gpt-4o-mini-2026".to_string()),
Some(77),
Some(88),
101,
);
let value = account_response_value(&account, Some(&site), Some(&latest));
let runtime_health = value
.get("runtimeHealth")
.expect("runtimeHealth should be present");
assert_eq!(
runtime_health.get("state").and_then(Value::as_str),
Some("unhealthy")
);
assert_eq!(
runtime_health.get("recentModel").and_then(Value::as_str),
Some("gpt-4o-mini-2026")
);
assert_eq!(
runtime_health.get("modelRequested").and_then(Value::as_str),
Some("gpt-4o-mini")
);
assert_eq!(
runtime_health.get("routeId").and_then(Value::as_i64),
Some(77)
);
assert_eq!(
runtime_health.get("channelId").and_then(Value::as_i64),
Some(88)
);
assert_eq!(
runtime_health.get("logId").and_then(Value::as_i64),
Some(101)
);
}
#[test]
fn successful_but_slow_proxy_log_marks_runtime_health_degraded() {
let account = account_fixture("active", "apikey");
let site = site_fixture("active");
let latest = (
"success".to_string(),
Some(200),
None,
Some(7928),
Some("2026-06-08 10:00:00".to_string()),
Some("gpt-4o-mini".to_string()),
Some("gpt-4o-mini".to_string()),
Some(77),
Some(88),
101,
);
let value = account_response_value(&account, Some(&site), Some(&latest));
let runtime_health = value
.get("runtimeHealth")
.expect("runtimeHealth should be present");
assert_eq!(
runtime_health.get("state").and_then(Value::as_str),
Some("degraded")
);
assert_eq!(
runtime_health.get("reason").and_then(Value::as_str),
Some("最近代理请求成功但延迟偏高,用时 7928ms")
);
assert_eq!(
runtime_health.get("latencyMs").and_then(Value::as_i64),
Some(7928)
);
}
}
+1669 -54
View File
File diff suppressed because it is too large Load Diff
+63 -15
View File
@@ -24,7 +24,7 @@ mod sites;
mod tokens; mod tokens;
mod users; mod users;
/// Middleware to require admin authentication via config token or admin JWT. /// Middleware to require admin authentication via the configured admin API token.
async fn require_admin_auth( async fn require_admin_auth(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -37,20 +37,8 @@ async fn require_admin_auth(
.and_then(|v| v.strip_prefix("Bearer ")) .and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?; .ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
if auth_header == state.config.auth.auth_token { if auth_header != state.config.auth.auth_token {
return Ok(next.run(request).await); return Err(AppError::Unauthorized("Invalid admin token".into()));
}
match crate::auth::jwt::validate_jwt(&state.config.auth.jwt_secret, auth_header) {
Ok(claims) if claims.role == "admin" => {}
Ok(_) => {
return Err(AppError::Forbidden(
"Admin account is required for this route".into(),
));
}
Err(_) => {
return Err(AppError::Unauthorized("Invalid admin token".into()));
}
} }
Ok(next.run(request).await) Ok(next.run(request).await)
@@ -73,6 +61,15 @@ pub fn routes(state: AppState) -> Router<AppState> {
.route( .route(
"/api/sites/{id}/disabled-models", "/api/sites/{id}/disabled-models",
get(sites::list_site_disabled_models).put(sites::update_site_disabled_models), get(sites::list_site_disabled_models).put(sites::update_site_disabled_models),
)
.route(
"/api/sites/{id}/available-models",
get(sites::list_site_available_models),
)
.route("/api/sites/{id}/probe-now", post(sites::probe_site_now))
.route(
"/api/sites/{id}/probe-stream",
get(sites::probe_site_stream),
); );
let accounts_routes = Router::new() let accounts_routes = Router::new()
@@ -289,9 +286,15 @@ async fn update_setting(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::auth::jwt::generate_jwt;
use crate::config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig}; use crate::config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig};
use crate::services::builtin_keys::BuiltinKeys; use crate::services::builtin_keys::BuiltinKeys;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use sqlx::sqlite::SqlitePoolOptions; use sqlx::sqlite::SqlitePoolOptions;
use tower::ServiceExt as _;
async fn settings_state() -> AppState { async fn settings_state() -> AppState {
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
@@ -414,4 +417,49 @@ mod tests {
.expect("update ok"); .expect("update ok");
assert_eq!(resp.0["data"]["value"], "Renamed"); assert_eq!(resp.0["data"]["value"], "Renamed");
} }
#[tokio::test]
async fn admin_routes_accept_config_token_and_reject_admin_jwt() {
let state = settings_state().await;
let app = routes(state.clone()).with_state(state.clone());
let token_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/settings/auth/info")
.header("Authorization", "Bearer auth")
.body(Body::empty())
.unwrap(),
)
.await
.expect("token request");
assert_eq!(token_response.status(), StatusCode::OK);
let admin_jwt = generate_jwt(&state.config.auth.jwt_secret, "1", "admin", 1)
.expect("admin jwt");
let jwt_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/settings/auth/info")
.header("Authorization", format!("Bearer {admin_jwt}"))
.body(Body::empty())
.unwrap(),
)
.await
.expect("jwt request");
assert_eq!(jwt_response.status(), StatusCode::UNAUTHORIZED);
let missing_response = app
.oneshot(
Request::builder()
.uri("/api/settings/auth/info")
.body(Body::empty())
.unwrap(),
)
.await
.expect("missing auth request");
assert_eq!(missing_response.status(), StatusCode::UNAUTHORIZED);
}
} }
+894 -2
View File
@@ -1,16 +1,27 @@
use crate::{ use crate::{
db::models::site::Site, db::models::site::Site,
error::{AppError, AppResult}, error::{AppError, AppResult},
proxy::{conductor::SuccessMetrics, executors, token_router::TokenRouter},
services::oauth::oauth_http_client,
AppState, AppState,
}; };
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
response::sse::{Event, Sse},
Json, Json,
}; };
use futures::Stream;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use tracing::info; use std::{
collections::HashSet,
convert::Infallible,
time::{Duration, Instant},
};
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
use tokio_stream::wrappers::UnboundedReceiverStream;
use tracing::{info, warn};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct DetectSiteRequest { pub struct DetectSiteRequest {
@@ -398,12 +409,89 @@ async fn site_api_endpoints(state: &AppState, site_id: i64) -> AppResult<Vec<Val
.collect()) .collect())
} }
async fn resolve_site_api_base_url(state: &AppState, site: &Site) -> AppResult<String> {
let endpoint: Option<String> = sqlx::query_scalar(
"SELECT url
FROM site_api_endpoints
WHERE site_id = ?
AND COALESCE(enabled, 1) = 1
AND (cooldown_until IS NULL OR cooldown_until <= datetime('now'))
ORDER BY COALESCE(sort_order, 0), id
LIMIT 1",
)
.bind(site.id)
.fetch_optional(&*state.db)
.await?;
let raw_url = endpoint.as_deref().unwrap_or(&site.url).trim();
if raw_url.is_empty() {
return Err(AppError::BadRequest("site url is required".into()));
}
let normalized = raw_url.trim_end_matches('/').to_string();
reqwest::Url::parse(&normalized)
.map_err(|err| AppError::BadRequest(format!("Invalid site API URL: {}", err)))?;
Ok(normalized)
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdateDisabledModelsRequest { pub struct UpdateDisabledModelsRequest {
#[serde(default)] #[serde(default)]
pub models: Vec<String>, pub models: Vec<String>,
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProbeSiteOptions {
pub scope: Option<String>,
pub model_name: Option<String>,
pub latency_threshold_ms: Option<i64>,
}
#[derive(Debug, Clone)]
struct SiteProbeModel {
name: String,
source_account_id: i64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct SiteProbeModelResult {
model_name: String,
status: String,
latency_ms: Option<i64>,
latency_exceeded: bool,
reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
http_status: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
stage: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
source: Option<&'static str>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct SiteProbeSummary {
site_id: i64,
scope: String,
models_count: usize,
probed: usize,
supported: usize,
unsupported: usize,
disabled_models: Vec<String>,
results: Vec<SiteProbeModelResult>,
}
#[derive(Debug, Clone)]
struct SiteProbePlan {
site: Site,
base_url: String,
scope: String,
latency_threshold_ms: i64,
models: Vec<SiteProbeModel>,
}
/// GET /api/sites/{id}/disabled-models — list disabled model names for a site. /// GET /api/sites/{id}/disabled-models — list disabled model names for a site.
pub async fn list_site_disabled_models( pub async fn list_site_disabled_models(
State(state): State<AppState>, State(state): State<AppState>,
@@ -477,6 +565,727 @@ pub async fn update_site_disabled_models(
Ok(Json(json!({ "models": normalised }))) Ok(Json(json!({ "models": normalised })))
} }
/// GET /api/sites/{id}/available-models — list currently known available models for a site.
pub async fn list_site_available_models(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
ensure_site_exists(&state, id).await?;
let models = site_available_model_names(&state, id).await?;
Ok(Json(json!({ "models": models })))
}
/// POST /api/sites/{id}/probe-now — actively probe one or all known models for a site.
pub async fn probe_site_now(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(options): Json<ProbeSiteOptions>,
) -> AppResult<Json<Value>> {
let plan = build_probe_plan(&state, id, options).await?;
let summary = run_site_probe_plan(state, plan, None).await;
let summary = summary?;
Ok(Json(json!({ "success": true, "probe": summary })))
}
/// GET /api/sites/{id}/probe-stream — stream active site probe progress as SSE.
pub async fn probe_site_stream(
State(state): State<AppState>,
Path(id): Path<i64>,
Query(options): Query<ProbeSiteOptions>,
) -> AppResult<Sse<impl Stream<Item = Result<Event, Infallible>>>> {
let plan = build_probe_plan(&state, id, options).await?;
let (tx, rx) = unbounded_channel::<Result<Event, Infallible>>();
tokio::spawn(async move {
if let Err(err) = run_site_probe_plan(state, plan, Some(tx.clone())).await {
send_probe_sse(&tx, "error", probe_error_payload(&err, None, None));
}
});
Ok(Sse::new(UnboundedReceiverStream::new(rx)))
}
async fn ensure_site_exists(state: &AppState, id: i64) -> AppResult<()> {
let exists: Option<i64> = sqlx::query_scalar("SELECT id FROM sites WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?;
if exists.is_none() {
return Err(AppError::NotFound("site not found".into()));
}
Ok(())
}
async fn site_available_model_names(state: &AppState, site_id: i64) -> AppResult<Vec<String>> {
let rows: Vec<String> = sqlx::query_scalar(
"SELECT DISTINCT ma.model_name
FROM model_availability ma
JOIN accounts a ON a.id = ma.account_id
WHERE a.site_id = ?
AND a.status = 'active'
AND COALESCE(ma.available, 0) = 1
AND TRIM(COALESCE(ma.model_name, '')) <> ''
ORDER BY ma.model_name ASC",
)
.bind(site_id)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn build_probe_plan(
state: &AppState,
site_id: i64,
options: ProbeSiteOptions,
) -> AppResult<SiteProbePlan> {
let site: Site = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
.bind(site_id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound("site not found".into()))?;
if site.status != "active" {
return Err(AppError::BadRequest("站点未启用,无法执行探测".into()));
}
let scope = match options
.scope
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("single")
{
"all" => "all",
"single" => "single",
_ => return Err(AppError::BadRequest("scope must be single or all".into())),
}
.to_string();
let latency_threshold_ms = options.latency_threshold_ms.unwrap_or(0).max(0);
let base_url = resolve_site_api_base_url(state, &site).await?;
let requested_model = options
.model_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let models = if scope == "all" {
site_probe_models(state, site_id, None).await?
} else if let Some(model_name) = requested_model {
site_probe_models(state, site_id, Some(model_name))
.await?
.into_iter()
.next()
.map(|model| vec![model])
.unwrap_or_else(|| {
vec![SiteProbeModel {
name: model_name.to_string(),
source_account_id: 0,
}]
})
} else {
site_probe_models(state, site_id, None)
.await?
.into_iter()
.take(1)
.collect()
};
if models.is_empty() {
return Err(AppError::BadRequest(
"当前站点还没有可探测模型,请先刷新账号模型列表".into(),
));
}
Ok(SiteProbePlan {
site,
base_url,
scope,
latency_threshold_ms,
models,
})
}
async fn site_probe_models(
state: &AppState,
site_id: i64,
model_name: Option<&str>,
) -> AppResult<Vec<SiteProbeModel>> {
let rows: Vec<(String, i64)> = if let Some(model_name) = model_name {
sqlx::query_as(
"SELECT ma.model_name, MIN(a.id) AS source_account_id
FROM model_availability ma
JOIN accounts a ON a.id = ma.account_id
WHERE a.site_id = ?
AND a.status = 'active'
AND COALESCE(ma.available, 0) = 1
AND ma.model_name = ?
GROUP BY ma.model_name
ORDER BY ma.model_name ASC",
)
.bind(site_id)
.bind(model_name)
.fetch_all(&*state.db)
.await?
} else {
sqlx::query_as(
"SELECT ma.model_name, MIN(a.id) AS source_account_id
FROM model_availability ma
JOIN accounts a ON a.id = ma.account_id
WHERE a.site_id = ?
AND a.status = 'active'
AND COALESCE(ma.available, 0) = 1
AND TRIM(COALESCE(ma.model_name, '')) <> ''
GROUP BY ma.model_name
ORDER BY ma.model_name ASC",
)
.bind(site_id)
.fetch_all(&*state.db)
.await?
};
Ok(rows
.into_iter()
.map(|(name, source_account_id)| SiteProbeModel {
name,
source_account_id,
})
.collect())
}
async fn run_site_probe_plan(
state: AppState,
plan: SiteProbePlan,
tx: Option<UnboundedSender<Result<Event, Infallible>>>,
) -> AppResult<SiteProbeSummary> {
if let Some(tx) = &tx {
send_probe_sse(
tx,
"start",
json!({
"siteId": plan.site.id,
"scope": plan.scope,
"modelsCount": plan.models.len(),
"latencyThresholdMs": plan.latency_threshold_ms,
}),
);
}
let mut results = Vec::with_capacity(plan.models.len());
let mut disabled_models = Vec::new();
let mut disabled_seen = HashSet::new();
for model in &plan.models {
let result = probe_site_model(&state, &plan, model).await;
if let Some(tx) = &tx {
send_probe_sse(tx, "model", json!(&result));
}
if result.status == "unsupported" {
if disabled_seen.insert(result.model_name.clone()) {
disabled_models.push(result.model_name.clone());
}
if let Err(err) = disable_site_model(&state, plan.site.id, &result.model_name).await {
warn!(
site_id = plan.site.id,
model = %result.model_name,
error = %err,
"failed to add probed model to site disabled list"
);
} else if let Some(tx) = &tx {
send_probe_sse(
tx,
"action",
json!({ "action": "disabled", "modelName": result.model_name }),
);
}
}
results.push(result);
}
if !disabled_models.is_empty() {
spawn_route_rebuild(state.clone());
}
let supported = results
.iter()
.filter(|result| result.status == "supported")
.count();
let unsupported = results
.iter()
.filter(|result| result.status == "unsupported")
.count();
let summary = SiteProbeSummary {
site_id: plan.site.id,
scope: plan.scope,
models_count: results.len(),
probed: results.len(),
supported,
unsupported,
disabled_models,
results,
};
if let Some(tx) = &tx {
send_probe_sse(
tx,
"complete",
json!({
"siteId": summary.site_id,
"scope": summary.scope,
"modelsCount": summary.models_count,
"probed": summary.probed,
"supported": summary.supported,
"unsupported": summary.unsupported,
"disabledModels": summary.disabled_models,
}),
);
}
Ok(summary)
}
async fn probe_site_model(
state: &AppState,
plan: &SiteProbePlan,
model: &SiteProbeModel,
) -> SiteProbeModelResult {
let started_at = Instant::now();
let mut selected = match resolve_probe_channel(state, plan, model).await {
Ok(selected) => selected,
Err(err) => {
let diagnostics = probe_model_error_diagnostics(&err, "model");
return SiteProbeModelResult {
model_name: model.name.clone(),
status: "unsupported".to_string(),
latency_ms: None,
latency_exceeded: false,
reason: Some(probe_error_message(&err)),
http_status: diagnostics.http_status,
stage: Some(diagnostics.stage),
source: Some(diagnostics.source),
};
}
};
selected.base_url = plan.base_url.clone();
let request_body = json!({
"model": selected.actual_model,
"messages": [
{ "role": "user", "content": "ping" }
],
"max_tokens": 1,
"stream": false,
});
let mut probe_state = state.clone();
match site_probe_http_client(state, &plan.site).await {
Ok(client) => probe_state.http = client,
Err(err) => {
let diagnostics = probe_model_error_diagnostics(&err, "connect");
return SiteProbeModelResult {
model_name: model.name.clone(),
status: "unsupported".to_string(),
latency_ms: None,
latency_exceeded: false,
reason: Some(probe_error_message(&err)),
http_status: diagnostics.http_status,
stage: Some(diagnostics.stage),
source: Some(diagnostics.source),
};
}
}
let timeout_secs = state.config.proxy.upstream_timeout_secs.max(10);
let response = match tokio::time::timeout(
Duration::from_secs(timeout_secs),
executors::execute_chat_completions(
probe_state,
selected_channel_to_upstream_route(&selected),
request_body,
),
)
.await
{
Ok(result) => result,
Err(_) => Err(AppError::GatewayTimeout(format!(
"探测请求超时({}s)",
timeout_secs
))),
};
let latency_ms = started_at.elapsed().as_millis().min(i64::MAX as u128) as i64;
let latency_exceeded = plan.latency_threshold_ms > 0 && latency_ms > plan.latency_threshold_ms;
match response {
Ok(_) if latency_exceeded => {
if let Err(err) =
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, true)
.await
{
warn!(error = %err, model = %model.name, "failed to save probe latency");
}
SiteProbeModelResult {
model_name: model.name.clone(),
status: "unsupported".to_string(),
latency_ms: Some(latency_ms),
latency_exceeded: true,
reason: Some(format!("响应延迟 {}ms 超过阈值 {}ms", latency_ms, plan.latency_threshold_ms)),
http_status: None,
stage: Some("model"),
source: Some("probe"),
}
}
Ok(_) => {
if let Some(channel_id) = selected_channel_id(&selected) {
let router = TokenRouter::new(state.db.clone());
if let Err(err) = router
.record_success(
channel_id,
SuccessMetrics {
latency_ms: Some(latency_ms as u64),
cost: None,
metadata: Default::default(),
},
)
.await
{
warn!(channel_id, error = %err, "failed to record probe success");
}
}
if let Err(err) =
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, true)
.await
{
warn!(error = %err, model = %model.name, "failed to save probe latency");
}
SiteProbeModelResult {
model_name: model.name.clone(),
status: "supported".to_string(),
latency_ms: Some(latency_ms),
latency_exceeded: false,
reason: None,
http_status: None,
stage: Some("model"),
source: Some("upstream"),
}
}
Err(err) => {
let diagnostics = probe_model_error_diagnostics(&err, "model");
if let Some(channel_id) = selected_channel_id(&selected) {
let router = TokenRouter::new(state.db.clone());
if let Err(record_err) = router
.record_failure(
channel_id,
crate::proxy::conductor::FailureInfo {
action: "terminal".to_string(),
status: diagnostics.http_status,
raw_error_text: Some(probe_error_message(&err)),
},
)
.await
{
warn!(channel_id, error = %record_err, "failed to record probe failure");
}
}
if let Err(update_err) =
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, false)
.await
{
warn!(error = %update_err, model = %model.name, "failed to save failed probe state");
}
SiteProbeModelResult {
model_name: model.name.clone(),
status: "unsupported".to_string(),
latency_ms: Some(latency_ms),
latency_exceeded: false,
reason: Some(probe_error_message(&err)),
http_status: diagnostics.http_status,
stage: Some(diagnostics.stage),
source: Some(diagnostics.source),
}
}
}
}
async fn resolve_probe_channel(
state: &AppState,
plan: &SiteProbePlan,
model: &SiteProbeModel,
) -> AppResult<crate::proxy::conductor::SelectedChannel> {
let router = TokenRouter::new(state.db.clone());
if let Ok(selected) = router.resolve_selected(&model.name).await {
if selected.site_id == plan.site.id {
return Ok(selected);
}
}
let row: Option<(i64, String, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT id, access_token, api_token, oauth_provider
FROM accounts
WHERE id = ?
AND site_id = ?
AND status = 'active'
LIMIT 1",
)
.bind(model.source_account_id)
.bind(plan.site.id)
.fetch_optional(&*state.db)
.await?;
let (account_id, access_token, api_token, oauth_provider) = match row {
Some(row) => row,
None => {
let fallback: Option<(i64, String, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT id, access_token, api_token, oauth_provider
FROM accounts
WHERE site_id = ?
AND status = 'active'
ORDER BY sort_order, id
LIMIT 1",
)
.bind(plan.site.id)
.fetch_optional(&*state.db)
.await?;
fallback.ok_or_else(|| AppError::BadRequest("当前站点没有启用中的账号,无法探测".into()))?
}
};
let api_key = if oauth_provider
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
{
crate::services::oauth::OAuthService::new(state.db.clone())
.refresh_token(account_id, plan.site.id)
.await?
.access_token
} else {
api_token.unwrap_or(access_token)
}
.trim()
.to_string();
let api_key = if api_key.is_empty() {
plan.site.api_key.clone().unwrap_or_default()
} else {
api_key
};
if api_key.trim().is_empty() {
return Err(AppError::BadRequest("当前站点账号缺少可用 Token,无法探测".into()));
}
Ok(crate::proxy::conductor::SelectedChannel {
channel_id: 0,
route_id: None,
site_id: plan.site.id,
account_id: Some(account_id),
account_unit_cost: None,
base_url: plan.base_url.clone(),
api_key,
model: model.name.clone(),
actual_model: model.name.clone(),
token_name: None,
token_value: None,
})
}
async fn site_probe_http_client(state: &AppState, site: &Site) -> AppResult<reqwest::Client> {
let has_custom_proxy = site
.proxy_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
if has_custom_proxy || site.use_system_proxy.unwrap_or(false) {
return oauth_http_client(
&state.db,
site.proxy_url.as_deref(),
site.use_system_proxy.unwrap_or(false),
)
.await;
}
Ok(state.http.clone())
}
fn selected_channel_to_upstream_route(
selected: &crate::proxy::conductor::SelectedChannel,
) -> crate::proxy::executors::UpstreamRoute {
crate::proxy::executors::UpstreamRoute {
base_url: selected.base_url.clone(),
api_key: selected.api_key.clone(),
model: selected.actual_model.clone(),
}
}
fn selected_channel_id(selected: &crate::proxy::conductor::SelectedChannel) -> Option<i64> {
(selected.channel_id > 0).then_some(selected.channel_id)
}
async fn update_model_probe_latency(
state: &AppState,
account_id: Option<i64>,
model_name: &str,
latency_ms: i64,
available: bool,
) -> AppResult<()> {
let Some(account_id) = account_id else {
return Ok(());
};
sqlx::query(
"UPDATE model_availability
SET latency_ms = ?,
available = ?,
checked_at = datetime('now')
WHERE account_id = ?
AND model_name = ?",
)
.bind(latency_ms)
.bind(available)
.bind(account_id)
.bind(model_name)
.execute(&*state.db)
.await?;
Ok(())
}
async fn disable_site_model(state: &AppState, site_id: i64, model_name: &str) -> AppResult<()> {
sqlx::query(
"INSERT INTO site_disabled_models (site_id, model_name)
VALUES (?, ?)
ON CONFLICT(site_id, model_name) DO NOTHING",
)
.bind(site_id)
.bind(model_name)
.execute(&*state.db)
.await?;
Ok(())
}
fn send_probe_sse(
tx: &UnboundedSender<Result<Event, Infallible>>,
event_type: &str,
payload: Value,
) -> bool {
tx.send(Ok(Event::default()
.event(event_type)
.json_data(payload)
.unwrap_or_else(|_| Event::default().event(event_type).data("{}"))))
.is_ok()
}
fn probe_error_message(err: &AppError) -> String {
match err {
AppError::NotFound(message)
| AppError::BadRequest(message)
| AppError::Forbidden(message)
| AppError::Unauthorized(message)
| AppError::ServiceUnavailable(message)
| AppError::GatewayTimeout(message) => message.clone(),
AppError::UpstreamError(message) => simplify_upstream_error_message(message),
AppError::RateLimited => "触发频率限制".to_string(),
AppError::RateLimitedWithRetry { message, .. } => message.clone(),
AppError::Internal(err) => err.to_string(),
AppError::Database(err) => err.to_string(),
}
}
fn probe_error_payload(err: &AppError, site_id: Option<i64>, model_name: Option<&str>) -> Value {
let message = probe_error_message(err);
let diagnostics = probe_model_error_diagnostics(err, "stream");
json!({
"message": message,
"httpStatus": diagnostics.http_status,
"siteId": site_id,
"modelName": model_name,
"stage": diagnostics.stage,
"source": diagnostics.source,
})
}
fn simplify_upstream_error_message(message: &str) -> String {
let marker = "Upstream returned ";
if let Some(start) = message.find(marker).map(|index| index + marker.len()) {
let tail = &message[start..];
if let Some((status, body)) = tail.split_once(':') {
let body = body.trim();
return if body.is_empty() {
format!("上游返回 HTTP {}", status.trim())
} else {
format!(
"上游返回 HTTP {}:{}",
status.trim(),
body.chars().take(180).collect::<String>()
)
};
}
}
message.chars().take(220).collect()
}
fn upstream_status_from_error(err: &AppError) -> Option<u16> {
match err {
AppError::UpstreamError(message) => extract_http_status_from_text(message),
_ => None,
}
}
fn extract_http_status_from_text(message: &str) -> Option<u16> {
message
.split(|ch: char| !ch.is_ascii_digit())
.find_map(|token| {
if token.len() != 3 {
return None;
}
let status = token.parse::<u16>().ok()?;
if (100..=599).contains(&status) {
Some(status)
} else {
None
}
})
}
#[derive(Debug, Clone, Copy)]
struct ProbeErrorDiagnostics {
http_status: Option<u16>,
stage: &'static str,
source: &'static str,
}
fn probe_model_error_diagnostics(
err: &AppError,
fallback_stage: &'static str,
) -> ProbeErrorDiagnostics {
if matches!(err, AppError::UpstreamError(_)) {
return ProbeErrorDiagnostics {
http_status: upstream_status_from_error(err),
stage: "model",
source: "upstream",
};
}
ProbeErrorDiagnostics {
http_status: None,
stage: fallback_stage,
source: "probe",
}
}
fn spawn_route_rebuild(state: AppState) {
tokio::spawn(async move {
match crate::services::route_rebuild::rebuild_routes(&state).await {
Ok(summary) => {
info!(
created_routes = summary.created_routes,
reused_routes = summary.reused_routes,
created_channels = summary.created_channels,
deleted_channels = summary.deleted_channels,
"route rebuild completed after site probe"
);
}
Err(err) => {
warn!(error = %err, "route rebuild after site probe failed");
}
}
});
}
fn normalize_site_url(raw: &str) -> AppResult<String> { fn normalize_site_url(raw: &str) -> AppResult<String> {
let trimmed = raw.trim().trim_end_matches('/').to_string(); let trimmed = raw.trim().trim_end_matches('/').to_string();
if trimmed.is_empty() { if trimmed.is_empty() {
@@ -645,3 +1454,86 @@ async fn replace_site_api_endpoints(
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn probe_options_accept_camel_case_payload() {
let options: ProbeSiteOptions = serde_json::from_value(json!({
"scope": "single",
"modelName": "gpt-4o",
"latencyThresholdMs": 1800
}))
.expect("probe options should deserialize from frontend payload");
assert_eq!(options.scope.as_deref(), Some("single"));
assert_eq!(options.model_name.as_deref(), Some("gpt-4o"));
assert_eq!(options.latency_threshold_ms, Some(1800));
}
#[test]
fn simplify_upstream_error_message_names_http_status() {
let message = simplify_upstream_error_message(
"Upstream returned 404 Not Found: {\"error\":\"model missing\"}",
);
assert_eq!(
message,
"上游返回 HTTP 404 Not Found:{\"error\":\"model missing\"}"
);
}
#[test]
fn upstream_status_from_error_extracts_probe_failure_status() {
let err = AppError::UpstreamError("Upstream returned 503 Service Unavailable: busy".into());
assert_eq!(upstream_status_from_error(&err), Some(503));
}
#[test]
fn upstream_status_from_error_extracts_localized_probe_failure_status() {
let err = AppError::UpstreamError("上游返回 HTTP 404 Not Found:model missing".into());
assert_eq!(upstream_status_from_error(&err), Some(404));
}
#[test]
fn site_probe_model_result_serializes_structured_diagnostics() {
let result = SiteProbeModelResult {
model_name: "gpt-4o".to_string(),
status: "unsupported".to_string(),
latency_ms: Some(88),
latency_exceeded: false,
reason: Some("Not found".to_string()),
http_status: Some(404),
stage: Some("model"),
source: Some("upstream"),
};
let payload = serde_json::to_value(&result).expect("model result should serialize");
assert_eq!(payload.get("httpStatus").and_then(Value::as_u64), Some(404));
assert_eq!(payload.get("stage").and_then(Value::as_str), Some("model"));
assert_eq!(payload.get("source").and_then(Value::as_str), Some("upstream"));
assert_eq!(payload.get("modelName").and_then(Value::as_str), Some("gpt-4o"));
}
#[test]
fn probe_error_payload_preserves_upstream_status_for_sse_diagnostics() {
let err = AppError::UpstreamError("Upstream returned 404 Not Found: model missing".into());
let payload = probe_error_payload(&err, Some(7), Some("gpt-4o"));
assert_eq!(payload.get("httpStatus").and_then(Value::as_u64), Some(404));
assert_eq!(payload.get("stage").and_then(Value::as_str), Some("model"));
assert_eq!(payload.get("source").and_then(Value::as_str), Some("upstream"));
assert_eq!(payload.get("siteId").and_then(Value::as_i64), Some(7));
assert_eq!(payload.get("modelName").and_then(Value::as_str), Some("gpt-4o"));
assert_eq!(
payload.get("message").and_then(Value::as_str),
Some("上游返回 HTTP 404 Not Found:model missing")
);
}
}
+150 -1
View File
@@ -324,7 +324,10 @@ pub async fn get_user_usage(
// Recent logs // Recent logs
let recent_logs: Vec<Value> = sqlx::query( let recent_logs: Vec<Value> = sqlx::query(
r#" r#"
SELECT l.* SELECT
l.*,
k.id AS downstream_api_key_id,
k.name AS downstream_key_name
FROM usage_logs l FROM usage_logs l
JOIN downstream_api_keys k ON l.downstream_key_id = k.id JOIN downstream_api_keys k ON l.downstream_key_id = k.id
WHERE k.user_id = ? WHERE k.user_id = ?
@@ -341,6 +344,11 @@ pub async fn get_user_usage(
json!({ json!({
"id": row.get::<i64, _>("id"), "id": row.get::<i64, _>("id"),
"model": row.get::<Option<String>, _>("model"), "model": row.get::<Option<String>, _>("model"),
"downstreamApiKeyId": row.get::<Option<i64>, _>("downstream_api_key_id"),
"downstreamKeyName": row.get::<Option<String>, _>("downstream_key_name"),
"status": "success",
"httpStatus": 200,
"source": "usage-logs",
"promptTokens": row.get::<Option<i64>, _>("prompt_tokens").unwrap_or(0), "promptTokens": row.get::<Option<i64>, _>("prompt_tokens").unwrap_or(0),
"completionTokens": row.get::<Option<i64>, _>("completion_tokens").unwrap_or(0), "completionTokens": row.get::<Option<i64>, _>("completion_tokens").unwrap_or(0),
"totalTokens": row.get::<Option<i64>, _>("total_tokens").unwrap_or(0), "totalTokens": row.get::<Option<i64>, _>("total_tokens").unwrap_or(0),
@@ -699,3 +707,144 @@ pub async fn update_user_key(
Ok(Json(json!({ "data": updated }))) Ok(Json(json!({ "data": updated })))
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::{
config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig},
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
};
use axum::extract::State;
use sqlx::sqlite::SqlitePoolOptions;
async fn test_state() -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("in-memory database");
sqlx::query(
"CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
username TEXT NOT NULL,
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(&pool)
.await
.expect("users table");
sqlx::query(
"CREATE TABLE downstream_api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
key TEXT NOT NULL,
description TEXT,
group_name TEXT,
tags TEXT,
enabled INTEGER DEFAULT 1,
expires_at TEXT,
max_cost REAL,
used_cost REAL DEFAULT 0,
max_requests INTEGER,
used_requests INTEGER DEFAULT 0,
supported_models TEXT,
allowed_route_ids TEXT,
site_weight_multipliers TEXT,
excluded_site_ids TEXT,
excluded_credential_refs TEXT,
last_used_at TEXT,
user_id INTEGER,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(&pool)
.await
.expect("downstream keys table");
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".into(),
port: 0,
log_level: "info".into(),
cors_origins: vec![],
},
db: DbConfig {
url: "sqlite::memory:".into(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "test-secret".into(),
jwt_expiry_hours: 1,
auth_token: "auth".into(),
proxy_token: "proxy".into(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 3,
proxy_log_retention_days: 7,
template_proxy_url: None,
},
comfyui: Default::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-test".into()),
},
RuntimeAuthSettings::from_token_for_test("proxy"),
)
}
#[tokio::test]
async fn update_user_persists_admin_role_for_existing_user() {
let state = test_state().await;
let user_id = sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'user', 'active')",
)
.bind("Regular")
.bind("regular@example.test")
.bind("hash")
.execute(&*state.db)
.await
.expect("insert user")
.last_insert_rowid();
let Json(value) = update_user(
State(state.clone()),
Path(user_id),
Json(UpdateUserRequest {
username: None,
email: None,
password: None,
role: Some("admin".into()),
status: None,
}),
)
.await
.expect("update user");
assert_eq!(value["data"]["role"], "admin");
let role: String = sqlx::query_scalar("SELECT role FROM users WHERE id = ?")
.bind(user_id)
.fetch_one(&*state.db)
.await
.expect("read role");
assert_eq!(role, "admin");
}
}
+867 -13
View File
@@ -10,14 +10,14 @@ use crate::{
}; };
use axum::{ use axum::{
extract::{ConnectInfo, Path, State}, extract::{ConnectInfo, Path, State},
http::HeaderMap, http::{header, HeaderMap},
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::net::SocketAddr; use std::net::{IpAddr, SocketAddr};
use tracing::info; use tracing::{info, warn};
// ── Request / Response types ───────────────────────────────────────── // ── Request / Response types ─────────────────────────────────────────
@@ -70,7 +70,6 @@ pub fn routes() -> Router<AppState> {
.route("/api/users/register", post(register)) .route("/api/users/register", post(register))
.route("/api/auth/login", post(login)) .route("/api/auth/login", post(login))
.route("/api/users/login", post(login)) .route("/api/users/login", post(login))
.route("/api/auth/login-api-key", post(login_api_key))
.route("/api/users/login-api-key", post(login_api_key)) .route("/api/users/login-api-key", post(login_api_key))
.route("/api/auth/me", get(me)) .route("/api/auth/me", get(me))
.route("/api/users/me", get(me).patch(update_me)) .route("/api/users/me", get(me).patch(update_me))
@@ -229,24 +228,82 @@ async fn register(
/// POST /api/auth/login /// POST /api/auth/login
async fn login( async fn login(
State(state): State<AppState>, State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(payload): Json<LoginRequest>, Json(payload): Json<LoginRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
info!("Login: {}", payload.email); info!("Login: {}", payload.email);
let client_ip = client_ip_from_headers(&headers, addr);
let user_agent = user_agent_from_headers(&headers);
let email = payload.email.trim().to_string();
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?") let user: User = if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
.bind(&payload.email) .bind(&email)
.fetch_optional(&*state.db) .fetch_optional(&*state.db)
.await? .await?
.ok_or_else(|| AppError::Unauthorized("Invalid email or password".into()))?; {
user
} else {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::Password,
success: false,
user: None,
identifier: Some(&email),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Invalid email or password"),
})
.await;
return Err(AppError::Unauthorized("Invalid email or password".into()));
};
if user.status != "active" { if user.status != "active" {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::Password,
success: false,
user: Some(&user),
identifier: Some(&email),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Account is disabled"),
})
.await;
return Err(AppError::Forbidden("Account is disabled".into())); return Err(AppError::Forbidden("Account is disabled".into()));
} }
if user.role == "admin" {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::Password,
success: false,
user: Some(&user),
identifier: Some(&email),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Admin users must sign in with the admin API token"),
})
.await;
return Err(AppError::Forbidden(
"Admin users must sign in with the admin API token".into(),
));
}
let valid = verify_password(&payload.password, &user.password_hash) let valid = verify_password(&payload.password, &user.password_hash)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?; .map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
if !valid { if !valid {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::Password,
success: false,
user: Some(&user),
identifier: Some(&email),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Invalid email or password"),
})
.await;
return Err(AppError::Unauthorized("Invalid email or password".into())); return Err(AppError::Unauthorized("Invalid email or password".into()));
} }
@@ -258,6 +315,18 @@ async fn login(
) )
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?; .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::Password,
success: true,
user: Some(&user),
identifier: Some(&email),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: None,
})
.await;
Ok(Json(json!({ Ok(Json(json!({
"token": token, "token": token,
"user": { "user": {
@@ -288,10 +357,25 @@ async fn login(
/// be enabled, and the owning user must be active. /// be enabled, and the owning user must be active.
async fn login_api_key( async fn login_api_key(
State(state): State<AppState>, State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(payload): Json<LoginApiKeyRequest>, Json(payload): Json<LoginApiKeyRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let client_ip = client_ip_from_headers(&headers, addr);
let user_agent = user_agent_from_headers(&headers);
let key = payload.api_key.trim(); let key = payload.api_key.trim();
if key.is_empty() { if key.is_empty() {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: None,
identifier: None,
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("api_key is required"),
})
.await;
return Err(AppError::BadRequest("api_key is required".into())); return Err(AppError::BadRequest("api_key is required".into()));
} }
@@ -302,20 +386,91 @@ async fn login_api_key(
.fetch_optional(&*state.db) .fetch_optional(&*state.db)
.await?; .await?;
let api_key = row.ok_or_else(|| AppError::Unauthorized("Invalid API key".into()))?; let api_key = if let Some(api_key) = row {
let user_id = api_key api_key
.user_id } else {
.ok_or_else(|| AppError::Unauthorized("API key is not bound to a user".into()))?; let masked_key = mask_api_key(key);
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: None,
identifier: Some(masked_key.as_str()),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Invalid API key"),
})
.await;
return Err(AppError::Unauthorized("Invalid API key".into()));
};
let user_id = if let Some(user_id) = api_key.user_id {
user_id
} else {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: None,
identifier: Some(&api_key.name),
api_key: Some(&api_key),
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("API key is not bound to a user"),
})
.await;
return Err(AppError::Unauthorized("API key is not bound to a user".into()));
};
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?") let user: User = if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(user_id) .bind(user_id)
.fetch_optional(&*state.db) .fetch_optional(&*state.db)
.await? .await?
.ok_or_else(|| AppError::Unauthorized("User not found".into()))?; {
user
} else {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: None,
identifier: Some(&api_key.name),
api_key: Some(&api_key),
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("User not found"),
})
.await;
return Err(AppError::Unauthorized("User not found".into()));
};
if user.status != "active" { if user.status != "active" {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: Some(&user),
identifier: Some(&user.email),
api_key: Some(&api_key),
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Account is disabled"),
})
.await;
return Err(AppError::Forbidden("Account is disabled".into())); return Err(AppError::Forbidden("Account is disabled".into()));
} }
if user.role == "admin" {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: Some(&user),
identifier: Some(&user.email),
api_key: Some(&api_key),
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Admin users must sign in with the admin API token"),
})
.await;
return Err(AppError::Forbidden(
"Admin users must sign in with the admin API token".into(),
));
}
info!("Login via API key: user_id={}", user.id); info!("Login via API key: user_id={}", user.id);
@@ -327,6 +482,18 @@ async fn login_api_key(
) )
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?; .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: true,
user: Some(&user),
identifier: Some(&user.email),
api_key: Some(&api_key),
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: None,
})
.await;
Ok(Json(json!({ Ok(Json(json!({
"token": token, "token": token,
"user": { "user": {
@@ -503,6 +670,225 @@ async fn get_user(
}))) })))
} }
// ── User login audit helpers ──────────────────────────────────────────
#[derive(Debug, Clone, Copy)]
enum LoginMethod {
Password,
ApiKey,
}
impl LoginMethod {
fn label(self) -> &'static str {
match self {
LoginMethod::Password => "password",
LoginMethod::ApiKey => "api_key",
}
}
fn title_label(self) -> &'static str {
match self {
LoginMethod::Password => "密码登录",
LoginMethod::ApiKey => "API Key 登录",
}
}
}
struct LoginAuditEvent<'a> {
method: LoginMethod,
success: bool,
user: Option<&'a User>,
identifier: Option<&'a str>,
api_key: Option<&'a DownstreamApiKey>,
client_ip: &'a str,
user_agent: Option<&'a str>,
reason: Option<&'a str>,
}
async fn audit_user_login(state: &AppState, audit: LoginAuditEvent<'_>) {
let event_type = "user_login";
let level = if audit.success { "info" } else { "warning" };
let account_label = display_user_for_audit(audit.user, audit.identifier);
let title = if audit.success {
format!("用户登录成功:{account_label}")
} else {
format!("用户登录失败:{account_label}")
};
let mut message_parts = Vec::new();
if let Some(user) = audit.user {
message_parts.push(format!("账号={}", user_account_label_for_audit(user)));
if !user.email.trim().is_empty() {
message_parts.push(format!("邮箱={}", user.email.trim()));
}
message_parts.push(format!("用户ID={}", user.id));
} else if let Some(identifier) = audit.identifier {
message_parts.push(format!("标识={}", identifier));
}
message_parts.push(format!("方式={}", audit.method.label()));
message_parts.push(format!("IP={}", audit.client_ip));
if let Some(api_key) = audit.api_key {
message_parts.push(format!("密钥={}", api_key.name));
message_parts.push(format!("密钥ID={}", api_key.id));
}
if let Some(reason) = audit.reason.filter(|value| !value.trim().is_empty()) {
message_parts.push(format!("原因={reason}"));
}
if let Some(user_agent) = audit.user_agent.filter(|value| !value.trim().is_empty()) {
message_parts.push(format!("UA={}", truncate_for_audit(user_agent, 180)));
}
if let Err(err) = sqlx::query(
"INSERT INTO events (type, title, message, level, read, related_id, related_type)
VALUES (?, ?, ?, ?, 0, ?, ?)",
)
.bind(event_type)
.bind(title)
.bind(message_parts.join(" · "))
.bind(level)
.bind(audit.user.map(|user| user.id))
.bind(if audit.user.is_some() { Some("user") } else { None::<&str> })
.execute(&*state.db)
.await
{
warn!(
%err,
method = audit.method.label(),
success = audit.success,
client_ip = audit.client_ip,
"failed to write user login audit event",
);
} else {
info!(
method = audit.method.label(),
method_title = audit.method.title_label(),
success = audit.success,
client_ip = audit.client_ip,
"user login audit recorded",
);
}
}
fn display_user_for_audit(user: Option<&User>, identifier: Option<&str>) -> String {
if let Some(user) = user {
return user_account_label_for_audit(user);
}
identifier
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("未知用户")
.to_string()
}
fn user_account_label_for_audit(user: &User) -> String {
let username = user.username.trim();
let email = user.email.trim();
match (username.is_empty(), email.is_empty(), username == email) {
(false, false, false) => format!("{username} <{email}>"),
(false, _, _) => username.to_string(),
(_, false, _) => email.to_string(),
_ => format!("用户 #{}", user.id),
}
}
fn user_agent_from_headers(headers: &HeaderMap) -> Option<String> {
headers
.get(header::USER_AGENT)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| truncate_for_audit(value, 240))
}
fn client_ip_from_headers(headers: &HeaderMap, fallback: SocketAddr) -> String {
let mut candidates = Vec::new();
for name in [
"cf-connecting-ip",
"x-real-ip",
"x-forwarded-for",
"forwarded",
] {
candidates.extend(header_ip_values(headers, name));
}
if let Some(ipv4) = candidates.iter().find(|value| is_ipv4_address(value)) {
return ipv4.clone();
}
if let Some(first) = candidates.into_iter().next() {
return first;
}
fallback.ip().to_string()
}
fn is_ipv4_address(value: &str) -> bool {
matches!(value.parse::<IpAddr>(), Ok(IpAddr::V4(_)))
}
fn header_ip_values(headers: &HeaderMap, name: &str) -> Vec<String> {
let Some(raw) = headers.get(name).and_then(|value| value.to_str().ok()) else {
return Vec::new();
};
let raw = raw.trim();
if raw.is_empty() {
return Vec::new();
}
if name.eq_ignore_ascii_case("forwarded") {
return forwarded_header_ips(raw);
}
raw.split(',').filter_map(normalize_ip_token).collect()
}
fn forwarded_header_ips(raw: &str) -> Vec<String> {
raw.split(',')
.flat_map(|entry| entry.split(';'))
.filter_map(|part| {
let (key, value) = part.split_once('=')?;
if !key.trim().eq_ignore_ascii_case("for") {
return None;
}
normalize_ip_token(value)
})
.collect()
}
fn normalize_ip_token(raw: &str) -> Option<String> {
let mut token = raw.trim().trim_matches('"').trim_matches('\'').trim();
if token.is_empty() || token.eq_ignore_ascii_case("unknown") {
return None;
}
if token.starts_with('[') {
if let Some(end) = token.find(']') {
token = &token[1..end];
}
} else if let Some((host, port)) = token.rsplit_once(':') {
if host.matches(':').count() == 0 && port.parse::<u16>().is_ok() {
token = host;
}
}
token
.parse::<IpAddr>()
.map(|ip| ip.to_string())
.ok()
}
fn truncate_for_audit(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
return value.to_string();
}
let mut truncated = value.chars().take(max_chars.saturating_sub(1)).collect::<String>();
truncated.push('…');
truncated
}
fn mask_api_key(key: &str) -> String {
let trimmed = key.trim();
let tail = trimmed.chars().rev().take(4).collect::<Vec<_>>();
if tail.is_empty() {
return "sk-****".to_string();
}
let suffix = tail.into_iter().rev().collect::<String>();
format!("••••{suffix}")
}
// ── Inline JWT extract helper for this module ────────────────────────── // ── Inline JWT extract helper for this module ──────────────────────────
fn extract_jwt(state: &AppState, headers: &HeaderMap) -> AppResult<(i64, String)> { fn extract_jwt(state: &AppState, headers: &HeaderMap) -> AppResult<(i64, String)> {
@@ -522,3 +908,471 @@ fn extract_jwt(state: &AppState, headers: &HeaderMap) -> AppResult<(i64, String)
Ok((uid, claims.role)) Ok((uid, claims.role))
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::{
auth::{jwt::validate_jwt, password::hash_password},
config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig},
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
};
use sqlx::sqlite::SqlitePoolOptions;
async fn test_state() -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("in-memory database");
sqlx::query(
"CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
username TEXT NOT NULL,
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(&pool)
.await
.expect("users table");
sqlx::query(
"CREATE TABLE downstream_api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
key TEXT NOT NULL,
description TEXT,
group_name TEXT,
tags TEXT,
enabled INTEGER DEFAULT 1,
expires_at TEXT,
max_cost REAL,
used_cost REAL DEFAULT 0,
max_requests INTEGER,
used_requests INTEGER DEFAULT 0,
supported_models TEXT,
allowed_route_ids TEXT,
site_weight_multipliers TEXT,
excluded_site_ids TEXT,
excluded_credential_refs TEXT,
last_used_at TEXT,
user_id INTEGER,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(&pool)
.await
.expect("downstream keys table");
sqlx::query(
"CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT,
level TEXT NOT NULL DEFAULT 'info',
read INTEGER DEFAULT 0,
related_id INTEGER,
related_type TEXT,
created_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(&pool)
.await
.expect("events table");
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".into(),
port: 0,
log_level: "info".into(),
cors_origins: vec![],
},
db: DbConfig {
url: "sqlite::memory:".into(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "test-secret".into(),
jwt_expiry_hours: 1,
auth_token: "auth".into(),
proxy_token: "proxy".into(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 3,
proxy_log_retention_days: 7,
template_proxy_url: None,
},
comfyui: Default::default(),
payment: Default::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-test".into()),
},
RuntimeAuthSettings::from_token_for_test("proxy"),
)
}
fn test_addr() -> ConnectInfo<SocketAddr> {
ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345)))
}
fn empty_headers() -> HeaderMap {
HeaderMap::new()
}
fn headers_with(name: &'static str, value: &'static str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(name, value.parse().expect("valid test header"));
headers
}
async fn latest_audit_event(state: &AppState) -> (String, String, String, i64, String) {
sqlx::query_as::<_, (String, String, String, i64, String)>(
"SELECT type, title, COALESCE(message, ''), COALESCE(related_id, 0), COALESCE(related_type, '')
FROM events
ORDER BY id DESC
LIMIT 1",
)
.fetch_one(&*state.db)
.await
.expect("latest audit event")
}
#[tokio::test]
async fn password_login_rejects_admin_users() {
let state = test_state().await;
let password_hash = hash_password("admin-password").expect("password hash");
sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'admin', 'active')",
)
.bind("Admin")
.bind("admin@example.test")
.bind(password_hash)
.execute(&*state.db)
.await
.expect("insert admin user");
let err = login(
State(state),
test_addr(),
empty_headers(),
Json(LoginRequest {
email: "admin@example.test".into(),
password: "admin-password".into(),
}),
)
.await
.expect_err("admin password login must be rejected");
assert!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
}
#[tokio::test]
async fn password_login_allows_regular_users() {
let state = test_state().await;
let password_hash = hash_password("user-password").expect("password hash");
let user_id = sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'user', 'active')",
)
.bind("Regular User")
.bind("user@example.test")
.bind(password_hash)
.execute(&*state.db)
.await
.expect("insert user")
.last_insert_rowid();
let Json(value) = login(
State(state.clone()),
test_addr(),
empty_headers(),
Json(LoginRequest {
email: "user@example.test".into(),
password: "user-password".into(),
}),
)
.await
.expect("regular user password login succeeds");
assert_eq!(value["user"]["role"], "user");
let token = value["token"].as_str().expect("token");
let claims = validate_jwt(&state.config.auth.jwt_secret, token).expect("valid jwt");
assert_eq!(claims.sub, user_id.to_string());
assert_eq!(claims.role, "user");
}
#[tokio::test]
async fn password_login_audits_forwarded_client_ip() {
let state = test_state().await;
let password_hash = hash_password("user-password").expect("password hash");
let user_id = sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'user', 'active')",
)
.bind("Regular User")
.bind("audit@example.test")
.bind(password_hash)
.execute(&*state.db)
.await
.expect("insert user")
.last_insert_rowid();
let Json(_) = login(
State(state.clone()),
test_addr(),
headers_with("x-forwarded-for", "203.0.113.8, 10.0.0.1"),
Json(LoginRequest {
email: "audit@example.test".into(),
password: "user-password".into(),
}),
)
.await
.expect("regular user password login succeeds");
let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await;
assert_eq!(event_type, "user_login");
assert!(title.contains("用户登录成功"));
assert!(title.contains("Regular User <audit@example.test>"));
assert!(message.starts_with("账号=Regular User <audit@example.test>"));
assert!(message.contains("邮箱=audit@example.test"));
assert!(message.contains("方式=password"));
assert!(message.contains("IP=203.0.113.8"));
assert_eq!(related_id, user_id);
assert_eq!(related_type, "user");
}
#[tokio::test]
async fn api_key_login_rejects_admin_role() {
let state = test_state().await;
let password_hash = hash_password("unused-password").expect("password hash");
let user_id = sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'admin', 'active')",
)
.bind("Admin")
.bind("admin@example.test")
.bind(password_hash)
.execute(&*state.db)
.await
.expect("insert admin user")
.last_insert_rowid();
sqlx::query(
"INSERT INTO downstream_api_keys (name, key, user_id, enabled)
VALUES (?, ?, ?, 1)",
)
.bind("Admin default")
.bind("sk-admin-bound")
.bind(user_id)
.execute(&*state.db)
.await
.expect("insert bound api key");
let err = login_api_key(
State(state),
test_addr(),
empty_headers(),
Json(LoginApiKeyRequest {
api_key: "sk-admin-bound".into(),
}),
)
.await
.expect_err("admin api key login must be rejected");
assert!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
assert!(user_id > 0);
}
#[tokio::test]
async fn api_key_login_rejects_latest_admin_role_after_promotion() {
let state = test_state().await;
let password_hash = hash_password("unused-password").expect("password hash");
let user_id = sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'user', 'active')",
)
.bind("Promoted User")
.bind("promoted@example.test")
.bind(password_hash)
.execute(&*state.db)
.await
.expect("insert regular user")
.last_insert_rowid();
sqlx::query(
"INSERT INTO downstream_api_keys (name, key, user_id, enabled)
VALUES (?, ?, ?, 1)",
)
.bind("Existing user key")
.bind("sk-promoted-bound")
.bind(user_id)
.execute(&*state.db)
.await
.expect("insert bound api key");
sqlx::query("UPDATE users SET role = 'admin' WHERE id = ?")
.bind(user_id)
.execute(&*state.db)
.await
.expect("promote user to admin");
let err = login_api_key(
State(state),
test_addr(),
empty_headers(),
Json(LoginApiKeyRequest {
api_key: "sk-promoted-bound".into(),
}),
)
.await
.expect_err("api key login must use latest role and reject admin");
assert!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
assert!(user_id > 0);
}
#[tokio::test]
async fn api_key_login_allows_regular_user_role() {
let state = test_state().await;
let password_hash = hash_password("unused-password").expect("password hash");
let user_id = sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'user', 'active')",
)
.bind("Regular User")
.bind("regular@example.test")
.bind(password_hash)
.execute(&*state.db)
.await
.expect("insert regular user")
.last_insert_rowid();
sqlx::query(
"INSERT INTO downstream_api_keys (name, key, user_id, enabled)
VALUES (?, ?, ?, 1)",
)
.bind("Regular default")
.bind("sk-user-bound")
.bind(user_id)
.execute(&*state.db)
.await
.expect("insert bound api key");
let Json(value) = login_api_key(
State(state.clone()),
test_addr(),
empty_headers(),
Json(LoginApiKeyRequest {
api_key: "sk-user-bound".into(),
}),
)
.await
.expect("regular api key login succeeds");
assert_eq!(value["user"]["role"], "user");
assert_eq!(value["data"]["user"]["role"], "user");
let token = value["token"].as_str().expect("token");
let claims = validate_jwt(&state.config.auth.jwt_secret, token).expect("valid jwt");
assert_eq!(claims.sub, user_id.to_string());
assert_eq!(claims.role, "user");
}
#[tokio::test]
async fn api_key_login_audits_forwarded_header_ip_and_key_name() {
let state = test_state().await;
let password_hash = hash_password("unused-password").expect("password hash");
let user_id = sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'user', 'active')",
)
.bind("Regular User")
.bind("key-audit@example.test")
.bind(password_hash)
.execute(&*state.db)
.await
.expect("insert regular user")
.last_insert_rowid();
sqlx::query(
"INSERT INTO downstream_api_keys (name, key, user_id, enabled)
VALUES (?, ?, ?, 1)",
)
.bind("Mobile Login Key")
.bind("sk-user-audit-bound")
.bind(user_id)
.execute(&*state.db)
.await
.expect("insert bound api key");
let Json(_) = login_api_key(
State(state.clone()),
test_addr(),
headers_with("forwarded", "for=\"198.51.100.24:443\";proto=https"),
Json(LoginApiKeyRequest {
api_key: "sk-user-audit-bound".into(),
}),
)
.await
.expect("regular api key login succeeds");
let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await;
assert_eq!(event_type, "user_login");
assert!(title.contains("用户登录成功"));
assert!(title.contains("Regular User <key-audit@example.test>"));
assert!(message.starts_with("账号=Regular User <key-audit@example.test>"));
assert!(message.contains("方式=api_key"));
assert!(message.contains("IP=198.51.100.24"));
assert!(message.contains("密钥=Mobile Login Key"));
assert_eq!(related_id, user_id);
assert_eq!(related_type, "user");
}
#[tokio::test]
async fn login_audit_prefers_ipv4_from_proxy_headers() {
let state = test_state().await;
let password_hash = hash_password("user-password").expect("password hash");
sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'user', 'active')",
)
.bind("IPv4 User")
.bind("ipv4@example.test")
.bind(password_hash)
.execute(&*state.db)
.await
.expect("insert user");
let Json(_) = login(
State(state.clone()),
test_addr(),
headers_with("x-forwarded-for", "2a09:bac2:3918:16dc::247:4d, 203.0.113.9"),
Json(LoginRequest {
email: "ipv4@example.test".into(),
password: "user-password".into(),
}),
)
.await
.expect("regular user password login succeeds");
let (_, _, message, _, _) = latest_audit_event(&state).await;
assert!(message.contains("IP=203.0.113.9"), "{message}");
}
}
+109
View File
@@ -6,6 +6,7 @@
use crate::{ use crate::{
auth::api_key::{authorize_downstream_token, DownstreamAuthResult}, auth::api_key::{authorize_downstream_token, DownstreamAuthResult},
auth::jwt::validate_jwt,
error::{AppError, AppResult}, error::{AppError, AppResult},
AppState, AppState,
}; };
@@ -47,6 +48,15 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
return Ok(()); return Ok(());
} }
if let Ok(claims) = validate_jwt(&state.config.auth.jwt_secret, auth) {
if claims.role == "admin" {
return Ok(());
}
return Err(AppError::Forbidden(
"Admin account is required for this route".into(),
));
}
// Downstream API key // Downstream API key
if auth.starts_with("sk-") { if auth.starts_with("sk-") {
let proxy_token = state.runtime_proxy_token(); let proxy_token = state.runtime_proxy_token();
@@ -69,6 +79,105 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::{
auth::jwt::generate_jwt,
config::{AppConfig, AuthConfig, ComfyUIConfig, DbConfig, ProxyConfig, ServerConfig},
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
};
use axum::http::HeaderValue;
use sqlx::sqlite::SqlitePoolOptions;
async fn test_state() -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool");
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 4000,
log_level: "info".to_string(),
cors_origins: Vec::new(),
},
db: DbConfig {
url: "sqlite::memory:".to_string(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "test-jwt-secret".to_string(),
jwt_expiry_hours: 24,
auth_token: "test-admin-token".to_string(),
proxy_token: "sk-test-proxy".to_string(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 1,
proxy_log_retention_days: 7,
template_proxy_url: None,
},
comfyui: ComfyUIConfig::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (0, "sk-comfyui-test".to_string()),
},
RuntimeAuthSettings::from_token_for_test("sk-test-proxy"),
)
}
fn bearer_headers(token: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("Bearer {token}")).expect("valid bearer header"),
);
headers
}
#[tokio::test]
async fn comfyui_auth_accepts_admin_jwt() {
let state = test_state().await;
let token = generate_jwt(
&state.config.auth.jwt_secret,
"42",
"admin",
state.config.auth.jwt_expiry_hours,
)
.expect("admin jwt");
check_auth(&state, &bearer_headers(&token))
.await
.expect("admin jwt can access comfyui proxy routes");
}
#[tokio::test]
async fn comfyui_auth_rejects_user_jwt() {
let state = test_state().await;
let token = generate_jwt(
&state.config.auth.jwt_secret,
"43",
"user",
state.config.auth.jwt_expiry_hours,
)
.expect("user jwt");
let result = check_auth(&state, &bearer_headers(&token)).await;
assert!(matches!(result, Err(AppError::Forbidden(_))));
}
}
/// Get ComfyUI base URL from embedded/external runtime configuration. /// Get ComfyUI base URL from embedded/external runtime configuration.
fn comfyui_base(state: &AppState) -> String { fn comfyui_base(state: &AppState) -> String {
state.comfyui.internal_base_url() state.comfyui.internal_base_url()
+585 -10
View File
@@ -2,6 +2,7 @@ use crate::{
auth::jwt::validate_jwt, auth::jwt::validate_jwt,
db::models::{background_task::BackgroundTask, downstream_api_key::DownstreamApiKey}, db::models::{background_task::BackgroundTask, downstream_api_key::DownstreamApiKey},
error::{AppError, AppResult}, error::{AppError, AppResult},
services::billing::{ComfyTaskBillingEvent, UsageTracker},
services::workspace::{self, WorkspaceAssetInput}, services::workspace::{self, WorkspaceAssetInput},
workflow::storage, workflow::storage,
AppState, AppState,
@@ -9,7 +10,7 @@ use crate::{
use axum::{ use axum::{
body::Bytes, body::Bytes,
extract::{Multipart, Path, Query, State}, extract::{Multipart, Path, Query, State},
http::{header, HeaderMap, Method}, http::{header, HeaderMap, Method, StatusCode},
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
@@ -22,11 +23,61 @@ const DIGITAL_HUMAN_TIMEOUT_SECS: u64 = 900;
const DIGITAL_HUMAN_POLL_INTERVAL_SECS: u64 = 5; const DIGITAL_HUMAN_POLL_INTERVAL_SECS: u64 = 5;
const DIGITAL_HUMAN_MAX_POLLS: usize = 180; const DIGITAL_HUMAN_MAX_POLLS: usize = 180;
fn normalize_video_model(value: &str) -> String {
match value.trim() {
"seedance-1-0-lite-t2v" => "seedance-1-0-lite-t2v-250428".to_string(),
"seedance-1-0-lite-i2v" => "seedance-1-0-pro-i2v-250528".to_string(),
other => other.to_string(),
}
}
fn configured_task_cost(value: f64) -> f64 {
if value.is_finite() {
value.max(0.0)
} else {
0.0
}
}
fn truncate_chars(value: &str, limit: usize) -> String {
value.chars().take(limit).collect()
}
fn comfy_task_event(
auth: &DigitalHumanAuth,
model: String,
prompt_tokens: i64,
completion_tokens: i64,
cost: f64,
script: &str,
request: &Value,
response: Option<&Value>,
error_message: Option<String>,
) -> ComfyTaskBillingEvent {
ComfyTaskBillingEvent {
key_id: auth.key_id,
model,
task_type: TASK_TYPE_DIGITAL_HUMAN,
client_family: "digital-human",
client_app_id: "digital-human",
client_app_name: "Digital Human",
prompt_tokens,
completion_tokens,
cost,
content_summary: Some(truncate_chars(script, 240)),
request_content: Some(request.to_string()),
response_content: response.map(Value::to_string),
error_message,
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct DigitalHumanAuth { struct DigitalHumanAuth {
user_id: Option<i64>, user_id: Option<i64>,
role: String, role: String,
outbound_key: String, outbound_key: String,
key_id: Option<i64>,
key_name: String,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -110,6 +161,8 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<DigitalHu
user_id: None, user_id: None,
role: "admin".to_string(), role: "admin".to_string(),
outbound_key: proxy_token, outbound_key: proxy_token,
key_id: None,
key_name: "global-proxy".to_string(),
}); });
} }
@@ -128,21 +181,26 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<DigitalHu
user_id: Some(user_id), user_id: Some(user_id),
role: claims.role, role: claims.role,
outbound_key: key.key, outbound_key: key.key,
key_id: Some(key.id),
key_name: key.name,
}), }),
Err(_) if claims.role == "admin" => Ok(DigitalHumanAuth { Err(_) if claims.role == "admin" => Ok(DigitalHumanAuth {
user_id: Some(user_id), user_id: Some(user_id),
role: claims.role, role: claims.role,
outbound_key: state.runtime_proxy_token(), outbound_key: state.runtime_proxy_token(),
key_id: None,
key_name: "global-proxy".to_string(),
}), }),
Err(message) => Err(AppError::Forbidden(message)), Err(message) => Err(AppError::Forbidden(message)),
} }
} }
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> { async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let _ = resolve_auth(&headers, &state).await?; let auth = resolve_auth(&headers, &state).await?;
Ok(Json(json!({ Ok(Json(json!({
"success": true, "success": true,
"mode": if state.config.comfyui.uses_native_backend() { "comfy-native" } else { "boosapi-video-route" }, "mode": if state.config.comfyui.uses_native_backend() { "comfy-native" } else { "boosapi-video-route" },
"keyName": auth.key_name,
"runninghub": { "runninghub": {
"configured": state.config.comfyui.api_key.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()), "configured": state.config.comfyui.api_key.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()),
"endpoint": "https://www.runninghub.cn/task/openapi/ai-app/run" "endpoint": "https://www.runninghub.cn/task/openapi/ai-app/run"
@@ -486,6 +544,7 @@ fn default_mapping() -> Value {
json!({ json!({
"prompt": { "nodeId": "", "field": "prompt" }, "prompt": { "nodeId": "", "field": "prompt" },
"script": { "nodeId": "", "field": "text" }, "script": { "nodeId": "", "field": "text" },
"model": { "nodeId": "", "field": "model" },
"avatar": { "nodeId": "", "field": "image" }, "avatar": { "nodeId": "", "field": "image" },
"referenceAudio": { "nodeId": "", "field": "audio" }, "referenceAudio": { "nodeId": "", "field": "audio" },
"voiceReference": { "nodeId": "", "field": "audio" }, "voiceReference": { "nodeId": "", "field": "audio" },
@@ -520,11 +579,106 @@ fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value
Ok(()) Ok(())
} }
fn graph_node_type(node: &Value) -> String {
node.get("type")
.or_else(|| node.get("class_type"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
}
fn graph_node_params(node: &Value) -> serde_json::Map<String, Value> {
node.get("params")
.and_then(Value::as_object)
.cloned()
.or_else(|| node.get("inputs").and_then(Value::as_object).cloned())
.unwrap_or_default()
}
fn normalize_comfy_value(class_type: &str, field: &str, value: Value) -> Value {
match (class_type, field) {
("KSampler", "cfg") => value
.as_f64()
.filter(|value| (0.0..=50.0).contains(value))
.map_or(json!(8.0), |value| json!(value)),
("KSampler", "denoise") => value
.as_f64()
.map(|value| if value > 1.0 { value / 100.0 } else { value })
.filter(|value| (0.0..=1.0).contains(value))
.map_or(json!(1.0), |value| json!(value)),
("KSampler", "sampler_name") => {
if value.is_string() {
value
} else {
json!("euler")
}
}
("KSampler", "scheduler") => {
if value.is_string() {
value
} else {
json!("normal")
}
}
("KSampler", "seed") => value.as_i64().map_or(json!(0), |value| json!(value.max(0))),
("EmptyLatentImage", "width") | ("EmptyLatentImage", "height") => value
.as_i64()
.filter(|value| (64..=4096).contains(value))
.map_or(json!(1024), |value| json!(value)),
("EmptyLatentImage", "batch_size") => value
.as_i64()
.filter(|value| (1..=16).contains(value))
.map_or(json!(1), |value| json!(value)),
_ => value,
}
}
fn split_endpoint(value: &str) -> Option<(&str, &str)> {
let (node_id, port) = value.split_once('.')?;
Some((node_id.trim(), port.trim()))
}
fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
let nodes = graph.get("nodes")?.as_array()?;
let mut prompt = serde_json::Map::new();
for node in nodes {
let id = scalar_string(node.get("id").or_else(|| node.get("nodeId")))?;
let class_type = graph_node_type(node);
if class_type.trim().is_empty() {
continue;
}
let mut inputs = serde_json::Map::new();
for (field, value) in graph_node_params(node) {
inputs.insert(field.clone(), normalize_comfy_value(&class_type, &field, value));
}
prompt.insert(id, json!({
"class_type": class_type,
"inputs": inputs,
}));
}
for edge in graph.get("edges").and_then(Value::as_array).into_iter().flatten() {
let Some(from) = edge.get("from").and_then(Value::as_str) else { continue; };
let Some(to) = edge.get("to").and_then(Value::as_str) else { continue; };
let Some((from_id, _from_port)) = split_endpoint(from) else { continue; };
let Some((to_id, to_field)) = split_endpoint(to) else { continue; };
if let Some(inputs) = prompt
.get_mut(to_id)
.and_then(Value::as_object_mut)
.and_then(|node| node.get_mut("inputs"))
.and_then(Value::as_object_mut)
{
inputs.insert(to_field.to_string(), json!([from_id, 0]));
}
}
Some(Value::Object(prompt))
}
fn workflow_prompt_from_graph(graph: &Value) -> Value { fn workflow_prompt_from_graph(graph: &Value) -> Value {
graph.get("prompt") graph.get("prompt")
.cloned() .cloned()
.or_else(|| graph.get("workflow").cloned()) .or_else(|| graph.get("workflow").cloned())
.or_else(|| graph.get("comfyPrompt").cloned()) .or_else(|| graph.get("comfyPrompt").cloned())
.or_else(|| graph_to_comfy_prompt(graph))
.unwrap_or_else(|| graph.clone()) .unwrap_or_else(|| graph.clone())
} }
@@ -583,6 +737,16 @@ fn inject_digital_human_inputs(
&mapping_field(mapping, &["script", "scriptNode", "script_node"], "text"), &mapping_field(mapping, &["script", "scriptNode", "script_node"], "text"),
json!(product_script), json!(product_script),
)?; )?;
if let Some(model) = optional_string(request, &["comfyApiModel", "comfy_api_model", "apiModel", "api_model"]) {
if let Some(model_node) = mapping_node_id(mapping, &["model", "modelNode", "apiModel"]) {
apply_node_input(
prompt,
&model_node,
&mapping_field(mapping, &["model", "modelNode", "apiModel"], "model"),
json!(normalize_video_model(&model)),
)?;
}
}
apply_node_input( apply_node_input(
prompt, prompt,
&avatar_node, &avatar_node,
@@ -677,6 +841,13 @@ fn native_request(state: &AppState, method: Method, path: &str) -> reqwest::Requ
request request
} }
fn is_pending_history_status(status: StatusCode) -> bool {
matches!(
status,
StatusCode::NOT_FOUND | StatusCode::ACCEPTED | StatusCode::NO_CONTENT
)
}
async fn native_json( async fn native_json(
state: &AppState, state: &AppState,
method: Method, method: Method,
@@ -703,6 +874,35 @@ async fn native_json(
Ok(value) Ok(value)
} }
async fn native_json_optional(
state: &AppState,
method: Method,
path: &str,
body: Option<Value>,
) -> AppResult<Option<Value>> {
let mut request = native_request(state, method, path);
if let Some(body) = body {
request = request.json(&body);
}
let response = request
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("Comfy backend unreachable: {err}")))?;
let status = response.status();
if is_pending_history_status(status) {
return Ok(None);
}
let text = response
.text()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
let value = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({ "body": text }));
if !status.is_success() {
return Err(AppError::UpstreamError(value.to_string()));
}
Ok(Some(value))
}
async fn submit_prompt(state: &AppState, prompt: Value) -> AppResult<(String, Value)> { async fn submit_prompt(state: &AppState, prompt: Value) -> AppResult<(String, Value)> {
let body = json!({ let body = json!({
"client_id": format!("boosapi-digital-human-{}", uuid::Uuid::new_v4().simple()), "client_id": format!("boosapi-digital-human-{}", uuid::Uuid::new_v4().simple()),
@@ -720,13 +920,13 @@ async fn submit_prompt(state: &AppState, prompt: Value) -> AppResult<(String, Va
Ok((prompt_id, value)) Ok((prompt_id, value))
} }
async fn prompt_history(state: &AppState, prompt_id: &str) -> AppResult<Value> { async fn prompt_history_optional(state: &AppState, prompt_id: &str) -> AppResult<Option<Value>> {
let path = if state.config.comfyui.uses_cloud_backend() { let path = if state.config.comfyui.uses_cloud_backend() {
format!("/jobs/{prompt_id}") format!("/jobs/{prompt_id}")
} else { } else {
format!("/history/{prompt_id}") format!("/history/{prompt_id}")
}; };
native_json(state, Method::GET, &path, None).await native_json_optional(state, Method::GET, &path, None).await
} }
fn prompt_failure_message(value: &Value) -> Option<String> { fn prompt_failure_message(value: &Value) -> Option<String> {
@@ -746,12 +946,24 @@ fn prompt_failure_message(value: &Value) -> Option<String> {
fn output_objects(value: &Value) -> Vec<Value> { fn output_objects(value: &Value) -> Vec<Value> {
let mut out = Vec::new(); let mut out = Vec::new();
fn push_unique(out: &mut Vec<Value>, item: Value) {
let identity = output_identity(&item);
if out.iter().any(|existing| output_identity(existing) == identity) {
return;
}
out.push(item);
}
fn walk(value: &Value, out: &mut Vec<Value>) { fn walk(value: &Value, out: &mut Vec<Value>) {
match value { match value {
Value::Object(object) => { Value::Object(object) => {
if output_filename(value).is_some() || output_remote_url(value).is_some() {
push_unique(out, value.clone());
}
for key in ["videos", "gifs", "images", "audio"] { for key in ["videos", "gifs", "images", "audio"] {
if let Some(items) = object.get(key).and_then(Value::as_array) { if let Some(items) = object.get(key).and_then(Value::as_array) {
out.extend(items.iter().filter(|item| item.is_object()).cloned()); for item in items {
walk(item, out);
}
} }
} }
for value in object.values() { for value in object.values() {
@@ -763,6 +975,11 @@ fn output_objects(value: &Value) -> Vec<Value> {
walk(item, out); walk(item, out);
} }
} }
Value::String(value) => {
if let Some(item) = output_item_from_reference(value) {
push_unique(out, item);
}
}
_ => {} _ => {}
} }
} }
@@ -770,10 +987,112 @@ fn output_objects(value: &Value) -> Vec<Value> {
out out
} }
fn output_identity(item: &Value) -> String {
output_remote_url(item)
.or_else(|| output_filename(item))
.unwrap_or_else(|| item.to_string())
}
fn looks_like_media_reference(value: &str) -> bool {
let trimmed = value.trim();
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://") || trimmed.starts_with('/')) {
return false;
}
let lower = trimmed
.split(['?', '#'])
.next()
.unwrap_or(trimmed)
.to_ascii_lowercase();
lower.contains("/view")
|| lower.ends_with(".mp4")
|| lower.ends_with(".webm")
|| lower.ends_with(".mov")
|| lower.ends_with(".m4v")
|| lower.ends_with(".gif")
|| lower.ends_with(".png")
|| lower.ends_with(".jpg")
|| lower.ends_with(".jpeg")
|| lower.ends_with(".webp")
|| lower.ends_with(".mp3")
|| lower.ends_with(".wav")
|| lower.ends_with(".m4a")
}
fn output_item_from_reference(value: &str) -> Option<Value> {
let trimmed = value.trim();
if !looks_like_media_reference(trimmed) {
return None;
}
let filename = trimmed
.split(['?', '#'])
.next()
.unwrap_or(trimmed)
.rsplit('/')
.next()
.unwrap_or("digital-human-output")
.to_string();
Some(json!({
"url": trimmed,
"filename": filename,
"type": "output"
}))
}
fn output_filename(item: &Value) -> Option<String> {
["filename", "fileName", "name", "file"]
.iter()
.find_map(|key| scalar_string(item.get(*key)))
}
fn output_remote_url(item: &Value) -> Option<String> {
[
"url",
"remoteUrl",
"remote_url",
"videoUrl",
"video_url",
"audioUrl",
"audio_url",
"imageUrl",
"image_url",
"fileUrl",
"file_url",
"downloadUrl",
"download_url",
"video",
"audio",
"image",
]
.iter()
.find_map(|key| scalar_string(item.get(*key)))
.filter(|value| looks_like_media_reference(value))
}
fn output_kind(item: &Value) -> &'static str { fn output_kind(item: &Value) -> &'static str {
let filename = scalar_string(item.get("filename")).unwrap_or_default(); let explicit = scalar_string(item.get("kind"))
let lower = filename.to_ascii_lowercase(); .or_else(|| scalar_string(item.get("mediaType")))
if lower.ends_with(".mp4") || lower.ends_with(".webm") || lower.ends_with(".mov") { .or_else(|| scalar_string(item.get("mime")))
.unwrap_or_default()
.to_ascii_lowercase();
if explicit.contains("video") {
return "video";
}
if explicit.contains("audio") {
return "audio";
}
if explicit.contains("image") {
return "image";
}
let lower = output_filename(item)
.or_else(|| output_remote_url(item))
.unwrap_or_default()
.to_ascii_lowercase();
if lower.ends_with(".mp4")
|| lower.ends_with(".webm")
|| lower.ends_with(".mov")
|| lower.ends_with(".m4v")
|| lower.ends_with(".gif")
{
"video" "video"
} else if lower.ends_with(".mp3") || lower.ends_with(".wav") || lower.ends_with(".m4a") { } else if lower.ends_with(".mp3") || lower.ends_with(".wav") || lower.ends_with(".m4a") {
"audio" "audio"
@@ -784,7 +1103,7 @@ fn output_kind(item: &Value) -> &'static str {
fn output_query(item: &Value) -> HashMap<String, String> { fn output_query(item: &Value) -> HashMap<String, String> {
let mut params = HashMap::new(); let mut params = HashMap::new();
if let Some(filename) = scalar_string(item.get("filename")) { if let Some(filename) = output_filename(item) {
params.insert("filename".to_string(), filename); params.insert("filename".to_string(), filename);
} }
if let Some(file_type) = scalar_string(item.get("type")) { if let Some(file_type) = scalar_string(item.get("type")) {
@@ -829,6 +1148,56 @@ async fn archive_output(
prompt_id: &str, prompt_id: &str,
script: &str, script: &str,
) -> Option<Value> { ) -> Option<Value> {
if let Some(remote_url) = output_remote_url(item) {
let filename = output_filename(item).unwrap_or_else(|| {
remote_url
.split(['?', '#'])
.next()
.unwrap_or(&remote_url)
.rsplit('/')
.next()
.unwrap_or("digital-human-output")
.to_string()
});
let kind = output_kind(item).to_string();
let input = WorkspaceAssetInput {
user_id: auth.user_id,
source: "digital-human".to_string(),
kind: kind.clone(),
title: Some(filename.chars().take(80).collect()),
prompt: Some(script.chars().take(1000).collect()),
model: Some("comfy-native".to_string()),
remote_url: Some(remote_url.clone()),
metadata: json!({
"workspaceAsset": { "role": "output" },
"digitalHuman": {
"promptId": prompt_id,
"filename": filename,
"raw": item,
}
}),
};
return match workspace::record_remote_asset(state, input).await {
Ok(asset) => Some(json!({
"publicId": asset.public_id,
"kind": kind,
"filename": filename,
"status": asset.status,
"remoteUrl": asset.remote_url,
"fileName": asset.file_name,
})),
Err(err) => {
tracing::warn!(%err, "failed to archive remote digital human output");
Some(json!({
"kind": kind,
"filename": filename,
"status": "failed",
"remoteUrl": remote_url,
"error": err.to_string(),
}))
}
};
}
let params = output_query(item); let params = output_query(item);
let filename = params.get("filename")?.clone(); let filename = params.get("filename")?.clone();
let kind = output_kind(item).to_string(); let kind = output_kind(item).to_string();
@@ -963,6 +1332,9 @@ async fn create_job(
Json(body): Json<Value>, Json(body): Json<Value>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?; let auth = resolve_auth(&headers, &state).await?;
if let Some(key_id) = auth.key_id {
UsageTracker::new(state.db.clone()).check_rate_limit(key_id).await?;
}
if !state.config.comfyui.uses_native_backend() || state.config.comfyui.uses_runninghub_backend() if !state.config.comfyui.uses_native_backend() || state.config.comfyui.uses_runninghub_backend()
{ {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
@@ -985,8 +1357,11 @@ async fn create_job(
let initial_result = json!({ let initial_result = json!({
"userId": auth.user_id, "userId": auth.user_id,
"role": auth.role, "role": auth.role,
"keyId": auth.key_id,
"keyName": auth.key_name,
"templateId": template_id, "templateId": template_id,
"templateName": workflow.name, "templateName": workflow.name,
"script": script,
"audioRole": optional_string(&body, &["audioRole", "audio_role"]).unwrap_or_else(|| "both".to_string()), "audioRole": optional_string(&body, &["audioRole", "audio_role"]).unwrap_or_else(|| "both".to_string()),
"request": body, "request": body,
"mapping": mapping, "mapping": mapping,
@@ -1008,6 +1383,14 @@ async fn create_job(
let task_id_for_spawn = task_id.clone(); let task_id_for_spawn = task_id.clone();
let auth_for_task = auth.clone(); let auth_for_task = auth.clone();
tokio::spawn(async move { tokio::spawn(async move {
let failure_body = body.clone();
let failure_script = script.clone();
let billing_model = optional_string(
&failure_body,
&["comfyApiModel", "comfy_api_model", "apiModel", "api_model", "model"],
)
.map(|value| normalize_video_model(&value))
.unwrap_or_else(|| workflow.name.clone());
let result = async { let result = async {
update_progress(&state_for_task, &task_id_for_spawn, 2, "正在提交 Comfy 工作流") update_progress(&state_for_task, &task_id_for_spawn, 2, "正在提交 Comfy 工作流")
.await?; .await?;
@@ -1015,8 +1398,11 @@ async fn create_job(
let mid_result = json!({ let mid_result = json!({
"userId": auth_for_task.user_id, "userId": auth_for_task.user_id,
"role": auth_for_task.role, "role": auth_for_task.role,
"keyId": auth_for_task.key_id,
"keyName": auth_for_task.key_name,
"templateId": template_id, "templateId": template_id,
"templateName": workflow.name, "templateName": workflow.name,
"script": script,
"promptId": prompt_id, "promptId": prompt_id,
"submitResponse": submit_response, "submitResponse": submit_response,
"request": body, "request": body,
@@ -1036,7 +1422,12 @@ async fn create_job(
} }
update_progress(&state_for_task, &task_id_for_spawn, 3, "Comfy 正在生成视频") update_progress(&state_for_task, &task_id_for_spawn, 3, "Comfy 正在生成视频")
.await?; .await?;
let history = prompt_history(&state_for_task, &prompt_id).await?; let Some(history) = prompt_history_optional(&state_for_task, &prompt_id).await?
else {
tokio::time::sleep(Duration::from_secs(DIGITAL_HUMAN_POLL_INTERVAL_SECS))
.await;
continue;
};
if let Some(message) = prompt_failure_message(&history) { if let Some(message) = prompt_failure_message(&history) {
return Err(AppError::UpstreamError(message)); return Err(AppError::UpstreamError(message));
} }
@@ -1063,17 +1454,62 @@ async fn create_job(
assets.push(asset); assets.push(asset);
} }
} }
let tracker = UsageTracker::new(state_for_task.db.clone());
let billing_response = json!({
"promptId": prompt_id,
"history": history,
"outputs": outputs,
"assets": assets,
});
let billing = match tracker
.record_comfy_task_success(comfy_task_event(
&auth_for_task,
billing_model.clone(),
0,
0,
configured_task_cost(state_for_task.config.comfyui.digital_human_video_cost),
&script,
&body,
Some(&billing_response),
None,
))
.await
{
Ok(details) => details,
Err(err) => {
tracing::warn!(
%err,
task_id = %task_id_for_spawn,
"failed to record digital human comfy billing",
);
json!({
"source": "comfy-native-task",
"taskType": TASK_TYPE_DIGITAL_HUMAN,
"status": "success",
"charged": false,
"failed": true,
"error": err.to_string(),
"totalCost": 0.0,
"steps": []
})
}
};
let final_result = json!({ let final_result = json!({
"userId": auth_for_task.user_id, "userId": auth_for_task.user_id,
"role": auth_for_task.role, "role": auth_for_task.role,
"keyId": auth_for_task.key_id,
"keyName": auth_for_task.key_name,
"templateId": template_id, "templateId": template_id,
"templateName": workflow.name, "templateName": workflow.name,
"model": billing_model.clone(),
"script": script,
"promptId": prompt_id, "promptId": prompt_id,
"request": body, "request": body,
"mapping": mapping, "mapping": mapping,
"history": history, "history": history,
"outputs": outputs, "outputs": outputs,
"assets": assets, "assets": assets,
"billing": billing,
}); });
sqlx::query( sqlx::query(
"UPDATE background_tasks "UPDATE background_tasks
@@ -1093,6 +1529,27 @@ async fn create_job(
} }
.await; .await;
if let Err(err) = result { if let Err(err) = result {
let tracker = UsageTracker::new(state_for_task.db.clone());
if let Err(log_err) = tracker
.record_comfy_task_failure(comfy_task_event(
&auth_for_task,
billing_model,
0,
0,
0.0,
&failure_script,
&failure_body,
None,
Some(err.to_string()),
))
.await
{
tracing::warn!(
%log_err,
task_id = %task_id_for_spawn,
"failed to record digital human comfy failure log",
);
}
let _ = sqlx::query( let _ = sqlx::query(
"UPDATE background_tasks "UPDATE background_tasks
SET status = 'failed', message = '数字人任务失败', error = ?, updated_at = datetime('now'), finished_at = datetime('now') SET status = 'failed', message = '数字人任务失败', error = ?, updated_at = datetime('now'), finished_at = datetime('now')
@@ -1278,3 +1735,121 @@ async fn upload_asset(
} }
}))) })))
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workflow_prompt_from_graph_converts_node_edge_workflow_to_comfy_prompt() {
let graph = json!({
"nodes": [
{ "id": "1", "type": "LoadImage", "params": { "image": "avatar.png" } },
{ "id": "2", "type": "VHS_VideoCombine", "params": { "filename_prefix": "digital-human" } }
],
"edges": [
{ "from": "1.IMAGE", "to": "2.images" }
]
});
let prompt = workflow_prompt_from_graph(&graph);
assert_eq!(prompt.pointer("/1/class_type").and_then(Value::as_str), Some("LoadImage"));
assert_eq!(prompt.pointer("/2/class_type").and_then(Value::as_str), Some("VHS_VideoCombine"));
assert_eq!(prompt.pointer("/2/inputs/images"), Some(&json!(["1", 0])));
}
#[test]
fn output_objects_collects_cloud_and_native_media_shapes() {
let history = json!({
"data": {
"outputs": [
"https://cdn.example.com/avatar-final.mp4",
{ "audioUrl": "https://cdn.example.com/voice.wav" }
]
},
"outputs": {
"9": {
"images": [
{ "filename": "cover.webp", "subfolder": "previews", "type": "output" }
]
}
}
});
let outputs = output_objects(&history);
assert_eq!(outputs.len(), 3);
assert!(outputs.iter().any(|item| output_remote_url(item).as_deref() == Some("https://cdn.example.com/avatar-final.mp4")));
assert!(outputs.iter().any(|item| output_kind(item) == "audio"));
assert!(outputs.iter().any(|item| output_filename(item).as_deref() == Some("cover.webp")));
}
#[test]
fn output_objects_deduplicates_repeated_remote_urls() {
let history = json!({
"outputs": {
"1": {
"videoUrl": "https://cdn.example.com/final.mp4",
"videos": [
{ "url": "https://cdn.example.com/final.mp4", "filename": "final.mp4" }
]
}
}
});
let outputs = output_objects(&history);
assert_eq!(outputs.len(), 1);
assert_eq!(output_remote_url(&outputs[0]).as_deref(), Some("https://cdn.example.com/final.mp4"));
}
#[test]
fn history_polling_treats_not_ready_statuses_as_pending() {
assert!(is_pending_history_status(StatusCode::NOT_FOUND));
assert!(is_pending_history_status(StatusCode::ACCEPTED));
assert!(is_pending_history_status(StatusCode::NO_CONTENT));
assert!(!is_pending_history_status(StatusCode::OK));
assert!(!is_pending_history_status(StatusCode::BAD_GATEWAY));
}
#[test]
fn inject_digital_human_inputs_writes_optional_comfy_api_model() {
let mut prompt = json!({
"1": { "class_type": "PromptTextNode", "inputs": { "prompt": "" } },
"2": { "class_type": "TTSVoiceNode", "inputs": { "text": "" } },
"3": { "class_type": "LoadImage", "inputs": { "image": "" } },
"4": { "class_type": "LoadAudio", "inputs": { "audio": "" } },
"5": { "class_type": "ByteDanceDigitalHumanNode", "inputs": { "model": "seedance-1-0-lite-t2v-250428" } }
});
let mapping = json!({
"prompt": { "nodeId": "1", "field": "prompt" },
"script": { "nodeId": "2", "field": "text" },
"avatar": { "nodeId": "3", "field": "image" },
"referenceAudio": { "nodeId": "4", "field": "audio" },
"model": { "nodeId": "5", "field": "model" }
});
let request = json!({
"avatarUrl": "https://cdn.example.com/avatar.png",
"referenceAudioUrl": "https://cdn.example.com/voice.wav",
"productScript": "介绍新款咖啡机",
"prompt": "真人主播讲解商品",
"audioRole": "both",
"comfyApiModel": "seedance-1-0-lite-t2v"
});
let script = inject_digital_human_inputs(&mut prompt, &mapping, &request)
.expect("digital human inputs should be injected");
assert_eq!(script, "介绍新款咖啡机");
assert_eq!(
prompt.pointer("/5/inputs/model").and_then(Value::as_str),
Some("seedance-1-0-lite-t2v-250428")
);
assert_eq!(
prompt.pointer("/1/inputs/prompt").and_then(Value::as_str),
Some("真人主播讲解商品")
);
assert_eq!(
prompt.pointer("/3/inputs/image").and_then(Value::as_str),
Some("https://cdn.example.com/avatar.png")
);
}
}
+595 -10
View File
@@ -1,8 +1,16 @@
// Events routes — system event notifications // Events routes — system event notifications
use crate::{db::models::event::Event, error::AppResult, services::runtime_logs, AppState}; use crate::{
db::models::event::Event,
error::{AppError, AppResult},
services::runtime_logs,
AppState,
};
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
http::HeaderMap,
middleware,
response::Response,
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
@@ -20,12 +28,35 @@ pub struct ListEventsQuery {
read: Option<String>, // "true" | "false" read: Option<String>, // "true" | "false"
} }
pub fn routes() -> Router<AppState> { pub fn routes(state: AppState) -> Router<AppState> {
Router::new() Router::new()
.route("/api/events", get(list_events).delete(clear_events)) .route("/api/events", get(list_events).delete(clear_events))
.route("/api/events/count", get(unread_count)) .route("/api/events/count", get(unread_count))
.route("/api/events/{id}/read", post(mark_read)) .route("/api/events/{id}/read", post(mark_read))
.route("/api/events/read-all", post(mark_all_read)) .route("/api/events/read-all", post(mark_all_read))
.route_layer(middleware::from_fn_with_state(
state,
require_events_admin_auth,
))
}
async fn require_events_admin_auth(
State(state): State<AppState>,
headers: HeaderMap,
request: axum::http::Request<axum::body::Body>,
next: axum::middleware::Next,
) -> Result<Response, AppError> {
let auth_header = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
if auth_header != state.config.auth.auth_token {
return Err(AppError::Unauthorized("Invalid admin token".into()));
}
Ok(next.run(request).await)
} }
/// GET /api/events /// GET /api/events
@@ -36,15 +67,26 @@ async fn list_events(
debug!("Listing events"); debug!("Listing events");
let limit = q.limit.unwrap_or(30).clamp(1, 500); let limit = q.limit.unwrap_or(30).clamp(1, 500);
let offset = q.offset.unwrap_or(0).max(0); let offset = q.offset.unwrap_or(0).max(0);
let filter_type = q
.filter_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let read_filter = q.read.as_deref();
let source_limit = offset
.saturating_add(limit)
.saturating_add(200)
.clamp(50, 1000);
let mut sql = "SELECT id, type, title, message, level, read, related_id, related_type, created_at FROM events WHERE 1=1".to_string(); let mut sql = "SELECT id, type, title, message, level, read, related_id, related_type, created_at FROM events WHERE 1=1".to_string();
let mut params: Vec<String> = vec![]; let mut params: Vec<String> = vec![];
if let Some(ref t) = q.filter_type { if let Some(ref t) = filter_type {
sql.push_str(" AND type = ?"); sql.push_str(" AND type = ?");
params.push(t.clone()); params.push(t.clone());
} }
if let Some(ref r) = q.read { if let Some(r) = read_filter {
if r == "true" { if r == "true" {
sql.push_str(" AND read = 1"); sql.push_str(" AND read = 1");
} else if r == "false" { } else if r == "false" {
@@ -61,18 +103,34 @@ async fn list_events(
let events: Vec<Event> = query.fetch_all(&*state.db).await?; let events: Vec<Event> = query.fetch_all(&*state.db).await?;
let mut rows: Vec<EventListItem> = events.into_iter().map(EventListItem::from).collect(); let mut rows: Vec<EventListItem> = events.into_iter().map(EventListItem::from).collect();
if q.filter_type if should_include_source(filter_type.as_deref(), "runtime") {
.as_deref()
.is_none_or(|filter_type| filter_type == "runtime")
{
rows.extend( rows.extend(
runtime_logs::list() runtime_logs::list()
.into_iter() .into_iter()
.filter(|entry| q.read.as_deref() != Some("false") || !entry.is_read) .filter(|entry| read_filter != Some("false") || !entry.is_read)
.map(EventListItem::from), .map(EventListItem::from),
); );
} }
if should_include_read_only_source(filter_type.as_deref(), read_filter, "checkin") {
rows.extend(load_checkin_log_events(&state, source_limit).await?);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "proxy") {
rows.extend(load_proxy_log_events(&state, source_limit).await?);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "balance") {
rows.extend(load_balance_log_events(&state, source_limit).await?);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "token") {
rows.extend(load_token_log_events(&state, source_limit).await?);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "site_notice") {
rows.extend(load_site_notice_events(&state, source_limit).await?);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "status") {
rows.extend(load_status_log_events(&state, source_limit).await?);
}
rows.sort_by(|a, b| { rows.sort_by(|a, b| {
b.created_at b.created_at
.cmp(&a.created_at) .cmp(&a.created_at)
@@ -89,6 +147,253 @@ async fn list_events(
Ok(Json(json!({ "data": page }))) Ok(Json(json!({ "data": page })))
} }
fn should_include_source(filter_type: Option<&str>, source: &str) -> bool {
filter_type.is_none_or(|value| value == source)
}
fn should_include_read_only_source(
filter_type: Option<&str>,
read_filter: Option<&str>,
source: &str,
) -> bool {
should_include_source(filter_type, source) && read_filter != Some("false")
}
async fn load_checkin_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-10000000000 - c.id) AS id,
'checkin' AS event_type,
CASE
WHEN lower(COALESCE(c.status, '')) IN ('success', 'ok', 'completed') THEN '签到成功'
WHEN lower(COALESCE(c.status, '')) IN ('skipped', 'skip') THEN '签到跳过'
ELSE '签到失败'
END || ':' || COALESCE(NULLIF(s.name, ''), '账号 #' || c.account_id) AS title,
COALESCE(NULLIF(a.username, ''), '账号 #' || c.account_id)
|| ' · 状态=' || COALESCE(NULLIF(c.status, ''), '-')
|| CASE WHEN c.reward IS NOT NULL AND trim(c.reward) != '' THEN ' · 奖励=' || c.reward ELSE '' END
|| CASE WHEN c.message IS NOT NULL AND trim(c.message) != '' THEN ' · ' || c.message ELSE '' END AS message,
CASE
WHEN lower(COALESCE(c.status, '')) IN ('success', 'ok', 'completed') THEN 'info'
WHEN lower(COALESCE(c.status, '')) IN ('skipped', 'skip') THEN 'warning'
ELSE 'error'
END AS level,
1 AS is_read,
c.id AS related_id,
'checkin_log' AS related_type,
c.created_at AS created_at
FROM checkin_logs c
LEFT JOIN accounts a ON a.id = c.account_id
LEFT JOIN sites s ON s.id = a.site_id
ORDER BY c.created_at DESC
LIMIT ?
"#,
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn load_proxy_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-20000000000 - l.id) AS id,
'proxy' AS event_type,
CASE
WHEN lower(COALESCE(l.status, '')) = 'success' THEN '代理请求成功'
ELSE '代理请求失败'
END || ':' || COALESCE(NULLIF(l.model_requested, ''), NULLIF(l.model_actual, ''), '未知模型') AS title,
'站点=' || COALESCE(NULLIF(s.name, ''), '-')
|| ' · 账号=' || COALESCE(NULLIF(a.username, ''), CASE WHEN l.account_id IS NOT NULL THEN '账号 #' || l.account_id ELSE '-' END)
|| ' · 状态=' || COALESCE(NULLIF(l.status, ''), '-')
|| CASE WHEN l.http_status IS NOT NULL THEN ' · HTTP ' || l.http_status ELSE '' END
|| CASE WHEN l.latency_ms IS NOT NULL THEN ' · ' || l.latency_ms || 'ms' ELSE '' END
|| CASE WHEN l.total_tokens IS NOT NULL THEN ' · Tokens=' || l.total_tokens ELSE '' END
|| CASE WHEN l.estimated_cost IS NOT NULL THEN ' · 成本=' || printf('$%.6f', l.estimated_cost) ELSE '' END
|| CASE WHEN l.error_message IS NOT NULL AND trim(l.error_message) != '' THEN ' · ' || l.error_message ELSE '' END AS message,
CASE WHEN lower(COALESCE(l.status, '')) = 'success' THEN 'info' ELSE 'error' END AS level,
1 AS is_read,
l.id AS related_id,
'proxy_log' AS related_type,
l.created_at AS created_at
FROM proxy_logs l
LEFT JOIN accounts a ON a.id = l.account_id
LEFT JOIN sites s ON s.id = a.site_id
ORDER BY l.created_at DESC
LIMIT ?
"#,
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn load_balance_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-30000000000 - a.id) AS id,
'balance' AS event_type,
'余额已刷新:' || COALESCE(NULLIF(s.name, ''), '账号 #' || a.id) AS title,
COALESCE(NULLIF(a.username, ''), '账号 #' || a.id)
|| ' · 当前余额=' || printf('%.6f', COALESCE(a.balance, 0))
|| ' · 已用=' || printf('%.6f', COALESCE(a.balance_used, 0))
|| ' · 额度=' || printf('%.6f', COALESCE(a.quota, 0)) AS message,
'info' AS level,
1 AS is_read,
a.id AS related_id,
'account' AS related_type,
a.last_balance_refresh AS created_at
FROM accounts a
LEFT JOIN sites s ON s.id = a.site_id
WHERE a.last_balance_refresh IS NOT NULL
AND trim(a.last_balance_refresh) != ''
ORDER BY a.last_balance_refresh DESC
LIMIT ?
"#,
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn load_token_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-40000000000 - t.id) AS id,
'token' AS event_type,
CASE
WHEN COALESCE(t.updated_at, t.created_at, '') != COALESCE(t.created_at, '') THEN '令牌已更新'
ELSE '令牌已创建'
END || ':' || COALESCE(NULLIF(t.name, ''), 'Token #' || t.id) AS title,
COALESCE(NULLIF(s.name, ''), '-')
|| ' · ' || COALESCE(NULLIF(a.username, ''), '账号 #' || t.account_id)
|| ' · 状态=' || COALESCE(NULLIF(t.value_status, ''), '-')
|| CASE WHEN COALESCE(t.enabled, 1) = 1 THEN ' · 已启用' ELSE ' · 已禁用' END
|| CASE WHEN t.token_group IS NOT NULL AND trim(t.token_group) != '' THEN ' · 分组=' || t.token_group ELSE '' END
|| CASE WHEN t.source IS NOT NULL AND trim(t.source) != '' THEN ' · 来源=' || t.source ELSE '' END AS message,
CASE
WHEN COALESCE(t.enabled, 1) = 1
AND lower(COALESCE(t.value_status, 'ready')) IN ('ready', 'valid') THEN 'info'
ELSE 'warning'
END AS level,
1 AS is_read,
t.id AS related_id,
'account_token' AS related_type,
COALESCE(t.updated_at, t.created_at) AS created_at
FROM account_tokens t
LEFT JOIN accounts a ON a.id = t.account_id
LEFT JOIN sites s ON s.id = a.site_id
WHERE COALESCE(t.updated_at, t.created_at) IS NOT NULL
AND trim(COALESCE(t.updated_at, t.created_at)) != ''
ORDER BY COALESCE(t.updated_at, t.created_at) DESC
LIMIT ?
"#,
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn load_site_notice_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-50000000000 - n.id) AS id,
'site_notice' AS event_type,
'站点公告:' || COALESCE(NULLIF(n.title, ''), '未命名公告') AS title,
COALESCE(NULLIF(s.name, ''), '站点 #' || n.site_id)
|| CASE WHEN n.content IS NOT NULL AND trim(n.content) != '' THEN ' · ' || substr(n.content, 1, 420) ELSE '' END AS message,
COALESCE(NULLIF(n.level, ''), 'info') AS level,
1 AS is_read,
n.id AS related_id,
'site_announcement' AS related_type,
COALESCE(n.upstream_created_at, n.first_seen_at) AS created_at
FROM site_announcements n
LEFT JOIN sites s ON s.id = n.site_id
ORDER BY COALESCE(n.upstream_created_at, n.first_seen_at) DESC
LIMIT ?
"#,
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn load_status_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let per_source_limit = limit.clamp(50, 1000);
let mut rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-60000000000 - s.id) AS id,
'status' AS event_type,
'站点状态:' || COALESCE(NULLIF(s.name, ''), '站点 #' || s.id) AS title,
'平台=' || COALESCE(NULLIF(s.platform, ''), '-')
|| ' · 状态=' || COALESCE(NULLIF(s.status, ''), '-')
|| CASE WHEN s.url IS NOT NULL AND trim(s.url) != '' THEN ' · ' || s.url ELSE '' END AS message,
CASE
WHEN lower(COALESCE(s.status, 'active')) IN ('active', 'enabled', 'healthy') THEN 'info'
WHEN lower(COALESCE(s.status, '')) IN ('disabled', 'inactive') THEN 'warning'
ELSE 'error'
END AS level,
1 AS is_read,
s.id AS related_id,
'site' AS related_type,
COALESCE(s.updated_at, s.created_at) AS created_at
FROM sites s
WHERE COALESCE(s.updated_at, s.created_at) IS NOT NULL
AND trim(COALESCE(s.updated_at, s.created_at)) != ''
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
LIMIT ?
"#,
)
.bind(per_source_limit)
.fetch_all(&*state.db)
.await?;
rows.extend(
sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-70000000000 - a.id) AS id,
'status' AS event_type,
'账号状态:' || COALESCE(NULLIF(s.name, ''), '站点 #' || a.site_id) AS title,
COALESCE(NULLIF(a.username, ''), '账号 #' || a.id)
|| ' · 状态=' || COALESCE(NULLIF(a.status, ''), '-')
|| CASE WHEN a.oauth_provider IS NOT NULL AND trim(a.oauth_provider) != '' THEN ' · OAuth=' || a.oauth_provider ELSE '' END AS message,
CASE
WHEN lower(COALESCE(a.status, 'active')) IN ('active', 'enabled', 'healthy') THEN 'info'
WHEN lower(COALESCE(a.status, '')) IN ('disabled', 'inactive') THEN 'warning'
ELSE 'error'
END AS level,
1 AS is_read,
a.id AS related_id,
'account' AS related_type,
COALESCE(a.updated_at, a.created_at) AS created_at
FROM accounts a
LEFT JOIN sites s ON s.id = a.site_id
WHERE COALESCE(a.updated_at, a.created_at) IS NOT NULL
AND trim(COALESCE(a.updated_at, a.created_at)) != ''
ORDER BY COALESCE(a.updated_at, a.created_at) DESC
LIMIT ?
"#,
)
.bind(per_source_limit)
.fetch_all(&*state.db)
.await?,
);
Ok(rows)
}
/// GET /api/events/count — unread count /// GET /api/events/count — unread count
async fn unread_count(State(state): State<AppState>) -> AppResult<Json<Value>> { async fn unread_count(State(state): State<AppState>) -> AppResult<Json<Value>> {
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM events WHERE read = 0") let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM events WHERE read = 0")
@@ -126,7 +431,7 @@ async fn clear_events(State(state): State<AppState>) -> AppResult<Json<Value>> {
Ok(Json(json!({ "success": true }))) Ok(Json(json!({ "success": true })))
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize, sqlx::FromRow)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct EventListItem { struct EventListItem {
id: i64, id: i64,
@@ -173,3 +478,283 @@ impl From<runtime_logs::RuntimeLogEntry> for EventListItem {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::{
auth::jwt::generate_jwt,
config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig},
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
};
use axum::{
body::Body,
http::{Request, StatusCode},
};
use axum::extract::{Query, State};
use sqlx::sqlite::SqlitePoolOptions;
use tower::ServiceExt as _;
async fn test_state() -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("memory sqlite");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("run migrations");
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".into(),
port: 0,
log_level: "info".into(),
cors_origins: vec![],
},
db: DbConfig {
url: "sqlite::memory:".into(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "secret".into(),
jwt_expiry_hours: 1,
auth_token: "auth".into(),
proxy_token: "proxy".into(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 3,
proxy_log_retention_days: 7,
template_proxy_url: None,
},
comfyui: Default::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-test".into()),
},
RuntimeAuthSettings::from_token_for_test("proxy"),
)
}
async fn seed_event_sources(state: &AppState) {
sqlx::query(
"INSERT INTO sites (id, name, url, platform, status)
VALUES (1, '主站', 'https://example.test', 'new-api', 'active')",
)
.execute(&*state.db)
.await
.unwrap();
sqlx::query(
"INSERT INTO accounts
(id, site_id, username, access_token, balance, balance_used, quota, last_balance_refresh, status)
VALUES
(10, 1, 'alice', 'access-token', 12.5, 1.25, 20.0, '2026-06-08T09:30:00Z', 'active')",
)
.execute(&*state.db)
.await
.unwrap();
sqlx::query(
"INSERT INTO account_tokens
(id, account_id, name, token, source, token_group, value_status, enabled, created_at, updated_at)
VALUES
(20, 10, '默认 Key', 'sk-account-token', 'manual', 'prod', 'ready', 1, '2026-06-08T09:00:00Z', '2026-06-08T09:40:00Z')",
)
.execute(&*state.db)
.await
.unwrap();
sqlx::query(
"INSERT INTO checkin_logs (id, account_id, status, message, reward, created_at)
VALUES (30, 10, 'success', 'Daily checkin completed', '+$0.0100', '2026-06-08T09:50:00Z')",
)
.execute(&*state.db)
.await
.unwrap();
sqlx::query(
"INSERT INTO proxy_logs
(id, account_id, model_requested, model_actual, status, http_status, latency_ms, total_tokens, estimated_cost, error_message, created_at)
VALUES
(40, 10, 'gpt-test', 'gpt-test', 'failed', 404, 812, 33, 0.0012, 'model not found', '2026-06-08T10:00:00Z')",
)
.execute(&*state.db)
.await
.unwrap();
sqlx::query(
"INSERT INTO site_announcements
(id, site_id, platform, source_key, title, content, level, first_seen_at, read_at)
VALUES
(50, 1, 'new-api', 'notice-1', '维护通知', '上游将维护 30 分钟', 'warning', '2026-06-08T10:10:00Z', NULL)",
)
.execute(&*state.db)
.await
.unwrap();
}
async fn list_for_type(state: AppState, filter_type: Option<&str>) -> Value {
let Json(body) = list_events(
State(state),
Query(ListEventsQuery {
limit: Some(50),
offset: Some(0),
filter_type: filter_type.map(str::to_string),
read: None,
}),
)
.await
.unwrap();
body
}
#[tokio::test]
async fn events_router_requires_admin_api_token() {
let state = test_state().await;
let app = routes(state.clone()).with_state(state.clone());
let ok_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/events")
.header("Authorization", "Bearer auth")
.body(Body::empty())
.unwrap(),
)
.await
.expect("admin token request");
assert_eq!(ok_response.status(), StatusCode::OK);
let user_jwt = generate_jwt(&state.config.auth.jwt_secret, "1", "user", 1)
.expect("user jwt");
let user_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/events")
.header("Authorization", format!("Bearer {user_jwt}"))
.body(Body::empty())
.unwrap(),
)
.await
.expect("user jwt request");
assert_eq!(user_response.status(), StatusCode::UNAUTHORIZED);
let missing_response = app
.oneshot(
Request::builder()
.uri("/api/events")
.body(Body::empty())
.unwrap(),
)
.await
.expect("missing auth request");
assert_eq!(missing_response.status(), StatusCode::UNAUTHORIZED);
}
fn data_types(body: &Value) -> Vec<String> {
body["data"]
.as_array()
.unwrap()
.iter()
.filter_map(|row| row.get("type").and_then(Value::as_str).map(str::to_string))
.collect()
}
#[tokio::test]
async fn list_events_aggregates_business_log_sources() {
let state = test_state().await;
seed_event_sources(&state).await;
let all = list_for_type(state.clone(), None).await;
let all_types = data_types(&all);
assert!(all_types.contains(&"checkin".to_string()));
assert!(all_types.contains(&"proxy".to_string()));
assert!(all_types.contains(&"balance".to_string()));
assert!(all_types.contains(&"token".to_string()));
assert!(all_types.contains(&"site_notice".to_string()));
assert!(all_types.contains(&"status".to_string()));
for (filter_type, expected_title) in [
("checkin", "签到成功"),
("proxy", "代理请求失败"),
("balance", "余额已刷新"),
("token", "令牌已更新"),
("site_notice", "站点公告"),
("status", "站点状态"),
] {
let body = list_for_type(state.clone(), Some(filter_type)).await;
let rows = body["data"].as_array().unwrap();
assert!(!rows.is_empty(), "{filter_type} should return rows");
assert!(rows
.iter()
.all(|row| row.get("type").and_then(Value::as_str) == Some(filter_type)));
assert!(rows.iter().any(|row| row
.get("title")
.and_then(Value::as_str)
.unwrap_or_default()
.contains(expected_title)));
}
}
#[tokio::test]
async fn unread_filter_excludes_read_only_virtual_logs() {
let state = test_state().await;
seed_event_sources(&state).await;
let Json(checkin_body) = list_events(
State(state.clone()),
Query(ListEventsQuery {
limit: Some(50),
offset: Some(0),
filter_type: Some("checkin".into()),
read: Some("false".into()),
}),
)
.await
.unwrap();
assert!(checkin_body["data"].as_array().unwrap().is_empty());
let Json(notice_body) = list_events(
State(state),
Query(ListEventsQuery {
limit: Some(50),
offset: Some(0),
filter_type: Some("site_notice".into()),
read: Some("false".into()),
}),
)
.await
.unwrap();
assert!(notice_body["data"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn status_snapshots_are_available_in_all_logs_and_status_filter() {
let state = test_state().await;
seed_event_sources(&state).await;
let all = list_for_type(state.clone(), None).await;
assert!(data_types(&all).contains(&"status".to_string()));
let status = list_for_type(state, Some("status")).await;
let rows = status["data"].as_array().unwrap();
assert!(rows.iter().any(|row| row["title"]
.as_str()
.unwrap_or_default()
.contains("站点状态")));
assert!(rows.iter().any(|row| row["title"]
.as_str()
.unwrap_or_default()
.contains("账号状态")));
assert!(rows
.iter()
.all(|row| row.get("type").and_then(Value::as_str) == Some("status")));
}
}
+30 -3
View File
@@ -27,7 +27,6 @@ mod comfyui_agent;
mod digital_human; mod digital_human;
mod events; mod events;
mod health; mod health;
mod monitor;
mod money_printer; mod money_printer;
mod oauth; mod oauth;
mod organizations; mod organizations;
@@ -62,9 +61,8 @@ pub fn build_router(state: AppState) -> Router {
let api_routes = api_routes let api_routes = api_routes
.merge(auth::routes()) .merge(auth::routes())
.merge(events::routes()) .merge(events::routes(state.clone()))
.merge(checkin::routes()) .merge(checkin::routes())
.merge(monitor::routes())
.merge(oauth::routes()) .merge(oauth::routes())
.merge(organizations::routes()) .merge(organizations::routes())
.merge(search::routes()) .merge(search::routes())
@@ -221,6 +219,8 @@ fn is_api_path(path: &str) -> bool {
fn should_serve_website(path: &str) -> bool { fn should_serve_website(path: &str) -> bool {
path == "/" path == "/"
|| path == "/new"
|| path.starts_with("/new/")
|| path.starts_with("/_next/") || path.starts_with("/_next/")
|| path == "/favicon.ico" || path == "/favicon.ico"
|| path == "/favicon.png" || path == "/favicon.png"
@@ -230,6 +230,9 @@ fn should_serve_website(path: &str) -> bool {
fn embedded_asset_path(path: &str) -> String { fn embedded_asset_path(path: &str) -> String {
let trimmed = path.trim_start_matches('/'); let trimmed = path.trim_start_matches('/');
if trimmed == "new" || trimmed == "new/" || trimmed == "new/index.html" {
return "new.html".to_string();
}
if trimmed.is_empty() || trimmed.ends_with('/') { if trimmed.is_empty() || trimmed.ends_with('/') {
format!("{trimmed}index.html") format!("{trimmed}index.html")
} else { } else {
@@ -269,3 +272,27 @@ async fn removed_comfy_workbench() -> (StatusCode, Json<Value>) {
})), })),
) )
} }
#[cfg(test)]
mod tests {
use super::{embedded_asset_path, should_serve_website};
#[test]
fn experimental_website_route_is_served_from_website_export() {
assert!(should_serve_website("/new"));
assert!(should_serve_website("/new/"));
assert!(should_serve_website("/new/index.html"));
assert_eq!(embedded_asset_path("/new"), "new.html");
assert_eq!(embedded_asset_path("/new/"), "new.html");
assert_eq!(embedded_asset_path("/new/index.html"), "new.html");
}
#[test]
fn application_routes_still_use_web_spa() {
assert!(!should_serve_website("/boos"));
assert!(!should_serve_website("/dashboard"));
assert!(!should_serve_website("/user/recharge"));
assert!(!should_serve_website("/logs"));
assert!(!should_serve_website("/events"));
}
}
+557 -11
View File
@@ -2,13 +2,14 @@ use crate::{
auth::jwt::validate_jwt, auth::jwt::validate_jwt,
db::models::{background_task::BackgroundTask, downstream_api_key::DownstreamApiKey}, db::models::{background_task::BackgroundTask, downstream_api_key::DownstreamApiKey},
error::{AppError, AppResult}, error::{AppError, AppResult},
services::billing::{ComfyTaskBillingEvent, UsageTracker},
services::workspace::{self, WorkspaceAssetInput}, services::workspace::{self, WorkspaceAssetInput},
workflow::storage, workflow::storage,
AppState, AppState,
}; };
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
http::{header, HeaderMap, Method}, http::{header, HeaderMap, Method, StatusCode},
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
@@ -23,11 +24,60 @@ const VIDEO_MAX_POLLS: usize = 120;
const COMFY_POLL_INTERVAL_SECS: u64 = 5; const COMFY_POLL_INTERVAL_SECS: u64 = 5;
const COMFY_MAX_POLLS: usize = 180; const COMFY_MAX_POLLS: usize = 180;
fn normalize_video_model(value: &str) -> String {
match value.trim() {
"seedance-1-0-lite-t2v" => "seedance-1-0-lite-t2v-250428".to_string(),
"seedance-1-0-lite-i2v" => "seedance-1-0-pro-i2v-250528".to_string(),
other => other.to_string(),
}
}
fn configured_task_cost(value: f64) -> f64 {
if value.is_finite() {
value.max(0.0)
} else {
0.0
}
}
fn truncate_chars(value: &str, limit: usize) -> String {
value.chars().take(limit).collect()
}
fn comfy_task_event(
auth: &MoneyPrinterAuth,
model: String,
prompt_tokens: i64,
completion_tokens: i64,
cost: f64,
topic: &str,
request: &Value,
response: Option<&Value>,
error_message: Option<String>,
) -> ComfyTaskBillingEvent {
ComfyTaskBillingEvent {
key_id: auth.key_id,
model,
task_type: TASK_TYPE_MONEY_PRINTER,
client_family: "money-printer",
client_app_id: "money-printer",
client_app_name: "Money Printer",
prompt_tokens,
completion_tokens,
cost,
content_summary: Some(truncate_chars(topic, 240)),
request_content: Some(request.to_string()),
response_content: response.map(Value::to_string),
error_message,
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct MoneyPrinterAuth { struct MoneyPrinterAuth {
user_id: Option<i64>, user_id: Option<i64>,
role: String, role: String,
outbound_key: String, outbound_key: String,
key_id: Option<i64>,
key_name: String, key_name: String,
} }
@@ -94,6 +144,7 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<MoneyPrin
user_id: None, user_id: None,
role: "admin".to_string(), role: "admin".to_string(),
outbound_key: proxy_token, outbound_key: proxy_token,
key_id: None,
key_name: "global-proxy".to_string(), key_name: "global-proxy".to_string(),
}); });
} }
@@ -113,12 +164,14 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<MoneyPrin
user_id: Some(user_id), user_id: Some(user_id),
role: claims.role, role: claims.role,
outbound_key: key.key, outbound_key: key.key,
key_id: Some(key.id),
key_name: key.name, key_name: key.name,
}), }),
Err(_) if claims.role == "admin" => Ok(MoneyPrinterAuth { Err(_) if claims.role == "admin" => Ok(MoneyPrinterAuth {
user_id: Some(user_id), user_id: Some(user_id),
role: claims.role, role: claims.role,
outbound_key: state.runtime_proxy_token(), outbound_key: state.runtime_proxy_token(),
key_id: None,
key_name: "global-proxy".to_string(), key_name: "global-proxy".to_string(),
}), }),
Err(message) => Err(AppError::Forbidden(message)), Err(message) => Err(AppError::Forbidden(message)),
@@ -150,6 +203,11 @@ async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<
"label": "内置 Cloud Seedance 文生视频", "label": "内置 Cloud Seedance 文生视频",
"nodeType": "ByteDanceTextToVideoNode", "nodeType": "ByteDanceTextToVideoNode",
"model": "seedance-1-0-lite-t2v-250428", "model": "seedance-1-0-lite-t2v-250428",
"models": [
"seedance-1-0-lite-t2v-250428",
"seedance-1-0-pro-t2v-250528",
"seedance-1-0-pro-i2v-250528"
],
"duration": { "min": 3, "max": 12, "default": 5 }, "duration": { "min": 3, "max": 12, "default": 5 },
"resolutions": ["480p", "720p", "1080p"], "resolutions": ["480p", "720p", "1080p"],
"ratios": ["9:16", "16:9", "1:1", "4:3", "3:4", "21:9"] "ratios": ["9:16", "16:9", "1:1", "4:3", "3:4", "21:9"]
@@ -420,6 +478,13 @@ fn native_request(state: &AppState, method: Method, path: &str) -> reqwest::Requ
request request
} }
fn is_pending_history_status(status: StatusCode) -> bool {
matches!(
status,
StatusCode::NOT_FOUND | StatusCode::ACCEPTED | StatusCode::NO_CONTENT
)
}
async fn native_json( async fn native_json(
state: &AppState, state: &AppState,
method: Method, method: Method,
@@ -446,6 +511,35 @@ async fn native_json(
Ok(value) Ok(value)
} }
async fn native_json_optional(
state: &AppState,
method: Method,
path: &str,
body: Option<Value>,
) -> AppResult<Option<Value>> {
let mut request = native_request(state, method, path);
if let Some(body) = body {
request = request.json(&body);
}
let response = request
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("Comfy backend unreachable: {err}")))?;
let status = response.status();
if is_pending_history_status(status) {
return Ok(None);
}
let text = response
.text()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
let value = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({ "body": text }));
if !status.is_success() {
return Err(AppError::UpstreamError(value.to_string()));
}
Ok(Some(value))
}
fn encode_query(value: &str) -> String { fn encode_query(value: &str) -> String {
let mut out = String::new(); let mut out = String::new();
for byte in value.bytes() { for byte in value.bytes() {
@@ -488,6 +582,7 @@ fn default_comfy_mapping() -> Value {
json!({ json!({
"prompt": { "nodeId": "", "field": "prompt" }, "prompt": { "nodeId": "", "field": "prompt" },
"negativePrompt": { "nodeId": "", "field": "text" }, "negativePrompt": { "nodeId": "", "field": "text" },
"model": { "nodeId": "", "field": "model" },
"ratio": { "nodeId": "", "field": "ratio" }, "ratio": { "nodeId": "", "field": "ratio" },
"resolution": { "nodeId": "", "field": "resolution" }, "resolution": { "nodeId": "", "field": "resolution" },
"duration": { "nodeId": "", "field": "duration" }, "duration": { "nodeId": "", "field": "duration" },
@@ -649,6 +744,7 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
let ratio_field = first_existing_input_field(object, &video_node, &["ratio", "model.ratio", "aspect_ratio"], "ratio"); let ratio_field = first_existing_input_field(object, &video_node, &["ratio", "model.ratio", "aspect_ratio"], "ratio");
let resolution_field = first_existing_input_field(object, &video_node, &["model.resolution", "resolution", "size"], "resolution"); let resolution_field = first_existing_input_field(object, &video_node, &["model.resolution", "resolution", "size"], "resolution");
let duration_field = first_existing_input_field(object, &video_node, &["model.duration", "duration", "length"], "duration"); let duration_field = first_existing_input_field(object, &video_node, &["model.duration", "duration", "length"], "duration");
let model_field = first_existing_input_field(object, &video_node, &["model", "model_name", "ckpt_name"], "model");
json!({ json!({
"prompt": { "prompt": {
"nodeId": prompt_node, "nodeId": prompt_node,
@@ -658,6 +754,10 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
"nodeId": negative_node, "nodeId": negative_node,
"field": "text" "field": "text"
}, },
"model": {
"nodeId": video_node,
"field": model_field
},
"ratio": { "ratio": {
"nodeId": video_node, "nodeId": video_node,
"field": ratio_field "field": ratio_field
@@ -869,6 +969,15 @@ fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value,
json!(negative), json!(negative),
)?; )?;
} }
if let Some(model) = string_from(body, &["comfyApiModel", "comfy_api_model", "apiModel", "api_model"]) {
apply_optional_node_input(
prompt,
mapping,
&["model", "modelNode", "apiModel"],
"model",
json!(normalize_video_model(&model)),
);
}
if let Some(value) = ["ratio", "aspectRatio", "aspect_ratio"].iter().find_map(|key| body.get(*key).cloned()) { if let Some(value) = ["ratio", "aspectRatio", "aspect_ratio"].iter().find_map(|key| body.get(*key).cloned()) {
apply_optional_node_input(prompt, mapping, &["ratio", "ratioNode"], "ratio", value); apply_optional_node_input(prompt, mapping, &["ratio", "ratioNode"], "ratio", value);
} }
@@ -1042,13 +1151,13 @@ async fn submit_comfy_prompt(state: &AppState, prompt: Value) -> AppResult<(Stri
Ok((prompt_id, value)) Ok((prompt_id, value))
} }
async fn comfy_history(state: &AppState, prompt_id: &str) -> AppResult<Value> { async fn comfy_history_optional(state: &AppState, prompt_id: &str) -> AppResult<Option<Value>> {
let path = if state.config.comfyui.uses_cloud_backend() { let path = if state.config.comfyui.uses_cloud_backend() {
format!("/jobs/{prompt_id}") format!("/jobs/{prompt_id}")
} else { } else {
format!("/history/{prompt_id}") format!("/history/{prompt_id}")
}; };
native_json(state, Method::GET, &path, None).await native_json_optional(state, Method::GET, &path, None).await
} }
fn comfy_failure_message(value: &Value) -> Option<String> { fn comfy_failure_message(value: &Value) -> Option<String> {
@@ -1071,12 +1180,24 @@ fn comfy_failure_message(value: &Value) -> Option<String> {
fn comfy_output_objects(value: &Value) -> Vec<Value> { fn comfy_output_objects(value: &Value) -> Vec<Value> {
let mut out = Vec::new(); let mut out = Vec::new();
fn push_unique(out: &mut Vec<Value>, item: Value) {
let identity = output_identity(&item);
if out.iter().any(|existing| output_identity(existing) == identity) {
return;
}
out.push(item);
}
fn walk(value: &Value, out: &mut Vec<Value>) { fn walk(value: &Value, out: &mut Vec<Value>) {
match value { match value {
Value::Object(object) => { Value::Object(object) => {
if output_filename(value).is_some() || output_remote_url(value).is_some() {
push_unique(out, value.clone());
}
for key in ["videos", "gifs", "images", "audio"] { for key in ["videos", "gifs", "images", "audio"] {
if let Some(items) = object.get(key).and_then(Value::as_array) { if let Some(items) = object.get(key).and_then(Value::as_array) {
out.extend(items.iter().filter(|item| item.is_object()).cloned()); for item in items {
walk(item, out);
}
} }
} }
for value in object.values() { for value in object.values() {
@@ -1088,6 +1209,11 @@ fn comfy_output_objects(value: &Value) -> Vec<Value> {
walk(item, out); walk(item, out);
} }
} }
Value::String(value) => {
if let Some(item) = output_item_from_reference(value) {
push_unique(out, item);
}
}
_ => {} _ => {}
} }
} }
@@ -1095,9 +1221,112 @@ fn comfy_output_objects(value: &Value) -> Vec<Value> {
out out
} }
fn output_identity(item: &Value) -> String {
output_remote_url(item)
.or_else(|| output_filename(item))
.unwrap_or_else(|| item.to_string())
}
fn looks_like_media_reference(value: &str) -> bool {
let trimmed = value.trim();
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://") || trimmed.starts_with('/')) {
return false;
}
let lower = trimmed
.split(['?', '#'])
.next()
.unwrap_or(trimmed)
.to_ascii_lowercase();
lower.contains("/view")
|| lower.ends_with(".mp4")
|| lower.ends_with(".webm")
|| lower.ends_with(".mov")
|| lower.ends_with(".m4v")
|| lower.ends_with(".gif")
|| lower.ends_with(".png")
|| lower.ends_with(".jpg")
|| lower.ends_with(".jpeg")
|| lower.ends_with(".webp")
|| lower.ends_with(".mp3")
|| lower.ends_with(".wav")
|| lower.ends_with(".m4a")
}
fn output_item_from_reference(value: &str) -> Option<Value> {
let trimmed = value.trim();
if !looks_like_media_reference(trimmed) {
return None;
}
let filename = trimmed
.split(['?', '#'])
.next()
.unwrap_or(trimmed)
.rsplit('/')
.next()
.unwrap_or("comfy-output")
.to_string();
Some(json!({
"url": trimmed,
"filename": filename,
"type": "output"
}))
}
fn output_filename(item: &Value) -> Option<String> {
["filename", "fileName", "name", "file"]
.iter()
.find_map(|key| scalar_string(item.get(*key)))
}
fn output_remote_url(item: &Value) -> Option<String> {
[
"url",
"remoteUrl",
"remote_url",
"videoUrl",
"video_url",
"audioUrl",
"audio_url",
"imageUrl",
"image_url",
"fileUrl",
"file_url",
"downloadUrl",
"download_url",
"video",
"audio",
"image",
]
.iter()
.find_map(|key| scalar_string(item.get(*key)))
.filter(|value| looks_like_media_reference(value))
}
fn output_kind(item: &Value) -> &'static str { fn output_kind(item: &Value) -> &'static str {
let filename = scalar_string(item.get("filename")).unwrap_or_default().to_ascii_lowercase(); let explicit = scalar_string(item.get("kind"))
if filename.ends_with(".mp4") || filename.ends_with(".webm") || filename.ends_with(".mov") { .or_else(|| scalar_string(item.get("mediaType")))
.or_else(|| scalar_string(item.get("mime")))
.unwrap_or_default()
.to_ascii_lowercase();
if explicit.contains("video") {
return "video";
}
if explicit.contains("audio") {
return "audio";
}
if explicit.contains("image") {
return "image";
}
let filename = output_filename(item)
.or_else(|| output_remote_url(item))
.unwrap_or_default()
.to_ascii_lowercase();
if filename.ends_with(".mp4")
|| filename.ends_with(".webm")
|| filename.ends_with(".mov")
|| filename.ends_with(".m4v")
|| filename.ends_with(".gif")
{
"video" "video"
} else if filename.ends_with(".mp3") || filename.ends_with(".wav") || filename.ends_with(".m4a") { } else if filename.ends_with(".mp3") || filename.ends_with(".wav") || filename.ends_with(".m4a") {
"audio" "audio"
@@ -1107,7 +1336,7 @@ fn output_kind(item: &Value) -> &'static str {
} }
fn output_query(item: &Value) -> Option<String> { fn output_query(item: &Value) -> Option<String> {
let filename = scalar_string(item.get("filename"))?; let filename = output_filename(item)?;
let file_type = scalar_string(item.get("type")).unwrap_or_else(|| "output".to_string()); let file_type = scalar_string(item.get("type")).unwrap_or_else(|| "output".to_string());
let subfolder = scalar_string(item.get("subfolder")).unwrap_or_default(); let subfolder = scalar_string(item.get("subfolder")).unwrap_or_default();
let mut parts = vec![ let mut parts = vec![
@@ -1128,6 +1357,37 @@ async fn archive_comfy_output(
prompt: &str, prompt: &str,
title: &str, title: &str,
) -> Option<Value> { ) -> Option<Value> {
if let Some(remote_url) = output_remote_url(item) {
let kind = output_kind(item).to_string();
let input = WorkspaceAssetInput {
user_id: auth.user_id,
source: "money-printer".to_string(),
kind: kind.clone(),
title: Some(title.chars().take(80).collect()),
prompt: Some(prompt.chars().take(1000).collect()),
model: Some("comfy-api".to_string()),
remote_url: Some(remote_url.clone()),
metadata: json!({
"workspaceAsset": { "role": "output" },
"moneyPrinter": { "backend": "comfy", "promptId": prompt_id, "raw": item },
}),
};
return match workspace::record_remote_asset(state, input).await {
Ok(asset) => Some(json!({
"publicId": asset.public_id,
"kind": kind,
"status": asset.status,
"remoteUrl": asset.remote_url,
"fileName": asset.file_name,
})),
Err(err) => Some(json!({
"kind": kind,
"status": "failed",
"remoteUrl": remote_url,
"error": err.to_string(),
})),
};
}
let query = output_query(item)?; let query = output_query(item)?;
let kind = output_kind(item).to_string(); let kind = output_kind(item).to_string();
let path = if kind == "video" { "/viewvideo" } else { "/view" }; let path = if kind == "video" { "/viewvideo" } else { "/view" };
@@ -1195,9 +1455,13 @@ async fn submit_comfy_video(
} }
let video_prompt = inject_comfy_video_inputs(&mut prompt, &mapping, body, plan)?; let video_prompt = inject_comfy_video_inputs(&mut prompt, &mapping, body, plan)?;
let (prompt_id, response) = submit_comfy_prompt(state, prompt).await?; let (prompt_id, response) = submit_comfy_prompt(state, prompt).await?;
let model = string_from(body, &["comfyApiModel", "comfy_api_model", "apiModel", "api_model"])
.map(|value| normalize_video_model(&value))
.unwrap_or_else(|| "comfy-api".to_string());
Ok(json!({ Ok(json!({
"backend": "comfy", "backend": "comfy",
"promptId": prompt_id, "promptId": prompt_id,
"model": model,
"prompt": video_prompt, "prompt": video_prompt,
"mapping": mapping, "mapping": mapping,
"response": response, "response": response,
@@ -1211,7 +1475,8 @@ async fn submit_video(
plan: &Value, plan: &Value,
) -> AppResult<Value> { ) -> AppResult<Value> {
let model = string_from(body, &["videoModel", "video_model", "model"]) let model = string_from(body, &["videoModel", "video_model", "model"])
.unwrap_or_else(|| "seedance-1-0-lite-t2v".to_string()); .map(|value| normalize_video_model(&value))
.unwrap_or_else(|| "seedance-1-0-lite-t2v-250428".to_string());
let prompt = string_from(body, &["videoPrompt", "video_prompt"]).unwrap_or_else(|| { let prompt = string_from(body, &["videoPrompt", "video_prompt"]).unwrap_or_else(|| {
plan.get("video_prompt") plan.get("video_prompt")
.and_then(Value::as_str) .and_then(Value::as_str)
@@ -1352,6 +1617,9 @@ async fn create_job(
Json(body): Json<Value>, Json(body): Json<Value>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?; let auth = resolve_auth(&headers, &state).await?;
if let Some(key_id) = auth.key_id {
UsageTracker::new(state.db.clone()).check_rate_limit(key_id).await?;
}
let topic = string_from(&body, &["topic", "prompt"]) let topic = string_from(&body, &["topic", "prompt"])
.ok_or_else(|| AppError::BadRequest("topic is required".into()))?; .ok_or_else(|| AppError::BadRequest("topic is required".into()))?;
let task_id = format!("mp_{}", uuid::Uuid::new_v4().simple()); let task_id = format!("mp_{}", uuid::Uuid::new_v4().simple());
@@ -1359,6 +1627,7 @@ async fn create_job(
let initial_result = json!({ let initial_result = json!({
"userId": auth.user_id, "userId": auth.user_id,
"role": auth.role, "role": auth.role,
"keyId": auth.key_id,
"keyName": auth.key_name, "keyName": auth.key_name,
"topic": topic, "topic": topic,
"request": body, "request": body,
@@ -1380,6 +1649,23 @@ async fn create_job(
let task_id_for_spawn = task_id.clone(); let task_id_for_spawn = task_id.clone();
let auth_for_task = auth.clone(); let auth_for_task = auth.clone();
tokio::spawn(async move { tokio::spawn(async move {
let failure_body = body.clone();
let failure_topic = topic.clone();
let failure_backend = string_from(&failure_body, &["videoBackend", "video_backend"])
.unwrap_or_else(|| "boosapi".to_string())
.to_ascii_lowercase();
let failure_model = string_from(
&failure_body,
&["comfyApiModel", "comfy_api_model", "apiModel", "api_model", "videoModel", "video_model", "model"],
)
.map(|value| normalize_video_model(&value))
.unwrap_or_else(|| {
if failure_backend == "comfy" {
"comfy-api".to_string()
} else {
"video".to_string()
}
});
let result = async { let result = async {
update_progress(&state_for_task, &task_id_for_spawn, 2, "正在生成脚本和分镜").await?; update_progress(&state_for_task, &task_id_for_spawn, 2, "正在生成脚本和分镜").await?;
let script = generate_script(&state_for_task, &auth_for_task, &body).await?; let script = generate_script(&state_for_task, &auth_for_task, &body).await?;
@@ -1390,6 +1676,7 @@ async fn create_job(
let mid_result = json!({ let mid_result = json!({
"userId": auth_for_task.user_id, "userId": auth_for_task.user_id,
"role": auth_for_task.role, "role": auth_for_task.role,
"keyId": auth_for_task.key_id,
"keyName": auth_for_task.key_name, "keyName": auth_for_task.key_name,
"topic": topic, "topic": topic,
"request": body, "request": body,
@@ -1426,15 +1713,21 @@ async fn create_job(
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or_default() .unwrap_or_default()
.to_string(); .to_string();
let model = submitted
.get("model")
.and_then(Value::as_str)
.unwrap_or("comfy-api")
.to_string();
let submitted_result = json!({ let submitted_result = json!({
"userId": auth_for_task.user_id, "userId": auth_for_task.user_id,
"role": auth_for_task.role, "role": auth_for_task.role,
"keyId": auth_for_task.key_id,
"keyName": auth_for_task.key_name, "keyName": auth_for_task.key_name,
"topic": topic, "topic": topic,
"request": body, "request": body,
"script": script, "script": script,
"videoBackend": video_backend, "videoBackend": video_backend,
"model": "comfy-api", "model": model,
"prompt": prompt, "prompt": prompt,
"video": submitted, "video": submitted,
}); });
@@ -1449,7 +1742,11 @@ async fn create_job(
return Ok(()); return Ok(());
} }
update_progress(&state_for_task, &task_id_for_spawn, 4, "Comfy 正在生成视频").await?; update_progress(&state_for_task, &task_id_for_spawn, 4, "Comfy 正在生成视频").await?;
let history = comfy_history(&state_for_task, &prompt_id).await?; let Some(history) = comfy_history_optional(&state_for_task, &prompt_id).await?
else {
tokio::time::sleep(Duration::from_secs(COMFY_POLL_INTERVAL_SECS)).await;
continue;
};
if let Some(message) = comfy_failure_message(&history) { if let Some(message) = comfy_failure_message(&history) {
return Err(AppError::UpstreamError(message)); return Err(AppError::UpstreamError(message));
} }
@@ -1478,7 +1775,7 @@ async fn create_job(
if final_history.is_null() { if final_history.is_null() {
return Err(AppError::GatewayTimeout("Comfy 视频任务等待超时".into())); return Err(AppError::GatewayTimeout("Comfy 视频任务等待超时".into()));
} }
(submitted, final_history, None, prompt, "comfy-api".to_string()) (submitted, final_history, None, prompt, model)
} else { } else {
update_progress(&state_for_task, &task_id_for_spawn, 3, "正在提交 BoosAPI 视频模型").await?; update_progress(&state_for_task, &task_id_for_spawn, 3, "正在提交 BoosAPI 视频模型").await?;
let submitted = submit_video(&state_for_task, &auth_for_task, &body, &plan).await?; let submitted = submit_video(&state_for_task, &auth_for_task, &body, &plan).await?;
@@ -1524,9 +1821,46 @@ async fn create_job(
} }
(submitted, final_video_response, video_url, prompt, model) (submitted, final_video_response, video_url, prompt, model)
}; };
let billing = if video_backend == "comfy" {
let tracker = UsageTracker::new(state_for_task.db.clone());
let event = comfy_task_event(
&auth_for_task,
model.clone(),
0,
0,
configured_task_cost(state_for_task.config.comfyui.money_printer_video_cost),
&topic,
&body,
Some(&final_video_response),
None,
);
match tracker.record_comfy_task_success(event).await {
Ok(details) => details,
Err(err) => {
tracing::warn!(
%err,
task_id = %task_id_for_spawn,
"failed to record money printer comfy billing",
);
json!({
"source": "comfy-native-task",
"taskType": TASK_TYPE_MONEY_PRINTER,
"status": "success",
"charged": false,
"failed": true,
"error": err.to_string(),
"totalCost": 0.0,
"steps": []
})
}
}
} else {
Value::Null
};
let final_result = json!({ let final_result = json!({
"userId": auth_for_task.user_id, "userId": auth_for_task.user_id,
"role": auth_for_task.role, "role": auth_for_task.role,
"keyId": auth_for_task.key_id,
"keyName": auth_for_task.key_name, "keyName": auth_for_task.key_name,
"topic": topic, "topic": topic,
"request": body, "request": body,
@@ -1538,6 +1872,7 @@ async fn create_job(
"finalVideoResponse": final_video_response, "finalVideoResponse": final_video_response,
"videoUrl": video_url, "videoUrl": video_url,
"assets": assets, "assets": assets,
"billing": billing,
}); });
sqlx::query( sqlx::query(
"UPDATE background_tasks "UPDATE background_tasks
@@ -1553,6 +1888,27 @@ async fn create_job(
} }
.await; .await;
if let Err(err) = result { if let Err(err) = result {
if failure_backend == "comfy" {
let tracker = UsageTracker::new(state_for_task.db.clone());
let event = comfy_task_event(
&auth_for_task,
failure_model,
0,
0,
0.0,
&failure_topic,
&failure_body,
None,
Some(err.to_string()),
);
if let Err(log_err) = tracker.record_comfy_task_failure(event).await {
tracing::warn!(
%log_err,
task_id = %task_id_for_spawn,
"failed to record money printer comfy failure log",
);
}
}
let _ = sqlx::query( let _ = sqlx::query(
"UPDATE background_tasks "UPDATE background_tasks
SET status = 'failed', message = '短视频生成失败', error = ?, updated_at = datetime('now'), finished_at = datetime('now') SET status = 'failed', message = '短视频生成失败', error = ?, updated_at = datetime('now'), finished_at = datetime('now')
@@ -1660,3 +2016,193 @@ async fn clear_jobs(State(state): State<AppState>, headers: HeaderMap) -> AppRes
} }
Ok(Json(json!({ "success": true, "cleared": cleared }))) Ok(Json(json!({ "success": true, "cleared": cleared })))
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::{
config::{AppConfig, AuthConfig, ComfyUIBackend, ComfyUIConfig, DbConfig, ProxyConfig, ServerConfig},
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
};
use axum::http::HeaderValue;
use sqlx::sqlite::SqlitePoolOptions;
async fn test_state_with_comfy_backend(backend: ComfyUIBackend) -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("in-memory database");
let comfyui = ComfyUIConfig {
enabled: true,
backend,
base_url: Some("https://cloud.comfy.org".to_string()),
api_key: Some("sk-comfy-cloud-test".to_string()),
..Default::default()
};
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".into(),
port: 0,
log_level: "info".into(),
cors_origins: vec![],
},
db: DbConfig {
url: "sqlite::memory:".into(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "test-secret".into(),
jwt_expiry_hours: 1,
auth_token: "admin-token".into(),
proxy_token: "proxy".into(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 3,
proxy_log_retention_days: 7,
template_proxy_url: None,
},
comfyui,
},
pool,
BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-test".into()),
},
RuntimeAuthSettings::from_token_for_test("proxy"),
)
}
#[tokio::test]
async fn status_exposes_comfy_cloud_api_model_candidates() {
let state = test_state_with_comfy_backend(ComfyUIBackend::Cloud).await;
let mut headers = HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_static("Bearer proxy"),
);
let Json(value) = status(State(state), headers)
.await
.expect("status response");
assert_eq!(value["builtinCloudTemplate"]["enabled"], true);
assert_eq!(
value["builtinCloudTemplate"]["model"],
"seedance-1-0-lite-t2v-250428"
);
let models = value["builtinCloudTemplate"]["models"]
.as_array()
.expect("model candidates");
assert!(models
.iter()
.any(|item| item.as_str() == Some("seedance-1-0-pro-t2v-250528")));
assert!(models
.iter()
.any(|item| item.as_str() == Some("seedance-1-0-pro-i2v-250528")));
}
#[test]
fn comfy_output_objects_collects_nested_filename_and_remote_url_outputs() {
let history = json!({
"status": "success",
"outputs": {
"12": {
"videos": [
{ "filename": "clip.mp4", "subfolder": "final", "type": "output" }
],
"images": [
"https://cdn.example.com/poster.png"
]
},
"13": {
"result": {
"videoUrl": "https://cdn.example.com/final.webm?token=1"
}
}
}
});
let outputs = comfy_output_objects(&history);
assert_eq!(outputs.len(), 3);
assert!(outputs.iter().any(|item| output_filename(item).as_deref() == Some("clip.mp4")));
assert!(outputs.iter().any(|item| output_remote_url(item).as_deref() == Some("https://cdn.example.com/poster.png")));
assert!(outputs.iter().any(|item| output_kind(item) == "video"));
}
#[test]
fn comfy_output_objects_deduplicates_repeated_media_references() {
let history = json!({
"outputs": {
"1": {
"videoUrl": "https://cdn.example.com/final.mp4",
"videos": [
{ "url": "https://cdn.example.com/final.mp4", "filename": "final.mp4" }
]
}
}
});
let outputs = comfy_output_objects(&history);
assert_eq!(outputs.len(), 1);
assert_eq!(output_remote_url(&outputs[0]).as_deref(), Some("https://cdn.example.com/final.mp4"));
}
#[test]
fn comfy_history_polling_treats_not_ready_statuses_as_pending() {
assert!(is_pending_history_status(StatusCode::NOT_FOUND));
assert!(is_pending_history_status(StatusCode::ACCEPTED));
assert!(is_pending_history_status(StatusCode::NO_CONTENT));
assert!(!is_pending_history_status(StatusCode::OK));
assert!(!is_pending_history_status(StatusCode::INTERNAL_SERVER_ERROR));
}
#[test]
fn comfy_api_model_is_injected_into_mapped_video_node() {
let mut prompt = json!({
"1": {
"class_type": "ByteDanceTextToVideoNode",
"inputs": {
"model": "seedance-1-0-lite-t2v-250428",
"prompt": "placeholder",
"resolution": "480p",
"aspect_ratio": "9:16",
"duration": 5
}
}
});
let mapping = json!({
"prompt": { "nodeId": "1", "field": "prompt" },
"model": { "nodeId": "1", "field": "model" },
"ratio": { "nodeId": "1", "field": "aspect_ratio" },
"resolution": { "nodeId": "1", "field": "resolution" },
"duration": { "nodeId": "1", "field": "duration" }
});
let body = json!({
"comfyApiModel": "seedance-1-0-lite-t2v",
"ratio": "16:9",
"resolution": "720p",
"duration": 8
});
let plan = json!({
"video_prompt": "cinematic product demo"
});
let prompt_text = inject_comfy_video_inputs(&mut prompt, &mapping, &body, &plan)
.expect("injecting Comfy video inputs should succeed");
assert_eq!(prompt_text, "cinematic product demo");
assert_eq!(prompt.pointer("/1/inputs/model").and_then(Value::as_str), Some("seedance-1-0-lite-t2v-250428"));
assert_eq!(prompt.pointer("/1/inputs/prompt").and_then(Value::as_str), Some("cinematic product demo"));
assert_eq!(prompt.pointer("/1/inputs/aspect_ratio").and_then(Value::as_str), Some("16:9"));
assert_eq!(prompt.pointer("/1/inputs/resolution").and_then(Value::as_str), Some("720p"));
assert_eq!(prompt.pointer("/1/inputs/duration").and_then(Value::as_i64), Some(8));
}
}
-297
View File
@@ -1,297 +0,0 @@
// Monitor routes — LDOH incident dashboard proxy with cookie auth
use crate::{
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, State},
http::{header, HeaderMap},
response::Response,
routing::{get, post},
Json, Router,
};
use serde_json::{json, Value};
const MONITOR_AUTH_COOKIE: &str = "meta_monitor_auth";
const LDOH_BASE_URL: &str = "https://ldoh.105117.xyz";
const LDOH_COOKIE_SETTING_KEY: &str = "monitor_ldoh_cookie";
pub fn routes() -> Router<AppState> {
Router::new()
.route(
"/api/monitor/config",
get(get_monitor_config).put(update_monitor_config),
)
.route("/api/monitor/session", post(create_session))
.route("/monitor-proxy/ldoh", get(ldoh_proxy_handler))
.route("/monitor-proxy/ldoh/", get(ldoh_proxy_handler))
.route("/monitor-proxy/ldoh/{*path}", get(ldoh_proxy_handler))
}
/// GET /api/monitor/config — returns whether LDOH cookie is configured
async fn get_monitor_config(State(state): State<AppState>) -> AppResult<Json<Value>> {
let cookie: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
.bind(LDOH_COOKIE_SETTING_KEY)
.fetch_optional(&*state.db)
.await?;
let raw = cookie
.and_then(|(v,)| {
serde_json::from_str::<serde_json::Value>(&v)
.ok()
.and_then(|parsed| parsed.as_str().map(String::from))
})
.unwrap_or_default();
let configured = !raw.is_empty();
let masked = if configured {
mask_cookie_value(&raw)
} else {
String::new()
};
Ok(Json(json!({
"ldohCookieConfigured": configured,
"ldohCookieMasked": masked,
})))
}
/// PUT /api/monitor/config — save LDOH cookie
async fn update_monitor_config(
State(state): State<AppState>,
Json(body): Json<serde_json::Value>,
) -> AppResult<Json<Value>> {
let cookie = body
.get("ldohCookie")
.and_then(|v| v.as_str())
.unwrap_or("");
if cookie.is_empty() {
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, '')")
.bind(LDOH_COOKIE_SETTING_KEY)
.execute(&*state.db)
.await?;
return Ok(Json(json!({
"success": true,
"message": "LDOH Cookie 已清空",
"ldohCookieConfigured": false,
})));
}
let normalized = normalize_ldoh_cookie(cookie);
if !normalized.starts_with("ld_auth_session=") || normalized.len() < 24 {
return Err(AppError::BadRequest("Cookie 格式无效".into()));
}
let escaped = serde_json::to_string(&normalized).unwrap_or_default();
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(LDOH_COOKIE_SETTING_KEY)
.bind(&escaped)
.execute(&*state.db)
.await?;
Ok(Json(json!({
"success": true,
"message": "LDOH Cookie 已保存",
"ldohCookieConfigured": true,
"ldohCookieMasked": mask_cookie_value(&normalized),
})))
}
/// POST /api/monitor/session — returns cookie value for client to set
async fn create_session(State(state): State<AppState>) -> Json<Value> {
let token = &state.config.auth.auth_token;
Json(json!({
"success": true,
"_cookie": format!("{}={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=7200", MONITOR_AUTH_COOKIE, token),
}))
}
/// GET /monitor-proxy/ldoh/{*path} — reverse proxy to ldoh.105117.xyz
async fn ldoh_proxy_handler(
State(state): State<AppState>,
headers: HeaderMap,
Path(path): Path<String>,
) -> Result<Response, AppError> {
// Auth check via cookie
let cookies = parse_cookies(
headers
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or(""),
);
if cookies.get(MONITOR_AUTH_COOKIE).map(|s| s.as_str()) != Some(&state.config.auth.auth_token) {
return Err(AppError::Unauthorized(
"Missing or invalid monitor session".into(),
));
}
// Get stored LDOH cookie
let stored: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
.bind(LDOH_COOKIE_SETTING_KEY)
.fetch_optional(&*state.db)
.await?;
let ldoh_cookie = stored
.and_then(|(v,)| {
serde_json::from_str::<serde_json::Value>(&v)
.ok()
.and_then(|parsed| parsed.as_str().map(String::from))
})
.unwrap_or_default();
if ldoh_cookie.is_empty() {
return Err(AppError::BadRequest("LDOH cookie not configured".into()));
}
// Build upstream URL
let clean_path = if path.is_empty() || path == "/" {
String::new()
} else {
format!("/{}", path)
};
let target_url = format!("{}{}", LDOH_BASE_URL, clean_path);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to build client: {e}")))?;
let mut req = client
.get(&target_url)
.header("cookie", &ldoh_cookie)
.header(
"accept",
headers
.get("accept")
.and_then(|v| v.to_str().ok())
.unwrap_or("*/*"),
)
.header(
"accept-language",
headers
.get("accept-language")
.and_then(|v| v.to_str().ok())
.unwrap_or("zh-CN,zh;q=0.9,en;q=0.8"),
)
.header(
"user-agent",
headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.unwrap_or("boosapiMonitorProxy/1.0"),
);
if let Some(referer) = headers.get("referer").and_then(|v| v.to_str().ok()) {
req = req.header("referer", referer.replace("/monitor-proxy/ldoh", ""));
}
let resp = req
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("LDOH unreachable: {e}")))?;
let status = resp.status();
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let mut builder = axum::response::Response::builder().status(status);
if let Some(ctl) = resp
.headers()
.get("cache-control")
.and_then(|v| v.to_str().ok())
{
builder = builder.header("cache-control", ctl);
}
// Rewrite text content
if content_type.contains("text/html")
|| content_type.contains("javascript")
|| content_type.contains("text/css")
|| content_type.contains("application/json")
{
let text = resp
.text()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
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}")));
}
let bytes = resp
.bytes()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
builder
.header(header::CONTENT_TYPE, &content_type)
.body(axum::body::Body::from(bytes))
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))
}
// ── Helpers ────────────────────────────────────────────────────────────
fn parse_cookies(raw: &str) -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new();
for part in raw.split(';') {
let part = part.trim();
if let Some(idx) = part.find('=') {
let key = part[..idx].trim().to_string();
let val = part[idx + 1..].trim().to_string();
if !key.is_empty() {
map.insert(key, val);
}
}
}
map
}
fn mask_cookie_value(cookie_text: &str) -> String {
let raw = cookie_text.trim();
if raw.is_empty() {
return String::new();
}
let value = raw.split('=').nth(1).unwrap_or(raw);
if value.len() <= 10 {
format!("{}****", &value[..2.min(value.len())])
} else {
format!("{}****{}", &value[..6], &value[value.len() - 4..])
}
}
fn normalize_ldoh_cookie(raw: &str) -> String {
let trimmed = raw.trim().to_string();
if trimmed.contains("ld_auth_session=") {
if let Some(first) = trimmed.split(';').next() {
if first.trim().starts_with("ld_auth_session=") {
return first.trim().to_string();
}
}
}
format!("ld_auth_session={}", trimmed)
}
fn rewrite_proxy_text(text: &str) -> String {
text.replace("https://ldoh.105117.xyz/", "/monitor-proxy/ldoh/")
.replace(
"https:\\/\\/ldoh.105117.xyz\\/",
"\\/monitor-proxy\\/ldoh\\/",
)
.replace("src=\"/", "src=\"/monitor-proxy/ldoh/")
.replace("src='/", "src='/monitor-proxy/ldoh/")
.replace("href=\"/", "href=\"/monitor-proxy/ldoh/")
.replace("href='/", "href='/monitor-proxy/ldoh/")
.replace("action=\"/", "action=\"/monitor-proxy/ldoh/")
.replace("action='/", "action='/monitor-proxy/ldoh/")
.replace("\"/_next/", "\"/monitor-proxy/ldoh/_next/")
.replace("'/_next/", "'/monitor-proxy/ldoh/_next/")
.replace("\"\\\\/api/", "\"\\\\/monitor-proxy\\\\/ldoh\\\\/api/")
.replace("'/api/", "'/monitor-proxy/ldoh/api/")
.replace("\"/api/", "\"/monitor-proxy/ldoh/api/")
}
+4206 -117
View File
File diff suppressed because it is too large Load Diff
+155
View File
@@ -0,0 +1,155 @@
use crate::{
auth::jwt::validate_jwt,
error::{AppError, AppResult},
services::payment,
AppState,
};
use axum::{
body::Bytes,
extract::{Path, State},
http::{HeaderMap, StatusCode},
routing::{get, post},
Json, Router,
};
use serde_json::{json, Value};
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/payment/config", get(get_payment_config))
.route("/api/user/payment/orders/{public_id}", get(get_payment_order))
.route(
"/api/v1/payment/webhook/stripe",
post(stripe_webhook),
)
}
fn current_user_id(headers: &HeaderMap, state: &AppState) -> AppResult<i64> {
let auth = headers
.get("Authorization")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
claims
.sub
.parse()
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))
}
async fn get_payment_config(State(state): State<AppState>) -> AppResult<Json<Value>> {
Ok(Json(json!({
"success": true,
"payment": payment::public_config(&state.config.payment),
"webhooks": {
"stripe": "/api/v1/payment/webhook/stripe",
},
})))
}
async fn get_payment_order(
State(state): State<AppState>,
headers: HeaderMap,
Path(public_id): Path<String>,
) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let order = payment::fetch_payment_order_by_public_id(&state.db, user_id, public_id.trim()).await?;
Ok(Json(json!({
"success": true,
"order": order,
})))
}
async fn stripe_webhook(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> AppResult<(StatusCode, Json<Value>)> {
let webhook_secret = state
.config
.payment
.stripe_webhook_secret
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| AppError::ServiceUnavailable("Stripe webhook secret is not configured".into()))?;
let signature = headers
.get("stripe-signature")
.or_else(|| headers.get("Stripe-Signature"))
.and_then(|value| value.to_str().ok())
.ok_or_else(|| AppError::Unauthorized("Missing Stripe signature".into()))?;
if !payment::verify_stripe_signature(&body, signature, webhook_secret, 300) {
return Err(AppError::Unauthorized("Invalid Stripe signature".into()));
}
let event: Value = serde_json::from_slice(&body)
.map_err(|_| AppError::BadRequest("Invalid Stripe webhook JSON".into()))?;
let Some((event_type, session_id, payment_intent)) = payment::stripe_event_checkout_session(&event) else {
return Ok((StatusCode::OK, Json(json!({ "received": true, "ignored": true }))));
};
match event_type.as_str() {
"checkout.session.completed" => {
let completion = payment::complete_stripe_order_by_session(
&state.db,
&session_id,
payment_intent.as_deref(),
)
.await?;
Ok((
StatusCode::OK,
Json(json!({
"received": true,
"completed": true,
"order": completion.order,
"rechargeRecord": completion.recharge_record,
"creditedKeyId": completion.credited_key_id,
})),
))
}
"checkout.session.expired" => {
let order = payment::mark_stripe_order_failed_by_session(&state.db, &session_id).await?;
Ok((
StatusCode::OK,
Json(json!({
"received": true,
"failed": order.is_some(),
"order": order,
})),
))
}
_ => Ok((StatusCode::OK, Json(json!({ "received": true, "ignored": true })))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn stripe_signature_verification_matches_route_header_shape() {
let payload = br#"{"type":"checkout.session.completed"}"#;
let secret = "whsec_route_test";
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(format!("{timestamp}.{}", String::from_utf8_lossy(payload)).as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
let header = HeaderValue::from_str(&format!("t={timestamp},v1={signature}")).unwrap();
assert!(payment::verify_stripe_signature(
payload,
header.to_str().unwrap(),
secret,
300,
));
}
}
+6
View File
@@ -32,6 +32,7 @@ struct PictureAgentAuth {
user_id: Option<i64>, user_id: Option<i64>,
role: String, role: String,
outbound_key: String, outbound_key: String,
key_id: Option<i64>,
key_name: String, key_name: String,
} }
@@ -129,6 +130,7 @@ async fn resolve_picture_agent_auth(
user_id: None, user_id: None,
role: "admin".to_string(), role: "admin".to_string(),
outbound_key: proxy_token, outbound_key: proxy_token,
key_id: None,
key_name: "global-proxy".to_string(), key_name: "global-proxy".to_string(),
}); });
} }
@@ -149,12 +151,14 @@ async fn resolve_picture_agent_auth(
user_id: Some(user_id), user_id: Some(user_id),
role: claims.role, role: claims.role,
outbound_key: key.key, outbound_key: key.key,
key_id: Some(key.id),
key_name: key.name, key_name: key.name,
}), }),
Err(_) if claims.role == "admin" => Ok(PictureAgentAuth { Err(_) if claims.role == "admin" => Ok(PictureAgentAuth {
user_id: Some(user_id), user_id: Some(user_id),
role: claims.role, role: claims.role,
outbound_key: state.runtime_proxy_token(), outbound_key: state.runtime_proxy_token(),
key_id: None,
key_name: "global-proxy".to_string(), key_name: "global-proxy".to_string(),
}), }),
Err(message) => Err(AppError::Forbidden(message)), Err(message) => Err(AppError::Forbidden(message)),
@@ -656,6 +660,7 @@ async fn create_generation_job(
let initial_result = json!({ let initial_result = json!({
"userId": auth.user_id, "userId": auth.user_id,
"role": auth.role, "role": auth.role,
"keyId": auth.key_id,
"keyName": auth.key_name, "keyName": auth.key_name,
"prompt": prompt, "prompt": prompt,
"model": model, "model": model,
@@ -737,6 +742,7 @@ async fn create_generation_job(
let final_result = json!({ let final_result = json!({
"userId": auth_for_task.user_id, "userId": auth_for_task.user_id,
"role": auth_for_task.role, "role": auth_for_task.role,
"keyId": auth_for_task.key_id,
"keyName": auth_for_task.key_name, "keyName": auth_for_task.key_name,
"prompt": prompt, "prompt": prompt,
"model": model, "model": model,
+57 -2
View File
@@ -1087,7 +1087,11 @@ where
} }
let raw_error_text = Some(err.to_string()); let raw_error_text = Some(err.to_string());
if matches!(action, AttemptFailureAction::Terminal | AttemptFailureAction::Stop) || attempts >= 5 { if matches!(
action,
AttemptFailureAction::Terminal | AttemptFailureAction::Stop
) || attempts >= 5
{
return Err(StreamOpenFailure { return Err(StreamOpenFailure {
selected: Some(selected), selected: Some(selected),
attempts, attempts,
@@ -1661,7 +1665,10 @@ async fn execute_chat_completions_stream_with_conductor(
let request_log_body = body.clone(); let request_log_body = body.clone();
let body_for_attempt = body.clone(); let body_for_attempt = body.clone();
let opened = open_stream_with_failover(&state, &model, move |state, selected| { let opened = open_stream_with_failover(&state, &model, move |state, selected| {
let body = ensure_stream_usage(body_with_selected_model(body_for_attempt.clone(), &selected)); let body = ensure_stream_usage(body_with_selected_model(
body_for_attempt.clone(),
&selected,
));
async move { async move {
executors::execute_chat_completions_stream_response( executors::execute_chat_completions_stream_response(
state, state,
@@ -1883,12 +1890,22 @@ mod tests {
use sqlx::sqlite::SqlitePoolOptions; use sqlx::sqlite::SqlitePoolOptions;
async fn test_state(upstream_base_url: String) -> AppState { async fn test_state(upstream_base_url: String) -> AppState {
crate::proxy::token_router::router::invalidate_route_cache();
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
.max_connections(1) .max_connections(1)
.connect("sqlite::memory:") .connect("sqlite::memory:")
.await .await
.unwrap(); .unwrap();
sqlx::query(
"CREATE TABLE settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query( sqlx::query(
"CREATE TABLE downstream_api_keys ( "CREATE TABLE downstream_api_keys (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
@@ -1948,6 +1965,8 @@ mod tests {
site_id INTEGER NOT NULL, site_id INTEGER NOT NULL,
access_token TEXT NOT NULL, access_token TEXT NOT NULL,
api_token TEXT, api_token TEXT,
oauth_provider TEXT,
extra_config TEXT,
unit_cost REAL, unit_cost REAL,
status TEXT NOT NULL DEFAULT 'active' status TEXT NOT NULL DEFAULT 'active'
)", )",
@@ -1973,6 +1992,7 @@ mod tests {
route_id INTEGER NOT NULL, route_id INTEGER NOT NULL,
account_id INTEGER NOT NULL, account_id INTEGER NOT NULL,
token_id INTEGER, token_id INTEGER,
oauth_route_unit_id INTEGER,
source_model TEXT, source_model TEXT,
priority INTEGER DEFAULT 0, priority INTEGER DEFAULT 0,
weight INTEGER DEFAULT 10, weight INTEGER DEFAULT 10,
@@ -1992,6 +2012,41 @@ mod tests {
.execute(&pool) .execute(&pool)
.await .await
.unwrap(); .unwrap();
sqlx::query(
"CREATE TABLE oauth_route_units (
id INTEGER PRIMARY KEY,
site_id INTEGER NOT NULL,
provider TEXT NOT NULL,
name TEXT NOT NULL,
strategy TEXT NOT NULL DEFAULT 'round_robin',
enabled INTEGER DEFAULT 1
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE oauth_route_unit_members (
id INTEGER PRIMARY KEY,
unit_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
sort_order INTEGER DEFAULT 0,
success_count INTEGER DEFAULT 0,
fail_count INTEGER DEFAULT 0,
total_latency_ms INTEGER DEFAULT 0,
total_cost REAL DEFAULT 0,
last_used_at TEXT,
last_selected_at TEXT,
last_fail_at TEXT,
consecutive_fail_count INTEGER NOT NULL DEFAULT 0,
cooldown_level INTEGER NOT NULL DEFAULT 0,
cooldown_until TEXT,
updated_at TEXT
)",
)
.execute(&pool)
.await
.unwrap();
// The channel-selection query joins through site_api_endpoints to // The channel-selection query joins through site_api_endpoints to
// pick a per-site override URL. We don't need any rows — the // pick a per-site override URL. We don't need any rows — the
// COALESCE falls back to sites.url when this table is empty — // COALESCE falls back to sites.url when this table is empty —
+462 -44
View File
@@ -8,8 +8,8 @@ use axum::{
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use sqlx::Row; use sqlx::{QueryBuilder, Row, Sqlite};
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap, HashSet};
use tracing::info; use tracing::info;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -34,6 +34,324 @@ pub struct ProxyDebugTraceQuery {
status: Option<String>, status: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
enum ProxyLogClientFilter {
App(String),
Family(String),
Raw(String),
}
#[derive(Debug, Clone, Default)]
struct ProxyLogFilters {
status: Option<String>,
search_like: Option<String>,
client: Option<ProxyLogClientFilter>,
site_id: Option<i64>,
downstream_key_id: Option<i64>,
from: Option<String>,
to: Option<String>,
}
fn normalize_proxy_log_status(value: Option<&String>) -> Option<String> {
match value
.map(|item| item.trim().to_ascii_lowercase())
.as_deref()
{
Some("success") => Some("success".to_string()),
Some("failed") => Some("failed".to_string()),
_ => None,
}
}
fn normalize_positive_i64(value: Option<&String>) -> Option<i64> {
value
.and_then(|item| item.trim().parse::<i64>().ok())
.filter(|item| *item > 0)
}
fn normalize_non_empty(value: Option<&String>) -> Option<String> {
value
.map(|item| item.trim())
.filter(|item| !item.is_empty())
.map(ToOwned::to_owned)
}
fn parse_proxy_log_client_filter(value: Option<&String>) -> Option<ProxyLogClientFilter> {
let text = normalize_non_empty(value)?;
if let Some((scope, token)) = text.split_once(':') {
let token = token.trim();
if !token.is_empty() {
return match scope.trim().to_ascii_lowercase().as_str() {
"app" => Some(ProxyLogClientFilter::App(token.to_string())),
"family" => Some(ProxyLogClientFilter::Family(token.to_ascii_lowercase())),
_ => Some(ProxyLogClientFilter::Raw(text)),
};
}
}
Some(ProxyLogClientFilter::Raw(text))
}
fn parse_proxy_log_filters(q: &std::collections::HashMap<String, String>) -> ProxyLogFilters {
let search = normalize_non_empty(q.get("search").or_else(|| q.get("q")));
ProxyLogFilters {
status: normalize_proxy_log_status(q.get("status")),
search_like: search.map(|value| format!("%{value}%")),
client: parse_proxy_log_client_filter(q.get("client")),
site_id: normalize_positive_i64(q.get("siteId").or_else(|| q.get("site_id"))),
downstream_key_id: normalize_positive_i64(
q.get("downstreamKeyId")
.or_else(|| q.get("downstream_key_id"))
.or_else(|| q.get("keyId"))
.or_else(|| q.get("key_id")),
),
from: normalize_non_empty(q.get("from")),
to: normalize_non_empty(q.get("to")),
}
}
fn push_proxy_log_condition_prefix(
builder: &mut QueryBuilder<'_, Sqlite>,
condition_count: &mut usize,
) {
builder.push(if *condition_count == 0 {
" WHERE "
} else {
" AND "
});
*condition_count += 1;
}
fn push_proxy_log_raw_condition(
builder: &mut QueryBuilder<'_, Sqlite>,
condition_count: &mut usize,
condition: &str,
) {
push_proxy_log_condition_prefix(builder, condition_count);
builder.push(condition);
}
fn push_proxy_log_where(
builder: &mut QueryBuilder<'_, Sqlite>,
filters: &ProxyLogFilters,
) -> usize {
let mut condition_count = 0;
if let Some(status) = &filters.status {
push_proxy_log_condition_prefix(builder, &mut condition_count);
builder.push("l.status = ");
builder.push_bind(status.clone());
}
if let Some(search_like) = &filters.search_like {
push_proxy_log_condition_prefix(builder, &mut condition_count);
builder.push("(l.model_requested LIKE ");
builder.push_bind(search_like.clone());
builder.push(" OR l.model_actual LIKE ");
builder.push_bind(search_like.clone());
builder.push(" OR l.error_message LIKE ");
builder.push_bind(search_like.clone());
builder.push(" OR l.content_summary LIKE ");
builder.push_bind(search_like.clone());
builder.push(" OR dk.name LIKE ");
builder.push_bind(search_like.clone());
builder.push(" OR dk.group_name LIKE ");
builder.push_bind(search_like.clone());
builder.push(" OR dk.tags LIKE ");
builder.push_bind(search_like.clone());
builder.push(" OR a.username LIKE ");
builder.push_bind(search_like.clone());
builder.push(" OR s.name LIKE ");
builder.push_bind(search_like.clone());
builder.push(")");
}
if let Some(client) = &filters.client {
push_proxy_log_condition_prefix(builder, &mut condition_count);
match client {
ProxyLogClientFilter::App(value) => {
builder.push("(l.client_app_id = ");
builder.push_bind(value.clone());
builder.push(" OR l.client_app_name = ");
builder.push_bind(value.clone());
builder.push(")");
}
ProxyLogClientFilter::Family(value) => {
builder.push("LOWER(l.client_family) = ");
builder.push_bind(value.clone());
}
ProxyLogClientFilter::Raw(value) => {
builder.push("(l.client_app_id = ");
builder.push_bind(value.clone());
builder.push(" OR l.client_family = ");
builder.push_bind(value.clone());
builder.push(" OR l.client_app_name = ");
builder.push_bind(value.clone());
builder.push(")");
}
}
}
if let Some(site_id) = filters.site_id {
push_proxy_log_condition_prefix(builder, &mut condition_count);
builder.push("a.site_id = ");
builder.push_bind(site_id);
}
if let Some(downstream_key_id) = filters.downstream_key_id {
push_proxy_log_condition_prefix(builder, &mut condition_count);
builder.push("l.downstream_api_key_id = ");
builder.push_bind(downstream_key_id);
}
if let Some(from) = &filters.from {
push_proxy_log_condition_prefix(builder, &mut condition_count);
builder.push("datetime(l.created_at) >= datetime(");
builder.push_bind(from.clone());
builder.push(")");
}
if let Some(to) = &filters.to {
push_proxy_log_condition_prefix(builder, &mut condition_count);
builder.push("datetime(l.created_at) <= datetime(");
builder.push_bind(to.clone());
builder.push(")");
}
condition_count
}
fn push_proxy_log_joined_from(builder: &mut QueryBuilder<'_, Sqlite>) {
builder.push(
r#"
FROM proxy_logs l
LEFT JOIN accounts a ON a.id = l.account_id
LEFT JOIN sites s ON s.id = a.site_id
LEFT JOIN downstream_api_keys dk ON dk.id = l.downstream_api_key_id
"#,
);
}
fn normalize_owned_non_empty(value: Option<String>) -> Option<String> {
value
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
}
fn proxy_log_client_family_label(value: &str) -> String {
match value.trim().to_ascii_lowercase().as_str() {
"codex" => "Codex".to_string(),
"claude_code" => "Claude Code".to_string(),
"gemini_cli" => "Gemini CLI".to_string(),
"generic" => "通用".to_string(),
_ => value.trim().to_string(),
}
}
fn push_proxy_log_client_option(
options: &mut Vec<Value>,
seen: &mut HashSet<String>,
value: String,
label: String,
) {
if seen.insert(value.clone()) {
options.push(json!({ "value": value, "label": label }));
}
}
async fn build_proxy_log_client_options(
state: &AppState,
filters: &ProxyLogFilters,
) -> AppResult<Vec<Value>> {
let mut scoped = filters.clone();
scoped.client = None;
let mut options = Vec::new();
let mut seen = HashSet::new();
let mut app_query =
QueryBuilder::<Sqlite>::new("SELECT DISTINCT l.client_app_id, l.client_app_name ");
push_proxy_log_joined_from(&mut app_query);
let mut condition_count = push_proxy_log_where(&mut app_query, &scoped);
push_proxy_log_raw_condition(
&mut app_query,
&mut condition_count,
"((l.client_app_id IS NOT NULL AND TRIM(l.client_app_id) <> '') OR (l.client_app_name IS NOT NULL AND TRIM(l.client_app_name) <> ''))",
);
app_query.push(
" ORDER BY LOWER(COALESCE(NULLIF(l.client_app_name, ''), l.client_app_id)) ASC LIMIT 200",
);
let app_rows: Vec<(Option<String>, Option<String>)> =
app_query.build_query_as().fetch_all(&*state.db).await?;
for (client_app_id, client_app_name) in app_rows {
let app_id = normalize_owned_non_empty(client_app_id);
let app_name = normalize_owned_non_empty(client_app_name);
let Some(token) = app_id.clone().or_else(|| app_name.clone()) else {
continue;
};
let label_name = app_name.or(app_id).unwrap_or_else(|| token.clone());
push_proxy_log_client_option(
&mut options,
&mut seen,
format!("app:{token}"),
format!("应用 · {label_name}"),
);
}
let mut family_query = QueryBuilder::<Sqlite>::new("SELECT DISTINCT l.client_family ");
push_proxy_log_joined_from(&mut family_query);
let mut condition_count = push_proxy_log_where(&mut family_query, &scoped);
push_proxy_log_raw_condition(
&mut family_query,
&mut condition_count,
"l.client_family IS NOT NULL AND TRIM(l.client_family) <> ''",
);
family_query.push(" ORDER BY LOWER(l.client_family) ASC LIMIT 200");
let family_rows: Vec<(Option<String>,)> =
family_query.build_query_as().fetch_all(&*state.db).await?;
for (client_family,) in family_rows {
let Some(family) = normalize_owned_non_empty(client_family) else {
continue;
};
let token = family.to_ascii_lowercase();
push_proxy_log_client_option(
&mut options,
&mut seen,
format!("family:{token}"),
format!("协议 · {}", proxy_log_client_family_label(&family)),
);
}
Ok(options)
}
async fn build_proxy_log_site_options(
state: &AppState,
filters: &ProxyLogFilters,
) -> AppResult<Vec<Value>> {
let mut scoped = filters.clone();
scoped.site_id = None;
let mut site_query = QueryBuilder::<Sqlite>::new(
"SELECT DISTINCT s.id, COALESCE(NULLIF(s.name, ''), '站点 #' || s.id) AS name, s.status ",
);
push_proxy_log_joined_from(&mut site_query);
let mut condition_count = push_proxy_log_where(&mut site_query, &scoped);
push_proxy_log_raw_condition(&mut site_query, &mut condition_count, "s.id IS NOT NULL");
site_query
.push(" ORDER BY LOWER(COALESCE(NULLIF(s.name, ''), '站点 #' || s.id)) ASC LIMIT 200");
let rows: Vec<(i64, String, Option<String>)> =
site_query.build_query_as().fetch_all(&*state.db).await?;
Ok(rows
.into_iter()
.map(|(id, name, status)| json!({ "id": id, "name": name, "status": status }))
.collect())
}
pub fn routes() -> Router<AppState> { pub fn routes() -> Router<AppState> {
Router::new() Router::new()
.route("/api/stats/dashboard", get(dashboard)) .route("/api/stats/dashboard", get(dashboard))
@@ -417,48 +735,77 @@ async fn proxy_logs(
.and_then(|v| v.parse::<i64>().ok()) .and_then(|v| v.parse::<i64>().ok())
.unwrap_or(0) .unwrap_or(0)
.max(0); .max(0);
let view = q.get("view").map(String::as_str);
let meta_only = matches!(view, Some("meta"));
let include_meta = !matches!(view, Some("query"));
let filters = parse_proxy_log_filters(&q);
let items: Vec<Value> = sqlx::query_as::<_, ProxyLogRow>( let items: Vec<Value> = if meta_only {
Vec::new()
} else {
let mut list_query = QueryBuilder::<Sqlite>::new(
r#"
SELECT
l.id, l.route_id, l.channel_id, l.account_id, l.downstream_api_key_id,
l.model_requested, l.model_actual, l.status, l.http_status,
l.latency_ms, l.first_byte_latency_ms,
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
l.error_message, l.retry_count, l.is_stream,
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
l.billing_details, l.content_summary, l.request_content, l.response_content,
l.created_at,
a.site_id AS site_id,
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
s.name AS site_name,
s.url AS site_url,
dk.name AS downstream_key_name,
dk.group_name AS downstream_key_group_name,
dk.tags AS downstream_key_tags
"#,
);
push_proxy_log_joined_from(&mut list_query);
push_proxy_log_where(&mut list_query, &filters);
list_query.push(
r#"
ORDER BY l.created_at DESC
"#,
);
list_query.push(" LIMIT ");
list_query.push_bind(limit);
list_query.push(" OFFSET ");
list_query.push_bind(offset);
list_query
.build_query_as::<ProxyLogRow>()
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|row| serde_json::to_value(row).unwrap_or_else(|_| json!({})))
.collect()
};
let mut summary_query = QueryBuilder::<Sqlite>::new(
r#" r#"
SELECT SELECT
l.id, l.route_id, l.channel_id, l.account_id, l.downstream_api_key_id, COUNT(l.id) AS total,
l.model_requested, l.model_actual, l.status, l.http_status, COALESCE(SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END), 0) AS success,
l.latency_ms, l.first_byte_latency_ms, COALESCE(SUM(CASE WHEN l.status != 'success' OR l.status IS NULL THEN 1 ELSE 0 END), 0) AS failed,
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost, COALESCE(SUM(l.estimated_cost), 0.0) AS cost,
l.error_message, l.retry_count, l.is_stream, COALESCE(SUM(l.total_tokens), 0) AS total_tokens
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
l.content_summary, l.request_content, l.response_content,
l.created_at,
a.site_id AS site_id,
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
s.name AS site_name,
s.url AS site_url,
dk.name AS downstream_key_name,
dk.group_name AS downstream_key_group_name,
dk.tags AS downstream_key_tags
FROM proxy_logs l
LEFT JOIN accounts a ON a.id = l.account_id
LEFT JOIN sites s ON s.id = a.site_id
LEFT JOIN downstream_api_keys dk ON dk.id = l.downstream_api_key_id
ORDER BY l.created_at DESC
LIMIT ? OFFSET ?
"#, "#,
) );
.bind(limit) push_proxy_log_joined_from(&mut summary_query);
.bind(offset) push_proxy_log_where(&mut summary_query, &filters);
.fetch_all(&*state.db) let (total, success, failed, cost, total_tokens): (i64, i64, i64, f64, i64) =
.await? summary_query.build_query_as().fetch_one(&*state.db).await?;
.into_iter() let (client_options, sites) = if include_meta {
.map(|row| serde_json::to_value(row).unwrap_or_else(|_| json!({}))) (
.collect(); build_proxy_log_client_options(&state, &filters).await?,
build_proxy_log_site_options(&state, &filters).await?,
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM proxy_logs") )
.fetch_one(&*state.db) } else {
.await?; (Vec::new(), Vec::new())
let success: i64 = };
sqlx::query_scalar("SELECT COUNT(*) FROM proxy_logs WHERE status = 'success'")
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ Ok(Json(json!({
"items": items, "items": items,
@@ -469,10 +816,12 @@ async fn proxy_logs(
"summary": { "summary": {
"total": total, "total": total,
"success": success, "success": success,
"failed": total - success, "failed": failed,
"cost": cost,
"totalTokens": total_tokens,
}, },
"clientOptions": [], "clientOptions": client_options,
"sites": [], "sites": sites,
}))) })))
} }
@@ -507,6 +856,7 @@ pub struct ProxyLogRow {
client_app_id: Option<String>, client_app_id: Option<String>,
client_app_name: Option<String>, client_app_name: Option<String>,
client_confidence: Option<String>, client_confidence: Option<String>,
billing_details: Option<String>,
content_summary: Option<String>, content_summary: Option<String>,
request_content: Option<String>, request_content: Option<String>,
response_content: Option<String>, response_content: Option<String>,
@@ -526,7 +876,7 @@ async fn proxy_log_detail(
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost, l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
l.error_message, l.retry_count, l.is_stream, l.error_message, l.retry_count, l.is_stream,
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence, l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
l.content_summary, l.request_content, l.response_content, l.billing_details, l.content_summary, l.request_content, l.response_content,
l.created_at, l.created_at,
a.site_id AS site_id, a.site_id AS site_id,
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username, COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
@@ -989,3 +1339,71 @@ async fn site_trend(
Ok(Json(json!({ "trend": trend }))) Ok(Json(json!({ "trend": trend })))
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn proxy_log_filters_accept_downstream_key_aliases() {
let mut params = std::collections::HashMap::new();
params.insert("status".to_string(), "FAILED".to_string());
params.insert("q".to_string(), " gpt-4o ".to_string());
params.insert("site_id".to_string(), "9".to_string());
params.insert("key_id".to_string(), "17".to_string());
params.insert("client".to_string(), "family:codex".to_string());
let filters = parse_proxy_log_filters(&params);
assert_eq!(filters.status.as_deref(), Some("failed"));
assert_eq!(filters.search_like.as_deref(), Some("%gpt-4o%"));
assert_eq!(filters.site_id, Some(9));
assert_eq!(filters.downstream_key_id, Some(17));
assert_eq!(
filters.client,
Some(ProxyLogClientFilter::Family("codex".to_string())),
);
}
#[test]
fn proxy_log_filters_parse_client_scopes() {
let mut params = std::collections::HashMap::new();
params.insert("client".to_string(), " app:cherry_studio ".to_string());
let filters = parse_proxy_log_filters(&params);
assert_eq!(
filters.client,
Some(ProxyLogClientFilter::App("cherry_studio".to_string())),
);
params.insert("client".to_string(), "FAMILY:Codex".to_string());
let filters = parse_proxy_log_filters(&params);
assert_eq!(
filters.client,
Some(ProxyLogClientFilter::Family("codex".to_string())),
);
params.insert("client".to_string(), "legacy-client".to_string());
let filters = parse_proxy_log_filters(&params);
assert_eq!(
filters.client,
Some(ProxyLogClientFilter::Raw("legacy-client".to_string())),
);
}
#[test]
fn proxy_log_filters_drop_invalid_values() {
let mut params = std::collections::HashMap::new();
params.insert("status".to_string(), "warning".to_string());
params.insert("downstreamKeyId".to_string(), "-3".to_string());
params.insert("siteId".to_string(), "0".to_string());
params.insert("search".to_string(), " ".to_string());
let filters = parse_proxy_log_filters(&params);
assert!(filters.status.is_none());
assert!(filters.downstream_key_id.is_none());
assert!(filters.site_id.is_none());
assert!(filters.search_like.is_none());
}
}
+1594 -109
View File
File diff suppressed because it is too large Load Diff
+118 -6
View File
@@ -15,6 +15,7 @@ use std::convert::Infallible;
use tracing::info; use tracing::info;
use crate::{ use crate::{
auth::jwt::validate_jwt,
error::{AppError, AppResult}, error::{AppError, AppResult},
AppState, AppState,
}; };
@@ -200,15 +201,28 @@ async fn external_comfyui_status_value(state: &AppState) -> Value {
} }
fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> { fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
let expected = format!("Bearer {}", state.config.auth.auth_token); let token = headers
let actual = headers
.get(header::AUTHORIZATION) .get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.unwrap_or(""); .and_then(|value| {
value
.strip_prefix("Bearer ")
.or_else(|| value.strip_prefix("bearer "))
})
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".to_string()))?;
if actual != expected { if token == state.config.auth.auth_token {
return Err(AppError::BadRequest("unauthorized".to_string())); return Ok(());
}
let claims = validate_jwt(&state.config.auth.jwt_secret, token)
.map_err(|_| AppError::Unauthorized("Invalid admin token".to_string()))?;
if claims.role != "admin" {
return Err(AppError::Forbidden(
"Admin account is required for this route".to_string(),
));
} }
Ok(()) Ok(())
@@ -633,3 +647,101 @@ async fn full_workflow(
} }
}))) })))
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::{
auth::jwt::generate_jwt,
config::{AppConfig, AuthConfig, ComfyUIConfig, DbConfig, ProxyConfig, ServerConfig},
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
};
use axum::http::HeaderValue;
use sqlx::sqlite::SqlitePoolOptions;
async fn test_state() -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool");
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 4000,
log_level: "info".to_string(),
cors_origins: Vec::new(),
},
db: DbConfig {
url: "sqlite::memory:".to_string(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "test-jwt-secret".to_string(),
jwt_expiry_hours: 24,
auth_token: "test-admin-token".to_string(),
proxy_token: "sk-test-proxy".to_string(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 1,
proxy_log_retention_days: 7,
template_proxy_url: None,
},
comfyui: ComfyUIConfig::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (0, "sk-comfyui-test".to_string()),
},
RuntimeAuthSettings::from_token_for_test("sk-test-proxy"),
)
}
fn bearer_headers(token: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {token}")).expect("valid bearer header"),
);
headers
}
#[tokio::test]
async fn legacy_video_agent_auth_accepts_admin_jwt() {
let state = test_state().await;
let token = generate_jwt(
&state.config.auth.jwt_secret,
"42",
"admin",
state.config.auth.jwt_expiry_hours,
)
.expect("admin jwt");
check_auth(&bearer_headers(&token), &state)
.expect("admin jwt can access legacy video agent routes");
}
#[tokio::test]
async fn legacy_video_agent_auth_rejects_user_jwt() {
let state = test_state().await;
let token = generate_jwt(
&state.config.auth.jwt_secret,
"43",
"user",
state.config.auth.jwt_expiry_hours,
)
.expect("user jwt");
let result = check_auth(&bearer_headers(&token), &state);
assert!(matches!(result, Err(AppError::Forbidden(_))));
}
}
+158 -27
View File
@@ -30,7 +30,7 @@ use serde_json::{json, Map, Value};
use sqlx::Row; use sqlx::Row;
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
path::PathBuf, path::{Path as StdPath, PathBuf},
sync::{mpsc, Arc}, sync::{mpsc, Arc},
time::{Duration, Instant, SystemTime, UNIX_EPOCH}, time::{Duration, Instant, SystemTime, UNIX_EPOCH},
}; };
@@ -46,6 +46,7 @@ const NATIVE_WORKFLOW_CACHE_MAX_AGE: Duration = Duration::from_secs(21600);
const WORKFLOW_UPLOAD_BODY_LIMIT_BYTES: usize = 256 * 1024 * 1024; const WORKFLOW_UPLOAD_BODY_LIMIT_BYTES: usize = 256 * 1024 * 1024;
const OFFICIAL_WORKFLOW_TEMPLATES_BASE: &str = const OFFICIAL_WORKFLOW_TEMPLATES_BASE: &str =
"https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates"; "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates";
const DEFAULT_TEXT_TO_VIDEO_MODEL: &str = "seedance-1-0-lite-t2v-250428";
static EMBEDDED_WORKFLOW_UI: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/workflow-ui"); static EMBEDDED_WORKFLOW_UI: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/workflow-ui");
static EMBEDDED_WORKFLOW_UI_EXTERNAL: Dir<'_> = static EMBEDDED_WORKFLOW_UI_EXTERNAL: Dir<'_> =
include_dir!("$CARGO_MANIFEST_DIR/dist/workflow-ui-external"); include_dir!("$CARGO_MANIFEST_DIR/dist/workflow-ui-external");
@@ -210,9 +211,9 @@ pub fn routes() -> Router<AppState> {
fn workflow_ui_root_for_config(config: &crate::config::ComfyUIConfig) -> PathBuf { fn workflow_ui_root_for_config(config: &crate::config::ComfyUIConfig) -> PathBuf {
if let Ok(path) = std::env::var("BOOSAPI_WORKFLOW_UI_DIR") { if let Ok(path) = std::env::var("BOOSAPI_WORKFLOW_UI_DIR") {
return PathBuf::from(path); return resolve_workflow_ui_dir(PathBuf::from(path));
} }
let preferred = if config.uses_cloud_backend() { let preferred = resolve_workflow_ui_dir(if config.uses_cloud_backend() {
std::env::var("BOOSAPI_WORKFLOW_UI_CLOUD_DIR") std::env::var("BOOSAPI_WORKFLOW_UI_CLOUD_DIR")
.map(PathBuf::from) .map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("dist/workflow-ui-cloud")) .unwrap_or_else(|_| PathBuf::from("dist/workflow-ui-cloud"))
@@ -222,11 +223,23 @@ fn workflow_ui_root_for_config(config: &crate::config::ComfyUIConfig) -> PathBuf
.unwrap_or_else(|_| PathBuf::from("dist/workflow-ui-external")) .unwrap_or_else(|_| PathBuf::from("dist/workflow-ui-external"))
} else { } else {
PathBuf::from("dist/workflow-ui") PathBuf::from("dist/workflow-ui")
}; });
if workflow_ui_dir_is_usable(&preferred) { if workflow_ui_dir_is_usable(&preferred) {
preferred preferred
} else { } else {
PathBuf::from("dist/workflow-ui") resolve_workflow_ui_dir(PathBuf::from("dist/workflow-ui"))
}
}
fn resolve_workflow_ui_dir(path: PathBuf) -> PathBuf {
if path.is_absolute() || workflow_ui_dir_is_usable(&path) {
return path;
}
let manifest_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(&path);
if workflow_ui_dir_is_usable(&manifest_path) {
manifest_path
} else {
path
} }
} }
@@ -272,14 +285,24 @@ async fn workflow_ui_runtime(State(state): State<AppState>) -> Json<Value> {
} else { } else {
"boosapi" "boosapi"
}; };
let frontend_dir = workflow_ui_root_for_config(&state.config.comfyui);
Json(json!({ Json(json!({
"mode": mode, "mode": mode,
"baseUrl": state.config.comfyui.target_base_url(), "baseUrl": state.config.comfyui.target_base_url(),
"frontendDir": workflow_ui_root_for_config(&state.config.comfyui), "frontendDir": frontend_dir,
"frontendBuild": workflow_ui_build_metadata(&frontend_dir),
"cloudApiKeyConfigured": workflow_cloud_api_key(&state.config.comfyui).is_some(), "cloudApiKeyConfigured": workflow_cloud_api_key(&state.config.comfyui).is_some(),
})) }))
} }
fn workflow_ui_build_metadata(dir: &StdPath) -> Value {
let path = dir.join("boosapi-workflow-ui.json");
std::fs::read_to_string(path)
.ok()
.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
.unwrap_or(Value::Null)
}
fn workflow_cloud_api_key(config: &crate::config::ComfyUIConfig) -> Option<&str> { fn workflow_cloud_api_key(config: &crate::config::ComfyUIConfig) -> Option<&str> {
config config
.api_key .api_key
@@ -675,7 +698,7 @@ fn text_to_video_workflow_graph() -> Value {
{ "name": "raw", "type": "JSON", "links": null, "slot_index": 2 } { "name": "raw", "type": "JSON", "links": null, "slot_index": 2 }
], ],
"properties": { "Node name for S&R": "boosapi.text_to_video" }, "properties": { "Node name for S&R": "boosapi.text_to_video" },
"widgets_values": ["seedance-1-0-lite-t2v", "16:9", 5, "720p", 24, true, 5, 180] "widgets_values": [DEFAULT_TEXT_TO_VIDEO_MODEL, "16:9", 5, "720p", 24, true, 5, 180]
}, },
{ {
"id": 3, "id": 3,
@@ -691,7 +714,7 @@ fn text_to_video_workflow_graph() -> Value {
{ "name": "raw", "type": "JSON", "links": null, "slot_index": 1 } { "name": "raw", "type": "JSON", "links": null, "slot_index": 1 }
], ],
"properties": { "Node name for S&R": "boosapi.video_status" }, "properties": { "Node name for S&R": "boosapi.video_status" },
"widgets_values": ["seedance-1-0-lite-t2v"] "widgets_values": [DEFAULT_TEXT_TO_VIDEO_MODEL]
}, },
{ {
"id": 4, "id": 4,
@@ -1073,11 +1096,10 @@ fn workflow_folder_models(folder: &str, models: &[String]) -> Vec<String> {
.cloned() .cloned()
.collect(), .collect(),
"clip" => models.to_vec(), "clip" => models.to_vec(),
"video_models" => models // Comfy's video_models folder is a native Comfy API model folder. Keep it
.iter() // separate from BoosAPI route models so video apps submit real Comfy API
.filter(|model| is_video_workflow_model(model)) // model names instead of OpenAI-compatible route aliases.
.cloned() "video_models" => Vec::new(),
.collect(),
"audio_tts_models" => models "audio_tts_models" => models
.iter() .iter()
.filter(|model| is_audio_workflow_model(model)) .filter(|model| is_audio_workflow_model(model))
@@ -1102,6 +1124,32 @@ fn workflow_folder_models(folder: &str, models: &[String]) -> Vec<String> {
} }
} }
fn workflow_folder_is_api_only(folder: &str) -> bool {
matches!(
folder.trim().to_ascii_lowercase().as_str(),
"boosapi" | "boosapi_models" | "api_models"
)
}
fn workflow_folder_has_route_models(folder: &str) -> bool {
matches!(
folder.trim().to_ascii_lowercase().as_str(),
"boosapi"
| "boosapi_models"
| "api_models"
| "image_models"
| "chat_models"
| "audio_tts_models"
| "embeddings"
| "3d_models"
| "vision_models"
)
}
fn workflow_folder_route_model_items(folder: &str, route_models: &[String]) -> Vec<Value> {
model_payload_items_from_names(workflow_folder_models(folder, route_models), 0)
}
fn combo_choices_from_node_input( fn combo_choices_from_node_input(
object_info: &Value, object_info: &Value,
node_type: &str, node_type: &str,
@@ -1231,7 +1279,7 @@ fn inject_model_choices(defs: &mut Map<String, Value>, models: &[String]) {
{ {
optional.insert( optional.insert(
"model".to_string(), "model".to_string(),
comfy_combo_input(&video_models, "seedance-1-0-lite-t2v"), comfy_combo_input(&video_models, DEFAULT_TEXT_TO_VIDEO_MODEL),
); );
} }
} }
@@ -1788,7 +1836,7 @@ async fn serve_file(path: PathBuf) -> AppResult<Response> {
} }
} }
fn content_type_for_path(path: &PathBuf) -> &'static str { fn content_type_for_path(path: &StdPath) -> &'static str {
match path match path
.extension() .extension()
.and_then(|ext| ext.to_str()) .and_then(|ext| ext.to_str())
@@ -1809,15 +1857,15 @@ fn content_type_for_path(path: &PathBuf) -> &'static str {
} }
} }
fn cache_control_for_path(path: &PathBuf) -> &'static str { fn cache_control_for_path(path: &StdPath) -> &'static str {
let ext = path let ext = path
.extension() .extension()
.and_then(|ext| ext.to_str()) .and_then(|ext| ext.to_str())
.unwrap_or("") .unwrap_or("")
.to_ascii_lowercase(); .to_ascii_lowercase();
match ext.as_str() { match ext.as_str() {
"html" => "no-cache", "html" | "js" | "mjs" | "css" => "no-cache",
"js" | "mjs" | "css" | "woff2" | "png" | "jpg" | "jpeg" | "webp" | "svg" => { "woff2" | "png" | "jpg" | "jpeg" | "webp" | "svg" => {
"public, max-age=604800" "public, max-age=604800"
} }
_ => "public, max-age=3600", _ => "public, max-age=3600",
@@ -4792,15 +4840,28 @@ async fn experiment_models_in_folder(
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
if state.config.comfyui.uses_cloud_backend() { if state.config.comfyui.uses_cloud_backend() {
let _ = auth_caller_optional(&state, &headers); let _ = auth_caller_optional(&state, &headers);
let normalized_folder = folder.trim().to_ascii_lowercase(); if workflow_folder_has_route_models(&folder) {
if matches!(
normalized_folder.as_str(),
"boosapi" | "boosapi_models" | "api_models"
) {
let route_models = workflow_route_models(&state).await.unwrap_or_default(); let route_models = workflow_route_models(&state).await.unwrap_or_default();
let api_model_items = let api_model_items = workflow_folder_route_model_items(&folder, &route_models);
model_payload_items_from_names(workflow_folder_models(&folder, &route_models), 0); if workflow_folder_is_api_only(&folder) {
return Ok(Json(Value::Array(api_model_items))); return Ok(Json(Value::Array(api_model_items)));
}
let mut native_items = Vec::<Value>::new();
let path = format!("/experiment/models/{}", simple_urlencode(&folder));
if let Ok(value) = native_workflow_json_cached(
&state,
Method::GET,
&path,
None,
native_workflow_metadata_ttl(&state),
)
.await
{
if let Value::Array(items) = value.0 {
native_items.extend(items);
}
}
return Ok(Json(merge_model_payload_items(api_model_items, native_items)));
} }
let path = format!("/experiment/models/{}", simple_urlencode(&folder)); let path = format!("/experiment/models/{}", simple_urlencode(&folder));
return native_workflow_json_cached( return native_workflow_json_cached(
@@ -4813,7 +4874,7 @@ async fn experiment_models_in_folder(
.await; .await;
} }
let route_models = workflow_route_models(&state).await.unwrap_or_default(); let route_models = workflow_route_models(&state).await.unwrap_or_default();
let api_model_items = model_payload_items_from_names(workflow_folder_models(&folder, &route_models), 0); let api_model_items = workflow_folder_route_model_items(&folder, &route_models);
if state.config.comfyui.uses_native_backend() { if state.config.comfyui.uses_native_backend() {
let _ = auth_caller_optional(&state, &headers); let _ = auth_caller_optional(&state, &headers);
@@ -5701,3 +5762,73 @@ fn parse_workflow_cookie(raw: &str) -> Option<&str> {
.map(|value| value.trim()) .map(|value| value.trim())
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn video_model_folder_does_not_use_route_video_models() {
let route_models = vec![
"gpt-5.4".to_string(),
"gpt-image-2".to_string(),
"seedance-1-0-pro-t2v-250528".to_string(),
"wan2.1-i2v".to_string(),
"text-embedding-3-large".to_string(),
];
let names = workflow_folder_models("video_models", &route_models);
assert!(names.is_empty());
}
#[test]
fn api_backed_workflow_folders_are_not_native_only() {
assert!(!workflow_folder_has_route_models("video_models"));
assert!(workflow_folder_has_route_models("image_models"));
assert!(workflow_folder_has_route_models("api_models"));
assert!(workflow_folder_is_api_only("api_models"));
assert!(!workflow_folder_is_api_only("video_models"));
assert!(!workflow_folder_has_route_models("checkpoints"));
}
#[test]
fn video_model_folder_prefers_native_comfy_items_without_route_aliases() {
let route_models = vec![
"seedance-1-0-pro-t2v-250528".to_string(),
"wan2.1-t2v".to_string(),
];
let api_items = workflow_folder_route_model_items("video_models", &route_models);
let native_items = vec![
json!({ "name": "wan2.1-t2v", "pathIndex": 2 }),
json!({ "name": "local-video-model.safetensors", "pathIndex": 1 }),
];
let merged = merge_model_payload_items(api_items, native_items);
let names = merged
.as_array()
.expect("merged model payload should be an array")
.iter()
.filter_map(|item| item.get("name").and_then(Value::as_str))
.collect::<Vec<_>>();
assert_eq!(
names,
vec![
"wan2.1-t2v",
"local-video-model.safetensors",
]
);
}
#[test]
fn workflow_ui_runtime_assets_revalidate_after_install() {
assert_eq!(cache_control_for_path(StdPath::new("index.html")), "no-cache");
assert_eq!(cache_control_for_path(StdPath::new("assets/index.js")), "no-cache");
assert_eq!(cache_control_for_path(StdPath::new("assets/index.css")), "no-cache");
assert_eq!(
cache_control_for_path(StdPath::new("assets/logo.svg")),
"public, max-age=604800"
);
}
}
+358
View File
@@ -4,6 +4,7 @@ use crate::{
}; };
use chrono::Timelike; use chrono::Timelike;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sqlx::{FromRow, Row, SqlitePool}; use sqlx::{FromRow, Row, SqlitePool};
use std::sync::Arc; use std::sync::Arc;
@@ -22,6 +23,23 @@ pub struct UsageSummary {
pub total_cost: f64, pub total_cost: f64,
} }
#[derive(Debug, Clone)]
pub struct ComfyTaskBillingEvent {
pub key_id: Option<i64>,
pub model: String,
pub task_type: &'static str,
pub client_family: &'static str,
pub client_app_id: &'static str,
pub client_app_name: &'static str,
pub prompt_tokens: i64,
pub completion_tokens: i64,
pub cost: f64,
pub content_summary: Option<String>,
pub request_content: Option<String>,
pub response_content: Option<String>,
pub error_message: Option<String>,
}
impl UsageTracker { impl UsageTracker {
pub fn new(db: Arc<SqlitePool>) -> Self { pub fn new(db: Arc<SqlitePool>) -> Self {
Self { db } Self { db }
@@ -119,6 +137,112 @@ impl UsageTracker {
Ok(()) Ok(())
} }
pub async fn record_comfy_task_success(
&self,
event: ComfyTaskBillingEvent,
) -> AppResult<Value> {
Self::ensure_schema(&self.db).await?;
Self::ensure_proxy_log_task_columns(&self.db).await?;
let prompt_tokens = event.prompt_tokens.max(0);
let completion_tokens = event.completion_tokens.max(0);
let total_tokens = prompt_tokens + completion_tokens;
let cost = event.cost.max(0.0);
let billing_details = comfy_task_billing_details(&event, "success", cost);
let mut tx = self.db.begin().await?;
if let Some(key_id) = event.key_id {
sqlx::query(
"INSERT INTO usage_logs
(downstream_key_id, model, prompt_tokens, completion_tokens, total_tokens, cost)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(key_id)
.bind(&event.model)
.bind(prompt_tokens)
.bind(completion_tokens)
.bind(total_tokens)
.bind(cost)
.execute(&mut *tx)
.await?;
sqlx::query(
"UPDATE downstream_api_keys
SET used_requests = COALESCE(used_requests, 0) + 1,
used_cost = COALESCE(used_cost, 0) + ?,
last_used_at = datetime('now'),
updated_at = datetime('now')
WHERE id = ?",
)
.bind(cost)
.bind(key_id)
.execute(&mut *tx)
.await?;
}
sqlx::query(
"INSERT INTO proxy_logs (
downstream_api_key_id, model_requested, model_actual, status, http_status,
prompt_tokens, completion_tokens, total_tokens, estimated_cost,
retry_count, is_stream, client_family, client_app_id, client_app_name,
client_confidence, billing_details, content_summary, request_content,
response_content
) VALUES (?, ?, ?, 'success', 200, ?, ?, ?, ?, 0, 0, ?, ?, ?, 'high', ?, ?, ?, ?)",
)
.bind(event.key_id)
.bind(&event.model)
.bind(&event.model)
.bind(prompt_tokens)
.bind(completion_tokens)
.bind(total_tokens)
.bind(cost)
.bind(event.client_family)
.bind(event.client_app_id)
.bind(event.client_app_name)
.bind(billing_details.to_string())
.bind(event.content_summary.as_deref())
.bind(event.request_content.as_deref())
.bind(event.response_content.as_deref())
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(billing_details)
}
pub async fn record_comfy_task_failure(
&self,
event: ComfyTaskBillingEvent,
) -> AppResult<Value> {
Self::ensure_schema(&self.db).await?;
Self::ensure_proxy_log_task_columns(&self.db).await?;
let billing_details = comfy_task_billing_details(&event, "failed", 0.0);
sqlx::query(
"INSERT INTO proxy_logs (
downstream_api_key_id, model_requested, model_actual, status, http_status,
prompt_tokens, completion_tokens, total_tokens, estimated_cost, error_message,
retry_count, is_stream, client_family, client_app_id, client_app_name,
client_confidence, billing_details, content_summary, request_content,
response_content
) VALUES (?, ?, ?, 'failed', 502, 0, 0, 0, 0.0, ?, 0, 0, ?, ?, ?, 'high', ?, ?, ?, ?)",
)
.bind(event.key_id)
.bind(&event.model)
.bind(&event.model)
.bind(event.error_message.as_deref().unwrap_or("Comfy task failed"))
.bind(event.client_family)
.bind(event.client_app_id)
.bind(event.client_app_name)
.bind(billing_details.to_string())
.bind(event.content_summary.as_deref())
.bind(event.request_content.as_deref())
.bind(event.response_content.as_deref())
.execute(&*self.db)
.await?;
Ok(billing_details)
}
pub async fn create_daily_usage_snapshot(&self) -> AppResult<()> { pub async fn create_daily_usage_snapshot(&self) -> AppResult<()> {
Self::create_daily_usage_snapshot_for_db(&self.db).await Self::create_daily_usage_snapshot_for_db(&self.db).await
} }
@@ -485,6 +609,112 @@ impl UsageTracker {
.await?; .await?;
Ok(()) Ok(())
} }
async fn ensure_proxy_log_task_columns(db: &SqlitePool) -> AppResult<()> {
for (name, definition) in [
("client_family", "TEXT"),
("client_app_id", "TEXT"),
("client_app_name", "TEXT"),
("client_confidence", "TEXT"),
("is_stream", "INTEGER"),
("first_byte_latency_ms", "INTEGER"),
("billing_details", "TEXT"),
("content_summary", "TEXT"),
("request_content", "TEXT"),
("response_content", "TEXT"),
] {
ensure_table_column(db, "proxy_logs", name, definition).await?;
}
Ok(())
}
}
async fn ensure_table_column(
db: &SqlitePool,
table: &str,
name: &str,
definition: &str,
) -> AppResult<()> {
let info_sql = format!("PRAGMA table_info({table})");
let rows = sqlx::query(&info_sql).fetch_all(db).await?;
if rows.is_empty() {
return Ok(());
}
let exists = rows
.iter()
.filter_map(|row| row.try_get::<String, _>("name").ok())
.any(|column| column == name);
if exists {
return Ok(());
}
let alter_sql = format!("ALTER TABLE {table} ADD COLUMN {name} {definition}");
sqlx::query(&alter_sql).execute(db).await?;
Ok(())
}
fn comfy_task_billing_details(
event: &ComfyTaskBillingEvent,
status: &str,
total_cost: f64,
) -> Value {
let prompt_tokens = event.prompt_tokens.max(0);
let completion_tokens = event.completion_tokens.max(0);
let total_tokens = prompt_tokens + completion_tokens;
json!({
"source": "comfy-native-task",
"taskType": event.task_type,
"status": status,
"currency": "USD",
"charged": status == "success" && event.key_id.is_some(),
"totalCost": total_cost,
"usage": {
"promptTokens": prompt_tokens,
"completionTokens": completion_tokens,
"totalTokens": total_tokens,
"cacheReadTokens": 0,
"cacheCreationTokens": 0,
"billablePromptTokens": prompt_tokens,
"promptTokensIncludeCache": false,
},
"pricing": {
"modelRatio": 1,
"completionRatio": 1,
"cacheRatio": 0,
"cacheCreationRatio": 0,
"groupRatio": 1,
},
"breakdown": {
"inputPerMillion": 0,
"outputPerMillion": 0,
"cacheReadPerMillion": 0,
"cacheCreationPerMillion": 0,
"inputCost": 0,
"outputCost": total_cost,
"cacheReadCost": 0,
"cacheCreationCost": 0,
"totalCost": total_cost,
},
"steps": [
{
"name": "submit_workflow",
"status": status,
"model": event.model,
"cost": 0.0,
},
{
"name": "generate_outputs",
"status": status,
"model": event.model,
"cost": total_cost,
},
{
"name": "archive_outputs",
"status": status,
"model": event.model,
"cost": 0.0,
}
]
})
} }
async fn touch_projection_checkpoint( async fn touch_projection_checkpoint(
@@ -614,6 +844,56 @@ mod tests {
pool pool
} }
async fn create_proxy_logs(pool: &SqlitePool) {
sqlx::query(
"CREATE TABLE proxy_logs (
id INTEGER PRIMARY KEY,
downstream_api_key_id INTEGER,
model_requested TEXT,
model_actual TEXT,
status TEXT,
http_status INTEGER,
prompt_tokens INTEGER,
completion_tokens INTEGER,
total_tokens INTEGER,
estimated_cost REAL,
error_message TEXT,
retry_count INTEGER DEFAULT 0,
is_stream INTEGER,
client_family TEXT,
client_app_id TEXT,
client_app_name TEXT,
client_confidence TEXT,
billing_details TEXT,
content_summary TEXT,
request_content TEXT,
response_content TEXT,
created_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(pool)
.await
.unwrap();
}
fn test_comfy_event(cost: f64) -> ComfyTaskBillingEvent {
ComfyTaskBillingEvent {
key_id: Some(1),
model: "seedance-comfy".to_string(),
task_type: "money-printer-video",
client_family: "money-printer",
client_app_id: "money-printer",
client_app_name: "Money Printer",
prompt_tokens: 0,
completion_tokens: 0,
cost,
content_summary: Some("topic".to_string()),
request_content: Some(json!({ "topic": "topic" }).to_string()),
response_content: Some(json!({ "ok": true }).to_string()),
error_message: None,
}
}
#[tokio::test] #[tokio::test]
async fn records_and_aggregates_usage() { async fn records_and_aggregates_usage() {
let tracker = UsageTracker::new(Arc::new(pool().await)); let tracker = UsageTracker::new(Arc::new(pool().await));
@@ -662,6 +942,84 @@ mod tests {
assert_eq!(count, 1); assert_eq!(count, 1);
} }
#[tokio::test]
async fn records_comfy_task_success_as_usage_and_proxy_log() {
let pool = pool().await;
create_proxy_logs(&pool).await;
let tracker = UsageTracker::new(Arc::new(pool));
let billing = tracker
.record_comfy_task_success(test_comfy_event(0.35))
.await
.unwrap();
assert_eq!(billing["source"], "comfy-native-task");
assert_eq!(billing["charged"], true);
assert_eq!(billing["totalCost"], 0.35);
let usage: (i64, f64) = sqlx::query_as(
"SELECT COUNT(*), COALESCE(SUM(cost), 0.0) FROM usage_logs WHERE downstream_key_id = 1",
)
.fetch_one(&*tracker.db)
.await
.unwrap();
assert_eq!(usage, (1, 0.35));
let key_usage: (i64, f64) =
sqlx::query_as("SELECT used_requests, used_cost FROM downstream_api_keys WHERE id = 1")
.fetch_one(&*tracker.db)
.await
.unwrap();
assert_eq!(key_usage, (1, 0.35));
let proxy_log: (String, f64, String, String) = sqlx::query_as(
"SELECT status, estimated_cost, client_app_id, billing_details FROM proxy_logs WHERE downstream_api_key_id = 1",
)
.fetch_one(&*tracker.db)
.await
.unwrap();
assert_eq!(proxy_log.0, "success");
assert_eq!(proxy_log.1, 0.35);
assert_eq!(proxy_log.2, "money-printer");
assert!(proxy_log.3.contains("\"steps\""));
}
#[tokio::test]
async fn records_comfy_task_failure_without_debiting_usage() {
let pool = pool().await;
create_proxy_logs(&pool).await;
let tracker = UsageTracker::new(Arc::new(pool));
let mut event = test_comfy_event(0.35);
event.error_message = Some("Not found".to_string());
let billing = tracker.record_comfy_task_failure(event).await.unwrap();
assert_eq!(billing["charged"], false);
assert_eq!(billing["totalCost"], 0.0);
let usage_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM usage_logs WHERE downstream_key_id = 1")
.fetch_one(&*tracker.db)
.await
.unwrap();
assert_eq!(usage_count, 0);
let key_usage: (i64, f64) =
sqlx::query_as("SELECT used_requests, used_cost FROM downstream_api_keys WHERE id = 1")
.fetch_one(&*tracker.db)
.await
.unwrap();
assert_eq!(key_usage, (0, 0.0));
let proxy_log: (String, f64, String) = sqlx::query_as(
"SELECT status, estimated_cost, error_message FROM proxy_logs WHERE downstream_api_key_id = 1",
)
.fetch_one(&*tracker.db)
.await
.unwrap();
assert_eq!(proxy_log, ("failed".to_string(), 0.0, "Not found".to_string()));
}
#[tokio::test] #[tokio::test]
async fn projects_proxy_logs_to_site_and_model_usage() { async fn projects_proxy_logs_to_site_and_model_usage() {
let pool = pool().await; let pool = pool().await;
+1
View File
@@ -5,6 +5,7 @@ pub mod comfyui_process;
pub mod cron; pub mod cron;
pub mod notification; pub mod notification;
pub mod oauth; pub mod oauth;
pub mod payment;
pub mod register_captcha; pub mod register_captcha;
pub mod registration_limiter; pub mod registration_limiter;
pub mod route_rebuild; pub mod route_rebuild;
+523 -50
View File
@@ -1,13 +1,16 @@
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::{json, Map, Value};
use sqlx::{FromRow, SqlitePool}; use sqlx::{FromRow, SqlitePool};
use std::sync::Arc; use std::sync::Arc;
pub const SYSTEM_PROXY_URL_SETTING_KEY: &str = "runtime.system_proxy_url";
#[derive(Clone)] #[derive(Clone)]
pub struct OAuthService { pub struct OAuthService {
db: Arc<SqlitePool>, db: Arc<SqlitePool>,
client: reqwest::Client, #[cfg(test)]
client_override: Option<reqwest::Client>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
@@ -26,6 +29,7 @@ pub struct OAuthRouteUnit {
pub struct RefreshedToken { pub struct RefreshedToken {
pub account_id: i64, pub account_id: i64,
pub access_token: String, pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: Option<String>, pub expires_at: Option<String>,
} }
@@ -33,7 +37,16 @@ impl OAuthService {
pub fn new(db: Arc<SqlitePool>) -> Self { pub fn new(db: Arc<SqlitePool>) -> Self {
Self { Self {
db, db,
client: reqwest::Client::new(), #[cfg(test)]
client_override: None,
}
}
#[cfg(test)]
fn with_client(db: Arc<SqlitePool>, client: reqwest::Client) -> Self {
Self {
db,
client_override: Some(client),
} }
} }
@@ -55,6 +68,7 @@ impl OAuthService {
return Ok(RefreshedToken { return Ok(RefreshedToken {
account_id, account_id,
access_token: current_access_token, access_token: current_access_token,
refresh_token,
expires_at: token_expiry(extra_config.as_deref()), expires_at: token_expiry(extra_config.as_deref()),
}); });
} }
@@ -63,21 +77,24 @@ impl OAuthService {
.filter(|token| !token.trim().is_empty()) .filter(|token| !token.trim().is_empty())
.ok_or_else(|| AppError::BadRequest("OAuth account has no refresh token".into()))?; .ok_or_else(|| AppError::BadRequest("OAuth account has no refresh token".into()))?;
let mut refreshed = self.perform_refresh(site_id, &refresh_token).await?; let (proxy_url, use_system_proxy) = oauth_proxy_settings(extra_config.as_deref());
let client = self
.configured_http_client(proxy_url.as_deref(), use_system_proxy)
.await?;
let mut refreshed = self
.perform_refresh(&client, site_id, &refresh_token)
.await?;
refreshed.account_id = account_id; refreshed.account_id = account_id;
let next_extra_config =
merge_refreshed_extra_config(extra_config.as_deref(), &refreshed, &refresh_token)?;
sqlx::query( sqlx::query(
"UPDATE accounts "UPDATE accounts
SET access_token = ?, extra_config = ?, updated_at = datetime('now') SET access_token = ?, api_token = ?, extra_config = ?, updated_at = datetime('now')
WHERE id = ? AND site_id = ?", WHERE id = ? AND site_id = ?",
) )
.bind(&refreshed.access_token) .bind(&refreshed.access_token)
.bind( .bind(refreshed.refresh_token.as_deref().unwrap_or(&refresh_token))
serde_json::to_string(&serde_json::json!({ .bind(next_extra_config)
"expires_at": refreshed.expires_at,
"refreshed_at": chrono::Utc::now().to_rfc3339(),
}))
.map_err(|err| AppError::Internal(err.into()))?,
)
.bind(account_id) .bind(account_id)
.bind(site_id) .bind(site_id)
.execute(&*self.db) .execute(&*self.db)
@@ -90,6 +107,19 @@ impl OAuthService {
token_expired(extra_config) token_expired(extra_config)
} }
async fn configured_http_client(
&self,
proxy_url: Option<&str>,
use_system_proxy: bool,
) -> AppResult<reqwest::Client> {
#[cfg(test)]
if let Some(client) = &self.client_override {
return Ok(client.clone());
}
oauth_http_client(&*self.db, proxy_url, use_system_proxy).await
}
pub async fn list_route_units(&self, site_id: Option<i64>) -> AppResult<Vec<OAuthRouteUnit>> { pub async fn list_route_units(&self, site_id: Option<i64>) -> AppResult<Vec<OAuthRouteUnit>> {
if let Some(site_id) = site_id { if let Some(site_id) = site_id {
sqlx::query_as("SELECT * FROM oauth_route_units WHERE site_id = ? ORDER BY id") sqlx::query_as("SELECT * FROM oauth_route_units WHERE site_id = ? ORDER BY id")
@@ -132,29 +162,37 @@ impl OAuthService {
async fn perform_refresh( async fn perform_refresh(
&self, &self,
client: &reqwest::Client,
site_id: i64, site_id: i64,
refresh_token: &str, refresh_token: &str,
) -> AppResult<RefreshedToken> { ) -> AppResult<RefreshedToken> {
let token_endpoint: Option<String> = sqlx::query_scalar( let raw_custom_headers: Option<String> =
"SELECT json_extract(custom_headers, '$.oauth_token_url') FROM sites WHERE id = ?", sqlx::query_scalar("SELECT custom_headers FROM sites WHERE id = ?")
) .bind(site_id)
.bind(site_id) .fetch_optional(&*self.db)
.fetch_optional(&*self.db) .await?;
.await?;
let Some(token_endpoint) = token_endpoint.filter(|url| !url.trim().is_empty()) else { let token_config = oauth_token_config(raw_custom_headers.as_deref())?;
let Some(token_endpoint) = token_config.token_url.filter(|url| !url.trim().is_empty())
else {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"OAuth token endpoint is not configured for this site".into(), "OAuth token endpoint is not configured for this site".into(),
)); ));
}; };
let mut form = vec![
("grant_type".to_string(), "refresh_token".to_string()),
("refresh_token".to_string(), refresh_token.to_string()),
];
if let Some(client_id) = token_config.client_id {
form.push(("client_id".to_string(), client_id));
}
if let Some(client_secret) = token_config.client_secret {
form.push(("client_secret".to_string(), client_secret));
}
let response: Value = self let response: Value = client
.client
.post(token_endpoint) .post(token_endpoint)
.form(&[ .form(&form)
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
])
.send() .send()
.await .await
.map_err(|err| AppError::UpstreamError(format!("OAuth refresh failed: {err}")))? .map_err(|err| AppError::UpstreamError(format!("OAuth refresh failed: {err}")))?
@@ -162,46 +200,481 @@ impl OAuthService {
.await .await
.map_err(|err| AppError::UpstreamError(format!("Invalid OAuth response: {err}")))?; .map_err(|err| AppError::UpstreamError(format!("Invalid OAuth response: {err}")))?;
let access_token = response let access_token = string_field(&response, &["access_token", "accessToken"])
.get("access_token")
.and_then(Value::as_str)
.ok_or_else(|| AppError::UpstreamError("OAuth response missing access_token".into()))? .ok_or_else(|| AppError::UpstreamError("OAuth response missing access_token".into()))?
.to_string(); .to_string();
let expires_at = response let refresh_token =
.get("expires_at") string_field(&response, &["refresh_token", "refreshToken"]).map(str::to_string);
.and_then(Value::as_str) let expires_at = token_expiry_from_response(&response);
.map(str::to_string);
Ok(RefreshedToken { Ok(RefreshedToken {
account_id: 0, account_id: 0,
access_token, access_token,
refresh_token,
expires_at, expires_at,
}) })
} }
} }
pub async fn system_proxy_url(db: &SqlitePool) -> AppResult<Option<String>> {
let raw: Option<String> = sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
.bind(SYSTEM_PROXY_URL_SETTING_KEY)
.fetch_optional(db)
.await?;
Ok(raw
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()))
}
pub async fn oauth_http_client(
db: &SqlitePool,
proxy_url: Option<&str>,
use_system_proxy: bool,
) -> AppResult<reqwest::Client> {
let resolved_proxy = proxy_url
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let resolved_proxy = match resolved_proxy {
Some(proxy_url) => Some(proxy_url),
None if use_system_proxy => system_proxy_url(db).await?,
None => None,
};
let mut builder = reqwest::Client::builder();
if let Some(proxy_url) = resolved_proxy {
let proxy = reqwest::Proxy::all(&proxy_url)
.map_err(|err| AppError::BadRequest(format!("Invalid OAuth proxy URL: {err}")))?;
builder = builder.proxy(proxy);
}
builder
.build()
.map_err(|err| AppError::Internal(err.into()))
}
#[derive(Debug, Clone, Default)]
struct OAuthTokenConfig {
token_url: Option<String>,
client_id: Option<String>,
client_secret: Option<String>,
}
fn string_field<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a str> {
keys.iter()
.find_map(|key| value.get(*key))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn oauth_token_config(raw_custom_headers: Option<&str>) -> AppResult<OAuthTokenConfig> {
let Some(raw) = raw_custom_headers
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(OAuthTokenConfig::default());
};
let value = serde_json::from_str::<Value>(raw)
.map_err(|err| AppError::BadRequest(format!("Invalid site custom_headers JSON: {err}")))?;
let object = value
.as_object()
.ok_or_else(|| AppError::BadRequest("site custom_headers must be a JSON object".into()))?;
Ok(OAuthTokenConfig {
token_url: [
"oauth_token_url",
"oauth_token_endpoint",
"token_url",
"tokenUrl",
"tokenEndpoint",
]
.iter()
.find_map(|key| object.get(*key))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
client_id: ["oauth_client_id", "client_id", "clientId", "oauthClientId"]
.iter()
.find_map(|key| object.get(*key))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
client_secret: [
"oauth_client_secret",
"client_secret",
"clientSecret",
"oauthClientSecret",
]
.iter()
.find_map(|key| object.get(*key))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
})
}
fn token_expiry_from_response(response: &Value) -> Option<String> {
for key in [
"expires_at",
"expiresAt",
"tokenExpiresAt",
"token_expires_at",
] {
if let Some(expires_at) = response.get(key).and_then(expiry_datetime_from_value) {
return Some(expires_at.to_rfc3339());
}
}
let expires_in = response
.get("expires_in")
.or_else(|| response.get("expiresIn"))
.and_then(|value| {
value
.as_i64()
.or_else(|| value.as_str()?.trim().parse::<i64>().ok())
})
.filter(|value| *value > 0)?;
Some((chrono::Utc::now() + chrono::Duration::seconds(expires_in)).to_rfc3339())
}
fn expiry_datetime_from_value(value: &Value) -> Option<chrono::DateTime<chrono::Utc>> {
if let Some(number) = value.as_i64() {
return expiry_datetime_from_unixish(number);
}
let text = value.as_str()?.trim();
if text.is_empty() {
return None;
}
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(text) {
return Some(parsed.with_timezone(&chrono::Utc));
}
text.parse::<i64>()
.ok()
.and_then(expiry_datetime_from_unixish)
}
fn expiry_datetime_from_unixish(value: i64) -> Option<chrono::DateTime<chrono::Utc>> {
if value <= 0 {
return None;
}
let millis = if value < 10_000_000_000 {
value.checked_mul(1000)?
} else {
value
};
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(millis)
}
fn parse_extra_config_object(raw_extra_config: Option<&str>) -> AppResult<Map<String, Value>> {
let Some(raw) = raw_extra_config
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(Map::new());
};
let value = serde_json::from_str::<Value>(raw)
.map_err(|err| AppError::BadRequest(format!("Invalid extra_config JSON: {err}")))?;
value
.as_object()
.cloned()
.ok_or_else(|| AppError::BadRequest("extra_config must be a JSON object".into()))
}
fn oauth_proxy_settings(raw_extra_config: Option<&str>) -> (Option<String>, bool) {
let Ok(config) = parse_extra_config_object(raw_extra_config) else {
return (None, false);
};
let proxy_url = config
.get("proxyUrl")
.or_else(|| config.get("proxy_url"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let use_system_proxy = config
.get("useSystemProxy")
.and_then(Value::as_bool)
.or_else(|| config.get("use_system_proxy").and_then(Value::as_bool))
.unwrap_or(false);
(proxy_url, use_system_proxy)
}
fn merge_refreshed_extra_config(
raw_extra_config: Option<&str>,
refreshed: &RefreshedToken,
previous_refresh_token: &str,
) -> AppResult<Option<String>> {
let mut config = parse_extra_config_object(raw_extra_config)?;
config.insert(
"refreshed_at".to_string(),
json!(chrono::Utc::now().to_rfc3339()),
);
if let Some(expires_at) = refreshed.expires_at.as_deref() {
config.insert("expires_at".to_string(), json!(expires_at));
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(expires_at) {
config.insert(
"tokenExpiresAt".to_string(),
json!(parsed.timestamp_millis()),
);
}
}
config.insert(
"refreshToken".to_string(),
json!(refreshed
.refresh_token
.as_deref()
.unwrap_or(previous_refresh_token)),
);
if config.is_empty() {
Ok(None)
} else {
serde_json::to_string(&Value::Object(config))
.map(Some)
.map_err(|err| AppError::Internal(err.into()))
}
}
fn token_expired(extra_config: Option<&str>) -> bool { fn token_expired(extra_config: Option<&str>) -> bool {
let Some(extra_config) = extra_config else { token_expiry_datetime(extra_config)
return true;
};
let Ok(value) = serde_json::from_str::<Value>(extra_config) else {
return true;
};
let Some(expires_at) = value.get("expires_at").and_then(Value::as_str) else {
return true;
};
chrono::DateTime::parse_from_rfc3339(expires_at)
.map(|expires_at| expires_at <= chrono::Utc::now() + chrono::Duration::minutes(5)) .map(|expires_at| expires_at <= chrono::Utc::now() + chrono::Duration::minutes(5))
.unwrap_or(true) .unwrap_or(true)
} }
fn token_expiry(extra_config: Option<&str>) -> Option<String> { fn token_expiry(extra_config: Option<&str>) -> Option<String> {
extra_config token_expiry_datetime(extra_config).map(|expires_at| expires_at.to_rfc3339())
.and_then(|config| serde_json::from_str::<Value>(config).ok()) }
.and_then(|value| {
value fn token_expiry_datetime(extra_config: Option<&str>) -> Option<chrono::DateTime<chrono::Utc>> {
.get("expires_at") let value = extra_config.and_then(|config| serde_json::from_str::<Value>(config).ok())?;
.and_then(Value::as_str) if let Some(expires_at) = value
.map(str::to_string) .get("expires_at")
}) .or_else(|| value.get("expiresAt"))
.and_then(Value::as_str)
{
return chrono::DateTime::parse_from_rfc3339(expires_at)
.map(|value| value.with_timezone(&chrono::Utc))
.ok();
}
let millis = value
.get("tokenExpiresAt")
.or_else(|| value.get("token_expires_at"))
.and_then(expiry_datetime_from_value)?;
Some(millis)
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn test_pool() -> SqlitePool {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool");
sqlx::query(
"CREATE TABLE settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT
)",
)
.execute(&pool)
.await
.expect("create settings");
sqlx::query(
"CREATE TABLE sites (
id INTEGER PRIMARY KEY,
custom_headers TEXT
)",
)
.execute(&pool)
.await
.expect("create sites");
sqlx::query(
"CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
site_id INTEGER NOT NULL,
access_token TEXT NOT NULL,
api_token TEXT,
extra_config TEXT,
oauth_provider TEXT,
updated_at TEXT
)",
)
.execute(&pool)
.await
.expect("create accounts");
pool
}
#[tokio::test]
async fn refresh_token_preserves_extra_config_and_updates_refresh_token() {
let pool = test_pool().await;
let mock = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.and(body_string_contains("grant_type=refresh_token"))
.and(body_string_contains("refresh_token=refresh-old"))
.and(body_string_contains("client_id=client-a"))
.and(body_string_contains("client_secret=secret-a"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"access_token": "access-new",
"refresh_token": "refresh-new",
"expires_at": 1893456000_i64
})))
.mount(&mock)
.await;
sqlx::query("INSERT INTO sites (id, custom_headers) VALUES (1, ?)")
.bind(
json!({
"oauth_token_url": format!("{}/oauth/token", mock.uri()),
"oauth_client_id": "client-a",
"oauth_client_secret": "secret-a"
})
.to_string(),
)
.execute(&pool)
.await
.expect("insert site");
sqlx::query(
"INSERT INTO accounts (id, site_id, access_token, api_token, extra_config, oauth_provider)
VALUES (7, 1, 'access-old', 'refresh-old', ?, 'codex')",
)
.bind(
json!({
"credentialMode": "oauth",
"proxyUrl": "socks5://127.0.0.1:1080",
"expires_at": "2000-01-01T00:00:00Z"
})
.to_string(),
)
.execute(&pool)
.await
.expect("insert account");
let service = OAuthService::with_client(Arc::new(pool.clone()), reqwest::Client::new());
let refreshed = service.refresh_token(7, 1).await.expect("refresh token");
assert_eq!(refreshed.access_token, "access-new");
assert_eq!(refreshed.refresh_token.as_deref(), Some("refresh-new"));
assert!(refreshed.expires_at.is_some());
let row: (String, Option<String>, Option<String>) = sqlx::query_as(
"SELECT access_token, api_token, extra_config FROM accounts WHERE id = 7",
)
.fetch_one(&pool)
.await
.expect("account row");
assert_eq!(row.0, "access-new");
assert_eq!(row.1.as_deref(), Some("refresh-new"));
let extra: Value =
serde_json::from_str(row.2.as_deref().expect("extra config")).expect("extra json");
assert_eq!(
extra.get("credentialMode").and_then(Value::as_str),
Some("oauth")
);
assert_eq!(
extra.get("proxyUrl").and_then(Value::as_str),
Some("socks5://127.0.0.1:1080")
);
assert_eq!(
extra.get("refreshToken").and_then(Value::as_str),
Some("refresh-new")
);
assert_eq!(
extra.get("expires_at").and_then(Value::as_str),
Some("2030-01-01T00:00:00+00:00")
);
assert_eq!(
extra.get("tokenExpiresAt").and_then(Value::as_i64),
Some(1893456000000_i64)
);
}
#[test]
fn token_expiry_from_response_accepts_string_token_expires_at() {
assert_eq!(
token_expiry_from_response(&json!({ "tokenExpiresAt": "1893456000" })).as_deref(),
Some("2030-01-01T00:00:00+00:00")
);
assert_eq!(
token_expiry_from_response(&json!({ "token_expires_at": 1893456000000_i64 }))
.as_deref(),
Some("2030-01-01T00:00:00+00:00")
);
}
#[tokio::test]
async fn refresh_token_keeps_current_token_when_not_expired() {
let pool = test_pool().await;
sqlx::query("INSERT INTO sites (id, custom_headers) VALUES (1, '{}')")
.execute(&pool)
.await
.expect("insert site");
let future = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
sqlx::query(
"INSERT INTO accounts (id, site_id, access_token, api_token, extra_config, oauth_provider)
VALUES (7, 1, 'access-current', 'refresh-current', ?, 'codex')",
)
.bind(json!({ "expires_at": future }).to_string())
.execute(&pool)
.await
.expect("insert account");
let service = OAuthService::with_client(Arc::new(pool), reqwest::Client::new());
let refreshed = service.refresh_token(7, 1).await.expect("current token");
assert_eq!(refreshed.access_token, "access-current");
assert_eq!(refreshed.refresh_token.as_deref(), Some("refresh-current"));
}
#[tokio::test]
async fn refresh_token_keeps_current_token_when_token_expires_at_is_future() {
let pool = test_pool().await;
sqlx::query("INSERT INTO sites (id, custom_headers) VALUES (1, '{}')")
.execute(&pool)
.await
.expect("insert site");
let future_millis = (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp_millis();
sqlx::query(
"INSERT INTO accounts (id, site_id, access_token, api_token, extra_config, oauth_provider)
VALUES (7, 1, 'access-current', 'refresh-current', ?, 'codex')",
)
.bind(json!({ "tokenExpiresAt": future_millis }).to_string())
.execute(&pool)
.await
.expect("insert account");
let service = OAuthService::with_client(Arc::new(pool), reqwest::Client::new());
let refreshed = service.refresh_token(7, 1).await.expect("current token");
assert_eq!(refreshed.access_token, "access-current");
assert_eq!(refreshed.refresh_token.as_deref(), Some("refresh-current"));
assert!(refreshed
.expires_at
.as_deref()
.is_some_and(|value| value.contains('T')));
}
#[tokio::test]
async fn system_proxy_url_reads_trimmed_runtime_setting() {
let pool = test_pool().await;
assert_eq!(system_proxy_url(&pool).await.unwrap(), None);
sqlx::query("INSERT INTO settings (key, value) VALUES (?, ?)")
.bind(SYSTEM_PROXY_URL_SETTING_KEY)
.bind(" http://127.0.0.1:7890 ")
.execute(&pool)
.await
.expect("insert system proxy");
assert_eq!(
system_proxy_url(&pool).await.unwrap().as_deref(),
Some("http://127.0.0.1:7890")
);
}
} }
File diff suppressed because it is too large Load Diff
+111 -5
View File
@@ -51,6 +51,7 @@ pub struct RebuildSummary {
struct AvailRow { struct AvailRow {
account_id: i64, account_id: i64,
model_name: String, model_name: String,
oauth_route_unit_id: Option<i64>,
} }
/// Run the rebuild against the live database. The whole thing runs inside a /// Run the rebuild against the live database. The whole thing runs inside a
@@ -75,10 +76,11 @@ pub(crate) async fn rebuild_routes_inner(
// lets us drop inactive accounts and apply site_disabled_models in one // lets us drop inactive accounts and apply site_disabled_models in one
// query — keeping the in-memory set small for installs with hundreds of // query — keeping the in-memory set small for installs with hundreds of
// accounts. // accounts.
let rows: Vec<(i64, String)> = sqlx::query_as( let rows: Vec<(i64, String, Option<i64>)> = sqlx::query_as(
"SELECT ma.account_id, ma.model_name "SELECT ma.account_id, ma.model_name, rum.unit_id
FROM model_availability ma FROM model_availability ma
JOIN accounts a ON a.id = ma.account_id JOIN accounts a ON a.id = ma.account_id
LEFT JOIN oauth_route_unit_members rum ON rum.account_id = a.id
WHERE COALESCE(ma.available, 0) = 1 WHERE COALESCE(ma.available, 0) = 1
AND a.status = 'active' AND a.status = 'active'
AND NOT EXISTS ( AND NOT EXISTS (
@@ -93,9 +95,10 @@ pub(crate) async fn rebuild_routes_inner(
let avail: Vec<AvailRow> = rows let avail: Vec<AvailRow> = rows
.into_iter() .into_iter()
.map(|(account_id, model_name)| AvailRow { .map(|(account_id, model_name, oauth_route_unit_id)| AvailRow {
account_id, account_id,
model_name, model_name,
oauth_route_unit_id,
}) })
.collect(); .collect();
@@ -112,11 +115,16 @@ pub(crate) async fn rebuild_routes_inner(
// 2. Group by model name. Each group becomes one token_routes row. // 2. Group by model name. Each group becomes one token_routes row.
let mut by_model: HashMap<String, Vec<i64>> = HashMap::new(); let mut by_model: HashMap<String, Vec<i64>> = HashMap::new();
let mut route_unit_by_key: HashMap<(i64, String), Option<i64>> = HashMap::new();
for row in &avail { for row in &avail {
by_model by_model
.entry(row.model_name.clone()) .entry(row.model_name.clone())
.or_default() .or_default()
.push(row.account_id); .push(row.account_id);
route_unit_by_key.insert(
(row.account_id, row.model_name.clone()),
row.oauth_route_unit_id,
);
} }
// 3. Pre-load existing routes by model_pattern so we can reuse rows. The // 3. Pre-load existing routes by model_pattern so we can reuse rows. The
@@ -211,21 +219,41 @@ pub(crate) async fn rebuild_routes_inner(
.fetch_optional(&mut *tx) .fetch_optional(&mut *tx)
.await?; .await?;
let oauth_route_unit_id = route_unit_by_key.get(key).copied().flatten();
sqlx::query( sqlx::query(
"INSERT INTO route_channels "INSERT INTO route_channels
(route_id, account_id, token_id, source_model, (route_id, account_id, token_id, source_model,
priority, weight, enabled, manual_override) oauth_route_unit_id, priority, weight, enabled, manual_override)
VALUES (?, ?, ?, ?, 0, 10, 1, 0)", VALUES (?, ?, ?, ?, ?, 0, 10, 1, 0)",
) )
.bind(route_id) .bind(route_id)
.bind(key.0) .bind(key.0)
.bind(token_id) .bind(token_id)
.bind(&key.1) .bind(&key.1)
.bind(oauth_route_unit_id)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
summary.created_channels += 1; summary.created_channels += 1;
} }
// Keep OAuth route-unit annotations in sync for existing auto-managed channels.
for (key, ch_id) in &auto_by_key {
if !desired.contains(key) {
continue;
}
let oauth_route_unit_id = route_unit_by_key.get(key).copied().flatten();
sqlx::query(
"UPDATE route_channels
SET oauth_route_unit_id = ?
WHERE id = ?
AND COALESCE(manual_override, 0) = 0",
)
.bind(oauth_route_unit_id)
.bind(ch_id)
.execute(&mut *tx)
.await?;
}
// Delete auto channels that are no longer desired (account dropped the // Delete auto channels that are no longer desired (account dropped the
// model, or the model is now in site_disabled_models). // model, or the model is now in site_disabled_models).
for (key, ch_id) in &auto_by_key { for (key, ch_id) in &auto_by_key {
@@ -379,6 +407,7 @@ mod tests {
route_id INTEGER NOT NULL, route_id INTEGER NOT NULL,
account_id INTEGER NOT NULL, account_id INTEGER NOT NULL,
token_id INTEGER, token_id INTEGER,
oauth_route_unit_id INTEGER,
source_model TEXT, source_model TEXT,
priority INTEGER DEFAULT 0, priority INTEGER DEFAULT 0,
weight INTEGER DEFAULT 10, weight INTEGER DEFAULT 10,
@@ -391,6 +420,20 @@ mod tests {
enabled INTEGER DEFAULT 1, enabled INTEGER DEFAULT 1,
is_default INTEGER DEFAULT 0 is_default INTEGER DEFAULT 0
)", )",
"CREATE TABLE oauth_route_units (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL,
provider TEXT NOT NULL,
name TEXT NOT NULL,
strategy TEXT NOT NULL DEFAULT 'round_robin',
enabled INTEGER DEFAULT 1
)",
"CREATE TABLE oauth_route_unit_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
unit_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
sort_order INTEGER DEFAULT 0
)",
] { ] {
sqlx::query(ddl) sqlx::query(ddl)
.execute(&pool) .execute(&pool)
@@ -524,6 +567,69 @@ mod tests {
assert_eq!(route_count, 1); assert_eq!(route_count, 1);
} }
#[tokio::test]
async fn rebuild_marks_auto_channels_with_oauth_route_unit_id() {
let pool = fixture_pool().await;
sqlx::query(
"INSERT INTO accounts (id, site_id, status) VALUES
(1, 1, 'active'),
(2, 1, 'active')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO model_availability (account_id, model_name, available) VALUES
(1, 'gpt-oauth', 1),
(2, 'gpt-oauth', 1)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO oauth_route_units (id, site_id, provider, name, strategy)
VALUES (9, 1, 'codex', 'Codex Pool', 'round_robin')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO oauth_route_unit_members (unit_id, account_id, sort_order) VALUES
(9, 1, 0),
(9, 2, 1)",
)
.execute(&pool)
.await
.unwrap();
let summary = rebuild_routes_inner(&pool).await.expect("rebuild");
assert_eq!(summary.created_channels, 2);
let unit_ids: Vec<Option<i64>> = sqlx::query_scalar(
"SELECT oauth_route_unit_id FROM route_channels ORDER BY account_id",
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(unit_ids, vec![Some(9), Some(9)]);
sqlx::query("DELETE FROM oauth_route_unit_members WHERE unit_id = 9")
.execute(&pool)
.await
.unwrap();
let summary = rebuild_routes_inner(&pool).await.expect("second rebuild");
assert_eq!(summary.created_channels, 0);
let unit_ids: Vec<Option<i64>> = sqlx::query_scalar(
"SELECT oauth_route_unit_id FROM route_channels ORDER BY account_id",
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(unit_ids, vec![None, None]);
}
/// User-reported bug: empty DB still shows hundreds of routes on the /// User-reported bug: empty DB still shows hundreds of routes on the
/// `/routes` page, and clicking "rebuild" does nothing. Reproduces by /// `/routes` page, and clicking "rebuild" does nothing. Reproduces by
/// pre-seeding `token_routes` rows that no account / availability row /// pre-seeding `token_routes` rows that no account / availability row
+425 -5
View File
@@ -1,8 +1,18 @@
import { describe, expect, it, vi } from 'vitest'; import { act } from 'react';
import { create, type ReactTestInstance } from 'react-test-renderer'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom'; import { create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { clearSession, commitSession, getSessionToken } from './authSession.js';
import App from './App.js';
import AdminLoginPage from './pages/auth/AdminLoginPage.js'; import AdminLoginPage from './pages/auth/AdminLoginPage.js';
import UserLoginPage from './pages/auth/UserLoginPage.js'; import UserLoginPage from './pages/auth/UserLoginPage.js';
import UserRegister from './pages/UserRegister.js';
vi.mock('./pages/Dashboard.js', () => ({
default: () => <div>Dashboard Page</div>,
}));
function collectText(node: ReactTestInstance): string { function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => { return (node.children || []).map((child) => {
@@ -11,6 +21,77 @@ function collectText(node: ReactTestInstance): string {
}).join(''); }).join('');
} }
function createMemoryStorage() {
const store = new Map<string, string>();
return {
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, value);
},
removeItem(key: string) {
store.delete(key);
},
};
}
function unsignedJwt(payload: Record<string, unknown>): string {
const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8')
.toString('base64url');
return `test.${encodedPayload}.signature`;
}
function PathProbe({ onPath }: { onPath: (path: string) => void }) {
const location = useLocation();
onPath(location.pathname);
return null;
}
function stubAppRuntime(storage: ReturnType<typeof createMemoryStorage>) {
const matchMedia = (query: string) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
onchange: null,
});
const documentElement = {
setAttribute: vi.fn(),
getAttribute: vi.fn(() => null),
};
vi.stubGlobal('localStorage', storage);
vi.stubGlobal('window', {
innerWidth: 1280,
matchMedia,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('document', {
body: { style: {}, dataset: {} },
documentElement,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
getElementById: vi.fn(() => null),
});
vi.stubGlobal('Node', { TEXT_NODE: 3, ELEMENT_NODE: 1 });
vi.stubGlobal('MutationObserver', class {
observe = vi.fn();
disconnect = vi.fn();
takeRecords = vi.fn(() => []);
});
}
afterEach(() => {
clearSession('admin');
clearSession('user');
vi.unstubAllGlobals();
});
describe('Login surface', () => { describe('Login surface', () => {
it('renders a poster-style hero with a floating admin login panel', () => { it('renders a poster-style hero with a floating admin login panel', () => {
const root = create( const root = create(
@@ -51,18 +132,267 @@ describe('Login surface', () => {
node.type === 'input' node.type === 'input'
&& node.props.placeholder === '管理员令牌' && node.props.placeholder === '管理员令牌'
)); ));
expect(tokenInput.props.type).toBe('password');
expect(root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('login-mode-tab')
))).toHaveLength(0);
expect(pageText).toContain('仅允许使用管理员 API Token 进入控制台。');
expect(pageText).toContain('管理员后台不接受账号密码或用户 API Key 登录。');
expect(root.root.findAllByProps({ id: 'admin-email-input' })).toHaveLength(0);
expect(root.root.findAllByProps({ id: 'admin-password-input' })).toHaveLength(0);
expect(root.root.findAllByProps({ id: 'admin-api-key-input' })).toHaveLength(0);
const form = root.root.findByProps({ id: 'admin-login-panel-token' });
expect(form.props.onSubmit).toBeTypeOf('function');
} finally {
root?.unmount();
}
});
it('only accepts the admin token on /boos and clears stale user session', async () => {
const storage = createMemoryStorage();
stubAppRuntime(storage);
commitSession('user', 'stale-user-jwt', undefined, storage);
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
text: async () => JSON.stringify({ enabled: true, mode: 'admin-token' }),
});
vi.stubGlobal('fetch', fetchMock);
const paths: string[] = [];
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/boos']}>
<App />
<PathProbe onPath={(path) => paths.push(path)} />
</MemoryRouter>,
);
});
await act(async () => {
root.root.findByProps({ id: 'admin-token-input' }).props.onChange({
target: { value: 'admin-config-token' },
});
});
await act(async () => {
await root.root.findByProps({ id: 'admin-login-panel-token' }).props.onSubmit({
preventDefault: vi.fn(),
});
});
expect(fetchMock).toHaveBeenCalledWith('/api/settings/auth/info', expect.objectContaining({
headers: { Authorization: 'Bearer admin-config-token' },
}));
expect(fetchMock).not.toHaveBeenCalledWith('/api/auth/login-api-key', expect.anything());
expect(fetchMock).not.toHaveBeenCalledWith('/api/auth/login', expect.anything());
expect(getSessionToken('admin', storage)).toBe('admin-config-token');
expect(getSessionToken('user', storage)).toBeNull();
expect(paths.at(-1)).toBe('/dashboard');
} finally {
act(() => {
root?.unmount();
});
}
});
it('does not expose account password or API Key controls on the admin login page', async () => {
const storage = createMemoryStorage();
vi.stubGlobal('localStorage', storage);
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const onLogin = vi.fn();
const root = create(
<MemoryRouter>
<AdminLoginPage t={(text) => text} onLogin={onLogin} />
</MemoryRouter>,
);
try {
expect(root.root.findAllByProps({ id: 'admin-email-input' })).toHaveLength(0);
expect(root.root.findAllByProps({ id: 'admin-password-input' })).toHaveLength(0);
expect(root.root.findAllByProps({ id: 'admin-api-key-input' })).toHaveLength(0);
expect(collectText(root.root)).not.toContain('该 API Key 不属于管理员账号。');
expect(fetchMock).not.toHaveBeenCalled();
expect(onLogin).not.toHaveBeenCalled();
expect(getSessionToken('admin', storage)).toBeNull();
} finally {
root?.unmount();
}
});
it('connects user login mode tabs to the active login form panel', () => {
const root = create(
<MemoryRouter>
<UserLoginPage t={(text) => text} />
</MemoryRouter>,
);
try {
const loginModeTabs = root.root.findAll((node) => ( const loginModeTabs = root.root.findAll((node) => (
node.type === 'button' node.type === 'button'
&& typeof node.props.className === 'string' && typeof node.props.className === 'string'
&& node.props.className.includes('login-mode-tab') && node.props.className.includes('login-mode-tab')
)); ));
expect(tokenInput.props.type).toBe('password'); expect(loginModeTabs.map((node) => collectText(node))).toEqual(['邮箱密码', 'API Key']);
expect(loginModeTabs.map((node) => collectText(node))).toEqual(['Auth Token', '账号密码']); expect(loginModeTabs.every((node) => node.props.role === 'tab')).toBe(true);
expect(loginModeTabs[0].props['aria-selected']).toBe(true);
expect(loginModeTabs[0].props['aria-controls']).toBe('user-login-panel-account');
const activePanel = root.root.find((node) => node.props.role === 'tabpanel');
expect(activePanel.props.id).toBe('user-login-panel-account');
expect(activePanel.props['aria-labelledby']).toBe('user-login-tab-account');
} finally { } finally {
root?.unmount(); root?.unmount();
} }
}); });
it('rejects admin API Key login on the user login page', async () => {
const storage = createMemoryStorage();
vi.stubGlobal('localStorage', storage);
commitSession('user', 'stale-user-jwt', undefined, storage);
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
token: 'jwt-admin-from-key',
user: {
role: 'admin',
},
}),
});
vi.stubGlobal('fetch', fetchMock);
const onLogin = vi.fn();
const paths: string[] = [];
const root = create(
<MemoryRouter initialEntries={['/user/login']}>
<UserLoginPage t={(text) => text} onLogin={onLogin} />
<PathProbe onPath={(path) => paths.push(path)} />
</MemoryRouter>,
);
try {
const apiKeyTab = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('login-mode-tab')
))[1];
await act(async () => {
apiKeyTab.props.onClick();
});
const input = root.root.findByProps({ id: 'user-api-key-input' });
await act(async () => {
input.props.onChange({ target: { value: 'sk-admin-user-key' } });
});
const form = root.root.findByProps({ id: 'user-login-panel-apiKey' });
await act(async () => {
await form.props.onSubmit({ preventDefault: vi.fn() });
});
expect(fetchMock).toHaveBeenCalledWith('/api/users/login-api-key', expect.objectContaining({
method: 'POST',
body: JSON.stringify({ apiKey: 'sk-admin-user-key' }),
}));
expect(getSessionToken('admin', storage)).toBeNull();
expect(getSessionToken('user', storage)).toBe('stale-user-jwt');
expect(onLogin).not.toHaveBeenCalled();
expect(collectText(root.root)).toContain('管理员请使用管理员 API Token 登录。');
expect(paths.at(-1)).toBe('/user/login');
} finally {
root?.unmount();
}
});
it('rejects API Key login when JWT role falls back to admin', async () => {
const storage = createMemoryStorage();
vi.stubGlobal('localStorage', storage);
const adminJwt = unsignedJwt({ sub: '5', role: 'admin', exp: 9_999_999_999 });
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
token: adminJwt,
user: {
id: 5,
email: 'admin@example.test',
},
}),
});
vi.stubGlobal('fetch', fetchMock);
const onLogin = vi.fn();
const paths: string[] = [];
const root = create(
<MemoryRouter initialEntries={['/user/login']}>
<UserLoginPage t={(text) => text} onLogin={onLogin} />
<PathProbe onPath={(path) => paths.push(path)} />
</MemoryRouter>,
);
try {
const apiKeyTab = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('login-mode-tab')
))[1];
await act(async () => {
apiKeyTab.props.onClick();
});
await act(async () => {
root.root.findByProps({ id: 'user-api-key-input' }).props.onChange({
target: { value: 'sk-admin-bound' },
});
});
await act(async () => {
await root.root.findByProps({ id: 'user-login-panel-apiKey' }).props.onSubmit({
preventDefault: vi.fn(),
});
});
expect(getSessionToken('admin', storage)).toBeNull();
expect(getSessionToken('user', storage)).toBeNull();
expect(onLogin).not.toHaveBeenCalled();
expect(collectText(root.root)).toContain('管理员请使用管理员 API Token 登录。');
expect(paths.at(-1)).toBe('/user/login');
} finally {
root?.unmount();
}
});
it('prefers the admin dashboard on public user auth pages when both sessions exist', () => {
const storage = createMemoryStorage();
stubAppRuntime(storage);
commitSession('admin', 'jwt-admin', undefined, storage);
commitSession('user', 'jwt-user-stale', undefined, storage);
vi.stubGlobal('fetch', vi.fn());
const paths: string[] = [];
let root!: ReactTestRenderer;
try {
act(() => {
root = create(
<MemoryRouter initialEntries={['/user/login']}>
<App />
<PathProbe onPath={(path) => paths.push(path)} />
</MemoryRouter>,
);
});
expect(paths.at(-1)).toBe('/dashboard');
} finally {
act(() => {
root?.unmount();
});
}
});
it('renders OAuth provider entries as configured placeholders on user login', () => { it('renders OAuth provider entries as configured placeholders on user login', () => {
const root = create( const root = create(
<MemoryRouter> <MemoryRouter>
@@ -80,8 +410,98 @@ describe('Login surface', () => {
expect(providers.map((node) => node.props['data-provider'])).toEqual(['google', 'github', 'wechat']); expect(providers.map((node) => node.props['data-provider'])).toEqual(['google', 'github', 'wechat']);
expect(providers.every((node) => node.props.disabled === true)).toBe(true); expect(providers.every((node) => node.props.disabled === true)).toBe(true);
expect(collectText(root.root)).toContain('待配置'); expect(collectText(root.root)).toContain('待配置');
const wechatProvider = providers.find((node) => node.props['data-provider'] === 'wechat');
const wechatIcons = wechatProvider?.findAll((node) => (
node.type === 'svg'
&& typeof node.props.className === 'string'
&& node.props.className.includes('login-oauth-wechat-glyph')
));
expect(wechatIcons).toHaveLength(1);
expect(wechatIcons?.[0]?.props.width).toBe('30');
expect(wechatIcons?.[0]?.props.height).toBe('30');
expect(wechatIcons?.[0]?.props.viewBox).toBe('0 0 64 64');
expect(wechatProvider?.findAll((node) => node.type === 'img')).toHaveLength(0);
} finally { } finally {
root?.unmount(); root?.unmount();
} }
}); });
it('uses a complete local WeChat glyph on the register OAuth placeholder', () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: {
id: 'captcha-test',
image: 'data:image/png;base64,test',
},
}),
});
const originalFetch = globalThis.fetch;
vi.stubGlobal('fetch', fetchMock);
const root = create(
<MemoryRouter>
<UserRegister />
</MemoryRouter>,
);
try {
const oauthSlot = root.root.find((node) => node.props['data-testid'] === 'oauth-slot');
const wechatProvider = oauthSlot.find((node) => (
node.type === 'button'
&& node.props['data-provider'] === 'wechat'
));
const wechatIcon = wechatProvider.find((node) => (
node.type === 'svg'
&& typeof node.props.className === 'string'
&& node.props.className.includes('login-oauth-wechat-glyph')
));
const vectorParts = wechatIcon.findAll((node) => node.type === 'path' || node.type === 'circle');
expect(oauthSlot.props['data-mode']).toBe('register');
expect(wechatProvider.props.disabled).toBe(true);
expect(wechatProvider.props.onClick).toBeUndefined();
expect(wechatIcon.props.width).toBe('30');
expect(wechatIcon.props.height).toBe('30');
expect(wechatIcon.props.viewBox).toBe('0 0 64 64');
expect(wechatIcon.props['aria-hidden']).toBe('true');
expect(vectorParts).toHaveLength(6);
expect(wechatProvider.findAll((node) => node.type === 'img')).toHaveLength(0);
} finally {
root?.unmount();
vi.stubGlobal('fetch', originalFetch);
}
});
it('keeps the local WeChat OAuth mark unclipped in CSS', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(css).toContain('.login-oauth-provider[data-provider="wechat"] .login-oauth-provider-mark {\n');
expect(css).toContain(' overflow: visible;\n box-shadow:\n inset 0 1px 0 rgba(255, 255, 255, 0.72),');
expect(css).toContain('.login-oauth-wechat-glyph {\n display: block;\n width: 30px;\n height: 30px;\n flex: 0 0 30px;\n overflow: visible;\n}');
});
it('keeps login brand panels bounded on short and mobile viewports', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(css).not.toContain('min-height: 650px;');
expect(css).not.toContain('min-height: 580px;');
expect(css).toContain('.login-brand-panel {\n min-height: min(650px, calc(100dvh - 92px));');
expect(css).toContain('.login-shell[data-variant="user"] .login-brand-panel {\n min-height: min(580px, calc(100dvh - 92px));');
expect(css).toContain('@media (max-height: 760px) and (min-width: 1181px) {');
expect(css).toContain('.login-brand-panel,\n .login-shell[data-variant="user"] .login-brand-panel {\n min-height: auto;');
expect(css).toContain('@media (max-width: 1180px) {');
expect(css).toContain('.login-shell[data-variant="user"] .login-brand-panel {\n min-height: auto;');
});
it('keeps admin and user login columns shrink-safe before the single-column breakpoint', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(css).toContain('grid-template-columns: minmax(min(620px, 100%), 1fr) minmax(min(360px, 100%), 420px);');
expect(css).toContain('grid-template-columns: minmax(min(520px, 100%), 1.08fr) minmax(min(380px, 100%), 0.92fr);');
expect(css).toContain('grid-template-columns: minmax(min(440px, 100%), 0.95fr) minmax(min(380px, 100%), 1.05fr);');
expect(css).not.toContain('grid-template-columns: minmax(620px, 1fr) minmax(360px, 420px);');
expect(css).not.toContain('grid-template-columns: minmax(520px, 1.08fr) minmax(380px, 0.92fr);');
expect(css).not.toContain('grid-template-columns: minmax(440px, 0.95fr) minmax(380px, 1.05fr);');
});
}); });
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('App route transition feedback', () => {
it('tracks pathname and query changes without remounting page content for search-only navigation', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain('const routeTransitionKey = `${location.pathname}${location.search}`;');
expect(source).toContain('const previousRouteTransitionKeyRef = useRef(routeTransitionKey);');
expect(source).toContain('const sidebarRef = useRef<HTMLElement>(null);');
expect(source).toContain('const mainRef = useRef<HTMLElement>(null);');
expect(source).toContain("import { shouldFocusMainOnRouteChange } from './pages/helpers/routeFocus.js';");
expect(source).toContain('previousRouteTransitionKeyRef.current === routeTransitionKey');
expect(source).toContain('className="skip-link"');
expect(source).toContain('href="#main-content"');
expect(source).toContain('mainRef.current?.focus({ preventScroll: true });');
expect(source).toContain("{t('跳到主内容')}");
expect(source).toContain('ref={mainRef}');
expect(source).toContain('id="main-content"');
expect(source).toContain('tabIndex={-1}');
expect(source).toContain("aria-label={t('主内容')}");
expect(source).toContain('const pageMotionKey = `${location.pathname}${location.search}`;');
expect(source).toContain('key={location.pathname} motionKey={pageMotionKey}');
expect(source).toContain('<Suspense fallback={null}>');
expect(source).not.toContain('function RouteLoadingFallback');
expect(source).not.toContain('fallback={<RouteLoadingFallback />}');
expect(source).not.toContain('routeTransitioning');
expect(source).not.toContain('is-route-transitioning');
expect(source).toContain('className="sr-only"');
expect(source).toContain('aria-live="polite"');
expect(source).toContain('aria-atomic="true"');
});
it('moves focus to the main region on shell-driven route changes without stealing field focus', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain('const shouldFocusMain = shouldFocusMainOnRouteChange(document.activeElement, {');
expect(source).toContain('body: document.body,');
expect(source).toContain('topbar: topbarRef.current,');
expect(source).toContain('sidebar: sidebarRef.current,');
expect(source).not.toContain('const shouldFocusMain = !activeElement');
expect(source).toContain('let focusFrameId: number | null = null;');
expect(source).toContain("const schedule = typeof window.requestAnimationFrame === 'function'");
expect(source).toContain('focusFrameId = schedule(() => {');
expect(source).toContain('mainRef.current?.focus({ preventScroll: true });');
expect(source).toContain("typeof window.cancelAnimationFrame === 'function'");
expect(source).toContain('window.cancelAnimationFrame(focusFrameId);');
});
it('does not render a blocking shell loading frame while lazy routes resolve', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8').replace(/\r\n/g, '\n');
expect(css).toContain('.skip-link');
expect(css).toContain('.skip-link:focus,\n.skip-link:focus-visible');
expect(css).toContain('.sr-only');
expect(css).toContain('.skip-link,\n .skip-link:focus,\n .skip-link:focus-visible');
expect(source).not.toContain('RouteLoadingFallback');
expect(source).not.toContain('animate-fade-in');
expect(source).not.toContain('aria-busy={routeTransitioning}');
expect(css).not.toContain('.route-progress');
expect(css).not.toContain('@keyframes route-progress-sweep');
expect(css).not.toContain('.main-content.is-route-transitioning');
});
});
+175 -4
View File
@@ -11,17 +11,166 @@ describe('App sidebar config', () => {
expect(source).not.toContain("{ to: '/tokens', label: '令牌管理'"); expect(source).not.toContain("{ to: '/tokens', label: '令牌管理'");
}); });
it('places downstream key navigation under 控制台 instead of 系统', () => { it('places downstream key navigation under 接入管理 instead of 系统', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8'); const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const consoleGroupIndex = source.indexOf("label: '控制台'"); const accessGroupIndex = source.indexOf("label: '接入管理'");
const downstreamIndex = source.indexOf("{ to: '/downstream-keys', label: '下游密钥'"); const downstreamIndex = source.indexOf("{ to: '/downstream-keys', label: '下游密钥'");
const systemGroupIndex = source.indexOf("label: '系统'"); const systemGroupIndex = source.indexOf("label: '系统'");
expect(consoleGroupIndex).toBeGreaterThanOrEqual(0); expect(accessGroupIndex).toBeGreaterThanOrEqual(0);
expect(downstreamIndex).toBeGreaterThan(consoleGroupIndex); expect(downstreamIndex).toBeGreaterThan(accessGroupIndex);
expect(systemGroupIndex).toBeGreaterThan(downstreamIndex); expect(systemGroupIndex).toBeGreaterThan(downstreamIndex);
}); });
it('keeps access navigation flat and non-collapsible', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const accessGroupIndex = source.indexOf("label: '接入管理'");
const creativeGroupIndex = source.indexOf("label: '创作应用'");
const accessGroupSource = source.slice(accessGroupIndex, creativeGroupIndex);
expect(accessGroupIndex).toBeGreaterThanOrEqual(0);
expect(source).not.toContain("label: '模型接入'");
expect(source).not.toContain("label: '渠道管理'");
expect(source).not.toContain("label: '接入配置'");
expect(source).not.toContain("label: '接入与路由'");
expect(accessGroupSource).not.toContain('children:');
expect(accessGroupSource).not.toContain('collapsed');
expect(accessGroupSource).not.toContain('expanded');
expect(accessGroupSource).not.toContain('aria-expanded');
expect(accessGroupSource).toContain("{ to: '/sites', label: '站点管理'");
expect(accessGroupSource).toContain("{ to: '/routes', label: '路由管理'");
expect(accessGroupSource).toContain("{ to: '/ip-management', label: 'IP 管理'");
});
it('adds an accessible collapsible desktop sidebar', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
expect(source).toContain("const BOOSAPI_SIDEBAR_COLLAPSED_KEY = 'boosapi.sidebar.collapsed';");
expect(source).toContain('function resolveStoredSidebarCollapsed(): boolean {');
expect(source).toContain('const [sidebarCollapsed, setSidebarCollapsed] = useState(() => resolveStoredSidebarCollapsed());');
expect(source).toContain('className={`app-layout ${sidebarCollapsed ? \'sidebar-is-collapsed\' : \'\'}`.trim()}');
expect(source).toContain('<aside ref={sidebarRef} className="sidebar" data-collapsed={sidebarCollapsed ? \'true\' : \'false\'}>');
expect(source).toContain('className="sidebar-collapse-button"');
expect(source).toContain("aria-label={sidebarCollapsed ? t('展开侧边栏') : t('收起侧边栏')}");
expect(source).toContain('aria-controls={sidebarNavId}');
expect(source).toContain('aria-expanded={!sidebarCollapsed}');
expect(source).toContain('onClick={() => setSidebarCollapsed((value) => !value)}');
expect(css).toContain('.app-layout.sidebar-is-collapsed .sidebar {');
expect(css).toContain('flex-basis: 72px;');
expect(css).toContain('.sidebar-collapse-button {');
});
it('uses flat sidebar selection styling without tree-like rails', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
const sidebarCss = css.slice(css.indexOf('/* ===== Sidebar ===== */'));
const sidebarActiveBeforeRules = Array.from(
sidebarCss.matchAll(/\.sidebar-item\.active::before\s*\{[^}]*\}/g),
(match) => match[0],
);
expect(sidebarActiveBeforeRules.length).toBeGreaterThan(0);
sidebarActiveBeforeRules.forEach((rule) => {
expect(rule).toContain('content: none;');
expect(rule).toContain('display: none;');
expect(rule).not.toContain('left: 6px;');
expect(rule).not.toContain('width: 3px;');
expect(rule).not.toContain('height: 16px;');
});
const mobileActiveBeforeRules = Array.from(
sidebarCss.matchAll(/\.mobile-nav-item\.active::before\s*\{[^}]*\}/g),
(match) => match[0],
);
expect(mobileActiveBeforeRules.length).toBeGreaterThan(0);
mobileActiveBeforeRules.forEach((rule) => {
expect(rule).toContain('content: none;');
expect(rule).toContain('display: none;');
});
const mobileLabelRules = Array.from(
sidebarCss.matchAll(/\.mobile-nav-label\s*\{[^}]*\}/g),
(match) => match[0],
);
expect(mobileLabelRules.length).toBeGreaterThan(0);
mobileLabelRules.forEach((rule) => {
expect(rule).toContain('text-transform: none;');
expect(rule).toContain('letter-spacing: 0;');
});
expect(sidebarCss).not.toContain('inset 4px 0 0');
expect(sidebarCss).not.toContain('sidebar-submenu');
expect(sidebarCss).not.toContain('mobile-nav-parent');
const sidebarGroupRules = Array.from(
css.matchAll(/\.sidebar-group(?:\:last-of-type)?\s*\{[^}]*\}/g),
(match) => match[0],
);
expect(sidebarGroupRules.length).toBeGreaterThan(0);
const hasNonZeroDeclaration = (rule: string, property: string) => (
Array.from(rule.matchAll(new RegExp(`${property}\\s*:\\s*([^;]+)`, 'g')))
.some((match) => match[1]?.trim() !== '0')
);
const treeLikeSidebarGroupRules = sidebarGroupRules.filter((rule) => (
hasNonZeroDeclaration(rule, 'border-left')
|| hasNonZeroDeclaration(rule, 'padding-left')
|| hasNonZeroDeclaration(rule, 'margin-left')
));
expect(treeLikeSidebarGroupRules).toEqual([]);
expect(sidebarGroupRules.some((rule) => rule.includes('border-left: 0;'))).toBe(true);
expect(sidebarGroupRules.some((rule) => rule.includes('padding-left: 0;'))).toBe(true);
const mobileFlatRules = Array.from(
css.matchAll(/\.mobile-nav-group,\s*\.mobile-nav-label,\s*\.mobile-nav-item\s*\{[^}]*\}/g),
(match) => match[0],
);
expect(mobileFlatRules.length).toBeGreaterThan(0);
mobileFlatRules.forEach((rule) => {
expect(rule).toContain('margin-left: 0;');
expect(rule).toContain('border-left: 0;');
});
});
it('keeps internal usage log navigation and removes external monitor from admin shell', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const adminGroupsSource = source.slice(
source.indexOf('export const sidebarGroups'),
source.indexOf('export const userSidebarGroups'),
);
expect(adminGroupsSource).toContain("{ to: '/logs', label: '使用日志'");
expect(adminGroupsSource).toContain("{ to: '/events', label: '系统日志'");
expect(adminGroupsSource).not.toContain("{ to: '/monitor', label: '外部监控'");
expect(adminGroupsSource).not.toContain("{ to: '/events', label: '程序日志'");
expect(adminGroupsSource).not.toContain('children:');
expect(adminGroupsSource).not.toContain('aria-expanded');
expect(adminGroupsSource).not.toContain('sidebar-submenu');
expect(adminGroupsSource).not.toContain('mobile-nav-parent');
expect(adminGroupsSource).toContain("label: '接入管理'");
expect(adminGroupsSource).toContain("{ to: '/ops', label: '运维监控'");
const accessGroupSource = adminGroupsSource.slice(
adminGroupsSource.indexOf("label: '接入管理'"),
adminGroupsSource.indexOf("label: '创作应用'"),
);
const systemGroupSource = adminGroupsSource.slice(adminGroupsSource.indexOf("label: '系统'"));
expect(accessGroupSource).toContain("{ to: '/logs', label: '使用日志'");
expect(accessGroupSource).not.toContain("{ to: '/events', label: '系统日志'");
expect(systemGroupSource).toContain("{ to: '/events', label: '系统日志'");
expect(systemGroupSource).not.toContain("{ to: '/logs', label: '使用日志'");
expect(source).toContain('<Route path="/logs" element={<ProxyLogs />} />');
expect(source).not.toContain("const Monitors = lazy(() => import('./pages/Monitors.js'));");
expect(source).not.toContain('<Route path="/monitor"');
expect(source).toContain('<Route path="/events" element={<ProgramLogs />} />');
});
it('keeps user-side usage records navigation and route intact', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const userGroupsSource = source.slice(
source.indexOf('export const userSidebarGroups'),
source.indexOf('const topNavItems'),
);
expect(userGroupsSource).toContain("{ to: '/user/usage', label: '使用记录'");
expect(source).toContain("const UsageRecords = lazy(() => import('./pages/UsageRecords.js'));");
expect(source).toContain('<Route path="/user/usage" element={<UsageRecords />} />');
});
it('adds standalone OAuth 管理 navigation entry', () => { it('adds standalone OAuth 管理 navigation entry', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8'); const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
@@ -29,4 +178,26 @@ describe('App sidebar config', () => {
expect(source).toContain("const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));"); expect(source).toContain("const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));");
expect(source).toContain('<Route path="/oauth" element={<OAuthManagement />} />'); expect(source).toContain('<Route path="/oauth" element={<OAuthManagement />} />');
}); });
it('registers the model tester route used by route diagnostics', () => {
const appSource = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const tokenRoutesSource = readFileSync(resolve(process.cwd(), 'src/web/pages/TokenRoutes.tsx'), 'utf8');
const topNavSource = appSource.slice(
appSource.indexOf('const topNavItems'),
appSource.indexOf('function isExactSidebarPath'),
);
expect(appSource).toContain("const ModelTester = lazy(() => import('./pages/ModelTester.js'));");
expect(topNavSource).toContain("{ label: '模型测试', to: '/model-tester' }");
expect(appSource).toContain('<Route path="/model-tester" element={<ModelTester />} />');
expect(tokenRoutesSource).toContain('navigate(`/model-tester?model=${encodeURIComponent(model)}`)');
});
it('adds a system-internal recharge page to the user workspace', () => {
const appSource = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
expect(appSource).toContain("const UserRecharge = lazy(() => import('./pages/UserRecharge.js'));");
expect(appSource).toContain("{ to: '/user/recharge', label: '账户充值'");
expect(appSource).toContain('<Route path="/user/recharge" element={<UserRecharge />} />');
});
}); });
@@ -0,0 +1,117 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
function readAppSource(): string {
return readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8').replace(/\r\n/g, '\n');
}
describe('App topbar menu accessibility', () => {
it('exposes theme and account dropdowns as controlled menus', () => {
const source = readAppSource();
expect(source).toContain('const themeMenuId = useId();');
expect(source).toContain('const userMenuId = useId();');
expect(source).toContain('const userTriggerRef = useRef<HTMLButtonElement>(null);');
expect(source).toContain('const themeTriggerRef = useRef<HTMLButtonElement>(null);');
expect(source).toContain('const searchDialogId = useId();');
expect(source).toContain('const notificationPanelId = useId();');
expect(source).toContain('function openSearch() {');
expect(source).toContain('aria-haspopup="menu"');
expect(source).toContain('aria-haspopup="dialog"');
expect(source).toContain('aria-expanded={showSearch}');
expect(source).toContain('aria-controls={showSearch ? searchDialogId : undefined}');
expect(source).toContain('aria-controls={themeMenuPresence.shouldRender ? themeMenuId : undefined}');
expect(source).toContain('aria-controls={userMenuPresence.shouldRender ? userMenuId : undefined}');
expect(source).toContain('aria-controls={showNotifications ? notificationPanelId : undefined}');
expect(source).toContain('role="menu"');
expect(source).toContain('aria-label={t(\'主题菜单\')}');
expect(source).toContain('aria-label={t(\'账户菜单\')}');
expect(source).toContain('role="menuitemradio"');
expect(source).toContain('role="menuitem"');
expect(source).toContain('dialogId={searchDialogId}');
expect(source).toContain("type ThemePreset = 'clearops' | 'terminal' | 'studio';");
expect(source).toContain('const THEME_PRESETS: Array<{ value: ThemePreset; label: string; description: string }> = [');
expect(source).toContain("const [themePreset, setThemePreset] = useState<ThemePreset>(() => resolveStoredThemePreset());");
expect(source).toContain("document.documentElement.setAttribute('data-theme-preset', themePreset);");
expect(source).toContain("document.documentElement.setAttribute('data-theme-tone', resolvedThemeTone);");
expect(source).toContain('className={`theme-preset-swatch theme-preset-swatch-${preset.value}`}');
});
it('keeps closing dropdown actions out of the tab order and closes menus on Escape', () => {
const source = readAppSource();
expect(source).toContain('tabIndex={themeMenuPresence.isVisible ? 0 : -1}');
expect(source).toContain('tabIndex={userMenuPresence.isVisible ? 0 : -1}');
expect(source).toContain('aria-hidden={!themeMenuPresence.isVisible}');
expect(source).toContain('aria-hidden={!userMenuPresence.isVisible}');
expect(source).toContain('if (!showThemeMenu && !showUserMenu) return undefined;');
expect(source).toContain("if (e.key !== 'Escape') return;");
expect(source).toContain('setShowThemeMenu(false);');
expect(source).toContain('setShowUserMenu(false);');
expect(source).toContain('setShowNotifications(false);');
expect(source).toContain('setShowSearch(false);');
expect(source).toContain('triggerRef.current?.focus({ preventScroll: true });');
expect(source).toContain('themeTriggerRef.current?.focus({ preventScroll: true });');
expect(source).toContain('if (e.defaultPrevented) return;');
expect(source).toContain("e.key.toLowerCase() === 'k'");
expect(source).toContain("document.addEventListener('keydown', handler);");
expect(source).toContain("return () => document.removeEventListener('keydown', handler);");
});
it('supports keyboard opening and roving focus inside topbar menus', () => {
const source = readAppSource();
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain("type MenuFocusEdge = 'first' | 'last';");
expect(source).toContain("type PendingMenuFocus = { menu: 'theme' | 'user'; edge: MenuFocusEdge } | null;");
expect(source).toContain('const [pendingMenuFocus, setPendingMenuFocus] = useState<PendingMenuFocus>(null);');
expect(source).toContain('const focusMenuItem = (menuRef: React.RefObject<HTMLDivElement>, edge: MenuFocusEdge = \'first\') => {');
expect(source).toContain('target?.focus({ preventScroll: true });');
expect(source).toContain('if (!pendingMenuFocus) return;');
expect(source).toContain('const menuRef = pendingMenuFocus.menu === \'theme\' ? themeMenuRef : userMenuRef;');
expect(source).toContain('setPendingMenuFocus(null);');
expect(source).toContain('const handleTopbarMenuTriggerKeyDown = (');
expect(source).toContain("if (!['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) return;");
expect(source).toContain("const focusEdge: MenuFocusEdge = e.key === 'ArrowUp' ? 'last' : 'first';");
expect(source).toContain("setPendingMenuFocus({ menu: 'theme', edge: focusEdge });");
expect(source).toContain("setPendingMenuFocus({ menu: 'user', edge: focusEdge });");
expect(source).toContain('const handleTopbarMenuKeyDown = (');
expect(source).toContain("if (e.key === 'Escape') {\n e.preventDefault();\n setShowThemeMenu(false);");
expect(source).toContain("if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {");
expect(source).toContain('items[nextIndex]?.focus({ preventScroll: true });');
expect(source).toContain("if (e.key === 'Home') {");
expect(source).toContain('items[0]?.focus({ preventScroll: true });');
expect(source).toContain("if (e.key === 'End') {");
expect(source).toContain('items[items.length - 1]?.focus({ preventScroll: true });');
expect(source).toContain('target?.focus({ preventScroll: true });');
expect(source).toContain("onKeyDown={(e) => handleTopbarMenuTriggerKeyDown(e, 'theme')}");
expect(source).toContain("onKeyDown={(e) => handleTopbarMenuTriggerKeyDown(e, 'user')}");
expect(source).toContain('onKeyDown={handleNotificationTriggerKeyDown}');
expect(source).toContain("onKeyDown={(e) => handleTopbarMenuKeyDown(e, 'theme')}");
expect(source).toContain("onKeyDown={(e) => handleTopbarMenuKeyDown(e, 'user')}");
expect(css).toContain('.user-dropdown-item:focus-visible');
expect(css).toContain('[data-theme="dark"] .user-dropdown-item:focus-visible');
});
it('keeps the desktop topbar grid shrink-safe before the mobile breakpoint', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
const finalTopbarStart = css.indexOf('/* Final topbar layout guard: keep brand, nav, and user actions in their own lanes. */');
const finalTopbarEnd = css.indexOf('@media (max-width: 768px)', finalTopbarStart);
const finalTopbarCss = css.slice(finalTopbarStart, finalTopbarEnd);
expect(finalTopbarStart).toBeGreaterThanOrEqual(0);
expect(finalTopbarEnd).toBeGreaterThan(finalTopbarStart);
expect(finalTopbarCss).toContain('grid-template-columns: minmax(min(230px, 100%), 280px) auto minmax(min(300px, 100%), 1fr);');
expect(finalTopbarCss).toContain('grid-template-columns: minmax(min(170px, 100%), 220px) auto minmax(min(220px, 100%), 1fr);');
expect(finalTopbarCss).toContain('grid-template-columns: minmax(min(150px, 100%), 190px) auto minmax(min(150px, 100%), 1fr);');
expect(finalTopbarCss).toContain('width: max-content;');
expect(finalTopbarCss).toContain('max-width: min(430px, 100%);');
expect(finalTopbarCss).toContain('overflow-x: auto;');
expect(finalTopbarCss).toContain('.topbar-nav::-webkit-scrollbar {\n display: none;\n}');
expect(finalTopbarCss).not.toContain('width: min(520px, 100%);');
expect(finalTopbarCss).not.toContain('grid-template-columns: minmax(230px, 280px) minmax(320px, max-content) minmax(300px, 1fr);');
expect(finalTopbarCss).not.toContain('grid-template-columns: minmax(170px, 220px) minmax(280px, max-content) minmax(220px, 1fr);');
expect(finalTopbarCss).not.toContain('grid-template-columns: minmax(150px, 190px) minmax(240px, max-content) minmax(150px, 1fr);');
});
});
+685
View File
@@ -0,0 +1,685 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import App from './App.js';
const { apiMock, authSessionMock } = vi.hoisted(() => ({
apiMock: {
getEvents: vi.fn(),
},
authSessionMock: {
hasValidAuthSession: vi.fn(),
persistAuthSession: vi.fn(),
clearAuthSession: vi.fn(),
getSessionToken: vi.fn(),
commitSession: vi.fn(),
clearSession: vi.fn(),
},
}));
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: unknown) => node,
};
});
vi.mock('./api.js', () => ({
api: apiMock,
}));
vi.mock('./authSession.js', () => ({
hasValidAuthSession: authSessionMock.hasValidAuthSession,
persistAuthSession: authSessionMock.persistAuthSession,
clearAuthSession: authSessionMock.clearAuthSession,
getSessionToken: authSessionMock.getSessionToken,
commitSession: authSessionMock.commitSession,
clearSession: authSessionMock.clearSession,
}));
vi.mock('./components/SearchModal.js', () => ({
default: ({ open, dialogId }: { open: boolean; dialogId?: string }) => (
open ? <div id={dialogId} role="dialog" aria-modal="true" aria-label="搜索" /> : null
),
}));
vi.mock('./components/NotificationPanel.js', () => ({
default: () => null,
}));
vi.mock('./components/TooltipLayer.js', () => ({
default: () => null,
}));
vi.mock('./components/useAnimatedVisibility.js', () => ({
useAnimatedVisibility: (open: boolean) => ({
shouldRender: open,
isVisible: open,
}),
}));
vi.mock('./i18n.js', () => ({
I18nProvider: ({ children }: { children: ReactNode }) => children,
useI18n: () => ({
language: 'zh',
toggleLanguage: vi.fn(),
t: (text: string) => text,
}),
}));
vi.mock('./pages/Dashboard.js', () => ({
default: ({ adminName }: { adminName?: string }) => <div>{adminName || 'Dashboard'}</div>,
}));
function createLocalStorage() {
const store = new Map<string, string>([
['theme_mode', 'light'],
['theme_preset', 'clearops'],
['metapi.firstUseDocReminder', '1'],
['admin_profile', JSON.stringify({
name: '管理员',
avatarSeed: 'seed-1',
avatarStyle: 'identicon',
})],
]);
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
dump: () => new Map(store),
};
}
function setupRuntime() {
const matchMedia = (query: string) => ({
matches: query.includes('prefers-color-scheme') ? false : false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
onchange: null,
});
const storage = createLocalStorage();
const documentElementAttributes = new Map<string, string>();
vi.stubGlobal('localStorage', storage);
vi.stubGlobal('window', {
innerWidth: 1280,
matchMedia,
requestAnimationFrame: (callback: FrameRequestCallback) => {
callback(0);
return 1;
},
cancelAnimationFrame: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('document', {
body: { style: {} },
activeElement: null,
documentElement: {
setAttribute: vi.fn((name: string, value: string) => {
documentElementAttributes.set(name, value);
}),
getAttribute: vi.fn((name: string) => documentElementAttributes.get(name) ?? null),
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
return { documentElementAttributes, storage };
}
function keyEvent(key: string) {
return {
key,
preventDefault: vi.fn(),
};
}
function documentKeyEvent(overrides: Partial<KeyboardEvent> & { key: string }) {
return {
ctrlKey: false,
defaultPrevented: false,
metaKey: false,
preventDefault: vi.fn(),
...overrides,
};
}
function latestDocumentKeydownListener(): EventListener {
const calls = vi.mocked(document.addEventListener).mock.calls;
const listeners = calls
.filter(([type]) => type === 'keydown')
.map(([, listener]) => listener as EventListener);
const listener = listeners.at(-1);
if (!listener) throw new Error('No document keydown listener registered');
return listener;
}
function findButton(root: ReactTestInstance, matcher: (ariaLabel: string) => boolean): ReactTestInstance {
return root.find((node) => (
node.type === 'button'
&& typeof node.props['aria-label'] === 'string'
&& matcher(node.props['aria-label'])
));
}
function findMenus(root: ReactTestInstance, label: string): ReactTestInstance[] {
return root.findAll((node) => node.props.role === 'menu' && node.props['aria-label'] === label);
}
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
describe('App topbar menu behavior', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
apiMock.getEvents.mockResolvedValue([]);
authSessionMock.hasValidAuthSession.mockReturnValue(true);
authSessionMock.getSessionToken.mockImplementation((audience: string) =>
audience === 'admin' ? 'fake-admin-token' : null,
);
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('opens the theme menu from the keyboard trigger', async () => {
setupRuntime();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
);
});
await flushMicrotasks();
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
const event = keyEvent('ArrowDown');
await act(async () => {
themeTrigger.props.onKeyDown(event);
});
await flushMicrotasks();
expect(event.preventDefault).toHaveBeenCalledTimes(1);
expect(findButton(root.root, (label) => label.includes('浅色模式')).props['aria-expanded']).toBe(true);
expect(findMenus(root.root, '主题菜单')).toHaveLength(1);
expect(findMenus(root.root, '账户菜单')).toHaveLength(0);
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('moves focus across theme menu items without scrolling the page', async () => {
setupRuntime();
const menuItems: Array<{ label: string; focus: ReturnType<typeof vi.fn> }> = [
{ label: '跟随系统', focus: vi.fn() },
{ label: '浅色模式', focus: vi.fn() },
{ label: '深色模式', focus: vi.fn() },
{ label: '清透运维', focus: vi.fn() },
{ label: '深色终端', focus: vi.fn() },
{ label: '云端实验室', focus: vi.fn() },
];
let activeElement: unknown = menuItems[0];
Object.defineProperty(document, 'activeElement', {
configurable: true,
get: () => activeElement,
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
{
createNodeMock: (element) => {
const children = Array.isArray(element.props.children)
? element.props.children
: [element.props.children];
const isThemeMenuWrapper = (
element.type === 'div'
&& element.props.style?.position === 'relative'
&& children.some((child: any) => (
child?.type === 'button'
&& child.props?.['aria-haspopup'] === 'menu'
&& String(child.props?.['aria-label'] || '').includes('浅色模式')
))
);
if (isThemeMenuWrapper) {
return {
contains: () => true,
querySelectorAll: () => menuItems,
};
}
return null;
},
},
);
});
await flushMicrotasks();
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
const openEvent = keyEvent('ArrowDown');
await act(async () => {
themeTrigger.props.onKeyDown(openEvent);
});
await flushMicrotasks();
expect(openEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(menuItems[0]!.focus).toHaveBeenCalledWith({ preventScroll: true });
const menu = findMenus(root.root, '主题菜单')[0]!;
const arrowDownEvent = keyEvent('ArrowDown');
await act(async () => {
menu.props.onKeyDown(arrowDownEvent);
});
expect(arrowDownEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(menuItems[1]!.focus).toHaveBeenCalledWith({ preventScroll: true });
activeElement = menuItems[1];
const endEvent = keyEvent('End');
await act(async () => {
menu.props.onKeyDown(endEvent);
});
expect(endEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(menuItems[5]!.focus).toHaveBeenCalledWith({ preventScroll: true });
activeElement = menuItems[2];
const homeEvent = keyEvent('Home');
await act(async () => {
menu.props.onKeyDown(homeEvent);
});
expect(homeEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(menuItems[0]!.focus).toHaveBeenCalledTimes(2);
expect(menuItems[0]!.focus).toHaveBeenLastCalledWith({ preventScroll: true });
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('returns focus to the theme trigger after selecting a theme', async () => {
setupRuntime();
const themeFocus = vi.fn();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
{
createNodeMock: (element) => (
element.type === 'button'
&& String(element.props.className || '').includes('topbar-icon-btn')
&& element.props['aria-haspopup'] === 'menu'
? { focus: themeFocus }
: null
),
},
);
});
await flushMicrotasks();
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
await act(async () => {
themeTrigger.props.onKeyDown(keyEvent('ArrowDown'));
});
await flushMicrotasks();
const darkModeItem = root.root.find((node) => (
node.type === 'button'
&& node.props.role === 'menuitemradio'
&& collectText(node) === '深色模式'
));
await act(async () => {
darkModeItem.props.onClick();
});
await flushMicrotasks();
expect(themeFocus).toHaveBeenCalledWith({ preventScroll: true });
expect(findMenus(root.root, '主题菜单')).toHaveLength(0);
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('persists named theme presets and exposes root theme attributes', async () => {
const runtime = setupRuntime();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
);
});
await flushMicrotasks();
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
await act(async () => {
themeTrigger.props.onClick();
});
await flushMicrotasks();
const terminalPresetItem = root.root.find((node) => (
node.type === 'button'
&& node.props.role === 'menuitemradio'
&& collectText(node).includes('深色终端')
));
await act(async () => {
terminalPresetItem.props.onClick();
});
await flushMicrotasks();
expect(runtime.storage.getItem('theme_preset')).toBe('terminal');
expect(runtime.documentElementAttributes.get('data-theme-preset')).toBe('terminal');
expect(runtime.documentElementAttributes.get('data-theme-tone')).toBe('dark');
expect(findMenus(root.root, '主题菜单')).toHaveLength(0);
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('opens the notification panel from the keyboard trigger as a controlled dialog', async () => {
setupRuntime();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
);
});
await flushMicrotasks();
const notificationTrigger = findButton(root.root, (label) => label === '通知');
expect(notificationTrigger.props['aria-haspopup']).toBe('dialog');
expect(notificationTrigger.props['aria-expanded']).toBe(false);
expect(notificationTrigger.props['aria-controls']).toBeUndefined();
const openEvent = keyEvent('ArrowDown');
await act(async () => {
notificationTrigger.props.onKeyDown(openEvent);
});
await flushMicrotasks();
const openedNotificationTrigger = findButton(root.root, (label) => label === '通知');
expect(openEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(openedNotificationTrigger.props['aria-expanded']).toBe(true);
expect(typeof openedNotificationTrigger.props['aria-controls']).toBe('string');
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
await act(async () => {
themeTrigger.props.onClick();
});
await flushMicrotasks();
expect(findButton(root.root, (label) => label === '通知').props['aria-expanded']).toBe(false);
expect(findButton(root.root, (label) => label.includes('浅色模式')).props['aria-expanded']).toBe(true);
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('opens search as a controlled dialog trigger and closes other topbar surfaces', async () => {
setupRuntime();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
);
});
await flushMicrotasks();
const notificationTrigger = findButton(root.root, (label) => label === '通知');
await act(async () => {
notificationTrigger.props.onKeyDown(keyEvent('ArrowDown'));
});
await flushMicrotasks();
expect(findButton(root.root, (label) => label === '通知').props['aria-expanded']).toBe(true);
const searchTrigger = findButton(root.root, (label) => label === '搜索 (Ctrl+K)');
expect(searchTrigger.props['aria-haspopup']).toBe('dialog');
expect(searchTrigger.props['aria-expanded']).toBe(false);
expect(searchTrigger.props['aria-controls']).toBeUndefined();
await act(async () => {
searchTrigger.props.onClick();
});
await flushMicrotasks();
const openedSearchTrigger = findButton(root.root, (label) => label === '搜索 (Ctrl+K)');
expect(openedSearchTrigger.props['aria-expanded']).toBe(true);
expect(typeof openedSearchTrigger.props['aria-controls']).toBe('string');
const searchDialog = root.root.find((node) => (
node.props.role === 'dialog'
&& node.props.id === openedSearchTrigger.props['aria-controls']
));
expect(searchDialog.props['aria-modal']).toBe('true');
expect(findButton(root.root, (label) => label === '通知').props['aria-expanded']).toBe(false);
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
await act(async () => {
themeTrigger.props.onClick();
});
await flushMicrotasks();
expect(findButton(root.root, (label) => label === '搜索 (Ctrl+K)').props['aria-expanded']).toBe(false);
expect(findButton(root.root, (label) => label.includes('浅色模式')).props['aria-expanded']).toBe(true);
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('opens search from Ctrl or Command K without stealing handled shortcuts', async () => {
setupRuntime();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
);
});
await flushMicrotasks();
const keydown = latestDocumentKeydownListener();
const ignoredEvent = documentKeyEvent({ ctrlKey: true, defaultPrevented: true, key: 'k' });
await act(async () => {
keydown(ignoredEvent as unknown as Event);
});
await flushMicrotasks();
expect(ignoredEvent.preventDefault).not.toHaveBeenCalled();
expect(findButton(root.root, (label) => label === '搜索 (Ctrl+K)').props['aria-expanded']).toBe(false);
const ctrlEvent = documentKeyEvent({ ctrlKey: true, key: 'K' });
await act(async () => {
keydown(ctrlEvent as unknown as Event);
});
await flushMicrotasks();
expect(ctrlEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(findButton(root.root, (label) => label === '搜索 (Ctrl+K)').props['aria-expanded']).toBe(true);
const themeTrigger = findButton(root.root, (label) => label.includes('浅色模式'));
await act(async () => {
themeTrigger.props.onClick();
});
await flushMicrotasks();
expect(findButton(root.root, (label) => label === '搜索 (Ctrl+K)').props['aria-expanded']).toBe(false);
const metaEvent = documentKeyEvent({ key: 'k', metaKey: true });
await act(async () => {
keydown(metaEvent as unknown as Event);
});
await flushMicrotasks();
expect(metaEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(findButton(root.root, (label) => label === '搜索 (Ctrl+K)').props['aria-expanded']).toBe(true);
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('renders skip-to-main and route status landmarks in the app shell', async () => {
setupRuntime();
let root!: WebTestRenderer;
const mainFocus = vi.fn();
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
{
createNodeMock: (element) => (
element.type === 'main'
? { focus: mainFocus }
: null
),
},
);
});
await flushMicrotasks();
const skipLink = root.root.find((node) => (
node.type === 'a'
&& node.props.className === 'skip-link'
));
expect(skipLink.props.href).toBe('#main-content');
await act(async () => {
skipLink.props.onClick();
});
expect(mainFocus).toHaveBeenCalledWith({ preventScroll: true });
const main = root.root.find((node) => node.type === 'main');
expect(main.props.id).toBe('main-content');
expect(main.props.tabIndex).toBe(-1);
expect(main.props['aria-label']).toBe('主内容');
const liveRegion = root.root.find((node) => (
node.props.className === 'sr-only'
&& node.props['aria-live'] === 'polite'
));
expect(liveRegion.props['aria-atomic']).toBe('true');
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('opens the account menu from the keyboard trigger and closes it with Escape', async () => {
setupRuntime();
const accountFocus = vi.fn();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
{
createNodeMock: (element) => (
element.type === 'button'
&& String(element.props.className || '').includes('topbar-user-trigger')
? { focus: accountFocus }
: null
),
},
);
});
await flushMicrotasks();
const accountTrigger = findButton(root.root, (label) => label.startsWith('账户菜单:'));
const openEvent = keyEvent('ArrowUp');
await act(async () => {
accountTrigger.props.onKeyDown(openEvent);
});
await flushMicrotasks();
expect(openEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(findButton(root.root, (label) => label.startsWith('账户菜单:')).props['aria-expanded']).toBe(true);
const menus = findMenus(root.root, '账户菜单');
expect(menus).toHaveLength(1);
expect(findMenus(root.root, '主题菜单')).toHaveLength(0);
const closeEvent = keyEvent('Escape');
await act(async () => {
menus[0]!.props.onKeyDown(closeEvent);
});
await flushMicrotasks();
expect(closeEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(accountFocus).toHaveBeenCalledWith({ preventScroll: true });
expect(findButton(root.root, (label) => label.startsWith('账户菜单:')).props['aria-expanded']).toBe(false);
expect(findMenus(root.root, '账户菜单')).toHaveLength(0);
} finally {
await act(async () => {
root?.unmount();
});
}
});
});
+6 -2
View File
@@ -3,16 +3,20 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
describe('App topbar tooltips', () => { describe('App topbar tooltips', () => {
it('removes topbar hover tooltips while preserving sidebar collapsed tooltips', () => { it('removes hover-only tooltips from topbar and fixed desktop sidebar navigation', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8'); const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const topbarStart = source.indexOf('<div className="topbar-right">'); const topbarStart = source.indexOf('<div className="topbar-right">');
const topbarEnd = source.indexOf('</header>'); const topbarEnd = source.indexOf('</header>');
const sidebarItemStart = source.indexOf('const renderDesktopSidebarItem');
const sidebarItemEnd = source.indexOf('return (', source.indexOf('<a'));
expect(topbarStart).toBeGreaterThanOrEqual(0); expect(topbarStart).toBeGreaterThanOrEqual(0);
expect(topbarEnd).toBeGreaterThan(topbarStart); expect(topbarEnd).toBeGreaterThan(topbarStart);
const topbarSection = source.slice(topbarStart, topbarEnd); const topbarSection = source.slice(topbarStart, topbarEnd);
expect(topbarSection).not.toContain('data-tooltip='); expect(topbarSection).not.toContain('data-tooltip=');
expect(source).toContain("data-tooltip={sidebarCollapsed ? t(item.label) : undefined}"); expect(source).not.toContain('data-tooltip={sidebarCollapsed');
expect(sidebarItemStart).toBeGreaterThanOrEqual(0);
expect(sidebarItemEnd).toBeGreaterThan(sidebarItemStart);
}); });
}); });
+572 -124
View File
File diff suppressed because it is too large Load Diff
+385 -2
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { api, type ProxyTestRequestEnvelope } from './api.js'; import { api, buildSiteProbeStreamUrl, userApi, type ProxyTestRequestEnvelope } from './api.js';
import { persistAuthSession } from './authSession.js'; import { commitSession, persistAuthSession } from './authSession.js';
function createMemoryStorage() { function createMemoryStorage() {
const store = new Map<string, string>(); const store = new Map<string, string>();
@@ -122,6 +122,20 @@ describe('api proxy test timeout handling', () => {
expect(result.error.message).toBe('请求超时(120s)'); expect(result.error.message).toBe('请求超时(120s)');
}); });
it('builds site probe stream URLs with frontend camelCase query names', () => {
expect(buildSiteProbeStreamUrl(9, {
scope: 'single',
modelName: ' gpt-4o ',
latencyThresholdMs: 1800.9,
})).toBe('/api/sites/9/probe-stream?scope=single&modelName=gpt-4o&latencyThresholdMs=1800');
expect(buildSiteProbeStreamUrl(9, {
scope: 'all',
modelName: 'gpt-4o',
latencyThresholdMs: 0,
})).toBe('/api/sites/9/probe-stream?scope=all');
});
it('times out replay hydration file-content fetches after 30 seconds', async () => { it('times out replay hydration file-content fetches after 30 seconds', async () => {
installPendingFetch(); installPendingFetch();
@@ -179,3 +193,372 @@ describe('api proxy test timeout handling', () => {
expect(api.proxyTestStream).toBe(api.testProxyStream); expect(api.proxyTestStream).toBe(api.testProxyStream);
}); });
}); });
describe('api oauth compatibility', () => {
beforeEach(() => {
vi.useRealTimers();
vi.stubGlobal('localStorage', createMemoryStorage());
persistAuthSession(globalThis.localStorage as Storage, 'token-1');
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('normalizes oauth provider responses before the page renders selectors', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
providers: [
{
id: 'codex',
name: 'Codex',
type: 'codex',
enabled: true,
},
{
provider: 'undefined',
label: 'undefined',
platform: 'undefined',
enabled: true,
},
{
value: 'gemini-cli',
displayName: 'Gemini CLI',
requires_project_id: true,
},
],
defaults: {
systemProxyConfigured: true,
},
}), { status: 200, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
const result = await api.getOAuthProviders();
expect(fetchMock).toHaveBeenCalledWith('/api/oauth/providers', expect.any(Object));
expect(result.defaults?.systemProxyConfigured).toBe(true);
expect(result.providers).toEqual([
{
provider: 'codex',
label: 'Codex',
platform: 'codex',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
{
provider: 'gemini-cli',
label: 'Gemini CLI',
platform: 'gemini-cli',
enabled: true,
loginType: 'oauth',
requiresProjectId: true,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
]);
});
it('normalizes legacy oauth connection list responses into the page contract', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
data: [
{
id: 7,
site_id: 2,
username: 'codex-user',
oauthProvider: 'codex',
oauthAccountKey: 'chatgpt-account-123',
oauthProjectId: 'project-a',
models_preview: '["gpt-5.4","gpt-5.2-codex"]',
expires_at: '2030-01-01T00:00:00+00:00',
token_expires_at: 1893456000000,
status: 'active',
},
],
total: 1,
}), { status: 200, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
const result = await api.getOAuthConnections({ limit: 25, offset: 5 });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/oauth/connections?limit=25&offset=5');
expect(result.items).toEqual([
expect.objectContaining({
accountId: 7,
siteId: 2,
username: 'codex-user',
provider: 'codex',
accountKey: 'chatgpt-account-123',
projectId: 'project-a',
modelCount: 2,
modelsPreview: ['gpt-5.4', 'gpt-5.2-codex'],
expiresAt: '2030-01-01T00:00:00+00:00',
tokenExpiresAt: 1893456000000,
status: 'healthy',
}),
]);
expect(result.total).toBe(1);
expect(result.offset).toBe(0);
});
it('uses the Rust oauth route paths for quota and proxy mutations', async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
})));
vi.stubGlobal('fetch', fetchMock);
await api.refreshOAuthConnectionQuota(7);
await api.refreshOAuthConnectionQuotaBatch([7, 8]);
await api.updateOAuthConnectionProxy(7, { proxyUrl: 'http://127.0.0.1:7890', useSystemProxy: false });
expect(fetchMock.mock.calls.map((call) => [call[0], (call[1] as RequestInit | undefined)?.method])).toEqual([
['/api/oauth/connections/7/refresh-quota', 'POST'],
['/api/oauth/connections/refresh-quota-batch', 'POST'],
['/api/oauth/connections/7/proxy', 'POST'],
]);
});
it('uses the Rust oauth route paths for connection import, deletion, and route pools', async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
})));
vi.stubGlobal('fetch', fetchMock);
await api.deleteOAuthConnection(7);
await api.importOAuthConnections({ type: 'codex', access_token: 'token-a' });
await api.importOAuthConnections({ items: [{ type: 'claude', access_token: 'token-b' }] });
await api.createOAuthRouteUnit({
accountIds: [7, 8],
name: 'OAuth Pool',
strategy: 'round_robin',
});
await api.updateOAuthRouteUnit(42, {
name: 'OAuth Pool 2',
strategy: 'stick_until_unavailable',
enabled: true,
});
await api.deleteOAuthRouteUnit(42);
expect(fetchMock.mock.calls.map((call) => [call[0], (call[1] as RequestInit | undefined)?.method])).toEqual([
['/api/oauth/connections/7', 'DELETE'],
['/api/oauth/import', 'POST'],
['/api/oauth/import', 'POST'],
['/api/oauth/route-units', 'POST'],
['/api/oauth/route-units/42', 'PUT'],
['/api/oauth/route-units/42', 'DELETE'],
]);
const singleImportInit = fetchMock.mock.calls[1]?.[1] as RequestInit | undefined;
const batchImportInit = fetchMock.mock.calls[2]?.[1] as RequestInit | undefined;
expect(singleImportInit?.body).toBe(JSON.stringify({ data: { type: 'codex', access_token: 'token-a' } }));
expect(batchImportInit?.body).toBe(JSON.stringify({ items: [{ type: 'claude', access_token: 'token-b' }] }));
});
});
describe('api workflow model folder compatibility', () => {
beforeEach(() => {
vi.useRealTimers();
vi.stubGlobal('localStorage', createMemoryStorage());
persistAuthSession(globalThis.localStorage as Storage, 'token-1');
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('normalizes model folder responses from items, names, data, and value fields', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
success: true,
items: [
{ name: 'wan2.1-t2v', pathIndex: 0 },
{ value: 'seedance-1-0-lite-t2v-250428' },
],
names: ['wan2.1-t2v', 'seedance-1-0-pro-t2v-250528'],
data: [{ filename: 'kling-video' }],
}), { status: 200, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
const payload = await api.getWorkflowModelFolder('video_models');
expect(fetchMock.mock.calls[0]?.[0]).toBe('/workflow-ui/api/experiment/models/video_models');
expect((fetchMock.mock.calls[0]?.[1] as RequestInit)?.headers).toBeInstanceOf(Headers);
expect(((fetchMock.mock.calls[0]?.[1] as RequestInit)?.headers as Headers).get('Authorization')).toBe('Bearer token-1');
expect(payload.names).toEqual([
'wan2.1-t2v',
'seedance-1-0-lite-t2v-250428',
'kling-video',
'seedance-1-0-pro-t2v-250528',
]);
expect(payload.items.map((item) => item.name)).toEqual(payload.names);
});
});
describe('api proxy log filters', () => {
beforeEach(() => {
vi.useRealTimers();
vi.stubGlobal('localStorage', createMemoryStorage());
persistAuthSession(globalThis.localStorage as Storage, 'token-1');
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('serializes downstream key filters for server-driven proxy logs', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
items: [],
total: 0,
summary: { total: 0, success: 0, failed: 0 },
}), { status: 200, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
await api.getProxyLogsQuery({
limit: 25,
offset: 50,
status: 'failed',
downstreamKeyId: 17,
search: 'gpt-4o',
});
const url = String(fetchMock.mock.calls[0]?.[0] || '');
expect(url).toContain('/api/stats/proxy-logs?');
expect(url).toContain('downstreamKeyId=17');
expect(url).toContain('status=failed');
expect(url).toContain('search=gpt-4o');
expect(url).toContain('view=query');
});
it('parses proxy log billing details from snake_case JSON strings', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
items: [
{
id: 42,
model_requested: 'seedance-comfy',
status: 'success',
estimated_cost: 0.35,
billing_details: JSON.stringify({
source: 'comfy-native-task',
totalCost: 0.35,
steps: [{ name: 'archive_outputs', cost: 0.35 }],
}),
},
],
total: 1,
summary: { total: 1, success: 1, failed: 0, cost: 0.35 },
}), { status: 200, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
const response = await api.getProxyLogsQuery({ limit: 1 });
expect(response.items[0]?.billingDetails).toMatchObject({
source: 'comfy-native-task',
totalCost: 0.35,
steps: [{ name: 'archive_outputs', cost: 0.35 }],
});
});
});
describe('user recharge api', () => {
beforeEach(() => {
vi.useRealTimers();
const storage = createMemoryStorage();
vi.stubGlobal('localStorage', storage);
commitSession('user', 'user-token', undefined, storage);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('creates recharge checkout records before returning the checkout url', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
success: true,
checkoutUrl: 'https://checkout.comfy.org/c/pay/session',
record: {
id: 1,
publicId: 'rch_test',
provider: 'comfy_checkout',
checkoutUrl: 'https://checkout.comfy.org/c/pay/session',
status: 'pending',
},
}), { status: 201, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
const payload = await userApi.createRechargeCheckout({
optionId: 'usd-50',
amountUsd: 50,
});
expect(payload.record.publicId).toBe('rch_test');
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/user/recharge/checkout');
expect((fetchMock.mock.calls[0]?.[1] as RequestInit)?.method).toBe('POST');
expect((fetchMock.mock.calls[0]?.[1] as RequestInit)?.body).toBe(JSON.stringify({
optionId: 'usd-50',
amountUsd: 50,
}));
});
it('can cancel and clear user recharge records', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
success: true,
record: {
id: 1,
publicId: 'rch_test',
provider: 'comfy_checkout',
checkoutUrl: 'https://checkout.comfy.org/c/pay/session',
status: 'cancelled',
},
}), { status: 200, headers: { 'content-type': 'application/json' } }))
.mockResolvedValueOnce(new Response(JSON.stringify({
success: true,
cleared: 2,
records: [],
items: [],
}), { status: 200, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
const cancelled = await userApi.cancelRechargeRecord('rch_test');
const cleared = await userApi.clearRechargeRecords();
expect(cancelled.record.status).toBe('cancelled');
expect(cleared.cleared).toBe(2);
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/user/recharge-records/rch_test/cancel');
expect((fetchMock.mock.calls[0]?.[1] as RequestInit)?.method).toBe('POST');
expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/user/recharge-records');
expect((fetchMock.mock.calls[1]?.[1] as RequestInit)?.method).toBe('DELETE');
});
it('keeps billing guidance on quota errors', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
error: { message: '下游密钥成本额度已用完' },
retry_after_secs: 60,
billing: {
reason: 'insufficient_quota',
rechargePath: '/user/recharge',
usagePath: '/user/usage',
},
}), { status: 429, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
await expect(userApi.createMoneyPrinterJob({ topic: 'demo' })).rejects.toMatchObject({
status: 429,
message: '下游密钥成本额度已用完',
billing: {
rechargePath: '/user/recharge',
usagePath: '/user/usage',
},
retryAfterSecs: 60,
});
});
});
+423 -21
View File
@@ -11,6 +11,22 @@ type RequestOptions = RequestInit & {
timeoutMs?: number; timeoutMs?: number;
}; };
export type ApiRequestError = Error & {
status?: number;
payload?: any;
billing?: any;
retryAfterSecs?: number | null;
};
function createApiRequestError(message: string, status: number, payload: any): ApiRequestError {
const error = new Error(message) as ApiRequestError;
error.status = status;
error.payload = payload;
error.billing = payload?.billing ?? payload?.error?.billing ?? null;
error.retryAfterSecs = payload?.retry_after_secs ?? payload?.retryAfterSecs ?? null;
return error;
}
function requireAuthToken(): string { function requireAuthToken(): string {
const token = getSessionToken('admin', localStorage); const token = getSessionToken('admin', localStorage);
if (!token) { if (!token) {
@@ -28,13 +44,15 @@ function requireAuthToken(): string {
return token; return token;
} }
async function extractResponseErrorMessage(res: Response): Promise<string> { async function extractResponseError(res: Response): Promise<ApiRequestError> {
let message = `HTTP ${res.status}`; let message = `HTTP ${res.status}`;
let payload: any = null;
try { try {
const text = await res.text(); const text = await res.text();
if (text) { if (text) {
try { try {
const json = JSON.parse(text); const json = JSON.parse(text);
payload = json;
if (json?.message && typeof json.message === "string") { if (json?.message && typeof json.message === "string") {
message = json.message; message = json.message;
} else if (json?.error && typeof json.error === "string") { } else if (json?.error && typeof json.error === "string") {
@@ -47,12 +65,19 @@ async function extractResponseErrorMessage(res: Response): Promise<string> {
} else { } else {
message = `${message}: ${text.slice(0, 120)}`; message = `${message}: ${text.slice(0, 120)}`;
} }
if (typeof json?.path === "string" && json.path.trim()) {
message = `${message} (${json.path.trim()})`;
}
} catch { } catch {
message = `${message}: ${text.slice(0, 120)}`; message = `${message}: ${text.slice(0, 120)}`;
} }
} }
} catch {} } catch {}
return message; return createApiRequestError(message, res.status, payload);
}
async function extractResponseErrorMessage(res: Response): Promise<string> {
return (await extractResponseError(res)).message;
} }
async function parseJsonResponse<T = any>(res: Response): Promise<T> { async function parseJsonResponse<T = any>(res: Response): Promise<T> {
@@ -248,13 +273,54 @@ export async function fetchSessionResponse(
: fetchAuthenticatedResponse(url, options); : fetchAuthenticatedResponse(url, options);
} }
export type SiteProbeOptions = {
scope?: "single" | "all";
modelName?: string;
latencyThresholdMs?: number;
};
export function buildSiteProbeStreamUrl(
siteId: number,
options: SiteProbeOptions = {},
): string {
const params = new URLSearchParams();
const scope = options.scope === "all" ? "all" : "single";
params.set("scope", scope);
const modelName = typeof options.modelName === "string"
? options.modelName.trim()
: "";
if (scope === "single" && modelName) {
params.set("modelName", modelName);
}
const latencyThresholdMs = Number(options.latencyThresholdMs);
if (Number.isFinite(latencyThresholdMs) && latencyThresholdMs > 0) {
params.set("latencyThresholdMs", String(Math.trunc(latencyThresholdMs)));
}
return `/api/sites/${siteId}/probe-stream?${params.toString()}`;
}
export function fetchSiteProbeStream(
siteId: number,
options: SiteProbeOptions = {},
signal?: AbortSignal,
): Promise<Response> {
return fetchSessionResponse("admin", buildSiteProbeStreamUrl(siteId, options), {
signal,
headers: { Accept: "text/event-stream" },
timeoutMs: options.scope === "all" ? 300_000 : 120_000,
});
}
async function userRequest<T = any>( async function userRequest<T = any>(
url: string, url: string,
options: RequestOptions = {}, options: RequestOptions = {},
): Promise<T> { ): Promise<T> {
const res = await fetchUserResponse(url, options); const res = await fetchUserResponse(url, options);
if (!res.ok) { if (!res.ok) {
throw new Error(await extractResponseErrorMessage(res)); throw await extractResponseError(res);
} }
return parseJsonResponse<T>(res); return parseJsonResponse<T>(res);
} }
@@ -265,7 +331,7 @@ async function request<T = any>(
): Promise<T> { ): Promise<T> {
const res = await fetchAuthenticatedResponse(url, options); const res = await fetchAuthenticatedResponse(url, options);
if (!res.ok) { if (!res.ok) {
throw new Error(await extractResponseErrorMessage(res)); throw await extractResponseError(res);
} }
return parseJsonResponse<T>(res); return parseJsonResponse<T>(res);
} }
@@ -299,6 +365,20 @@ function parseJsonArrayField(value: any): any[] {
} }
} }
function parseJsonObjectField(value: any): any {
if (!value) return null;
if (typeof value === "object") return value;
if (typeof value !== "string" || !value.trim()) return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: null;
} catch {
return null;
}
}
function normalizeRouteRow(row: any): any { function normalizeRouteRow(row: any): any {
if (!row || typeof row !== "object") return row; if (!row || typeof row !== "object") return row;
return { return {
@@ -392,6 +472,8 @@ function normalizeProxyLogRow(row: any): any {
clientAppId: row.clientAppId ?? row.client_app_id ?? null, clientAppId: row.clientAppId ?? row.client_app_id ?? null,
clientAppName: row.clientAppName ?? row.client_app_name ?? null, clientAppName: row.clientAppName ?? row.client_app_name ?? null,
clientConfidence: row.clientConfidence ?? row.client_confidence ?? null, clientConfidence: row.clientConfidence ?? row.client_confidence ?? null,
billingDetails:
row.billingDetails ?? parseJsonObjectField(row.billing_details),
contentSummary: row.contentSummary ?? row.content_summary ?? null, contentSummary: row.contentSummary ?? row.content_summary ?? null,
requestContent: row.requestContent ?? row.request_content ?? null, requestContent: row.requestContent ?? row.request_content ?? null,
responseContent: row.responseContent ?? row.response_content ?? null, responseContent: row.responseContent ?? row.response_content ?? null,
@@ -472,6 +554,144 @@ function normalizeProxyDebugTraceDetail(response: any): ProxyDebugTraceDetail {
}; };
} }
function normalizeOAuthConnectionRow(row: any): OAuthConnectionInfo | null {
if (!row || typeof row !== "object") return null;
const rawAccountId = row.accountId ?? row.account_id ?? row.id;
const accountId = Number(rawAccountId);
if (!Number.isFinite(accountId) || accountId <= 0) return null;
const rawSiteId = row.siteId ?? row.site_id ?? row.site?.id ?? 0;
const siteId = Number(rawSiteId);
const provider = String(row.provider ?? row.oauthProvider ?? row.oauth_provider ?? "").trim();
const modelsPreview = parseJsonArrayField(row.modelsPreview ?? row.models_preview)
.map((item) => String(item || "").trim())
.filter(Boolean);
const modelCount = Number(row.modelCount ?? row.model_count ?? modelsPreview.length);
const routeChannelCount = Number(row.routeChannelCount ?? row.route_channel_count);
const rawStatus = String(row.status ?? "").trim().toLowerCase();
const status: OAuthConnectionInfo["status"] = rawStatus === "healthy" || rawStatus === "active" || rawStatus === "enabled"
? "healthy"
: "abnormal";
const rawSite = row.site && typeof row.site === "object" ? row.site : null;
return {
...row,
accountId,
siteId: Number.isFinite(siteId) ? siteId : 0,
provider,
username: row.username ?? null,
email: row.email ?? null,
accountKey: row.accountKey ?? row.account_key ?? row.oauthAccountKey ?? row.oauth_account_key ?? null,
planType: row.planType ?? row.plan_type ?? null,
projectId: row.projectId ?? row.project_id ?? row.oauthProjectId ?? row.oauth_project_id ?? null,
modelCount: Number.isFinite(modelCount) ? modelCount : modelsPreview.length,
modelsPreview,
status,
quota: row.quota ?? null,
expiresAt: row.expiresAt ?? row.expires_at ?? null,
tokenExpiresAt: row.tokenExpiresAt ?? row.token_expires_at ?? null,
routeChannelCount: Number.isFinite(routeChannelCount) ? routeChannelCount : undefined,
lastModelSyncAt: row.lastModelSyncAt ?? row.last_model_sync_at ?? null,
lastModelSyncError: row.lastModelSyncError ?? row.last_model_sync_error ?? null,
proxyUrl: row.proxyUrl ?? row.proxy_url ?? null,
useSystemProxy: row.useSystemProxy ?? row.use_system_proxy ?? false,
routeUnit: row.routeUnit ?? row.route_unit ?? null,
routeParticipation: row.routeParticipation ?? row.route_participation ?? null,
site: rawSite
? {
id: Number(rawSite.id ?? rawSite.siteId ?? rawSite.site_id ?? siteId) || 0,
name: String(rawSite.name ?? rawSite.siteName ?? rawSite.site_name ?? "").trim(),
url: String(rawSite.url ?? rawSite.siteUrl ?? rawSite.site_url ?? "").trim(),
platform: String(rawSite.platform ?? provider).trim(),
}
: null,
};
}
function normalizeOAuthConnectionsResponse(response: any): OAuthConnectionsResponse {
const wrapped = wrapItemsResponse(response);
const items = unwrapArrayResponse(wrapped)
.map(normalizeOAuthConnectionRow)
.filter((item): item is OAuthConnectionInfo => item != null);
return {
...wrapped,
items,
total: Number(wrapped.total ?? items.length),
limit: Number(wrapped.limit ?? items.length),
offset: Number(wrapped.offset ?? 0),
};
}
function cleanOAuthProviderText(value: unknown): string {
if (typeof value !== "string") return "";
const text = value.trim();
const lowerText = text.toLowerCase();
if (lowerText === "undefined" || lowerText === "null") return "";
return text;
}
function resolveOAuthProviderFallbackLabel(providerKey: string): string {
const normalized = providerKey.trim().toLowerCase();
if (normalized === "codex") return "Codex";
if (normalized === "openai") return "OpenAI";
if (normalized === "anthropic") return "Anthropic";
if (normalized === "claude") return "Claude";
if (normalized === "google") return "Google AI";
if (normalized === "gemini-cli" || normalized === "gemini") return "Gemini CLI";
if (normalized === "antigravity") return "Antigravity";
return providerKey;
}
function normalizeOAuthProviderRow(row: any): OAuthProviderInfo | null {
if (!row || typeof row !== "object") return null;
const provider = cleanOAuthProviderText(row.provider)
|| cleanOAuthProviderText(row.oauthProvider)
|| cleanOAuthProviderText(row.oauth_provider)
|| cleanOAuthProviderText(row.id)
|| cleanOAuthProviderText(row.key)
|| cleanOAuthProviderText(row.value)
|| cleanOAuthProviderText(row.type);
if (!provider) return null;
const label = cleanOAuthProviderText(row.label)
|| cleanOAuthProviderText(row.name)
|| cleanOAuthProviderText(row.displayName)
|| cleanOAuthProviderText(row.display_name)
|| resolveOAuthProviderFallbackLabel(provider);
const platform = cleanOAuthProviderText(row.platform)
|| cleanOAuthProviderText(row.type)
|| provider;
return {
provider,
label,
platform,
enabled: row.enabled !== false,
loginType: "oauth",
requiresProjectId: row.requiresProjectId === true || row.requires_project_id === true,
supportsDirectAccountRouting: row.supportsDirectAccountRouting ?? row.supports_direct_account_routing ?? true,
supportsCloudValidation: row.supportsCloudValidation ?? row.supports_cloud_validation ?? true,
supportsNativeProxy: row.supportsNativeProxy ?? row.supports_native_proxy ?? true,
};
}
function normalizeOAuthProvidersResponse(response: any): OAuthProvidersResponse {
const rawProviders: unknown[] = Array.isArray(response?.providers) ? response.providers : [];
const providers = rawProviders
.map(normalizeOAuthProviderRow)
.filter((item): item is OAuthProviderInfo => item != null);
return {
...response,
providers: Array.from(
new Map(providers.map((provider) => [provider.provider, provider])).values(),
),
defaults: response?.defaults && typeof response.defaults === "object"
? response.defaults
: undefined,
};
}
async function streamSse( async function streamSse(
url: string, url: string,
handlers: { handlers: {
@@ -789,6 +1009,7 @@ export type ProxyLogListItem = {
promptTokens?: number | null; promptTokens?: number | null;
completionTokens?: number | null; completionTokens?: number | null;
estimatedCost?: number | null; estimatedCost?: number | null;
billingDetails?: ProxyLogBillingDetails;
contentSummary?: string | null; contentSummary?: string | null;
requestContent?: string | null; requestContent?: string | null;
responseContent?: string | null; responseContent?: string | null;
@@ -816,6 +1037,7 @@ export type ProxyLogsQuery = {
search?: string; search?: string;
client?: string; client?: string;
siteId?: number; siteId?: number;
downstreamKeyId?: number;
from?: string; from?: string;
to?: string; to?: string;
}; };
@@ -1002,6 +1224,8 @@ export type OAuthConnectionInfo = {
modelsPreview: string[]; modelsPreview: string[];
status: "healthy" | "abnormal"; status: "healthy" | "abnormal";
quota?: OAuthQuotaInfo | null; quota?: OAuthQuotaInfo | null;
expiresAt?: string | null;
tokenExpiresAt?: number | null;
routeChannelCount?: number; routeChannelCount?: number;
lastModelSyncAt?: string | null; lastModelSyncAt?: string | null;
lastModelSyncError?: string | null; lastModelSyncError?: string | null;
@@ -1034,11 +1258,12 @@ export type OAuthQuotaBatchRefreshResponse = {
export type OAuthImportResponse = { export type OAuthImportResponse = {
success: boolean; success: boolean;
imported: number; imported: number;
updated?: number;
skipped: number; skipped: number;
failed: number; failed: number;
items: Array<{ items: Array<{
name: string; name: string;
status: "imported" | "skipped" | "failed"; status: "imported" | "updated" | "skipped" | "failed";
accountId?: number; accountId?: number;
provider?: string; provider?: string;
message?: string; message?: string;
@@ -1192,6 +1417,22 @@ export type DigitalHumanJobsResponse = {
jobs: DigitalHumanJob[]; jobs: DigitalHumanJob[];
}; };
export type DigitalHumanStatusResponse = {
success: boolean;
mode: string;
keyName?: string;
capabilities?: string[];
runninghub?: {
configured?: boolean;
endpoint?: string;
};
comfy?: {
configured?: boolean;
backend?: string;
baseUrl?: string;
};
};
export type MoneyPrinterJob = DigitalHumanJob; export type MoneyPrinterJob = DigitalHumanJob;
export type MoneyPrinterJobResponse = { export type MoneyPrinterJobResponse = {
@@ -1219,6 +1460,7 @@ export type MoneyPrinterStatusResponse = {
label?: string; label?: string;
nodeType?: string; nodeType?: string;
model?: string; model?: string;
models?: string[];
duration?: { duration?: {
min?: number; min?: number;
max?: number; max?: number;
@@ -1229,6 +1471,139 @@ export type MoneyPrinterStatusResponse = {
}; };
}; };
export type UserRechargeRecord = {
id: number;
publicId: string;
userId?: number;
optionId?: string | null;
label?: string | null;
title?: string | null;
amountUsd?: number | null;
provider: string;
checkoutUrl: string;
status: string;
note?: string | null;
metadata?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
completedAt?: string | null;
};
export type UserBillingSummary = {
keyCount: number;
usedCost: number;
maxCost?: number | null;
remainingCost?: number | null;
unlimitedKeyCount: number;
rechargePath: string;
usagePath: string;
};
export type UserRechargeRecordsResponse = {
success: boolean;
records: UserRechargeRecord[];
items: UserRechargeRecord[];
billing?: UserBillingSummary;
arrivalNote?: string;
checkoutProvider?: string;
usagePath?: string;
rechargePath?: string;
};
export type CreateUserRechargeCheckoutPayload = {
optionId?: string;
amountUsd?: number;
};
export type CreateUserRechargeCheckoutResponse = {
success: boolean;
record: UserRechargeRecord;
checkoutUrl: string;
arrivalNote?: string;
checkoutProvider?: string;
billing?: UserBillingSummary;
usagePath?: string;
rechargePath?: string;
};
export type UserRechargeRecordActionResponse = {
success: boolean;
record: UserRechargeRecord;
billing?: UserBillingSummary;
arrivalNote?: string;
checkoutProvider?: string;
usagePath?: string;
rechargePath?: string;
};
export type ClearUserRechargeRecordsResponse = {
success: boolean;
cleared: number;
records: UserRechargeRecord[];
items: UserRechargeRecord[];
billing?: UserBillingSummary;
arrivalNote?: string;
checkoutProvider?: string;
usagePath?: string;
rechargePath?: string;
};
export type WorkflowModelItem = {
name: string;
pathIndex?: number;
path_index?: number;
[key: string]: unknown;
};
export type WorkflowModelFolderResponse = {
success: boolean;
items: WorkflowModelItem[];
names: string[];
};
function normalizeWorkflowModelFolderResponse(value: any): WorkflowModelFolderResponse {
const rawItems = Array.isArray(value)
? value
: ["items", "data", "models", "names"].flatMap((key) => (
Array.isArray(value?.[key]) ? value[key] : []
));
const seen = new Set<string>();
const items: WorkflowModelItem[] = [];
for (const item of rawItems) {
const name = String(
typeof item === "string"
? item
: item?.name ?? item?.model ?? item?.value ?? item?.filename ?? item?.path ?? "",
).trim();
if (!name || seen.has(name)) continue;
seen.add(name);
items.push({
...(item && typeof item === "object" && !Array.isArray(item) ? item : {}),
name,
});
}
return {
success: value?.success ?? true,
items,
names: items.map((item) => item.name),
};
}
async function workflowModelFolderRequest(
requester: <T = any>(url: string, options?: RequestOptions) => Promise<T>,
folder: string,
): Promise<WorkflowModelFolderResponse> {
const normalized = folder.trim();
if (!normalized) return { success: true, items: [], names: [] };
const value = await requester<any>(
`/workflow-ui/api/experiment/models/${encodeURIComponent(normalized)}`,
{ method: "GET" },
);
return normalizeWorkflowModelFolderResponse(value);
}
export const api = { export const api = {
// Sites // Sites
getSites: async () => { getSites: async () => {
@@ -1258,12 +1633,14 @@ export const api = {
}), }),
getSiteAvailableModels: (siteId: number) => getSiteAvailableModels: (siteId: number) =>
request(`/api/sites/${siteId}/available-models`), request(`/api/sites/${siteId}/available-models`),
probeSiteNow: (siteId: number, options?: { scope?: 'single' | 'all'; modelName?: string; latencyThresholdMs?: number }) => probeSiteNow: (siteId: number, options?: SiteProbeOptions) =>
request(`/api/sites/${siteId}/probe-now`, { request(`/api/sites/${siteId}/probe-now`, {
method: 'POST', method: 'POST',
body: JSON.stringify(options || {}), body: JSON.stringify(options || {}),
timeoutMs: options?.scope === 'all' ? 120_000 : 30_000, timeoutMs: options?.scope === 'all' ? 120_000 : 30_000,
}), }),
probeSiteStream: (siteId: number, options?: SiteProbeOptions, signal?: AbortSignal) =>
fetchSiteProbeStream(siteId, options, signal),
// Accounts // Accounts
getAccounts: async (params?: { includeOauth?: boolean }) => { getAccounts: async (params?: { includeOauth?: boolean }) => {
@@ -1643,7 +2020,8 @@ export const api = {
// OAuth // OAuth
getOAuthProviders: () => getOAuthProviders: () =>
request("/api/oauth/providers") as Promise<OAuthProvidersResponse>, request("/api/oauth/providers")
.then(normalizeOAuthProvidersResponse) as Promise<OAuthProvidersResponse>,
startOAuthProvider: ( startOAuthProvider: (
provider: string, provider: string,
data?: { data?: {
@@ -1672,14 +2050,14 @@ export const api = {
getOAuthConnections: (params?: { limit?: number; offset?: number }) => getOAuthConnections: (params?: { limit?: number; offset?: number }) =>
request( request(
`/api/oauth/connections${buildQueryString(params)}`, `/api/oauth/connections${buildQueryString(params)}`,
) as Promise<OAuthConnectionsResponse>, ).then(normalizeOAuthConnectionsResponse) as Promise<OAuthConnectionsResponse>,
refreshOAuthConnectionQuota: (accountId: number) => refreshOAuthConnectionQuota: (accountId: number) =>
request(`/api/oauth/connections/${accountId}/quota/refresh`, { request(`/api/oauth/connections/${accountId}/refresh-quota`, {
method: "POST", method: "POST",
body: JSON.stringify({}), body: JSON.stringify({}),
}) as Promise<{ success: true; quota: OAuthQuotaInfo }>, }) as Promise<{ success: true; quota: OAuthQuotaInfo }>,
refreshOAuthConnectionQuotaBatch: (accountIds: number[]) => refreshOAuthConnectionQuotaBatch: (accountIds: number[]) =>
request("/api/oauth/connections/quota/refresh-batch", { request("/api/oauth/connections/refresh-quota-batch", {
method: "POST", method: "POST",
body: JSON.stringify({ accountIds }), body: JSON.stringify({ accountIds }),
}) as Promise<OAuthQuotaBatchRefreshResponse>, }) as Promise<OAuthQuotaBatchRefreshResponse>,
@@ -1688,7 +2066,7 @@ export const api = {
data: { proxyUrl?: string | null; useSystemProxy?: boolean }, data: { proxyUrl?: string | null; useSystemProxy?: boolean },
) => ) =>
request(`/api/oauth/connections/${accountId}/proxy`, { request(`/api/oauth/connections/${accountId}/proxy`, {
method: "PATCH", method: "POST",
body: JSON.stringify(data || {}), body: JSON.stringify(data || {}),
}) as Promise<{ success: true }>, }) as Promise<{ success: true }>,
rebindOAuthConnection: ( rebindOAuthConnection: (
@@ -1717,6 +2095,18 @@ export const api = {
method: "POST", method: "POST",
body: JSON.stringify(data), body: JSON.stringify(data),
}) as Promise<OAuthRouteUnitMutationResponse>, }) as Promise<OAuthRouteUnitMutationResponse>,
updateOAuthRouteUnit: (
routeUnitId: number,
data: {
name?: string;
strategy?: OAuthRouteUnitStrategy;
enabled?: boolean;
},
) =>
request(`/api/oauth/route-units/${routeUnitId}`, {
method: "PUT",
body: JSON.stringify(data || {}),
}) as Promise<OAuthRouteUnitMutationResponse>,
deleteOAuthRouteUnit: (routeUnitId: number) => deleteOAuthRouteUnit: (routeUnitId: number) =>
request(`/api/oauth/route-units/${routeUnitId}`, { request(`/api/oauth/route-units/${routeUnitId}`, {
method: "DELETE", method: "DELETE",
@@ -1923,15 +2313,6 @@ export const api = {
testNotification: () => testNotification: () =>
request("/api/settings/notify/test", { method: "POST" }), request("/api/settings/notify/test", { method: "POST" }),
// Monitor embed
getMonitorConfig: () => request("/api/monitor/config"),
updateMonitorConfig: (data: { ldohCookie?: string | null }) =>
request("/api/monitor/config", {
method: "PUT",
body: JSON.stringify(data),
}),
initMonitorSession: () => request("/api/monitor/session", { method: "POST" }),
// Models marketplace // Models marketplace
getModelsMarketplace: (options?: { getModelsMarketplace: (options?: {
refresh?: boolean; refresh?: boolean;
@@ -2057,6 +2438,8 @@ export const api = {
}), }),
getWorkflowNodeTypes: () => getWorkflowNodeTypes: () =>
request<{ success: boolean; nodeTypes: any[] }>("/api/workflow/node-types"), request<{ success: boolean; nodeTypes: any[] }>("/api/workflow/node-types"),
getWorkflowModelFolder: (folder: string) =>
workflowModelFolderRequest(request, folder),
getWorkflows: (params?: { include_public?: boolean }) => getWorkflows: (params?: { include_public?: boolean }) =>
request<{ success: boolean; items: WorkflowSummary[] }>(`/api/workflows${buildQueryString(params)}`), request<{ success: boolean; items: WorkflowSummary[] }>(`/api/workflows${buildQueryString(params)}`),
createWorkflow: (data: WorkflowSavePayload) => createWorkflow: (data: WorkflowSavePayload) =>
@@ -2075,6 +2458,8 @@ export const api = {
request<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/workflow-runs${buildQueryString(params)}`), request<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/workflow-runs${buildQueryString(params)}`),
getWorkflowRun: (publicId: string) => getWorkflowRun: (publicId: string) =>
request<{ success: boolean; run: WorkflowRunSummary }>(`/api/workflow-runs/${encodeURIComponent(publicId)}`), request<{ success: boolean; run: WorkflowRunSummary }>(`/api/workflow-runs/${encodeURIComponent(publicId)}`),
getDigitalHumanStatus: () =>
request<DigitalHumanStatusResponse>("/api/digital-human/status"),
createDigitalHumanJob: (data: any) => createDigitalHumanJob: (data: any) =>
request<DigitalHumanJobResponse>("/api/digital-human/jobs", { request<DigitalHumanJobResponse>("/api/digital-human/jobs", {
method: "POST", method: "POST",
@@ -2129,10 +2514,23 @@ export const userApi = {
userRequest(`/api/user-api-keys/${id}`, { method: "PATCH", body: JSON.stringify(data) }), userRequest(`/api/user-api-keys/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteApiKey: (id: number) => deleteApiKey: (id: number) =>
userRequest(`/api/user-api-keys/${id}`, { method: "DELETE" }), userRequest(`/api/user-api-keys/${id}`, { method: "DELETE" }),
getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; status?: string; date_from?: string; date_to?: string }) => getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; key_id?: number; key?: string; status?: string; date_from?: string; date_to?: string }) =>
userRequest(`/api/user/logs${buildQueryString(params)}`), userRequest(`/api/user/logs${buildQueryString(params)}`),
getSummary: () => userRequest("/api/user/summary"), getSummary: () => userRequest("/api/user/summary"),
getDashboard: () => userRequest("/api/user/dashboard"), getDashboard: () => userRequest("/api/user/dashboard"),
getRechargeRecords: () =>
userRequest<UserRechargeRecordsResponse>("/api/user/recharge-records"),
createRechargeCheckout: (data: CreateUserRechargeCheckoutPayload) =>
userRequest<CreateUserRechargeCheckoutResponse>("/api/user/recharge/checkout", {
method: "POST",
body: JSON.stringify(data),
}),
cancelRechargeRecord: (publicId: string) =>
userRequest<UserRechargeRecordActionResponse>(`/api/user/recharge-records/${encodeURIComponent(publicId)}/cancel`, {
method: "POST",
}),
clearRechargeRecords: () =>
userRequest<ClearUserRechargeRecordsResponse>("/api/user/recharge-records", { method: "DELETE" }),
getModelsMarketplace: (options?: { getModelsMarketplace: (options?: {
refresh?: boolean; refresh?: boolean;
includePricing?: boolean; includePricing?: boolean;
@@ -2162,6 +2560,8 @@ export const userApi = {
}), }),
getWorkflowNodeTypes: () => getWorkflowNodeTypes: () =>
userRequest<{ success: boolean; nodeTypes: any[] }>("/api/user/workflow/node-types"), userRequest<{ success: boolean; nodeTypes: any[] }>("/api/user/workflow/node-types"),
getWorkflowModelFolder: (folder: string) =>
workflowModelFolderRequest(userRequest, folder),
getWorkflows: (params?: { include_public?: boolean }) => getWorkflows: (params?: { include_public?: boolean }) =>
userRequest<{ success: boolean; items: WorkflowSummary[] }>(`/api/user/workflows${buildQueryString(params)}`), userRequest<{ success: boolean; items: WorkflowSummary[] }>(`/api/user/workflows${buildQueryString(params)}`),
createWorkflow: (data: WorkflowSavePayload) => createWorkflow: (data: WorkflowSavePayload) =>
@@ -2180,6 +2580,8 @@ export const userApi = {
userRequest<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/user/workflow-runs${buildQueryString(params)}`), userRequest<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/user/workflow-runs${buildQueryString(params)}`),
getWorkflowRun: (publicId: string) => getWorkflowRun: (publicId: string) =>
userRequest<{ success: boolean; run: WorkflowRunSummary }>(`/api/user/workflow-runs/${encodeURIComponent(publicId)}`), userRequest<{ success: boolean; run: WorkflowRunSummary }>(`/api/user/workflow-runs/${encodeURIComponent(publicId)}`),
getDigitalHumanStatus: () =>
userRequest<DigitalHumanStatusResponse>("/api/digital-human/status"),
createDigitalHumanJob: (data: any) => createDigitalHumanJob: (data: any) =>
userRequest<DigitalHumanJobResponse>("/api/digital-human/jobs", { userRequest<DigitalHumanJobResponse>("/api/digital-human/jobs", {
method: "POST", method: "POST",
+2
View File
@@ -1,6 +1,7 @@
import { clearSession } from './authSession.js'; import { clearSession } from './authSession.js';
export const THEME_MODE_STORAGE_KEY = 'theme_mode'; export const THEME_MODE_STORAGE_KEY = 'theme_mode';
export const THEME_PRESET_STORAGE_KEY = 'theme_preset';
export const LEGACY_THEME_STORAGE_KEY = 'theme'; export const LEGACY_THEME_STORAGE_KEY = 'theme';
export const USER_PROFILE_STORAGE_KEY = 'user_profile'; export const USER_PROFILE_STORAGE_KEY = 'user_profile';
export const ADMIN_PROFILE_STORAGE_KEY = 'admin_profile'; export const ADMIN_PROFILE_STORAGE_KEY = 'admin_profile';
@@ -25,6 +26,7 @@ export function clearAppInstallationState(storage?: StorageLike | null): void {
if (!target) return; if (!target) return;
clearSession('admin', target as never); clearSession('admin', target as never);
target.removeItem(THEME_MODE_STORAGE_KEY); target.removeItem(THEME_MODE_STORAGE_KEY);
target.removeItem(THEME_PRESET_STORAGE_KEY);
target.removeItem(LEGACY_THEME_STORAGE_KEY); target.removeItem(LEGACY_THEME_STORAGE_KEY);
target.removeItem(USER_PROFILE_STORAGE_KEY); target.removeItem(USER_PROFILE_STORAGE_KEY);
target.removeItem(ADMIN_PROFILE_STORAGE_KEY); target.removeItem(ADMIN_PROFILE_STORAGE_KEY);
@@ -0,0 +1,73 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
function readSource(relativePath: string): string {
return readFileSync(resolve(process.cwd(), relativePath), 'utf8').replace(/\r\n/g, '\n');
}
describe('AppMotion animation contract', () => {
it('uses GSAP matchMedia so page transitions respect reduced motion and viewport density', () => {
const source = readSource('src/web/components/AppMotion.tsx');
expect(source).toContain("import { gsap } from 'gsap';");
expect(source).toContain('const mm = gsap.matchMedia();');
expect(source).toContain("reduceMotion: '(prefers-reduced-motion: reduce)'");
expect(source).toContain("isDesktop: '(min-width: 900px)'");
expect(source).toContain('if (conditions.reduceMotion) {');
expect(source).toContain("gsap.set([root, ...targets], { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });");
expect(source).toContain('return () => mm.revert();');
});
it('keeps route/login entrance motion scoped, subtle, and cleaned up on unmount', () => {
const source = readSource('src/web/components/AppMotion.tsx');
expect(source).toContain('motionVariant === \'login\' ? 14 : (conditions.isDesktop ? 26 : 16)');
expect(source).toContain('const timeline = gsap.timeline({');
expect(source).toContain("ease: 'power2.out'");
expect(source).toContain("overwrite: 'auto'");
expect(source).toContain('timeline.fromTo(');
expect(source).toContain("stagger: {\n each: motionVariant === 'login' ? 0.045 : 0.026,");
expect(source).toContain('timeline.kill();');
expect(source).toContain('gsap.killTweensOf([root, ...targets]);');
expect(source).toContain("gsap.set([root, ...targets], { clearProps: 'transform,opacity,visibility' });");
});
it('includes the newer dashboard operations blocks in the GSAP entrance target set', () => {
const source = readSource('src/web/components/AppMotion.tsx');
expect(source).toContain("':scope > .dashboard-war-room'");
expect(source).toContain("':scope > .dashboard-command-strip'");
expect(source).toContain("':scope .dashboard-pulse-card'");
expect(source).toContain("':scope .dashboard-risk-panel'");
expect(source).toContain("':scope .dashboard-command-item'");
expect(source).toContain("':scope .ops-monitor-log-card'");
expect(source).toContain("':scope .responsive-batch-action-bar'");
});
it('keeps responsive batch action bars reachable by the GSAP selector', () => {
const mobileBatchBar = readSource('src/web/components/MobileBatchBar.tsx');
const responsiveBatchBar = readSource('src/web/components/ResponsiveBatchActionBar.tsx');
expect(mobileBatchBar).toContain('responsive-batch-action-bar');
expect(responsiveBatchBar).toContain('responsive-batch-action-bar');
expect(mobileBatchBar).toContain('className="mobile-actions-info" role="status" aria-live="polite"');
expect(responsiveBatchBar).toContain('<span role="status" aria-live="polite"');
});
it('keeps high-frequency overlay motion reduced-motion friendly', () => {
const css = readSource('src/web/index.css');
expect(css).toContain('@media (prefers-reduced-motion: reduce) {');
expect(css).toContain('.user-dropdown,\n .user-dropdown.is-closing');
expect(css).toContain('.modal-backdrop,\n .modal-backdrop.is-closing');
expect(css).toContain('.mobile-drawer-panel,\n .mobile-drawer-panel-right');
expect(css).toContain('.filter-panel.filter-collapsible,\n .filter-panel.filter-collapsible.is-closing');
expect(css).toContain('animation: none !important;');
expect(css).toContain('transition: none !important;');
expect(css).toContain('transform: none !important;');
expect(css).toContain('.user-dropdown.is-closing,\n .modal-backdrop.is-closing');
expect(css).not.toContain('.main-content.is-route-transitioning');
expect(css).not.toContain('.route-progress');
});
});
+75 -20
View File
@@ -17,9 +17,33 @@ const PAGE_TARGET_SELECTOR = [
':scope > .picture-agent-shell', ':scope > .picture-agent-shell',
':scope > .workspace-layout', ':scope > .workspace-layout',
':scope > .dashboard-grid', ':scope > .dashboard-grid',
':scope > .dashboard-war-room',
':scope > .dashboard-command-strip',
':scope > .digital-human-layout', ':scope > .digital-human-layout',
':scope > .money-printer-layout', ':scope > .money-printer-layout',
':scope > .about-page', ':scope .console-hero',
':scope .page-header',
':scope .stat-card',
':scope .chart-container',
':scope .dashboard-panel',
':scope .dashboard-grid',
':scope .dashboard-pulse-card',
':scope .dashboard-risk-panel',
':scope .dashboard-command-item',
':scope .ops-monitor-log-card',
':scope .card',
':scope .mobile-card',
':scope .data-table',
':scope .workflow-shell',
':scope .workspace-layout',
':scope .picture-agent-shell',
':scope .digital-human-layout',
':scope .money-printer-layout',
':scope .settings-card',
':scope .settings-section-nav',
':scope .admin-ops-toolbar',
':scope .responsive-filter-panel',
':scope .responsive-batch-action-bar',
].join(', '); ].join(', ');
const LOGIN_TARGET_SELECTOR = [ const LOGIN_TARGET_SELECTOR = [
@@ -35,9 +59,22 @@ const LOGIN_TARGET_SELECTOR = [
'.login-auth-footer', '.login-auth-footer',
].join(', '); ].join(', ');
function selectMotionTargets(root: HTMLDivElement, variant: MotionVariant): Element[] { function selectMotionTargets(root: HTMLDivElement, variant: MotionVariant, limit: number): Element[] {
const selector = variant === 'login' ? LOGIN_TARGET_SELECTOR : PAGE_TARGET_SELECTOR; const selector = variant === 'login' ? LOGIN_TARGET_SELECTOR : PAGE_TARGET_SELECTOR;
return Array.from(root.querySelectorAll(selector)).slice(0, variant === 'login' ? 14 : 18); const selected: Element[] = [];
for (const element of Array.from(root.querySelectorAll(selector))) {
if (selected.some((selectedElement) => selectedElement.contains(element))) {
continue;
}
selected.push(element);
if (selected.length >= limit) {
break;
}
}
return selected;
} }
export default function MotionDiv({ export default function MotionDiv({
@@ -62,45 +99,63 @@ export default function MotionDiv({
}, },
(context) => { (context) => {
const conditions = context.conditions || {}; const conditions = context.conditions || {};
const targets = selectMotionTargets(
root,
motionVariant,
motionVariant === 'login' ? 14 : (conditions.isDesktop ? 26 : 16),
);
if (conditions.reduceMotion) { if (conditions.reduceMotion) {
gsap.set(root, { autoAlpha: 1, clearProps: 'transform,opacity,visibility' }); gsap.set([root, ...targets], { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });
return undefined; return undefined;
} }
const targets = selectMotionTargets(root, motionVariant); const distance = motionVariant === 'login' ? 16 : (conditions.isDesktop ? 12 : 8);
const distance = motionVariant === 'login' ? 16 : 10; const timeline = gsap.timeline({
defaults: {
ease: 'power2.out',
overwrite: 'auto',
},
});
const rootTween = gsap.fromTo( timeline.fromTo(
root, root,
{ autoAlpha: 0, y: Math.max(6, distance - 4) }, { autoAlpha: 0, y: Math.max(6, distance - 4) },
{ {
autoAlpha: 1, autoAlpha: 1,
y: 0, y: 0,
duration: motionVariant === 'login' ? 0.46 : 0.34, duration: motionVariant === 'login' ? 0.46 : 0.32,
ease: 'power2.out',
clearProps: 'transform,opacity,visibility', clearProps: 'transform,opacity,visibility',
}, },
); );
const targetTween = targets.length if (targets.length) {
? gsap.fromTo( timeline.fromTo(
targets, targets,
{ autoAlpha: 0, y: distance }, {
autoAlpha: 0,
y: distance,
scale: motionVariant === 'login' ? 0.992 : 0.996,
},
{ {
autoAlpha: 1, autoAlpha: 1,
y: 0, y: 0,
duration: motionVariant === 'login' ? 0.58 : 0.42, scale: 1,
ease: 'power2.out', duration: motionVariant === 'login' ? 0.58 : 0.38,
stagger: motionVariant === 'login' ? 0.045 : 0.025, stagger: {
delay: 0.04, each: motionVariant === 'login' ? 0.045 : 0.026,
from: 'start',
},
clearProps: 'transform,opacity,visibility', clearProps: 'transform,opacity,visibility',
}, },
) motionVariant === 'login' ? '-=0.2' : '-=0.18',
: null; );
}
return () => { return () => {
rootTween.kill(); timeline.kill();
targetTween?.kill(); gsap.killTweensOf([root, ...targets]);
gsap.set([root, ...targets], { clearProps: 'transform,opacity,visibility' });
}; };
}, },
); );
+1 -1
View File
@@ -30,7 +30,7 @@ export function useIconCdn() {
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark'); setIsDark(document.documentElement.getAttribute('data-theme') === 'dark');
}); });
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-theme-preset'] });
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
return isDark ? ICON_CDN : ICON_CDN_LIGHT; return isDark ? ICON_CDN : ICON_CDN_LIGHT;
+60 -4
View File
@@ -1,5 +1,6 @@
import React, { useEffect } from 'react'; import React, { useEffect, useId, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { trapFocusInContainer } from './focusTrap.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js'; import { useAnimatedVisibility } from './useAnimatedVisibility.js';
type CenteredModalProps = { type CenteredModalProps = {
@@ -28,6 +29,9 @@ export default function CenteredModal({
showCloseButton = true, showCloseButton = true,
}: CenteredModalProps) { }: CenteredModalProps) {
const presence = useAnimatedVisibility(open, 220); const presence = useAnimatedVisibility(open, 220);
const titleId = useId();
const dialogRef = useRef<HTMLDivElement | null>(null);
const lastActiveElementRef = useRef<HTMLElement | null>(null);
const canUsePortal = typeof document !== 'undefined' const canUsePortal = typeof document !== 'undefined'
&& !!document.body && !!document.body
&& typeof document.body.appendChild === 'function' && typeof document.body.appendChild === 'function'
@@ -43,9 +47,52 @@ export default function CenteredModal({
}, [canUsePortal, open]); }, [canUsePortal, open]);
useEffect(() => { useEffect(() => {
if (!open || !closeOnEscape || !canUsePortal) return; if (open) {
const activeElement = typeof document !== 'undefined' ? document.activeElement : null;
lastActiveElementRef.current = typeof HTMLElement !== 'undefined' && activeElement instanceof HTMLElement
? activeElement
: null;
const focusDialog = () => {
const dialog = dialogRef.current;
if (!dialog) return;
const currentActiveElement = typeof document !== 'undefined' ? document.activeElement : null;
if (typeof Node !== 'undefined' && currentActiveElement instanceof Node && dialog.contains(currentActiveElement)) return;
dialog.focus({ preventScroll: true });
};
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
const animationFrame = window.requestAnimationFrame(focusDialog);
return () => {
if (typeof window.cancelAnimationFrame === 'function') {
window.cancelAnimationFrame(animationFrame);
}
};
}
focusDialog();
return undefined;
}
if (lastActiveElementRef.current?.isConnected !== false) {
lastActiveElementRef.current?.focus({ preventScroll: true });
}
lastActiveElementRef.current = null;
return undefined;
}, [open]);
useEffect(() => () => {
if (lastActiveElementRef.current?.isConnected !== false) {
lastActiveElementRef.current?.focus({ preventScroll: true });
}
lastActiveElementRef.current = null;
}, []);
useEffect(() => {
if (!open || !canUsePortal) return;
const handleKeydown = (event: KeyboardEvent) => { const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose(); if (event.key === 'Escape' && closeOnEscape) {
onClose();
return;
}
trapFocusInContainer(event, dialogRef.current);
}; };
document.addEventListener('keydown', handleKeydown); document.addEventListener('keydown', handleKeydown);
return () => { return () => {
@@ -55,24 +102,33 @@ export default function CenteredModal({
if (!presence.shouldRender) return null; if (!presence.shouldRender) return null;
const interactive = open && presence.isVisible;
const modal = ( const modal = (
<div <div
className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
onClick={closeOnBackdrop ? onClose : undefined} onClick={closeOnBackdrop ? onClose : undefined}
> >
<div <div
ref={dialogRef}
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-hidden={!interactive}
tabIndex={-1}
style={{ maxWidth }} style={{ maxWidth }}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<div className="modal-header"> <div className="modal-header">
<div className="modal-title">{title}</div> <div id={titleId} className="modal-title">{title}</div>
{showCloseButton ? ( {showCloseButton ? (
<button <button
type="button" type="button"
className="modal-close-button" className="modal-close-button"
onClick={onClose} onClick={onClose}
aria-label="关闭弹框" aria-label="关闭弹框"
tabIndex={interactive ? 0 : -1}
> >
× ×
</button> </button>
+54 -66
View File
@@ -1,12 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import { useToast } from './Toast.js'; import { useToast } from './Toast.js';
import { commitSession } from '../authSession.js'; import { commitSession } from '../authSession.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js'; import CenteredModal from './CenteredModal.js';
export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClose: () => void }) { export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const presence = useAnimatedVisibility(open, 200);
const [oldToken, setOldToken] = useState(''); const [oldToken, setOldToken] = useState('');
const [newToken, setNewToken] = useState(''); const [newToken, setNewToken] = useState('');
const [confirmToken, setConfirmToken] = useState(''); const [confirmToken, setConfirmToken] = useState('');
@@ -14,15 +12,6 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
const [error, setError] = useState(''); const [error, setError] = useState('');
const toast = useToast(); const toast = useToast();
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previous;
};
}, [open]);
const handleSubmit = async () => { const handleSubmit = async () => {
setError(''); setError('');
if (!oldToken || !newToken || !confirmToken) { if (!oldToken || !newToken || !confirmToken) {
@@ -58,67 +47,66 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
} }
}; };
if (!presence.shouldRender) return null;
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)', width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none', borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none',
background: 'var(--color-bg)', color: 'var(--color-text-primary)', background: 'var(--color-bg)', color: 'var(--color-text-primary)',
}; };
const modal = ( return (
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}> <CenteredModal
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={e => e.stopPropagation()} style={{ maxWidth: 420 }}> open={open}
<div className="modal-header">修改管理员 Token</div> onClose={onClose}
title="修改管理员 Token"
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> maxWidth={420}
<div> closeOnBackdrop
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>旧 Token</label> closeOnEscape
<input bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
type="password" footer={(
value={oldToken} <>
onChange={e => { setOldToken(e.target.value); setError(''); }} <button type="button" onClick={onClose} className="btn btn-ghost">取消</button>
placeholder="输入当前 Token" <button type="button" onClick={handleSubmit} disabled={saving} className="btn btn-primary">
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>新 Token</label>
<input
type="password"
value={newToken}
onChange={e => { setNewToken(e.target.value); setError(''); }}
placeholder="输入新 Token (至少 6 位)"
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>确认新 Token</label>
<input
type="password"
value={confirmToken}
onChange={e => { setConfirmToken(e.target.value); setError(''); }}
placeholder="再次输入新 Token"
style={inputStyle}
/>
</div>
{error && (
<div className="alert alert-error">
{error}
</div>
)}
</div>
<div className="modal-footer">
<button onClick={onClose} className="btn btn-ghost">取消</button>
<button onClick={handleSubmit} disabled={saving} className="btn btn-primary">
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />更新中...</> : '确认修改'} {saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />更新中...</> : '确认修改'}
</button> </button>
</div> </>
)}
>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>旧 Token</label>
<input
type="password"
value={oldToken}
onChange={e => { setOldToken(e.target.value); setError(''); }}
placeholder="输入当前 Token"
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>新 Token</label>
<input
type="password"
value={newToken}
onChange={e => { setNewToken(e.target.value); setError(''); }}
placeholder="输入新 Token (至少 6 位)"
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>确认新 Token</label>
<input
type="password"
value={confirmToken}
onChange={e => { setConfirmToken(e.target.value); setError(''); }}
placeholder="再次输入新 Token"
style={inputStyle}
/>
</div> </div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal; {error && (
<div className="alert alert-error">
{error}
</div>
)}
</CenteredModal>
);
} }
+3 -2
View File
@@ -28,11 +28,12 @@ export default function DeleteConfirmModal({
onClose={onClose} onClose={onClose}
title={title} title={title}
maxWidth={560} maxWidth={560}
closeOnEscape
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }} bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={( footer={(
<> <>
<button onClick={onClose} className="btn btn-ghost" disabled={loading}>{cancelText}</button> <button type="button" onClick={onClose} className="btn btn-ghost" disabled={loading}>{cancelText}</button>
<button onClick={onConfirm} className="btn btn-danger" disabled={loading}> <button type="button" onClick={onConfirm} className="btn btn-danger" disabled={loading}>
{loading {loading
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 删除中...</> ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 删除中...</>
: confirmText} : confirmText}
@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from "vitest";
import { create, type ReactTestInstance } from "react-test-renderer";
import { MemoryRouter } from "react-router-dom";
import DiagnosticActionGroup from "./DiagnosticActionGroup.js";
function collectText(node: ReactTestInstance): string {
return (node.children || [])
.map((child) => (typeof child === "string" ? child : collectText(child)))
.join("");
}
describe("DiagnosticActionGroup", () => {
it("renders router links, buttons, and muted hints as a compact action group", () => {
const onClick = vi.fn();
const root = create(
<MemoryRouter>
<DiagnosticActionGroup
actions={[
{ label: "查看失败日志", href: "/logs?status=failed", tone: "danger" },
{ label: "重新绑定", onClick, hint: "更新连接凭证" },
{ label: "等待首次调用", hint: "首次代理请求后自动更新", tone: "muted" },
]}
/>
</MemoryRouter>,
);
const links = root.root.findAll((node) => node.type === "a");
expect(links).toHaveLength(1);
expect(links[0]!.props.href).toBe("/logs?status=failed");
const button = root.root.find((node) => node.type === "button");
expect(collectText(button)).toBe("重新绑定");
button.props.onClick({ stopPropagation: vi.fn() });
expect(onClick).toHaveBeenCalledTimes(1);
expect(collectText(root.root)).toContain("等待首次调用");
expect(collectText(root.root)).toContain("首次代理请求后自动更新");
root.unmount();
});
it("falls back to normal anchors outside a router", () => {
const root = create(
<DiagnosticActionGroup
actions={[{ label: "日志", href: "/logs" }]}
/>,
);
const link = root.root.find((node) => node.type === "a");
expect(link.props.href).toBe("/logs");
root.unmount();
});
it("keeps nested action clicks from bubbling into parent row handlers", () => {
const parentClick = vi.fn();
const stopPropagation = vi.fn();
const root = create(
<div onClick={parentClick}>
<MemoryRouter>
<DiagnosticActionGroup
actions={[{ label: "日志", href: "/logs" }]}
/>
</MemoryRouter>
</div>,
);
const link = root.root.find((node) => node.type === "a");
link.props.onClick({ stopPropagation });
expect(stopPropagation).toHaveBeenCalledTimes(1);
expect(parentClick).not.toHaveBeenCalled();
root.unmount();
});
it("renders disabled actions as inert status pills", () => {
const onClick = vi.fn();
const root = create(
<DiagnosticActionGroup
actions={[{ label: "复制验证结果", onClick, disabled: true, hint: "等待一次验证回复" }]}
/>,
);
expect(root.root.findAll((node) => node.type === "button")).toHaveLength(0);
const disabledAction = root.root.find((node) => (
node.type === "span"
&& node.props["aria-disabled"] === "true"
));
expect(collectText(disabledAction)).toBe("复制验证结果");
expect(onClick).not.toHaveBeenCalled();
root.unmount();
});
});
@@ -0,0 +1,95 @@
import { Link, useInRouterContext } from "react-router-dom";
export type DiagnosticAction = {
label: string;
hint?: string;
href?: string;
onClick?: () => void;
tone?: "primary" | "danger" | "muted";
disabled?: boolean;
};
type DiagnosticActionGroupProps = {
actions: DiagnosticAction[];
className?: string;
interactive?: boolean;
};
export default function DiagnosticActionGroup({
actions,
className = "",
interactive = true,
}: DiagnosticActionGroupProps) {
const inRouter = useInRouterContext();
const visibleActions = actions.filter(
(action) => action.label && (action.href || action.onClick || action.hint),
);
if (visibleActions.length === 0) return null;
return (
<span className={`diagnostic-action-group ${className}`.trim()}>
{visibleActions.map((action) => {
const tone = action.tone || "primary";
const key = `${action.label}-${action.href || action.hint || "action"}`;
const body = action.disabled ? (
<span
className="diagnostic-action is-disabled"
data-tone={tone}
aria-disabled="true"
>
{action.label}
</span>
) : action.href ? (
inRouter ? (
<Link
className="diagnostic-action"
data-tone={tone}
onClick={(event) => event.stopPropagation()}
tabIndex={interactive ? undefined : -1}
to={action.href}
>
{action.label}
</Link>
) : (
<a
className="diagnostic-action"
data-tone={tone}
href={action.href}
onClick={(event) => event.stopPropagation()}
tabIndex={interactive ? undefined : -1}
>
{action.label}
</a>
)
) : action.onClick ? (
<button
type="button"
className="diagnostic-action"
data-tone={tone}
disabled={false}
onClick={(event) => {
event?.stopPropagation?.();
action.onClick?.();
}}
tabIndex={interactive ? undefined : -1}
>
{action.label}
</button>
) : (
<span className="diagnostic-action is-muted" data-tone="muted">
{action.label}
</span>
);
return (
<span className="diagnostic-action-item" key={key}>
{body}
{action.hint ? (
<span className="diagnostic-action-hint">{action.hint}</span>
) : null}
</span>
);
})}
</span>
);
}
@@ -0,0 +1,38 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
function readSource(relativePath: string): string {
return readFileSync(resolve(process.cwd(), relativePath), 'utf8').replace(/\r\n/g, '\n');
}
describe('ListPresenceMotion animation contract', () => {
it('uses GSAP matchMedia with reduced-motion cleanup for async list transitions', () => {
const source = readSource('src/web/components/ListPresenceMotion.tsx');
expect(source).toContain("import { gsap } from 'gsap';");
expect(source).toContain('const mm = gsap.matchMedia();');
expect(source).toContain("reduceMotion: '(prefers-reduced-motion: reduce)'");
expect(source).toContain("isDesktop: '(min-width: 900px)'");
expect(source).toContain('if (conditions.reduceMotion) {');
expect(source).toContain("gsap.set(animatedNodes, { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });");
expect(source).toContain('timeline.fromTo(');
expect(source).toContain('stagger: targets.length > 1');
expect(source).toContain('timeline.kill();');
expect(source).toContain('gsap.killTweensOf(animatedNodes);');
expect(source).toContain('return () => mm.revert();');
});
it('keeps TokenRoutes full rendering while animating the loading-to-ready list swap', () => {
const source = readSource('src/web/pages/TokenRoutes.tsx');
expect(source).toContain("import ListPresenceMotion from '../components/ListPresenceMotion.js';");
expect(source).toContain('<ListPresenceMotion');
expect(source).toContain("targetSelector=\".route-loading-card, .route-card-grid > *, .mobile-card-list > *\"");
expect(source).toContain('maxTargets={isMobile ? 16 : 24}');
expect(source).toContain("motionKey={`${initialLoading ? 'loading' : 'ready'}:${routeSummaries.length}:${isMobile ? 'mobile' : 'desktop'}`}");
expect(source).toContain('() => filteredRoutes,\n [filteredRoutes],');
expect(source).not.toContain('visibleRouteCount');
expect(source).not.toContain('当前已加载路由');
});
});
+106
View File
@@ -0,0 +1,106 @@
import React, { useEffect, useRef } from 'react';
import { gsap } from 'gsap';
interface ListPresenceMotionProps extends React.HTMLAttributes<HTMLDivElement> {
motionKey: string;
targetSelector?: string;
maxTargets?: number;
}
const DEFAULT_TARGET_SELECTOR = ':scope > *';
function selectPresenceTargets(root: HTMLDivElement, selector: string, limit: number): Element[] {
const selected: Element[] = [];
for (const element of Array.from(root.querySelectorAll(selector))) {
if (selected.some((selectedElement) => selectedElement.contains(element))) {
continue;
}
selected.push(element);
if (selected.length >= limit) break;
}
return selected;
}
export default function ListPresenceMotion({
children,
className,
motionKey,
targetSelector = DEFAULT_TARGET_SELECTOR,
maxTargets = 24,
...props
}: ListPresenceMotionProps) {
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return undefined;
const mm = gsap.matchMedia();
mm.add(
{
reduceMotion: '(prefers-reduced-motion: reduce)',
isDesktop: '(min-width: 900px)',
},
(context) => {
const conditions = context.conditions || {};
const targets = selectPresenceTargets(
root,
targetSelector,
Math.max(1, maxTargets),
);
const animatedNodes = targets.length ? targets : [root];
if (conditions.reduceMotion) {
gsap.set(animatedNodes, { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });
return undefined;
}
const timeline = gsap.timeline({
defaults: {
ease: 'power2.out',
overwrite: 'auto',
},
});
const distance = conditions.isDesktop ? 10 : 7;
timeline.fromTo(
animatedNodes,
{
autoAlpha: 0,
y: distance,
},
{
autoAlpha: 1,
y: 0,
duration: conditions.isDesktop ? 0.32 : 0.26,
stagger: targets.length > 1
? { each: conditions.isDesktop ? 0.018 : 0.014, from: 'start' }
: 0,
clearProps: 'transform,opacity,visibility',
},
);
return () => {
timeline.kill();
gsap.killTweensOf(animatedNodes);
gsap.set(animatedNodes, { clearProps: 'transform,opacity,visibility' });
};
},
);
return () => mm.revert();
}, [maxTargets, motionKey, targetSelector]);
return (
<div
ref={rootRef}
className={['list-presence-motion', className].filter(Boolean).join(' ')}
{...props}
>
{children}
</div>
);
}
+4 -2
View File
@@ -7,8 +7,10 @@ type MobileBatchBarProps = {
export default function MobileBatchBar({ info, children }: MobileBatchBarProps) { export default function MobileBatchBar({ info, children }: MobileBatchBarProps) {
return ( return (
<div className="mobile-actions-bar mobile-batch-bar"> <div
<span className="mobile-actions-info">{info}</span> className="mobile-actions-bar mobile-batch-bar responsive-batch-action-bar"
>
<span className="mobile-actions-info" role="status" aria-live="polite">{info}</span>
<div className="mobile-actions-row">{children}</div> <div className="mobile-actions-row">{children}</div>
</div> </div>
); );
+64 -22
View File
@@ -1,5 +1,7 @@
import React, { useEffect, useId, useMemo, useState, useCallback } from 'react'; import React, { useCallback, useEffect, useId, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { trapFocusInContainer } from './focusTrap.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
type MobileDrawerProps = { type MobileDrawerProps = {
open: boolean; open: boolean;
@@ -8,6 +10,7 @@ type MobileDrawerProps = {
title?: React.ReactNode; title?: React.ReactNode;
closeLabel?: string; closeLabel?: string;
side?: 'left' | 'right'; side?: 'left' | 'right';
panelId?: string;
}; };
function MobileDrawer({ function MobileDrawer({
@@ -17,28 +20,15 @@ function MobileDrawer({
title, title,
closeLabel = '关闭导航', closeLabel = '关闭导航',
side = 'left', side = 'left',
panelId,
}: MobileDrawerProps) { }: MobileDrawerProps) {
const [shouldRender, setShouldRender] = useState(open); const presence = useAnimatedVisibility(open, 280);
const [isClosing, setIsClosing] = useState(false);
const titleId = useId(); const titleId = useId();
const labelledBy = title ? titleId : undefined; const labelledBy = title ? titleId : undefined;
const panelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { const lastActiveElementRef = useRef<HTMLElement | null>(null);
if (open) {
setShouldRender(true);
setIsClosing(false);
} else if (shouldRender) {
setIsClosing(true);
const timer = setTimeout(() => {
setShouldRender(false);
setIsClosing(false);
}, 280);
return () => clearTimeout(timer);
}
}, [open]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setIsClosing(true);
onClose(); onClose();
}, [onClose]); }, [onClose]);
@@ -51,12 +41,53 @@ function MobileDrawer({
}; };
}, [open]); }, [open]);
useEffect(() => {
if (open) {
const activeElement = typeof document !== 'undefined' ? document.activeElement : null;
lastActiveElementRef.current = typeof HTMLElement !== 'undefined' && activeElement instanceof HTMLElement
? activeElement
: null;
const focusPanel = () => {
const panel = panelRef.current;
if (!panel) return;
const currentActiveElement = typeof document !== 'undefined' ? document.activeElement : null;
if (typeof Node !== 'undefined' && currentActiveElement instanceof Node && panel.contains(currentActiveElement)) return;
panel.focus({ preventScroll: true });
};
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
const animationFrame = window.requestAnimationFrame(focusPanel);
return () => {
if (typeof window.cancelAnimationFrame === 'function') {
window.cancelAnimationFrame(animationFrame);
}
};
}
focusPanel();
return undefined;
}
if (lastActiveElementRef.current?.isConnected !== false) {
lastActiveElementRef.current?.focus({ preventScroll: true });
}
lastActiveElementRef.current = null;
return undefined;
}, [open]);
useEffect(() => () => {
if (lastActiveElementRef.current?.isConnected !== false) {
lastActiveElementRef.current?.focus({ preventScroll: true });
}
lastActiveElementRef.current = null;
}, []);
useEffect(() => { useEffect(() => {
if (!open || typeof document === 'undefined') return; if (!open || typeof document === 'undefined') return;
const handleKeydown = (event: KeyboardEvent) => { const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
handleClose(); handleClose();
return;
} }
trapFocusInContainer(event, panelRef.current);
}; };
document.addEventListener('keydown', handleKeydown); document.addEventListener('keydown', handleKeydown);
return () => { return () => {
@@ -64,23 +95,34 @@ function MobileDrawer({
}; };
}, [handleClose, open]); }, [handleClose, open]);
if (!shouldRender) return null; if (!presence.shouldRender) return null;
const interactive = open && presence.isVisible;
const drawer = ( const drawer = (
<div className={`mobile-drawer-root ${isClosing ? 'is-closing' : ''}`}> <div className={`mobile-drawer-root ${presence.isVisible ? '' : 'is-closing'}`.trim()}>
<div <div
className="mobile-drawer-backdrop" className="mobile-drawer-backdrop"
onClick={handleClose} onClick={handleClose}
aria-hidden="true" aria-hidden="true"
/> />
<div className={`mobile-drawer-panel mobile-drawer-panel-${side}`} role="dialog" aria-modal="true" aria-labelledby={labelledBy}> <div
id={panelId}
ref={panelRef}
className={`mobile-drawer-panel mobile-drawer-panel-${side}`}
role="dialog"
aria-modal="true"
aria-labelledby={labelledBy}
aria-hidden={!interactive}
tabIndex={-1}
>
<div className="mobile-drawer-toolbar"> <div className="mobile-drawer-toolbar">
{title ? ( {title ? (
<div className="mobile-drawer-title" id={titleId}> <div className="mobile-drawer-title" id={titleId}>
{title} {title}
</div> </div>
) : <div />} ) : <div />}
<button type="button" className="mobile-drawer-close" onClick={handleClose} aria-label={closeLabel}> <button type="button" className="mobile-drawer-close" onClick={handleClose} aria-label={closeLabel} tabIndex={interactive ? 0 : -1}>
× ×
</button> </button>
</div> </div>
+3 -1
View File
@@ -6,6 +6,7 @@ type MobileFilterSheetProps = {
onClose: () => void; onClose: () => void;
title?: React.ReactNode; title?: React.ReactNode;
children: React.ReactNode; children: React.ReactNode;
panelId?: string;
}; };
export default function MobileFilterSheet({ export default function MobileFilterSheet({
@@ -13,9 +14,10 @@ export default function MobileFilterSheet({
onClose, onClose,
title = '筛选', title = '筛选',
children, children,
panelId,
}: MobileFilterSheetProps) { }: MobileFilterSheetProps) {
return ( return (
<MobileDrawer open={open} onClose={onClose} title={title} closeLabel="关闭筛选"> <MobileDrawer open={open} onClose={onClose} title={title} closeLabel="关闭筛选" panelId={panelId}>
<div className="mobile-filter-panel"> <div className="mobile-filter-panel">
{children} {children}
</div> </div>
@@ -1,4 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { act, create, type ReactTestInstance } from 'react-test-renderer'; import { act, create, type ReactTestInstance } from 'react-test-renderer';
import ModelAnalysisPanel from './ModelAnalysisPanel.js'; import ModelAnalysisPanel from './ModelAnalysisPanel.js';
@@ -17,6 +19,27 @@ function collectText(node: ReactTestInstance): string {
}).join(''); }).join('');
} }
function panelData() {
return {
totals: {
spend: 2,
calls: 10,
tokens: 3000,
},
spendDistribution: [{ model: 'gpt-5', spend: 2, calls: 10 }],
spendTrend: [{ day: '2026-06-07', spend: 2 }],
callsDistribution: [{ model: 'gpt-5', calls: 10, share: 1 }],
callRanking: [{ model: 'gpt-5', calls: 10, successRate: 99, avgLatencyMs: 1200, spend: 2, tokens: 3000 }],
};
}
function keyEvent(key: string) {
return {
key,
preventDefault: vi.fn(),
};
}
describe('ModelAnalysisPanel token summaries', () => { describe('ModelAnalysisPanel token summaries', () => {
const originalDocument = globalThis.document; const originalDocument = globalThis.document;
const originalGetComputedStyle = globalThis.getComputedStyle; const originalGetComputedStyle = globalThis.getComputedStyle;
@@ -66,6 +89,151 @@ describe('ModelAnalysisPanel token summaries', () => {
root?.unmount(); root?.unmount();
}); });
it('uses shrink-safe summary and legend layout classes', () => {
let root!: WebTestRenderer;
act(() => {
root = create(
<ModelAnalysisPanel data={panelData()} />,
);
});
const summaryGrid = root.root.find((node) => node.props.className === 'model-analysis-summary-grid');
expect(summaryGrid).toBeTruthy();
const legend = root.root.find((node) => node.props.className === 'model-analysis-legend');
expect(legend).toBeTruthy();
expect(root.root.findAll((node) => node.props.className === 'model-analysis-legend-item').length).toBeGreaterThan(0);
expect(root.root.findAll((node) => node.props.className === 'model-analysis-legend-name').length).toBeGreaterThan(0);
root?.unmount();
});
it('keeps model analysis CSS free of fixed three-column summary grids', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/components/ModelAnalysisPanel.tsx'), 'utf8').replace(/\r\n/g, '\n');
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain('className="model-analysis-summary-grid"');
expect(source).toContain('className="model-analysis-legend"');
expect(source).toContain('className="model-analysis-legend-name"');
expect(source).not.toContain("gridTemplateColumns: 'repeat(3, 1fr)'");
expect(source).not.toContain("maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'");
expect(css).toContain('.model-analysis-summary-grid {');
expect(css).toContain('grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr));');
expect(css).toContain('.model-analysis-legend-item {');
expect(css).toContain('min-width: 0;');
expect(css).toContain('.model-analysis-legend-name {');
expect(css).toContain('max-width: min(150px, 52vw);');
});
it('exposes chart mode switches as tabs with a labelled tab panel', () => {
let root!: WebTestRenderer;
act(() => {
root = create(
<ModelAnalysisPanel data={panelData()} />,
);
});
const tablist = root.root.find((node) => node.props.role === 'tablist');
expect(tablist.props['aria-label']).toBe('模型分析视图');
const tabs = root.root.findAll((node) => node.props.role === 'tab');
expect(tabs).toHaveLength(4);
expect(tabs[0].props['aria-selected']).toBe(true);
expect(tabs[0].props['aria-controls']).toBe('model-analysis-panel-spend');
expect(tabs.map((tab) => tab.props.tabIndex)).toEqual([0, -1, -1, -1]);
let panel = root.root.find((node) => node.props.role === 'tabpanel');
expect(panel.props.id).toBe('model-analysis-panel-spend');
expect(panel.props['aria-labelledby']).toBe('model-analysis-tab-spend');
expect(panel.props.className).toBe('model-analysis-tab-panel');
act(() => {
tabs[1].props.onClick();
});
const updatedTabs = root.root.findAll((node) => node.props.role === 'tab');
expect(updatedTabs[0].props['aria-selected']).toBe(false);
expect(updatedTabs[1].props['aria-selected']).toBe(true);
expect(updatedTabs.map((tab) => tab.props.tabIndex)).toEqual([-1, 0, -1, -1]);
panel = root.root.find((node) => node.props.role === 'tabpanel');
expect(panel.props.id).toBe('model-analysis-panel-trend');
expect(panel.props['aria-labelledby']).toBe('model-analysis-tab-trend');
root?.unmount();
});
it('supports arrow Home and End keyboard navigation across chart tabs', () => {
const focusCalls = {
spend: vi.fn(),
trend: vi.fn(),
calls: vi.fn(),
rank: vi.fn(),
};
let root!: WebTestRenderer;
act(() => {
root = create(
<ModelAnalysisPanel data={panelData()} />,
{
createNodeMock: (element) => {
if (element.type !== 'button') return null;
const id = String(element.props.id || '');
if (!id.startsWith('model-analysis-tab-')) return null;
const key = id.replace('model-analysis-tab-', '') as keyof typeof focusCalls;
return {
focus: focusCalls[key],
};
},
},
);
});
const tablist = root.root.find((node) => node.props.role === 'tablist');
let tabs = root.root.findAll((node) => node.props.role === 'tab');
const arrowRightEvent = keyEvent('ArrowRight');
act(() => {
tablist.props.onKeyDown(arrowRightEvent);
});
expect(arrowRightEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(focusCalls.trend).toHaveBeenCalledWith({ preventScroll: true });
tabs = root.root.findAll((node) => node.props.role === 'tab');
expect(tabs.map((tab) => tab.props['aria-selected'])).toEqual([false, true, false, false]);
const endEvent = keyEvent('End');
act(() => {
tablist.props.onKeyDown(endEvent);
});
expect(endEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(focusCalls.rank).toHaveBeenCalledWith({ preventScroll: true });
tabs = root.root.findAll((node) => node.props.role === 'tab');
expect(tabs.map((tab) => tab.props['aria-selected'])).toEqual([false, false, false, true]);
const homeEvent = keyEvent('Home');
act(() => {
tablist.props.onKeyDown(homeEvent);
});
expect(homeEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(focusCalls.spend).toHaveBeenCalledWith({ preventScroll: true });
tabs = root.root.findAll((node) => node.props.role === 'tab');
expect(tabs.map((tab) => tab.props['aria-selected'])).toEqual([true, false, false, false]);
const ignoredEvent = keyEvent('PageDown');
act(() => {
tablist.props.onKeyDown(ignoredEvent);
});
expect(ignoredEvent.preventDefault).not.toHaveBeenCalled();
root?.unmount();
});
it('renders with fallback label color when browser theme APIs are unavailable', async () => { it('renders with fallback label color when browser theme APIs are unavailable', async () => {
globalThis.document = { globalThis.document = {
documentElement: {}, documentElement: {},
+187 -134
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { VChart } from '@visactor/react-vchart'; import { VChart } from '@visactor/react-vchart';
import { InlineBrandIcon } from './BrandIcon.js'; import { InlineBrandIcon } from './BrandIcon.js';
import { formatCompactTokenMetric } from '../numberFormat.js'; import { formatCompactTokenMetric } from '../numberFormat.js';
@@ -64,7 +64,15 @@ function EmptyBlock() {
export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) { export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
const [activeTab, setActiveTab] = useState<TabKey>('spend'); const [activeTab, setActiveTab] = useState<TabKey>('spend');
const tabButtonRefs = useRef<Record<TabKey, HTMLButtonElement | null>>({
spend: null,
trend: null,
calls: null,
rank: null,
});
const labelColor = useThemeLabelColor(); const labelColor = useThemeLabelColor();
const activeTabPanelId = `model-analysis-panel-${activeTab}`;
const activeTabId = `model-analysis-tab-${activeTab}`;
const totals = { const totals = {
spend: toSafeNumber(data?.totals?.spend), spend: toSafeNumber(data?.totals?.spend),
@@ -119,10 +127,38 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
if (!hasData) return <EmptyBlock />; if (!hasData) return <EmptyBlock />;
const activateTab = (tabKey: TabKey, options?: { focus?: boolean }) => {
setActiveTab(tabKey);
if (options?.focus) {
tabButtonRefs.current[tabKey]?.focus({ preventScroll: true });
}
};
const handleTabKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const currentIndex = tabs.findIndex((tab) => tab.key === activeTab);
if (currentIndex < 0) return;
let nextIndex = currentIndex;
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
nextIndex = (currentIndex + 1) % tabs.length;
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
} else if (event.key === 'Home') {
nextIndex = 0;
} else if (event.key === 'End') {
nextIndex = tabs.length - 1;
} else {
return;
}
event.preventDefault();
activateTab(tabs[nextIndex].key, { focus: true });
};
return ( return (
<div> <div>
{/* Summary Cards */} {/* Summary Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 20 }}> <div className="model-analysis-summary-grid">
<div className="stat-summary-card stat-summary-purple"> <div className="stat-summary-card stat-summary-purple">
<div className="stat-summary-card-label">总消耗</div> <div className="stat-summary-card-label">总消耗</div>
<div className="stat-summary-card-value">{formatCurrency(totals.spend)}</div> <div className="stat-summary-card-value">{formatCurrency(totals.spend)}</div>
@@ -139,12 +175,21 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
{/* Pill Tabs */} {/* Pill Tabs */}
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<div className="pill-tabs"> <div className="pill-tabs" role="tablist" aria-label="模型分析视图" onKeyDown={handleTabKeyDown}>
{tabs.map(tab => ( {tabs.map(tab => (
<button <button
key={tab.key} key={tab.key}
ref={(node) => {
tabButtonRefs.current[tab.key] = node;
}}
id={`model-analysis-tab-${tab.key}`}
type="button"
className={`pill-tab ${activeTab === tab.key ? 'active' : ''}`} className={`pill-tab ${activeTab === tab.key ? 'active' : ''}`}
onClick={() => setActiveTab(tab.key)} role="tab"
aria-selected={activeTab === tab.key}
aria-controls={`model-analysis-panel-${tab.key}`}
tabIndex={activeTab === tab.key ? 0 : -1}
onClick={() => activateTab(tab.key)}
> >
<span className="pill-tab-code">{tab.code}</span> <span className="pill-tab-code">{tab.code}</span>
{tab.label} {tab.label}
@@ -154,144 +199,152 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
</div> </div>
{/* Chart Content */} {/* Chart Content */}
{activeTab === 'spend' && ( <div
<div> key={activeTab}
<div style={{ height: 300 }}> id={activeTabPanelId}
<VChart spec={spendBarSpec} /> className="model-analysis-tab-panel"
</div> role="tabpanel"
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}> aria-labelledby={activeTabId}
{spendDistribution.map(d => ( >
<span key={d.model} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--color-text-secondary)' }}> {activeTab === 'spend' && (
<InlineBrandIcon model={d.model} size={13} /> <div>
<span style={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.model}</span> <div style={{ height: 300 }}>
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{formatCurrency(d.spend)}</span> <VChart spec={spendBarSpec} />
</span> </div>
))} <div className="model-analysis-legend">
</div> {spendDistribution.map(d => (
</div> <span key={d.model} className="model-analysis-legend-item">
)}
{activeTab === 'trend' && (
<div style={{ height: 300 }}>
<VChart spec={trendSpec} />
</div>
)}
{activeTab === 'calls' && (
<div>
<div style={{ height: 300 }}>
<VChart spec={callsPieSpec} />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}>
{callsDistribution.map((d, idx) => {
const pieColors = ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
return (
<span key={d.model} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--color-text-secondary)' }}>
<span style={{ width: 8, height: 8, borderRadius: 2, background: pieColors[idx % pieColors.length], flexShrink: 0 }} />
<InlineBrandIcon model={d.model} size={13} /> <InlineBrandIcon model={d.model} size={13} />
<span style={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.model}</span> <span className="model-analysis-legend-name">{d.model}</span>
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{d.calls}</span> <span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{formatCurrency(d.spend)}</span>
</span> </span>
); ))}
})} </div>
</div> </div>
</div> )}
)}
{activeTab === 'rank' && ( {activeTab === 'trend' && (
<div style={{ overflow: 'hidden', border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-md)' }}> <div style={{ height: 300 }}>
<table className="data-table" style={{ width: '100%' }}> <VChart spec={trendSpec} />
<thead> </div>
<tr> )}
<th style={{ width: 36, textAlign: 'center' }}>#</th>
<th>模型</th>
<th style={{ textAlign: 'center' }}>调用</th>
<th style={{ textAlign: 'center' }}>成功率</th>
<th style={{ textAlign: 'center' }}>平均延迟</th>
<th style={{ textAlign: 'right' }}>消耗</th>
</tr>
</thead>
<tbody>
{callRanking.map((item, index) => {
const latMs = item.avgLatencyMs;
const latSec = latMs / 1000;
// ≤15s green, 15-60s gradient green→yellow→red, >60s or failed → red
let latColor: string;
let latBg: string;
if (latSec <= 15) {
// green gradient: 0s=#22c55e → 15s=blend towards yellow
const t = Math.min(latSec / 15, 1);
const r = Math.round(34 + t * (245 - 34));
const g = Math.round(197 + t * (158 - 197));
const b = Math.round(94 + t * (11 - 94));
latColor = `rgb(${r},${g},${b})`;
latBg = `rgba(${r},${g},${b},0.08)`;
} else if (latSec <= 60) {
// yellow→red gradient: 15s=#f59e0b → 60s=#ef4444
const t = Math.min((latSec - 15) / 45, 1);
const r = Math.round(245 + t * (239 - 245));
const g = Math.round(158 + t * (68 - 158));
const b = Math.round(11 + t * (68 - 11));
latColor = `rgb(${r},${g},${b})`;
latBg = `rgba(${r},${g},${b},0.08)`;
} else {
latColor = '#ef4444';
latBg = 'rgba(239,68,68,0.08)';
}
const latText = latMs >= 1000 ? `${(latMs / 1000).toFixed(latSec >= 60 ? 0 : 1)}s` : `${latMs}ms`;
const rateColor = item.successRate >= 90 ? '#16a34a' : item.successRate >= 60 ? '#d97706' : '#dc2626';
const rateBg = item.successRate >= 90 ? 'rgba(34,197,94,0.1)' : item.successRate >= 60 ? 'rgba(245,158,11,0.1)' : 'rgba(239,68,68,0.1)';
{activeTab === 'calls' && (
<div>
<div style={{ height: 300 }}>
<VChart spec={callsPieSpec} />
</div>
<div className="model-analysis-legend">
{callsDistribution.map((d, idx) => {
const pieColors = ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
return ( return (
<tr key={item.model}> <span key={d.model} className="model-analysis-legend-item">
<td style={{ textAlign: 'center', padding: '8px 4px' }}> <span style={{ width: 8, height: 8, borderRadius: 2, background: pieColors[idx % pieColors.length], flexShrink: 0 }} />
<span style={{ <InlineBrandIcon model={d.model} size={13} />
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', <span className="model-analysis-legend-name">{d.model}</span>
width: 22, height: 22, borderRadius: 6, fontSize: 11, fontWeight: 700, <span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{d.calls}</span>
background: index < 3 </span>
? ['linear-gradient(135deg,#fbbf24,#f59e0b)', 'linear-gradient(135deg,#94a3b8,#cbd5e1)', 'linear-gradient(135deg,#d97706,#fbbf24)'][index]
: 'var(--color-bg)',
color: index < 3 ? '#fff' : 'var(--color-text-muted)',
}}>
{index + 1}
</span>
</td>
<td>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<InlineBrandIcon model={item.model} size={14} />
<code style={{ fontSize: 12, fontWeight: 500 }}>{item.model}</code>
</span>
</td>
<td style={{ textAlign: 'center', fontWeight: 600, fontVariantNumeric: 'tabular-nums', fontSize: 13 }}>
{Math.round(item.calls).toLocaleString()}
</td>
<td style={{ textAlign: 'center' }}>
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 600,
background: rateBg, color: rateColor,
}}>
{formatPercent(item.successRate)}
</span>
</td>
<td style={{ textAlign: 'center' }}>
<span style={{
fontVariantNumeric: 'tabular-nums', fontSize: 12, fontWeight: 600,
color: latColor, background: latBg,
padding: '2px 8px', borderRadius: 4,
}}>
{latText}
</span>
</td>
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 500, fontSize: 13 }}>
{formatCurrency(item.spend)}
</td>
</tr>
); );
})} })}
</tbody> </div>
</table> </div>
</div> )}
)}
{activeTab === 'rank' && (
<div style={{ overflow: 'hidden', border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-md)' }}>
<table className="data-table" style={{ width: '100%' }}>
<thead>
<tr>
<th style={{ width: 36, textAlign: 'center' }}>#</th>
<th>模型</th>
<th style={{ textAlign: 'center' }}>调用</th>
<th style={{ textAlign: 'center' }}>成功率</th>
<th style={{ textAlign: 'center' }}>平均延迟</th>
<th style={{ textAlign: 'right' }}>消耗</th>
</tr>
</thead>
<tbody>
{callRanking.map((item, index) => {
const latMs = item.avgLatencyMs;
const latSec = latMs / 1000;
// ≤15s green, 15-60s gradient green→yellow→red, >60s or failed → red
let latColor: string;
let latBg: string;
if (latSec <= 15) {
// green gradient: 0s=#22c55e → 15s=blend towards yellow
const t = Math.min(latSec / 15, 1);
const r = Math.round(34 + t * (245 - 34));
const g = Math.round(197 + t * (158 - 197));
const b = Math.round(94 + t * (11 - 94));
latColor = `rgb(${r},${g},${b})`;
latBg = `rgba(${r},${g},${b},0.08)`;
} else if (latSec <= 60) {
// yellow→red gradient: 15s=#f59e0b → 60s=#ef4444
const t = Math.min((latSec - 15) / 45, 1);
const r = Math.round(245 + t * (239 - 245));
const g = Math.round(158 + t * (68 - 158));
const b = Math.round(11 + t * (68 - 11));
latColor = `rgb(${r},${g},${b})`;
latBg = `rgba(${r},${g},${b},0.08)`;
} else {
latColor = '#ef4444';
latBg = 'rgba(239,68,68,0.08)';
}
const latText = latMs >= 1000 ? `${(latMs / 1000).toFixed(latSec >= 60 ? 0 : 1)}s` : `${latMs}ms`;
const rateColor = item.successRate >= 90 ? '#16a34a' : item.successRate >= 60 ? '#d97706' : '#dc2626';
const rateBg = item.successRate >= 90 ? 'rgba(34,197,94,0.1)' : item.successRate >= 60 ? 'rgba(245,158,11,0.1)' : 'rgba(239,68,68,0.1)';
return (
<tr key={item.model}>
<td style={{ textAlign: 'center', padding: '8px 4px' }}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 22, height: 22, borderRadius: 6, fontSize: 11, fontWeight: 700,
background: index < 3
? ['linear-gradient(135deg,#fbbf24,#f59e0b)', 'linear-gradient(135deg,#94a3b8,#cbd5e1)', 'linear-gradient(135deg,#d97706,#fbbf24)'][index]
: 'var(--color-bg)',
color: index < 3 ? '#fff' : 'var(--color-text-muted)',
}}>
{index + 1}
</span>
</td>
<td>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<InlineBrandIcon model={item.model} size={14} />
<code style={{ fontSize: 12, fontWeight: 500 }}>{item.model}</code>
</span>
</td>
<td style={{ textAlign: 'center', fontWeight: 600, fontVariantNumeric: 'tabular-nums', fontSize: 13 }}>
{Math.round(item.calls).toLocaleString()}
</td>
<td style={{ textAlign: 'center' }}>
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 600,
background: rateBg, color: rateColor,
}}>
{formatPercent(item.successRate)}
</span>
</td>
<td style={{ textAlign: 'center' }}>
<span style={{
fontVariantNumeric: 'tabular-nums', fontSize: 12, fontWeight: 600,
color: latColor, background: latBg,
padding: '2px 8px', borderRadius: 4,
}}>
{latText}
</span>
</td>
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 500, fontSize: 13 }}>
{formatCurrency(item.spend)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div> </div>
); );
} }
+281 -16
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer'; import { act, create, type ReactTestInstance } from 'react-test-renderer';
import ModernSelect from './ModernSelect.js'; import ModernSelect from './ModernSelect.js';
@@ -17,6 +17,33 @@ function collectInstanceText(node: ReactTestInstance): string {
}).join(''); }).join('');
} }
function findTrigger(root: ReturnType<typeof create>) {
return root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-trigger')
));
}
function findOptionButtons(root: ReturnType<typeof create>) {
return root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-option')
));
}
function findListbox(root: ReturnType<typeof create>) {
return root.root.find((node) => node.props.role === 'listbox');
}
function keyEvent(key: string) {
return {
key,
preventDefault: vi.fn(),
};
}
describe('ModernSelect', () => { describe('ModernSelect', () => {
it('renders icon nodes for the selected option', () => { it('renders icon nodes for the selected option', () => {
const root = create( const root = create(
@@ -53,11 +80,7 @@ describe('ModernSelect', () => {
/>, />,
); );
const trigger = root.root.find((node) => ( const trigger = findTrigger(root);
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-trigger')
));
await act(async () => { await act(async () => {
trigger.props.onClick(); trigger.props.onClick();
@@ -72,22 +95,264 @@ describe('ModernSelect', () => {
searchInput.props.onChange({ target: { value: 'codex' } }); searchInput.props.onChange({ target: { value: 'codex' } });
}); });
let optionButtons = root.root.findAll((node) => ( let optionButtons = findOptionButtons(root);
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-option')
));
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual(['Beta Relay (codex)https://beta.example.com']); expect(optionButtons.map((node) => collectInstanceText(node))).toEqual(['Beta Relay (codex)https://beta.example.com']);
await act(async () => { await act(async () => {
searchInput.props.onChange({ target: { value: 'third.example.com' } }); searchInput.props.onChange({ target: { value: 'third.example.com' } });
}); });
optionButtons = root.root.findAll((node) => ( optionButtons = findOptionButtons(root);
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-option')
));
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual(['Gamma APIhttps://third.example.com']); expect(optionButtons.map((node) => collectInstanceText(node))).toEqual(['Gamma APIhttps://third.example.com']);
}); });
it('keeps closed searchable options out of the tab order', async () => {
const root = create(
<ModernSelect
searchable
searchPlaceholder="筛选站点"
value=""
onChange={() => {}}
options={[
{ value: 'alpha', label: 'Alpha Mirror' },
{ value: 'beta', label: 'Beta Relay', disabled: true },
{ value: 'gamma', label: 'Gamma API' },
]}
/>,
);
expect(findListbox(root).props['aria-hidden']).toBe(true);
expect(root.root.findByProps({ placeholder: '筛选站点' }).props.tabIndex).toBe(-1);
expect(findOptionButtons(root).map((node) => node.props.tabIndex)).toEqual([-1, -1, -1]);
await act(async () => {
findTrigger(root).props.onClick();
});
expect(findListbox(root).props['aria-hidden']).toBe(false);
expect(root.root.findByProps({ placeholder: '筛选站点' }).props.tabIndex).toBe(0);
expect(findOptionButtons(root).map((node) => node.props.tabIndex)).toEqual([0, -1, 0]);
});
it('falls back to stable text when options are missing labels', async () => {
const root = create(
<ModernSelect
searchable
value="codex"
onChange={() => {}}
options={[
{ value: 'codex', label: undefined, description: undefined },
{ value: 'claude', label: '', description: null },
{ value: '', label: null, description: '' },
]}
/>,
);
expect(collectText(root.root)).toContain('codex');
expect(collectText(root.root)).not.toContain('undefined');
const trigger = findTrigger(root);
await act(async () => {
trigger.props.onClick();
});
const optionButtons = findOptionButtons(root);
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual([
'codex',
'claude',
]);
});
it('treats literal undefined/null option text as missing data', async () => {
const root = create(
<ModernSelect
searchable
value="codex"
onChange={() => {}}
options={[
{ value: 'codex', label: 'undefined', description: 'null' },
{ value: 'undefined', label: 'undefined', description: 'undefined' },
{ value: 'null', label: 'null', description: 'null' },
]}
/>,
);
expect(collectText(root.root)).toContain('codex');
expect(collectText(root.root)).not.toContain('undefined');
expect(collectText(root.root)).not.toContain('null');
const trigger = findTrigger(root);
await act(async () => {
trigger.props.onClick();
});
const optionButtons = findOptionButtons(root);
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual([
'codex',
]);
});
it('drops fully empty options after normalizing polluted text', async () => {
const root = create(
<ModernSelect
searchable
value=""
onChange={() => {}}
options={[
{ value: 'undefined', label: 'undefined', description: 'undefined' },
{ value: 'null', label: 'null', description: 'null' },
{ value: '', label: '全部 Provider', description: '' },
{ value: 'codex', label: 'Codex', description: 'codex' },
]}
/>,
);
const trigger = findTrigger(root);
await act(async () => {
trigger.props.onClick();
});
const optionButtons = findOptionButtons(root);
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual([
'全部 Provider',
'Codexcodex',
]);
expect(collectText(root.root)).not.toContain('undefined');
expect(collectText(root.root)).not.toContain('null');
});
it('supports listbox semantics and keyboard selection while skipping disabled options', async () => {
const onChange = vi.fn();
const root = create(
<ModernSelect
value=""
onChange={onChange}
options={[
{ value: 'codex', label: 'Codex' },
{ value: 'disabled', label: 'Disabled Provider', disabled: true },
{ value: 'claude', label: 'Claude' },
]}
/>,
);
const trigger = findTrigger(root);
const openEvent = keyEvent('ArrowDown');
await act(async () => {
trigger.props.onKeyDown(openEvent);
});
expect(openEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(trigger.props.role).toBe('combobox');
expect(trigger.props['aria-haspopup']).toBe('listbox');
expect(trigger.props['aria-expanded']).toBe(true);
expect(trigger.props['aria-controls']).toBeTruthy();
expect(trigger.props['aria-activedescendant']).toContain('option-0');
let optionButtons = findOptionButtons(root);
expect(optionButtons.map((node) => node.props.role)).toEqual(['option', 'option', 'option']);
expect(optionButtons[0]!.props['aria-selected']).toBe(false);
expect(String(optionButtons[0]!.props.className)).toContain('is-highlighted');
await act(async () => {
trigger.props.onKeyDown(keyEvent('ArrowDown'));
});
optionButtons = findOptionButtons(root);
expect(String(optionButtons[1]!.props.className)).not.toContain('is-highlighted');
expect(String(optionButtons[2]!.props.className)).toContain('is-highlighted');
const enterEvent = keyEvent('Enter');
await act(async () => {
trigger.props.onKeyDown(enterEvent);
});
expect(enterEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('claude');
expect(trigger.props['aria-expanded']).toBe(false);
});
it('opens from the last enabled option when pressing ArrowUp from a closed trigger', async () => {
const root = create(
<ModernSelect
value=""
onChange={() => {}}
options={[
{ value: 'codex', label: 'Codex' },
{ value: 'disabled', label: 'Disabled Provider', disabled: true },
{ value: 'claude', label: 'Claude' },
]}
/>,
);
const trigger = findTrigger(root);
await act(async () => {
trigger.props.onKeyDown(keyEvent('ArrowUp'));
});
const optionButtons = findOptionButtons(root);
expect(String(optionButtons[0]!.props.className)).not.toContain('is-highlighted');
expect(String(optionButtons[1]!.props.className)).not.toContain('is-highlighted');
expect(String(optionButtons[2]!.props.className)).toContain('is-highlighted');
expect(trigger.props['aria-activedescendant']).toContain('option-2');
});
it('supports keyboard selection while focus is inside the search input', async () => {
const onChange = vi.fn();
const root = create(
<ModernSelect
searchable
searchPlaceholder="筛选 Provider"
value=""
onChange={onChange}
options={[
{ value: 'codex', label: 'Codex' },
{ value: 'codex-disabled', label: 'Codex Disabled', disabled: true },
{ value: 'codex-plus', label: 'Codex Plus' },
{ value: 'claude', label: 'Claude' },
]}
/>,
);
await act(async () => {
findTrigger(root).props.onClick();
});
const searchInput = root.root.findByProps({ placeholder: '筛选 Provider' });
await act(async () => {
searchInput.props.onChange({ target: { value: 'codex' } });
});
await act(async () => {
searchInput.props.onKeyDown(keyEvent('ArrowDown'));
});
const optionButtons = findOptionButtons(root);
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual([
'Codex',
'Codex Disabled',
'Codex Plus',
]);
expect(String(optionButtons[1]!.props.className)).not.toContain('is-highlighted');
expect(String(optionButtons[2]!.props.className)).toContain('is-highlighted');
const spaceEvent = keyEvent(' ');
await act(async () => {
searchInput.props.onKeyDown(spaceEvent);
});
expect(spaceEvent.preventDefault).not.toHaveBeenCalled();
expect(onChange).not.toHaveBeenCalled();
const enterEvent = keyEvent('Enter');
await act(async () => {
searchInput.props.onKeyDown(enterEvent);
});
expect(enterEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('codex-plus');
});
}); });
+189 -19
View File
@@ -1,13 +1,21 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import {
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from 'react';
type ModernSelectOption = { type ModernSelectOption = {
value: string; value: string;
label: string; label?: string | null;
description?: string; description?: string | null;
disabled?: boolean; disabled?: boolean;
iconNode?: ReactNode; iconNode?: ReactNode;
iconUrl?: string; iconUrl?: string | null;
iconText?: string; iconText?: string | null;
}; };
type ModernSelectProps = { type ModernSelectProps = {
@@ -25,6 +33,14 @@ type ModernSelectProps = {
searchPlaceholder?: string; searchPlaceholder?: string;
}; };
function resolveSelectText(value: unknown): string {
if (typeof value !== 'string') return '';
const text = value.trim();
const lowerText = text.toLowerCase();
if (lowerText === 'undefined' || lowerText === 'null') return '';
return text;
}
export default function ModernSelect({ export default function ModernSelect({
value, value,
onChange, onChange,
@@ -41,18 +57,54 @@ export default function ModernSelect({
}: ModernSelectProps) { }: ModernSelectProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const keyboardHighlightPendingRef = useRef(false);
const panelId = useId();
const normalizedOptions = useMemo(
() => options.flatMap((item, index) => {
const optionValue = resolveSelectText(item.value);
const rawLabel = resolveSelectText(item.label);
const optionDescription = resolveSelectText(item.description);
const optionIconUrl = resolveSelectText(item.iconUrl);
const optionIconText = resolveSelectText(item.iconText);
const hasVisibleContent = Boolean(
optionValue
|| rawLabel
|| optionDescription
|| optionIconUrl
|| optionIconText
|| item.iconNode,
);
if (!hasVisibleContent) return [];
const optionLabel = rawLabel
|| optionValue
|| `未命名选项 ${index + 1}`;
return [{
...item,
value: optionValue,
label: optionLabel,
description: optionDescription || undefined,
iconUrl: optionIconUrl || undefined,
iconText: optionIconText || undefined,
}];
}),
[options],
);
const selected = useMemo( const selected = useMemo(
() => options.find((item) => item.value === value), () => normalizedOptions.find((item) => item.value === value),
[options, value], [normalizedOptions, value],
); );
const visibleOptions = useMemo(() => { const visibleOptions = useMemo(() => {
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
if (!searchable || !query) return options; if (!searchable || !query) return normalizedOptions;
return options.filter((item) => { return normalizedOptions.filter((item) => {
const haystack = [ const haystack = [
item.label, item.label,
item.description, item.description,
@@ -63,7 +115,31 @@ export default function ModernSelect({
.toLowerCase(); .toLowerCase();
return haystack.includes(query); return haystack.includes(query);
}); });
}, [options, searchQuery, searchable]); }, [normalizedOptions, searchQuery, searchable]);
const findNextEnabledIndex = (
startIndex: number,
direction: 1 | -1,
): number => {
if (visibleOptions.length === 0) return -1;
for (let step = 0; step < visibleOptions.length; step += 1) {
const index = (startIndex + step * direction + visibleOptions.length) % visibleOptions.length;
if (!visibleOptions[index]?.disabled) return index;
}
return -1;
};
useEffect(() => {
if (!open) return;
if (keyboardHighlightPendingRef.current) {
keyboardHighlightPendingRef.current = false;
return;
}
const selectedIndex = visibleOptions.findIndex((item) => item.value === value && !item.disabled);
setHighlightedIndex(selectedIndex >= 0 ? selectedIndex : findNextEnabledIndex(0, 1));
}, [open, value, visibleOptions]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -76,7 +152,7 @@ export default function ModernSelect({
} }
}; };
const handleEscape = (event: KeyboardEvent) => { const handleEscape = (event: globalThis.KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false); if (event.key === 'Escape') setOpen(false);
}; };
@@ -111,6 +187,77 @@ export default function ModernSelect({
return null; return null;
}; };
const selectOption = (index: number) => {
const item = visibleOptions[index];
if (!item || item.disabled) return;
onChange(item.value);
setHighlightedIndex(-1);
setOpen(false);
};
const moveHighlight = (direction: 1 | -1) => {
keyboardHighlightPendingRef.current = true;
setOpen(true);
setHighlightedIndex((current) => {
const startIndex = current >= 0
? current + direction
: direction === 1
? 0
: visibleOptions.length - 1;
return findNextEnabledIndex(startIndex, direction);
});
};
const handleTriggerKeyDown = (
event: ReactKeyboardEvent<HTMLButtonElement>,
) => {
if (disabled) return;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
moveHighlight(event.key === 'ArrowDown' ? 1 : -1);
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (!open) {
setOpen(true);
return;
}
selectOption(highlightedIndex);
return;
}
if (event.key === 'Escape' && open) {
event.preventDefault();
setHighlightedIndex(-1);
setOpen(false);
}
};
const handleSearchKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
moveHighlight(event.key === 'ArrowDown' ? 1 : -1);
return;
}
if (event.key === 'Enter') {
event.preventDefault();
selectOption(highlightedIndex);
return;
}
if (event.key === 'Escape' && open) {
event.preventDefault();
setHighlightedIndex(-1);
setOpen(false);
}
};
return ( return (
<div <div
ref={rootRef} ref={rootRef}
@@ -121,9 +268,19 @@ export default function ModernSelect({
type="button" type="button"
className="modern-select-trigger" className="modern-select-trigger"
onClick={() => { onClick={() => {
if (!disabled) setOpen((prev) => !prev); if (!disabled) {
setOpen((prev) => {
if (prev) setHighlightedIndex(-1);
return !prev;
});
}
}} }}
onKeyDown={handleTriggerKeyDown}
role="combobox"
aria-haspopup="listbox"
aria-expanded={open} aria-expanded={open}
aria-controls={panelId}
aria-activedescendant={open && highlightedIndex >= 0 ? `${panelId}-option-${highlightedIndex}` : undefined}
disabled={disabled} disabled={disabled}
> >
<span className={`modern-select-value ${selected ? '' : 'is-placeholder'}`.trim()}> <span className={`modern-select-value ${selected ? '' : 'is-placeholder'}`.trim()}>
@@ -148,15 +305,23 @@ export default function ModernSelect({
</svg> </svg>
</button> </button>
<div className="modern-select-panel" style={{ maxHeight: menuMaxHeight }}> <div
id={panelId}
className="modern-select-panel"
style={{ maxHeight: menuMaxHeight }}
role="listbox"
aria-hidden={!open}
>
{searchable && ( {searchable && (
<div className="modern-select-search-shell"> <div className="modern-select-search-shell">
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)} onChange={(event) => setSearchQuery(event.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
className="modern-select-search-input" className="modern-select-search-input"
tabIndex={open ? 0 : -1}
/> />
</div> </div>
)} )}
@@ -164,19 +329,24 @@ export default function ModernSelect({
{visibleOptions.length === 0 ? ( {visibleOptions.length === 0 ? (
<div className="modern-select-empty">{emptyLabel}</div> <div className="modern-select-empty">{emptyLabel}</div>
) : ( ) : (
visibleOptions.map((item) => { visibleOptions.map((item, index) => {
const active = item.value === value; const active = item.value === value;
return ( return (
<button <button
key={item.value} id={`${panelId}-option-${index}`}
key={`${item.value || 'option'}-${index}`}
type="button" type="button"
className={`modern-select-option ${active ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`.trim()} className={`modern-select-option ${active ? 'is-active' : ''} ${highlightedIndex === index ? 'is-highlighted' : ''} ${item.disabled ? 'is-disabled' : ''}`.trim()}
role="option"
aria-selected={active}
onClick={() => { onClick={() => {
if (item.disabled) return; selectOption(index);
onChange(item.value); }}
setOpen(false); onMouseEnter={() => {
if (!item.disabled) setHighlightedIndex(index);
}} }}
disabled={item.disabled} disabled={item.disabled}
tabIndex={open && !item.disabled ? 0 : -1}
> >
<div className="modern-select-option-main"> <div className="modern-select-option-main">
{renderOptionIcon(item)} {renderOptionIcon(item)}
+273 -27
View File
@@ -1,10 +1,11 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { api } from '../api.js'; import { api, userApi } from '../api.js';
import { formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js'; import { formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
import { buildEventNavigationPath } from '../pages/helpers/navigationFocus.js'; import { buildEventNavigationPath } from '../pages/helpers/navigationFocus.js';
import { useI18n } from '../i18n.js'; import { useI18n } from '../i18n.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js'; import { useAnimatedVisibility } from './useAnimatedVisibility.js';
import DiagnosticActionGroup, { type DiagnosticAction } from './DiagnosticActionGroup.js';
const levelColors: Record<string, string> = { const levelColors: Record<string, string> = {
info: 'var(--color-info)', info: 'var(--color-info)',
@@ -19,48 +20,220 @@ const typeLabels: Record<string, string> = {
proxy: '代理', proxy: '代理',
status: '状态', status: '状态',
site_notice: '站点公告', site_notice: '站点公告',
user_announcement: '用户公告',
}; };
type NotificationAudience = 'admin' | 'user';
const adminFilterTypes = ['', 'checkin', 'balance', 'token', 'proxy', 'status', 'site_notice'];
const userFilterTypes = ['', 'user_announcement'];
function normalizeUserAnnouncement(row: any) {
return {
id: row?.id,
type: 'user_announcement',
level: row?.level || 'info',
title: row?.title || '用户公告',
message: row?.content || '',
read: !!row?.read,
relatedType: 'user_announcement',
relatedId: row?.id,
createdAt: row?.publishedAt || row?.firstSeenAt || row?.lastSeenAt || row?.createdAt || row?.created_at || null,
};
}
function buildUserNotificationNavigationPath(event: any): string {
if (String(event?.relatedType || '').toLowerCase() === 'user_announcement') {
return '/user/announcements';
}
return '/user/announcements';
}
function buildNotificationDiagnosticActions(event: any, targetPath: string): DiagnosticAction[] {
const relatedType = String(event?.relatedType || '').toLowerCase();
const eventType = String(event?.type || '').toLowerCase();
const targetIsGeneric = !targetPath || targetPath === '/events';
const tone = event?.level === 'error' || event?.level === 'warning' ? 'danger' : 'primary';
if (relatedType === 'proxy_log' || relatedType === 'proxy') {
return [{
label: '查看日志',
href: targetPath,
hint: '打开这条代理请求',
tone,
}];
}
if (relatedType === 'proxy_debug_trace' || relatedType === 'debug_trace') {
return [{
label: '查看追踪',
href: targetPath,
hint: '定位原始下游请求',
tone,
}];
}
if (relatedType === 'route') {
return [{
label: '诊断路由',
href: targetPath,
hint: '打开相关路由配置',
tone,
}];
}
if (relatedType === 'site') {
return [{
label: '站点管理',
href: targetPath,
hint: '检查站点健康和代理',
tone,
}];
}
if (relatedType === 'account') {
return [{
label: eventType === 'token' ? '重新绑定' : '账号详情',
href: targetPath,
hint: eventType === 'token' ? '更新连接凭证' : '检查账号状态',
tone,
}];
}
if (relatedType === 'site_announcement') {
return [{
label: '查看公告',
href: targetPath,
hint: '打开站点公告详情',
tone: 'primary',
}];
}
if (relatedType === 'user_announcement') {
return [{
label: '查看公告',
href: targetPath,
hint: '打开用户公告',
tone: 'primary',
}];
}
if (eventType === 'proxy' && !targetIsGeneric) {
return [{
label: '打开日志',
href: targetPath,
hint: '查看代理请求',
tone,
}];
}
return [{
label: '查看详情',
href: targetIsGeneric ? '/events' : targetPath,
hint: '打开运行事件中心',
tone: 'muted',
}];
}
export default function NotificationPanel({ export default function NotificationPanel({
open, open,
onClose, onClose,
anchorRef, anchorRef,
onUnreadCountChange, onUnreadCountChange,
panelId,
audience = 'admin',
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
anchorRef: React.RefObject<HTMLButtonElement | null>; anchorRef: React.RefObject<HTMLButtonElement | null>;
onUnreadCountChange?: (count: number) => void; onUnreadCountChange?: (count: number) => void;
panelId?: string;
audience?: NotificationAudience;
}) { }) {
const { t: tr } = useI18n(); const { t: tr } = useI18n();
const presence = useAnimatedVisibility(open, 160); const presence = useAnimatedVisibility(open, 160);
const [events, setEvents] = useState<any[]>([]); const [events, setEvents] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<string>(''); const [filter, setFilter] = useState<string>('');
const [activeEventIndex, setActiveEventIndex] = useState(-1);
const [activeFocusRequest, setActiveFocusRequest] = useState(0);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const eventRefs = useRef<Array<HTMLDivElement | null>>([]);
const mountedRef = useRef(true);
const openRef = useRef(open);
const loadSeqRef = useRef(0);
const focusActiveEventRef = useRef(false);
const navigate = useNavigate(); const navigate = useNavigate();
const focusAnchor = useCallback(() => {
const anchor = anchorRef.current;
if (!anchor || anchor.isConnected === false) return;
anchor.focus({ preventScroll: true });
}, [anchorRef]);
const load = useCallback(async () => { const load = useCallback(async () => {
const loadSeq = ++loadSeqRef.current;
setLoading(true); setLoading(true);
try { try {
const params = filter ? `type=${filter}` : ''; const params = filter ? `type=${filter}` : '';
const data = await api.getEvents(params); let data: any[];
if (audience === 'user') {
const announcements = await userApi.getAnnouncements('limit=50');
data = Array.isArray(announcements)
? announcements.map(normalizeUserAnnouncement)
: [];
} else {
data = await api.getEvents(params);
}
if (!mountedRef.current || !openRef.current || loadSeqRef.current !== loadSeq) return;
setEvents(data); setEvents(data);
// Auto mark all as read on open // Auto mark all as read on open
const hasUnread = Array.isArray(data) && data.some((e: any) => !e.read); const hasUnread = Array.isArray(data) && data.some((e: any) => !e.read);
if (hasUnread) { if (hasUnread) {
api.markAllEventsRead().catch(() => {}); if (audience === 'user') {
userApi.markAllAnnouncementsRead().catch(() => {});
} else {
api.markAllEventsRead().catch(() => {});
}
onUnreadCountChange?.(0); onUnreadCountChange?.(0);
} }
} catch { /* ignore */ } } catch { /* ignore */ }
finally { setLoading(false); } finally {
}, [filter, onUnreadCountChange]); if (mountedRef.current && openRef.current && loadSeqRef.current === loadSeq) {
setLoading(false);
}
}
}, [audience, filter, onUnreadCountChange]);
useEffect(() => () => {
mountedRef.current = false;
loadSeqRef.current += 1;
}, []);
useEffect(() => { useEffect(() => {
if (open) load(); openRef.current = open;
if (!open) {
loadSeqRef.current += 1;
focusActiveEventRef.current = false;
setLoading(false);
setActiveEventIndex(-1);
}
}, [open]);
useEffect(() => {
if (!open) return;
panelRef.current?.focus({ preventScroll: true });
load();
}, [open, load]); }, [open, load]);
useEffect(() => {
focusActiveEventRef.current = false;
setActiveEventIndex(-1);
}, [audience, filter]);
useEffect(() => {
if (activeEventIndex < 0) return;
const activeEvent = eventRefs.current[activeEventIndex];
activeEvent?.scrollIntoView({ block: 'nearest' });
if (focusActiveEventRef.current) {
activeEvent?.focus({ preventScroll: true });
focusActiveEventRef.current = false;
}
}, [activeEventIndex, activeFocusRequest]);
useEffect(() => { useEffect(() => {
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
if ( if (
@@ -74,54 +247,109 @@ export default function NotificationPanel({
return () => document.removeEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler);
}, [open, onClose, anchorRef]); }, [open, onClose, anchorRef]);
useEffect(() => {
if (!open) return undefined;
const handler = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
onClose();
focusAnchor();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [focusAnchor, open, onClose]);
const clearAll = async () => { const clearAll = async () => {
await api.clearEvents(); if (audience === 'user') {
await userApi.markAllAnnouncementsRead();
} else {
await api.clearEvents();
}
if (!mountedRef.current || !openRef.current) return;
setEvents([]); setEvents([]);
setActiveEventIndex(-1);
onUnreadCountChange?.(0); onUnreadCountChange?.(0);
}; };
const moveActiveEvent = (direction: 1 | -1) => {
if (!events.length) return;
focusActiveEventRef.current = true;
setActiveEventIndex((current) => {
if (current < 0) return direction > 0 ? 0 : events.length - 1;
return (current + direction + events.length) % events.length;
});
setActiveFocusRequest((request) => request + 1);
};
if (!presence.shouldRender) return null; if (!presence.shouldRender) return null;
const interactive = open && presence.isVisible;
return ( return (
<div <div
id={panelId}
ref={panelRef} ref={panelRef}
className={`user-dropdown ${presence.isVisible ? '' : 'is-closing'}`.trim()} className={`user-dropdown notification-panel ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ right: 0, top: '100%', width: 360, maxHeight: 480, padding: 0, marginTop: 4 }} role="dialog"
aria-label={tr('通知')}
aria-hidden={!interactive}
tabIndex={-1}
style={{ right: 0, top: '100%', padding: 0, marginTop: 4 }}
onKeyDown={(event) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
moveActiveEvent(event.key === 'ArrowDown' ? 1 : -1);
}
}}
> >
{/* Header */} {/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: '1px solid var(--color-border-light)' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: '1px solid var(--color-border-light)' }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{tr('通知')}</span> <span style={{ fontWeight: 600, fontSize: 14 }}>{tr('通知')}</span>
<button onClick={clearAll} className="btn btn-link"> <button type="button" onClick={clearAll} className="btn btn-link" tabIndex={interactive ? 0 : -1}>
{tr('清空')} {tr('清空')}
</button> </button>
</div> </div>
{/* Filters */} {/* Filters */}
<div style={{ display: 'flex', gap: 4, padding: '8px 12px', borderBottom: '1px solid var(--color-border-light)', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 4, padding: '8px 12px', borderBottom: '1px solid var(--color-border-light)', flexWrap: 'wrap' }}>
{['', 'checkin', 'balance', 'token', 'proxy', 'status', 'site_notice'].map((filterType) => ( {(audience === 'user' ? userFilterTypes : adminFilterTypes).map((filterType) => {
<button key={filterType} onClick={() => setFilter(filterType)} const filterLabel = filterType ? tr(typeLabels[filterType] || filterType) : tr('全部');
style={{ const active = filter === filterType;
fontSize: 11, padding: '3px 8px', borderRadius: 12, return (
border: filter === filterType ? '1px solid var(--color-primary)' : '1px solid var(--color-border)', <button
background: filter === filterType ? 'var(--color-primary-light)' : 'transparent', key={filterType}
color: filter === filterType ? 'var(--color-primary)' : 'var(--color-text-muted)', type="button"
cursor: 'pointer', aria-pressed={active}
}}> aria-label={`${tr('通知筛选')}: ${filterLabel}`}
{filterType ? tr(typeLabels[filterType] || filterType) : tr('全部')} tabIndex={interactive ? 0 : -1}
</button> onClick={() => setFilter(filterType)}
))} style={{
fontSize: 11, padding: '3px 8px', borderRadius: 12,
border: active ? '1px solid var(--color-primary)' : '1px solid var(--color-border)',
background: active ? 'var(--color-primary-light)' : 'transparent',
color: active ? 'var(--color-primary)' : 'var(--color-text-muted)',
cursor: 'pointer',
}}
>
{filterLabel}
</button>
);
})}
</div> </div>
{/* Events list */} {/* Events list */}
<div style={{ maxHeight: 360, overflow: 'auto' }}> <div role="list" aria-busy={loading} aria-live="polite" className="notification-panel-list">
{loading && <div style={{ padding: 20, textAlign: 'center' }}><span className="spinner spinner-sm" /></div>} {loading && <div style={{ padding: 20, textAlign: 'center' }}><span className="spinner spinner-sm" /></div>}
{!loading && events.length === 0 && ( {!loading && events.length === 0 && (
<div style={{ padding: '32px 16px', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 13 }}> <div style={{ padding: '32px 16px', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 13 }}>
{tr('暂无通知')} {tr('暂无通知')}
</div> </div>
)} )}
{events.map((ev: any) => { {events.map((ev: any, index: number) => {
const targetPath = buildEventNavigationPath(ev); const targetPath = audience === 'user'
? buildUserNotificationNavigationPath(ev)
: buildEventNavigationPath(ev);
const diagnosticActions = buildNotificationDiagnosticActions(ev, targetPath);
const active = activeEventIndex === index;
const openTarget = () => { const openTarget = () => {
onClose(); onClose();
navigate(targetPath); navigate(targetPath);
@@ -129,7 +357,10 @@ export default function NotificationPanel({
return ( return (
<div <div
key={ev.id} key={ev.id}
className="notification-event-item" ref={(node) => {
eventRefs.current[index] = node;
}}
className={`notification-event-item ${active ? 'is-active' : ''}`.trim()}
style={{ style={{
padding: '10px 16px', padding: '10px 16px',
borderBottom: '1px solid var(--color-border-light)', borderBottom: '1px solid var(--color-border-light)',
@@ -140,8 +371,18 @@ export default function NotificationPanel({
}} }}
onClick={openTarget} onClick={openTarget}
role="button" role="button"
tabIndex={0} tabIndex={interactive ? 0 : -1}
aria-current={active ? 'true' : undefined}
onMouseEnter={() => {
focusActiveEventRef.current = false;
setActiveEventIndex(index);
}}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
moveActiveEvent(event.key === 'ArrowDown' ? 1 : -1);
return;
}
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault(); event.preventDefault();
openTarget(); openTarget();
@@ -163,6 +404,11 @@ export default function NotificationPanel({
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 4 }}> <div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 4 }}>
{formatDateTimeMinuteLocal(ev.createdAt)} {formatDateTimeMinuteLocal(ev.createdAt)}
</div> </div>
<DiagnosticActionGroup
actions={diagnosticActions}
className="notification-event-actions"
interactive={interactive}
/>
</div> </div>
</div> </div>
); );
@@ -11,8 +11,12 @@ describe('ResponsiveBatchActionBar', () => {
</ResponsiveBatchActionBar>, </ResponsiveBatchActionBar>,
); );
const bar = root.root.find((node) => node.props.className === 'mobile-actions-bar mobile-batch-bar'); const bar = root.root.find((node) => String(node.props.className || '').includes('mobile-batch-bar'));
expect(bar).toBeTruthy(); expect(bar).toBeTruthy();
expect(bar.props.className).toContain('responsive-batch-action-bar');
const info = root.root.find((node) => node.props.className === 'mobile-actions-info');
expect(info.props.role).toBe('status');
expect(info.props['aria-live']).toBe('polite');
expect(root.root.findByType('button').children).toContain('批量启用'); expect(root.root.findByType('button').children).toContain('批量启用');
}); });
@@ -23,8 +27,11 @@ describe('ResponsiveBatchActionBar', () => {
</ResponsiveBatchActionBar>, </ResponsiveBatchActionBar>,
); );
const card = root.root.find((node) => node.props.className === 'card'); const card = root.root.find((node) => String(node.props.className || '').includes('responsive-batch-action-bar'));
expect(card).toBeTruthy(); expect(card).toBeTruthy();
expect(root.root.findAllByType('span').some((node) => node.children.includes('已选 3 项'))).toBe(true); expect(card.props.className).toContain('card');
const info = root.root.find((node) => node.type === 'span' && node.children.includes('已选 3 项'));
expect(info.props.role).toBe('status');
expect(info.props['aria-live']).toBe('polite');
}); });
}); });
@@ -34,8 +34,11 @@ export default function ResponsiveBatchActionBar({
} }
return ( return (
<div className="card" style={{ ...DEFAULT_DESKTOP_STYLE, ...desktopStyle }}> <div
<span style={{ ...DEFAULT_INFO_STYLE, ...infoStyle }}>{info}</span> className="card responsive-batch-action-bar"
style={{ ...DEFAULT_DESKTOP_STYLE, ...desktopStyle }}
>
<span role="status" aria-live="polite" style={{ ...DEFAULT_INFO_STYLE, ...infoStyle }}>{info}</span>
{children} {children}
</div> </div>
); );
+6 -1
View File
@@ -32,6 +32,8 @@ export default function ResponsiveFilterPanel({
mobileTriggerButtonClassName = 'btn btn-ghost', mobileTriggerButtonClassName = 'btn btn-ghost',
mobileTriggerButtonStyle = { border: '1px solid var(--color-border)' }, mobileTriggerButtonStyle = { border: '1px solid var(--color-border)' },
}: ResponsiveFilterPanelProps) { }: ResponsiveFilterPanelProps) {
const mobilePanelId = React.useId();
if (!isMobile) { if (!isMobile) {
return <>{desktopContent}</>; return <>{desktopContent}</>;
} }
@@ -44,13 +46,16 @@ export default function ResponsiveFilterPanel({
type="button" type="button"
className={mobileTriggerButtonClassName} className={mobileTriggerButtonClassName}
style={mobileTriggerButtonStyle} style={mobileTriggerButtonStyle}
aria-haspopup="dialog"
aria-expanded={mobileOpen}
aria-controls={mobilePanelId}
onClick={onMobileOpen} onClick={onMobileOpen}
> >
{mobileTriggerLabel} {mobileTriggerLabel}
</button> </button>
</div> </div>
) : null)} ) : null)}
<MobileFilterSheet open={mobileOpen} onClose={onMobileClose} title={mobileTitle}> <MobileFilterSheet open={mobileOpen} onClose={onMobileClose} title={mobileTitle} panelId={mobilePanelId}>
{mobileContent} {mobileContent}
</MobileFilterSheet> </MobileFilterSheet>
</> </>
+271 -37
View File
@@ -1,9 +1,10 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback, useId, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import { formatDateLocal, formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js'; import { formatDateLocal, formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
import { buildAccountFocusPath, buildSiteFocusPath, buildTokenFocusPath } from '../pages/helpers/navigationFocus.js'; import { buildAccountFocusPath, buildSiteFocusPath, buildTokenFocusPath } from '../pages/helpers/navigationFocus.js';
import { useI18n } from '../i18n.js'; import { useI18n } from '../i18n.js';
import { trapFocusInContainer } from './focusTrap.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js'; import { useAnimatedVisibility } from './useAnimatedVisibility.js';
interface SiteResult { interface SiteResult {
@@ -65,25 +66,106 @@ interface SearchResult {
models: ModelSearchResult[]; models: ModelSearchResult[];
} }
export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) { type SearchResultAction = {
key: string;
path: string;
};
export default function SearchModal({
open,
onClose,
dialogId,
}: {
open: boolean;
onClose: () => void;
dialogId?: string;
}) {
const { t } = useI18n(); const { t } = useI18n();
const presence = useAnimatedVisibility(open, 180); const presence = useAnimatedVisibility(open, 180);
const titleId = useId();
const resultsId = useId();
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult | null>(null); const [results, setResults] = useState<SearchResult | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [activeResultIndex, setActiveResultIndex] = useState(-1);
const [activeFocusRequest, setActiveFocusRequest] = useState(0);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useRef<HTMLDivElement | null>(null);
const resultRefs = useRef<Array<HTMLButtonElement | null>>([]);
const lastActiveElementRef = useRef<HTMLElement | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const timerRef = useRef<number>(); const timerRef = useRef<number | null>(null);
const focusTimerRef = useRef<number | null>(null);
const mountedRef = useRef(true);
const openRef = useRef(open);
const searchSeqRef = useRef(0);
const focusActiveResultRef = useRef(false);
const canLockBodyScroll = typeof document !== 'undefined' && !!document.body?.style;
const clearSearchTimer = useCallback(() => {
if (timerRef.current === null) return;
window.clearTimeout(timerRef.current);
timerRef.current = null;
}, []);
const clearFocusTimer = useCallback(() => {
if (focusTimerRef.current === null) return;
window.clearTimeout(focusTimerRef.current);
focusTimerRef.current = null;
}, []);
useEffect(() => () => {
mountedRef.current = false;
clearSearchTimer();
clearFocusTimer();
}, [clearFocusTimer, clearSearchTimer]);
useEffect(() => {
openRef.current = open;
}, [open]);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
const activeElement = typeof document !== 'undefined' ? document.activeElement : null;
lastActiveElementRef.current = typeof HTMLElement !== 'undefined' && activeElement instanceof HTMLElement
? activeElement
: null;
searchSeqRef.current += 1;
focusActiveResultRef.current = false;
setQuery(''); setQuery('');
setResults(null); setResults(null);
setTimeout(() => inputRef.current?.focus(), 100); setActiveResultIndex(-1);
clearFocusTimer();
focusTimerRef.current = window.setTimeout(() => {
focusTimerRef.current = null;
inputRef.current?.focus();
}, 100);
} else {
searchSeqRef.current += 1;
focusActiveResultRef.current = false;
clearSearchTimer();
clearFocusTimer();
setLoading(false);
setActiveResultIndex(-1);
if (lastActiveElementRef.current?.isConnected !== false) {
lastActiveElementRef.current?.focus({ preventScroll: true });
}
lastActiveElementRef.current = null;
} }
}, [open]); }, [clearFocusTimer, clearSearchTimer, open]);
const doSearch = useCallback(async (q: string) => { useEffect(() => {
if (!open || !canLockBodyScroll) return undefined;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [canLockBodyScroll, open]);
const doSearch = useCallback(async (q: string, searchSeq: number) => {
if (!q.trim()) { if (!q.trim()) {
setResults(null); setResults(null);
return; return;
@@ -92,6 +174,7 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
setLoading(true); setLoading(true);
try { try {
const res = await api.search(q); const res = await api.search(q);
if (!mountedRef.current || !openRef.current || searchSeqRef.current !== searchSeq) return;
setResults({ setResults({
models: Array.isArray(res?.models) ? res.models : [], models: Array.isArray(res?.models) ? res.models : [],
accounts: Array.isArray(res?.accounts) ? res.accounts : [], accounts: Array.isArray(res?.accounts) ? res.accounts : [],
@@ -100,17 +183,25 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
checkinLogs: Array.isArray(res?.checkinLogs) ? res.checkinLogs : [], checkinLogs: Array.isArray(res?.checkinLogs) ? res.checkinLogs : [],
proxyLogs: Array.isArray(res?.proxyLogs) ? res.proxyLogs : [], proxyLogs: Array.isArray(res?.proxyLogs) ? res.proxyLogs : [],
}); });
setActiveResultIndex(-1);
focusActiveResultRef.current = false;
} catch { } catch {
// ignore search errors in modal // ignore search errors in modal
} finally { } finally {
setLoading(false); if (mountedRef.current && openRef.current && searchSeqRef.current === searchSeq) setLoading(false);
} }
}, []); }, []);
const handleInput = (val: string) => { const handleInput = (val: string) => {
setQuery(val); setQuery(val);
clearTimeout(timerRef.current); focusActiveResultRef.current = false;
timerRef.current = window.setTimeout(() => doSearch(val), 300); setActiveResultIndex(-1);
clearSearchTimer();
const searchSeq = searchSeqRef.current;
timerRef.current = window.setTimeout(() => {
timerRef.current = null;
void doSearch(val, searchSeq);
}, 300);
}; };
const goTo = (path: string) => { const goTo = (path: string) => {
@@ -119,8 +210,24 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
}; };
useEffect(() => { useEffect(() => {
if (activeResultIndex < 0) return;
const activeResult = resultRefs.current[activeResultIndex];
activeResult?.scrollIntoView({ block: 'nearest' });
if (focusActiveResultRef.current) {
activeResult?.focus({ preventScroll: true });
focusActiveResultRef.current = false;
}
}, [activeFocusRequest, activeResultIndex]);
useEffect(() => {
if (typeof document === 'undefined') return undefined;
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && open) onClose(); if (!open) return;
if (e.key === 'Escape') {
onClose();
return;
}
trapFocusInContainer(e, dialogRef.current);
}; };
document.addEventListener('keydown', handler); document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler);
@@ -128,6 +235,8 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
if (!presence.shouldRender) return null; if (!presence.shouldRender) return null;
const interactive = open && presence.isVisible;
const hasResults = results && ( const hasResults = results && (
results.models.length results.models.length
|| results.accounts.length || results.accounts.length
@@ -136,11 +245,103 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|| results.checkinLogs.length || results.checkinLogs.length
|| results.proxyLogs.length || results.proxyLogs.length
); );
const resultActions: SearchResultAction[] = results ? [
...results.models.map((model) => ({
key: `model:${model.name}`,
path: `/models?q=${encodeURIComponent(model.name)}`,
})),
...results.sites.map((site) => ({
key: `site:${site.id}`,
path: buildSiteFocusPath(site.id),
})),
...results.accounts.map((account) => ({
key: `account:${account.id}`,
path: buildAccountFocusPath(account.id, {
openRebind: account.status === 'expired',
segment: account.segment,
}),
})),
...results.accountTokens.map((token) => ({
key: `token:${token.id}`,
path: buildTokenFocusPath(token.id),
})),
...results.checkinLogs.map((log) => ({
key: `checkin:${log.id}`,
path: '/checkin',
})),
...results.proxyLogs.map((log) => ({
key: `proxy:${log.id}`,
path: '/logs',
})),
] : [];
const activeResultId = activeResultIndex >= 0 && activeResultIndex < resultActions.length
? `${resultsId}-option-${activeResultIndex}`
: undefined;
const moveActiveResult = (direction: 1 | -1, shouldFocusResult: boolean) => {
if (!resultActions.length) return;
focusActiveResultRef.current = shouldFocusResult;
setActiveResultIndex((current) => {
if (current < 0) return direction > 0 ? 0 : resultActions.length - 1;
return (current + direction + resultActions.length) % resultActions.length;
});
if (shouldFocusResult) {
setActiveFocusRequest((request) => request + 1);
}
};
const buildResultProps = (action: SearchResultAction, index: number) => ({
id: `${resultsId}-option-${index}`,
ref: (node: HTMLButtonElement | null) => {
resultRefs.current[index] = node;
},
role: 'option',
'aria-selected': activeResultIndex === index,
className: `search-result-item ${activeResultIndex === index ? 'is-active' : ''}`.trim(),
tabIndex: interactive ? 0 : -1,
onClick: () => goTo(action.path),
onMouseEnter: () => {
focusActiveResultRef.current = false;
setActiveResultIndex(index);
},
onKeyDown: (event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
moveActiveResult(event.key === 'ArrowDown' ? 1 : -1, true);
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
goTo(action.path);
}
},
});
const handleInputKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
if (!resultActions.length) return;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
moveActiveResult(event.key === 'ArrowDown' ? 1 : -1, false);
return;
}
if (event.key === 'Enter' && activeResultIndex >= 0 && activeResultIndex < resultActions.length) {
event.preventDefault();
goTo(resultActions[activeResultIndex].path);
}
};
let resultIndex = 0;
return ( return (
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}> <div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} style={{ maxWidth: 560, padding: 0 }} onClick={e => e.stopPropagation()}> <div
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px', borderBottom: '1px solid var(--color-border-light)' }}> id={dialogId}
ref={dialogRef}
className={`modal-content search-modal ${presence.isVisible ? '' : 'is-closing'}`.trim()}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-hidden={!interactive}
onClick={e => e.stopPropagation()}
>
<div className="search-modal-header">
<span id={titleId} className="sr-only">{t('全局搜索')}</span>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="var(--color-text-muted)"> <svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="var(--color-text-muted)">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg> </svg>
@@ -148,6 +349,12 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
ref={inputRef} ref={inputRef}
value={query} value={query}
onChange={e => handleInput(e.target.value)} onChange={e => handleInput(e.target.value)}
onKeyDown={handleInputKeyDown}
role="combobox"
aria-expanded={Boolean(hasResults)}
aria-controls={resultsId}
aria-activedescendant={activeResultId}
tabIndex={interactive ? 0 : -1}
placeholder={t('搜索站点、账号、模型、日志...')} placeholder={t('搜索站点、账号、模型、日志...')}
style={{ flex: 1, border: 'none', outline: 'none', fontSize: 14, background: 'transparent', color: 'var(--color-text-primary)' }} style={{ flex: 1, border: 'none', outline: 'none', fontSize: 14, background: 'transparent', color: 'var(--color-text-primary)' }}
/> />
@@ -155,7 +362,7 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
<kbd style={{ fontSize: 11, padding: '2px 6px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 4, color: 'var(--color-text-muted)' }}>ESC</kbd> <kbd style={{ fontSize: 11, padding: '2px 6px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 4, color: 'var(--color-text-muted)' }}>ESC</kbd>
</div> </div>
<div style={{ maxHeight: 400, overflow: 'auto', padding: '8px 0' }}> <div id={resultsId} role="listbox" aria-hidden={!interactive} aria-busy={loading} aria-live="polite" className="search-modal-results">
{query && !loading && !hasResults && ( {query && !loading && !hasResults && (
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 13 }}> <div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 13 }}>
{t('没有找到匹配结果')} {t('没有找到匹配结果')}
@@ -165,8 +372,12 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
{results?.models.length ? ( {results?.models.length ? (
<div> <div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('模型广场')}</div> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('模型广场')}</div>
{results.models.map((m) => ( {results.models.map((m) => {
<button key={m.name} className="search-result-item" onClick={() => goTo(`/models?q=${encodeURIComponent(m.name)}`)}> const action = resultActions[resultIndex];
const props = buildResultProps(action, resultIndex);
resultIndex += 1;
return (
<button type="button" key={m.name} {...props}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L4 12l5.75-5M14.25 7L20 12l-5.75 5M14 4l-4 16" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L4 12l5.75-5M14.25 7L20 12l-5.75 5M14 4l-4 16" />
</svg> </svg>
@@ -177,15 +388,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
</div> </div>
</div> </div>
</button> </button>
))} );
})}
</div> </div>
) : null} ) : null}
{results?.sites.length ? ( {results?.sites.length ? (
<div> <div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('站点')}</div> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('站点')}</div>
{results.sites.map((s) => ( {results.sites.map((s) => {
<button key={s.id} className="search-result-item" onClick={() => goTo(buildSiteFocusPath(s.id))}> const action = resultActions[resultIndex];
const props = buildResultProps(action, resultIndex);
resultIndex += 1;
return (
<button type="button" key={s.id} {...props}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9" />
</svg> </svg>
@@ -194,21 +410,23 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{s.url}</div> <div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{s.url}</div>
</div> </div>
</button> </button>
))} );
})}
</div> </div>
) : null} ) : null}
{results?.accounts.length ? ( {results?.accounts.length ? (
<div> <div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('账号')}</div> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('账号')}</div>
{results.accounts.map((a) => ( {results.accounts.map((a) => {
const action = resultActions[resultIndex];
const props = buildResultProps(action, resultIndex);
resultIndex += 1;
return (
<button <button
type="button"
key={a.id} key={a.id}
className="search-result-item" {...props}
onClick={() => goTo(buildAccountFocusPath(a.id, {
openRebind: a.status === 'expired',
segment: a.segment,
}))}
> >
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
@@ -225,18 +443,23 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
</div> </div>
</div> </div>
</button> </button>
))} );
})}
</div> </div>
) : null} ) : null}
{results?.accountTokens.length ? ( {results?.accountTokens.length ? (
<div> <div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('账号令牌')}</div> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('账号令牌')}</div>
{results.accountTokens.map((token) => ( {results.accountTokens.map((token) => {
const action = resultActions[resultIndex];
const props = buildResultProps(action, resultIndex);
resultIndex += 1;
return (
<button <button
type="button"
key={token.id} key={token.id}
className="search-result-item" {...props}
onClick={() => goTo(buildTokenFocusPath(token.id))}
> >
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
@@ -251,15 +474,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
</div> </div>
</div> </div>
</button> </button>
))} );
})}
</div> </div>
) : null} ) : null}
{results?.checkinLogs.length ? ( {results?.checkinLogs.length ? (
<div> <div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('签到记录')}</div> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('签到记录')}</div>
{results.checkinLogs.map((l) => ( {results.checkinLogs.map((l) => {
<button key={l.id} className="search-result-item" onClick={() => goTo('/checkin')}> const action = resultActions[resultIndex];
const props = buildResultProps(action, resultIndex);
resultIndex += 1;
return (
<button type="button" key={l.id} {...props}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
@@ -270,15 +498,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
</div> </div>
</div> </div>
</button> </button>
))} );
})}
</div> </div>
) : null} ) : null}
{results?.proxyLogs.length ? ( {results?.proxyLogs.length ? (
<div> <div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('使用日志')}</div> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('使用日志')}</div>
{results.proxyLogs.map((l) => ( {results.proxyLogs.map((l) => {
<button key={l.id} className="search-result-item" onClick={() => goTo('/logs')}> const action = resultActions[resultIndex];
const props = buildResultProps(action, resultIndex);
resultIndex += 1;
return (
<button type="button" key={l.id} {...props}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
</svg> </svg>
@@ -289,12 +522,13 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
</div> </div>
</div> </div>
</button> </button>
))} );
})}
</div> </div>
) : null} ) : null}
</div> </div>
<div style={{ padding: '8px 16px', borderTop: '1px solid var(--color-border-light)', fontSize: 11, color: 'var(--color-text-muted)', display: 'flex', gap: 12 }}> <div className="search-modal-footer">
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>↑↓</kbd> {t('导航')}</span> <span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>↑↓</kbd> {t('导航')}</span>
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>Enter</kbd> {t('打开')}</span> <span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>Enter</kbd> {t('打开')}</span>
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>Esc</kbd> {t('关闭')}</span> <span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>Esc</kbd> {t('关闭')}</span>
@@ -74,6 +74,16 @@ describe('SiteCreatedModal', () => {
]), ]),
); );
expect(choiceButtons.every((button) => !String(button.props.className || '').includes('btn-outline'))).toBe(true); expect(choiceButtons.every((button) => !String(button.props.className || '').includes('btn-outline'))).toBe(true);
expect(choiceButtons.every((button) => button.props.type === 'button')).toBe(true);
expect(choiceButtons.find((button) => collectText(button) === '添加 API Key(推荐)')?.props['aria-label'])
.toBe('站点创建推荐操作:添加 API Key(推荐)');
expect(choiceButtons.find((button) => collectText(button) === '添加账号(用户名密码登录)')?.props['aria-label'])
.toBe('站点创建后续操作:添加账号(用户名密码登录)');
const laterButton = root.root.find((node) => (
node.type === 'button'
&& collectText(node) === '稍后配置'
));
expect(laterButton.props.type).toBe('button');
const sessionButton = choiceButtons.find((button) => collectText(button) === '添加账号(用户名密码登录)'); const sessionButton = choiceButtons.find((button) => collectText(button) === '添加账号(用户名密码登录)');
await act(async () => { await act(async () => {
@@ -101,5 +111,6 @@ describe('SiteCreatedModal', () => {
)); ));
expect(buttons.some((button) => collectText(button) === '添加 OAuth 连接')).toBe(true); expect(buttons.some((button) => collectText(button) === '添加 OAuth 连接')).toBe(true);
expect(buttons.every((button) => button.props.type === 'button')).toBe(true);
}); });
}); });
+5 -1
View File
@@ -56,10 +56,12 @@ export default function SiteCreatedModal({
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }} bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={( footer={(
<> <>
<button onClick={() => onChoice('later')} className="btn btn-ghost"> <button type="button" onClick={() => onChoice('later')} className="btn btn-ghost">
稍后配置 稍后配置
</button> </button>
<button <button
type="button"
aria-label={`站点创建后续操作:${secondaryAction.label}`}
onClick={() => onChoice(secondaryAction.choice)} onClick={() => onChoice(secondaryAction.choice)}
className="btn btn-ghost" className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }} style={{ border: '1px solid var(--color-border)' }}
@@ -67,6 +69,8 @@ export default function SiteCreatedModal({
{secondaryAction.label} {secondaryAction.label}
</button> </button>
<button <button
type="button"
aria-label={`站点创建推荐操作:${primaryAction.label}`}
onClick={() => onChoice(primaryAction.choice)} onClick={() => onChoice(primaryAction.choice)}
className="btn btn-primary" className="btn btn-primary"
> >
+112
View File
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { ToastProvider, useToast } from './Toast.js';
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => (
typeof child === 'string' ? child : collectText(child)
)).join('');
}
function ToastHarness() {
const toast = useToast();
return (
<div>
<button type="button" onClick={() => toast.success('保存成功')}>success</button>
<button type="button" onClick={() => toast.error('保存失败')}>error</button>
</div>
);
}
function renderToastHarness() {
let root!: ReturnType<typeof create>;
act(() => {
root = create(
<ToastProvider>
<ToastHarness />
</ToastProvider>,
);
});
return root;
}
function clickButton(root: ReturnType<typeof create>, label: string) {
const button = root.root.find((node) => node.type === 'button' && collectText(node) === label);
act(() => {
button.props.onClick();
});
}
describe('ToastProvider', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});
it('announces non-error toasts as polite status messages with a close control', () => {
const root = renderToastHarness();
clickButton(root, 'success');
const toast = root.root.findByProps({ role: 'status' });
expect(toast.props['aria-live']).toBe('polite');
expect(toast.props['aria-atomic']).toBe('true');
expect(toast.props.style).toMatchObject({ '--toast-duration': '3200ms' });
expect(collectText(toast)).toContain('保存成功');
const dismiss = toast.findByProps({ className: 'toast-dismiss' });
expect(dismiss.props.type).toBe('button');
expect(dismiss.props['aria-label']).toBe('关闭通知:保存成功');
});
it('announces error toasts assertively', () => {
const root = renderToastHarness();
clickButton(root, 'error');
const toast = root.root.findByProps({ role: 'alert' });
expect(toast.props['aria-live']).toBe('assertive');
expect(toast.props['aria-atomic']).toBe('true');
expect(collectText(toast)).toContain('保存失败');
});
it('dismisses toasts through the close control and keeps the exit animation state', () => {
const root = renderToastHarness();
clickButton(root, 'success');
const toast = root.root.findByProps({ role: 'status' });
const dismiss = toast.findByProps({ className: 'toast-dismiss' });
act(() => {
dismiss.props.onClick();
});
const exitingToast = root.root.findByProps({ role: 'status' });
expect(exitingToast.props.className).toContain('toast-exit');
expect(exitingToast.props['aria-hidden']).toBe('true');
expect(exitingToast.findByProps({ className: 'toast-dismiss' }).props.tabIndex).toBe(-1);
act(() => {
vi.advanceTimersByTime(250);
});
expect(root.root.findAllByProps({ role: 'status' })).toHaveLength(0);
});
it('clears pending toast timers when the provider unmounts', () => {
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
const root = renderToastHarness();
clickButton(root, 'success');
act(() => {
root.unmount();
});
expect(clearTimeoutSpy).toHaveBeenCalled();
});
});
+52 -10
View File
@@ -1,6 +1,9 @@
import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react'; import React, { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react';
type ToastType = 'success' | 'error' | 'info'; type ToastType = 'success' | 'error' | 'info';
const TOAST_DURATION_MS = 3200;
const TOAST_EXIT_DURATION_MS = 250;
type ToastTimer = Parameters<typeof clearTimeout>[0];
interface Toast { interface Toast {
id: number; id: number;
@@ -33,19 +36,42 @@ const icons: Record<ToastType, React.ReactNode> = {
export function ToastProvider({ children }: { children: React.ReactNode }) { export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]); const [toasts, setToasts] = useState<Toast[]>([]);
const idRef = useRef(0); const idRef = useRef(0);
const timersRef = useRef(new Map<number, ToastTimer[]>());
const trackTimer = useCallback((id: number, timer: ToastTimer) => {
const timers = timersRef.current.get(id) || [];
timers.push(timer);
timersRef.current.set(id, timers);
}, []);
const clearToastTimers = useCallback((id: number) => {
const timers = timersRef.current.get(id) || [];
timers.forEach((timer) => globalThis.clearTimeout(timer));
timersRef.current.delete(id);
}, []);
useEffect(() => () => {
timersRef.current.forEach((timers) => {
timers.forEach((timer) => globalThis.clearTimeout(timer));
});
timersRef.current.clear();
}, []);
const removeToast = useCallback((id: number) => { const removeToast = useCallback((id: number) => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t)); setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t));
setTimeout(() => { const timer = globalThis.setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id)); setToasts(prev => prev.filter(t => t.id !== id));
}, 250); clearToastTimers(id);
}, []); }, TOAST_EXIT_DURATION_MS) as ToastTimer;
trackTimer(id, timer);
}, [clearToastTimers, trackTimer]);
const addToast = useCallback((type: ToastType, message: string) => { const addToast = useCallback((type: ToastType, message: string) => {
const id = ++idRef.current; const id = ++idRef.current;
setToasts(prev => [...prev, { id, type, message }]); setToasts(prev => [...prev, { id, type, message }]);
setTimeout(() => removeToast(id), 3200); const timer = globalThis.setTimeout(() => removeToast(id), TOAST_DURATION_MS) as ToastTimer;
}, [removeToast]); trackTimer(id, timer);
}, [removeToast, trackTimer]);
const success = useCallback((msg: string) => addToast('success', msg), [addToast]); const success = useCallback((msg: string) => addToast('success', msg), [addToast]);
const error = useCallback((msg: string) => addToast('error', msg), [addToast]); const error = useCallback((msg: string) => addToast('error', msg), [addToast]);
@@ -68,11 +94,27 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
<div <div
key={t.id} key={t.id}
className={`toast toast-${t.type} ${t.exiting ? 'toast-exit' : ''}`} className={`toast toast-${t.type} ${t.exiting ? 'toast-exit' : ''}`}
onClick={() => removeToast(t.id)} role={t.type === 'error' ? 'alert' : 'status'}
style={{ cursor: 'pointer' }} aria-live={t.type === 'error' ? 'assertive' : 'polite'}
aria-atomic="true"
aria-hidden={t.exiting ? 'true' : undefined}
style={{ '--toast-duration': `${TOAST_DURATION_MS}ms` } as React.CSSProperties}
> >
<span style={{ flexShrink: 0, marginTop: 1 }}>{icons[t.type]}</span> <button
<span style={{ fontSize: 13, lineHeight: 1.5 }}>{t.message}</span> type="button"
className="toast-dismiss"
aria-label={`关闭通知:${t.message}`}
tabIndex={t.exiting ? -1 : 0}
onClick={() => removeToast(t.id)}
>
<span className="toast-icon" aria-hidden="true">{icons[t.type]}</span>
<span className="toast-message">{t.message}</span>
<span className="toast-close" aria-hidden="true">
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.25} d="M6 18L18 6M6 6l12 12" />
</svg>
</span>
</button>
<div className="toast-progress" /> <div className="toast-progress" />
</div> </div>
))} ))}
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import UserAvatar, {
getUserAvatarInitials,
getUserAvatarStyle,
userAvatarRoleLabel,
userAvatarStatusLabel,
} from './UserAvatar.js';
describe('UserAvatar', () => {
it('builds readable initials from user names and email fallbacks', () => {
expect(getUserAvatarInitials({ id: 1, username: 'auto_user', email: 'auto@example.com' })).toBe('AU');
expect(getUserAvatarInitials({ id: 2, username: 'ops lead', email: 'ops@example.com' })).toBe('OL');
expect(getUserAvatarInitials({ id: 3, username: '', email: 'billing.owner@example.com' })).toBe('BO');
expect(getUserAvatarInitials({ id: 4, username: '', email: '' })).toBe('U4');
});
it('renders role status markers and stable hue variables', () => {
const root = create(
<UserAvatar
id={7}
username="ops lead"
email="ops@example.com"
role="admin"
status="disabled"
size="lg"
className="custom-avatar"
/>,
);
const avatar = root.root.findByProps({ role: 'img' });
expect(avatar.props.className).toContain('user-avatar');
expect(avatar.props.className).toContain('user-avatar-lg');
expect(avatar.props.className).toContain('is-admin');
expect(avatar.props.className).toContain('is-disabled');
expect(avatar.props.className).toContain('custom-avatar');
expect(avatar.props['data-role']).toBe('admin');
expect(avatar.props['data-status']).toBe('disabled');
expect(avatar.props['aria-label']).toContain('管理员');
expect(avatar.props['aria-label']).toContain('已禁用');
expect(avatar.findByProps({ className: 'user-avatar-initials' }).children.join('')).toBe('OL');
expect(avatar.findByProps({ className: 'user-avatar-role-mark' }).children.join('')).toBe('A');
expect(avatar.findByProps({ className: 'user-avatar-status' }).props['aria-hidden']).toBe('true');
expect(avatar.props.style['--user-avatar-hue']).toMatch(/^\d+$/);
expect(avatar.props.style['--user-avatar-hue-2']).toMatch(/^\d+$/);
expect(getUserAvatarStyle({ id: 7, username: 'ops lead', email: 'ops@example.com' })).toEqual(avatar.props.style);
});
it('normalizes unknown roles and statuses to the user-facing defaults', () => {
const root = create(<UserAvatar id={8} email="reader@example.com" role="reader" status="pending" />);
const avatar = root.root.findByProps({ role: 'img' });
expect(userAvatarRoleLabel('reader')).toBe('普通用户');
expect(userAvatarStatusLabel('pending')).toBe('正常');
expect(avatar.props['data-role']).toBe('user');
expect(avatar.props['data-status']).toBe('active');
expect(avatar.findByProps({ className: 'user-avatar-role-mark' }).children.join('')).toBe('U');
});
});
+109
View File
@@ -0,0 +1,109 @@
import React from 'react';
export type UserAvatarRole = 'admin' | 'user' | string;
export type UserAvatarStatus = 'active' | 'disabled' | string;
export interface UserAvatarIdentity {
id?: number | string | null;
username?: string | null;
email?: string | null;
role?: UserAvatarRole | null;
status?: UserAvatarStatus | null;
avatarUrl?: string | null;
}
export interface UserAvatarProps extends UserAvatarIdentity {
className?: string;
size?: 'sm' | 'md' | 'lg';
styleOverride?: React.CSSProperties;
}
function hashString(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = (hash << 5) - hash + input.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
export function userAvatarRoleLabel(role?: UserAvatarRole | null): string {
return role === 'admin' ? '管理员' : '普通用户';
}
export function userAvatarStatusLabel(status?: UserAvatarStatus | null): string {
return status === 'disabled' ? '已禁用' : '正常';
}
export function getUserAvatarInitials(identity: UserAvatarIdentity): string {
const emailName = (identity.email || '').split('@')[0] || '';
const source = (identity.username || emailName || `user-${identity.id || ''}`).trim();
const segments = source
.replace(/[_+.-]+/g, ' ')
.split(/\s+/)
.filter(Boolean);
const raw = segments.length >= 2
? `${Array.from(segments[0])[0] || ''}${Array.from(segments[1])[0] || ''}`
: Array.from(source).slice(0, 2).join('');
return (raw || 'U').toUpperCase();
}
export function getUserAvatarStyle(identity: UserAvatarIdentity): React.CSSProperties {
const seed = `${identity.id || 'user'}:${identity.username || ''}:${identity.email || ''}`;
const hue = hashString(seed) % 360;
const secondHue = (hue + 42 + (hashString(identity.email || seed) % 80)) % 360;
return {
'--user-avatar-hue': String(hue),
'--user-avatar-hue-2': String(secondHue),
} as React.CSSProperties;
}
export default function UserAvatar({
id,
username,
email,
role = 'user',
status = 'active',
avatarUrl,
className = '',
size = 'md',
styleOverride,
}: UserAvatarProps) {
const [imageBroken, setImageBroken] = React.useState(false);
React.useEffect(() => {
setImageBroken(false);
}, [avatarUrl]);
const isAdmin = role === 'admin';
const isDisabled = status === 'disabled';
const hasAvatarImage = Boolean(avatarUrl && !imageBroken);
const roleMark = isAdmin ? 'A' : 'U';
const labelTarget = username || email || id || '用户';
const rootClassName = [
'user-avatar',
`user-avatar-${size}`,
isAdmin ? 'is-admin' : '',
isDisabled ? 'is-disabled' : '',
hasAvatarImage ? 'has-image' : '',
className,
].filter(Boolean).join(' ');
return (
<div
className={rootClassName}
style={{ ...getUserAvatarStyle({ id, username, email }), ...styleOverride }}
data-role={isAdmin ? 'admin' : 'user'}
data-status={isDisabled ? 'disabled' : 'active'}
role="img"
aria-label={`用户头像:${labelTarget},${userAvatarRoleLabel(role)},${userAvatarStatusLabel(status)}`}
>
{hasAvatarImage ? (
<img src={avatarUrl || ''} alt="" onError={() => setImageBroken(true)} />
) : (
<span className="user-avatar-initials">{getUserAvatarInitials({ id, username, email })}</span>
)}
<span className="user-avatar-role-mark" aria-hidden="true">{roleMark}</span>
<span className="user-avatar-status" aria-hidden="true" />
</div>
);
}
@@ -0,0 +1,90 @@
import {
USER_RECHARGE_CONFIG,
isUserRechargeOptionPayable,
type UserRechargeOption,
type UserRechargeCheckoutOption,
} from '../userRecharge.js';
type UserRechargeOptionsProps = {
className?: string;
compact?: boolean;
onPay?: (option: UserRechargeCheckoutOption) => void | Promise<void>;
pendingOptionId?: string | null;
internalOnly?: boolean;
};
function rechargeAriaLabel(option: UserRechargeOption) {
return `通过 ${USER_RECHARGE_CONFIG.providerLabel} 充值 ${option.label}`;
}
function unavailableAriaLabel(option: UserRechargeOption) {
return `${option.label} 充值暂未配置`;
}
export default function UserRechargeOptions({
className = '',
compact = false,
onPay,
pendingOptionId = null,
internalOnly = false,
}: UserRechargeOptionsProps) {
const options = USER_RECHARGE_CONFIG.options.length > 0
? USER_RECHARGE_CONFIG.options
: [USER_RECHARGE_CONFIG.defaultOption];
return (
<div className={['user-recharge-options', compact ? 'is-compact' : '', className].filter(Boolean).join(' ')}>
{options.map((option) => {
const className = `user-recharge-option${option.recommended ? ' is-recommended' : ''}${isUserRechargeOptionPayable(option) ? '' : ' is-disabled'}`;
if (!isUserRechargeOptionPayable(option)) {
return (
<button
key={option.id}
type="button"
className={className}
aria-label={unavailableAriaLabel(option)}
title={option.unavailableReason || '该额度支付链接未配置'}
disabled
>
<strong>{option.label}</strong>
<span>{option.title}</span>
<em>待配置</em>
</button>
);
}
if (internalOnly || !onPay) {
return (
<a
key={option.id}
href="/user/recharge"
className={className}
aria-label={`前往系统内充值页确认 ${option.label}`}
>
<strong>{option.label}</strong>
<span>{option.title}</span>
</a>
);
}
const pending = pendingOptionId === option.id;
return (
<button
key={option.id}
type="button"
className={className}
aria-label={rechargeAriaLabel(option)}
onClick={() => void onPay(option)}
disabled={pending}
data-checkout-option-id={option.id}
>
<strong>{option.label}</strong>
<span>{option.title}</span>
{pending ? <em>创建记录中</em> : null}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { readdirSync, readFileSync } from 'node:fs';
import { extname, join, relative, sep } from 'node:path';
const webRoot = join(process.cwd(), 'src/web');
const centeredModalSource = join(webRoot, 'components/CenteredModal.tsx');
function listSourceFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) return listSourceFiles(path);
if (!entry.isFile()) return [];
if (path === centeredModalSource) return [];
if (!['.ts', '.tsx'].includes(extname(path))) return [];
if (path.includes('.test.') || path.includes('.spec.')) return [];
return [path];
});
}
function toProjectPath(path: string): string {
return relative(process.cwd(), path).split(sep).join('/');
}
function findCenteredModalBlocks(source: string): string[] {
const blocks: string[] = [];
let cursor = 0;
while (cursor < source.length) {
const start = source.indexOf('<CenteredModal', cursor);
if (start === -1) break;
const closingTag = source.indexOf('</CenteredModal>', start);
const end = closingTag === -1
? source.indexOf('>', start)
: closingTag + '</CenteredModal>'.length;
blocks.push(source.slice(start, end === -1 ? source.length : end));
cursor = end === -1 ? source.length : end;
}
return blocks;
}
describe('CenteredModal source usage', () => {
it('keeps Escape closing explicit on every production CenteredModal call site', () => {
const missingCloseOnEscape = listSourceFiles(webRoot).flatMap((path) => {
const source = readFileSync(path, 'utf8');
return findCenteredModalBlocks(source)
.map((block, index) => ({ block, index }))
.filter(({ block }) => !/\bcloseOnEscape\b/.test(block))
.map(({ index }) => `${toProjectPath(path)}#${index + 1}`);
});
expect(missingCloseOnEscape).toEqual([]);
});
});
+113
View File
@@ -6,9 +6,12 @@ import CenteredModal from './CenteredModal.js';
describe('CenteredModal component', () => { describe('CenteredModal component', () => {
const originalDocument = globalThis.document; const originalDocument = globalThis.document;
const originalHTMLElement = globalThis.HTMLElement;
afterEach(() => { afterEach(() => {
vi.useRealTimers();
globalThis.document = originalDocument; globalThis.document = originalDocument;
globalThis.HTMLElement = originalHTMLElement;
}); });
it('does not close on backdrop click by default and exposes an explicit close button', async () => { it('does not close on backdrop click by default and exposes an explicit close button', async () => {
@@ -47,13 +50,123 @@ describe('CenteredModal component', () => {
} }
}); });
it('renders as a labelled dialog and restores focus when it closes', async () => {
class MockHTMLElement {
isConnected = true;
focus = vi.fn();
}
const activeElement = new MockHTMLElement() as HTMLElement & { focus: ReturnType<typeof vi.fn> };
globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
globalThis.document = {
activeElement,
body: { style: {} },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as Document;
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<CenteredModal open onClose={() => {}} title="测试弹框">
<div>content</div>
</CenteredModal>,
);
});
const dialog = root.root.find((node) => node.props.role === 'dialog');
expect(dialog.props['aria-modal']).toBe('true');
expect(dialog.props['aria-hidden']).toBe(false);
expect(typeof dialog.props['aria-labelledby']).toBe('string');
expect(dialog.props.tabIndex).toBe(-1);
const title = root.root.find((node) => (
node.props.id === dialog.props['aria-labelledby']
));
expect(title.props.className).toBe('modal-title');
await act(async () => {
root.update(
<CenteredModal open={false} onClose={() => {}} title="测试弹框">
<div>content</div>
</CenteredModal>,
);
});
expect(activeElement.focus).toHaveBeenCalledWith({ preventScroll: true });
} finally {
root?.unmount();
}
});
it('hides dialog semantics and close button tab stop during the closing animation', async () => {
vi.useFakeTimers();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<CenteredModal open onClose={() => {}} title="测试弹框">
<div>content</div>
</CenteredModal>,
);
});
await act(async () => {
root.update(
<CenteredModal open={false} onClose={() => {}} title="测试弹框">
<div>content</div>
</CenteredModal>,
);
});
const dialog = root.root.find((node) => node.props.role === 'dialog');
expect(dialog.props['aria-hidden']).toBe(true);
expect(String(dialog.props.className)).toContain('is-closing');
const closeButton = root.root.find((node) => (
node.type === 'button'
&& node.props['aria-label'] === '关闭弹框'
));
expect(closeButton.props.tabIndex).toBe(-1);
await act(async () => {
vi.advanceTimersByTime(220);
});
expect(root.toJSON()).toBeNull();
} finally {
root?.unmount();
}
});
it('uses the shared centered modal shell pattern', () => { it('uses the shared centered modal shell pattern', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/components/CenteredModal.tsx'), 'utf8'); const source = readFileSync(resolve(process.cwd(), 'src/web/components/CenteredModal.tsx'), 'utf8');
expect(source).toContain('modal-backdrop'); expect(source).toContain('modal-backdrop');
expect(source).toContain('modal-content'); expect(source).toContain('modal-content');
expect(source).toContain('role="dialog"');
expect(source).toContain('aria-modal="true"');
expect(source).toContain('aria-labelledby={titleId}');
expect(source).toContain('const dialogRef = useRef<HTMLDivElement | null>(null);');
expect(source).toContain('ref={dialogRef}');
expect(source).toContain('tabIndex={-1}');
expect(source).toContain('dialog.focus({ preventScroll: true });');
expect(source).toContain('lastActiveElementRef.current?.focus({ preventScroll: true });');
expect(source).toContain('useAnimatedVisibility'); expect(source).toContain('useAnimatedVisibility');
expect(source).toContain('createPortal'); expect(source).toContain('createPortal');
expect(source).toContain("import { trapFocusInContainer } from './focusTrap.js';");
expect(source).toContain('trapFocusInContainer(event, dialogRef.current);');
});
it('bounds shared modal height with dynamic mobile viewports', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(css).toContain('.modal-content {\n background: var(--color-bg-card);');
expect(css).toContain('max-height: calc(100dvh - 32px);');
expect(css).not.toContain('max-height: calc(100vh - 32px);');
expect(css).toContain('.modal-content {\n margin: 12px;');
expect(css).toContain('max-height: calc(100dvh - 24px);');
}); });
it('renders safely without touching document.body or listeners when no usable body exists', async () => { it('renders safely without touching document.body or listeners when no usable body exists', async () => {
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import ChangeKeyModal from './ChangeKeyModal.js';
const { apiMock, authSessionMock, toastMock } = vi.hoisted(() => ({
apiMock: {
changeAuthToken: vi.fn(),
},
authSessionMock: {
commitSession: vi.fn(),
},
toastMock: {
success: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../authSession.js', () => ({
commitSession: authSessionMock.commitSession,
}));
vi.mock('./Toast.js', () => ({
useToast: () => toastMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function findButton(root: ReactTestInstance, label: string): ReactTestInstance {
return root.find((node) => node.type === 'button' && collectText(node).includes(label));
}
function findInput(root: ReactTestInstance, placeholder: string): ReactTestInstance {
return root.find((node) => node.type === 'input' && node.props.placeholder === placeholder);
}
describe('ChangeKeyModal', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('localStorage', {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('uses the shared centered dialog shell', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(<ChangeKeyModal open onClose={() => {}} />);
});
const dialog = root.root.find((node) => node.props.role === 'dialog');
expect(dialog.props['aria-modal']).toBe('true');
expect(collectText(dialog)).toContain('修改管理员 Token');
expect(findButton(root.root, '取消').props.type).toBe('button');
expect(findButton(root.root, '确认修改').props.type).toBe('button');
} finally {
root?.unmount();
}
});
it('validates fields before submitting token changes', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(<ChangeKeyModal open onClose={() => {}} />);
});
await act(async () => {
findButton(root.root, '确认修改').props.onClick();
});
expect(apiMock.changeAuthToken).not.toHaveBeenCalled();
expect(collectText(root.root)).toContain('请填写所有字段');
} finally {
root?.unmount();
}
});
it('submits valid token changes and commits the new admin session', async () => {
apiMock.changeAuthToken.mockResolvedValue({ success: true });
const onClose = vi.fn();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(<ChangeKeyModal open onClose={onClose} />);
});
await act(async () => {
findInput(root.root, '输入当前 Token').props.onChange({ target: { value: 'old-token' } });
findInput(root.root, '输入新 Token (至少 6 位)').props.onChange({ target: { value: 'new-token' } });
findInput(root.root, '再次输入新 Token').props.onChange({ target: { value: 'new-token' } });
});
await act(async () => {
findButton(root.root, '确认修改').props.onClick();
await Promise.resolve();
});
expect(apiMock.changeAuthToken).toHaveBeenCalledWith('old-token', 'new-token');
expect(authSessionMock.commitSession).toHaveBeenCalledWith('admin', 'new-token', undefined, localStorage);
expect(toastMock.success).toHaveBeenCalledWith('Token 已更新,请使用新 Token 重新登录');
expect(onClose).toHaveBeenCalledTimes(1);
} finally {
root?.unmount();
}
});
});
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestRenderer } from 'react-test-renderer'; import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
import DownstreamKeyTrendChart from './DownstreamKeyTrendChart.js'; import DownstreamKeyTrendChart from './DownstreamKeyTrendChart.js';
import { formatDateTimeMinuteLocal } from '../../pages/helpers/checkinLogTime.js'; import { formatDateTimeMinuteLocal } from '../../pages/helpers/checkinLogTime.js';
@@ -24,6 +24,13 @@ function formatExpectedAxisLabel(raw: string, bucketSeconds: number): string {
return `${month}/${day} ${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}`; return `${month}/${day} ${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}`;
} }
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
describe('DownstreamKeyTrendChart', () => { describe('DownstreamKeyTrendChart', () => {
const originalDocument = globalThis.document; const originalDocument = globalThis.document;
const originalGetComputedStyle = globalThis.getComputedStyle; const originalGetComputedStyle = globalThis.getComputedStyle;
@@ -125,4 +132,77 @@ describe('DownstreamKeyTrendChart', () => {
renderer.unmount(); renderer.unmount();
}); });
it('exposes metric toggles as pressed non-submit buttons', async () => {
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(
<DownstreamKeyTrendChart
buckets={[
{
startUtc: '2026-04-05T15:00:00.000Z',
totalRequests: 2,
totalTokens: 2128425,
totalCost: 2.128425,
successRate: 100,
},
]}
/>,
);
});
const metricGroup = renderer.root.find((node) => node.props.role === 'group' && node.props['aria-label'] === '下游 Key 趋势指标');
const buttons = renderer.root.findAll((node) => node.type === 'button' && node.parent === metricGroup);
expect(buttons.map((button) => button.props.type)).toEqual(['button', 'button', 'button']);
expect(buttons.map((button) => button.props['aria-pressed'])).toEqual([true, false, false]);
expect(buttons.map((button) => button.props['aria-label'])).toEqual([
'切换下游 Key 趋势指标:Tokens',
'切换下游 Key 趋势指标:请求数',
'切换下游 Key 趋势指标:成本',
]);
const costButton = buttons.find((button) => collectText(button) === '成本');
await act(async () => {
costButton!.props.onClick();
});
const updatedButtons = renderer.root.findAll((node) => node.type === 'button' && node.parent?.props['aria-label'] === '下游 Key 趋势指标');
expect(updatedButtons.map((button) => button.props['aria-pressed'])).toEqual([false, false, true]);
renderer.unmount();
});
it('shows a composed empty state instead of rendering a zero-only chart', async () => {
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(
<DownstreamKeyTrendChart
buckets={[
{
startUtc: '2026-04-05T15:00:00.000Z',
totalRequests: 0,
totalTokens: 0,
totalCost: 0,
successRate: null,
},
{
startUtc: '2026-04-05T16:00:00.000Z',
totalRequests: 0,
totalTokens: 0,
totalCost: 0,
successRate: null,
},
]}
/>,
);
});
expect(vChartSpy).not.toHaveBeenCalled();
expect(collectText(renderer.root)).toContain('暂无Tokens趋势');
expect(collectText(renderer.root)).toContain('所选时间范围内该指标为 0');
renderer.unmount();
});
}); });
@@ -1,5 +1,6 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { VChart } from '@visactor/react-vchart'; import { VChart } from '@visactor/react-vchart';
import { useThemeColor, useThemeLabelColor } from '../useThemeLabelColor.js';
import { formatDateTimeMinuteLocal } from '../../pages/helpers/checkinLogTime.js'; import { formatDateTimeMinuteLocal } from '../../pages/helpers/checkinLogTime.js';
type Metric = 'tokens' | 'requests' | 'cost'; type Metric = 'tokens' | 'requests' | 'cost';
@@ -31,6 +32,29 @@ function formatTrendAxisLabel(raw: string, bucketSeconds: number): string {
return `${month}/${day} ${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}`; return `${month}/${day} ${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}`;
} }
function formatMetricValue(metric: Metric, value: number): string {
if (!Number.isFinite(value)) return metric === 'cost' ? '$0.000000' : '0';
if (metric === 'cost') return `$${value.toFixed(value >= 1 ? 3 : 6)}`;
return Math.trunc(value).toLocaleString();
}
function formatMetricAxisValue(metric: Metric, value: string): string {
const numberValue = Number(value || 0);
if (!Number.isFinite(numberValue)) return String(value || '');
if (metric === 'cost') {
if (numberValue >= 1) return `$${numberValue.toFixed(1)}`;
if (numberValue > 0) return `$${numberValue.toFixed(4)}`;
return '$0';
}
if (numberValue >= 1_000_000) return `${(numberValue / 1_000_000).toFixed(1)}M`;
if (numberValue >= 1_000) return `${(numberValue / 1_000).toFixed(1)}K`;
return Math.round(numberValue).toLocaleString();
}
function getMetricLabel(metric: Metric): string {
return METRIC_OPTIONS.find((opt) => opt.key === metric)?.label || 'Value';
}
export default function DownstreamKeyTrendChart({ export default function DownstreamKeyTrendChart({
buckets, buckets,
bucketSeconds = 3600, bucketSeconds = 3600,
@@ -43,6 +67,9 @@ export default function DownstreamKeyTrendChart({
height?: number; height?: number;
}) { }) {
const [metric, setMetric] = useState<Metric>('tokens'); const [metric, setMetric] = useState<Metric>('tokens');
const labelColor = useThemeLabelColor('#7d8b82');
const axisColor = useThemeColor('--color-border', '#dce6dd');
const primaryColor = useThemeColor('--color-primary', '#0f766e');
const flatData = useMemo(() => { const flatData = useMemo(() => {
if (!Array.isArray(buckets) || buckets.length === 0) return []; if (!Array.isArray(buckets) || buckets.length === 0) return [];
@@ -62,27 +89,38 @@ export default function DownstreamKeyTrendChart({
}) })
.filter((row) => row.date.length > 0); .filter((row) => row.date.length > 0);
}, [buckets, metric]); }, [buckets, metric]);
const metricTotal = useMemo(
() => flatData.reduce((sum, row) => sum + Number(row.value || 0), 0),
[flatData],
);
const metricPeak = useMemo(
() => flatData.reduce((peak, row) => Math.max(peak, Number(row.value || 0)), 0),
[flatData],
);
const hasPositiveValue = flatData.some((row) => Number(row.value || 0) > 0);
if (loading) { if (loading) {
return ( return (
<div style={containerStyle}> <div className="downstream-key-trend-chart">
<div style={headerStyle}> <div className="downstream-key-trend-chart-head">
<div className="skeleton" style={{ width: 160, height: 30, borderRadius: 'var(--radius-sm)' }} /> <div className="skeleton" style={{ width: 160, height: 30, borderRadius: 'var(--radius-sm)' }} />
<div className="skeleton" style={{ width: 96, height: 30, borderRadius: 'var(--radius-sm)' }} />
</div> </div>
<div className="skeleton" style={{ width: '100%', height, borderRadius: 'var(--radius-sm)' }} /> <div className="skeleton" style={{ width: '100%', height, borderRadius: 'var(--radius-md)' }} />
</div> </div>
); );
} }
if (!flatData || flatData.length === 0) { if (!flatData || flatData.length === 0 || !hasPositiveValue) {
return ( return (
<div style={containerStyle}> <div className="downstream-key-trend-chart">
<div style={headerStyle}> <div className="downstream-key-trend-chart-head">
<MetricToggle metric={metric} onChange={setMetric} /> <MetricToggle metric={metric} onChange={setMetric} />
<MetricSummary metric={metric} total={metricTotal} peak={metricPeak} />
</div> </div>
<div className="empty-state" style={{ padding: 32 }}> <div className="downstream-key-chart-empty" style={{ minHeight: height }}>
<div className="empty-state-title">暂无趋势数据</div> <div className="downstream-key-chart-empty-title">暂无{getMetricLabel(metric)}趋势</div>
<div className="empty-state-desc">该 Key 在所选时间范围内没有可用的 tokens 记录</div> <div className="downstream-key-chart-empty-desc">所选时间范围内该指标为 0</div>
</div> </div>
</div> </div>
); );
@@ -110,16 +148,19 @@ export default function DownstreamKeyTrendChart({
{ {
orient: 'bottom', orient: 'bottom',
label: { label: {
style: { fontSize: 11, fill: 'var(--color-text-muted)' }, style: { fontSize: 11, fill: labelColor },
formatMethod: (value: string) => formatTrendAxisLabel(String(value || ''), bucketSeconds), formatMethod: (value: string) => formatTrendAxisLabel(String(value || ''), bucketSeconds),
}, },
domainLine: { style: { stroke: 'var(--color-border-light)' } }, domainLine: { style: { stroke: axisColor, lineWidth: 1 } },
tick: { style: { stroke: 'var(--color-border-light)' } }, tick: { style: { stroke: axisColor, lineWidth: 1 } },
}, },
{ {
orient: 'left', orient: 'left',
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } }, label: {
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [4, 4] } }, style: { fontSize: 11, fill: labelColor },
formatMethod: (value: string) => formatMetricAxisValue(metric, value),
},
grid: { style: { stroke: axisColor, strokeOpacity: 0.74, lineDash: [3, 6], lineWidth: 1 } },
domainLine: { visible: false }, domainLine: { visible: false },
}, },
], ],
@@ -128,37 +169,54 @@ export default function DownstreamKeyTrendChart({
title: { value: (datum: Record<string, unknown>) => String(datum?.tooltipDate || datum?.date || '') }, title: { value: (datum: Record<string, unknown>) => String(datum?.tooltipDate || datum?.date || '') },
content: [ content: [
{ {
key: () => METRIC_OPTIONS.find((opt) => opt.key === metric)?.label || 'Value', key: () => getMetricLabel(metric),
value: (datum: Record<string, unknown>) => { value: (datum: Record<string, unknown>) => {
const value = Number(datum?.value ?? 0); const value = Number(datum?.value ?? 0);
if (metric === 'cost') return `$${value.toFixed(6)}`; return formatMetricValue(metric, value);
return value.toLocaleString();
}, },
}, },
], ],
}, },
}, },
color: ['var(--color-primary)'], color: [primaryColor],
background: 'transparent', background: 'transparent',
animationAppear: { animationAppear: {
area: { type: 'fadeIn', duration: 500, easing: 'cubicOut' }, area: { type: 'fadeIn', duration: 500, easing: 'cubicOut' },
line: { type: 'clipIn', duration: 700, easing: 'cubicOut' }, line: { type: 'clipIn', duration: 700, easing: 'cubicOut' },
}, },
padding: { left: 8, right: 16, top: 8, bottom: 8 }, padding: { left: metric === 'cost' ? 58 : 50, right: 16, top: 12, bottom: 18 },
}; };
return ( return (
<div style={containerStyle}> <div className="downstream-key-trend-chart">
<div style={headerStyle}> <div className="downstream-key-trend-chart-head">
<MetricToggle metric={metric} onChange={setMetric} /> <MetricToggle metric={metric} onChange={setMetric} />
<MetricSummary metric={metric} total={metricTotal} peak={metricPeak} />
</div> </div>
<div style={{ width: '100%', height }}> <div className="downstream-key-trend-chart-stage" style={{ height }}>
<VChart spec={spec as any} style={{ width: '100%', height: '100%' }} /> <VChart spec={spec as any} style={{ width: '100%', height: '100%' }} />
</div> </div>
</div> </div>
); );
} }
function MetricSummary({
metric,
total,
peak,
}: {
metric: Metric;
total: number;
peak: number;
}) {
return (
<div className="downstream-key-chart-summary" aria-label={`${getMetricLabel(metric)}趋势摘要`}>
<span>合计 {formatMetricValue(metric, total)}</span>
<span>峰值 {formatMetricValue(metric, peak)}</span>
</div>
);
}
function MetricToggle({ function MetricToggle({
metric, metric,
onChange, onChange,
@@ -167,15 +225,15 @@ function MetricToggle({
onChange: (m: Metric) => void; onChange: (m: Metric) => void;
}) { }) {
return ( return (
<div style={toggleGroupStyle}> <div className="downstream-key-metric-toggle" role="group" aria-label="下游 Key 趋势指标">
{METRIC_OPTIONS.map((opt) => ( {METRIC_OPTIONS.map((opt) => (
<button <button
key={opt.key} key={opt.key}
type="button"
aria-pressed={metric === opt.key}
aria-label={`切换下游 Key 趋势指标:${opt.label}`}
onClick={() => onChange(opt.key)} onClick={() => onChange(opt.key)}
style={{ className={metric === opt.key ? 'is-active' : ''}
...toggleBtnBase,
...(metric === opt.key ? toggleBtnActive : toggleBtnInactive),
}}
> >
{opt.label} {opt.label}
</button> </button>
@@ -183,46 +241,3 @@ function MetricToggle({
</div> </div>
); );
} }
const containerStyle: React.CSSProperties = {
background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-light)',
boxShadow: 'var(--shadow-card)',
padding: 16,
};
const headerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 10,
};
const toggleGroupStyle: React.CSSProperties = {
display: 'inline-flex',
gap: 0,
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)',
overflow: 'hidden',
};
const toggleBtnBase: React.CSSProperties = {
padding: '6px 12px',
fontSize: 12,
fontWeight: 500,
cursor: 'pointer',
border: 'none',
transition: 'all 0.2s ease',
fontFamily: 'inherit',
};
const toggleBtnActive: React.CSSProperties = {
background: 'var(--color-primary)',
color: '#ffffff',
};
const toggleBtnInactive: React.CSSProperties = {
background: 'var(--color-bg-card)',
color: 'var(--color-text-secondary)',
};
@@ -1,11 +1,18 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer'; import { act, create, type ReactTestInstance } from 'react-test-renderer';
import SiteDistributionChart from './SiteDistributionChart.js'; import SiteDistributionChart from './SiteDistributionChart.js';
vi.mock('@visactor/react-vchart', () => ({ vi.mock('@visactor/react-vchart', () => ({
VChart: () => null, VChart: () => null,
})); }));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
describe('SiteDistributionChart', () => { describe('SiteDistributionChart', () => {
const originalDocument = globalThis.document; const originalDocument = globalThis.document;
const originalGetComputedStyle = globalThis.getComputedStyle; const originalGetComputedStyle = globalThis.getComputedStyle;
@@ -48,4 +55,43 @@ describe('SiteDistributionChart', () => {
renderer.unmount(); renderer.unmount();
}); });
it('exposes distribution view toggles as pressed non-submit buttons', async () => {
let renderer!: WebTestRenderer;
await act(async () => {
renderer = create(
<SiteDistributionChart
data={[
{
siteName: 'Demo Site',
platform: 'demo',
totalBalance: 12.34,
totalSpend: 1.23,
accountCount: 2,
},
]}
/>,
);
});
const group = renderer.root.find((node) => node.props.role === 'group' && node.props['aria-label'] === '站点分布视图');
const buttons = group.findAllByType('button');
expect(buttons.map((button) => button.props.type)).toEqual(['button', 'button']);
expect(buttons.map((button) => button.props['aria-pressed'])).toEqual([true, false]);
expect(buttons.map((button) => button.props['aria-label'])).toEqual([
'切换站点分布视图:余额分布',
'切换站点分布视图:消耗分布',
]);
const spendButton = buttons.find((button) => collectText(button) === '消耗分布');
await act(async () => {
spendButton!.props.onClick();
});
const updatedButtons = renderer.root.find((node) => node.props.role === 'group' && node.props['aria-label'] === '站点分布视图').findAllByType('button');
expect(updatedButtons.map((button) => button.props['aria-pressed'])).toEqual([false, true]);
renderer.unmount();
});
}); });
@@ -239,6 +239,8 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
{/* Toggle buttons */} {/* Toggle buttons */}
<div <div
role="group"
aria-label="站点分布视图"
style={{ style={{
display: 'flex', display: 'flex',
gap: 0, gap: 0,
@@ -249,6 +251,9 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
}} }}
> >
<button <button
type="button"
aria-pressed={viewMode === 'balance'}
aria-label="切换站点分布视图:余额分布"
onClick={() => setViewMode('balance')} onClick={() => setViewMode('balance')}
style={{ style={{
padding: '5px 14px', padding: '5px 14px',
@@ -266,6 +271,9 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
余额分布 余额分布
</button> </button>
<button <button
type="button"
aria-pressed={viewMode === 'spend'}
aria-label="切换站点分布视图:消耗分布"
onClick={() => setViewMode('spend')} onClick={() => setViewMode('spend')}
style={{ style={{
padding: '5px 14px', padding: '5px 14px',
@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import SiteTrendChart from './SiteTrendChart.js';
vi.mock('@visactor/react-vchart', () => ({
VChart: () => null,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
describe('SiteTrendChart', () => {
const originalDocument = globalThis.document;
const originalGetComputedStyle = globalThis.getComputedStyle;
const originalMutationObserver = globalThis.MutationObserver;
beforeEach(() => {
globalThis.document = {
documentElement: {
getAttribute: vi.fn(),
},
} as unknown as Document;
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'getComputedStyle');
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'MutationObserver');
});
afterEach(() => {
globalThis.document = originalDocument;
globalThis.getComputedStyle = originalGetComputedStyle;
globalThis.MutationObserver = originalMutationObserver;
});
it('exposes trend metric toggles as pressed non-submit buttons', async () => {
let renderer!: WebTestRenderer;
await act(async () => {
renderer = create(
<SiteTrendChart
data={[
{
date: '2026-06-07',
spend: 1.2,
calls: 3,
tokens: 400,
},
]}
/>,
);
});
const group = renderer.root.find((node) => node.props.role === 'group' && node.props['aria-label'] === '站点趋势指标');
const buttons = renderer.root.findAll((node) => node.type === 'button' && node.parent === group);
expect(buttons.map((button) => button.props.type)).toEqual(['button', 'button']);
expect(buttons.map((button) => button.props['aria-pressed'])).toEqual([true, false]);
expect(buttons.map((button) => button.props['aria-label'])).toEqual([
'切换站点趋势指标:消耗趋势',
'切换站点趋势指标:调用趋势',
]);
const callsButton = buttons.find((button) => collectText(button) === '调用趋势');
await act(async () => {
callsButton!.props.onClick();
});
const updatedButtons = renderer.root.findAll((node) => node.type === 'button' && node.parent?.props['aria-label'] === '站点趋势指标');
expect(updatedButtons.map((button) => button.props['aria-pressed'])).toEqual([false, true]);
renderer.unmount();
});
});
+4 -1
View File
@@ -214,10 +214,13 @@ function MetricToggle({
onChange: (m: Metric) => void; onChange: (m: Metric) => void;
}) { }) {
return ( return (
<div style={toggleGroupStyle}> <div style={toggleGroupStyle} role="group" aria-label="站点趋势指标">
{METRIC_OPTIONS.map((opt) => ( {METRIC_OPTIONS.map((opt) => (
<button <button
key={opt.key} key={opt.key}
type="button"
aria-pressed={metric === opt.key}
aria-label={`切换站点趋势指标:${opt.label}`}
onClick={() => onChange(opt.key)} onClick={() => onChange(opt.key)}
style={{ style={{
...toggleBtnBase, ...toggleBtnBase,
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import CenteredModal from './CenteredModal.js';
import DeleteConfirmModal from './DeleteConfirmModal.js';
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
describe('DeleteConfirmModal', () => {
it('uses the shared labelled modal shell and enables Escape dismissal', async () => {
const onClose = vi.fn();
const onConfirm = vi.fn();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<DeleteConfirmModal
open
title="确认删除下游密钥"
description={<span>确定要删除密钥 smoke-key 吗?</span>}
onClose={onClose}
onConfirm={onConfirm}
/>,
);
});
const dialog = root.root.find((node) => node.props.role === 'dialog');
expect(dialog.props['aria-modal']).toBe('true');
expect(collectText(dialog)).toContain('确认删除下游密钥');
expect(collectText(dialog)).toContain('此操作不可撤销');
expect(collectText(dialog)).toContain('smoke-key');
const centeredDialog = root.root.findByType(CenteredModal);
expect(centeredDialog.props.closeOnEscape).toBe(true);
expect(centeredDialog.props.maxWidth).toBe(560);
} finally {
root?.unmount();
}
});
it('disables destructive actions while loading', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<DeleteConfirmModal
open
description="确定删除?"
loading
onClose={() => {}}
onConfirm={() => {}}
/>,
);
});
const buttons = root.root.findAllByType('button');
const cancelButton = buttons.find((button) => collectText(button) === '取消');
const confirmButton = buttons.find((button) => collectText(button).includes('删除中...'));
expect(cancelButton?.props.type).toBe('button');
expect(confirmButton?.props.type).toBe('button');
expect(cancelButton?.props.disabled).toBe(true);
expect(confirmButton?.props.disabled).toBe(true);
} finally {
root?.unmount();
}
});
});
+100
View File
@@ -0,0 +1,100 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { trapFocusInContainer } from './focusTrap.js';
function keyboardEvent(key: string, shiftKey = false) {
return {
key,
shiftKey,
preventDefault: vi.fn(),
} as unknown as KeyboardEvent & { preventDefault: ReturnType<typeof vi.fn> };
}
function focusableElement(tabIndex = 0) {
return {
tabIndex,
focus: vi.fn(),
hasAttribute: vi.fn(() => false),
getAttribute: vi.fn(() => null),
} as unknown as HTMLElement & { focus: ReturnType<typeof vi.fn> };
}
function containerElement(elements: HTMLElement[], containsActive = true) {
return {
focus: vi.fn(),
contains: vi.fn(() => containsActive),
querySelectorAll: vi.fn(() => elements),
} as unknown as HTMLElement & {
focus: ReturnType<typeof vi.fn>;
contains: ReturnType<typeof vi.fn>;
querySelectorAll: ReturnType<typeof vi.fn>;
};
}
describe('focus trap helper', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
function stubHTMLElement() {
class MockHTMLElement {}
vi.stubGlobal('HTMLElement', MockHTMLElement);
return MockHTMLElement;
}
function stubDocumentActiveElement(activeElement: Element | null) {
vi.stubGlobal('document', {
activeElement,
});
}
it('wraps Tab from the last focusable element to the first', () => {
const MockHTMLElement = stubHTMLElement();
const first = Object.assign(new MockHTMLElement(), focusableElement()) as HTMLElement & { focus: ReturnType<typeof vi.fn> };
const last = Object.assign(new MockHTMLElement(), focusableElement()) as HTMLElement & { focus: ReturnType<typeof vi.fn> };
const container = containerElement([first, last]);
stubDocumentActiveElement(last);
const event = keyboardEvent('Tab');
trapFocusInContainer(event, container);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
expect(first.focus).toHaveBeenCalledWith({ preventScroll: true });
});
it('wraps Shift+Tab from the first focusable element to the last', () => {
const MockHTMLElement = stubHTMLElement();
const first = Object.assign(new MockHTMLElement(), focusableElement()) as HTMLElement & { focus: ReturnType<typeof vi.fn> };
const last = Object.assign(new MockHTMLElement(), focusableElement()) as HTMLElement & { focus: ReturnType<typeof vi.fn> };
const container = containerElement([first, last]);
stubDocumentActiveElement(first);
const event = keyboardEvent('Tab', true);
trapFocusInContainer(event, container);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
expect(last.focus).toHaveBeenCalledWith({ preventScroll: true });
});
it('returns focus to the container when no child control is focusable', () => {
stubHTMLElement();
const container = containerElement([]);
const event = keyboardEvent('Tab');
trapFocusInContainer(event, container);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
expect(container.focus).toHaveBeenCalledWith({ preventScroll: true });
});
it('ignores non-Tab keyboard events', () => {
const MockHTMLElement = stubHTMLElement();
const first = Object.assign(new MockHTMLElement(), focusableElement()) as HTMLElement & { focus: ReturnType<typeof vi.fn> };
const container = containerElement([first]);
const event = keyboardEvent('Escape');
trapFocusInContainer(event, container);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(first.focus).not.toHaveBeenCalled();
});
});
+51
View File
@@ -0,0 +1,51 @@
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
function isFocusableElement(element: Element): element is HTMLElement {
if (!(element instanceof HTMLElement)) return false;
if (element.hasAttribute('disabled')) return false;
if (element.getAttribute('aria-hidden') === 'true') return false;
return element.tabIndex >= 0;
}
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR))
.filter(isFocusableElement);
}
export function trapFocusInContainer(
event: KeyboardEvent,
container: HTMLElement | null,
) {
if (event.key !== 'Tab' || !container) return;
const focusableElements = getFocusableElements(container);
if (focusableElements.length === 0) {
event.preventDefault();
container.focus({ preventScroll: true });
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const activeElement = document.activeElement;
if (event.shiftKey) {
if (activeElement === firstElement || !container.contains(activeElement)) {
event.preventDefault();
lastElement.focus({ preventScroll: true });
}
return;
}
if (activeElement === lastElement || !container.contains(activeElement)) {
event.preventDefault();
firstElement.focus({ preventScroll: true });
}
}
+5 -1
View File
@@ -17,6 +17,10 @@ describe('MobileBatchBar', () => {
expect(text).toContain('已选 2 项'); expect(text).toContain('已选 2 项');
expect(text).toContain('批量删除'); expect(text).toContain('批量删除');
expect(root.root.find((node) => node.props?.className === 'mobile-actions-bar mobile-batch-bar')).toBeTruthy(); const bar = root.root.find((node) => String(node.props?.className || '').includes('mobile-batch-bar'));
expect(bar.props.className).toContain('responsive-batch-action-bar');
const info = root.root.find((node) => node.props.className === 'mobile-actions-info');
expect(info.props.role).toBe('status');
expect(info.props['aria-live']).toBe('polite');
}); });
}); });
+123 -2
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { act, create } from 'react-test-renderer'; import { act, create } from 'react-test-renderer';
@@ -9,10 +9,28 @@ vi.mock('react-dom', () => ({
})); }));
describe('MobileDrawer', () => { describe('MobileDrawer', () => {
let activeElementMock!: HTMLElement & { focus: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.useFakeTimers();
class MockHTMLElement {
isConnected = true;
focus = vi.fn();
}
activeElementMock = new MockHTMLElement() as HTMLElement & { focus: ReturnType<typeof vi.fn> };
vi.stubGlobal('HTMLElement', MockHTMLElement);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('renders content, locks body scroll, and exposes explicit close affordances', async () => { it('renders content, locks body scroll, and exposes explicit close affordances', async () => {
const onClose = vi.fn(); const onClose = vi.fn();
let root!: WebTestRenderer; let root!: WebTestRenderer;
vi.stubGlobal('document', { vi.stubGlobal('document', {
activeElement: activeElementMock,
body: { body: {
style: { style: {
overflow: '', overflow: '',
@@ -40,6 +58,11 @@ describe('MobileDrawer', () => {
expect(text).toContain('导航菜单'); expect(text).toContain('导航菜单');
expect(document.body.style.overflow).toBe('hidden'); expect(document.body.style.overflow).toBe('hidden');
const dialog = root.root.find((node) => node.props.role === 'dialog');
expect(dialog.props['aria-modal']).toBe('true');
expect(dialog.props['aria-hidden']).toBe(false);
expect(dialog.props.tabIndex).toBe(-1);
const backdrop = root.root.find((node) => node.props.className === 'mobile-drawer-backdrop'); const backdrop = root.root.find((node) => node.props.className === 'mobile-drawer-backdrop');
expect(backdrop.type).toBe('div'); expect(backdrop.type).toBe('div');
@@ -59,10 +82,108 @@ describe('MobileDrawer', () => {
}); });
} }
expect(document.body.style.overflow).toBe(''); expect(document.body.style.overflow).toBe('');
vi.unstubAllGlobals(); expect(activeElementMock.focus).toHaveBeenCalledWith({ preventScroll: true });
} }
}); });
it('allows callers to provide a stable dialog id for trigger ownership', async () => {
let root!: WebTestRenderer;
vi.stubGlobal('document', {
activeElement: activeElementMock,
body: {
style: {
overflow: '',
},
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
try {
await act(async () => {
root = create(
<MobileDrawer open onClose={() => {}} title="筛选条件" panelId="filters-dialog">
<div>FilterContent</div>
</MobileDrawer>,
);
});
const dialog = root.root.find((node) => node.props.role === 'dialog');
expect(dialog.props.id).toBe('filters-dialog');
} finally {
root?.unmount();
}
});
it('keeps the drawer mounted during the closing animation', async () => {
let root!: WebTestRenderer;
vi.stubGlobal('document', {
activeElement: activeElementMock,
body: {
style: {
overflow: '',
},
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
try {
await act(async () => {
root = create(
<MobileDrawer open onClose={() => {}} title="导航菜单">
<div>DrawerContent</div>
</MobileDrawer>,
);
});
await act(async () => {
root.update(
<MobileDrawer open={false} onClose={() => {}} title="导航菜单">
<div>DrawerContent</div>
</MobileDrawer>,
);
});
expect(root.root.findByProps({ className: 'mobile-drawer-root is-closing' })).toBeTruthy();
expect(root.root.findByProps({ className: 'mobile-drawer-backdrop' })).toBeTruthy();
const closingDialog = root.root.find((node) => node.props.role === 'dialog');
expect(closingDialog.props['aria-hidden']).toBe(true);
const closeButton = root.root.find((node) => (
node.type === 'button'
&& node.props.className === 'mobile-drawer-close'
));
expect(closeButton.props.tabIndex).toBe(-1);
await act(async () => {
vi.advanceTimersByTime(279);
});
expect(root.root.findByProps({ className: 'mobile-drawer-root is-closing' })).toBeTruthy();
await act(async () => {
vi.advanceTimersByTime(1);
});
expect(root.toJSON()).toBeNull();
} finally {
root?.unmount();
}
});
it('uses the shared animated visibility primitive for drawer presence', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/components/MobileDrawer.tsx'), 'utf8');
expect(source).toContain("import { useAnimatedVisibility } from './useAnimatedVisibility.js';");
expect(source).toContain('const presence = useAnimatedVisibility(open, 280);');
expect(source).toContain("presence.isVisible ? '' : 'is-closing'");
expect(source).toContain('const panelRef = useRef<HTMLDivElement | null>(null);');
expect(source).toContain('ref={panelRef}');
expect(source).toContain('tabIndex={-1}');
expect(source).toContain('panel.focus({ preventScroll: true });');
expect(source).toContain('lastActiveElementRef.current?.focus({ preventScroll: true });');
expect(source).toContain("import { trapFocusInContainer } from './focusTrap.js';");
expect(source).toContain('trapFocusInContainer(event, panelRef.current);');
});
it('defines independent right-side drawer animations in shared css', () => { it('defines independent right-side drawer animations in shared css', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n'); const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
@@ -20,7 +20,7 @@ describe('MobileFilterSheet', () => {
try { try {
const root = create( const root = create(
<MobileFilterSheet open onClose={() => {}} title="筛选条件"> <MobileFilterSheet open onClose={() => {}} title="筛选条件" panelId="mobile-filters">
<div>FilterContent</div> <div>FilterContent</div>
</MobileFilterSheet>, </MobileFilterSheet>,
); );
@@ -33,6 +33,7 @@ describe('MobileFilterSheet', () => {
expect(text).toContain('筛选条件'); expect(text).toContain('筛选条件');
expect(text).toContain('FilterContent'); expect(text).toContain('FilterContent');
expect(root.root.find((node) => node.props?.className === 'mobile-filter-panel')).toBeTruthy(); expect(root.root.find((node) => node.props?.className === 'mobile-filter-panel')).toBeTruthy();
expect(root.root.find((node) => node.props?.role === 'dialog').props.id).toBe('mobile-filters');
} finally { } finally {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
} }
@@ -0,0 +1,941 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { isValidElement } from 'react';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter, useLocation } from 'react-router-dom';
import NotificationPanel from './NotificationPanel.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getEvents: vi.fn(),
markAllEventsRead: vi.fn(),
clearEvents: vi.fn(),
getAnnouncements: vi.fn(),
markAllAnnouncementsRead: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: {
getEvents: apiMock.getEvents,
markAllEventsRead: apiMock.markAllEventsRead,
clearEvents: apiMock.clearEvents,
},
userApi: {
getAnnouncements: apiMock.getAnnouncements,
markAllAnnouncementsRead: apiMock.markAllAnnouncementsRead,
},
}));
vi.mock('../i18n.js', () => ({
useI18n: () => ({
t: (value: string) => value,
}),
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function collectReactNodeText(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') {
return String(value);
}
if (Array.isArray(value)) {
return value.map(collectReactNodeText).join('');
}
if (isValidElement<{ children?: unknown }>(value)) {
return collectReactNodeText(value.props.children);
}
return '';
}
function LocationProbe() {
const location = useLocation();
return <div id="location-probe">{`${location.pathname}${location.search}`}</div>;
}
function findEventList(root: ReactTestInstance): ReactTestInstance {
return root.find((node) => (
node.props['aria-live'] === 'polite'
&& Object.prototype.hasOwnProperty.call(node.props, 'aria-busy')
));
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('NotificationPanel', () => {
let listeners!: Map<string, EventListener[]>;
let anchorRef!: { current: HTMLButtonElement | null };
let focusedNodes!: Array<{ text: string; options?: FocusOptions }>;
let scrolledNodes!: string[];
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
focusedNodes = [];
scrolledNodes = [];
listeners = new Map();
vi.stubGlobal('document', {
addEventListener: vi.fn((type: string, listener: EventListener) => {
listeners.set(type, [...(listeners.get(type) || []), listener]);
}),
removeEventListener: vi.fn((type: string, listener: EventListener) => {
listeners.set(type, (listeners.get(type) || []).filter((item) => item !== listener));
}),
});
anchorRef = { current: null };
apiMock.markAllEventsRead.mockResolvedValue({ success: true });
apiMock.clearEvents.mockResolvedValue({ success: true });
apiMock.getAnnouncements.mockResolvedValue([]);
apiMock.markAllAnnouncementsRead.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('renders as a labelled dialog when opened', async () => {
apiMock.getEvents.mockResolvedValue([]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<NotificationPanel
open
onClose={() => {}}
anchorRef={anchorRef}
panelId="notification-panel-test-id"
/>
</MemoryRouter>,
);
});
await flushMicrotasks();
const dialog = root.root.find((node) => node.props.role === 'dialog');
expect(dialog.props.id).toBe('notification-panel-test-id');
expect(dialog.props['aria-label']).toBe('通知');
expect(dialog.props['aria-hidden']).toBe(false);
expect(dialog.props.tabIndex).toBe(-1);
const eventList = findEventList(root.root);
expect(eventList.props.role).toBe('list');
expect(eventList.props['aria-busy']).toBe(false);
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('exposes the active notification filter as a pressed button', async () => {
apiMock.getEvents.mockResolvedValue([]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<NotificationPanel
open
onClose={() => {}}
anchorRef={anchorRef}
/>
</MemoryRouter>,
);
});
await flushMicrotasks();
const allFilter = root.root.find((node) => (
node.type === 'button'
&& node.props['aria-label'] === '通知筛选: 全部'
));
const proxyFilter = root.root.find((node) => (
node.type === 'button'
&& node.props['aria-label'] === '通知筛选: 代理'
));
expect(allFilter.props['aria-pressed']).toBe(true);
expect(proxyFilter.props['aria-pressed']).toBe(false);
await act(async () => {
proxyFilter.props.onClick();
});
await flushMicrotasks();
const pressedProxyFilter = root.root.find((node) => (
node.type === 'button'
&& node.props['aria-label'] === '通知筛选: 代理'
));
const releasedAllFilter = root.root.find((node) => (
node.type === 'button'
&& node.props['aria-label'] === '通知筛选: 全部'
));
expect(pressedProxyFilter.props['aria-pressed']).toBe(true);
expect(releasedAllFilter.props['aria-pressed']).toBe(false);
expect(apiMock.getEvents).toHaveBeenLastCalledWith('type=proxy');
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('marks unread events as read and navigates when an event is opened', async () => {
const onClose = vi.fn();
const onUnreadCountChange = vi.fn();
apiMock.getEvents.mockResolvedValue([
{
id: 11,
type: 'proxy',
level: 'warning',
title: 'Slow proxy request',
message: 'Latency spike',
createdAt: '2026-06-07T00:00:00Z',
read: false,
relatedType: 'proxy_log',
relatedId: 101,
},
]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<LocationProbe />
<NotificationPanel
open
onClose={onClose}
anchorRef={anchorRef}
onUnreadCountChange={onUnreadCountChange}
/>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(apiMock.getEvents).toHaveBeenCalledWith('');
expect(apiMock.markAllEventsRead).toHaveBeenCalledTimes(1);
expect(onUnreadCountChange).toHaveBeenCalledWith(0);
expect(collectText(root.root)).toContain('Slow proxy request');
expect(collectText(root.root)).toContain('查看日志');
expect(collectText(root.root)).toContain('打开这条代理请求');
expect(findEventList(root.root).props['aria-busy']).toBe(false);
const item = root.root.find((node) => (
String(node.props.className || '').includes('notification-event-item')
));
expect(item.props.role).toBe('button');
expect(item.props.tabIndex).toBe(0);
const preventDefault = vi.fn();
await act(async () => {
item.props.onKeyDown({
key: ' ',
preventDefault,
});
});
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
const locationAfterClick = root.root.find((node) => node.props?.id === 'location-probe');
expect(collectText(locationAfterClick)).toBe('/logs?logId=101');
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('uses user announcements instead of admin events for user notifications', async () => {
const onClose = vi.fn();
const onUnreadCountChange = vi.fn();
apiMock.getEvents.mockResolvedValue([
{
id: 500,
type: 'runtime',
level: 'info',
title: 'Admin runtime event',
message: 'Must not leak to users',
read: false,
},
]);
apiMock.getAnnouncements.mockResolvedValue([
{
id: 7,
title: '普通用户公告',
content: '只显示用户公告',
level: 'warning',
publishedAt: '2026-06-09T08:00:00Z',
read: false,
},
]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/user/dashboard']}>
<LocationProbe />
<NotificationPanel
open
onClose={onClose}
anchorRef={anchorRef}
onUnreadCountChange={onUnreadCountChange}
audience="user"
/>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(apiMock.getAnnouncements).toHaveBeenCalledWith('limit=50');
expect(apiMock.getEvents).not.toHaveBeenCalled();
expect(apiMock.markAllAnnouncementsRead).toHaveBeenCalledTimes(1);
expect(apiMock.markAllEventsRead).not.toHaveBeenCalled();
expect(onUnreadCountChange).toHaveBeenCalledWith(0);
expect(collectText(root.root)).toContain('普通用户公告');
expect(collectText(root.root)).toContain('只显示用户公告');
expect(collectText(root.root)).not.toContain('Admin runtime event');
expect(root.root.findAll((node) => (
node.type === 'button'
&& node.props['aria-label'] === '通知筛选: 代理'
))).toHaveLength(0);
expect(root.root.find((node) => (
node.type === 'button'
&& node.props['aria-label'] === '通知筛选: 用户公告'
)).props['aria-pressed']).toBe(false);
const item = root.root.find((node) => (
String(node.props.className || '').includes('notification-event-item')
));
await act(async () => {
item.props.onClick();
});
expect(onClose).toHaveBeenCalledTimes(1);
const locationAfterClick = root.root.find((node) => node.props?.id === 'location-probe');
expect(collectText(locationAfterClick)).toBe('/user/announcements');
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('shows diagnostic actions for notification targets without triggering the row open handler', async () => {
const onClose = vi.fn();
apiMock.getEvents.mockResolvedValue([
{
id: 14,
type: 'proxy',
level: 'warning',
title: 'Proxy failed',
message: 'Open the exact log',
createdAt: '2026-06-07T00:00:00Z',
read: true,
relatedType: 'proxy_log',
relatedId: 101,
},
]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<LocationProbe />
<NotificationPanel
open
onClose={onClose}
anchorRef={anchorRef}
/>
</MemoryRouter>,
);
});
await flushMicrotasks();
const logAction = root.root.find((node) => (
node.type === 'a'
&& collectText(node).trim() === '查看日志'
));
expect(logAction.props.href).toBe('/logs?logId=101');
expect(logAction.props.tabIndex).toBeUndefined();
const stopPropagation = vi.fn();
await act(async () => {
logAction.props.onClick({ stopPropagation });
});
expect(stopPropagation).toHaveBeenCalledTimes(1);
expect(onClose).not.toHaveBeenCalled();
const locationAfterAction = root.root.find((node) => node.props?.id === 'location-probe');
expect(collectText(locationAfterAction)).toBe('/');
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('supports arrow key navigation between notification items', async () => {
apiMock.getEvents.mockResolvedValue([
{
id: 31,
type: 'status',
level: 'info',
title: 'First event',
message: 'First message',
read: true,
},
{
id: 32,
type: 'proxy',
level: 'warning',
title: 'Second event',
message: 'Second message',
read: true,
relatedType: 'route',
relatedId: 77,
},
]);
const onClose = vi.fn();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<LocationProbe />
<NotificationPanel open onClose={onClose} anchorRef={anchorRef} />
</MemoryRouter>,
{
createNodeMock: (element) => {
if (
typeof element.type === 'string'
&& String(element.props.className || '').includes('notification-event-item')
) {
return {
focus: (options?: FocusOptions) => {
focusedNodes.push({ text: collectReactNodeText(element.props.children), options });
},
scrollIntoView: () => {
scrolledNodes.push(collectReactNodeText(element.props.children));
},
};
}
return null;
},
},
);
});
await flushMicrotasks();
const dialog = root.root.find((node) => node.props.role === 'dialog');
await act(async () => {
dialog.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
});
let items = root.root.findAll((node) => String(node.props.className || '').includes('notification-event-item'));
expect(String(items[0]?.props.className)).toContain('is-active');
expect(items[0]?.props['aria-current']).toBe('true');
expect(scrolledNodes.at(-1)).toContain('First event');
expect(focusedNodes.at(-1)).toEqual({
text: expect.stringContaining('First event'),
options: { preventScroll: true },
});
await act(async () => {
dialog.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
});
items = root.root.findAll((node) => String(node.props.className || '').includes('notification-event-item'));
expect(String(items[1]?.props.className)).toContain('is-active');
expect(items[1]?.props['aria-current']).toBe('true');
expect(scrolledNodes.at(-1)).toContain('Second event');
expect(focusedNodes.at(-1)).toEqual({
text: expect.stringContaining('Second event'),
options: { preventScroll: true },
});
await act(async () => {
items[1]!.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() });
});
const locationAfterEnter = root.root.find((node) => node.props?.id === 'location-probe');
expect(collectText(locationAfterEnter)).toBe('/routes?routeId=77');
expect(onClose).toHaveBeenCalledTimes(1);
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('keeps active notification keyboard affordances wired in source', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/components/NotificationPanel.tsx'), 'utf8');
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
expect(source).toContain('const activeEvent = eventRefs.current[activeEventIndex];');
expect(source).toContain('activeEvent?.scrollIntoView({ block: \'nearest\' });');
expect(source).toContain('activeEvent?.focus({ preventScroll: true });');
expect(source).toContain('focusActiveEventRef.current = true;');
expect(source).toContain('focusActiveEventRef.current = false;');
expect(source).toContain('tabIndex={-1}');
expect(source).toContain("className={`notification-event-item ${active ? 'is-active' : ''}`.trim()}");
expect(source).toContain("aria-current={active ? 'true' : undefined}");
expect(css).toContain('.notification-event-item.is-active');
});
it('keeps the notification flyout bounded on narrow mobile viewports', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/components/NotificationPanel.tsx'), 'utf8');
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
expect(source).toContain('className={`user-dropdown notification-panel ${presence.isVisible ? \'\' : \'is-closing\'}`.trim()}');
expect(source).toContain('className="notification-panel-list"');
expect(source).not.toContain('width: 360');
expect(source).not.toContain('maxHeight: 480');
expect(source).not.toContain('maxHeight: 360');
expect(css).toContain('.notification-panel {\n width: min(360px, calc(100vw - 24px));');
expect(css).toContain('max-height: min(480px, calc(100dvh - var(--topbar-height) - 18px));');
expect(css).toContain('.notification-panel-list {\n max-height: min(360px, calc(100dvh - var(--topbar-height) - 156px));');
expect(css).toContain('overscroll-behavior: contain;');
expect(css).toContain('.notification-panel {\n right: -8px !important;');
expect(css).toContain('max-height: calc(100dvh - var(--topbar-height) - 14px);');
});
it('opens trace and route related events at their focused destinations', async () => {
const onClose = vi.fn();
apiMock.getEvents.mockResolvedValue([
{
id: 21,
type: 'proxy',
level: 'warning',
title: 'Trace captured',
message: 'Debug trace detail',
read: true,
relatedType: 'proxy_debug_trace',
relatedId: 701,
},
{
id: 22,
type: 'proxy',
level: 'info',
title: 'Route selected',
message: 'Route detail',
read: true,
relatedType: 'route',
relatedId: 42,
},
]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<LocationProbe />
<NotificationPanel open onClose={onClose} anchorRef={anchorRef} />
</MemoryRouter>,
);
});
await flushMicrotasks();
const items = root.root.findAll((node) => String(node.props.className || '').includes('notification-event-item'));
expect(items).toHaveLength(2);
await act(async () => {
items[0]!.props.onClick();
});
let locationAfterClick = root.root.find((node) => node.props?.id === 'location-probe');
expect(collectText(locationAfterClick)).toBe('/logs?traceId=701');
await act(async () => {
items[1]!.props.onClick();
});
locationAfterClick = root.root.find((node) => node.props?.id === 'location-probe');
expect(collectText(locationAfterClick)).toBe('/routes?routeId=42');
expect(onClose).toHaveBeenCalledTimes(2);
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('closes on Escape and removes key listeners on close', async () => {
const onClose = vi.fn();
const anchorFocus = vi.fn();
anchorRef.current = {
isConnected: true,
focus: anchorFocus,
contains: () => false,
} as unknown as HTMLButtonElement;
apiMock.getEvents.mockResolvedValue([]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<NotificationPanel open onClose={onClose} anchorRef={anchorRef} />
</MemoryRouter>,
);
});
await flushMicrotasks();
const keydownListeners = listeners.get('keydown') || [];
expect(keydownListeners.length).toBeGreaterThan(0);
keydownListeners.at(-1)?.({ key: 'Escape' } as KeyboardEvent);
expect(onClose).toHaveBeenCalledTimes(1);
expect(anchorFocus).toHaveBeenCalledWith({ preventScroll: true });
await act(async () => {
root.update(
<MemoryRouter initialEntries={['/']}>
<NotificationPanel open={false} onClose={onClose} anchorRef={anchorRef} />
</MemoryRouter>,
);
});
expect(listeners.get('keydown') || []).toHaveLength(0);
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('removes interactive children from the tab order while closing', async () => {
apiMock.getEvents.mockResolvedValue([
{
id: 41,
type: 'status',
level: 'info',
title: 'Closing event',
message: 'Should leave tab order',
read: true,
relatedType: 'proxy_log',
relatedId: 101,
},
]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<NotificationPanel open onClose={() => {}} anchorRef={anchorRef} />
</MemoryRouter>,
);
});
await flushMicrotasks();
await act(async () => {
root.update(
<MemoryRouter initialEntries={['/']}>
<NotificationPanel open={false} onClose={() => {}} anchorRef={anchorRef} />
</MemoryRouter>,
);
});
const dialog = root.root.find((node) => node.props.role === 'dialog');
expect(dialog.props['aria-hidden']).toBe(true);
expect(String(dialog.props.className)).toContain('is-closing');
const clearButton = root.root.find((node) => (
node.type === 'button'
&& collectText(node).trim() === '清空'
));
expect(clearButton.props.tabIndex).toBe(-1);
const filterButtons = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props['aria-label'] === 'string'
&& node.props['aria-label'].startsWith('通知筛选:')
));
expect(filterButtons.length).toBeGreaterThan(0);
expect(filterButtons.every((button) => button.props.tabIndex === -1)).toBe(true);
const items = root.root.findAll((node) => String(node.props.className || '').includes('notification-event-item'));
expect(items).toHaveLength(1);
expect(items[0].props.tabIndex).toBe(-1);
const diagnosticLinks = root.root.findAll((node) => (
node.type === 'a'
&& String(node.props.className || '').includes('diagnostic-action')
));
expect(diagnosticLinks.length).toBeGreaterThan(0);
expect(diagnosticLinks.every((link) => link.props.tabIndex === -1)).toBe(true);
await act(async () => {
vi.advanceTimersByTime(160);
});
expect(root.root.findAll((node) => node.props.role === 'dialog')).toHaveLength(0);
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('ignores in-flight event loads after the panel closes', async () => {
let resolveEvents!: (value: unknown) => void;
apiMock.getEvents.mockReturnValue(new Promise((resolve) => {
resolveEvents = resolve;
}));
const onUnreadCountChange = vi.fn();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<NotificationPanel
open
onClose={() => {}}
anchorRef={anchorRef}
onUnreadCountChange={onUnreadCountChange}
/>
</MemoryRouter>,
);
});
expect(apiMock.getEvents).toHaveBeenCalledWith('');
expect(findEventList(root.root).props['aria-busy']).toBe(true);
await act(async () => {
root.update(
<MemoryRouter initialEntries={['/']}>
<NotificationPanel
open={false}
onClose={() => {}}
anchorRef={anchorRef}
onUnreadCountChange={onUnreadCountChange}
/>
</MemoryRouter>,
);
});
await act(async () => {
resolveEvents([
{
id: 12,
type: 'status',
level: 'info',
title: 'Late event',
message: 'Should not render',
read: false,
},
]);
await Promise.resolve();
});
expect(JSON.stringify(root.toJSON())).not.toContain('Late event');
expect(apiMock.markAllEventsRead).not.toHaveBeenCalled();
expect(onUnreadCountChange).not.toHaveBeenCalled();
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('does not rewrite closed panel state after clearing events resolves late', async () => {
let resolveClear!: (value: unknown) => void;
apiMock.getEvents.mockResolvedValue([
{
id: 13,
type: 'status',
level: 'info',
title: 'Clear me',
message: 'Will be cleared',
read: true,
},
]);
apiMock.clearEvents.mockReturnValue(new Promise((resolve) => {
resolveClear = resolve;
}));
const onUnreadCountChange = vi.fn();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<NotificationPanel
open
onClose={() => {}}
anchorRef={anchorRef}
onUnreadCountChange={onUnreadCountChange}
/>
</MemoryRouter>,
);
});
await flushMicrotasks();
const clearButton = root.root.find((node) => (
node.type === 'button'
&& collectText(node).trim() === '清空'
));
expect(clearButton.props.type).toBe('button');
await act(async () => {
clearButton.props.onClick();
});
await act(async () => {
root.update(
<MemoryRouter initialEntries={['/']}>
<NotificationPanel
open={false}
onClose={() => {}}
anchorRef={anchorRef}
onUnreadCountChange={onUnreadCountChange}
/>
</MemoryRouter>,
);
});
await act(async () => {
resolveClear({ success: true });
await Promise.resolve();
});
expect(onUnreadCountChange).not.toHaveBeenCalledWith(0);
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('does not steal focus when pointer hover only previews a notification item', async () => {
apiMock.getEvents.mockResolvedValue([
{
id: 51,
type: 'status',
level: 'info',
title: 'Hover event',
message: 'Preview without focus',
read: true,
},
]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<NotificationPanel open onClose={() => {}} anchorRef={anchorRef} />
</MemoryRouter>,
{
createNodeMock: (element) => {
if (
typeof element.type === 'string'
&& String(element.props.className || '').includes('notification-event-item')
) {
return {
focus: (options?: FocusOptions) => {
focusedNodes.push({ text: collectReactNodeText(element.props.children), options });
},
scrollIntoView: () => {
scrolledNodes.push(collectReactNodeText(element.props.children));
},
};
}
return null;
},
},
);
});
await flushMicrotasks();
const item = root.root.find((node) => String(node.props.className || '').includes('notification-event-item'));
await act(async () => {
item.props.onMouseEnter();
});
const hoveredItem = root.root.find((node) => String(node.props.className || '').includes('notification-event-item'));
expect(String(hoveredItem.props.className)).toContain('is-active');
expect(scrolledNodes.at(-1)).toContain('Hover event');
expect(focusedNodes).toHaveLength(0);
} finally {
await act(async () => {
root?.unmount();
});
}
});
it('moves focus when keyboard navigation confirms an item already previewed by hover', async () => {
apiMock.getEvents.mockResolvedValue([
{
id: 61,
type: 'status',
level: 'info',
title: 'Only event',
message: 'Hover then keyboard',
read: true,
},
]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<NotificationPanel open onClose={() => {}} anchorRef={anchorRef} />
</MemoryRouter>,
{
createNodeMock: (element) => {
if (
typeof element.type === 'string'
&& String(element.props.className || '').includes('notification-event-item')
) {
return {
focus: (options?: FocusOptions) => {
focusedNodes.push({ text: collectReactNodeText(element.props.children), options });
},
scrollIntoView: () => {
scrolledNodes.push(collectReactNodeText(element.props.children));
},
};
}
return null;
},
},
);
});
await flushMicrotasks();
const item = root.root.find((node) => String(node.props.className || '').includes('notification-event-item'));
await act(async () => {
item.props.onMouseEnter();
});
expect(focusedNodes).toHaveLength(0);
const dialog = root.root.find((node) => node.props.role === 'dialog');
await act(async () => {
dialog.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
});
expect(focusedNodes.at(-1)).toEqual({
text: expect.stringContaining('Only event'),
options: { preventScroll: true },
});
} finally {
await act(async () => {
root?.unmount();
});
}
});
});
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest';
import { create } from 'react-test-renderer';
import ResponsiveFilterPanel from './ResponsiveFilterPanel.js';
vi.mock('react-dom', () => ({
createPortal: (node: unknown) => node,
}));
describe('ResponsiveFilterPanel', () => {
it('links the mobile filter trigger to the sheet dialog', () => {
const onOpen = vi.fn();
const onClose = vi.fn();
vi.stubGlobal('document', {
activeElement: null,
body: {
style: {
overflow: '',
},
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
try {
const root = create(
<ResponsiveFilterPanel
isMobile
mobileOpen
onMobileOpen={onOpen}
onMobileClose={onClose}
mobileTitle="筛选条件"
mobileContent={<div>MobileFilters</div>}
desktopContent={<div>DesktopFilters</div>}
/>,
);
const trigger = root.root.find((node) => node.type === 'button' && node.children.includes('筛选'));
expect(trigger.props['aria-haspopup']).toBe('dialog');
expect(trigger.props['aria-expanded']).toBe(true);
expect(trigger.props['aria-controls']).toBeTruthy();
const dialog = root.root.find((node) => node.props.role === 'dialog');
expect(dialog.props.id).toBe(trigger.props['aria-controls']);
expect(root.root.findAllByProps({ children: 'DesktopFilters' })).toHaveLength(0);
trigger.props.onClick();
expect(onOpen).toHaveBeenCalledTimes(1);
} finally {
vi.unstubAllGlobals();
}
});
it('renders only the desktop content outside mobile layouts', () => {
const root = create(
<ResponsiveFilterPanel
isMobile={false}
mobileOpen={false}
onMobileClose={() => {}}
mobileTitle="筛选条件"
mobileContent={<div>MobileFilters</div>}
desktopContent={<div>DesktopFilters</div>}
/>,
);
const text = root.root.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
expect(text).toContain('DesktopFilters');
expect(text).not.toContain('MobileFilters');
expect(root.root.findAll((node) => node.type === 'button')).toHaveLength(0);
});
});
@@ -1,4 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { isValidElement } from 'react';
import { act, create, type ReactTestInstance } from 'react-test-renderer'; import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter, useLocation } from 'react-router-dom'; import { MemoryRouter, useLocation } from 'react-router-dom';
import SearchModal from './SearchModal.js'; import SearchModal from './SearchModal.js';
@@ -27,11 +30,31 @@ function collectText(node: ReactTestInstance): string {
}).join(''); }).join('');
} }
function collectReactNodeText(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') {
return String(value);
}
if (Array.isArray(value)) {
return value.map(collectReactNodeText).join('');
}
if (isValidElement<{ children?: unknown }>(value)) {
return collectReactNodeText(value.props.children);
}
return '';
}
function LocationProbe() { function LocationProbe() {
const location = useLocation(); const location = useLocation();
return <div id="location-probe">{`${location.pathname}${location.search}`}</div>; return <div id="location-probe">{`${location.pathname}${location.search}`}</div>;
} }
function findResultsRegion(root: ReactTestInstance): ReactTestInstance {
return root.find((node) => (
node.props['aria-live'] === 'polite'
&& Object.prototype.hasOwnProperty.call(node.props, 'aria-busy')
));
}
async function flushMicrotasks() { async function flushMicrotasks() {
await act(async () => { await act(async () => {
await Promise.resolve(); await Promise.resolve();
@@ -40,16 +63,35 @@ async function flushMicrotasks() {
} }
describe('SearchModal results', () => { describe('SearchModal results', () => {
let activeElementMock!: HTMLElement & { focus: ReturnType<typeof vi.fn> };
let documentBodyStyle!: { overflow: string };
let focusedResults!: Array<{ text: string; options?: FocusOptions }>;
let scrolledResults!: string[];
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.useFakeTimers(); vi.useFakeTimers();
focusedResults = [];
scrolledResults = [];
class MockHTMLElement {
focus = vi.fn();
}
activeElementMock = new MockHTMLElement() as unknown as HTMLElement & { focus: ReturnType<typeof vi.fn> };
Object.defineProperty(globalThis, 'HTMLElement', {
value: MockHTMLElement,
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, 'window', { Object.defineProperty(globalThis, 'window', {
value: globalThis, value: globalThis,
configurable: true, configurable: true,
writable: true, writable: true,
}); });
documentBodyStyle = { overflow: 'auto' };
Object.defineProperty(globalThis, 'document', { Object.defineProperty(globalThis, 'document', {
value: { value: {
activeElement: activeElementMock,
body: { style: documentBodyStyle },
addEventListener: () => undefined, addEventListener: () => undefined,
removeEventListener: () => undefined, removeEventListener: () => undefined,
}, },
@@ -64,6 +106,122 @@ describe('SearchModal results', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('renders as a labelled dialog and restores focus after closing', async () => {
apiMock.search.mockResolvedValue({
models: [],
sites: [],
checkinLogs: [],
proxyLogs: [],
accounts: [],
accountTokens: [],
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<SearchModal open onClose={() => {}} dialogId="global-search-dialog-test" />
</MemoryRouter>,
);
});
const dialog = root.root.find((node) => node.props.role === 'dialog');
expect(dialog.props.id).toBe('global-search-dialog-test');
expect(dialog.props['aria-modal']).toBe('true');
expect(typeof dialog.props['aria-labelledby']).toBe('string');
expect(dialog.props['aria-hidden']).toBe(false);
expect(collectText(dialog)).toContain('全局搜索');
const resultsRegion = findResultsRegion(root.root);
expect(resultsRegion.props.role).toBe('listbox');
expect(resultsRegion.props['aria-hidden']).toBe(false);
expect(resultsRegion.props['aria-busy']).toBe(false);
await act(async () => {
root.update(
<MemoryRouter initialEntries={['/']}>
<SearchModal open={false} onClose={() => {}} />
</MemoryRouter>,
);
});
expect(activeElementMock.focus).toHaveBeenCalledWith({ preventScroll: true });
} finally {
root?.unmount();
}
});
it('locks body scrolling while open and restores it after close', async () => {
apiMock.search.mockResolvedValue({
models: [],
sites: [],
checkinLogs: [],
proxyLogs: [],
accounts: [],
accountTokens: [],
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<SearchModal open onClose={() => {}} />
</MemoryRouter>,
);
});
expect(documentBodyStyle.overflow).toBe('hidden');
await act(async () => {
root.update(
<MemoryRouter initialEntries={['/']}>
<SearchModal open={false} onClose={() => {}} />
</MemoryRouter>,
);
});
expect(documentBodyStyle.overflow).toBe('auto');
} finally {
root?.unmount();
}
});
it('renders safely when document.body is unavailable', async () => {
Object.defineProperty(globalThis, 'document', {
value: {
activeElement: activeElementMock,
addEventListener: () => undefined,
removeEventListener: () => undefined,
},
configurable: true,
writable: true,
});
apiMock.search.mockResolvedValue({
models: [],
sites: [],
checkinLogs: [],
proxyLogs: [],
accounts: [],
accountTokens: [],
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<SearchModal open onClose={() => {}} />
</MemoryRouter>,
);
});
expect(root.toJSON()).toBeTruthy();
} finally {
root?.unmount();
}
});
it('renders account token results and navigates API key accounts to the apikey segment', async () => { it('renders account token results and navigates API key accounts to the apikey segment', async () => {
apiMock.search.mockResolvedValue({ apiMock.search.mockResolvedValue({
models: [], models: [],
@@ -119,6 +277,13 @@ describe('SearchModal results', () => {
const buttons = root.root.findAll((node) => node.type === 'button'); const buttons = root.root.findAll((node) => node.type === 'button');
const accountButton = buttons.find((node) => collectText(node).includes('API Key 连接')); const accountButton = buttons.find((node) => collectText(node).includes('API Key 连接'));
expect(accountButton).toBeTruthy(); expect(accountButton).toBeTruthy();
expect(accountButton!.props.type).toBe('button');
const resultButtons = buttons.filter((node) => String(node.props.className || '').includes('search-result-item'));
expect(resultButtons.length).toBeGreaterThan(0);
expect(resultButtons.every((node) => node.props.type === 'button')).toBe(true);
expect(resultButtons.every((node) => node.props.tabIndex === 0)).toBe(true);
expect(findResultsRegion(root.root).props['aria-busy']).toBe(false);
await act(async () => { await act(async () => {
accountButton!.props.onClick(); accountButton!.props.onClick();
@@ -129,4 +294,357 @@ describe('SearchModal results', () => {
root?.unmount(); root?.unmount();
} }
}); });
it('supports arrow key navigation and Enter activation from the search field', async () => {
apiMock.search.mockResolvedValue({
models: [
{ name: 'gpt-search', accountCount: 1, tokenCount: 2, siteCount: 3 },
],
sites: [
{ id: 21, name: 'Search Site', url: 'https://search.example.com' },
],
checkinLogs: [],
proxyLogs: [],
accounts: [],
accountTokens: [],
});
const onClose = vi.fn();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<LocationProbe />
<SearchModal open onClose={onClose} />
</MemoryRouter>,
);
});
let input = root.root.findByType('input');
await act(async () => {
input.props.onChange({ target: { value: 'gpt' } });
vi.advanceTimersByTime(300);
await Promise.resolve();
});
await flushMicrotasks();
input = root.root.findByType('input');
expect(input.props.role).toBe('combobox');
expect(input.props['aria-expanded']).toBe(true);
expect(input.props['aria-controls']).toBeTruthy();
expect(input.props['aria-activedescendant']).toBeUndefined();
await act(async () => {
input.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
});
input = root.root.findByType('input');
const firstActiveId = input.props['aria-activedescendant'];
expect(firstActiveId).toBeTruthy();
let resultButtons = root.root.findAll((node) => String(node.props.className || '').includes('search-result-item'));
expect(resultButtons[0]?.props.id).toBe(firstActiveId);
expect(resultButtons[0]?.props.role).toBe('option');
expect(resultButtons[0]?.props['aria-selected']).toBe(true);
expect(String(resultButtons[0]?.props.className)).toContain('is-active');
await act(async () => {
input.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
});
input = root.root.findByType('input');
const secondActiveId = input.props['aria-activedescendant'];
resultButtons = root.root.findAll((node) => String(node.props.className || '').includes('search-result-item'));
expect(resultButtons[1]?.props.id).toBe(secondActiveId);
expect(String(resultButtons[1]?.props.className)).toContain('is-active');
await act(async () => {
input.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() });
});
const locationAfterEnter = root.root.find((node) => node.props?.id === 'location-probe');
expect(collectText(locationAfterEnter)).toBe('/sites?focusSiteId=21');
expect(onClose).toHaveBeenCalledTimes(1);
} finally {
root?.unmount();
}
});
it('supports keyboard navigation and activation after focus moves into result items', async () => {
apiMock.search.mockResolvedValue({
models: [
{ name: 'keyboard-model', accountCount: 1, tokenCount: 2, siteCount: 3 },
],
sites: [
{ id: 31, name: 'Keyboard Site', url: 'https://keyboard.example.com' },
],
checkinLogs: [],
proxyLogs: [],
accounts: [],
accountTokens: [],
});
const onClose = vi.fn();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<LocationProbe />
<SearchModal open onClose={onClose} />
</MemoryRouter>,
{
createNodeMock: (element) => {
if (
typeof element.type === 'string'
&& String(element.props.className || '').includes('search-result-item')
) {
return {
focus: (options?: FocusOptions) => {
focusedResults.push({ text: collectReactNodeText(element.props.children), options });
},
scrollIntoView: () => {
scrolledResults.push(collectReactNodeText(element.props.children));
},
};
}
return null;
},
},
);
});
const input = root.root.findByType('input');
await act(async () => {
input.props.onChange({ target: { value: 'keyboard' } });
vi.advanceTimersByTime(300);
await Promise.resolve();
});
await flushMicrotasks();
let resultButtons = root.root.findAll((node) => String(node.props.className || '').includes('search-result-item'));
expect(resultButtons).toHaveLength(2);
expect(resultButtons.every((node) => typeof node.props.onKeyDown === 'function')).toBe(true);
await act(async () => {
resultButtons[0]!.props.onMouseEnter();
});
expect(scrolledResults.at(-1)).toContain('keyboard-model');
expect(focusedResults).toHaveLength(0);
await act(async () => {
resultButtons[0]!.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
});
resultButtons = root.root.findAll((node) => String(node.props.className || '').includes('search-result-item'));
expect(String(resultButtons[1]?.props.className)).toContain('is-active');
expect(resultButtons[1]?.props['aria-selected']).toBe(true);
expect(scrolledResults.at(-1)).toContain('Keyboard Site');
expect(focusedResults.at(-1)).toEqual({
text: expect.stringContaining('Keyboard Site'),
options: { preventScroll: true },
});
await act(async () => {
resultButtons[1]!.props.onKeyDown({ key: ' ', preventDefault: vi.fn() });
});
const locationAfterSpace = root.root.find((node) => node.props?.id === 'location-probe');
expect(collectText(locationAfterSpace)).toBe('/sites?focusSiteId=31');
expect(onClose).toHaveBeenCalledTimes(1);
} finally {
root?.unmount();
}
});
it('keeps keyboard navigation wired to active result scrolling', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/components/SearchModal.tsx'), 'utf8');
expect(source).toContain('const activeResult = resultRefs.current[activeResultIndex];');
expect(source).toContain("activeResult?.scrollIntoView({ block: 'nearest' });");
expect(source).toContain('activeResult?.focus({ preventScroll: true });');
expect(source).toContain('const moveActiveResult = (direction: 1 | -1, shouldFocusResult: boolean) => {');
expect(source).toContain('moveActiveResult(event.key === \'ArrowDown\' ? 1 : -1, true);');
expect(source).toContain('moveActiveResult(event.key === \'ArrowDown\' ? 1 : -1, false);');
expect(source).toContain('role="combobox"');
expect(source).toContain('role="listbox"');
expect(source).toContain("import { trapFocusInContainer } from './focusTrap.js';");
expect(source).toContain('const dialogRef = useRef<HTMLDivElement | null>(null);');
expect(source).toContain('ref={dialogRef}');
expect(source).toContain("if (typeof document === 'undefined') return undefined;");
expect(source).toContain('trapFocusInContainer(e, dialogRef.current);');
expect(source).toContain("role: 'option'");
expect(source).toContain("'aria-selected': activeResultIndex === index");
});
it('keeps the global search dialog bounded on mobile viewports', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/components/SearchModal.tsx'), 'utf8');
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
expect(source).toContain('className={`modal-content search-modal ${presence.isVisible ? \'\' : \'is-closing\'}`.trim()}');
expect(source).toContain('className="search-modal-header"');
expect(source).toContain('className="search-modal-results"');
expect(source).toContain('className="search-modal-footer"');
expect(source).not.toContain('style={{ maxWidth: 560, padding: 0 }}');
expect(source).not.toContain('maxHeight: 400');
expect(css).toContain('.search-modal {\n width: min(92vw, 560px);');
expect(css).toContain('.search-modal-results {\n max-height: min(400px, calc(100dvh - var(--topbar-height) - 220px));');
expect(css).toContain('overscroll-behavior: contain;');
expect(css).toContain('.search-modal {\n width: calc(100vw - 24px);');
expect(css).toContain('.search-modal-results {\n max-height: calc(100dvh - 190px);');
expect(css).toContain('.search-modal-footer {\n flex-wrap: wrap;');
});
it('cancels pending debounced searches when the modal closes', async () => {
apiMock.search.mockResolvedValue({
models: [{ name: 'gpt-hidden', accountCount: 0, tokenCount: 0, siteCount: 0 }],
sites: [],
checkinLogs: [],
proxyLogs: [],
accounts: [],
accountTokens: [],
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<SearchModal open onClose={() => {}} />
</MemoryRouter>,
);
});
const input = root.root.findByType('input');
await act(async () => {
input.props.onChange({ target: { value: 'hidden' } });
});
await act(async () => {
root.update(
<MemoryRouter initialEntries={['/']}>
<SearchModal open={false} onClose={() => {}} />
</MemoryRouter>,
);
});
await act(async () => {
vi.advanceTimersByTime(300);
});
await flushMicrotasks();
expect(apiMock.search).not.toHaveBeenCalled();
expect(JSON.stringify(root.toJSON())).not.toContain('gpt-hidden');
} finally {
root?.unmount();
}
});
it('removes search input and results from the tab order while closing', async () => {
apiMock.search.mockResolvedValue({
models: [{ name: 'gpt-closing', accountCount: 1, tokenCount: 1, siteCount: 1 }],
sites: [],
checkinLogs: [],
proxyLogs: [],
accounts: [],
accountTokens: [],
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<SearchModal open onClose={() => {}} />
</MemoryRouter>,
);
});
const input = root.root.findByType('input');
await act(async () => {
input.props.onChange({ target: { value: 'closing' } });
vi.advanceTimersByTime(300);
await Promise.resolve();
});
await flushMicrotasks();
expect(root.root.findAll((node) => String(node.props.className || '').includes('search-result-item'))).toHaveLength(1);
await act(async () => {
root.update(
<MemoryRouter initialEntries={['/']}>
<SearchModal open={false} onClose={() => {}} />
</MemoryRouter>,
);
});
const closingDialog = root.root.find((node) => node.props.role === 'dialog');
expect(closingDialog.props['aria-hidden']).toBe(true);
expect(String(closingDialog.props.className)).toContain('is-closing');
expect(root.root.findByType('input').props.tabIndex).toBe(-1);
expect(findResultsRegion(root.root).props['aria-hidden']).toBe(true);
const closingResultButtons = root.root.findAll((node) => String(node.props.className || '').includes('search-result-item'));
expect(closingResultButtons).toHaveLength(1);
expect(closingResultButtons[0].props.tabIndex).toBe(-1);
await act(async () => {
vi.advanceTimersByTime(180);
});
expect(root.root.findAll((node) => node.props.role === 'dialog')).toHaveLength(0);
} finally {
root?.unmount();
}
});
it('ignores in-flight search results after the modal closes', async () => {
let resolveSearch!: (value: unknown) => void;
apiMock.search.mockReturnValue(new Promise((resolve) => {
resolveSearch = resolve;
}));
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<SearchModal open onClose={() => {}} />
</MemoryRouter>,
);
});
const input = root.root.findByType('input');
await act(async () => {
input.props.onChange({ target: { value: 'late' } });
vi.advanceTimersByTime(300);
await Promise.resolve();
});
expect(apiMock.search).toHaveBeenCalledWith('late');
expect(findResultsRegion(root.root).props['aria-busy']).toBe(true);
await act(async () => {
root.update(
<MemoryRouter initialEntries={['/']}>
<SearchModal open={false} onClose={() => {}} />
</MemoryRouter>,
);
});
await act(async () => {
resolveSearch({
models: [{ name: 'late-model', accountCount: 0, tokenCount: 0, siteCount: 0 }],
sites: [],
checkinLogs: [],
proxyLogs: [],
accounts: [],
accountTokens: [],
});
await Promise.resolve();
});
expect(JSON.stringify(root.toJSON())).not.toContain('late-model');
} finally {
root?.unmount();
}
});
}); });
@@ -0,0 +1,19 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('Toast accessibility architecture', () => {
it('keeps toast announcements semantic and dismissible without relying on a div click target', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/components/Toast.tsx'), 'utf8').replace(/\r\n/g, '\n');
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain('const TOAST_DURATION_MS = 3200;');
expect(source).toContain("role={t.type === 'error' ? 'alert' : 'status'}");
expect(source).toContain("aria-live={t.type === 'error' ? 'assertive' : 'polite'}");
expect(source).toContain('aria-atomic="true"');
expect(source).toContain('className="toast-dismiss"');
expect(source).toContain('aria-label={`关闭通知:${t.message}`}');
expect(css).toContain('.toast-dismiss:focus-visible {');
expect(css).toContain('animation: progress-bar var(--toast-duration, 3200ms) linear both;');
});
});
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('topbar overlay keyboard focus styles', () => {
it('keeps focus-visible feedback for searchable and notification rows', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(css).toContain('.search-result-item:hover,\n.search-result-item.is-active,\n.search-result-item:focus-visible');
expect(css).toContain('.search-result-item:focus-visible,\n.notification-event-item:focus-visible');
expect(css).toContain('.notification-event-item:hover,\n.notification-event-item.is-active,\n.notification-event-item:focus-visible');
});
it('keeps focus-visible feedback for keyboard-expandable route cards', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(css).toContain('.route-card-collapsed:hover,\n.route-card-collapsed:focus-visible');
expect(css).toContain('.route-card-collapsed:focus-visible');
});
it('keeps focus-visible feedback for keyboard-selectable table rows', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(css).toContain('.data-table tbody tr.row-selectable:hover,\n.data-table tbody tr.row-selectable:focus-visible');
expect(css).toContain('[data-theme="dark"] .data-table tbody tr.row-selectable:hover,\n[data-theme="dark"] .data-table tbody tr.row-selectable:focus-visible');
expect(css).toContain('.data-table tbody tr.row-selectable:focus-visible');
});
});
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
function PresenceProbe({ visible, durationMs = 220 }: { visible: boolean; durationMs?: number }) {
const presence = useAnimatedVisibility(visible, durationMs);
return (
<output>
{presence.shouldRender ? 'rendered' : 'unmounted'}
{'/'}
{presence.isVisible ? 'visible' : 'hidden'}
</output>
);
}
function installWindowMock(reducedMotion: boolean) {
vi.stubGlobal('window', {
matchMedia: vi.fn((query: string) => ({
matches: query === '(prefers-reduced-motion: reduce)' ? reducedMotion : false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
onchange: null,
})),
requestAnimationFrame: (callback: FrameRequestCallback) => {
callback(0);
return 1;
},
cancelAnimationFrame: vi.fn(),
});
}
function renderedText(root: WebTestRenderer): string {
return root.root.findByType('output').children.join('');
}
describe('useAnimatedVisibility', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('keeps content mounted until the exit animation duration completes', async () => {
installWindowMock(false);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(<PresenceProbe visible durationMs={180} />);
});
expect(renderedText(root)).toBe('rendered/visible');
await act(async () => {
root.update(<PresenceProbe visible={false} durationMs={180} />);
});
expect(renderedText(root)).toBe('rendered/hidden');
await act(async () => {
vi.advanceTimersByTime(179);
});
expect(renderedText(root)).toBe('rendered/hidden');
await act(async () => {
vi.advanceTimersByTime(1);
});
expect(renderedText(root)).toBe('unmounted/hidden');
} finally {
root?.unmount();
}
});
it('skips delayed exit presence when reduced motion is requested', async () => {
installWindowMock(true);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(<PresenceProbe visible durationMs={280} />);
});
expect(renderedText(root)).toBe('rendered/visible');
await act(async () => {
root.update(<PresenceProbe visible={false} durationMs={280} />);
});
expect(renderedText(root)).toBe('unmounted/hidden');
await act(async () => {
vi.advanceTimersByTime(280);
});
expect(renderedText(root)).toBe('unmounted/hidden');
} finally {
root?.unmount();
}
});
});
+11 -1
View File
@@ -5,8 +5,18 @@ export function useAnimatedVisibility(visible: boolean, durationMs = 220) {
const [isVisible, setIsVisible] = useState(visible); const [isVisible, setIsVisible] = useState(visible);
useEffect(() => { useEffect(() => {
const reducedMotion = (
typeof window !== 'undefined'
&& typeof window.matchMedia === 'function'
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
if (visible) { if (visible) {
setShouldRender(true); setShouldRender(true);
if (reducedMotion) {
setIsVisible(true);
return undefined;
}
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
const rafId = window.requestAnimationFrame(() => setIsVisible(true)); const rafId = window.requestAnimationFrame(() => setIsVisible(true));
return () => window.cancelAnimationFrame(rafId); return () => window.cancelAnimationFrame(rafId);
@@ -16,7 +26,7 @@ export function useAnimatedVisibility(visible: boolean, durationMs = 220) {
} }
setIsVisible(false); setIsVisible(false);
if (durationMs <= 0) { if (reducedMotion || durationMs <= 0) {
setShouldRender(false); setShouldRender(false);
return undefined; return undefined;
} }
+13 -9
View File
@@ -1,23 +1,23 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
function readThemeLabelColor(): string | null { function readThemeColor(name: string): string | null {
if (typeof document === 'undefined') return null; if (typeof document === 'undefined') return null;
const root = document.documentElement; const root = document.documentElement;
if (!root || typeof globalThis.getComputedStyle !== 'function') return null; if (!root || typeof globalThis.getComputedStyle !== 'function') return null;
const color = globalThis.getComputedStyle(root).getPropertyValue('--color-text-secondary').trim(); const color = globalThis.getComputedStyle(root).getPropertyValue(name).trim();
return color || null; return color || null;
} }
export function useThemeLabelColor(fallback = '#9ca3af'): string { export function useThemeColor(name: string, fallback = '#9ca3af'): string {
const [labelColor, setLabelColor] = useState(fallback); const [themeColor, setThemeColor] = useState(fallback);
useEffect(() => { useEffect(() => {
const root = typeof document !== 'undefined' ? document.documentElement : null; const root = typeof document !== 'undefined' ? document.documentElement : null;
const read = () => { const read = () => {
const color = readThemeLabelColor(); const color = readThemeColor(name);
if (color) setLabelColor(color); if (color) setThemeColor(color);
}; };
read(); read();
@@ -27,9 +27,13 @@ export function useThemeLabelColor(fallback = '#9ca3af'): string {
} }
const observer = new globalThis.MutationObserver(read); const observer = new globalThis.MutationObserver(read);
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] }); observer.observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-theme-preset'] });
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, [name]);
return labelColor; return themeColor;
}
export function useThemeLabelColor(fallback = '#9ca3af'): string {
return useThemeColor('--color-text-secondary', fallback);
} }
+13 -4
View File
@@ -80,7 +80,18 @@ export const zhToEnSupplemental: Record<string, string> = {
'成功': 'success', '成功': 'success',
'成功 (${countBy(\'success\')})': 'Success (${countBy(\'success\')})', '成功 (${countBy(\'success\')})': 'Success (${countBy(\'success\')})',
'成功率': 'success rate', '成功率': 'success rate',
'程序日志': 'Program log', '程序日志': 'System Logs',
'加载运行日志失败': 'Failed to load runtime logs',
'筛选运行日志': 'Filter runtime logs',
'仅看未读运行日志': 'Unread runtime logs only',
'当前筛选条件下没有运行日志。': 'No runtime logs match the current filters.',
'使用日志、运行日志、通知渠道和运行事件集中留痕。': 'Usage logs, runtime logs, notification channels, and runtime events are recorded centrally.',
'清理运行日志': 'Clean runtime logs',
'请求日志、运行日志和任务排障集中在这里': 'Request logs, runtime logs, and task triage are centralized here',
'聚合请求吞吐、路由健康、站点风险、后台任务和运行日志,作为值班排障的第一屏。': 'Aggregate request throughput, route health, site risk, background tasks, and runtime logs as the first on-call triage screen.',
'任务失败,需要进入对应业务页重试或查看运行日志。': 'Task failed. Retry in the related app page or inspect runtime logs.',
'查看运行日志': 'View runtime logs',
'暂无运行日志': 'No runtime logs',
'触发 Cloudflare 验证': 'Trigger Cloudflare verification', '触发 Cloudflare 验证': 'Trigger Cloudflare verification',
'触发测试通知失败': 'Trigger test notification failed', '触发测试通知失败': 'Trigger test notification failed',
'触发签到失败': 'Failed to trigger sign-in', '触发签到失败': 'Failed to trigger sign-in',
@@ -229,7 +240,7 @@ export const zhToEnSupplemental: Record<string, string> = {
'集中管理 New API / One API / OneHub / DoneHub / Veloera 等': 'Centralized management New API / One API / OneHub / DoneHub / Veloera, etc.', '集中管理 New API / One API / OneHub / DoneHub / Veloera 等': 'Centralized management New API / One API / OneHub / DoneHub / Veloera, etc.',
'计算中...': 'Calculating...', '计算中...': 'Calculating...',
'技术栈': 'technology stack', '技术栈': 'technology stack',
'加载程序日志失败': 'Loader log failed', '加载程序日志失败': 'Failed to load system logs',
'加载更多': 'load more', '加载更多': 'load more',
'加载监控配置失败': 'Failed to load monitoring configuration', '加载监控配置失败': 'Failed to load monitoring configuration',
'加载令牌失败': 'Failed to load token', '加载令牌失败': 'Failed to load token',
@@ -480,8 +491,6 @@ export const zhToEnSupplemental: Record<string, string> = {
'收到流式 [DONE] 信号。': 'Streaming [DONE] signal received.', '收到流式 [DONE] 信号。': 'Streaming [DONE] signal received.',
'收件人地址': 'Recipient address', '收件人地址': 'Recipient address',
'收起': 'close', '收起': 'close',
'收起侧边栏': 'Collapse sidebar',
'展开侧边栏': 'Expand sidebar',
'收起高级参数': 'Close advanced parameters', '收起高级参数': 'Close advanced parameters',
'手动模式适合高级场景;自动路由仍会保持开启。': 'Manual mode is suitable for advanced scenarios; automatic routing will still remain on.', '手动模式适合高级场景;自动路由仍会保持开启。': 'Manual mode is suitable for advanced scenarios; automatic routing will still remain on.',
'手动增改路由': 'Manually add and modify routes', '手动增改路由': 'Manually add and modify routes',

Some files were not shown because too many files have changed in this diff Show More