refactor(web): align quota panel with Figma design and migrate to base UI tooltip

- Rename title from "Quota" to "AI Credits" and update tooltip copy
  (Message Credits → AI Credits, free → Trial)
- Show "Credits exhausted" in destructive text when credits reach zero
  instead of displaying the number "0"
- Migrate from deprecated Tooltip to base UI Tooltip compound component
- Add 4px grid background with radial fade mask via CSS module
- Simplify provider icon tooltip text for uninstalled state
- Update i18n keys for both en-US and zh-Hans
This commit is contained in:
yyh
2026-03-04 20:49:35 +08:00
parent 8c4afc0c18
commit d44682e957
5 changed files with 106 additions and 67 deletions

View File

@@ -0,0 +1,8 @@
.gridBg {
background-size: 4px 4px;
background-image:
linear-gradient(to right, var(--color-divider-subtle) 0.5px, transparent 0.5px),
linear-gradient(to bottom, var(--color-divider-subtle) 0.5px, transparent 0.5px);
-webkit-mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.6), transparent 70%);
mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.6), transparent 70%);
}

View File

@@ -125,7 +125,7 @@ describe('QuotaPanel', () => {
render(<QuotaPanel providers={mockProviders} />)
expect(screen.getByText('0')).toBeInTheDocument()
expect(screen.getByText(/modelProvider\.card\.quotaExhausted/)).toBeInTheDocument()
expect(screen.queryByText(/modelProvider\.resetDate/)).not.toBeInTheDocument()
})

View File

