Merge pull request #6009 from HypoxanthineOvO/fix/cn-quota-refresh-affordance

fix(frontend): make CN provider quota/balance refresh affordance explicit
This commit is contained in:
Wesley Liddick
2026-08-21 21:25:05 +08:00
committed by GitHub
6 changed files with 158 additions and 44 deletions
@@ -1,13 +1,35 @@
<template>
<div v-if="visible" class="space-y-1">
<!-- Balance value row: static display (snapshot or probe result) -->
<div class="flex flex-wrap items-center gap-1.5">
<span
data-test="cn-provider-balance-value"
:class="['text-[10px] font-medium leading-4', platformTextClass(account.platform)]"
:title="t('admin.accounts.cnProviders.balanceProbeTooltip')"
>
{{ balanceLabel }}
</span>
<!-- Low balance badge (reactive 402/429 marker or probe-detected) -->
<span
v-if="balanceLow"
class="inline-flex items-center rounded bg-red-100 px-1 py-0.5 text-[10px] font-medium text-red-700 dark:bg-red-900/30 dark:text-red-300"
>
{{ t('admin.accounts.cnProviders.balanceLow') }}
</span>
</div>
<!-- Explicit refresh action (aligned with the OpenAI "Query" / Grok "Probe"
buttons): the old chip doubled as the data display and a hidden click
target, which users could not discover. The verb label makes the
affordance explicit. -->
<div class="flex flex-wrap items-center gap-1.5">
<button
type="button"
:class="[
'inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-dark-600',
platformTextClass(account.platform)
]"
data-test="cn-provider-balance-probe"
class="inline-flex items-center gap-0.5 whitespace-nowrap rounded px-1.5 py-0.5 text-[10px] font-medium leading-4 text-blue-600 transition-colors hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:bg-blue-900/30"
:disabled="loading"
:title="t('admin.accounts.cnProviders.balanceProbeTooltip')"
@click="handleProbe"
>
<svg
@@ -24,16 +46,8 @@
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{{ balanceLabel }}
{{ t('admin.accounts.cnProviders.probe') }}
</button>
<!-- Low balance badge (reactive 402/429 marker or probe-detected) -->
<span
v-if="balanceLow"
class="inline-flex items-center rounded bg-red-100 px-1 py-0.5 text-[10px] font-medium text-red-700 dark:bg-red-900/30 dark:text-red-300"
>
{{ t('admin.accounts.cnProviders.balanceLow') }}
</span>
</div>
<div v-if="error" class="truncate text-[10px] text-red-600 dark:text-red-400" :title="error">
@@ -4,36 +4,7 @@
data-test="cn-provider-quota"
class="min-w-[220px] space-y-1"
>
<div class="flex flex-wrap items-center gap-1.5">
<button
type="button"
:class="[
'inline-flex items-center gap-0.5 whitespace-nowrap rounded px-1.5 py-0.5 text-[10px] font-medium leading-4 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-dark-600',
platformTextClass(account.platform)
]"
:disabled="loading"
:title="t('admin.accounts.cnProviders.probeTooltip')"
@click="handleProbe()"
>
<svg
class="h-2.5 w-2.5"
:class="{ 'animate-spin': loading }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{{ t('admin.accounts.cnProviders.window5h') }}/{{ t('admin.accounts.cnProviders.windowWeekly') }}
</button>
</div>
<!-- Tier rows: 5h + weekly utilization bars -->
<!-- Tier rows: 5h + weekly utilization bars (snapshot renders on mount) -->
<div v-if="data?.success && data.tiers?.length" class="space-y-1">
<div
v-for="tier in data.tiers"
@@ -67,6 +38,37 @@
</div>
</div>
<!-- Explicit refresh action (aligned with the OpenAI "Query" / Grok "Probe"
buttons): a verb label tells users this chip is clickable. The previous
noun label ("5h/weekly") read as a passive caption and users could not
discover the manual refresh. -->
<div class="flex flex-wrap items-center gap-1.5">
<button
type="button"
data-test="cn-provider-quota-probe"
class="inline-flex items-center gap-0.5 whitespace-nowrap rounded px-1.5 py-0.5 text-[10px] font-medium leading-4 text-blue-600 transition-colors hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:bg-blue-900/30"
:disabled="loading"
:title="t('admin.accounts.cnProviders.probeTooltip')"
@click="handleProbe()"
>
<svg
class="h-2.5 w-2.5"
:class="{ 'animate-spin': loading }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{{ t('admin.accounts.cnProviders.probe') }}
</button>
</div>
<div
v-if="error"
class="truncate text-[10px] leading-4 text-red-600 dark:text-red-400"
@@ -83,7 +85,6 @@ import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import type { CNProviderQuotaProbeResult } from '@/api/admin/cnProviders'
import type { Account } from '@/types'
import { platformTextClass } from '@/utils/platformColors'
import { cnQuotaCellVisible } from './credentialsBuilder'
const props = defineProps<{
@@ -0,0 +1,76 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CNProviderBalanceCell from '../CNProviderBalanceCell.vue'
import type { Account } from '@/types'
const { queryBalance } = vi.hoisted(() => ({
queryBalance: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
cnProviders: { queryBalance }
}
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
const account = {
id: 7,
platform: 'kimi',
type: 'apikey',
credentials: { account_mode: 'payg' },
extra: {
kimi_balance: 12.5,
kimi_balance_currency: 'CNY'
}
} as Account
describe('CNProviderBalanceCell', () => {
beforeEach(() => {
queryBalance.mockReset()
})
it('renders the persisted balance as static text with an explicit query action', async () => {
const wrapper = mount(CNProviderBalanceCell, { props: { account } })
await flushPromises()
// Snapshot value renders without any probe.
expect(queryBalance).not.toHaveBeenCalled()
expect(wrapper.get('[data-test="cn-provider-balance-value"]').text()).toContain('CNY 12.50')
// The control reads as an action; the i18n mock returns the key itself.
const probeButton = wrapper.get('[data-test="cn-provider-balance-probe"]')
expect(probeButton.text()).toBe('admin.accounts.cnProviders.probe')
await probeButton.trigger('click')
await flushPromises()
expect(queryBalance).toHaveBeenCalledWith(account.id)
})
it('shows the low-balance badge from the snapshot marker', () => {
const lowAccount = {
...account,
extra: { kimi_balance: 0.4, kimi_balance_low: true }
} as Account
const wrapper = mount(CNProviderBalanceCell, { props: { account: lowAccount } })
expect(wrapper.text()).toContain('admin.accounts.cnProviders.balanceLow')
})
it('keeps the snapshot balance visible when a query fails', async () => {
queryBalance.mockResolvedValue({ success: false, error: 'HTTP 401' })
const wrapper = mount(CNProviderBalanceCell, { props: { account } })
await wrapper.get('[data-test="cn-provider-balance-probe"]').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('CNY 12.50')
expect(wrapper.text()).toContain('HTTP 401')
})
})
@@ -73,4 +73,23 @@ describe('CNProviderQuotaCell', () => {
expect(queryQuota).toHaveBeenCalledWith(account.id)
})
it('labels the refresh control with an explicit action verb, not a data caption', async () => {
const wrapper = mount(CNProviderQuotaCell, { props: { account } })
await flushPromises()
// The snapshot is fresh (usage_updated_at = now): bars render without probing.
expect(queryQuota).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('27%')
// The control reads as an action ("query"), unlike the old noun label
// ("5-hour window/weekly window") which looked like a passive caption.
// The i18n mock returns the key itself.
const probeButton = wrapper.get('[data-test="cn-provider-quota-probe"]')
expect(probeButton.text()).toBe('admin.accounts.cnProviders.probe')
await probeButton.trigger('click')
await flushPromises()
expect(queryQuota).toHaveBeenCalledWith(account.id)
})
})
@@ -132,7 +132,9 @@ export default {
balance: 'Balance --',
window5h: '5-hour window',
windowWeekly: 'Weekly window',
probe: 'Query',
probeTooltip: 'Query the provider quota endpoint for 5-hour / weekly rolling window usage',
balanceProbeTooltip: 'Query the provider balance endpoint for the account balance',
balanceLow: 'Insufficient balance',
noBalanceEndpoint: 'This platform has no balance query endpoint',
resetSoon: 'reset soon',
@@ -335,7 +335,9 @@ export default {
balance: '余额 --',
window5h: '5 小时窗口',
windowWeekly: '每周窗口',
probe: '查询',
probeTooltip: '请求供应商额度端点,查询 5 小时 / 每周滚动窗口用量',
balanceProbeTooltip: '请求供应商余额端点,查询账户余额',
balanceLow: '余额不足',
noBalanceEndpoint: '该平台暂无余额查询接口',
resetSoon: '即将重置',