Merge pull request #1603 from Zqysl/qingyu/fix-datatable-mobile-double-render
fix(frontend): reduce account usage request fan-out on pagination
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="showUsageWindows">
|
<div ref="rootRef" v-if="showUsageWindows">
|
||||||
<!-- Anthropic OAuth and Setup Token accounts: fetch real usage data -->
|
<!-- Anthropic OAuth and Setup Token accounts: fetch real usage data -->
|
||||||
<template
|
<template
|
||||||
v-if="
|
v-if="
|
||||||
@@ -371,7 +371,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Non-OAuth/Setup-Token accounts -->
|
<!-- Non-OAuth/Setup-Token accounts -->
|
||||||
<div v-else>
|
<div ref="rootRef" v-else>
|
||||||
<!-- Gemini API Key accounts: show quota info -->
|
<!-- Gemini API Key accounts: show quota info -->
|
||||||
<AccountQuotaInfo v-if="account.platform === 'gemini'" :account="account" />
|
<AccountQuotaInfo v-if="account.platform === 'gemini'" :account="account" />
|
||||||
<!-- Key/Bedrock accounts: show today stats + optional quota bars -->
|
<!-- Key/Bedrock accounts: show today stats + optional quota bars -->
|
||||||
@@ -439,7 +439,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
|
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
|
||||||
@@ -463,11 +463,23 @@ const props = withDefaults(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const desktopViewportQuery = '(min-width: 768px)'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const activeQueryLoading = ref(false)
|
const activeQueryLoading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const usageInfo = ref<AccountUsageInfo | null>(null)
|
const usageInfo = ref<AccountUsageInfo | null>(null)
|
||||||
|
const rootRef = ref<HTMLElement | null>(null)
|
||||||
|
const isDesktopViewport = ref(
|
||||||
|
typeof window === 'undefined' ? true : window.matchMedia(desktopViewportQuery).matches
|
||||||
|
)
|
||||||
|
const hasEnteredViewport = ref(false)
|
||||||
|
const pendingAutoLoad = ref(false)
|
||||||
|
const pendingAutoLoadSource = ref<'passive' | 'active' | undefined>(undefined)
|
||||||
|
|
||||||
|
let desktopViewportMediaQuery: MediaQueryList | null = null
|
||||||
|
let desktopViewportListener: ((event: MediaQueryListEvent) => void) | null = null
|
||||||
|
let visibilityObserver: IntersectionObserver | null = null
|
||||||
|
|
||||||
// Show usage windows for OAuth and Setup Token accounts
|
// Show usage windows for OAuth and Setup Token accounts
|
||||||
const showUsageWindows = computed(() => {
|
const showUsageWindows = computed(() => {
|
||||||
@@ -514,6 +526,10 @@ const shouldAutoLoadUsageOnMount = computed(() => {
|
|||||||
return shouldFetchUsage.value
|
return shouldFetchUsage.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const shouldLazyLoadOnMobile = computed(() => {
|
||||||
|
return shouldFetchUsage.value && !isDesktopViewport.value
|
||||||
|
})
|
||||||
|
|
||||||
// Antigravity quota types (用于 API 返回的数据)
|
// Antigravity quota types (用于 API 返回的数据)
|
||||||
interface AntigravityUsageResult {
|
interface AntigravityUsageResult {
|
||||||
utilization: number
|
utilization: number
|
||||||
@@ -941,6 +957,56 @@ const loadUsage = async (source?: 'passive' | 'active') => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const flushPendingAutoLoad = () => {
|
||||||
|
if (!pendingAutoLoad.value) return
|
||||||
|
const source = pendingAutoLoadSource.value
|
||||||
|
pendingAutoLoad.value = false
|
||||||
|
pendingAutoLoadSource.value = undefined
|
||||||
|
loadUsage(source).catch((e) => {
|
||||||
|
console.error('Failed to load deferred usage:', e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestAutoLoad = (source?: 'passive' | 'active') => {
|
||||||
|
if (!shouldFetchUsage.value) return
|
||||||
|
if (shouldLazyLoadOnMobile.value && !hasEnteredViewport.value) {
|
||||||
|
pendingAutoLoad.value = true
|
||||||
|
pendingAutoLoadSource.value = source
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadUsage(source).catch((e) => {
|
||||||
|
console.error('Failed to auto load usage:', e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const detachVisibilityObserver = () => {
|
||||||
|
visibilityObserver?.disconnect()
|
||||||
|
visibilityObserver = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachVisibilityObserver = () => {
|
||||||
|
detachVisibilityObserver()
|
||||||
|
if (!shouldLazyLoadOnMobile.value || hasEnteredViewport.value) return
|
||||||
|
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
|
||||||
|
hasEnteredViewport.value = true
|
||||||
|
flushPendingAutoLoad()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!rootRef.value) return
|
||||||
|
|
||||||
|
visibilityObserver = new IntersectionObserver((entries) => {
|
||||||
|
if (!entries.some((entry) => entry.isIntersecting)) return
|
||||||
|
hasEnteredViewport.value = true
|
||||||
|
detachVisibilityObserver()
|
||||||
|
flushPendingAutoLoad()
|
||||||
|
}, {
|
||||||
|
root: null,
|
||||||
|
rootMargin: '200px 0px',
|
||||||
|
threshold: 0.01
|
||||||
|
})
|
||||||
|
visibilityObserver.observe(rootRef.value)
|
||||||
|
}
|
||||||
|
|
||||||
const loadActiveUsage = async () => {
|
const loadActiveUsage = async () => {
|
||||||
activeQueryLoading.value = true
|
activeQueryLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -1040,18 +1106,29 @@ const formatKeyUserCost = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
desktopViewportMediaQuery = window.matchMedia(desktopViewportQuery)
|
||||||
|
isDesktopViewport.value = desktopViewportMediaQuery.matches
|
||||||
|
desktopViewportListener = (event: MediaQueryListEvent) => {
|
||||||
|
isDesktopViewport.value = event.matches
|
||||||
|
}
|
||||||
|
if (typeof desktopViewportMediaQuery.addEventListener === 'function') {
|
||||||
|
desktopViewportMediaQuery.addEventListener('change', desktopViewportListener)
|
||||||
|
} else {
|
||||||
|
desktopViewportMediaQuery.addListener(desktopViewportListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!shouldAutoLoadUsageOnMount.value) return
|
if (!shouldAutoLoadUsageOnMount.value) return
|
||||||
const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined
|
const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined
|
||||||
loadUsage(source)
|
requestAutoLoad(source)
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
|
watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
|
||||||
if (!prevKey || nextKey === prevKey) return
|
if (!prevKey || nextKey === prevKey) return
|
||||||
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return
|
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return
|
||||||
|
|
||||||
loadUsage().catch((e) => {
|
requestAutoLoad()
|
||||||
console.error('Failed to refresh OpenAI usage:', e)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -1061,9 +1138,43 @@ watch(
|
|||||||
if (!shouldFetchUsage.value) return
|
if (!shouldFetchUsage.value) return
|
||||||
|
|
||||||
const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined
|
const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined
|
||||||
loadUsage(source).catch((e) => {
|
requestAutoLoad(source)
|
||||||
console.error('Failed to refresh usage after manual refresh:', e)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[rootRef, shouldLazyLoadOnMobile],
|
||||||
|
() => {
|
||||||
|
if (shouldLazyLoadOnMobile.value) {
|
||||||
|
attachVisibilityObserver()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detachVisibilityObserver()
|
||||||
|
},
|
||||||
|
{ immediate: true, flush: 'post' }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(isDesktopViewport, (isDesktop) => {
|
||||||
|
if (isDesktop) {
|
||||||
|
detachVisibilityObserver()
|
||||||
|
hasEnteredViewport.value = true
|
||||||
|
flushPendingAutoLoad()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasEnteredViewport.value = false
|
||||||
|
attachVisibilityObserver()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
detachVisibilityObserver()
|
||||||
|
if (desktopViewportMediaQuery && desktopViewportListener) {
|
||||||
|
if (typeof desktopViewportMediaQuery.removeEventListener === 'function') {
|
||||||
|
desktopViewportMediaQuery.removeEventListener('change', desktopViewportListener)
|
||||||
|
} else {
|
||||||
|
desktopViewportMediaQuery.removeListener(desktopViewportListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
desktopViewportListener = null
|
||||||
|
desktopViewportMediaQuery = null
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="md:hidden space-y-3">
|
<div v-if="!isDesktopViewport" class="space-y-3">
|
||||||
<template v-if="loading">
|
<template v-if="loading">
|
||||||
<div v-for="i in 5" :key="i" class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900">
|
<div v-for="i in 5" :key="i" class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@@ -61,8 +61,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
v-else
|
||||||
ref="tableWrapperRef"
|
ref="tableWrapperRef"
|
||||||
class="table-wrapper hidden md:block"
|
class="table-wrapper"
|
||||||
:class="{
|
:class="{
|
||||||
'actions-expanded': actionsExpanded,
|
'actions-expanded': actionsExpanded,
|
||||||
'is-scrollable': isScrollable
|
'is-scrollable': isScrollable
|
||||||
@@ -203,6 +204,11 @@ import Icon from '@/components/icons/Icon.vue'
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const desktopViewportQuery = '(min-width: 768px)'
|
||||||
|
const isDesktopViewport = ref(
|
||||||
|
typeof window === 'undefined' ? true : window.matchMedia(desktopViewportQuery).matches
|
||||||
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
sort: [key: string, order: 'asc' | 'desc']
|
sort: [key: string, order: 'asc' | 'desc']
|
||||||
}>()
|
}>()
|
||||||
@@ -268,8 +274,19 @@ const checkActionsColumnWidth = () => {
|
|||||||
// 监听尺寸变化
|
// 监听尺寸变化
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
let resizeHandler: (() => void) | null = null
|
let resizeHandler: (() => void) | null = null
|
||||||
|
let desktopViewportMediaQuery: MediaQueryList | null = null
|
||||||
|
let desktopViewportListener: ((event: MediaQueryListEvent) => void) | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
const detachDesktopTableTracking = () => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
if (resizeHandler) {
|
||||||
|
window.removeEventListener('resize', resizeHandler)
|
||||||
|
resizeHandler = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachDesktopTableTracking = () => {
|
||||||
checkScrollable()
|
checkScrollable()
|
||||||
checkActionsColumnWidth()
|
checkActionsColumnWidth()
|
||||||
if (tableWrapperRef.value && typeof ResizeObserver !== 'undefined') {
|
if (tableWrapperRef.value && typeof ResizeObserver !== 'undefined') {
|
||||||
@@ -286,14 +303,34 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
window.addEventListener('resize', resizeHandler)
|
window.addEventListener('resize', resizeHandler)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
desktopViewportMediaQuery = window.matchMedia(desktopViewportQuery)
|
||||||
|
isDesktopViewport.value = desktopViewportMediaQuery.matches
|
||||||
|
desktopViewportListener = (event: MediaQueryListEvent) => {
|
||||||
|
isDesktopViewport.value = event.matches
|
||||||
|
}
|
||||||
|
if (typeof desktopViewportMediaQuery.addEventListener === 'function') {
|
||||||
|
desktopViewportMediaQuery.addEventListener('change', desktopViewportListener)
|
||||||
|
} else {
|
||||||
|
desktopViewportMediaQuery.addListener(desktopViewportListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
resizeObserver?.disconnect()
|
detachDesktopTableTracking()
|
||||||
if (resizeHandler) {
|
if (desktopViewportMediaQuery && desktopViewportListener) {
|
||||||
window.removeEventListener('resize', resizeHandler)
|
if (typeof desktopViewportMediaQuery.removeEventListener === 'function') {
|
||||||
resizeHandler = null
|
desktopViewportMediaQuery.removeEventListener('change', desktopViewportListener)
|
||||||
|
} else {
|
||||||
|
desktopViewportMediaQuery.removeListener(desktopViewportListener)
|
||||||
|
}
|
||||||
|
desktopViewportListener = null
|
||||||
}
|
}
|
||||||
|
desktopViewportMediaQuery = null
|
||||||
})
|
})
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -470,6 +507,17 @@ const columnsSignature = computed(() =>
|
|||||||
props.columns.map((column) => `${column.key}:${column.sortable ? '1' : '0'}`).join('|')
|
props.columns.map((column) => `${column.key}:${column.sortable ? '1' : '0'}`).join('|')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
isDesktopViewport,
|
||||||
|
async (isDesktop) => {
|
||||||
|
detachDesktopTableTracking()
|
||||||
|
if (!isDesktop) return
|
||||||
|
await nextTick()
|
||||||
|
attachDesktopTableTracking()
|
||||||
|
},
|
||||||
|
{ immediate: true, flush: 'post' }
|
||||||
|
)
|
||||||
|
|
||||||
// 数据/列变化时重新检查滚动状态
|
// 数据/列变化时重新检查滚动状态
|
||||||
// 注意:不能监听 actionsExpanded,因为 checkActionsColumnWidth 会临时修改它,会导致无限循环
|
// 注意:不能监听 actionsExpanded,因为 checkActionsColumnWidth 会临时修改它,会导致无限循环
|
||||||
watch(
|
watch(
|
||||||
@@ -526,7 +574,7 @@ const sortedData = computed(() => {
|
|||||||
|
|
||||||
// --- Virtual scrolling ---
|
// --- Virtual scrolling ---
|
||||||
const rowVirtualizer = useVirtualizer(computed(() => ({
|
const rowVirtualizer = useVirtualizer(computed(() => ({
|
||||||
count: sortedData.value?.length ?? 0,
|
count: isDesktopViewport.value ? (sortedData.value?.length ?? 0) : 0,
|
||||||
getScrollElement: () => tableWrapperRef.value,
|
getScrollElement: () => tableWrapperRef.value,
|
||||||
estimateSize: () => props.estimateRowHeight ?? 56,
|
estimateSize: () => props.estimateRowHeight ?? 56,
|
||||||
overscan: props.overscan ?? 5,
|
overscan: props.overscan ?? 5,
|
||||||
|
|||||||
Reference in New Issue
Block a user