refactor(web): extract SystemQuotaCard compound component and shared useTrialCredits hook

Extract trial credits calculation into a shared useTrialCredits hook to prevent
logic drift between QuotaPanel and CredentialPanel. Add SystemQuotaCard compound
component with explicit default/destructive variants for the system quota UI
state in provider cards, replacing inline conditional styling with composable
Label and Actions slots. Remove unnecessary useMemo for simple derived values.
This commit is contained in:
yyh
2026-03-04 23:30:25 +08:00
parent 5ed4797078
commit 648d9ef1f9
7 changed files with 124 additions and 28 deletions

View File

@ -2,7 +2,6 @@ import type {
ModelProvider,
} from '../declarations'
import { useQueryClient } from '@tanstack/react-query'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast'
import { ConfigProvider } from '@/app/components/header/account-setting/model-provider-page/model-auth'
@ -25,6 +24,8 @@ import {
import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from './index'
import PrioritySelector from './priority-selector'
import PriorityUseTip from './priority-use-tip'
import SystemQuotaCard from './system-quota-card'
import { useTrialCredits } from './use-trial-credits'
type CredentialPanelProps = {
provider: ModelProvider
@ -53,6 +54,8 @@ const CredentialPanel = ({
} = useCredentialStatus(provider)
const showPrioritySelector = systemConfig.enabled && isCustomConfigured && IS_CLOUD_EDITION
const isUsingSystemQuota = systemConfig.enabled && priorityUseType === PreferredProviderTypeEnum.system && IS_CLOUD_EDITION
const { isExhausted } = useTrialCredits()
const handleChangePriority = async (key: PreferredProviderTypeEnum) => {
const res = await changeModelProviderPriority({
@ -80,24 +83,40 @@ const CredentialPanel = ({
} as any)
}
}
const credentialLabel = useMemo(() => {
if (!hasCredential)
return t('modelProvider.auth.unAuthorized', { ns: 'common' })
if (authorized)
return current_credential_name
if (authRemoved)
return t('modelProvider.auth.authRemoved', { ns: 'common' })
const credentialLabel = !hasCredential
? t('modelProvider.auth.unAuthorized', { ns: 'common' })
: authorized
? current_credential_name
: authRemoved
? t('modelProvider.auth.authRemoved', { ns: 'common' })
: ''
return ''
}, [authorized, authRemoved, current_credential_name, hasCredential, t])
const color = (authRemoved || !hasCredential)
? 'red'
: notAllowedToUse
? 'gray'
: 'green'
const color = useMemo(() => {
if (authRemoved || !hasCredential)
return 'red'
if (notAllowedToUse)
return 'gray'
return 'green'
}, [authRemoved, notAllowedToUse, hasCredential])
if (isUsingSystemQuota) {
return (
<SystemQuotaCard variant={isExhausted ? 'destructive' : 'default'}>
<SystemQuotaCard.Label>
{isExhausted
? t('modelProvider.card.quotaExhausted', { ns: 'common' })
: t('modelProvider.card.aiCreditsInUse', { ns: 'common' })}
</SystemQuotaCard.Label>
<SystemQuotaCard.Actions>
<ConfigProvider provider={provider} />
{showPrioritySelector && (
<PrioritySelector
value={priorityUseType}
onSelect={handleChangePriority}
/>
)}
</SystemQuotaCard.Actions>
</SystemQuotaCard>
)
}
return (
<>
@ -108,7 +127,7 @@ const CredentialPanel = ({
authRemoved && 'border-state-destructive-border bg-state-destructive-hover',
)}
>
<div className="system-xs-medium mb-1 flex h-5 items-center justify-between pl-2 pr-[7px] pt-1 text-text-tertiary">
<div className="mb-1 flex h-5 items-center justify-between pl-2 pr-[7px] pt-1 text-text-tertiary system-xs-medium">
<div
className={cn(
'grow truncate',

View File

@ -11,7 +11,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/u
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { useSystemFeaturesQuery } from '@/context/global-public-context'
import useTimestamp from '@/hooks/use-timestamp'
import { useCurrentWorkspace } from '@/service/use-common'
import { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { cn } from '@/utils/classnames'
import { formatNumber } from '@/utils/format'
@ -19,6 +18,7 @@ import { PreferredProviderTypeEnum } from '../declarations'
import { useMarketplaceAllPlugins } from '../hooks'
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap } from '../utils'
import styles from './quota-panel.module.css'
import { useTrialCredits } from './use-trial-credits'
const providerIconMap: Record<ModelProviderQuotaGetPaid, ComponentType<{ className?: string }>> = {
[ModelProviderQuotaGetPaid.OPENAI]: OpenaiSmall,
@ -50,11 +50,9 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
providers,
}) => {
const { t } = useTranslation()
const { data: currentWorkspace, isPending: isPendingWorkspace } = useCurrentWorkspace()
const { credits, isExhausted, isLoading, nextCreditResetDate } = useTrialCredits()
const { data: systemFeatures } = useSystemFeaturesQuery()
const trialModels = systemFeatures?.trial_models ?? []
const credits = Math.max(((currentWorkspace?.trial_credits ?? 0) - (currentWorkspace?.trial_credits_used ?? 0)) || 0, 0)
const isLoading = isPendingWorkspace && !currentWorkspace
const providerMap = useMemo(() => new Map(
providers.map(p => [p.provider, p.preferred_provider_type]),
), [providers])
@ -111,7 +109,7 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
return (
<div className={cn(
'relative my-2 min-w-[72px] shrink-0 overflow-hidden rounded-xl border-[0.5px] pb-2.5 pl-4 pr-2.5 pt-3 shadow-xs',
credits <= 0
isExhausted
? 'border-state-destructive-border hover:bg-state-destructive-hover'
: 'border-components-panel-border bg-third-party-model-bg-default',
)}
@ -140,14 +138,14 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
{credits > 0
? <span className="mr-0.5 text-text-secondary system-xl-semibold">{formatNumber(credits)}</span>
: <span className="mr-0.5 text-text-destructive system-xl-semibold">{t('modelProvider.card.quotaExhausted', { ns: 'common' })}</span>}
{currentWorkspace?.next_credit_reset_date
{nextCreditResetDate
? (
<>
<span>·</span>
<span>
{t('modelProvider.resetDate', {
ns: 'common',
date: formatTime(currentWorkspace.next_credit_reset_date, t('dateFormat', { ns: 'appLog' })),
date: formatTime(nextCreditResetDate!, t('dateFormat', { ns: 'appLog' })),
interpolation: { escapeValue: false },
})}
</span>

View File

@ -0,0 +1,67 @@
import type { ReactNode } from 'react'
import { createContext, useContext } from 'react'
import { cn } from '@/utils/classnames'
import styles from './quota-panel.module.css'
type Variant = 'default' | 'destructive'
const VariantContext = createContext<Variant>('default')
const containerVariants: Record<Variant, string> = {
default: 'border-components-panel-border bg-white/[0.18]',
destructive: 'border-state-destructive-border bg-state-destructive-hover',
}
const labelVariants: Record<Variant, string> = {
default: 'text-text-secondary',
destructive: 'text-text-destructive',
}
type SystemQuotaCardProps = {
variant?: Variant
children: ReactNode
}
const SystemQuotaCard = ({
variant = 'default',
children,
}: SystemQuotaCardProps) => {
return (
<VariantContext.Provider value={variant}>
<div className={cn(
'relative isolate ml-1 flex w-[128px] shrink-0 flex-col justify-between rounded-lg border-[0.5px] p-1 shadow-xs',
containerVariants[variant],
)}
>
<div className={cn('pointer-events-none absolute inset-0 rounded-[7px]', styles.gridBg)} />
{children}
</div>
</VariantContext.Provider>
)
}
const Label = ({ children }: { children: ReactNode }) => {
const variant = useContext(VariantContext)
return (
<div className={cn(
'relative z-[1] truncate px-1.5 pt-1 system-xs-medium',
labelVariants[variant],
)}
>
{children}
</div>
)
}
const Actions = ({ children }: { children: ReactNode }) => {
return (
<div className="relative z-[1] flex items-center gap-0.5">
{children}
</div>
)
}
SystemQuotaCard.Label = Label
SystemQuotaCard.Actions = Actions
export default SystemQuotaCard

View File

@ -0,0 +1,13 @@
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)
return {
credits,
isExhausted: credits <= 0,
isLoading: isPending && !currentWorkspace,
nextCreditResetDate: currentWorkspace?.next_credit_reset_date,
}
}

View File

@ -4858,9 +4858,6 @@
}
},
"app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}

View File

@ -340,6 +340,7 @@
"modelProvider.auth.unAuthorized": "Unauthorized",
"modelProvider.buyQuota": "Buy Quota",
"modelProvider.callTimes": "Call times",
"modelProvider.card.aiCreditsInUse": "AI credits in use",
"modelProvider.card.buyQuota": "Buy Quota",
"modelProvider.card.callTimes": "Call times",
"modelProvider.card.modelAPI": "{{modelName}} models are using the API Key.",

View File

@ -340,6 +340,7 @@
"modelProvider.auth.unAuthorized": "未授权",
"modelProvider.buyQuota": "购买额度",
"modelProvider.callTimes": "调用次数",
"modelProvider.card.aiCreditsInUse": "AI 额度使用中",
"modelProvider.card.buyQuota": "购买额度",
"modelProvider.card.callTimes": "调用次数",
"modelProvider.card.modelAPI": "{{modelName}} 模型正在使用 API Key。",