aps-agent/apps/web/src/auth/InterfaceGate.tsx

65 lines
2.2 KiB
TypeScript
Raw Normal View History

import { ReloadOutlined } from '@ant-design/icons';
import { Button, Result } from 'antd';
import { type ReactNode, useCallback, useEffect, useState } from 'react';
import { clientFetch } from '../api/client';
import { INTERFACE_VERSION } from '../api/types';
type GateState =
| { status: 'loading' }
| { status: 'ready' }
| { status: 'unavailable'; message: string }
| { status: 'incompatible'; serverVersion: string };
export default function InterfaceGate(props: { children: ReactNode }) {
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<GateState>({ status: 'loading' });
const retry = useCallback(() => {
setState({ status: 'loading' });
setAttempt(value => value + 1);
}, []);
useEffect(() => {
let cancelled = false;
void clientFetch('/api/health')
.then(async response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const handshake = await response.json() as { interfaceVersion?: string };
if (cancelled) return;
if (handshake.interfaceVersion !== INTERFACE_VERSION) {
setState({
status: 'incompatible',
serverVersion: handshake.interfaceVersion || 'missing',
});
return;
}
setState({ status: 'ready' });
})
.catch(error => {
if (!cancelled) {
setState({ status: 'unavailable', message: error instanceof Error ? error.message : String(error) });
}
});
return () => { cancelled = true; };
}, [attempt]);
if (state.status === 'ready') return <>{props.children}</>;
if (state.status === 'loading') return <main className="workspace-boot"><span /></main>;
const incompatible = state.status === 'incompatible';
const detail = incompatible
? `客户端接口 ${INTERFACE_VERSION},服务端接口 ${state.serverVersion}`
: `健康检查失败:${state.message}`;
return (
<main className="workspace-boot">
<Result
status="error"
title={incompatible ? '客户端与服务端版本不兼容' : '服务暂不可用'}
subTitle={detail}
extra={<Button icon={<ReloadOutlined />} onClick={retry}>重试</Button>}
/>
</main>
);
}