feat: refine BoosAPI console and user workspace
This commit is contained in:
@@ -27,3 +27,7 @@ openspec/
|
||||
boosapi
|
||||
bossai
|
||||
docker/*.tar
|
||||
boosapi.db-shm
|
||||
boosapi.db-wal
|
||||
.tours/
|
||||
bun.lock
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-1
@@ -25,6 +25,9 @@ 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"
|
||||
|
||||
[comfyui]
|
||||
enabled = false
|
||||
@@ -35,12 +38,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);
|
||||
@@ -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') {
|
||||
|
||||
@@ -56,6 +56,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")]
|
||||
@@ -95,6 +99,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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+813
-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,207 @@ 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 title = if audit.success {
|
||||
format!("用户登录成功:{}", display_user_for_audit(audit.user, audit.identifier))
|
||||
} else {
|
||||
format!("用户登录失败:{}", display_user_for_audit(audit.user, audit.identifier))
|
||||
};
|
||||
|
||||
let mut message_parts = vec![
|
||||
format!("方式={}", audit.method.label()),
|
||||
format!("IP={}", audit.client_ip),
|
||||
];
|
||||
if let Some(user) = audit.user {
|
||||
message_parts.push(format!("用户ID={}", user.id));
|
||||
message_parts.push(format!("邮箱={}", user.email));
|
||||
} else if let Some(identifier) = audit.identifier {
|
||||
message_parts.push(format!("标识={}", identifier));
|
||||
}
|
||||
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 {
|
||||
if !user.email.trim().is_empty() {
|
||||
return user.email.clone();
|
||||
}
|
||||
if !user.username.trim().is_empty() {
|
||||
return user.username.clone();
|
||||
}
|
||||
return format!("用户 #{}", user.id);
|
||||
}
|
||||
identifier
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("未知用户")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
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 {
|
||||
for name in [
|
||||
"cf-connecting-ip",
|
||||
"x-real-ip",
|
||||
"x-forwarded-for",
|
||||
"forwarded",
|
||||
] {
|
||||
if let Some(value) = header_ip_value(headers, name) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
fallback.ip().to_string()
|
||||
}
|
||||
|
||||
fn header_ip_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
let raw = headers.get(name)?.to_str().ok()?.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if name.eq_ignore_ascii_case("forwarded") {
|
||||
return forwarded_header_ip(raw);
|
||||
}
|
||||
raw.split(',')
|
||||
.filter_map(normalize_ip_token)
|
||||
.next()
|
||||
}
|
||||
|
||||
fn forwarded_header_ip(raw: &str) -> Option<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)
|
||||
})
|
||||
.next()
|
||||
}
|
||||
|
||||
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 +890,435 @@ 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(),
|
||||
},
|
||||
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("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!(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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+1414
-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;
|
||||
|
||||
+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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
+354
-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,341 @@ 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('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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+395
-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,117 @@ 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 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 +1611,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 +1998,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 +2028,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 +2044,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 +2073,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 +2291,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 +2416,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 +2436,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 +2492,17 @@ 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),
|
||||
}),
|
||||
getModelsMarketplace: (options?: {
|
||||
refresh?: boolean;
|
||||
includePricing?: boolean;
|
||||
@@ -2162,6 +2532,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 +2552,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',
|
||||
|
||||
+129
-2
@@ -1,5 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { translateText } from './i18n.js';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { translateDomSubtree, translateText } from './i18n.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
class MockTextNode {
|
||||
readonly nodeType = 3;
|
||||
nodeValue: string;
|
||||
parentElement: MockElementNode | null = null;
|
||||
|
||||
constructor(value: string) {
|
||||
this.nodeValue = value;
|
||||
}
|
||||
}
|
||||
|
||||
class MockElementNode {
|
||||
readonly nodeType = 1;
|
||||
readonly childNodes: Array<MockElementNode | MockTextNode>;
|
||||
readonly isContentEditable = false;
|
||||
parentElement: MockElementNode | null = null;
|
||||
private readonly attrs = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
attrs: Record<string, string> = {},
|
||||
childNodes: Array<MockElementNode | MockTextNode> = [],
|
||||
) {
|
||||
this.childNodes = childNodes;
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
this.attrs.set(key, value);
|
||||
}
|
||||
for (const child of childNodes) {
|
||||
child.parentElement = this;
|
||||
}
|
||||
}
|
||||
|
||||
get textContent(): string {
|
||||
return this.childNodes
|
||||
.map((child) => child instanceof MockTextNode ? child.nodeValue : child.textContent)
|
||||
.join('');
|
||||
}
|
||||
|
||||
matches() {
|
||||
return false;
|
||||
}
|
||||
|
||||
closest() {
|
||||
return null;
|
||||
}
|
||||
|
||||
getAttribute(attr: string) {
|
||||
return this.attrs.get(attr) ?? null;
|
||||
}
|
||||
|
||||
setAttribute(attr: string, value: string) {
|
||||
this.attrs.set(attr, value);
|
||||
}
|
||||
}
|
||||
|
||||
describe('translateText', () => {
|
||||
it('keeps zh text unchanged in zh mode', () => {
|
||||
@@ -36,6 +93,8 @@ describe('translateText', () => {
|
||||
'签到任务执行中,请稍后查看签到日志',
|
||||
'下游访问令牌至少 6 位(含 sk-)',
|
||||
'路由重建任务执行中,请稍后查看程序日志',
|
||||
'运行日志',
|
||||
'加载运行日志失败',
|
||||
];
|
||||
|
||||
for (const sample of samples) {
|
||||
@@ -43,5 +102,73 @@ describe('translateText', () => {
|
||||
expect(translated).not.toBe('Untranslated');
|
||||
expect(translated).not.toMatch(/[\u3400-\u9fff]/);
|
||||
}
|
||||
|
||||
expect(translateText('运行日志', 'en')).toBe('Runtime log');
|
||||
expect(translateText('加载运行日志失败', 'en')).toBe('Failed to load runtime logs');
|
||||
});
|
||||
|
||||
it('uses concrete english translations for dashboard operations text', () => {
|
||||
const samples = [
|
||||
'下午好,管理员',
|
||||
'加载失败',
|
||||
'关键运行信号',
|
||||
'实时运营摘要',
|
||||
'运营战情室',
|
||||
'日志中心',
|
||||
'值班入口',
|
||||
'站点可用性观测',
|
||||
'模型数据分析',
|
||||
'请求日志、运行日志和任务排障集中在这里',
|
||||
'使用日志、运行日志、通知渠道和运行事件集中留痕。',
|
||||
'平台存在明显异常,建议立即进入运维监控检查路由、站点和请求异常。',
|
||||
'站点可用性、平均延迟和失败数都处于可接受范围。',
|
||||
];
|
||||
|
||||
for (const sample of samples) {
|
||||
const translated = translateText(sample, 'en');
|
||||
expect(translated).not.toBe('Untranslated');
|
||||
expect(translated).not.toMatch(/[\u3400-\u9fff]/);
|
||||
expect(translated).not.toMatch(/[,。:;!?()【】“”‘’、]/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('translateDomSubtree', () => {
|
||||
it('restores dashboard text and attributes when switching from English back to Chinese', () => {
|
||||
vi.stubGlobal('Node', { TEXT_NODE: 3, ELEMENT_NODE: 1 });
|
||||
|
||||
const title = new MockElementNode({}, [new MockTextNode('下午好,管理员')]);
|
||||
const button = new MockElementNode(
|
||||
{ title: '查看日志', 'aria-label': '刷新' },
|
||||
[new MockTextNode('刷新')],
|
||||
);
|
||||
const section = new MockElementNode({ 'aria-label': '日志中心' }, [
|
||||
button,
|
||||
]);
|
||||
const root = new MockElementNode({}, [title, section]);
|
||||
|
||||
translateDomSubtree(root as unknown as Node, 'en');
|
||||
|
||||
expect(title.textContent).toBe('Good afternoon, Admin');
|
||||
expect(section.getAttribute('aria-label')).toBe('Log center');
|
||||
expect(button.getAttribute('title')).toBe('View logs');
|
||||
expect(button.getAttribute('aria-label')).toBe('Refresh');
|
||||
expect(button.textContent).toBe('Refresh');
|
||||
|
||||
translateDomSubtree(root as unknown as Node, 'zh');
|
||||
|
||||
expect(title.textContent).toBe('下午好,管理员');
|
||||
expect(section.getAttribute('aria-label')).toBe('日志中心');
|
||||
expect(button.getAttribute('title')).toBe('查看日志');
|
||||
expect(button.getAttribute('aria-label')).toBe('刷新');
|
||||
expect(button.textContent).toBe('刷新');
|
||||
|
||||
translateDomSubtree(root as unknown as Node, 'en');
|
||||
|
||||
expect(title.textContent).toBe('Good afternoon, Admin');
|
||||
expect(section.getAttribute('aria-label')).toBe('Log center');
|
||||
expect(button.getAttribute('title')).toBe('View logs');
|
||||
expect(button.getAttribute('aria-label')).toBe('Refresh');
|
||||
expect(button.textContent).toBe('Refresh');
|
||||
});
|
||||
});
|
||||
|
||||
+403
-84
@@ -19,6 +19,15 @@ const zhToEn: Record<string, string> = {
|
||||
'管理员入口': 'Admin Access',
|
||||
'部署文档': 'Deployment Docs',
|
||||
'管理员登录后继续。': 'Continue with admin sign-in.',
|
||||
'仅允许使用管理员 API Token 进入控制台。': 'Only an admin API token can enter the console.',
|
||||
'管理员身份用于管理站点、模型路由、用户、计费与系统日志。': 'Admin access manages sites, model routes, users, billing, and system logs.',
|
||||
'管理员后台不接受账号密码或用户 API Key 登录。': 'The admin console does not accept account passwords or user API Keys.',
|
||||
'进入控制台': 'Enter Console',
|
||||
'密码': 'Password',
|
||||
'登录响应缺少访问令牌。': 'The login response is missing an access token.',
|
||||
'管理员请使用管理员 API Token 登录。': 'Admins must sign in with the admin API token.',
|
||||
'识别已绑定普通用户的 API Key,进入用户控制台。': 'Recognizes API Keys bound to regular users and opens the user console.',
|
||||
'仅支持普通用户 API Key;管理员请使用管理员 API Token。': 'Only regular user API Keys are supported; admins must use the admin API token.',
|
||||
'仅校验本地服务访问权限,不会把令牌发送到第三方。': 'Only checks local service access and never sends the token to a third party.',
|
||||
'验证中...': 'Verifying...',
|
||||
'登录': 'Sign In',
|
||||
@@ -59,13 +68,24 @@ const zhToEn: Record<string, string> = {
|
||||
'保存': 'Save',
|
||||
'关闭': 'Close',
|
||||
'打开': 'Open',
|
||||
'收起侧边栏': 'Collapse sidebar',
|
||||
'展开侧边栏': 'Expand sidebar',
|
||||
'导航': 'Navigate',
|
||||
'主内容': 'Main content',
|
||||
'系统日志': 'System Logs',
|
||||
'跳到主内容': 'Skip to main content',
|
||||
'重置': 'Reset',
|
||||
'全部': 'All',
|
||||
'清空': 'Clear',
|
||||
'全部已读': 'Mark All Read',
|
||||
'控制台': 'Console',
|
||||
'仪表盘': 'Dashboard',
|
||||
'运维监控': 'Ops Monitor',
|
||||
'运营管理': 'Operations',
|
||||
'接入管理': 'Access Management',
|
||||
'路由管理': 'Route Management',
|
||||
'IP 管理': 'IP Management',
|
||||
'基础设置': 'Basic Settings',
|
||||
'站点': 'Sites',
|
||||
'账号': 'Accounts',
|
||||
'连接管理': 'Connection Management',
|
||||
@@ -94,6 +114,10 @@ const zhToEn: Record<string, string> = {
|
||||
'系统': 'System',
|
||||
'设置': 'Settings',
|
||||
'程序日志': 'System Logs',
|
||||
'加载运行日志失败': 'Failed to load runtime logs',
|
||||
'筛选运行日志': 'Filter runtime logs',
|
||||
'仅看未读运行日志': 'Unread runtime logs only',
|
||||
'当前筛选条件下没有运行日志。': 'No runtime logs match the current filters.',
|
||||
'导入/导出': 'Import/Export',
|
||||
'通知设置': 'Notification Settings',
|
||||
'概览': 'Overview',
|
||||
@@ -138,7 +162,8 @@ const zhToEn: Record<string, string> = {
|
||||
'用户体系': 'User system',
|
||||
'普通用户拥有独立 API Key、使用日志、工作空间和可见模型范围。': 'Regular users have independent API keys, usage logs, workspaces, and visible model scopes.',
|
||||
'审计告警': 'Audit and alerts',
|
||||
'使用日志、程序日志、通知渠道和运行事件集中留痕。': 'Usage logs, program logs, notification channels, and runtime events are recorded centrally.',
|
||||
'使用日志、运行日志、通知渠道和运行事件集中留痕。': 'Usage logs, runtime logs, notification channels, and runtime events are recorded centrally.',
|
||||
'清理运行日志': 'Clean runtime logs',
|
||||
'只归档最终产出,按用户隔离图片、视频、音频和工作流资产。': 'Archive only final outputs, with images, videos, audio, and workflow assets isolated by user.',
|
||||
'任务编排': 'Task orchestration',
|
||||
'后台生成、取消、查询、归档和失败原因追踪保持一致体验。': 'Background generation, cancellation, queries, archiving, and failure tracking stay consistent.',
|
||||
@@ -181,9 +206,15 @@ const zhToEn: Record<string, string> = {
|
||||
'跟随系统': 'Follow System',
|
||||
'浅色模式': 'Light Mode',
|
||||
'深色模式': 'Dark Mode',
|
||||
'色彩模式': 'Color Mode',
|
||||
'主题风格': 'Theme Style',
|
||||
'清透运维': 'ClearOps',
|
||||
'深色终端': 'Terminal',
|
||||
'云端实验室': 'Cloud Studio',
|
||||
'低噪音控制台': 'Low-noise console',
|
||||
'高对比数据模式': 'High-contrast data mode',
|
||||
'轻创作视觉': 'Light creative visual',
|
||||
'退出登录': 'Sign Out',
|
||||
'收起侧边栏': 'Collapse Sidebar',
|
||||
'展开侧边栏': 'Expand Sidebar',
|
||||
'系统设置': 'System Settings',
|
||||
'站点管理': 'Site Management',
|
||||
'账号管理': 'Connection Management',
|
||||
@@ -428,7 +459,40 @@ const zhToEn: Record<string, string> = {
|
||||
'你的昵称': 'Your nickname',
|
||||
'用户中心': 'User Portal',
|
||||
'API 密钥': 'API Keys',
|
||||
'API Key 与计费': 'API Key & Billing',
|
||||
'新建密钥': 'New Key',
|
||||
'充值': 'Recharge',
|
||||
'去充值': 'Recharge',
|
||||
'账户充值': 'Account Recharge',
|
||||
'账户余额': 'Account Balance',
|
||||
'打开支付页': 'Open Payment Page',
|
||||
'为模型和 Comfy API 添加积分': 'Add credits for models and Comfy API',
|
||||
'选择一个额度后会打开支付页': 'Choose an amount to open the payment page',
|
||||
'选择已配置额度后会打开支付页': 'Choose a configured amount to open the payment page',
|
||||
'该额度支付链接未配置': 'Payment link is not configured for this amount',
|
||||
'已配置支付页': 'Configured payment page',
|
||||
'付款方式': 'Payment method',
|
||||
'外部 Checkout': 'External Checkout',
|
||||
'支付链接在新窗口打开,BossAI 不在前端收集银行卡或支付凭证。': 'The payment link opens in a new window. BossAI does not collect card or payment credentials in the frontend.',
|
||||
'额度同步': 'Credit Sync',
|
||||
'付款后返回系统': 'Return after payment',
|
||||
'如果余额没有立刻变化,刷新页面或联系管理员核对付款记录。': 'If the balance does not update immediately, refresh the page or contact an admin to verify the payment record.',
|
||||
'使用范围': 'Usage Scope',
|
||||
'模型与创作任务': 'Models and Creative Tasks',
|
||||
'充值额度': 'Recharge amount',
|
||||
'充值说明': 'Recharge Notes',
|
||||
'返回 API Key 与计费': 'Back to API Key & Billing',
|
||||
'轻量试用': 'Light trial',
|
||||
'日常调用': 'Daily use',
|
||||
'创作加量': 'Creator boost',
|
||||
'团队高频': 'Team scale',
|
||||
'添加积分后继续调用模型与 Comfy API': 'Add credits to keep using models and the Comfy API',
|
||||
'进入系统内充值页确认额度后,再跳转到 Comfy Checkout 完成付款。': 'Confirm the amount on the in-system recharge page, then continue to Comfy Checkout to complete payment.',
|
||||
'先在系统内确认充值入口,再跳转到 Comfy Checkout 完成付款。付款完成后回到 BossAI 继续使用,如额度未立即变化请刷新或联系管理员确认。': 'Confirm the recharge entry inside the system first, then continue to Comfy Checkout to complete payment. After payment, return to BossAI to continue. If credits do not update immediately, refresh or contact an admin.',
|
||||
'充值额度用于继续调用模型、短视频工厂、数字人和 Comfy API 工作流。': 'Credits are used for model calls, Short Video Factory, Digital Human, and Comfy API workflows.',
|
||||
'支付页会在新窗口打开。付款完成后回到系统继续使用,如额度未立即变化请刷新或联系管理员确认。': 'The payment page opens in a new window. After payment, return here to continue. If credits do not update immediately, refresh or contact an admin.',
|
||||
'需要更多额度时,可跳转到 Comfy Checkout 添加积分。': 'When you need more quota, open Comfy Checkout to add credits.',
|
||||
'查看当前账号的密钥、额度、最近请求和累计消费。': 'View this account\'s keys, quota, recent requests, and total spend.',
|
||||
'当前密码': 'Current Password',
|
||||
'新密码': 'New Password',
|
||||
'确认新密码': 'Confirm New Password',
|
||||
@@ -437,7 +501,7 @@ const zhToEn: Record<string, string> = {
|
||||
'密钥名称': 'Key Name',
|
||||
'暂无 API 密钥': 'No API Keys',
|
||||
'注册时间': 'Registered',
|
||||
'正常': 'Active',
|
||||
'正常': 'Normal',
|
||||
'已禁用': 'Disabled',
|
||||
'普通用户': 'User',
|
||||
'创建': 'Create',
|
||||
@@ -446,6 +510,235 @@ const zhToEn: Record<string, string> = {
|
||||
'用户管理': 'User Management',
|
||||
'个用户': ' users',
|
||||
'总用户': 'Total Users',
|
||||
|
||||
// Dashboard operations surface
|
||||
'夜深了': "It's late",
|
||||
'早上好': 'Good morning',
|
||||
'中午好': 'Good noon',
|
||||
'下午好': 'Good afternoon',
|
||||
'晚上好': 'Good evening',
|
||||
'加载仪表盘失败': 'Failed to load dashboard',
|
||||
'加载失败': 'Load failed',
|
||||
'重试': 'Retry',
|
||||
'稳定': 'Stable',
|
||||
'需关注': 'Needs attention',
|
||||
'暂无流量': 'No traffic',
|
||||
'平台运行平稳,可以继续观察吞吐和成本变化。': 'The platform is stable. Keep watching throughput and cost changes.',
|
||||
'部分指标需要留意,建议优先查看风险站点、路由和最近日志。': 'Some metrics need attention. Check risky sites, routes, and recent logs first.',
|
||||
'部分指标需要留意,建议优先查看风险站点、路由和运维监控。': 'Some metrics need attention. Check risky sites, routes, and Ops Monitor first.',
|
||||
'平台存在明显异常,建议立即进入运维监控检查路由、站点和请求异常。': 'The platform has clear issues. Open Ops Monitor to check routes, sites, and request issues immediately.',
|
||||
'关键运行信号': 'Key runtime signals',
|
||||
'实时运营摘要': 'Live operations summary',
|
||||
'运营战情室': 'Operations room',
|
||||
'风险雷达': 'Risk radar',
|
||||
'优先检查这些站点': 'Check these sites first',
|
||||
'暂无明显站点风险': 'No obvious site risk',
|
||||
'站点可用性、平均延迟和失败数都处于可接受范围。': 'Site availability, average latency, and failure count are within acceptable ranges.',
|
||||
'排障队列': 'Triage queue',
|
||||
'下一步该看哪里': 'Where to look next',
|
||||
'打开日志台': 'Open logs',
|
||||
'运行健康处理队列': 'Health action queue',
|
||||
'个事项需要处理': 'items need attention',
|
||||
'当前没有高优先级事项': 'No high-priority items',
|
||||
'复制交接': 'Copy handoff',
|
||||
'已复制运行健康交接摘要': 'Health handoff summary copied',
|
||||
'复制运行健康交接摘要失败': 'Failed to copy health handoff summary',
|
||||
'日志台': 'Logs',
|
||||
'值班入口': 'On-call entry',
|
||||
'正在整理平台运行数据。': 'Organizing platform runtime data.',
|
||||
'刷新:': 'Refreshed: ',
|
||||
'刷新中...': 'Refreshing...',
|
||||
'聚合请求吞吐、路由健康、站点风险、后台任务和运行日志,作为值班排障的第一屏。': 'Aggregate request throughput, route health, site risk, background tasks, and runtime logs as the first on-call triage screen.',
|
||||
'核心指标': 'Core metrics',
|
||||
'运行状态': 'Runtime status',
|
||||
'实时吞吐': 'Live throughput',
|
||||
'路由风险': 'Route risk',
|
||||
'条路由快照': 'route snapshots',
|
||||
'个活跃站点': 'active sites',
|
||||
'均值': 'avg',
|
||||
'个数据源暂时不可用,已展示可获取的数据': 'data sources are temporarily unavailable; showing available data',
|
||||
'日志中心': 'Log center',
|
||||
'请求日志、运行日志和任务排障集中在这里': 'Request logs, runtime logs, and task triage are centralized here.',
|
||||
'失败请求': 'Failed requests',
|
||||
'全部请求': 'All requests',
|
||||
'条成功': 'successful',
|
||||
'个失败任务': 'failed tasks',
|
||||
'任务运行': 'Task runtime',
|
||||
'请求错误': 'Request errors',
|
||||
'最近 24h 存在失败请求': 'Failed requests in the last 24h',
|
||||
'条': 'items',
|
||||
'先确认错误是否集中在单个站点、模型或账号。': 'Check whether errors are concentrated on one site, model, or account first.',
|
||||
'路由候选需要确认。': 'Route candidates need review.',
|
||||
'打开路由': 'Open route',
|
||||
'暂无可用率': 'No availability',
|
||||
'次失败': 'failures',
|
||||
'查看站点日志': 'View site logs',
|
||||
'后台任务': 'Background tasks',
|
||||
'任务失败,需要进入对应业务页重试或查看运行日志。': 'Task failed. Retry in the related app page or inspect runtime logs.',
|
||||
'查看运行日志': 'View runtime logs',
|
||||
'值班队列': 'On-call queue',
|
||||
'暂无高优先级事项': 'No high-priority items',
|
||||
'请求、路由、站点和后台任务没有触发高风险阈值。': 'Requests, routes, sites, and background tasks have not crossed high-risk thresholds.',
|
||||
'继续观察': 'Keep watching',
|
||||
'优先队列': 'Priority queue',
|
||||
'当前平稳': 'Currently steady',
|
||||
'按风险收敛': 'Prioritized by risk',
|
||||
'值班台': 'On-call desk',
|
||||
'路由与站点': 'Routes and sites',
|
||||
'最需要看的对象': 'Objects to inspect first',
|
||||
'站点暂无风险项': 'No site risk items',
|
||||
'个进行中': 'in progress',
|
||||
'日志': 'Logs',
|
||||
'暂无后台任务': 'No background tasks',
|
||||
'条需关注': 'need attention',
|
||||
'运行日志': 'Runtime log',
|
||||
'暂无运行日志': 'No runtime logs',
|
||||
'已取消': 'Cancelled',
|
||||
'运行中': 'Running',
|
||||
'等待中': 'Pending',
|
||||
'注意': 'Attention',
|
||||
';': '; ',
|
||||
'路由已禁用': 'Route disabled',
|
||||
'路由启用': 'Route enabled',
|
||||
'没有通道': 'No channels',
|
||||
'候选全部不可用': 'All candidates unavailable',
|
||||
'暂无选择概率快照': 'No selection probability snapshot',
|
||||
'通道可用': 'channels available',
|
||||
'候选可选': 'candidates eligible',
|
||||
'个候选近期失败或被避让': 'candidates recently failed or avoided',
|
||||
'最高选择概率': 'Highest selection probability',
|
||||
'运营快捷入口': 'Operations shortcuts',
|
||||
'整体状态': 'Overall status',
|
||||
'24h 失败': '24h failures',
|
||||
'账户启用': 'Account enabled',
|
||||
'活跃站点': 'active sites',
|
||||
'奖励': 'Reward',
|
||||
'站点风险': 'Site risk',
|
||||
'今日成本': 'Today cost',
|
||||
'当前吞吐': 'Current throughput',
|
||||
'路由健康': 'Route health',
|
||||
'需处理路由': 'Routes need attention',
|
||||
'配置正常': 'Config normal',
|
||||
'等待路由': 'Waiting for routes',
|
||||
'24h 请求': '24h requests',
|
||||
'最近 24h 请求': 'Last 24h requests',
|
||||
'查看该站点 24h 请求': 'View this site\'s 24h requests',
|
||||
'需关注站点': 'Sites need attention',
|
||||
'风险站点': 'Risk sites',
|
||||
'账户启用率': 'Account enabled rate',
|
||||
'路由配置': 'Route config',
|
||||
'诊断路由': 'Diagnose route',
|
||||
'查看命中链路与候选通道': 'View matched path and candidate channels',
|
||||
'测试器验证': 'Verify in tester',
|
||||
'发起最小化验证': 'Start minimal verification',
|
||||
'路由通道可用': 'Route channels available',
|
||||
'路由等待启用': 'Routes waiting to be enabled',
|
||||
'已配置路由未发现通道可用性或候选失败风险。': 'Configured routes show no channel availability or candidate failure risk.',
|
||||
'当前路由多为禁用状态,启用前建议先检查通道和候选快照。': 'Most routes are disabled. Check channels and candidate snapshots before enabling them.',
|
||||
'路由页': 'Routes page',
|
||||
'查看路由表和通道': 'View route table and channels',
|
||||
'模型测试器': 'Model tester',
|
||||
'手动验证模型': 'Manually verify model',
|
||||
'错误日志': 'Error logs',
|
||||
'先查看最近失败请求': 'Check recent failed requests first',
|
||||
'从失败日志定位上游、路由或账号问题。': 'Use failed logs to locate upstream, route, or account issues.',
|
||||
'查看失败日志': 'View failed logs',
|
||||
'按失败状态筛选最近请求': 'Filter recent requests by failed status',
|
||||
'查看完整请求流': 'View full request stream',
|
||||
'最近 24h 没有失败请求': 'No failed requests in the last 24h',
|
||||
'日志侧暂无明显错误,可以继续观察吞吐、成本和站点响应。': 'No obvious log errors. Continue watching throughput, cost, and site responses.',
|
||||
'查看全部日志': 'View all logs',
|
||||
'继续观察请求趋势': 'Continue watching request trends',
|
||||
'看站点日志': 'View site logs',
|
||||
'聚焦该站点 24h 请求': 'Focus on this site\'s 24h requests',
|
||||
'检查代理和禁用模型': 'Check proxy and disabled models',
|
||||
'站点观测正常': 'Site observation normal',
|
||||
'查看站点': 'View sites',
|
||||
'维护站点和模型配置': 'Maintain sites and model config',
|
||||
'检查代理和模型配置': 'Check proxy and model config',
|
||||
'模型调用暂无异常': 'No model call issues',
|
||||
'等待流量': 'Waiting for traffic',
|
||||
'排行榜中暂未发现低成功率或高延迟模型。': 'No low-success or high-latency models found in the ranking.',
|
||||
'有模型调用后会在这里出现诊断入口。': 'Diagnostic entries appear here after model calls.',
|
||||
'查看路由表': 'View route table',
|
||||
'先收敛最近失败请求': 'Triage recent failed requests first',
|
||||
'直接过滤失败请求': 'Filter failed requests directly',
|
||||
'复现失败模型': 'Reproduce failed model',
|
||||
'失败请求正常': 'Failed requests normal',
|
||||
'最近 24h 未发现失败请求,日志侧可以作为低优先级观察项。': 'No failed requests in the last 24h. Logs can be a low-priority watch item.',
|
||||
'查看完整请求': 'View full requests',
|
||||
'查看命中链路': 'View matched path',
|
||||
'最小化验证': 'Minimal verification',
|
||||
'路由候选正常': 'Route candidates normal',
|
||||
'暂无路由快照': 'No route snapshot',
|
||||
'等待数据': 'Waiting for data',
|
||||
'当前没有 danger/warning 路由,继续观察候选通道即可。': 'No danger or warning routes. Keep watching candidate channels.',
|
||||
'路由摘要尚未返回,进入路由页可手动刷新。': 'Route summary has not returned yet. Open routes to refresh manually.',
|
||||
'站点健康': 'Site health',
|
||||
'优先确认该站点代理、密钥和禁用模型配置。': 'Confirm this site\'s proxy, key, and disabled model config first.',
|
||||
'检查配置': 'Check config',
|
||||
'站点风险正常': 'Site risk normal',
|
||||
'活跃': 'active',
|
||||
'活跃站点没有触发可用性、延迟或失败阈值。': 'Active sites did not trigger availability, latency, or failure thresholds.',
|
||||
'维护站点': 'Maintain sites',
|
||||
'模型风险': 'Model risk',
|
||||
'推演候选': 'Inspect candidates',
|
||||
'发起验证': 'Start verification',
|
||||
'模型调用正常': 'Model calls normal',
|
||||
'等待模型流量': 'Waiting for model traffic',
|
||||
'模型排行榜未发现低成功率或高延迟异常。': 'Model ranking shows no low-success or high-latency issues.',
|
||||
'有模型调用后会自动形成风险队列项。': 'Risk queue items are created automatically after model calls.',
|
||||
'统一观察模型路由、站点健康、账户余额、请求吞吐和计费成本。这里是整个平台的运营控制台。': 'Monitor model routes, site health, account balances, request throughput, and billing costs from one operations console.',
|
||||
'账户数据': 'Account data',
|
||||
'当前余额': 'Current balance',
|
||||
'累计消耗': 'Total spend',
|
||||
'今日': 'Today',
|
||||
'使用统计': 'Usage stats',
|
||||
'成功请求': 'Successful requests',
|
||||
'资源消耗': 'Resource usage',
|
||||
'活跃账户': 'Active accounts',
|
||||
'签到状态': 'Check-in status',
|
||||
'今日签到': 'Today check-ins',
|
||||
'性能指标': 'Performance',
|
||||
'最近': 'Last',
|
||||
'秒请求': 'sec requests',
|
||||
'秒 Tokens': 'sec Tokens',
|
||||
'站点分析': 'Site analysis',
|
||||
'站点趋势时间范围': 'Site trend range',
|
||||
'天': 'd',
|
||||
'站点可用性观测': 'Site availability',
|
||||
'最近 24 小时 · 每色块 = 1h · 按使用量排序': 'Last 24 hours · each block = 1h · sorted by usage',
|
||||
'低': 'Low',
|
||||
'高': 'High',
|
||||
'隐藏未使用': 'Hide unused',
|
||||
'显示未使用': 'Show unused',
|
||||
'查看运维监控': 'View Ops Monitor',
|
||||
'查看日志': 'View logs',
|
||||
'暂无站点观测数据': 'No site observability data',
|
||||
'有代理请求后,这里会自动生成每个站点的可用性条和平均响应速度。': 'After proxy requests arrive, each site gets an availability strip and average response speed here.',
|
||||
'暂无延迟': 'No latency',
|
||||
'测速': 'Speed test',
|
||||
'超时': 'Timeout',
|
||||
'全部测速完成': 'All speed tests complete',
|
||||
'测速失败': 'Speed test failed',
|
||||
'模型数据分析': 'Model data analysis',
|
||||
'一键测速': 'Test all speeds',
|
||||
'站点信息': 'Site info',
|
||||
'跳转': 'Open',
|
||||
'代理端点可用': 'Proxy endpoint available',
|
||||
'使用': 'Use',
|
||||
'访问': 'Visit',
|
||||
'24h 活跃调用': '24h active calls',
|
||||
'可用性': 'Availability',
|
||||
'无请求': 'No requests',
|
||||
'请求': 'Requests',
|
||||
'成功/失败': 'Success/Failed',
|
||||
'平均响应': 'Avg response',
|
||||
'暂无': 'N/A',
|
||||
'次': 'times',
|
||||
'账号健康': 'Account health',
|
||||
'检查': 'Check',
|
||||
'代理与出口': 'Proxy and egress',
|
||||
};
|
||||
|
||||
for (const [source, target] of Object.entries(zhToEnSupplemental)) {
|
||||
@@ -481,8 +774,12 @@ const CJK_PUNCT_TO_ASCII: Record<string, string> = {
|
||||
'、': ', ',
|
||||
};
|
||||
|
||||
function normalizeCjkPunctuation(text: string): string {
|
||||
return text.replace(/[,。:;!?()【】“”‘’、]/g, (ch) => CJK_PUNCT_TO_ASCII[ch] ?? ch);
|
||||
}
|
||||
|
||||
function enforceStrictEnglish(text: string): string {
|
||||
const normalizedPunctuation = text.replace(/[,。:;!?()【】“”‘’、]/g, (ch) => CJK_PUNCT_TO_ASCII[ch] ?? ch);
|
||||
const normalizedPunctuation = normalizeCjkPunctuation(text);
|
||||
const strippedHan = normalizedPunctuation.replace(HAN_BLOCK_RE, ' ');
|
||||
const compacted = strippedHan.replace(/\s+/g, ' ').trim();
|
||||
if (!compacted) return 'Untranslated';
|
||||
@@ -512,13 +809,98 @@ export function translateText(text: string, language: Language): string {
|
||||
translated = translated.split(source).join(target);
|
||||
}
|
||||
if (HAS_HAN_RE.test(translated)) return enforceStrictEnglish(translated);
|
||||
return translated;
|
||||
return normalizeCjkPunctuation(translated);
|
||||
}
|
||||
|
||||
export function tr(text: string): string {
|
||||
return translateText(text, runtimeLanguage);
|
||||
}
|
||||
|
||||
export function translateDomSubtree(root: Node, language: Language) {
|
||||
const shouldTranslateTextNode = (node: Text): boolean => {
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return false;
|
||||
if (parent.closest(SKIP_PARENT_SELECTOR)) return false;
|
||||
if (parent.isContentEditable) return false;
|
||||
const value = node.nodeValue || '';
|
||||
if (!value.trim()) return false;
|
||||
if (!HAS_HAN_RE.test(value) && language !== 'zh') return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const processTextNode = (node: Text) => {
|
||||
if (!shouldTranslateTextNode(node)) return;
|
||||
const current = node.nodeValue || '';
|
||||
const stored = textNodeOriginalMap.get(node);
|
||||
if (!stored) {
|
||||
textNodeOriginalMap.set(node, current);
|
||||
} else {
|
||||
const expectedEnglish = translateText(stored, 'en');
|
||||
if (
|
||||
current !== expectedEnglish
|
||||
&& current !== stored
|
||||
&& HAS_HAN_RE.test(current)
|
||||
) {
|
||||
textNodeOriginalMap.set(node, current);
|
||||
}
|
||||
}
|
||||
const source = textNodeOriginalMap.get(node) || current;
|
||||
const next = translateText(source, language);
|
||||
if (next !== current) {
|
||||
node.nodeValue = next;
|
||||
}
|
||||
};
|
||||
|
||||
const processElementAttrs = (el: Element) => {
|
||||
if (el.matches(SKIP_PARENT_SELECTOR)) return;
|
||||
let attrMap = elementAttrOriginalMap.get(el);
|
||||
if (!attrMap) {
|
||||
attrMap = new Map<string, string>();
|
||||
elementAttrOriginalMap.set(el, attrMap);
|
||||
}
|
||||
|
||||
for (const attr of TRANSLATABLE_ATTRS) {
|
||||
const current = el.getAttribute(attr);
|
||||
if (!current || !current.trim()) continue;
|
||||
const stored = attrMap.get(attr);
|
||||
if (!stored) {
|
||||
attrMap.set(attr, current);
|
||||
} else {
|
||||
const expectedEnglish = translateText(stored, 'en');
|
||||
if (
|
||||
current !== expectedEnglish
|
||||
&& current !== stored
|
||||
&& HAS_HAN_RE.test(current)
|
||||
) {
|
||||
attrMap.set(attr, current);
|
||||
}
|
||||
}
|
||||
|
||||
const source = attrMap.get(attr) || current;
|
||||
const next = translateText(source, language);
|
||||
if (next !== current) {
|
||||
el.setAttribute(attr, next);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const walk = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
processTextNode(node as Text);
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
|
||||
const el = node as Element;
|
||||
processElementAttrs(el);
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
}
|
||||
|
||||
type I18nContextValue = {
|
||||
language: Language;
|
||||
setLanguage: (next: Language) => void;
|
||||
@@ -543,81 +925,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
const root = document.body;
|
||||
if (!root) return;
|
||||
|
||||
const shouldTranslateTextNode = (node: Text): boolean => {
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return false;
|
||||
if (parent.closest(SKIP_PARENT_SELECTOR)) return false;
|
||||
if (parent.isContentEditable) return false;
|
||||
const value = node.nodeValue || '';
|
||||
if (!value.trim()) return false;
|
||||
if (!HAS_HAN_RE.test(value) && language !== 'zh') return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const processTextNode = (node: Text) => {
|
||||
if (!shouldTranslateTextNode(node)) return;
|
||||
const current = node.nodeValue || '';
|
||||
const stored = textNodeOriginalMap.get(node);
|
||||
if (!stored) {
|
||||
textNodeOriginalMap.set(node, current);
|
||||
} else {
|
||||
const expected = translateText(stored, language);
|
||||
if (current !== expected && current !== stored) {
|
||||
textNodeOriginalMap.set(node, current);
|
||||
}
|
||||
}
|
||||
const source = textNodeOriginalMap.get(node) || current;
|
||||
const next = translateText(source, language);
|
||||
if (next !== current) {
|
||||
node.nodeValue = next;
|
||||
}
|
||||
};
|
||||
|
||||
const processElementAttrs = (el: Element) => {
|
||||
if (el.matches(SKIP_PARENT_SELECTOR)) return;
|
||||
let attrMap = elementAttrOriginalMap.get(el);
|
||||
if (!attrMap) {
|
||||
attrMap = new Map<string, string>();
|
||||
elementAttrOriginalMap.set(el, attrMap);
|
||||
}
|
||||
|
||||
for (const attr of TRANSLATABLE_ATTRS) {
|
||||
const current = el.getAttribute(attr);
|
||||
if (!current || !current.trim()) continue;
|
||||
const stored = attrMap.get(attr);
|
||||
if (!stored) {
|
||||
attrMap.set(attr, current);
|
||||
} else {
|
||||
const expected = translateText(stored, language);
|
||||
if (current !== expected && current !== stored) {
|
||||
attrMap.set(attr, current);
|
||||
}
|
||||
}
|
||||
|
||||
const source = attrMap.get(attr) || current;
|
||||
const next = translateText(source, language);
|
||||
if (next !== current) {
|
||||
el.setAttribute(attr, next);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const walk = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
processTextNode(node as Text);
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
|
||||
const el = node as Element;
|
||||
processElementAttrs(el);
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
translateDomSubtree(root, language);
|
||||
if (language !== 'en') {
|
||||
return;
|
||||
}
|
||||
@@ -625,18 +933,18 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
if (record.type === 'characterData') {
|
||||
processTextNode(record.target as Text);
|
||||
translateDomSubtree(record.target, language);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (record.type === 'attributes') {
|
||||
processElementAttrs(record.target as Element);
|
||||
translateDomSubtree(record.target, language);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (record.type === 'childList') {
|
||||
for (const node of Array.from(record.addedNodes)) {
|
||||
walk(node);
|
||||
translateDomSubtree(node, language);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -685,3 +993,14 @@ export function useI18n() {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function useOptionalI18n() {
|
||||
const value = useContext(I18nContext);
|
||||
const fallbackT = useCallback((text: string) => translateText(text, runtimeLanguage), []);
|
||||
return value ?? {
|
||||
language: runtimeLanguage,
|
||||
setLanguage: () => undefined,
|
||||
toggleLanguage: () => undefined,
|
||||
t: fallbackT,
|
||||
};
|
||||
}
|
||||
|
||||
+8654
-313
File diff suppressed because it is too large
Load Diff
+15
-4
@@ -10,11 +10,22 @@
|
||||
<body class="min-h-screen">
|
||||
<script>
|
||||
(function(){
|
||||
var t = localStorage.getItem('theme');
|
||||
if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
var mode = localStorage.getItem('theme_mode');
|
||||
var legacy = localStorage.getItem('theme');
|
||||
var preset = localStorage.getItem('theme_preset');
|
||||
var normalizedMode = mode === 'system' || mode === 'light' || mode === 'dark'
|
||||
? mode
|
||||
: (legacy === 'light' || legacy === 'dark' ? legacy : 'system');
|
||||
var normalizedPreset = preset === 'terminal' || preset === 'studio' ? preset : 'clearops';
|
||||
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var resolved = normalizedMode === 'system' ? (prefersDark ? 'dark' : 'light') : normalizedMode;
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
document.documentElement.setAttribute('data-theme-mode', normalizedMode);
|
||||
document.documentElement.setAttribute('data-theme-preset', normalizedPreset);
|
||||
if (normalizedPreset === 'terminal') {
|
||||
document.documentElement.setAttribute('data-theme-tone', 'dark');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
document.documentElement.setAttribute('data-theme-tone', resolved);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -27,6 +27,9 @@ export function resolveLoginErrorMessage(
|
||||
if (status === 403 && normalizedReason.includes('ip not allowed')) {
|
||||
return '当前 IP 不在管理白名单中';
|
||||
}
|
||||
if (status === 403 && normalizedReason.includes('admin api token')) {
|
||||
return '管理员请使用管理员 API Token 登录。';
|
||||
}
|
||||
if (status === 401 || (status === 403 && normalizedReason.includes('invalid token'))) {
|
||||
return '登录令牌无效';
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ const OPERATIONS = [
|
||||
{ title: '站点与账号', desc: '维护上游站点、账号、OAuth、API Key、余额和连接状态。' },
|
||||
{ title: '模型广场', desc: '聚合模型可用性、价格、账号覆盖、绑定令牌和路由健康度。' },
|
||||
{ title: '用户体系', desc: '普通用户拥有独立 API Key、使用日志、工作空间和可见模型范围。' },
|
||||
{ title: '审计告警', desc: '使用日志、程序日志、通知渠道和运行事件集中留痕。' },
|
||||
{ title: '审计告警', desc: '使用日志、运行日志、通知渠道和运行事件集中留痕。' },
|
||||
{ title: '工作空间', desc: '只归档最终产出,按用户隔离图片、视频、音频和工作流资产。' },
|
||||
{ title: '任务编排', desc: '后台生成、取消、查询、归档和失败原因追踪保持一致体验。' },
|
||||
];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user