154 lines
6.3 KiB
TypeScript
154 lines
6.3 KiB
TypeScript
import { IconAlertTriangle } from '@douyinfe/semi-icons';
|
|
import { Card, Tag, Typography } from '@douyinfe/semi-ui';
|
|
import { Component, type ElementType, type ErrorInfo, type ReactNode, Suspense, useCallback, useMemo, useState } from 'react';
|
|
import { useLocation } from 'react-router-dom';
|
|
import { PageLoading } from '../shared/AsyncState';
|
|
import { RecoveryActions } from '../shared/RecoveryActions';
|
|
|
|
const { Title, Text } = Typography;
|
|
const CHUNK_RELOAD_KEY = 'vehicle-platform:route-chunk-reload';
|
|
const CHUNK_RELOAD_COOLDOWN_MS = 2 * 60_000;
|
|
type RecoveryTone = 'timeout' | 'update' | 'runtime' | 'shell';
|
|
|
|
function RouteRecoveryView({
|
|
tone,
|
|
eyebrow,
|
|
title,
|
|
description,
|
|
error,
|
|
children,
|
|
root = false
|
|
}: {
|
|
tone: RecoveryTone;
|
|
eyebrow: string;
|
|
title: string;
|
|
description: string;
|
|
error: Error;
|
|
children: ReactNode;
|
|
root?: boolean;
|
|
}) {
|
|
const tagColor = tone === 'update' ? 'blue' : tone === 'timeout' ? 'orange' : 'red';
|
|
return <section className={`v2-route-error is-${tone}${root ? ' v2-root-error' : ''}`} role="alert">
|
|
<Card className="v2-route-error-card" bodyStyle={{ padding: 0 }}>
|
|
<div className="v2-route-error-content">
|
|
<header>
|
|
<span className="v2-route-error-icon"><IconAlertTriangle /></span>
|
|
<Tag color={tagColor}>{eyebrow}</Tag>
|
|
</header>
|
|
<div className="v2-route-error-copy">
|
|
<Title heading={2}>{title}</Title>
|
|
<Text type="secondary">{description}</Text>
|
|
</div>
|
|
<details className="v2-route-error-detail">
|
|
<summary>查看技术信息</summary>
|
|
<code>{error.message || error.name}</code>
|
|
</details>
|
|
{children}
|
|
</div>
|
|
</Card>
|
|
</section>;
|
|
}
|
|
|
|
export function isRouteChunkError(error: unknown) {
|
|
const message = error instanceof Error ? `${error.name} ${error.message}` : String(error);
|
|
return /ChunkLoadError|RouteChunkLoadTimeoutError|Route chunk load timed out|Loading chunk .+ failed|Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module/i.test(message);
|
|
}
|
|
|
|
function isRouteChunkTimeout(error: unknown) {
|
|
return error instanceof Error && (error.name === 'RouteChunkLoadTimeoutError' || /Route chunk load timed out/i.test(error.message));
|
|
}
|
|
|
|
function canAutoReload(routeKey: string) {
|
|
try {
|
|
const marker = JSON.parse(window.sessionStorage.getItem(CHUNK_RELOAD_KEY) ?? 'null') as { routeKey?: string; at?: number } | null;
|
|
return !marker || marker.routeKey !== routeKey || Date.now() - (marker.at ?? 0) > CHUNK_RELOAD_COOLDOWN_MS;
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function rememberAutoReload(routeKey: string) {
|
|
try {
|
|
window.sessionStorage.setItem(CHUNK_RELOAD_KEY, JSON.stringify({ routeKey, at: Date.now() }));
|
|
} catch {
|
|
// Recovery must still work when storage is unavailable.
|
|
}
|
|
}
|
|
|
|
class RecoverableRouteBoundary extends Component<{ children: ReactNode; routeKey: string; retryKey: number; onRetry: () => void }, { error?: Error }> {
|
|
state: { error?: Error } = {};
|
|
|
|
static getDerivedStateFromError(error: Error) {
|
|
return { error };
|
|
}
|
|
|
|
componentDidCatch(error: Error, _info: ErrorInfo) {
|
|
if (isRouteChunkError(error) && canAutoReload(this.props.routeKey)) {
|
|
rememberAutoReload(this.props.routeKey);
|
|
window.location.reload();
|
|
}
|
|
}
|
|
|
|
componentDidUpdate(previous: Readonly<{ children: ReactNode; routeKey: string; retryKey: number; onRetry: () => void }>) {
|
|
if (this.state.error && (previous.routeKey !== this.props.routeKey || previous.retryKey !== this.props.retryKey)) {
|
|
this.setState({ error: undefined });
|
|
}
|
|
}
|
|
|
|
render() {
|
|
const { error } = this.state;
|
|
if (!error) return this.props.children;
|
|
const chunkError = isRouteChunkError(error);
|
|
const chunkTimeout = isRouteChunkTimeout(error);
|
|
const tone: RecoveryTone = chunkTimeout ? 'timeout' : chunkError ? 'update' : 'runtime';
|
|
return <RouteRecoveryView
|
|
tone={tone}
|
|
eyebrow={chunkTimeout ? '页面资源加载超时' : chunkError ? '检测到页面版本更新' : '页面运行异常'}
|
|
title={chunkTimeout ? '当前模块没有在预期时间内加载完成' : chunkError ? '正在等待加载最新页面资源' : '当前模块暂时无法显示'}
|
|
description={chunkTimeout ? '系统已等待并尝试恢复,可能是网络暂时不稳定。刷新后会重新加载当前模块,不会丢失服务端数据。' : chunkError ? '通常是发布后旧标签页仍引用上一版本资源。刷新后会恢复,不会丢失服务端数据。' : '页面已被安全隔离,侧栏和其他模块仍可使用。更换筛选条件会自动重试,也可刷新当前页面。'}
|
|
error={error}
|
|
>
|
|
<RecoveryActions
|
|
primaryLabel="重试加载模块"
|
|
onPrimary={this.props.onRetry}
|
|
secondaryLabel="刷新当前页面"
|
|
onSecondary={() => window.location.reload()}
|
|
/>
|
|
</RouteRecoveryView>;
|
|
}
|
|
}
|
|
|
|
export class PlatformErrorBoundary extends Component<{ children: ReactNode }, { error?: Error }> {
|
|
state: { error?: Error } = {};
|
|
|
|
static getDerivedStateFromError(error: Error) {
|
|
return { error };
|
|
}
|
|
|
|
render() {
|
|
const { error } = this.state;
|
|
if (!error) return this.props.children;
|
|
return <RouteRecoveryView
|
|
tone="shell"
|
|
eyebrow="平台外壳运行异常"
|
|
title="工作台暂时无法继续显示"
|
|
description="系统已阻止异常扩散。请重新加载最新版本;服务端数据不会因页面恢复操作而改变。"
|
|
error={error}
|
|
root
|
|
>
|
|
<RecoveryActions primaryLabel="重新加载平台" onPrimary={() => window.location.reload()} />
|
|
</RouteRecoveryView>;
|
|
}
|
|
}
|
|
|
|
export function RoutePage({ page, recreatePage, label }: { page: ElementType; recreatePage?: () => ElementType; label: string }) {
|
|
const location = useLocation();
|
|
const routeKey = `${location.pathname}${location.search}`;
|
|
const [retryKey, setRetryKey] = useState(0);
|
|
const Page = useMemo(() => retryKey && recreatePage ? recreatePage() : page, [page, recreatePage, retryKey]);
|
|
const retry = useCallback(() => setRetryKey((value) => value + 1), []);
|
|
return <RecoverableRouteBoundary key={location.pathname} routeKey={routeKey} retryKey={retryKey} onRetry={retry}>
|
|
<Suspense fallback={<PageLoading label={`正在加载${label}`} />}><Page key={retryKey} /></Suspense>
|
|
</RecoverableRouteBoundary>;
|
|
}
|