refactor(web): rewrite CredentialPanel with declarative variant-driven state and new ModelAuthDropdown

- Extract useCredentialPanelState hook with discriminated union CardVariant type replacing scattered boolean conditions
- Create ModelAuthDropdown compound component (Popover-based) with UsagePrioritySection, CreditsExhaustedAlert, and ApiKeySection
- Enhance SystemQuotaCard.Label to accept className override for flexible styling
- Add i18n keys for new card states and dropdown content (en-US, zh-Hans)
This commit is contained in:
yyh
2026-03-05 08:33:04 +08:00
parent 77d81aebe8
commit b8b70da9ad
11 changed files with 599 additions and 139 deletions

View File

@ -1,36 +1,40 @@
import type {
ModelProvider,
PreferredProviderTypeEnum,
} from '../declarations'
import type { CardVariant } from './use-credential-panel-state'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback } 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'
import { useCredentialStatus } from '@/app/components/header/account-setting/model-provider-page/model-auth/hooks'
import Indicator from '@/app/components/header/indicator'
import { IS_CLOUD_EDITION } from '@/config'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { consoleQuery } from '@/service/client'
import { changeModelProviderPriority } from '@/service/common'
import { cn } from '@/utils/classnames'
import {
ConfigurationMethodEnum,
CustomConfigurationStatusEnum,
PreferredProviderTypeEnum,
} from '../declarations'
import {
useUpdateModelList,
useUpdateModelProviders,
} from '../hooks'
import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from './index'
import PrioritySelector from './priority-selector'
import PriorityUseTip from './priority-use-tip'
import ModelAuthDropdown from './model-auth-dropdown'
import SystemQuotaCard from './system-quota-card'
import { useTrialCredits } from './use-trial-credits'
import { isDestructiveVariant, useCredentialPanelState } from './use-credential-panel-state'
type CredentialPanelProps = {
provider: ModelProvider
}
const TEXT_LABEL_VARIANTS = new Set<CardVariant>([
'credits-active',
'credits-exhausted',
'no-usage',
'api-required-add',
'api-required-configure',
])
const CredentialPanel = ({
provider,
}: CredentialPanelProps) => {
@ -40,29 +44,12 @@ const CredentialPanel = ({
const queryClient = useQueryClient()
const updateModelList = useUpdateModelList()
const updateModelProviders = useUpdateModelProviders()
const customConfig = provider.custom_configuration
const systemConfig = provider.system_configuration
const priorityUseType = provider.preferred_provider_type
const isCustomConfigured = customConfig.status === CustomConfigurationStatusEnum.active
const configurateMethods = provider.configurate_methods
const {
hasCredential,
authorized,
authRemoved,
current_credential_name,
notAllowedToUse,
} = useCredentialStatus(provider)
const state = useCredentialPanelState(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 handleChangePriority = useCallback(async (key: PreferredProviderTypeEnum) => {
const res = await changeModelProviderPriority({
url: `/workspaces/current/model-providers/${provider.provider}/preferred-provider-type`,
body: {
preferred_provider_type: key,
},
body: { preferred_provider_type: key },
})
if (res.result === 'success') {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
@ -71,105 +58,82 @@ const CredentialPanel = ({
refetchType: 'none',
})
updateModelProviders()
configurateMethods.forEach((method) => {
provider.configurate_methods.forEach((method) => {
if (method === ConfigurationMethodEnum.predefinedModel)
provider.supported_model_types.forEach(modelType => updateModelList(modelType))
})
eventEmitter?.emit({
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
payload: provider.provider,
} as any)
} as { type: string, payload: string })
}
}
const credentialLabel = !hasCredential
? t('modelProvider.auth.unAuthorized', { ns: 'common' })
: authorized
? current_credential_name
: authRemoved
? t('modelProvider.auth.authRemoved', { ns: 'common' })
: ''
}, [provider, notify, t, queryClient, updateModelProviders, updateModelList, eventEmitter])
const color = (authRemoved || !hasCredential)
? 'red'
: notAllowedToUse
? 'gray'
: 'green'
const { variant, credentialName } = state
const isDestructive = isDestructiveVariant(variant)
const isTextLabel = TEXT_LABEL_VARIANTS.has(variant)
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 (
<SystemQuotaCard variant={isDestructive ? 'destructive' : 'default'}>
<SystemQuotaCard.Label className={isTextLabel ? undefined : 'gap-1'}>
{isTextLabel
? <TextLabel variant={variant} />
: <StatusLabel variant={variant} credentialName={credentialName} />}
</SystemQuotaCard.Label>
<SystemQuotaCard.Actions>
<ModelAuthDropdown
provider={provider}
state={state}
onChangePriority={handleChangePriority}
/>
</SystemQuotaCard.Actions>
</SystemQuotaCard>
)
}
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'
return (
<span className={isDestructive ? 'text-text-destructive' : 'text-text-secondary'}>
{t(labelKey, { ns: 'common' })}
</span>
)
}
function StatusLabel({ variant, credentialName }: {
variant: CardVariant
credentialName: string | undefined
}) {
const { t } = useTranslation()
const dotColor = variant === 'api-unavailable' ? 'red' : 'green'
const showWarning = variant === 'api-fallback'
return (
<>
{
provider.provider_credential_schema && (
<div className={cn(
'relative ml-1 w-[120px] shrink-0 rounded-lg border-[0.5px] border-components-panel-border bg-white/[0.18] p-1',
authRemoved && 'border-state-destructive-border bg-state-destructive-hover',
)}
>
<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',
authRemoved && 'text-text-destructive',
)}
title={credentialLabel}
>
{credentialLabel}
</div>
<Indicator className="shrink-0" color={color} />
</div>
<div className="flex items-center gap-0.5">
<ConfigProvider
provider={provider}
/>
{
showPrioritySelector && (
<PrioritySelector
value={priorityUseType}
onSelect={handleChangePriority}
/>
)
}
</div>
{
priorityUseType === PreferredProviderTypeEnum.custom && systemConfig.enabled && (
<PriorityUseTip />
)
}
</div>
)
}
{
showPrioritySelector && !provider.provider_credential_schema && (
<div className="ml-1">
<PrioritySelector
value={priorityUseType}
onSelect={handleChangePriority}
/>
</div>
)
}
<Indicator className="shrink-0" color={dotColor} />
<span
className="truncate text-text-secondary"
title={credentialName}
>
{credentialName}
</span>
{showWarning && (
<span className="i-ri-error-warning-fill h-3 w-3 shrink-0 text-text-warning" />
)}
{variant === 'api-unavailable' && (
<span className="shrink-0 text-text-destructive system-2xs-medium">
{t('modelProvider.card.unavailable', { ns: 'common' })}
</span>
)}
</>
)
}