@@ -7,7 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AnthropicShortLight, Deepseek, Gemini, Grok, OpenaiSmall, Tongyi } from '@/app/components/base/icons/src/public/llm'
import Loading from '@/app/components/base/loading'
import Tooltip from '@/app/components/base/tooltip'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { useSystemFeaturesQuery } from '@/context/global-public-context'
import useTimestamp from '@/hooks/use-timestamp'
@@ -18,8 +18,8 @@ import { formatNumber } from '@/utils/format'
import { PreferredProviderTypeEnum } from '../declarations'
import { useMarketplaceAllPlugins } from '../hooks'
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap } from '../utils'
import styles from './quota-panel.module.css'
// Icon map for each provider - single source of truth for provider icons
const providerIconMap: Record<ModelProviderQuotaGetPaid, ComponentType<{ className?: string }>> = {
[ModelProviderQuotaGetPaid.OPENAI]: OpenaiSmall,
[ModelProviderQuotaGetPaid.ANTHROPIC]: AnthropicShortLight,
@@ -29,14 +29,11 @@ const providerIconMap: Record<ModelProviderQuotaGetPaid, ComponentType<{ classNa
[ModelProviderQuotaGetPaid.TONGYI]: Tongyi,
}
// Derive allProviders from the shared constant
const allProviders = MODEL_PROVIDER_QUOTA_GET_PAID.map(key => ({
key,
Icon: providerIconMap[key],
}))
// Map provider key to plugin ID
// provider key format: langgenius/provider/model, plugin ID format: langgenius/provider
const providerKeyToPluginId: Record<ModelProviderQuotaGetPaid, string> = {
[ModelProviderQuotaGetPaid.OPENAI]: 'langgenius/openai',
[ModelProviderQuotaGetPaid.ANTHROPIC]: 'langgenius/anthropic',
@@ -98,6 +95,11 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
}
}, [providers, isShowInstallModal, hideInstallFromMarketplace])
const tipText = t('modelProvider.card.tip', {
ns: 'common',
modelNames: trialModels.map(key => modelNameMap[key as keyof typeof modelNameMap]).filter(Boolean).join(', '),
})
if (isLoading) {
return (
<div className="my-2 flex min-h-[72px] items-center justify-center rounded-xl border-[0.5px] border-components-panel-border bg-third-party-model-bg-default shadow-xs">
@@ -107,59 +109,88 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
}
return (
<div className={cn('my-2 min-w-[72px] shrink-0 rounded-xl border-[0.5px] pb-2.5 pl-4 pr-2.5 pt-3 shadow-xs', credits <= 0 ? 'border-state-destructive-border hover:bg-state-destructive-hover' : 'border-components-panel-border bg-third-party-model-bg-default')}>
<div className="system-xs-medium-uppercase mb-2 flex h-4 items-center text-text-tertiary">
{t('modelProvider.quota', { ns: 'common' })}
<Tooltip popupContent={t('modelProvider.card.tip', { ns: 'common', modelNames: trialModels.map(key => modelNameMap[key as keyof typeof modelNameMap]).filter(Boolean).join(', ') })} />
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-text-tertiary">
<span className="system-md-semibold-uppercase mr-0.5 text-text-secondary">{formatNumber(credits)}</span>
<span>{t('modelProvider.credits', { ns: 'common' })}</span>
{currentWorkspace?.next_credit_reset_date
? (
<>
<span>·</span>
<span>
{t('modelProvider.resetDate', {
ns: 'common',
date: formatTime(currentWorkspace.next_credit_reset_date, t('dateFormat', { ns: 'appLog' })),
interpolation: { escapeValue: false },
})}
</span>
</>
)
: null}
<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
? 'border-state-destructive-border hover:bg-state-destructive-hover'
: 'border-components-panel-border bg-third-party-model-bg-default',
)}
>
<div className={cn('pointer-events-none absolute inset-0', styles.gridBg)} />
<div className="relative">
<div className="mb-2 flex h-4 items-center text-text-tertiary system-xs-medium-uppercase">
{t('modelProvider.quota', { ns: 'common' })}
<Tooltip>
<TooltipTrigger
aria-label={tipText}
delay={0}
render={(
<span className="ml-0.5 flex h-4 w-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
</span>
)}
/>
<TooltipContent>
{tipText}
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-1">
{allProviders.filter(({ key }) => trialModels.includes(key)).map(({ key, Icon }) => {
const providerType = providerMap.get(key)
const isConfigured = (installedProvidersMap.get(key)?.length ?? 0) > 0 // means the provider is configured API key
const getTooltipKey = () => {
// if provider type is not set, it means the provider is not installed
if (!providerType)
return 'modelProvider.card.modelNotSupported'
if (isConfigured && providerType === PreferredProviderTypeEnum.custom)
return 'modelProvider.card.modelAPI'
return 'modelProvider.card.modelSupported'
}
return (
<Tooltip
key={key}
popupContent={t(getTooltipKey(), { modelName: modelNameMap[key], ns: 'common' })}
>
<div
className={cn('relative h-6 w-6', !providerType && 'cursor-pointer hover:opacity-80')}
onClick={() => handleIconClick(key)}
>
<Icon className="h-6 w-6 rounded-lg" />
{!providerType && (
<div className="absolute inset-0 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge opacity-30" />
)}
</div>
</Tooltip>
)
})}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-text-tertiary">
{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
? (
<>
<span>·</span>
<span>
{t('modelProvider.resetDate', {
ns: 'common',
date: formatTime(currentWorkspace.next_credit_reset_date, t('dateFormat', { ns: 'appLog' })),
interpolation: { escapeValue: false },
})}
</span>
</>
)
: null}
</div>
<div className="flex items-center gap-1">
{allProviders.filter(({ key }) => trialModels.includes(key)).map(({ key, Icon }) => {
const providerType = providerMap.get(key)
const isConfigured = (installedProvidersMap.get(key)?.length ?? 0) > 0
const getTooltipKey = () => {
if (!providerType)
return 'modelProvider.card.modelNotSupported'
if (isConfigured && providerType === PreferredProviderTypeEnum.custom)
return 'modelProvider.card.modelAPI'
return 'modelProvider.card.modelSupported'
}
const tooltipText = t(getTooltipKey(), { modelName: modelNameMap[key], ns: 'common' })
return (
<Tooltip key={key}>
<TooltipTrigger
aria-label={tooltipText}
delay={0}
render={(
<div
className={cn('relative h-6 w-6', !providerType && 'cursor-pointer hover:opacity-80')}
onClick={() => handleIconClick(key)}
>
<Icon className="h-6 w-6 rounded-lg" />
{!providerType && (
<div className="absolute inset-0 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge opacity-30" />
)}
</div>
)}
/>
<TooltipContent>
{tooltipText}
</TooltipContent>
</Tooltip>
)
})}
</div>
</div>
</div>
{isShowInstallModal && selectedPlugin && (

View File

@@ -343,15 +343,15 @@
"modelProvider.card.buyQuota": "Buy Quota",
"modelProvider.card.callTimes": "Call times",
"modelProvider.card.modelAPI": "{{modelName}} models are using the API Key.",
"modelProvider.card.modelNotSupported": "{{modelName}} models are not installed.",
"modelProvider.card.modelSupported": "{{modelName}} models are using this quota.",
"modelProvider.card.modelNotSupported": "{{modelName}} not installed",
"modelProvider.card.modelSupported": "{{modelName}} models are using these credits.",
"modelProvider.card.onTrial": "On Trial",
"modelProvider.card.paid": "Paid",
"modelProvider.card.priorityUse": "Priority use",
"modelProvider.card.quota": "QUOTA",
"modelProvider.card.quotaExhausted": "Quota exhausted",
"modelProvider.card.quotaExhausted": "Credits exhausted",
"modelProvider.card.removeKey": "Remove API Key",
"modelProvider.card.tip": "Message Credits supports models from {{modelNames}}. Priority will be given to the paid quota. The free quota will be used after the paid quota is exhausted.",
"modelProvider.card.tip": "AI Credits supports models from {{modelNames}}. Priority will be given to the paid quota. The Trial quota will be used after the paid quota is exhausted.",
"modelProvider.card.tokens": "Tokens",
"modelProvider.collapse": "Collapse",
"modelProvider.config": "Config",
@@ -397,7 +397,7 @@
"modelProvider.priorityUsing": "Prioritize using",
"modelProvider.providerManaged": "Provider managed",
"modelProvider.providerManagedDescription": "Use the single set of credentials provided by the model provider.",
"modelProvider.quota": "Quota",
"modelProvider.quota": "AI Credits",
"modelProvider.quotaTip": "Remaining available free tokens",
"modelProvider.rerankModel.key": "Rerank Model",
"modelProvider.rerankModel.tip": "Rerank model will reorder the candidate document list based on the semantic match with user query, improving the results of semantic ranking",

View File

@@ -343,15 +343,15 @@
"modelProvider.card.buyQuota": "购买额度",
"modelProvider.card.callTimes": "调用次数",
"modelProvider.card.modelAPI": "{{modelName}} 模型正在使用 API Key。",
"modelProvider.card.modelNotSupported": "{{modelName}} 模型未安装",
"modelProvider.card.modelNotSupported": "{{modelName}} 未安装",
"modelProvider.card.modelSupported": "{{modelName}} 模型正在使用此额度。",
"modelProvider.card.onTrial": "试用中",
"modelProvider.card.paid": "已购买",
"modelProvider.card.priorityUse": "优先使用",
"modelProvider.card.quota": "额度",
"modelProvider.card.quotaExhausted": "额已用",
"modelProvider.card.quotaExhausted": "额已用",
"modelProvider.card.removeKey": "删除 API 密钥",
"modelProvider.card.tip": "消息额度支持使用 {{modelNames}} 的模型;免费额度会在付费额度用尽后才会消耗。",
"modelProvider.card.tip": "AI Credits 支持使用 {{modelNames}} 的模型;试用额度会在付费额度用尽后才会消耗。",
"modelProvider.card.tokens": "Tokens",
"modelProvider.collapse": "收起",
"modelProvider.config": "配置",
@@ -397,7 +397,7 @@
"modelProvider.priorityUsing": "优先使用",
"modelProvider.providerManaged": "由模型供应商管理",
"modelProvider.providerManagedDescription": "使用模型供应商提供的单组凭据",
"modelProvider.quota": "额度",
"modelProvider.quota": "AI Credits",
"modelProvider.quotaTip": "剩余免费额度",
"modelProvider.rerankModel.key": "Rerank 模型",
"modelProvider.rerankModel.tip": "重排序模型将根据候选文档列表与用户问题语义匹配度进行重新排序,从而改进语义排序的结果",