refactor(frontend): extract pool key display panels

This commit is contained in:
elky
2026-06-30 17:01:39 +08:00
parent d7411a3104
commit 15b6606c82
4 changed files with 383 additions and 229 deletions
@@ -0,0 +1,136 @@
<template>
<div
v-if="variant === 'mobile'"
class="rounded-xl border border-border/50 bg-muted/30 px-3 py-2 text-xs"
>
<div class="text-muted-foreground mb-1">
{{ legacyT('配额') }}
</div>
<div
v-if="items.length"
class="space-y-2"
>
<QuotaProgressRows
:items="items"
mobile
/>
<div
v-if="accountQuotaText"
class="text-[10px] leading-none text-muted-foreground tabular-nums"
>
{{ accountQuotaText }}
</div>
</div>
<div
v-else-if="accountQuotaText || fallbackText"
:class="textClass"
>
{{ accountQuotaText || fallbackText }}
</div>
<div
v-else
class="text-muted-foreground"
>
-
</div>
</div>
<template v-else>
<div
v-if="items.length"
class="max-w-[208px] space-y-2"
>
<QuotaProgressRows :items="items" />
<div
v-if="accountQuotaText"
class="text-[10px] leading-none text-muted-foreground tabular-nums"
>
{{ accountQuotaText }}
</div>
</div>
<span
v-else-if="accountQuotaText || fallbackText"
:class="textClass"
>
{{ accountQuotaText || fallbackText }}
</span>
<span
v-else
class="text-xs text-muted-foreground"
>-</span>
</template>
</template>
<script setup lang="ts">
import { defineComponent, h, type PropType } from 'vue'
import { useI18n } from '@/i18n'
export interface PoolQuotaProgressDisplayItem {
label: string
remainingPercent: number
resetText: string
meterText: string
barClass: string
meterClass: string
}
withDefaults(defineProps<{
items: PoolQuotaProgressDisplayItem[]
accountQuotaText?: string | null
fallbackText?: string | null
textClass?: string
variant?: 'desktop' | 'mobile'
}>(), {
accountQuotaText: null,
fallbackText: null,
textClass: '',
variant: 'desktop',
})
const { legacyT } = useI18n()
const QuotaProgressRows = defineComponent({
name: 'QuotaProgressRows',
props: {
items: {
type: Array as PropType<PoolQuotaProgressDisplayItem[]>,
required: true,
},
mobile: {
type: Boolean,
default: false,
},
},
setup(props) {
return () => props.items.map((item, idx) => h('div', {
key: `${item.label}-${idx}`,
class: props.mobile
? 'flex flex-col gap-1 min-w-0'
: 'flex flex-col gap-1 min-w-[140px] max-w-[208px]',
}, [
h('div', { class: 'flex items-center justify-between text-[10px] leading-none' }, [
h('span', { class: 'text-muted-foreground font-medium shrink-0' }, item.label),
item.resetText
? h('span', {
'data-testid': 'pool-quota-reset-text',
class: 'text-muted-foreground/80 tabular-nums truncate',
title: item.resetText,
}, item.resetText)
: null,
]),
h('div', { class: 'flex items-center gap-1.5' }, [
h('div', { class: 'relative flex-1 h-1.5 rounded-full bg-border overflow-hidden' }, [
h('div', {
class: ['absolute left-0 top-0 h-full rounded-full transition-all duration-300', item.barClass],
style: { width: `${item.remainingPercent}%` },
}),
]),
h('span', {
'data-testid': 'pool-quota-meter-text',
class: ['shrink-0 text-[10px] font-medium tabular-nums leading-none', item.meterClass],
}, item.meterText),
]),
]))
},
})
</script>
@@ -0,0 +1,126 @@
<template>
<div
v-if="cycle"
:class="cycleContainerClass"
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-groups' : undefined"
>
<div
:class="cycleGridClass"
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-grid' : 'pool-mobile-stats-cycle-grid'"
>
<span aria-hidden="true" />
<span
:class="cycleGroupLabelClass"
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-group-5h' : 'pool-mobile-stats-cycle-group-5h'"
>5H</span>
<span class="text-center text-muted-foreground/50">|</span>
<span
:class="cycleGroupLabelClass"
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-group-weekly' : 'pool-mobile-stats-cycle-group-weekly'"
>{{ legacyT('周') }}</span>
<template
v-for="row in cycleRows"
:key="`${row.key}-${variant}-cycle-row`"
>
<span class="text-muted-foreground truncate">{{ row.label }}</span>
<span
:class="[cycleValueClass, row.fiveH.missing ? 'text-muted-foreground/80' : '']"
:data-testid="variant === 'desktop' ? `pool-stats-5h-${row.key}` : undefined"
:title="row.fiveH.value"
>{{ row.fiveH.value }}</span>
<span class="text-center text-muted-foreground/50">|</span>
<span
:class="[cycleValueClass, row.weekly.missing ? 'text-muted-foreground/80' : '']"
:data-testid="variant === 'desktop' ? `pool-stats-weekly-${row.key}` : undefined"
:title="row.weekly.value"
>{{ row.weekly.value }}</span>
</template>
</div>
</div>
<div
v-else
:class="accountContainerClass"
:data-testid="variant === 'desktop' ? 'pool-stats-account-total' : undefined"
>
<div
class="invisible h-4"
aria-hidden="true"
>
-
</div>
<div
v-for="metric in accountMetrics"
:key="`${metric.key}-${variant}-account-total`"
:class="accountMetricRowClass"
>
<span class="text-muted-foreground truncate">{{ metric.label }}</span>
<span
:class="accountValueClass"
:title="metric.value"
>
{{ metric.value }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '@/i18n'
import type { PoolStatsMetric } from '@/features/pool/utils/poolStatsDisplay'
export interface PoolKeyCycleStatsRow {
key: PoolStatsMetric['key']
label: string
fiveH: PoolStatsMetric
weekly: PoolStatsMetric
}
const props = withDefaults(defineProps<{
cycle: boolean
cycleRows: PoolKeyCycleStatsRow[]
accountMetrics: PoolStatsMetric[]
variant?: 'desktop' | 'mobile'
}>(), {
variant: 'desktop',
})
const { legacyT } = useI18n()
const cycleContainerClass = computed(() => props.variant === 'desktop'
? 'mx-auto w-[188px] text-[10px] leading-4'
: ''
)
const cycleGridClass = computed(() => [
'grid min-h-16 w-[188px] grid-cols-[38px_64px_10px_64px] items-center gap-x-1',
props.variant === 'mobile' ? 'text-left' : '',
].filter(Boolean).join(' '))
const cycleGroupLabelClass = computed(() => props.variant === 'desktop'
? 'text-center text-[9px] font-semibold text-muted-foreground/80'
: 'text-center text-[10px] font-semibold text-foreground'
)
const cycleValueClass = computed(() => [
'min-w-0 truncate text-center text-foreground/90',
props.variant === 'desktop' ? 'tabular-nums' : 'font-medium tabular-nums',
].join(' '))
const accountContainerClass = computed(() => props.variant === 'desktop'
? 'grid min-h-16 w-[188px] grid-rows-4 gap-0 mx-auto text-[10px] leading-4'
: ''
)
const accountMetricRowClass = computed(() => props.variant === 'desktop'
? 'grid grid-cols-[64px_124px] items-center'
: 'grid h-4 w-[188px] grid-cols-[64px_124px] items-center text-left'
)
const accountValueClass = computed(() => [
'min-w-0 truncate text-center text-foreground/90',
props.variant === 'desktop' ? 'tabular-nums' : 'font-medium',
].join(' '))
</script>
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { createApp, defineComponent, h } from 'vue'
import PoolKeyQuotaPanel from '@/features/pool/components/PoolKeyQuotaPanel.vue'
import PoolKeyStatsPanel from '@/features/pool/components/PoolKeyStatsPanel.vue'
import { createI18n } from '@/i18n'
describe('pool key display panels', () => {
it('renders codex cycle stats with stable test hooks', () => {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(PoolKeyStatsPanel, {
cycle: true,
cycleRows: [{
key: 'request_count',
label: '请求',
fiveH: { key: 'request_count', label: '请求', value: '12', missing: false },
weekly: { key: 'request_count', label: '请求', value: '88', missing: false },
}],
accountMetrics: [],
})
app.use(createI18n())
app.mount(root)
expect(root.querySelector('[data-testid="pool-stats-cycle-grid"]')).toBeTruthy()
expect(root.querySelector('[data-testid="pool-stats-5h-request_count"]')?.textContent).toBe('12')
expect(root.querySelector('[data-testid="pool-stats-weekly-request_count"]')?.textContent).toBe('88')
app.unmount()
root.remove()
})
it('renders quota progress rows and fallback quota text', () => {
const Probe = defineComponent({
setup() {
return () => h('div', [
h(PoolKeyQuotaPanel, {
items: [{
label: '5H',
remainingPercent: 42,
resetText: '1h 后重置',
meterText: '42.0%',
barClass: 'bg-amber-500',
meterClass: 'text-amber-600',
}],
}),
h(PoolKeyQuotaPanel, {
items: [],
accountQuotaText: null,
fallbackText: '额度未知',
textClass: 'text-muted-foreground',
variant: 'mobile',
}),
])
},
})
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(Probe)
app.use(createI18n())
app.mount(root)
expect(root.querySelector('[data-testid="pool-quota-reset-text"]')?.textContent).toBe('1h 后重置')
expect(root.querySelector('[data-testid="pool-quota-meter-text"]')?.textContent).toBe('42.0%')
expect(root.textContent).toContain('额度未知')
app.unmount()
root.remove()
})
})
+50 -229
View File
@@ -304,124 +304,19 @@
v-if="showAccountQuotaColumn"
class="py-3 align-middle"
>
<div
v-if="quotaProgressMap[key.key_id]?.length"
class="max-w-[208px] space-y-2"
>
<div
v-for="(item, idx) in quotaProgressMap[key.key_id]"
:key="`${key.key_id}-quota-${idx}`"
class="flex flex-col gap-1 min-w-[140px] max-w-[208px]"
>
<div class="flex items-center justify-between text-[10px] leading-none">
<span class="text-muted-foreground font-medium shrink-0">{{ getQuotaProgressLabel(item.label) }}</span>
<span
v-if="getQuotaProgressResetDisplayText(item)"
data-testid="pool-quota-reset-text"
class="text-muted-foreground/80 tabular-nums truncate"
:title="getQuotaProgressResetDisplayText(item)"
>{{ getQuotaProgressResetDisplayText(item) }}</span>
</div>
<div class="flex items-center gap-1.5">
<div class="relative flex-1 h-1.5 rounded-full bg-border overflow-hidden">
<div
class="absolute left-0 top-0 h-full rounded-full transition-all duration-300"
:class="getQuotaRemainingBarColorByRemaining(item.remainingPercent)"
:style="{ width: `${item.remainingPercent}%` }"
/>
</div>
<span
data-testid="pool-quota-meter-text"
class="shrink-0 text-[10px] font-medium tabular-nums leading-none"
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
>{{ getQuotaProgressMeterDisplayText(item) }}</span>
</div>
</div>
<div
v-if="keyUiStateMap[key.key_id]?.accountQuotaText"
class="text-[10px] leading-none text-muted-foreground tabular-nums"
>
{{ keyUiStateMap[key.key_id]?.accountQuotaText }}
</div>
</div>
<span
v-else-if="keyUiStateMap[key.key_id]?.accountQuotaText || keyUiStateMap[key.key_id]?.quotaFallbackText"
:class="keyUiStateMap[key.key_id]?.quotaTextClass || ''"
>
{{ keyUiStateMap[key.key_id]?.accountQuotaText || keyUiStateMap[key.key_id]?.quotaFallbackText }}
</span>
<span
v-else
class="text-xs text-muted-foreground"
>-</span>
<PoolKeyQuotaPanel
:items="quotaProgressDisplayMap[key.key_id] || []"
:account-quota-text="keyUiStateMap[key.key_id]?.accountQuotaText"
:fallback-text="keyUiStateMap[key.key_id]?.quotaFallbackText"
:text-class="keyUiStateMap[key.key_id]?.quotaTextClass || ''"
/>
</TableCell>
<TableCell class="py-3 px-2 align-middle">
<div
v-if="isPoolKeyCycleStatsDisplay(key)"
class="mx-auto w-[188px] text-[10px] leading-4"
data-testid="pool-stats-cycle-groups"
>
<div
class="grid min-h-16 w-[188px] grid-cols-[38px_64px_10px_64px] items-center gap-x-1"
data-testid="pool-stats-cycle-grid"
>
<span aria-hidden="true" />
<span
class="text-center text-[9px] font-semibold text-muted-foreground/80"
data-testid="pool-stats-cycle-group-5h"
>5H</span>
<span class="text-center text-muted-foreground/50">|</span>
<span
class="text-center text-[9px] font-semibold text-muted-foreground/80"
data-testid="pool-stats-cycle-group-weekly"
></span>
<template
v-for="row in getPoolKeyCycleStatsRows(key)"
:key="`${key.key_id}-${row.key}-desktop-cycle-row`"
>
<span class="text-muted-foreground truncate">{{ row.label }}</span>
<span
class="min-w-0 truncate text-center tabular-nums text-foreground/90"
:class="row.fiveH.missing ? 'text-muted-foreground/80' : ''"
:data-testid="`pool-stats-5h-${row.key}`"
:title="row.fiveH.value"
>{{ row.fiveH.value }}</span>
<span class="text-center text-muted-foreground/50">|</span>
<span
class="min-w-0 truncate text-center tabular-nums text-foreground/90"
:class="row.weekly.missing ? 'text-muted-foreground/80' : ''"
:data-testid="`pool-stats-weekly-${row.key}`"
:title="row.weekly.value"
>{{ row.weekly.value }}</span>
</template>
</div>
</div>
<div
v-else
class="grid min-h-16 w-[188px] grid-rows-4 gap-0 mx-auto text-[10px] leading-4"
data-testid="pool-stats-account-total"
>
<div
class="invisible h-4"
aria-hidden="true"
>
-
</div>
<div
v-for="metric in getPoolKeyAccountStatsMetrics(key)"
:key="`${key.key_id}-${metric.key}-account-total`"
class="grid grid-cols-[64px_124px] items-center"
>
<span class="text-muted-foreground truncate">{{ metric.label }}</span>
<span
class="min-w-0 truncate text-center tabular-nums text-foreground/90"
:title="metric.value"
>
{{ metric.value }}
</span>
</div>
</div>
<PoolKeyStatsPanel
:cycle="isPoolKeyCycleStatsDisplay(key)"
:cycle-rows="getPoolKeyCycleStatsRows(key)"
:account-metrics="getPoolKeyAccountStatsMetrics(key)"
/>
</TableCell>
<TableCell class="py-3 text-center">
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
@@ -695,60 +590,12 @@
<div class="overflow-x-auto rounded-xl border border-border/50 bg-muted/30 px-3 py-2 text-[11px] text-muted-foreground">
<div class="space-y-1 text-center">
<template v-if="isPoolKeyCycleStatsDisplay(key)">
<div
class="grid min-h-16 w-[188px] grid-cols-[38px_64px_10px_64px] items-center gap-x-1 text-left"
data-testid="pool-mobile-stats-cycle-grid"
>
<span aria-hidden="true" />
<span
class="text-center text-[10px] font-semibold text-foreground"
data-testid="pool-mobile-stats-cycle-group-5h"
>5H</span>
<span class="text-center text-muted-foreground/50">|</span>
<span
class="text-center text-[10px] font-semibold text-foreground"
data-testid="pool-mobile-stats-cycle-group-weekly"
>周</span>
<template
v-for="row in getPoolKeyCycleStatsRows(key)"
:key="`${key.key_id}-${row.key}-mobile-cycle-row`"
>
<span class="text-muted-foreground truncate">{{ row.label }}</span>
<span
class="min-w-0 truncate text-center font-medium text-foreground/90 tabular-nums"
:class="row.fiveH.missing ? 'text-muted-foreground/80' : ''"
:title="row.fiveH.value"
>{{ row.fiveH.value }}</span>
<span class="text-center text-muted-foreground/50">|</span>
<span
class="min-w-0 truncate text-center font-medium text-foreground/90 tabular-nums"
:class="row.weekly.missing ? 'text-muted-foreground/80' : ''"
:title="row.weekly.value"
>{{ row.weekly.value }}</span>
</template>
</div>
</template>
<template v-else>
<div
class="invisible h-4"
aria-hidden="true"
>
-
</div>
<div
v-for="metric in getPoolKeyAccountStatsMetrics(key)"
:key="`${key.key_id}-${metric.key}-mobile-account-total`"
class="grid h-4 w-[188px] grid-cols-[64px_124px] items-center text-left"
>
<span class="text-muted-foreground truncate">{{ metric.label }}</span>
<span
class="min-w-0 truncate text-center font-medium text-foreground/90"
:title="metric.value"
>{{ metric.value }}</span>
</div>
</template>
<PoolKeyStatsPanel
:cycle="isPoolKeyCycleStatsDisplay(key)"
:cycle-rows="getPoolKeyCycleStatsRows(key)"
:account-metrics="getPoolKeyAccountStatsMetrics(key)"
variant="mobile"
/>
<div class="flex items-center justify-between gap-2 border-t border-border/40 pt-1 mt-1">
<span class="text-muted-foreground">导入</span>
<span class="font-medium text-foreground/90">{{ keyUiStateMap[key.key_id]?.importedAtRelative || '-' }}</span>
@@ -822,66 +669,14 @@
</div>
</div>
<div
<PoolKeyQuotaPanel
v-if="showAccountQuotaColumn"
class="rounded-xl border border-border/50 bg-muted/30 px-3 py-2 text-xs"
>
<div class="text-muted-foreground mb-1">
配额
</div>
<div
v-if="quotaProgressMap[key.key_id]?.length"
class="space-y-2"
>
<div
v-for="(item, idx) in quotaProgressMap[key.key_id]"
:key="`${key.key_id}-quota-mobile-${idx}`"
class="flex flex-col gap-1 min-w-0"
>
<div class="flex items-center justify-between text-[10px] leading-none">
<span class="text-muted-foreground font-medium shrink-0">{{ getQuotaProgressLabel(item.label) }}</span>
<span
v-if="getQuotaProgressResetDisplayText(item)"
data-testid="pool-quota-reset-text"
class="text-muted-foreground/80 tabular-nums truncate"
:title="getQuotaProgressResetDisplayText(item)"
>{{ getQuotaProgressResetDisplayText(item) }}</span>
</div>
<div class="flex items-center gap-1.5">
<div class="relative flex-1 h-1.5 rounded-full bg-border overflow-hidden">
<div
class="absolute left-0 top-0 h-full rounded-full transition-all duration-300"
:class="getQuotaRemainingBarColorByRemaining(item.remainingPercent)"
:style="{ width: `${item.remainingPercent}%` }"
/>
</div>
<span
data-testid="pool-quota-meter-text"
class="shrink-0 text-[10px] font-medium tabular-nums leading-none"
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
>{{ getQuotaProgressMeterDisplayText(item) }}</span>
</div>
</div>
<div
v-if="keyUiStateMap[key.key_id]?.accountQuotaText"
class="text-[10px] leading-none text-muted-foreground tabular-nums"
>
{{ keyUiStateMap[key.key_id]?.accountQuotaText }}
</div>
</div>
<div
v-else-if="keyUiStateMap[key.key_id]?.accountQuotaText || keyUiStateMap[key.key_id]?.quotaFallbackText"
:class="keyUiStateMap[key.key_id]?.quotaTextClass || ''"
>
{{ keyUiStateMap[key.key_id]?.accountQuotaText || keyUiStateMap[key.key_id]?.quotaFallbackText }}
</div>
<div
v-else
class="text-muted-foreground"
>
-
</div>
</div>
:items="quotaProgressDisplayMap[key.key_id] || []"
:account-quota-text="keyUiStateMap[key.key_id]?.accountQuotaText"
:fallback-text="keyUiStateMap[key.key_id]?.quotaFallbackText"
:text-class="keyUiStateMap[key.key_id]?.quotaTextClass || ''"
variant="mobile"
/>
<div class="flex items-center gap-0.5">
<div
@@ -1248,6 +1043,8 @@ import PoolAdvancedDialog from '@/features/pool/components/PoolAdvancedDialog.vu
import PoolDemandMetricsDialog from '@/features/pool/components/PoolDemandMetricsDialog.vue'
import PoolAccountBatchDialog from '@/features/pool/components/PoolAccountBatchDialog.vue'
import PoolManagementHeader from '@/features/pool/components/PoolManagementHeader.vue'
import PoolKeyQuotaPanel from '@/features/pool/components/PoolKeyQuotaPanel.vue'
import PoolKeyStatsPanel from '@/features/pool/components/PoolKeyStatsPanel.vue'
import KeyAllowedModelsEditDialog from '@/features/providers/components/KeyAllowedModelsEditDialog.vue'
import KeyFormDialog from '@/features/providers/components/KeyFormDialog.vue'
import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDialog.vue'
@@ -2022,6 +1819,15 @@ interface QuotaProgressItem {
allowDynamicReset?: boolean
}
interface QuotaProgressDisplayItem {
label: string
remainingPercent: number
resetText: string
meterText: string
barClass: string
meterClass: string
}
interface PoolCodexCycleStatsRow {
key: PoolStatsMetric['key']
label: string
@@ -2067,6 +1873,21 @@ const quotaProgressMap = computed<Record<string, QuotaProgressItem[]>>(() => {
return map
})
const quotaProgressDisplayMap = computed<Record<string, QuotaProgressDisplayItem[]>>(() => {
const map: Record<string, QuotaProgressDisplayItem[]> = {}
for (const key of keyPage.value.keys) {
map[key.key_id] = (quotaProgressMap.value[key.key_id] || []).map(item => ({
label: getQuotaProgressLabel(item.label),
remainingPercent: item.remainingPercent,
resetText: getQuotaProgressResetDisplayText(item),
meterText: getQuotaProgressMeterDisplayText(item),
barClass: getQuotaRemainingBarColorByRemaining(item.remainingPercent),
meterClass: getQuotaRemainingClassByRemaining(item.remainingPercent),
}))
}
return map
})
const keyUiStateMap = computed<Record<string, PoolKeyUiState>>(() => {
const map: Record<string, PoolKeyUiState> = {}