mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(accounts): refine upstream quota display and layout
Show quota availability consistently for unlimited and stale observations, keep pool summaries at the bottom, and compact plan counts with an inline overflow popover. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -13,6 +13,7 @@ const props = withDefaults(
|
||||
upstream?: AccountUpstream | null
|
||||
upstreamPeer?: boolean | null
|
||||
planTypeDisplay: string
|
||||
count?: number
|
||||
size?: 'xs' | 'sm' | 'md'
|
||||
}>(),
|
||||
{
|
||||
@@ -62,13 +63,14 @@ const paletteClass = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccountUpstreamBadge v-if="upstreamPeer || (upstream?.discovered && upstream.observedAt)" :size="size" />
|
||||
<AccountUpstreamBadge v-if="upstreamPeer || upstream?.discovered" :size="size" />
|
||||
<span
|
||||
v-else-if="!accountUpstreamPending(upstream)"
|
||||
class="inline-flex min-w-0 max-w-full items-center justify-center whitespace-nowrap leading-none shadow-z-tertiary"
|
||||
class="inline-flex min-w-0 max-w-full items-center justify-center gap-1 whitespace-nowrap leading-none shadow-z-tertiary"
|
||||
:class="[sizeClass, paletteClass]"
|
||||
:title="displayText || undefined"
|
||||
>
|
||||
<span class="truncate">{{ displayText }}</span>
|
||||
<span v-if="count !== undefined" class="shrink-0 tabular-nums">{{ count }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -116,10 +116,6 @@ function quotaWindowCode(windowSeconds: number | null, role: AccountQuotaWindow[
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="item.observationDisplay" class="mt-2 text-[10px] leading-4 text-z-text-tertiary">
|
||||
{{ item.observationDisplay }}
|
||||
</p>
|
||||
|
||||
<dl class="mt-2.5 grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5 text-[10px] leading-4">
|
||||
<template v-if="item.local">
|
||||
<dt class="font-emphasis text-z-text-tertiary">
|
||||
|
||||
@@ -13,15 +13,15 @@ const status = computed(() => {
|
||||
return accountUpstreamErrorMessage(upstream.lastError)
|
||||
if (!upstream.observedAt)
|
||||
return ''
|
||||
const observed = t('accounts.upstream.observedAt', {
|
||||
return t('accounts.upstream.observedAt', {
|
||||
time: formatRelativeTime(upstream.observedAtRelative),
|
||||
})
|
||||
return upstream.stale
|
||||
? `${observed} · ${t('accounts.upstream.stale')}`
|
||||
: observed
|
||||
})
|
||||
const detail = computed(() => [
|
||||
props.upstream.observedAtDisplay,
|
||||
props.upstream.stale
|
||||
? t('accounts.upstream.stale')
|
||||
: null,
|
||||
accountUpstreamErrorMessage(props.upstream.lastError),
|
||||
].filter(Boolean).join(' · '))
|
||||
</script>
|
||||
|
||||
@@ -381,8 +381,11 @@ function rollingWindowDurationDisplay(windowSeconds: number | null | undefined)
|
||||
}
|
||||
|
||||
export function quotaWindowPresentation(window: AccountQuotaWindow, minimumWidth: string) {
|
||||
const percent = clamp(window.usedPercent ?? 0, 0, 100)
|
||||
const tone = quotaWindowTone(window.stale ? null : window.usedPercent)
|
||||
const unlimited = window.amount?.totalDisplay === '∞'
|
||||
const percent = unlimited
|
||||
? 100
|
||||
: clamp(window.usedPercent ?? 0, 0, 100)
|
||||
const tone = quotaWindowTone(unlimited ? 0 : window.usedPercent)
|
||||
return {
|
||||
barStyle: {
|
||||
width: `${percent}%`,
|
||||
|
||||
@@ -627,6 +627,8 @@
|
||||
"unavailable": "Upstream unavailable",
|
||||
"pool": "Shared pool",
|
||||
"ready": "available",
|
||||
"otherPlans": "Other plans",
|
||||
"morePlans": "View {count} more plans",
|
||||
"nodeQuota": "Node quota",
|
||||
"quota": "Upstream quota"
|
||||
}
|
||||
|
||||
@@ -627,6 +627,8 @@
|
||||
"unavailable": "上游暂不可用",
|
||||
"pool": "共享池",
|
||||
"ready": "可用",
|
||||
"otherPlans": "其他套餐",
|
||||
"morePlans": "查看其余 {count} 种套餐",
|
||||
"nodeQuota": "节点额度",
|
||||
"quota": "上游额度"
|
||||
}
|
||||
|
||||
@@ -627,6 +627,8 @@
|
||||
"unavailable": "上游暫不可用",
|
||||
"pool": "共享池",
|
||||
"ready": "可用",
|
||||
"otherPlans": "其他套餐",
|
||||
"morePlans": "查看其餘 {count} 種套餐",
|
||||
"nodeQuota": "節點額度",
|
||||
"quota": "上游額度"
|
||||
}
|
||||
|
||||
@@ -33,6 +33,12 @@ const activeNode = computed(() => activeIndex.value === null
|
||||
const activeWindows = computed(() => groupedAccountQuotaWindows(
|
||||
orderedPanelQuotaWindows(activeNode.value?.windows ?? []),
|
||||
).flatMap(entry => entry.windows))
|
||||
const remainingLabel = computed(() => {
|
||||
const remaining = activeNode.value?.remainingPercent
|
||||
return remaining == null
|
||||
? ''
|
||||
: t('accounts.quota.remaining', { remaining: Math.round(remaining * 10) / 10 })
|
||||
})
|
||||
const canZoom = computed(() => props.nodes.length > MIN_VISIBLE_NODES)
|
||||
let chart: EChartsType | undefined
|
||||
let highlightedIndex: number | null = null
|
||||
@@ -49,6 +55,7 @@ const option = computed<EChartsOption>(() => {
|
||||
unknown: color('color-border', ''),
|
||||
}
|
||||
const nodes = props.nodes
|
||||
const trackFill = color('color-border-secondary', '')
|
||||
return {
|
||||
animation: reducedMotion.value !== 'reduce',
|
||||
animationDuration: 0,
|
||||
@@ -89,13 +96,15 @@ const option = computed<EChartsOption>(() => {
|
||||
series: [
|
||||
{
|
||||
type: 'custom',
|
||||
// 节点命中由容器统一处理,避免色条与空隙触发不同光标
|
||||
silent: true,
|
||||
animationThreshold: nodes.length,
|
||||
clip: true,
|
||||
dimensions: ['account', 'value'],
|
||||
encode: { x: 0, y: 1 },
|
||||
data: nodes.map((node, index) => ({
|
||||
id: node.id,
|
||||
value: [index, 1],
|
||||
value: [index, (node.remainingPercent ?? 0) / 100],
|
||||
})),
|
||||
renderItem: (_params, api) => {
|
||||
const index = Number(api.value(0))
|
||||
@@ -103,19 +112,34 @@ const option = computed<EChartsOption>(() => {
|
||||
const pitch = api.coord([index + 1, 0])[0]! - x
|
||||
const width = Math.min(8, pitch * 0.72)
|
||||
const fill = colors[nodes[index]!.tone]
|
||||
const shape = {
|
||||
x: x - width / 2,
|
||||
y: 7,
|
||||
width,
|
||||
height: 22,
|
||||
r: Math.min(2, width / 2),
|
||||
}
|
||||
// 耗尽时保留红色底线,未知额度只显示灰色轨道
|
||||
const height = nodes[index]!.remainingPercent === null
|
||||
? 0
|
||||
: Math.max(2, Number(api.value(1)) * 22)
|
||||
return {
|
||||
type: 'rect',
|
||||
shape: {
|
||||
x: x - width / 2,
|
||||
y: 7,
|
||||
width,
|
||||
height: 22,
|
||||
r: Math.min(2, width / 2),
|
||||
},
|
||||
style: { fill, stroke: fill, lineWidth: 0 },
|
||||
emphasis: {
|
||||
style: { fill, stroke: fill, lineWidth: 2 },
|
||||
},
|
||||
type: 'group',
|
||||
children: [
|
||||
{
|
||||
type: 'rect',
|
||||
shape,
|
||||
style: { fill: trackFill, stroke: fill, lineWidth: 0 },
|
||||
emphasis: {
|
||||
style: { stroke: fill, lineWidth: 2 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'rect',
|
||||
shape: { ...shape, y: 29 - height, height },
|
||||
style: { fill },
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -260,12 +284,12 @@ onBeforeUnmount(() => chart?.off('datazoom', onDataZoom))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0">
|
||||
<ZPopover ref="popover" v-model="open" class="w-full" placement="top" trigger="hover-click" :reference-element="anchor" :disabled="!activeNode?.windows.length" :hover-delay="180" animate-position>
|
||||
<template #reference>
|
||||
<div
|
||||
class="relative w-full rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-z-control-outline"
|
||||
role="slider" tabindex="0" :aria-valuemin="1" :aria-valuemax="nodes.length" :aria-valuenow="(activeIndex ?? 0) + 1" :aria-valuetext="t('accounts.upstream.account', { number: (activeIndex ?? 0) + 1 })" :aria-label="t('accounts.upstream.poolChart')" :aria-description="t('accounts.upstream.poolKeyboard')"
|
||||
role="slider" tabindex="0" :aria-valuemin="1" :aria-valuemax="nodes.length" :aria-valuenow="(activeIndex ?? 0) + 1" :aria-valuetext="[t('accounts.upstream.account', { number: (activeIndex ?? 0) + 1 }), remainingLabel].filter(Boolean).join(' · ')" :aria-label="t('accounts.upstream.poolChart')" :aria-description="t('accounts.upstream.poolKeyboard')"
|
||||
@pointermove="onPointerMove" @pointerleave="onPointerLeave" @click="onChartClick" @dblclick="resetZoom" @keydown="onKeydown"
|
||||
>
|
||||
<BaseChart :option="option" :height="36" @ready="onReady" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountQuotaWindow, AccountUpstream } from '@/api'
|
||||
import { ZPopover, ZScrollbar } from '@codex-proxy/ui'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
|
||||
@@ -9,21 +10,50 @@ import PoolChart from './PoolChart.vue'
|
||||
const props = defineProps<{ pool?: AccountUpstream['pool'], windows: AccountQuotaWindow[] }>()
|
||||
const { t } = useI18n({ useScope: 'global' })
|
||||
const nodes = computed(() => poolNodes(props.windows, props.pool?.accountCount))
|
||||
const plans = computed(() => {
|
||||
const sorted = props.pool?.plans.slice().sort((left, right) => right.count - left.count) ?? []
|
||||
return {
|
||||
visible: sorted.slice(0, 3),
|
||||
overflow: sorted.slice(3),
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="grid min-w-0 gap-1" :aria-label="t('accounts.upstream.pool')">
|
||||
<PoolChart v-if="nodes.length" :nodes="nodes" />
|
||||
<div v-if="pool" class="flex min-h-5 min-w-0 items-center justify-between gap-x-3">
|
||||
<div v-if="pool" class="flex min-h-5 min-w-0 items-end justify-between gap-x-3">
|
||||
<div class="flex shrink-0 items-baseline gap-1 whitespace-nowrap" :aria-label="`${pool.states.ready} / ${pool.accountCount} ${t('accounts.upstream.ready')}`">
|
||||
<strong class="font-mono text-z-xs leading-[normal] font-heavy tabular-nums" :class="pool.states.ready > 0 ? 'text-z-success-text' : 'text-z-text'">{{ pool.states.ready }}</strong>
|
||||
<span class="font-mono text-z-xs leading-[normal] tabular-nums text-z-text-quaternary">/ {{ pool.accountCount }}</span>
|
||||
</div>
|
||||
<div v-if="pool.plans.length" class="flex min-w-0 flex-wrap items-center justify-end gap-x-3 gap-y-1.5">
|
||||
<span v-for="plan in pool.plans" :key="plan.name" class="inline-flex min-w-0 items-center gap-1.5">
|
||||
<AccountPlanBadge :plan-type="plan.name" :plan-type-display="plan.name" size="xs" />
|
||||
<span class="font-mono text-z-xs leading-[normal] font-emphasis tabular-nums text-z-text-secondary">{{ plan.count }}</span>
|
||||
</span>
|
||||
<div v-if="pool.plans.length" class="flex min-w-0 items-end justify-end gap-1.5">
|
||||
<AccountPlanBadge
|
||||
v-for="plan in plans.visible" :key="plan.name"
|
||||
:plan-type="plan.name" :plan-type-display="plan.name" :count="plan.count"
|
||||
size="xs" class="max-w-24"
|
||||
/>
|
||||
<ZPopover v-if="plans.overflow.length" class="shrink-0" trigger="hover-click" placement="top-end" :offset="12" :hover-delay="240">
|
||||
<template #reference="{ open, id }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-4.5 min-w-6 cursor-pointer items-center justify-center rounded-full border-0 bg-z-fill-quaternary px-1.5 text-[10px] font-emphasis tabular-nums text-z-text-secondary outline-none transition-colors hover:bg-z-fill-tertiary hover:text-z-text focus-visible:ring-2 focus-visible:ring-z-control-outline"
|
||||
:aria-label="t('accounts.upstream.morePlans', { count: plans.overflow.length })"
|
||||
aria-haspopup="dialog" :aria-expanded="open" :aria-controls="open ? id : undefined"
|
||||
>
|
||||
+{{ plans.overflow.length }}
|
||||
</button>
|
||||
</template>
|
||||
<section class="w-max max-w-[min(20rem,calc(100vw-2rem))] overflow-hidden rounded-z-lg" role="dialog" :aria-label="t('accounts.upstream.otherPlans')">
|
||||
<ZScrollbar max-height="min(14rem, calc(100dvh - 4rem))">
|
||||
<ul class="m-0 flex list-none flex-wrap items-center gap-2 p-3">
|
||||
<li v-for="plan in plans.overflow" :key="plan.name" class="flex min-w-0">
|
||||
<AccountPlanBadge :plan-type="plan.name" :plan-type-display="plan.name" :count="plan.count" size="xs" />
|
||||
</li>
|
||||
</ul>
|
||||
</ZScrollbar>
|
||||
</section>
|
||||
</ZPopover>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Account } from '@/api'
|
||||
|
||||
import { ZEmpty, ZIconButton } from '@codex-proxy/ui'
|
||||
import { ZEmpty, ZIconButton, ZScrollbar } from '@codex-proxy/ui'
|
||||
|
||||
import { ChartNoAxesCombined, RefreshCw, UserRound } from '@lucide/vue'
|
||||
|
||||
@@ -67,7 +67,7 @@ watch(hasPersonalInfo, (available) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex min-h-0 min-w-0 flex-col rounded-lg bg-z-bg-container p-4 shadow-z-tertiary">
|
||||
<section class="flex max-h-72 min-h-0 min-w-0 flex-col rounded-lg bg-z-bg-container p-4 shadow-z-tertiary">
|
||||
<div class="mb-3 grid shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1">
|
||||
<h3 class="m-0 text-z-lg font-heavy text-z-text">
|
||||
{{ account.upstream?.discovered ? t('accounts.upstream.quota') : t('accounts.quota.accountLimit') }}
|
||||
@@ -78,7 +78,6 @@ watch(hasPersonalInfo, (available) => {
|
||||
>
|
||||
<AccountPlanBadge v-if="hasPlanBadge" :upstream="account.upstream" :authentication-kind="account.authenticationKind" :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" size="sm" class="shrink-0" />
|
||||
<template v-if="showUpstreamStatus || lastRefreshed">
|
||||
<span v-if="hasPlanBadge" class="shrink-0">·</span>
|
||||
<AccountUpstreamStatus v-if="showUpstreamStatus && account.upstream" :upstream="account.upstream" />
|
||||
<span v-else-if="lastRefreshed" class="min-w-0 truncate" :title="lastRefreshed">{{ lastRefreshed }}</span>
|
||||
</template>
|
||||
@@ -130,14 +129,18 @@ watch(hasPersonalInfo, (available) => {
|
||||
surface="none"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="grid min-h-0 gap-3">
|
||||
<AccountQuotaPanelEntry
|
||||
v-for="entry in quotaEntries"
|
||||
:key="entry.key"
|
||||
:label="entry.label"
|
||||
:windows="entry.windows"
|
||||
/>
|
||||
<AccountUpstreamPool v-if="hasUpstreamPool" :pool="account.upstream?.pool" :windows="poolWindows" />
|
||||
<div v-else class="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<ZScrollbar v-if="quotaEntries.length" height="100%" class="-mr-3 [--z-scrollbar-track-inset-block-start:0px]">
|
||||
<div class="grid gap-3 pr-3">
|
||||
<AccountQuotaPanelEntry
|
||||
v-for="entry in quotaEntries"
|
||||
:key="entry.key"
|
||||
:label="entry.label"
|
||||
:windows="entry.windows"
|
||||
/>
|
||||
</div>
|
||||
</ZScrollbar>
|
||||
<AccountUpstreamPool v-if="hasUpstreamPool" class="mt-auto" :pool="account.upstream?.pool" :windows="poolWindows" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AccountQuotaWindow } from '@/api'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { quotaWindowTone } from '@/components/account/account-usage-window/presenter'
|
||||
|
||||
// 与互联协议的账号上限一致,汇总数字不能触发无界节点分配
|
||||
@@ -7,6 +8,7 @@ const MAX_POOL_NODES = 3_000
|
||||
export interface PoolNode {
|
||||
id: string
|
||||
windows: AccountQuotaWindow[]
|
||||
remainingPercent: number | null
|
||||
tone: ReturnType<typeof quotaWindowTone>
|
||||
}
|
||||
|
||||
@@ -25,21 +27,31 @@ export function poolNodes(windows: AccountQuotaWindow[], count?: number): PoolNo
|
||||
accounts.set(window.sourceAccountId, group)
|
||||
}
|
||||
const nodes: PoolNode[] = [...accounts].map(([id, windows]) => {
|
||||
const percentages = windows.filter(window => !window.stale)
|
||||
const percentages = windows
|
||||
.map(window => window.limitReached ? 100 : window.usedPercent)
|
||||
.filter((value): value is number => typeof value === 'number')
|
||||
const usedPercent = percentages.length
|
||||
? Math.max(...percentages)
|
||||
: null
|
||||
return {
|
||||
id,
|
||||
windows,
|
||||
tone: quotaWindowTone(percentages.length
|
||||
? Math.max(...percentages)
|
||||
: null),
|
||||
remainingPercent: usedPercent === null
|
||||
? null
|
||||
: 100 - clamp(usedPercent, 0, 100),
|
||||
tone: quotaWindowTone(usedPercent),
|
||||
}
|
||||
})
|
||||
// 未公开逐账号额度时仅补灰色位置,不能把汇总可用数解释为各账号剩余额度
|
||||
if (count !== undefined) {
|
||||
while (nodes.length < limit)
|
||||
nodes.push({ id: `unobserved:${nodes.length}`, windows: [], tone: 'unknown' })
|
||||
while (nodes.length < limit) {
|
||||
nodes.push({
|
||||
id: `unobserved:${nodes.length}`,
|
||||
windows: [],
|
||||
remainingPercent: null,
|
||||
tone: 'unknown',
|
||||
})
|
||||
}
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ const hasQuotaWindow = computed(() => props.windows.some(window => typeof window
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ZPopover class="grid! w-full grid-cols-subgrid" trigger="hover-click" placement="right" :hover-delay="240">
|
||||
<ZPopover class="grid! w-full grid-cols-subgrid" trigger="hover-click" placement="right" :offset="12" :hover-delay="240">
|
||||
<template #reference="{ open }">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -365,7 +365,7 @@ const {
|
||||
</template>
|
||||
|
||||
<template #expanded="{ row }">
|
||||
<div class="grid items-stretch gap-3 p-4 lg:grid-cols-[1.05fr_2.45fr] xl:min-h-77">
|
||||
<div class="grid items-stretch gap-3 p-4 lg:grid-cols-[1.05fr_2.45fr] xl:h-77.5">
|
||||
<AccountQuotaPanel
|
||||
:account="row"
|
||||
:refreshing="refreshingQuotaAccountIds.has(row.id)"
|
||||
|
||||
Reference in New Issue
Block a user