Files
vf_react/src/components/ErrorPage/index.tsx
zdl fa6b7edae9 pref: ErrorPage 功能增强
ErrorPage 新增功能:
 - 浮动动画效果 (keyframes)
 - 可配置错误原因列表 (reasons prop)
 - 技术详情折叠面板 (techDetails prop)
 - 可选搜索功能 (search prop)
 - 更丰富的导航选项
2025-12-05 14:34:03 +08:00

441 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ErrorPage - 通用错误页面组件
* 用于显示加载失败、网络错误、404等异常状态
* 设计风格:黑色背景 + 金色边框
*/
import React from 'react';
import {
Box,
Text,
Button,
VStack,
HStack,
Collapse,
useDisclosure,
} from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
import { ExclamationCircleOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { trackEventAsync } from '@/lib/posthog';
// 主题色(保持原来的配色)
const GOLD_COLOR = '#D4A574';
const BG_COLOR = '#1A202C';
// 错误原因项配置
export interface ErrorReasonItem {
/** 图标emoji 或自定义组件) */
icon: string | React.ReactNode;
/** 原因标题 */
title: string;
/** 原因描述 */
description: string;
}
// 操作按钮配置
export interface ActionButton {
/** 按钮文本 */
label: string;
/** 按钮图标(可选,放在文本前) */
icon?: string;
/** 按钮类型primary主要、secondary次要、outline轮廓 */
variant?: 'primary' | 'secondary' | 'outline';
/** 点击回调 */
onClick?: () => void;
/** 跳转链接(与 onClick 二选一) */
href?: string;
}
// 技术详情配置
export interface TechDetails {
/** 请求 URL */
requestUrl?: string;
/** 错误类型 */
errorType?: string;
/** 错误信息 */
errorMessage?: string;
/** 时间戳 */
timestamp?: string;
/** 相关 ID */
relatedId?: string;
/** 自定义字段 */
customFields?: Record<string, string>;
}
// 完整的 ErrorPage 配置
export interface ErrorPageProps {
// ===== 基础配置 =====
/** 错误标题 */
title?: string;
/** 错误副标题(如显示错误 ID */
subtitle?: string;
/** 错误描述信息 */
description?: string;
// ===== 详细信息 =====
/** 详细信息值 */
detail?: string;
/** 详细信息标签 */
detailLabel?: string;
// ===== 错误原因列表 =====
/** 错误原因列表 */
reasons?: ErrorReasonItem[];
// ===== 技术详情 =====
/** 技术详情(可展开查看) */
techDetails?: TechDetails;
// ===== 操作按钮 =====
/** 自定义操作按钮列表 */
actions?: ActionButton[];
/** 快捷配置:是否显示重试按钮 */
showRetry?: boolean;
/** 重试回调 */
onRetry?: () => void;
/** 快捷配置:是否显示返回按钮 */
showBack?: boolean;
/** 返回回调 */
onBack?: () => void;
/** 快捷配置:是否显示返回首页按钮 */
showHome?: boolean;
/** 首页路径 */
homePath?: string;
// ===== 布局配置 =====
/** 是否全屏显示 */
fullScreen?: boolean;
/** 最大宽度 */
maxWidth?: string;
// ===== 网络状态 =====
/** 是否检查网络状态并显示离线提示 */
checkOffline?: boolean;
// ===== 错误上报 =====
/** 是否启用内置 PostHog 错误上报(默认 true */
enableBuiltInReport?: boolean;
/** 自定义错误上报回调(可选,与内置上报同时生效) */
onErrorReport?: (errorInfo: Record<string, unknown>) => void;
}
// 默认错误原因
const DEFAULT_REASONS: ErrorReasonItem[] = [
{
icon: '🔍',
title: 'ID 可能输入错误',
description: '请检查 URL 中的 ID 是否正确',
},
{
icon: '🗑️',
title: '内容可能已被删除',
description: '该内容可能因过期或调整而被下架',
},
{
icon: '🔄',
title: '系统暂时无法访问',
description: '请稍后重试或联系技术支持',
},
];
const ErrorPage: React.FC<ErrorPageProps> = ({
title = '加载失败',
subtitle,
description = '我们无法找到您请求的内容,这可能是因为:',
detail,
detailLabel = 'ID',
reasons = DEFAULT_REASONS,
techDetails,
actions,
showRetry = false,
onRetry,
showBack = false,
onBack,
showHome = false,
homePath = '/',
fullScreen = true,
maxWidth = '500px',
checkOffline = true,
enableBuiltInReport = true,
onErrorReport,
}) => {
const navigate = useNavigate();
const { isOpen: isTechOpen, onToggle: onTechToggle } = useDisclosure();
const [isOffline, setIsOffline] = React.useState(!navigator.onLine);
// 监听网络状态
React.useEffect(() => {
if (!checkOffline) return;
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, [checkOffline]);
// 错误上报
React.useEffect(() => {
const errorInfo = {
error_title: title,
error_detail: detail,
error_type: techDetails?.errorType,
error_message: techDetails?.errorMessage,
page_url: window.location.href,
referrer: document.referrer,
user_agent: navigator.userAgent,
event_id: techDetails?.relatedId,
};
// 内置 PostHog 上报(异步,不阻塞渲染)
if (enableBuiltInReport) {
trackEventAsync('error_page_view', errorInfo);
}
// 自定义上报回调(保持兼容)
if (onErrorReport) {
onErrorReport({
...errorInfo,
timestamp: new Date().toISOString(),
...techDetails,
});
}
}, [enableBuiltInReport, onErrorReport, title, detail, techDetails]);
// 构建操作按钮列表
const buildActionButtons = (): ActionButton[] => {
if (actions) return actions;
const buttons: ActionButton[] = [];
if (showBack) {
buttons.push({
label: '返回',
variant: 'outline',
onClick: onBack || (() => window.history.back()),
});
}
if (showRetry && onRetry) {
buttons.push({
label: '重试',
variant: 'primary',
onClick: onRetry,
});
}
if (showHome) {
buttons.push({
label: '返回首页',
variant: 'outline',
onClick: () => navigate(homePath),
});
}
return buttons;
};
// 获取按钮样式(保持原来的金色风格)
const getButtonStyle = (variant: ActionButton['variant']) => {
switch (variant) {
case 'primary':
return {
bg: GOLD_COLOR,
color: BG_COLOR,
border: '1px solid',
borderColor: GOLD_COLOR,
_hover: { bg: '#C49A6C' },
};
case 'outline':
default:
return {
variant: 'outline' as const,
borderColor: GOLD_COLOR,
color: GOLD_COLOR,
_hover: { bg: GOLD_COLOR, color: 'black' },
};
}
};
const actionButtons = buildActionButtons();
const hasButtons = actionButtons.length > 0;
return (
<Box
h={fullScreen ? '100vh' : '60vh'}
w="100%"
display="flex"
alignItems="center"
justifyContent="center"
>
<Box
bg={BG_COLOR}
border="1px solid"
borderColor={GOLD_COLOR}
borderRadius="lg"
p={8}
maxW={maxWidth}
w="90%"
textAlign="center"
>
{/* 金色圆形感叹号图标 */}
<Box mx="auto" mb={4}>
<ExclamationCircleOutlined style={{ fontSize: '40px', color: GOLD_COLOR }} />
</Box>
{/* 金色标题 */}
<Text color={GOLD_COLOR} fontSize="lg" fontWeight="medium" mb={2}>
{title}
</Text>
{/* 副标题ID 显示) */}
{(subtitle || detail) && (
<Text
color="gray.400"
fontSize="sm"
fontFamily="monospace"
mb={2}
>
{subtitle || `${detailLabel}: ${detail}`}
</Text>
)}
{/* 离线提示 */}
{checkOffline && isOffline && (
<Box
bg="orange.900"
border="1px solid"
borderColor="orange.600"
borderRadius="md"
p={2}
mb={4}
>
<Text color="orange.300" fontSize="sm">
线
</Text>
</Box>
)}
{/* 错误原因列表 */}
{reasons.length > 0 && (
<Box
bg="gray.800"
borderRadius="md"
p={4}
mb={4}
textAlign="left"
>
<Text color="gray.400" fontSize="sm" mb={3}>
{description}
</Text>
<VStack spacing={3} align="stretch">
{reasons.map((reason, index) => (
<HStack key={index} spacing={3} align="flex-start">
<Text fontSize="lg" flexShrink={0}>
{typeof reason.icon === 'string' ? reason.icon : reason.icon}
</Text>
<Box>
<Text fontWeight="500" color="gray.300" fontSize="sm">
{reason.title}
</Text>
<Text fontSize="xs" color="gray.500">
{reason.description}
</Text>
</Box>
</HStack>
))}
</VStack>
</Box>
)}
{/* 技术详情(可展开) */}
{techDetails && (
<Box mb={4}>
<Button
variant="ghost"
size="sm"
color="gray.500"
rightIcon={isTechOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
onClick={onTechToggle}
_hover={{ bg: 'transparent', color: 'gray.400' }}
>
</Button>
<Collapse in={isTechOpen}>
<Box
mt={2}
p={3}
bg="gray.800"
borderRadius="md"
fontFamily="monospace"
fontSize="xs"
color="gray.500"
textAlign="left"
overflowX="auto"
>
{techDetails.requestUrl && (
<Text>URL: {techDetails.requestUrl}</Text>
)}
{techDetails.errorType && (
<Text>: {techDetails.errorType}</Text>
)}
{techDetails.errorMessage && (
<Text>: {techDetails.errorMessage}</Text>
)}
{techDetails.timestamp && (
<Text>: {techDetails.timestamp}</Text>
)}
{techDetails.relatedId && (
<Text>ID: {techDetails.relatedId}</Text>
)}
{techDetails.customFields &&
Object.entries(techDetails.customFields).map(([key, value]) => (
<Text key={key}>
{key}: {value}
</Text>
))}
</Box>
</Collapse>
</Box>
)}
{/* 按钮组 */}
{hasButtons && (
<HStack justify="center" spacing={3} mt={4}>
{actionButtons.map((btn, index) => (
<Button
key={index}
size="sm"
px={6}
fontWeight="medium"
{...getButtonStyle(btn.variant)}
onClick={btn.href ? () => navigate(btn.href!) : btn.onClick}
>
{btn.icon && <Text as="span" mr={2}>{btn.icon}</Text>}
{btn.label}
</Button>
))}
</HStack>
)}
{/* 底部帮助提示 */}
<Text fontSize="xs" color="gray.500" mt={6}>
<Text as="span" color={GOLD_COLOR} fontWeight="medium">
</Text>
</Text>
</Box>
</Box>
);
};
export default ErrorPage;