mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
feat: refine admin web experience
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -16,7 +16,9 @@ use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
admin::auth::session::require_admin_session,
|
||||
admin::response::{AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse},
|
||||
admin::response::{
|
||||
AdminEnvelope, AdminError, AdminResponse, CursorPageMeta, NumberedPageMeta, PageMeta,
|
||||
},
|
||||
admin::{
|
||||
accounts::service::{AdminAccountError, AdminAccountMetadata, AdminAccountMetadataUpdate},
|
||||
monitoring::{
|
||||
@@ -28,10 +30,11 @@ use crate::{
|
||||
},
|
||||
http::middleware::request_id::RequestId,
|
||||
infra::{
|
||||
json::{clamp_limit, clamp_page, Page},
|
||||
json::{clamp_limit, clamp_page, total_pages, Page},
|
||||
time::{china_datetime, china_relative_time, china_rfc3339, china_rfc3339_str},
|
||||
},
|
||||
runtime::state::AppState,
|
||||
upstream::accounts::model::AccountStatus,
|
||||
upstream::accounts::store::StoredAccount,
|
||||
};
|
||||
|
||||
@@ -314,6 +317,68 @@ impl AccountListStats {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AdminAccountSummaryData {
|
||||
total: u64,
|
||||
active: u64,
|
||||
high_usage: u64,
|
||||
attention: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AdminAccountPageData {
|
||||
items: Vec<AdminAccountData>,
|
||||
page: PageMeta,
|
||||
summary: AdminAccountSummaryData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AdminAccountPageEnvelope {
|
||||
code: u32,
|
||||
message: String,
|
||||
data: AdminAccountPageData,
|
||||
}
|
||||
|
||||
impl AdminAccountPageEnvelope {
|
||||
fn cursor(page: Page<AdminAccountData>, limit: u32, summary: AdminAccountSummaryData) -> Self {
|
||||
Self {
|
||||
code: 200,
|
||||
message: "OK".into(),
|
||||
data: AdminAccountPageData {
|
||||
items: page.items,
|
||||
page: PageMeta::Cursor(CursorPageMeta {
|
||||
limit,
|
||||
next_cursor: page.next_cursor,
|
||||
}),
|
||||
summary,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered(
|
||||
page: crate::infra::json::NumberedPage<AdminAccountData>,
|
||||
summary: AdminAccountSummaryData,
|
||||
) -> Self {
|
||||
Self {
|
||||
code: 200,
|
||||
message: "OK".into(),
|
||||
data: AdminAccountPageData {
|
||||
page: PageMeta::Numbered(NumberedPageMeta {
|
||||
page: page.page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
total_pages: total_pages(page.total, page.page_size),
|
||||
}),
|
||||
items: page.items,
|
||||
summary,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BatchDeleteAccountsData {
|
||||
@@ -430,7 +495,9 @@ 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;
|
||||
|
||||
if use_numbered_page {
|
||||
return match state
|
||||
@@ -452,7 +519,7 @@ pub(crate) async fn accounts(
|
||||
};
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminPageEnvelope::numbered(page, request_id),
|
||||
AdminAccountPageEnvelope::numbered(page, summary),
|
||||
))
|
||||
}
|
||||
Err(error) => Err(account_error(error, request_id)),
|
||||
@@ -476,7 +543,7 @@ pub(crate) async fn accounts(
|
||||
};
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminPageEnvelope::ok(page, limit, request_id),
|
||||
AdminAccountPageEnvelope::cursor(page, limit, summary),
|
||||
))
|
||||
}
|
||||
Err(error) => Err(account_error(error, request_id)),
|
||||
@@ -874,6 +941,81 @@ 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 total = accounts.len() as u64;
|
||||
let active = accounts
|
||||
.iter()
|
||||
.filter(|account| account.status == AccountStatus::Active)
|
||||
.count() as u64;
|
||||
let high_usage = accounts
|
||||
.iter()
|
||||
.filter(|account| {
|
||||
stats
|
||||
.quota_by_account
|
||||
.get(&account.id)
|
||||
.is_some_and(account_quota_has_high_usage)
|
||||
})
|
||||
.count() as u64;
|
||||
let attention = accounts
|
||||
.iter()
|
||||
.filter(|account| account_summary_needs_attention(account.status))
|
||||
.count() as u64;
|
||||
|
||||
AdminAccountSummaryData {
|
||||
total,
|
||||
active,
|
||||
high_usage,
|
||||
attention,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_all_account_metadata(
|
||||
state: &AppState,
|
||||
search: Option<&str>,
|
||||
) -> 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),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let total = result.total;
|
||||
accounts.extend(result.items);
|
||||
if accounts.len() as u64 >= total || total == 0 {
|
||||
return accounts;
|
||||
}
|
||||
page = page.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn account_quota_has_high_usage(quota: &AdminAccountQuotaData) -> bool {
|
||||
quota
|
||||
.windows
|
||||
.iter()
|
||||
.any(|window| window.used_percent.is_some_and(|percent| percent >= 80.0))
|
||||
}
|
||||
|
||||
fn account_summary_needs_attention(status: AccountStatus) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
AccountStatus::Expired | AccountStatus::Disabled | AccountStatus::Banned
|
||||
)
|
||||
}
|
||||
|
||||
async fn list_all_usage_records(state: &AppState) -> Vec<AdminUsageRecord> {
|
||||
let mut cursor = None;
|
||||
let mut records = Vec::new();
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! 管理端设置处理器。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::State,
|
||||
@@ -17,31 +15,17 @@ use crate::{
|
||||
admin::response::{AdminEnvelope, AdminError, AdminResponse},
|
||||
config::{
|
||||
settings::{AdminSettingsPatch, RuntimeSettingsError, SettingsServiceError},
|
||||
types::{AppConfig, QuotaWarningThresholds},
|
||||
types::AppConfig,
|
||||
},
|
||||
http::middleware::request_id::RequestId,
|
||||
runtime::state::AppState,
|
||||
};
|
||||
|
||||
const ALLOWED_SETTINGS_KEYS: [&str; 18] = [
|
||||
const ALLOWED_SETTINGS_KEYS: [&str; 4] = [
|
||||
"defaultModel",
|
||||
"defaultReasoningEffort",
|
||||
"serviceTier",
|
||||
"modelAliases",
|
||||
"refreshEnabled",
|
||||
"refreshMarginSeconds",
|
||||
"refreshConcurrency",
|
||||
"maxConcurrentPerAccount",
|
||||
"requestIntervalMs",
|
||||
"rotationStrategy",
|
||||
"tierPriority",
|
||||
"quotaRefreshIntervalMinutes",
|
||||
"quotaWarningThresholds",
|
||||
"quotaSkipExhausted",
|
||||
"logsEnabled",
|
||||
"logsCapacity",
|
||||
"logsCaptureBody",
|
||||
"usageHistoryRetentionDays",
|
||||
];
|
||||
|
||||
/// 管理端设置响应。
|
||||
@@ -49,46 +33,18 @@ const ALLOWED_SETTINGS_KEYS: [&str; 18] = [
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminSettingsData {
|
||||
pub default_model: String,
|
||||
pub default_reasoning_effort: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
pub model_aliases: BTreeMap<String, String>,
|
||||
pub refresh_enabled: bool,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u32,
|
||||
pub max_concurrent_per_account: usize,
|
||||
pub request_interval_ms: u64,
|
||||
pub rotation_strategy: String,
|
||||
pub tier_priority: Vec<String>,
|
||||
pub quota_refresh_interval_minutes: u64,
|
||||
pub quota_warning_thresholds: QuotaWarningThresholds,
|
||||
pub quota_skip_exhausted: bool,
|
||||
pub logs_enabled: bool,
|
||||
pub logs_capacity: u32,
|
||||
pub logs_capture_body: bool,
|
||||
pub usage_history_retention_days: Option<u64>,
|
||||
}
|
||||
|
||||
impl AdminSettingsData {
|
||||
fn from_config(config: &AppConfig) -> Self {
|
||||
Self {
|
||||
default_model: config.model.default_model.clone(),
|
||||
default_reasoning_effort: config.model.default_reasoning_effort.clone(),
|
||||
service_tier: config.model.service_tier.clone(),
|
||||
model_aliases: config.model.aliases.clone(),
|
||||
refresh_enabled: config.auth.refresh_enabled,
|
||||
refresh_margin_seconds: config.auth.refresh_margin_seconds,
|
||||
refresh_concurrency: config.auth.refresh_concurrency,
|
||||
max_concurrent_per_account: config.auth.max_concurrent_per_account,
|
||||
request_interval_ms: config.auth.request_interval_ms,
|
||||
rotation_strategy: config.auth.rotation_strategy.clone(),
|
||||
tier_priority: config.auth.tier_priority.clone(),
|
||||
quota_refresh_interval_minutes: config.quota.refresh_interval_minutes,
|
||||
quota_warning_thresholds: config.quota.warning_thresholds.clone(),
|
||||
quota_skip_exhausted: config.quota.skip_exhausted,
|
||||
logs_enabled: config.logging.enabled,
|
||||
logs_capacity: config.logging.capacity,
|
||||
logs_capture_body: config.logging.capture_body,
|
||||
usage_history_retention_days: config.usage_stats.history_retention_days,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-143
@@ -107,40 +107,12 @@ impl Default for AdminQuotaWarningThresholds {
|
||||
pub struct AdminSettingsPatch {
|
||||
/// 默认模型 ID。
|
||||
pub default_model: Option<String>,
|
||||
/// 默认 reasoning effort。
|
||||
pub default_reasoning_effort: Option<String>,
|
||||
/// 默认服务层级。
|
||||
pub service_tier: Option<String>,
|
||||
/// 模型别名映射。
|
||||
pub model_aliases: Option<BTreeMap<String, String>>,
|
||||
/// 是否启用访问令牌刷新。
|
||||
pub refresh_enabled: Option<bool>,
|
||||
/// 访问令牌过期前多少秒开始刷新。
|
||||
pub refresh_margin_seconds: Option<u64>,
|
||||
/// 访问令牌刷新并发数。
|
||||
pub refresh_concurrency: Option<u32>,
|
||||
/// 单账号最大并发请求数。
|
||||
pub max_concurrent_per_account: Option<usize>,
|
||||
/// 同账号请求间隔毫秒数。
|
||||
pub request_interval_ms: Option<u64>,
|
||||
/// 账号轮换策略。
|
||||
pub rotation_strategy: Option<String>,
|
||||
/// 计划类型优先级。
|
||||
pub tier_priority: Option<Vec<String>>,
|
||||
/// 配额刷新间隔分钟数。
|
||||
pub quota_refresh_interval_minutes: Option<u64>,
|
||||
/// 配额预警阈值。
|
||||
pub quota_warning_thresholds: Option<AdminQuotaWarningThresholds>,
|
||||
/// 配额耗尽账号是否跳过调度。
|
||||
pub quota_skip_exhausted: Option<bool>,
|
||||
/// 是否启用事件日志。
|
||||
pub logs_enabled: Option<bool>,
|
||||
/// 事件日志容量。
|
||||
pub logs_capacity: Option<u32>,
|
||||
/// 事件日志是否捕获请求/响应体。
|
||||
pub logs_capture_body: Option<bool>,
|
||||
/// 用量历史保留天数。
|
||||
pub usage_history_retention_days: Option<u64>,
|
||||
}
|
||||
|
||||
/// 设置领域服务。
|
||||
@@ -156,27 +128,6 @@ impl SettingsService {
|
||||
if let Some(default_model) = patch.default_model {
|
||||
current.default_model = non_empty_string("defaultModel", default_model)?;
|
||||
}
|
||||
if let Some(default_reasoning_effort) = patch.default_reasoning_effort {
|
||||
current.default_reasoning_effort = Some(non_empty_string(
|
||||
"defaultReasoningEffort",
|
||||
default_reasoning_effort,
|
||||
)?);
|
||||
}
|
||||
if let Some(service_tier) = patch.service_tier {
|
||||
current.service_tier = Some(non_empty_string("serviceTier", service_tier)?);
|
||||
}
|
||||
if let Some(model_aliases) = patch.model_aliases {
|
||||
current.model_aliases = validate_aliases(model_aliases)?;
|
||||
}
|
||||
if let Some(refresh_enabled) = patch.refresh_enabled {
|
||||
current.refresh_enabled = refresh_enabled;
|
||||
}
|
||||
if let Some(refresh_margin_seconds) = patch.refresh_margin_seconds {
|
||||
current.refresh_margin_seconds = refresh_margin_seconds;
|
||||
}
|
||||
if let Some(refresh_concurrency) = patch.refresh_concurrency {
|
||||
current.refresh_concurrency = positive_u32("refreshConcurrency", refresh_concurrency)?;
|
||||
}
|
||||
if let Some(max_concurrent_per_account) = patch.max_concurrent_per_account {
|
||||
current.max_concurrent_per_account =
|
||||
positive_usize("maxConcurrentPerAccount", max_concurrent_per_account)?;
|
||||
@@ -187,37 +138,6 @@ impl SettingsService {
|
||||
if let Some(rotation_strategy) = patch.rotation_strategy {
|
||||
current.rotation_strategy = validate_rotation_strategy(rotation_strategy)?;
|
||||
}
|
||||
if let Some(tier_priority) = patch.tier_priority {
|
||||
current.tier_priority = validate_tier_priority(tier_priority)?;
|
||||
}
|
||||
if let Some(quota_refresh_interval_minutes) = patch.quota_refresh_interval_minutes {
|
||||
current.quota_refresh_interval_minutes = positive_u64(
|
||||
"quotaRefreshIntervalMinutes",
|
||||
quota_refresh_interval_minutes,
|
||||
)?;
|
||||
}
|
||||
if let Some(quota_warning_thresholds) = patch.quota_warning_thresholds {
|
||||
validate_thresholds("a_warning_thresholds)?;
|
||||
current.quota_warning_thresholds = quota_warning_thresholds;
|
||||
}
|
||||
if let Some(quota_skip_exhausted) = patch.quota_skip_exhausted {
|
||||
current.quota_skip_exhausted = quota_skip_exhausted;
|
||||
}
|
||||
if let Some(logs_enabled) = patch.logs_enabled {
|
||||
current.logs_enabled = logs_enabled;
|
||||
}
|
||||
if let Some(logs_capacity) = patch.logs_capacity {
|
||||
current.logs_capacity = positive_u32("logsCapacity", logs_capacity)?;
|
||||
}
|
||||
if let Some(logs_capture_body) = patch.logs_capture_body {
|
||||
current.logs_capture_body = logs_capture_body;
|
||||
}
|
||||
if let Some(usage_history_retention_days) = patch.usage_history_retention_days {
|
||||
current.usage_history_retention_days = Some(positive_u64(
|
||||
"usageHistoryRetentionDays",
|
||||
usage_history_retention_days,
|
||||
)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -250,26 +170,6 @@ impl SettingsServiceError {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_aliases(
|
||||
aliases: BTreeMap<String, String>,
|
||||
) -> Result<BTreeMap<String, String>, SettingsServiceError> {
|
||||
let mut validated = BTreeMap::new();
|
||||
for (alias, target) in aliases {
|
||||
let alias = non_empty_string("modelAliases", alias)?;
|
||||
let target = non_empty_string("modelAliases", target)?;
|
||||
validated.insert(alias, target);
|
||||
}
|
||||
Ok(validated)
|
||||
}
|
||||
|
||||
fn validate_tier_priority(tiers: Vec<String>) -> Result<Vec<String>, SettingsServiceError> {
|
||||
let mut validated = Vec::with_capacity(tiers.len());
|
||||
for tier in tiers {
|
||||
validated.push(non_empty_string("tierPriority", tier)?);
|
||||
}
|
||||
Ok(validated)
|
||||
}
|
||||
|
||||
fn validate_rotation_strategy(strategy: String) -> Result<String, SettingsServiceError> {
|
||||
let strategy = non_empty_string("rotationStrategy", strategy)?;
|
||||
if ROTATION_STRATEGIES.contains(&strategy.as_str()) {
|
||||
@@ -282,29 +182,6 @@ fn validate_rotation_strategy(strategy: String) -> Result<String, SettingsServic
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_thresholds(
|
||||
thresholds: &AdminQuotaWarningThresholds,
|
||||
) -> Result<(), SettingsServiceError> {
|
||||
if thresholds.primary.is_empty() && thresholds.secondary.is_empty() {
|
||||
return Err(invalid_field(
|
||||
"quotaWarningThresholds",
|
||||
"must include at least one threshold",
|
||||
));
|
||||
}
|
||||
if thresholds
|
||||
.primary
|
||||
.iter()
|
||||
.chain(thresholds.secondary.iter())
|
||||
.any(|t| *t > 100)
|
||||
{
|
||||
return Err(invalid_field(
|
||||
"quotaWarningThresholds",
|
||||
"thresholds must be between 0 and 100",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn non_empty_string(field: &str, value: String) -> Result<String, SettingsServiceError> {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty() {
|
||||
@@ -314,22 +191,6 @@ fn non_empty_string(field: &str, value: String) -> Result<String, SettingsServic
|
||||
}
|
||||
}
|
||||
|
||||
fn positive_u32(field: &str, value: u32) -> Result<u32, SettingsServiceError> {
|
||||
if value == 0 {
|
||||
Err(invalid_field(field, "must be greater than 0"))
|
||||
} else {
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn positive_u64(field: &str, value: u64) -> Result<u64, SettingsServiceError> {
|
||||
if value == 0 {
|
||||
Err(invalid_field(field, "must be greater than 0"))
|
||||
} else {
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn positive_usize(field: &str, value: usize) -> Result<usize, SettingsServiceError> {
|
||||
if value == 0 {
|
||||
Err(invalid_field(field, "must be greater than 0"))
|
||||
@@ -411,12 +272,13 @@ pub enum RuntimeSettingsError {
|
||||
}
|
||||
|
||||
fn admin_settings_from_config(config: &AppConfig) -> AdminSettings {
|
||||
let defaults = AdminSettings::default();
|
||||
AdminSettings {
|
||||
default_model: config.model.default_model.clone(),
|
||||
default_reasoning_effort: config.model.default_reasoning_effort.clone(),
|
||||
service_tier: config.model.service_tier.clone(),
|
||||
model_aliases: config.model.aliases.clone(),
|
||||
refresh_enabled: config.auth.refresh_enabled,
|
||||
refresh_enabled: defaults.refresh_enabled,
|
||||
refresh_margin_seconds: config.auth.refresh_margin_seconds,
|
||||
refresh_concurrency: config.auth.refresh_concurrency,
|
||||
max_concurrent_per_account: config.auth.max_concurrent_per_account,
|
||||
@@ -428,9 +290,9 @@ fn admin_settings_from_config(config: &AppConfig) -> AdminSettings {
|
||||
primary: config.quota.warning_thresholds.primary.clone(),
|
||||
secondary: config.quota.warning_thresholds.secondary.clone(),
|
||||
},
|
||||
quota_skip_exhausted: config.quota.skip_exhausted,
|
||||
logs_enabled: config.logging.enabled,
|
||||
logs_capacity: config.logging.capacity,
|
||||
quota_skip_exhausted: defaults.quota_skip_exhausted,
|
||||
logs_enabled: defaults.logs_enabled,
|
||||
logs_capacity: defaults.logs_capacity,
|
||||
logs_capture_body: config.logging.capture_body,
|
||||
usage_history_retention_days: config.usage_stats.history_retention_days,
|
||||
}
|
||||
|
||||
@@ -519,17 +519,15 @@ impl AccountPool {
|
||||
pub fn capacity_summary(&mut self, now: DateTime<Utc>) -> AccountCapacitySummary {
|
||||
self.cleanup_stale_slots(now);
|
||||
self.refresh_account_statuses(now);
|
||||
let is_capacity_account = |account: &Account| {
|
||||
account.status == AccountStatus::Active
|
||||
&& AccountService::quota_available_at(account, now, self.options.skip_quota_limited)
|
||||
&& AccountService::cloudflare_available_at(account, now)
|
||||
};
|
||||
let active_accounts = self
|
||||
.accounts
|
||||
.values()
|
||||
.filter(|account| {
|
||||
account.status == AccountStatus::Active
|
||||
&& AccountService::quota_available_at(
|
||||
account,
|
||||
now,
|
||||
self.options.skip_quota_limited,
|
||||
)
|
||||
})
|
||||
.filter(|account| is_capacity_account(account))
|
||||
.count();
|
||||
let total_slots = active_accounts * self.options.max_concurrent_per_account;
|
||||
let used_slots = self
|
||||
@@ -538,7 +536,7 @@ impl AccountPool {
|
||||
.filter(|(account_id, _)| {
|
||||
self.accounts
|
||||
.get(*account_id)
|
||||
.is_some_and(|account| account.status == AccountStatus::Active)
|
||||
.is_some_and(|account| is_capacity_account(account))
|
||||
})
|
||||
.map(|(_, slots)| slots.len().min(self.options.max_concurrent_per_account))
|
||||
.sum();
|
||||
|
||||
+5
-5
@@ -14,24 +14,24 @@
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@lucide/vue": "^1.18.0",
|
||||
"axios": "^1.18.0",
|
||||
"@lucide/vue": "^1.21.0",
|
||||
"axios": "^1.18.1",
|
||||
"echarts": "^6.1.0",
|
||||
"gsap": "^3.15.0",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"vue": "^3.5.38",
|
||||
"vue": "^3.5.39",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/node": "^26.0.1",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"prettier": "^3.8.4",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vite": "^8.1.0",
|
||||
"vue-tsc": "^3.3.5"
|
||||
},
|
||||
"prettier": {
|
||||
|
||||
Generated
+299
-258
File diff suppressed because it is too large
Load Diff
@@ -20,5 +20,4 @@ export * from './modules/dashboard'
|
||||
export * from './modules/settings'
|
||||
|
||||
// 请求工具
|
||||
export * from './request'
|
||||
export type * from './types'
|
||||
export { default as request } from './request'
|
||||
|
||||
+32
-181
@@ -1,206 +1,57 @@
|
||||
import { requestJson, requestPageJson } from '../request'
|
||||
import type { PaginatedResult } from '../types'
|
||||
import request from '../request'
|
||||
|
||||
export interface Account {
|
||||
id: string
|
||||
email?: string
|
||||
accountId?: string
|
||||
userId?: string
|
||||
planType?: string
|
||||
status: 'active' | 'expired' | 'disabled' | 'banned' | 'quota_exhausted' | 'refreshing'
|
||||
label?: string
|
||||
accessTokenExpiresAt?: string
|
||||
accessTokenExpiresAtDisplay?: string
|
||||
addedAt: string
|
||||
addedAtDisplay: string
|
||||
updatedAt: string
|
||||
updatedAtDisplay: string
|
||||
quota: AccountQuota
|
||||
usage: AccountUsage
|
||||
}
|
||||
|
||||
export interface AccountQuota {
|
||||
refreshedAtDisplay: string
|
||||
windows: AccountQuotaWindow[]
|
||||
}
|
||||
|
||||
export interface AccountQuotaWindow {
|
||||
key: string
|
||||
group: 'monthly' | 'shortTerm' | 'other'
|
||||
windowSeconds?: number | null
|
||||
labelDisplay: string
|
||||
usedPercent?: number | null
|
||||
usedPercentDisplay: string
|
||||
resetAtDisplay: string
|
||||
windowUsedDisplay: string
|
||||
}
|
||||
|
||||
export interface AccountUsage {
|
||||
requestCount: number
|
||||
requestCountDisplay: string
|
||||
emptyResponseCount: number
|
||||
inputTokens: number
|
||||
inputTokensDisplay: string
|
||||
outputTokens: number
|
||||
outputTokensDisplay: string
|
||||
cachedTokens: number
|
||||
cachedTokensDisplay: string
|
||||
reasoningTokens: number
|
||||
totalTokens: number
|
||||
totalTokensDisplay: string
|
||||
imageInputTokens: number
|
||||
imageOutputTokens: number
|
||||
imageTokensDisplay: string
|
||||
imageRequestCount: number
|
||||
imageRequestFailedCount: number
|
||||
createdTokens: number
|
||||
createdTokensDisplay: string
|
||||
readTokens: number
|
||||
readTokensDisplay: string
|
||||
lastUsedAt?: string
|
||||
lastUsedAtDisplay: string
|
||||
models: AccountModelUsage[]
|
||||
}
|
||||
|
||||
export interface AccountModelUsage {
|
||||
model: string
|
||||
requestCount: number
|
||||
requestCountDisplay: string
|
||||
successRate: number
|
||||
successRateDisplay: string
|
||||
inputTokens: number
|
||||
inputTokensDisplay: string
|
||||
outputTokens: number
|
||||
outputTokensDisplay: string
|
||||
cachedTokens: number
|
||||
cachedTokensDisplay: string
|
||||
totalTokens: number
|
||||
totalTokensDisplay: string
|
||||
totalCostUsd: number
|
||||
totalCostUsdDisplay: string
|
||||
lastUsedAt?: string
|
||||
lastUsedAtDisplay: string
|
||||
}
|
||||
|
||||
export interface AccountsQuery {
|
||||
page: number
|
||||
pageSize: number
|
||||
search?: string
|
||||
}
|
||||
|
||||
export function getAccounts(): Promise<Account[]>
|
||||
export function getAccounts(query: AccountsQuery): Promise<PaginatedResult<Account>>
|
||||
export async function getAccounts(query?: AccountsQuery) {
|
||||
if (query) {
|
||||
return requestPageJson<Account>('/api/admin/accounts', {
|
||||
method: 'GET',
|
||||
params: compactQuery(query),
|
||||
})
|
||||
}
|
||||
|
||||
const result = await requestPageJson<Account>('/api/admin/accounts')
|
||||
return result.items
|
||||
}
|
||||
|
||||
function compactQuery(query: AccountsQuery) {
|
||||
return {
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
search: query.search?.trim() || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateAccountPayload {
|
||||
refreshToken: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function createAccount(payload: CreateAccountPayload) {
|
||||
return requestJson<Account>('/api/admin/accounts', {
|
||||
method: 'POST',
|
||||
data: payload,
|
||||
export function getAccounts(params?: any) {
|
||||
return request({
|
||||
url: '/api/admin/accounts',
|
||||
method: 'GET',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteAccount(accountId: string) {
|
||||
return requestJson<{ deleted: number }>('/api/admin/accounts/delete', {
|
||||
export function createAccount(data: any) {
|
||||
return request({
|
||||
url: '/api/admin/accounts',
|
||||
method: 'POST',
|
||||
data: { ids: [accountId] },
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function refreshAccount(accountId: string) {
|
||||
return requestJson<Account>('/api/admin/accounts/refresh', {
|
||||
export function deleteAccounts(data: any) {
|
||||
return request({
|
||||
url: '/api/admin/accounts/delete',
|
||||
method: 'POST',
|
||||
data: { id: accountId },
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export interface UpdateAccountPayload {
|
||||
label?: string | null
|
||||
email?: string | null
|
||||
accountId?: string | null
|
||||
userId?: string | null
|
||||
planType?: string | null
|
||||
status?: Account['status']
|
||||
}
|
||||
|
||||
export function updateAccount(accountId: string, payload: UpdateAccountPayload) {
|
||||
return requestJson<Account>('/api/admin/accounts/update', {
|
||||
export function refreshAccount(data: any) {
|
||||
return request({
|
||||
url: '/api/admin/accounts/refresh',
|
||||
method: 'POST',
|
||||
data: { id: accountId, ...payload },
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateAccountLabel(accountId: string, label: string | null) {
|
||||
return updateAccount(accountId, { label })
|
||||
}
|
||||
|
||||
export function updateAccountStatus(accountId: string, status: Account['status']) {
|
||||
return updateAccount(accountId, { status })
|
||||
}
|
||||
|
||||
export function batchDeleteAccounts(accountIds: string[]) {
|
||||
return requestJson<{ deleted: number }>('/api/admin/accounts/delete', {
|
||||
export function updateAccount(data: any) {
|
||||
return request({
|
||||
url: '/api/admin/accounts/update',
|
||||
method: 'POST',
|
||||
data: { ids: accountIds },
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export interface QuotaInfo {
|
||||
requestsCount: number
|
||||
requestsLimit: number
|
||||
resetAt: string
|
||||
export function getAccountQuota(params: any) {
|
||||
return request({
|
||||
url: '/api/admin/accounts/quota',
|
||||
method: 'GET',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
export function getAccountQuota(accountId: string) {
|
||||
const params = new URLSearchParams({ id: accountId })
|
||||
return requestJson<QuotaInfo>(`/api/admin/accounts/quota?${params}`)
|
||||
}
|
||||
|
||||
export interface AccountHealthCheckSummary {
|
||||
total: number
|
||||
alive: number
|
||||
dead: number
|
||||
skipped: number
|
||||
}
|
||||
|
||||
export interface AccountHealthCheckResult {
|
||||
id: string
|
||||
email?: string | null
|
||||
result: 'alive' | 'dead' | string
|
||||
error?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
export interface AccountHealthCheckData {
|
||||
summary: AccountHealthCheckSummary
|
||||
results: AccountHealthCheckResult[]
|
||||
}
|
||||
|
||||
export function testAccountConnection(accountId: string) {
|
||||
return requestJson<AccountHealthCheckData>('/api/admin/accounts/health-check', {
|
||||
export function testAccountConnection(data: any) {
|
||||
return request({
|
||||
url: '/api/admin/accounts/health-check',
|
||||
method: 'POST',
|
||||
data: { ids: [accountId] },
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,66 +1,34 @@
|
||||
import { requestJson, requestPageJson } from '../request'
|
||||
import request from '../request'
|
||||
|
||||
// ==================== API Keys 管理 ====================
|
||||
|
||||
export interface ClientApiKey {
|
||||
id: string
|
||||
name: string
|
||||
label?: string
|
||||
prefix: string
|
||||
enabled: boolean
|
||||
createdAt: string
|
||||
createdAtDisplay: string
|
||||
lastUsedAt?: string
|
||||
lastUsedAtDisplay: string
|
||||
}
|
||||
|
||||
export interface CreateApiKeyPayload {
|
||||
name: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface CreateApiKeyResponse {
|
||||
key: string
|
||||
id: string
|
||||
prefix: string
|
||||
}
|
||||
|
||||
export async function getApiKeys() {
|
||||
const result = await requestPageJson<ClientApiKey>('/api/admin/keys')
|
||||
return result.items
|
||||
}
|
||||
|
||||
export function createApiKey(payload: CreateApiKeyPayload) {
|
||||
return requestJson<CreateApiKeyResponse>('/api/admin/keys', {
|
||||
method: 'POST',
|
||||
data: payload,
|
||||
export function getApiKeys() {
|
||||
return request({
|
||||
url: '/api/admin/keys',
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteApiKey(keyId: string) {
|
||||
return requestJson<{ deleted: number }>('/api/admin/keys/delete', {
|
||||
export function createApiKey(data: any) {
|
||||
return request({
|
||||
url: '/api/admin/keys',
|
||||
method: 'POST',
|
||||
data: { ids: [keyId] },
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateApiKeyLabel(keyId: string, label: string | null) {
|
||||
return requestJson<ClientApiKey>('/api/admin/keys/update', {
|
||||
export function deleteApiKeys(data: any) {
|
||||
return request({
|
||||
url: '/api/admin/keys/delete',
|
||||
method: 'POST',
|
||||
data: { id: keyId, label },
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateApiKeyStatus(keyId: string, enabled: boolean) {
|
||||
return requestJson<ClientApiKey>('/api/admin/keys/update', {
|
||||
export function updateApiKey(data: any) {
|
||||
return request({
|
||||
url: '/api/admin/keys/update',
|
||||
method: 'POST',
|
||||
data: { id: keyId, status: enabled ? 'active' : 'disabled' },
|
||||
})
|
||||
}
|
||||
|
||||
export function batchDeleteApiKeys(keyIds: string[]) {
|
||||
return requestJson<{ deleted: number }>('/api/admin/keys/delete', {
|
||||
method: 'POST',
|
||||
data: { ids: keyIds },
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
+13
-21
@@ -1,31 +1,23 @@
|
||||
import { requestJson } from '../request'
|
||||
import request from '../request'
|
||||
|
||||
// ==================== 认证相关 ====================
|
||||
|
||||
export interface AuthStatus {
|
||||
authenticated: boolean
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username?: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export function login(payload: LoginRequest) {
|
||||
return requestJson<LoginResponse>('/api/admin/login', {
|
||||
export function login(data: any) {
|
||||
return request({
|
||||
url: '/api/admin/login',
|
||||
method: 'POST',
|
||||
data: payload,
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function getAuthStatus() {
|
||||
return requestJson<AuthStatus>('/api/admin/auth/status')
|
||||
return request({
|
||||
url: '/api/admin/auth/status',
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return requestJson<void>('/api/admin/logout', { method: 'POST' })
|
||||
return request({
|
||||
url: '/api/admin/logout',
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,144 +1,16 @@
|
||||
import { requestJson } from '../request'
|
||||
|
||||
export type DashboardTrendKind = 'usage' | 'latency' | 'errors'
|
||||
|
||||
export interface DashboardSummary {
|
||||
cards: DashboardCards
|
||||
trend: DashboardTrend
|
||||
healthTimeline: DashboardHealthTimeline
|
||||
accountUsage: DashboardAccountUsage[]
|
||||
serviceStatuses: DashboardServiceStatus[]
|
||||
eventLogs: DashboardEventLog[]
|
||||
poolSummary: DashboardAccountPoolSummary
|
||||
capacityInfo: DashboardAccountCapacityInfo
|
||||
rotationStrategy?: string
|
||||
}
|
||||
|
||||
export interface DashboardHealthTimeline {
|
||||
title: string
|
||||
description: string
|
||||
rangeDisplay: string
|
||||
reliabilityDisplay: string
|
||||
oldestLabel: string
|
||||
newestLabel: string
|
||||
points: string
|
||||
}
|
||||
|
||||
export interface DashboardCards {
|
||||
accounts: DashboardAccountsCard
|
||||
traffic: DashboardTrafficCard
|
||||
tokens: DashboardTokenCard
|
||||
cache: DashboardCacheCard
|
||||
}
|
||||
|
||||
export interface DashboardAccountsCard {
|
||||
total: number
|
||||
enabled: number
|
||||
abnormal: number
|
||||
}
|
||||
|
||||
export interface DashboardTrafficCard {
|
||||
todayRequests: number
|
||||
yesterdayRequests: number
|
||||
totalRequests: number
|
||||
rpm: number
|
||||
tpm: number
|
||||
}
|
||||
|
||||
export interface DashboardTokenCard {
|
||||
todayTokens: number
|
||||
yesterdayTokens: number
|
||||
totalTokens: number
|
||||
todayCostUsd?: number | null
|
||||
totalCostUsd?: number | null
|
||||
}
|
||||
|
||||
export interface DashboardCacheCard {
|
||||
todayHitRate?: number | null
|
||||
yesterdayHitRate?: number | null
|
||||
totalHitRate?: number | null
|
||||
totalCachedTokens: number
|
||||
firstTokenLatencyMs?: number | null
|
||||
completionLatencyMs?: number | null
|
||||
}
|
||||
|
||||
export interface DashboardTrend {
|
||||
kind: DashboardTrendKind
|
||||
points: DashboardTrendPoint[]
|
||||
summary: DashboardTrendSummary[]
|
||||
}
|
||||
|
||||
export interface DashboardTrendPoint {
|
||||
time: string
|
||||
requests: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cachedTokens: number
|
||||
tokens: number
|
||||
errors: number
|
||||
latency: number
|
||||
maxLatency: number
|
||||
minLatency: number
|
||||
successRate: number
|
||||
}
|
||||
|
||||
export interface DashboardTrendSummary {
|
||||
label: string
|
||||
value: number
|
||||
ratio?: number | null
|
||||
}
|
||||
|
||||
export interface DashboardAccountUsage {
|
||||
name: string
|
||||
email: string
|
||||
plan: string
|
||||
requests: number
|
||||
tokens: number
|
||||
quotaUsedPercent?: number | null
|
||||
lastUsed: string
|
||||
status: 'active' | 'expired' | 'disabled' | 'banned' | 'quota_exhausted' | 'refreshing'
|
||||
}
|
||||
|
||||
export interface DashboardEventLog {
|
||||
id: string
|
||||
time: string
|
||||
level: 'debug' | 'info' | 'warn' | 'error'
|
||||
requestId?: string
|
||||
route?: string
|
||||
model?: string
|
||||
statusCode?: number
|
||||
latencyMs?: number
|
||||
}
|
||||
|
||||
export interface DashboardServiceStatus {
|
||||
label: string
|
||||
value: string
|
||||
detail: string
|
||||
tone: 'normal' | 'info' | 'success' | 'warning' | 'danger'
|
||||
}
|
||||
|
||||
export interface DashboardAccountPoolSummary {
|
||||
total: number
|
||||
active: number
|
||||
expired: number
|
||||
quotaExhausted: number
|
||||
refreshing: number
|
||||
disabled: number
|
||||
banned: number
|
||||
}
|
||||
|
||||
export interface DashboardAccountCapacityInfo {
|
||||
maxConcurrentPerAccount: number
|
||||
totalSlots: number
|
||||
usedSlots: number
|
||||
availableSlots: number
|
||||
}
|
||||
import request from '../request'
|
||||
|
||||
export function getDashboardSummary() {
|
||||
return requestJson<DashboardSummary>('/api/admin/dashboard/summary')
|
||||
return request({
|
||||
url: '/api/admin/dashboard/summary',
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export function getDashboardTrend(kind: DashboardTrendKind) {
|
||||
const params = new URLSearchParams({ kind })
|
||||
return requestJson<DashboardTrend>(`/api/admin/dashboard/trend?${params}`)
|
||||
export function getDashboardTrend(params: any) {
|
||||
return request({
|
||||
url: '/api/admin/dashboard/trend',
|
||||
method: 'GET',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
+15
-70
@@ -1,79 +1,24 @@
|
||||
import { requestJson, requestPageJson } from '../request'
|
||||
import type { PaginatedResult } from '../types'
|
||||
import request from '../request'
|
||||
|
||||
export interface EventLog {
|
||||
id: string
|
||||
requestId?: string
|
||||
kind: string
|
||||
level: 'debug' | 'info' | 'warn' | 'error'
|
||||
accountId?: string
|
||||
route?: string
|
||||
model?: string
|
||||
statusCode?: number
|
||||
transport?: string
|
||||
attemptIndex?: number
|
||||
upstreamStatusCode?: number
|
||||
failureClass?: string
|
||||
responseId?: string
|
||||
upstreamRequestId?: string
|
||||
latencyMs?: number
|
||||
message: string
|
||||
metadata?: any
|
||||
createdAt: string
|
||||
createdAtDisplay: string
|
||||
export function getLogs(params?: any) {
|
||||
return request({
|
||||
url: '/api/admin/logs',
|
||||
method: 'GET',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
export interface LogsQuery {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
level?: EventLog['level']
|
||||
kind?: string
|
||||
accountId?: string
|
||||
requestId?: string
|
||||
route?: string
|
||||
model?: string
|
||||
statusCode?: number
|
||||
transport?: string
|
||||
search?: string
|
||||
}
|
||||
|
||||
export type LogsPageQuery = LogsQuery & {
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export function getLogs(query: LogsPageQuery): Promise<PaginatedResult<EventLog>>
|
||||
export function getLogs(query?: LogsQuery): Promise<EventLog[]>
|
||||
export async function getLogs(query: LogsQuery = {}) {
|
||||
const params = new URLSearchParams()
|
||||
if (query.page) params.set('page', String(query.page))
|
||||
if (query.pageSize) params.set('pageSize', String(query.pageSize))
|
||||
if (query.level) params.set('level', query.level)
|
||||
if (query.kind) params.set('kind', query.kind)
|
||||
if (query.accountId) params.set('accountId', query.accountId)
|
||||
if (query.requestId) params.set('requestId', query.requestId)
|
||||
if (query.route) params.set('route', query.route)
|
||||
if (query.model) params.set('model', query.model)
|
||||
if (query.statusCode) params.set('statusCode', String(query.statusCode))
|
||||
if (query.transport) params.set('transport', query.transport)
|
||||
if (query.search) params.set('search', query.search)
|
||||
|
||||
const url = `/api/admin/logs${params.toString() ? `?${params}` : ''}`
|
||||
if (query.page || query.pageSize) {
|
||||
return requestPageJson<EventLog>(url)
|
||||
}
|
||||
|
||||
const result = await requestPageJson<EventLog>(url)
|
||||
return result.items
|
||||
}
|
||||
|
||||
export function getLogDetail(logId: string) {
|
||||
const params = new URLSearchParams({ id: logId })
|
||||
return requestJson<EventLog>(`/api/admin/logs/detail?${params}`)
|
||||
export function getLogDetail(params: any) {
|
||||
return request({
|
||||
url: '/api/admin/logs/detail',
|
||||
method: 'GET',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
export function clearLogs() {
|
||||
return requestJson<{ cleared: number }>('/api/admin/logs/delete', {
|
||||
return request({
|
||||
url: '/api/admin/logs/delete',
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,41 +1,16 @@
|
||||
import { requestJson } from '../request'
|
||||
|
||||
// ==================== 设置管理 ====================
|
||||
|
||||
export interface QuotaWarningThresholds {
|
||||
primary: number[]
|
||||
secondary: number[]
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
defaultModel: string
|
||||
defaultReasoningEffort?: string | null
|
||||
serviceTier?: string | null
|
||||
modelAliases?: Record<string, string>
|
||||
refreshEnabled: boolean
|
||||
refreshMarginSeconds?: number
|
||||
refreshConcurrency?: number
|
||||
maxConcurrentPerAccount: number
|
||||
requestIntervalMs: number
|
||||
rotationStrategy: string
|
||||
tierPriority?: string[]
|
||||
quotaRefreshIntervalMinutes?: number
|
||||
quotaWarningThresholds: QuotaWarningThresholds
|
||||
quotaSkipExhausted: boolean
|
||||
logsEnabled: boolean
|
||||
logsCapacity: number
|
||||
logsCaptureBody?: boolean
|
||||
usageHistoryRetentionDays?: number | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
import request from '../request'
|
||||
|
||||
export function getSettings() {
|
||||
return requestJson<Settings>('/api/admin/settings')
|
||||
return request({
|
||||
url: '/api/admin/settings',
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export function updateSettings(settings: Partial<Settings>) {
|
||||
return requestJson<Settings>('/api/admin/settings', {
|
||||
export function updateSettings(data: any) {
|
||||
return request({
|
||||
url: '/api/admin/settings',
|
||||
method: 'POST',
|
||||
data: settings,
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,71 +1,16 @@
|
||||
import { requestJson, requestPageJson } from '../request'
|
||||
import type { PaginatedResult } from '../types'
|
||||
|
||||
// ==================== 使用统计 ====================
|
||||
|
||||
export interface UsageSummary {
|
||||
accountCount: number
|
||||
requestCount: number
|
||||
emptyResponseCount: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cachedTokens: number
|
||||
reasoningTokens: number
|
||||
totalTokens: number
|
||||
imageInputTokens: number
|
||||
imageOutputTokens: number
|
||||
imageRequestCount: number
|
||||
imageRequestFailedCount: number
|
||||
}
|
||||
import request from '../request'
|
||||
|
||||
export function getUsageSummary() {
|
||||
return requestJson<UsageSummary>('/api/admin/usage/summary')
|
||||
return request({
|
||||
url: '/api/admin/usage/summary',
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export interface AccountUsageStats {
|
||||
accountId: string
|
||||
email?: string
|
||||
label?: string
|
||||
planType?: string
|
||||
requestCount: number
|
||||
emptyResponseCount: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cachedTokens: number
|
||||
reasoningTokens: number
|
||||
totalTokens: number
|
||||
imageInputTokens: number
|
||||
imageOutputTokens: number
|
||||
imageRequestCount: number
|
||||
imageRequestFailedCount: number
|
||||
lastUsedAt?: string
|
||||
lastUsedAtDisplay: string
|
||||
}
|
||||
|
||||
export interface UsageStatsQuery {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export type UsageStatsPageQuery = UsageStatsQuery & {
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export function getUsageStats(
|
||||
query: UsageStatsPageQuery,
|
||||
): Promise<PaginatedResult<AccountUsageStats>>
|
||||
export function getUsageStats(query?: UsageStatsQuery): Promise<AccountUsageStats[]>
|
||||
export async function getUsageStats(query: UsageStatsQuery = {}) {
|
||||
const params = new URLSearchParams()
|
||||
if (query.page) params.set('page', String(query.page))
|
||||
if (query.pageSize) params.set('pageSize', String(query.pageSize))
|
||||
const url = `/api/admin/usage${params.toString() ? `?${params}` : ''}`
|
||||
|
||||
if (query.page || query.pageSize) {
|
||||
return requestPageJson<AccountUsageStats>(url)
|
||||
}
|
||||
|
||||
const result = await requestPageJson<AccountUsageStats>(url)
|
||||
return result.items
|
||||
export function getUsageStats(params?: any) {
|
||||
return request({
|
||||
url: '/api/admin/usage',
|
||||
method: 'GET',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
+8
-73
@@ -6,9 +6,7 @@ import axios, {
|
||||
type InternalAxiosRequestConfig,
|
||||
} from 'axios'
|
||||
|
||||
import type { ApiEnvelope, ApiPageEnvelope, PaginatedResult } from './types'
|
||||
|
||||
export class ApiError extends Error {
|
||||
class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
@@ -20,19 +18,15 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export interface RequestConfig extends AxiosRequestConfig {
|
||||
interface RequestConfig extends AxiosRequestConfig {
|
||||
skipErrorHandler?: boolean
|
||||
skipAuth?: boolean
|
||||
}
|
||||
|
||||
const http: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/',
|
||||
baseURL: import.meta.env.DEV ? '/dev' : '',
|
||||
timeout: 30000,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
http.interceptors.request.use(
|
||||
@@ -56,7 +50,7 @@ http.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
return response
|
||||
},
|
||||
(error: AxiosError<ApiEnvelope<unknown>>) => {
|
||||
(error: AxiosError<any>) => {
|
||||
const { response, config } = error
|
||||
|
||||
if ((config as RequestConfig)?.skipErrorHandler) {
|
||||
@@ -89,7 +83,7 @@ http.interceptors.response.use(
|
||||
},
|
||||
)
|
||||
|
||||
function isApiEnvelope<T>(value: unknown): value is ApiEnvelope<T> {
|
||||
function isApiEnvelope(value: any): value is { data: any } {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
@@ -99,73 +93,14 @@ function isApiEnvelope<T>(value: unknown): value is ApiEnvelope<T> {
|
||||
)
|
||||
}
|
||||
|
||||
function isApiPageEnvelope<T>(value: unknown): value is ApiPageEnvelope<T> {
|
||||
return (
|
||||
isApiEnvelope<unknown>(value) &&
|
||||
typeof value.data === 'object' &&
|
||||
value.data !== null &&
|
||||
'items' in value.data &&
|
||||
'page' in value.data
|
||||
)
|
||||
}
|
||||
|
||||
export async function requestJson<T = any, D = any>(
|
||||
url: string,
|
||||
config?: RequestConfig,
|
||||
): Promise<T> {
|
||||
const response = await http.request<ApiEnvelope<T> | T, AxiosResponse<ApiEnvelope<T> | T>, D>({
|
||||
url,
|
||||
export default async function request<T = any>(config: RequestConfig): Promise<T> {
|
||||
const response = await http.request<any, AxiosResponse<any>>({
|
||||
...config,
|
||||
})
|
||||
|
||||
if (isApiEnvelope<T>(response.data)) {
|
||||
if (isApiEnvelope(response.data)) {
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function requestPageJson<T = any, D = any>(
|
||||
url: string,
|
||||
config?: RequestConfig,
|
||||
): Promise<PaginatedResult<T>> {
|
||||
const response = await http.request<ApiPageEnvelope<T>, AxiosResponse<ApiPageEnvelope<T>>, D>({
|
||||
url,
|
||||
...config,
|
||||
})
|
||||
|
||||
if (!isApiPageEnvelope<T>(response.data)) {
|
||||
throw new ApiError('分页响应格式无效', response.status)
|
||||
}
|
||||
|
||||
return {
|
||||
items: response.data.data.items,
|
||||
page: response.data.data.page,
|
||||
}
|
||||
}
|
||||
|
||||
export function get<T = any>(url: string, params?: any, config?: RequestConfig): Promise<T> {
|
||||
return requestJson<T>(url, {
|
||||
method: 'GET',
|
||||
params,
|
||||
...config,
|
||||
})
|
||||
}
|
||||
|
||||
export function post<T = any, D = any>(url: string, data?: D, config?: RequestConfig): Promise<T> {
|
||||
return requestJson<T, D>(url, {
|
||||
method: 'POST',
|
||||
data,
|
||||
...config,
|
||||
})
|
||||
}
|
||||
|
||||
export { http }
|
||||
|
||||
export default {
|
||||
http,
|
||||
request: requestJson,
|
||||
requestPage: requestPageJson,
|
||||
get,
|
||||
post,
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
export interface ApiEnvelope<T> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface ApiPageMeta {
|
||||
limit?: number
|
||||
page?: number
|
||||
pageSize?: number
|
||||
total?: number
|
||||
totalPages?: number
|
||||
}
|
||||
|
||||
export interface ApiPageData<T> {
|
||||
items: T[]
|
||||
page: ApiPageMeta
|
||||
}
|
||||
|
||||
export type ApiPageEnvelope<T> = ApiEnvelope<ApiPageData<T>>
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[]
|
||||
page: ApiPageMeta
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { LoaderCircle } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
type ButtonVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'ghost'
|
||||
type ButtonSize = 'sm' | 'default' | 'lg'
|
||||
type ButtonSize = 'sm' | 'default' | 'md' | 'lg'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -11,6 +11,10 @@ const props = withDefaults(
|
||||
size?: ButtonSize
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
active?: boolean
|
||||
iconOnly?: boolean
|
||||
label?: string
|
||||
title?: string
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
}>(),
|
||||
{
|
||||
@@ -18,10 +22,14 @@ const props = withDefaults(
|
||||
size: 'default',
|
||||
loading: false,
|
||||
disabled: false,
|
||||
active: false,
|
||||
iconOnly: false,
|
||||
type: 'button',
|
||||
},
|
||||
)
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const variantClasses = {
|
||||
default:
|
||||
'bg-(--cp-bg-subtle) text-(--cp-text-primary) hover:bg-(--cp-default-bg-hover) active:bg-(--cp-default-bg-active)',
|
||||
@@ -37,33 +45,81 @@ const variantClasses = {
|
||||
'bg-transparent text-(--cp-text-secondary) hover:bg-(--cp-bg-subtle) active:bg-(--cp-bg-muted)',
|
||||
}
|
||||
|
||||
const iconOnlyDefaultClasses =
|
||||
'bg-(--cp-bg-surface) text-(--cp-text-secondary) shadow-(--cp-shadow-control) hover:bg-(--cp-default-bg-hover) hover:text-(--cp-normal) active:bg-(--cp-default-bg-active)'
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'h-8 gap-1.5 px-3 text-xs',
|
||||
default: 'h-10 gap-2 px-4 text-[13px]',
|
||||
lg: 'h-12 gap-2.5 px-5 text-[15px]',
|
||||
sm: 'h-7 gap-1.5 px-3 text-xs',
|
||||
default: 'h-9 gap-2 px-4 text-[13px]',
|
||||
md: 'h-11 gap-2 px-4 text-[13px]',
|
||||
lg: 'h-10.5 gap-2.5 px-5 text-[15px]',
|
||||
}
|
||||
|
||||
const iconOnlySizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'size-8 rounded-(--cp-icon-button-radius)',
|
||||
default: 'size-9 rounded-(--cp-icon-button-radius)',
|
||||
md: 'size-11 rounded-(--cp-icon-button-radius)',
|
||||
lg: 'size-10.5 rounded-(--cp-icon-button-radius)',
|
||||
}
|
||||
|
||||
const loadingIconSize: Record<ButtonSize, number> = {
|
||||
sm: 14,
|
||||
default: 15,
|
||||
md: 16,
|
||||
lg: 17,
|
||||
}
|
||||
|
||||
const classes = computed(() => [
|
||||
'inline-flex items-center justify-center rounded-(--cp-button-radius-base) border-0 font-[720] leading-[1.15] transition-all cursor-pointer outline-none',
|
||||
sizeClasses[props.size],
|
||||
'inline-flex items-center justify-center border-0 font-[720] leading-[1.15] transition-[background-color,box-shadow,color,opacity,transform] duration-150 cursor-pointer outline-none',
|
||||
props.iconOnly ? iconOnlySizeClasses[props.size] : sizeClasses[props.size],
|
||||
props.iconOnly ? '' : 'rounded-(--cp-button-radius-base)',
|
||||
'focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
|
||||
'disabled:cursor-not-allowed disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none',
|
||||
variantClasses[props.variant],
|
||||
props.iconOnly && (props.variant === 'default' || props.variant === 'primary')
|
||||
? iconOnlyDefaultClasses
|
||||
: variantClasses[props.variant],
|
||||
props.active ? 'bg-(--cp-default-bg-hover) text-(--cp-normal)' : undefined,
|
||||
])
|
||||
|
||||
const ariaLabel = computed(() => props.label || props.title)
|
||||
|
||||
const labelText = computed(() => {
|
||||
const defaultSlot = slots.default?.()
|
||||
const text = defaultSlot
|
||||
?.map((node) => (typeof node.children === 'string' ? node.children : ''))
|
||||
.join('')
|
||||
.trim()
|
||||
|
||||
return text || ''
|
||||
})
|
||||
|
||||
const labelClasses = computed(() => [
|
||||
'inline-flex min-w-0 items-center justify-center',
|
||||
labelText.value.length === 2 ? 'tracking-[0.12em]' : undefined,
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :type="type" :class="classes" :disabled="disabled || loading">
|
||||
<button
|
||||
:type="type"
|
||||
:class="classes"
|
||||
:disabled="disabled || loading"
|
||||
:aria-label="iconOnly ? ariaLabel : undefined"
|
||||
:title="title || (iconOnly ? label : undefined)"
|
||||
:aria-busy="loading"
|
||||
>
|
||||
<LoaderCircle v-if="loading" class="animate-spin" :size="loadingIconSize[size]" />
|
||||
<span v-if="$slots.icon && !loading" class="inline-flex shrink-0">
|
||||
<slot name="icon" />
|
||||
</span>
|
||||
<slot />
|
||||
<span v-if="!iconOnly && $slots.default" :class="labelClasses">
|
||||
<slot />
|
||||
</span>
|
||||
<span
|
||||
v-if="iconOnly && !$slots.icon && !loading"
|
||||
class="inline-flex items-center justify-center"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -1,22 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
type CardVariant = 'default' | 'dashboard'
|
||||
type HeaderCollapseAt = 'sm' | 'lg' | 'none'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
as?: keyof HTMLElementTagNameMap
|
||||
padded?: boolean
|
||||
variant?: CardVariant
|
||||
title?: string
|
||||
description?: string
|
||||
headerCollapseAt?: HeaderCollapseAt
|
||||
radiusClass?: string
|
||||
shadowClass?: string
|
||||
headerClass?: string
|
||||
headerContentClass?: string
|
||||
actionsClass?: string
|
||||
bodyClass?: string
|
||||
}>(),
|
||||
{
|
||||
as: 'section',
|
||||
padded: true,
|
||||
variant: 'default',
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
headerCollapseAt: 'sm',
|
||||
radiusClass: 'rounded-(--cp-card-radius)',
|
||||
shadowClass: 'shadow-(--cp-shadow-card)',
|
||||
headerClass: 'px-6 pt-6 pb-3 md:px-7',
|
||||
headerClass: undefined,
|
||||
headerContentClass: '',
|
||||
actionsClass: '',
|
||||
bodyClass: '',
|
||||
},
|
||||
)
|
||||
|
||||
const paddingClasses: Record<CardVariant, string> = {
|
||||
default: 'px-5 py-3',
|
||||
dashboard: 'px-7 py-5.5',
|
||||
}
|
||||
|
||||
const slots = defineSlots<{
|
||||
header?: () => unknown
|
||||
title?: () => unknown
|
||||
description?: () => unknown
|
||||
actions?: () => unknown
|
||||
body?: () => unknown
|
||||
default?: () => unknown
|
||||
}>()
|
||||
|
||||
const hasManagedHeader = computed(
|
||||
() =>
|
||||
!!props.title || !!props.description || !!slots.actions || !!slots.title || !!slots.description,
|
||||
)
|
||||
|
||||
const managedHeaderLayoutClasses = computed(() => {
|
||||
if (props.headerCollapseAt === 'none') {
|
||||
return 'flex items-start justify-between gap-3'
|
||||
}
|
||||
|
||||
if (props.headerCollapseAt === 'lg') {
|
||||
return 'flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between'
|
||||
}
|
||||
|
||||
return 'flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -26,21 +74,36 @@ const props = withDefaults(
|
||||
:class="[
|
||||
props.radiusClass,
|
||||
props.shadowClass,
|
||||
!$slots.header && !$slots.body && props.padded ? 'p-6 md:px-7' : undefined,
|
||||
props.padded ? paddingClasses[props.variant] : undefined,
|
||||
]"
|
||||
>
|
||||
<template v-if="$slots.header || $slots.body">
|
||||
<header v-if="$slots.header" class="shrink-0" :class="props.headerClass">
|
||||
<slot name="header" />
|
||||
<template v-if="$slots.header || hasManagedHeader || $slots.body">
|
||||
<header v-if="$slots.header || hasManagedHeader" class="shrink-0" :class="props.headerClass">
|
||||
<slot name="header">
|
||||
<div :class="managedHeaderLayoutClasses">
|
||||
<div class="min-w-0 pt-0.5" :class="props.headerContentClass">
|
||||
<h2
|
||||
v-if="props.title || $slots.title"
|
||||
class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)"
|
||||
>
|
||||
<slot name="title">{{ props.title }}</slot>
|
||||
</h2>
|
||||
<p
|
||||
v-if="props.description || $slots.description"
|
||||
class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)"
|
||||
>
|
||||
<slot name="description">{{ props.description }}</slot>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.actions" class="shrink-0" :class="props.actionsClass">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="$slots.body || $slots.default"
|
||||
:class="[
|
||||
props.padded ? ($slots.header ? 'px-6 pb-6 md:px-7' : 'p-6 md:px-7') : undefined,
|
||||
props.bodyClass,
|
||||
]"
|
||||
>
|
||||
<div v-if="$slots.body || $slots.default" :class="props.bodyClass">
|
||||
<slot name="body">
|
||||
<slot />
|
||||
</slot>
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
title?: string
|
||||
size?: 'default' | 'sm' | 'md'
|
||||
variant?: 'default' | 'ghost'
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{
|
||||
size: 'default',
|
||||
variant: 'default',
|
||||
active: false,
|
||||
disabled: false,
|
||||
loading: false,
|
||||
},
|
||||
)
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
if (props.size === 'md') return 'h-11 w-11 rounded-(--cp-icon-button-radius)'
|
||||
if (props.size === 'sm') return 'h-8 w-8 rounded-(--cp-icon-button-radius)'
|
||||
return 'h-9 w-9 rounded-(--cp-icon-button-radius)'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="inline-flex items-center justify-center border-0 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
|
||||
:class="[
|
||||
sizeClasses,
|
||||
variant === 'ghost'
|
||||
? 'bg-transparent text-(--cp-text-secondary) hover:bg-(--cp-bg-subtle) shadow-none'
|
||||
: 'bg-(--cp-bg-surface) text-(--cp-text-secondary) shadow-(--cp-shadow-control) hover:bg-(--cp-default-bg-hover) hover:text-(--cp-normal)',
|
||||
active ? 'bg-(--cp-default-bg-hover) text-(--cp-normal)' : '',
|
||||
disabled || loading ? 'cursor-not-allowed opacity-50' : '',
|
||||
]"
|
||||
:aria-label="label || title"
|
||||
:title="title || label"
|
||||
:aria-busy="loading"
|
||||
:disabled="disabled || loading"
|
||||
type="button"
|
||||
>
|
||||
<span class="inline-flex items-center justify-center" :class="loading ? 'animate-spin' : ''">
|
||||
<slot />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -3,7 +3,6 @@ import { AlertCircle, AlertTriangle, CheckCircle2, Info, X } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import BaseButton from './BaseButton.vue'
|
||||
import BaseIconButton from './BaseIconButton.vue'
|
||||
|
||||
type ModalVariant = 'default' | 'info' | 'warning' | 'danger' | 'success'
|
||||
|
||||
@@ -63,55 +62,96 @@ function closeModal() {
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="fixed inset-0 z-50 grid place-items-center p-6" role="presentation">
|
||||
<div class="absolute inset-0 bg-(--cp-overlay-scrim)" @click="closeModal" />
|
||||
<section
|
||||
class="[--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] relative w-[min(560px,100%)] rounded-(--cp-card-radius) bg-(--cp-bg-surface) shadow-(--cp-shadow-popover)"
|
||||
:style="modalStyle"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title"
|
||||
>
|
||||
<header class="grid grid-cols-[auto_minmax(0,1fr)_28px] gap-4 p-7 pb-0">
|
||||
<span
|
||||
v-if="variant !== 'default'"
|
||||
class="inline-flex size-11 items-center justify-center rounded-(--cp-icon-button-radius)"
|
||||
:class="variantClasses[variant].iconBg"
|
||||
>
|
||||
<component :is="iconMap[variant]" :size="18" :class="variantClasses[variant].icon" />
|
||||
</span>
|
||||
<div class="min-w-0" :class="variant === 'default' ? 'col-span-2' : ''">
|
||||
<h2 class="m-0 text-lg leading-[1.15] font-[760] text-(--cp-text-primary)">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="description"
|
||||
class="mt-2 mb-0 text-[13px] font-semibold leading-[1.45] text-(--cp-text-secondary)"
|
||||
<Transition name="cp-modal">
|
||||
<div v-if="open" class="fixed inset-0 z-50 grid place-items-center p-6" role="presentation">
|
||||
<div class="absolute inset-0 bg-(--cp-overlay-scrim)" @click="closeModal" />
|
||||
<section
|
||||
class="cp-modal-panel [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] relative w-[min(560px,100%)] rounded-(--cp-card-radius) bg-(--cp-bg-surface) shadow-(--cp-shadow-popover)"
|
||||
:style="modalStyle"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title"
|
||||
>
|
||||
<header class="grid grid-cols-[auto_minmax(0,1fr)_28px] gap-4 p-7 pb-0">
|
||||
<span
|
||||
v-if="variant !== 'default'"
|
||||
class="inline-flex size-11 items-center justify-center rounded-(--cp-icon-button-radius)"
|
||||
:class="variantClasses[variant].iconBg"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
<component :is="iconMap[variant]" :size="18" :class="variantClasses[variant].icon" />
|
||||
</span>
|
||||
<div class="min-w-0" :class="variant === 'default' ? 'col-span-2' : ''">
|
||||
<h2 class="m-0 text-lg leading-[1.15] font-[760] text-(--cp-text-primary)">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="description"
|
||||
class="mt-2 mb-0 text-[13px] leading-[1.45] font-semibold text-(--cp-text-secondary)"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton
|
||||
icon-only
|
||||
class="col-start-3"
|
||||
label="关闭"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:disabled="closeDisabled"
|
||||
@click="closeModal"
|
||||
>
|
||||
<X :size="16" />
|
||||
</BaseButton>
|
||||
</header>
|
||||
<div class="p-7" :class="description || variant !== 'default' ? 'pt-5' : 'pt-6'">
|
||||
<slot />
|
||||
</div>
|
||||
<BaseIconButton
|
||||
class="col-start-3"
|
||||
label="关闭"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:disabled="closeDisabled"
|
||||
@click="closeModal"
|
||||
>
|
||||
<X :size="16" />
|
||||
</BaseIconButton>
|
||||
</header>
|
||||
<div class="p-7" :class="description || variant !== 'default' ? 'pt-5' : 'pt-6'">
|
||||
<slot />
|
||||
</div>
|
||||
<footer class="flex justify-end gap-3 px-7 pb-7">
|
||||
<slot name="footer">
|
||||
<BaseButton variant="default" @click="open = false">取消</BaseButton>
|
||||
<BaseButton variant="primary" @click="open = false">确认</BaseButton>
|
||||
</slot>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
<footer class="flex justify-end gap-3 px-7 pb-7">
|
||||
<slot name="footer">
|
||||
<BaseButton variant="default" @click="open = false">取消</BaseButton>
|
||||
<BaseButton variant="primary" @click="open = false">确认</BaseButton>
|
||||
</slot>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cp-modal-enter-active,
|
||||
.cp-modal-leave-active {
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
|
||||
.cp-modal-enter-active .cp-modal-panel,
|
||||
.cp-modal-leave-active .cp-modal-panel {
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.cp-modal-enter-from,
|
||||
.cp-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cp-modal-enter-from .cp-modal-panel {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 8px, 0) scale(0.985);
|
||||
}
|
||||
|
||||
.cp-modal-leave-to .cp-modal-panel {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 4px, 0) scale(0.99);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cp-modal-enter-active,
|
||||
.cp-modal-leave-active,
|
||||
.cp-modal-enter-active .cp-modal-panel,
|
||||
.cp-modal-leave-active .cp-modal-panel {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -61,7 +61,10 @@ function updatePopoverPosition() {
|
||||
const viewportPadding = 8
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect()
|
||||
const panelRect = popoverRef.value?.getBoundingClientRect()
|
||||
const panelWidth = Math.max(panelRect?.width ?? 0, toCssLength(props.width) ? 0 : triggerRect.width)
|
||||
const panelWidth = Math.max(
|
||||
panelRect?.width ?? 0,
|
||||
toCssLength(props.width) ? 0 : triggerRect.width,
|
||||
)
|
||||
const panelHeight = panelRect?.height ?? 0
|
||||
const belowSpace = window.innerHeight - triggerRect.bottom - props.offset
|
||||
const aboveSpace = triggerRect.top - props.offset
|
||||
@@ -162,14 +165,23 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
ref="popoverRef"
|
||||
:class="popoverClasses"
|
||||
:style="[sizeStyle, popoverStyle]"
|
||||
<Transition
|
||||
enter-active-class="transition-[opacity,transform] duration-150 ease-out"
|
||||
enter-from-class="-translate-y-1 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition-opacity duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<slot :open="open" :close="closePopover" :toggle="togglePopover" />
|
||||
</div>
|
||||
<div
|
||||
v-if="open"
|
||||
ref="popoverRef"
|
||||
:class="popoverClasses"
|
||||
:style="[sizeStyle, popoverStyle]"
|
||||
>
|
||||
<slot :open="open" :close="closePopover" :toggle="togglePopover" />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,29 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface SegmentedOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
options: SegmentedOption[]
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
|
||||
const activeIndex = computed(() => {
|
||||
const index = props.options.findIndex((option) => option.value === model.value)
|
||||
return index >= 0 ? index : 0
|
||||
})
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `repeat(${Math.max(props.options.length, 1)}, minmax(0, 1fr))`,
|
||||
}))
|
||||
|
||||
const indicatorStyle = computed(() => ({
|
||||
width: `calc((100% - 8px) / ${Math.max(props.options.length, 1)})`,
|
||||
transform: `translateX(${activeIndex.value * 100}%)`,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex h-9 items-center gap-1 rounded-(--cp-icon-button-radius) bg-(--cp-bg-muted) p-1"
|
||||
class="relative inline-grid h-9 items-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-muted) p-1"
|
||||
:style="gridStyle"
|
||||
role="tablist"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute top-1 bottom-1 left-1 rounded-(--cp-input-radius-base) bg-(--cp-bg-surface) shadow-(--cp-shadow-control) transition-transform duration-200 ease-out"
|
||||
:style="indicatorStyle"
|
||||
/>
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="h-7 min-w-0 flex-1 rounded-(--cp-input-radius-base) border-0 px-3 text-xs leading-none font-[650] transition-colors outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
|
||||
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="
|
||||
model === option.value
|
||||
? 'bg-(--cp-bg-surface) text-(--cp-text-primary) shadow-(--cp-shadow-control)'
|
||||
: 'bg-transparent text-(--cp-text-secondary) hover:text-(--cp-text-primary)'
|
||||
? 'text-(--cp-text-primary)'
|
||||
: 'text-(--cp-text-secondary) hover:text-(--cp-text-primary)'
|
||||
"
|
||||
type="button"
|
||||
role="tab"
|
||||
|
||||
@@ -80,7 +80,7 @@ const triggerClasses = computed(() => [
|
||||
])
|
||||
|
||||
const popoverClasses = computed(() => [
|
||||
'fixed z-50 rounded-(--cp-popover-radius) border-0 bg-(--cp-bg-surface) p-1.5 shadow-(--cp-shadow-popover)',
|
||||
'fixed z-50 flex flex-col gap-1 rounded-(--cp-popover-radius) border-0 bg-(--cp-bg-surface) p-1 shadow-(--cp-shadow-popover)',
|
||||
props.options.length > 6 ? 'cp-scrollbar overflow-y-auto' : 'overflow-visible',
|
||||
])
|
||||
|
||||
@@ -300,45 +300,54 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
:id="`${selectId}-listbox`"
|
||||
ref="popoverRef"
|
||||
:class="popoverClasses"
|
||||
:style="popoverStyle"
|
||||
role="listbox"
|
||||
:aria-labelledby="selectId"
|
||||
<Transition
|
||||
enter-active-class="transition-[opacity,transform] duration-150 ease-out"
|
||||
enter-from-class="-translate-y-1 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition-opacity duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="options.length === 0"
|
||||
class="flex h-8.5 items-center rounded-(--cp-control-radius-base) px-3 text-[13px] font-[650] leading-none text-(--cp-text-muted)"
|
||||
v-if="open"
|
||||
:id="`${selectId}-listbox`"
|
||||
ref="popoverRef"
|
||||
:class="popoverClasses"
|
||||
:style="popoverStyle"
|
||||
role="listbox"
|
||||
:aria-labelledby="selectId"
|
||||
>
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="(option, index) in options"
|
||||
:id="optionId(index)"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="option.value === model"
|
||||
:disabled="option.disabled"
|
||||
:class="optionClasses(option, index)"
|
||||
@mouseenter="activeIndex = option.disabled ? activeIndex : index"
|
||||
@mousedown.prevent
|
||||
@click="chooseOption(option, index)"
|
||||
<div
|
||||
v-if="options.length === 0"
|
||||
class="flex h-8.5 items-center rounded-(--cp-input-radius-small) px-3 text-[13px] leading-none font-[650] text-(--cp-text-muted)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
|
||||
<Check
|
||||
v-if="option.value === model"
|
||||
class="shrink-0 text-(--cp-info)"
|
||||
:size="size === 'pagination' ? 13 : 15"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="(option, index) in options"
|
||||
:id="optionId(index)"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="option.value === model"
|
||||
:disabled="option.disabled"
|
||||
:class="optionClasses(option, index)"
|
||||
@mouseenter="activeIndex = option.disabled ? activeIndex : index"
|
||||
@mousedown.prevent
|
||||
@click="chooseOption(option, index)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
|
||||
<Check
|
||||
v-if="option.value === model"
|
||||
class="shrink-0 text-(--cp-info)"
|
||||
:size="size === 'pagination' ? 13 : 15"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -15,7 +15,7 @@ const props = withDefaults(
|
||||
const model = defineModel<boolean>({ default: false })
|
||||
|
||||
const trackClasses = computed(() => [
|
||||
'relative inline-flex h-6 w-10.5 shrink-0 rounded-full border-0 transition-colors duration-180 outline-none',
|
||||
'relative inline-flex h-6 w-10.5 shrink-0 rounded-full border-0 transition-colors duration-200 outline-none',
|
||||
props.disabled
|
||||
? 'cursor-not-allowed bg-(--cp-disabled-bg) opacity-70'
|
||||
: model.value
|
||||
@@ -24,7 +24,7 @@ const trackClasses = computed(() => [
|
||||
])
|
||||
|
||||
const knobClasses = computed(() => [
|
||||
'absolute top-0.75 left-0 size-4.5 rounded-full bg-(--cp-bg-surface) shadow-(--cp-shadow-control) transition-transform duration-180',
|
||||
'absolute top-0.75 left-0 size-4.5 rounded-full bg-(--cp-bg-surface) shadow-(--cp-shadow-control) transition-transform duration-200',
|
||||
model.value ? 'translate-x-5' : 'translate-x-0.75',
|
||||
])
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ export interface BaseTableColumn {
|
||||
flex?: number
|
||||
fixed?: 'left'
|
||||
align?: 'left' | 'right' | 'center'
|
||||
ellipsis?: boolean
|
||||
emptyText?: string
|
||||
mono?: boolean
|
||||
tabular?: boolean
|
||||
headerClass?: string
|
||||
cellClass?: string
|
||||
}
|
||||
@@ -131,6 +135,10 @@ const pageSizeOptions = computed(() => {
|
||||
const pageSizeModel = computed({
|
||||
get: () => String(props.pagination?.pageSize ?? ''),
|
||||
set: (value: string) => {
|
||||
if (props.loading) {
|
||||
return
|
||||
}
|
||||
|
||||
const pageSize = Number(value)
|
||||
if (Number.isFinite(pageSize) && pageSize > 0) {
|
||||
emit('page-size-change', pageSize)
|
||||
@@ -266,6 +274,28 @@ 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)
|
||||
@@ -445,11 +475,22 @@ function cellContentClass(column: BaseTableColumn) {
|
||||
return 'min-w-0 overflow-visible'
|
||||
}
|
||||
|
||||
return 'min-w-0 truncate'
|
||||
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.pagination || page < 1 || page > totalPages.value || page === currentPage.value) {
|
||||
if (
|
||||
props.loading ||
|
||||
!props.pagination ||
|
||||
page < 1 ||
|
||||
page > totalPages.value ||
|
||||
page === currentPage.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -485,7 +526,7 @@ function paginationPageClass(page: number) {
|
||||
<div class="flex min-h-0 max-w-full flex-1 flex-col overflow-hidden">
|
||||
<div ref="headerWrap" class="max-w-full overflow-hidden">
|
||||
<table
|
||||
class="w-full shrink-0 table-fixed border-separate border-spacing-y-2 text-left"
|
||||
class="w-full shrink-0 table-fixed border-separate border-spacing-y-1 text-left"
|
||||
:style="tableStyle()"
|
||||
role="table"
|
||||
>
|
||||
@@ -568,14 +609,15 @@ function paginationPageClass(page: number) {
|
||||
]"
|
||||
role="cell"
|
||||
>
|
||||
<div :class="cellContentClass(column)">
|
||||
<div :class="cellContentClass(column)" :title="cellTitle(column, row)">
|
||||
<slot
|
||||
:name="column.key"
|
||||
:row="row"
|
||||
:value="cellValue(row, column.key)"
|
||||
:display-value="cellDisplayValue(column, row)"
|
||||
:index="index"
|
||||
>
|
||||
{{ cellValue(row, column.key) }}
|
||||
{{ cellDisplayValue(column, row) }}
|
||||
</slot>
|
||||
</div>
|
||||
</td>
|
||||
@@ -624,6 +666,7 @@ function paginationPageClass(page: number) {
|
||||
<BaseSelect
|
||||
v-model="pageSizeModel"
|
||||
:options="pageSizeOptions"
|
||||
:disabled="loading"
|
||||
size="pagination"
|
||||
class="w-28"
|
||||
/>
|
||||
@@ -631,8 +674,8 @@ function paginationPageClass(page: number) {
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
:class="paginationButtonClass(currentPage <= 1)"
|
||||
:disabled="currentPage <= 1"
|
||||
:class="paginationButtonClass(loading || currentPage <= 1)"
|
||||
:disabled="loading || currentPage <= 1"
|
||||
title="上一页"
|
||||
aria-label="上一页"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
@@ -651,7 +694,7 @@ function paginationPageClass(page: number) {
|
||||
v-else
|
||||
type="button"
|
||||
:class="paginationPageClass(item)"
|
||||
:disabled="item === currentPage"
|
||||
:disabled="loading || item === currentPage"
|
||||
:aria-current="item === currentPage ? 'page' : undefined"
|
||||
@click="goToPage(item)"
|
||||
>
|
||||
@@ -661,8 +704,8 @@ function paginationPageClass(page: number) {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:class="paginationButtonClass(currentPage >= totalPages)"
|
||||
:disabled="currentPage >= totalPages"
|
||||
:class="paginationButtonClass(loading || currentPage >= totalPages)"
|
||||
:disabled="loading || currentPage >= totalPages"
|
||||
title="下一页"
|
||||
aria-label="下一页"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Users,
|
||||
} from '@lucide/vue'
|
||||
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import { useAuthStore } from '@/stores/modules/auth'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -267,7 +267,8 @@ watch(
|
||||
</span>
|
||||
|
||||
<div class="flex items-center" :class="collapsed ? 'grid gap-1' : 'gap-1'">
|
||||
<BaseIconButton
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
:size="collapsed ? 'default' : 'sm'"
|
||||
data-sidebar-toggle
|
||||
@@ -276,9 +277,10 @@ watch(
|
||||
>
|
||||
<PanelLeftOpen v-if="collapsed" :size="19" />
|
||||
<PanelLeftClose v-else :size="18" />
|
||||
</BaseIconButton>
|
||||
</BaseButton>
|
||||
|
||||
<BaseIconButton
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
:size="collapsed ? 'default' : 'sm'"
|
||||
label="退出登录"
|
||||
@@ -286,7 +288,7 @@ watch(
|
||||
@click="handleLogout"
|
||||
>
|
||||
<LogOut :size="collapsed ? 19 : 18" />
|
||||
</BaseIconButton>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import AdminLayout from '@/layout/index.vue'
|
||||
import AccountsView from '@/views/accounts/AccountsView.vue'
|
||||
import ApiKeysView from '@/views/api-keys/ApiKeysView.vue'
|
||||
import DashboardView from '@/views/dashboard/DashboardView.vue'
|
||||
import LoginView from '@/views/login/LoginView.vue'
|
||||
import LogsView from '@/views/logs/LogsView.vue'
|
||||
import SettingsView from '@/views/settings/SettingsView.vue'
|
||||
import AccountsView from '@/views/accounts/index.vue'
|
||||
import ApiKeysView from '@/views/api-keys/index.vue'
|
||||
import DashboardView from '@/views/dashboard/index.vue'
|
||||
import LoginView from '@/views/login/index.vue'
|
||||
import LogsView from '@/views/logs/index.vue'
|
||||
import SettingsView from '@/views/settings/index.vue'
|
||||
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { getAuthStatus, login as apiLogin, logout as apiLogout } from '@/api'
|
||||
import type { LoginRequest } from '@/api'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const isAuthenticated = ref(false)
|
||||
@@ -23,7 +22,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function login(payload: LoginRequest) {
|
||||
async function login(payload: any) {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
+1
-76
@@ -1,20 +1,8 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 360px;
|
||||
background: var(--cp-bg-page);
|
||||
color: var(--cp-text-primary);
|
||||
font-family:
|
||||
'Inter Variable',
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -22,38 +10,9 @@ html {
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: var(--cp-bg-page);
|
||||
}
|
||||
|
||||
input[type='checkbox']:not(.sr-only) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
border: 1px solid var(--cp-control-border);
|
||||
border-radius: var(--cp-checkbox-radius);
|
||||
background: var(--cp-bg-surface);
|
||||
accent-color: var(--cp-info);
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
input[type='checkbox']:not(.sr-only):hover {
|
||||
border-color: var(--cp-control-border-hover);
|
||||
}
|
||||
|
||||
input[type='checkbox']:not(.sr-only):focus-visible {
|
||||
outline: 3px solid var(--cp-control-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
input[type='checkbox']:not(.sr-only):disabled {
|
||||
border-color: var(--cp-disabled-border);
|
||||
background: var(--cp-disabled-bg);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
@@ -62,37 +21,3 @@ button {
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.cp-scrollbar {
|
||||
scrollbar-color: var(--cp-scrollbar-thumb) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.cp-scrollbar::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.cp-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cp-scrollbar::-webkit-scrollbar-thumb {
|
||||
min-height: 56px;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-color: var(--cp-scrollbar-thumb);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.cp-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--cp-scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
.cp-scrollbar::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type Component } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
@@ -20,7 +20,6 @@ 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 BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BasePopover from '@/components/base/BasePopover.vue'
|
||||
@@ -29,22 +28,25 @@ import BaseTable from '@/components/base/BaseTable.vue'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { withMinimumDuration } from '@/utils/async'
|
||||
|
||||
import type { Account, AccountQuotaWindow } from '@/api'
|
||||
import {
|
||||
batchDeleteAccounts,
|
||||
createAccount,
|
||||
deleteAccount,
|
||||
deleteAccounts,
|
||||
getAccountQuota,
|
||||
getAccounts,
|
||||
refreshAccount,
|
||||
testAccountConnection,
|
||||
updateAccount,
|
||||
updateAccountStatus,
|
||||
} from '@/api'
|
||||
|
||||
const loading = ref(true)
|
||||
const accounts = ref<Account[]>([])
|
||||
const accounts = ref<any[]>([])
|
||||
const totalAccounts = ref(0)
|
||||
const accountSummary = ref({
|
||||
total: 0,
|
||||
active: 0,
|
||||
highUsage: 0,
|
||||
attention: 0,
|
||||
})
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const searchQuery = ref('')
|
||||
@@ -53,13 +55,14 @@ const expandedAccountIds = ref<Set<string>>(new Set())
|
||||
const showCreateModal = ref(false)
|
||||
const showDeleteModal = ref(false)
|
||||
const showSingleDeleteModal = ref(false)
|
||||
const editingAccount = ref<Account | null>(null)
|
||||
const pendingDeleteAccount = ref<Account | null>(null)
|
||||
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 testingConnectionIds = ref<Set<string>>(new Set())
|
||||
const deletingAccount = ref(false)
|
||||
const loaded = ref(false)
|
||||
const savingAccount = ref(false)
|
||||
const batchDeleting = ref(false)
|
||||
let searchTimer: number | undefined
|
||||
@@ -74,7 +77,7 @@ const editForm = ref<{
|
||||
accountId: string
|
||||
userId: string
|
||||
planType: string
|
||||
status: Account['status']
|
||||
status: string
|
||||
}>({
|
||||
label: '',
|
||||
email: '',
|
||||
@@ -84,22 +87,6 @@ const editForm = ref<{
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
type OverviewTone = 'info' | 'success' | 'warning' | 'danger'
|
||||
|
||||
interface OverviewItem {
|
||||
label: string
|
||||
value: string
|
||||
caption: string
|
||||
tone: OverviewTone
|
||||
icon: Component
|
||||
}
|
||||
|
||||
const attentionStatuses = new Set<Account['status']>([
|
||||
'expired',
|
||||
'disabled',
|
||||
'banned',
|
||||
'quota_exhausted',
|
||||
])
|
||||
const relaxedCellClass = 'py-3 align-middle'
|
||||
|
||||
const accountColumns = [
|
||||
@@ -140,7 +127,7 @@ const accountColumns = [
|
||||
},
|
||||
]
|
||||
|
||||
const statusLabels: Record<Account['status'], string> = {
|
||||
const statusLabels: Record<string, string> = {
|
||||
active: '正常',
|
||||
expired: '已过期',
|
||||
disabled: '已禁用',
|
||||
@@ -149,7 +136,7 @@ const statusLabels: Record<Account['status'], string> = {
|
||||
refreshing: '刷新中',
|
||||
}
|
||||
|
||||
const editableStatusOptions: Array<{ value: Account['status']; label: string }> = [
|
||||
const editableStatusOptions: Array<{ value: string; label: string }> = [
|
||||
{ value: 'active', label: statusLabels.active },
|
||||
{ value: 'disabled', label: statusLabels.disabled },
|
||||
{ value: 'expired', label: statusLabels.expired },
|
||||
@@ -162,22 +149,22 @@ const editStatusModel = computed<string>({
|
||||
get: () => editForm.value.status,
|
||||
set: (value) => {
|
||||
if (editableStatusOptions.some((option) => option.value === value)) {
|
||||
editForm.value.status = value as Account['status']
|
||||
editForm.value.status = value
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const statusTones: Record<Account['status'], 'success' | 'danger' | 'warning' | 'info' | 'normal'> =
|
||||
{
|
||||
active: 'success',
|
||||
expired: 'warning',
|
||||
disabled: 'normal',
|
||||
banned: 'danger',
|
||||
quota_exhausted: 'warning',
|
||||
refreshing: 'info',
|
||||
}
|
||||
const statusTones: Record<string, 'success' | 'danger' | 'warning' | 'info' | 'normal'> = {
|
||||
active: 'success',
|
||||
expired: 'warning',
|
||||
disabled: 'normal',
|
||||
banned: 'danger',
|
||||
quota_exhausted: 'warning',
|
||||
refreshing: 'info',
|
||||
}
|
||||
|
||||
const filteredAccounts = computed(() => accounts.value)
|
||||
const initialLoading = computed(() => loading.value && !loaded.value)
|
||||
|
||||
const allSelected = computed(
|
||||
() =>
|
||||
@@ -204,38 +191,32 @@ const showEditModal = computed({
|
||||
}
|
||||
},
|
||||
})
|
||||
const loadedAccountsCount = computed(() => accounts.value.length)
|
||||
const activeAccountsCount = computed(
|
||||
() => accounts.value.filter((account) => account.status === 'active').length,
|
||||
)
|
||||
const highUsageAccountsCount = computed(() => accounts.value.filter(accountHasHighUsage).length)
|
||||
const attentionAccountsCount = computed(() => accounts.value.filter(accountNeedsAttention).length)
|
||||
const overviewItems = computed<OverviewItem[]>(() => [
|
||||
const overviewItems = computed(() => [
|
||||
{
|
||||
label: '总账号',
|
||||
value: formatCount(totalAccounts.value),
|
||||
value: formatCount(accountSummary.value.total),
|
||||
caption: searchQuery.value.trim() ? '匹配筛选结果' : '账号池规模',
|
||||
tone: 'info',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: '当前页正常',
|
||||
value: formatCount(activeAccountsCount.value),
|
||||
caption: `${formatCount(loadedAccountsCount.value)} 个已加载账号`,
|
||||
label: '正常账号',
|
||||
value: formatCount(accountSummary.value.active),
|
||||
caption: '可参与调度',
|
||||
tone: 'success',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
label: '额度预警',
|
||||
value: formatCount(highUsageAccountsCount.value),
|
||||
value: formatCount(accountSummary.value.highUsage),
|
||||
caption: '任一窗口 >= 80%',
|
||||
tone: 'warning',
|
||||
icon: Gauge,
|
||||
},
|
||||
{
|
||||
label: '需处理',
|
||||
value: formatCount(attentionAccountsCount.value),
|
||||
caption: '过期 / 禁用 / 封禁 / 耗尽',
|
||||
value: formatCount(accountSummary.value.attention),
|
||||
caption: '过期 / 禁用 / 封禁',
|
||||
tone: 'danger',
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
@@ -250,6 +231,7 @@ async function loadAccounts() {
|
||||
search: searchQuery.value,
|
||||
})
|
||||
accounts.value = result.items
|
||||
accountSummary.value = result.summary
|
||||
totalAccounts.value = result.page.total ?? result.items.length
|
||||
page.value = result.page.page ?? page.value
|
||||
pageSize.value = result.page.pageSize ?? pageSize.value
|
||||
@@ -260,6 +242,7 @@ async function loadAccounts() {
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,7 +261,7 @@ async function handleCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
function openEditAccount(account: Account) {
|
||||
function openEditAccount(account: any) {
|
||||
editingAccount.value = account
|
||||
editForm.value = {
|
||||
label: account.label || '',
|
||||
@@ -296,7 +279,8 @@ async function handleSaveAccount() {
|
||||
|
||||
try {
|
||||
savingAccount.value = true
|
||||
await updateAccount(account.id, {
|
||||
await updateAccount({
|
||||
id: account.id,
|
||||
label: nullableTrimmedValue(editForm.value.label),
|
||||
email: nullableTrimmedValue(editForm.value.email),
|
||||
accountId: nullableTrimmedValue(editForm.value.accountId),
|
||||
@@ -319,7 +303,7 @@ function nullableTrimmedValue(value: string) {
|
||||
return trimmed || null
|
||||
}
|
||||
|
||||
function requestDeleteAccount(account: Account) {
|
||||
function requestDeleteAccount(account: any) {
|
||||
pendingDeleteAccount.value = account
|
||||
showSingleDeleteModal.value = true
|
||||
}
|
||||
@@ -330,7 +314,7 @@ async function handleDelete() {
|
||||
|
||||
try {
|
||||
deletingAccount.value = true
|
||||
await deleteAccount(accountId)
|
||||
await deleteAccounts({ ids: [accountId] })
|
||||
showSingleDeleteModal.value = false
|
||||
pendingDeleteAccount.value = null
|
||||
await loadAccounts()
|
||||
@@ -347,7 +331,7 @@ async function handleBatchDelete() {
|
||||
|
||||
try {
|
||||
batchDeleting.value = true
|
||||
await batchDeleteAccounts([...selectedIds.value])
|
||||
await deleteAccounts({ ids: [...selectedIds.value] })
|
||||
selectedIds.value = new Set()
|
||||
showDeleteModal.value = false
|
||||
await loadAccounts()
|
||||
@@ -363,7 +347,7 @@ async function handleRefresh(accountId: string) {
|
||||
refreshingAccountIds.value = new Set(refreshingAccountIds.value).add(accountId)
|
||||
try {
|
||||
await withMinimumDuration(async () => {
|
||||
await refreshAccount(accountId)
|
||||
await refreshAccount({ id: accountId })
|
||||
await loadAccounts()
|
||||
})
|
||||
toast.success('Token 已刷新')
|
||||
@@ -376,12 +360,14 @@ async function handleRefresh(accountId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestConnection(account: Account) {
|
||||
async function handleTestConnection(account: any) {
|
||||
if (testingConnectionIds.value.has(account.id)) return
|
||||
testingConnectionIds.value = new Set(testingConnectionIds.value).add(account.id)
|
||||
try {
|
||||
const result = await withMinimumDuration(async () => testAccountConnection(account.id))
|
||||
const check = result.results.find((item) => item.id === account.id) || result.results[0]
|
||||
const result = await withMinimumDuration(async () =>
|
||||
testAccountConnection({ ids: [account.id] }),
|
||||
)
|
||||
const check = result.results.find((item: any) => item.id === account.id) || result.results[0]
|
||||
if (check?.result === 'alive') {
|
||||
toast.success(`连接正常${check.durationMs !== undefined ? ` · ${check.durationMs}ms` : ''}`)
|
||||
} else {
|
||||
@@ -401,7 +387,7 @@ async function handleRefreshQuota(accountId: string) {
|
||||
refreshingQuotaAccountIds.value = new Set(refreshingQuotaAccountIds.value).add(accountId)
|
||||
try {
|
||||
await withMinimumDuration(async () => {
|
||||
await getAccountQuota(accountId)
|
||||
await getAccountQuota({ id: accountId })
|
||||
await loadAccounts()
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -413,12 +399,12 @@ async function handleRefreshQuota(accountId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleSchedule(account: Account) {
|
||||
async function handleToggleSchedule(account: any) {
|
||||
if (updatingStatusAccountIds.value.has(account.id)) return
|
||||
const nextStatus: Account['status'] = account.status === 'disabled' ? 'active' : 'disabled'
|
||||
const nextStatus = account.status === 'disabled' ? 'active' : 'disabled'
|
||||
updatingStatusAccountIds.value = new Set(updatingStatusAccountIds.value).add(account.id)
|
||||
try {
|
||||
await updateAccountStatus(account.id, nextStatus)
|
||||
await updateAccount({ id: account.id, status: nextStatus })
|
||||
await loadAccounts()
|
||||
toast.success(nextStatus === 'disabled' ? '已禁用调度' : '已启用调度')
|
||||
} catch (error: any) {
|
||||
@@ -430,19 +416,19 @@ async function handleToggleSchedule(account: Account) {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleActionLabel(account: Account) {
|
||||
function scheduleActionLabel(account: any) {
|
||||
return account.status === 'disabled' ? '启用调度' : '禁用调度'
|
||||
}
|
||||
|
||||
function statusTone(status: Account['status']) {
|
||||
function statusTone(status: string) {
|
||||
return statusTones[status]
|
||||
}
|
||||
|
||||
function statusLabel(status: Account['status']) {
|
||||
function statusLabel(status: string) {
|
||||
return statusLabels[status]
|
||||
}
|
||||
|
||||
function statusTextClass(status: Account['status']) {
|
||||
function statusTextClass(status: string) {
|
||||
const tone = statusTone(status)
|
||||
if (tone === 'success') {
|
||||
return 'text-(--cp-success-text)'
|
||||
@@ -459,7 +445,7 @@ function statusTextClass(status: Account['status']) {
|
||||
return 'text-(--cp-text-secondary)'
|
||||
}
|
||||
|
||||
function statusDotClass(status: Account['status']) {
|
||||
function statusDotClass(status: string) {
|
||||
const tone = statusTone(status)
|
||||
if (tone === 'success') {
|
||||
return 'bg-(--cp-success)'
|
||||
@@ -494,11 +480,11 @@ function planTypeClass(planType?: string) {
|
||||
return 'bg-(--cp-bg-subtle) text-(--cp-text-secondary)'
|
||||
}
|
||||
|
||||
function accountDisplayTitle(account: Account) {
|
||||
function accountDisplayTitle(account: any) {
|
||||
return account.label?.trim() || account.email || account.accountId || account.id
|
||||
}
|
||||
|
||||
function accountSecondaryText(account: Account) {
|
||||
function accountSecondaryText(account: any) {
|
||||
const title = accountDisplayTitle(account)
|
||||
const secondary = [account.email, account.accountId, account.userId, account.id].find(
|
||||
(value) => value && value !== title,
|
||||
@@ -506,11 +492,11 @@ function accountSecondaryText(account: Account) {
|
||||
return secondary || account.id
|
||||
}
|
||||
|
||||
function accountInitial(account: Account) {
|
||||
function accountInitial(account: any) {
|
||||
return accountDisplayTitle(account).slice(0, 1).toUpperCase()
|
||||
}
|
||||
|
||||
function accountAvatarClass(account: Account) {
|
||||
function accountAvatarClass(account: any) {
|
||||
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)]',
|
||||
@@ -526,14 +512,6 @@ function formatCount(value: number) {
|
||||
return value.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function accountNeedsAttention(account: Account) {
|
||||
return attentionStatuses.has(account.status)
|
||||
}
|
||||
|
||||
function accountHasHighUsage(account: Account) {
|
||||
return quotaWindows(account).some((window) => quotaWindowPercent(window) >= 80)
|
||||
}
|
||||
|
||||
function toggleSelection(accountId: string) {
|
||||
if (selectedIds.value.has(accountId)) {
|
||||
selectedIds.value.delete(accountId)
|
||||
@@ -571,23 +549,23 @@ function handlePageSizeChange(nextPageSize: number) {
|
||||
void loadAccounts()
|
||||
}
|
||||
|
||||
function quotaWindows(account: Account) {
|
||||
return account.quota.windows
|
||||
function quotaWindows(account: any) {
|
||||
return account.quota.windows as any[]
|
||||
}
|
||||
|
||||
function quotaWindowsByGroup(account: Account, group: AccountQuotaWindow['group']) {
|
||||
function quotaWindowsByGroup(account: any, group: string) {
|
||||
return quotaWindows(account).filter((window) => window.group === group)
|
||||
}
|
||||
|
||||
function quotaWindowPercent(window?: AccountQuotaWindow) {
|
||||
function quotaWindowPercent(window?: any) {
|
||||
return Math.max(0, Math.min(window?.usedPercent ?? 0, 100))
|
||||
}
|
||||
|
||||
function quotaWindowBarWidth(window?: AccountQuotaWindow) {
|
||||
function quotaWindowBarWidth(window?: any) {
|
||||
return `${quotaWindowPercent(window)}%`
|
||||
}
|
||||
|
||||
function quotaWindowBarStyle(window?: AccountQuotaWindow) {
|
||||
function quotaWindowBarStyle(window?: any) {
|
||||
const percent = quotaWindowPercent(window)
|
||||
return {
|
||||
width: `${percent}%`,
|
||||
@@ -595,7 +573,7 @@ function quotaWindowBarStyle(window?: AccountQuotaWindow) {
|
||||
}
|
||||
}
|
||||
|
||||
function quotaWindowBarClass(window?: AccountQuotaWindow) {
|
||||
function quotaWindowBarClass(window?: any) {
|
||||
if (window?.usedPercent === null || window?.usedPercent === undefined) {
|
||||
return 'bg-(--cp-default-border-hover)'
|
||||
}
|
||||
@@ -608,7 +586,7 @@ function quotaWindowBarClass(window?: AccountQuotaWindow) {
|
||||
return 'bg-(--cp-success)'
|
||||
}
|
||||
|
||||
function overviewIconClass(tone: OverviewTone) {
|
||||
function overviewIconClass(tone: string) {
|
||||
if (tone === 'success') {
|
||||
return 'bg-(--cp-success-bg) text-(--cp-success-text)'
|
||||
}
|
||||
@@ -650,43 +628,45 @@ onBeforeUnmount(() => {
|
||||
账号管理
|
||||
</h1>
|
||||
<p class="mt-2.5 text-[15px] leading-[1.15] font-semibold mb-0 text-(--cp-text-secondary)">
|
||||
管理 Codex 账号 · 共 {{ totalAccounts }} 个账号
|
||||
维护 Codex 账号池,快速确认可用性、配额与连接状态。
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mt-5 grid shrink-0 grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<section
|
||||
<BaseCard
|
||||
v-for="item in overviewItems"
|
||||
:key="item.label"
|
||||
class="min-h-24 rounded-lg bg-(--cp-bg-surface) px-4 py-3.5 shadow-(--cp-shadow-control)"
|
||||
as="article"
|
||||
:padded="false"
|
||||
class="h-24"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<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="mt-2 block font-mono text-[26px] leading-none font-[820] text-(--cp-text-primary)"
|
||||
class="block font-mono text-[26px] leading-none font-[820] text-(--cp-text-primary)"
|
||||
>
|
||||
{{ item.value }}
|
||||
</strong>
|
||||
<p class="m-0 mt-2 truncate text-[12px] leading-none font-[650] text-(--cp-text-muted)">
|
||||
<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 rounded-lg"
|
||||
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>
|
||||
</section>
|
||||
</BaseCard>
|
||||
</div>
|
||||
|
||||
<BaseCard
|
||||
v-loading="loading"
|
||||
v-loading="initialLoading"
|
||||
:padded="false"
|
||||
class="mt-4 flex min-h-0 flex-1 flex-col"
|
||||
header-class="px-4 pt-4 pb-2 md:px-5"
|
||||
@@ -730,6 +710,7 @@ onBeforeUnmount(() => {
|
||||
class="min-h-0 flex-1"
|
||||
:columns="accountColumns"
|
||||
:rows="filteredAccounts"
|
||||
:loading="loading"
|
||||
:selected-row-keys="selectedRowKeys"
|
||||
:expanded-row-keys="[...expandedAccountIds]"
|
||||
:pagination="accountPagination"
|
||||
@@ -809,7 +790,7 @@ onBeforeUnmount(() => {
|
||||
<template #usage="{ row }">
|
||||
<div
|
||||
v-if="quotaWindows(row).length > 0"
|
||||
class="flex w-full max-w-[124px] min-w-0 flex-col gap-2 whitespace-normal py-1.5"
|
||||
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(row)"
|
||||
@@ -852,16 +833,18 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template #actions="{ row }">
|
||||
<div class="relative flex items-center justify-end gap-1">
|
||||
<BaseIconButton
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="编辑账号"
|
||||
@click.stop="openEditAccount(row)"
|
||||
>
|
||||
<Pencil class="size-3.5" />
|
||||
</BaseIconButton>
|
||||
</BaseButton>
|
||||
|
||||
<BaseIconButton
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="删除账号"
|
||||
@@ -869,13 +852,13 @@ onBeforeUnmount(() => {
|
||||
@click.stop="requestDeleteAccount(row)"
|
||||
>
|
||||
<Trash2 class="size-3.5 text-(--cp-danger)" />
|
||||
</BaseIconButton>
|
||||
</BaseButton>
|
||||
|
||||
<BasePopover placement="bottom-end" width="160px">
|
||||
<template #trigger="{ open }">
|
||||
<BaseIconButton variant="ghost" size="sm" title="更多操作" :active="open">
|
||||
<BaseButton icon-only variant="ghost" size="sm" title="更多操作" :active="open">
|
||||
<MoreHorizontal class="size-4" />
|
||||
</BaseIconButton>
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<template #default="{ close }">
|
||||
@@ -922,7 +905,8 @@ onBeforeUnmount(() => {
|
||||
{{ row.quota.refreshedAtDisplay }}
|
||||
</p>
|
||||
</div>
|
||||
<BaseIconButton
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="刷新额度"
|
||||
@@ -930,14 +914,14 @@ onBeforeUnmount(() => {
|
||||
@click="handleRefreshQuota(row.id)"
|
||||
>
|
||||
<RefreshCw class="size-3.5" />
|
||||
</BaseIconButton>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div
|
||||
v-for="window in quotaWindowsByGroup(row, 'monthly')"
|
||||
:key="window.key"
|
||||
class="rounded-lg bg-(--cp-bg-subtle) py-2"
|
||||
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>
|
||||
@@ -964,7 +948,7 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
v-for="window in quotaWindowsByGroup(row, 'shortTerm')"
|
||||
:key="window.key"
|
||||
class="rounded-lg bg-(--cp-bg-subtle) py-2"
|
||||
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>
|
||||
@@ -990,7 +974,7 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
v-for="window in quotaWindowsByGroup(row, 'other')"
|
||||
:key="window.key"
|
||||
class="rounded-lg bg-(--cp-bg-subtle) py-2"
|
||||
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>
|
||||
@@ -1,30 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { Plus, RefreshCw, Trash2, Download, Upload, Search, Copy } from '@lucide/vue'
|
||||
import { Plus, Trash2, Search, Copy } 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 BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseTable from '@/components/base/BaseTable.vue'
|
||||
import { withMinimumDuration } from '@/utils/async'
|
||||
|
||||
import type { ClientApiKey } from '@/api'
|
||||
import {
|
||||
batchDeleteApiKeys,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
getApiKeys,
|
||||
updateApiKeyLabel,
|
||||
updateApiKeyStatus,
|
||||
} from '@/api'
|
||||
import { createApiKey, deleteApiKeys, getApiKeys, updateApiKey } from '@/api'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
|
||||
const loading = ref(true)
|
||||
const apiKeys = ref<ClientApiKey[]>([])
|
||||
const apiKeys = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const searchQuery = ref('')
|
||||
@@ -35,8 +25,7 @@ const showSingleDeleteModal = ref(false)
|
||||
const showKeyModal = ref(false)
|
||||
const createdKey = ref('')
|
||||
const editingLabel = ref<{ id: string; value: string } | null>(null)
|
||||
const pendingDeleteKey = ref<ClientApiKey | null>(null)
|
||||
const refreshingList = ref(false)
|
||||
const pendingDeleteKey = ref<any | null>(null)
|
||||
const deletingKey = ref(false)
|
||||
const batchDeleting = ref(false)
|
||||
|
||||
@@ -47,12 +36,33 @@ const createForm = ref({
|
||||
|
||||
const apiKeyColumns = [
|
||||
{ key: 'selection', label: '', width: '48px', align: 'center' as const },
|
||||
{ key: 'identity', label: '名称 / 标签' },
|
||||
{ key: 'prefix', label: '密钥前缀' },
|
||||
{ key: 'enabled', label: '状态' },
|
||||
{ key: 'createdAt', label: '创建时间' },
|
||||
{ key: 'lastUsedAt', label: '最后使用' },
|
||||
{ key: 'actions', label: '操作', width: '80px', align: 'right' 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(() => {
|
||||
@@ -91,7 +101,7 @@ async function loadApiKeys() {
|
||||
try {
|
||||
loading.value = true
|
||||
const data = await getApiKeys()
|
||||
apiKeys.value = data
|
||||
apiKeys.value = data.items
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || '加载失败')
|
||||
} finally {
|
||||
@@ -99,16 +109,6 @@ async function loadApiKeys() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshApiKeys() {
|
||||
if (refreshingList.value || loading.value) return
|
||||
refreshingList.value = true
|
||||
try {
|
||||
await withMinimumDuration(loadApiKeys)
|
||||
} finally {
|
||||
refreshingList.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!createForm.value.name.trim()) {
|
||||
toast.warning('请输入 API Key 名称')
|
||||
@@ -133,7 +133,7 @@ async function handleCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
function requestDeleteKey(key: ClientApiKey) {
|
||||
function requestDeleteKey(key: any) {
|
||||
pendingDeleteKey.value = key
|
||||
showSingleDeleteModal.value = true
|
||||
}
|
||||
@@ -144,7 +144,7 @@ async function handleDelete() {
|
||||
|
||||
try {
|
||||
deletingKey.value = true
|
||||
await deleteApiKey(keyId)
|
||||
await deleteApiKeys({ ids: [keyId] })
|
||||
showSingleDeleteModal.value = false
|
||||
pendingDeleteKey.value = null
|
||||
await loadApiKeys()
|
||||
@@ -162,7 +162,7 @@ async function handleBatchDelete() {
|
||||
try {
|
||||
batchDeleting.value = true
|
||||
const deleteCount = selectedIds.value.size
|
||||
await batchDeleteApiKeys([...selectedIds.value])
|
||||
await deleteApiKeys({ ids: [...selectedIds.value] })
|
||||
selectedIds.value = new Set()
|
||||
showDeleteModal.value = false
|
||||
await loadApiKeys()
|
||||
@@ -174,9 +174,9 @@ async function handleBatchDelete() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleStatus(key: ClientApiKey) {
|
||||
async function handleToggleStatus(key: any) {
|
||||
try {
|
||||
await updateApiKeyStatus(key.id, !key.enabled)
|
||||
await updateApiKey({ id: key.id, status: key.enabled ? 'disabled' : 'active' })
|
||||
await loadApiKeys()
|
||||
toast.success(key.enabled ? '已禁用' : '已启用')
|
||||
} catch (error: any) {
|
||||
@@ -186,7 +186,7 @@ async function handleToggleStatus(key: ClientApiKey) {
|
||||
|
||||
async function handleUpdateLabel(keyId: string, label: string) {
|
||||
try {
|
||||
await updateApiKeyLabel(keyId, label || null)
|
||||
await updateApiKey({ id: keyId, label: label || null })
|
||||
editingLabel.value = null
|
||||
await loadApiKeys()
|
||||
toast.success('标签已更新')
|
||||
@@ -195,7 +195,7 @@ async function handleUpdateLabel(keyId: string, label: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function startEditLabel(key: ClientApiKey) {
|
||||
function startEditLabel(key: any) {
|
||||
editingLabel.value = { id: key.id, value: key.label || '' }
|
||||
}
|
||||
|
||||
@@ -279,178 +279,172 @@ watch(filteredKeys, () => {
|
||||
<div class="flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
<header class="flex h-17 shrink-0 items-start justify-between">
|
||||
<div>
|
||||
<h1 class="mt-0 text-[34px] leading-[1.15] font-extrabold mb-0 text-(--cp-text-primary)">
|
||||
API Keys
|
||||
<h1 class="mt-0 mb-0 text-[34px] leading-[1.15] font-extrabold text-(--cp-text-primary)">
|
||||
API 密钥
|
||||
</h1>
|
||||
<p class="mt-2.5 text-[15px] leading-[1.15] font-semibold mb-0 text-(--cp-text-secondary)">
|
||||
管理客户端访问密钥 · 共 {{ apiKeys.length }} 个
|
||||
<p class="mt-2.5 mb-0 text-[15px] leading-[1.15] font-semibold text-(--cp-text-secondary)">
|
||||
签发与维护客户端访问凭证,控制网关调用入口。
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mt-6 flex shrink-0 items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<BaseInput v-model="searchQuery" placeholder="搜索名称、标签或 ID..." class="w-80">
|
||||
<template #prefix>
|
||||
<Search class="size-4.5 text-(--cp-text-tertiary)" />
|
||||
<BaseCard
|
||||
v-loading="loading"
|
||||
:padded="false"
|
||||
class="mt-5 flex min-h-0 flex-1 flex-col"
|
||||
header-class="px-5 pt-4"
|
||||
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>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<BaseTable
|
||||
:columns="apiKeyColumns"
|
||||
:rows="pagedKeys"
|
||||
:selected-row-keys="selectedRowKeys"
|
||||
:pagination="apiKeyPagination"
|
||||
empty-text="暂无 API Key"
|
||||
min-width="1240px"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
>
|
||||
<template #header-selection>
|
||||
<BaseCheckbox
|
||||
:model-value="allSelected"
|
||||
:indeterminate="indeterminate"
|
||||
label="选择当前页密钥"
|
||||
@update:model-value="toggleAll"
|
||||
/>
|
||||
</template>
|
||||
</BaseInput>
|
||||
|
||||
<BaseButton
|
||||
v-if="selectedIds.size > 0"
|
||||
variant="danger"
|
||||
:disabled="batchDeleting"
|
||||
@click="showDeleteModal = true"
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
删除选中 ({{ selectedIds.size }})
|
||||
</BaseButton>
|
||||
</div>
|
||||
<template #selection="{ row }">
|
||||
<BaseCheckbox
|
||||
:model-value="selectedIds.has(row.id)"
|
||||
label="选择密钥"
|
||||
@update:model-value="toggleSelection(row.id)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<BaseIconButton variant="ghost" size="md" title="导出密钥">
|
||||
<Download class="size-4.5" />
|
||||
</BaseIconButton>
|
||||
|
||||
<BaseIconButton variant="ghost" size="md" title="导入密钥">
|
||||
<Upload class="size-4.5" />
|
||||
</BaseIconButton>
|
||||
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="md"
|
||||
title="刷新列表"
|
||||
:loading="refreshingList"
|
||||
:disabled="loading"
|
||||
@click="refreshApiKeys"
|
||||
>
|
||||
<RefreshCw class="size-4.5" />
|
||||
</BaseIconButton>
|
||||
|
||||
<BaseButton variant="primary" @click="showCreateModal = true">
|
||||
<Plus class="size-4" />
|
||||
创建 API Key
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseCard v-loading="loading" class="mt-5 flex min-h-0 flex-1 p-0">
|
||||
<BaseTable
|
||||
:columns="apiKeyColumns"
|
||||
:rows="pagedKeys"
|
||||
:selected-row-keys="selectedRowKeys"
|
||||
:pagination="apiKeyPagination"
|
||||
empty-text="暂无 API Keys"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
>
|
||||
<template #header-selection>
|
||||
<BaseCheckbox
|
||||
:model-value="allSelected"
|
||||
:indeterminate="indeterminate"
|
||||
label="选择当前页密钥"
|
||||
@update:model-value="toggleAll"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #selection="{ row }">
|
||||
<BaseCheckbox
|
||||
:model-value="selectedIds.has(row.id)"
|
||||
label="选择密钥"
|
||||
@update:model-value="toggleSelection(row.id)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #identity="{ row }">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-[14px] font-medium 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"
|
||||
/>
|
||||
<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
|
||||
class="text-[12px] text-(--cp-accent-primary) hover:underline"
|
||||
@click="submitEditingLabel(row.id)"
|
||||
v-else-if="row.label"
|
||||
class="text-left text-[12px] font-[650] text-(--cp-text-tertiary) hover:text-(--cp-info-text)"
|
||||
@click="startEditLabel(row)"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
class="text-[12px] text-(--cp-text-tertiary) hover:underline"
|
||||
@click="cancelEditLabel"
|
||||
>
|
||||
取消
|
||||
{{ row.label }}
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<template #enabled="{ row }">
|
||||
<button
|
||||
v-else-if="row.label"
|
||||
class="text-left text-[13px] text-(--cp-text-tertiary) hover:text-(--cp-accent-primary)"
|
||||
@click="startEditLabel(row)"
|
||||
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.label }}
|
||||
{{ row.enabled ? '已启用' : '已禁用' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #prefix="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="font-mono text-(--cp-text-primary)">
|
||||
{{ maskKey(row.prefix) }}
|
||||
</code>
|
||||
<button
|
||||
class="cursor-pointer border-0 bg-transparent p-0 text-(--cp-text-tertiary) hover:text-(--cp-accent-primary)"
|
||||
@click="copyToClipboard(row.prefix)"
|
||||
>
|
||||
<Copy class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #enabled="{ row }">
|
||||
<button
|
||||
class="inline-flex cursor-pointer items-center rounded-full border-0 px-2 py-0.5 text-[12px] font-medium"
|
||||
: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>
|
||||
</template>
|
||||
|
||||
<template #createdAt="{ row }">
|
||||
<span class="text-(--cp-text-secondary)">
|
||||
{{ row.createdAtDisplay }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #lastUsedAt="{ row }">
|
||||
<span class="text-(--cp-text-secondary)">
|
||||
{{ row.lastUsedAtDisplay }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #actions="{ row }">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="删除密钥"
|
||||
:disabled="deletingKey"
|
||||
@click="requestDeleteKey(row)"
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
<template #actions="{ row }">
|
||||
<div class="flex items-center justify-center">
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="删除密钥"
|
||||
:disabled="deletingKey"
|
||||
@click="requestDeleteKey(row)"
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</template>
|
||||
</BaseCard>
|
||||
|
||||
<!-- 创建 API Key 模态框 -->
|
||||
@@ -512,9 +506,9 @@ watch(filteredKeys, () => {
|
||||
>
|
||||
{{ createdKey }}
|
||||
</code>
|
||||
<BaseIconButton size="md" title="复制" @click="copyToClipboard(createdKey)">
|
||||
<BaseButton icon-only size="md" title="复制" @click="copyToClipboard(createdKey)">
|
||||
<Copy class="size-4" />
|
||||
</BaseIconButton>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4,35 +4,27 @@ import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
|
||||
|
||||
import BaseCard from '../../../components/base/BaseCard.vue'
|
||||
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
|
||||
import type {
|
||||
AccountCapacityInfo,
|
||||
AccountPoolSummary,
|
||||
AccountStatusRow,
|
||||
AccountUsageItem,
|
||||
ScheduleStat,
|
||||
SemanticTone,
|
||||
} from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
accounts: AccountUsageItem[]
|
||||
pool?: AccountPoolSummary | null
|
||||
capacity?: AccountCapacityInfo | null
|
||||
accounts: any[]
|
||||
pool?: any
|
||||
capacity?: any
|
||||
rotationStrategy?: string | null
|
||||
}>()
|
||||
|
||||
const scheduleStats = computed<ScheduleStat[]>(() => {
|
||||
const scheduleStats = computed(() => {
|
||||
const cap = props.capacity
|
||||
return [
|
||||
{ label: '单账号并发', value: cap ? String(cap.maxConcurrentPerAccount) : '—' },
|
||||
{ label: '可用槽位', value: cap ? String(cap.totalSlots) : '—' },
|
||||
{ label: '剩余槽位', value: cap ? String(cap.availableSlots) : '—' },
|
||||
{ label: '总槽位', value: cap ? String(cap.totalSlots) : '—' },
|
||||
{ label: '空闲槽位', value: cap ? String(cap.availableSlots) : '—' },
|
||||
]
|
||||
})
|
||||
|
||||
const usedPercent = computed(() => {
|
||||
const cap = props.capacity
|
||||
if (!cap || cap.totalSlots === 0) return 0
|
||||
return Math.round((cap.usedSlots / cap.totalSlots) * 100)
|
||||
return Math.min(100, Math.round((cap.usedSlots / cap.totalSlots) * 100))
|
||||
})
|
||||
|
||||
const usedRatio = computed(() => {
|
||||
@@ -47,12 +39,12 @@ const strategyLabel = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
least_used: '最少使用优先',
|
||||
round_robin: '轮询',
|
||||
random: '随机',
|
||||
sticky: '会话粘性',
|
||||
}
|
||||
return map[s] || s
|
||||
})
|
||||
|
||||
const statusRows = computed<AccountStatusRow[]>(() => {
|
||||
const statusRows = computed(() => {
|
||||
const p = props.pool
|
||||
const active = p?.active ?? 0
|
||||
const refreshing = p?.refreshing ?? 0
|
||||
@@ -67,28 +59,28 @@ const statusRows = computed<AccountStatusRow[]>(() => {
|
||||
label: '活跃账号',
|
||||
description: '可直接参与调度',
|
||||
value: String(active),
|
||||
tone: 'success' as SemanticTone,
|
||||
tone: 'success',
|
||||
icon: CircleCheck,
|
||||
},
|
||||
{
|
||||
label: '刷新中',
|
||||
description: '令牌自动刷新中',
|
||||
value: String(refreshing),
|
||||
tone: 'normal' as SemanticTone,
|
||||
tone: 'normal',
|
||||
icon: RefreshCw,
|
||||
},
|
||||
{
|
||||
label: '额度受限',
|
||||
description: '已触发或接近额度阈值',
|
||||
value: String(quota),
|
||||
tone: 'warning' as SemanticTone,
|
||||
tone: 'warning',
|
||||
icon: TriangleAlert,
|
||||
},
|
||||
{
|
||||
label: '不可用',
|
||||
description: `过期 ${expired} · 禁用 ${disabled} · 封禁 ${banned}`,
|
||||
value: String(unavailable),
|
||||
tone: 'danger' as SemanticTone,
|
||||
tone: 'danger',
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
]
|
||||
@@ -115,7 +107,7 @@ const statusBars = computed(() => {
|
||||
].filter((b) => b.pct > 0)
|
||||
})
|
||||
|
||||
const rowToneClasses: Record<SemanticTone, string> = {
|
||||
const rowToneClasses: Record<string, string> = {
|
||||
normal: 'bg-(--cp-normal-bg) text-(--cp-normal)',
|
||||
info: 'bg-(--cp-info-bg) text-(--cp-info)',
|
||||
success: 'bg-(--cp-success-bg) text-(--cp-success)',
|
||||
@@ -123,7 +115,7 @@ const rowToneClasses: Record<SemanticTone, string> = {
|
||||
danger: 'bg-(--cp-danger-bg) text-(--cp-danger)',
|
||||
}
|
||||
|
||||
const valueToneClasses: Record<SemanticTone, string> = {
|
||||
const valueToneClasses: Record<string, string> = {
|
||||
normal: 'text-(--cp-normal-text)',
|
||||
info: 'text-(--cp-info-text)',
|
||||
success: 'text-(--cp-success-text)',
|
||||
@@ -131,7 +123,7 @@ const valueToneClasses: Record<SemanticTone, string> = {
|
||||
danger: 'text-(--cp-danger-text)',
|
||||
}
|
||||
|
||||
const quotaToneClasses: Record<SemanticTone, string> = {
|
||||
const quotaToneClasses: Record<string, string> = {
|
||||
normal: 'bg-(--cp-normal)',
|
||||
info: 'bg-(--cp-info)',
|
||||
success: 'bg-(--cp-success)',
|
||||
@@ -155,7 +147,7 @@ const quotaToneClasses: Record<SemanticTone, string> = {
|
||||
|
||||
<div class="mt-7.75 h-30.5 rounded-[14px] bg-(--cp-bg-subtle) px-4 pt-4.5">
|
||||
<span class="block h-3.5 text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)"
|
||||
>可调度容量</span
|
||||
>槽位占用</span
|
||||
>
|
||||
<div class="mt-3 grid h-8.5 grid-cols-[minmax(0,1fr)_auto] items-start gap-4">
|
||||
<strong
|
||||
@@ -163,7 +155,7 @@ const quotaToneClasses: Record<SemanticTone, string> = {
|
||||
>{{ usedRatio }}</strong
|
||||
>
|
||||
<span class="mt-3.5 text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)"
|
||||
>{{ usedPercent }}% 已分配</span
|
||||
>{{ usedPercent }}% 已占用</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-4.5 h-2.5 w-full overflow-hidden rounded-full bg-slate-200">
|
||||
|
||||
@@ -4,10 +4,9 @@ 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 type { EventLogItem, SemanticTone } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
rows: EventLogItem[]
|
||||
rows: any[]
|
||||
}>()
|
||||
|
||||
const activeFilter = ref('all')
|
||||
@@ -16,16 +15,16 @@ const filters = [
|
||||
{ label: '错误', value: 'error' },
|
||||
]
|
||||
const eventLogColumns = [
|
||||
{ key: 'time', label: '时间', width: 86, fixed: 'left' as const },
|
||||
{ key: 'time', label: '时间', width: 86, fixed: 'left' as const, mono: true, tabular: true },
|
||||
{ key: 'level', label: '级别' },
|
||||
{ key: 'requestId', label: '请求 ID' },
|
||||
{ key: 'requestId', label: '请求 ID', mono: true, tabular: true },
|
||||
{ key: 'route', label: '路由' },
|
||||
{ key: 'model', label: '模型' },
|
||||
{ key: 'statusCode', label: '状态码' },
|
||||
{ key: 'latency', label: '延迟' },
|
||||
{ key: 'statusCode', label: '状态码', mono: true, tabular: true },
|
||||
{ key: 'latency', label: '延迟', mono: true, tabular: true },
|
||||
]
|
||||
|
||||
const levelToneClasses: Record<SemanticTone, string> = {
|
||||
const levelToneClasses: Record<string, string> = {
|
||||
normal: 'bg-(--cp-normal-bg) text-(--cp-normal-text)',
|
||||
info: 'bg-(--cp-info-bg) text-(--cp-info-text)',
|
||||
success: 'bg-(--cp-success-bg) text-(--cp-success-text)',
|
||||
@@ -33,7 +32,7 @@ const levelToneClasses: Record<SemanticTone, string> = {
|
||||
danger: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
|
||||
}
|
||||
|
||||
const statusToneClasses: Record<SemanticTone, string> = {
|
||||
const statusToneClasses: Record<string, string> = {
|
||||
normal: 'text-(--cp-normal-text)',
|
||||
info: 'text-(--cp-success-text)',
|
||||
success: 'text-(--cp-success-text)',
|
||||
@@ -42,7 +41,7 @@ const statusToneClasses: Record<SemanticTone, string> = {
|
||||
}
|
||||
|
||||
function rowTone(row: object) {
|
||||
return (row as EventLogItem).tone
|
||||
return (row as any).tone
|
||||
}
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
@@ -53,61 +52,48 @@ const filteredRows = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard as="article" :padded="false" class="h-87.5 w-full px-7 pt-6">
|
||||
<header class="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">事件日志</h2>
|
||||
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)">
|
||||
最近 50 条事件
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BaseCard
|
||||
as="article"
|
||||
variant="dashboard"
|
||||
title="事件日志"
|
||||
description="最近 50 条事件"
|
||||
class="h-87.5 w-full"
|
||||
>
|
||||
<template #actions>
|
||||
<BaseSegmented v-model="activeFilter" :options="filters" class="w-38" />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<div class="mt-4.25 flex h-60 w-full justify-between overflow-hidden">
|
||||
<BaseEmpty
|
||||
v-if="filteredRows.length === 0"
|
||||
compact
|
||||
class="w-full place-content-center"
|
||||
:title="rows.length === 0 ? '暂无事件日志' : '没有匹配日志'"
|
||||
:description="
|
||||
rows.length === 0 ? '请求经过代理后会在这里显示最近事件。' : '调整筛选条件后再查看。'
|
||||
"
|
||||
/>
|
||||
<BaseTable
|
||||
v-else
|
||||
class="min-w-0 flex-1"
|
||||
:columns="eventLogColumns"
|
||||
:rows="filteredRows"
|
||||
min-width="760px"
|
||||
>
|
||||
<template #time="{ value }">
|
||||
<span class="font-mono tabular-nums">{{ value }}</span>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="mt-4.25 flex h-60 w-full justify-between overflow-hidden">
|
||||
<BaseEmpty
|
||||
v-if="filteredRows.length === 0"
|
||||
compact
|
||||
class="w-full place-content-center"
|
||||
:title="rows.length === 0 ? '暂无事件日志' : '没有匹配日志'"
|
||||
:description="
|
||||
rows.length === 0 ? '请求经过代理后会在这里显示最近事件。' : '调整筛选条件后再查看。'
|
||||
"
|
||||
/>
|
||||
<BaseTable
|
||||
v-else
|
||||
class="min-w-0 flex-1"
|
||||
:columns="eventLogColumns"
|
||||
:rows="filteredRows"
|
||||
min-width="760px"
|
||||
>
|
||||
<template #level="{ row, value }">
|
||||
<span
|
||||
class="inline-flex h-6 w-14.5 items-center justify-center rounded-full text-xs leading-[1.15] font-bold"
|
||||
:class="levelToneClasses[rowTone(row)]"
|
||||
>{{ value }}</span
|
||||
>
|
||||
</template>
|
||||
|
||||
<template #level="{ row, value }">
|
||||
<span
|
||||
class="inline-flex h-6 w-14.5 items-center justify-center rounded-full text-xs leading-[1.15] font-bold"
|
||||
:class="levelToneClasses[rowTone(row)]"
|
||||
>{{ value }}</span
|
||||
>
|
||||
</template>
|
||||
|
||||
<template #requestId="{ value }">
|
||||
<span class="font-mono tabular-nums">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #statusCode="{ row, value }">
|
||||
<span class="font-mono font-bold tabular-nums" :class="statusToneClasses[rowTone(row)]">{{
|
||||
value
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<template #latency="{ value }">
|
||||
<span class="font-mono tabular-nums">{{ value }}</span>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
<template #statusCode="{ row, value }">
|
||||
<span class="font-bold" :class="statusToneClasses[rowTone(row)]">{{ value }}</span>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
</template>
|
||||
</BaseCard>
|
||||
</template>
|
||||
|
||||
@@ -4,13 +4,12 @@ import { computed } from 'vue'
|
||||
|
||||
import BaseCard from '../../../components/base/BaseCard.vue'
|
||||
import BaseChart from '../../../components/charts/BaseChart.vue'
|
||||
import type { MetricCardItem, SemanticTone } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
metric: MetricCardItem
|
||||
metric: any
|
||||
}>()
|
||||
|
||||
const iconToneClasses: Record<SemanticTone, string> = {
|
||||
const iconToneClasses: Record<string, string> = {
|
||||
normal: 'bg-(--cp-normal-bg) text-(--cp-normal)',
|
||||
info: 'bg-(--cp-info-bg) text-(--cp-info)',
|
||||
success: 'bg-(--cp-success-bg) text-(--cp-success)',
|
||||
@@ -18,7 +17,7 @@ const iconToneClasses: Record<SemanticTone, string> = {
|
||||
danger: 'bg-(--cp-danger-bg) text-(--cp-danger)',
|
||||
}
|
||||
|
||||
const detailToneClasses: Record<SemanticTone, string> = {
|
||||
const detailToneClasses: Record<string, string> = {
|
||||
normal: 'text-(--cp-normal-text)',
|
||||
info: 'text-(--cp-info-text)',
|
||||
success: 'text-(--cp-success-text)',
|
||||
@@ -26,7 +25,7 @@ const detailToneClasses: Record<SemanticTone, string> = {
|
||||
danger: 'text-(--cp-danger-text)',
|
||||
}
|
||||
|
||||
const trendToneClasses: Record<SemanticTone, string> = {
|
||||
const trendToneClasses: Record<string, string> = {
|
||||
normal: 'bg-(--cp-normal-text)',
|
||||
info: 'bg-(--cp-info-text)',
|
||||
success: 'bg-(--cp-success-text)',
|
||||
@@ -34,7 +33,7 @@ const trendToneClasses: Record<SemanticTone, string> = {
|
||||
danger: 'bg-(--cp-danger-text)',
|
||||
}
|
||||
|
||||
const sparklineColors: Record<SemanticTone, string> = {
|
||||
const sparklineColors: Record<string, string> = {
|
||||
normal: '#94A3B8',
|
||||
info: '#60A5FA',
|
||||
success: '#5CCB8A',
|
||||
@@ -43,7 +42,7 @@ const sparklineColors: Record<SemanticTone, string> = {
|
||||
}
|
||||
|
||||
const sparklineOption = computed<EChartsOption | null>(() => {
|
||||
const values = props.metric.sparkline?.values ?? []
|
||||
const values = (props.metric.sparkline?.values ?? []) as number[]
|
||||
if (values.length < 2) return null
|
||||
|
||||
const color = sparklineColors[props.metric.sparkline?.tone ?? 'normal']
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import BaseCard from '../../../components/base/BaseCard.vue'
|
||||
import type { DashboardHealthTimeline } from '@/api'
|
||||
|
||||
const props = defineProps<{
|
||||
timeline: DashboardHealthTimeline
|
||||
timeline: any
|
||||
}>()
|
||||
|
||||
const points = computed(() => props.timeline.points.split(''))
|
||||
@@ -19,17 +18,15 @@ function cellClass(point: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard as="article" :padded="false" class="w-full px-4 pt-5.5 pb-6 lg:px-7">
|
||||
<header class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="pt-0.5">
|
||||
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">
|
||||
{{ timeline.title }}
|
||||
</h2>
|
||||
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)">
|
||||
{{ timeline.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BaseCard
|
||||
as="article"
|
||||
variant="dashboard"
|
||||
:title="timeline.title"
|
||||
:description="timeline.description"
|
||||
header-collapse-at="lg"
|
||||
class="w-full"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="grid gap-2 lg:justify-items-end">
|
||||
<span class="font-mono text-xs leading-none font-[650] text-(--cp-text-secondary)">
|
||||
{{ timeline.rangeDisplay }}
|
||||
@@ -48,17 +45,19 @@ function cellClass(point: string) {
|
||||
<span>{{ timeline.newestLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<div class="mt-4.25">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<i
|
||||
v-for="(point, index) in points"
|
||||
:key="index"
|
||||
class="size-2.5 rounded-[3px]"
|
||||
:class="cellClass(point)"
|
||||
/>
|
||||
<template #body>
|
||||
<div class="mt-4.25">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<i
|
||||
v-for="(point, index) in points"
|
||||
:key="index"
|
||||
class="size-2.5 rounded-[3px]"
|
||||
:class="cellClass(point)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BaseCard>
|
||||
</template>
|
||||
|
||||
@@ -5,11 +5,10 @@ import BaseCard from '../../../components/base/BaseCard.vue'
|
||||
import BaseChart from '../../../components/charts/BaseChart.vue'
|
||||
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
|
||||
import BaseSegmented from '../../../components/base/BaseSegmented.vue'
|
||||
import type { TrendPoint, TrendSummaryItem } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
points: TrendPoint[]
|
||||
summary: TrendSummaryItem[]
|
||||
points: any[]
|
||||
summary: any[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
@@ -195,64 +194,65 @@ function formatLatency(ms: number): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard as="article" :padded="false" class="min-h-95 w-full px-4 pt-5.5 pb-6 lg:px-7">
|
||||
<header class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="pt-0.5">
|
||||
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">使用趋势</h2>
|
||||
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)">
|
||||
最近 24 小时
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BaseCard
|
||||
as="article"
|
||||
variant="dashboard"
|
||||
title="使用趋势"
|
||||
description="最近 24 小时"
|
||||
class="min-h-95 w-full"
|
||||
>
|
||||
<template #actions>
|
||||
<BaseSegmented
|
||||
v-model="activeTab"
|
||||
:options="tabs"
|
||||
class="w-full max-w-61.5 sm:w-61.5"
|
||||
@update:model-value="emit('trendChange', $event)"
|
||||
/>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="mt-4.75 grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(150px,180px)] lg:gap-7.5"
|
||||
>
|
||||
<div class="relative h-67 w-full overflow-hidden rounded-[10px] bg-white">
|
||||
<BaseChart v-if="hasSamples" :option="chartOption" :height="268" />
|
||||
<BaseEmpty
|
||||
v-if="!hasSamples"
|
||||
compact
|
||||
title="暂无趋势数据"
|
||||
description="最近 24 小时还没有可用于绘制趋势的请求日志。"
|
||||
class="h-full place-content-center bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside class="grid h-67 w-full grid-rows-3 rounded-2xl bg-(--cp-bg-subtle) px-5 py-4.5">
|
||||
<div
|
||||
v-for="item in props.summary"
|
||||
:key="item.label"
|
||||
class="grid grid-cols-[minmax(0,1fr)_8px] items-center gap-x-3 py-2"
|
||||
>
|
||||
<span class="grid gap-1.75">
|
||||
<span class="text-xs leading-[1.15] font-bold text-(--cp-text-secondary)">{{
|
||||
item.label
|
||||
}}</span>
|
||||
<strong
|
||||
class="font-mono text-2xl leading-[1.15] font-[760] tabular-nums text-(--cp-text-primary)"
|
||||
>{{ item.value }}</strong
|
||||
>
|
||||
</span>
|
||||
<i
|
||||
class="size-2 justify-self-end rounded-full"
|
||||
:class="{
|
||||
'bg-(--cp-info)': item.tone === 'info',
|
||||
'bg-(--cp-success)': item.tone === 'success',
|
||||
'bg-(--cp-warning)': item.tone === 'warning',
|
||||
'bg-(--cp-danger)': item.tone === 'danger',
|
||||
'bg-(--cp-normal)': item.tone === 'normal',
|
||||
}"
|
||||
<template #body>
|
||||
<div
|
||||
class="mt-4.75 grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(150px,180px)] lg:gap-7.5"
|
||||
>
|
||||
<div class="relative h-67 w-full overflow-hidden rounded-[10px] bg-white">
|
||||
<BaseChart v-if="hasSamples" :option="chartOption" :height="268" />
|
||||
<BaseEmpty
|
||||
v-if="!hasSamples"
|
||||
compact
|
||||
title="暂无趋势数据"
|
||||
description="最近 24 小时还没有可用于绘制趋势的请求日志。"
|
||||
class="h-full place-content-center bg-white"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<aside class="grid h-67 w-full grid-rows-3 rounded-2xl bg-(--cp-bg-subtle) px-5 py-4.5">
|
||||
<div
|
||||
v-for="item in props.summary"
|
||||
:key="item.label"
|
||||
class="grid grid-cols-[minmax(0,1fr)_8px] items-center gap-x-3 py-2"
|
||||
>
|
||||
<span class="grid gap-1.75">
|
||||
<span class="text-xs leading-[1.15] font-bold text-(--cp-text-secondary)">{{
|
||||
item.label
|
||||
}}</span>
|
||||
<strong
|
||||
class="font-mono text-2xl leading-[1.15] font-[760] tabular-nums text-(--cp-text-primary)"
|
||||
>{{ item.value }}</strong
|
||||
>
|
||||
</span>
|
||||
<i
|
||||
class="size-2 justify-self-end rounded-full"
|
||||
:class="{
|
||||
'bg-(--cp-info)': item.tone === 'info',
|
||||
'bg-(--cp-success)': item.tone === 'success',
|
||||
'bg-(--cp-warning)': item.tone === 'warning',
|
||||
'bg-(--cp-danger)': item.tone === 'danger',
|
||||
'bg-(--cp-normal)': item.tone === 'normal',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
</BaseCard>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import BaseCard from '../../../components/base/BaseCard.vue'
|
||||
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
|
||||
import type { SemanticTone, ServiceStatusItem } from '../types'
|
||||
|
||||
defineProps<{
|
||||
items: ServiceStatusItem[]
|
||||
items: any[]
|
||||
}>()
|
||||
|
||||
const iconToneClasses: Record<SemanticTone, string> = {
|
||||
const iconToneClasses: Record<string, string> = {
|
||||
normal: 'bg-(--cp-normal-bg) text-(--cp-normal)',
|
||||
info: 'bg-(--cp-info-bg) text-(--cp-info)',
|
||||
success: 'bg-(--cp-success-bg) text-(--cp-success)',
|
||||
@@ -15,7 +14,7 @@ const iconToneClasses: Record<SemanticTone, string> = {
|
||||
danger: 'bg-(--cp-danger-bg) text-(--cp-danger)',
|
||||
}
|
||||
|
||||
const valueToneClasses: Record<SemanticTone, string> = {
|
||||
const valueToneClasses: Record<string, string> = {
|
||||
normal: 'text-(--cp-normal-text)',
|
||||
info: 'text-(--cp-info-text)',
|
||||
success: 'text-(--cp-success-text)',
|
||||
@@ -25,56 +24,56 @@ const valueToneClasses: Record<SemanticTone, string> = {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard as="article" :padded="false" class="h-95 w-full px-7 pt-6">
|
||||
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">指纹信息</h2>
|
||||
|
||||
<div class="mt-5.75 grid gap-2">
|
||||
<BaseEmpty
|
||||
v-if="items.length === 0"
|
||||
compact
|
||||
title="暂无指纹信息"
|
||||
description="诊断接口返回后会显示客户端指纹信息。"
|
||||
class="h-72 place-content-center"
|
||||
/>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.label"
|
||||
class="grid h-12.5 w-full grid-cols-[22px_12px_1fr_1fr_1fr] items-center rounded-[14px] bg-(--cp-bg-subtle) px-3.5"
|
||||
>
|
||||
<span
|
||||
class="inline-flex size-6 items-center justify-center rounded-lg"
|
||||
:class="iconToneClasses[item.tone]"
|
||||
<BaseCard as="article" variant="dashboard" title="指纹信息" class="h-95 w-full">
|
||||
<template #body>
|
||||
<div class="mt-5.75 grid gap-2">
|
||||
<BaseEmpty
|
||||
v-if="items.length === 0"
|
||||
compact
|
||||
title="暂无指纹信息"
|
||||
description="诊断接口返回后会显示客户端指纹信息。"
|
||||
class="h-72 place-content-center"
|
||||
/>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.label"
|
||||
class="grid h-12.5 w-full grid-cols-[22px_12px_1fr_1fr_1fr] items-center rounded-[14px] bg-(--cp-bg-subtle) px-3.5"
|
||||
>
|
||||
<component :is="item.icon" :size="14" />
|
||||
</span>
|
||||
<template v-if="item.label === 'User Agent' || item.label === '更新时间'">
|
||||
<strong
|
||||
class="col-start-3 text-[13px] leading-[1.15] font-[650] text-(--cp-text-primary)"
|
||||
>{{ item.label }}</strong
|
||||
>
|
||||
<span
|
||||
class="col-start-4 col-span-2 text-[13px] leading-[1.15] font-mono font-semibold text-(--cp-text-secondary) truncate"
|
||||
>{{ item.value }}</span
|
||||
class="inline-flex size-6 items-center justify-center rounded-lg"
|
||||
:class="iconToneClasses[item.tone]"
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<strong
|
||||
class="col-start-3 text-[13px] leading-[1.15] font-[650] text-(--cp-text-primary)"
|
||||
>{{ item.label }}</strong
|
||||
>
|
||||
<span
|
||||
class="col-start-4 text-[13px] leading-[1.15] font-bold"
|
||||
:class="valueToneClasses[item.tone]"
|
||||
>{{ item.value }}</span
|
||||
>
|
||||
<span
|
||||
class="col-start-5 text-right font-mono text-xs leading-[1.15] font-semibold text-(--cp-text-secondary)"
|
||||
>{{ item.detail }}</span
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<component :is="item.icon" :size="14" />
|
||||
</span>
|
||||
<template v-if="item.label === 'User Agent' || item.label === '更新时间'">
|
||||
<strong
|
||||
class="col-start-3 text-[13px] leading-[1.15] font-[650] text-(--cp-text-primary)"
|
||||
>{{ item.label }}</strong
|
||||
>
|
||||
<span
|
||||
class="col-start-4 col-span-2 text-[13px] leading-[1.15] font-mono font-semibold text-(--cp-text-secondary) truncate"
|
||||
>{{ item.value }}</span
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<strong
|
||||
class="col-start-3 text-[13px] leading-[1.15] font-[650] text-(--cp-text-primary)"
|
||||
>{{ item.label }}</strong
|
||||
>
|
||||
<span
|
||||
class="col-start-4 text-[13px] leading-[1.15] font-bold"
|
||||
:class="valueToneClasses[item.tone]"
|
||||
>{{ item.value }}</span
|
||||
>
|
||||
<span
|
||||
class="col-start-5 text-right font-mono text-xs leading-[1.15] font-semibold text-(--cp-text-secondary)"
|
||||
>{{ item.detail }}</span
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</BaseCard>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import {
|
||||
Activity,
|
||||
@@ -13,57 +12,23 @@ import {
|
||||
} from '@lucide/vue'
|
||||
|
||||
import { getDashboardSummary, getDashboardTrend } from '@/api'
|
||||
import type {
|
||||
DashboardAccountUsage,
|
||||
DashboardHealthTimeline,
|
||||
DashboardServiceStatus,
|
||||
DashboardSummary,
|
||||
DashboardTrend,
|
||||
DashboardTrendKind,
|
||||
} from '@/api'
|
||||
import type {
|
||||
AccountCapacityInfo,
|
||||
AccountPoolSummary,
|
||||
AccountUsageItem,
|
||||
EventLogItem,
|
||||
MetricCardItem,
|
||||
SemanticTone,
|
||||
ServiceStatusItem,
|
||||
TrendPoint,
|
||||
TrendSummaryItem,
|
||||
} from '../types'
|
||||
import { withMinimumDuration } from '@/utils/async'
|
||||
|
||||
export function useDashboard(): {
|
||||
loading: Ref<boolean>
|
||||
refreshing: Ref<boolean>
|
||||
trendLoading: Ref<boolean>
|
||||
metrics: Ref<MetricCardItem[]>
|
||||
trendPoints: Ref<TrendPoint[]>
|
||||
trendSummary: Ref<TrendSummaryItem[]>
|
||||
healthTimeline: Ref<DashboardHealthTimeline>
|
||||
accountUsage: Ref<AccountUsageItem[]>
|
||||
serviceStatuses: Ref<ServiceStatusItem[]>
|
||||
eventLogs: Ref<EventLogItem[]>
|
||||
poolSummary: Ref<AccountPoolSummary | null>
|
||||
capacityInfo: Ref<AccountCapacityInfo | null>
|
||||
rotationStrategy: Ref<string | null>
|
||||
refresh: () => Promise<void>
|
||||
loadTrend: (tab: string) => Promise<void>
|
||||
} {
|
||||
const metrics = ref<MetricCardItem[]>(metricCards(emptyDashboardSummary()))
|
||||
const trendPoints = ref<TrendPoint[]>([])
|
||||
const trendSummary = ref<TrendSummaryItem[]>([])
|
||||
const healthTimeline = ref<DashboardHealthTimeline>(emptyDashboardSummary().healthTimeline)
|
||||
const accountUsage = ref<AccountUsageItem[]>([])
|
||||
const serviceStatuses = ref<ServiceStatusItem[]>([])
|
||||
const eventLogs = ref<EventLogItem[]>([])
|
||||
const poolSummary = ref<AccountPoolSummary | null>(null)
|
||||
const capacityInfo = ref<AccountCapacityInfo | null>(null)
|
||||
const rotationStrategy = ref<string | null>(null)
|
||||
export function useDashboard(): any {
|
||||
const metrics = ref<any[]>(metricCards(emptyDashboardSummary()))
|
||||
const trendPoints = ref<any[]>([])
|
||||
const trendSummary = ref<any[]>([])
|
||||
const healthTimeline = ref<any>(emptyDashboardSummary().healthTimeline)
|
||||
const accountUsage = ref<any[]>([])
|
||||
const serviceStatuses = ref<any[]>([])
|
||||
const eventLogs = ref<any[]>([])
|
||||
const poolSummary = ref<any>(null)
|
||||
const capacityInfo = ref<any>(null)
|
||||
const rotationStrategy = ref<any>(null)
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const trendLoading = ref(false)
|
||||
const lastRefreshedAt = ref('')
|
||||
const autoRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
async function loadDashboardData() {
|
||||
@@ -97,7 +62,7 @@ export function useDashboard(): {
|
||||
const kind = trendKindFromTab(tab)
|
||||
try {
|
||||
trendLoading.value = true
|
||||
applyTrend(await getDashboardTrend(kind))
|
||||
applyTrend(await getDashboardTrend({ kind }))
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard trend:', error)
|
||||
} finally {
|
||||
@@ -105,13 +70,13 @@ export function useDashboard(): {
|
||||
}
|
||||
}
|
||||
|
||||
function applySummary(summary: DashboardSummary) {
|
||||
function applySummary(summary: any) {
|
||||
metrics.value = metricCards(summary)
|
||||
applyTrend(summary.trend)
|
||||
healthTimeline.value = summary.healthTimeline
|
||||
accountUsage.value = summary.accountUsage.map(accountUsageItem)
|
||||
serviceStatuses.value = summary.serviceStatuses.map(serviceStatusItem)
|
||||
eventLogs.value = summary.eventLogs.map((log) => ({
|
||||
eventLogs.value = (summary.eventLogs as any[]).map((log) => ({
|
||||
id: log.id,
|
||||
time: log.time,
|
||||
level: levelLabel(log.level),
|
||||
@@ -125,9 +90,10 @@ export function useDashboard(): {
|
||||
poolSummary.value = summary.poolSummary
|
||||
capacityInfo.value = summary.capacityInfo
|
||||
rotationStrategy.value = summary.rotationStrategy ?? null
|
||||
lastRefreshedAt.value = formatDateTime(new Date())
|
||||
}
|
||||
|
||||
function emptyDashboardSummary(): DashboardSummary {
|
||||
function emptyDashboardSummary() {
|
||||
return {
|
||||
cards: {
|
||||
accounts: {
|
||||
@@ -194,9 +160,9 @@ export function useDashboard(): {
|
||||
}
|
||||
}
|
||||
|
||||
function metricCards(summary: DashboardSummary): MetricCardItem[] {
|
||||
function metricCards(summary: any) {
|
||||
const { accounts, traffic, tokens, cache } = summary.cards
|
||||
const points = summary.trend.points
|
||||
const points = summary.trend.points as any[]
|
||||
return [
|
||||
{
|
||||
title: '账号',
|
||||
@@ -276,13 +242,13 @@ export function useDashboard(): {
|
||||
]
|
||||
}
|
||||
|
||||
function sparkline(values: number[], tone: SemanticTone): MetricCardItem['sparkline'] {
|
||||
function sparkline(values: number[], tone: string) {
|
||||
return values.some((value) => value > 0) ? { values, tone } : undefined
|
||||
}
|
||||
|
||||
function applyTrend(trend: DashboardTrend) {
|
||||
function applyTrend(trend: any) {
|
||||
trendPoints.value = trend.points
|
||||
trendSummary.value = trend.summary.map((item) => {
|
||||
trendSummary.value = (trend.summary as any[]).map((item) => {
|
||||
if (trend.kind === 'latency') {
|
||||
return {
|
||||
label: item.label,
|
||||
@@ -303,7 +269,7 @@ export function useDashboard(): {
|
||||
})
|
||||
}
|
||||
|
||||
function accountUsageItem(item: DashboardAccountUsage): AccountUsageItem {
|
||||
function accountUsageItem(item: any) {
|
||||
const tone = accountTone(item.status)
|
||||
const quotaPercent = quotaUsedPercent(item.quotaUsedPercent)
|
||||
return {
|
||||
@@ -324,14 +290,14 @@ export function useDashboard(): {
|
||||
return Math.max(0, Math.min(100, Math.round(value)))
|
||||
}
|
||||
|
||||
function quotaTone(percent: number): SemanticTone {
|
||||
function quotaTone(percent: number) {
|
||||
if (percent >= 95) return 'danger'
|
||||
if (percent >= 80) return 'warning'
|
||||
if (percent > 0) return 'success'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
function serviceStatusItem(item: DashboardServiceStatus, index: number): ServiceStatusItem {
|
||||
function serviceStatusItem(item: any, index: number) {
|
||||
const icons = [MonitorCheck, MonitorCheck, CloudCheck, RefreshCw, Gauge]
|
||||
return {
|
||||
label: item.label,
|
||||
@@ -342,23 +308,19 @@ export function useDashboard(): {
|
||||
}
|
||||
}
|
||||
|
||||
function trendState(
|
||||
current: number,
|
||||
previous: number,
|
||||
fallbackTone: SemanticTone,
|
||||
): MetricCardItem['trend'] {
|
||||
function trendState(current: number, previous: number, fallbackTone: string) {
|
||||
if (current > previous) return { direction: 'up', tone: 'success' }
|
||||
if (current < previous) return { direction: 'down', tone: 'danger' }
|
||||
return previous > 0 || current > 0 ? { direction: 'flat', tone: fallbackTone } : undefined
|
||||
}
|
||||
|
||||
function trendKindFromTab(tab: string): DashboardTrendKind {
|
||||
function trendKindFromTab(tab: string) {
|
||||
if (tab === '延迟') return 'latency'
|
||||
if (tab === '错误') return 'errors'
|
||||
return 'usage'
|
||||
}
|
||||
|
||||
function trendSummaryTone(label: string): SemanticTone {
|
||||
function trendSummaryTone(label: string) {
|
||||
if (label.includes('错误')) return 'danger'
|
||||
if (label.includes('最高')) return 'warning'
|
||||
if (label.includes('输出') || label.includes('最低') || label.includes('成功')) return 'success'
|
||||
@@ -366,7 +328,7 @@ export function useDashboard(): {
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function accountTone(status: DashboardAccountUsage['status']): SemanticTone {
|
||||
function accountTone(status: string) {
|
||||
if (status === 'active') return 'success'
|
||||
if (status === 'quota_exhausted' || status === 'expired') return 'warning'
|
||||
if (status === 'banned' || status === 'disabled') return 'danger'
|
||||
@@ -374,7 +336,7 @@ export function useDashboard(): {
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
function eventTone(level: string, statusCode?: number): SemanticTone {
|
||||
function eventTone(level: string, statusCode?: number) {
|
||||
if (level === 'warn') return 'warning'
|
||||
if (level === 'error' || (typeof statusCode === 'number' && statusCode >= 400)) return 'danger'
|
||||
if (level === 'debug') return 'normal'
|
||||
@@ -427,6 +389,13 @@ export function useDashboard(): {
|
||||
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(() => {
|
||||
@@ -454,6 +423,7 @@ export function useDashboard(): {
|
||||
loading,
|
||||
refreshing,
|
||||
trendLoading,
|
||||
lastRefreshedAt,
|
||||
metrics,
|
||||
trendPoints,
|
||||
trendSummary,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { RefreshCw } from '@lucide/vue'
|
||||
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
import AccountOverviewCard from './components/AccountOverviewCard.vue'
|
||||
import EventLogCard from './components/EventLogCard.vue'
|
||||
@@ -42,7 +42,8 @@ const {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BaseIconButton
|
||||
<BaseButton
|
||||
icon-only
|
||||
class="mt-0.5 text-(--cp-normal)"
|
||||
size="md"
|
||||
label="刷新概览"
|
||||
@@ -50,7 +51,7 @@ const {
|
||||
@click="refresh"
|
||||
>
|
||||
<RefreshCw :size="19" />
|
||||
</BaseIconButton>
|
||||
</BaseButton>
|
||||
</header>
|
||||
|
||||
<section
|
||||
@@ -1,107 +0,0 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export type SemanticTone = 'normal' | 'info' | 'success' | 'warning' | 'danger'
|
||||
|
||||
export interface MetricDetail {
|
||||
label: string
|
||||
value: string
|
||||
tone?: SemanticTone
|
||||
}
|
||||
|
||||
export interface MetricCardItem {
|
||||
title: string
|
||||
value: string
|
||||
icon: Component
|
||||
tone: SemanticTone
|
||||
details: MetricDetail[]
|
||||
sparkline?: {
|
||||
values: number[]
|
||||
tone: SemanticTone
|
||||
}
|
||||
trend?: {
|
||||
direction: 'up' | 'down' | 'flat'
|
||||
tone: SemanticTone
|
||||
}
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
time: string
|
||||
requests: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cachedTokens: number
|
||||
tokens: number
|
||||
errors: number
|
||||
latency: number
|
||||
maxLatency: number
|
||||
minLatency: number
|
||||
successRate: number
|
||||
}
|
||||
|
||||
export interface TrendSummaryItem {
|
||||
label: string
|
||||
value: string
|
||||
tone: SemanticTone
|
||||
}
|
||||
|
||||
export interface AccountUsageItem {
|
||||
name: string
|
||||
email: string
|
||||
plan: string
|
||||
requests: string
|
||||
tokens: string
|
||||
lastUsed: string
|
||||
tone: SemanticTone
|
||||
quotaPercent: number
|
||||
quotaTone: SemanticTone
|
||||
}
|
||||
|
||||
export interface ServiceStatusItem {
|
||||
label: string
|
||||
value: string
|
||||
detail: string
|
||||
tone: SemanticTone
|
||||
icon: Component
|
||||
}
|
||||
|
||||
export interface EventLogItem {
|
||||
id: string
|
||||
time: string
|
||||
level: string
|
||||
requestId: string
|
||||
route: string
|
||||
model: string
|
||||
statusCode: string
|
||||
latency: string
|
||||
tone: SemanticTone
|
||||
}
|
||||
|
||||
export interface AccountPoolSummary {
|
||||
total: number
|
||||
active: number
|
||||
expired: number
|
||||
quotaExhausted: number
|
||||
refreshing: number
|
||||
disabled: number
|
||||
banned: number
|
||||
}
|
||||
|
||||
export interface AccountCapacityInfo {
|
||||
maxConcurrentPerAccount: number
|
||||
totalSlots: number
|
||||
usedSlots: number
|
||||
availableSlots: number
|
||||
}
|
||||
|
||||
export interface AccountStatusRow {
|
||||
label: string
|
||||
description: string
|
||||
value: string
|
||||
tone: SemanticTone
|
||||
icon: Component
|
||||
}
|
||||
|
||||
export interface ScheduleStat {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
@@ -5,64 +5,94 @@ import { Search, RefreshCw, Trash2, Eye } from '@lucide/vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.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 type { EventLog } from '@/api'
|
||||
import { clearLogs, getLogDetail, getLogs } from '@/api'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
|
||||
const loading = ref(true)
|
||||
const logs = ref<EventLog[]>([])
|
||||
const logs = ref<any[]>([])
|
||||
const totalLogs = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const searchQuery = ref('')
|
||||
const filterLevel = ref<string>('')
|
||||
const filterKind = ref<string>('')
|
||||
const filterLevel = ref('')
|
||||
const showClearModal = ref(false)
|
||||
const showDetailModal = ref(false)
|
||||
const selectedLog = ref<EventLog | null>(null)
|
||||
const selectedLog = ref<any>(null)
|
||||
const refreshingList = ref(false)
|
||||
const clearingLogs = ref(false)
|
||||
const loaded = ref(false)
|
||||
let searchTimer: number | undefined
|
||||
|
||||
const levelOptions = [
|
||||
{ label: '全部级别', value: '' },
|
||||
{ label: '信息', value: 'info' },
|
||||
{ label: '警告', value: 'warn' },
|
||||
{ label: '错误', value: 'error' },
|
||||
]
|
||||
|
||||
const kindOptions = [
|
||||
{ label: '全部类型', value: '' },
|
||||
{ label: '请求', value: 'request' },
|
||||
{ label: '响应', value: 'response' },
|
||||
{ label: '系统', value: 'system' },
|
||||
]
|
||||
|
||||
const logColumns = [
|
||||
{ key: 'createdAt', label: '时间' },
|
||||
{ key: 'level', label: '级别' },
|
||||
{ key: 'kind', label: '类型' },
|
||||
{ key: 'requestId', label: '请求 ID' },
|
||||
{ key: 'route', label: '路由' },
|
||||
{ key: 'statusCode', label: '状态码' },
|
||||
{ key: 'message', label: '消息' },
|
||||
{ key: 'actions', label: '操作', width: '76px', align: 'right' as const },
|
||||
{
|
||||
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 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,
|
||||
@@ -76,8 +106,7 @@ async function loadLogs() {
|
||||
const result = await getLogs({
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
level: filterLevel.value ? (filterLevel.value as EventLog['level']) : undefined,
|
||||
kind: filterKind.value || undefined,
|
||||
level: filterLevel.value || undefined,
|
||||
search: searchQuery.value || undefined,
|
||||
})
|
||||
logs.value = result.items
|
||||
@@ -93,6 +122,7 @@ async function loadLogs() {
|
||||
toast.error(error.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,9 +162,9 @@ function handlePageSizeChange(nextPageSize: number) {
|
||||
void loadLogs()
|
||||
}
|
||||
|
||||
async function handleViewDetail(log: EventLog) {
|
||||
async function handleViewDetail(log: any) {
|
||||
try {
|
||||
const detail = await getLogDetail(log.id)
|
||||
const detail = await getLogDetail({ id: log.id })
|
||||
selectedLog.value = detail
|
||||
showDetailModal.value = true
|
||||
} catch (error: any) {
|
||||
@@ -144,6 +174,7 @@ async function handleViewDetail(log: EventLog) {
|
||||
|
||||
function getLevelLabel(level: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
debug: '调试',
|
||||
info: '信息',
|
||||
warn: '警告',
|
||||
error: '错误',
|
||||
@@ -151,11 +182,31 @@ function getLevelLabel(level: string): string {
|
||||
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()
|
||||
})
|
||||
|
||||
watch([searchQuery, filterLevel, filterKind], () => {
|
||||
watch([searchQuery, filterLevel], () => {
|
||||
page.value = 1
|
||||
if (searchTimer) {
|
||||
window.clearTimeout(searchTimer)
|
||||
@@ -174,129 +225,114 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
<header class="flex h-17 shrink-0 items-start justify-between">
|
||||
<header class="flex min-h-17 shrink-0 items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="mt-0 text-[34px] leading-[1.15] font-extrabold mb-0 text-(--cp-text-primary)">
|
||||
<h1 class="mt-0 mb-0 text-[34px] leading-[1.15] font-extrabold text-(--cp-text-primary)">
|
||||
事件日志
|
||||
</h1>
|
||||
<p class="mt-2.5 text-[15px] leading-[1.15] font-semibold mb-0 text-(--cp-text-secondary)">
|
||||
查看系统运行日志 · 共 {{ totalLogs }} 条
|
||||
<p class="mt-2.5 mb-0 text-[15px] leading-[1.15] font-semibold text-(--cp-text-secondary)">
|
||||
追踪网关请求、上游响应与异常线索。
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mt-6 flex shrink-0 items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<BaseInput v-model="searchQuery" placeholder="搜索消息、请求 ID 或路由..." class="w-80">
|
||||
<template #prefix>
|
||||
<Search class="size-4.5 text-(--cp-text-tertiary)" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
|
||||
<BaseSelect v-model="filterLevel" :options="levelOptions" class="w-36" />
|
||||
|
||||
<BaseSelect v-model="filterKind" :options="kindOptions" class="w-36" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="md"
|
||||
title="刷新列表"
|
||||
:loading="refreshingList"
|
||||
:disabled="loading"
|
||||
@click="refreshLogs"
|
||||
>
|
||||
<RefreshCw class="size-4.5" />
|
||||
</BaseIconButton>
|
||||
|
||||
<BaseButton variant="danger" :disabled="clearingLogs" @click="showClearModal = true">
|
||||
<Trash2 class="size-4" />
|
||||
清空日志
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseCard v-loading="loading" class="mt-5 flex min-h-0 flex-1 p-0">
|
||||
<BaseTable
|
||||
:columns="logColumns"
|
||||
:rows="filteredLogs"
|
||||
:pagination="logPagination"
|
||||
empty-text="暂无日志记录"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
>
|
||||
<template #createdAt="{ row }">
|
||||
<span class="font-mono text-(--cp-text-secondary)">
|
||||
{{ row.createdAtDisplay }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #level="{ row }">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-[12px] font-medium"
|
||||
:class="[
|
||||
levelColors[row.level]?.bg || 'bg-(--cp-bg-subtle)',
|
||||
levelColors[row.level]?.text || 'text-(--cp-text-secondary)',
|
||||
]"
|
||||
>
|
||||
{{ getLevelLabel(row.level) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #kind="{ row }">
|
||||
<span class="capitalize text-(--cp-text-secondary)">
|
||||
{{ row.kind }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #requestId="{ row }">
|
||||
<code class="font-mono text-(--cp-text-primary)">
|
||||
{{ row.requestId || '—' }}
|
||||
</code>
|
||||
</template>
|
||||
|
||||
<template #route="{ row }">
|
||||
<code class="font-mono text-(--cp-text-primary)">
|
||||
{{ row.route || '—' }}
|
||||
</code>
|
||||
</template>
|
||||
|
||||
<template #statusCode="{ row }">
|
||||
<span
|
||||
class="font-mono"
|
||||
:class="{
|
||||
'text-(--cp-success-text)':
|
||||
row.statusCode !== undefined && row.statusCode >= 200 && row.statusCode < 300,
|
||||
'text-(--cp-warning-text)':
|
||||
row.statusCode !== undefined && row.statusCode >= 300 && row.statusCode < 400,
|
||||
'text-(--cp-danger-text)': row.statusCode !== undefined && row.statusCode >= 400,
|
||||
'text-(--cp-text-secondary)': row.statusCode === undefined,
|
||||
}"
|
||||
>
|
||||
{{ row.statusCode ?? '—' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #message="{ row }">
|
||||
<p class="m-0 max-w-sm truncate text-(--cp-text-primary)">
|
||||
{{ row.message }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template #actions="{ row }">
|
||||
<div class="flex items-center justify-end">
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="查看详情"
|
||||
@click="handleViewDetail(row)"
|
||||
<BaseCard
|
||||
v-loading="initialLoading"
|
||||
:padded="false"
|
||||
class="mt-5 flex min-h-0 flex-1 flex-col"
|
||||
header-class="px-5 pt-4"
|
||||
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"
|
||||
>
|
||||
<Eye class="size-3.5" />
|
||||
</BaseIconButton>
|
||||
<template #prefix>
|
||||
<Search class="size-4.5 text-(--cp-text-tertiary)" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
|
||||
<BaseSelect v-model="filterLevel" :options="levelOptions" class="w-34" />
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<BaseTable
|
||||
:columns="logColumns"
|
||||
:rows="filteredLogs"
|
||||
:loading="loading"
|
||||
:pagination="logPagination"
|
||||
empty-text="暂无日志记录"
|
||||
min-width="1280px"
|
||||
@page-change="handlePageChange"
|
||||
@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>
|
||||
</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) }}
|
||||
</template>
|
||||
|
||||
<template #actions="{ row }">
|
||||
<div class="flex items-center justify-center">
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="查看日志详情"
|
||||
@click="handleViewDetail(row)"
|
||||
>
|
||||
<Eye class="size-3.5" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</template>
|
||||
</BaseCard>
|
||||
|
||||
<BaseConfirmModal
|
||||
@@ -319,7 +355,7 @@ onBeforeUnmount(() => {
|
||||
variant="info"
|
||||
width="720px"
|
||||
>
|
||||
<div v-if="selectedLog" class="flex flex-col gap-4">
|
||||
<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>
|
||||
@@ -374,12 +410,17 @@ onBeforeUnmount(() => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedLog.metadata">
|
||||
<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>
|
||||
<pre
|
||||
class="m-0 overflow-x-auto rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5 font-mono text-[12px] text-(--cp-text-primary)"
|
||||
>{{ JSON.stringify(selectedLog.metadata, null, 2) }}</pre
|
||||
<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>
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Cpu, Database, Gauge, RefreshCw, RotateCw, Save, ShieldCheck, Timer } from '@lucide/vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseSelect from '@/components/base/BaseSelect.vue'
|
||||
import BaseSwitch from '@/components/base/BaseSwitch.vue'
|
||||
import { withMinimumDuration } from '@/utils/async'
|
||||
|
||||
import type { Settings } from '@/api'
|
||||
import { getSettings, updateSettings } from '@/api'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
|
||||
const loading = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const form = ref<Settings>({
|
||||
defaultModel: '',
|
||||
refreshEnabled: true,
|
||||
maxConcurrentPerAccount: 3,
|
||||
requestIntervalMs: 0,
|
||||
rotationStrategy: 'least_used',
|
||||
quotaWarningThresholds: { primary: [], secondary: [] },
|
||||
quotaSkipExhausted: false,
|
||||
logsEnabled: true,
|
||||
logsCapacity: 5000,
|
||||
})
|
||||
|
||||
const rotationOptions = [
|
||||
{ label: '最少使用优先', value: 'least_used' },
|
||||
{ label: '轮询', value: 'round_robin' },
|
||||
{ label: '随机', value: 'random' },
|
||||
]
|
||||
|
||||
function numericModel(key: 'maxConcurrentPerAccount' | 'requestIntervalMs' | 'logsCapacity') {
|
||||
return computed({
|
||||
get: () => String(form.value[key] ?? 0),
|
||||
set: (value: string) => {
|
||||
const parsed = Number(value)
|
||||
form.value[key] = Number.isFinite(parsed) ? parsed : 0
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const maxConcurrentPerAccountValue = numericModel('maxConcurrentPerAccount')
|
||||
const requestIntervalMsValue = numericModel('requestIntervalMs')
|
||||
const logsCapacityValue = numericModel('logsCapacity')
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
loading.value = true
|
||||
const data = await getSettings()
|
||||
form.value = data
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSettings() {
|
||||
if (refreshing.value || loading.value) return
|
||||
refreshing.value = true
|
||||
try {
|
||||
await withMinimumDuration(loadSettings)
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
saving.value = true
|
||||
const patch: Partial<Settings> = {
|
||||
defaultModel: form.value.defaultModel,
|
||||
refreshEnabled: form.value.refreshEnabled,
|
||||
maxConcurrentPerAccount: form.value.maxConcurrentPerAccount,
|
||||
requestIntervalMs: form.value.requestIntervalMs,
|
||||
rotationStrategy: form.value.rotationStrategy,
|
||||
quotaWarningThresholds: form.value.quotaWarningThresholds,
|
||||
quotaSkipExhausted: form.value.quotaSkipExhausted,
|
||||
logsEnabled: form.value.logsEnabled,
|
||||
logsCapacity: form.value.logsCapacity,
|
||||
}
|
||||
await updateSettings(patch)
|
||||
toast.success('设置已保存')
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<header class="flex h-17 items-start justify-between">
|
||||
<div>
|
||||
<h1 class="mt-0 text-[34px] leading-[1.15] font-extrabold mb-0 text-(--cp-text-primary)">
|
||||
系统设置
|
||||
</h1>
|
||||
<p class="mt-2.5 text-[15px] leading-[1.15] font-semibold mb-0 text-(--cp-text-secondary)">
|
||||
配置系统运行参数
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<section
|
||||
v-loading="loading"
|
||||
class="mt-6 overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-surface) p-5 shadow-(--cp-shadow-card) md:p-6"
|
||||
>
|
||||
<header class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h2 class="m-0 text-xl font-[760] leading-[1.15] text-(--cp-text-primary)">运行参数</h2>
|
||||
<p
|
||||
class="mt-1.75 mb-0 text-[13px] font-semibold leading-[1.15] text-(--cp-text-secondary)"
|
||||
>
|
||||
控制模型调度、刷新策略和事件日志保留。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
:loading="refreshing"
|
||||
:disabled="loading"
|
||||
@click="refreshSettings"
|
||||
>
|
||||
<template #icon>
|
||||
<RefreshCw class="size-4" />
|
||||
</template>
|
||||
重置
|
||||
</BaseButton>
|
||||
<BaseButton variant="primary" :disabled="saving" @click="handleSave">
|
||||
<Save class="size-4" />
|
||||
{{ saving ? '保存中...' : '保存设置' }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mt-5 grid gap-4 xl:grid-cols-12">
|
||||
<section
|
||||
class="min-w-0 rounded-(--cp-panel-radius) bg-(--cp-bg-subtle) p-4 md:p-5 xl:col-span-7"
|
||||
>
|
||||
<div class="mb-5 flex items-center gap-3">
|
||||
<span
|
||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-info-bg) text-(--cp-info)"
|
||||
>
|
||||
<Cpu class="size-4.5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="m-0 text-[15px] font-[760] leading-[1.15] text-(--cp-text-primary)">
|
||||
调度配置
|
||||
</h3>
|
||||
<p class="mt-1 mb-0 text-xs font-[650] leading-[1.15] text-(--cp-text-secondary)">
|
||||
模型、并发与账号轮换策略。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="grid gap-2 md:col-span-2">
|
||||
<span class="text-xs font-bold leading-[1.15] text-(--cp-text-secondary)">
|
||||
默认模型
|
||||
</span>
|
||||
<BaseInput v-model="form.defaultModel">
|
||||
<template #prefix>
|
||||
<Cpu class="size-4 text-(--cp-text-muted)" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<span class="text-xs font-bold leading-[1.15] text-(--cp-text-secondary)">
|
||||
单账号最大并发
|
||||
</span>
|
||||
<BaseInput v-model="maxConcurrentPerAccountValue" type="number">
|
||||
<template #prefix>
|
||||
<Gauge class="size-4 text-(--cp-text-muted)" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<span class="text-xs font-bold leading-[1.15] text-(--cp-text-secondary)">
|
||||
请求间隔 ms
|
||||
</span>
|
||||
<BaseInput v-model="requestIntervalMsValue" type="number">
|
||||
<template #prefix>
|
||||
<Timer class="size-4 text-(--cp-text-muted)" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 md:col-span-2">
|
||||
<span class="text-xs font-bold leading-[1.15] text-(--cp-text-secondary)">
|
||||
轮换策略
|
||||
</span>
|
||||
<BaseSelect v-model="form.rotationStrategy" :options="rotationOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid min-w-0 content-start gap-4 xl:col-span-5">
|
||||
<section class="rounded-(--cp-panel-radius) bg-(--cp-bg-subtle) p-4 md:p-5">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<span
|
||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-success-bg) text-(--cp-success-text)"
|
||||
>
|
||||
<RotateCw class="size-4.5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="m-0 text-[15px] font-[760] leading-[1.15] text-(--cp-text-primary)">
|
||||
运行策略
|
||||
</h3>
|
||||
<p class="mt-1 mb-0 text-xs font-[650] leading-[1.15] text-(--cp-text-secondary)">
|
||||
影响调度健康度与账号可用性。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div
|
||||
class="flex min-h-14 items-center justify-between gap-4 rounded-(--cp-icon-button-radius) bg-(--cp-bg-surface) px-4 py-2 shadow-(--cp-shadow-control)"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 text-[13px] font-[720] leading-[1.15] text-(--cp-text-primary)">
|
||||
自动刷新
|
||||
</p>
|
||||
<p class="mt-1 mb-0 text-xs font-[650] leading-[1.15] text-(--cp-text-secondary)">
|
||||
定时刷新令牌和账号配额
|
||||
</p>
|
||||
</div>
|
||||
<BaseSwitch v-model="form.refreshEnabled" label="启用自动刷新" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex min-h-14 items-center justify-between gap-4 rounded-(--cp-icon-button-radius) bg-(--cp-bg-surface) px-4 py-2 shadow-(--cp-shadow-control)"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 text-[13px] font-[720] leading-[1.15] text-(--cp-text-primary)">
|
||||
跳过耗尽账号
|
||||
</p>
|
||||
<p class="mt-1 mb-0 text-xs font-[650] leading-[1.15] text-(--cp-text-secondary)">
|
||||
调度时避开配额受限账号
|
||||
</p>
|
||||
</div>
|
||||
<BaseSwitch v-model="form.quotaSkipExhausted" label="跳过配额耗尽账号" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-(--cp-panel-radius) bg-(--cp-bg-subtle) p-4 md:p-5">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<span
|
||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-warning-bg) text-(--cp-warning-text)"
|
||||
>
|
||||
<ShieldCheck class="size-4.5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="m-0 text-[15px] font-[760] leading-[1.15] text-(--cp-text-primary)">
|
||||
日志策略
|
||||
</h3>
|
||||
<p class="mt-1 mb-0 text-xs font-[650] leading-[1.15] text-(--cp-text-secondary)">
|
||||
控制事件日志采集与保留容量。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div
|
||||
class="flex min-h-14 items-center justify-between gap-4 rounded-(--cp-icon-button-radius) bg-(--cp-bg-surface) px-4 py-2 shadow-(--cp-shadow-control)"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 text-[13px] font-[720] leading-[1.15] text-(--cp-text-primary)">
|
||||
启用事件日志
|
||||
</p>
|
||||
<p class="mt-1 mb-0 text-xs font-[650] leading-[1.15] text-(--cp-text-secondary)">
|
||||
记录代理请求和上游状态
|
||||
</p>
|
||||
</div>
|
||||
<BaseSwitch v-model="form.logsEnabled" label="启用事件日志" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<span class="text-xs font-bold leading-[1.15] text-(--cp-text-secondary)">
|
||||
容量上限
|
||||
</span>
|
||||
<BaseInput v-model="logsCapacityValue" type="number">
|
||||
<template #prefix>
|
||||
<Database class="size-4 text-(--cp-text-muted)" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Cpu, Gauge, RefreshCw, Save, Timer } from '@lucide/vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseSelect from '@/components/base/BaseSelect.vue'
|
||||
import { withMinimumDuration } from '@/utils/async'
|
||||
|
||||
import { getSettings, updateSettings } from '@/api'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
|
||||
const loading = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const form = ref({
|
||||
defaultModel: '',
|
||||
maxConcurrentPerAccount: 3,
|
||||
requestIntervalMs: 0,
|
||||
rotationStrategy: 'least_used',
|
||||
})
|
||||
|
||||
const rotationOptions = [
|
||||
{ label: '最少使用优先', value: 'least_used' },
|
||||
{ label: '轮询', value: 'round_robin' },
|
||||
{ label: '会话粘性', value: 'sticky' },
|
||||
]
|
||||
|
||||
function numericModel(key: 'maxConcurrentPerAccount' | 'requestIntervalMs') {
|
||||
return computed({
|
||||
get: () => String(form.value[key] ?? 0),
|
||||
set: (value: string) => {
|
||||
const parsed = Number(value)
|
||||
form.value[key] = Number.isFinite(parsed) ? parsed : 0
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const maxConcurrentPerAccountValue = numericModel('maxConcurrentPerAccount')
|
||||
const requestIntervalMsValue = numericModel('requestIntervalMs')
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
loading.value = true
|
||||
const data = await getSettings()
|
||||
form.value = data
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSettings() {
|
||||
if (refreshing.value || loading.value) return
|
||||
refreshing.value = true
|
||||
try {
|
||||
await withMinimumDuration(loadSettings)
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
saving.value = true
|
||||
const patch = {
|
||||
defaultModel: form.value.defaultModel,
|
||||
maxConcurrentPerAccount: form.value.maxConcurrentPerAccount,
|
||||
requestIntervalMs: form.value.requestIntervalMs,
|
||||
rotationStrategy: form.value.rotationStrategy,
|
||||
}
|
||||
await updateSettings(patch)
|
||||
toast.success('设置已保存')
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<header class="flex min-h-17 items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="mt-0 text-[34px] leading-[1.15] font-extrabold mb-0 text-(--cp-text-primary)">
|
||||
系统设置
|
||||
</h1>
|
||||
<p class="mt-2.5 text-[15px] leading-[1.15] font-semibold mb-0 text-(--cp-text-secondary)">
|
||||
配置默认模型和账号调度参数。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-0.5 flex shrink-0 items-center gap-2">
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
:loading="refreshing"
|
||||
:disabled="loading"
|
||||
@click="refreshSettings"
|
||||
>
|
||||
<template #icon>
|
||||
<RefreshCw class="size-4" />
|
||||
</template>
|
||||
重置
|
||||
</BaseButton>
|
||||
<BaseButton variant="primary" :disabled="saving" @click="handleSave">
|
||||
<template #icon>
|
||||
<Save class="size-4" />
|
||||
</template>
|
||||
{{ saving ? '保存中...' : '保存设置' }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<BaseCard v-loading="loading" :padded="false" class="mt-5 max-w-5xl" body-class="px-5 py-5">
|
||||
<template #body>
|
||||
<div class="grid gap-5 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<span class="text-xs leading-[1.15] font-bold text-(--cp-text-secondary)"
|
||||
>默认模型</span
|
||||
>
|
||||
<BaseInput v-model="form.defaultModel">
|
||||
<template #prefix>
|
||||
<Cpu class="size-4 text-(--cp-text-muted)" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<span class="text-xs leading-[1.15] font-bold text-(--cp-text-secondary)">
|
||||
单账号最大并发
|
||||
</span>
|
||||
<BaseInput v-model="maxConcurrentPerAccountValue" type="number">
|
||||
<template #prefix>
|
||||
<Gauge class="size-4 text-(--cp-text-muted)" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<span class="text-xs leading-[1.15] font-bold text-(--cp-text-secondary)"
|
||||
>轮换策略</span
|
||||
>
|
||||
<BaseSelect v-model="form.rotationStrategy" :options="rotationOptions" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<span class="text-xs leading-[1.15] font-bold text-(--cp-text-secondary)">
|
||||
请求间隔 ms
|
||||
</span>
|
||||
<BaseInput v-model="requestIntervalMsValue" type="number">
|
||||
<template #prefix>
|
||||
<Timer class="size-4 text-(--cp-text-muted)" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BaseCard>
|
||||
</div>
|
||||
</template>
|
||||
+5
-2
@@ -14,8 +14,11 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'^/api(/|$)': 'http://127.0.0.1:8080',
|
||||
'^/auth(/|$)': 'http://127.0.0.1:8080',
|
||||
'/dev': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/dev/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
Reference in New Issue
Block a user