Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d697146941 | |||
| d1f3f4e93d | |||
| df97633300 | |||
| 3d54c7d529 | |||
| 55b3d4d99a | |||
| 59106e6a96 |
@@ -27,3 +27,7 @@ openspec/
|
||||
boosapi
|
||||
bossai
|
||||
docker/*.tar
|
||||
boosapi.db-shm
|
||||
boosapi.db-wal
|
||||
.tours/
|
||||
bun.lock
|
||||
|
||||
Generated
+3
@@ -399,6 +399,8 @@ dependencies = [
|
||||
"dotenvy",
|
||||
"figment",
|
||||
"futures",
|
||||
"hex",
|
||||
"hmac",
|
||||
"include_dir",
|
||||
"jsonwebtoken",
|
||||
"lettre",
|
||||
@@ -411,6 +413,7 @@ dependencies = [
|
||||
"rstest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
|
||||
@@ -70,6 +70,9 @@ once_cell = "1"
|
||||
parking_lot = "0.12"
|
||||
mime = "0.3"
|
||||
base64 = "0.22"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
include_dir = "0.7"
|
||||
mime_guess = "2"
|
||||
|
||||
|
||||
@@ -57,6 +57,18 @@
|
||||
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
|
||||
| 📋 不同团队需要不同配额 | **套餐系统**,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)
|
||||
@@ -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。
|
||||
|
||||
> 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
|
||||
EOF
|
||||
|
||||
# AUTH_TOKEN = 管理后台登录令牌
|
||||
# AUTH_TOKEN = 管理后台登录令牌(管理员只支持 API Token 登录)
|
||||
# PROXY_TOKEN = 下游客户端调用 /v1/* 的 Token
|
||||
export AUTH_TOKEN=your-admin-token
|
||||
export PROXY_TOKEN=sk-your-proxy-token
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
启动后访问 `http://localhost:4000`,用 `AUTH_TOKEN` 登录即可。
|
||||
启动后访问 `http://localhost:4000/boos`,用 `AUTH_TOKEN` 登录管理员后台;普通用户从用户登录页使用账号密码进入用户工作台。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。
|
||||
@@ -329,7 +341,7 @@ docker compose up -d
|
||||
│ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶)
|
||||
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
|
||||
│ ├── pages/
|
||||
│ │ ├── auth/ # 登录页拆分:LoginShell + AdminLoginPage + UserLoginPage(OAuthSlot 占位,行为由后续 change 接入)
|
||||
│ │ ├── auth/ # 登录页拆分:LoginShell + AdminLoginPage(token-only) + UserLoginPage(OAuthSlot 占位)
|
||||
│ │ ├── ComfyUIAgent.tsx # 视频/工作流助手聊天界面
|
||||
│ │ ├── Workflows.tsx # Workflow Studio 入口
|
||||
│ │ └── ...
|
||||
@@ -454,11 +466,22 @@ npm install
|
||||
# 开发模式(热更新,需要 Node.js)
|
||||
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
|
||||
```
|
||||
|
||||
Workflow UI 前端资产单独更新:
|
||||
|
||||
```bash
|
||||
# Comfy Cloud 前端
|
||||
npm run workflow-ui:install:cloud
|
||||
|
||||
# External / RunningHub 前端
|
||||
npm run workflow-ui:install:external
|
||||
```
|
||||
|
||||
可选使用 Bun 加速主前端构建:
|
||||
|
||||
```bash
|
||||
|
||||
+31
-1
@@ -25,6 +25,28 @@ upstream_timeout_secs = 300
|
||||
first_byte_timeout_secs = 60
|
||||
max_retries = 3
|
||||
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]
|
||||
enabled = false
|
||||
@@ -35,12 +57,20 @@ max_restarts = 5
|
||||
# backend modes:
|
||||
# - "boosapi": use BoosAPI's own Rust Workflow/API node executor.
|
||||
# - "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.
|
||||
# - "docker": legacy local Docker ComfyUI mode; not recommended for the current API Workflow path.
|
||||
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"
|
||||
# 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"
|
||||
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
|
||||
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);
|
||||
@@ -35,9 +35,18 @@ const variants = {
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
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, {
|
||||
cwd: options.cwd || ROOT,
|
||||
env: { ...process.env, ...(options.env || {}) },
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
@@ -55,6 +64,11 @@ function selectedVariants() {
|
||||
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) {
|
||||
const ref = config.ref;
|
||||
const dist = config.dist;
|
||||
@@ -77,17 +91,39 @@ async function buildVariant(name, config) {
|
||||
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);
|
||||
if (hasPnpmLock) {
|
||||
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 {
|
||||
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.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}`);
|
||||
|
||||
if (name === 'cloud') {
|
||||
|
||||
@@ -13,6 +13,8 @@ pub struct AppConfig {
|
||||
pub proxy: ProxyConfig,
|
||||
#[serde(default)]
|
||||
pub comfyui: ComfyUIConfig,
|
||||
#[serde(default)]
|
||||
pub payment: PaymentConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
@@ -56,6 +58,10 @@ pub struct ComfyUIConfig {
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
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")]
|
||||
pub docker_image: String,
|
||||
#[serde(default = "default_comfyui_container_name")]
|
||||
@@ -71,6 +77,23 @@ pub struct ComfyUIConfig {
|
||||
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)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ComfyUIBackend {
|
||||
@@ -95,6 +118,8 @@ impl Default for ComfyUIConfig {
|
||||
Self {
|
||||
enabled: false,
|
||||
backend: ComfyUIBackend::Boosapi,
|
||||
money_printer_video_cost: 0.0,
|
||||
digital_human_video_cost: 0.0,
|
||||
docker_image: default_comfyui_docker_image(),
|
||||
container_name: default_comfyui_container_name(),
|
||||
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 {
|
||||
pub fn uses_native_backend(&self) -> bool {
|
||||
matches!(
|
||||
|
||||
@@ -42,6 +42,7 @@ pub async fn ensure_sqlite_upstream_compat(pool: &SqlitePool) -> anyhow::Result<
|
||||
("client_confidence", "TEXT"),
|
||||
("is_stream", "INTEGER"),
|
||||
("first_byte_latency_ms", "INTEGER"),
|
||||
("billing_details", "TEXT"),
|
||||
("content_summary", "TEXT"),
|
||||
("request_content", "TEXT"),
|
||||
("response_content", "TEXT"),
|
||||
|
||||
@@ -66,6 +66,14 @@ impl IntoResponse for AppError {
|
||||
} => Some(*retry_after_secs),
|
||||
_ => None,
|
||||
};
|
||||
let billing = match &self {
|
||||
AppError::RateLimited | AppError::RateLimitedWithRetry { .. } => Some(json!({
|
||||
"reason": "insufficient_quota",
|
||||
"rechargePath": "/user/recharge",
|
||||
"usagePath": "/user/usage",
|
||||
})),
|
||||
_ => None,
|
||||
};
|
||||
(
|
||||
status,
|
||||
Json(json!({
|
||||
@@ -74,6 +82,7 @@ impl IntoResponse for AppError {
|
||||
"type": status.canonical_reason(),
|
||||
},
|
||||
"retry_after_secs": retry_after_secs,
|
||||
"billing": billing,
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
|
||||
@@ -2,21 +2,26 @@ use super::types::{RouteDecision, RouteMatch};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::proxy::conductor::{FailureInfo, SelectedChannel, SuccessMetrics};
|
||||
use crate::proxy::executors::UpstreamRoute;
|
||||
use crate::services::oauth::OAuthService;
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::RwLock;
|
||||
use regex::Regex;
|
||||
use sqlx::{FromRow, SqlitePool};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
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>>>>> =
|
||||
Lazy::new(|| RwLock::new(None));
|
||||
static CHANNEL_ROWS_CACHE: Lazy<RwLock<HashMap<i64, TimedCache<Vec<ChannelRow>>>>> =
|
||||
type DbCacheKey = usize;
|
||||
|
||||
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()));
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -32,16 +37,10 @@ impl<T> TimedCache<T> {
|
||||
}
|
||||
|
||||
pub fn invalidate_route_cache() {
|
||||
*ROUTE_ROWS_CACHE.write() = None;
|
||||
ROUTE_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)]
|
||||
pub struct TokenRouter {
|
||||
db: Arc<SqlitePool>,
|
||||
@@ -60,15 +59,43 @@ struct ChannelRow {
|
||||
channel_id: i64,
|
||||
route_id: i64,
|
||||
account_id: i64,
|
||||
oauth_route_unit_id: Option<i64>,
|
||||
oauth_route_unit_strategy: Option<String>,
|
||||
site_id: i64,
|
||||
base_url: String,
|
||||
site_api_key: Option<String>,
|
||||
account_access_token: String,
|
||||
account_api_token: Option<String>,
|
||||
oauth_provider: Option<String>,
|
||||
account_unit_cost: Option<f64>,
|
||||
token_name: Option<String>,
|
||||
token_value: 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 {
|
||||
@@ -76,6 +103,20 @@ impl TokenRouter {
|
||||
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> {
|
||||
let decision = self.resolve_decision(model, &[]).await?;
|
||||
Ok(UpstreamRoute {
|
||||
@@ -161,12 +202,27 @@ impl TokenRouter {
|
||||
exclude_channel_ids: &[i64],
|
||||
) -> AppResult<Option<SelectedChannel>> {
|
||||
let rows = self.channel_rows(route.id).await?;
|
||||
let row = select_row_with_route_units(rows, exclude_channel_ids);
|
||||
|
||||
let row = rows
|
||||
.into_iter()
|
||||
.find(|row| !exclude_channel_ids.contains(&row.channel_id));
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
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
|
||||
.model_mapping
|
||||
.clone()
|
||||
@@ -177,11 +233,13 @@ impl TokenRouter {
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| requested_model.to_string());
|
||||
let api_key = row
|
||||
.token_value
|
||||
.clone()
|
||||
.or(row.account_api_token.clone())
|
||||
.unwrap_or(row.account_access_token.clone());
|
||||
let api_key = row.token_value.clone().unwrap_or_else(|| {
|
||||
oauth_access_token.unwrap_or_else(|| {
|
||||
row.account_api_token
|
||||
.clone()
|
||||
.unwrap_or(row.account_access_token.clone())
|
||||
})
|
||||
});
|
||||
|
||||
SelectedChannel {
|
||||
channel_id: row.channel_id,
|
||||
@@ -203,7 +261,8 @@ impl TokenRouter {
|
||||
}
|
||||
|
||||
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() {
|
||||
return Ok(cache.value.clone());
|
||||
}
|
||||
@@ -218,15 +277,19 @@ impl TokenRouter {
|
||||
.fetch_all(&*self.db)
|
||||
.await?;
|
||||
|
||||
*ROUTE_ROWS_CACHE.write() = Some(TimedCache {
|
||||
loaded_at: Instant::now(),
|
||||
value: rows.clone(),
|
||||
});
|
||||
ROUTE_ROWS_CACHE.write().insert(
|
||||
db_cache_key,
|
||||
TimedCache {
|
||||
loaded_at: Instant::now(),
|
||||
value: rows.clone(),
|
||||
},
|
||||
);
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
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() {
|
||||
return Ok(cache.value.clone());
|
||||
}
|
||||
@@ -237,6 +300,8 @@ impl TokenRouter {
|
||||
rc.id AS channel_id,
|
||||
rc.route_id AS route_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,
|
||||
COALESCE((
|
||||
SELECT sae.url
|
||||
@@ -250,18 +315,25 @@ impl TokenRouter {
|
||||
s.api_key AS site_api_key,
|
||||
a.access_token AS account_access_token,
|
||||
a.api_token AS account_api_token,
|
||||
a.oauth_provider AS oauth_provider,
|
||||
a.unit_cost AS account_unit_cost,
|
||||
at.name AS token_name,
|
||||
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
|
||||
JOIN accounts a ON a.id = rc.account_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
|
||||
WHERE rc.route_id = ?
|
||||
AND rc.enabled = 1
|
||||
AND a.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.token_id IS NULL OR at.id IS NOT NULL)
|
||||
ORDER BY
|
||||
@@ -276,7 +348,7 @@ impl TokenRouter {
|
||||
.await?;
|
||||
|
||||
CHANNEL_ROWS_CACHE.write().insert(
|
||||
route_id,
|
||||
(db_cache_key, route_id),
|
||||
TimedCache {
|
||||
loaded_at: Instant::now(),
|
||||
value: rows.clone(),
|
||||
@@ -303,12 +375,14 @@ impl TokenRouter {
|
||||
.bind(channel_id)
|
||||
.execute(&*self.db)
|
||||
.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(())
|
||||
}
|
||||
|
||||
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(
|
||||
"UPDATE route_channels
|
||||
SET fail_count = COALESCE(fail_count, 0) + 1,
|
||||
@@ -326,9 +400,156 @@ impl TokenRouter {
|
||||
.bind(channel_id)
|
||||
.execute(&*self.db)
|
||||
.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(())
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -393,14 +614,27 @@ fn wildcard_matches(model: &str, pattern: &str) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
static TEST_CACHE_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
invalidate_route_cache();
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE sites (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -408,7 +642,8 @@ mod tests {
|
||||
url TEXT NOT NULL,
|
||||
platform TEXT NOT NULL DEFAULT 'openai',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
api_key TEXT
|
||||
api_key TEXT,
|
||||
custom_headers TEXT
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
@@ -432,8 +667,11 @@ mod tests {
|
||||
site_id INTEGER NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
api_token TEXT,
|
||||
oauth_provider TEXT,
|
||||
extra_config TEXT,
|
||||
unit_cost REAL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
updated_at TEXT
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
@@ -457,6 +695,7 @@ mod tests {
|
||||
route_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
token_id INTEGER,
|
||||
oauth_route_unit_id INTEGER,
|
||||
source_model TEXT,
|
||||
priority INTEGER DEFAULT 0,
|
||||
weight INTEGER DEFAULT 10,
|
||||
@@ -476,6 +715,41 @@ mod tests {
|
||||
.execute(&pool)
|
||||
.await
|
||||
.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
|
||||
// site_api_endpoints. The table must exist or the SELECT errors
|
||||
// out — we leave it empty and the COALESCE falls back to sites.url.
|
||||
@@ -501,16 +775,25 @@ mod tests {
|
||||
.execute(&pool)
|
||||
.await
|
||||
.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')")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO account_tokens (id, account_id, name, token) VALUES (2, 2, 'secondary', 'token-key-2')")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolves_exact_model_route() {
|
||||
let _guard = TEST_CACHE_LOCK.lock().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')")
|
||||
.execute(&pool)
|
||||
@@ -530,8 +813,57 @@ mod tests {
|
||||
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]
|
||||
async fn resolves_wildcard_route_and_skips_excluded_channel() {
|
||||
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)
|
||||
@@ -551,4 +883,373 @@ mod tests {
|
||||
assert_eq!(decision.match_kind, RouteMatch::Wildcard);
|
||||
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
@@ -13,6 +13,8 @@ use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
|
||||
const RUNTIME_HEALTH_DEGRADED_LATENCY_MS: i64 = 5_000;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListAccountsQuery {
|
||||
pub site_id: Option<i64>,
|
||||
@@ -151,10 +153,23 @@ pub async fn list_accounts(
|
||||
let site_by_id: std::collections::HashMap<i64, &Site> =
|
||||
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(
|
||||
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
|
||||
INNER JOIN (
|
||||
SELECT account_id, MAX(id) AS id
|
||||
@@ -176,14 +191,42 @@ pub async fn list_accounts(
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
Option<i64>,
|
||||
i64,
|
||||
),
|
||||
> = latest_health_rows
|
||||
.into_iter()
|
||||
.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,
|
||||
(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<i64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
Option<i64>,
|
||||
i64,
|
||||
)>,
|
||||
) -> Value {
|
||||
let credential_mode = resolve_account_credential_mode(account);
|
||||
@@ -246,16 +294,46 @@ fn account_response_value(
|
||||
"state": "unhealthy",
|
||||
"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
|
||||
{
|
||||
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!({
|
||||
"state": if ok { "healthy" } else { "unhealthy" },
|
||||
"state": if !ok {
|
||||
"unhealthy"
|
||||
} else if is_degraded_latency {
|
||||
"degraded"
|
||||
} else {
|
||||
"healthy"
|
||||
},
|
||||
"reason": if ok {
|
||||
match latency_ms {
|
||||
Some(ms) => format!("最近代理请求成功,用时 {}ms", ms),
|
||||
None => "最近代理请求成功".to_string(),
|
||||
if is_degraded_latency {
|
||||
match latency_ms {
|
||||
Some(ms) => format!("最近代理请求成功但延迟偏高,用时 {}ms", ms),
|
||||
None => "最近代理请求成功但延迟偏高".to_string(),
|
||||
}
|
||||
} else {
|
||||
match latency_ms {
|
||||
Some(ms) => format!("最近代理请求成功,用时 {}ms", ms),
|
||||
None => "最近代理请求成功".to_string(),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error_message
|
||||
@@ -265,11 +343,17 @@ fn account_response_value(
|
||||
"latencyMs": latency_ms,
|
||||
"httpStatus": http_status,
|
||||
"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" {
|
||||
json!({
|
||||
"state": "degraded",
|
||||
"reason": "尚无代理请求记录,等待首次调用后更新",
|
||||
"state": "unknown",
|
||||
"reason": "尚无代理请求记录,首次调用后自动更新",
|
||||
})
|
||||
} else {
|
||||
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
File diff suppressed because it is too large
Load Diff
+63
-15
@@ -24,7 +24,7 @@ mod sites;
|
||||
mod tokens;
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -37,20 +37,8 @@ async fn require_admin_auth(
|
||||
.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 Ok(next.run(request).await);
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
if auth_header != state.config.auth.auth_token {
|
||||
return Err(AppError::Unauthorized("Invalid admin token".into()));
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
@@ -73,6 +61,15 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
.route(
|
||||
"/api/sites/{id}/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()
|
||||
@@ -289,9 +286,15 @@ async fn update_setting(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::jwt::generate_jwt;
|
||||
use crate::config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig};
|
||||
use crate::services::builtin_keys::BuiltinKeys;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
async fn settings_state() -> AppState {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
@@ -414,4 +417,49 @@ mod tests {
|
||||
.expect("update ok");
|
||||
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
@@ -1,16 +1,27 @@
|
||||
use crate::{
|
||||
db::models::site::Site,
|
||||
error::{AppError, AppResult},
|
||||
proxy::{conductor::SuccessMetrics, executors, token_router::TokenRouter},
|
||||
services::oauth::oauth_http_client,
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::sse::{Event, Sse},
|
||||
Json,
|
||||
};
|
||||
use futures::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
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)]
|
||||
pub struct DetectSiteRequest {
|
||||
@@ -398,12 +409,89 @@ async fn site_api_endpoints(state: &AppState, site_id: i64) -> AppResult<Vec<Val
|
||||
.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)]
|
||||
pub struct UpdateDisabledModelsRequest {
|
||||
#[serde(default)]
|
||||
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.
|
||||
pub async fn list_site_disabled_models(
|
||||
State(state): State<AppState>,
|
||||
@@ -477,6 +565,727 @@ pub async fn update_site_disabled_models(
|
||||
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> {
|
||||
let trimmed = raw.trim().trim_end_matches('/').to_string();
|
||||
if trimmed.is_empty() {
|
||||
@@ -645,3 +1454,86 @@ async fn replace_site_api_endpoints(
|
||||
|
||||
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
@@ -324,7 +324,10 @@ pub async fn get_user_usage(
|
||||
// Recent logs
|
||||
let recent_logs: Vec<Value> = sqlx::query(
|
||||
r#"
|
||||
SELECT l.*
|
||||
SELECT
|
||||
l.*,
|
||||
k.id AS downstream_api_key_id,
|
||||
k.name AS downstream_key_name
|
||||
FROM usage_logs l
|
||||
JOIN downstream_api_keys k ON l.downstream_key_id = k.id
|
||||
WHERE k.user_id = ?
|
||||
@@ -341,6 +344,11 @@ pub async fn get_user_usage(
|
||||
json!({
|
||||
"id": row.get::<i64, _>("id"),
|
||||
"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),
|
||||
"completionTokens": row.get::<Option<i64>, _>("completion_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 })))
|
||||
}
|
||||
|
||||
#[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
@@ -10,14 +10,14 @@ use crate::{
|
||||
};
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::HeaderMap,
|
||||
http::{header, HeaderMap},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::net::SocketAddr;
|
||||
use tracing::info;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use tracing::{info, warn};
|
||||
|
||||
// ── Request / Response types ─────────────────────────────────────────
|
||||
|
||||
@@ -70,7 +70,6 @@ pub fn routes() -> Router<AppState> {
|
||||
.route("/api/users/register", post(register))
|
||||
.route("/api/auth/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/auth/me", get(me))
|
||||
.route("/api/users/me", get(me).patch(update_me))
|
||||
@@ -229,24 +228,82 @@ async fn register(
|
||||
/// POST /api/auth/login
|
||||
async fn login(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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 = ?")
|
||||
.bind(&payload.email)
|
||||
let user: User = if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
|
||||
.bind(&email)
|
||||
.fetch_optional(&*state.db)
|
||||
.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" {
|
||||
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()));
|
||||
}
|
||||
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)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
@@ -258,6 +315,18 @@ async fn login(
|
||||
)
|
||||
.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!({
|
||||
"token": token,
|
||||
"user": {
|
||||
@@ -288,10 +357,25 @@ async fn login(
|
||||
/// be enabled, and the owning user must be active.
|
||||
async fn login_api_key(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<LoginApiKeyRequest>,
|
||||
) -> 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();
|
||||
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()));
|
||||
}
|
||||
|
||||
@@ -302,20 +386,91 @@ async fn login_api_key(
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
let api_key = row.ok_or_else(|| AppError::Unauthorized("Invalid API key".into()))?;
|
||||
let user_id = api_key
|
||||
.user_id
|
||||
.ok_or_else(|| AppError::Unauthorized("API key is not bound to a user".into()))?;
|
||||
let api_key = if let Some(api_key) = row {
|
||||
api_key
|
||||
} else {
|
||||
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)
|
||||
.fetch_optional(&*state.db)
|
||||
.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" {
|
||||
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()));
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -327,6 +482,18 @@ async fn login_api_key(
|
||||
)
|
||||
.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!({
|
||||
"token": token,
|
||||
"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 ──────────────────────────
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
use crate::{
|
||||
auth::api_key::{authorize_downstream_token, DownstreamAuthResult},
|
||||
auth::jwt::validate_jwt,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
@@ -47,6 +48,15 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
|
||||
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
|
||||
if auth.starts_with("sk-") {
|
||||
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.
|
||||
fn comfyui_base(state: &AppState) -> String {
|
||||
state.comfyui.internal_base_url()
|
||||
|
||||
+585
-10
@@ -2,6 +2,7 @@ use crate::{
|
||||
auth::jwt::validate_jwt,
|
||||
db::models::{background_task::BackgroundTask, downstream_api_key::DownstreamApiKey},
|
||||
error::{AppError, AppResult},
|
||||
services::billing::{ComfyTaskBillingEvent, UsageTracker},
|
||||
services::workspace::{self, WorkspaceAssetInput},
|
||||
workflow::storage,
|
||||
AppState,
|
||||
@@ -9,7 +10,7 @@ use crate::{
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Multipart, Path, Query, State},
|
||||
http::{header, HeaderMap, Method},
|
||||
http::{header, HeaderMap, Method, StatusCode},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
@@ -22,11 +23,61 @@ const DIGITAL_HUMAN_TIMEOUT_SECS: u64 = 900;
|
||||
const DIGITAL_HUMAN_POLL_INTERVAL_SECS: u64 = 5;
|
||||
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)]
|
||||
struct DigitalHumanAuth {
|
||||
user_id: Option<i64>,
|
||||
role: String,
|
||||
outbound_key: String,
|
||||
key_id: Option<i64>,
|
||||
key_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -110,6 +161,8 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<DigitalHu
|
||||
user_id: None,
|
||||
role: "admin".to_string(),
|
||||
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),
|
||||
role: claims.role,
|
||||
outbound_key: key.key,
|
||||
key_id: Some(key.id),
|
||||
key_name: key.name,
|
||||
}),
|
||||
Err(_) if claims.role == "admin" => Ok(DigitalHumanAuth {
|
||||
user_id: Some(user_id),
|
||||
role: claims.role,
|
||||
outbound_key: state.runtime_proxy_token(),
|
||||
key_id: None,
|
||||
key_name: "global-proxy".to_string(),
|
||||
}),
|
||||
Err(message) => Err(AppError::Forbidden(message)),
|
||||
}
|
||||
}
|
||||
|
||||
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!({
|
||||
"success": true,
|
||||
"mode": if state.config.comfyui.uses_native_backend() { "comfy-native" } else { "boosapi-video-route" },
|
||||
"keyName": auth.key_name,
|
||||
"runninghub": {
|
||||
"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"
|
||||
@@ -486,6 +544,7 @@ fn default_mapping() -> Value {
|
||||
json!({
|
||||
"prompt": { "nodeId": "", "field": "prompt" },
|
||||
"script": { "nodeId": "", "field": "text" },
|
||||
"model": { "nodeId": "", "field": "model" },
|
||||
"avatar": { "nodeId": "", "field": "image" },
|
||||
"referenceAudio": { "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(())
|
||||
}
|
||||
|
||||
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 {
|
||||
graph.get("prompt")
|
||||
.cloned()
|
||||
.or_else(|| graph.get("workflow").cloned())
|
||||
.or_else(|| graph.get("comfyPrompt").cloned())
|
||||
.or_else(|| graph_to_comfy_prompt(graph))
|
||||
.unwrap_or_else(|| graph.clone())
|
||||
}
|
||||
|
||||
@@ -583,6 +737,16 @@ fn inject_digital_human_inputs(
|
||||
&mapping_field(mapping, &["script", "scriptNode", "script_node"], "text"),
|
||||
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(
|
||||
prompt,
|
||||
&avatar_node,
|
||||
@@ -677,6 +841,13 @@ fn native_request(state: &AppState, method: Method, path: &str) -> reqwest::Requ
|
||||
request
|
||||
}
|
||||
|
||||
fn is_pending_history_status(status: StatusCode) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
StatusCode::NOT_FOUND | StatusCode::ACCEPTED | StatusCode::NO_CONTENT
|
||||
)
|
||||
}
|
||||
|
||||
async fn native_json(
|
||||
state: &AppState,
|
||||
method: Method,
|
||||
@@ -703,6 +874,35 @@ async fn native_json(
|
||||
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)> {
|
||||
let body = json!({
|
||||
"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))
|
||||
}
|
||||
|
||||
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() {
|
||||
format!("/jobs/{prompt_id}")
|
||||
} else {
|
||||
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> {
|
||||
@@ -746,12 +946,24 @@ fn prompt_failure_message(value: &Value) -> Option<String> {
|
||||
|
||||
fn output_objects(value: &Value) -> Vec<Value> {
|
||||
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>) {
|
||||
match value {
|
||||
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"] {
|
||||
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() {
|
||||
@@ -763,6 +975,11 @@ fn output_objects(value: &Value) -> Vec<Value> {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
let filename = scalar_string(item.get("filename")).unwrap_or_default();
|
||||
let lower = filename.to_ascii_lowercase();
|
||||
if lower.ends_with(".mp4") || lower.ends_with(".webm") || lower.ends_with(".mov") {
|
||||
let explicit = scalar_string(item.get("kind"))
|
||||
.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 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"
|
||||
} else if lower.ends_with(".mp3") || lower.ends_with(".wav") || lower.ends_with(".m4a") {
|
||||
"audio"
|
||||
@@ -784,7 +1103,7 @@ fn output_kind(item: &Value) -> &'static str {
|
||||
|
||||
fn output_query(item: &Value) -> HashMap<String, String> {
|
||||
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);
|
||||
}
|
||||
if let Some(file_type) = scalar_string(item.get("type")) {
|
||||
@@ -829,6 +1148,56 @@ async fn archive_output(
|
||||
prompt_id: &str,
|
||||
script: &str,
|
||||
) -> 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 filename = params.get("filename")?.clone();
|
||||
let kind = output_kind(item).to_string();
|
||||
@@ -963,6 +1332,9 @@ async fn create_job(
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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()
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
@@ -985,8 +1357,11 @@ async fn create_job(
|
||||
let initial_result = json!({
|
||||
"userId": auth.user_id,
|
||||
"role": auth.role,
|
||||
"keyId": auth.key_id,
|
||||
"keyName": auth.key_name,
|
||||
"templateId": template_id,
|
||||
"templateName": workflow.name,
|
||||
"script": script,
|
||||
"audioRole": optional_string(&body, &["audioRole", "audio_role"]).unwrap_or_else(|| "both".to_string()),
|
||||
"request": body,
|
||||
"mapping": mapping,
|
||||
@@ -1008,6 +1383,14 @@ async fn create_job(
|
||||
let task_id_for_spawn = task_id.clone();
|
||||
let auth_for_task = auth.clone();
|
||||
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 {
|
||||
update_progress(&state_for_task, &task_id_for_spawn, 2, "正在提交 Comfy 工作流")
|
||||
.await?;
|
||||
@@ -1015,8 +1398,11 @@ async fn create_job(
|
||||
let mid_result = json!({
|
||||
"userId": auth_for_task.user_id,
|
||||
"role": auth_for_task.role,
|
||||
"keyId": auth_for_task.key_id,
|
||||
"keyName": auth_for_task.key_name,
|
||||
"templateId": template_id,
|
||||
"templateName": workflow.name,
|
||||
"script": script,
|
||||
"promptId": prompt_id,
|
||||
"submitResponse": submit_response,
|
||||
"request": body,
|
||||
@@ -1036,7 +1422,12 @@ async fn create_job(
|
||||
}
|
||||
update_progress(&state_for_task, &task_id_for_spawn, 3, "Comfy 正在生成视频")
|
||||
.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) {
|
||||
return Err(AppError::UpstreamError(message));
|
||||
}
|
||||
@@ -1063,17 +1454,62 @@ async fn create_job(
|
||||
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!({
|
||||
"userId": auth_for_task.user_id,
|
||||
"role": auth_for_task.role,
|
||||
"keyId": auth_for_task.key_id,
|
||||
"keyName": auth_for_task.key_name,
|
||||
"templateId": template_id,
|
||||
"templateName": workflow.name,
|
||||
"model": billing_model.clone(),
|
||||
"script": script,
|
||||
"promptId": prompt_id,
|
||||
"request": body,
|
||||
"mapping": mapping,
|
||||
"history": history,
|
||||
"outputs": outputs,
|
||||
"assets": assets,
|
||||
"billing": billing,
|
||||
});
|
||||
sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
@@ -1093,6 +1529,27 @@ async fn create_job(
|
||||
}
|
||||
.await;
|
||||
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(
|
||||
"UPDATE background_tasks
|
||||
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
@@ -1,8 +1,16 @@
|
||||
// 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::{
|
||||
extract::{Path, Query, State},
|
||||
http::HeaderMap,
|
||||
middleware,
|
||||
response::Response,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
@@ -20,12 +28,35 @@ pub struct ListEventsQuery {
|
||||
read: Option<String>, // "true" | "false"
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
pub fn routes(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/events", get(list_events).delete(clear_events))
|
||||
.route("/api/events/count", get(unread_count))
|
||||
.route("/api/events/{id}/read", post(mark_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
|
||||
@@ -36,15 +67,26 @@ async fn list_events(
|
||||
debug!("Listing events");
|
||||
let limit = q.limit.unwrap_or(30).clamp(1, 500);
|
||||
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 params: Vec<String> = vec![];
|
||||
|
||||
if let Some(ref t) = q.filter_type {
|
||||
if let Some(ref t) = filter_type {
|
||||
sql.push_str(" AND type = ?");
|
||||
params.push(t.clone());
|
||||
}
|
||||
if let Some(ref r) = q.read {
|
||||
if let Some(r) = read_filter {
|
||||
if r == "true" {
|
||||
sql.push_str(" AND read = 1");
|
||||
} else if r == "false" {
|
||||
@@ -61,18 +103,34 @@ async fn list_events(
|
||||
let events: Vec<Event> = query.fetch_all(&*state.db).await?;
|
||||
let mut rows: Vec<EventListItem> = events.into_iter().map(EventListItem::from).collect();
|
||||
|
||||
if q.filter_type
|
||||
.as_deref()
|
||||
.is_none_or(|filter_type| filter_type == "runtime")
|
||||
{
|
||||
if should_include_source(filter_type.as_deref(), "runtime") {
|
||||
rows.extend(
|
||||
runtime_logs::list()
|
||||
.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),
|
||||
);
|
||||
}
|
||||
|
||||
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| {
|
||||
b.created_at
|
||||
.cmp(&a.created_at)
|
||||
@@ -89,6 +147,253 @@ async fn list_events(
|
||||
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
|
||||
async fn unread_count(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
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 })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EventListItem {
|
||||
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
@@ -27,7 +27,6 @@ mod comfyui_agent;
|
||||
mod digital_human;
|
||||
mod events;
|
||||
mod health;
|
||||
mod monitor;
|
||||
mod money_printer;
|
||||
mod oauth;
|
||||
mod organizations;
|
||||
@@ -62,9 +61,8 @@ pub fn build_router(state: AppState) -> Router {
|
||||
|
||||
let api_routes = api_routes
|
||||
.merge(auth::routes())
|
||||
.merge(events::routes())
|
||||
.merge(events::routes(state.clone()))
|
||||
.merge(checkin::routes())
|
||||
.merge(monitor::routes())
|
||||
.merge(oauth::routes())
|
||||
.merge(organizations::routes())
|
||||
.merge(search::routes())
|
||||
@@ -221,6 +219,8 @@ fn is_api_path(path: &str) -> bool {
|
||||
|
||||
fn should_serve_website(path: &str) -> bool {
|
||||
path == "/"
|
||||
|| path == "/new"
|
||||
|| path.starts_with("/new/")
|
||||
|| path.starts_with("/_next/")
|
||||
|| path == "/favicon.ico"
|
||||
|| path == "/favicon.png"
|
||||
@@ -230,6 +230,9 @@ fn should_serve_website(path: &str) -> bool {
|
||||
|
||||
fn embedded_asset_path(path: &str) -> String {
|
||||
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('/') {
|
||||
format!("{trimmed}index.html")
|
||||
} 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
@@ -2,13 +2,14 @@ use crate::{
|
||||
auth::jwt::validate_jwt,
|
||||
db::models::{background_task::BackgroundTask, downstream_api_key::DownstreamApiKey},
|
||||
error::{AppError, AppResult},
|
||||
services::billing::{ComfyTaskBillingEvent, UsageTracker},
|
||||
services::workspace::{self, WorkspaceAssetInput},
|
||||
workflow::storage,
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{header, HeaderMap, Method},
|
||||
http::{header, HeaderMap, Method, StatusCode},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
@@ -23,11 +24,60 @@ const VIDEO_MAX_POLLS: usize = 120;
|
||||
const COMFY_POLL_INTERVAL_SECS: u64 = 5;
|
||||
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)]
|
||||
struct MoneyPrinterAuth {
|
||||
user_id: Option<i64>,
|
||||
role: String,
|
||||
outbound_key: String,
|
||||
key_id: Option<i64>,
|
||||
key_name: String,
|
||||
}
|
||||
|
||||
@@ -94,6 +144,7 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<MoneyPrin
|
||||
user_id: None,
|
||||
role: "admin".to_string(),
|
||||
outbound_key: proxy_token,
|
||||
key_id: None,
|
||||
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),
|
||||
role: claims.role,
|
||||
outbound_key: key.key,
|
||||
key_id: Some(key.id),
|
||||
key_name: key.name,
|
||||
}),
|
||||
Err(_) if claims.role == "admin" => Ok(MoneyPrinterAuth {
|
||||
user_id: Some(user_id),
|
||||
role: claims.role,
|
||||
outbound_key: state.runtime_proxy_token(),
|
||||
key_id: None,
|
||||
key_name: "global-proxy".to_string(),
|
||||
}),
|
||||
Err(message) => Err(AppError::Forbidden(message)),
|
||||
@@ -150,6 +203,11 @@ async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<
|
||||
"label": "内置 Cloud Seedance 文生视频",
|
||||
"nodeType": "ByteDanceTextToVideoNode",
|
||||
"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 },
|
||||
"resolutions": ["480p", "720p", "1080p"],
|
||||
"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
|
||||
}
|
||||
|
||||
fn is_pending_history_status(status: StatusCode) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
StatusCode::NOT_FOUND | StatusCode::ACCEPTED | StatusCode::NO_CONTENT
|
||||
)
|
||||
}
|
||||
|
||||
async fn native_json(
|
||||
state: &AppState,
|
||||
method: Method,
|
||||
@@ -446,6 +511,35 @@ async fn native_json(
|
||||
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 {
|
||||
let mut out = String::new();
|
||||
for byte in value.bytes() {
|
||||
@@ -488,6 +582,7 @@ fn default_comfy_mapping() -> Value {
|
||||
json!({
|
||||
"prompt": { "nodeId": "", "field": "prompt" },
|
||||
"negativePrompt": { "nodeId": "", "field": "text" },
|
||||
"model": { "nodeId": "", "field": "model" },
|
||||
"ratio": { "nodeId": "", "field": "ratio" },
|
||||
"resolution": { "nodeId": "", "field": "resolution" },
|
||||
"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 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 model_field = first_existing_input_field(object, &video_node, &["model", "model_name", "ckpt_name"], "model");
|
||||
json!({
|
||||
"prompt": {
|
||||
"nodeId": prompt_node,
|
||||
@@ -658,6 +754,10 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
|
||||
"nodeId": negative_node,
|
||||
"field": "text"
|
||||
},
|
||||
"model": {
|
||||
"nodeId": video_node,
|
||||
"field": model_field
|
||||
},
|
||||
"ratio": {
|
||||
"nodeId": video_node,
|
||||
"field": ratio_field
|
||||
@@ -869,6 +969,15 @@ fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value,
|
||||
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()) {
|
||||
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))
|
||||
}
|
||||
|
||||
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() {
|
||||
format!("/jobs/{prompt_id}")
|
||||
} else {
|
||||
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> {
|
||||
@@ -1071,12 +1180,24 @@ fn comfy_failure_message(value: &Value) -> Option<String> {
|
||||
|
||||
fn comfy_output_objects(value: &Value) -> Vec<Value> {
|
||||
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>) {
|
||||
match value {
|
||||
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"] {
|
||||
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() {
|
||||
@@ -1088,6 +1209,11 @@ fn comfy_output_objects(value: &Value) -> Vec<Value> {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
let filename = scalar_string(item.get("filename")).unwrap_or_default().to_ascii_lowercase();
|
||||
if filename.ends_with(".mp4") || filename.ends_with(".webm") || filename.ends_with(".mov") {
|
||||
let explicit = scalar_string(item.get("kind"))
|
||||
.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"
|
||||
} else if filename.ends_with(".mp3") || filename.ends_with(".wav") || filename.ends_with(".m4a") {
|
||||
"audio"
|
||||
@@ -1107,7 +1336,7 @@ fn output_kind(item: &Value) -> &'static str {
|
||||
}
|
||||
|
||||
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 subfolder = scalar_string(item.get("subfolder")).unwrap_or_default();
|
||||
let mut parts = vec![
|
||||
@@ -1128,6 +1357,37 @@ async fn archive_comfy_output(
|
||||
prompt: &str,
|
||||
title: &str,
|
||||
) -> 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 kind = output_kind(item).to_string();
|
||||
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 (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!({
|
||||
"backend": "comfy",
|
||||
"promptId": prompt_id,
|
||||
"model": model,
|
||||
"prompt": video_prompt,
|
||||
"mapping": mapping,
|
||||
"response": response,
|
||||
@@ -1211,7 +1475,8 @@ async fn submit_video(
|
||||
plan: &Value,
|
||||
) -> AppResult<Value> {
|
||||
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(|| {
|
||||
plan.get("video_prompt")
|
||||
.and_then(Value::as_str)
|
||||
@@ -1352,6 +1617,9 @@ async fn create_job(
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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"])
|
||||
.ok_or_else(|| AppError::BadRequest("topic is required".into()))?;
|
||||
let task_id = format!("mp_{}", uuid::Uuid::new_v4().simple());
|
||||
@@ -1359,6 +1627,7 @@ async fn create_job(
|
||||
let initial_result = json!({
|
||||
"userId": auth.user_id,
|
||||
"role": auth.role,
|
||||
"keyId": auth.key_id,
|
||||
"keyName": auth.key_name,
|
||||
"topic": topic,
|
||||
"request": body,
|
||||
@@ -1380,6 +1649,23 @@ async fn create_job(
|
||||
let task_id_for_spawn = task_id.clone();
|
||||
let auth_for_task = auth.clone();
|
||||
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 {
|
||||
update_progress(&state_for_task, &task_id_for_spawn, 2, "正在生成脚本和分镜").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!({
|
||||
"userId": auth_for_task.user_id,
|
||||
"role": auth_for_task.role,
|
||||
"keyId": auth_for_task.key_id,
|
||||
"keyName": auth_for_task.key_name,
|
||||
"topic": topic,
|
||||
"request": body,
|
||||
@@ -1426,15 +1713,21 @@ async fn create_job(
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let model = submitted
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("comfy-api")
|
||||
.to_string();
|
||||
let submitted_result = json!({
|
||||
"userId": auth_for_task.user_id,
|
||||
"role": auth_for_task.role,
|
||||
"keyId": auth_for_task.key_id,
|
||||
"keyName": auth_for_task.key_name,
|
||||
"topic": topic,
|
||||
"request": body,
|
||||
"script": script,
|
||||
"videoBackend": video_backend,
|
||||
"model": "comfy-api",
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"video": submitted,
|
||||
});
|
||||
@@ -1449,7 +1742,11 @@ async fn create_job(
|
||||
return Ok(());
|
||||
}
|
||||
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) {
|
||||
return Err(AppError::UpstreamError(message));
|
||||
}
|
||||
@@ -1478,7 +1775,7 @@ async fn create_job(
|
||||
if final_history.is_null() {
|
||||
return Err(AppError::GatewayTimeout("Comfy 视频任务等待超时".into()));
|
||||
}
|
||||
(submitted, final_history, None, prompt, "comfy-api".to_string())
|
||||
(submitted, final_history, None, prompt, model)
|
||||
} else {
|
||||
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?;
|
||||
@@ -1524,9 +1821,46 @@ async fn create_job(
|
||||
}
|
||||
(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!({
|
||||
"userId": auth_for_task.user_id,
|
||||
"role": auth_for_task.role,
|
||||
"keyId": auth_for_task.key_id,
|
||||
"keyName": auth_for_task.key_name,
|
||||
"topic": topic,
|
||||
"request": body,
|
||||
@@ -1538,6 +1872,7 @@ async fn create_job(
|
||||
"finalVideoResponse": final_video_response,
|
||||
"videoUrl": video_url,
|
||||
"assets": assets,
|
||||
"billing": billing,
|
||||
});
|
||||
sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
@@ -1553,6 +1888,27 @@ async fn create_job(
|
||||
}
|
||||
.await;
|
||||
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(
|
||||
"UPDATE background_tasks
|
||||
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 })))
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ struct PictureAgentAuth {
|
||||
user_id: Option<i64>,
|
||||
role: String,
|
||||
outbound_key: String,
|
||||
key_id: Option<i64>,
|
||||
key_name: String,
|
||||
}
|
||||
|
||||
@@ -129,6 +130,7 @@ async fn resolve_picture_agent_auth(
|
||||
user_id: None,
|
||||
role: "admin".to_string(),
|
||||
outbound_key: proxy_token,
|
||||
key_id: None,
|
||||
key_name: "global-proxy".to_string(),
|
||||
});
|
||||
}
|
||||
@@ -149,12 +151,14 @@ async fn resolve_picture_agent_auth(
|
||||
user_id: Some(user_id),
|
||||
role: claims.role,
|
||||
outbound_key: key.key,
|
||||
key_id: Some(key.id),
|
||||
key_name: key.name,
|
||||
}),
|
||||
Err(_) if claims.role == "admin" => Ok(PictureAgentAuth {
|
||||
user_id: Some(user_id),
|
||||
role: claims.role,
|
||||
outbound_key: state.runtime_proxy_token(),
|
||||
key_id: None,
|
||||
key_name: "global-proxy".to_string(),
|
||||
}),
|
||||
Err(message) => Err(AppError::Forbidden(message)),
|
||||
@@ -656,6 +660,7 @@ async fn create_generation_job(
|
||||
let initial_result = json!({
|
||||
"userId": auth.user_id,
|
||||
"role": auth.role,
|
||||
"keyId": auth.key_id,
|
||||
"keyName": auth.key_name,
|
||||
"prompt": prompt,
|
||||
"model": model,
|
||||
@@ -737,6 +742,7 @@ async fn create_generation_job(
|
||||
let final_result = json!({
|
||||
"userId": auth_for_task.user_id,
|
||||
"role": auth_for_task.role,
|
||||
"keyId": auth_for_task.key_id,
|
||||
"keyName": auth_for_task.key_name,
|
||||
"prompt": prompt,
|
||||
"model": model,
|
||||
|
||||
+57
-2
@@ -1087,7 +1087,11 @@ where
|
||||
}
|
||||
|
||||
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 {
|
||||
selected: Some(selected),
|
||||
attempts,
|
||||
@@ -1661,7 +1665,10 @@ async fn execute_chat_completions_stream_with_conductor(
|
||||
let request_log_body = body.clone();
|
||||
let body_for_attempt = body.clone();
|
||||
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 {
|
||||
executors::execute_chat_completions_stream_response(
|
||||
state,
|
||||
@@ -1883,12 +1890,22 @@ mod tests {
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
async fn test_state(upstream_base_url: String) -> AppState {
|
||||
crate::proxy::token_router::router::invalidate_route_cache();
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE downstream_api_keys (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -1948,6 +1965,8 @@ mod tests {
|
||||
site_id INTEGER NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
api_token TEXT,
|
||||
oauth_provider TEXT,
|
||||
extra_config TEXT,
|
||||
unit_cost REAL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
)",
|
||||
@@ -1973,6 +1992,7 @@ mod tests {
|
||||
route_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
token_id INTEGER,
|
||||
oauth_route_unit_id INTEGER,
|
||||
source_model TEXT,
|
||||
priority INTEGER DEFAULT 0,
|
||||
weight INTEGER DEFAULT 10,
|
||||
@@ -1992,6 +2012,41 @@ mod tests {
|
||||
.execute(&pool)
|
||||
.await
|
||||
.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
|
||||
// pick a per-site override URL. We don't need any rows — the
|
||||
// COALESCE falls back to sites.url when this table is empty —
|
||||
|
||||
+462
-44
@@ -8,8 +8,8 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::Row;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use sqlx::{QueryBuilder, Row, Sqlite};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -34,6 +34,324 @@ pub struct ProxyDebugTraceQuery {
|
||||
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> {
|
||||
Router::new()
|
||||
.route("/api/stats/dashboard", get(dashboard))
|
||||
@@ -417,48 +735,77 @@ async fn proxy_logs(
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.unwrap_or(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#"
|
||||
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.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 ?
|
||||
COUNT(l.id) AS total,
|
||||
COALESCE(SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END), 0) AS success,
|
||||
COALESCE(SUM(CASE WHEN l.status != 'success' OR l.status IS NULL THEN 1 ELSE 0 END), 0) AS failed,
|
||||
COALESCE(SUM(l.estimated_cost), 0.0) AS cost,
|
||||
COALESCE(SUM(l.total_tokens), 0) AS total_tokens
|
||||
"#,
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| serde_json::to_value(row).unwrap_or_else(|_| json!({})))
|
||||
.collect();
|
||||
|
||||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM proxy_logs")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let success: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM proxy_logs WHERE status = 'success'")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
);
|
||||
push_proxy_log_joined_from(&mut summary_query);
|
||||
push_proxy_log_where(&mut summary_query, &filters);
|
||||
let (total, success, failed, cost, total_tokens): (i64, i64, i64, f64, i64) =
|
||||
summary_query.build_query_as().fetch_one(&*state.db).await?;
|
||||
let (client_options, sites) = if include_meta {
|
||||
(
|
||||
build_proxy_log_client_options(&state, &filters).await?,
|
||||
build_proxy_log_site_options(&state, &filters).await?,
|
||||
)
|
||||
} else {
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"items": items,
|
||||
@@ -469,10 +816,12 @@ async fn proxy_logs(
|
||||
"summary": {
|
||||
"total": total,
|
||||
"success": success,
|
||||
"failed": total - success,
|
||||
"failed": failed,
|
||||
"cost": cost,
|
||||
"totalTokens": total_tokens,
|
||||
},
|
||||
"clientOptions": [],
|
||||
"sites": [],
|
||||
"clientOptions": client_options,
|
||||
"sites": sites,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -507,6 +856,7 @@ pub struct ProxyLogRow {
|
||||
client_app_id: Option<String>,
|
||||
client_app_name: Option<String>,
|
||||
client_confidence: Option<String>,
|
||||
billing_details: Option<String>,
|
||||
content_summary: Option<String>,
|
||||
request_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.error_message, l.retry_count, l.is_stream,
|
||||
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,
|
||||
a.site_id AS site_id,
|
||||
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 })))
|
||||
}
|
||||
|
||||
#[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(¶ms);
|
||||
|
||||
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(¶ms);
|
||||
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(¶ms);
|
||||
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(¶ms);
|
||||
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(¶ms);
|
||||
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ use std::convert::Infallible;
|
||||
use tracing::info;
|
||||
|
||||
use crate::{
|
||||
auth::jwt::validate_jwt,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
@@ -200,15 +201,28 @@ async fn external_comfyui_status_value(state: &AppState) -> Value {
|
||||
}
|
||||
|
||||
fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
|
||||
let expected = format!("Bearer {}", state.config.auth.auth_token);
|
||||
|
||||
let actual = headers
|
||||
let token = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.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 {
|
||||
return Err(AppError::BadRequest("unauthorized".to_string()));
|
||||
if token == state.config.auth.auth_token {
|
||||
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(())
|
||||
@@ -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
@@ -30,7 +30,7 @@ use serde_json::{json, Map, Value};
|
||||
use sqlx::Row;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::PathBuf,
|
||||
path::{Path as StdPath, PathBuf},
|
||||
sync::{mpsc, Arc},
|
||||
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 OFFICIAL_WORKFLOW_TEMPLATES_BASE: &str =
|
||||
"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_EXTERNAL: Dir<'_> =
|
||||
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 {
|
||||
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")
|
||||
.map(PathBuf::from)
|
||||
.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"))
|
||||
} else {
|
||||
PathBuf::from("dist/workflow-ui")
|
||||
};
|
||||
});
|
||||
if workflow_ui_dir_is_usable(&preferred) {
|
||||
preferred
|
||||
} 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 {
|
||||
"boosapi"
|
||||
};
|
||||
let frontend_dir = workflow_ui_root_for_config(&state.config.comfyui);
|
||||
Json(json!({
|
||||
"mode": mode,
|
||||
"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(),
|
||||
}))
|
||||
}
|
||||
|
||||
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> {
|
||||
config
|
||||
.api_key
|
||||
@@ -675,7 +698,7 @@ fn text_to_video_workflow_graph() -> Value {
|
||||
{ "name": "raw", "type": "JSON", "links": null, "slot_index": 2 }
|
||||
],
|
||||
"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,
|
||||
@@ -691,7 +714,7 @@ fn text_to_video_workflow_graph() -> Value {
|
||||
{ "name": "raw", "type": "JSON", "links": null, "slot_index": 1 }
|
||||
],
|
||||
"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,
|
||||
@@ -1073,11 +1096,10 @@ fn workflow_folder_models(folder: &str, models: &[String]) -> Vec<String> {
|
||||
.cloned()
|
||||
.collect(),
|
||||
"clip" => models.to_vec(),
|
||||
"video_models" => models
|
||||
.iter()
|
||||
.filter(|model| is_video_workflow_model(model))
|
||||
.cloned()
|
||||
.collect(),
|
||||
// Comfy's video_models folder is a native Comfy API model folder. Keep it
|
||||
// separate from BoosAPI route models so video apps submit real Comfy API
|
||||
// model names instead of OpenAI-compatible route aliases.
|
||||
"video_models" => Vec::new(),
|
||||
"audio_tts_models" => models
|
||||
.iter()
|
||||
.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(
|
||||
object_info: &Value,
|
||||
node_type: &str,
|
||||
@@ -1231,7 +1279,7 @@ fn inject_model_choices(defs: &mut Map<String, Value>, models: &[String]) {
|
||||
{
|
||||
optional.insert(
|
||||
"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
|
||||
.extension()
|
||||
.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
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"html" => "no-cache",
|
||||
"js" | "mjs" | "css" | "woff2" | "png" | "jpg" | "jpeg" | "webp" | "svg" => {
|
||||
"html" | "js" | "mjs" | "css" => "no-cache",
|
||||
"woff2" | "png" | "jpg" | "jpeg" | "webp" | "svg" => {
|
||||
"public, max-age=604800"
|
||||
}
|
||||
_ => "public, max-age=3600",
|
||||
@@ -4792,15 +4840,28 @@ async fn experiment_models_in_folder(
|
||||
) -> AppResult<Json<Value>> {
|
||||
if state.config.comfyui.uses_cloud_backend() {
|
||||
let _ = auth_caller_optional(&state, &headers);
|
||||
let normalized_folder = folder.trim().to_ascii_lowercase();
|
||||
if matches!(
|
||||
normalized_folder.as_str(),
|
||||
"boosapi" | "boosapi_models" | "api_models"
|
||||
) {
|
||||
if workflow_folder_has_route_models(&folder) {
|
||||
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);
|
||||
return Ok(Json(Value::Array(api_model_items)));
|
||||
let api_model_items = workflow_folder_route_model_items(&folder, &route_models);
|
||||
if workflow_folder_is_api_only(&folder) {
|
||||
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));
|
||||
return native_workflow_json_cached(
|
||||
@@ -4813,7 +4874,7 @@ async fn experiment_models_in_folder(
|
||||
.await;
|
||||
}
|
||||
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() {
|
||||
let _ = auth_caller_optional(&state, &headers);
|
||||
@@ -5701,3 +5762,73 @@ fn parse_workflow_cookie(raw: &str) -> Option<&str> {
|
||||
.map(|value| value.trim())
|
||||
.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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::{
|
||||
};
|
||||
use chrono::Timelike;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{FromRow, Row, SqlitePool};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -22,6 +23,23 @@ pub struct UsageSummary {
|
||||
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 {
|
||||
pub fn new(db: Arc<SqlitePool>) -> Self {
|
||||
Self { db }
|
||||
@@ -119,6 +137,112 @@ impl UsageTracker {
|
||||
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<()> {
|
||||
Self::create_daily_usage_snapshot_for_db(&self.db).await
|
||||
}
|
||||
@@ -485,6 +609,112 @@ impl UsageTracker {
|
||||
.await?;
|
||||
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(
|
||||
@@ -614,6 +844,56 @@ mod tests {
|
||||
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]
|
||||
async fn records_and_aggregates_usage() {
|
||||
let tracker = UsageTracker::new(Arc::new(pool().await));
|
||||
@@ -662,6 +942,84 @@ mod tests {
|
||||
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]
|
||||
async fn projects_proxy_logs_to_site_and_model_usage() {
|
||||
let pool = pool().await;
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod comfyui_process;
|
||||
pub mod cron;
|
||||
pub mod notification;
|
||||
pub mod oauth;
|
||||
pub mod payment;
|
||||
pub mod register_captcha;
|
||||
pub mod registration_limiter;
|
||||
pub mod route_rebuild;
|
||||
|
||||
+523
-50
@@ -1,13 +1,16 @@
|
||||
use crate::error::{AppError, AppResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Map, Value};
|
||||
use sqlx::{FromRow, SqlitePool};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub const SYSTEM_PROXY_URL_SETTING_KEY: &str = "runtime.system_proxy_url";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OAuthService {
|
||||
db: Arc<SqlitePool>,
|
||||
client: reqwest::Client,
|
||||
#[cfg(test)]
|
||||
client_override: Option<reqwest::Client>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
@@ -26,6 +29,7 @@ pub struct OAuthRouteUnit {
|
||||
pub struct RefreshedToken {
|
||||
pub account_id: i64,
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
@@ -33,7 +37,16 @@ impl OAuthService {
|
||||
pub fn new(db: Arc<SqlitePool>) -> Self {
|
||||
Self {
|
||||
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 {
|
||||
account_id,
|
||||
access_token: current_access_token,
|
||||
refresh_token,
|
||||
expires_at: token_expiry(extra_config.as_deref()),
|
||||
});
|
||||
}
|
||||
@@ -63,21 +77,24 @@ impl OAuthService {
|
||||
.filter(|token| !token.trim().is_empty())
|
||||
.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;
|
||||
let next_extra_config =
|
||||
merge_refreshed_extra_config(extra_config.as_deref(), &refreshed, &refresh_token)?;
|
||||
sqlx::query(
|
||||
"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 = ?",
|
||||
)
|
||||
.bind(&refreshed.access_token)
|
||||
.bind(
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"expires_at": refreshed.expires_at,
|
||||
"refreshed_at": chrono::Utc::now().to_rfc3339(),
|
||||
}))
|
||||
.map_err(|err| AppError::Internal(err.into()))?,
|
||||
)
|
||||
.bind(refreshed.refresh_token.as_deref().unwrap_or(&refresh_token))
|
||||
.bind(next_extra_config)
|
||||
.bind(account_id)
|
||||
.bind(site_id)
|
||||
.execute(&*self.db)
|
||||
@@ -90,6 +107,19 @@ impl OAuthService {
|
||||
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>> {
|
||||
if let Some(site_id) = site_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(
|
||||
&self,
|
||||
client: &reqwest::Client,
|
||||
site_id: i64,
|
||||
refresh_token: &str,
|
||||
) -> AppResult<RefreshedToken> {
|
||||
let token_endpoint: Option<String> = sqlx::query_scalar(
|
||||
"SELECT json_extract(custom_headers, '$.oauth_token_url') FROM sites WHERE id = ?",
|
||||
)
|
||||
.bind(site_id)
|
||||
.fetch_optional(&*self.db)
|
||||
.await?;
|
||||
let raw_custom_headers: Option<String> =
|
||||
sqlx::query_scalar("SELECT custom_headers FROM sites WHERE id = ?")
|
||||
.bind(site_id)
|
||||
.fetch_optional(&*self.db)
|
||||
.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(
|
||||
"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
|
||||
.client
|
||||
let response: Value = client
|
||||
.post(token_endpoint)
|
||||
.form(&[
|
||||
("grant_type", "refresh_token"),
|
||||
("refresh_token", refresh_token),
|
||||
])
|
||||
.form(&form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(format!("OAuth refresh failed: {err}")))?
|
||||
@@ -162,46 +200,481 @@ impl OAuthService {
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(format!("Invalid OAuth response: {err}")))?;
|
||||
|
||||
let access_token = response
|
||||
.get("access_token")
|
||||
.and_then(Value::as_str)
|
||||
let access_token = string_field(&response, &["access_token", "accessToken"])
|
||||
.ok_or_else(|| AppError::UpstreamError("OAuth response missing access_token".into()))?
|
||||
.to_string();
|
||||
let expires_at = response
|
||||
.get("expires_at")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
let refresh_token =
|
||||
string_field(&response, &["refresh_token", "refreshToken"]).map(str::to_string);
|
||||
let expires_at = token_expiry_from_response(&response);
|
||||
|
||||
Ok(RefreshedToken {
|
||||
account_id: 0,
|
||||
access_token,
|
||||
refresh_token,
|
||||
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 {
|
||||
let Some(extra_config) = extra_config else {
|
||||
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)
|
||||
token_expiry_datetime(extra_config)
|
||||
.map(|expires_at| expires_at <= chrono::Utc::now() + chrono::Duration::minutes(5))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
fn token_expiry(extra_config: Option<&str>) -> Option<String> {
|
||||
extra_config
|
||||
.and_then(|config| serde_json::from_str::<Value>(config).ok())
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("expires_at")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
})
|
||||
token_expiry_datetime(extra_config).map(|expires_at| expires_at.to_rfc3339())
|
||||
}
|
||||
|
||||
fn token_expiry_datetime(extra_config: Option<&str>) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
let value = extra_config.and_then(|config| serde_json::from_str::<Value>(config).ok())?;
|
||||
if let Some(expires_at) = value
|
||||
.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
@@ -51,6 +51,7 @@ pub struct RebuildSummary {
|
||||
struct AvailRow {
|
||||
account_id: i64,
|
||||
model_name: String,
|
||||
oauth_route_unit_id: Option<i64>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
// query — keeping the in-memory set small for installs with hundreds of
|
||||
// accounts.
|
||||
let rows: Vec<(i64, String)> = sqlx::query_as(
|
||||
"SELECT ma.account_id, ma.model_name
|
||||
let rows: Vec<(i64, String, Option<i64>)> = sqlx::query_as(
|
||||
"SELECT ma.account_id, ma.model_name, rum.unit_id
|
||||
FROM model_availability ma
|
||||
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
|
||||
AND a.status = 'active'
|
||||
AND NOT EXISTS (
|
||||
@@ -93,9 +95,10 @@ pub(crate) async fn rebuild_routes_inner(
|
||||
|
||||
let avail: Vec<AvailRow> = rows
|
||||
.into_iter()
|
||||
.map(|(account_id, model_name)| AvailRow {
|
||||
.map(|(account_id, model_name, oauth_route_unit_id)| AvailRow {
|
||||
account_id,
|
||||
model_name,
|
||||
oauth_route_unit_id,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -112,11 +115,16 @@ pub(crate) async fn rebuild_routes_inner(
|
||||
|
||||
// 2. Group by model name. Each group becomes one token_routes row.
|
||||
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 {
|
||||
by_model
|
||||
.entry(row.model_name.clone())
|
||||
.or_default()
|
||||
.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
|
||||
@@ -211,21 +219,41 @@ pub(crate) async fn rebuild_routes_inner(
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let oauth_route_unit_id = route_unit_by_key.get(key).copied().flatten();
|
||||
sqlx::query(
|
||||
"INSERT INTO route_channels
|
||||
(route_id, account_id, token_id, source_model,
|
||||
priority, weight, enabled, manual_override)
|
||||
VALUES (?, ?, ?, ?, 0, 10, 1, 0)",
|
||||
oauth_route_unit_id, priority, weight, enabled, manual_override)
|
||||
VALUES (?, ?, ?, ?, ?, 0, 10, 1, 0)",
|
||||
)
|
||||
.bind(route_id)
|
||||
.bind(key.0)
|
||||
.bind(token_id)
|
||||
.bind(&key.1)
|
||||
.bind(oauth_route_unit_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
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
|
||||
// model, or the model is now in site_disabled_models).
|
||||
for (key, ch_id) in &auto_by_key {
|
||||
@@ -379,6 +407,7 @@ mod tests {
|
||||
route_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
token_id INTEGER,
|
||||
oauth_route_unit_id INTEGER,
|
||||
source_model TEXT,
|
||||
priority INTEGER DEFAULT 0,
|
||||
weight INTEGER DEFAULT 10,
|
||||
@@ -391,6 +420,20 @@ mod tests {
|
||||
enabled INTEGER DEFAULT 1,
|
||||
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)
|
||||
.execute(&pool)
|
||||
@@ -524,6 +567,69 @@ mod tests {
|
||||
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
|
||||
/// `/routes` page, and clicking "rebuild" does nothing. Reproduces by
|
||||
/// pre-seeding `token_routes` rows that no account / availability row
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { act } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
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 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 {
|
||||
return (node.children || []).map((child) => {
|
||||
@@ -11,6 +21,77 @@ function collectText(node: ReactTestInstance): string {
|
||||
}).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', () => {
|
||||
it('renders a poster-style hero with a floating admin login panel', () => {
|
||||
const root = create(
|
||||
@@ -51,18 +132,267 @@ describe('Login surface', () => {
|
||||
node.type === 'input'
|
||||
&& 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) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('login-mode-tab')
|
||||
));
|
||||
expect(tokenInput.props.type).toBe('password');
|
||||
expect(loginModeTabs.map((node) => collectText(node))).toEqual(['Auth Token', '账号密码']);
|
||||
expect(loginModeTabs.map((node) => collectText(node))).toEqual(['邮箱密码', 'API Key']);
|
||||
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 {
|
||||
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', () => {
|
||||
const root = create(
|
||||
<MemoryRouter>
|
||||
@@ -80,8 +410,98 @@ describe('Login surface', () => {
|
||||
expect(providers.map((node) => node.props['data-provider'])).toEqual(['google', 'github', 'wechat']);
|
||||
expect(providers.every((node) => node.props.disabled === true)).toBe(true);
|
||||
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 {
|
||||
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);');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -11,17 +11,166 @@ describe('App sidebar config', () => {
|
||||
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 consoleGroupIndex = source.indexOf("label: '控制台'");
|
||||
const accessGroupIndex = source.indexOf("label: '接入管理'");
|
||||
const downstreamIndex = source.indexOf("{ to: '/downstream-keys', label: '下游密钥'");
|
||||
const systemGroupIndex = source.indexOf("label: '系统'");
|
||||
|
||||
expect(consoleGroupIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(downstreamIndex).toBeGreaterThan(consoleGroupIndex);
|
||||
expect(accessGroupIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(downstreamIndex).toBeGreaterThan(accessGroupIndex);
|
||||
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', () => {
|
||||
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('<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);');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,16 +3,20 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
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 topbarStart = source.indexOf('<div className="topbar-right">');
|
||||
const topbarEnd = source.indexOf('</header>');
|
||||
const sidebarItemStart = source.indexOf('const renderDesktopSidebarItem');
|
||||
const sidebarItemEnd = source.indexOf('return (', source.indexOf('<a'));
|
||||
|
||||
expect(topbarStart).toBeGreaterThanOrEqual(0);
|
||||
expect(topbarEnd).toBeGreaterThan(topbarStart);
|
||||
|
||||
const topbarSection = source.slice(topbarStart, topbarEnd);
|
||||
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
File diff suppressed because it is too large
Load Diff
+385
-2
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { api, type ProxyTestRequestEnvelope } from './api.js';
|
||||
import { persistAuthSession } from './authSession.js';
|
||||
import { api, buildSiteProbeStreamUrl, userApi, type ProxyTestRequestEnvelope } from './api.js';
|
||||
import { commitSession, persistAuthSession } from './authSession.js';
|
||||
|
||||
function createMemoryStorage() {
|
||||
const store = new Map<string, string>();
|
||||
@@ -122,6 +122,20 @@ describe('api proxy test timeout handling', () => {
|
||||
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 () => {
|
||||
installPendingFetch();
|
||||
|
||||
@@ -179,3 +193,372 @@ describe('api proxy test timeout handling', () => {
|
||||
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
@@ -11,6 +11,22 @@ type RequestOptions = RequestInit & {
|
||||
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 {
|
||||
const token = getSessionToken('admin', localStorage);
|
||||
if (!token) {
|
||||
@@ -28,13 +44,15 @@ function requireAuthToken(): string {
|
||||
return token;
|
||||
}
|
||||
|
||||
async function extractResponseErrorMessage(res: Response): Promise<string> {
|
||||
async function extractResponseError(res: Response): Promise<ApiRequestError> {
|
||||
let message = `HTTP ${res.status}`;
|
||||
let payload: any = null;
|
||||
try {
|
||||
const text = await res.text();
|
||||
if (text) {
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
payload = json;
|
||||
if (json?.message && typeof json.message === "string") {
|
||||
message = json.message;
|
||||
} else if (json?.error && typeof json.error === "string") {
|
||||
@@ -47,12 +65,19 @@ async function extractResponseErrorMessage(res: Response): Promise<string> {
|
||||
} else {
|
||||
message = `${message}: ${text.slice(0, 120)}`;
|
||||
}
|
||||
if (typeof json?.path === "string" && json.path.trim()) {
|
||||
message = `${message} (${json.path.trim()})`;
|
||||
}
|
||||
} catch {
|
||||
message = `${message}: ${text.slice(0, 120)}`;
|
||||
}
|
||||
}
|
||||
} 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> {
|
||||
@@ -248,13 +273,54 @@ export async function fetchSessionResponse(
|
||||
: 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>(
|
||||
url: string,
|
||||
options: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const res = await fetchUserResponse(url, options);
|
||||
if (!res.ok) {
|
||||
throw new Error(await extractResponseErrorMessage(res));
|
||||
throw await extractResponseError(res);
|
||||
}
|
||||
return parseJsonResponse<T>(res);
|
||||
}
|
||||
@@ -265,7 +331,7 @@ async function request<T = any>(
|
||||
): Promise<T> {
|
||||
const res = await fetchAuthenticatedResponse(url, options);
|
||||
if (!res.ok) {
|
||||
throw new Error(await extractResponseErrorMessage(res));
|
||||
throw await extractResponseError(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 {
|
||||
if (!row || typeof row !== "object") return row;
|
||||
return {
|
||||
@@ -392,6 +472,8 @@ function normalizeProxyLogRow(row: any): any {
|
||||
clientAppId: row.clientAppId ?? row.client_app_id ?? null,
|
||||
clientAppName: row.clientAppName ?? row.client_app_name ?? null,
|
||||
clientConfidence: row.clientConfidence ?? row.client_confidence ?? null,
|
||||
billingDetails:
|
||||
row.billingDetails ?? parseJsonObjectField(row.billing_details),
|
||||
contentSummary: row.contentSummary ?? row.content_summary ?? null,
|
||||
requestContent: row.requestContent ?? row.request_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(
|
||||
url: string,
|
||||
handlers: {
|
||||
@@ -789,6 +1009,7 @@ export type ProxyLogListItem = {
|
||||
promptTokens?: number | null;
|
||||
completionTokens?: number | null;
|
||||
estimatedCost?: number | null;
|
||||
billingDetails?: ProxyLogBillingDetails;
|
||||
contentSummary?: string | null;
|
||||
requestContent?: string | null;
|
||||
responseContent?: string | null;
|
||||
@@ -816,6 +1037,7 @@ export type ProxyLogsQuery = {
|
||||
search?: string;
|
||||
client?: string;
|
||||
siteId?: number;
|
||||
downstreamKeyId?: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
@@ -1002,6 +1224,8 @@ export type OAuthConnectionInfo = {
|
||||
modelsPreview: string[];
|
||||
status: "healthy" | "abnormal";
|
||||
quota?: OAuthQuotaInfo | null;
|
||||
expiresAt?: string | null;
|
||||
tokenExpiresAt?: number | null;
|
||||
routeChannelCount?: number;
|
||||
lastModelSyncAt?: string | null;
|
||||
lastModelSyncError?: string | null;
|
||||
@@ -1034,11 +1258,12 @@ export type OAuthQuotaBatchRefreshResponse = {
|
||||
export type OAuthImportResponse = {
|
||||
success: boolean;
|
||||
imported: number;
|
||||
updated?: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
items: Array<{
|
||||
name: string;
|
||||
status: "imported" | "skipped" | "failed";
|
||||
status: "imported" | "updated" | "skipped" | "failed";
|
||||
accountId?: number;
|
||||
provider?: string;
|
||||
message?: string;
|
||||
@@ -1192,6 +1417,22 @@ export type DigitalHumanJobsResponse = {
|
||||
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 MoneyPrinterJobResponse = {
|
||||
@@ -1219,6 +1460,7 @@ export type MoneyPrinterStatusResponse = {
|
||||
label?: string;
|
||||
nodeType?: string;
|
||||
model?: string;
|
||||
models?: string[];
|
||||
duration?: {
|
||||
min?: 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 = {
|
||||
// Sites
|
||||
getSites: async () => {
|
||||
@@ -1258,12 +1633,14 @@ export const api = {
|
||||
}),
|
||||
getSiteAvailableModels: (siteId: number) =>
|
||||
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`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(options || {}),
|
||||
timeoutMs: options?.scope === 'all' ? 120_000 : 30_000,
|
||||
}),
|
||||
probeSiteStream: (siteId: number, options?: SiteProbeOptions, signal?: AbortSignal) =>
|
||||
fetchSiteProbeStream(siteId, options, signal),
|
||||
|
||||
// Accounts
|
||||
getAccounts: async (params?: { includeOauth?: boolean }) => {
|
||||
@@ -1643,7 +2020,8 @@ export const api = {
|
||||
|
||||
// OAuth
|
||||
getOAuthProviders: () =>
|
||||
request("/api/oauth/providers") as Promise<OAuthProvidersResponse>,
|
||||
request("/api/oauth/providers")
|
||||
.then(normalizeOAuthProvidersResponse) as Promise<OAuthProvidersResponse>,
|
||||
startOAuthProvider: (
|
||||
provider: string,
|
||||
data?: {
|
||||
@@ -1672,14 +2050,14 @@ export const api = {
|
||||
getOAuthConnections: (params?: { limit?: number; offset?: number }) =>
|
||||
request(
|
||||
`/api/oauth/connections${buildQueryString(params)}`,
|
||||
) as Promise<OAuthConnectionsResponse>,
|
||||
).then(normalizeOAuthConnectionsResponse) as Promise<OAuthConnectionsResponse>,
|
||||
refreshOAuthConnectionQuota: (accountId: number) =>
|
||||
request(`/api/oauth/connections/${accountId}/quota/refresh`, {
|
||||
request(`/api/oauth/connections/${accountId}/refresh-quota`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
}) as Promise<{ success: true; quota: OAuthQuotaInfo }>,
|
||||
refreshOAuthConnectionQuotaBatch: (accountIds: number[]) =>
|
||||
request("/api/oauth/connections/quota/refresh-batch", {
|
||||
request("/api/oauth/connections/refresh-quota-batch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ accountIds }),
|
||||
}) as Promise<OAuthQuotaBatchRefreshResponse>,
|
||||
@@ -1688,7 +2066,7 @@ export const api = {
|
||||
data: { proxyUrl?: string | null; useSystemProxy?: boolean },
|
||||
) =>
|
||||
request(`/api/oauth/connections/${accountId}/proxy`, {
|
||||
method: "PATCH",
|
||||
method: "POST",
|
||||
body: JSON.stringify(data || {}),
|
||||
}) as Promise<{ success: true }>,
|
||||
rebindOAuthConnection: (
|
||||
@@ -1717,6 +2095,18 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}) 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) =>
|
||||
request(`/api/oauth/route-units/${routeUnitId}`, {
|
||||
method: "DELETE",
|
||||
@@ -1923,15 +2313,6 @@ export const api = {
|
||||
testNotification: () =>
|
||||
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
|
||||
getModelsMarketplace: (options?: {
|
||||
refresh?: boolean;
|
||||
@@ -2057,6 +2438,8 @@ export const api = {
|
||||
}),
|
||||
getWorkflowNodeTypes: () =>
|
||||
request<{ success: boolean; nodeTypes: any[] }>("/api/workflow/node-types"),
|
||||
getWorkflowModelFolder: (folder: string) =>
|
||||
workflowModelFolderRequest(request, folder),
|
||||
getWorkflows: (params?: { include_public?: boolean }) =>
|
||||
request<{ success: boolean; items: WorkflowSummary[] }>(`/api/workflows${buildQueryString(params)}`),
|
||||
createWorkflow: (data: WorkflowSavePayload) =>
|
||||
@@ -2075,6 +2458,8 @@ export const api = {
|
||||
request<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/workflow-runs${buildQueryString(params)}`),
|
||||
getWorkflowRun: (publicId: string) =>
|
||||
request<{ success: boolean; run: WorkflowRunSummary }>(`/api/workflow-runs/${encodeURIComponent(publicId)}`),
|
||||
getDigitalHumanStatus: () =>
|
||||
request<DigitalHumanStatusResponse>("/api/digital-human/status"),
|
||||
createDigitalHumanJob: (data: any) =>
|
||||
request<DigitalHumanJobResponse>("/api/digital-human/jobs", {
|
||||
method: "POST",
|
||||
@@ -2129,10 +2514,23 @@ export const userApi = {
|
||||
userRequest(`/api/user-api-keys/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
deleteApiKey: (id: number) =>
|
||||
userRequest(`/api/user-api-keys/${id}`, { method: "DELETE" }),
|
||||
getLogs: (params?: { limit?: number; offset?: number; 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)}`),
|
||||
getSummary: () => userRequest("/api/user/summary"),
|
||||
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?: {
|
||||
refresh?: boolean;
|
||||
includePricing?: boolean;
|
||||
@@ -2162,6 +2560,8 @@ export const userApi = {
|
||||
}),
|
||||
getWorkflowNodeTypes: () =>
|
||||
userRequest<{ success: boolean; nodeTypes: any[] }>("/api/user/workflow/node-types"),
|
||||
getWorkflowModelFolder: (folder: string) =>
|
||||
workflowModelFolderRequest(userRequest, folder),
|
||||
getWorkflows: (params?: { include_public?: boolean }) =>
|
||||
userRequest<{ success: boolean; items: WorkflowSummary[] }>(`/api/user/workflows${buildQueryString(params)}`),
|
||||
createWorkflow: (data: WorkflowSavePayload) =>
|
||||
@@ -2180,6 +2580,8 @@ export const userApi = {
|
||||
userRequest<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/user/workflow-runs${buildQueryString(params)}`),
|
||||
getWorkflowRun: (publicId: string) =>
|
||||
userRequest<{ success: boolean; run: WorkflowRunSummary }>(`/api/user/workflow-runs/${encodeURIComponent(publicId)}`),
|
||||
getDigitalHumanStatus: () =>
|
||||
userRequest<DigitalHumanStatusResponse>("/api/digital-human/status"),
|
||||
createDigitalHumanJob: (data: any) =>
|
||||
userRequest<DigitalHumanJobResponse>("/api/digital-human/jobs", {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { clearSession } from './authSession.js';
|
||||
|
||||
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 USER_PROFILE_STORAGE_KEY = 'user_profile';
|
||||
export const ADMIN_PROFILE_STORAGE_KEY = 'admin_profile';
|
||||
@@ -25,6 +26,7 @@ export function clearAppInstallationState(storage?: StorageLike | null): void {
|
||||
if (!target) return;
|
||||
clearSession('admin', target as never);
|
||||
target.removeItem(THEME_MODE_STORAGE_KEY);
|
||||
target.removeItem(THEME_PRESET_STORAGE_KEY);
|
||||
target.removeItem(LEGACY_THEME_STORAGE_KEY);
|
||||
target.removeItem(USER_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');
|
||||
});
|
||||
});
|
||||
@@ -17,9 +17,33 @@ const PAGE_TARGET_SELECTOR = [
|
||||
':scope > .picture-agent-shell',
|
||||
':scope > .workspace-layout',
|
||||
':scope > .dashboard-grid',
|
||||
':scope > .dashboard-war-room',
|
||||
':scope > .dashboard-command-strip',
|
||||
':scope > .digital-human-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(', ');
|
||||
|
||||
const LOGIN_TARGET_SELECTOR = [
|
||||
@@ -35,9 +59,22 @@ const LOGIN_TARGET_SELECTOR = [
|
||||
'.login-auth-footer',
|
||||
].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;
|
||||
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({
|
||||
@@ -62,45 +99,63 @@ export default function MotionDiv({
|
||||
},
|
||||
(context) => {
|
||||
const conditions = context.conditions || {};
|
||||
const targets = selectMotionTargets(
|
||||
root,
|
||||
motionVariant,
|
||||
motionVariant === 'login' ? 14 : (conditions.isDesktop ? 26 : 16),
|
||||
);
|
||||
|
||||
if (conditions.reduceMotion) {
|
||||
gsap.set(root, { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });
|
||||
gsap.set([root, ...targets], { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const targets = selectMotionTargets(root, motionVariant);
|
||||
const distance = motionVariant === 'login' ? 16 : 10;
|
||||
const distance = motionVariant === 'login' ? 16 : (conditions.isDesktop ? 12 : 8);
|
||||
const timeline = gsap.timeline({
|
||||
defaults: {
|
||||
ease: 'power2.out',
|
||||
overwrite: 'auto',
|
||||
},
|
||||
});
|
||||
|
||||
const rootTween = gsap.fromTo(
|
||||
timeline.fromTo(
|
||||
root,
|
||||
{ autoAlpha: 0, y: Math.max(6, distance - 4) },
|
||||
{
|
||||
autoAlpha: 1,
|
||||
y: 0,
|
||||
duration: motionVariant === 'login' ? 0.46 : 0.34,
|
||||
ease: 'power2.out',
|
||||
duration: motionVariant === 'login' ? 0.46 : 0.32,
|
||||
clearProps: 'transform,opacity,visibility',
|
||||
},
|
||||
);
|
||||
|
||||
const targetTween = targets.length
|
||||
? gsap.fromTo(
|
||||
if (targets.length) {
|
||||
timeline.fromTo(
|
||||
targets,
|
||||
{ autoAlpha: 0, y: distance },
|
||||
{
|
||||
autoAlpha: 0,
|
||||
y: distance,
|
||||
scale: motionVariant === 'login' ? 0.992 : 0.996,
|
||||
},
|
||||
{
|
||||
autoAlpha: 1,
|
||||
y: 0,
|
||||
duration: motionVariant === 'login' ? 0.58 : 0.42,
|
||||
ease: 'power2.out',
|
||||
stagger: motionVariant === 'login' ? 0.045 : 0.025,
|
||||
delay: 0.04,
|
||||
scale: 1,
|
||||
duration: motionVariant === 'login' ? 0.58 : 0.38,
|
||||
stagger: {
|
||||
each: motionVariant === 'login' ? 0.045 : 0.026,
|
||||
from: 'start',
|
||||
},
|
||||
clearProps: 'transform,opacity,visibility',
|
||||
},
|
||||
)
|
||||
: null;
|
||||
motionVariant === 'login' ? '-=0.2' : '-=0.18',
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
rootTween.kill();
|
||||
targetTween?.kill();
|
||||
timeline.kill();
|
||||
gsap.killTweensOf([root, ...targets]);
|
||||
gsap.set([root, ...targets], { clearProps: 'transform,opacity,visibility' });
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ export function useIconCdn() {
|
||||
const observer = new MutationObserver(() => {
|
||||
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 isDark ? ICON_CDN : ICON_CDN_LIGHT;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useId, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { trapFocusInContainer } from './focusTrap.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
|
||||
type CenteredModalProps = {
|
||||
@@ -28,6 +29,9 @@ export default function CenteredModal({
|
||||
showCloseButton = true,
|
||||
}: CenteredModalProps) {
|
||||
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'
|
||||
&& !!document.body
|
||||
&& typeof document.body.appendChild === 'function'
|
||||
@@ -43,9 +47,52 @@ export default function CenteredModal({
|
||||
}, [canUsePortal, open]);
|
||||
|
||||
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) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
if (event.key === 'Escape' && closeOnEscape) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
trapFocusInContainer(event, dialogRef.current);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
@@ -55,24 +102,33 @@ export default function CenteredModal({
|
||||
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const interactive = open && presence.isVisible;
|
||||
|
||||
const modal = (
|
||||
<div
|
||||
className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
onClick={closeOnBackdrop ? onClose : undefined}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-hidden={!interactive}
|
||||
tabIndex={-1}
|
||||
style={{ maxWidth }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">{title}</div>
|
||||
<div id={titleId} className="modal-title">{title}</div>
|
||||
{showCloseButton ? (
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close-button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭弹框"
|
||||
tabIndex={interactive ? 0 : -1}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import React, { useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from './Toast.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 }) {
|
||||
const presence = useAnimatedVisibility(open, 200);
|
||||
const [oldToken, setOldToken] = useState('');
|
||||
const [newToken, setNewToken] = useState('');
|
||||
const [confirmToken, setConfirmToken] = useState('');
|
||||
@@ -14,15 +12,6 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
|
||||
const [error, setError] = useState('');
|
||||
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 () => {
|
||||
setError('');
|
||||
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 = {
|
||||
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none',
|
||||
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
|
||||
};
|
||||
|
||||
const modal = (
|
||||
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
|
||||
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={e => e.stopPropagation()} style={{ maxWidth: 420 }}>
|
||||
<div className="modal-header">修改管理员 Token</div>
|
||||
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<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>
|
||||
|
||||
{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">
|
||||
return (
|
||||
<CenteredModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="修改管理员 Token"
|
||||
maxWidth={420}
|
||||
closeOnBackdrop
|
||||
closeOnEscape
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
footer={(
|
||||
<>
|
||||
<button type="button" onClick={onClose} className="btn btn-ghost">取消</button>
|
||||
<button type="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)' }} />更新中...</> : '确认修改'}
|
||||
</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>
|
||||
);
|
||||
|
||||
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||
{error && (
|
||||
<div className="alert alert-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</CenteredModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,11 +28,12 @@ export default function DeleteConfirmModal({
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
maxWidth={560}
|
||||
closeOnEscape
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
footer={(
|
||||
<>
|
||||
<button onClick={onClose} className="btn btn-ghost" disabled={loading}>{cancelText}</button>
|
||||
<button onClick={onConfirm} className="btn btn-danger" disabled={loading}>
|
||||
<button type="button" onClick={onClose} className="btn btn-ghost" disabled={loading}>{cancelText}</button>
|
||||
<button type="button" onClick={onConfirm} className="btn btn-danger" disabled={loading}>
|
||||
{loading
|
||||
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 删除中...</>
|
||||
: 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('当前已加载路由');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,10 @@ type MobileBatchBarProps = {
|
||||
|
||||
export default function MobileBatchBar({ info, children }: MobileBatchBarProps) {
|
||||
return (
|
||||
<div className="mobile-actions-bar mobile-batch-bar">
|
||||
<span className="mobile-actions-info">{info}</span>
|
||||
<div
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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 { trapFocusInContainer } from './focusTrap.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
|
||||
type MobileDrawerProps = {
|
||||
open: boolean;
|
||||
@@ -8,6 +10,7 @@ type MobileDrawerProps = {
|
||||
title?: React.ReactNode;
|
||||
closeLabel?: string;
|
||||
side?: 'left' | 'right';
|
||||
panelId?: string;
|
||||
};
|
||||
|
||||
function MobileDrawer({
|
||||
@@ -17,28 +20,15 @@ function MobileDrawer({
|
||||
title,
|
||||
closeLabel = '关闭导航',
|
||||
side = 'left',
|
||||
panelId,
|
||||
}: MobileDrawerProps) {
|
||||
const [shouldRender, setShouldRender] = useState(open);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const presence = useAnimatedVisibility(open, 280);
|
||||
const titleId = useId();
|
||||
const labelledBy = title ? titleId : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setShouldRender(true);
|
||||
setIsClosing(false);
|
||||
} else if (shouldRender) {
|
||||
setIsClosing(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
setIsClosing(false);
|
||||
}, 280);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [open]);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastActiveElementRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
@@ -51,12 +41,53 @@ function MobileDrawer({
|
||||
};
|
||||
}, [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(() => {
|
||||
if (!open || typeof document === 'undefined') return;
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
trapFocusInContainer(event, panelRef.current);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
@@ -64,23 +95,34 @@ function MobileDrawer({
|
||||
};
|
||||
}, [handleClose, open]);
|
||||
|
||||
if (!shouldRender) return null;
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const interactive = open && presence.isVisible;
|
||||
|
||||
const drawer = (
|
||||
<div className={`mobile-drawer-root ${isClosing ? 'is-closing' : ''}`}>
|
||||
<div className={`mobile-drawer-root ${presence.isVisible ? '' : 'is-closing'}`.trim()}>
|
||||
<div
|
||||
className="mobile-drawer-backdrop"
|
||||
onClick={handleClose}
|
||||
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">
|
||||
{title ? (
|
||||
<div className="mobile-drawer-title" id={titleId}>
|
||||
{title}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ type MobileFilterSheetProps = {
|
||||
onClose: () => void;
|
||||
title?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
panelId?: string;
|
||||
};
|
||||
|
||||
export default function MobileFilterSheet({
|
||||
@@ -13,9 +14,10 @@ export default function MobileFilterSheet({
|
||||
onClose,
|
||||
title = '筛选',
|
||||
children,
|
||||
panelId,
|
||||
}: MobileFilterSheetProps) {
|
||||
return (
|
||||
<MobileDrawer open={open} onClose={onClose} title={title} closeLabel="关闭筛选">
|
||||
<MobileDrawer open={open} onClose={onClose} title={title} closeLabel="关闭筛选" panelId={panelId}>
|
||||
<div className="mobile-filter-panel">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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 ModelAnalysisPanel from './ModelAnalysisPanel.js';
|
||||
|
||||
@@ -17,6 +19,27 @@ function collectText(node: ReactTestInstance): string {
|
||||
}).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', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalGetComputedStyle = globalThis.getComputedStyle;
|
||||
@@ -66,6 +89,151 @@ describe('ModelAnalysisPanel token summaries', () => {
|
||||
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 () => {
|
||||
globalThis.document = {
|
||||
documentElement: {},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo, useRef, useState, type KeyboardEvent } from 'react';
|
||||
import { VChart } from '@visactor/react-vchart';
|
||||
import { InlineBrandIcon } from './BrandIcon.js';
|
||||
import { formatCompactTokenMetric } from '../numberFormat.js';
|
||||
@@ -64,7 +64,15 @@ function EmptyBlock() {
|
||||
|
||||
export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
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 activeTabPanelId = `model-analysis-panel-${activeTab}`;
|
||||
const activeTabId = `model-analysis-tab-${activeTab}`;
|
||||
|
||||
const totals = {
|
||||
spend: toSafeNumber(data?.totals?.spend),
|
||||
@@ -119,10 +127,38 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
|
||||
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 (
|
||||
<div>
|
||||
{/* 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-label">总消耗</div>
|
||||
<div className="stat-summary-card-value">{formatCurrency(totals.spend)}</div>
|
||||
@@ -139,12 +175,21 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
|
||||
{/* Pill Tabs */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="pill-tabs">
|
||||
<div className="pill-tabs" role="tablist" aria-label="模型分析视图" onKeyDown={handleTabKeyDown}>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
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' : ''}`}
|
||||
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>
|
||||
{tab.label}
|
||||
@@ -154,144 +199,152 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
</div>
|
||||
|
||||
{/* Chart Content */}
|
||||
{activeTab === 'spend' && (
|
||||
<div>
|
||||
<div style={{ height: 300 }}>
|
||||
<VChart spec={spendBarSpec} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}>
|
||||
{spendDistribution.map(d => (
|
||||
<span key={d.model} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--color-text-secondary)' }}>
|
||||
<InlineBrandIcon model={d.model} size={13} />
|
||||
<span style={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.model}</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{formatCurrency(d.spend)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 }} />
|
||||
<div
|
||||
key={activeTab}
|
||||
id={activeTabPanelId}
|
||||
className="model-analysis-tab-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby={activeTabId}
|
||||
>
|
||||
{activeTab === 'spend' && (
|
||||
<div>
|
||||
<div style={{ height: 300 }}>
|
||||
<VChart spec={spendBarSpec} />
|
||||
</div>
|
||||
<div className="model-analysis-legend">
|
||||
{spendDistribution.map(d => (
|
||||
<span key={d.model} className="model-analysis-legend-item">
|
||||
<InlineBrandIcon model={d.model} size={13} />
|
||||
<span style={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.model}</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{d.calls}</span>
|
||||
<span className="model-analysis-legend-name">{d.model}</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{formatCurrency(d.spend)}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</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)';
|
||||
{activeTab === 'trend' && (
|
||||
<div style={{ height: 300 }}>
|
||||
<VChart spec={trendSpec} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 (
|
||||
<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>
|
||||
<span key={d.model} className="model-analysis-legend-item">
|
||||
<span style={{ width: 8, height: 8, borderRadius: 2, background: pieColors[idx % pieColors.length], flexShrink: 0 }} />
|
||||
<InlineBrandIcon model={d.model} size={13} />
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ModernSelect from './ModernSelect.js';
|
||||
|
||||
@@ -17,6 +17,33 @@ function collectInstanceText(node: ReactTestInstance): string {
|
||||
}).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', () => {
|
||||
it('renders icon nodes for the selected option', () => {
|
||||
const root = create(
|
||||
@@ -53,11 +80,7 @@ describe('ModernSelect', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-trigger')
|
||||
));
|
||||
const trigger = findTrigger(root);
|
||||
|
||||
await act(async () => {
|
||||
trigger.props.onClick();
|
||||
@@ -72,22 +95,264 @@ describe('ModernSelect', () => {
|
||||
searchInput.props.onChange({ target: { value: 'codex' } });
|
||||
});
|
||||
|
||||
let optionButtons = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-option')
|
||||
));
|
||||
let optionButtons = findOptionButtons(root);
|
||||
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual(['Beta Relay (codex)https://beta.example.com']);
|
||||
|
||||
await act(async () => {
|
||||
searchInput.props.onChange({ target: { value: 'third.example.com' } });
|
||||
});
|
||||
|
||||
optionButtons = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-option')
|
||||
));
|
||||
optionButtons = findOptionButtons(root);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
label?: string | null;
|
||||
description?: string | null;
|
||||
disabled?: boolean;
|
||||
iconNode?: ReactNode;
|
||||
iconUrl?: string;
|
||||
iconText?: string;
|
||||
iconUrl?: string | null;
|
||||
iconText?: string | null;
|
||||
};
|
||||
|
||||
type ModernSelectProps = {
|
||||
@@ -25,6 +33,14 @@ type ModernSelectProps = {
|
||||
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({
|
||||
value,
|
||||
onChange,
|
||||
@@ -41,18 +57,54 @@ export default function ModernSelect({
|
||||
}: ModernSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
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(
|
||||
() => options.find((item) => item.value === value),
|
||||
[options, value],
|
||||
() => normalizedOptions.find((item) => item.value === value),
|
||||
[normalizedOptions, value],
|
||||
);
|
||||
|
||||
const visibleOptions = useMemo(() => {
|
||||
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 = [
|
||||
item.label,
|
||||
item.description,
|
||||
@@ -63,7 +115,31 @@ export default function ModernSelect({
|
||||
.toLowerCase();
|
||||
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(() => {
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -111,6 +187,77 @@ export default function ModernSelect({
|
||||
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 (
|
||||
<div
|
||||
ref={rootRef}
|
||||
@@ -121,9 +268,19 @@ export default function ModernSelect({
|
||||
type="button"
|
||||
className="modern-select-trigger"
|
||||
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-controls={panelId}
|
||||
aria-activedescendant={open && highlightedIndex >= 0 ? `${panelId}-option-${highlightedIndex}` : undefined}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className={`modern-select-value ${selected ? '' : 'is-placeholder'}`.trim()}>
|
||||
@@ -148,15 +305,23 @@ export default function ModernSelect({
|
||||
</svg>
|
||||
</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 && (
|
||||
<div className="modern-select-search-shell">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder={searchPlaceholder}
|
||||
className="modern-select-search-input"
|
||||
tabIndex={open ? 0 : -1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -164,19 +329,24 @@ export default function ModernSelect({
|
||||
{visibleOptions.length === 0 ? (
|
||||
<div className="modern-select-empty">{emptyLabel}</div>
|
||||
) : (
|
||||
visibleOptions.map((item) => {
|
||||
visibleOptions.map((item, index) => {
|
||||
const active = item.value === value;
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
id={`${panelId}-option-${index}`}
|
||||
key={`${item.value || 'option'}-${index}`}
|
||||
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={() => {
|
||||
if (item.disabled) return;
|
||||
onChange(item.value);
|
||||
setOpen(false);
|
||||
selectOption(index);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (!item.disabled) setHighlightedIndex(index);
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
tabIndex={open && !item.disabled ? 0 : -1}
|
||||
>
|
||||
<div className="modern-select-option-main">
|
||||
{renderOptionIcon(item)}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
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 { buildEventNavigationPath } from '../pages/helpers/navigationFocus.js';
|
||||
import { useI18n } from '../i18n.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
import DiagnosticActionGroup, { type DiagnosticAction } from './DiagnosticActionGroup.js';
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
info: 'var(--color-info)',
|
||||
@@ -19,48 +20,220 @@ const typeLabels: Record<string, string> = {
|
||||
proxy: '代理',
|
||||
status: '状态',
|
||||
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({
|
||||
open,
|
||||
onClose,
|
||||
anchorRef,
|
||||
onUnreadCountChange,
|
||||
panelId,
|
||||
audience = 'admin',
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
||||
onUnreadCountChange?: (count: number) => void;
|
||||
panelId?: string;
|
||||
audience?: NotificationAudience;
|
||||
}) {
|
||||
const { t: tr } = useI18n();
|
||||
const presence = useAnimatedVisibility(open, 160);
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
const [activeEventIndex, setActiveEventIndex] = useState(-1);
|
||||
const [activeFocusRequest, setActiveFocusRequest] = useState(0);
|
||||
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 focusAnchor = useCallback(() => {
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor || anchor.isConnected === false) return;
|
||||
anchor.focus({ preventScroll: true });
|
||||
}, [anchorRef]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const loadSeq = ++loadSeqRef.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
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);
|
||||
|
||||
// Auto mark all as read on open
|
||||
const hasUnread = Array.isArray(data) && data.some((e: any) => !e.read);
|
||||
if (hasUnread) {
|
||||
api.markAllEventsRead().catch(() => {});
|
||||
if (audience === 'user') {
|
||||
userApi.markAllAnnouncementsRead().catch(() => {});
|
||||
} else {
|
||||
api.markAllEventsRead().catch(() => {});
|
||||
}
|
||||
onUnreadCountChange?.(0);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoading(false); }
|
||||
}, [filter, onUnreadCountChange]);
|
||||
finally {
|
||||
if (mountedRef.current && openRef.current && loadSeqRef.current === loadSeq) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [audience, filter, onUnreadCountChange]);
|
||||
|
||||
useEffect(() => () => {
|
||||
mountedRef.current = false;
|
||||
loadSeqRef.current += 1;
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
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(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (
|
||||
@@ -74,54 +247,109 @@ export default function NotificationPanel({
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [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 () => {
|
||||
await api.clearEvents();
|
||||
if (audience === 'user') {
|
||||
await userApi.markAllAnnouncementsRead();
|
||||
} else {
|
||||
await api.clearEvents();
|
||||
}
|
||||
if (!mountedRef.current || !openRef.current) return;
|
||||
setEvents([]);
|
||||
setActiveEventIndex(-1);
|
||||
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;
|
||||
|
||||
const interactive = open && presence.isVisible;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={panelId}
|
||||
ref={panelRef}
|
||||
className={`user-dropdown ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
style={{ right: 0, top: '100%', width: 360, maxHeight: 480, padding: 0, marginTop: 4 }}
|
||||
className={`user-dropdown notification-panel ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
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 */}
|
||||
<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>
|
||||
<button onClick={clearAll} className="btn btn-link">
|
||||
<button type="button" onClick={clearAll} className="btn btn-link" tabIndex={interactive ? 0 : -1}>
|
||||
{tr('清空')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<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) => (
|
||||
<button key={filterType} onClick={() => setFilter(filterType)}
|
||||
style={{
|
||||
fontSize: 11, padding: '3px 8px', borderRadius: 12,
|
||||
border: filter === filterType ? '1px solid var(--color-primary)' : '1px solid var(--color-border)',
|
||||
background: filter === filterType ? 'var(--color-primary-light)' : 'transparent',
|
||||
color: filter === filterType ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
{filterType ? tr(typeLabels[filterType] || filterType) : tr('全部')}
|
||||
</button>
|
||||
))}
|
||||
{(audience === 'user' ? userFilterTypes : adminFilterTypes).map((filterType) => {
|
||||
const filterLabel = filterType ? tr(typeLabels[filterType] || filterType) : tr('全部');
|
||||
const active = filter === filterType;
|
||||
return (
|
||||
<button
|
||||
key={filterType}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
aria-label={`${tr('通知筛选')}: ${filterLabel}`}
|
||||
tabIndex={interactive ? 0 : -1}
|
||||
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>
|
||||
|
||||
{/* 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 && events.length === 0 && (
|
||||
<div style={{ padding: '32px 16px', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 13 }}>
|
||||
{tr('暂无通知')}
|
||||
</div>
|
||||
)}
|
||||
{events.map((ev: any) => {
|
||||
const targetPath = buildEventNavigationPath(ev);
|
||||
{events.map((ev: any, index: number) => {
|
||||
const targetPath = audience === 'user'
|
||||
? buildUserNotificationNavigationPath(ev)
|
||||
: buildEventNavigationPath(ev);
|
||||
const diagnosticActions = buildNotificationDiagnosticActions(ev, targetPath);
|
||||
const active = activeEventIndex === index;
|
||||
const openTarget = () => {
|
||||
onClose();
|
||||
navigate(targetPath);
|
||||
@@ -129,7 +357,10 @@ export default function NotificationPanel({
|
||||
return (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="notification-event-item"
|
||||
ref={(node) => {
|
||||
eventRefs.current[index] = node;
|
||||
}}
|
||||
className={`notification-event-item ${active ? 'is-active' : ''}`.trim()}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid var(--color-border-light)',
|
||||
@@ -140,8 +371,18 @@ export default function NotificationPanel({
|
||||
}}
|
||||
onClick={openTarget}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
tabIndex={interactive ? 0 : -1}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
onMouseEnter={() => {
|
||||
focusActiveEventRef.current = false;
|
||||
setActiveEventIndex(index);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveActiveEvent(event.key === 'ArrowDown' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
openTarget();
|
||||
@@ -163,6 +404,11 @@ export default function NotificationPanel({
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
{formatDateTimeMinuteLocal(ev.createdAt)}
|
||||
</div>
|
||||
<DiagnosticActionGroup
|
||||
actions={diagnosticActions}
|
||||
className="notification-event-actions"
|
||||
interactive={interactive}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,8 +11,12 @@ describe('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.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('批量启用');
|
||||
});
|
||||
|
||||
@@ -23,8 +27,11 @@ describe('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(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 (
|
||||
<div className="card" style={{ ...DEFAULT_DESKTOP_STYLE, ...desktopStyle }}>
|
||||
<span style={{ ...DEFAULT_INFO_STYLE, ...infoStyle }}>{info}</span>
|
||||
<div
|
||||
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}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -32,6 +32,8 @@ export default function ResponsiveFilterPanel({
|
||||
mobileTriggerButtonClassName = 'btn btn-ghost',
|
||||
mobileTriggerButtonStyle = { border: '1px solid var(--color-border)' },
|
||||
}: ResponsiveFilterPanelProps) {
|
||||
const mobilePanelId = React.useId();
|
||||
|
||||
if (!isMobile) {
|
||||
return <>{desktopContent}</>;
|
||||
}
|
||||
@@ -44,13 +46,16 @@ export default function ResponsiveFilterPanel({
|
||||
type="button"
|
||||
className={mobileTriggerButtonClassName}
|
||||
style={mobileTriggerButtonStyle}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls={mobilePanelId}
|
||||
onClick={onMobileOpen}
|
||||
>
|
||||
{mobileTriggerLabel}
|
||||
</button>
|
||||
</div>
|
||||
) : null)}
|
||||
<MobileFilterSheet open={mobileOpen} onClose={onMobileClose} title={mobileTitle}>
|
||||
<MobileFilterSheet open={mobileOpen} onClose={onMobileClose} title={mobileTitle} panelId={mobilePanelId}>
|
||||
{mobileContent}
|
||||
</MobileFilterSheet>
|
||||
</>
|
||||
|
||||
@@ -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 { api } from '../api.js';
|
||||
import { formatDateLocal, formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
|
||||
import { buildAccountFocusPath, buildSiteFocusPath, buildTokenFocusPath } from '../pages/helpers/navigationFocus.js';
|
||||
import { useI18n } from '../i18n.js';
|
||||
import { trapFocusInContainer } from './focusTrap.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
|
||||
interface SiteResult {
|
||||
@@ -65,25 +66,106 @@ interface SearchResult {
|
||||
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 presence = useAnimatedVisibility(open, 180);
|
||||
const titleId = useId();
|
||||
const resultsId = useId();
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeResultIndex, setActiveResultIndex] = useState(-1);
|
||||
const [activeFocusRequest, setActiveFocusRequest] = useState(0);
|
||||
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 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(() => {
|
||||
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('');
|
||||
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()) {
|
||||
setResults(null);
|
||||
return;
|
||||
@@ -92,6 +174,7 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.search(q);
|
||||
if (!mountedRef.current || !openRef.current || searchSeqRef.current !== searchSeq) return;
|
||||
setResults({
|
||||
models: Array.isArray(res?.models) ? res.models : [],
|
||||
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 : [],
|
||||
proxyLogs: Array.isArray(res?.proxyLogs) ? res.proxyLogs : [],
|
||||
});
|
||||
setActiveResultIndex(-1);
|
||||
focusActiveResultRef.current = false;
|
||||
} catch {
|
||||
// ignore search errors in modal
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (mountedRef.current && openRef.current && searchSeqRef.current === searchSeq) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleInput = (val: string) => {
|
||||
setQuery(val);
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = window.setTimeout(() => doSearch(val), 300);
|
||||
focusActiveResultRef.current = false;
|
||||
setActiveResultIndex(-1);
|
||||
clearSearchTimer();
|
||||
const searchSeq = searchSeqRef.current;
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
void doSearch(val, searchSeq);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const goTo = (path: string) => {
|
||||
@@ -119,8 +210,24 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
};
|
||||
|
||||
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) => {
|
||||
if (e.key === 'Escape' && open) onClose();
|
||||
if (!open) return;
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
trapFocusInContainer(e, dialogRef.current);
|
||||
};
|
||||
document.addEventListener('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;
|
||||
|
||||
const interactive = open && presence.isVisible;
|
||||
|
||||
const hasResults = results && (
|
||||
results.models.length
|
||||
|| results.accounts.length
|
||||
@@ -136,11 +245,103 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
|| results.checkinLogs.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 (
|
||||
<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 style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<div
|
||||
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)">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
@@ -148,6 +349,12 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
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('搜索站点、账号、模型、日志...')}
|
||||
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>
|
||||
</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 && (
|
||||
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 13 }}>
|
||||
{t('没有找到匹配结果')}
|
||||
@@ -165,8 +372,12 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
{results?.models.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('模型广场')}</div>
|
||||
{results.models.map((m) => (
|
||||
<button key={m.name} className="search-result-item" onClick={() => goTo(`/models?q=${encodeURIComponent(m.name)}`)}>
|
||||
{results.models.map((m) => {
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L4 12l5.75-5M14.25 7L20 12l-5.75 5M14 4l-4 16" />
|
||||
</svg>
|
||||
@@ -177,15 +388,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.sites.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('站点')}</div>
|
||||
{results.sites.map((s) => (
|
||||
<button key={s.id} className="search-result-item" onClick={() => goTo(buildSiteFocusPath(s.id))}>
|
||||
{results.sites.map((s) => {
|
||||
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">
|
||||
<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>
|
||||
@@ -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>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.accounts.length ? (
|
||||
<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
|
||||
type="button"
|
||||
key={a.id}
|
||||
className="search-result-item"
|
||||
onClick={() => goTo(buildAccountFocusPath(a.id, {
|
||||
openRebind: a.status === 'expired',
|
||||
segment: a.segment,
|
||||
}))}
|
||||
{...props}
|
||||
>
|
||||
<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" />
|
||||
@@ -225,18 +443,23 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.accountTokens.length ? (
|
||||
<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
|
||||
type="button"
|
||||
key={token.id}
|
||||
className="search-result-item"
|
||||
onClick={() => goTo(buildTokenFocusPath(token.id))}
|
||||
{...props}
|
||||
>
|
||||
<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" />
|
||||
@@ -251,15 +474,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.checkinLogs.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('签到记录')}</div>
|
||||
{results.checkinLogs.map((l) => (
|
||||
<button key={l.id} className="search-result-item" onClick={() => goTo('/checkin')}>
|
||||
{results.checkinLogs.map((l) => {
|
||||
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">
|
||||
<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>
|
||||
@@ -270,15 +498,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.proxyLogs.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('使用日志')}</div>
|
||||
{results.proxyLogs.map((l) => (
|
||||
<button key={l.id} className="search-result-item" onClick={() => goTo('/logs')}>
|
||||
{results.proxyLogs.map((l) => {
|
||||
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">
|
||||
<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>
|
||||
@@ -289,12 +522,13 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</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 }}>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>
|
||||
|
||||
@@ -74,6 +74,16 @@ describe('SiteCreatedModal', () => {
|
||||
]),
|
||||
);
|
||||
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) === '添加账号(用户名密码登录)');
|
||||
await act(async () => {
|
||||
@@ -101,5 +111,6 @@ describe('SiteCreatedModal', () => {
|
||||
));
|
||||
|
||||
expect(buttons.some((button) => collectText(button) === '添加 OAuth 连接')).toBe(true);
|
||||
expect(buttons.every((button) => button.props.type === 'button')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,10 +56,12 @@ export default function SiteCreatedModal({
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
footer={(
|
||||
<>
|
||||
<button onClick={() => onChoice('later')} className="btn btn-ghost">
|
||||
<button type="button" onClick={() => onChoice('later')} className="btn btn-ghost">
|
||||
稍后配置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`站点创建后续操作:${secondaryAction.label}`}
|
||||
onClick={() => onChoice(secondaryAction.choice)}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
@@ -67,6 +69,8 @@ export default function SiteCreatedModal({
|
||||
{secondaryAction.label}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`站点创建推荐操作:${primaryAction.label}`}
|
||||
onClick={() => onChoice(primaryAction.choice)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
const TOAST_DURATION_MS = 3200;
|
||||
const TOAST_EXIT_DURATION_MS = 250;
|
||||
type ToastTimer = Parameters<typeof clearTimeout>[0];
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
@@ -33,19 +36,42 @@ const icons: Record<ToastType, React.ReactNode> = {
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
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) => {
|
||||
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));
|
||||
}, 250);
|
||||
}, []);
|
||||
clearToastTimers(id);
|
||||
}, TOAST_EXIT_DURATION_MS) as ToastTimer;
|
||||
trackTimer(id, timer);
|
||||
}, [clearToastTimers, trackTimer]);
|
||||
|
||||
const addToast = useCallback((type: ToastType, message: string) => {
|
||||
const id = ++idRef.current;
|
||||
setToasts(prev => [...prev, { id, type, message }]);
|
||||
setTimeout(() => removeToast(id), 3200);
|
||||
}, [removeToast]);
|
||||
const timer = globalThis.setTimeout(() => removeToast(id), TOAST_DURATION_MS) as ToastTimer;
|
||||
trackTimer(id, timer);
|
||||
}, [removeToast, trackTimer]);
|
||||
|
||||
const success = useCallback((msg: string) => addToast('success', msg), [addToast]);
|
||||
const error = useCallback((msg: string) => addToast('error', msg), [addToast]);
|
||||
@@ -68,11 +94,27 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
<div
|
||||
key={t.id}
|
||||
className={`toast toast-${t.type} ${t.exiting ? 'toast-exit' : ''}`}
|
||||
onClick={() => removeToast(t.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
role={t.type === 'error' ? 'alert' : 'status'}
|
||||
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>
|
||||
<span style={{ fontSize: 13, lineHeight: 1.5 }}>{t.message}</span>
|
||||
<button
|
||||
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>
|
||||
))}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,12 @@ import CenteredModal from './CenteredModal.js';
|
||||
|
||||
describe('CenteredModal component', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalHTMLElement = globalThis.HTMLElement;
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.HTMLElement = originalHTMLElement;
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/components/CenteredModal.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('modal-backdrop');
|
||||
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('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 () => {
|
||||
|
||||
@@ -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 { 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 { 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())}`;
|
||||
}
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
describe('DownstreamKeyTrendChart', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalGetComputedStyle = globalThis.getComputedStyle;
|
||||
@@ -125,4 +132,77 @@ describe('DownstreamKeyTrendChart', () => {
|
||||
|
||||
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 { VChart } from '@visactor/react-vchart';
|
||||
import { useThemeColor, useThemeLabelColor } from '../useThemeLabelColor.js';
|
||||
import { formatDateTimeMinuteLocal } from '../../pages/helpers/checkinLogTime.js';
|
||||
|
||||
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())}`;
|
||||
}
|
||||
|
||||
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({
|
||||
buckets,
|
||||
bucketSeconds = 3600,
|
||||
@@ -43,6 +67,9 @@ export default function DownstreamKeyTrendChart({
|
||||
height?: number;
|
||||
}) {
|
||||
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(() => {
|
||||
if (!Array.isArray(buckets) || buckets.length === 0) return [];
|
||||
@@ -62,27 +89,38 @@ export default function DownstreamKeyTrendChart({
|
||||
})
|
||||
.filter((row) => row.date.length > 0);
|
||||
}, [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) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={headerStyle}>
|
||||
<div className="downstream-key-trend-chart">
|
||||
<div className="downstream-key-trend-chart-head">
|
||||
<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 className="skeleton" style={{ width: '100%', height, borderRadius: 'var(--radius-sm)' }} />
|
||||
<div className="skeleton" style={{ width: '100%', height, borderRadius: 'var(--radius-md)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!flatData || flatData.length === 0) {
|
||||
if (!flatData || flatData.length === 0 || !hasPositiveValue) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={headerStyle}>
|
||||
<div className="downstream-key-trend-chart">
|
||||
<div className="downstream-key-trend-chart-head">
|
||||
<MetricToggle metric={metric} onChange={setMetric} />
|
||||
<MetricSummary metric={metric} total={metricTotal} peak={metricPeak} />
|
||||
</div>
|
||||
<div className="empty-state" style={{ padding: 32 }}>
|
||||
<div className="empty-state-title">暂无趋势数据</div>
|
||||
<div className="empty-state-desc">该 Key 在所选时间范围内没有可用的 tokens 记录</div>
|
||||
<div className="downstream-key-chart-empty" style={{ minHeight: height }}>
|
||||
<div className="downstream-key-chart-empty-title">暂无{getMetricLabel(metric)}趋势</div>
|
||||
<div className="downstream-key-chart-empty-desc">所选时间范围内该指标为 0</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -110,16 +148,19 @@ export default function DownstreamKeyTrendChart({
|
||||
{
|
||||
orient: 'bottom',
|
||||
label: {
|
||||
style: { fontSize: 11, fill: 'var(--color-text-muted)' },
|
||||
style: { fontSize: 11, fill: labelColor },
|
||||
formatMethod: (value: string) => formatTrendAxisLabel(String(value || ''), bucketSeconds),
|
||||
},
|
||||
domainLine: { style: { stroke: 'var(--color-border-light)' } },
|
||||
tick: { style: { stroke: 'var(--color-border-light)' } },
|
||||
domainLine: { style: { stroke: axisColor, lineWidth: 1 } },
|
||||
tick: { style: { stroke: axisColor, lineWidth: 1 } },
|
||||
},
|
||||
{
|
||||
orient: 'left',
|
||||
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } },
|
||||
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [4, 4] } },
|
||||
label: {
|
||||
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 },
|
||||
},
|
||||
],
|
||||
@@ -128,37 +169,54 @@ export default function DownstreamKeyTrendChart({
|
||||
title: { value: (datum: Record<string, unknown>) => String(datum?.tooltipDate || datum?.date || '') },
|
||||
content: [
|
||||
{
|
||||
key: () => METRIC_OPTIONS.find((opt) => opt.key === metric)?.label || 'Value',
|
||||
key: () => getMetricLabel(metric),
|
||||
value: (datum: Record<string, unknown>) => {
|
||||
const value = Number(datum?.value ?? 0);
|
||||
if (metric === 'cost') return `$${value.toFixed(6)}`;
|
||||
return value.toLocaleString();
|
||||
return formatMetricValue(metric, value);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
color: ['var(--color-primary)'],
|
||||
color: [primaryColor],
|
||||
background: 'transparent',
|
||||
animationAppear: {
|
||||
area: { type: 'fadeIn', duration: 500, 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 (
|
||||
<div style={containerStyle}>
|
||||
<div style={headerStyle}>
|
||||
<div className="downstream-key-trend-chart">
|
||||
<div className="downstream-key-trend-chart-head">
|
||||
<MetricToggle metric={metric} onChange={setMetric} />
|
||||
<MetricSummary metric={metric} total={metricTotal} peak={metricPeak} />
|
||||
</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%' }} />
|
||||
</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({
|
||||
metric,
|
||||
onChange,
|
||||
@@ -167,15 +225,15 @@ function MetricToggle({
|
||||
onChange: (m: Metric) => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={toggleGroupStyle}>
|
||||
<div className="downstream-key-metric-toggle" role="group" aria-label="下游 Key 趋势指标">
|
||||
{METRIC_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
aria-pressed={metric === opt.key}
|
||||
aria-label={`切换下游 Key 趋势指标:${opt.label}`}
|
||||
onClick={() => onChange(opt.key)}
|
||||
style={{
|
||||
...toggleBtnBase,
|
||||
...(metric === opt.key ? toggleBtnActive : toggleBtnInactive),
|
||||
}}
|
||||
className={metric === opt.key ? 'is-active' : ''}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
@@ -183,46 +241,3 @@ function MetricToggle({
|
||||
</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 { act, create } from 'react-test-renderer';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import SiteDistributionChart from './SiteDistributionChart.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('SiteDistributionChart', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalGetComputedStyle = globalThis.getComputedStyle;
|
||||
@@ -48,4 +55,43 @@ describe('SiteDistributionChart', () => {
|
||||
|
||||
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 */}
|
||||
<div
|
||||
role="group"
|
||||
aria-label="站点分布视图"
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 0,
|
||||
@@ -249,6 +251,9 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={viewMode === 'balance'}
|
||||
aria-label="切换站点分布视图:余额分布"
|
||||
onClick={() => setViewMode('balance')}
|
||||
style={{
|
||||
padding: '5px 14px',
|
||||
@@ -266,6 +271,9 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
||||
余额分布
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={viewMode === 'spend'}
|
||||
aria-label="切换站点分布视图:消耗分布"
|
||||
onClick={() => setViewMode('spend')}
|
||||
style={{
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -214,10 +214,13 @@ function MetricToggle({
|
||||
onChange: (m: Metric) => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={toggleGroupStyle}>
|
||||
<div style={toggleGroupStyle} role="group" aria-label="站点趋势指标">
|
||||
{METRIC_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
aria-pressed={metric === opt.key}
|
||||
aria-label={`切换站点趋势指标:${opt.label}`}
|
||||
onClick={() => onChange(opt.key)}
|
||||
style={{
|
||||
...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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@ describe('MobileBatchBar', () => {
|
||||
|
||||
expect(text).toContain('已选 2 项');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 { resolve } from 'node:path';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
@@ -9,10 +9,28 @@ vi.mock('react-dom', () => ({
|
||||
}));
|
||||
|
||||
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 () => {
|
||||
const onClose = vi.fn();
|
||||
let root!: WebTestRenderer;
|
||||
vi.stubGlobal('document', {
|
||||
activeElement: activeElementMock,
|
||||
body: {
|
||||
style: {
|
||||
overflow: '',
|
||||
@@ -40,6 +58,11 @@ describe('MobileDrawer', () => {
|
||||
expect(text).toContain('导航菜单');
|
||||
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');
|
||||
expect(backdrop.type).toBe('div');
|
||||
|
||||
@@ -59,10 +82,108 @@ describe('MobileDrawer', () => {
|
||||
});
|
||||
}
|
||||
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', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('MobileFilterSheet', () => {
|
||||
|
||||
try {
|
||||
const root = create(
|
||||
<MobileFilterSheet open onClose={() => {}} title="筛选条件">
|
||||
<MobileFilterSheet open onClose={() => {}} title="筛选条件" panelId="mobile-filters">
|
||||
<div>FilterContent</div>
|
||||
</MobileFilterSheet>,
|
||||
);
|
||||
@@ -33,6 +33,7 @@ describe('MobileFilterSheet', () => {
|
||||
expect(text).toContain('筛选条件');
|
||||
expect(text).toContain('FilterContent');
|
||||
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 {
|
||||
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 { 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 SearchModal from './SearchModal.js';
|
||||
@@ -27,11 +30,31 @@ function collectText(node: ReactTestInstance): string {
|
||||
}).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 findResultsRegion(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();
|
||||
@@ -40,16 +63,35 @@ async function flushMicrotasks() {
|
||||
}
|
||||
|
||||
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(() => {
|
||||
vi.clearAllMocks();
|
||||
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', {
|
||||
value: globalThis,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
documentBodyStyle = { overflow: 'auto' };
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
value: {
|
||||
activeElement: activeElementMock,
|
||||
body: { style: documentBodyStyle },
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
},
|
||||
@@ -64,6 +106,122 @@ describe('SearchModal results', () => {
|
||||
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 () => {
|
||||
apiMock.search.mockResolvedValue({
|
||||
models: [],
|
||||
@@ -119,6 +277,13 @@ describe('SearchModal results', () => {
|
||||
const buttons = root.root.findAll((node) => node.type === 'button');
|
||||
const accountButton = buttons.find((node) => collectText(node).includes('API Key 连接'));
|
||||
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 () => {
|
||||
accountButton!.props.onClick();
|
||||
@@ -129,4 +294,357 @@ describe('SearchModal results', () => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,18 @@ export function useAnimatedVisibility(visible: boolean, durationMs = 220) {
|
||||
const [isVisible, setIsVisible] = useState(visible);
|
||||
|
||||
useEffect(() => {
|
||||
const reducedMotion = (
|
||||
typeof window !== 'undefined'
|
||||
&& typeof window.matchMedia === 'function'
|
||||
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
|
||||
if (visible) {
|
||||
setShouldRender(true);
|
||||
if (reducedMotion) {
|
||||
setIsVisible(true);
|
||||
return undefined;
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
const rafId = window.requestAnimationFrame(() => setIsVisible(true));
|
||||
return () => window.cancelAnimationFrame(rafId);
|
||||
@@ -16,7 +26,7 @@ export function useAnimatedVisibility(visible: boolean, durationMs = 220) {
|
||||
}
|
||||
|
||||
setIsVisible(false);
|
||||
if (durationMs <= 0) {
|
||||
if (reducedMotion || durationMs <= 0) {
|
||||
setShouldRender(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function readThemeLabelColor(): string | null {
|
||||
function readThemeColor(name: string): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
|
||||
const root = document.documentElement;
|
||||
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;
|
||||
}
|
||||
|
||||
export function useThemeLabelColor(fallback = '#9ca3af'): string {
|
||||
const [labelColor, setLabelColor] = useState(fallback);
|
||||
export function useThemeColor(name: string, fallback = '#9ca3af'): string {
|
||||
const [themeColor, setThemeColor] = useState(fallback);
|
||||
|
||||
useEffect(() => {
|
||||
const root = typeof document !== 'undefined' ? document.documentElement : null;
|
||||
const read = () => {
|
||||
const color = readThemeLabelColor();
|
||||
if (color) setLabelColor(color);
|
||||
const color = readThemeColor(name);
|
||||
if (color) setThemeColor(color);
|
||||
};
|
||||
|
||||
read();
|
||||
@@ -27,9 +27,13 @@ export function useThemeLabelColor(fallback = '#9ca3af'): string {
|
||||
}
|
||||
|
||||
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();
|
||||
}, []);
|
||||
}, [name]);
|
||||
|
||||
return labelColor;
|
||||
return themeColor;
|
||||
}
|
||||
|
||||
export function useThemeLabelColor(fallback = '#9ca3af'): string {
|
||||
return useThemeColor('--color-text-secondary', fallback);
|
||||
}
|
||||
|
||||
@@ -80,7 +80,18 @@ export const zhToEnSupplemental: Record<string, string> = {
|
||||
'成功': 'success',
|
||||
'成功 (${countBy(\'success\')})': 'Success (${countBy(\'success\')})',
|
||||
'成功率': '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',
|
||||
'触发测试通知失败': 'Trigger test notification failed',
|
||||
'触发签到失败': '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.',
|
||||
'计算中...': 'Calculating...',
|
||||
'技术栈': 'technology stack',
|
||||
'加载程序日志失败': 'Loader log failed',
|
||||
'加载程序日志失败': 'Failed to load system logs',
|
||||
'加载更多': 'load more',
|
||||
'加载监控配置失败': 'Failed to load monitoring configuration',
|
||||
'加载令牌失败': 'Failed to load token',
|
||||
@@ -480,8 +491,6 @@ export const zhToEnSupplemental: Record<string, string> = {
|
||||
'收到流式 [DONE] 信号。': 'Streaming [DONE] signal received.',
|
||||
'收件人地址': 'Recipient address',
|
||||
'收起': 'close',
|
||||
'收起侧边栏': 'Collapse sidebar',
|
||||
'展开侧边栏': 'Expand sidebar',
|
||||
'收起高级参数': 'Close advanced parameters',
|
||||
'手动模式适合高级场景;自动路由仍会保持开启。': 'Manual mode is suitable for advanced scenarios; automatic routing will still remain on.',
|
||||
'手动增改路由': 'Manually add and modify routes',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user