mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-23 16:14:45 +08:00
Merge pull request #1058 from Ethan0x0000/main
fix(admin/accounts): make usage window refresh deterministic and restore missing stats
This commit is contained in:
7
.gitattributes
vendored
7
.gitattributes
vendored
@@ -4,6 +4,13 @@ backend/migrations/*.sql text eol=lf
|
|||||||
# Go 源代码文件
|
# Go 源代码文件
|
||||||
*.go text eol=lf
|
*.go text eol=lf
|
||||||
|
|
||||||
|
# 前端 源代码文件
|
||||||
|
*.ts text eol=lf
|
||||||
|
*.tsx text eol=lf
|
||||||
|
*.js text eol=lf
|
||||||
|
*.jsx text eol=lf
|
||||||
|
*.vue text eol=lf
|
||||||
|
|
||||||
# Shell 脚本
|
# Shell 脚本
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
|||||||
@@ -446,23 +446,17 @@ func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Accou
|
|||||||
}
|
}
|
||||||
|
|
||||||
if stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, now.Add(-5*time.Hour)); err == nil {
|
if stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, now.Add(-5*time.Hour)); err == nil {
|
||||||
windowStats := windowStatsFromAccountStats(stats)
|
|
||||||
if hasMeaningfulWindowStats(windowStats) {
|
|
||||||
if usage.FiveHour == nil {
|
if usage.FiveHour == nil {
|
||||||
usage.FiveHour = &UsageProgress{Utilization: 0}
|
usage.FiveHour = &UsageProgress{Utilization: 0}
|
||||||
}
|
}
|
||||||
usage.FiveHour.WindowStats = windowStats
|
usage.FiveHour.WindowStats = windowStatsFromAccountStats(stats)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, now.Add(-7*24*time.Hour)); err == nil {
|
if stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, now.Add(-7*24*time.Hour)); err == nil {
|
||||||
windowStats := windowStatsFromAccountStats(stats)
|
|
||||||
if hasMeaningfulWindowStats(windowStats) {
|
|
||||||
if usage.SevenDay == nil {
|
if usage.SevenDay == nil {
|
||||||
usage.SevenDay = &UsageProgress{Utilization: 0}
|
usage.SevenDay = &UsageProgress{Utilization: 0}
|
||||||
}
|
}
|
||||||
usage.SevenDay.WindowStats = windowStats
|
usage.SevenDay.WindowStats = windowStatsFromAccountStats(stats)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return usage, nil
|
return usage, nil
|
||||||
@@ -992,13 +986,6 @@ func windowStatsFromAccountStats(stats *usagestats.AccountStats) *WindowStats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasMeaningfulWindowStats(stats *WindowStats) bool {
|
|
||||||
if stats == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return stats.Requests > 0 || stats.Tokens > 0 || stats.Cost > 0 || stats.StandardCost > 0 || stats.UserCost > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildCodexUsageProgressFromExtra(extra map[string]any, window string, now time.Time) *UsageProgress {
|
func buildCodexUsageProgressFromExtra(extra map[string]any, window string, now time.Time) *UsageProgress {
|
||||||
if len(extra) == 0 {
|
if len(extra) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1530,7 +1530,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
|||||||
if len(input.Credentials) > 0 {
|
if len(input.Credentials) > 0 {
|
||||||
account.Credentials = input.Credentials
|
account.Credentials = input.Credentials
|
||||||
}
|
}
|
||||||
if len(input.Extra) > 0 {
|
// Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。
|
||||||
|
// 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。
|
||||||
|
if input.Extra != nil {
|
||||||
// 保留配额用量字段,防止编辑账号时意外重置
|
// 保留配额用量字段,防止编辑账号时意外重置
|
||||||
for _, key := range []string{"quota_used", "quota_daily_used", "quota_daily_start", "quota_weekly_used", "quota_weekly_start"} {
|
for _, key := range []string{"quota_used", "quota_daily_used", "quota_daily_start", "quota_weekly_used", "quota_weekly_start"} {
|
||||||
if v, ok := account.Extra[key]; ok {
|
if v, ok := account.Extra[key]; ok {
|
||||||
|
|||||||
@@ -121,3 +121,35 @@ func TestUpdateAccount_EnableOveragesClearsModelRateLimitsBeforePersist(t *testi
|
|||||||
_, exists := repo.account.Extra[modelRateLimitsKey]
|
_, exists := repo.account.Extra[modelRateLimitsKey]
|
||||||
require.False(t, exists, "开启 overages 时应在持久化前清掉旧模型限流")
|
require.False(t, exists, "开启 overages 时应在持久化前清掉旧模型限流")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateAccount_EmptyExtraPayloadCanClearQuotaLimits(t *testing.T) {
|
||||||
|
accountID := int64(103)
|
||||||
|
repo := &updateAccountOveragesRepoStub{
|
||||||
|
account: &Account{
|
||||||
|
ID: accountID,
|
||||||
|
Platform: PlatformAnthropic,
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Status: StatusActive,
|
||||||
|
Extra: map[string]any{
|
||||||
|
"quota_limit": 100.0,
|
||||||
|
"quota_daily_limit": 10.0,
|
||||||
|
"quota_weekly_limit": 40.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &adminServiceImpl{accountRepo: repo}
|
||||||
|
updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
|
||||||
|
// 显式空对象:语义是“清空 extra 中的可配置键”(例如关闭配额限制)
|
||||||
|
Extra: map[string]any{},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, updated)
|
||||||
|
require.Equal(t, 1, repo.updateCalls)
|
||||||
|
require.NotNil(t, repo.account.Extra)
|
||||||
|
require.NotContains(t, repo.account.Extra, "quota_limit")
|
||||||
|
require.NotContains(t, repo.account.Extra, "quota_daily_limit")
|
||||||
|
require.NotContains(t, repo.account.Extra, "quota_weekly_limit")
|
||||||
|
require.Len(t, repo.account.Extra, 0)
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
|
|
||||||
<!-- OpenAI OAuth accounts: prefer fresh usage query for active rate-limited rows -->
|
<!-- OpenAI OAuth accounts: prefer fresh usage query for active rate-limited rows -->
|
||||||
<template v-else-if="account.platform === 'openai' && account.type === 'oauth'">
|
<template v-else-if="account.platform === 'openai' && account.type === 'oauth'">
|
||||||
<div v-if="preferFetchedOpenAIUsage" class="space-y-1">
|
<div v-if="hasOpenAIUsageFallback" class="space-y-1">
|
||||||
<UsageProgressBar
|
<UsageProgressBar
|
||||||
v-if="usageInfo?.five_hour"
|
v-if="usageInfo?.five_hour"
|
||||||
label="5h"
|
label="5h"
|
||||||
@@ -136,24 +136,6 @@
|
|||||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="hasOpenAIUsageFallback" class="space-y-1">
|
|
||||||
<UsageProgressBar
|
|
||||||
v-if="usageInfo?.five_hour"
|
|
||||||
label="5h"
|
|
||||||
:utilization="usageInfo.five_hour.utilization"
|
|
||||||
:resets-at="usageInfo.five_hour.resets_at"
|
|
||||||
:window-stats="usageInfo.five_hour.window_stats"
|
|
||||||
color="indigo"
|
|
||||||
/>
|
|
||||||
<UsageProgressBar
|
|
||||||
v-if="usageInfo?.seven_day"
|
|
||||||
label="7d"
|
|
||||||
:utilization="usageInfo.seven_day.utilization"
|
|
||||||
:resets-at="usageInfo.seven_day.resets_at"
|
|
||||||
:window-stats="usageInfo.seven_day.window_stats"
|
|
||||||
color="emerald"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-xs text-gray-400">-</div>
|
<div v-else class="text-xs text-gray-400">-</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -389,8 +371,43 @@
|
|||||||
<div v-else>
|
<div 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 -->
|
||||||
|
<div v-else class="space-y-1">
|
||||||
|
<!-- Today stats row (requests, tokens, cost, user_cost) -->
|
||||||
|
<div
|
||||||
|
v-if="todayStats"
|
||||||
|
class="mb-0.5 flex items-center"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
||||||
|
{{ formatKeyRequests }} req
|
||||||
|
</span>
|
||||||
|
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
||||||
|
{{ formatKeyTokens }}
|
||||||
|
</span>
|
||||||
|
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800" :title="t('usage.accountBilled')">
|
||||||
|
A ${{ formatKeyCost }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="todayStats.user_cost != null"
|
||||||
|
class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
|
||||||
|
:title="t('usage.userBilled')"
|
||||||
|
>
|
||||||
|
U ${{ formatKeyUserCost }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Loading skeleton for today stats -->
|
||||||
|
<div
|
||||||
|
v-else-if="todayStatsLoading"
|
||||||
|
class="mb-0.5 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<div class="h-3 w-10 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||||
|
<div class="h-3 w-8 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||||
|
<div class="h-3 w-12 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- API Key accounts with quota limits: show progress bars -->
|
<!-- API Key accounts with quota limits: show progress bars -->
|
||||||
<div v-else-if="hasApiKeyQuota" class="space-y-1">
|
|
||||||
<UsageProgressBar
|
<UsageProgressBar
|
||||||
v-if="quotaDailyBar"
|
v-if="quotaDailyBar"
|
||||||
label="1d"
|
label="1d"
|
||||||
@@ -411,8 +428,10 @@
|
|||||||
:utilization="quotaTotalBar.utilization"
|
:utilization="quotaTotalBar.utilization"
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- No data at all -->
|
||||||
|
<div v-if="!todayStats && !todayStatsLoading && !hasApiKeyQuota" class="text-xs text-gray-400">-</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-xs text-gray-400">-</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -423,12 +442,23 @@ import { adminAPI } from '@/api/admin'
|
|||||||
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
|
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
|
||||||
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
|
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
|
||||||
import { resolveCodexUsageWindow } from '@/utils/codexUsage'
|
import { resolveCodexUsageWindow } from '@/utils/codexUsage'
|
||||||
|
import { formatCompactNumber } from '@/utils/format'
|
||||||
import UsageProgressBar from './UsageProgressBar.vue'
|
import UsageProgressBar from './UsageProgressBar.vue'
|
||||||
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
account: Account
|
account: Account
|
||||||
}>()
|
todayStats?: WindowStats | null
|
||||||
|
todayStatsLoading?: boolean
|
||||||
|
manualRefreshToken?: number
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
todayStats: null,
|
||||||
|
todayStatsLoading: false,
|
||||||
|
manualRefreshToken: 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -490,26 +520,9 @@ const isActiveOpenAIRateLimited = computed(() => {
|
|||||||
return !Number.isNaN(resetAt) && resetAt > Date.now()
|
return !Number.isNaN(resetAt) && resetAt > Date.now()
|
||||||
})
|
})
|
||||||
|
|
||||||
const preferFetchedOpenAIUsage = computed(() => {
|
|
||||||
return (isActiveOpenAIRateLimited.value || isOpenAICodexSnapshotStale.value) && hasOpenAIUsageFallback.value
|
|
||||||
})
|
|
||||||
|
|
||||||
const openAIUsageRefreshKey = computed(() => buildOpenAIUsageRefreshKey(props.account))
|
const openAIUsageRefreshKey = computed(() => buildOpenAIUsageRefreshKey(props.account))
|
||||||
|
|
||||||
const isOpenAICodexSnapshotStale = computed(() => {
|
|
||||||
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return false
|
|
||||||
const extra = props.account.extra as Record<string, unknown> | undefined
|
|
||||||
const updatedAtRaw = extra?.codex_usage_updated_at
|
|
||||||
if (!updatedAtRaw) return true
|
|
||||||
const updatedAt = Date.parse(String(updatedAtRaw))
|
|
||||||
if (Number.isNaN(updatedAt)) return true
|
|
||||||
return Date.now() - updatedAt >= 10 * 60 * 1000
|
|
||||||
})
|
|
||||||
|
|
||||||
const shouldAutoLoadUsageOnMount = computed(() => {
|
const shouldAutoLoadUsageOnMount = computed(() => {
|
||||||
if (props.account.platform === 'openai' && props.account.type === 'oauth') {
|
|
||||||
return isActiveOpenAIRateLimited.value || !hasCodexUsage.value || isOpenAICodexSnapshotStale.value
|
|
||||||
}
|
|
||||||
return shouldFetchUsage.value
|
return shouldFetchUsage.value
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1006,6 +1019,28 @@ const quotaTotalBar = computed((): QuotaBarInfo | null => {
|
|||||||
return makeQuotaBar(props.account.quota_used ?? 0, limit)
|
return makeQuotaBar(props.account.quota_used ?? 0, limit)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ===== Key account today stats formatters =====
|
||||||
|
|
||||||
|
const formatKeyRequests = computed(() => {
|
||||||
|
if (!props.todayStats) return ''
|
||||||
|
return formatCompactNumber(props.todayStats.requests, { allowBillions: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatKeyTokens = computed(() => {
|
||||||
|
if (!props.todayStats) return ''
|
||||||
|
return formatCompactNumber(props.todayStats.tokens)
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatKeyCost = computed(() => {
|
||||||
|
if (!props.todayStats) return '0.00'
|
||||||
|
return props.todayStats.cost.toFixed(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatKeyUserCost = computed(() => {
|
||||||
|
if (!props.todayStats || props.todayStats.user_cost == null) return '0.00'
|
||||||
|
return props.todayStats.user_cost.toFixed(2)
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!shouldAutoLoadUsageOnMount.value) return
|
if (!shouldAutoLoadUsageOnMount.value) return
|
||||||
loadUsage()
|
loadUsage()
|
||||||
@@ -1014,10 +1049,21 @@ onMounted(() => {
|
|||||||
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
|
||||||
if (!isActiveOpenAIRateLimited.value && hasCodexUsage.value && !isOpenAICodexSnapshotStale.value) return
|
|
||||||
|
|
||||||
loadUsage().catch((e) => {
|
loadUsage().catch((e) => {
|
||||||
console.error('Failed to refresh OpenAI usage:', e)
|
console.error('Failed to refresh OpenAI usage:', e)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.manualRefreshToken,
|
||||||
|
(nextToken, prevToken) => {
|
||||||
|
if (nextToken === prevToken) return
|
||||||
|
if (!shouldFetchUsage.value) return
|
||||||
|
|
||||||
|
loadUsage().catch((e) => {
|
||||||
|
console.error('Failed to refresh usage after manual refresh:', e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<!-- Window stats row (above progress bar) -->
|
<!-- Window stats row (above progress bar) -->
|
||||||
<div
|
<div
|
||||||
v-if="windowStats"
|
v-if="windowStats && (windowStats.requests > 0 || windowStats.tokens > 0)"
|
||||||
class="mb-0.5 flex items-center"
|
class="mb-0.5 flex items-center"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400">
|
<div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400">
|
||||||
@@ -12,12 +12,13 @@
|
|||||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
||||||
{{ formatTokens }}
|
{{ formatTokens }}
|
||||||
</span>
|
</span>
|
||||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800" :title="t('usage.accountBilled')">
|
||||||
A ${{ formatAccountCost }}
|
A ${{ formatAccountCost }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="windowStats?.user_cost != null"
|
v-if="windowStats?.user_cost != null"
|
||||||
class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
|
class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
|
||||||
|
:title="t('usage.userBilled')"
|
||||||
>
|
>
|
||||||
U ${{ formatUserCost }}
|
U ${{ formatUserCost }}
|
||||||
</span>
|
</span>
|
||||||
@@ -56,7 +57,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { WindowStats } from '@/types'
|
import type { WindowStats } from '@/types'
|
||||||
|
import { formatCompactNumber } from '@/utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
label: string
|
label: string
|
||||||
@@ -66,6 +69,8 @@ const props = defineProps<{
|
|||||||
windowStats?: WindowStats | null
|
windowStats?: WindowStats | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
// Label background colors
|
// Label background colors
|
||||||
const labelClass = computed(() => {
|
const labelClass = computed(() => {
|
||||||
const colors = {
|
const colors = {
|
||||||
@@ -135,19 +140,12 @@ const formatResetTime = computed(() => {
|
|||||||
// Window stats formatters
|
// Window stats formatters
|
||||||
const formatRequests = computed(() => {
|
const formatRequests = computed(() => {
|
||||||
if (!props.windowStats) return ''
|
if (!props.windowStats) return ''
|
||||||
const r = props.windowStats.requests
|
return formatCompactNumber(props.windowStats.requests, { allowBillions: false })
|
||||||
if (r >= 1000000) return `${(r / 1000000).toFixed(1)}M`
|
|
||||||
if (r >= 1000) return `${(r / 1000).toFixed(1)}K`
|
|
||||||
return r.toString()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatTokens = computed(() => {
|
const formatTokens = computed(() => {
|
||||||
if (!props.windowStats) return ''
|
if (!props.windowStats) return ''
|
||||||
const t = props.windowStats.tokens
|
return formatCompactNumber(props.windowStats.tokens)
|
||||||
if (t >= 1000000000) return `${(t / 1000000000).toFixed(1)}B`
|
|
||||||
if (t >= 1000000) return `${(t / 1000000).toFixed(1)}M`
|
|
||||||
if (t >= 1000) return `${(t / 1000).toFixed(1)}K`
|
|
||||||
return t.toString()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatAccountCost = computed(() => {
|
const formatAccountCost = computed(() => {
|
||||||
|
|||||||
@@ -198,7 +198,34 @@ describe('AccountUsageCell', () => {
|
|||||||
expect(wrapper.text()).toContain('7d|77|300')
|
expect(wrapper.text()).toContain('7d|77|300')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('OpenAI OAuth 有现成快照且未限额时不会首屏请求 usage', async () => {
|
it('OpenAI OAuth 有现成快照时首屏先显示快照再加载 usage 覆盖', async () => {
|
||||||
|
getUsage.mockResolvedValue({
|
||||||
|
five_hour: {
|
||||||
|
utilization: 18,
|
||||||
|
resets_at: '2099-03-07T12:00:00Z',
|
||||||
|
remaining_seconds: 3600,
|
||||||
|
window_stats: {
|
||||||
|
requests: 9,
|
||||||
|
tokens: 900,
|
||||||
|
cost: 0.09,
|
||||||
|
standard_cost: 0.09,
|
||||||
|
user_cost: 0.09
|
||||||
|
}
|
||||||
|
},
|
||||||
|
seven_day: {
|
||||||
|
utilization: 36,
|
||||||
|
resets_at: '2099-03-13T12:00:00Z',
|
||||||
|
remaining_seconds: 3600,
|
||||||
|
window_stats: {
|
||||||
|
requests: 9,
|
||||||
|
tokens: 900,
|
||||||
|
cost: 0.09,
|
||||||
|
standard_cost: 0.09,
|
||||||
|
user_cost: 0.09
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const wrapper = mount(AccountUsageCell, {
|
const wrapper = mount(AccountUsageCell, {
|
||||||
props: {
|
props: {
|
||||||
account: makeAccount({
|
account: makeAccount({
|
||||||
@@ -218,7 +245,7 @@ describe('AccountUsageCell', () => {
|
|||||||
stubs: {
|
stubs: {
|
||||||
UsageProgressBar: {
|
UsageProgressBar: {
|
||||||
props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'],
|
props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'],
|
||||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
|
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}</div>'
|
||||||
},
|
},
|
||||||
AccountQuotaInfo: true
|
AccountQuotaInfo: true
|
||||||
}
|
}
|
||||||
@@ -227,9 +254,80 @@ describe('AccountUsageCell', () => {
|
|||||||
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(getUsage).not.toHaveBeenCalled()
|
// 始终拉 usage,fetched data 优先显示(包含 window_stats)
|
||||||
expect(wrapper.text()).toContain('5h|12')
|
expect(getUsage).toHaveBeenCalledWith(2001)
|
||||||
expect(wrapper.text()).toContain('7d|34')
|
expect(wrapper.text()).toContain('5h|18|900')
|
||||||
|
expect(wrapper.text()).toContain('7d|36|900')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('OpenAI OAuth 有现成快照时,手动刷新信号会触发 usage 重拉', async () => {
|
||||||
|
getUsage.mockResolvedValue({
|
||||||
|
five_hour: {
|
||||||
|
utilization: 18,
|
||||||
|
resets_at: '2099-03-07T12:00:00Z',
|
||||||
|
remaining_seconds: 3600,
|
||||||
|
window_stats: {
|
||||||
|
requests: 9,
|
||||||
|
tokens: 900,
|
||||||
|
cost: 0.09,
|
||||||
|
standard_cost: 0.09,
|
||||||
|
user_cost: 0.09
|
||||||
|
}
|
||||||
|
},
|
||||||
|
seven_day: {
|
||||||
|
utilization: 36,
|
||||||
|
resets_at: '2099-03-13T12:00:00Z',
|
||||||
|
remaining_seconds: 3600,
|
||||||
|
window_stats: {
|
||||||
|
requests: 9,
|
||||||
|
tokens: 900,
|
||||||
|
cost: 0.09,
|
||||||
|
standard_cost: 0.09,
|
||||||
|
user_cost: 0.09
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(AccountUsageCell, {
|
||||||
|
props: {
|
||||||
|
account: makeAccount({
|
||||||
|
id: 2010,
|
||||||
|
platform: 'openai',
|
||||||
|
type: 'oauth',
|
||||||
|
extra: {
|
||||||
|
codex_usage_updated_at: '2099-03-07T10:00:00Z',
|
||||||
|
codex_5h_used_percent: 12,
|
||||||
|
codex_5h_reset_at: '2099-03-07T12:00:00Z',
|
||||||
|
codex_7d_used_percent: 34,
|
||||||
|
codex_7d_reset_at: '2099-03-13T12:00:00Z'
|
||||||
|
},
|
||||||
|
rate_limit_reset_at: null
|
||||||
|
}),
|
||||||
|
manualRefreshToken: 0
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
UsageProgressBar: {
|
||||||
|
props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'],
|
||||||
|
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}</div>'
|
||||||
|
},
|
||||||
|
AccountQuotaInfo: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
// mount 时已经拉取一次
|
||||||
|
expect(getUsage).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
await wrapper.setProps({ manualRefreshToken: 1 })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// 手动刷新再拉一次
|
||||||
|
expect(getUsage).toHaveBeenCalledTimes(2)
|
||||||
|
expect(getUsage).toHaveBeenCalledWith(2010)
|
||||||
|
// fetched data 优先显示,包含 window_stats
|
||||||
|
expect(wrapper.text()).toContain('5h|18|900')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('OpenAI OAuth 在无 codex 快照时会回退显示 usage 接口窗口', async () => {
|
it('OpenAI OAuth 在无 codex 快照时会回退显示 usage 接口窗口', async () => {
|
||||||
@@ -419,4 +517,91 @@ describe('AccountUsageCell', () => {
|
|||||||
expect(wrapper.text()).toContain('7d|100|106540000')
|
expect(wrapper.text()).toContain('7d|100|106540000')
|
||||||
expect(wrapper.text()).not.toContain('5h|0|')
|
expect(wrapper.text()).not.toContain('5h|0|')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Key 账号会展示 today stats 徽章并带 A/U 提示', async () => {
|
||||||
|
const wrapper = mount(AccountUsageCell, {
|
||||||
|
props: {
|
||||||
|
account: makeAccount({
|
||||||
|
id: 3001,
|
||||||
|
platform: 'anthropic',
|
||||||
|
type: 'apikey'
|
||||||
|
}),
|
||||||
|
todayStats: {
|
||||||
|
requests: 1_000_000,
|
||||||
|
tokens: 1_000_000_000,
|
||||||
|
cost: 12.345,
|
||||||
|
standard_cost: 12.345,
|
||||||
|
user_cost: 6.789
|
||||||
|
}
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
UsageProgressBar: true,
|
||||||
|
AccountQuotaInfo: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('1.0M req')
|
||||||
|
expect(wrapper.text()).toContain('1.0B')
|
||||||
|
expect(wrapper.text()).toContain('A $12.35')
|
||||||
|
expect(wrapper.text()).toContain('U $6.79')
|
||||||
|
|
||||||
|
const badges = wrapper.findAll('span[title]')
|
||||||
|
expect(badges.some(node => node.attributes('title') === 'usage.accountBilled')).toBe(true)
|
||||||
|
expect(badges.some(node => node.attributes('title') === 'usage.userBilled')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Key 账号在 today stats loading 时显示骨架屏', async () => {
|
||||||
|
const wrapper = mount(AccountUsageCell, {
|
||||||
|
props: {
|
||||||
|
account: makeAccount({
|
||||||
|
id: 3002,
|
||||||
|
platform: 'anthropic',
|
||||||
|
type: 'apikey'
|
||||||
|
}),
|
||||||
|
todayStats: null,
|
||||||
|
todayStatsLoading: true
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
UsageProgressBar: true,
|
||||||
|
AccountQuotaInfo: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.findAll('.animate-pulse').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Key 账号在无 today stats 且无配额时显示兜底短横线', async () => {
|
||||||
|
const wrapper = mount(AccountUsageCell, {
|
||||||
|
props: {
|
||||||
|
account: makeAccount({
|
||||||
|
id: 3003,
|
||||||
|
platform: 'anthropic',
|
||||||
|
type: 'apikey',
|
||||||
|
quota_limit: 0,
|
||||||
|
quota_daily_limit: 0,
|
||||||
|
quota_weekly_limit: 0
|
||||||
|
}),
|
||||||
|
todayStats: null,
|
||||||
|
todayStatsLoading: false
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
UsageProgressBar: true,
|
||||||
|
AccountQuotaInfo: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text().trim()).toBe('-')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ describe('buildOpenAIUsageRefreshKey', () => {
|
|||||||
platform: 'openai',
|
platform: 'openai',
|
||||||
type: 'oauth',
|
type: 'oauth',
|
||||||
updated_at: '2026-03-07T10:00:00Z',
|
updated_at: '2026-03-07T10:00:00Z',
|
||||||
|
last_used_at: '2026-03-07T09:59:00Z',
|
||||||
extra: {
|
extra: {
|
||||||
codex_usage_updated_at: '2026-03-07T10:00:00Z',
|
codex_usage_updated_at: '2026-03-07T10:00:00Z',
|
||||||
codex_5h_used_percent: 0,
|
codex_5h_used_percent: 0,
|
||||||
@@ -27,12 +28,35 @@ describe('buildOpenAIUsageRefreshKey', () => {
|
|||||||
expect(buildOpenAIUsageRefreshKey(base)).not.toBe(buildOpenAIUsageRefreshKey(next))
|
expect(buildOpenAIUsageRefreshKey(base)).not.toBe(buildOpenAIUsageRefreshKey(next))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('会在 last_used_at 变化时生成不同 key', () => {
|
||||||
|
const base = {
|
||||||
|
id: 3,
|
||||||
|
platform: 'openai',
|
||||||
|
type: 'oauth',
|
||||||
|
updated_at: '2026-03-07T10:00:00Z',
|
||||||
|
last_used_at: '2026-03-07T10:00:00Z',
|
||||||
|
extra: {
|
||||||
|
codex_usage_updated_at: '2026-03-07T10:00:00Z',
|
||||||
|
codex_5h_used_percent: 12,
|
||||||
|
codex_7d_used_percent: 24
|
||||||
|
}
|
||||||
|
} as any
|
||||||
|
|
||||||
|
const next = {
|
||||||
|
...base,
|
||||||
|
last_used_at: '2026-03-07T10:02:00Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(buildOpenAIUsageRefreshKey(base)).not.toBe(buildOpenAIUsageRefreshKey(next))
|
||||||
|
})
|
||||||
|
|
||||||
it('非 OpenAI OAuth 账号返回空 key', () => {
|
it('非 OpenAI OAuth 账号返回空 key', () => {
|
||||||
expect(buildOpenAIUsageRefreshKey({
|
expect(buildOpenAIUsageRefreshKey({
|
||||||
id: 2,
|
id: 2,
|
||||||
platform: 'anthropic',
|
platform: 'anthropic',
|
||||||
type: 'oauth',
|
type: 'oauth',
|
||||||
updated_at: '2026-03-07T10:00:00Z',
|
updated_at: '2026-03-07T10:00:00Z',
|
||||||
|
last_used_at: '2026-03-07T10:00:00Z',
|
||||||
extra: {}
|
extra: {}
|
||||||
} as any)).toBe('')
|
} as any)).toBe('')
|
||||||
})
|
})
|
||||||
|
|||||||
22
frontend/src/utils/__tests__/formatCompactNumber.spec.ts
Normal file
22
frontend/src/utils/__tests__/formatCompactNumber.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { formatCompactNumber } from '../format'
|
||||||
|
|
||||||
|
describe('formatCompactNumber', () => {
|
||||||
|
it('formats boundary values with K/M/B', () => {
|
||||||
|
expect(formatCompactNumber(0)).toBe('0')
|
||||||
|
expect(formatCompactNumber(999)).toBe('999')
|
||||||
|
expect(formatCompactNumber(1000)).toBe('1.0K')
|
||||||
|
expect(formatCompactNumber(999999)).toBe('1000.0K')
|
||||||
|
expect(formatCompactNumber(1000000)).toBe('1.0M')
|
||||||
|
expect(formatCompactNumber(1000000000)).toBe('1.0B')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports disabling billion unit (requests style)', () => {
|
||||||
|
expect(formatCompactNumber(1000000000, { allowBillions: false })).toBe('1000.0M')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 0 for nullish input', () => {
|
||||||
|
expect(formatCompactNumber(null)).toBe('0')
|
||||||
|
expect(formatCompactNumber(undefined)).toBe('0')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,7 +5,7 @@ const normalizeUsageRefreshValue = (value: unknown): string => {
|
|||||||
return String(value)
|
return String(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildOpenAIUsageRefreshKey = (account: Pick<Account, 'id' | 'platform' | 'type' | 'updated_at' | 'rate_limit_reset_at' | 'extra'>): string => {
|
export const buildOpenAIUsageRefreshKey = (account: Pick<Account, 'id' | 'platform' | 'type' | 'updated_at' | 'last_used_at' | 'rate_limit_reset_at' | 'extra'>): string => {
|
||||||
if (account.platform !== 'openai' || account.type !== 'oauth') {
|
if (account.platform !== 'openai' || account.type !== 'oauth') {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,7 @@ export const buildOpenAIUsageRefreshKey = (account: Pick<Account, 'id' | 'platfo
|
|||||||
return [
|
return [
|
||||||
account.id,
|
account.id,
|
||||||
account.updated_at,
|
account.updated_at,
|
||||||
|
account.last_used_at,
|
||||||
account.rate_limit_reset_at,
|
account.rate_limit_reset_at,
|
||||||
extra.codex_usage_updated_at,
|
extra.codex_usage_updated_at,
|
||||||
extra.codex_5h_used_percent,
|
extra.codex_5h_used_percent,
|
||||||
|
|||||||
@@ -247,6 +247,26 @@ export function formatTokensK(tokens: number): string {
|
|||||||
return tokens.toString()
|
return tokens.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化大数字(K/M/B,保留 1 位小数)
|
||||||
|
* @param num 数字
|
||||||
|
* @param options allowBillions=false 时最高只显示到 M
|
||||||
|
*/
|
||||||
|
export function formatCompactNumber(
|
||||||
|
num: number | null | undefined,
|
||||||
|
options?: { allowBillions?: boolean }
|
||||||
|
): string {
|
||||||
|
if (num === null || num === undefined) return '0'
|
||||||
|
|
||||||
|
const abs = Math.abs(num)
|
||||||
|
const allowBillions = options?.allowBillions !== false
|
||||||
|
|
||||||
|
if (allowBillions && abs >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`
|
||||||
|
if (abs >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`
|
||||||
|
if (abs >= 1_000) return `${(num / 1_000).toFixed(1)}K`
|
||||||
|
return num.toString()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化倒计时(从现在到目标时间的剩余时间)
|
* 格式化倒计时(从现在到目标时间的剩余时间)
|
||||||
* @param targetDate 目标日期字符串或 Date 对象
|
* @param targetDate 目标日期字符串或 Date 对象
|
||||||
|
|||||||
@@ -203,7 +203,12 @@
|
|||||||
<AccountGroupsCell :groups="row.groups" :max-display="4" />
|
<AccountGroupsCell :groups="row.groups" :max-display="4" />
|
||||||
</template>
|
</template>
|
||||||
<template #cell-usage="{ row }">
|
<template #cell-usage="{ row }">
|
||||||
<AccountUsageCell :account="row" />
|
<AccountUsageCell
|
||||||
|
:account="row"
|
||||||
|
:today-stats="todayStatsByAccountId[String(row.id)] ?? null"
|
||||||
|
:today-stats-loading="todayStatsLoading"
|
||||||
|
:manual-refresh-token="usageManualRefreshToken"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-proxy="{ row }">
|
<template #cell-proxy="{ row }">
|
||||||
<div v-if="row.proxy" class="flex items-center gap-2">
|
<div v-if="row.proxy" class="flex items-center gap-2">
|
||||||
@@ -323,13 +328,13 @@ import Icon from '@/components/icons/Icon.vue'
|
|||||||
import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue'
|
import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue'
|
||||||
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
|
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
|
||||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||||
import type { Account, AccountPlatform, AccountType, Proxy, AdminGroup, WindowStats, ClaudeModel } from '@/types'
|
import type { Account, AccountPlatform, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel } from '@/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const proxies = ref<Proxy[]>([])
|
const proxies = ref<AccountProxy[]>([])
|
||||||
const groups = ref<AdminGroup[]>([])
|
const groups = ref<AdminGroup[]>([])
|
||||||
const accountTableRef = ref<HTMLElement | null>(null)
|
const accountTableRef = ref<HTMLElement | null>(null)
|
||||||
const selPlatforms = computed<AccountPlatform[]>(() => {
|
const selPlatforms = computed<AccountPlatform[]>(() => {
|
||||||
@@ -402,6 +407,7 @@ const todayStatsLoading = ref(false)
|
|||||||
const todayStatsError = ref<string | null>(null)
|
const todayStatsError = ref<string | null>(null)
|
||||||
const todayStatsReqSeq = ref(0)
|
const todayStatsReqSeq = ref(0)
|
||||||
const pendingTodayStatsRefresh = ref(false)
|
const pendingTodayStatsRefresh = ref(false)
|
||||||
|
const usageManualRefreshToken = ref(0)
|
||||||
|
|
||||||
const buildDefaultTodayStats = (): WindowStats => ({
|
const buildDefaultTodayStats = (): WindowStats => ({
|
||||||
requests: 0,
|
requests: 0,
|
||||||
@@ -412,7 +418,11 @@ const buildDefaultTodayStats = (): WindowStats => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const refreshTodayStatsBatch = async () => {
|
const refreshTodayStatsBatch = async () => {
|
||||||
if (hiddenColumns.has('today_stats')) {
|
// Why this checks both columns:
|
||||||
|
// - today_stats column shows dedicated today's metrics.
|
||||||
|
// - usage column also embeds today's stats for Key/Bedrock rows.
|
||||||
|
// So we only skip fetching when BOTH columns are hidden.
|
||||||
|
if (hiddenColumns.has('today_stats') && hiddenColumns.has('usage')) {
|
||||||
todayStatsLoading.value = false
|
todayStatsLoading.value = false
|
||||||
todayStatsError.value = null
|
todayStatsError.value = null
|
||||||
return
|
return
|
||||||
@@ -464,13 +474,19 @@ const loadSavedColumns = () => {
|
|||||||
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
|
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
|
||||||
if (saved) {
|
if (saved) {
|
||||||
const parsed = JSON.parse(saved) as string[]
|
const parsed = JSON.parse(saved) as string[]
|
||||||
parsed.forEach(key => hiddenColumns.add(key))
|
parsed.forEach(key => {
|
||||||
|
hiddenColumns.add(key)
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
DEFAULT_HIDDEN_COLUMNS.forEach(key => {
|
||||||
|
hiddenColumns.add(key)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load saved columns:', e)
|
console.error('Failed to load saved columns:', e)
|
||||||
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
DEFAULT_HIDDEN_COLUMNS.forEach(key => {
|
||||||
|
hiddenColumns.add(key)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,7 +560,7 @@ const toggleColumn = (key: string) => {
|
|||||||
hiddenColumns.add(key)
|
hiddenColumns.add(key)
|
||||||
}
|
}
|
||||||
saveColumnsToStorage()
|
saveColumnsToStorage()
|
||||||
if (key === 'today_stats' && wasHidden) {
|
if ((key === 'today_stats' || key === 'usage') && wasHidden) {
|
||||||
refreshTodayStatsBatch().catch((error) => {
|
refreshTodayStatsBatch().catch((error) => {
|
||||||
console.error('Failed to load account today stats after showing column:', error)
|
console.error('Failed to load account today stats after showing column:', error)
|
||||||
})
|
})
|
||||||
@@ -768,11 +784,15 @@ const refreshAccountsIncrementally = async () => {
|
|||||||
|
|
||||||
const handleManualRefresh = async () => {
|
const handleManualRefresh = async () => {
|
||||||
await load()
|
await load()
|
||||||
|
// Force usage cells to refetch /usage on explicit user refresh.
|
||||||
|
usageManualRefreshToken.value += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncPendingListChanges = async () => {
|
const syncPendingListChanges = async () => {
|
||||||
hasPendingListSync.value = false
|
hasPendingListSync.value = false
|
||||||
await load()
|
await load()
|
||||||
|
// Keep behavior consistent with manual refresh.
|
||||||
|
usageManualRefreshToken.value += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
|
const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
|
||||||
@@ -888,7 +908,8 @@ const openMenu = (a: Account, e: MouseEvent) => {
|
|||||||
const viewportWidth = window.innerWidth
|
const viewportWidth = window.innerWidth
|
||||||
const viewportHeight = window.innerHeight
|
const viewportHeight = window.innerHeight
|
||||||
|
|
||||||
let left, top
|
let left: number
|
||||||
|
let top: number
|
||||||
|
|
||||||
if (viewportWidth < 768) {
|
if (viewportWidth < 768) {
|
||||||
// 居中显示,水平位置
|
// 居中显示,水平位置
|
||||||
|
|||||||
Reference in New Issue
Block a user