diff --git a/desktop/src/components/onboarding/ProviderOnboarding.test.tsx b/desktop/src/components/onboarding/ProviderOnboarding.test.tsx
new file mode 100644
index 0000000..95bed2a
--- /dev/null
+++ b/desktop/src/components/onboarding/ProviderOnboarding.test.tsx
@@ -0,0 +1,92 @@
+import { StrictMode } from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { ProviderPreset } from '../../types/providerPreset'
+
+const providersApiMock = vi.hoisted(() => ({
+ presets: vi.fn(),
+}))
+
+vi.mock('../../api/providers', () => ({
+ providersApi: providersApiMock,
+}))
+
+import { useProviderStore } from '../../stores/providerStore'
+import { ProviderOnboarding } from './ProviderOnboarding'
+
+const dreamfieldPreset: ProviderPreset = {
+ id: 'dreamfield',
+ name: 'DreamField',
+ baseUrl: 'https://example.invalid/api',
+ apiFormat: 'anthropic',
+ defaultModels: {
+ main: 'dreamfield-main',
+ haiku: '',
+ sonnet: '',
+ opus: '',
+ },
+ needsApiKey: true,
+ websiteUrl: 'https://example.invalid',
+}
+
+describe('ProviderOnboarding', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ useProviderStore.setState({
+ providers: [],
+ activeId: null,
+ hasLoadedProviders: true,
+ presets: [],
+ isLoading: false,
+ isPresetsLoading: false,
+ error: null,
+ })
+ })
+
+ it('loads presets once after mounting and advances past the loading screen', async () => {
+ providersApiMock.presets.mockResolvedValue({ presets: [dreamfieldPreset] })
+
+ render(
+
+
+ ,
+ )
+
+ expect(await screen.findByRole('textbox', { name: /^API Key/ })).toBeInTheDocument()
+ expect(providersApiMock.presets).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not refetch presets that are already available', () => {
+ useProviderStore.setState({ presets: [dreamfieldPreset] })
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByRole('textbox', { name: /^API Key/ })).toBeInTheDocument()
+ expect(providersApiMock.presets).not.toHaveBeenCalled()
+ })
+
+ it('shows an error with a retry button when preset loading fails, and recovers on retry', async () => {
+ providersApiMock.presets
+ .mockRejectedValueOnce(new Error('network down'))
+ .mockResolvedValueOnce({ presets: [dreamfieldPreset] })
+
+ render(
+
+
+ ,
+ )
+
+ const retryButton = await screen.findByRole('button', { name: '重试' })
+ expect(screen.getByText(/network down/)).toBeInTheDocument()
+
+ fireEvent.click(retryButton)
+
+ expect(await screen.findByRole('textbox', { name: /^API Key/ })).toBeInTheDocument()
+ expect(providersApiMock.presets).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/desktop/src/components/onboarding/ProviderOnboarding.tsx b/desktop/src/components/onboarding/ProviderOnboarding.tsx
index 81102de..0a900ef 100644
--- a/desktop/src/components/onboarding/ProviderOnboarding.tsx
+++ b/desktop/src/components/onboarding/ProviderOnboarding.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { useProviderStore } from '../../stores/providerStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { Input } from '../shared/Input'
@@ -6,14 +6,30 @@ import { Button } from '../shared/Button'
import { DreamCoderIcon } from '../shared/DreamCoderIcon'
export function ProviderOnboarding() {
- const { presets, createProvider, activateProvider, fetchPresets } = useProviderStore()
+ const { presets, error, isPresetsLoading, createProvider, activateProvider, fetchPresets } = useProviderStore()
const setOnboardingCompleted = useSettingsStore((s) => s.setOnboardingCompleted)
const fetchSettings = useSettingsStore((s) => s.fetchAll)
const [apiKey, setApiKey] = useState('')
const [loading, setLoading] = useState(false)
+ const requestedPresetsRef = useRef(false)
+
+ useEffect(() => {
+ if (presets.length > 0 || requestedPresetsRef.current) return
+ requestedPresetsRef.current = true
+ void fetchPresets()
+ }, [fetchPresets, presets.length])
if (presets.length === 0) {
- fetchPresets()
+ // fetchPresets 失败只会写入 store 的 error,这里必须给出重试入口,
+ // 否则全新 profile 会永远停在加载动画上(issue #40 的遗留场景)。
+ if (error && !isPresetsLoading) {
+ return (
+
+
预设加载失败:{error}
+
+
+ )
+ }
return (