View File

@ -0,0 +1,88 @@
import type { Credential, CustomModel, ModelProvider } from '../../declarations'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import CredentialItem from '../../model-auth/authorized/credential-item'
type ApiKeySectionProps = {
provider: ModelProvider
credentials: Credential[]
selectedCredentialId: string | undefined
onItemClick: (credential: Credential, model?: CustomModel) => void
onEdit: (credential?: Credential) => void
onDelete: (credential?: Credential) => void
onAdd: () => void
}
function ApiKeySection({
provider,
credentials,
selectedCredentialId,
onItemClick,
onEdit,
onDelete,
onAdd,
}: ApiKeySectionProps) {
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>
</div>
{!notAllowCustomCredential && (
<Button
onClick={onAdd}
className="mt-1 w-full"
>
{t('modelProvider.auth.addApiKey', { ns: 'common' })}
</Button>
)}
</div>
)
}
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>
{!notAllowCustomCredential && (
<div className="border-t border-t-divider-subtle p-2">
<Button
onClick={onAdd}
className="w-full"
>
{t('modelProvider.auth.addApiKey', { ns: 'common' })}
</Button>
</div>
)}
</div>
)
}
export default memo(ApiKeySection)

View File

@ -0,0 +1,51 @@
import { useTranslation } from 'react-i18next'
import { formatNumber } from '@/utils/format'
import { useTrialCredits } from '../use-trial-credits'
type CreditsExhaustedAlertProps = {
hasApiKeyFallback: boolean
}
export default function CreditsExhaustedAlert({ hasApiKeyFallback }: CreditsExhaustedAlertProps) {
const { t } = useTranslation()
const { credits } = useTrialCredits()
const totalCredits = 10_000
const titleKey = hasApiKeyFallback
? 'modelProvider.card.creditsExhaustedFallback'
: 'modelProvider.card.creditsExhaustedMessage'
const descriptionKey = hasApiKeyFallback
? 'modelProvider.card.creditsExhaustedFallbackDescription'
: 'modelProvider.card.creditsExhaustedDescription'
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>
</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>
</div>
)
}

View File

