fix: 首次启动模型配置步骤白屏

- ModelCheckStep 的 onDone 引用不稳定导致 useEffect 反复执行 checkModels
- configured 时 return null 导致父容器可见但内容为空(白屏)
- 添加 AppErrorBoundary 防止未捕获错误导致白屏
This commit is contained in:
钟明原 committed 2026-03-17 15:55:15 +08:00
1 parent 44aaa1fef0
commit dcde9f8f71
2 files changed
+74 -11

No files matched your search

+52 -3
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { Component, useCallback, useEffect, useState } from 'react'
import type { ErrorInfo, ReactNode } from 'react'
import { useApp } from '@/store/app-context'
import { AppProvider } from '@/store/app-context'
import { LanguageProvider } from '@/i18n'
@@ -55,9 +56,13 @@ function AppRoot() {
if (state.gatewayConnected && !everConnected) setEverConnected(true)
}, [state.gatewayConnected, everConnected])
// 稳定引用,避免 gateway 事件导致 AppRoot re-render 时传给 OnboardingFlow 的 onDone 引用变化,
// 进而触发 ModelCheckStep 内 useEffect 反复执行 checkModels
const handleOnboardingDone = useCallback(() => setOnboardingDone(true), [])
// 新用户:走完整 onboarding(gateway loading → profile setup)
if (!onboardingDone) {
return <OnboardingFlow onDone={() => setOnboardingDone(true)} />
return <OnboardingFlow onDone={handleOnboardingDone} />
}
// 老用户:未连接过 → 显示启动封面(品牌 Logo + 连接进度 + 日志)
@@ -68,6 +73,48 @@ function AppRoot() {
return <MainContent />
}
// ── ErrorBoundary: 防止未捕获的 React 错误导致白屏 ──────────────────────────
interface ErrorBoundaryState {
error: Error | null
}
class AppErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null }
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('[AppErrorBoundary]', error, info.componentStack)
}
render(): ReactNode {
if (this.state.error) {
return (
<div className="h-screen flex items-center justify-center bg-background p-8">
<div className="max-w-md text-center space-y-4">
<h2 className="text-lg font-semibold text-destructive">Something went wrong</h2>
<p className="text-sm text-muted-foreground break-all">{this.state.error.message}</p>
<button
type="button"
onClick={() => {
this.setState({ error: null })
window.location.reload()
}}
className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary/90 transition-colors"
>
Reload
</button>
</div>
</div>
)
}
return this.props.children
}
}
export default function App() {
useAppUpdate()
@@ -75,7 +122,9 @@ export default function App() {
<LanguageProvider initialLocale="zh-CN" initialPreference="system">
<TooltipProvider>
<AppProvider>
<AppRoot />
<AppErrorBoundary>
<AppRoot />
</AppErrorBoundary>
<PortConflictDialog />
</AppProvider>
<Toaster />
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
// 剥离 ANSI 颜色/控制转义码,避免日志乱码
const ANSI_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g
@@ -519,6 +519,10 @@ function ModelCheckStep({ onDone, onOpenSettings }: { onDone: () => void; onOpen
const [checking, setChecking] = useState(true)
const [configured, setConfigured] = useState(false)
// 用 ref 持有最新的 onDone,避免 onDone 引用变化触发 useEffect 重复执行
const onDoneRef = useRef(onDone)
onDoneRef.current = onDone
const checkModels = useCallback(async () => {
setChecking(true)
try {
@@ -529,10 +533,13 @@ function ModelCheckStep({ onDone, onOpenSettings }: { onDone: () => void; onOpen
const hasPrimary = !!result.defaults?.primary
if (hasProviders && hasPrimary) {
setConfigured(true)
setChecking(false)
return true
}
}
} catch { /* ignore */ }
} catch (err) {
console.warn('[ModelCheckStep] checkModels failed:', err)
}
setConfigured(false)
setChecking(false)
return false
@@ -540,16 +547,16 @@ function ModelCheckStep({ onDone, onOpenSettings }: { onDone: () => void; onOpen
useEffect(() => {
checkModels().then((ok) => {
if (ok) onDone()
if (ok) onDoneRef.current()
})
}, [checkModels, onDone])
}, [checkModels])
// Re-check when settings dialog closes
const recheck = useCallback(() => {
checkModels().then((ok) => {
if (ok) onDone()
if (ok) onDoneRef.current()
})
}, [checkModels, onDone])
}, [checkModels])
// Expose recheck for parent to call after settings close
useEffect(() => {
@@ -557,7 +564,7 @@ function ModelCheckStep({ onDone, onOpenSettings }: { onDone: () => void; onOpen
return () => { delete (window as unknown as Record<string, unknown>).__modelCheckRecheck }
}, [recheck])
if (checking && !configured) {
if (checking) {
return (
<div className="flex items-center justify-center">
<Loader2 className="h-8 w-8 text-primary animate-spin" />
@@ -565,7 +572,14 @@ function ModelCheckStep({ onDone, onOpenSettings }: { onDone: () => void; onOpen
)
}
if (configured) return null
// 模型已配置时也显示 spinner(onDone 会很快切走整个 onboarding)
if (configured) {
return (
<div className="flex items-center justify-center">
<Loader2 className="h-8 w-8 text-primary animate-spin" />
</div>
)
}
return (
<div className="flex flex-col items-center justify-center gap-6 text-center">