mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
feat: refine admin console and api keys
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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(),
|
||||
)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 更小。
|
||||
|
||||
后续继续优化时,必须按本文件的目标目录和落地顺序推进。
|
||||
@@ -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",
|
||||
|
||||
Generated
+8
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+29
-228
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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_... 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,
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
+149
-1463
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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user