refactor(web): make provider reset event-driven and scope model invalidation

- remove provider-page lifecycle reset effect and handle reset in explicit tab/close actions
- switch account setting tab state to controlled/uncontrolled pattern without sync effect
- use provider-scoped model list queryKey with exact invalidation in credential and model toggle mutations
- update related tests and mocks for new behavior
This commit is contained in:
yyh
2026-03-05 13:28:30 +08:00
parent 5f4ed4c6f6
commit 61e2672b59
8 changed files with 62 additions and 47 deletions

View File

@ -7,6 +7,8 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { ACCOUNT_SETTING_TAB } from './constants'
import AccountSetting from './index'
const mockResetModelProviderListExpanded = vi.fn()
vi.mock('@/context/provider-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/provider-context')>()
return {
@ -47,10 +49,15 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
useDefaultModel: vi.fn(() => ({ data: null, isLoading: false })),
useUpdateDefaultModel: vi.fn(() => ({ trigger: vi.fn() })),
useUpdateModelList: vi.fn(() => vi.fn()),
useInvalidateDefaultModel: vi.fn(() => vi.fn()),
useModelList: vi.fn(() => ({ data: [], isLoading: false })),
useSystemDefaultModelAndModelList: vi.fn(() => [null, vi.fn()]),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/atoms', () => ({
useResetModelProviderListExpanded: () => mockResetModelProviderListExpanded,
}))
vi.mock('@/service/use-datasource', () => ({
useGetDataSourceListAuth: vi.fn(() => ({ data: { result: [] } })),
}))
@ -272,8 +279,10 @@ describe('AccountSetting', () => {
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0])
const closeIcon = document.querySelector('.i-ri-close-line')
const closeButton = closeIcon?.closest('button')
expect(closeButton).not.toBeNull()
fireEvent.click(closeButton!)
// Assert
expect(mockOnCancel).toHaveBeenCalled()

View File

