fix(web): align dropdown alerts with Figma design and fix hardcoded credits total

- Expose totalCredits from useTrialCredits hook instead of hardcoding 10,000
- Align CreditsExhaustedAlert with Figma: dynamic progress bar, correct
  design tokens (components-progress-error-bg/progress), sm-medium/xs-regular
  typography
- Align CreditsFallbackAlert typography to sm-medium/xs-regular
- Fix ApiKeySection empty state: horizontal gradient, sm-medium title,
  Figma-aligned padding (pl-7 for API KEYS label)
- Hoist empty credentials array constant to stabilize memo (rerender-memo-with-default-value)
- Remove redundant useCallback wrapper in ApiKeySection
- Replace nested ternary with Record lookup in TextLabel
- Remove dead || 0 guard in useTrialCredits
- Update all test mocks with totalCredits field
This commit is contained in:
yyh
2026-03-05 10:09:51 +08:00
parent b597d52c11
commit f13f0d1f9a
11 changed files with 96 additions and 79 deletions

View File

@ -21,7 +21,7 @@ const {
mockToastNotify: vi.fn(),
mockUpdateModelList: vi.fn(),
mockUpdateModelProviders: vi.fn(),
mockTrialCredits: { credits: 100, isExhausted: false, isLoading: false, nextCreditResetDate: undefined },
mockTrialCredits: { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false, nextCreditResetDate: undefined },
mockChangePriorityFn: vi.fn().mockResolvedValue({ result: 'success' }),
}))
@ -113,7 +113,7 @@ const renderWithQueryClient = (provider: ModelProvider) => {
describe('CredentialPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.assign(mockTrialCredits, { credits: 100, isExhausted: false, isLoading: false })
Object.assign(mockTrialCredits, { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false })
})
describe('Text label variants', () => {

View File

@ -97,16 +97,18 @@ const CredentialPanel = ({
)
}
const TEXT_LABEL_KEYS = {
'credits-active': 'modelProvider.card.aiCreditsInUse',
'credits-exhausted': 'modelProvider.card.quotaExhausted',
'no-usage': 'modelProvider.card.noAvailableUsage',
'api-required-add': 'modelProvider.card.apiKeyRequired',
'api-required-configure': 'modelProvider.card.apiKeyRequired',
} as const satisfies Partial<Record<CardVariant, string>>
function TextLabel({ variant }: { variant: CardVariant }) {
const { t } = useTranslation()
const isDestructive = isDestructiveVariant(variant)
const labelKey = variant === 'credits-active'
? 'modelProvider.card.aiCreditsInUse'
: variant === 'credits-exhausted'
? 'modelProvider.card.quotaExhausted'
: variant === 'no-usage'
? 'modelProvider.card.noAvailableUsage'
: 'modelProvider.card.apiKeyRequired'
const labelKey = TEXT_LABEL_KEYS[variant as keyof typeof TEXT_LABEL_KEYS]
return (
<span className={isDestructive ? 'text-text-destructive' : 'text-text-secondary'}>

View File

@ -1,5 +1,5 @@
import type { Credential, CustomModel, ModelProvider } from '../../declarations'
import { memo, useCallback } from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import CredentialItem from '../../model-auth/authorized/credential-item'
@ -26,25 +26,23 @@ function ApiKeySection({
const { t } = useTranslation()
const notAllowCustomCredential = provider.allow_custom_token === false
const handleItemClick = useCallback((credential: Credential) => {
onItemClick(credential)
}, [onItemClick])
if (!credentials.length) {
return (
<div className="p-2">
<div className="rounded-[10px] bg-gradient-to-b from-state-base-hover to-transparent p-3">
<div className="text-text-secondary system-xs-medium">
{t('modelProvider.card.noApiKeysTitle', { ns: 'common' })}
</div>
<div className="mt-0.5 text-text-tertiary system-2xs-regular">
{t('modelProvider.card.noApiKeysDescription', { ns: 'common' })}
<div className="flex flex-col gap-2 p-2">
<div className="rounded-[10px] bg-gradient-to-r from-state-base-hover to-transparent p-4">
<div className="flex flex-col gap-1">
<div className="text-text-secondary system-sm-medium">
{t('modelProvider.card.noApiKeysTitle', { ns: 'common' })}
</div>
<div className="text-text-tertiary system-xs-regular">
{t('modelProvider.card.noApiKeysDescription', { ns: 'common' })}
</div>
</div>
</div>
{!notAllowCustomCredential && (
<Button
onClick={onAdd}
className="mt-1 w-full"
className="w-full"
>
{t('modelProvider.auth.addApiKey', { ns: 'common' })}
</Button>
@ -55,24 +53,26 @@ function ApiKeySection({
return (
<div>
<div className="px-3 pb-0.5 pt-2 text-text-tertiary system-2xs-medium-uppercase">
{t('modelProvider.auth.apiKeys', { ns: 'common' })}
</div>
<div className="max-h-[200px] overflow-y-auto px-1">
{credentials.map(credential => (
<CredentialItem
key={credential.credential_id}
credential={credential}
showSelectedIcon
selectedCredentialId={selectedCredentialId}
onItemClick={handleItemClick}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
<div className="px-1">
<div className="pb-1 pl-7 pr-2 pt-3 text-text-tertiary system-xs-medium-uppercase">
{t('modelProvider.auth.apiKeys', { ns: 'common' })}
</div>
<div className="max-h-[200px] overflow-y-auto">
{credentials.map(credential => (
<CredentialItem
key={credential.credential_id}
credential={credential}
showSelectedIcon
selectedCredentialId={selectedCredentialId}
onItemClick={onItemClick}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
</div>
{!notAllowCustomCredential && (
<div className="border-t border-t-divider-subtle p-2">
<div className="p-2">
<Button
onClick={onAdd}
className="w-full"

View File

@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react'
import CreditsExhaustedAlert from './credits-exhausted-alert'
const mockTrialCredits = { credits: 0, isExhausted: true, isLoading: false, nextCreditResetDate: undefined }
const mockTrialCredits = { credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false, nextCreditResetDate: undefined }
vi.mock('../use-trial-credits', () => ({
useTrialCredits: () => mockTrialCredits,

View File

@ -8,8 +8,7 @@ type CreditsExhaustedAlertProps = {
export default function CreditsExhaustedAlert({ hasApiKeyFallback }: CreditsExhaustedAlertProps) {
const { t } = useTranslation()
const { credits } = useTrialCredits()
const totalCredits = 10_000
const { credits, totalCredits } = useTrialCredits()
const titleKey = hasApiKeyFallback
? 'modelProvider.card.creditsExhaustedFallback'
@ -18,33 +17,43 @@ export default function CreditsExhaustedAlert({ hasApiKeyFallback }: CreditsExha
? 'modelProvider.card.creditsExhaustedFallbackDescription'
: 'modelProvider.card.creditsExhaustedDescription'
const usedCredits = totalCredits - credits
const usagePercent = totalCredits > 0 ? Math.min((usedCredits / totalCredits) * 100, 100) : 100
return (
<div className="mx-3 mb-1 mt-0.5 rounded-lg bg-background-section-burn p-3">
<div className="text-text-primary system-xs-medium">
{t(titleKey, { ns: 'common' })}
</div>
<div className="mt-0.5 text-text-tertiary system-2xs-regular">
{t(descriptionKey, {
ns: 'common',
upgradeLink: `<a class="text-text-accent cursor-pointer system-2xs-medium-uppercase">${t('modelProvider.card.upgradePlan', { ns: 'common' })}</a>`,
interpolation: { escapeValue: false },
})}
</div>
<div className="mt-2 flex items-center justify-between">
<span className="text-text-tertiary system-2xs-regular">
{t('modelProvider.card.usageLabel', { ns: 'common' })}
</span>
<div className="flex items-center gap-0.5 text-text-tertiary system-2xs-regular">
<span className="i-ri-coin-line h-3 w-3" />
<span>
{formatNumber(totalCredits - credits)}
/
{formatNumber(totalCredits)}
</span>
<div className="mx-1 mb-1 mt-0.5 rounded-lg bg-background-section-burn p-3">
<div className="flex flex-col gap-1">
<div className="text-text-primary system-sm-medium">
{t(titleKey, { ns: 'common' })}
</div>
<div className="text-text-tertiary system-xs-regular">
{t(descriptionKey, {
ns: 'common',
upgradeLink: `<a class="text-text-accent cursor-pointer system-xs-medium">${t('modelProvider.card.upgradePlan', { ns: 'common' })}</a>`,
interpolation: { escapeValue: false },
})}
</div>
</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-state-destructive-hover-alt">
<div className="h-full rounded-full bg-state-destructive-solid" style={{ width: '100%' }} />
<div className="mt-3 flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="text-text-tertiary system-xs-medium">
{t('modelProvider.card.usageLabel', { ns: 'common' })}
</span>
<div className="flex items-center gap-0.5 text-text-tertiary system-xs-regular">
<span className="i-ri-coin-line h-3 w-3" />
<span>
{formatNumber(usedCredits)}
/
{formatNumber(totalCredits)}
</span>
</div>
</div>
<div className="h-1 overflow-hidden rounded-[6px] bg-components-progress-error-bg">
<div
className="h-full rounded-l-[6px] bg-components-progress-error-progress"
style={{ width: `${usagePercent}%` }}
/>
</div>
</div>
</div>
)

View File

@ -12,15 +12,17 @@ export default function CreditsFallbackAlert({ hasCredentials }: CreditsFallback
: 'modelProvider.card.noApiKeysFallback'
return (
<div className="mx-3 mb-1 mt-0.5 rounded-lg bg-background-section-burn p-3">
<div className="text-text-primary system-xs-medium">
{t(titleKey, { ns: 'common' })}
</div>
{hasCredentials && (
<div className="mt-0.5 text-text-tertiary system-2xs-regular">
{t('modelProvider.card.apiKeyUnavailableFallbackDescription', { ns: 'common' })}
<div className="mx-1 mb-1 mt-0.5 rounded-lg bg-background-section-burn p-3">
<div className="flex flex-col gap-1">
<div className="text-text-primary system-sm-medium">
{t(titleKey, { ns: 'common' })}
</div>
)}
{hasCredentials && (
<div className="text-text-tertiary system-xs-regular">
{t('modelProvider.card.apiKeyUnavailableFallbackDescription', { ns: 'common' })}
</div>
)}
</div>
</div>
)
}

View File

@ -12,7 +12,7 @@ const mockHandleConfirmDelete = vi.fn()
let mockDeleteCredentialId: string | null = null
vi.mock('../use-trial-credits', () => ({
useTrialCredits: () => ({ credits: 0, isExhausted: true, isLoading: false }),
useTrialCredits: () => ({ credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false }),
}))
vi.mock('../../model-auth/hooks', () => ({

View File

@ -18,6 +18,8 @@ import CreditsExhaustedAlert from './credits-exhausted-alert'
import CreditsFallbackAlert from './credits-fallback-alert'
import UsagePrioritySection from './usage-priority-section'
const EMPTY_CREDENTIALS: Credential[] = []
type DropdownContentProps = {
provider: ModelProvider
state: CredentialPanelState
@ -95,7 +97,7 @@ function DropdownContent({
)}
<ApiKeySection
provider={provider}
credentials={available_credentials ?? []}
credentials={available_credentials ?? EMPTY_CREDENTIALS}
selectedCredentialId={current_credential_id}
onItemClick={handleItemClick}
onEdit={handleEdit}

View File

@ -17,7 +17,7 @@ vi.mock('../../model-auth/hooks', () => ({
}))
vi.mock('../use-trial-credits', () => ({
useTrialCredits: () => ({ credits: 0, isExhausted: true, isLoading: false }),
useTrialCredits: () => ({ credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false }),
}))
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({

View File

@ -8,7 +8,7 @@ import {
} from '../declarations'
import { isDestructiveVariant, useCredentialPanelState } from './use-credential-panel-state'
const mockTrialCredits = { credits: 100, isExhausted: false, isLoading: false, nextCreditResetDate: undefined }
const mockTrialCredits = { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false, nextCreditResetDate: undefined }
vi.mock('./use-trial-credits', () => ({
useTrialCredits: () => mockTrialCredits,
@ -38,7 +38,7 @@ const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider =
describe('useCredentialPanelState', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.assign(mockTrialCredits, { credits: 100, isExhausted: false, isLoading: false })
Object.assign(mockTrialCredits, { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false })
})
// Credits priority variants

View File

@ -2,10 +2,12 @@ import { useCurrentWorkspace } from '@/service/use-common'
export const useTrialCredits = () => {
const { data: currentWorkspace, isPending } = useCurrentWorkspace()
const credits = Math.max(((currentWorkspace?.trial_credits ?? 0) - (currentWorkspace?.trial_credits_used ?? 0)) || 0, 0)
const totalCredits = currentWorkspace?.trial_credits ?? 0
const credits = Math.max(totalCredits - (currentWorkspace?.trial_credits_used ?? 0), 0)
return {
credits,
totalCredits,
isExhausted: credits <= 0,
isLoading: isPending && !currentWorkspace,
nextCreditResetDate: currentWorkspace?.next_credit_reset_date,