@ -0,0 +1,123 @@
import type { Credential, ModelProvider, PreferredProviderTypeEnum } from '../../declarations'
import type { CredentialPanelState } from '../use-credential-panel-state'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { ConfigurationMethodEnum } from '../../declarations'
import { useAuth } from '../../model-auth/hooks'
import ApiKeySection from './api-key-section'
import CreditsExhaustedAlert from './credits-exhausted-alert'
import UsagePrioritySection from './usage-priority-section'
type DropdownContentProps = {
provider: ModelProvider
state: CredentialPanelState
onChangePriority: (key: PreferredProviderTypeEnum) => void
onClose: () => void
}
function DropdownContent({
provider,
state,
onChangePriority,
onClose,
}: DropdownContentProps) {
const { t } = useTranslation()
const {
current_credential_id,
available_credentials,
} = provider.custom_configuration
const {
openConfirmDelete,
closeConfirmDelete,
doingAction,
handleActiveCredential,
handleConfirmDelete,
deleteCredentialId,
handleOpenModal,
} = useAuth(provider, ConfigurationMethodEnum.predefinedModel)
const handleItemClick = useCallback((credential: Credential) => {
handleActiveCredential(credential)
onClose()
}, [handleActiveCredential, onClose])
const handleEdit = useCallback((credential?: Credential) => {
handleOpenModal(credential)
onClose()
}, [handleOpenModal, onClose])
const handleDelete = useCallback((credential?: Credential) => {
if (credential)
openConfirmDelete(credential)
}, [openConfirmDelete])
const handleAdd = useCallback(() => {
handleOpenModal()
onClose()
}, [handleOpenModal, onClose])
const showCreditsAlert = state.isCreditsExhausted && state.supportsCredits
const hasApiKeyFallback = state.variant === 'api-fallback'
|| (state.variant === 'api-active' && state.priority === 'apiKey')
return (
<>
<div className="w-[320px]">
{state.showPrioritySwitcher && (
<UsagePrioritySection
value={state.priority}
onSelect={onChangePriority}
/>
)}
{showCreditsAlert && (
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
)}
<ApiKeySection
provider={provider}
credentials={available_credentials ?? []}
selectedCredentialId={current_credential_id}
onItemClick={handleItemClick}
onEdit={handleEdit}
onDelete={handleDelete}
onAdd={handleAdd}
/>
</div>
<AlertDialog
open={!!deleteCredentialId}
onOpenChange={(open) => {
if (!open)
closeConfirmDelete()
}}
>
<AlertDialogContent>
<div className="p-6 pb-0">
<AlertDialogTitle className="text-text-primary system-xl-semibold">
{t('modelProvider.confirmDelete', { ns: 'common' })}
</AlertDialogTitle>
<AlertDialogDescription className="mt-1 text-text-secondary system-sm-regular" />
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={doingAction}>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton disabled={doingAction} onClick={handleConfirmDelete}>
{t('operation.delete', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default memo(DropdownContent)

View File

@ -0,0 +1,78 @@
import type { ModelProvider, PreferredProviderTypeEnum } from '../../declarations'
import type { CardVariant, CredentialPanelState } from '../use-credential-panel-state'
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import DropdownContent from './dropdown-content'
type ModelAuthDropdownProps = {
provider: ModelProvider
state: CredentialPanelState
onChangePriority: (key: PreferredProviderTypeEnum) => void
}
const ACCENT_VARIANTS = new Set<CardVariant>([
'api-required-add',
'api-required-configure',
])
function getButtonConfig(variant: CardVariant, hasCredentials: boolean, t: (key: string, opts?: Record<string, string>) => string) {
if (ACCENT_VARIANTS.has(variant)) {
return {
text: variant === 'api-required-add'
? t('modelProvider.auth.addApiKey', { ns: 'common' })
: t('operation.config', { ns: 'common' }),
variant: 'secondary-accent' as const,
}
}
const text = hasCredentials
? t('operation.config', { ns: 'common' })
: t('modelProvider.auth.addApiKey', { ns: 'common' })
return { text, variant: 'secondary' as const }
}
function ModelAuthDropdown({ provider, state, onChangePriority }: ModelAuthDropdownProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleClose = useCallback(() => setOpen(false), [])
const buttonConfig = getButtonConfig(state.variant, state.hasCredentials, t)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<Button
className="flex grow"
size="small"
variant={buttonConfig.variant}
title={buttonConfig.text}
>
<span className="i-ri-equalizer-2-line mr-1 h-3.5 w-3.5 shrink-0" />
<span className="w-0 grow truncate text-left">
{buttonConfig.text}
</span>
</Button>
)}
/>
<PopoverContent placement="bottom-end">
<DropdownContent
provider={provider}
state={state}
onChangePriority={onChangePriority}
onClose={handleClose}
/>
</PopoverContent>
</Popover>
)
}
export default memo(ModelAuthDropdown)

View File

@ -0,0 +1,48 @@
import type { UsagePriority } from '../use-credential-panel-state'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import { PreferredProviderTypeEnum } from '../../declarations'
type UsagePrioritySectionProps = {
value: UsagePriority
onSelect: (key: PreferredProviderTypeEnum) => void
}
const options = [
{ key: PreferredProviderTypeEnum.system, labelKey: 'modelProvider.card.aiCreditsOption' },
{ key: PreferredProviderTypeEnum.custom, labelKey: 'modelProvider.card.apiKeyOption' },
] as const
export default function UsagePrioritySection({ value, onSelect }: UsagePrioritySectionProps) {
const { t } = useTranslation()
const selectedKey = value === 'credits'
? PreferredProviderTypeEnum.system
: PreferredProviderTypeEnum.custom
return (
<div className="border-b border-b-divider-subtle px-3 pb-2 pt-2.5">
<div className="mb-1.5 flex items-center gap-1 text-text-tertiary system-xs-medium">
<span className="i-ri-arrow-up-down-line h-4 w-4 shrink-0" />
<span>{t('modelProvider.card.usagePriority', { ns: 'common' })}</span>
<span className="i-ri-question-line h-3.5 w-3.5 shrink-0 text-text-quaternary" />
</div>
<div className="flex gap-1.5">
{options.map(option => (
<button
key={option.key}
type="button"
className={cn(
'flex-1 rounded-lg px-2 py-1 text-center transition-colors system-xs-medium',
selectedKey === option.key
? 'border-[1.5px] border-components-option-card-option-selected-border bg-components-panel-bg text-text-primary shadow-xs'
: 'border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary hover:bg-components-option-card-option-bg-hover',
)}
onClick={() => onSelect(option.key)}
>
{t(option.labelKey, { ns: 'common' })}
</button>
))}
</div>
</div>
)
}

View File

@ -40,12 +40,12 @@ const SystemQuotaCard = ({
)
}
const Label = ({ children }: { children: ReactNode }) => {
const Label = ({ children, className }: { children: ReactNode, className?: string }) => {
const variant = useContext(VariantContext)
return (
<div className={cn(
'relative z-[1] truncate px-1.5 pt-1 system-xs-medium',
labelVariants[variant],
'relative z-[1] flex items-center gap-1 truncate px-1.5 pt-1 system-xs-medium',
className ?? labelVariants[variant],
)}
>
{children}

View File

@ -0,0 +1,100 @@
import type { ModelProvider } from '../declarations'
import { useCredentialStatus } from '@/app/components/header/account-setting/model-provider-page/model-auth/hooks'
import { IS_CLOUD_EDITION } from '@/config'
import {
CustomConfigurationStatusEnum,
PreferredProviderTypeEnum,
} from '../declarations'
import { useTrialCredits } from './use-trial-credits'
export type UsagePriority = 'credits' | 'apiKey' | 'apiKeyOnly'
export type CardVariant
= | 'credits-active'
| 'credits-exhausted'
| 'no-usage'
| 'api-fallback'
| 'api-active'
| 'api-required-add'
| 'api-required-configure'
| 'api-unavailable'
export type CredentialPanelState = {
variant: CardVariant
priority: UsagePriority
supportsCredits: boolean
showPrioritySwitcher: boolean
hasCredentials: boolean
isCreditsExhausted: boolean
credentialName: string | undefined
credits: number
}
const DESTRUCTIVE_VARIANTS = new Set<CardVariant>([
'credits-exhausted',
'no-usage',
'api-unavailable',
])
export const isDestructiveVariant = (variant: CardVariant) =>
DESTRUCTIVE_VARIANTS.has(variant)
function deriveVariant(
priority: UsagePriority,
isExhausted: boolean,
hasCredential: boolean,
authorized: boolean | undefined,
): CardVariant {
if (priority === 'credits') {
if (!isExhausted)
return 'credits-active'
if (hasCredential && authorized)
return 'api-fallback'
if (hasCredential && !authorized)
return 'no-usage'
return 'credits-exhausted'
}
if (hasCredential && authorized)
return 'api-active'
if (hasCredential && !authorized)
return 'api-unavailable'
return 'api-required-add'
}
export function useCredentialPanelState(provider: ModelProvider): CredentialPanelState {
const { isExhausted, credits } = useTrialCredits()
const {
hasCredential,
authorized,
current_credential_name,
} = useCredentialStatus(provider)
const systemConfig = provider.system_configuration
const customConfig = provider.custom_configuration
const preferredType = provider.preferred_provider_type
const supportsCredits = systemConfig.enabled && IS_CLOUD_EDITION
const isCustomConfigured = customConfig.status === CustomConfigurationStatusEnum.active
const priority: UsagePriority = !supportsCredits
? 'apiKeyOnly'
: preferredType === PreferredProviderTypeEnum.system
? 'credits'
: 'apiKey'
const showPrioritySwitcher = supportsCredits && isCustomConfigured
const variant = deriveVariant(priority, isExhausted, hasCredential, !!authorized)
return {
variant,
priority,
supportsCredits,
showPrioritySwitcher,
hasCredentials: hasCredential,
isCreditsExhausted: isExhausted,
credentialName: current_credential_name,
credits,
}
}