mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-06-13 03:30:10 +08:00
feat(desktop): 后端健康监控韧性 + onboarding 修复 + 全局代理 UI
- useCheckBackend 重写:60s 总超时取代 while(true) 死轮询,订阅 Tauri backend-ready/terminated/startup-timeout 事件,裸 fetch 探测避免 启动期 toast 叠堆 - Tauri lib.rs:spawn 后 HTTP 探针轮询 /api/sys_check 拿 200 才算就绪 (之前 TCP connect 会被孤儿进程误判);RunEvent::Exit 钩子退出前 kill sidecar,修孤儿进程占端口;restart 前发 backend-restarting 让前端忽略主动 kill 引发的 terminated - BackendInitDialog:失败态展示原因 + 最近 stderr + 重启/复制日志按钮 - StartupBanner:收到 restarted/ready 自动清「已退出」横幅 - BackendHealthIndicator:修 /api/api/sys_health 双前缀 404 - Onboarding:step1 后端连通改自动重试 + 事件触发 + 手动按钮;step2 撞预置供应商名时改为更新已存在供应商;errText 统一错误文案 - 全局代理 UI:下载配置页新增代理卡片(services/proxy.ts + ProxyConfig) - request.ts 加 suppressToast 配置位,预期失败不弹全局红 toast - NoteForm/taskStore:捕获就绪门禁错误,引导去音频转写配置页下载 - providerCard:整行可点切换(之前只有 icon 区域响应) - Monitor 页 Whisper 卡显示模型本地下载状态 - tauri/api 升级对齐 2.11,修 vite build 版本不匹配 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,52 +1,156 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import request from '@/utils/request'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
const MAX_RETRIES = 3
|
||||
const RETRY_INTERVAL = 10000 // 10秒
|
||||
// 后端就绪检测的几个时间常量
|
||||
// - 总等待上限 60s:超过这个时间没就绪就切「启动失败」UI,
|
||||
// 不再像旧实现 while(true) 无限转
|
||||
// - 轮询间隔 2s:比旧的 10s 更敏感,桌面端 sidecar 5-15s 解压期内能尽快感知就绪
|
||||
// - 单次请求超时 5s,避免连接 hang 拖到下一轮
|
||||
const TOTAL_TIMEOUT_MS = 60_000
|
||||
const POLL_INTERVAL_MS = 2_000
|
||||
const PROBE_TIMEOUT_MS = 5_000
|
||||
|
||||
export const useCheckBackend = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
|
||||
useEffect(() => {
|
||||
let retries = 0
|
||||
// 直接用 fetch 而非 utils/request 的共享 axios:那个 axios 装了全局 toast 拦截器,
|
||||
// 启动期每次 /sys_check 失败都会弹一个红色 toast,2s 一次轮询会叠出十几个。
|
||||
function getBackendBase(): string {
|
||||
const fromEnv = (import.meta as any).env?.VITE_API_BASE_URL as string | undefined
|
||||
return ((fromEnv && fromEnv.length > 0) ? fromEnv : '/api').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
const check = async () => {
|
||||
try {
|
||||
await request.get('/sys_check')
|
||||
setInitialized(true)
|
||||
setLoading(false)
|
||||
} catch {
|
||||
if (retries === 0) {
|
||||
// 第一次失败时开始显示加载状态
|
||||
setLoading(true)
|
||||
}
|
||||
async function probeSysCheck(): Promise<boolean> {
|
||||
const url = `${getBackendBase()}/sys_check`
|
||||
const ctrl = new AbortController()
|
||||
const t = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetch(url, { signal: ctrl.signal })
|
||||
if (!res.ok) return false
|
||||
const json = await res.json().catch(() => null)
|
||||
return json?.code === 0
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
clearTimeout(t)
|
||||
}
|
||||
}
|
||||
|
||||
if (retries < MAX_RETRIES) {
|
||||
retries++
|
||||
setTimeout(check, RETRY_INTERVAL)
|
||||
} else {
|
||||
// 达到重试上限,继续轮询直到后端就绪
|
||||
waitUntilBackendReady()
|
||||
}
|
||||
}
|
||||
}
|
||||
interface Status {
|
||||
loading: boolean
|
||||
initialized: boolean
|
||||
failed: boolean
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
const waitUntilBackendReady = async () => {
|
||||
while (true) {
|
||||
try {
|
||||
await request.get('/sys_health')
|
||||
setInitialized(true)
|
||||
setLoading(false)
|
||||
break
|
||||
} catch {
|
||||
await new Promise(res => setTimeout(res, RETRY_INTERVAL))
|
||||
}
|
||||
}
|
||||
}
|
||||
interface BackendCheck extends Status {
|
||||
retry: () => void
|
||||
}
|
||||
|
||||
check()
|
||||
const initialStatus: Status = {
|
||||
loading: true,
|
||||
initialized: false,
|
||||
failed: false,
|
||||
lastError: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端就绪检测。
|
||||
*
|
||||
* 三路信号汇聚:
|
||||
* 1. HTTP 轮询 /sys_check —— 所有平台通用
|
||||
* 2. Tauri 'backend-ready' 事件 —— 桌面端 sidecar 探测器先于 HTTP 一步触达
|
||||
* 3. Tauri 'backend-terminated' / 'backend-startup-timeout' 事件 —— sidecar 死了或超时
|
||||
* 立即进失败态,不再继续轮询(旧实现的 while(true) 就是死在这里)
|
||||
*
|
||||
* 任何一路报「ready」即成功;任何一路报「失败」立即停掉所有轮询。
|
||||
*/
|
||||
export const useCheckBackend = (): BackendCheck => {
|
||||
const [status, setStatus] = useState<Status>(initialStatus)
|
||||
// tick 用来强制重启 useEffect(retry 时 +1),不引入 ref 互斥逻辑的复杂性
|
||||
const [tick, setTick] = useState(0)
|
||||
// 标记当前 effect 是否已 settle(避免后到的事件覆盖已确定的成功/失败态)
|
||||
const settledRef = useRef(false)
|
||||
|
||||
const retry = useCallback(() => {
|
||||
settledRef.current = false
|
||||
setStatus(initialStatus)
|
||||
setTick((t) => t + 1)
|
||||
}, [])
|
||||
|
||||
return { loading, initialized }
|
||||
}
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let pollTimerId: ReturnType<typeof setTimeout> | null = null
|
||||
let cancelled = false
|
||||
const tauriUnsubs: Array<() => void> = []
|
||||
|
||||
const markReady = () => {
|
||||
if (cancelled || settledRef.current) return
|
||||
settledRef.current = true
|
||||
setStatus({ loading: false, initialized: true, failed: false, lastError: null })
|
||||
}
|
||||
|
||||
const markFailed = (msg: string) => {
|
||||
if (cancelled || settledRef.current) return
|
||||
settledRef.current = true
|
||||
setStatus({ loading: false, initialized: false, failed: true, lastError: msg })
|
||||
}
|
||||
|
||||
const poll = async () => {
|
||||
if (cancelled || settledRef.current) return
|
||||
const ok = await probeSysCheck()
|
||||
if (cancelled || settledRef.current) return
|
||||
if (ok) {
|
||||
markReady()
|
||||
return
|
||||
}
|
||||
// 单次失败不报 toast、不抛错,继续轮询
|
||||
setStatus((s) => ({ ...s, lastError: '后端尚未响应' }))
|
||||
pollTimerId = setTimeout(poll, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
// 总超时兜底
|
||||
timeoutId = setTimeout(() => {
|
||||
markFailed(`后端 ${TOTAL_TIMEOUT_MS / 1000}s 内未就绪,请检查后端日志或重启`)
|
||||
}, TOTAL_TIMEOUT_MS)
|
||||
|
||||
// 桌面端订阅 Tauri 事件(动态 import 避免 web 端打包报错)
|
||||
if (isTauri) {
|
||||
import('@tauri-apps/api/event')
|
||||
.then(async ({ listen }) => {
|
||||
if (cancelled) return
|
||||
const offReady = await listen<number>('backend-ready', () => markReady())
|
||||
const offTimeout = await listen<string>('backend-startup-timeout', (e) => {
|
||||
markFailed(typeof e.payload === 'string' ? e.payload : '后端启动超时')
|
||||
})
|
||||
const offTerm = await listen<number | null>('backend-terminated', (e) => {
|
||||
const code = e.payload
|
||||
markFailed(`后端进程已退出 (code=${code ?? 'unknown'})`)
|
||||
})
|
||||
tauriUnsubs.push(offReady, offTimeout, offTerm)
|
||||
})
|
||||
.catch((err) => {
|
||||
// 拿不到 @tauri-apps/api/event 不致命,继续走 HTTP 轮询
|
||||
console.warn('[useCheckBackend] 无法订阅 Tauri 事件:', err)
|
||||
})
|
||||
}
|
||||
|
||||
// 立刻开始第一轮轮询
|
||||
poll()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
if (pollTimerId) clearTimeout(pollTimerId)
|
||||
tauriUnsubs.forEach((off) => {
|
||||
try {
|
||||
off()
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [tick])
|
||||
|
||||
return { ...status, retry }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user