VetNet-LCPS / vetcopilot-app /src /components /ConnectionStatusBar.tsx
thomasm291678
VetCopilot-LCPS: LCPS 2.0 本地推理引擎替换 DeepSeek API
793e953
Raw History Blame Contribute Delete
8.74 kB
import React, { useEffect, useState, useCallback } from 'react'
import * as api from '../api'
import type { SystemStatus, ConnectionState } from '../api'
import { useTranslation } from '../i18n'
import { useDateFormatter } from '../utils/dateUtils'
interface ConnectionStatusBarProps {
pollIntervalMs?: number
className?: string
}
type ConnStatus = 'ready' | 'warning' | 'error' | 'unknown'
interface Snapshot {
xunfei: ConnStatus
xunfeiMessage: string
xunfeiLabel: string
deepseek: ConnStatus
deepseekMessage: string
whisper: ConnStatus
whisperMessage: string
asrActiveEngine: string
}
/** 将 ConnectionState 映射到视觉状态 */
function connStateToDot(state: ConnectionState): { dot: string; bg: string; text: string } {
switch (state) {
case 'online':
return { dot: 'bg-green-500', bg: 'bg-green-50 border-green-200', text: 'text-green-700' }
case 'timeout':
return { dot: 'bg-yellow-500', bg: 'bg-yellow-50 border-yellow-200', text: 'text-yellow-700' }
case 'auth_error':
return { dot: 'bg-yellow-500', bg: 'bg-yellow-50 border-yellow-200', text: 'text-yellow-700' }
case 'offline':
return { dot: 'bg-red-500', bg: 'bg-red-50 border-red-200', text: 'text-red-700' }
case 'unconfigured':
return { dot: 'bg-gray-400', bg: 'bg-gray-50 border-gray-200', text: 'text-gray-600' }
default:
return { dot: 'bg-gray-400', bg: 'bg-gray-50 border-gray-200', text: 'text-gray-600' }
}
}
const colorMap: Record<ConnStatus, { dot: string; bg: string; text: string }> = {
ready: { dot: 'bg-green-500', bg: 'bg-green-50 border-green-200', text: 'text-green-700' },
warning: { dot: 'bg-yellow-500', bg: 'bg-yellow-50 border-yellow-200', text: 'text-yellow-700' },
error: { dot: 'bg-red-500', bg: 'bg-red-50 border-red-200', text: 'text-red-700' },
unknown: { dot: 'bg-gray-400', bg: 'bg-gray-50 border-gray-200', text: 'text-gray-600' },
}
function summarize(h: Awaited<ReturnType<typeof api.getDetailedHealth>>, t: (key: string) => string): Snapshot {
return {
xunfei: (h.xunfei?.status as ConnStatus) || 'unknown',
xunfeiMessage: h.xunfei?.message || (h.xunfei?.available ? t('components.connection.xunfeiReady') : t('components.connection.xunfeiNotConfigured')),
xunfeiLabel: h.xunfei?.model_label || h.xunfei?.model || t('components.connection.xunfei'),
deepseek: (h.deepseek?.status as ConnStatus) || 'unknown',
deepseekMessage: h.deepseek?.message || (h.deepseek?.available ? t('components.connection.deepseekReady') : t('components.connection.deepseekUnavailable')),
whisper: (h.whisper?.status as ConnStatus) || 'unknown',
whisperMessage: h.whisper?.message || 'Whisper',
asrActiveEngine: h.asr_active_engine || 'xunfei',
}
}
/** 单个服务状态指示器(基于真实连接检测) */
const LivePill: React.FC<{
label: string
state: ConnectionState
detail: string
}> = ({ label, state, detail }) => {
const { t } = useTranslation()
const c = connStateToDot(state)
const isOnline = state === 'online'
const isOffline = state === 'offline'
return (
<div
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-lg border ${c.bg}`}
title={detail}
>
<span
className={`w-2 h-2 rounded-full ${c.dot} ${isOnline ? 'animate-pulse' : ''}`}
/>
<span className={`text-xs font-medium ${c.text}`}>{label}</span>
<span className="text-[10px] text-gray-500 hidden sm:inline">
{isOffline ? t('components.connection.offline') : isOnline ? t('components.connection.online') : state === 'timeout' ? t('components.connection.timeout') : state === 'auth_error' ? t('components.connection.authError') : detail}
</span>
</div>
)
}
const Pill: React.FC<{
label: string
status: ConnStatus
message: string
}> = ({ label, status, message }) => {
const c = colorMap[status]
return (
<div className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-lg border ${c.bg}`}>
<span className={`w-2 h-2 rounded-full ${c.dot} ${status === 'ready' ? 'animate-pulse' : ''}`} />
<span className={`text-xs font-medium ${c.text}`}>{label}</span>
<span className="text-[10px] text-gray-500 hidden sm:inline" title={message}>
{message}
</span>
</div>
)
}
export const ConnectionStatusBar: React.FC<ConnectionStatusBarProps> = ({
pollIntervalMs = 10000,
className = '',
}) => {
const { t } = useTranslation()
const { formatDate, formatDateTime, formatTime } = useDateFormatter()
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
const [lastUpdated, setLastUpdated] = useState<number>(0)
/** 真实连接状态(每 30 秒轮询) */
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null)
const [anyOffline, setAnyOffline] = useState(false)
// 轮询详细健康检查(配置/凭证状态)
useEffect(() => {
let timer: ReturnType<typeof setInterval> | null = null
let cancelled = false
const fetchOnce = async () => {
try {
const h = await api.getDetailedHealth()
if (cancelled) return
setSnapshot(summarize(h, t))
setLastUpdated(Date.now())
} catch (e) {
if (cancelled) return
setSnapshot({
xunfei: 'error',
xunfeiMessage: t('components.connection.backendUnreachable'),
xunfeiLabel: t('components.connection.xunfei'),
deepseek: 'error',
deepseekMessage: t('components.connection.backendUnreachable'),
whisper: 'error',
whisperMessage: t('components.connection.backendUnreachable'),
asrActiveEngine: 'unknown',
})
}
}
fetchOnce()
timer = setInterval(fetchOnce, pollIntervalMs)
return () => {
cancelled = true
if (timer) clearInterval(timer)
}
}, [pollIntervalMs])
// 轮询实时连接状态(每 30 秒)
const fetchSystemStatus = useCallback(async () => {
try {
const s = await api.getSystemStatus()
setSystemStatus(s)
setAnyOffline(
s.xunfei === 'offline' || s.deepseek === 'offline' ||
s.xunfei === 'timeout' || s.deepseek === 'timeout'
)
} catch {
// 后端不可达时不覆盖已有状态
}
}, [])
useEffect(() => {
fetchSystemStatus()
const timer = setInterval(fetchSystemStatus, 30000)
return () => clearInterval(timer)
}, [fetchSystemStatus])
if (!snapshot) {
return (
<div className={`flex items-center gap-2 ${className}`}>
<span className="text-[10px] text-gray-400">{t('components.connection.checking')}</span>
</div>
)
}
return (
<div className={`flex flex-col gap-1 ${className}`}>
{/* 离线警告横幅 */}
{anyOffline && systemStatus && (
<div className="flex items-center gap-2 px-2 py-1 bg-red-50 border border-red-300 rounded text-xs text-red-700">
<span className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
<span>
{!systemStatus.internet
? t('components.connection.networkDisconnected')
: systemStatus.dns === 'fail'
? t('components.connection.dnsFailed')
: t('components.connection.aiServiceUnavailable')}
</span>
</div>
)}
<div className="flex items-center gap-1.5 flex-wrap">
{/* 优先显示实时连接状态 */}
{systemStatus ? (
<>
<LivePill
label={`${t('components.connection.xunfei')} ${snapshot.xunfeiLabel}`}
state={systemStatus.xunfei}
detail={systemStatus.xunfei_detail}
/>
<LivePill
label="DeepSeek"
state={systemStatus.deepseek}
detail={systemStatus.deepseek_detail}
/>
</>
) : (
<>
<Pill
label={`${t('components.connection.xunfei')} ${snapshot.xunfeiLabel}`}
status={snapshot.xunfei}
message={snapshot.xunfeiMessage}
/>
<Pill
label="DeepSeek"
status={snapshot.deepseek}
message={snapshot.deepseekMessage}
/>
</>
)}
<Pill label="Whisper" status={snapshot.whisper} message={snapshot.whisperMessage} />
<span className="text-[10px] text-gray-400 hidden md:inline">
{t('components.connection.activeEngine')}: {snapshot.asrActiveEngine}
{lastUpdated > 0 && (
<> · {t('components.connection.updatedAt')} {formatTime(new Date(lastUpdated))}</>
)}
</span>
</div>
</div>
)
}
export default ConnectionStatusBar