From 71d192db459360ad77aa3f17d486933e757ea063 Mon Sep 17 00:00:00 2001 From: zyycn Date: Sat, 27 Jun 2026 21:26:14 +0800 Subject: [PATCH] feat: refine admin console and api keys Co-authored-by: Codex --- src/admin/accounts/routes.rs | 29 +- src/admin/keys/routes.rs | 6 +- src/admin/keys/service.rs | 77 +- src/admin/router.rs | 10 +- src/infra/identity.rs | 18 +- src/infra/schema.sql | 1 + tests/admin/accounts/import_export.rs | 96 + tests/admin/accounts/list.rs | 2 + tests/admin/accounts/mod.rs | 21 + tests/admin/accounts/testing.rs | 4 +- tests/admin/keys/authorization.rs | 25 +- tests/admin/keys/import_export.rs | 4 +- tests/admin/keys/lifecycle.rs | 10 +- tests/admin/keys/mod.rs | 2 +- tests/admin/keys/store.rs | 20 +- tests/infra/storage_schema/mod.rs | 2 +- tests/proxy/dispatch/chat_upstream/mod.rs | 18 +- tests/proxy/keys/auth.rs | 4 +- tests/proxy/openai/models_routes.rs | 12 +- tests/proxy/openai/responses_routes.rs | 10 +- {docs => web/docs}/codex-proxy-rs.pen | 0 web/docs/frontend-optimization-plan.md | 120 ++ web/package.json | 1 + web/pnpm-lock.yaml | 8 + web/src/api/modules/accounts.ts | 33 +- web/src/components/base/BaseSegmented.vue | 27 +- web/src/components/base/BaseTable/columns.ts | 165 ++ .../{BaseTable.vue => BaseTable/index.vue} | 257 +-- .../components/base/BaseTable/pagination.ts | 65 + web/src/utils/clipboard.ts | 39 + web/src/utils/date.ts | 12 + .../components/AccountConnectionTestModal.vue | 186 ++ .../components/AccountCreateModal.vue | 154 ++ .../accounts/components/AccountEditModal.vue | 102 ++ .../components/AccountIdentityCell.vue | 75 + .../components/AccountOverviewCards.vue | 92 + .../accounts/components/AccountQuotaPanel.vue | 121 ++ .../components/AccountQuotaSummaryCell.vue | 66 + .../components/AccountStatusBadge.vue | 68 + .../components/AccountTableActions.vue | 94 + .../accounts/components/AccountUsagePanel.vue | 92 + .../composables/useAccountConnectionTest.ts | 285 +++ .../accounts/composables/useAccountFilters.ts | 62 + .../composables/useAccountMutations.ts | 346 ++++ .../accounts/composables/useAccountsTable.ts | 60 + web/src/views/accounts/constants.ts | 71 + web/src/views/accounts/index.vue | 1612 ++--------------- .../api-keys/components/ApiKeyCreateModal.vue | 111 ++ .../api-keys/components/ApiKeyFilters.vue | 55 + .../components/ApiKeyIdentityCell.vue | 61 + .../api-keys/components/ApiKeyPrefixCell.vue | 31 + .../components/ApiKeyStatusToggle.vue | 27 + .../api-keys/composables/useApiKeyFilters.ts | 60 + .../composables/useApiKeyMutations.ts | 214 +++ .../api-keys/composables/useApiKeysTable.ts | 43 + web/src/views/api-keys/constants.ts | 32 + web/src/views/api-keys/index.vue | 508 +----- .../dashboard/components/EventLogCard.vue | 2 +- .../dashboard/components/RequestTrendCard.vue | 1 + .../dashboard/composables/useDashboard.ts | 10 +- web/src/views/dashboard/index.vue | 4 +- .../views/logs/components/LogDetailModal.vue | 86 + web/src/views/logs/components/LogFilters.vue | 60 + .../views/logs/components/LogLevelBadge.vue | 28 + .../logs/components/LogStatusCodeBadge.vue | 30 + .../views/logs/composables/useLogDetail.ts | 25 + .../views/logs/composables/useLogFilters.ts | 64 + .../views/logs/composables/useLogsTable.ts | 91 + web/src/views/logs/constants.ts | 70 + web/src/views/logs/index.vue | 401 +--- web/src/views/settings/index.vue | 17 +- 71 files changed, 3995 insertions(+), 2620 deletions(-) rename {docs => web/docs}/codex-proxy-rs.pen (100%) create mode 100644 web/docs/frontend-optimization-plan.md create mode 100644 web/src/components/base/BaseTable/columns.ts rename web/src/components/base/{BaseTable.vue => BaseTable/index.vue} (71%) create mode 100644 web/src/components/base/BaseTable/pagination.ts create mode 100644 web/src/utils/clipboard.ts create mode 100644 web/src/utils/date.ts create mode 100644 web/src/views/accounts/components/AccountConnectionTestModal.vue create mode 100644 web/src/views/accounts/components/AccountCreateModal.vue create mode 100644 web/src/views/accounts/components/AccountEditModal.vue create mode 100644 web/src/views/accounts/components/AccountIdentityCell.vue create mode 100644 web/src/views/accounts/components/AccountOverviewCards.vue create mode 100644 web/src/views/accounts/components/AccountQuotaPanel.vue create mode 100644 web/src/views/accounts/components/AccountQuotaSummaryCell.vue create mode 100644 web/src/views/accounts/components/AccountStatusBadge.vue create mode 100644 web/src/views/accounts/components/AccountTableActions.vue create mode 100644 web/src/views/accounts/components/AccountUsagePanel.vue create mode 100644 web/src/views/accounts/composables/useAccountConnectionTest.ts create mode 100644 web/src/views/accounts/composables/useAccountFilters.ts create mode 100644 web/src/views/accounts/composables/useAccountMutations.ts create mode 100644 web/src/views/accounts/composables/useAccountsTable.ts create mode 100644 web/src/views/accounts/constants.ts create mode 100644 web/src/views/api-keys/components/ApiKeyCreateModal.vue create mode 100644 web/src/views/api-keys/components/ApiKeyFilters.vue create mode 100644 web/src/views/api-keys/components/ApiKeyIdentityCell.vue create mode 100644 web/src/views/api-keys/components/ApiKeyPrefixCell.vue create mode 100644 web/src/views/api-keys/components/ApiKeyStatusToggle.vue create mode 100644 web/src/views/api-keys/composables/useApiKeyFilters.ts create mode 100644 web/src/views/api-keys/composables/useApiKeyMutations.ts create mode 100644 web/src/views/api-keys/composables/useApiKeysTable.ts create mode 100644 web/src/views/api-keys/constants.ts create mode 100644 web/src/views/logs/components/LogDetailModal.vue create mode 100644 web/src/views/logs/components/LogFilters.vue create mode 100644 web/src/views/logs/components/LogLevelBadge.vue create mode 100644 web/src/views/logs/components/LogStatusCodeBadge.vue create mode 100644 web/src/views/logs/composables/useLogDetail.ts create mode 100644 web/src/views/logs/composables/useLogFilters.ts create mode 100644 web/src/views/logs/composables/useLogsTable.ts create mode 100644 web/src/views/logs/constants.ts diff --git a/src/admin/accounts/routes.rs b/src/admin/accounts/routes.rs index 6841d7893..f83d6004a 100644 --- a/src/admin/accounts/routes.rs +++ b/src/admin/accounts/routes.rs @@ -2,7 +2,7 @@ use axum::{ body::{Body, Bytes}, - extract::{Path, Query, State}, + extract::{Query, State}, http::{ header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE}, HeaderMap, StatusCode, @@ -518,9 +518,8 @@ pub(crate) async fn accounts( require_admin_session(&state, &headers, &request_id).await?; let limit = clamp_limit(params.page_size.or(params.limit).unwrap_or(50)); let use_numbered_page = params.page.is_some() || params.page_size.is_some(); - let search = params.search.clone(); let stats = account_list_stats(&state).await; - let summary = account_summary_data(&state, &stats, search.as_deref()).await; + let summary = account_summary_data(&state, &stats).await; if use_numbered_page { return match state @@ -856,15 +855,16 @@ pub(crate) async fn import_accounts( } } -/// `POST /api/admin/accounts/:id/test` +/// `POST /api/admin/accounts/test?id=...` pub(crate) async fn test_account_connection( State(state): State, - Path(account_id): Path, + Query(query): Query, Extension(request_id): Extension, headers: HeaderMap, body: Bytes, ) -> Result { let request_id = request_id.as_str().to_string(); + let account_id = query.id; let payload = parse_account_test_request(&body, &request_id)?; require_admin_session(&state, &headers, &request_id).await?; @@ -899,14 +899,15 @@ pub(crate) async fn test_account_connection( }) } -/// `GET /api/admin/accounts/:id/test-models` +/// `GET /api/admin/accounts/models?id=...` pub(crate) async fn account_test_models( State(state): State, - Path(account_id): Path, + Query(query): Query, Extension(request_id): Extension, headers: HeaderMap, ) -> Result { let request_id = request_id.as_str().to_string(); + let account_id = query.id; require_admin_session(&state, &headers, &request_id).await?; match state .services @@ -1044,9 +1045,8 @@ async fn account_list_stats(state: &AppState) -> AccountListStats { async fn account_summary_data( state: &AppState, stats: &AccountListStats, - search: Option<&str>, ) -> AdminAccountSummaryData { - let accounts = list_all_account_metadata(state, search).await; + let accounts = list_all_account_metadata(state).await; let total = accounts.len() as u64; let active = accounts .iter() @@ -1074,21 +1074,14 @@ async fn account_summary_data( } } -async fn list_all_account_metadata( - state: &AppState, - search: Option<&str>, -) -> Vec { +async fn list_all_account_metadata(state: &AppState) -> Vec { let mut page = 1; let mut accounts = Vec::new(); loop { let Ok(result) = state .services .admin_accounts - .list_page( - page, - ACCOUNT_STATS_PAGE_LIMIT, - search.map(ToString::to_string), - ) + .list_page(page, ACCOUNT_STATS_PAGE_LIMIT, None) .await else { return Vec::new(); diff --git a/src/admin/keys/routes.rs b/src/admin/keys/routes.rs index c0374cd6b..f959b1192 100644 --- a/src/admin/keys/routes.rs +++ b/src/admin/keys/routes.rs @@ -59,6 +59,7 @@ pub struct ClientApiKeyData { pub name: String, pub label: Option, pub prefix: String, + pub key: String, pub enabled: bool, pub created_at: String, pub created_at_display: String, @@ -73,12 +74,12 @@ pub struct CreatedClientApiKeyData { pub name: String, pub label: Option, pub prefix: String, + pub key: String, pub enabled: bool, pub created_at: String, pub created_at_display: String, pub last_used_at: Option, pub last_used_at_display: String, - pub plaintext: String, } #[derive(Debug, Serialize)] @@ -117,6 +118,7 @@ impl From for ClientApiKeyData { name: k.name, label: k.label, prefix: k.prefix, + key: k.key, enabled: k.enabled, created_at_display: china_relative_time_str(Some(&k.created_at)), created_at: china_rfc3339_str(&k.created_at), @@ -147,12 +149,12 @@ impl From for CreatedClientApiKeyData { name: k.name, label: k.label, prefix: k.prefix, + key: k.key, enabled: k.enabled, created_at_display: china_relative_time_str(Some(&k.created_at)), created_at: china_rfc3339_str(&k.created_at), last_used_at_display: china_relative_time_str(k.last_used_at.as_deref()), last_used_at: k.last_used_at.as_deref().map(china_rfc3339_str), - plaintext: k.plaintext, } } } diff --git a/src/admin/keys/service.rs b/src/admin/keys/service.rs index 4d16d9130..abac9b0a5 100644 --- a/src/admin/keys/service.rs +++ b/src/admin/keys/service.rs @@ -4,11 +4,13 @@ use std::sync::Arc; use async_trait::async_trait; use chrono::Utc; +use secrecy::{ExposeSecret, SecretString}; use sqlx::{Row, SqlitePool}; use thiserror::Error; use uuid::Uuid; use crate::infra::{ + crypto::{CryptoError, SecretBox}, identity::{ApiKeyHasher, AuthError}, json::{decode_cursor, encode_cursor, Page}, }; @@ -56,7 +58,7 @@ impl ClientKeyService { /// 验证客户端 API Key。 pub async fn verify(&self, plaintext: &str) -> ClientKeyStoreResult { - if !plaintext.starts_with("cpr_") { + if !plaintext.starts_with("sk_") { return Ok(false); } self.store.verify_and_touch(plaintext).await @@ -221,6 +223,7 @@ pub struct AdminStoredClientApiKey { pub name: String, pub label: Option, pub prefix: String, + pub key: String, pub enabled: bool, pub created_at: String, pub last_used_at: Option, @@ -233,10 +236,10 @@ pub struct AdminCreatedClientApiKey { pub name: String, pub label: Option, pub prefix: String, + pub key: String, pub enabled: bool, pub created_at: String, pub last_used_at: Option, - pub plaintext: String, } /// 客户端 API Key 状态更新结果。 @@ -293,6 +296,7 @@ impl From for AdminStoredClientApiKey { name: key.name, label: key.label, prefix: key.prefix, + key: key.key, enabled: key.enabled, created_at: key.created_at, last_used_at: key.last_used_at, @@ -307,10 +311,10 @@ impl From for AdminCreatedClientApiKey { name: key.name, label: key.label, prefix: key.prefix, + key: key.key, enabled: key.enabled, created_at: key.created_at, last_used_at: key.last_used_at, - plaintext: key.plaintext, } } } @@ -328,6 +332,9 @@ pub enum SqliteClientKeyStoreError { /// API Key 验证错误。 #[error("sqlite client key auth error: {0}")] Auth(#[from] AuthError), + /// API Key 密文处理错误。 + #[error("sqlite client key crypto error: {0}")] + Crypto(#[from] CryptoError), /// 分页游标无效。 #[error("invalid client key pagination cursor")] InvalidCursor, @@ -344,6 +351,8 @@ pub struct StoredClientApiKey { pub label: Option, /// 明文 API Key 的短前缀。 pub prefix: String, + /// 管理端可复制的完整 API Key。 + pub key: String, /// 是否允许用于 `/v1` 认证。 pub enabled: bool, /// 创建时间。 @@ -363,14 +372,14 @@ pub struct CreatedClientApiKey { pub label: Option, /// 明文 API Key 的短前缀。 pub prefix: String, + /// 管理端可复制的完整 API Key。 + pub key: String, /// 是否允许用于 `/v1` 认证。 pub enabled: bool, /// 创建时间。 pub created_at: String, /// 最近一次成功使用时间。 pub last_used_at: Option, - /// 仅返回一次的明文 API Key。 - pub plaintext: String, } /// SQLite 客户端 API Key 存储。 @@ -378,12 +387,18 @@ pub struct CreatedClientApiKey { pub struct SqliteClientKeyStore { pool: SqlitePool, hasher: ApiKeyHasher, + secret_box: SecretBox, } impl SqliteClientKeyStore { /// 构造存储适配器。 pub fn new(pool: SqlitePool, hasher: ApiKeyHasher) -> Self { - Self { pool, hasher } + let secret_box = hasher.secret_box(); + Self { + pool, + hasher, + secret_box, + } } /// 暴露底层连接池,供集成测试和运行时组合层复用。 @@ -399,13 +414,17 @@ impl SqliteClientKeyStore { let generated = self.hasher.generate_client_api_key(name); let id = format!("key_{}", Uuid::new_v4().simple()); let now = Utc::now().to_rfc3339(); + let key_cipher = self + .secret_box + .encrypt(&SecretString::new(generated.key.clone().into()))?; sqlx::query( - "insert into client_api_keys (id, name, label, prefix, key_hash, enabled, created_at, last_used_at) values (?, ?, null, ?, ?, 1, ?, null)", + "insert into client_api_keys (id, name, label, prefix, key_hash, key_cipher, enabled, created_at, last_used_at) values (?, ?, null, ?, ?, ?, 1, ?, null)", ) .bind(&id) .bind(name) .bind(&generated.prefix) .bind(&generated.key_hash) + .bind(&key_cipher) .bind(&now) .execute(&self.pool) .await?; @@ -415,10 +434,10 @@ impl SqliteClientKeyStore { name: name.to_string(), label: None, prefix: generated.prefix, + key: generated.key, enabled: true, created_at: now, last_used_at: None, - plaintext: generated.plaintext, }) } @@ -433,7 +452,7 @@ impl SqliteClientKeyStore { let (created_at, id) = decode_cursor(&cursor).ok_or(SqliteClientKeyStoreError::InvalidCursor)?; sqlx::query( - "select id, name, label, prefix, enabled, created_at, last_used_at from client_api_keys where created_at < ? or (created_at = ? and id < ?) order by created_at desc, id desc limit ?", + "select id, name, label, prefix, key_cipher, enabled, created_at, last_used_at from client_api_keys where created_at < ? or (created_at = ? and id < ?) order by created_at desc, id desc limit ?", ) .bind(&created_at) .bind(created_at) @@ -443,7 +462,7 @@ impl SqliteClientKeyStore { .await? } else { sqlx::query( - "select id, name, label, prefix, enabled, created_at, last_used_at from client_api_keys order by created_at desc, id desc limit ?", + "select id, name, label, prefix, key_cipher, enabled, created_at, last_used_at from client_api_keys order by created_at desc, id desc limit ?", ) .bind(fetch_limit) .fetch_all(&self.pool) @@ -455,8 +474,8 @@ impl SqliteClientKeyStore { let items = rows .into_iter() .take(take_count) - .map(|row| key_from_row(&row)) - .collect::>(); + .map(|row| self.key_from_row(&row)) + .collect::, _>>()?; let next_cursor = if has_next { items .last() @@ -474,13 +493,13 @@ impl SqliteClientKeyStore { id: &str, ) -> Result, SqliteClientKeyStoreError> { let row = sqlx::query( - "select id, name, label, prefix, enabled, created_at, last_used_at from client_api_keys where id = ?", + "select id, name, label, prefix, key_cipher, enabled, created_at, last_used_at from client_api_keys where id = ?", ) .bind(id) .fetch_optional(&self.pool) .await?; - Ok(row.map(|row| key_from_row(&row))) + row.map(|row| self.key_from_row(&row)).transpose() } /// 更新客户端 API Key 启用状态。 @@ -525,6 +544,24 @@ impl SqliteClientKeyStore { Ok(result.rows_affected() > 0) } + + fn key_from_row( + &self, + row: &sqlx::sqlite::SqliteRow, + ) -> Result { + let key_cipher = row.get::("key_cipher"); + let key = self.secret_box.decrypt(&key_cipher)?; + Ok(StoredClientApiKey { + id: row.get("id"), + name: row.get("name"), + label: row.get("label"), + prefix: row.get("prefix"), + key: key.expose_secret().to_string(), + enabled: row.get::("enabled") != 0, + created_at: row.get("created_at"), + last_used_at: row.get("last_used_at"), + }) + } } #[async_trait] @@ -564,18 +601,6 @@ async fn verify_and_touch( Ok(false) } -fn key_from_row(row: &sqlx::sqlite::SqliteRow) -> StoredClientApiKey { - StoredClientApiKey { - id: row.get("id"), - name: row.get("name"), - label: row.get("label"), - prefix: row.get("prefix"), - enabled: row.get::("enabled") != 0, - created_at: row.get("created_at"), - last_used_at: row.get("last_used_at"), - } -} - fn map_client_key_store_error(error: SqliteClientKeyStoreError) -> ClientKeyStoreError { ClientKeyStoreError::OperationFailed { message: error.to_string(), diff --git a/src/admin/router.rs b/src/admin/router.rs index 2a2db1d38..fde97cc3d 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -47,14 +47,8 @@ pub fn router() -> Router { "/api/admin/accounts/health-check", post(health_check_accounts), ) - .route( - "/api/admin/accounts/{id}/test", - post(test_account_connection), - ) - .route( - "/api/admin/accounts/{id}/test-models", - get(account_test_models), - ) + .route("/api/admin/accounts/test", post(test_account_connection)) + .route("/api/admin/accounts/models", get(account_test_models)) .route("/api/admin/accounts/delete", post(batch_delete_accounts)) .route("/api/admin/accounts/update", post(update_account)) .route("/api/admin/accounts/refresh", post(refresh_account)) diff --git a/src/infra/identity.rs b/src/infra/identity.rs index 9d0d3c084..378d655cc 100644 --- a/src/infra/identity.rs +++ b/src/infra/identity.rs @@ -12,6 +12,8 @@ use rand::Rng; use sha2::Sha256; use thiserror::Error; +use crate::infra::crypto::SecretBox; + // --------------------------------------------------------------------------- // AuthError / AuthResult // --------------------------------------------------------------------------- @@ -72,7 +74,7 @@ type HmacSha256 = Hmac; #[derive(Debug, Clone)] pub struct GeneratedClientApiKey { /// 仅显示一次的明文密钥。 - pub plaintext: String, + pub key: String, /// 前缀缓存。 pub prefix: String, /// 持久化哈希。 @@ -124,12 +126,12 @@ impl ApiKeyHasher { pub fn generate_client_api_key(&self, _name: &str) -> GeneratedClientApiKey { let mut bytes = [0u8; 32]; rand::rng().fill_bytes(&mut bytes); - // cpr_ 只用于客户端调用 /v1,不能复用成管理员登录密码。 - let plaintext = format!("cpr_{}", URL_SAFE_NO_PAD.encode(bytes)); + // sk_ 只用于客户端调用 /v1,不能复用成管理员登录密码。 + let plaintext = format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes)); let prefix = plaintext.chars().take(12).collect::(); let key_hash = self.hash_client_api_key(&plaintext); GeneratedClientApiKey { - plaintext, + key: plaintext, prefix, key_hash, } @@ -144,12 +146,12 @@ impl ApiKeyHasher { /// 校验 API Key 是否有效。 pub fn verify_client_api_key(&self, plaintext: &str, key_hash: &str) -> AuthResult { - let Some(suffix) = plaintext.strip_prefix("cpr_") else { + let Some(suffix) = plaintext.strip_prefix("sk_") else { return Ok(false); }; let decoded_suffix = URL_SAFE_NO_PAD.decode(suffix)?; - let canonical = format!("cpr_{}", URL_SAFE_NO_PAD.encode(decoded_suffix)); + let canonical = format!("sk_{}", URL_SAFE_NO_PAD.encode(decoded_suffix)); let expected = URL_SAFE_NO_PAD.decode(key_hash)?; let mut mac = self.new_mac(); mac.update(canonical.as_bytes()); @@ -162,4 +164,8 @@ impl ApiKeyHasher { Err(error) => unreachable!("HMAC accepts any key size: {error}"), } } + + pub(crate) fn secret_box(&self) -> SecretBox { + SecretBox::new(self.pepper) + } } diff --git a/src/infra/schema.sql b/src/infra/schema.sql index 572a14e9f..4f171b17c 100644 --- a/src/infra/schema.sql +++ b/src/infra/schema.sql @@ -39,6 +39,7 @@ create table if not exists client_api_keys ( name text not null, prefix text not null, key_hash text not null, + key_cipher text not null, label text, enabled integer not null default 1 check (enabled in (0, 1)), created_at text not null, diff --git a/tests/admin/accounts/import_export.rs b/tests/admin/accounts/import_export.rs index da7e79f3a..8a6335175 100644 --- a/tests/admin/accounts/import_export.rs +++ b/tests/admin/accounts/import_export.rs @@ -259,6 +259,102 @@ async fn admin_accounts_import_should_fetch_wham_usage_for_current_openai_token_ assert_eq!(stored.status, AccountStatus::Active); } +#[tokio::test] +async fn admin_accounts_import_should_complete_chatgpt_account_id_from_refresh_token() { + let server = MockServer::start().await; + let access_token = test_jwt_with_exp( + None, + Some("rt-import-user"), + Some("rt-import@example.com"), + None, + 4_102_444_800, + ); + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token, + "refresh_token": "rt-import-rotated" + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/usage")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "account_id": "wham-account-from-rt", + "user_id": "rt-import-user", + "email": "rt-import@example.com", + "plan_type": "plus", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 0, + "reset_at": 1_800_000_000, + "limit_window_seconds": 18_000 + } + } + }))) + .mount(&server) + .await; + let (app, _state, pool, _dir, secret_box) = + admin_accounts_test_app_with_api_base_url_and_oauth_token_endpoint( + "admin-accounts-import-rt-complete.sqlite", + 125, + format!("{}/backend-api", server.uri()), + format!("{}/oauth/token", server.uri()), + ) + .await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/import") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_accounts_import_rt_complete") + .body(Body::from( + json!({ + "accounts": [{ "refreshToken": "rt-import-source" }] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json(response).await; + let stored = SqliteAccountStore::new(pool, secret_box) + .list_metadata_page(1, 10, None) + .await + .unwrap() + .items + .into_iter() + .next() + .unwrap(); + let list = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts?page=1&pageSize=10") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_accounts_import_rt_list") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let list_body = response_json(list).await; + + assert_eq!(body["data"]["imported"], 1); + assert_eq!(stored.account_id.as_deref(), Some("wham-account-from-rt")); + assert_eq!( + list_body["data"]["items"][0]["accountId"], + "wham-account-from-rt" + ); +} + #[tokio::test] async fn admin_accounts_import_should_fetch_usage_to_complete_missing_plan_and_quota() { let server = MockServer::start().await; diff --git a/tests/admin/accounts/list.rs b/tests/admin/accounts/list.rs index a948dbcb7..21079084b 100644 --- a/tests/admin/accounts/list.rs +++ b/tests/admin/accounts/list.rs @@ -255,4 +255,6 @@ async fn admin_accounts_list_should_return_numbered_page_with_search_total() { assert_eq!(body["data"]["page"]["pageSize"], 1); assert_eq!(body["data"]["page"]["total"], 2); assert_eq!(body["data"]["page"]["totalPages"], 2); + assert_eq!(body["data"]["summary"]["total"], 3); + assert_eq!(body["data"]["summary"]["active"], 3); } diff --git a/tests/admin/accounts/mod.rs b/tests/admin/accounts/mod.rs index 4e23700a9..78e507e19 100644 --- a/tests/admin/accounts/mod.rs +++ b/tests/admin/accounts/mod.rs @@ -159,6 +159,27 @@ async fn admin_accounts_test_app_with_oauth_token_endpoint( .await } +async fn admin_accounts_test_app_with_api_base_url_and_oauth_token_endpoint( + db_name: &str, + key_byte: u8, + api_base_url: String, + oauth_token_endpoint: String, +) -> ( + axum::Router, + AppState, + SqlitePool, + tempfile::TempDir, + SecretBox, +) { + admin_accounts_test_app_with_overrides( + db_name, + key_byte, + api_base_url, + Some(oauth_token_endpoint), + ) + .await +} + async fn admin_accounts_test_app_with_overrides( db_name: &str, key_byte: u8, diff --git a/tests/admin/accounts/testing.rs b/tests/admin/accounts/testing.rs index e721f68b7..e7cf93a5c 100644 --- a/tests/admin/accounts/testing.rs +++ b/tests/admin/accounts/testing.rs @@ -65,7 +65,7 @@ async fn account_test_models_should_return_upstream_models_only() { .oneshot( Request::builder() .method("GET") - .uri("/api/admin/accounts/acct_test/test-models") + .uri("/api/admin/accounts/models?id=acct_test") .header("cookie", "cpr_admin_session=session_1") .header("x-request-id", "req_account_test_models") .body(Body::empty()) @@ -137,7 +137,7 @@ async fn account_test_stream_should_translate_upstream_responses_sse() { .oneshot( Request::builder() .method("POST") - .uri("/api/admin/accounts/acct_test/test") + .uri("/api/admin/accounts/test?id=acct_test") .header("content-type", "application/json") .header("cookie", "cpr_admin_session=session_1") .header("x-request-id", "req_account_test_stream") diff --git a/tests/admin/keys/authorization.rs b/tests/admin/keys/authorization.rs index 9b3b5ff45..4f0dc89d0 100644 --- a/tests/admin/keys/authorization.rs +++ b/tests/admin/keys/authorization.rs @@ -11,7 +11,7 @@ async fn admin_client_keys_route_should_create_list_and_authorize_v1_requests() Request::builder() .method("GET") .uri("/v1/models") - .header("authorization", "Bearer cpr_not_stored") + .header("authorization", "Bearer sk_not_stored") .body(Body::empty()) .unwrap(), ) @@ -35,14 +35,11 @@ async fn admin_client_keys_route_should_create_list_and_authorize_v1_requests() .unwrap(); assert_eq!(create_response.status(), StatusCode::OK); let create_body = response_json(create_response).await; - assert!(create_body["data"]["plaintext"] + assert!(create_body["data"]["key"] .as_str() - .is_some_and(|p| p.starts_with("cpr_"))); + .is_some_and(|p| p.starts_with("sk_"))); - let plaintext = create_body["data"]["plaintext"] - .as_str() - .unwrap() - .to_string(); + let key = create_body["data"]["key"].as_str().unwrap().to_string(); let list_response = app .clone() @@ -58,14 +55,14 @@ async fn admin_client_keys_route_should_create_list_and_authorize_v1_requests() .unwrap(); let list_body = response_json(list_response).await; assert_eq!(list_body["data"]["items"][0]["name"], "cursor"); - assert!(list_body["data"]["items"][0].get("plaintext").is_none()); + assert_eq!(list_body["data"]["items"][0]["key"], key); let models_response = app .oneshot( Request::builder() .method("GET") .uri("/v1/models") - .header("authorization", format!("Bearer {plaintext}")) + .header("authorization", format!("Bearer {key}")) .body(Body::empty()) .unwrap(), ) @@ -78,7 +75,7 @@ async fn admin_client_keys_route_should_create_list_and_authorize_v1_requests() #[tokio::test] async fn admin_client_keys_status_should_disable_and_enable_authorization() { let (app, _dir) = admin_client_key_test_app("admin-client-key-status.sqlite").await; - let (key_id, plaintext) = create_admin_client_key(&app, "status-key").await; + let (key_id, key) = create_admin_client_key(&app, "status-key").await; let disabled = app .clone() @@ -104,7 +101,7 @@ async fn admin_client_keys_status_should_disable_and_enable_authorization() { Request::builder() .method("GET") .uri("/v1/models") - .header("authorization", format!("Bearer {plaintext}")) + .header("authorization", format!("Bearer {key}")) .body(Body::empty()) .unwrap(), ) @@ -135,7 +132,7 @@ async fn admin_client_keys_status_should_disable_and_enable_authorization() { Request::builder() .method("GET") .uri("/v1/models") - .header("authorization", format!("Bearer {plaintext}")) + .header("authorization", format!("Bearer {key}")) .body(Body::empty()) .unwrap(), ) @@ -148,7 +145,7 @@ async fn admin_client_keys_status_should_disable_and_enable_authorization() { #[tokio::test] async fn admin_client_keys_delete_should_remove_authorization() { let (app, _dir) = admin_client_key_test_app("admin-client-key-delete.sqlite").await; - let (key_id, plaintext) = create_admin_client_key(&app, "delete-key").await; + let (key_id, key) = create_admin_client_key(&app, "delete-key").await; let deleted = app .clone() @@ -171,7 +168,7 @@ async fn admin_client_keys_delete_should_remove_authorization() { Request::builder() .method("GET") .uri("/v1/models") - .header("authorization", format!("Bearer {plaintext}")) + .header("authorization", format!("Bearer {key}")) .body(Body::empty()) .unwrap(), ) diff --git a/tests/admin/keys/import_export.rs b/tests/admin/keys/import_export.rs index 5ad906eda..3ca0410f4 100644 --- a/tests/admin/keys/import_export.rs +++ b/tests/admin/keys/import_export.rs @@ -3,7 +3,7 @@ use super::*; #[tokio::test] async fn admin_client_keys_export_should_return_metadata_without_secret_material() { let (app, _dir) = admin_client_key_test_app("admin-client-key-export.sqlite").await; - let (key_id, _plaintext) = create_admin_client_key(&app, "export-key").await; + let (key_id, _key) = create_admin_client_key(&app, "export-key").await; let response = app .oneshot( @@ -21,5 +21,5 @@ async fn admin_client_keys_export_should_return_metadata_without_secret_material let body = response_json(response).await; assert_eq!(body["data"]["apiKeys"][0]["id"], key_id); assert_eq!(body["data"]["apiKeys"][0]["name"], "export-key"); - assert!(body["data"]["apiKeys"][0].get("plaintext").is_none()); + assert!(body["data"]["apiKeys"][0].get("key").is_none()); } diff --git a/tests/admin/keys/lifecycle.rs b/tests/admin/keys/lifecycle.rs index 236dbc97b..339d93867 100644 --- a/tests/admin/keys/lifecycle.rs +++ b/tests/admin/keys/lifecycle.rs @@ -3,7 +3,7 @@ use super::*; #[tokio::test] async fn admin_client_keys_label_should_update_clear_and_validate_label() { let (app, _dir) = admin_client_key_test_app("admin-client-key-label.sqlite").await; - let (key_id, _plaintext) = create_admin_client_key(&app, "label-key").await; + let (key_id, _key) = create_admin_client_key(&app, "label-key").await; let renamed = app .clone() @@ -61,8 +61,8 @@ async fn admin_client_keys_label_should_update_clear_and_validate_label() { #[tokio::test] async fn admin_client_keys_batch_delete_should_remove_found_keys_and_report_missing_ids() { let (app, _dir) = admin_client_key_test_app("admin-client-key-delete.sqlite").await; - let (key_a, plaintext_a) = create_admin_client_key(&app, "batch-a").await; - let (key_b, plaintext_b) = create_admin_client_key(&app, "batch-b").await; + let (key_a, api_key_a) = create_admin_client_key(&app, "batch-a").await; + let (key_b, api_key_b) = create_admin_client_key(&app, "batch-b").await; let response = app .clone() @@ -84,14 +84,14 @@ async fn admin_client_keys_batch_delete_should_remove_found_keys_and_report_miss assert_eq!(body["data"]["deleted"], 2); assert_eq!(body["data"]["notFound"], json!(["ghost"])); - for plaintext in [plaintext_a, plaintext_b] { + for api_key in [api_key_a, api_key_b] { let rejected = app .clone() .oneshot( Request::builder() .method("GET") .uri("/v1/models") - .header("authorization", format!("Bearer {plaintext}")) + .header("authorization", format!("Bearer {api_key}")) .body(Body::empty()) .unwrap(), ) diff --git a/tests/admin/keys/mod.rs b/tests/admin/keys/mod.rs index c2c332787..78a7123ac 100644 --- a/tests/admin/keys/mod.rs +++ b/tests/admin/keys/mod.rs @@ -79,7 +79,7 @@ async fn create_admin_client_key(app: &axum::Router, name: &str) -> (String, Str let body = response_json(response).await; ( body["data"]["id"].as_str().unwrap().to_string(), - body["data"]["plaintext"].as_str().unwrap().to_string(), + body["data"]["key"].as_str().unwrap().to_string(), ) } diff --git a/tests/admin/keys/store.rs b/tests/admin/keys/store.rs index de93c1859..32203fa6a 100644 --- a/tests/admin/keys/store.rs +++ b/tests/admin/keys/store.rs @@ -9,22 +9,32 @@ async fn client_key_store_should_create_list_disable_enable_and_delete_keys() { let created = store.create("cursor").await.unwrap(); assert_eq!(created.name, "cursor"); - assert!(created.plaintext.starts_with("cpr_")); - assert!(store.verify_and_touch(&created.plaintext).await.unwrap()); + assert!(created.key.starts_with("sk_")); + + let key_cipher: (String,) = + sqlx::query_as("select key_cipher from client_api_keys where id = ?") + .bind(&created.id) + .fetch_one(store.pool()) + .await + .unwrap(); + assert_ne!(key_cipher.0, created.key); + + assert!(store.verify_and_touch(&created.key).await.unwrap()); let first_page = store.list(None, 10).await.unwrap(); assert_eq!(first_page.items.len(), 1); assert_eq!(first_page.items[0].name, "cursor"); + assert_eq!(first_page.items[0].key, created.key); assert!(first_page.items[0].last_used_at.is_some()); assert!(store.set_enabled(&created.id, false).await.unwrap()); - assert!(!store.verify_and_touch(&created.plaintext).await.unwrap()); + assert!(!store.verify_and_touch(&created.key).await.unwrap()); assert!(store.set_enabled(&created.id, true).await.unwrap()); - assert!(store.verify_and_touch(&created.plaintext).await.unwrap()); + assert!(store.verify_and_touch(&created.key).await.unwrap()); assert!(store.delete(&created.id).await.unwrap()); - assert!(!store.verify_and_touch(&created.plaintext).await.unwrap()); + assert!(!store.verify_and_touch(&created.key).await.unwrap()); } #[tokio::test] diff --git a/tests/infra/storage_schema/mod.rs b/tests/infra/storage_schema/mod.rs index fee5cbacb..08718eacf 100644 --- a/tests/infra/storage_schema/mod.rs +++ b/tests/infra/storage_schema/mod.rs @@ -265,7 +265,7 @@ async fn sqlite_schema_should_reject_non_boolean_flags() { let pool = connect_sqlite(&url).await.unwrap(); let api_key_result = sqlx::query( - "insert into client_api_keys (id, name, prefix, key_hash, enabled, created_at) values ('key_bad', 'bad', 'cpr_x', 'hash', 2, '2026-06-14T00:00:00Z')", + "insert into client_api_keys (id, name, prefix, key_hash, key_cipher, enabled, created_at) values ('key_bad', 'bad', 'sk_x', 'hash', 'cipher', 2, '2026-06-14T00:00:00Z')", ) .execute(&pool) .await; diff --git a/tests/proxy/dispatch/chat_upstream/mod.rs b/tests/proxy/dispatch/chat_upstream/mod.rs index e6de2fff4..baad3e783 100644 --- a/tests/proxy/dispatch/chat_upstream/mod.rs +++ b/tests/proxy/dispatch/chat_upstream/mod.rs @@ -590,19 +590,11 @@ async fn test_app_with_two_accounts( } async fn insert_client_api_key(pool: &SqlitePool, hasher: &ApiKeyHasher) -> String { - let generated = hasher.generate_client_api_key("test"); - sqlx::query( - "insert into client_api_keys (id, name, prefix, key_hash, enabled, created_at) values (?, ?, ?, ?, 1, ?)", - ) - .bind("key_test") - .bind("test") - .bind(&generated.prefix) - .bind(&generated.key_hash) - .bind("2026-06-18T00:00:00Z") - .execute(pool) - .await - .unwrap(); - generated.plaintext + SqliteClientKeyStore::new(pool.clone(), hasher.clone()) + .create("test") + .await + .unwrap() + .key } async fn seed_openai_admin_session(pool: &SqlitePool, session_id: &str) { diff --git a/tests/proxy/keys/auth.rs b/tests/proxy/keys/auth.rs index 8753ce835..e40071bde 100644 --- a/tests/proxy/keys/auth.rs +++ b/tests/proxy/keys/auth.rs @@ -4,10 +4,10 @@ use codex_proxy_rs::infra::identity::ApiKeyHasher; fn client_api_key_has_proxy_prefix_and_verifies_against_hash() { let hasher = ApiKeyHasher::new([9u8; 32]); let generated = hasher.generate_client_api_key("cursor"); - assert!(generated.plaintext.starts_with("cpr_")); + assert!(generated.key.starts_with("sk_")); assert_eq!(generated.prefix.len(), 12); assert!(hasher - .verify_client_api_key(&generated.plaintext, &generated.key_hash) + .verify_client_api_key(&generated.key, &generated.key_hash) .unwrap()); assert!(!hasher .verify_client_api_key("wrong", &generated.key_hash) diff --git a/tests/proxy/openai/models_routes.rs b/tests/proxy/openai/models_routes.rs index b042ba46e..2e9e62fbc 100644 --- a/tests/proxy/openai/models_routes.rs +++ b/tests/proxy/openai/models_routes.rs @@ -57,7 +57,7 @@ async fn models_route_should_reject_unknown_client_api_key() { Request::builder() .method("GET") .uri("/v1/models") - .header("authorization", "Bearer cpr_not_stored") + .header("authorization", "Bearer sk_not_stored") .body(Body::empty()) .unwrap(), ) @@ -109,11 +109,11 @@ async fn models_route_should_accept_stored_client_api_key() { } async fn insert_client_api_key(pool: &SqlitePool, hasher: &ApiKeyHasher) -> String { - let generated = hasher.generate_client_api_key("test"); - sqlx::query("insert into client_api_keys (id, name, prefix, key_hash, enabled, created_at) values (?, ?, ?, ?, 1, ?)") - .bind("key_test").bind("test").bind(&generated.prefix).bind(&generated.key_hash) - .bind("2026-06-18T00:00:00Z").execute(pool).await.unwrap(); - generated.plaintext + SqliteClientKeyStore::new(pool.clone(), hasher.clone()) + .create("test") + .await + .unwrap() + .key } fn test_config(database_url: String) -> AppConfig { diff --git a/tests/proxy/openai/responses_routes.rs b/tests/proxy/openai/responses_routes.rs index 24630e9a9..e83d0c677 100644 --- a/tests/proxy/openai/responses_routes.rs +++ b/tests/proxy/openai/responses_routes.rs @@ -167,11 +167,11 @@ async fn test_app_with_client_api_key() -> (axum::Router, String, tempfile::Temp } async fn insert_client_api_key(pool: &SqlitePool, hasher: &ApiKeyHasher) -> String { - let generated = hasher.generate_client_api_key("test"); - sqlx::query("insert into client_api_keys (id, name, prefix, key_hash, enabled, created_at) values (?, ?, ?, ?, 1, ?)") - .bind("key_test").bind("test").bind(&generated.prefix).bind(&generated.key_hash) - .bind("2026-06-18T00:00:00Z").execute(pool).await.unwrap(); - generated.plaintext + SqliteClientKeyStore::new(pool.clone(), hasher.clone()) + .create("test") + .await + .unwrap() + .key } async fn response_json(response: axum::response::Response) -> Value { diff --git a/docs/codex-proxy-rs.pen b/web/docs/codex-proxy-rs.pen similarity index 100% rename from docs/codex-proxy-rs.pen rename to web/docs/codex-proxy-rs.pen diff --git a/web/docs/frontend-optimization-plan.md b/web/docs/frontend-optimization-plan.md new file mode 100644 index 000000000..1eaf08bcb --- /dev/null +++ b/web/docs/frontend-optimization-plan.md @@ -0,0 +1,120 @@ +# Frontend Optimization Plan + +## 目标 + +前端优化先解决结构坏味道,再处理局部实现细节。核心目标是让页面入口保持清晰,让业务流程、表格配置、弹窗和局部展示各自有稳定位置。 + +这份目录是第一批目标结构,不代表一次性创建所有空文件。只有当代码真实迁移进去时,才新增对应文件,避免为了目录完整而制造空壳。 + +## 原则 + +- 页面出口统一使用 `index.vue`。 +- 大页面先拆业务流程,再拆展示组件。 +- `constants.ts` 只放稳定配置,例如表格列、固定选项、状态映射。 +- `composables/` 放页面内可复用的状态与副作用流程,例如筛选、分页、请求、批量操作。 +- `components/` 放页面私有展示块或弹窗,不放跨页面基础组件。 +- 不为了抽象而抽象。组件提取必须减少页面职责或消除真实重复。 +- API 模块继续保持轻量,避免为每个接口参数声明大量类型。 +- 日期时间统一使用 `dayjs` 封装后的工具,不在页面里手写格式化。 + +## 第一批目标目录 + +```text +web/src/views/accounts/ + index.vue + constants.ts + composables/ + useAccountConnectionTest.ts + useAccountFilters.ts + useAccountMutations.ts + useAccountsTable.ts + components/ + AccountOverviewCards.vue + AccountIdentityCell.vue + AccountStatusBadge.vue + AccountCreateModal.vue + AccountEditModal.vue + AccountConnectionTestModal.vue + AccountQuotaPanel.vue + AccountQuotaSummaryCell.vue + AccountTableActions.vue + AccountUsagePanel.vue + +web/src/views/api-keys/ + index.vue + constants.ts + composables/ + useApiKeyFilters.ts + useApiKeyMutations.ts + useApiKeysTable.ts + components/ + ApiKeyFilters.vue + ApiKeyCreateModal.vue + ApiKeyIdentityCell.vue + ApiKeyPrefixCell.vue + ApiKeyStatusToggle.vue + +web/src/views/logs/ + index.vue + constants.ts + composables/ + useLogFilters.ts + useLogDetail.ts + useLogsTable.ts + components/ + LogDetailModal.vue + LogFilters.vue + LogLevelBadge.vue + LogStatusCodeBadge.vue + +web/src/components/base/BaseTable/ + index.vue + columns.ts + pagination.ts +``` + +## 落地顺序 + +1. `accounts` + +先处理最大页面。连接测试、筛选分页、账号变更请求、表格配置分别收口。弹窗和展开内容在逻辑稳定后再拆组件,避免过早组件化。 + +2. `api-keys` + +拆出表格列、筛选分页、创建/删除/状态/标签更新流程。弹窗组件只在页面脚本明显变轻后再迁移。 + +3. `logs` + +事件日志先整理筛选、表格和详情弹窗。详情弹窗里的元数据滚动与展示规则需要保持当前设计系统一致。 + +4. `BaseTable` + +最后处理基础表格。先抽列归一化和分页逻辑,再决定是否把 `BaseTable.vue` 迁移为目录入口 `BaseTable/index.vue`。基础组件变更影响面大,必须在页面侧坏味道收敛后再动。 + +## 当前进度 + +第一批已完成真实迁移: + +- `accounts` 已拆出连接测试、筛选分页、表格状态、账号变更流程、创建/编辑/测试弹窗、额度面板和用量面板。 +- `api-keys` 已拆出筛选分页、表格选择、密钥变更流程、表格列配置和创建弹窗。 +- `logs` 已拆出筛选区、详情弹窗、日志加载/刷新/清空流程、详情状态和表格列配置。 +- `components/base/BaseTable` 已迁移为目录入口,列解析与分页逻辑分别收口到 `columns.ts` 和 `pagination.ts`。 +- `utils/date.ts` 已落地,日期时间格式化统一走工具函数。 + +已完成的结构修正: + +- 筛选分页 composable 显式接收 `total` ref,不通过闭包读取外部总数。 +- 表格选择状态统一使用替换 `Set` 的方式更新,避免依赖原地 mutation。 +- 弹窗表单组件使用字段级 computed setter,避免子组件直接改父级表单对象的嵌套字段。 +- 日志列表命名回到 `logs`,避免把后端筛选结果误导为前端二次筛选。 +- 账号概览卡片、表格身份、额度摘要和行操作已拆为页面私有展示组件,让 `accounts/index.vue` 继续收敛为页面编排层。 +- 连接测试弹窗复用账号身份展示组件,不再维护第二套头像、标题和副标题展示逻辑。 +- 账号状态展示已收敛为页面私有 badge 组件,表格与连接测试弹窗共享同一套状态文案和色值。 +- API Key 筛选与顶部操作区已拆为页面私有组件,`api-keys/index.vue` 只保留状态接线和表格编排。 +- API Key 名称/标签编辑和完整密钥复制已拆为页面私有单元组件,标签编辑 composable 不再接收 DOM 事件。 +- API Key 启用状态切换已拆为页面私有单元组件,入口页不再直接维护状态按钮样式。 +- 日志级别与状态码展示已拆为页面私有 badge 组件,列表和详情弹窗共享同一套展示规则。 +- 账号表格不再保留 `filteredAccounts` 这类无意义身份别名,页面直接使用后端返回的 `accounts`。 +- 筛选 composable 不再向页面暴露未消费的内部派生状态,保持对外 API 更小。 + +后续继续优化时,必须按本文件的目标目录和落地顺序推进。 diff --git a/web/package.json b/web/package.json index 626ac306f..d206345f5 100644 --- a/web/package.json +++ b/web/package.json @@ -16,6 +16,7 @@ "@fontsource-variable/jetbrains-mono": "^5.2.8", "@lucide/vue": "^1.21.0", "axios": "^1.18.1", + "dayjs": "^1.11.21", "echarts": "^6.1.0", "gsap": "^3.15.0", "pinia": "^3.0.4", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index a91062d55..88a1a49cd 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: axios: specifier: ^1.18.1 version: 1.18.1 + dayjs: + specifier: ^1.11.21 + version: 1.11.21 echarts: specifier: ^6.1.0 version: 6.1.0 @@ -493,6 +496,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1399,6 +1405,8 @@ snapshots: csstype@3.2.3: {} + dayjs@1.11.21: {} + debug@4.4.3: dependencies: ms: 2.1.3 diff --git a/web/src/api/modules/accounts.ts b/web/src/api/modules/accounts.ts index 502014d9b..29b45508e 100644 --- a/web/src/api/modules/accounts.ts +++ b/web/src/api/modules/accounts.ts @@ -16,6 +16,14 @@ export function createAccount(data: any) { }) } +export function importAccounts(data: any) { + return request({ + url: '/api/admin/accounts/import', + method: 'POST', + data, + }) +} + export function deleteAccounts(data: any) { return request({ url: '/api/admin/accounts/delete', @@ -59,18 +67,16 @@ export function testAccountConnection(data: any) { export async function testAccountConnectionStream(data: any, onEvent: any, signal?: AbortSignal) { const { id, ...payload } = data const baseURL = import.meta.env.DEV ? '/dev' : '' - const response = await fetch( - `${baseURL}/api/admin/accounts/${encodeURIComponent(String(id))}/test`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - body: JSON.stringify(payload), - signal, + const params = new URLSearchParams({ id: String(id) }) + const response = await fetch(`${baseURL}/api/admin/accounts/test?${params}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', }, - ) + credentials: 'include', + body: JSON.stringify(payload), + signal, + }) if (!response.ok) { throw new Error((await response.text()) || `HTTP ${response.status}`) @@ -96,8 +102,11 @@ export async function testAccountConnectionStream(data: any, onEvent: any, signa export function getAccountTestModels(data: any) { return request({ - url: `/api/admin/accounts/${encodeURIComponent(String(data.id))}/test-models`, + url: '/api/admin/accounts/models', method: 'GET', + params: { + id: data.id, + }, }) } diff --git a/web/src/components/base/BaseSegmented.vue b/web/src/components/base/BaseSegmented.vue index 9c7abde1a..0fd4c445c 100644 --- a/web/src/components/base/BaseSegmented.vue +++ b/web/src/components/base/BaseSegmented.vue @@ -6,9 +6,15 @@ interface SegmentedOption { value: string } -const props = defineProps<{ - options: SegmentedOption[] -}>() +const props = withDefaults( + defineProps<{ + options: SegmentedOption[] + disabled?: boolean + }>(), + { + disabled: false, + }, +) const model = defineModel({ required: true }) @@ -25,6 +31,11 @@ const indicatorStyle = computed(() => ({ width: `calc((100% - 8px) / ${Math.max(props.options.length, 1)})`, transform: `translateX(${activeIndex.value * 100}%)`, })) + +function selectOption(value: string) { + if (props.disabled || value === model.value) return + model.value = value +}