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:
@@ -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: '即将重置',
|
||||
|
||||
Reference in New Issue
Block a user