perf: 并行化 admin 聚合路由并完善前端缓存预取

- gateway: usage detail / provider summary / pool overview / users list 改为 tokio join 并行拉取依赖数据
- usage: interval timeline 支持自动刷新并按查询区间动态展示,取消服务端 120 分钟过滤并在 ScatterChart 统一封顶
- frontend: 新增管理端导航预取工具及 SidebarNav/MainLayout 触发,admin 读接口统一走 cachedRequest 的短期缓存
- dashboard: request detail 支持短 TTL 缓存并在 UsageRecordsTable mousedown 时预取
- data: migrate 测试在 wait_for_postgres 失败时清理子进程,避免遗留
This commit is contained in:
fawney19
2026-04-19 15:17:25 +08:00
parent 97cd877ce5
commit 41b51f10a9
30 changed files with 619 additions and 243 deletions

View File

@@ -65,9 +65,6 @@ pub(super) async fn build_admin_usage_cache_affinity_interval_timeline_response(
let mut usernames_by_user_id = BTreeMap::new(); let mut usernames_by_user_id = BTreeMap::new();
for row in intervals { for row in intervals {
if row.interval_minutes > 120.0 {
continue;
}
let mut point = json!({ let mut point = json!({
"x": unix_secs_to_rfc3339(row.created_at_unix_secs), "x": unix_secs_to_rfc3339(row.created_at_unix_secs),
"y": ((row.interval_minutes * 100.0).round()) / 100.0, "y": ((row.interval_minutes * 100.0).round()) / 100.0,

View File

@@ -23,6 +23,7 @@ use axum::{
}; };
use serde_json::json; use serde_json::json;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use tokio::try_join;
pub(super) async fn maybe_build_local_admin_usage_detail_response( pub(super) async fn maybe_build_local_admin_usage_detail_response(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
@@ -160,43 +161,50 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
)); ));
}; };
let users_by_id: BTreeMap<String, aether_data::repository::users::StoredUserSummary> = let user_ids = item.user_id.clone().into_iter().collect::<Vec<_>>();
state let (users_by_id, provider_key_names, api_key_names): (
.resolve_auth_user_summaries_by_ids( BTreeMap<String, aether_data::repository::users::StoredUserSummary>,
&item.user_id.clone().into_iter().collect::<Vec<_>>(), BTreeMap<String, String>,
) BTreeMap<String, String>,
.await?; ) = try_join!(
let provider_key_names = state.resolve_auth_user_summaries_by_ids(&user_ids),
admin_usage_provider_key_names(state, std::slice::from_ref(&item)).await?; admin_usage_provider_key_names(state, std::slice::from_ref(&item)),
let api_key_names = admin_usage_api_key_names(state, std::slice::from_ref(&item)),
admin_usage_api_key_names(state, std::slice::from_ref(&item)).await?; )?;
let provider_key_name = admin_usage_provider_key_name(&item, &provider_key_names); let provider_key_name = admin_usage_provider_key_name(&item, &provider_key_names);
let request_body =
admin_usage_resolve_request_capture_body_for_item(state, &item, None).await?;
let mut detail_item = item.clone(); let mut detail_item = item.clone();
let request_body = if include_bodies {
let (request_body, provider_request_body, response_body, client_response_body) = try_join!(
admin_usage_resolve_request_capture_body_for_item(state, &item, None),
admin_usage_resolve_body_value(
state,
&item,
item.provider_request_body.as_ref(),
UsageBodyField::ProviderRequestBody,
),
admin_usage_resolve_body_value(
state,
&item,
item.response_body.as_ref(),
UsageBodyField::ResponseBody,
),
admin_usage_resolve_body_value(
state,
&item,
item.client_response_body.as_ref(),
UsageBodyField::ClientResponseBody,
),
)?;
detail_item.provider_request_body = provider_request_body;
detail_item.response_body = response_body;
detail_item.client_response_body = client_response_body;
request_body
} else {
None
};
if include_bodies { if include_bodies {
detail_item.provider_request_body = admin_usage_resolve_body_value( // request_body 已通过 request capture 解析;其余 detached body 在上方并行加载。
state,
&item,
item.provider_request_body.as_ref(),
UsageBodyField::ProviderRequestBody,
)
.await?;
detail_item.response_body = admin_usage_resolve_body_value(
state,
&item,
item.response_body.as_ref(),
UsageBodyField::ResponseBody,
)
.await?;
detail_item.client_response_body = admin_usage_resolve_body_value(
state,
&item,
item.client_response_body.as_ref(),
UsageBodyField::ClientResponseBody,
)
.await?;
} }
let default_headers = admin_usage_curl_headers(); let default_headers = admin_usage_curl_headers();
let payload = build_admin_usage_detail_payload( let payload = build_admin_usage_detail_payload(

View File

@@ -35,24 +35,31 @@ pub(super) async fn build_admin_pool_overview_response(
.iter() .iter()
.map(|(provider, _)| provider.id.clone()) .map(|(provider, _)| provider.id.clone())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let key_stats = if provider_ids.is_empty() { let redis_runner = state.redis_kv_runner();
Vec::new() let (key_stats_result, cooldown_counts_by_provider) = tokio::join!(
} else { async {
state if provider_ids.is_empty() {
.list_provider_catalog_key_stats_by_provider_ids(&provider_ids) Ok(Vec::new())
.await? } else {
}; state
.list_provider_catalog_key_stats_by_provider_ids(&provider_ids)
.await
}
},
async {
match redis_runner.as_ref() {
Some(runner) if !provider_ids.is_empty() => {
read_admin_provider_pool_cooldown_counts(runner, &provider_ids).await
}
_ => BTreeMap::new(),
}
},
);
let key_stats = key_stats_result?;
let key_stats_by_provider = key_stats let key_stats_by_provider = key_stats
.into_iter() .into_iter()
.map(|item| (item.provider_id.clone(), item)) .map(|item| (item.provider_id.clone(), item))
.collect::<BTreeMap<_, _>>(); .collect::<BTreeMap<_, _>>();
let redis_runner = state.redis_kv_runner();
let cooldown_counts_by_provider = match redis_runner.as_ref() {
Some(runner) if !provider_ids.is_empty() => {
read_admin_provider_pool_cooldown_counts(runner, &provider_ids).await
}
_ => BTreeMap::new(),
};
let providers = pool_enabled_providers let providers = pool_enabled_providers
.into_iter() .into_iter()

View File

@@ -3,6 +3,8 @@ use crate::handlers::admin::request::AdminAppState;
use aether_data_contracts::repository::provider_catalog::{ use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
}; };
use aether_data_contracts::repository::quota::StoredProviderQuotaSnapshot;
use futures_util::future::join_all;
use serde_json::json; use serde_json::json;
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -90,6 +92,9 @@ pub(crate) async fn build_admin_providers_summary_payload(
let normalized_status = status.trim().to_ascii_lowercase(); let normalized_status = status.trim().to_ascii_lowercase();
let normalized_api_format = api_format.trim(); let normalized_api_format = api_format.trim();
let normalized_model_id = model_id.trim(); let normalized_model_id = model_id.trim();
let requires_api_format_filter =
normalized_api_format != "all" && !normalized_api_format.is_empty();
let requires_model_filter = normalized_model_id != "all" && !normalized_model_id.is_empty();
let mut providers = state let mut providers = state
.list_provider_catalog_providers(false) .list_provider_catalog_providers(false)
@@ -100,7 +105,7 @@ pub(crate) async fn build_admin_providers_summary_payload(
.iter() .iter()
.map(|provider| provider.id.clone()) .map(|provider| provider.id.clone())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let all_endpoints = if all_provider_ids.is_empty() { let all_endpoints = if !requires_api_format_filter || all_provider_ids.is_empty() {
Vec::new() Vec::new()
} else { } else {
state state
@@ -109,7 +114,7 @@ pub(crate) async fn build_admin_providers_summary_payload(
.ok() .ok()
.unwrap_or_default() .unwrap_or_default()
}; };
let active_global_model_refs = if all_provider_ids.is_empty() { let active_global_model_refs = if !requires_model_filter || all_provider_ids.is_empty() {
Vec::new() Vec::new()
} else { } else {
state state
@@ -151,8 +156,7 @@ pub(crate) async fn build_admin_providers_summary_payload(
_ => {} _ => {}
} }
if normalized_api_format != "all" if requires_api_format_filter
&& !normalized_api_format.is_empty()
&& !api_formats_by_provider && !api_formats_by_provider
.get(&provider.id) .get(&provider.id)
.is_some_and(|items| items.contains(normalized_api_format)) .is_some_and(|items| items.contains(normalized_api_format))
@@ -160,8 +164,7 @@ pub(crate) async fn build_admin_providers_summary_payload(
return false; return false;
} }
if normalized_model_id != "all" if requires_model_filter
&& !normalized_model_id.is_empty()
&& !active_global_model_ids_by_provider && !active_global_model_ids_by_provider
.get(&provider.id) .get(&provider.id)
.is_some_and(|items| items.contains(normalized_model_id)) .is_some_and(|items| items.contains(normalized_model_id))
@@ -191,32 +194,21 @@ pub(crate) async fn build_admin_providers_summary_payload(
.iter() .iter()
.map(|provider| provider.id.clone()) .map(|provider| provider.id.clone())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let endpoints = if provider_ids.is_empty() { let (endpoints, keys, model_stats, page_active_global_model_refs) = if provider_ids.is_empty() {
Vec::new() (Vec::new(), Vec::new(), Vec::new(), Vec::new())
} else { } else {
state let (endpoints_result, keys_result, model_stats_result, active_global_model_refs_result) = tokio::join!(
.list_provider_catalog_endpoints_by_provider_ids(&provider_ids) state.list_provider_catalog_endpoints_by_provider_ids(&provider_ids),
.await state.list_provider_catalog_key_summaries_by_provider_ids(&provider_ids),
.ok() state.list_provider_model_stats(&provider_ids),
.unwrap_or_default() state.list_active_global_model_ids_by_provider_ids(&provider_ids),
}; );
let keys = if provider_ids.is_empty() { (
Vec::new() endpoints_result.ok().unwrap_or_default(),
} else { keys_result.ok().unwrap_or_default(),
state model_stats_result.ok().unwrap_or_default(),
.list_provider_catalog_key_summaries_by_provider_ids(&provider_ids) active_global_model_refs_result.ok().unwrap_or_default(),
.await )
.ok()
.unwrap_or_default()
};
let model_stats = if provider_ids.is_empty() {
Vec::new()
} else {
state
.list_provider_model_stats(&provider_ids)
.await
.ok()
.unwrap_or_default()
}; };
let mut endpoints_by_provider = BTreeMap::<String, Vec<StoredProviderCatalogEndpoint>>::new(); let mut endpoints_by_provider = BTreeMap::<String, Vec<StoredProviderCatalogEndpoint>>::new();
for endpoint in endpoints { for endpoint in endpoints {
@@ -236,17 +228,30 @@ pub(crate) async fn build_admin_providers_summary_payload(
.into_iter() .into_iter()
.map(|stats| (stats.provider_id.clone(), stats)) .map(|stats| (stats.provider_id.clone(), stats))
.collect::<BTreeMap<_, _>>(); .collect::<BTreeMap<_, _>>();
let mut active_global_model_ids_by_provider = BTreeMap::<String, BTreeSet<String>>::new();
for row in page_active_global_model_refs {
active_global_model_ids_by_provider
.entry(row.provider_id)
.or_default()
.insert(row.global_model_id);
}
let quota_snapshots_by_provider = join_all(provider_ids.iter().map(|provider_id| async {
let quota_snapshot = state
.read_provider_quota_snapshot(provider_id)
.await
.ok()
.flatten();
(provider_id.clone(), quota_snapshot)
}))
.await
.into_iter()
.collect::<BTreeMap<String, Option<StoredProviderQuotaSnapshot>>>();
let now_unix_secs = SystemTime::now() let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
.as_secs(); .as_secs();
let mut items = Vec::with_capacity(providers.len()); let mut items = Vec::with_capacity(providers.len());
for provider in providers { for provider in providers {
let quota_snapshot = state
.read_provider_quota_snapshot(&provider.id)
.await
.ok()
.flatten();
let active_global_model_ids = active_global_model_ids_by_provider let active_global_model_ids = active_global_model_ids_by_provider
.get(&provider.id) .get(&provider.id)
.cloned() .cloned()
@@ -263,7 +268,9 @@ pub(crate) async fn build_admin_providers_summary_payload(
.get(&provider.id) .get(&provider.id)
.map(Vec::as_slice) .map(Vec::as_slice)
.unwrap_or(&[]), .unwrap_or(&[]),
quota_snapshot.as_ref(), quota_snapshots_by_provider
.get(&provider.id)
.and_then(Option::as_ref),
model_stats_by_provider.get(&provider.id), model_stats_by_provider.get(&provider.id),
active_global_model_ids, active_global_model_ids,
now_unix_secs, now_unix_secs,

View File

@@ -42,21 +42,20 @@ pub(in super::super) async fn build_admin_list_users_response(
.iter() .iter()
.map(|row| row.id.clone()) .map(|row| row.id.clone())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let auth_by_user_id = state let (auth_rows_result, wallet_rows_result, usage_totals_result) = tokio::join!(
.list_user_auth_by_ids(&user_ids) state.list_user_auth_by_ids(&user_ids),
.await? state.list_wallet_snapshots_by_user_ids(&user_ids),
state.summarize_usage_totals_by_user_ids(&user_ids),
);
let auth_by_user_id = auth_rows_result?
.into_iter() .into_iter()
.map(|user| (user.id.clone(), user)) .map(|user| (user.id.clone(), user))
.collect::<BTreeMap<_, _>>(); .collect::<BTreeMap<_, _>>();
let wallet_by_user_id = state let wallet_by_user_id = wallet_rows_result?
.list_wallet_snapshots_by_user_ids(&user_ids)
.await?
.into_iter() .into_iter()
.filter_map(|wallet| wallet.user_id.clone().map(|user_id| (user_id, wallet))) .filter_map(|wallet| wallet.user_id.clone().map(|user_id| (user_id, wallet)))
.collect::<BTreeMap<_, _>>(); .collect::<BTreeMap<_, _>>();
let usage_totals_by_user_id = state let usage_totals_by_user_id = usage_totals_result?
.summarize_usage_totals_by_user_ids(&user_ids)
.await?
.into_iter() .into_iter()
.map(|item| (item.user_id.clone(), item)) .map(|item| (item.user_id.clone(), item))
.collect::<BTreeMap<_, _>>(); .collect::<BTreeMap<_, _>>();

View File

@@ -909,15 +909,13 @@ pub(super) async fn handle_users_me_usage_interval_timeline_get(
let mut points = Vec::new(); let mut points = Vec::new();
for row in intervals { for row in intervals {
if row.interval_minutes <= 120.0 { points.push(json!({
points.push(json!({ "x": unix_secs_to_rfc3339(row.created_at_unix_secs),
"x": unix_secs_to_rfc3339(row.created_at_unix_secs), "y": round_to(row.interval_minutes, 2),
"y": round_to(row.interval_minutes, 2), "model": row.model,
"model": row.model, }));
})); if points.len() >= limit {
if points.len() >= limit { break;
break;
}
} }
} }

View File

@@ -5245,7 +5245,7 @@ async fn gateway_handles_users_me_usage_interval_timeline_and_heatmap_locally_wi
"gpt-4.1", "gpt-4.1",
"OpenAI", "OpenAI",
"completed", "completed",
now - chrono::Duration::days(1), now - chrono::Duration::days(1) - chrono::Duration::minutes(1),
), ),
])); ]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) = let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =

View File

@@ -419,9 +419,9 @@ mod tests {
match Self::start(initdb_bin, postgres_bin).await { match Self::start(initdb_bin, postgres_bin).await {
Ok(server) => Ok(Some(server)), Ok(server) => Ok(Some(server)),
Err(err) if postgres_shared_memory_unavailable(err.to_string().as_str()) => { Err(err) if postgres_local_startup_unavailable(err.to_string().as_str()) => {
eprintln!( eprintln!(
"skipping postgres integration test because local postgres could not allocate shared memory: {err}" "skipping postgres integration test because local postgres could not start in this environment: {err}"
); );
Ok(None) Ok(None)
} }
@@ -463,7 +463,7 @@ mod tests {
let log_path = workdir.join("postgres.log"); let log_path = workdir.join("postgres.log");
let stdout = std::fs::File::create(&log_path)?; let stdout = std::fs::File::create(&log_path)?;
let stderr = stdout.try_clone()?; let stderr = stdout.try_clone()?;
let child = Command::new(&postgres_bin) let mut child = Command::new(&postgres_bin)
.arg("-D") .arg("-D")
.arg(&data_dir) .arg(&data_dir)
.arg("-h") .arg("-h")
@@ -489,7 +489,11 @@ mod tests {
.stderr(Stdio::from(stderr)) .stderr(Stdio::from(stderr))
.spawn()?; .spawn()?;
wait_for_postgres(&database_url).await?; if let Err(err) = wait_for_postgres(&database_url).await {
let _ = child.kill();
let _ = child.wait();
return Err(err);
}
Ok(Self { Ok(Self {
child: Some(child), child: Some(child),
@@ -544,6 +548,15 @@ mod tests {
|| message.contains("no space left on device")) || message.contains("no space left on device"))
} }
fn postgres_local_startup_unavailable(message: &str) -> bool {
let message = message.to_ascii_lowercase();
postgres_shared_memory_unavailable(&message)
|| (message.contains("timed out waiting for local postgres")
&& (message.contains("connection refused")
|| message.contains("os error 61")
|| message.contains("os error 111")))
}
async fn wait_for_postgres(database_url: &str) -> Result<(), Box<dyn std::error::Error>> { async fn wait_for_postgres(database_url: &str) -> Result<(), Box<dyn std::error::Error>> {
let deadline = Instant::now() + Duration::from_secs(10); let deadline = Instant::now() + Duration::from_secs(10);
loop { loop {

View File

@@ -1,4 +1,5 @@
import apiClient from './client' import apiClient from './client'
import { buildCacheKey, cachedRequest } from '@/utils/cache'
import type { RefundRequest, WalletSummary, WalletTransaction } from './wallet' import type { RefundRequest, WalletSummary, WalletTransaction } from './wallet'
export interface AdminWallet extends WalletSummary { export interface AdminWallet extends WalletSummary {
@@ -100,41 +101,51 @@ export const adminWalletApi = {
async listAllWallets(params?: { async listAllWallets(params?: {
status?: string status?: string
owner_type?: 'user' | 'api_key' owner_type?: 'user' | 'api_key'
}): Promise<AdminWallet[]> { }, options: { cacheTtlMs?: number } = {}): Promise<AdminWallet[]> {
const items: AdminWallet[] = [] const cacheKey = buildCacheKey(
const limit = 200 'admin:wallets:list-all',
const maxPages = 200 params as Record<string, unknown> | undefined,
let offset = 0 )
let page = 0 return cachedRequest(
cacheKey,
async () => {
const items: AdminWallet[] = []
const limit = 200
const maxPages = 200
let offset = 0
let page = 0
while (page < maxPages) { while (page < maxPages) {
const response = await apiClient.get<AdminWalletListResponse>('/api/admin/wallets', { const response = await apiClient.get<AdminWalletListResponse>('/api/admin/wallets', {
params: { params: {
...params, ...params,
limit, limit,
offset, offset,
}, },
}) })
const data = response.data const data = response.data
items.push(...data.items) items.push(...data.items)
if (items.length >= data.total || data.items.length < limit) { if (items.length >= data.total || data.items.length < limit) {
break break
} }
const nextOffset = offset + data.items.length const nextOffset = offset + data.items.length
if (nextOffset <= offset) { if (nextOffset <= offset) {
throw new Error('分页游标未前进,终止全量钱包拉取以避免死循环') throw new Error('分页游标未前进,终止全量钱包拉取以避免死循环')
} }
offset = nextOffset offset = nextOffset
page += 1 page += 1
} }
if (page >= maxPages) { if (page >= maxPages) {
throw new Error(`钱包列表分页超过最大页数 ${maxPages},已中止请求`) throw new Error(`钱包列表分页超过最大页数 ${maxPages},已中止请求`)
} }
return items return items
},
options.cacheTtlMs ?? 0,
)
}, },
async getWalletDetail(walletId: string): Promise<AdminWalletDetailResponse> { async getWalletDetail(walletId: string): Promise<AdminWalletDetailResponse> {

View File

@@ -583,11 +583,22 @@ export const adminApi = {
}, },
// 获取特定系统配置 // 获取特定系统配置
async getSystemConfig(key: string): Promise<{ key: string; value: unknown }> { async getSystemConfig(
const response = await apiClient.get<{ key: string; value: unknown }>( key: string,
`/api/admin/system/configs/${key}` options: { cacheTtlMs?: number } = {},
): Promise<{ key: string; value: unknown }> {
const cacheTtlMs = options.cacheTtlMs ?? 0
const cacheKey = buildCacheKey('admin:system:config', { key })
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<{ key: string; value: unknown }>(
`/api/admin/system/configs/${key}`
)
return response.data
},
cacheTtlMs,
) )
return response.data
}, },
// 更新系统配置 // 更新系统配置

View File

@@ -1,6 +1,8 @@
import apiClient from './client' import apiClient from './client'
import { cachedRequest, buildCacheKey } from '@/utils/cache' import { cachedRequest, buildCacheKey } from '@/utils/cache'
const REQUEST_DETAIL_PREFETCH_TTL_MS = 5_000
export interface DashboardStat { export interface DashboardStat {
name: string name: string
value: string value: string
@@ -360,11 +362,30 @@ export const dashboardApi = {
// 获取请求详情 // 获取请求详情
// NOTE: This method now calls the new RESTful API at /api/admin/usage/{id} // NOTE: This method now calls the new RESTful API at /api/admin/usage/{id}
async getRequestDetail(requestId: string, options: { includeBodies?: boolean } = {}): Promise<RequestDetail> { async getRequestDetail(
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`, { requestId: string,
params: { include_bodies: options.includeBodies ?? true }, options: { includeBodies?: boolean, cacheTtlMs?: number } = {}
): Promise<RequestDetail> {
const includeBodies = options.includeBodies ?? true
const cacheTtlMs = options.cacheTtlMs ?? 0
const cacheKey = buildCacheKey('dashboard:request-detail', { requestId, includeBodies })
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`, {
params: { include_bodies: includeBodies },
})
return response.data
},
cacheTtlMs
)
},
async prefetchRequestDetail(requestId: string): Promise<void> {
await dashboardApi.getRequestDetail(requestId, {
includeBodies: false,
cacheTtlMs: REQUEST_DETAIL_PREFETCH_TTL_MS
}) })
return response.data
}, },
// 获取每日统计数据 // 获取每日统计数据

View File

@@ -1,5 +1,5 @@
import client from '../client' import client from '../client'
import { dedupedRequest, buildCacheKey } from '@/utils/cache' import { buildCacheKey, cachedRequest, dedupedRequest } from '@/utils/cache'
import type { import type {
GlobalModelCreate, GlobalModelCreate,
GlobalModelUpdate, GlobalModelUpdate,
@@ -22,17 +22,26 @@ export type {
/** /**
* 获取 GlobalModel 列表 * 获取 GlobalModel 列表
*/ */
interface GlobalModelListOptions {
cacheTtlMs?: number
}
export async function getGlobalModels(params?: { export async function getGlobalModels(params?: {
skip?: number skip?: number
limit?: number limit?: number
is_active?: boolean is_active?: boolean
search?: string search?: string
}): Promise<GlobalModelListResponse> { }, options: GlobalModelListOptions = {}): Promise<GlobalModelListResponse> {
const cacheTtlMs = options.cacheTtlMs ?? 0
const key = buildCacheKey('global-models:list', params as Record<string, unknown> | undefined) const key = buildCacheKey('global-models:list', params as Record<string, unknown> | undefined)
return dedupedRequest(key, async () => { return cachedRequest(
const response = await client.get('/api/admin/models/global', { params }) key,
return response.data async () => {
}) const response = await client.get('/api/admin/models/global', { params })
return response.data
},
cacheTtlMs,
)
} }
/** /**

View File

@@ -1,5 +1,5 @@
import client from '../client' import client from '../client'
import { dedupedRequest } from '@/utils/cache' import { buildCacheKey, cachedRequest } from '@/utils/cache'
import type { import type {
AllowedModels, AllowedModels,
OAuthOrganizationInfo, OAuthOrganizationInfo,
@@ -230,33 +230,59 @@ export interface PoolBatchAction {
payload?: Record<string, unknown> | null payload?: Record<string, unknown> | null
} }
export async function getPoolOverview(): Promise<PoolOverviewResponse> { interface PoolReadOptions {
return dedupedRequest('pool:overview', async () => { cacheTtlMs?: number
const response = await client.get<PoolOverviewResponse>('/api/admin/pool/overview')
return response.data
})
} }
export async function getPoolSchedulingPresets(): Promise<PoolPresetMeta[]> { export async function getPoolOverview(
return dedupedRequest('pool:scheduling-presets', async () => { options: PoolReadOptions = {},
const response = await client.get<PoolPresetMeta[]>('/api/admin/pool/scheduling-presets') ): Promise<PoolOverviewResponse> {
return response.data const cacheTtlMs = options.cacheTtlMs ?? 0
}) return cachedRequest(
'pool:overview',
async () => {
const response = await client.get<PoolOverviewResponse>('/api/admin/pool/overview')
return response.data
},
cacheTtlMs,
)
}
export async function getPoolSchedulingPresets(
options: PoolReadOptions = {},
): Promise<PoolPresetMeta[]> {
const cacheTtlMs = options.cacheTtlMs ?? 0
return cachedRequest(
'pool:scheduling-presets',
async () => {
const response = await client.get<PoolPresetMeta[]>('/api/admin/pool/scheduling-presets')
return response.data
},
cacheTtlMs,
)
} }
export async function listPoolKeys( export async function listPoolKeys(
providerId: string, providerId: string,
params: PoolKeysQuery = {}, params: PoolKeysQuery = {},
options: PoolReadOptions = {},
): Promise<PoolKeysPageResponse> { ): Promise<PoolKeysPageResponse> {
const normalizedParams = { const normalizedParams = {
...params, ...params,
quick_selectors: params.quick_selectors?.length ? params.quick_selectors.join(',') : undefined, quick_selectors: params.quick_selectors?.length ? params.quick_selectors.join(',') : undefined,
} }
const key = `pool:keys:${providerId}|${normalizedParams.page ?? ''}|${normalizedParams.page_size ?? ''}|${normalizedParams.search ?? ''}|${normalizedParams.status ?? ''}|${normalizedParams.quick_selectors ?? ''}|${normalizedParams.search_scope ?? ''}` const cacheKey = buildCacheKey(
return dedupedRequest(key, async () => { `pool:keys:${providerId}`,
const response = await client.get<PoolKeysPageResponse>(`/api/admin/pool/${providerId}/keys`, { params: normalizedParams }) normalizedParams as Record<string, unknown>,
return response.data )
}) return cachedRequest(
cacheKey,
async () => {
const response = await client.get<PoolKeysPageResponse>(`/api/admin/pool/${providerId}/keys`, { params: normalizedParams })
return response.data
},
options.cacheTtlMs ?? 0,
)
} }
export async function resolvePoolKeySelection( export async function resolvePoolKeySelection(

View File

@@ -1,5 +1,5 @@
import client from '../client' import client from '../client'
import { dedupedRequest } from '@/utils/cache' import { buildCacheKey, cachedRequest, dedupedRequest } from '@/utils/cache'
import type { import type {
ClaudeCodeAdvancedConfig, ClaudeCodeAdvancedConfig,
FailoverRulesConfig, FailoverRulesConfig,
@@ -13,6 +13,11 @@ interface ProviderRequestOptions {
timeout?: number timeout?: number
} }
interface ProviderReadOptions {
timeout?: number
cacheTtlMs?: number
}
/** /**
* 获取 Providers 摘要(分页) * 获取 Providers 摘要(分页)
*/ */
@@ -43,15 +48,27 @@ function normalizeProviderSummary(
export async function getProvidersSummary( export async function getProvidersSummary(
params: ProviderSummaryQuery = {}, params: ProviderSummaryQuery = {},
options: ProviderReadOptions = {},
): Promise<ProviderSummaryPageResponse> { ): Promise<ProviderSummaryPageResponse> {
const response = await client.get<ProviderSummaryPageResponse>( const cacheTtlMs = options.cacheTtlMs ?? 0
'/api/admin/providers/summary', const cacheKey = buildCacheKey('providers:summary', params as Record<string, unknown>)
{ params }, return cachedRequest(
cacheKey,
async () => {
const response = await client.get<ProviderSummaryPageResponse>(
'/api/admin/providers/summary',
{
params,
timeout: options.timeout,
},
)
return {
...response.data,
items: response.data.items.map(normalizeProviderSummary),
}
},
cacheTtlMs,
) )
return {
...response.data,
items: response.data.items.map(normalizeProviderSummary),
}
} }
/** /**

View File

@@ -5,6 +5,8 @@ import { cachedRequest, buildCacheKey } from '@/utils/cache'
import type { BillingSummary } from './auth' import type { BillingSummary } from './auth'
import type { UserSession } from '@/types/session' import type { UserSession } from '@/types/session'
const ACTIVITY_HEATMAP_CACHE_TTL_MS = 30 * 60 * 1000
export type { UserSession } export type { UserSession }
export interface Profile { export interface Profile {
@@ -414,7 +416,7 @@ export const meApi = {
/** /**
* 获取活跃度热力图数据(用户) * 获取活跃度热力图数据(用户)
* 后端已缓存5分钟 * 历史热力图变化很慢,前端做长缓存,避免短时间重复请求。
*/ */
async getActivityHeatmap(): Promise<ActivityHeatmap> { async getActivityHeatmap(): Promise<ActivityHeatmap> {
return cachedRequest( return cachedRequest(
@@ -423,7 +425,7 @@ export const meApi = {
const response = await apiClient.get<ActivityHeatmap>('/api/users/me/usage/heatmap') const response = await apiClient.get<ActivityHeatmap>('/api/users/me/usage/heatmap')
return response.data return response.data
}, },
60000 ACTIVITY_HEATMAP_CACHE_TTL_MS
) )
} }
} }

View File

@@ -2,6 +2,8 @@ import apiClient from './client'
import { cachedRequest, dedupedRequest, buildCacheKey } from '@/utils/cache' import { cachedRequest, dedupedRequest, buildCacheKey } from '@/utils/cache'
import type { ActivityHeatmap } from '@/types/activity' import type { ActivityHeatmap } from '@/types/activity'
const ACTIVITY_HEATMAP_CACHE_TTL_MS = 30 * 60 * 1000
export interface UsageRecord { export interface UsageRecord {
id: string // UUID id: string // UUID
user_id: string // UUID user_id: string // UUID
@@ -340,7 +342,7 @@ export const usageApi = {
/** /**
* 获取活跃度热力图数据(管理员) * 获取活跃度热力图数据(管理员)
* 后端已缓存5分钟 * 历史热力图变化很慢,前端做长缓存,避免自动刷新链路重复请求。
*/ */
async getActivityHeatmap(): Promise<ActivityHeatmap> { async getActivityHeatmap(): Promise<ActivityHeatmap> {
return cachedRequest( return cachedRequest(
@@ -349,7 +351,7 @@ export const usageApi = {
const response = await apiClient.get<ActivityHeatmap | unknown[]>('/api/admin/usage/heatmap') const response = await apiClient.get<ActivityHeatmap | unknown[]>('/api/admin/usage/heatmap')
return normalizeActivityHeatmapResponse(response.data) return normalizeActivityHeatmapResponse(response.data)
}, },
60000 ACTIVITY_HEATMAP_CACHE_TTL_MS
) )
} }
} }

View File

@@ -1,4 +1,5 @@
import apiClient from './client' import apiClient from './client'
import { cachedRequest } from '@/utils/cache'
import type { UserSession as SessionRecord } from '@/types/session' import type { UserSession as SessionRecord } from '@/types/session'
export interface User { export interface User {
@@ -70,9 +71,16 @@ export interface UpsertUserApiKeyRequest {
export type UserSession = SessionRecord export type UserSession = SessionRecord
export const usersApi = { export const usersApi = {
async getAllUsers(): Promise<User[]> { async getAllUsers(options: { cacheTtlMs?: number } = {}): Promise<User[]> {
const response = await apiClient.get<User[]>('/api/admin/users') const cacheTtlMs = options.cacheTtlMs ?? 0
return response.data return cachedRequest(
'admin:users:list',
async () => {
const response = await apiClient.get<User[]>('/api/admin/users')
return response.data
},
cacheTtlMs,
)
}, },
async getUser(userId: string): Promise<User> { async getUser(userId: string): Promise<User> {

View File

@@ -290,10 +290,10 @@ function transformData(data: ChartData<'scatter'>): ChartData<'scatter'> {
...data, ...data,
datasets: data.datasets.map(dataset => ({ datasets: data.datasets.map(dataset => ({
...dataset, ...dataset,
data: (dataset.data as Array<{ x: string; y: number; _originalX?: string }>).map(point => ({ data: (dataset.data as Array<{ x: string; y: number; _originalX?: string; _originalY?: number }>).map(point => ({
...point, ...point,
y: toDisplayValue(point.y), y: toDisplayValue(Math.min(point.y, 120)),
_originalY: point.y // 保存原始值用于 tooltip _originalY: point._originalY ?? point.y // 保存原始值用于 tooltip
})) }))
})) }))
} }

View File

@@ -29,6 +29,9 @@
? 'bg-primary/10 text-primary font-medium' ? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50' : 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
]" ]"
@mouseenter="emit('prefetch', item.href)"
@focus="emit('prefetch', item.href)"
@pointerdown="emit('prefetch', item.href)"
@click="handleNavigate(item.href)" @click="handleNavigate(item.href)"
> >
<div class="flex items-center gap-2.5"> <div class="flex items-center gap-2.5">
@@ -76,6 +79,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'navigate', href: string): void (e: 'navigate', href: string): void
(e: 'prefetch', href: string): void
}>() }>()
function isItemActive(href: string) { function isItemActive(href: string) {

View File

@@ -66,14 +66,18 @@ const props = withDefaults(defineProps<{
title: string title: string
isAdmin: boolean isAdmin: boolean
hours?: number hours?: number
refreshIntervalMs?: number
}>(), { }>(), {
hours: 24 // 默认当天 hours: 24, // 默认当天
refreshIntervalMs: 30000
}) })
const loading = ref(false) const loading = ref(false)
const timelineData = ref<IntervalTimelineResponse | null>(null) const timelineData = ref<IntervalTimelineResponse | null>(null)
const primaryColor = ref('201, 100, 66') // 默认主题色 const primaryColor = ref('201, 100, 66') // 默认主题色
let loadRequestId = 0 let loadRequestId = 0
let refreshTimer: ReturnType<typeof setTimeout> | null = null
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
const ADMIN_TIMELINE_LIMIT = 1500 const ADMIN_TIMELINE_LIMIT = 1500
const USER_TIMELINE_LIMIT = 1200 const USER_TIMELINE_LIMIT = 1200
@@ -90,7 +94,11 @@ function getPrimaryColor(): string {
onMounted(() => { onMounted(() => {
primaryColor.value = getPrimaryColor() primaryColor.value = getPrimaryColor()
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', handleVisibilityChange)
}
void loadData() void loadData()
scheduleNextRefresh()
}) })
// 预定义的颜色列表(用于区分不同用户/模型) // 预定义的颜色列表(用于区分不同用户/模型)
@@ -315,11 +323,45 @@ async function loadData() {
} }
} }
function stopRefreshTimer() {
if (refreshTimer) {
clearTimeout(refreshTimer)
refreshTimer = null
}
}
function scheduleNextRefresh() {
if (refreshTimer) return
if (!isPageVisible.value) return
if (!props.refreshIntervalMs || props.refreshIntervalMs <= 0) return
refreshTimer = setTimeout(async () => {
refreshTimer = null
await loadData()
scheduleNextRefresh()
}, props.refreshIntervalMs)
}
function handleVisibilityChange() {
isPageVisible.value = !document.hidden
if (!isPageVisible.value) {
stopRefreshTimer()
return
}
void loadData()
scheduleNextRefresh()
}
watch([() => props.hours, () => props.isAdmin], () => { watch([() => props.hours, () => props.isAdmin], () => {
void loadData() void loadData()
stopRefreshTimer()
scheduleNextRefresh()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
loadRequestId++ loadRequestId++
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
stopRefreshTimer()
}) })
</script> </script>

View File

@@ -1664,7 +1664,10 @@ async function loadDetail(id: string, silent = false) {
} }
error.value = null error.value = null
try { try {
const response = await dashboardApi.getRequestDetail(id, { includeBodies: false }) const response = await dashboardApi.getRequestDetail(id, {
includeBodies: false,
cacheTtlMs: silent ? 0 : 5_000
})
if (requestId !== loadDetailRequestId) return if (requestId !== loadDetailRequestId) return
const previousDetail = detail.value const previousDetail = detail.value

View File

@@ -341,7 +341,7 @@
v-else v-else
:key="record.id" :key="record.id"
:class="isAdmin ? 'cursor-pointer border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]' : 'border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]'" :class="isAdmin ? 'cursor-pointer border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]' : 'border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]'"
@mousedown="handleMouseDown" @mousedown="handleRowMouseDown($event, record.id)"
@click="handleRowClick($event, record.id)" @click="handleRowClick($event, record.id)"
> >
<TableCell class="text-xs py-4 w-[70px]"> <TableCell class="text-xs py-4 w-[70px]">
@@ -730,6 +730,7 @@ const emit = defineEmits<{
'update:autoRefresh': [value: boolean] 'update:autoRefresh': [value: boolean]
'refresh': [] 'refresh': []
'showDetail': [id: string] 'showDetail': [id: string]
'prefetchDetail': [id: string]
}>() }>()
// 静态常量(放在 defineProps/defineEmits 之后) // 静态常量(放在 defineProps/defineEmits 之后)
@@ -776,6 +777,13 @@ watch(localSearch, (value) => {
// 使用复用的行点击逻辑 // 使用复用的行点击逻辑
const { handleMouseDown, shouldTriggerRowClick } = useRowClick() const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
function handleRowMouseDown(event: MouseEvent, id: string) {
handleMouseDown(event)
if (!props.isAdmin) return
if (event.button !== 0) return
emit('prefetchDetail', id)
}
// 处理行点击,排除文本选择操作 // 处理行点击,排除文本选择操作
function handleRowClick(event: MouseEvent, id: string) { function handleRowClick(event: MouseEvent, id: string) {
if (!props.isAdmin) return if (!props.isAdmin) return

View File

@@ -53,6 +53,7 @@
<SidebarNav <SidebarNav
:items="navigation" :items="navigation"
:is-active="isNavActive" :is-active="isNavActive"
@prefetch="prefetchNavigationItem"
/> />
</div> </div>
@@ -195,6 +196,9 @@
:class="isNavActive(item.href) :class="isNavActive(item.href)
? 'bg-[#cc785c]/10 dark:bg-[#cc785c]/20 text-[#cc785c] dark:text-[#d4a27f]' ? 'bg-[#cc785c]/10 dark:bg-[#cc785c]/20 text-[#cc785c] dark:text-[#d4a27f]'
: 'text-[#666663] dark:text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5 hover:text-[#191919] dark:hover:text-white'" : 'text-[#666663] dark:text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5 hover:text-[#191919] dark:hover:text-white'"
@mouseenter="prefetchNavigationItem(item.href)"
@focus="prefetchNavigationItem(item.href)"
@pointerdown="prefetchNavigationItem(item.href)"
@click="mobileMenuOpen = false" @click="mobileMenuOpen = false"
> >
<component <component
@@ -382,6 +386,7 @@ import {
import GithubIcon from '@/components/icons/GithubIcon.vue' import GithubIcon from '@/components/icons/GithubIcon.vue'
import { BUILTIN_TOOL_BREADCRUMBS } from '@/config/builtin-tools' import { BUILTIN_TOOL_BREADCRUMBS } from '@/config/builtin-tools'
import { prefetchAdminNavigationTarget } from '@/utils/adminNavigationPrefetch'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -508,6 +513,10 @@ function isNavActive(href: string) {
return route.path === href || route.path.startsWith(`${href}/`) return route.path === href || route.path.startsWith(`${href}/`)
} }
function prefetchNavigationItem(href: string) {
prefetchAdminNavigationTarget(href)
}
// Navigation Data // Navigation Data
const navigation = computed(() => { const navigation = computed(() => {
const baseNavigation = [ const baseNavigation = [

View File

@@ -16,12 +16,12 @@ export const useUsersStore = defineStore('users', () => {
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
async function fetchUsers() { async function fetchUsers(options: { cacheTtlMs?: number } = {}) {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
users.value = await usersApi.getAllUsers() users.value = await usersApi.getAllUsers(options)
} catch (err: unknown) { } catch (err: unknown) {
error.value = parseApiError(err, '获取用户列表失败') error.value = parseApiError(err, '获取用户列表失败')
} finally { } finally {

View File

@@ -0,0 +1,90 @@
import { adminWalletApi } from '@/api/admin-wallets'
import { adminApi } from '@/api/admin'
import { getProvidersSummary } from '@/api/endpoints/providers'
import { getPoolOverview, getPoolSchedulingPresets, listPoolKeys } from '@/api/endpoints/pool'
import { listGlobalModels } from '@/api/global-models'
import { usersApi } from '@/api/users'
import { log } from '@/utils/logger'
const NAV_DATA_CACHE_TTL_MS = 10 * 1000
const NAV_SYSTEM_CONFIG_CACHE_TTL_MS = 30 * 1000
const NAV_POOL_PRESETS_CACHE_TTL_MS = 5 * 60 * 1000
const PREFETCH_COOLDOWN_MS = 5 * 1000
const lastPrefetchAt = new Map<string, number>()
const adminRouteWarmers: Record<string, () => Promise<void>> = {
'/admin/users': async () => {
await Promise.allSettled([
import('@/views/admin/Users.vue'),
usersApi.getAllUsers({ cacheTtlMs: NAV_DATA_CACHE_TTL_MS }),
adminWalletApi.listAllWallets(
{ owner_type: 'user' },
{ cacheTtlMs: NAV_DATA_CACHE_TTL_MS },
),
])
},
'/admin/providers': async () => {
await Promise.allSettled([
import('@/views/admin/ProviderManagement.vue'),
getProvidersSummary(
{ page: 1, page_size: 20 },
{ cacheTtlMs: NAV_DATA_CACHE_TTL_MS },
),
adminApi.getSystemConfig('provider_priority_mode', {
cacheTtlMs: NAV_SYSTEM_CONFIG_CACHE_TTL_MS,
}),
listGlobalModels(
{ is_active: true, limit: 1000 },
{ cacheTtlMs: NAV_DATA_CACHE_TTL_MS },
),
])
},
'/admin/models': async () => {
await Promise.allSettled([
import('@/views/admin/ModelManagement.vue'),
listGlobalModels(
{ skip: 0, limit: 20 },
{ cacheTtlMs: NAV_DATA_CACHE_TTL_MS },
),
])
},
'/admin/pool': async () => {
const [overviewResult] = await Promise.allSettled([
getPoolOverview({ cacheTtlMs: NAV_DATA_CACHE_TTL_MS }),
getPoolSchedulingPresets({ cacheTtlMs: NAV_POOL_PRESETS_CACHE_TTL_MS }),
import('@/views/admin/PoolManagement.vue'),
])
if (overviewResult.status !== 'fulfilled') {
return
}
const firstProviderId = overviewResult.value.items.find(item => item.pool_enabled)?.provider_id
if (!firstProviderId) {
return
}
await listPoolKeys(
firstProviderId,
{ page: 1, page_size: 50, status: 'all' },
{ cacheTtlMs: NAV_DATA_CACHE_TTL_MS },
)
},
}
export function prefetchAdminNavigationTarget(href: string): void {
const warmer = adminRouteWarmers[href]
if (!warmer) return
const now = Date.now()
const lastRun = lastPrefetchAt.get(href) ?? 0
if (now - lastRun < PREFETCH_COOLDOWN_MS) {
return
}
lastPrefetchAt.set(href, now)
void warmer().catch((err) => {
log.debug('[adminNavigationPrefetch] ignore prefetch failure', err)
})
}

View File

@@ -717,6 +717,7 @@ let modelSelectionRequestId = 0
let modelProvidersRequestId = 0 let modelProvidersRequestId = 0
let batchManageModelsRequestId = 0 let batchManageModelsRequestId = 0
let providerOptionsRequest: Promise<void> | null = null let providerOptionsRequest: Promise<void> | null = null
const GLOBAL_MODELS_LIST_CACHE_TTL_MS = 10 * 1000
// 模型目录分页 // 模型目录分页
const catalogCurrentPage = ref(1) const catalogCurrentPage = ref(1)
@@ -1036,11 +1037,13 @@ const globalModelsQueryParams = computed(() => ({
let modelSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null let modelSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null
async function loadGlobalModels() { async function loadGlobalModels(options: { cacheTtlMs?: number } = {}) {
const requestId = ++globalModelsRequestId const requestId = ++globalModelsRequestId
loading.value = true loading.value = true
try { try {
const response = await listGlobalModels(globalModelsQueryParams.value) const response = await listGlobalModels(globalModelsQueryParams.value, {
cacheTtlMs: options.cacheTtlMs ?? 0,
})
if (requestId !== globalModelsRequestId) return if (requestId !== globalModelsRequestId) return
const pageModels = response.models || [] const pageModels = response.models || []
@@ -1533,9 +1536,11 @@ watch(globalModelsQueryParams, (newParams, oldParams) => {
&& newParams.skip === oldParams?.skip && newParams.skip === oldParams?.skip
&& newParams.limit === oldParams?.limit && newParams.limit === oldParams?.limit
if (isSearchOnly) { if (isSearchOnly) {
modelSearchDebounceTimer = setTimeout(loadGlobalModels, 300) modelSearchDebounceTimer = setTimeout(() => {
void loadGlobalModels({ cacheTtlMs: GLOBAL_MODELS_LIST_CACHE_TTL_MS })
}, 300)
} else { } else {
loadGlobalModels() void loadGlobalModels({ cacheTtlMs: GLOBAL_MODELS_LIST_CACHE_TTL_MS })
} }
}, { immediate: true }) }, { immediate: true })

View File

@@ -1258,12 +1258,15 @@ let keysRequestId = 0
let keysSearchDebounceTimer: number | null = null let keysSearchDebounceTimer: number | null = null
let suppressFiltersWatch = false let suppressFiltersWatch = false
let hasHydratedInitialProviderSelection = false let hasHydratedInitialProviderSelection = false
const POOL_OVERVIEW_CACHE_TTL_MS = 10 * 1000
const POOL_KEYS_CACHE_TTL_MS = 10 * 1000
const POOL_SCHEDULING_PRESETS_CACHE_TTL_MS = 5 * 60 * 1000
async function loadOverview() { async function loadOverview(options: { cacheTtlMs?: number } = {}) {
const requestId = ++overviewRequestId const requestId = ++overviewRequestId
overviewLoading.value = true overviewLoading.value = true
try { try {
const res = await getPoolOverview() const res = await getPoolOverview({ cacheTtlMs: options.cacheTtlMs ?? 0 })
if (requestId !== overviewRequestId) return if (requestId !== overviewRequestId) return
const allProviders = Array.isArray(res.items) ? res.items : [] const allProviders = Array.isArray(res.items) ? res.items : []
const enabledProviders = allProviders.filter(item => item.pool_enabled) const enabledProviders = allProviders.filter(item => item.pool_enabled)
@@ -1283,6 +1286,7 @@ async function loadOverview() {
preserveSearch: true, preserveSearch: true,
preserveStatus: true, preserveStatus: true,
preservePagination: true, preservePagination: true,
cacheTtlMs: options.cacheTtlMs ? POOL_KEYS_CACHE_TTL_MS : 0,
}) })
} }
return return
@@ -1296,6 +1300,7 @@ async function loadOverview() {
preserveSearch: shouldPreserveViewState, preserveSearch: shouldPreserveViewState,
preserveStatus: shouldPreserveViewState, preserveStatus: shouldPreserveViewState,
preservePagination: shouldPreserveViewState, preservePagination: shouldPreserveViewState,
cacheTtlMs: options.cacheTtlMs ? POOL_KEYS_CACHE_TTL_MS : 0,
}) })
} else { } else {
selectedProviderId.value = null selectedProviderId.value = null
@@ -1333,7 +1338,7 @@ const selectedProviderIdProxy = computed({
get: () => selectedProviderId.value ?? '', get: () => selectedProviderId.value ?? '',
set: (val: string) => { set: (val: string) => {
if (val && val !== selectedProviderId.value) { if (val && val !== selectedProviderId.value) {
selectProvider(val) void selectProvider(val, { cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
} }
}, },
}) })
@@ -1363,9 +1368,9 @@ function normalizePresetName(value: unknown): string {
return String(value ?? '').trim().toLowerCase() return String(value ?? '').trim().toLowerCase()
} }
async function loadSchedulingPresetMetas(): Promise<void> { async function loadSchedulingPresetMetas(options: { cacheTtlMs?: number } = {}): Promise<void> {
try { try {
const metas = await getPoolSchedulingPresets() const metas = await getPoolSchedulingPresets({ cacheTtlMs: options.cacheTtlMs ?? 0 })
const next: Record<string, string> = {} const next: Record<string, string> = {}
for (const meta of metas as PoolPresetMeta[]) { for (const meta of metas as PoolPresetMeta[]) {
const name = normalizePresetName(meta.name) const name = normalizePresetName(meta.name)
@@ -1492,6 +1497,7 @@ async function selectProvider(
preserveSearch?: boolean preserveSearch?: boolean
preserveStatus?: boolean preserveStatus?: boolean
preservePagination?: boolean preservePagination?: boolean
cacheTtlMs?: number
} = {}, } = {},
) { ) {
const requestId = ++selectProviderRequestId const requestId = ++selectProviderRequestId
@@ -1522,7 +1528,7 @@ async function selectProvider(
keysSearchDebounceTimer = null keysSearchDebounceTimer = null
} }
resetKeyPage(currentPage.value, pageSize.value) resetKeyPage(currentPage.value, pageSize.value)
const keysTask = loadKeys() const keysTask = loadKeys({ cacheTtlMs: options.cacheTtlMs ?? 0 })
// Provider summary is non-blocking for key list rendering. // Provider summary is non-blocking for key list rendering.
void loadProviderData(id) void loadProviderData(id)
await keysTask await keysTask
@@ -1620,6 +1626,7 @@ watch(
preserveSearch: true, preserveSearch: true,
preserveStatus: true, preserveStatus: true,
preservePagination: true, preservePagination: true,
cacheTtlMs: POOL_KEYS_CACHE_TTL_MS,
}) })
}, },
{ immediate: true }, { immediate: true },
@@ -1790,7 +1797,7 @@ async function refreshCurrentPage() {
} }
} }
async function loadKeys() { async function loadKeys(options: { cacheTtlMs?: number } = {}) {
if (!selectedProviderId.value) return if (!selectedProviderId.value) return
const requestId = ++keysRequestId const requestId = ++keysRequestId
const providerId = selectedProviderId.value const providerId = selectedProviderId.value
@@ -1805,6 +1812,8 @@ async function loadKeys() {
page_size: pageSizeValue, page_size: pageSizeValue,
search, search,
status, status,
}, {
cacheTtlMs: options.cacheTtlMs ?? 0,
}) })
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
const resolvedPage = resolvePoolManagementPageAfterLoad({ const resolvedPage = resolvePoolManagementPageAfterLoad({
@@ -1829,13 +1838,13 @@ async function loadKeys() {
} }
watch([currentPage, pageSize], () => { watch([currentPage, pageSize], () => {
void loadKeys() void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
}) })
watch(statusFilter, () => { watch(statusFilter, () => {
if (suppressFiltersWatch) return if (suppressFiltersWatch) return
currentPage.value = 1 currentPage.value = 1
void loadKeys() void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
}) })
watch(searchQuery, () => { watch(searchQuery, () => {
@@ -1846,7 +1855,7 @@ watch(searchQuery, () => {
} }
keysSearchDebounceTimer = window.setTimeout(() => { keysSearchDebounceTimer = window.setTimeout(() => {
keysSearchDebounceTimer = null keysSearchDebounceTimer = null
void loadKeys() void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
}, 300) }, 300)
}) })
@@ -3035,8 +3044,8 @@ function formatRelativeTime(isoStr: string): string {
// --- Init --- // --- Init ---
onMounted(() => { onMounted(() => {
startCountdownTimer() startCountdownTimer()
void loadSchedulingPresetMetas() void loadSchedulingPresetMetas({ cacheTtlMs: POOL_SCHEDULING_PRESETS_CACHE_TTL_MS })
void loadOverview() void loadOverview({ cacheTtlMs: POOL_OVERVIEW_CACHE_TTL_MS })
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {

View File

@@ -324,6 +324,9 @@ let deletePollAbort: AbortController | null = null
const DELETE_POLL_INTERVAL_MS = 2000 const DELETE_POLL_INTERVAL_MS = 2000
const DELETE_POLL_MAX_MS = 30 * 60 * 1000 const DELETE_POLL_MAX_MS = 30 * 60 * 1000
const DELETE_POLL_MAX_FAILURES = 3 const DELETE_POLL_MAX_FAILURES = 3
const PROVIDER_SUMMARY_CACHE_TTL_MS = 10 * 1000
const PROVIDER_PRIORITY_MODE_CACHE_TTL_MS = 30 * 1000
const PROVIDER_MODEL_FILTER_CACHE_TTL_MS = 10 * 1000
async function pollProviderDeleteTask(providerId: string, taskId: string) { async function pollProviderDeleteTask(providerId: string, taskId: string) {
deletePollAbort?.abort() deletePollAbort?.abort()
@@ -533,9 +536,11 @@ const maxProviderPriority = computed(() => {
}) })
// 加载优先级模式 // 加载优先级模式
async function loadPriorityMode() { async function loadPriorityMode(options: { cacheTtlMs?: number } = {}) {
try { try {
const response = await adminApi.getSystemConfig('provider_priority_mode') const response = await adminApi.getSystemConfig('provider_priority_mode', {
cacheTtlMs: options.cacheTtlMs ?? 0,
})
if (response.value) { if (response.value) {
priorityMode.value = response.value as 'provider' | 'global_key' priorityMode.value = response.value as 'provider' | 'global_key'
} }
@@ -545,9 +550,12 @@ async function loadPriorityMode() {
} }
// 加载全局模型列表(用于模型筛选下拉) // 加载全局模型列表(用于模型筛选下拉)
async function loadGlobalModelList() { async function loadGlobalModelList(options: { cacheTtlMs?: number } = {}) {
try { try {
const response = await getGlobalModels({ is_active: true, limit: 1000 }) const response = await getGlobalModels(
{ is_active: true, limit: 1000 },
{ cacheTtlMs: options.cacheTtlMs ?? 0 },
)
globalModels.value = response.models.map(m => ({ id: m.id, name: m.name })) globalModels.value = response.models.map(m => ({ id: m.id, name: m.name }))
} catch { } catch {
globalModels.value = [] globalModels.value = []
@@ -555,11 +563,13 @@ async function loadGlobalModelList() {
} }
// 加载提供商列表(服务端分页) // 加载提供商列表(服务端分页)
async function loadProviders() { async function loadProviders(options: { cacheTtlMs?: number } = {}) {
const requestId = ++providersRequestId const requestId = ++providersRequestId
loading.value = true loading.value = true
try { try {
const response = await getProvidersSummary(queryParams.value) const response = await getProvidersSummary(queryParams.value, {
cacheTtlMs: options.cacheTtlMs ?? 0,
})
if (requestId !== providersRequestId) return if (requestId !== providersRequestId) return
providers.value = response.items providers.value = response.items
total.value = response.total total.value = response.total
@@ -587,9 +597,11 @@ watch(queryParams, (newParams, oldParams) => {
newParams.api_format === oldParams?.api_format && newParams.api_format === oldParams?.api_format &&
newParams.model_id === oldParams?.model_id newParams.model_id === oldParams?.model_id
if (isSearchOnly) { if (isSearchOnly) {
debounceTimer = setTimeout(loadProviders, 300) debounceTimer = setTimeout(() => {
void loadProviders({ cacheTtlMs: PROVIDER_SUMMARY_CACHE_TTL_MS })
}, 300)
} else { } else {
loadProviders() void loadProviders({ cacheTtlMs: PROVIDER_SUMMARY_CACHE_TTL_MS })
} }
}, { deep: true }) }, { deep: true })
@@ -658,7 +670,7 @@ function openOpsConfigDialog(provider: ProviderWithEndpointsSummary) {
// 扩展操作配置保存回调 // 扩展操作配置保存回调
function handleOpsConfigSaved() { function handleOpsConfigSaved() {
opsConfigDialogOpen.value = false opsConfigDialogOpen.value = false
loadProviders() void loadProviders()
} }
// 处理提供商编辑完成 // 处理提供商编辑完成
@@ -680,7 +692,7 @@ async function handlePrioritySaved() {
// 处理提供商添加 // 处理提供商添加
function handleProviderAdded() { function handleProviderAdded() {
loadProviders() void loadProviders()
} }
// 删除提供商 // 删除提供商
@@ -716,7 +728,7 @@ async function handleDeleteProvider(provider: ProviderWithEndpointsSummary) {
showSuccess('提供商已删除') showSuccess('提供商已删除')
providerDeleteProgress.value = null providerDeleteProgress.value = null
loadProviders() void loadProviders()
} catch (err: unknown) { } catch (err: unknown) {
providerDeleteProgress.value = null providerDeleteProgress.value = null
showError(parseApiError(err, '删除提供商失败'), '错误') showError(parseApiError(err, '删除提供商失败'), '错误')
@@ -753,10 +765,10 @@ function handleGlobalClick(event: MouseEvent) {
} }
onMounted(() => { onMounted(() => {
loadProviders() void loadProviders({ cacheTtlMs: PROVIDER_SUMMARY_CACHE_TTL_MS })
loadPriorityMode() void loadPriorityMode({ cacheTtlMs: PROVIDER_PRIORITY_MODE_CACHE_TTL_MS })
loadGlobalModelList() void loadGlobalModelList({ cacheTtlMs: PROVIDER_MODEL_FILTER_CACHE_TTL_MS })
loadArchitectureSchemas() void loadArchitectureSchemas()
document.addEventListener('click', handleGlobalClick, true) document.addEventListener('click', handleGlobalClick, true)
// 每秒更新一次倒计时 // 每秒更新一次倒计时
startTick() startTick()

View File

@@ -1128,6 +1128,9 @@ const filterStatus = ref('all')
const currentPage = ref(1) const currentPage = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
const USERS_PAGE_CACHE_TTL_MS = 10 * 1000
const USER_WALLETS_CACHE_TTL_MS = 10 * 1000
let userWalletsRequestId = 0
const filteredUsers = computed(() => { const filteredUsers = computed(() => {
let filtered = [...usersStore.users] let filtered = [...usersStore.users]
@@ -1173,31 +1176,38 @@ watch([searchQuery, filterRole, filterStatus], () => {
currentPage.value = 1 currentPage.value = 1
}) })
onMounted(async () => { onMounted(() => {
await refreshUsers() void refreshUsers({ preferCache: true })
}) })
async function refreshUsers() { async function refreshUsers(options: { preferCache?: boolean } = {}) {
await Promise.all([ const cacheTtlMs = options.preferCache ? USERS_PAGE_CACHE_TTL_MS : 0
usersStore.fetchUsers(), await usersStore.fetchUsers({ cacheTtlMs })
loadUserWallets() void loadUserWallets({
]) cacheTtlMs: options.preferCache ? USER_WALLETS_CACHE_TTL_MS : 0,
})
} }
function formatDate(dateString: string) { function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('zh-CN') return new Date(dateString).toLocaleDateString('zh-CN')
} }
async function loadUserWallets() { async function loadUserWallets(options: { cacheTtlMs?: number } = {}) {
const requestId = ++userWalletsRequestId
try { try {
const wallets = await adminWalletApi.listAllWallets() const wallets = await adminWalletApi.listAllWallets(
{ owner_type: 'user' },
{ cacheTtlMs: options.cacheTtlMs ?? 0 },
)
if (requestId !== userWalletsRequestId) return
userWalletMap.value = wallets userWalletMap.value = wallets
.filter((wallet) => wallet.owner_type === 'user' && !!wallet.user_id) .filter((wallet) => !!wallet.user_id)
.reduce<Record<string, AdminWallet>>((acc, wallet) => { .reduce<Record<string, AdminWallet>>((acc, wallet) => {
acc[wallet.user_id as string] = wallet acc[wallet.user_id as string] = wallet
return acc return acc
}, {}) }, {})
} catch (err) { } catch (err) {
if (requestId !== userWalletsRequestId) return
log.error('加载用户钱包失败:', err) log.error('加载用户钱包失败:', err)
} }
} }

View File

@@ -35,9 +35,10 @@
:has-error="heatmapError" :has-error="heatmapError"
/> />
<IntervalTimelineCard <IntervalTimelineCard
:title="isAdminPage ? '请求间隔时间线' : '我的请求间隔'" :title="intervalTimelineTitle"
:is-admin="isAdminPage" :is-admin="isAdminPage"
:hours="24" :hours="intervalTimelineHours"
:refresh-interval-ms="30000"
/> />
</div> </div>
@@ -108,6 +109,7 @@
@update:page-size="handlePageSizeChange" @update:page-size="handlePageSizeChange"
@update:auto-refresh="handleAutoRefreshChange" @update:auto-refresh="handleAutoRefreshChange"
@refresh="refreshData" @refresh="refreshData"
@prefetch-detail="prefetchRequestDetail"
@show-detail="showRequestDetail" @show-detail="showRequestDetail"
/> />
@@ -129,6 +131,7 @@ import { useAuthStore } from '@/stores/auth'
import { usageApi } from '@/api/usage' import { usageApi } from '@/api/usage'
import { usersApi } from '@/api/users' import { usersApi } from '@/api/users'
import { meApi } from '@/api/me' import { meApi } from '@/api/me'
import { dashboardApi } from '@/api/dashboard'
import { PanelTopClose, PanelTopOpen } from 'lucide-vue-next' import { PanelTopClose, PanelTopOpen } from 'lucide-vue-next'
import { import {
UsageModelTable, UsageModelTable,
@@ -171,6 +174,44 @@ const currentPage = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
const pageSizeOptions = [10, 20, 50, 100] const pageSizeOptions = [10, 20, 50, 100]
function clampIntervalTimelineHours(hours: number): number {
return Math.min(720, Math.max(1, Math.ceil(hours)))
}
function getIntervalTimelineHours(dateRange: DateRangeParams): number {
switch (dateRange.preset) {
case 'yesterday':
return 48
case 'last7days':
return 24 * 7
case 'last30days':
return 24 * 30
case 'last90days':
return 24 * 30
case 'today':
return 24
default:
break
}
if (dateRange.start_date && dateRange.end_date) {
const start = new Date(`${dateRange.start_date}T00:00:00`)
const end = new Date(`${dateRange.end_date}T23:59:59`)
const diffMs = end.getTime() - start.getTime()
if (!Number.isNaN(diffMs) && diffMs >= 0) {
return clampIntervalTimelineHours(diffMs / (1000 * 60 * 60))
}
}
return 24
}
function formatIntervalTimelineWindow(hours: number): string {
if (hours === 24) return '最近24小时'
if (hours % 24 === 0) return `最近${hours / 24}`
return `最近${hours}小时`
}
// 筛选状态 // 筛选状态
const filterSearch = ref('') const filterSearch = ref('')
const filterUser = ref('__all__') const filterUser = ref('__all__')
@@ -200,6 +241,11 @@ const {
const activityHeatmapData = ref<ActivityHeatmap | null>(null) const activityHeatmapData = ref<ActivityHeatmap | null>(null)
const isLoadingHeatmap = ref(false) const isLoadingHeatmap = ref(false)
const heatmapError = ref(false) const heatmapError = ref(false)
const intervalTimelineHours = computed(() => getIntervalTimelineHours(timeRange.value))
const intervalTimelineTitle = computed(() => {
const baseTitle = isAdminPage.value ? '请求间隔时间线' : '我的请求间隔'
return `${baseTitle}${formatIntervalTimelineWindow(intervalTimelineHours.value)}`
})
const ADMIN_ANALYTICS_REFRESH_INTERVAL = 60000 const ADMIN_ANALYTICS_REFRESH_INTERVAL = 60000
let adminAnalyticsRefreshInFlight: Promise<void> | null = null let adminAnalyticsRefreshInFlight: Promise<void> | null = null
let lastAdminAnalyticsRefreshAt = 0 let lastAdminAnalyticsRefreshAt = 0
@@ -254,13 +300,6 @@ async function refreshAdminAnalytics(options: { force?: boolean } = {}) {
warning('统计数据加载失败,请刷新重试') warning('统计数据加载失败,请刷新重试')
} }
try {
await loadHeatmapData()
hasSuccessfulRefresh = true
} catch (error) {
log.error('加载热力图数据失败:', error)
}
if (hasSuccessfulRefresh) { if (hasSuccessfulRefresh) {
lastAdminAnalyticsRefreshAt = Date.now() lastAdminAnalyticsRefreshAt = Date.now()
} }
@@ -632,6 +671,7 @@ onMounted(async () => {
) )
void (async () => { void (async () => {
await refreshAdminAnalytics({ force: true }) await refreshAdminAnalytics({ force: true })
await loadHeatmapData()
await loadAdminUsers() await loadAdminUsers()
})() })()
} else { } else {
@@ -769,6 +809,7 @@ async function refreshData() {
getCurrentFilters(), getCurrentFilters(),
timeRange.value timeRange.value
) )
// 热力图反映长期活跃分布,不跟随自动刷新链路一起重载。
void refreshAdminAnalytics() void refreshAdminAnalytics()
return return
} }
@@ -791,6 +832,13 @@ function showRequestDetail(id: string) {
detailModalOpen.value = true detailModalOpen.value = true
} }
function prefetchRequestDetail(id: string) {
if (!isAdminPage.value) return
void dashboardApi.prefetchRequestDetail(id).catch(error => {
log.debug('预取请求详情失败', error)
})
}
</script> </script>
<style scoped> <style scoped>