Merge pull request #754 from xvhuan/perf/admin-core-large-dataset

perf(admin): 优化后台大数据场景加载性能(仪表盘/用户/账号/Ops)
This commit is contained in:
Wesley Liddick
2026-03-04 15:15:13 +08:00
committed by GitHub
27 changed files with 1110 additions and 175 deletions

View File

@@ -36,6 +36,7 @@ export async function list(
status?: string
group?: string
search?: string
lite?: string
},
options?: {
signal?: AbortSignal
@@ -66,6 +67,7 @@ export async function listWithEtag(
type?: string
status?: string
search?: string
lite?: string
},
options?: {
signal?: AbortSignal

View File

@@ -120,6 +120,31 @@ export interface GroupStatsResponse {
end_date: string
}
export interface DashboardSnapshotV2Params extends TrendParams {
include_stats?: boolean
include_trend?: boolean
include_model_stats?: boolean
include_group_stats?: boolean
include_users_trend?: boolean
users_trend_limit?: number
}
export interface DashboardSnapshotV2Stats extends DashboardStats {
uptime: number
}
export interface DashboardSnapshotV2Response {
generated_at: string
start_date: string
end_date: string
granularity: string
stats?: DashboardSnapshotV2Stats
trend?: TrendDataPoint[]
models?: ModelStat[]
groups?: GroupStat[]
users_trend?: UserUsageTrendPoint[]
}
/**
* Get group usage statistics
* @param params - Query parameters for filtering
@@ -130,6 +155,16 @@ export async function getGroupStats(params?: GroupStatsParams): Promise<GroupSta
return data
}
/**
* Get dashboard snapshot v2 (aggregated response for heavy admin pages).
*/
export async function getSnapshotV2(params?: DashboardSnapshotV2Params): Promise<DashboardSnapshotV2Response> {
const { data } = await apiClient.get<DashboardSnapshotV2Response>('/admin/dashboard/snapshot-v2', {
params
})
return data
}
export interface ApiKeyTrendParams extends TrendParams {
limit?: number
}
@@ -233,6 +268,7 @@ export const dashboardAPI = {
getUsageTrend,
getModelStats,
getGroupStats,
getSnapshotV2,
getApiKeyUsageTrend,
getUserUsageTrend,
getBatchUsersUsage,

View File

@@ -259,6 +259,13 @@ export interface OpsErrorDistributionResponse {
items: OpsErrorDistributionItem[]
}
export interface OpsDashboardSnapshotV2Response {
generated_at: string
overview: OpsDashboardOverview
throughput_trend: OpsThroughputTrendResponse
error_trend: OpsErrorTrendResponse
}
export type OpsOpenAITokenStatsTimeRange = '30m' | '1h' | '1d' | '15d' | '30d'
export interface OpsOpenAITokenStatsItem {
@@ -1004,6 +1011,24 @@ export async function getDashboardOverview(
return data
}
export async function getDashboardSnapshotV2(
params: {
time_range?: '5m' | '30m' | '1h' | '6h' | '24h'
start_time?: string
end_time?: string
platform?: string
group_id?: number | null
mode?: OpsQueryMode
},
options: OpsRequestOptions = {}
): Promise<OpsDashboardSnapshotV2Response> {
const { data } = await apiClient.get<OpsDashboardSnapshotV2Response>('/admin/ops/dashboard/snapshot-v2', {
params,
signal: options.signal
})
return data
}
export async function getThroughputTrend(
params: {
time_range?: '5m' | '30m' | '1h' | '6h' | '24h'
@@ -1329,6 +1354,7 @@ async function updateMetricThresholds(thresholds: OpsMetricThresholds): Promise<
}
export const opsAPI = {
getDashboardSnapshotV2,
getDashboardOverview,
getThroughputTrend,
getLatencyHistogram,

View File

@@ -22,6 +22,7 @@ export async function list(
role?: 'admin' | 'user'
search?: string
attributes?: Record<number, string> // attributeId -> value
include_subscriptions?: boolean
},
options?: {
signal?: AbortSignal
@@ -33,7 +34,8 @@ export async function list(
page_size: pageSize,
status: filters?.status,
role: filters?.role,
search: filters?.search
search: filters?.search,
include_subscriptions: filters?.include_subscriptions
}
// Add attribute filters as attr[id]=value

View File

@@ -359,7 +359,7 @@ const exportingData = ref(false)
const showColumnDropdown = ref(false)
const columnDropdownRef = ref<HTMLElement | null>(null)
const hiddenColumns = reactive<Set<string>>(new Set())
const DEFAULT_HIDDEN_COLUMNS = ['proxy', 'notes', 'priority', 'rate_multiplier']
const DEFAULT_HIDDEN_COLUMNS = ['today_stats', 'proxy', 'notes', 'priority', 'rate_multiplier']
const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
// Sorting settings
@@ -546,7 +546,7 @@ const {
handlePageSizeChange: baseHandlePageSizeChange
} = useTableLoader<Account, any>({
fetchFn: adminAPI.accounts.list,
initialParams: { platform: '', type: '', status: '', group: '', search: '' }
initialParams: { platform: '', type: '', status: '', group: '', search: '', lite: '1' }
})
const resetAutoRefreshCache = () => {
@@ -689,6 +689,7 @@ const refreshAccountsIncrementally = async () => {
type?: string
status?: string
search?: string
lite?: string
},
{ etag: autoRefreshETag.value }
)

View File

@@ -316,6 +316,7 @@ const trendData = ref<TrendDataPoint[]>([])
const modelStats = ref<ModelStat[]>([])
const userTrend = ref<UserUsageTrendPoint[]>([])
let chartLoadSeq = 0
let usersTrendLoadSeq = 0
// Helper function to format date in local timezone
const formatLocalDate = (date: Date): string => {
@@ -523,67 +524,74 @@ const onDateRangeChange = (range: {
}
// Load data
const loadDashboardStats = async () => {
loading.value = true
const loadDashboardSnapshot = async (includeStats: boolean) => {
const currentSeq = ++chartLoadSeq
if (includeStats && !stats.value) {
loading.value = true
}
chartsLoading.value = true
try {
stats.value = await adminAPI.dashboard.getStats()
const response = await adminAPI.dashboard.getSnapshotV2({
start_date: startDate.value,
end_date: endDate.value,
granularity: granularity.value,
include_stats: includeStats,
include_trend: true,
include_model_stats: true,
include_group_stats: false,
include_users_trend: false
})
if (currentSeq !== chartLoadSeq) return
if (includeStats && response.stats) {
stats.value = response.stats
}
trendData.value = response.trend || []
modelStats.value = response.models || []
} catch (error) {
if (currentSeq !== chartLoadSeq) return
appStore.showError(t('admin.dashboard.failedToLoad'))
console.error('Error loading dashboard stats:', error)
console.error('Error loading dashboard snapshot:', error)
} finally {
if (currentSeq !== chartLoadSeq) return
loading.value = false
chartsLoading.value = false
}
}
const loadChartData = async () => {
const currentSeq = ++chartLoadSeq
chartsLoading.value = true
const loadUsersTrend = async () => {
const currentSeq = ++usersTrendLoadSeq
userTrendLoading.value = true
try {
const params = {
start_date: startDate.value,
end_date: endDate.value,
granularity: granularity.value
}
const [trendResponse, modelResponse] = await Promise.all([
adminAPI.dashboard.getUsageTrend(params),
adminAPI.dashboard.getModelStats({ start_date: startDate.value, end_date: endDate.value })
])
if (currentSeq !== chartLoadSeq) return
trendData.value = trendResponse.trend || []
modelStats.value = modelResponse.models || []
} catch (error) {
if (currentSeq !== chartLoadSeq) return
console.error('Error loading chart data:', error)
} finally {
if (currentSeq !== chartLoadSeq) return
chartsLoading.value = false
}
try {
const params = {
const response = await adminAPI.dashboard.getUserUsageTrend({
start_date: startDate.value,
end_date: endDate.value,
granularity: granularity.value,
limit: 12
}
const userResponse = await adminAPI.dashboard.getUserUsageTrend(params)
if (currentSeq !== chartLoadSeq) return
userTrend.value = userResponse.trend || []
})
if (currentSeq !== usersTrendLoadSeq) return
userTrend.value = response.trend || []
} catch (error) {
if (currentSeq !== chartLoadSeq) return
console.error('Error loading user trend:', error)
if (currentSeq !== usersTrendLoadSeq) return
console.error('Error loading users trend:', error)
userTrend.value = []
} finally {
if (currentSeq !== chartLoadSeq) return
if (currentSeq !== usersTrendLoadSeq) return
userTrendLoading.value = false
}
}
const loadDashboardStats = async () => {
await loadDashboardSnapshot(true)
void loadUsersTrend()
}
const loadChartData = async () => {
await loadDashboardSnapshot(false)
void loadUsersTrend()
}
onMounted(() => {
loadDashboardStats()
loadChartData()
})
</script>

View File

@@ -655,16 +655,28 @@ const saveColumnsToStorage = () => {
// Toggle column visibility
const toggleColumn = (key: string) => {
const wasHidden = hiddenColumns.has(key)
if (hiddenColumns.has(key)) {
hiddenColumns.delete(key)
} else {
hiddenColumns.add(key)
}
saveColumnsToStorage()
if (wasHidden && (key === 'usage' || key.startsWith('attr_'))) {
refreshCurrentPageSecondaryData()
}
if (key === 'subscriptions') {
loadUsers()
}
}
// Check if column is visible (not in hidden set)
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
const hasVisibleUsageColumn = computed(() => !hiddenColumns.has('usage'))
const hasVisibleSubscriptionsColumn = computed(() => !hiddenColumns.has('subscriptions'))
const hasVisibleAttributeColumns = computed(() =>
attributeDefinitions.value.some((def) => def.enabled && !hiddenColumns.has(`attr_${def.id}`))
)
// Filtered columns based on visibility
const columns = computed<Column[]>(() =>
@@ -776,6 +788,60 @@ const editingUser = ref<AdminUser | null>(null)
const deletingUser = ref<AdminUser | null>(null)
const viewingUser = ref<AdminUser | null>(null)
let abortController: AbortController | null = null
let secondaryDataSeq = 0
const loadUsersSecondaryData = async (
userIds: number[],
signal?: AbortSignal,
expectedSeq?: number
) => {
if (userIds.length === 0) return
const tasks: Promise<void>[] = []
if (hasVisibleUsageColumn.value) {
tasks.push(
(async () => {
try {
const usageResponse = await adminAPI.dashboard.getBatchUsersUsage(userIds)
if (signal?.aborted) return
if (typeof expectedSeq === 'number' && expectedSeq !== secondaryDataSeq) return
usageStats.value = usageResponse.stats
} catch (e) {
if (signal?.aborted) return
console.error('Failed to load usage stats:', e)
}
})()
)
}
if (attributeDefinitions.value.length > 0 && hasVisibleAttributeColumns.value) {
tasks.push(
(async () => {
try {
const attrResponse = await adminAPI.userAttributes.getBatchUserAttributes(userIds)
if (signal?.aborted) return
if (typeof expectedSeq === 'number' && expectedSeq !== secondaryDataSeq) return
userAttributeValues.value = attrResponse.attributes
} catch (e) {
if (signal?.aborted) return
console.error('Failed to load user attribute values:', e)
}
})()
)
}
if (tasks.length > 0) {
await Promise.allSettled(tasks)
}
}
const refreshCurrentPageSecondaryData = () => {
const userIds = users.value.map((u) => u.id)
if (userIds.length === 0) return
const seq = ++secondaryDataSeq
void loadUsersSecondaryData(userIds, undefined, seq)
}
// Action Menu State
const activeMenuId = ref<number | null>(null)
@@ -913,7 +979,8 @@ const loadUsers = async () => {
role: filters.role as any,
status: filters.status as any,
search: searchQuery.value || undefined,
attributes: Object.keys(attrFilters).length > 0 ? attrFilters : undefined
attributes: Object.keys(attrFilters).length > 0 ? attrFilters : undefined,
include_subscriptions: hasVisibleSubscriptionsColumn.value
},
{ signal }
)
@@ -923,38 +990,17 @@ const loadUsers = async () => {
users.value = response.items
pagination.total = response.total
pagination.pages = response.pages
usageStats.value = {}
userAttributeValues.value = {}
// Load usage stats and attribute values for all users in the list
// Defer heavy secondary data so table can render first.
if (response.items.length > 0) {
const userIds = response.items.map((u) => u.id)
// Load usage stats
try {
const usageResponse = await adminAPI.dashboard.getBatchUsersUsage(userIds)
if (signal.aborted) {
return
}
usageStats.value = usageResponse.stats
} catch (e) {
if (signal.aborted) {
return
}
console.error('Failed to load usage stats:', e)
}
// Load attribute values
if (attributeDefinitions.value.length > 0) {
try {
const attrResponse = await adminAPI.userAttributes.getBatchUserAttributes(userIds)
if (signal.aborted) {
return
}
userAttributeValues.value = attrResponse.attributes
} catch (e) {
if (signal.aborted) {
return
}
console.error('Failed to load user attribute values:', e)
}
}
const seq = ++secondaryDataSeq
window.setTimeout(() => {
if (signal.aborted || seq !== secondaryDataSeq) return
void loadUsersSecondaryData(userIds, signal, seq)
}, 50)
}
} catch (error: any) {
const errorInfo = error as { name?: string; code?: string }

View File

@@ -586,6 +586,32 @@ async function refreshThroughputTrendWithCancel(fetchSeq: number, signal: AbortS
}
}
async function refreshCoreSnapshotWithCancel(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return
loadingTrend.value = true
loadingErrorTrend.value = true
try {
const data = await opsAPI.getDashboardSnapshotV2(buildApiParams(), { signal })
if (fetchSeq !== dashboardFetchSeq) return
overview.value = data.overview
throughputTrend.value = data.throughput_trend
errorTrend.value = data.error_trend
} catch (err: any) {
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
// Fallback to legacy split endpoints when snapshot endpoint is unavailable.
await Promise.all([
refreshOverviewWithCancel(fetchSeq, signal),
refreshThroughputTrendWithCancel(fetchSeq, signal),
refreshErrorTrendWithCancel(fetchSeq, signal)
])
} finally {
if (fetchSeq === dashboardFetchSeq) {
loadingTrend.value = false
loadingErrorTrend.value = false
}
}
}
async function refreshLatencyHistogramWithCancel(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return
loadingLatency.value = true
@@ -640,6 +666,14 @@ async function refreshErrorDistributionWithCancel(fetchSeq: number, signal: Abor
}
}
async function refreshDeferredPanels(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return
await Promise.all([
refreshLatencyHistogramWithCancel(fetchSeq, signal),
refreshErrorDistributionWithCancel(fetchSeq, signal)
])
}
function isOpsDisabledError(err: unknown): boolean {
return (
!!err &&
@@ -662,12 +696,8 @@ async function fetchData() {
errorMessage.value = ''
try {
await Promise.all([
refreshOverviewWithCancel(fetchSeq, dashboardFetchController.signal),
refreshThroughputTrendWithCancel(fetchSeq, dashboardFetchController.signal),
refreshCoreSnapshotWithCancel(fetchSeq, dashboardFetchController.signal),
refreshSwitchTrendWithCancel(fetchSeq, dashboardFetchController.signal),
refreshLatencyHistogramWithCancel(fetchSeq, dashboardFetchController.signal),
refreshErrorTrendWithCancel(fetchSeq, dashboardFetchController.signal),
refreshErrorDistributionWithCancel(fetchSeq, dashboardFetchController.signal)
])
if (fetchSeq !== dashboardFetchSeq) return
@@ -680,6 +710,9 @@ async function fetchData() {
if (autoRefreshEnabled.value) {
autoRefreshCountdown.value = Math.floor(autoRefreshIntervalMs.value / 1000)
}
// Defer non-core visual panels to reduce initial blocking.
void refreshDeferredPanels(fetchSeq, dashboardFetchController.signal)
} catch (err) {
if (!isOpsDisabledError(err)) {
console.error('[ops] failed to fetch dashboard data', err)