feat: refine admin console and api keys

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-27 21:26:14 +08:00
co-authored by Codex
parent e917ce0c9f
commit 71d192db45
71 changed files with 3995 additions and 2620 deletions
+11 -18
View File
@@ -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<AppState>,
Path(account_id): Path<String>,
Query(query): Query<AccountIdQuery>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, AdminError> {
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<AppState>,
Path(account_id): Path<String>,
Query(query): Query<AccountIdQuery>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
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<AdminAccountMetadata> {
async fn list_all_account_metadata(state: &AppState) -> Vec<AdminAccountMetadata> {
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();
+4 -2
View File
@@ -59,6 +59,7 @@ pub struct ClientApiKeyData {
pub name: String,
pub label: Option<String>,
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<String>,
pub prefix: String,
pub key: String,
pub enabled: bool,
pub created_at: String,
pub created_at_display: String,
pub last_used_at: Option<String>,
pub last_used_at_display: String,
pub plaintext: String,
}
#[derive(Debug, Serialize)]
@@ -117,6 +118,7 @@ impl From<AdminStoredClientApiKey> 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<AdminCreatedClientApiKey> 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,
}
}
}
+51 -26
View File
@@ -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<bool> {
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<String>,
pub prefix: String,
pub key: String,
pub enabled: bool,
pub created_at: String,
pub last_used_at: Option<String>,
@@ -233,10 +236,10 @@ pub struct AdminCreatedClientApiKey {
pub name: String,
pub label: Option<String>,
pub prefix: String,
pub key: String,
pub enabled: bool,
pub created_at: String,
pub last_used_at: Option<String>,
pub plaintext: String,
}
/// 客户端 API Key 状态更新结果。
@@ -293,6 +296,7 @@ impl From<StoredClientApiKey> 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<CreatedClientApiKey> 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<String>,
/// 明文 API Key 的短前缀。
pub prefix: String,
/// 管理端可复制的完整 API Key。
pub key: String,
/// 是否允许用于 `/v1` 认证。
pub enabled: bool,
/// 创建时间。
@@ -363,14 +372,14 @@ pub struct CreatedClientApiKey {
pub label: Option<String>,
/// 明文 API Key 的短前缀。
pub prefix: String,
/// 管理端可复制的完整 API Key。
pub key: String,
/// 是否允许用于 `/v1` 认证。
pub enabled: bool,
/// 创建时间。
pub created_at: String,
/// 最近一次成功使用时间。
pub last_used_at: Option<String>,
/// 仅返回一次的明文 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::<Vec<_>>();
.map(|row| self.key_from_row(&row))
.collect::<Result<Vec<_>, _>>()?;
let next_cursor = if has_next {
items
.last()
@@ -474,13 +493,13 @@ impl SqliteClientKeyStore {
id: &str,
) -> Result<Option<StoredClientApiKey>, 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<StoredClientApiKey, SqliteClientKeyStoreError> {
let key_cipher = row.get::<String, _>("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::<i64, _>("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::<i64, _>("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(),
+2 -8
View File
@@ -47,14 +47,8 @@ pub fn router() -> Router<AppState> {
"/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))
+12 -6
View File
@@ -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<Sha256>;
#[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::<String>();
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<bool> {
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)
}
}
+1
View File
@@ -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,
+96
View File
@@ -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;
+2
View File
@@ -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);
}
+21
View File
@@ -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,
+2 -2
View File
@@ -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")
+11 -14
View File
@@ -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(),
)
+2 -2
View File
@@ -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());
}
+5 -5
View File
@@ -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(),
)
+1 -1
View File
@@ -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(),
)
}
+15 -5
View File
@@ -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]
+1 -1
View File
@@ -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;
+5 -13
View File
@@ -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) {
+2 -2
View File
@@ -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)
+6 -6
View File
@@ -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 {
+5 -5
View File
@@ -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 {
+120
View File
@@ -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 更小。
后续继续优化时,必须按本文件的目标目录和落地顺序推进。
+1
View File
@@ -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",
+8
View File
@@ -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
+21 -12
View File
@@ -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,
},
})
}
+20 -7
View File
@@ -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<string>({ 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
}
</script>
<template>
@@ -41,15 +52,17 @@ const indicatorStyle = computed(() => ({
v-for="option in options"
:key="option.value"
class="relative z-10 h-7 min-w-0 rounded-(--cp-input-radius-base) border-0 bg-transparent px-3 text-xs leading-none font-[650] transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
:class="
:class="[
model === option.value
? 'text-(--cp-text-primary)'
: 'text-(--cp-text-secondary) hover:text-(--cp-text-primary)'
"
: 'text-(--cp-text-secondary) hover:text-(--cp-text-primary)',
disabled ? 'cursor-not-allowed opacity-60 hover:text-(--cp-text-secondary)' : undefined,
]"
type="button"
role="tab"
:aria-selected="model === option.value"
@click="model = option.value"
:disabled="disabled"
@click="selectOption(option.value)"
>
{{ option.label }}
</button>
@@ -0,0 +1,165 @@
export interface BaseTableColumn {
key: string
label?: string
format?: (value: any, row: Record<string, any>) => any
width?: number | string
minWidth?: number | string
maxWidth?: number | string
flex?: number
fixed?: 'left'
align?: 'left' | 'right' | 'center'
ellipsis?: boolean
emptyText?: string
mono?: boolean
tabular?: boolean
headerClass?: string
cellClass?: string
}
export type ResolvedTableColumn = BaseTableColumn & {
resolvedWidth: string
resolvedMinWidth?: string
resolvedMaxWidth?: string
}
export function resolveColumns(columns: BaseTableColumn[], minWidth?: number | string) {
const fixedPercentTotal = columns.reduce((sum, column) => {
return column.width === undefined ? sum : sum + numericPercentWidth(column.width)
}, 0)
const fixedPixelTotal = columns.reduce((sum, column) => {
return column.width === undefined ? sum : sum + numericPixelWidth(column.width)
}, 0)
const minWidthPixels = minWidth === undefined ? 0 : numericPixelWidth(minWidth)
const flexibleColumns = columns.filter((column) => column.width === undefined)
const flexTotal = flexibleColumns.reduce((sum, column) => sum + (column.flex ?? 1), 0)
const available = Math.max(100 - fixedPercentTotal, 0)
const availablePixels = Math.max(minWidthPixels - fixedPixelTotal, 0)
return columns.map((column) => {
const flex = column.flex ?? 1
const width =
column.width === undefined
? minWidthPixels > 0 && fixedPercentTotal === 0
? `${availablePixels / Math.max(flexibleColumns.length, 1)}px`
: flexTotal > 0
? `${(available * flex) / flexTotal}%`
: `${available / Math.max(flexibleColumns.length, 1)}%`
: normalizeWidth(column.width)
return {
...column,
resolvedWidth: width,
resolvedMinWidth: column.minWidth === undefined ? undefined : normalizeWidth(column.minWidth),
resolvedMaxWidth: column.maxWidth === undefined ? undefined : normalizeWidth(column.maxWidth),
}
})
}
export function normalizeWidth(width: number | string) {
return typeof width === 'number' ? `${width}px` : width
}
export function columnStyle(column: ResolvedTableColumn) {
return {
width: column.resolvedWidth,
minWidth: column.resolvedMinWidth,
maxWidth: column.resolvedMaxWidth,
}
}
export function tableStyle(minWidth?: number | string) {
if (minWidth === undefined) {
return undefined
}
return { width: `max(100%, ${normalizeWidth(minWidth)})` }
}
export function alignClass(column: BaseTableColumn) {
if (column.align === 'center') {
return 'text-center'
}
if (column.align === 'right') {
return 'text-right'
}
return 'text-left'
}
export function cellValue(row: Record<string, any>, key: string) {
return row[key]
}
export function isEmptyCellValue(value: unknown) {
return value === undefined || value === null || value === ''
}
export function cellDisplayValue(column: BaseTableColumn, row: Record<string, any>) {
const rawValue = cellValue(row, column.key)
const value = column.format ? column.format(rawValue, row) : rawValue
return isEmptyCellValue(value) ? (column.emptyText ?? '—') : value
}
export function cellTitle(column: BaseTableColumn, row: Record<string, any>) {
if (column.ellipsis === false || column.key === 'selection' || column.key === 'actions') {
return undefined
}
const value = cellDisplayValue(column, row)
if (isEmptyCellValue(value)) {
return undefined
}
return typeof value === 'string' || typeof value === 'number' ? String(value) : undefined
}
export function cellContentClass(column: BaseTableColumn) {
if (column.key === 'selection') {
return [
'flex min-w-0 items-center overflow-visible leading-none',
column.align === 'right'
? 'justify-end'
: column.align === 'center'
? 'justify-center'
: 'justify-start',
]
}
if (column.key === 'actions') {
return 'min-w-0 overflow-visible'
}
return [
'min-w-0',
column.ellipsis === false ? undefined : 'truncate',
column.mono ? 'font-mono text-[12px] font-[650]' : undefined,
column.tabular ? 'tabular-nums' : undefined,
]
}
function numericPercentWidth(width: number | string) {
if (typeof width === 'number') {
return 0
}
if (width.endsWith('%')) {
const parsed = Number.parseFloat(width)
return Number.isFinite(parsed) ? parsed : 0
}
return 0
}
function numericPixelWidth(width: number | string) {
if (typeof width === 'number') {
return width
}
if (width.endsWith('px')) {
const parsed = Number.parseFloat(width)
return Number.isFinite(parsed) ? parsed : 0
}
return 0
}
@@ -2,37 +2,30 @@
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, useTemplateRef } from 'vue'
import BaseEmpty from './BaseEmpty.vue'
import BaseScrollbar from './BaseScrollbar.vue'
import BaseSelect from './BaseSelect.vue'
export interface BaseTableColumn {
key: string
label?: string
width?: number | string
minWidth?: number | string
maxWidth?: number | string
flex?: number
fixed?: 'left'
align?: 'left' | 'right' | 'center'
ellipsis?: boolean
emptyText?: string
mono?: boolean
tabular?: boolean
headerClass?: string
cellClass?: string
}
export interface BaseTablePagination {
page: number
pageSize: number
total: number
pageSizes?: number[]
}
import BaseEmpty from '../BaseEmpty.vue'
import BaseScrollbar from '../BaseScrollbar.vue'
import BaseSelect from '../BaseSelect.vue'
import {
alignClass,
cellContentClass,
cellDisplayValue,
cellTitle,
cellValue,
columnStyle,
resolveColumns,
tableStyle as resolveTableStyle,
type BaseTableColumn,
} from './columns'
import {
getCurrentPage,
getPageSizeOptions,
getPagerItems,
getTotalPages,
type BaseTablePagination,
} from './pagination'
type TableRow = Record<string, any>
type RowKey = string | ((row: TableRow, index: number) => string | number)
type PagerItem = number | 'ellipsis'
const props = withDefaults(
defineProps<{
@@ -72,65 +65,11 @@ const headerCellClass =
const bodyCellClass =
'min-w-0 px-4 first:pl-3 text-[13px] text-(--cp-text-primary) first:rounded-l-lg'
const computedColumns = computed(() => {
const fixedPercentTotal = props.columns.reduce((sum, column) => {
return column.width === undefined ? sum : sum + numericPercentWidth(column.width)
}, 0)
const fixedPixelTotal = props.columns.reduce((sum, column) => {
return column.width === undefined ? sum : sum + numericPixelWidth(column.width)
}, 0)
const minWidthPixels = props.minWidth === undefined ? 0 : numericPixelWidth(props.minWidth)
const flexibleColumns = props.columns.filter((column) => column.width === undefined)
const flexTotal = flexibleColumns.reduce((sum, column) => sum + (column.flex ?? 1), 0)
const available = Math.max(100 - fixedPercentTotal, 0)
const availablePixels = Math.max(minWidthPixels - fixedPixelTotal, 0)
return props.columns.map((column) => {
const flex = column.flex ?? 1
const width =
column.width === undefined
? minWidthPixels > 0 && fixedPercentTotal === 0
? `${availablePixels / Math.max(flexibleColumns.length, 1)}px`
: flexTotal > 0
? `${(available * flex) / flexTotal}%`
: `${available / Math.max(flexibleColumns.length, 1)}%`
: normalizeWidth(column.width)
return {
...column,
resolvedWidth: width,
resolvedMinWidth: column.minWidth === undefined ? undefined : normalizeWidth(column.minWidth),
resolvedMaxWidth: column.maxWidth === undefined ? undefined : normalizeWidth(column.maxWidth),
}
})
})
const totalPages = computed(() => {
if (!props.pagination || props.pagination.total <= 0) {
return 0
}
return Math.max(1, Math.ceil(props.pagination.total / props.pagination.pageSize))
})
const currentPage = computed(() => {
if (!props.pagination || totalPages.value === 0) {
return 0
}
return Math.min(Math.max(props.pagination.page, 1), totalPages.value)
})
const pageSizeOptions = computed(() => {
const pageSizes = props.pagination?.pageSizes?.length
? props.pagination.pageSizes
: [10, 20, 50, 100]
return pageSizes.map((pageSize) => ({
label: `${pageSize} 条/页`,
value: String(pageSize),
}))
})
const computedColumns = computed(() => resolveColumns(props.columns, props.minWidth))
const totalPages = computed(() => getTotalPages(props.pagination))
const currentPage = computed(() => getCurrentPage(props.pagination, totalPages.value))
const pageSizeOptions = computed(() => getPageSizeOptions(props.pagination))
const tableViewStyle = computed(() => resolveTableStyle(props.minWidth))
const pageSizeModel = computed({
get: () => String(props.pagination?.pageSize ?? ''),
@@ -165,83 +104,7 @@ const horizontalThumbStyle = computed(() => ({
transform: `translateX(${horizontalThumbLeft.value}px)`,
}))
const pagerItems = computed<PagerItem[]>(() => {
const total = totalPages.value
const current = currentPage.value
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
}
const pages = new Set<number>([1, total, current, current - 1, current + 1])
if (current <= 3) {
pages.add(2)
pages.add(3)
pages.add(4)
}
if (current >= total - 2) {
pages.add(total - 1)
pages.add(total - 2)
pages.add(total - 3)
}
const sorted = [...pages].filter((page) => page >= 1 && page <= total).sort((a, b) => a - b)
const items: PagerItem[] = []
for (const page of sorted) {
const previous = items[items.length - 1]
if (typeof previous === 'number' && page - previous > 1) {
items.push('ellipsis')
}
items.push(page)
}
return items
})
function normalizeWidth(width: number | string) {
return typeof width === 'number' ? `${width}px` : width
}
function numericPercentWidth(width: number | string) {
if (typeof width === 'number') {
return 0
}
if (width.endsWith('%')) {
const parsed = Number.parseFloat(width)
return Number.isFinite(parsed) ? parsed : 0
}
return 0
}
function numericPixelWidth(width: number | string) {
if (typeof width === 'number') {
return width
}
if (width.endsWith('px')) {
const parsed = Number.parseFloat(width)
return Number.isFinite(parsed) ? parsed : 0
}
return 0
}
function columnStyle(column: (typeof computedColumns.value)[number]) {
return {
width: column.resolvedWidth,
minWidth: column.resolvedMinWidth,
maxWidth: column.resolvedMaxWidth,
}
}
function tableStyle() {
if (props.minWidth === undefined) {
return undefined
}
return { width: `max(100%, ${normalizeWidth(props.minWidth)})` }
}
const pagerItems = computed(() => getPagerItems(totalPages.value, currentPage.value))
function fixedHeaderClass(column: BaseTableColumn) {
if (column.fixed !== 'left') {
@@ -270,32 +133,6 @@ function fixedBodyClass(column: BaseTableColumn, row: TableRow, index: number) {
]
}
function cellValue(row: TableRow, key: string) {
return row[key]
}
function isEmptyCellValue(value: unknown) {
return value === undefined || value === null || value === ''
}
function cellDisplayValue(column: BaseTableColumn, row: TableRow) {
const value = cellValue(row, column.key)
return isEmptyCellValue(value) ? (column.emptyText ?? '—') : value
}
function cellTitle(column: BaseTableColumn, row: TableRow) {
if (column.ellipsis === false || column.key === 'selection' || column.key === 'actions') {
return undefined
}
const value = cellDisplayValue(column, row)
if (isEmptyCellValue(value)) {
return undefined
}
return typeof value === 'string' || typeof value === 'number' ? String(value) : undefined
}
function getRowKey(row: TableRow, index: number) {
if (typeof props.rowKey === 'function') {
return props.rowKey(row, index)
@@ -313,18 +150,6 @@ function isRowExpanded(row: TableRow, index: number) {
return props.expandedRowKeys.includes(getRowKey(row, index))
}
function alignClass(column: BaseTableColumn) {
if (column.align === 'center') {
return 'text-center'
}
if (column.align === 'right') {
return 'text-right'
}
return 'text-left'
}
function rowClass(row: TableRow, index: number) {
return [
bodyRowClass,
@@ -459,30 +284,6 @@ function isLastColumn(index: number) {
return index === computedColumns.value.length - 1
}
function cellContentClass(column: BaseTableColumn) {
if (column.key === 'selection') {
return [
'flex min-w-0 items-center overflow-visible leading-none',
column.align === 'right'
? 'justify-end'
: column.align === 'center'
? 'justify-center'
: 'justify-start',
]
}
if (column.key === 'actions') {
return 'min-w-0 overflow-visible'
}
return [
'min-w-0',
column.ellipsis === false ? undefined : 'truncate',
column.mono ? 'font-mono text-[12px] font-[650]' : undefined,
column.tabular ? 'tabular-nums' : undefined,
]
}
function goToPage(page: number) {
if (
props.loading ||
@@ -527,7 +328,7 @@ function paginationPageClass(page: number) {
<div ref="headerWrap" class="max-w-full overflow-hidden">
<table
class="w-full shrink-0 table-fixed border-separate border-spacing-y-1 text-left"
:style="tableStyle()"
:style="tableViewStyle"
role="table"
>
<colgroup>
@@ -575,7 +376,7 @@ function paginationPageClass(page: number) {
<table
ref="tableView"
class="w-full table-fixed border-separate border-spacing-y-2 text-left"
:style="tableStyle()"
:style="tableViewStyle"
role="table"
>
<colgroup>
@@ -0,0 +1,65 @@
export interface BaseTablePagination {
page: number
pageSize: number
total: number
pageSizes?: number[]
}
export type PagerItem = number | 'ellipsis'
const DEFAULT_PAGE_SIZES = [10, 20, 50, 100]
export function getTotalPages(pagination?: BaseTablePagination) {
if (!pagination || pagination.total <= 0) {
return 0
}
return Math.max(1, Math.ceil(pagination.total / pagination.pageSize))
}
export function getCurrentPage(pagination: BaseTablePagination | undefined, totalPages: number) {
if (!pagination || totalPages === 0) {
return 0
}
return Math.min(Math.max(pagination.page, 1), totalPages)
}
export function getPageSizeOptions(pagination?: BaseTablePagination) {
const pageSizes = pagination?.pageSizes?.length ? pagination.pageSizes : DEFAULT_PAGE_SIZES
return pageSizes.map((pageSize) => ({
label: `${pageSize} 条/页`,
value: String(pageSize),
}))
}
export function getPagerItems(total: number, current: number): PagerItem[] {
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
}
const pages = new Set<number>([1, total, current, current - 1, current + 1])
if (current <= 3) {
pages.add(2)
pages.add(3)
pages.add(4)
}
if (current >= total - 2) {
pages.add(total - 1)
pages.add(total - 2)
pages.add(total - 3)
}
const sorted = [...pages].filter((page) => page >= 1 && page <= total).sort((a, b) => a - b)
const items: PagerItem[] = []
for (const page of sorted) {
const previous = items[items.length - 1]
if (typeof previous === 'number' && page - previous > 1) {
items.push('ellipsis')
}
items.push(page)
}
return items
}
+39
View File
@@ -0,0 +1,39 @@
// @env browser
export async function copyText(text: string) {
if (!text) {
return false
}
if (window.isSecureContext && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Fall through to the textarea fallback below.
}
}
return copyTextWithTextarea(text)
}
function copyTextWithTextarea(text: string) {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.top = '0'
textarea.style.left = '-9999px'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange(0, textarea.value.length)
try {
return document.execCommand('copy')
} catch {
return false
} finally {
document.body.removeChild(textarea)
}
}
+12
View File
@@ -0,0 +1,12 @@
import dayjs, { type ConfigType } from 'dayjs'
const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'
const TIME_FORMAT = 'HH:mm:ss'
export function formatDateTime(value: ConfigType = new Date()): string {
return dayjs(value).format(DATE_TIME_FORMAT)
}
export function formatTime(value: ConfigType = new Date()): string {
return dayjs(value).format(TIME_FORMAT)
}
@@ -0,0 +1,186 @@
<script setup lang="ts">
import BaseButton from '@/components/base/BaseButton.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import BaseSelect from '@/components/base/BaseSelect.vue'
import AccountIdentityCell from './AccountIdentityCell.vue'
import AccountStatusBadge from './AccountStatusBadge.vue'
defineProps<{
account: any
status: string
model: string
logs: any[]
error: string
startedAt: string
finishedAt: string
durationMs: number | null
loadingModels: boolean
modelOptions: any[]
statusView: any
}>()
const open = defineModel<boolean>({ default: false })
const selectedModel = defineModel<string>('selectedModel', { required: true })
const emit = defineEmits<{
test: []
}>()
function connectionLogClass(tone: string) {
if (tone === 'success') return 'text-(--cp-success-text)'
if (tone === 'danger') return 'text-(--cp-danger-text)'
if (tone === 'info') return 'text-(--cp-info-text)'
return 'text-(--cp-text-secondary)'
}
</script>
<template>
<BaseModal
v-model="open"
title="测试连接"
description="验证账号令牌、ChatGPT 账号 ID 与 Codex 模型端点是否可用。"
variant="info"
width="720px"
>
<div v-if="account" class="flex flex-col gap-4">
<section
class="flex items-center justify-between gap-4 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3"
>
<AccountIdentityCell :account="account" size="lg" />
<AccountStatusBadge :status="account.status" variant="pill" />
</section>
<section class="rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3">
<div class="grid gap-2">
<label class="text-[12px] font-[760] text-(--cp-text-muted)">测试模型</label>
<BaseSelect
v-model="selectedModel"
:options="modelOptions"
:disabled="status === 'running' || loadingModels"
:placeholder="loadingModels ? '加载模型中...' : '选择上游模型'"
empty-text="上游没有返回模型"
/>
</div>
</section>
<section class="rounded-(--cp-card-radius) bg-(--cp-bg-subtle) p-4">
<div class="flex items-start justify-between gap-4">
<div class="flex min-w-0 items-start gap-3">
<span
class="inline-flex size-10 shrink-0 items-center justify-center rounded-lg"
:class="statusView.badge"
>
<component
:is="statusView.icon"
class="size-5"
:class="[statusView.iconClass, status === 'running' ? 'animate-pulse' : '']"
/>
</span>
<div class="min-w-0">
<p class="m-0 text-[16px] font-[780] text-(--cp-text-primary)">
{{ statusView.label }}
</p>
<p
class="mt-1.5 mb-0 text-[13px] leading-normal font-[650] text-(--cp-text-secondary)"
>
{{ statusView.description }}
</p>
</div>
</div>
<span
class="inline-flex h-7 shrink-0 items-center rounded-full px-2.5 text-[12px] font-[760]"
:class="statusView.badge"
>
{{ status === 'running' ? '检测中' : statusView.label }}
</span>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-3">
<div class="rounded-lg bg-(--cp-bg-surface) px-3 py-2.5">
<p class="m-0 text-[11px] font-[760] text-(--cp-text-muted)">开始时间</p>
<p class="mt-1.5 mb-0 font-mono text-[12px] font-[650] text-(--cp-text-primary)">
{{ startedAt || '-' }}
</p>
</div>
<div class="rounded-lg bg-(--cp-bg-surface) px-3 py-2.5">
<p class="m-0 text-[11px] font-[760] text-(--cp-text-muted)">完成时间</p>
<p class="mt-1.5 mb-0 font-mono text-[12px] font-[650] text-(--cp-text-primary)">
{{ finishedAt || '-' }}
</p>
</div>
<div class="rounded-lg bg-(--cp-bg-surface) px-3 py-2.5">
<p class="m-0 text-[11px] font-[760] text-(--cp-text-muted)">响应耗时</p>
<p class="mt-1.5 mb-0 font-mono text-[12px] font-[650] text-(--cp-text-primary)">
{{ durationMs !== null ? `${durationMs}ms` : '-' }}
</p>
</div>
</div>
<div class="mt-3 rounded-lg bg-(--cp-bg-surface) px-3 py-2.5">
<p class="m-0 text-[11px] font-[760] text-(--cp-text-muted)">测试模型</p>
<p
class="mt-1.5 mb-0 truncate font-mono text-[12px] font-[650] text-(--cp-text-primary)"
:title="model || '-'"
>
{{ model || '-' }}
</p>
</div>
<div class="mt-3 rounded-lg bg-(--cp-bg-surface) px-3 py-2.5">
<p class="m-0 text-[11px] font-[760] text-(--cp-text-muted)">事件轨迹</p>
<BaseScrollbar max-height="260px" view-class="pt-2 pr-2">
<div v-if="logs.length === 0" class="text-[12px] font-[650] text-(--cp-text-muted)">
-
</div>
<div v-else class="flex flex-col gap-1.5">
<div
v-for="item in logs"
:key="item.key"
class="grid grid-cols-[54px_minmax(0,1fr)] gap-2 text-[12px] leading-[1.45] font-[650]"
>
<span class="font-mono text-(--cp-text-muted)">{{ item.time }}</span>
<div class="min-w-0">
<p class="m-0 wrap-break-word" :class="connectionLogClass(item.tone)">
{{ item.text }}
</p>
<div v-if="item.detail" class="mt-2 rounded-lg bg-(--cp-bg-subtle) px-3 py-2">
<BaseScrollbar max-height="138px" view-class="pr-2">
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[11px] leading-[1.6] font-[620] text-(--cp-text-primary)"
>{{ item.detail }}</pre
>
</BaseScrollbar>
</div>
</div>
</div>
</div>
</BaseScrollbar>
</div>
<div v-if="error" class="mt-3 rounded-lg bg-(--cp-danger-bg) px-3 py-2.5">
<p class="m-0 text-[11px] font-[760] text-(--cp-danger-text)">错误信息</p>
<BaseScrollbar max-height="118px" view-class="pt-1.5 pr-2">
<p
class="m-0 wrap-break-word text-[12px] leading-[1.55] font-[650] text-(--cp-danger-text)"
>
{{ error }}
</p>
</BaseScrollbar>
</div>
</section>
</div>
<template #footer>
<BaseButton variant="ghost" @click="open = false">关闭</BaseButton>
<BaseButton
variant="primary"
:loading="status === 'running'"
:disabled="!account || loadingModels || !selectedModel"
@click="emit('test')"
>
{{ logs.length > 0 || error ? '重新测试' : '开始测试' }}
</BaseButton>
</template>
</BaseModal>
</template>
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue'
import { Upload } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
const props = withDefaults(
defineProps<{
saving?: boolean
}>(),
{
saving: false,
},
)
const open = defineModel<boolean>({ default: false })
const form = defineModel<any>('form', { required: true })
const emit = defineEmits<{
create: []
}>()
const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
const fileError = ref('')
const modeOptions = [
{ label: 'RT 导入', value: 'rt' },
{ label: '原生 JSON', value: 'native' },
{ label: 'Sub2API', value: 'sub2api' },
]
const mode = computed({
get: () => form.value.mode,
set: (value: string) => {
form.value = { ...form.value, mode: value }
fileError.value = ''
},
})
const refreshToken = computed({
get: () => form.value.refreshToken,
set: (value: string) => {
form.value = { ...form.value, refreshToken: value }
},
})
const importText = computed({
get: () => form.value.importText,
set: (value: string) => {
form.value = { ...form.value, importText: value }
fileError.value = ''
},
})
const canSubmit = computed(() => {
if (props.saving) return false
if (mode.value === 'rt') return refreshToken.value.trim().length > 0
return importText.value.trim().length > 0
})
const description = computed(() => {
if (mode.value === 'rt') {
return '一行一个 Refresh Token,导入时会自动换取访问令牌并补全账号信息。'
}
if (mode.value === 'sub2api') {
return '导入 Sub2API 导出的账号 JSON,已存在账号会更新。'
}
return '导入 Codex Proxy RS 原生账号 JSON,已存在账号会更新。'
})
async function handleFileChange(event: Event) {
fileError.value = ''
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
importText.value = await file.text()
} catch {
fileError.value = '文件读取失败'
} finally {
input.value = ''
}
}
</script>
<template>
<BaseModal
v-model="open"
title="导入账号"
:description="description"
variant="info"
width="620px"
:close-disabled="saving"
>
<div class="flex flex-col gap-4">
<BaseSegmented v-model="mode" :options="modeOptions" class="w-full" />
<div v-if="mode === 'rt'">
<label class="block text-[13px] font-medium text-(--cp-text-secondary) mb-2">
Refresh Token <span class="text-(--cp-danger)">*</span>
</label>
<textarea
v-model="refreshToken"
class="h-40 w-full resize-none rounded-(--cp-input-radius-base) border-0 bg-(--cp-input-current-bg,var(--cp-input-context-bg)) px-3.5 py-3 font-mono text-[12px] leading-[1.55] font-[650] text-(--cp-text-primary) shadow-(--cp-shadow-input) outline-none transition-[background-color,box-shadow] duration-160 placeholder:text-(--cp-text-muted) hover:bg-(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover)) hover:shadow-(--cp-shadow-input-hover) focus:bg-(--cp-input-soft-bg-focus) focus:shadow-(--cp-shadow-input-focus) disabled:cursor-not-allowed disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none"
placeholder="rt_...&#10;rt_..."
:disabled="saving"
/>
</div>
<div v-else class="flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<label class="block text-[13px] font-medium text-(--cp-text-secondary)">
JSON 内容 <span class="text-(--cp-danger)">*</span>
</label>
<BaseButton variant="default" size="sm" :disabled="saving" @click="fileInput?.click()">
<Upload class="size-4" />
上传文件
</BaseButton>
<input
ref="fileInput"
class="hidden"
type="file"
accept="application/json,.json"
@change="handleFileChange"
/>
</div>
<textarea
v-model="importText"
class="h-56 w-full resize-none rounded-(--cp-input-radius-base) border-0 bg-(--cp-input-current-bg,var(--cp-input-context-bg)) px-3.5 py-3 font-mono text-[12px] leading-[1.55] font-[650] text-(--cp-text-primary) shadow-(--cp-shadow-input) outline-none transition-[background-color,box-shadow] duration-160 placeholder:text-(--cp-text-muted) hover:bg-(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover)) hover:shadow-(--cp-shadow-input-hover) focus:bg-(--cp-input-soft-bg-focus) focus:shadow-(--cp-shadow-input-focus) disabled:cursor-not-allowed disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none"
placeholder='{"accounts":[...]}'
:disabled="saving"
/>
<p v-if="fileError" class="m-0 text-[12px] font-[650] text-(--cp-danger-text)">
{{ fileError }}
</p>
</div>
</div>
<template #footer>
<BaseButton variant="ghost" :disabled="saving" @click="open = false">取消</BaseButton>
<BaseButton
variant="primary"
:loading="saving"
:disabled="!canSubmit"
@click="emit('create')"
>
导入
</BaseButton>
</template>
</BaseModal>
</template>
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseSelect from '@/components/base/BaseSelect.vue'
defineProps<{
account: any
statusOptions: any[]
saving: boolean
}>()
const open = defineModel<boolean>({ default: false })
const form = defineModel<any>('form', { required: true })
const status = defineModel<string>('status', { required: true })
const emit = defineEmits<{
save: []
}>()
function formField(key: string) {
return computed({
get: () => form.value[key] ?? '',
set: (value: string) => {
form.value = { ...form.value, [key]: value }
},
})
}
const label = formField('label')
const email = formField('email')
const accountId = formField('accountId')
const userId = formField('userId')
const planType = formField('planType')
</script>
<template>
<BaseModal
v-model="open"
title="编辑账号"
description="更新账号元信息、套餐和调度状态。"
variant="info"
width="680px"
:close-disabled="saving"
>
<div class="grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label class="mb-2 block text-[13px] font-medium text-(--cp-text-secondary)">
内部 ID
</label>
<div
class="flex h-10 items-center truncate rounded-lg bg-(--cp-bg-subtle) px-3 font-mono text-[12px] font-[650] text-(--cp-text-muted)"
>
{{ account?.id || '-' }}
</div>
</div>
<div>
<label class="mb-2 block text-[13px] font-medium text-(--cp-text-secondary)">
备注标签
</label>
<BaseInput v-model="label" placeholder="例如:主账号 / 备用账号" />
</div>
<div>
<label class="mb-2 block text-[13px] font-medium text-(--cp-text-secondary)">邮箱</label>
<BaseInput v-model="email" placeholder="account@example.com" />
</div>
<div>
<label class="mb-2 block text-[13px] font-medium text-(--cp-text-secondary)">
ChatGPT 账号 ID
</label>
<BaseInput v-model="accountId" placeholder="chatgpt account id" />
</div>
<div>
<label class="mb-2 block text-[13px] font-medium text-(--cp-text-secondary)">
用户 ID
</label>
<BaseInput v-model="userId" placeholder="user id" />
</div>
<div>
<label class="mb-2 block text-[13px] font-medium text-(--cp-text-secondary)">套餐</label>
<BaseInput v-model="planType" placeholder="free / plus / pro / team" />
</div>
<div>
<label class="mb-2 block text-[13px] font-medium text-(--cp-text-secondary)">状态</label>
<BaseSelect v-model="status" :options="statusOptions" />
</div>
</div>
<template #footer>
<BaseButton variant="ghost" :disabled="saving" @click="open = false">取消</BaseButton>
<BaseButton variant="primary" :loading="saving" @click="emit('save')">保存</BaseButton>
</template>
</BaseModal>
</template>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
account: any
size?: 'md' | 'lg'
}>(),
{
size: 'md',
},
)
const displayTitle = computed(
() =>
props.account.label?.trim() ||
props.account.email ||
props.account.accountId ||
props.account.id,
)
const secondaryText = computed(() => {
const title = displayTitle.value
const secondary = [
props.account.email,
props.account.accountId,
props.account.userId,
props.account.id,
].find((value) => value && value !== title)
return secondary || props.account.id
})
const initial = computed(() => displayTitle.value.slice(0, 1).toUpperCase())
const avatarSizeClass = computed(() =>
props.size === 'lg' ? 'size-10 text-[15px]' : 'size-9 text-[13px]',
)
const secondaryClass = computed(() =>
props.size === 'lg'
? 'mt-1 text-[12px] text-(--cp-text-secondary)'
: 'mt-0.5 font-mono text-[11px] text-(--cp-text-muted)',
)
const avatarClass = computed(() => {
const palettes = [
'bg-(--cp-info-bg) text-(--cp-info-text) shadow-[inset_0_0_0_1px_var(--cp-info-border)]',
'bg-(--cp-success-bg) text-(--cp-success-text) shadow-[inset_0_0_0_1px_var(--cp-success-border)]',
'bg-(--cp-normal-bg) text-(--cp-normal-text) shadow-[inset_0_0_0_1px_var(--cp-normal-border)]',
'bg-(--cp-warning-bg) text-(--cp-warning-text) shadow-[inset_0_0_0_1px_var(--cp-warning-border)]',
]
const key = String(props.account.id || props.account.email || displayTitle.value)
const hash = [...key].reduce((sum, char) => sum + char.charCodeAt(0), 0)
return palettes[hash % palettes.length]
})
</script>
<template>
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex shrink-0 items-center justify-center rounded-lg font-[820]"
:class="[avatarSizeClass, avatarClass]"
>
{{ initial }}
</span>
<div class="min-w-0">
<div class="truncate text-[14px] font-[760] text-(--cp-text-primary)">
{{ displayTitle }}
</div>
<div class="truncate font-[650]" :class="secondaryClass">
{{ secondaryText }}
</div>
</div>
</div>
</template>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed } from 'vue'
import { AlertTriangle, Gauge, ShieldCheck, Users } from '@lucide/vue'
import BaseCard from '@/components/base/BaseCard.vue'
const props = defineProps<{
summary: any
}>()
const overviewItems = computed(() => [
{
label: '总账号',
value: formatCount(props.summary.total),
caption: '账号池规模',
tone: 'info',
icon: Users,
},
{
label: '正常账号',
value: formatCount(props.summary.active),
caption: '可参与调度',
tone: 'success',
icon: ShieldCheck,
},
{
label: '额度预警',
value: formatCount(props.summary.highUsage),
caption: '任一窗口 >= 80%',
tone: 'warning',
icon: Gauge,
},
{
label: '需处理',
value: formatCount(props.summary.attention),
caption: '过期 / 禁用 / 封禁',
tone: 'danger',
icon: AlertTriangle,
},
])
function formatCount(value: number) {
return value.toLocaleString('zh-CN')
}
function overviewIconClass(tone: string) {
if (tone === 'success') {
return 'bg-(--cp-success-bg) text-(--cp-success-text)'
}
if (tone === 'warning') {
return 'bg-(--cp-warning-bg) text-(--cp-warning-text)'
}
if (tone === 'danger') {
return 'bg-(--cp-danger-bg) text-(--cp-danger-text)'
}
return 'bg-(--cp-info-bg) text-(--cp-info-text)'
}
</script>
<template>
<div class="mt-5 grid shrink-0 grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
<BaseCard
v-for="item in overviewItems"
:key="item.label"
as="article"
:padded="false"
class="h-24"
>
<div class="flex h-full items-stretch justify-between gap-3 px-5 py-3">
<div class="flex min-w-0 flex-col justify-between">
<p class="m-0 text-[12px] leading-none font-[760] text-(--cp-text-secondary)">
{{ item.label }}
</p>
<strong
class="block font-mono text-[26px] leading-none font-[820] text-(--cp-text-primary)"
>
{{ item.value }}
</strong>
<p class="m-0 truncate text-[12px] leading-none font-[650] text-(--cp-text-muted)">
{{ item.caption }}
</p>
</div>
<span
class="inline-flex size-9 shrink-0 items-center justify-center self-start rounded-lg"
:class="overviewIconClass(item.tone)"
>
<component :is="item.icon" class="size-4.5" />
</span>
</div>
</BaseCard>
</div>
</template>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { RefreshCw } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
const props = defineProps<{
account: any
refreshing: boolean
}>()
const emit = defineEmits<{
refreshQuota: [accountId: string]
}>()
function quotaWindows(group: string) {
return (props.account.quota.windows as any[]).filter((window) => window.group === group)
}
function quotaWindowPercent(window?: any) {
return Math.max(0, Math.min(window?.usedPercent ?? 0, 100))
}
function quotaWindowBarWidth(window?: any) {
return `${quotaWindowPercent(window)}%`
}
</script>
<template>
<section class="rounded-lg bg-(--cp-bg-surface) p-4 shadow-(--cp-shadow-control)">
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<h3 class="m-0 text-[14px] font-[760] text-(--cp-text-primary)">账号额度</h3>
<p class="m-0 mt-1 text-[12px] font-[620] text-(--cp-text-secondary)">
Codex 额度 · 套餐: {{ account.planType || 'Free' }} · 最近刷新:
{{ account.quota.refreshedAtDisplay }}
</p>
</div>
<BaseButton
icon-only
variant="ghost"
size="sm"
title="刷新额度"
:disabled="refreshing"
@click="emit('refreshQuota', account.id)"
>
<RefreshCw class="size-3.5" :class="refreshing ? 'animate-spin' : undefined" />
</BaseButton>
</div>
<div class="grid gap-3">
<div
v-for="window in quotaWindows('monthly')"
:key="window.key"
class="rounded-lg bg-(--cp-bg-subtle) p-2"
>
<div class="flex items-center justify-between gap-3 text-[12px] font-[720]">
<span class="text-(--cp-text-secondary)">{{ window.labelDisplay }}</span>
<span class="text-(--cp-text-primary)">{{ window.usedPercentDisplay }}</span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-(--cp-bg-tertiary)">
<div
class="h-full rounded-full bg-(--cp-info)"
:style="{ width: quotaWindowBarWidth(window) }"
/>
</div>
<div
class="mt-3 flex flex-wrap justify-between gap-x-3 gap-y-1 text-[12px] font-[620] text-(--cp-text-secondary)"
>
<span>重置时间: {{ window.resetAtDisplay }}</span>
<span>窗口已用: {{ window.windowUsedDisplay }}</span>
</div>
</div>
<div v-if="quotaWindows('shortTerm').length > 0" class="grid gap-2 sm:grid-cols-2">
<div
v-for="window in quotaWindows('shortTerm')"
:key="window.key"
class="rounded-lg bg-(--cp-bg-subtle) p-2"
>
<div class="flex items-center justify-between gap-3 text-[12px] font-[720]">
<span class="text-(--cp-text-secondary)">{{ window.labelDisplay }}</span>
<span class="text-(--cp-text-primary)">{{ window.usedPercentDisplay }}</span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-(--cp-bg-tertiary)">
<div
class="h-full rounded-full bg-(--cp-info)"
:style="{ width: quotaWindowBarWidth(window) }"
/>
</div>
<div class="mt-3 flex flex-col gap-1 text-[12px] font-[620] text-(--cp-text-secondary)">
<span>重置时间: {{ window.resetAtDisplay }}</span>
<span>窗口已用: {{ window.windowUsedDisplay }}</span>
</div>
</div>
</div>
<div
v-for="window in quotaWindows('other')"
:key="window.key"
class="rounded-lg bg-(--cp-bg-subtle) p-2"
>
<div class="flex items-center justify-between gap-3 text-[12px] font-[720]">
<span class="text-(--cp-text-secondary)">{{ window.labelDisplay }}</span>
<span class="text-(--cp-text-primary)">{{ window.usedPercentDisplay }}</span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-(--cp-bg-tertiary)">
<div
class="h-full rounded-full bg-(--cp-info)"
:style="{ width: quotaWindowBarWidth(window) }"
/>
</div>
<div
class="mt-3 flex flex-wrap justify-between gap-x-3 gap-y-1 text-[12px] font-[620] text-(--cp-text-secondary)"
>
<span>重置时间: {{ window.resetAtDisplay }}</span>
<span>窗口已用: {{ window.windowUsedDisplay }}</span>
</div>
</div>
</div>
</section>
</template>
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
account: any
}>()
const quotaWindows = computed(() => props.account.quota.windows as any[])
function quotaWindowPercent(window?: any) {
return Math.max(0, Math.min(window?.usedPercent ?? 0, 100))
}
function quotaWindowBarStyle(window?: any) {
const percent = quotaWindowPercent(window)
return {
width: `${percent}%`,
minWidth: percent > 0 ? '6px' : '0',
}
}
function quotaWindowBarClass(window?: any) {
if (window?.usedPercent === null || window?.usedPercent === undefined) {
return 'bg-(--cp-default-border-hover)'
}
if (window.usedPercent >= 95) {
return 'bg-(--cp-danger)'
}
if (window.usedPercent >= 80) {
return 'bg-(--cp-warning)'
}
return 'bg-(--cp-success)'
}
</script>
<template>
<div
v-if="quotaWindows.length > 0"
class="flex w-full max-w-31 min-w-0 flex-col gap-2 whitespace-normal py-1.5"
>
<div
v-for="window in quotaWindows"
:key="window.key"
class="min-w-0 border-b border-slate-200/70 pb-2 last:border-b-0 last:pb-0"
>
<div
class="mb-1.5 flex items-center justify-between gap-2 text-[11px] leading-none font-[760]"
>
<span class="truncate text-(--cp-text-secondary)">
{{ window.labelDisplay }}
</span>
<span class="shrink-0 font-mono text-(--cp-text-primary)">
{{ window.usedPercentDisplay }}
</span>
</div>
<div class="h-1 w-full overflow-hidden rounded-full bg-(--cp-default-border)">
<div
class="h-full rounded-full transition-[width,background-color] duration-200"
:class="quotaWindowBarClass(window)"
:style="quotaWindowBarStyle(window)"
/>
</div>
</div>
</div>
<span v-else class="text-(--cp-text-muted)">-</span>
</template>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed } from 'vue'
import { statusLabels, statusTones } from '../constants'
const props = withDefaults(
defineProps<{
status: string
variant?: 'inline' | 'pill'
}>(),
{
variant: 'inline',
},
)
const tone = computed(() => statusTones[props.status])
const label = computed(() => statusLabels[props.status] || props.status)
const textClass = computed(() => {
if (tone.value === 'success') {
return 'text-(--cp-success-text)'
}
if (tone.value === 'danger') {
return 'text-(--cp-danger-text)'
}
if (tone.value === 'warning') {
return 'text-(--cp-warning-text)'
}
if (tone.value === 'info') {
return 'text-(--cp-info-text)'
}
return 'text-(--cp-text-secondary)'
})
const dotClass = computed(() => {
if (tone.value === 'success') {
return 'bg-(--cp-success)'
}
if (tone.value === 'danger') {
return 'bg-(--cp-danger)'
}
if (tone.value === 'warning') {
return 'bg-(--cp-warning)'
}
if (tone.value === 'info') {
return 'bg-(--cp-info)'
}
return 'bg-(--cp-text-muted)'
})
</script>
<template>
<span
v-if="variant === 'pill'"
class="inline-flex h-7 shrink-0 items-center rounded-full px-2.5 text-[12px] font-[760]"
:class="textClass"
>
{{ label }}
</span>
<span
v-else
class="inline-flex min-w-16 items-center gap-1.5 text-[12px] leading-none font-[650]"
:class="textClass"
>
<span class="size-1.5 rounded-full" :class="dotClass" />
<span>{{ label }}</span>
</span>
</template>
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { LoaderCircle, MoreHorizontal, Pencil, Power, RefreshCw, Trash2, Wifi } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BasePopover from '@/components/base/BasePopover.vue'
defineProps<{
account: any
deleting: boolean
refreshing: boolean
testing: boolean
updatingStatus: boolean
scheduleLabel: string
}>()
const emit = defineEmits<{
edit: [account: any]
delete: [account: any]
test: [account: any]
refresh: [accountId: string]
toggleSchedule: [account: any]
}>()
</script>
<template>
<div class="relative flex items-center justify-end gap-1">
<BaseButton
icon-only
variant="ghost"
size="sm"
title="编辑账号"
@click.stop="emit('edit', account)"
>
<Pencil class="size-3.5" />
</BaseButton>
<BaseButton
icon-only
variant="ghost"
size="sm"
title="删除账号"
:disabled="deleting"
@click.stop="emit('delete', account)"
>
<Trash2 class="size-3.5 text-(--cp-danger)" />
</BaseButton>
<BasePopover placement="bottom-end" width="160px">
<template #trigger="{ open }">
<BaseButton icon-only variant="ghost" size="sm" title="更多操作" :active="open">
<MoreHorizontal class="size-4" />
</BaseButton>
</template>
<template #default="{ close }">
<button
type="button"
class="flex h-8.5 w-full items-center gap-2 rounded-(--cp-input-radius-small) border-0 bg-transparent px-3 text-left text-[13px] leading-none font-[650] text-(--cp-text-primary) transition-colors hover:bg-(--cp-default-bg-hover) disabled:cursor-not-allowed disabled:text-(--cp-disabled-text)"
:disabled="testing"
@click.stop="(close(), emit('test', account))"
>
<LoaderCircle v-if="testing" class="size-3.5 animate-spin text-(--cp-text-muted)" />
<Wifi v-else class="size-3.5 text-(--cp-text-muted)" />
测试连接
</button>
<button
type="button"
class="flex h-8.5 w-full items-center gap-2 rounded-(--cp-input-radius-small) border-0 bg-transparent px-3 text-left text-[13px] leading-none font-[650] text-(--cp-text-primary) transition-colors hover:bg-(--cp-default-bg-hover) disabled:cursor-not-allowed disabled:text-(--cp-disabled-text)"
:disabled="refreshing"
@click.stop="(close(), emit('refresh', account.id))"
>
<RefreshCw
class="size-3.5 text-(--cp-text-muted)"
:class="refreshing ? 'animate-spin' : undefined"
/>
刷新 token
</button>
<button
type="button"
class="flex h-8.5 w-full items-center gap-2 rounded-(--cp-input-radius-small) border-0 bg-transparent px-3 text-left text-[13px] leading-none font-[650] text-(--cp-text-primary) transition-colors hover:bg-(--cp-default-bg-hover) disabled:cursor-not-allowed disabled:text-(--cp-disabled-text)"
:disabled="updatingStatus"
@click.stop="(close(), emit('toggleSchedule', account))"
>
<LoaderCircle
v-if="updatingStatus"
class="size-3.5 animate-spin text-(--cp-text-muted)"
/>
<Power v-else class="size-3.5 text-(--cp-text-muted)" />
{{ scheduleLabel }}
</button>
</template>
</BasePopover>
</div>
</template>
@@ -0,0 +1,92 @@
<script setup lang="ts">
defineProps<{
account: any
}>()
</script>
<template>
<section
class="grid gap-4 rounded-lg bg-(--cp-bg-surface) p-4 shadow-(--cp-shadow-control) xl:grid-cols-[0.52fr_1.48fr]"
>
<div>
<h3 class="m-0 mb-3 text-[14px] font-[760] text-(--cp-text-primary)">Token 结构</h3>
<div class="grid gap-2">
<div class="flex items-center justify-between rounded-lg bg-(--cp-success-bg) px-3 py-2">
<span class="text-[12px] font-bold text-(--cp-success-text)">输入 Tokens</span>
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
{{ account.usage.inputTokensDisplay }}
</strong>
</div>
<div class="flex items-center justify-between rounded-lg bg-(--cp-warning-bg) px-3 py-2">
<span class="text-[12px] font-bold text-(--cp-warning-text)">输出 Tokens</span>
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
{{ account.usage.outputTokensDisplay }}
</strong>
</div>
<div class="flex items-center justify-between rounded-lg bg-(--cp-normal-bg) px-3 py-2">
<span class="text-[12px] font-bold text-(--cp-normal-text)">缓存 Tokens</span>
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
{{ account.usage.cachedTokensDisplay }}
</strong>
</div>
<div class="flex items-center justify-between rounded-lg bg-(--cp-info-bg) px-3 py-2">
<span class="text-[12px] font-bold text-(--cp-info-text)">创建</span>
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
{{ account.usage.createdTokensDisplay }}
</strong>
</div>
<div class="flex items-center justify-between rounded-lg bg-(--cp-info-bg) px-3 py-2">
<span class="text-[12px] font-bold text-(--cp-info-text)">读取</span>
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
{{ account.usage.readTokensDisplay }}
</strong>
</div>
</div>
</div>
<div
class="min-w-0 pt-4 shadow-[inset_0_1px_0_rgba(216,224,234,0.42)] xl:pt-0 xl:pl-4 xl:shadow-[inset_1px_0_0_rgba(216,224,234,0.42)]"
>
<div class="mb-3 flex items-center justify-between">
<h3 class="m-0 text-[14px] font-[760] text-(--cp-text-primary)">模型使用排行</h3>
</div>
<div
class="grid grid-cols-[1.2fr_0.7fr_0.8fr_1fr_1fr_1fr_1fr_1fr_1.4fr] gap-3 pb-2 text-[11px] font-[760] text-(--cp-text-muted) shadow-[inset_0_-1px_0_rgba(216,224,234,0.42)]"
>
<span>模型</span>
<span>调用</span>
<span>成功率</span>
<span>输入</span>
<span>输出</span>
<span>缓存</span>
<span>总TOKEN</span>
<span>总花费</span>
<span>最近请求时间</span>
</div>
<div
v-if="account.usage.models.length === 0"
class="pt-3 text-[12px] font-[650] text-(--cp-text-muted)"
>
-
</div>
<template v-else>
<div
v-for="model in account.usage.models"
:key="model.model"
class="grid grid-cols-[1.2fr_0.7fr_0.8fr_1fr_1fr_1fr_1fr_1fr_1.4fr] gap-3 pt-3 text-[12px] font-[650] text-(--cp-text-primary)"
>
<span class="truncate">{{ model.model }}</span>
<span>{{ model.requestCountDisplay }}</span>
<span class="text-(--cp-warning-text)">{{ model.successRateDisplay }}</span>
<span>{{ model.inputTokensDisplay }}</span>
<span>{{ model.outputTokensDisplay }}</span>
<span>{{ model.cachedTokensDisplay }}</span>
<span>{{ model.totalTokensDisplay }}</span>
<span>{{ model.totalCostUsdDisplay }}</span>
<span>{{ model.lastUsedAtDisplay }}</span>
</div>
</template>
</div>
</section>
</template>
@@ -0,0 +1,285 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { CheckCircle2, Clock3, Wifi, XCircle } from '@lucide/vue'
import { getAccountTestModels, testAccountConnectionStream } from '@/api'
import { withMinimumDuration } from '@/utils/async'
import { formatDateTime, formatTime } from '@/utils/date'
export function useAccountConnectionTest() {
const showConnectionTestModal = ref(false)
const testingAccount = ref<any>(null)
const connectionTestStatus = ref('idle')
const connectionTestModel = ref('')
const connectionTestContent = ref('')
const connectionTestLogs = ref<any[]>([])
const connectionTestError = ref('')
const connectionTestStartedAt = ref('')
const connectionTestFinishedAt = ref('')
const connectionTestDurationMs = ref<number | null>(null)
const testingConnectionIds = ref<Set<string>>(new Set())
const loadingConnectionTestModels = ref(false)
const connectionTestSelectedModel = ref('')
const connectionTestModelOptions = ref<any[]>([])
let connectionTestAbortController: AbortController | undefined
let connectionTestStartedAtMs = 0
const connectionTestStatusView = computed(() => {
if (connectionTestStatus.value === 'running') {
return {
label: '正在测试',
description: '正在发送一条真实 Responses 流式请求。',
icon: Clock3,
badge: 'bg-(--cp-info-bg) text-(--cp-info-text)',
iconClass: 'text-(--cp-info)',
}
}
if (connectionTestStatus.value === 'success') {
return {
label: '连接正常',
description: '账号令牌可用,已完成 Codex Responses 流式验证。',
icon: CheckCircle2,
badge: 'bg-(--cp-success-bg) text-(--cp-success-text)',
iconClass: 'text-(--cp-success)',
}
}
if (connectionTestStatus.value === 'error') {
return {
label: '测试失败',
description: '真实请求未完成,优先检查令牌状态、账号权限或上游网络。',
icon: XCircle,
badge: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
iconClass: 'text-(--cp-danger)',
}
}
return {
label: '准备测试',
description: '点击开始后发送一条轻量 Responses 流式请求。',
icon: Wifi,
badge: 'bg-(--cp-bg-subtle) text-(--cp-text-secondary)',
iconClass: 'text-(--cp-text-muted)',
}
})
function openConnectionTest(account: any) {
abortConnectionTest()
testingAccount.value = account
connectionTestSelectedModel.value = ''
connectionTestModelOptions.value = []
showConnectionTestModal.value = true
resetConnectionTest()
void loadConnectionTestModels(account)
}
function resetConnectionTest() {
connectionTestStatus.value = 'idle'
connectionTestModel.value = ''
connectionTestContent.value = ''
connectionTestLogs.value = []
connectionTestError.value = ''
connectionTestStartedAt.value = ''
connectionTestFinishedAt.value = ''
connectionTestDurationMs.value = null
connectionTestStartedAtMs = 0
}
function formatConnectionTestDetail(value: any) {
if (value === undefined || value === null || value === '') return ''
if (typeof value === 'string') return value
return JSON.stringify(value, null, 2)
}
function connectionTestRequestText(payload: any) {
const texts = (payload?.input || [])
.flatMap((item: any) => item?.content || [])
.filter((item: any) => item?.type === 'input_text' && item?.text)
.map((item: any) => item.text)
return texts.join('\n')
}
function connectionTestLogItem(key: string, text: string, tone = 'normal', detail?: any) {
return {
key,
time: formatTime(),
text,
tone,
detail: formatConnectionTestDetail(detail),
}
}
function appendConnectionTestLog(text: string, tone = 'normal', detail?: any) {
connectionTestLogs.value = [
...connectionTestLogs.value,
connectionTestLogItem(`${Date.now()}-${connectionTestLogs.value.length}`, text, tone, detail),
]
}
function setConnectionTestLog(key: string, text: string, tone = 'normal', detail?: any) {
const index = connectionTestLogs.value.findIndex((item) => item.key === key)
const next = connectionTestLogItem(key, text, tone, detail)
if (index === -1) {
connectionTestLogs.value = [...connectionTestLogs.value, next]
return
}
connectionTestLogs.value = connectionTestLogs.value.map((item, itemIndex) =>
itemIndex === index ? { ...next, time: item.time } : item,
)
}
function finishConnectionTest(status: 'success' | 'error') {
connectionTestStatus.value = status
connectionTestFinishedAt.value = formatDateTime()
connectionTestDurationMs.value = Math.max(0, Date.now() - connectionTestStartedAtMs)
}
function handleConnectionTestEvent(event: any) {
if (event.type === 'test_start') {
connectionTestModel.value = event.model || connectionTestModel.value
appendConnectionTestLog(`开始测试 ${connectionTestModel.value || '默认模型'}`, 'info')
return
}
if (event.type === 'request') {
setConnectionTestLog('request', '发起请求', 'info', connectionTestRequestText(event.payload))
return
}
if (event.type === 'status' && event.text) {
appendConnectionTestLog(event.text, 'info')
return
}
if (event.type === 'content' && event.text) {
connectionTestContent.value += event.text
setConnectionTestLog('response', '接收响应内容', 'success', connectionTestContent.value)
return
}
if (event.type === 'test_complete') {
if (event.success) {
if (!connectionTestContent.value) {
setConnectionTestLog('response', '响应完成', 'success', '上游已完成,没有返回文本内容。')
}
appendConnectionTestLog('测试完成', 'success')
finishConnectionTest('success')
} else {
connectionTestError.value = event.error || '测试连接失败'
appendConnectionTestLog(connectionTestError.value, 'danger')
finishConnectionTest('error')
}
return
}
if (event.type === 'error') {
connectionTestError.value = event.error || '测试连接失败'
appendConnectionTestLog(connectionTestError.value, 'danger')
finishConnectionTest('error')
}
}
function abortConnectionTest() {
connectionTestAbortController?.abort()
connectionTestAbortController = undefined
}
async function loadConnectionTestModels(account = testingAccount.value) {
if (!account?.id) return
loadingConnectionTestModels.value = true
connectionTestError.value = ''
try {
const result = await getAccountTestModels({ id: account.id })
connectionTestModelOptions.value = (result.models || []).map((model: any) => ({
label: model.label || model.id,
value: model.id,
}))
connectionTestSelectedModel.value = connectionTestModelOptions.value[0]?.value || ''
if (!connectionTestSelectedModel.value) {
connectionTestError.value = '上游没有返回可测试模型'
}
} catch (error: any) {
connectionTestError.value = error.message || '加载测试模型失败'
connectionTestModelOptions.value = []
connectionTestSelectedModel.value = ''
} finally {
loadingConnectionTestModels.value = false
}
}
async function handleTestConnection(account = testingAccount.value) {
if (!account?.id) return
if (!connectionTestSelectedModel.value) {
connectionTestError.value = '请先选择上游返回的测试模型'
return
}
if (testingConnectionIds.value.has(account.id)) return
abortConnectionTest()
const controller = new AbortController()
connectionTestAbortController = controller
connectionTestStatus.value = 'running'
connectionTestModel.value = ''
connectionTestContent.value = ''
connectionTestLogs.value = []
connectionTestError.value = ''
connectionTestDurationMs.value = null
connectionTestModel.value = connectionTestSelectedModel.value
connectionTestStartedAtMs = Date.now()
connectionTestStartedAt.value = formatDateTime()
connectionTestFinishedAt.value = ''
appendConnectionTestLog('准备发送测试请求', 'info')
testingConnectionIds.value = new Set(testingConnectionIds.value).add(account.id)
try {
await withMinimumDuration(() =>
testAccountConnectionStream(
{
id: account.id,
modelId: connectionTestSelectedModel.value,
},
handleConnectionTestEvent,
controller.signal,
),
)
if (connectionTestStatus.value === 'running') {
connectionTestError.value = '测试连接未返回完成事件'
appendConnectionTestLog(connectionTestError.value, 'danger')
finishConnectionTest('error')
}
} catch (error: any) {
if (error?.name !== 'AbortError') {
connectionTestError.value = error.message || '测试连接失败'
appendConnectionTestLog(connectionTestError.value, 'danger')
finishConnectionTest('error')
}
} finally {
const next = new Set(testingConnectionIds.value)
next.delete(account.id)
testingConnectionIds.value = next
if (connectionTestAbortController === controller) {
connectionTestAbortController = undefined
}
}
}
watch(showConnectionTestModal, (open) => {
if (!open) {
abortConnectionTest()
}
})
onBeforeUnmount(() => {
abortConnectionTest()
})
return {
showConnectionTestModal,
testingAccount,
connectionTestStatus,
connectionTestModel,
connectionTestLogs,
connectionTestError,
connectionTestStartedAt,
connectionTestFinishedAt,
connectionTestDurationMs,
testingConnectionIds,
loadingConnectionTestModels,
connectionTestSelectedModel,
connectionTestModelOptions,
connectionTestStatusView,
openConnectionTest,
handleTestConnection,
}
}
@@ -0,0 +1,62 @@
// @env browser
import { computed, onBeforeUnmount, ref, watch, type Ref } from 'vue'
export function useAccountFilters(totalAccounts: Ref<number>) {
const page = ref(1)
const pageSize = ref(20)
const searchQuery = ref('')
let searchTimer: number | undefined
let loadAccounts: (() => Promise<void> | void) | undefined
const accountPagination = computed(() => ({
page: page.value,
pageSize: pageSize.value,
total: totalAccounts.value,
pageSizes: [10, 20, 50, 100],
}))
function bindAccountLoader(loader: () => Promise<void> | void) {
loadAccounts = loader
}
function requestLoad() {
if (loadAccounts) {
void loadAccounts()
}
}
function handlePageChange(nextPage: number) {
page.value = nextPage
requestLoad()
}
function handlePageSizeChange(nextPageSize: number) {
pageSize.value = nextPageSize
page.value = 1
requestLoad()
}
watch(searchQuery, () => {
page.value = 1
if (searchTimer) {
window.clearTimeout(searchTimer)
}
searchTimer = window.setTimeout(requestLoad, 250)
})
onBeforeUnmount(() => {
if (searchTimer) {
window.clearTimeout(searchTimer)
}
})
return {
page,
pageSize,
searchQuery,
accountPagination,
bindAccountLoader,
handlePageChange,
handlePageSizeChange,
}
}
@@ -0,0 +1,346 @@
import { computed, onMounted, ref, type Ref } from 'vue'
import {
deleteAccounts,
getAccountQuota,
getAccounts,
importAccounts,
refreshAccount,
updateAccount,
} from '@/api'
import { toast } from '@/components/base/BaseToast'
import { withMinimumDuration } from '@/utils/async'
import { editableStatusOptions } from '../constants'
export function useAccountMutations(options: {
page: Ref<number>
pageSize: Ref<number>
searchQuery: Ref<string>
selectedIds: Ref<Set<string>>
totalAccounts: Ref<number>
}) {
const loading = ref(true)
const accounts = ref<any[]>([])
const accountSummary = ref({
total: 0,
active: 0,
highUsage: 0,
attention: 0,
})
const showCreateModal = ref(false)
const showDeleteModal = ref(false)
const showSingleDeleteModal = ref(false)
const editingAccount = ref<any>(null)
const pendingDeleteAccount = ref<any>(null)
const refreshingAccountIds = ref<Set<string>>(new Set())
const refreshingQuotaAccountIds = ref<Set<string>>(new Set())
const updatingStatusAccountIds = ref<Set<string>>(new Set())
const deletingAccount = ref(false)
const loaded = ref(false)
const creatingAccount = ref(false)
const savingAccount = ref(false)
const batchDeleting = ref(false)
const createForm = ref({
mode: 'rt',
refreshToken: '',
importText: '',
})
const editForm = ref({
label: '',
email: '',
accountId: '',
userId: '',
planType: '',
status: 'active',
})
const initialLoading = computed(() => loading.value && !loaded.value)
const showEditModal = computed({
get: () => editingAccount.value !== null,
set: (value: boolean) => {
if (!value) {
editingAccount.value = null
}
},
})
const editStatusModel = computed<string>({
get: () => editForm.value.status,
set: (value) => {
if (editableStatusOptions.some((option) => option.value === value)) {
editForm.value.status = value
}
},
})
async function loadAccounts() {
try {
loading.value = true
const result = await getAccounts({
page: options.page.value,
pageSize: options.pageSize.value,
search: options.searchQuery.value,
})
accounts.value = result.items
accountSummary.value = result.summary
options.totalAccounts.value = result.page.total ?? result.items.length
options.page.value = result.page.page ?? options.page.value
options.pageSize.value = result.page.pageSize ?? options.pageSize.value
if (
accounts.value.length === 0 &&
options.totalAccounts.value > 0 &&
options.page.value > 1
) {
options.page.value = Math.max(1, result.page.totalPages ?? options.page.value - 1)
await loadAccounts()
}
} finally {
loading.value = false
loaded.value = true
}
}
async function handleCreate() {
if (creatingAccount.value) return
try {
const payload = accountImportPayload()
if (!payload) return
creatingAccount.value = true
const result = await importAccounts(payload)
showCreateModal.value = false
createForm.value = { mode: 'rt', refreshToken: '', importText: '' }
await loadAccounts()
toast.success(importSuccessText(result))
} catch (error) {
toast.error(error instanceof Error ? error.message : '导入失败')
} finally {
creatingAccount.value = false
}
}
function accountImportPayload() {
if (createForm.value.mode === 'rt') {
const accounts = createForm.value.refreshToken
.split(/\r?\n/)
.map((token) => token.trim())
.filter(Boolean)
.map((refreshToken) => ({ refreshToken }))
return accounts.length ? { sourceFormat: 'native', accounts } : null
}
const text = createForm.value.importText.trim()
if (!text) return null
let parsed
try {
parsed = JSON.parse(text)
} catch {
throw new Error('JSON 格式不正确')
}
const sourceFormat = createForm.value.mode === 'sub2api' ? 'sub2api' : 'native'
if (Array.isArray(parsed)) {
return { sourceFormat, accounts: parsed }
}
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.accounts)) {
return { ...parsed, sourceFormat }
}
return { sourceFormat, accounts: [parsed] }
}
function importSuccessText(result: any) {
const imported = result?.imported ?? 0
const skipped = result?.skipped ?? 0
if (skipped > 0) {
return `导入完成,写入 ${imported} 个,跳过 ${skipped} 个`
}
return `导入完成,写入 ${imported} 个`
}
function openEditAccount(account: any) {
editingAccount.value = account
editForm.value = {
label: account.label || '',
email: account.email || '',
accountId: account.accountId || '',
userId: account.userId || '',
planType: account.planType || '',
status: account.status,
}
}
async function handleSaveAccount() {
if (savingAccount.value) return
const account = editingAccount.value
if (!account) return
try {
savingAccount.value = true
await updateAccount({
id: account.id,
label: nullableTrimmedValue(editForm.value.label),
email: nullableTrimmedValue(editForm.value.email),
accountId: nullableTrimmedValue(editForm.value.accountId),
userId: nullableTrimmedValue(editForm.value.userId),
planType: nullableTrimmedValue(editForm.value.planType),
status: editForm.value.status,
})
editingAccount.value = null
await loadAccounts()
toast.success('账号已更新')
} catch (error: any) {
toast.error(error.message || '保存失败')
} finally {
savingAccount.value = false
}
}
function nullableTrimmedValue(value: string) {
const trimmed = value.trim()
return trimmed || null
}
function requestDeleteAccount(account: any) {
pendingDeleteAccount.value = account
showSingleDeleteModal.value = true
}
async function handleDelete() {
if (deletingAccount.value) return
const accountId = pendingDeleteAccount.value?.id
if (!accountId) return
try {
deletingAccount.value = true
await deleteAccounts({ ids: [accountId] })
showSingleDeleteModal.value = false
pendingDeleteAccount.value = null
await loadAccounts()
toast.success('账号已删除')
} catch (error: any) {
toast.error(error.message || '删除失败')
} finally {
deletingAccount.value = false
}
}
async function handleBatchDelete() {
if (batchDeleting.value) return
if (options.selectedIds.value.size === 0) return
try {
batchDeleting.value = true
await deleteAccounts({ ids: [...options.selectedIds.value] })
options.selectedIds.value = new Set()
showDeleteModal.value = false
await loadAccounts()
} catch (error) {
console.error('Failed to batch delete accounts:', error)
} finally {
batchDeleting.value = false
}
}
async function handleRefresh(accountId: string) {
if (refreshingAccountIds.value.has(accountId)) return
refreshingAccountIds.value = new Set(refreshingAccountIds.value).add(accountId)
try {
await withMinimumDuration(async () => {
await refreshAccount({ id: accountId })
await loadAccounts()
})
toast.success('Token 已刷新')
} catch (error: any) {
toast.error(error.message || '刷新失败')
} finally {
const next = new Set(refreshingAccountIds.value)
next.delete(accountId)
refreshingAccountIds.value = next
}
}
async function handleRefreshQuota(accountId: string) {
if (refreshingQuotaAccountIds.value.has(accountId)) return
refreshingQuotaAccountIds.value = new Set(refreshingQuotaAccountIds.value).add(accountId)
try {
await withMinimumDuration(async () => {
await getAccountQuota({ id: accountId })
await loadAccounts()
})
} catch (error) {
console.error('Failed to refresh account quota:', error)
} finally {
const next = new Set(refreshingQuotaAccountIds.value)
next.delete(accountId)
refreshingQuotaAccountIds.value = next
}
}
async function handleToggleSchedule(account: any) {
if (updatingStatusAccountIds.value.has(account.id)) return
const nextStatus = account.status === 'disabled' ? 'active' : 'disabled'
updatingStatusAccountIds.value = new Set(updatingStatusAccountIds.value).add(account.id)
try {
await updateAccount({ id: account.id, status: nextStatus })
await loadAccounts()
toast.success(nextStatus === 'disabled' ? '已禁用调度' : '已启用调度')
} catch (error: any) {
toast.error(error.message || '状态更新失败')
} finally {
const next = new Set(updatingStatusAccountIds.value)
next.delete(account.id)
updatingStatusAccountIds.value = next
}
}
function scheduleActionLabel(account: any) {
return account.status === 'disabled' ? '启用调度' : '禁用调度'
}
onMounted(() => {
loadAccounts()
})
return {
loading,
accounts,
accountSummary,
showCreateModal,
showDeleteModal,
showSingleDeleteModal,
showEditModal,
editingAccount,
pendingDeleteAccount,
refreshingAccountIds,
refreshingQuotaAccountIds,
updatingStatusAccountIds,
deletingAccount,
creatingAccount,
savingAccount,
batchDeleting,
createForm,
editForm,
editStatusModel,
initialLoading,
loadAccounts,
handleCreate,
openEditAccount,
handleSaveAccount,
requestDeleteAccount,
handleDelete,
handleBatchDelete,
handleRefresh,
handleRefreshQuota,
handleToggleSchedule,
scheduleActionLabel,
}
}
@@ -0,0 +1,60 @@
import { computed, ref, type Ref } from 'vue'
export function useAccountsTable(accounts: Ref<any[]>, selectedIds = ref<Set<string>>(new Set())) {
const expandedAccountIds = ref<Set<string>>(new Set())
const allSelected = computed(
() =>
accounts.value.length > 0 &&
accounts.value.every((account) => selectedIds.value.has(account.id)),
)
const indeterminate = computed(
() => accounts.value.some((account) => selectedIds.value.has(account.id)) && !allSelected.value,
)
const selectedRowKeys = computed(() => [...selectedIds.value])
const expandedRowKeys = computed(() => [...expandedAccountIds.value])
function toggleSelection(accountId: string) {
const next = new Set(selectedIds.value)
if (next.has(accountId)) {
next.delete(accountId)
} else {
next.add(accountId)
}
selectedIds.value = next
}
function toggleExpanded(accountId: string) {
const next = new Set(expandedAccountIds.value)
if (next.has(accountId)) {
next.delete(accountId)
} else {
next.add(accountId)
}
expandedAccountIds.value = next
}
function toggleAll() {
const next = new Set(selectedIds.value)
if (allSelected.value) {
accounts.value.forEach((account) => next.delete(account.id))
} else {
accounts.value.forEach((account) => next.add(account.id))
}
selectedIds.value = next
}
return {
selectedIds,
expandedAccountIds,
allSelected,
indeterminate,
selectedRowKeys,
expandedRowKeys,
toggleSelection,
toggleExpanded,
toggleAll,
}
}
+71
View File
@@ -0,0 +1,71 @@
const relaxedCellClass = 'py-3 align-middle'
export const accountColumns = [
{
key: 'expander',
label: '',
width: '40px',
align: 'center' as const,
headerClass: '!px-2',
cellClass: `!px-2 ${relaxedCellClass}`,
},
{
key: 'selection',
label: '',
width: '40px',
align: 'center' as const,
headerClass: '!px-2',
cellClass: `!px-2 ${relaxedCellClass}`,
},
{ key: 'identity', label: '邮箱', flex: 2.6, minWidth: '260px', cellClass: relaxedCellClass },
{ key: 'status', label: '状态', flex: 0.8, cellClass: relaxedCellClass },
{ key: 'planType', label: '套餐', flex: 0.8, cellClass: relaxedCellClass },
{ key: 'usage', label: '用量', flex: 1.5, minWidth: '220px', cellClass: relaxedCellClass },
{
key: 'updatedAtDisplay',
label: '最后使用',
flex: 1.2,
cellClass: `${relaxedCellClass} text-(--cp-text-secondary)`,
},
{
key: 'accessTokenExpiresAtDisplay',
label: '过期时间',
flex: 1.2,
cellClass: `${relaxedCellClass} text-(--cp-text-secondary)`,
},
{
key: 'actions',
label: '操作',
width: '116px',
align: 'right' as const,
headerClass: '!pr-3',
cellClass: `!px-2 ${relaxedCellClass}`,
},
]
export const statusLabels: Record<string, string> = {
active: '正常',
expired: '已过期',
disabled: '已禁用',
banned: '已封禁',
quota_exhausted: '配额耗尽',
refreshing: '刷新中',
}
export const editableStatusOptions = [
{ value: 'active', label: statusLabels.active },
{ value: 'disabled', label: statusLabels.disabled },
{ value: 'expired', label: statusLabels.expired },
{ value: 'quota_exhausted', label: statusLabels.quota_exhausted },
{ value: 'refreshing', label: statusLabels.refreshing },
{ value: 'banned', label: statusLabels.banned },
]
export const statusTones: Record<string, 'success' | 'danger' | 'warning' | 'info' | 'normal'> = {
active: 'success',
expired: 'warning',
disabled: 'normal',
banned: 'danger',
quota_exhausted: 'warning',
refreshing: 'info',
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { Copy } from '@lucide/vue'
import { computed } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal.vue'
defineProps<{
createdKey: string
saving: boolean
}>()
const open = defineModel<boolean>({ default: false })
const createdOpen = defineModel<boolean>('createdOpen', { default: false })
const form = defineModel<any>('form', { required: true })
const emit = defineEmits<{
create: []
copy: [text: string]
}>()
function formField(key: string) {
return computed({
get: () => form.value[key] ?? '',
set: (value: string) => {
form.value = { ...form.value, [key]: value }
},
})
}
const name = formField('name')
const label = formField('label')
</script>
<template>
<BaseModal
v-model="open"
title="创建 API Key"
description="为当前代理管理端生成一个新的访问密钥。创建后请立即保存。"
variant="info"
width="540px"
:close-disabled="saving"
>
<div class="flex flex-col gap-4">
<div>
<label class="block text-[13px] font-medium text-(--cp-text-secondary) mb-2">
名称 <span class="text-(--cp-danger)">*</span>
</label>
<BaseInput v-model="name" placeholder="例如:生产环境、测试账号..." />
</div>
<div>
<label class="block text-[13px] font-medium text-(--cp-text-secondary) mb-2">
标签(可选)
</label>
<BaseInput v-model="label" placeholder="备注信息..." />
</div>
</div>
<template #footer>
<BaseButton variant="ghost" :disabled="saving" @click="open = false">取消</BaseButton>
<BaseButton
variant="primary"
:loading="saving"
:disabled="!name.trim()"
@click="emit('create')"
>
创建
</BaseButton>
</template>
</BaseModal>
<BaseModal
v-model="createdOpen"
title="API Key 已创建"
description="密钥只会显示一次,关闭弹窗后无法再次查看完整内容。"
variant="success"
width="540px"
>
<div class="flex flex-col gap-4">
<div
class="rounded-(--cp-input-radius-base) border border-(--cp-warning-border) bg-(--cp-warning-bg) px-4 py-3"
>
<p class="m-0 text-[13px] font-semibold text-(--cp-warning-text)">
请妥善保存此密钥,它只会显示一次。
</p>
</div>
<div>
<label class="block text-[13px] font-medium text-(--cp-text-secondary) mb-2">
API Key
</label>
<div class="flex items-center gap-2">
<code
class="flex-1 px-3 py-2.5 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) text-[13px] font-mono text-(--cp-text-primary) break-all"
>
{{ createdKey }}
</code>
<BaseButton icon-only size="md" title="复制" @click="emit('copy', createdKey)">
<Copy class="size-4" />
</BaseButton>
</div>
</div>
</div>
<template #footer>
<BaseButton variant="primary" @click="createdOpen = false">我已保存</BaseButton>
</template>
</BaseModal>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { Plus, Search, Trash2 } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
defineProps<{
batchDeleting: boolean
selectedCount: number
}>()
const search = defineModel<string>('search', { required: true })
const emit = defineEmits<{
create: []
deleteSelected: []
}>()
</script>
<template>
<div class="flex flex-wrap items-center justify-between gap-3" aria-label="API Key 筛选">
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-3">
<BaseInput
v-model="search"
placeholder="搜索名称、标签或 ID"
class="min-w-64 flex-1 sm:max-w-96"
>
<template #prefix>
<Search class="size-4.5 text-(--cp-text-tertiary)" />
</template>
</BaseInput>
<BaseButton
v-if="selectedCount > 0"
variant="danger"
:disabled="batchDeleting"
@click="emit('deleteSelected')"
>
<template #icon>
<Trash2 class="size-4" />
</template>
删除选中 ({{ selectedCount }})
</BaseButton>
</div>
<div class="flex shrink-0 items-center gap-2">
<BaseButton variant="primary" @click="emit('create')">
<template #icon>
<Plus class="size-4" />
</template>
创建 API Key
</BaseButton>
</div>
</div>
</template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
apiKey: any
editing: boolean
editingValue: string
saving: boolean
}>()
const emit = defineEmits<{
startEdit: [apiKey: any]
updateEdit: [value: string]
submitEdit: [apiKeyId: string]
cancelEdit: []
}>()
const editValue = computed({
get: () => props.editingValue,
set: (value: string) => emit('updateEdit', value),
})
</script>
<template>
<div class="flex flex-col gap-0.5">
<span class="text-[13px] font-bold text-(--cp-text-primary)">
{{ apiKey.name }}
</span>
<div v-if="editing" class="flex items-center gap-2">
<input
v-model="editValue"
type="text"
class="h-(--cp-input-height-inline) min-w-34 rounded-(--cp-input-radius-small) border-0 bg-(--cp-input-soft-bg) px-2.5 text-[13px] font-[650] text-(--cp-text-primary) shadow-(--cp-shadow-input) outline-none transition placeholder:text-(--cp-text-muted) focus:bg-(--cp-input-soft-bg-focus) focus:shadow-(--cp-shadow-input-focus)"
:disabled="saving"
@keyup.enter="emit('submitEdit', apiKey.id)"
@keyup.escape="emit('cancelEdit')"
/>
<button
class="text-[12px] text-(--cp-accent-primary) hover:underline disabled:cursor-not-allowed disabled:text-(--cp-disabled-text) disabled:no-underline"
:disabled="saving"
@click="emit('submitEdit', apiKey.id)"
>
{{ saving ? '保存中' : '保存' }}
</button>
<button
class="text-[12px] text-(--cp-text-tertiary) hover:underline disabled:cursor-not-allowed disabled:text-(--cp-disabled-text) disabled:no-underline"
:disabled="saving"
@click="emit('cancelEdit')"
>
取消
</button>
</div>
<button
v-else-if="apiKey.label"
class="text-left text-[12px] font-[650] text-(--cp-text-tertiary) hover:text-(--cp-info-text)"
@click="emit('startEdit', apiKey)"
>
{{ apiKey.label }}
</button>
</div>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { Copy } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
defineProps<{
maskedPrefix: string
keyValue: string
}>()
const emit = defineEmits<{
copy: [text: string]
}>()
</script>
<template>
<div class="flex items-center gap-2">
<code class="block min-w-0 truncate font-mono text-[12px] font-[650] text-(--cp-text-primary)">
{{ maskedPrefix }}
</code>
<BaseButton
icon-only
variant="ghost"
size="sm"
label="复制完整密钥"
@click="emit('copy', keyValue)"
>
<Copy class="size-3.5" />
</BaseButton>
</div>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { LoaderCircle } from '@lucide/vue'
defineProps<{
apiKey: any
loading: boolean
}>()
const emit = defineEmits<{
toggle: [apiKey: any]
}>()
</script>
<template>
<button
class="inline-flex h-6 min-w-14 cursor-pointer items-center justify-center rounded-full border-0 px-2 text-[12px] leading-none font-bold"
:class="{
'bg-(--cp-success-bg) text-(--cp-success-text)': apiKey.enabled,
'bg-(--cp-bg-subtle) text-(--cp-text-secondary)': !apiKey.enabled,
}"
:disabled="loading"
@click="emit('toggle', apiKey)"
>
<LoaderCircle v-if="loading" class="mr-1 size-3 animate-spin" />
{{ apiKey.enabled ? '已启用' : '已禁用' }}
</button>
</template>
@@ -0,0 +1,60 @@
import { computed, ref, watch, type Ref } from 'vue'
export function useApiKeyFilters(apiKeys: Ref<any[]>) {
const page = ref(1)
const pageSize = ref(20)
const searchQuery = ref('')
const filteredKeys = computed(() => {
if (!searchQuery.value) return apiKeys.value
const query = searchQuery.value.toLowerCase()
return apiKeys.value.filter(
(key) =>
key.name.toLowerCase().includes(query) ||
key.label?.toLowerCase().includes(query) ||
key.id.toLowerCase().includes(query),
)
})
const pagedKeys = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredKeys.value.slice(start, start + pageSize.value)
})
const apiKeyPagination = computed(() => ({
page: page.value,
pageSize: pageSize.value,
total: filteredKeys.value.length,
pageSizes: [10, 20, 50, 100],
}))
function handlePageChange(nextPage: number) {
page.value = nextPage
}
function handlePageSizeChange(nextPageSize: number) {
pageSize.value = nextPageSize
page.value = 1
}
watch(searchQuery, () => {
page.value = 1
})
watch(filteredKeys, () => {
const totalPages = Math.max(1, Math.ceil(filteredKeys.value.length / pageSize.value))
if (page.value > totalPages) {
page.value = totalPages
}
})
return {
page,
pageSize,
searchQuery,
pagedKeys,
apiKeyPagination,
handlePageChange,
handlePageSizeChange,
}
}
@@ -0,0 +1,214 @@
// @env browser
import { onMounted, ref, type Ref } from 'vue'
import { createApiKey, deleteApiKeys, getApiKeys, updateApiKey } from '@/api'
import { toast } from '@/components/base/BaseToast'
import { copyText } from '@/utils/clipboard'
export function useApiKeyMutations(selectedIds: Ref<Set<string>>) {
const loading = ref(true)
const apiKeys = ref<any[]>([])
const showCreateModal = ref(false)
const showDeleteModal = ref(false)
const showSingleDeleteModal = ref(false)
const showKeyModal = ref(false)
const createdKey = ref('')
const editingLabel = ref<{ id: string; value: string } | null>(null)
const pendingDeleteKey = ref<any | null>(null)
const creatingKey = ref(false)
const deletingKey = ref(false)
const batchDeleting = ref(false)
const updatingStatusKeyIds = ref<Set<string>>(new Set())
const savingLabelKeyIds = ref<Set<string>>(new Set())
const createForm = ref({
name: '',
label: '',
})
async function loadApiKeys() {
try {
loading.value = true
const data = await getApiKeys()
apiKeys.value = data.items
} catch (error: any) {
toast.error(error.message || '加载失败')
} finally {
loading.value = false
}
}
async function handleCreate() {
if (creatingKey.value) return
if (!createForm.value.name.trim()) {
toast.warning('请输入 API Key 名称')
return
}
try {
creatingKey.value = true
const result = await createApiKey({
name: createForm.value.name,
label: createForm.value.label || undefined,
})
createdKey.value = result.key
showCreateModal.value = false
showKeyModal.value = true
createForm.value = { name: '', label: '' }
await loadApiKeys()
toast.success('API Key 创建成功')
} catch (error: any) {
toast.error(error.message || '创建失败')
} finally {
creatingKey.value = false
}
}
function requestDeleteKey(key: any) {
pendingDeleteKey.value = key
showSingleDeleteModal.value = true
}
async function handleDelete() {
if (deletingKey.value) return
const keyId = pendingDeleteKey.value?.id
if (!keyId) return
try {
deletingKey.value = true
await deleteApiKeys({ ids: [keyId] })
showSingleDeleteModal.value = false
pendingDeleteKey.value = null
await loadApiKeys()
toast.success('删除成功')
} catch (error: any) {
toast.error(error.message || '删除失败')
} finally {
deletingKey.value = false
}
}
async function handleBatchDelete() {
if (batchDeleting.value) return
if (selectedIds.value.size === 0) return
try {
batchDeleting.value = true
const deleteCount = selectedIds.value.size
await deleteApiKeys({ ids: [...selectedIds.value] })
selectedIds.value = new Set()
showDeleteModal.value = false
await loadApiKeys()
toast.success(`已删除 ${deleteCount} 个 API Key`)
} catch (error: any) {
toast.error(error.message || '批量删除失败')
} finally {
batchDeleting.value = false
}
}
async function handleToggleStatus(key: any) {
if (updatingStatusKeyIds.value.has(key.id)) return
updatingStatusKeyIds.value = new Set(updatingStatusKeyIds.value).add(key.id)
try {
await updateApiKey({ id: key.id, status: key.enabled ? 'disabled' : 'active' })
await loadApiKeys()
toast.success(key.enabled ? '已禁用' : '已启用')
} catch (error: any) {
toast.error(error.message || '状态更新失败')
} finally {
const next = new Set(updatingStatusKeyIds.value)
next.delete(key.id)
updatingStatusKeyIds.value = next
}
}
async function handleUpdateLabel(keyId: string, label: string) {
if (savingLabelKeyIds.value.has(keyId)) return
savingLabelKeyIds.value = new Set(savingLabelKeyIds.value).add(keyId)
try {
await updateApiKey({ id: keyId, label: label || null })
editingLabel.value = null
await loadApiKeys()
toast.success('标签已更新')
} catch (error: any) {
toast.error(error.message || '标签更新失败')
} finally {
const next = new Set(savingLabelKeyIds.value)
next.delete(keyId)
savingLabelKeyIds.value = next
}
}
function startEditLabel(key: any) {
editingLabel.value = { id: key.id, value: key.label || '' }
}
function cancelEditLabel() {
editingLabel.value = null
}
function currentEditingLabelValue() {
return editingLabel.value?.value ?? ''
}
function submitEditingLabel(keyId: string) {
void handleUpdateLabel(keyId, currentEditingLabelValue())
}
function updateEditingLabelValue(value: string) {
if (!editingLabel.value) return
editingLabel.value.value = value
}
async function copyToClipboard(text: string) {
const copied = await copyText(text)
if (copied) {
toast.success('已复制到剪贴板')
return
}
toast.error('复制失败')
}
function maskKey(prefix: string): string {
return `${prefix}••••••••••••••••`
}
onMounted(() => {
loadApiKeys()
})
return {
loading,
apiKeys,
showCreateModal,
showDeleteModal,
showSingleDeleteModal,
showKeyModal,
createdKey,
editingLabel,
pendingDeleteKey,
creatingKey,
deletingKey,
batchDeleting,
updatingStatusKeyIds,
savingLabelKeyIds,
createForm,
handleCreate,
requestDeleteKey,
handleDelete,
handleBatchDelete,
handleToggleStatus,
startEditLabel,
cancelEditLabel,
currentEditingLabelValue,
submitEditingLabel,
updateEditingLabelValue,
copyToClipboard,
maskKey,
}
}
@@ -0,0 +1,43 @@
import { computed, ref, type Ref } from 'vue'
export function useApiKeysTable(pagedKeys: Ref<any[]>, selectedIds = ref<Set<string>>(new Set())) {
const allSelected = computed(
() =>
pagedKeys.value.length > 0 && pagedKeys.value.every((key) => selectedIds.value.has(key.id)),
)
const indeterminate = computed(
() => pagedKeys.value.some((key) => selectedIds.value.has(key.id)) && !allSelected.value,
)
const selectedRowKeys = computed(() => [...selectedIds.value])
function toggleSelection(keyId: string) {
const next = new Set(selectedIds.value)
if (next.has(keyId)) {
next.delete(keyId)
} else {
next.add(keyId)
}
selectedIds.value = next
}
function toggleAll() {
const next = new Set(selectedIds.value)
if (allSelected.value) {
pagedKeys.value.forEach((key) => next.delete(key.id))
} else {
pagedKeys.value.forEach((key) => next.add(key.id))
}
selectedIds.value = next
}
return {
selectedIds,
allSelected,
indeterminate,
selectedRowKeys,
toggleSelection,
toggleAll,
}
}
+32
View File
@@ -0,0 +1,32 @@
const relaxedActionCellClass = '!px-4'
export const apiKeyColumns = [
{ key: 'selection', label: '', width: '48px', align: 'center' as const },
{ key: 'identity', label: '名称 / 标签', minWidth: '280px', flex: 1.25 },
{ key: 'prefix', label: '密钥前缀', minWidth: '300px', flex: 1.35 },
{ key: 'enabled', label: '状态', width: '112px', align: 'center' as const },
{
key: 'createdAtDisplay',
label: '创建时间',
width: '176px',
mono: true,
tabular: true,
cellClass: 'text-(--cp-text-secondary)',
},
{
key: 'lastUsedAtDisplay',
label: '最后使用',
width: '176px',
mono: true,
tabular: true,
cellClass: 'text-(--cp-text-secondary)',
},
{
key: 'actions',
label: '操作',
width: '108px',
align: 'center' as const,
headerClass: relaxedActionCellClass,
cellClass: relaxedActionCellClass,
},
]
+81 -427
View File
@@ -1,278 +1,60 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { Plus, Trash2, Search, Copy } from '@lucide/vue'
import { ref } from 'vue'
import { Trash2 } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseTable from '@/components/base/BaseTable.vue'
import BaseTable from '@/components/base/BaseTable/index.vue'
import { apiKeyColumns } from './constants'
import { useApiKeyFilters } from './composables/useApiKeyFilters'
import { useApiKeyMutations } from './composables/useApiKeyMutations'
import { useApiKeysTable } from './composables/useApiKeysTable'
import ApiKeyCreateModal from './components/ApiKeyCreateModal.vue'
import ApiKeyFilters from './components/ApiKeyFilters.vue'
import ApiKeyIdentityCell from './components/ApiKeyIdentityCell.vue'
import ApiKeyPrefixCell from './components/ApiKeyPrefixCell.vue'
import ApiKeyStatusToggle from './components/ApiKeyStatusToggle.vue'
import { createApiKey, deleteApiKeys, getApiKeys, updateApiKey } from '@/api'
import { toast } from '@/components/base/BaseToast'
const loading = ref(true)
const apiKeys = ref<any[]>([])
const page = ref(1)
const pageSize = ref(20)
const searchQuery = ref('')
const selectedIds = ref<Set<string>>(new Set())
const showCreateModal = ref(false)
const showDeleteModal = ref(false)
const showSingleDeleteModal = ref(false)
const showKeyModal = ref(false)
const createdKey = ref('')
const editingLabel = ref<{ id: string; value: string } | null>(null)
const pendingDeleteKey = ref<any | null>(null)
const deletingKey = ref(false)
const batchDeleting = ref(false)
const {
loading,
apiKeys,
showCreateModal,
showDeleteModal,
showSingleDeleteModal,
showKeyModal,
createdKey,
editingLabel,
pendingDeleteKey,
creatingKey,
deletingKey,
batchDeleting,
updatingStatusKeyIds,
savingLabelKeyIds,
createForm,
handleCreate,
requestDeleteKey,
handleDelete,
handleBatchDelete,
handleToggleStatus,
startEditLabel,
cancelEditLabel,
currentEditingLabelValue,
submitEditingLabel,
updateEditingLabelValue,
copyToClipboard,
maskKey,
} = useApiKeyMutations(selectedIds)
const createForm = ref({
name: '',
label: '',
})
const { searchQuery, pagedKeys, apiKeyPagination, handlePageChange, handlePageSizeChange } =
useApiKeyFilters(apiKeys)
const apiKeyColumns = [
{ key: 'selection', label: '', width: '48px', align: 'center' as const },
{ key: 'identity', label: '名称 / 标签', minWidth: '280px', flex: 1.25 },
{ key: 'prefix', label: '密钥前缀', minWidth: '300px', flex: 1.35 },
{ key: 'enabled', label: '状态', width: '112px', align: 'center' as const },
{
key: 'createdAtDisplay',
label: '创建时间',
width: '176px',
mono: true,
tabular: true,
cellClass: 'text-(--cp-text-secondary)',
},
{
key: 'lastUsedAtDisplay',
label: '最后使用',
width: '176px',
mono: true,
tabular: true,
cellClass: 'text-(--cp-text-secondary)',
},
{
key: 'actions',
label: '操作',
width: '108px',
align: 'center' as const,
headerClass: '!px-4',
cellClass: '!px-4',
},
]
const filteredKeys = computed(() => {
if (!searchQuery.value) return apiKeys.value
const query = searchQuery.value.toLowerCase()
return apiKeys.value.filter(
(key) =>
key.name.toLowerCase().includes(query) ||
key.label?.toLowerCase().includes(query) ||
key.id.toLowerCase().includes(query),
)
})
const pagedKeys = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredKeys.value.slice(start, start + pageSize.value)
})
const allSelected = computed(
() => pagedKeys.value.length > 0 && pagedKeys.value.every((key) => selectedIds.value.has(key.id)),
const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll } = useApiKeysTable(
pagedKeys,
selectedIds,
)
const indeterminate = computed(
() => pagedKeys.value.some((key) => selectedIds.value.has(key.id)) && !allSelected.value,
)
const selectedRowKeys = computed(() => [...selectedIds.value])
const apiKeyPagination = computed(() => ({
page: page.value,
pageSize: pageSize.value,
total: filteredKeys.value.length,
pageSizes: [10, 20, 50, 100],
}))
async function loadApiKeys() {
try {
loading.value = true
const data = await getApiKeys()
apiKeys.value = data.items
} catch (error: any) {
toast.error(error.message || '加载失败')
} finally {
loading.value = false
}
}
async function handleCreate() {
if (!createForm.value.name.trim()) {
toast.warning('请输入 API Key 名称')
return
}
try {
const result = await createApiKey({
name: createForm.value.name,
label: createForm.value.label || undefined,
})
createdKey.value = result.key
showCreateModal.value = false
showKeyModal.value = true
createForm.value = { name: '', label: '' }
await loadApiKeys()
toast.success('API Key 创建成功')
} catch (error: any) {
toast.error(error.message || '创建失败')
}
}
function requestDeleteKey(key: any) {
pendingDeleteKey.value = key
showSingleDeleteModal.value = true
}
async function handleDelete() {
const keyId = pendingDeleteKey.value?.id
if (!keyId) return
try {
deletingKey.value = true
await deleteApiKeys({ ids: [keyId] })
showSingleDeleteModal.value = false
pendingDeleteKey.value = null
await loadApiKeys()
toast.success('删除成功')
} catch (error: any) {
toast.error(error.message || '删除失败')
} finally {
deletingKey.value = false
}
}
async function handleBatchDelete() {
if (selectedIds.value.size === 0) return
try {
batchDeleting.value = true
const deleteCount = selectedIds.value.size
await deleteApiKeys({ ids: [...selectedIds.value] })
selectedIds.value = new Set()
showDeleteModal.value = false
await loadApiKeys()
toast.success(`已删除 ${deleteCount} 个 API Key`)
} catch (error: any) {
toast.error(error.message || '批量删除失败')
} finally {
batchDeleting.value = false
}
}
async function handleToggleStatus(key: any) {
try {
await updateApiKey({ id: key.id, status: key.enabled ? 'disabled' : 'active' })
await loadApiKeys()
toast.success(key.enabled ? '已禁用' : '已启用')
} catch (error: any) {
toast.error(error.message || '状态更新失败')
}
}
async function handleUpdateLabel(keyId: string, label: string) {
try {
await updateApiKey({ id: keyId, label: label || null })
editingLabel.value = null
await loadApiKeys()
toast.success('标签已更新')
} catch (error: any) {
toast.error(error.message || '标签更新失败')
}
}
function startEditLabel(key: any) {
editingLabel.value = { id: key.id, value: key.label || '' }
}
function cancelEditLabel() {
editingLabel.value = null
}
function currentEditingLabelValue() {
return editingLabel.value?.value ?? ''
}
function submitEditingLabel(keyId: string) {
void handleUpdateLabel(keyId, currentEditingLabelValue())
}
function updateEditingLabelValue(event: Event) {
if (!editingLabel.value) {
return
}
editingLabel.value.value = (event.target as HTMLInputElement).value
}
function toggleSelection(keyId: string) {
if (selectedIds.value.has(keyId)) {
selectedIds.value.delete(keyId)
} else {
selectedIds.value.add(keyId)
}
}
function toggleAll() {
if (allSelected.value) {
pagedKeys.value.forEach((key) => selectedIds.value.delete(key.id))
} else {
pagedKeys.value.forEach((key) => selectedIds.value.add(key.id))
}
}
function handlePageChange(nextPage: number) {
page.value = nextPage
}
function handlePageSizeChange(nextPageSize: number) {
pageSize.value = nextPageSize
page.value = 1
}
function copyToClipboard(text: string) {
navigator.clipboard
.writeText(text)
.then(() => {
toast.success('已复制到剪贴板')
})
.catch(() => {
toast.error('复制失败')
})
}
function maskKey(prefix: string): string {
return `${prefix}••••••••••••••••`
}
onMounted(() => {
loadApiKeys()
})
watch(searchQuery, () => {
page.value = 1
})
watch(filteredKeys, () => {
const totalPages = Math.max(1, Math.ceil(filteredKeys.value.length / pageSize.value))
if (page.value > totalPages) {
page.value = totalPages
}
})
</script>
<template>
@@ -296,40 +78,13 @@ watch(filteredKeys, () => {
body-class="min-h-0 flex-1 px-5 pt-3"
>
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3" aria-label="API Key 筛选">
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-3">
<BaseInput
v-model="searchQuery"
placeholder="搜索名称、标签或 ID"
class="min-w-64 flex-1 sm:max-w-96"
>
<template #prefix>
<Search class="size-4.5 text-(--cp-text-tertiary)" />
</template>
</BaseInput>
<BaseButton
v-if="selectedIds.size > 0"
variant="danger"
:disabled="batchDeleting"
@click="showDeleteModal = true"
>
<template #icon>
<Trash2 class="size-4" />
</template>
删除选中 ({{ selectedIds.size }})
</BaseButton>
</div>
<div class="flex shrink-0 items-center gap-2">
<BaseButton variant="primary" @click="showCreateModal = true">
<template #icon>
<Plus class="size-4" />
</template>
创建 API Key
</BaseButton>
</div>
</div>
<ApiKeyFilters
v-model:search="searchQuery"
:batch-deleting="batchDeleting"
:selected-count="selectedIds.size"
@create="showCreateModal = true"
@delete-selected="showDeleteModal = true"
/>
</template>
<template #body>
@@ -361,72 +116,32 @@ watch(filteredKeys, () => {
</template>
<template #identity="{ row }">
<div class="flex flex-col gap-0.5">
<span class="text-[13px] font-bold text-(--cp-text-primary)">
{{ row.name }}
</span>
<div v-if="editingLabel?.id === row.id" class="flex items-center gap-2">
<input
:value="currentEditingLabelValue()"
type="text"
class="h-(--cp-input-height-inline) min-w-34 rounded-(--cp-input-radius-small) border-0 bg-(--cp-input-soft-bg) px-2.5 text-[13px] font-[650] text-(--cp-text-primary) shadow-(--cp-shadow-input) outline-none transition placeholder:text-(--cp-text-muted) focus:bg-(--cp-input-soft-bg-focus) focus:shadow-(--cp-shadow-input-focus)"
@input="updateEditingLabelValue"
@keyup.enter="submitEditingLabel(row.id)"
@keyup.escape="cancelEditLabel"
/>
<button
class="text-[12px] text-(--cp-accent-primary) hover:underline"
@click="submitEditingLabel(row.id)"
>
保存
</button>
<button
class="text-[12px] text-(--cp-text-tertiary) hover:underline"
@click="cancelEditLabel"
>
取消
</button>
</div>
<button
v-else-if="row.label"
class="text-left text-[12px] font-[650] text-(--cp-text-tertiary) hover:text-(--cp-info-text)"
@click="startEditLabel(row)"
>
{{ row.label }}
</button>
</div>
<ApiKeyIdentityCell
:api-key="row"
:editing="editingLabel?.id === row.id"
:editing-value="currentEditingLabelValue()"
:saving="savingLabelKeyIds.has(row.id)"
@cancel-edit="cancelEditLabel"
@start-edit="startEditLabel"
@submit-edit="submitEditingLabel"
@update-edit="updateEditingLabelValue"
/>
</template>
<template #prefix="{ row }">
<div class="flex items-center gap-2">
<code
class="block min-w-0 truncate font-mono text-[12px] font-[650] text-(--cp-text-primary)"
>
{{ maskKey(row.prefix) }}
</code>
<BaseButton
icon-only
variant="ghost"
size="sm"
label="复制密钥前缀"
@click="copyToClipboard(row.prefix)"
>
<Copy class="size-3.5" />
</BaseButton>
</div>
<ApiKeyPrefixCell
:key-value="row.key"
:masked-prefix="maskKey(row.prefix)"
@copy="copyToClipboard"
/>
</template>
<template #enabled="{ row }">
<button
class="inline-flex h-6 min-w-14 cursor-pointer items-center justify-center rounded-full border-0 px-2 text-[12px] leading-none font-bold"
:class="{
'bg-(--cp-success-bg) text-(--cp-success-text)': row.enabled,
'bg-(--cp-bg-subtle) text-(--cp-text-secondary)': !row.enabled,
}"
@click="handleToggleStatus(row)"
>
{{ row.enabled ? '已启用' : '已禁用' }}
</button>
<ApiKeyStatusToggle
:api-key="row"
:loading="updatingStatusKeyIds.has(row.id)"
@toggle="handleToggleStatus"
/>
</template>
<template #actions="{ row }">
@@ -447,76 +162,15 @@ watch(filteredKeys, () => {
</template>
</BaseCard>
<!-- 创建 API Key 模态框 -->
<BaseModal
<ApiKeyCreateModal
v-model="showCreateModal"
title="创建 API Key"
description="为当前代理管理端生成一个新的访问密钥。创建后请立即保存。"
variant="info"
width="540px"
>
<div class="flex flex-col gap-4">
<div>
<label class="block text-[13px] font-medium text-(--cp-text-secondary) mb-2">
名称 <span class="text-(--cp-danger)">*</span>
</label>
<BaseInput v-model="createForm.name" placeholder="例如:生产环境、测试账号..." />
</div>
<div>
<label class="block text-[13px] font-medium text-(--cp-text-secondary) mb-2">
标签(可选)
</label>
<BaseInput v-model="createForm.label" placeholder="备注信息..." />
</div>
</div>
<template #footer>
<BaseButton variant="ghost" @click="showCreateModal = false"> 取消 </BaseButton>
<BaseButton variant="primary" :disabled="!createForm.name.trim()" @click="handleCreate">
创建
</BaseButton>
</template>
</BaseModal>
<!-- 显示新创建的密钥 -->
<BaseModal
v-model="showKeyModal"
title="API Key 已创建"
description="密钥只会显示一次,关闭弹窗后无法再次查看完整内容。"
variant="success"
width="540px"
>
<div class="flex flex-col gap-4">
<div
class="rounded-(--cp-input-radius-base) border border-(--cp-warning-border) bg-(--cp-warning-bg) px-4 py-3"
>
<p class="m-0 text-[13px] font-semibold text-(--cp-warning-text)">
请妥善保存此密钥,它只会显示一次。
</p>
</div>
<div>
<label class="block text-[13px] font-medium text-(--cp-text-secondary) mb-2">
API Key
</label>
<div class="flex items-center gap-2">
<code
class="flex-1 px-3 py-2.5 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) text-[13px] font-mono text-(--cp-text-primary) break-all"
>
{{ createdKey }}
</code>
<BaseButton icon-only size="md" title="复制" @click="copyToClipboard(createdKey)">
<Copy class="size-4" />
</BaseButton>
</div>
</div>
</div>
<template #footer>
<BaseButton variant="primary" @click="showKeyModal = false"> 我已保存 </BaseButton>
</template>
</BaseModal>
v-model:created-open="showKeyModal"
v-model:form="createForm"
:created-key="createdKey"
:saving="creatingKey"
@copy="copyToClipboard"
@create="handleCreate"
/>
<BaseConfirmModal
v-model="showDeleteModal"
@@ -3,7 +3,7 @@ import { computed, ref } from 'vue'
import BaseCard from '../../../components/base/BaseCard.vue'
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
import BaseSegmented from '../../../components/base/BaseSegmented.vue'
import BaseTable from '../../../components/base/BaseTable.vue'
import BaseTable from '../../../components/base/BaseTable/index.vue'
const props = defineProps<{
rows: any[]
@@ -205,6 +205,7 @@ function formatLatency(ms: number): string {
<BaseSegmented
v-model="activeTab"
:options="tabs"
:disabled="loading"
class="w-full max-w-61.5 sm:w-61.5"
@update:model-value="emit('trendChange', $event)"
/>
@@ -13,6 +13,7 @@ import {
import { getDashboardSummary, getDashboardTrend } from '@/api'
import { withMinimumDuration } from '@/utils/async'
import { formatDateTime } from '@/utils/date'
export function useDashboard(): any {
const metrics = ref<any[]>(metricCards(emptyDashboardSummary()))
@@ -90,7 +91,7 @@ export function useDashboard(): any {
poolSummary.value = summary.poolSummary
capacityInfo.value = summary.capacityInfo
rotationStrategy.value = summary.rotationStrategy ?? null
lastRefreshedAt.value = formatDateTime(new Date())
lastRefreshedAt.value = formatDateTime()
}
function emptyDashboardSummary() {
@@ -389,13 +390,6 @@ export function useDashboard(): any {
return `${ms}ms`
}
function formatDateTime(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
date.getHours(),
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
function startAutoRefresh() {
stopAutoRefresh()
autoRefreshTimer.value = setInterval(() => {
+2 -2
View File
@@ -47,10 +47,10 @@ const {
class="mt-0.5 text-(--cp-normal)"
size="md"
label="刷新概览"
:loading="loading || refreshing"
:disabled="loading || refreshing"
@click="refresh"
>
<RefreshCw :size="19" />
<RefreshCw :size="19" :class="loading || refreshing ? 'animate-spin' : undefined" />
</BaseButton>
</header>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import BaseButton from '@/components/base/BaseButton.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import LogLevelBadge from './LogLevelBadge.vue'
import LogStatusCodeBadge from './LogStatusCodeBadge.vue'
const open = defineModel<boolean>({ default: false })
defineProps<{
log: any
}>()
</script>
<template>
<BaseModal
v-model="open"
title="日志详情"
description="查看单条事件的请求、状态和元数据。"
variant="info"
width="720px"
>
<div v-if="log" class="flex max-h-[min(70vh,760px)] flex-col gap-4 overflow-hidden">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">时间</label>
<p class="m-0 text-[13px] text-(--cp-text-primary)">
{{ log.createdAtDisplay }}
</p>
</div>
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">级别</label>
<LogLevelBadge :level="log.level" />
</div>
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">请求 ID</label>
<code class="text-[13px] font-mono text-(--cp-text-primary)">
{{ log.requestId || '—' }}
</code>
</div>
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">状态码</label>
<LogStatusCodeBadge :status-code="log.statusCode" />
</div>
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">路由</label>
<code class="text-[13px] font-mono text-(--cp-text-primary)">
{{ log.route || '—' }}
</code>
</div>
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">延迟</label>
<span class="text-[13px] text-(--cp-text-primary)">
{{ log.latencyMs !== undefined ? `${log.latencyMs}ms` : '—' }}
</span>
</div>
</div>
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">消息</label>
<p
class="m-0 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5 text-[13px] text-(--cp-text-primary)"
>
{{ log.message }}
</p>
</div>
<div v-if="log.metadata" class="flex min-h-0 flex-1 flex-col">
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">元数据</label>
<BaseScrollbar
max-height="min(42vh, 420px)"
view-class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5"
>
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.65] text-(--cp-text-primary)"
>{{ JSON.stringify(log.metadata, null, 2) }}</pre
>
</BaseScrollbar>
</div>
</div>
<template #footer>
<BaseButton variant="primary" @click="open = false">关闭</BaseButton>
</template>
</BaseModal>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { RefreshCw, Search, Trash2 } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseSelect from '@/components/base/BaseSelect.vue'
defineProps<{
levelOptions: any[]
refreshing: boolean
loading: boolean
clearing: boolean
}>()
const search = defineModel<string>('search', { required: true })
const level = defineModel<string>('level', { required: true })
const emit = defineEmits<{
refresh: []
clear: []
}>()
</script>
<template>
<div class="flex flex-wrap items-center justify-between gap-3" aria-label="日志筛选">
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-3">
<BaseInput
v-model="search"
placeholder="搜索消息、请求 ID 或路由"
class="min-w-64 flex-1 sm:max-w-96"
>
<template #prefix>
<Search class="size-4.5 text-(--cp-text-tertiary)" />
</template>
</BaseInput>
<BaseSelect v-model="level" :options="levelOptions" class="w-34" />
</div>
<div class="flex shrink-0 items-center gap-2">
<BaseButton
icon-only
variant="ghost"
size="md"
label="刷新日志"
:disabled="loading || refreshing"
@click="emit('refresh')"
>
<RefreshCw class="size-4.5" :class="refreshing ? 'animate-spin' : undefined" />
</BaseButton>
<BaseButton variant="danger" :disabled="clearing" @click="emit('clear')">
<template #icon>
<Trash2 class="size-4" />
</template>
清空日志
</BaseButton>
</div>
</div>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { levelColors, levelLabels } from '../constants'
defineProps<{
level: string
compact?: boolean
}>()
function levelLabel(level: string) {
return levelLabels[level] || level
}
function levelClass(level: string) {
return [
levelColors[level]?.bg || 'bg-(--cp-bg-subtle)',
levelColors[level]?.text || 'text-(--cp-text-secondary)',
]
}
</script>
<template>
<span
class="inline-flex items-center justify-center rounded-full px-2 leading-none font-bold"
:class="[compact ? 'h-6 min-w-12 text-[12px]' : 'h-6 text-[12px]', levelClass(level)]"
>
{{ levelLabel(level) }}
</span>
</template>
@@ -0,0 +1,30 @@
<script setup lang="ts">
defineProps<{
statusCode?: number | null
}>()
function statusClass(statusCode?: number | null) {
if (statusCode === undefined || statusCode === null) {
return 'bg-(--cp-bg-subtle) text-(--cp-text-secondary)'
}
if (statusCode >= 200 && statusCode < 300) {
return 'bg-(--cp-success-bg) text-(--cp-success-text)'
}
if (statusCode >= 300 && statusCode < 400) {
return 'bg-(--cp-warning-bg) text-(--cp-warning-text)'
}
return 'bg-(--cp-danger-bg) text-(--cp-danger-text)'
}
</script>
<template>
<span
class="inline-flex h-6 min-w-12 items-center justify-center rounded-full px-2 font-mono text-[12px] leading-none font-bold"
:class="statusClass(statusCode)"
>
{{ statusCode ?? '—' }}
</span>
</template>
@@ -0,0 +1,25 @@
import { ref } from 'vue'
import { getLogDetail } from '@/api'
import { toast } from '@/components/base/BaseToast'
export function useLogDetail() {
const showDetailModal = ref(false)
const selectedLog = ref<any>(null)
async function handleViewDetail(log: any) {
try {
const detail = await getLogDetail({ id: log.id })
selectedLog.value = detail
showDetailModal.value = true
} catch (error: any) {
toast.error(error.message || '加载详情失败')
}
}
return {
showDetailModal,
selectedLog,
handleViewDetail,
}
}
@@ -0,0 +1,64 @@
// @env browser
import { computed, onBeforeUnmount, ref, watch, type Ref } from 'vue'
export function useLogFilters(totalLogs: Ref<number>) {
const page = ref(1)
const pageSize = ref(20)
const searchQuery = ref('')
const filterLevel = ref('')
let searchTimer: number | undefined
let loadLogs: (() => Promise<void> | void) | undefined
const logPagination = computed(() => ({
page: page.value,
pageSize: pageSize.value,
total: totalLogs.value,
pageSizes: [10, 20, 50, 100],
}))
function bindLogLoader(loader: () => Promise<void> | void) {
loadLogs = loader
}
function requestLoad() {
if (loadLogs) {
void loadLogs()
}
}
function handlePageChange(nextPage: number) {
page.value = nextPage
requestLoad()
}
function handlePageSizeChange(nextPageSize: number) {
pageSize.value = nextPageSize
page.value = 1
requestLoad()
}
watch([searchQuery, filterLevel], () => {
page.value = 1
if (searchTimer) {
window.clearTimeout(searchTimer)
}
searchTimer = window.setTimeout(requestLoad, 250)
})
onBeforeUnmount(() => {
if (searchTimer) {
window.clearTimeout(searchTimer)
}
})
return {
page,
pageSize,
searchQuery,
filterLevel,
logPagination,
bindLogLoader,
handlePageChange,
handlePageSizeChange,
}
}
@@ -0,0 +1,91 @@
import { computed, onMounted, ref, type Ref } from 'vue'
import { clearLogs, getLogs } from '@/api'
import { toast } from '@/components/base/BaseToast'
import { withMinimumDuration } from '@/utils/async'
export function useLogsTable(options: {
page: Ref<number>
pageSize: Ref<number>
searchQuery: Ref<string>
filterLevel: Ref<string>
totalLogs: Ref<number>
}) {
const loading = ref(true)
const logs = ref<any[]>([])
const showClearModal = ref(false)
const refreshingList = ref(false)
const clearingLogs = ref(false)
const loaded = ref(false)
const initialLoading = computed(() => loading.value && !loaded.value)
async function loadLogs() {
try {
loading.value = true
const result = await getLogs({
page: options.page.value,
pageSize: options.pageSize.value,
level: options.filterLevel.value || undefined,
search: options.searchQuery.value || undefined,
})
logs.value = result.items
options.pageSize.value = result.page.pageSize ?? options.pageSize.value
options.totalLogs.value = result.page.total ?? result.items.length
options.page.value = result.page.page ?? options.page.value
if (logs.value.length === 0 && options.totalLogs.value > 0 && options.page.value > 1) {
options.page.value = Math.max(1, result.page.totalPages ?? options.page.value - 1)
await loadLogs()
}
} catch (error: any) {
toast.error(error.message || '加载失败')
} finally {
loading.value = false
loaded.value = true
}
}
async function refreshLogs() {
if (refreshingList.value || loading.value) return
refreshingList.value = true
try {
await withMinimumDuration(loadLogs)
} finally {
refreshingList.value = false
}
}
async function handleClearLogs() {
if (clearingLogs.value) return
try {
clearingLogs.value = true
await clearLogs()
showClearModal.value = false
options.page.value = 1
await loadLogs()
toast.success('日志已清空')
} catch (error: any) {
toast.error(error.message || '清空失败')
} finally {
clearingLogs.value = false
}
}
onMounted(() => {
loadLogs()
})
return {
loading,
logs,
showClearModal,
refreshingList,
clearingLogs,
initialLoading,
loadLogs,
refreshLogs,
handleClearLogs,
}
}
+70
View File
@@ -0,0 +1,70 @@
export const logColumns = [
{
key: 'createdAtDisplay',
label: '时间',
width: '176px',
mono: true,
tabular: true,
cellClass: 'text-(--cp-text-secondary)',
},
{ key: 'level', label: '级别', width: '96px', ellipsis: false },
{
key: 'kind',
label: '类型',
width: '128px',
cellClass: 'font-[650] text-(--cp-text-secondary)',
},
{ key: 'requestId', label: '请求 ID', minWidth: '220px', flex: 1.1, mono: true },
{ key: 'route', label: '路由', minWidth: '156px', flex: 0.8, mono: true },
{
key: 'statusCode',
label: '状态',
width: '92px',
align: 'center' as const,
ellipsis: false,
mono: true,
tabular: true,
},
{
key: 'latencyMs',
label: '延迟',
width: '96px',
align: 'right' as const,
ellipsis: false,
mono: true,
tabular: true,
format: (value?: number | null) =>
value === undefined || value === null ? '—' : `${value} ms`,
cellClass: 'text-(--cp-text-secondary)',
},
{ key: 'message', label: '消息', minWidth: '260px', flex: 1.35 },
{
key: 'actions',
label: '操作',
width: '92px',
align: 'center' as const,
ellipsis: false,
headerClass: '!px-4',
cellClass: '!px-4',
},
]
export const levelColors: Record<string, { bg: string; text: string }> = {
info: { bg: 'bg-(--cp-info-bg)', text: 'text-(--cp-info-text)' },
warn: { bg: 'bg-(--cp-warning-bg)', text: 'text-(--cp-warning-text)' },
error: { bg: 'bg-(--cp-danger-bg)', text: 'text-(--cp-danger-text)' },
debug: { bg: 'bg-(--cp-bg-subtle)', text: 'text-(--cp-text-secondary)' },
}
export const levelOptions = [
{ label: '全部级别', value: '' },
{ label: '信息', value: 'info' },
{ label: '错误', value: 'error' },
]
export const levelLabels: Record<string, string> = {
debug: '调试',
info: '信息',
warn: '警告',
error: '错误',
}
+53 -348
View File
@@ -1,226 +1,54 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Search, RefreshCw, Trash2, Eye } from '@lucide/vue'
import { Eye } from '@lucide/vue'
import { ref } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseSelect from '@/components/base/BaseSelect.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import BaseTable from '@/components/base/BaseTable.vue'
import { withMinimumDuration } from '@/utils/async'
import BaseTable from '@/components/base/BaseTable/index.vue'
import { levelOptions, logColumns } from './constants'
import { useLogDetail } from './composables/useLogDetail'
import { useLogFilters } from './composables/useLogFilters'
import { useLogsTable } from './composables/useLogsTable'
import LogDetailModal from './components/LogDetailModal.vue'
import LogFilters from './components/LogFilters.vue'
import LogLevelBadge from './components/LogLevelBadge.vue'
import LogStatusCodeBadge from './components/LogStatusCodeBadge.vue'
import { clearLogs, getLogDetail, getLogs } from '@/api'
import { toast } from '@/components/base/BaseToast'
const loading = ref(true)
const logs = ref<any[]>([])
const totalLogs = ref(0)
const page = ref(1)
const pageSize = ref(20)
const searchQuery = ref('')
const filterLevel = ref('')
const showClearModal = ref(false)
const showDetailModal = ref(false)
const selectedLog = ref<any>(null)
const refreshingList = ref(false)
const clearingLogs = ref(false)
const loaded = ref(false)
let searchTimer: number | undefined
const logColumns = [
{
key: 'createdAtDisplay',
label: '时间',
width: '176px',
mono: true,
tabular: true,
cellClass: 'text-(--cp-text-secondary)',
},
{ key: 'level', label: '级别', width: '96px', ellipsis: false },
{
key: 'kind',
label: '类型',
width: '128px',
cellClass: 'font-[650] text-(--cp-text-secondary)',
},
{ key: 'requestId', label: '请求 ID', minWidth: '220px', flex: 1.1, mono: true },
{ key: 'route', label: '路由', minWidth: '156px', flex: 0.8, mono: true },
{
key: 'statusCode',
label: '状态',
width: '92px',
align: 'center' as const,
ellipsis: false,
mono: true,
tabular: true,
},
{
key: 'latencyMs',
label: '延迟',
width: '96px',
align: 'right' as const,
ellipsis: false,
mono: true,
tabular: true,
cellClass: 'text-(--cp-text-secondary)',
},
{ key: 'message', label: '消息', minWidth: '260px', flex: 1.35 },
{
key: 'actions',
label: '操作',
width: '92px',
align: 'center' as const,
ellipsis: false,
headerClass: '!px-4',
cellClass: '!px-4',
},
]
const {
page,
pageSize,
searchQuery,
filterLevel,
logPagination,
bindLogLoader,
handlePageChange,
handlePageSizeChange,
} = useLogFilters(totalLogs)
const levelColors: Record<string, { bg: string; text: string }> = {
info: { bg: 'bg-(--cp-info-bg)', text: 'text-(--cp-info-text)' },
warn: { bg: 'bg-(--cp-warning-bg)', text: 'text-(--cp-warning-text)' },
error: { bg: 'bg-(--cp-danger-bg)', text: 'text-(--cp-danger-text)' },
debug: { bg: 'bg-(--cp-bg-subtle)', text: 'text-(--cp-text-secondary)' },
}
const filteredLogs = computed(() => logs.value)
const initialLoading = computed(() => loading.value && !loaded.value)
const levelOptions = [
{ label: '全部级别', value: '' },
{ label: '信息', value: 'info' },
{ label: '错误', value: 'error' },
]
const logPagination = computed(() => ({
page: page.value,
pageSize: pageSize.value,
total: totalLogs.value,
pageSizes: [10, 20, 50, 100],
}))
async function loadLogs() {
try {
loading.value = true
const result = await getLogs({
page: page.value,
pageSize: pageSize.value,
level: filterLevel.value || undefined,
search: searchQuery.value || undefined,
})
logs.value = result.items
pageSize.value = result.page.pageSize ?? pageSize.value
totalLogs.value = result.page.total ?? result.items.length
page.value = result.page.page ?? page.value
if (logs.value.length === 0 && totalLogs.value > 0 && page.value > 1) {
page.value = Math.max(1, result.page.totalPages ?? page.value - 1)
await loadLogs()
}
} catch (error: any) {
toast.error(error.message || '加载失败')
} finally {
loading.value = false
loaded.value = true
}
}
async function refreshLogs() {
if (refreshingList.value || loading.value) return
refreshingList.value = true
try {
await withMinimumDuration(loadLogs)
} finally {
refreshingList.value = false
}
}
async function handleClearLogs() {
try {
clearingLogs.value = true
await clearLogs()
showClearModal.value = false
page.value = 1
await loadLogs()
toast.success('日志已清空')
} catch (error: any) {
toast.error(error.message || '清空失败')
} finally {
clearingLogs.value = false
}
}
function handlePageChange(nextPage: number) {
page.value = nextPage
void loadLogs()
}
function handlePageSizeChange(nextPageSize: number) {
pageSize.value = nextPageSize
page.value = 1
void loadLogs()
}
async function handleViewDetail(log: any) {
try {
const detail = await getLogDetail({ id: log.id })
selectedLog.value = detail
showDetailModal.value = true
} catch (error: any) {
toast.error(error.message || '加载详情失败')
}
}
function getLevelLabel(level: string): string {
const labels: Record<string, string> = {
debug: '调试',
info: '信息',
warn: '警告',
error: '错误',
}
return labels[level] || level
}
function statusClass(statusCode?: number) {
if (statusCode === undefined || statusCode === null) {
return 'bg-(--cp-bg-subtle) text-(--cp-text-secondary)'
}
if (statusCode >= 200 && statusCode < 300) {
return 'bg-(--cp-success-bg) text-(--cp-success-text)'
}
if (statusCode >= 300 && statusCode < 400) {
return 'bg-(--cp-warning-bg) text-(--cp-warning-text)'
}
return 'bg-(--cp-danger-bg) text-(--cp-danger-text)'
}
function latencyText(latencyMs?: number) {
return latencyMs === undefined || latencyMs === null ? '—' : `${latencyMs} ms`
}
onMounted(() => {
loadLogs()
const {
loading,
logs,
showClearModal,
refreshingList,
clearingLogs,
initialLoading,
loadLogs,
refreshLogs,
handleClearLogs,
} = useLogsTable({
page,
pageSize,
searchQuery,
filterLevel,
totalLogs,
})
watch([searchQuery, filterLevel], () => {
page.value = 1
if (searchTimer) {
window.clearTimeout(searchTimer)
}
searchTimer = window.setTimeout(() => {
void loadLogs()
}, 250)
})
const { showDetailModal, selectedLog, handleViewDetail } = useLogDetail()
onBeforeUnmount(() => {
if (searchTimer) {
window.clearTimeout(searchTimer)
}
})
bindLogLoader(loadLogs)
</script>
<template>
@@ -244,48 +72,22 @@ onBeforeUnmount(() => {
body-class="min-h-0 flex-1 px-5 pt-3"
>
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3" aria-label="日志筛选">
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-3">
<BaseInput
v-model="searchQuery"
placeholder="搜索消息、请求 ID 或路由"
class="min-w-64 flex-1 sm:max-w-96"
>
<template #prefix>
<Search class="size-4.5 text-(--cp-text-tertiary)" />
</template>
</BaseInput>
<BaseSelect v-model="filterLevel" :options="levelOptions" class="w-34" />
</div>
<div class="flex shrink-0 items-center gap-2">
<BaseButton
icon-only
variant="ghost"
size="md"
label="刷新日志"
:loading="refreshingList"
:disabled="loading"
@click="refreshLogs"
>
<RefreshCw class="size-4.5" />
</BaseButton>
<BaseButton variant="danger" :disabled="clearingLogs" @click="showClearModal = true">
<template #icon>
<Trash2 class="size-4" />
</template>
清空日志
</BaseButton>
</div>
</div>
<LogFilters
v-model:level="filterLevel"
v-model:search="searchQuery"
:clearing="clearingLogs"
:level-options="levelOptions"
:loading="loading"
:refreshing="refreshingList"
@clear="showClearModal = true"
@refresh="refreshLogs"
/>
</template>
<template #body>
<BaseTable
:columns="logColumns"
:rows="filteredLogs"
:rows="logs"
:loading="loading"
:pagination="logPagination"
empty-text="暂无日志记录"
@@ -294,28 +96,11 @@ onBeforeUnmount(() => {
@page-size-change="handlePageSizeChange"
>
<template #level="{ row }">
<span
class="inline-flex h-6 min-w-12 items-center justify-center rounded-full px-2 text-[12px] leading-none font-bold"
:class="[
levelColors[row.level]?.bg || 'bg-(--cp-bg-subtle)',
levelColors[row.level]?.text || 'text-(--cp-text-secondary)',
]"
>
{{ getLevelLabel(row.level) }}
</span>
<LogLevelBadge :level="row.level" compact />
</template>
<template #statusCode="{ row }">
<span
class="inline-flex h-6 min-w-12 items-center justify-center rounded-full px-2 leading-none font-bold"
:class="statusClass(row.statusCode)"
>
{{ row.statusCode ?? '—' }}
</span>
</template>
<template #latencyMs="{ row }">
{{ latencyText(row.latencyMs) }}
<LogStatusCodeBadge :status-code="row.statusCode" />
</template>
<template #actions="{ row }">
@@ -347,86 +132,6 @@ onBeforeUnmount(() => {
@confirm="handleClearLogs"
/>
<!-- 日志详情 -->
<BaseModal
v-model="showDetailModal"
title="日志详情"
description="查看单条事件的请求、状态和元数据。"
variant="info"
width="720px"
>
<div v-if="selectedLog" class="flex max-h-[min(70vh,760px)] flex-col gap-4 overflow-hidden">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">时间</label>
<p class="m-0 text-[13px] text-(--cp-text-primary)">
{{ selectedLog.createdAtDisplay }}
</p>
</div>
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">级别</label>
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-[12px] font-medium"
:class="[
levelColors[selectedLog.level]?.bg || 'bg-(--cp-bg-subtle)',
levelColors[selectedLog.level]?.text || 'text-(--cp-text-secondary)',
]"
>
{{ getLevelLabel(selectedLog.level) }}
</span>
</div>
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">请求 ID</label>
<code class="text-[13px] font-mono text-(--cp-text-primary)">
{{ selectedLog.requestId || '—' }}
</code>
</div>
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">状态码</label>
<span class="text-[13px] font-mono text-(--cp-text-primary)">
{{ selectedLog.statusCode ?? '—' }}
</span>
</div>
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">路由</label>
<code class="text-[13px] font-mono text-(--cp-text-primary)">
{{ selectedLog.route || '—' }}
</code>
</div>
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">延迟</label>
<span class="text-[13px] text-(--cp-text-primary)">
{{ selectedLog.latencyMs !== undefined ? `${selectedLog.latencyMs}ms` : '—' }}
</span>
</div>
</div>
<div>
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">消息</label>
<p
class="m-0 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5 text-[13px] text-(--cp-text-primary)"
>
{{ selectedLog.message }}
</p>
</div>
<div v-if="selectedLog.metadata" class="flex min-h-0 flex-1 flex-col">
<label class="block text-[11px] font-bold text-(--cp-text-muted) mb-1">元数据</label>
<BaseScrollbar
max-height="min(42vh, 420px)"
view-class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5"
>
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.65] text-(--cp-text-primary)"
>{{ JSON.stringify(selectedLog.metadata, null, 2) }}</pre
>
</BaseScrollbar>
</div>
</div>
<template #footer>
<BaseButton variant="primary" @click="showDetailModal = false"> 关闭 </BaseButton>
</template>
</BaseModal>
<LogDetailModal v-model="showDetailModal" :log="selectedLog" />
</div>
</template>
+9 -8
View File
@@ -64,6 +64,7 @@ async function refreshSettings() {
}
async function handleSave() {
if (saving.value || loading.value || refreshing.value) return
try {
saving.value = true
const patch = {
@@ -99,18 +100,18 @@ onMounted(() => {
</div>
<div class="mt-0.5 flex shrink-0 items-center gap-2">
<BaseButton
variant="ghost"
:loading="refreshing"
:disabled="loading"
@click="refreshSettings"
>
<BaseButton variant="ghost" :disabled="loading || refreshing" @click="refreshSettings">
<template #icon>
<RefreshCw class="size-4" />
<RefreshCw class="size-4" :class="refreshing ? 'animate-spin' : undefined" />
</template>
重置
</BaseButton>
<BaseButton variant="primary" :disabled="saving" @click="handleSave">
<BaseButton
variant="primary"
:loading="saving"
:disabled="loading || refreshing"
@click="handleSave"
>
<template #icon>
<Save class="size-4" />
</template>