@ -1,6 +1,6 @@
'use client'
import type { AccountSettingTab } from '@/app/components/header/account-setting/constants'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import SearchInput from '@/app/components/base/search-input'
import BillingPage from '@/app/components/billing/billing-page'
@ -20,6 +20,7 @@ import DataSourcePage from './data-source-page-new'
import LanguagePage from './language-page'
import MembersPage from './members-page'
import ModelProviderPage from './model-provider-page'
import { useResetModelProviderListExpanded } from './model-provider-page/atoms'
const iconClassName = `
w-5 h-5 mr-2
@ -41,13 +42,15 @@ type GroupItem = {
export default function AccountSetting({
onCancel,
activeTab = ACCOUNT_SETTING_TAB.MEMBERS,
activeTab,
onTabChange,
}: IAccountSettingProps) {
const [activeMenu, setActiveMenu] = useState<AccountSettingTab>(activeTab)
useEffect(() => {
setActiveMenu(activeTab)
}, [activeTab])
const resetModelProviderListExpanded = useResetModelProviderListExpanded()
const isControlledTab = activeTab !== undefined && !!onTabChange
const [uncontrolledActiveMenu, setUncontrolledActiveMenu] = useState<AccountSettingTab>(activeTab ?? ACCOUNT_SETTING_TAB.MEMBERS)
const activeMenu = isControlledTab
? (activeTab ?? ACCOUNT_SETTING_TAB.MEMBERS)
: uncontrolledActiveMenu
const { t } = useTranslation()
const { enableBilling, enableReplaceWebAppLogo } = useProviderContext()
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
@ -148,10 +151,25 @@ export default function AccountSetting({
const [searchValue, setSearchValue] = useState<string>('')
const handleTabChange = useCallback((tab: AccountSettingTab) => {
if (tab === ACCOUNT_SETTING_TAB.PROVIDER)
resetModelProviderListExpanded()
if (!isControlledTab)
setUncontrolledActiveMenu(tab)
onTabChange?.(tab)
}, [isControlledTab, onTabChange, resetModelProviderListExpanded])
const handleClose = useCallback(() => {
resetModelProviderListExpanded()
onCancel()
}, [onCancel, resetModelProviderListExpanded])
return (
<MenuDialog
show
onClose={onCancel}
onClose={handleClose}
>
<div className="mx-auto flex h-[100vh] max-w-[1048px]">
<div className="flex w-[44px] flex-col border-r border-divider-burn pl-4 pr-6 sm:w-[224px]">
@ -174,8 +192,7 @@ export default function AccountSetting({
)}
title={item.name}
onClick={() => {
setActiveMenu(item.key)
onTabChange?.(item.key)
handleTabChange(item.key)
}}
>
{activeMenu === item.key ? item.activeIcon : item.icon}
@ -195,7 +212,7 @@ export default function AccountSetting({
variant="tertiary"
size="large"
className="px-2"
onClick={onCancel}
onClick={handleClose}
>
<span className="i-ri-close-line h-5 w-5" />
</Button>

View File

@ -8,7 +8,6 @@ import {
import ModelProviderPage from './index'
let mockEnableMarketplace = true
const mockResetModelProviderListExpanded = vi.fn()
const mockQuotaConfig = {
quota_type: CurrentSystemQuotaTypeEnum.free,
@ -68,10 +67,6 @@ vi.mock('./hooks', () => ({
useDefaultModel: (type: string) => mockDefaultModels[type] ?? { data: null, isLoading: false },
}))
vi.mock('./atoms', () => ({
useResetModelProviderListExpanded: () => mockResetModelProviderListExpanded,
}))
vi.mock('./install-from-marketplace', () => ({
default: () => <div data-testid="install-from-marketplace" />,
}))

View File

@ -3,14 +3,13 @@ import type {
} from './declarations'
import type { PluginDetail } from '@/app/components/plugins/types'
import { useDebounce } from 'ahooks'
import { useEffect, useMemo } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { IS_CLOUD_EDITION } from '@/config'
import { useSystemFeaturesQuery } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
import { useCheckInstalled } from '@/service/use-plugins'
import { cn } from '@/utils/classnames'
import { useResetModelProviderListExpanded } from './atoms'
import {
CustomConfigurationStatusEnum,
ModelTypeEnum,
@ -35,7 +34,6 @@ const FixedModelProvider = ['langgenius/openai/openai', 'langgenius/anthropic/an
const ModelProviderPage = ({ searchText }: Props) => {
const debouncedSearchText = useDebounce(searchText, { wait: 500 })
const { t } = useTranslation()
const resetModelProviderListExpanded = useResetModelProviderListExpanded()
const { data: textGenerationDefaultModel, isLoading: isTextGenerationDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textGeneration)
const { data: embeddingsDefaultModel, isLoading: isEmbeddingsDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textEmbedding)
const { data: rerankDefaultModel, isLoading: isRerankDefaultModelLoading } = useDefaultModel(ModelTypeEnum.rerank)
@ -129,11 +127,6 @@ const ModelProviderPage = ({ searchText }: Props) => {
return [filteredConfiguredProviders, filteredNotConfiguredProviders]
}, [configuredProviders, debouncedSearchText, notConfiguredProviders])
useEffect(() => {
resetModelProviderListExpanded()
return resetModelProviderListExpanded
}, [resetModelProviderListExpanded])
return (
<div className="relative -mt-2 pt-1">
<div className={cn('mb-2 flex items-center')}>

View File

@ -35,7 +35,9 @@ vi.mock('@/app/components/base/toast', () => ({
vi.mock('@/service/client', () => ({
consoleQuery: {
modelProviders: {
models: { key: () => ['console', 'modelProviders', 'models'] },
models: {
queryKey: ({ input }: { input: { params: { provider: string } } }) => ['console', 'modelProviders', 'models', input.params.provider],
},
changePreferredProviderType: {
mutationOptions: (opts: Record<string, unknown>) => ({
mutationFn: (...args: unknown[]) => {

View File

@ -41,13 +41,21 @@ const CredentialPanel = ({
const updateModelList = useUpdateModelList()
const updateModelProviders = useUpdateModelProviders()
const state = useCredentialPanelState(provider)
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
input: {
params: {
provider: provider.provider,
},
},
})
const { mutate: changePriority, isPending: isChangingPriority } = useMutation(
consoleQuery.modelProviders.changePreferredProviderType.mutationOptions({
onSuccess: () => {
Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
queryClient.invalidateQueries({
queryKey: consoleQuery.modelProviders.models.key(),
queryKey: modelProviderModelListQueryKey,
exact: true,
refetchType: 'none',
})
updateModelProviders()

View File

@ -34,6 +34,13 @@ const ModelListItem = ({ model, provider, isConfigurable, onChange, onModifyLoad
const { isCurrentWorkspaceManager } = useAppContext()
const queryClient = useQueryClient()
const updateModelList = useUpdateModelList()
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
input: {
params: {
provider: provider.provider,
},
},
})
const toggleModelEnablingStatus = useCallback(async (enabled: boolean) => {
if (enabled)
@ -42,12 +49,13 @@ const ModelListItem = ({ model, provider, isConfigurable, onChange, onModifyLoad
await disableModel(`/workspaces/current/model-providers/${provider.provider}/models/disable`, { model: model.model, model_type: model.model_type })
queryClient.invalidateQueries({
queryKey: consoleQuery.modelProviders.models.key(),
queryKey: modelProviderModelListQueryKey,
exact: true,
refetchType: 'none',
})
updateModelList(model.model_type)
onChange?.(provider.provider)
}, [model.model, model.model_type, onChange, provider.provider, queryClient, updateModelList])
}, [model.model, model.model_type, modelProviderModelListQueryKey, onChange, provider.provider, queryClient, updateModelList])
const { run: debouncedToggleModelEnablingStatus } = useDebounceFn(toggleModelEnablingStatus, { wait: 500 })
@ -66,7 +74,7 @@ const ModelListItem = ({ model, provider, isConfigurable, onChange, onModifyLoad
modelName={model.model}
/>
<ModelName
className="system-md-regular grow text-text-secondary"
className="grow text-text-secondary system-md-regular"
modelItem={model}
showModelType
showMode