feat: refine admin web experience

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-27 03:49:13 +08:00
co-authored by Codex
parent 8860cea9a1
commit b99be20ca4
45 changed files with 1865 additions and 2567 deletions
+146 -4
View File
@@ -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();
+2 -46
View File
@@ -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
View File
@@ -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(&quota_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,
}
+7 -9
View File
@@ -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
View File
@@ -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": {
+299 -258
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -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
View File
@@ -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,
})
}
+17 -49
View File
@@ -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
View File
@@ -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',
})
}
+11 -139
View File
@@ -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
View File
@@ -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',
})
}
+9 -34
View File
@@ -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,
})
}
+11 -66
View File
@@ -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
View File
@@ -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,
}
-25
View File
@@ -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
}
+66 -10
View File
@@ -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>
+75 -12
View File
@@ -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>
+89 -49
View File
@@ -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>
+20 -8
View File
@@ -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>
+26 -5
View File
@@ -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"
+45 -36
View File
@@ -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>
+2 -2
View File
@@ -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',
])
+53 -10
View File
@@ -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)"
+7 -5
View File
@@ -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>
+6 -6
View File
@@ -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[] = [
{
+1 -2
View File
@@ -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
View File
@@ -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
-107
View File
@@ -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>
-306
View File
@@ -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>
+168
View File
@@ -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
View File
@@ -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: {