pref: ErrorPage 功能增强

ErrorPage 新增功能:
 - 浮动动画效果 (keyframes)
 - 可配置错误原因列表 (reasons prop)
 - 技术详情折叠面板 (techDetails prop)
 - 可选搜索功能 (search prop)
 - 更丰富的导航选项
This commit is contained in:
zdl
2025-12-05 14:34:03 +08:00
parent 39f14fb148
commit 302acbafe3
3 changed files with 747 additions and 82 deletions

View File

@@ -0,0 +1,335 @@
# ErrorPage 通用错误页面组件
通用错误页面组件用于显示加载失败、网络错误、404 等异常状态。
**设计风格**:黑色背景 (`#1A202C`) + 金色边框 (`#D4A574`)
## 效果预览
```
┌─────────────────────────────────────┐
│ ╭──────────╮ │
│ │ ⚠️ │ (金色圆形) │
│ ╰──────────╯ │
│ │
│ 事件走丢了 │
│ ID: ZXY-101 │
│ │
│ ┌──────────────────────────────┐ │
│ │ 抱歉,我们找不到您请求的事件... │ │
│ │ │ │
│ │ 🔍 事件ID输入错误 │ │
│ │ 请检查URL中的事件ID是否正确 │ │
│ │ │ │
│ │ 🗑️ 该事件已被删除或下架 │ │
│ │ 该事件可能因过期而被移除 │ │
│ │ │ │
│ │ 🔄 系统暂时无法访问该事件 │ │
│ │ 请稍后重试或联系技术支持 │ │
│ │ │ │
│ │ [查看技术信息 ▼] │ │
│ └──────────────────────────────┘ │
│ │
│ [返回] [重试] │
│ │
│ 点击右下角联系客服 │
└─────────────────────────────────────┘
```
## 快速开始
### 基础用法
```tsx
import ErrorPage from '@/components/ErrorPage';
// 最简单的用法 - 使用所有默认配置
<ErrorPage />
// 自定义标题和描述
<ErrorPage
title="事件走丢了"
description="抱歉,我们找不到您请求的事件"
/>
```
### 完整配置示例
```tsx
<ErrorPage
// 基础配置
title="事件走丢了"
subtitle="ID: ZXY-101"
description="抱歉,我们找不到您请求的事件,这可能是因为:"
// 错误原因列表
reasons={[
{
icon: '🔍',
title: '事件ID输入错误',
description: '请检查URL中的事件ID是否正确',
},
{
icon: '🗑️',
title: '该事件已被删除或下架',
description: '该事件可能因过期或内容调整而被移除',
},
{
icon: '🔄',
title: '系统暂时无法访问该事件',
description: '请稍后重试或联系技术支持',
},
]}
// 技术详情(可展开)
techDetails={{
requestUrl: 'http://localhost:3000/event-detail?id=ZXY-101',
errorType: '404 - Event Not Found',
errorMessage: 'Unexpected token...',
timestamp: '2024-01-15 14:30:22',
relatedId: '101',
}}
// 操作按钮
showBack
showRetry
onRetry={() => window.location.reload()}
showHome
homePath="/community"
// 网络状态检查
checkOffline
// 错误上报
onErrorReport={(info) => {
analytics.track('error_page_view', info);
}}
/>
```
## API 参考
### 基础配置
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `title` | `string` | `'加载失败'` | 错误标题 |
| `subtitle` | `string` | - | 错误副标题(如显示错误 ID |
| `description` | `string` | `'我们无法找到您请求的内容,这可能是因为:'` | 错误描述信息 |
| `detail` | `string` | - | 详细信息值(与 subtitle 二选一) |
| `detailLabel` | `string` | `'ID'` | 详细信息标签 |
### 错误原因配置
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `reasons` | `ErrorReasonItem[]` | 默认 3 条 | 错误原因列表 |
**ErrorReasonItem 结构:**
```typescript
interface ErrorReasonItem {
icon: string | React.ReactNode; // 图标emoji 或组件)
title: string; // 原因标题
description: string; // 原因描述
}
```
**默认错误原因:**
```typescript
[
{ icon: '🔍', title: 'ID 可能输入错误', description: '请检查 URL 中的 ID 是否正确' },
{ icon: '🗑️', title: '内容可能已被删除', description: '该内容可能因过期或调整而被下架' },
{ icon: '🔄', title: '系统暂时无法访问', description: '请稍后重试或联系技术支持' },
]
```
### 技术详情配置
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `techDetails` | `TechDetails` | - | 技术详情(可展开查看) |
**TechDetails 结构:**
```typescript
interface TechDetails {
requestUrl?: string; // 请求 URL
errorType?: string; // 错误类型
errorMessage?: string; // 错误信息
timestamp?: string; // 时间戳
relatedId?: string; // 相关 ID
customFields?: Record<string, string>; // 自定义字段
}
```
### 操作按钮配置
#### 快捷配置
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `showBack` | `boolean` | `false` | 是否显示返回按钮 |
| `onBack` | `() => void` | `history.back()` | 返回回调 |
| `showRetry` | `boolean` | `false` | 是否显示重试按钮 |
| `onRetry` | `() => void` | - | 重试回调 |
| `showHome` | `boolean` | `false` | 是否显示返回首页按钮 |
| `homePath` | `string` | `'/'` | 首页路径 |
#### 自定义按钮
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `actions` | `ActionButton[]` | - | 自定义操作按钮列表(覆盖快捷配置) |
**ActionButton 结构:**
```typescript
interface ActionButton {
label: string; // 按钮文本
icon?: string; // 按钮图标(可选)
variant?: 'primary' | 'secondary' | 'outline'; // 按钮类型
onClick?: () => void; // 点击回调
href?: string; // 跳转链接(与 onClick 二选一)
}
```
**示例:**
```tsx
<ErrorPage
actions={[
{ label: '刷新页面', variant: 'primary', onClick: () => location.reload() },
{ label: '返回列表', variant: 'outline', href: '/events' },
]}
/>
```
### 布局配置
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `fullScreen` | `boolean` | `true` | 是否全屏显示 |
| `maxWidth` | `string` | `'500px'` | 最大宽度 |
### 功能增强
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `checkOffline` | `boolean` | `true` | 是否检查网络状态并显示离线提示 |
| `enableBuiltInReport` | `boolean` | `true` | 是否启用内置 PostHog 错误上报 |
| `onErrorReport` | `(errorInfo: Record<string, unknown>) => void` | - | 自定义错误上报回调(与内置上报同时生效) |
**内置错误上报**
组件默认会自动上报错误到 PostHog上报事件名为 `error_page_view`,包含以下数据:
```typescript
{
error_title: string; // 错误标题
error_detail: string; // 详细信息如事件ID
error_type: string; // 错误类型(如 "404 - Event Not Found"
error_message: string; // 错误信息
page_url: string; // 当前页面 URL
referrer: string; // 来源页面
user_agent: string; // 用户代理
event_id: string; // 相关 ID
timestamp: string; // 时间戳(自动添加)
}
```
**禁用内置上报:**
```tsx
<ErrorPage enableBuiltInReport={false} />
```
**自定义上报回调示例:**
```tsx
<ErrorPage
onErrorReport={(errorInfo) => {
// 自定义上报逻辑(与内置 PostHog 上报同时生效)
customAnalytics.track('custom_error_event', errorInfo);
}}
/>
```
## 场景示例
### 1. 事件详情 404 页面
```tsx
<ErrorPage
title="事件走丢了"
subtitle={`ID: ${eventId}`}
description="抱歉,我们找不到您请求的事件,这可能是因为:"
reasons={[
{ icon: '🔍', title: '事件ID输入错误', description: '请检查URL中的事件ID是否正确' },
{ icon: '🗑️', title: '该事件已被删除或下架', description: '该事件可能因过期或内容调整而被移除' },
{ icon: '🔄', title: '系统暂时无法访问该事件', description: '请稍后重试或联系技术支持' },
]}
techDetails={{
requestUrl: window.location.href,
errorType: '404 - Event Not Found',
errorMessage: error.message,
timestamp: new Date().toISOString(),
relatedId: eventId,
}}
showRetry
onRetry={() => window.location.reload()}
showBack
showHome
homePath="/community"
/>
```
### 2. 网络错误页面
```tsx
<ErrorPage
title="网络连接失败"
description="无法连接到服务器,请检查网络后重试"
reasons={[
{ icon: '📶', title: '网络连接中断', description: '请检查您的网络连接是否正常' },
{ icon: '🔧', title: '服务器维护中', description: '服务器可能正在进行维护,请稍后重试' },
]}
showRetry
onRetry={() => window.location.reload()}
/>
```
### 3. 简洁模式(无原因列表)
```tsx
<ErrorPage
title="加载失败"
description="数据加载失败,请重试"
reasons={[]} // 不显示原因列表
fullScreen={false}
maxWidth="400px"
showRetry
onRetry={refetch}
/>
```
## 类型导出
组件导出以下 TypeScript 类型,方便外部使用:
```typescript
import ErrorPage, {
ErrorPageProps,
ErrorReasonItem,
ActionButton,
TechDetails,
} from '@/components/ErrorPage';
```
## 设计说明
- **配色**:黑色背景 (`#1A202C`) + 金色边框/按钮 (`#D4A574`)
- **图标**:金色圆形背景 (50px) + 黑色感叹号
- **布局**:居中卡片式布局,最大宽度 500px
- **底部提示**"点击右下角联系客服"(纯文本,无链接)

View File

@@ -1,59 +1,270 @@
/** /**
* ErrorPage - 通用错误页面组件 * ErrorPage - 通用错误页面组件
* 用于显示加载失败、网络错误等异常状态 * 用于显示加载失败、网络错误、404等异常状态
* 设计风格:黑色背景 + 金色边框 * 设计风格:黑色背景 + 金色边框
*/ */
import React from 'react'; import React from 'react';
import { import {
Box, Box,
Center,
Circle,
Text, Text,
Button, Button,
VStack, VStack,
HStack, HStack,
Icon, Collapse,
useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { WarningIcon } from '@chakra-ui/icons'; 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 GOLD_COLOR = '#D4A574';
const BG_COLOR = '#1A202C'; // 与页面背景一致 const BG_COLOR = '#1A202C';
interface ErrorPageProps { // 错误原因项配置
/** 错误标题,默认"加载失败" */ 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; title?: string;
/** 错误副标题(如显示错误 ID */
subtitle?: string;
/** 错误描述信息 */ /** 错误描述信息 */
description?: string; description?: string;
/** 详细信息如事件ID、订单号等 */
// ===== 详细信息 =====
/** 详细信息值 */
detail?: string; detail?: string;
/** 详细信息标签,默认"ID" */ /** 详细信息标签 */
detailLabel?: string; detailLabel?: string;
/** 是否显示重试按钮 */
// ===== 错误原因列表 =====
/** 错误原因列表 */
reasons?: ErrorReasonItem[];
// ===== 技术详情 =====
/** 技术详情(可展开查看) */
techDetails?: TechDetails;
// ===== 操作按钮 =====
/** 自定义操作按钮列表 */
actions?: ActionButton[];
/** 快捷配置:是否显示重试按钮 */
showRetry?: boolean; showRetry?: boolean;
/** 重试回调函数 */ /** 重试回调 */
onRetry?: () => void; onRetry?: () => void;
/** 是否显示返回按钮 */ /** 快捷配置:是否显示返回按钮 */
showBack?: boolean; showBack?: boolean;
/** 返回回调函数 */ /** 返回回调 */
onBack?: () => void; onBack?: () => void;
/** 是否全屏显示,默认 true */ /** 快捷配置:是否显示返回首页按钮 */
showHome?: boolean;
/** 首页路径 */
homePath?: string;
// ===== 布局配置 =====
/** 是否全屏显示 */
fullScreen?: boolean; 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> = ({ const ErrorPage: React.FC<ErrorPageProps> = ({
title = '加载失败', title = '加载失败',
description, subtitle,
description = '我们无法找到您请求的内容,这可能是因为:',
detail, detail,
detailLabel = 'ID', detailLabel = 'ID',
reasons = DEFAULT_REASONS,
techDetails,
actions,
showRetry = false, showRetry = false,
onRetry, onRetry,
showBack = false, showBack = false,
onBack, onBack,
showHome = false,
homePath = '/',
fullScreen = true, fullScreen = true,
maxWidth = '500px',
checkOffline = true,
enableBuiltInReport = true,
onErrorReport,
}) => { }) => {
const hasButtons = (showRetry && onRetry) || (showBack && onBack); 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 ( return (
<Box <Box
@@ -63,74 +274,165 @@ const ErrorPage: React.FC<ErrorPageProps> = ({
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
> >
<Box <Box
bg={BG_COLOR} bg={BG_COLOR}
border="1px solid" border="1px solid"
borderColor={GOLD_COLOR} borderColor={GOLD_COLOR}
borderRadius="lg" borderRadius="lg"
p={8} p={8}
maxW="400px" maxW={maxWidth}
w="90%" w="90%"
textAlign="center" textAlign="center"
> >
{/* 金色圆形图标 + 黑色感叹号 */} {/* 金色圆形感叹号图标 */}
<Circle size="50px" bg={GOLD_COLOR} mx="auto" mb={4}> <Box mx="auto" mb={4}>
<Icon as={WarningIcon} color={BG_COLOR} boxSize={5} /> <ExclamationCircleOutlined style={{ fontSize: '40px', color: GOLD_COLOR }} />
</Circle> </Box>
{/* 金色标题 */} {/* 金色标题 */}
<Text color={GOLD_COLOR} fontSize="lg" fontWeight="medium" mb={2}> <Text color={GOLD_COLOR} fontSize="lg" fontWeight="medium" mb={2}>
{title} {title}
</Text>
{/* 副标题ID 显示) */}
{(subtitle || detail) && (
<Text
color="gray.400"
fontSize="sm"
fontFamily="monospace"
mb={2}
>
{subtitle || `${detailLabel}: ${detail}`}
</Text> </Text>
)}
{/* 描述信息 */} {/* 离线提示 */}
{description && ( {checkOffline && isOffline && (
<Text color="gray.400" fontSize="sm" mb={2}> <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} {description}
</Text> </Text>
)}
{/* 详情 */} <VStack spacing={3} align="stretch">
{detail && ( {reasons.map((reason, index) => (
<Text color="gray.500" fontSize="sm" mb={4}> <HStack key={index} spacing={3} align="flex-start">
{detailLabel}: {detail} <Text fontSize="lg" flexShrink={0}>
</Text> {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>
)}
{/* 按钮组 */} {/* 技术详情(可展开) */}
{hasButtons && ( {techDetails && (
<HStack justify="center" spacing={3} mt={4}> <Box mb={4}>
{showBack && onBack && ( <Button
<Button variant="ghost"
variant="outline" size="sm"
borderColor={GOLD_COLOR} color="gray.500"
color={GOLD_COLOR} rightIcon={isTechOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
size="sm" onClick={onTechToggle}
px={6} _hover={{ bg: 'transparent', color: 'gray.400' }}
_hover={{ bg: GOLD_COLOR, color: 'black' }} >
onClick={onBack}
> </Button>
<Collapse in={isTechOpen}>
</Button> <Box
)} mt={2}
{showRetry && onRetry && ( p={3}
<Button bg="gray.800"
bg={GOLD_COLOR} borderRadius="md"
color={BG_COLOR} fontFamily="monospace"
borderColor={GOLD_COLOR} fontSize="xs"
border="1px solid" color="gray.500"
size="sm" textAlign="left"
px={6} overflowX="auto"
fontWeight="medium" >
_hover={{ bg: '#C49A6C' }} {techDetails.requestUrl && (
onClick={onRetry} <Text>URL: {techDetails.requestUrl}</Text>
> )}
{techDetails.errorType && (
</Button> <Text>: {techDetails.errorType}</Text>
)} )}
</HStack> {techDetails.errorMessage && (
)} <Text>: {techDetails.errorMessage}</Text>
</Box> )}
{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> </Box>
); );
}; };

View File

@@ -66,15 +66,43 @@ const EventDetail = () => {
} }
// 错误状态 // 错误状态
if (!error) { if (error) {
return ( return (
<ErrorPage <ErrorPage
title="页面找不到了" title="事件走丢了"
description={error} subtitle={encodedId ? `ID: ${encodedId}` : undefined}
description="抱歉,我们找不到您请求的事件,这可能是因为:"
detail={eventId} detail={eventId}
detailLabel="事件ID" detailLabel="事件ID"
reasons={[
{
icon: '🔍',
title: '事件ID输入错误',
description: '请检查URL中的事件ID是否正确',
},
{
icon: '🗑️',
title: '该事件已被删除或下架',
description: '该事件可能因过期或内容调整而被移除',
},
{
icon: '🔄',
title: '系统暂时无法访问该事件',
description: '请稍后重试或联系技术支持',
},
]}
techDetails={{
requestUrl: window.location.href,
errorType: '404 - Event Not Found',
errorMessage: error,
timestamp: new Date().toISOString(),
relatedId: eventId,
}}
showRetry showRetry
onRetry={() => window.location.reload()} onRetry={() => window.location.reload()}
showBack
showHome
homePath="/community"
/> />
); );
} }