Compare commits
5 Commits
6272e50348
...
302acbafe3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
302acbafe3 | ||
|
|
39f14fb148 | ||
|
|
0cc75462aa | ||
|
|
863212f53f | ||
|
|
d296b0919c |
335
src/components/ErrorPage/README.md
Normal file
335
src/components/ErrorPage/README.md
Normal 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
|
||||
- **底部提示**:"点击右下角联系客服"(纯文本,无链接)
|
||||
@@ -1,59 +1,270 @@
|
||||
/**
|
||||
* ErrorPage - 通用错误页面组件
|
||||
* 用于显示加载失败、网络错误等异常状态
|
||||
* 用于显示加载失败、网络错误、404等异常状态
|
||||
* 设计风格:黑色背景 + 金色边框
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Center,
|
||||
Circle,
|
||||
Text,
|
||||
Button,
|
||||
VStack,
|
||||
HStack,
|
||||
Icon,
|
||||
Collapse,
|
||||
useDisclosure,
|
||||
} 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 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;
|
||||
/** 错误副标题(如显示错误 ID) */
|
||||
subtitle?: string;
|
||||
/** 错误描述信息 */
|
||||
description?: string;
|
||||
/** 详细信息(如事件ID、订单号等) */
|
||||
|
||||
// ===== 详细信息 =====
|
||||
/** 详细信息值 */
|
||||
detail?: string;
|
||||
/** 详细信息标签,默认"ID" */
|
||||
/** 详细信息标签 */
|
||||
detailLabel?: string;
|
||||
/** 是否显示重试按钮 */
|
||||
|
||||
// ===== 错误原因列表 =====
|
||||
/** 错误原因列表 */
|
||||
reasons?: ErrorReasonItem[];
|
||||
|
||||
// ===== 技术详情 =====
|
||||
/** 技术详情(可展开查看) */
|
||||
techDetails?: TechDetails;
|
||||
|
||||
// ===== 操作按钮 =====
|
||||
/** 自定义操作按钮列表 */
|
||||
actions?: ActionButton[];
|
||||
/** 快捷配置:是否显示重试按钮 */
|
||||
showRetry?: boolean;
|
||||
/** 重试回调函数 */
|
||||
/** 重试回调 */
|
||||
onRetry?: () => void;
|
||||
/** 是否显示返回按钮 */
|
||||
/** 快捷配置:是否显示返回按钮 */
|
||||
showBack?: boolean;
|
||||
/** 返回回调函数 */
|
||||
/** 返回回调 */
|
||||
onBack?: () => void;
|
||||
/** 是否全屏显示,默认 true */
|
||||
/** 快捷配置:是否显示返回首页按钮 */
|
||||
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 = '加载失败',
|
||||
description,
|
||||
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 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 (
|
||||
<Box
|
||||
@@ -63,74 +274,165 @@ const ErrorPage: React.FC<ErrorPageProps> = ({
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Box
|
||||
bg={BG_COLOR}
|
||||
border="1px solid"
|
||||
borderColor={GOLD_COLOR}
|
||||
borderRadius="lg"
|
||||
p={8}
|
||||
maxW="400px"
|
||||
w="90%"
|
||||
textAlign="center"
|
||||
>
|
||||
{/* 金色圆形图标 + 黑色感叹号 */}
|
||||
<Circle size="50px" bg={GOLD_COLOR} mx="auto" mb={4}>
|
||||
<Icon as={WarningIcon} color={BG_COLOR} boxSize={5} />
|
||||
</Circle>
|
||||
<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 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>
|
||||
)}
|
||||
|
||||
{/* 描述信息 */}
|
||||
{description && (
|
||||
<Text color="gray.400" fontSize="sm" mb={2}>
|
||||
{/* 离线提示 */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 详情 */}
|
||||
{detail && (
|
||||
<Text color="gray.500" fontSize="sm" mb={4}>
|
||||
{detailLabel}: {detail}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 按钮组 */}
|
||||
{hasButtons && (
|
||||
<HStack justify="center" spacing={3} mt={4}>
|
||||
{showBack && onBack && (
|
||||
<Button
|
||||
variant="outline"
|
||||
borderColor={GOLD_COLOR}
|
||||
color={GOLD_COLOR}
|
||||
size="sm"
|
||||
px={6}
|
||||
_hover={{ bg: GOLD_COLOR, color: 'black' }}
|
||||
onClick={onBack}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
)}
|
||||
{showRetry && onRetry && (
|
||||
<Button
|
||||
bg={GOLD_COLOR}
|
||||
color={BG_COLOR}
|
||||
borderColor={GOLD_COLOR}
|
||||
border="1px solid"
|
||||
size="sm"
|
||||
px={6}
|
||||
fontWeight="medium"
|
||||
_hover={{ bg: '#C49A6C' }}
|
||||
onClick={onRetry}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -537,6 +537,31 @@ export const mockFutureEvents = [
|
||||
];
|
||||
|
||||
export const mockCalendarEvents = [
|
||||
{
|
||||
id: 408,
|
||||
user_id: 1,
|
||||
title: '2025中医药高质量发展大会将于12月5日至7日举办',
|
||||
date: '2025-12-05',
|
||||
event_date: '2025-12-05',
|
||||
type: 'policy',
|
||||
category: 'industry_event',
|
||||
description: `基于提供的路演记录、新闻动态以及上市公司公告,以下是与"2025中医药高质量发展大会将于12月5日至7日举办"相关的信息整理:
|
||||
|
||||
事件背景:
|
||||
"2025中医药高质量发展大会"将于12月5日至7日在北京召开,由国家中医药管理局主办,旨在总结十四五期间中医药发展成果,部署下一阶段重点任务。大会主题为"守正创新、传承发展",将邀请国内外中医药领域专家学者、企业代表共商中医药现代化发展路径。
|
||||
|
||||
政策支持:
|
||||
1. 国务院办公厅印发《中医药振兴发展重大工程实施方案》,明确到2025年中医药服务体系更加完善
|
||||
2. 国家医保局持续推进中成药集采,优质中药企业有望受益于市场集中度提升
|
||||
3. 各地出台中医药产业发展支持政策,加大对中药创新药研发的资金支持
|
||||
|
||||
行业展望:
|
||||
中医药行业正处于政策红利期,创新中药、配方颗粒、中药材种植等细分领域景气度较高。预计大会将释放更多利好政策信号,推动行业高质量发展。`,
|
||||
importance: 5,
|
||||
source: 'future',
|
||||
stocks: ['002424.SZ', '002873.SZ', '600518.SH', '002907.SZ', '600129.SH', '300519.SZ', '300878.SZ', '002275.SZ', '600222.SH'],
|
||||
created_at: '2025-12-01T10:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 401,
|
||||
user_id: 1,
|
||||
|
||||
@@ -145,4 +145,14 @@ export interface PlanningContextValue {
|
||||
|
||||
/** 卡片背景色 */
|
||||
cardBg: string;
|
||||
|
||||
// 导航方法(可选,用于空状态引导)
|
||||
/** 切换视图模式 */
|
||||
setViewMode?: (mode: 'calendar' | 'list') => void;
|
||||
|
||||
/** 切换列表 Tab */
|
||||
setListTab?: (tab: number) => void;
|
||||
|
||||
/** 关闭弹窗 */
|
||||
closeModal?: () => void;
|
||||
}
|
||||
|
||||
@@ -3,39 +3,24 @@
|
||||
* 使用 FullCalendar 展示投资计划、复盘等事件
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, lazy, Suspense } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
Tooltip,
|
||||
Icon,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
Link,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiPlus,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiStar,
|
||||
FiTrendingUp,
|
||||
} from 'react-icons/fi';
|
||||
import { FiCalendar } from 'react-icons/fi';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
@@ -45,10 +30,11 @@ import dayjs, { Dayjs } from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import { EventFormModal } from './EventFormModal';
|
||||
import { EventDetailCard } from './EventDetailCard';
|
||||
import type { InvestmentEvent } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
|
||||
// 懒加载投资日历组件
|
||||
const InvestmentCalendar = lazy(() => import('@/views/Community/components/InvestmentCalendar'));
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
@@ -74,17 +60,17 @@ interface CalendarEvent {
|
||||
export const CalendarPanel: React.FC = () => {
|
||||
const {
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
toast,
|
||||
borderColor,
|
||||
secondaryText,
|
||||
setViewMode,
|
||||
setListTab,
|
||||
} = usePlanningData();
|
||||
|
||||
// 详情弹窗
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
// 添加弹窗状态
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState<boolean>(false);
|
||||
// 投资日历弹窗
|
||||
const [isInvestmentCalendarOpen, setIsInvestmentCalendarOpen] = useState(false);
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
|
||||
const [selectedDateEvents, setSelectedDateEvents] = useState<InvestmentEvent[]>([]);
|
||||
@@ -129,61 +115,6 @@ export const CalendarPanel: React.FC = () => {
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 打开添加弹窗
|
||||
const handleOpenAddModal = (): void => {
|
||||
onClose(); // 先关闭详情弹窗
|
||||
setIsAddModalOpen(true);
|
||||
};
|
||||
|
||||
// 关闭添加弹窗
|
||||
const handleCloseAddModal = (): void => {
|
||||
setIsAddModalOpen(false);
|
||||
};
|
||||
|
||||
// 删除事件
|
||||
const handleDeleteEvent = async (eventId: number): Promise<void> => {
|
||||
if (!eventId) {
|
||||
logger.warn('CalendarPanel', '删除事件失败: 缺少事件 ID', { eventId });
|
||||
toast({
|
||||
title: '无法删除',
|
||||
description: '缺少事件 ID',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/calendar/events/${eventId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('CalendarPanel', '删除事件成功', { eventId });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadAllData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('CalendarPanel', 'handleDeleteEvent', error, { eventId });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 查看事件详情(关闭弹窗)
|
||||
const handleViewDetails = (): void => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{loading ? (
|
||||
@@ -228,124 +159,92 @@ export const CalendarPanel: React.FC = () => {
|
||||
<ModalBody>
|
||||
{selectedDateEvents.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack>
|
||||
<Text color={secondaryText}>当天没有事件</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={handleOpenAddModal}
|
||||
>
|
||||
添加投资计划
|
||||
</Button>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiCalendar} boxSize={10} color="gray.300" />
|
||||
<Text color={secondaryText}>当天暂无事件</Text>
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
可在
|
||||
<Link
|
||||
color="purple.500"
|
||||
fontWeight="medium"
|
||||
mx={1}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
setViewMode?.('list');
|
||||
setListTab?.(0);
|
||||
}}
|
||||
cursor="pointer"
|
||||
>
|
||||
计划
|
||||
</Link>
|
||||
或
|
||||
<Link
|
||||
color="green.500"
|
||||
fontWeight="medium"
|
||||
mx={1}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
setViewMode?.('list');
|
||||
setListTab?.(1);
|
||||
}}
|
||||
cursor="pointer"
|
||||
>
|
||||
复盘
|
||||
</Link>
|
||||
添加,或关注
|
||||
<Link
|
||||
color="blue.500"
|
||||
fontWeight="medium"
|
||||
mx={1}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
setIsInvestmentCalendarOpen(true);
|
||||
}}
|
||||
cursor="pointer"
|
||||
>
|
||||
投资日历
|
||||
</Link>
|
||||
中的未来事件
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{selectedDateEvents.map((event, idx) => (
|
||||
<Box
|
||||
<EventDetailCard
|
||||
key={idx}
|
||||
p={4}
|
||||
borderRadius="md"
|
||||
border="1px"
|
||||
event={event}
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Flex justify="space-between" align="start" mb={2}>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{event.title}
|
||||
</Text>
|
||||
{event.source === 'future' ? (
|
||||
<Badge colorScheme="blue" variant="subtle">系统事件</Badge>
|
||||
) : event.type === 'plan' ? (
|
||||
<Badge colorScheme="purple" variant="subtle">我的计划</Badge>
|
||||
) : (
|
||||
<Badge colorScheme="green" variant="subtle">我的复盘</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
{event.importance && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiStar} color="yellow.500" />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
重要度: {event.importance}/5
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
<HStack>
|
||||
{!event.source || event.source === 'user' ? (
|
||||
<>
|
||||
<Tooltip label="查看详情">
|
||||
<IconButton
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
onClick={() => handleViewDetails()}
|
||||
aria-label="查看详情"
|
||||
/>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDeleteEvent(event.id)}
|
||||
aria-label="删除事件"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{event.description && (
|
||||
<Text fontSize="sm" color={secondaryText} mb={2}>
|
||||
{event.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{event.stocks && event.stocks.length > 0 && (
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Text fontSize="sm" color={secondaryText}>相关股票:</Text>
|
||||
{event.stocks.map((stock, i) => (
|
||||
<Tag key={i} size="sm" colorScheme="blue">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
secondaryText={secondaryText}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 使用通用弹窗组件 - 添加事件 */}
|
||||
<EventFormModal
|
||||
isOpen={isAddModalOpen}
|
||||
onClose={handleCloseAddModal}
|
||||
mode="create"
|
||||
eventType="plan"
|
||||
initialDate={selectedDate?.format('YYYY-MM-DD')}
|
||||
onSuccess={loadAllData}
|
||||
colorScheme="purple"
|
||||
label="事件"
|
||||
showDatePicker={false}
|
||||
showTypeSelect={true}
|
||||
showStatusSelect={false}
|
||||
showImportance={true}
|
||||
showTags={false}
|
||||
stockInputMode="text"
|
||||
apiEndpoint="calendar/events"
|
||||
/>
|
||||
{/* 投资日历 Modal */}
|
||||
{isInvestmentCalendarOpen && (
|
||||
<Modal
|
||||
isOpen={isInvestmentCalendarOpen}
|
||||
onClose={() => setIsInvestmentCalendarOpen(false)}
|
||||
size="6xl"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="1200px">
|
||||
<ModalHeader>投资日历</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<Suspense fallback={<Center py={8}><Spinner size="xl" color="blue.500" /></Center>}>
|
||||
<InvestmentCalendar />
|
||||
</Suspense>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
145
src/views/Dashboard/components/EventDetailCard.tsx
Normal file
145
src/views/Dashboard/components/EventDetailCard.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* EventDetailCard - 事件详情卡片组件
|
||||
* 用于日历视图中展示单个事件的详细信息
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Badge,
|
||||
Flex,
|
||||
HStack,
|
||||
Text,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
Button,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiTrendingUp,
|
||||
FiChevronDown,
|
||||
FiChevronUp,
|
||||
} from 'react-icons/fi';
|
||||
import type { InvestmentEvent } from '@/types';
|
||||
|
||||
/**
|
||||
* EventDetailCard Props
|
||||
*/
|
||||
export interface EventDetailCardProps {
|
||||
/** 事件数据 */
|
||||
event: InvestmentEvent;
|
||||
/** 边框颜色 */
|
||||
borderColor?: string;
|
||||
/** 次要文字颜色 */
|
||||
secondaryText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 最大显示行数
|
||||
*/
|
||||
const MAX_LINES = 3;
|
||||
|
||||
/**
|
||||
* EventDetailCard 组件
|
||||
*/
|
||||
export const EventDetailCard: React.FC<EventDetailCardProps> = ({
|
||||
event,
|
||||
borderColor: borderColorProp,
|
||||
secondaryText: secondaryTextProp,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isOverflow, setIsOverflow] = useState(false);
|
||||
const descriptionRef = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
// 默认颜色
|
||||
const defaultBorderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const defaultSecondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
const borderColor = borderColorProp || defaultBorderColor;
|
||||
const secondaryText = secondaryTextProp || defaultSecondaryText;
|
||||
|
||||
// 检测内容是否溢出
|
||||
useEffect(() => {
|
||||
const el = descriptionRef.current;
|
||||
if (el) {
|
||||
// 计算行高和最大高度
|
||||
const lineHeight = parseInt(getComputedStyle(el).lineHeight) || 20;
|
||||
const maxHeight = lineHeight * MAX_LINES;
|
||||
setIsOverflow(el.scrollHeight > maxHeight + 5); // 5px 容差
|
||||
}
|
||||
}, [event.description]);
|
||||
|
||||
// 获取事件类型标签
|
||||
const getEventBadge = () => {
|
||||
if (event.source === 'future') {
|
||||
return <Badge colorScheme="blue" variant="subtle">系统事件</Badge>;
|
||||
} else if (event.type === 'plan') {
|
||||
return <Badge colorScheme="purple" variant="subtle">我的计划</Badge>;
|
||||
} else if (event.type === 'review') {
|
||||
return <Badge colorScheme="green" variant="subtle">我的复盘</Badge>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={4}
|
||||
borderRadius="md"
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
{/* 标题和标签 */}
|
||||
<Flex justify="space-between" align="start" mb={2}>
|
||||
<HStack flexWrap="wrap" flex={1}>
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{event.title}
|
||||
</Text>
|
||||
{getEventBadge()}
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 描述内容 - 支持展开/收起 */}
|
||||
{event.description && (
|
||||
<Box mb={2}>
|
||||
<Text
|
||||
ref={descriptionRef}
|
||||
fontSize="sm"
|
||||
color={secondaryText}
|
||||
noOfLines={isExpanded ? undefined : MAX_LINES}
|
||||
whiteSpace="pre-wrap"
|
||||
>
|
||||
{event.description}
|
||||
</Text>
|
||||
{isOverflow && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="link"
|
||||
colorScheme="blue"
|
||||
mt={1}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
rightIcon={isExpanded ? <FiChevronUp /> : <FiChevronDown />}
|
||||
>
|
||||
{isExpanded ? '收起' : '展开'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 相关股票 */}
|
||||
{event.stocks && event.stocks.length > 0 && (
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Text fontSize="sm" color={secondaryText}>相关股票:</Text>
|
||||
{event.stocks.map((stock, i) => (
|
||||
<Tag key={i} size="sm" colorScheme="blue" mb={1}>
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetailCard;
|
||||
@@ -269,10 +269,10 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
||||
<Spinner size="xl" color={`${colorScheme}.500`} />
|
||||
</Center>
|
||||
) : events.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiFileText} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText}>暂无投资{label}</Text>
|
||||
<Center py={{ base: 6, md: 8 }}>
|
||||
<VStack spacing={{ base: 2, md: 3 }}>
|
||||
<Icon as={FiFileText} boxSize={{ base: 8, md: 12 }} color="gray.300" />
|
||||
<Text color={secondaryText} fontSize={{ base: 'sm', md: 'md' }}>暂无投资{label}</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
|
||||
@@ -134,6 +134,8 @@ const InvestmentPlanningCenter: React.FC = () => {
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
setViewMode,
|
||||
setListTab,
|
||||
};
|
||||
|
||||
// 计算各类型事件数量
|
||||
@@ -143,34 +145,36 @@ const InvestmentPlanningCenter: React.FC = () => {
|
||||
return (
|
||||
<PlanningDataProvider value={contextValue}>
|
||||
<Card bg={bgColor} shadow="md">
|
||||
<CardHeader pb={4}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack>
|
||||
<Icon as={FiTarget} color="purple.500" boxSize={5} />
|
||||
<Heading size="md">投资规划中心</Heading>
|
||||
<CardHeader pb={{ base: 2, md: 4 }} px={{ base: 3, md: 5 }}>
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap={2}>
|
||||
<HStack spacing={{ base: 1, md: 2 }}>
|
||||
<Icon as={FiTarget} color="purple.500" boxSize={{ base: 4, md: 5 }} />
|
||||
<Heading size={{ base: 'sm', md: 'md' }}>投资规划中心</Heading>
|
||||
</HStack>
|
||||
{/* 视图切换按钮组 */}
|
||||
<ButtonGroup size="sm" isAttached variant="outline">
|
||||
<ButtonGroup size={{ base: 'xs', md: 'sm' }} isAttached variant="outline">
|
||||
<Button
|
||||
leftIcon={<Icon as={FiList} />}
|
||||
leftIcon={<Icon as={FiList} boxSize={{ base: 3, md: 4 }} />}
|
||||
colorScheme={viewMode === 'list' ? 'purple' : 'gray'}
|
||||
variant={viewMode === 'list' ? 'solid' : 'outline'}
|
||||
onClick={() => setViewMode('list')}
|
||||
fontSize={{ base: 'xs', md: 'sm' }}
|
||||
>
|
||||
列表视图
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<Icon as={FiCalendar} />}
|
||||
leftIcon={<Icon as={FiCalendar} boxSize={{ base: 3, md: 4 }} />}
|
||||
colorScheme={viewMode === 'calendar' ? 'purple' : 'gray'}
|
||||
variant={viewMode === 'calendar' ? 'solid' : 'outline'}
|
||||
onClick={() => setViewMode('calendar')}
|
||||
fontSize={{ base: 'xs', md: 'sm' }}
|
||||
>
|
||||
日历视图
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
<CardBody pt={0} px={{ base: 3, md: 5 }}>
|
||||
{viewMode === 'calendar' ? (
|
||||
/* 日历视图 */
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
@@ -183,22 +187,24 @@ const InvestmentPlanningCenter: React.FC = () => {
|
||||
onChange={setListTab}
|
||||
variant="enclosed"
|
||||
colorScheme="purple"
|
||||
size={{ base: 'sm', md: 'md' }}
|
||||
>
|
||||
<Flex justify="space-between" align="center" mb={4}>
|
||||
<Flex justify="space-between" align="center" mb={{ base: 2, md: 4 }} wrap="wrap" gap={2}>
|
||||
<TabList mb={0} borderBottom="none">
|
||||
<Tab>
|
||||
<Icon as={FiTarget} mr={2} />
|
||||
<Tab fontSize={{ base: 'xs', md: 'sm' }} px={{ base: 2, md: 4 }}>
|
||||
<Icon as={FiTarget} mr={{ base: 1, md: 2 }} boxSize={{ base: 3, md: 4 }} />
|
||||
我的计划 ({planCount})
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={FiFileText} mr={2} />
|
||||
<Tab fontSize={{ base: 'xs', md: 'sm' }} px={{ base: 2, md: 4 }}>
|
||||
<Icon as={FiFileText} mr={{ base: 1, md: 2 }} boxSize={{ base: 3, md: 4 }} />
|
||||
我的复盘 ({reviewCount})
|
||||
</Tab>
|
||||
</TabList>
|
||||
<Button
|
||||
size="sm"
|
||||
size={{ base: 'xs', md: 'sm' }}
|
||||
colorScheme="purple"
|
||||
leftIcon={<Icon as={FiPlus} />}
|
||||
leftIcon={<Icon as={FiPlus} boxSize={{ base: 3, md: 4 }} />}
|
||||
fontSize={{ base: 'xs', md: 'sm' }}
|
||||
onClick={() => {
|
||||
if (listTab === 0) {
|
||||
setOpenPlanModalTrigger(prev => prev + 1);
|
||||
|
||||
@@ -66,15 +66,43 @@ const EventDetail = () => {
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (!error) {
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorPage
|
||||
title="页面找不到了"
|
||||
description={error}
|
||||
title="事件走丢了"
|
||||
subtitle={encodedId ? `ID: ${encodedId}` : undefined}
|
||||
description="抱歉,我们找不到您请求的事件,这可能是因为:"
|
||||
detail={eventId}
|
||||
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
|
||||
onRetry={() => window.location.reload()}
|
||||
showBack
|
||||
showHome
|
||||
homePath="/community"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user