Merge branch 'feature_bugfix/251201_py_h5_ui' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251201_py_h5_ui
This commit is contained in:
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,14 +6,7 @@ import * as echarts from 'echarts';
|
||||
import dayjs from 'dayjs';
|
||||
import { stockService } from '@services/eventService';
|
||||
import { selectIsMobile } from '@store/slices/deviceSlice';
|
||||
|
||||
/**
|
||||
* 股票信息
|
||||
*/
|
||||
interface StockInfo {
|
||||
stock_code: string;
|
||||
stock_name?: string;
|
||||
}
|
||||
import { StockInfo } from './types';
|
||||
|
||||
/**
|
||||
* KLineChartModal 组件 Props
|
||||
|
||||
@@ -21,14 +21,7 @@ import * as echarts from 'echarts';
|
||||
import dayjs from 'dayjs';
|
||||
import { klineDataCache, getCacheKey, fetchKlineData } from '@views/Community/components/StockDetailPanel/utils/klineDataCache';
|
||||
import { selectIsMobile } from '@store/slices/deviceSlice';
|
||||
|
||||
/**
|
||||
* 股票信息
|
||||
*/
|
||||
interface StockInfo {
|
||||
stock_code: string;
|
||||
stock_name?: string;
|
||||
}
|
||||
import { StockInfo } from './types';
|
||||
|
||||
/**
|
||||
* TimelineChartModal 组件 Props
|
||||
|
||||
@@ -289,53 +289,412 @@ export const mockEventComments = [
|
||||
// ==================== 投资计划与复盘数据 ====================
|
||||
|
||||
export const mockInvestmentPlans = [
|
||||
// ==================== 计划数据(符合计划模板) ====================
|
||||
{
|
||||
id: 301,
|
||||
user_id: 1,
|
||||
type: 'plan',
|
||||
title: '2025年Q1 新能源板块布局计划',
|
||||
content: '计划在Q1分批建仓新能源板块,重点关注宁德时代、比亚迪、隆基绿能三只标的。目标仓位15%,预计收益率20%。\n\n具体策略:\n1. 宁德时代:占比6%,等待回调至160元附近分批买入\n2. 比亚迪:占比6%,当前价位可以开始建仓\n3. 隆基绿能:占比3%,观察光伏行业景气度再决定\n\n风险控制:单只个股止损-8%,板块整体止损-10%',
|
||||
content: `【目标】
|
||||
在Q1末实现新能源板块仓位15%,预计收益率20%,重点捕捉新能源政策利好和销量数据催化。
|
||||
|
||||
【策略】
|
||||
1. 宁德时代:占比6%,等待回调至160元附近分批买入,技术面看好底部放量信号
|
||||
2. 比亚迪:占比6%,当前价位可以开始建仓,采用金字塔式加仓
|
||||
3. 隆基绿能:占比3%,观察光伏行业景气度再决定,等待基本面拐点确认
|
||||
|
||||
【风险控制】
|
||||
- 单只个股止损-8%
|
||||
- 板块整体止损-10%
|
||||
- 遇到系统性风险事件,果断减仓50%
|
||||
- 避免在重大财报日前重仓
|
||||
|
||||
【时间规划】
|
||||
- 1月中旬:完成第一批建仓(5%仓位)
|
||||
- 2月春节后:根据市场情况加仓(5%仓位)
|
||||
- 3月中旬:完成最终布局(5%仓位)
|
||||
- 季度末:复盘调整,决定是否持有到Q2`,
|
||||
target_date: '2025-03-31',
|
||||
status: 'in_progress',
|
||||
created_at: '2025-01-10T10:00:00Z',
|
||||
updated_at: '2025-01-15T14:30:00Z',
|
||||
tags: ['新能源', '布局计划', 'Q1计划']
|
||||
},
|
||||
{
|
||||
id: 302,
|
||||
user_id: 1,
|
||||
type: 'review',
|
||||
title: '2024年12月投资复盘 - 白酒板块大涨',
|
||||
content: '12月白酒板块表现优异,贵州茅台上涨12%,五粮液上涨8%。\n\n操作回顾:\n1. 11月底在1550元加仓茅台,获利6.5%\n2. 五粮液持仓未动,获利4.2%\n\n经验总结:\n- 消费板块在年底有明显的估值修复行情\n- 龙头白马股在市场震荡时更具韧性\n- 应该更大胆一些,仓位可以再提高2-3个点\n\n下月计划:\n- 继续持有茅台、五粮液,不轻易卖出\n- 关注春节前的消费旺季催化',
|
||||
target_date: '2024-12-31',
|
||||
status: 'completed',
|
||||
created_at: '2025-01-02T09:00:00Z',
|
||||
updated_at: '2025-01-02T09:00:00Z',
|
||||
tags: ['月度复盘', '白酒', '2024年12月']
|
||||
tags: ['新能源', '布局计划', 'Q1计划'],
|
||||
stocks: ['300750.SZ', '002594.SZ', '601012.SH']
|
||||
},
|
||||
{
|
||||
id: 303,
|
||||
user_id: 1,
|
||||
type: 'plan',
|
||||
title: 'AI 算力板块波段交易计划',
|
||||
content: '随着ChatGPT-5即将发布,AI算力板块有望迎来新一轮炒作。\n\n标的选择:\n- 寒武纪:AI芯片龙头,弹性最大\n- 中科曙光:服务器厂商,业绩支撑\n- 浪潮信息:算力基础设施\n\n交易策略:\n- 仓位控制在10%以内(高风险高弹性)\n- 采用金字塔式买入,第一笔3%\n- 快进快出,涨幅20%分批止盈\n- 破位及时止损,控制在-5%以内',
|
||||
content: `【目标】
|
||||
捕捉ChatGPT-5发布带来的AI算力板块短期行情,目标收益15-20%,控制最大回撤在8%以内。
|
||||
|
||||
【策略】
|
||||
- 寒武纪:AI芯片龙头,弹性最大,首选标的
|
||||
- 中科曙光:服务器厂商,业绩支撑更扎实
|
||||
- 浪潮信息:算力基础设施,流动性好
|
||||
- 采用金字塔式买入,第一笔3%,后续根据走势加仓
|
||||
- 快进快出,涨幅20%分批止盈
|
||||
|
||||
【风险控制】
|
||||
- 仓位控制在10%以内(高风险高弹性)
|
||||
- 单只个股止损-5%
|
||||
- 破位及时止损,不恋战
|
||||
- 避免追高,只在回调时介入
|
||||
|
||||
【时间规划】
|
||||
- 本周:观察消息面发酵情况,确定进场时机
|
||||
- 发布前1周:逐步建仓
|
||||
- 发布后:根据市场反应决定持有还是止盈
|
||||
- 2月底前:完成此轮操作`,
|
||||
target_date: '2025-02-28',
|
||||
status: 'pending',
|
||||
created_at: '2025-01-14T16:00:00Z',
|
||||
updated_at: '2025-01-14T16:00:00Z',
|
||||
tags: ['AI', '算力', '波段交易']
|
||||
tags: ['AI', '算力', '波段交易'],
|
||||
stocks: ['688256.SH', '603019.SH', '000977.SZ']
|
||||
},
|
||||
{
|
||||
id: 305,
|
||||
user_id: 1,
|
||||
type: 'plan',
|
||||
title: '银行股防守配置计划',
|
||||
content: `【目标】
|
||||
构建15%仓位的银行股防守配置,获取稳定分红收益(股息率5%+),同时等待估值修复带来的资本利得。
|
||||
|
||||
【策略】
|
||||
1. 招商银行:零售银行龙头,ROE持续优秀,配置8%
|
||||
2. 兴业银行:同业业务优势明显,配置4%
|
||||
3. 成都银行:城商行中成长性最好,配置3%
|
||||
选股逻辑:优先选择ROE高、资产质量好、分红稳定的标的
|
||||
|
||||
【风险控制】
|
||||
- 银行股整体波动较小,但需关注宏观经济风险
|
||||
- 如遇利率大幅下行或地产风险暴露,需重新评估持仓
|
||||
- 单只银行股止损-15%(较宽松,适合长线持有)
|
||||
- 定期关注季报中的不良贷款率和拨备覆盖率
|
||||
|
||||
【时间规划】
|
||||
- 春节前:完成建仓
|
||||
- 全年持有:享受分红收益
|
||||
- 年中复盘:根据半年报调整配置比例
|
||||
- 年底:评估是否继续持有到下一年`,
|
||||
target_date: '2025-06-30',
|
||||
status: 'active',
|
||||
created_at: '2025-01-08T11:00:00Z',
|
||||
updated_at: '2025-01-08T11:00:00Z',
|
||||
tags: ['银行', '防守配置', '高股息'],
|
||||
stocks: ['600036.SH', '601166.SH', '601838.SH']
|
||||
},
|
||||
{
|
||||
id: 306,
|
||||
user_id: 1,
|
||||
type: 'plan',
|
||||
title: '医药创新药中长线布局',
|
||||
content: `【目标】
|
||||
布局医药创新药板块,目标3-6个月内获得25%收益,享受创新药产品上市带来的业绩爆发。
|
||||
|
||||
【策略】
|
||||
1. 恒瑞医药:创新药管线最丰富,PD-1放量进行中
|
||||
2. 药明康德:CRO龙头,受益于全球创新药研发外包
|
||||
3. 百济神州:海外收入占比高,泽布替尼持续放量
|
||||
采用分批建仓策略,避免一次性重仓
|
||||
|
||||
【风险控制】
|
||||
- 总仓位不超过12%
|
||||
- 单只个股止损-10%
|
||||
- 关注集采政策风险,如有利空政策出台立即减仓
|
||||
- 关注核心产品的销售数据和临床进展
|
||||
|
||||
【时间规划】
|
||||
- 第1个月:建立6%底仓
|
||||
- 第2-3个月:根据业绩催化加仓至12%
|
||||
- 第4-6个月:达到目标收益后分批止盈
|
||||
- 每月关注:产品获批进展、销售数据、研报观点`,
|
||||
target_date: '2025-06-30',
|
||||
status: 'active',
|
||||
created_at: '2025-01-05T14:00:00Z',
|
||||
updated_at: '2025-01-12T09:30:00Z',
|
||||
tags: ['医药', '创新药', '中长线'],
|
||||
stocks: ['600276.SH', '603259.SH', '688235.SH']
|
||||
},
|
||||
{
|
||||
id: 307,
|
||||
user_id: 1,
|
||||
type: 'plan',
|
||||
title: '消费复苏主题布局计划',
|
||||
content: `【目标】
|
||||
捕捉春节消费旺季和全年消费复苏趋势,目标收益20%,重点布局白酒和免税龙头。
|
||||
|
||||
【策略】
|
||||
1. 贵州茅台:高端白酒龙头,提价预期+渠道优化
|
||||
2. 五粮液:次高端领军,估值修复空间大
|
||||
3. 中国中免:免税龙头,海南自贸港政策利好
|
||||
分散配置,每只占比3-5%
|
||||
|
||||
【风险控制】
|
||||
- 总仓位控制在15%以内
|
||||
- 单只个股止损-8%
|
||||
- 关注消费数据变化,如不及预期及时调整
|
||||
- 警惕宏观经济下行风险对消费的冲击
|
||||
|
||||
【时间规划】
|
||||
- 春节前2周:完成建仓
|
||||
- 春节后:观察销售数据和股价反应
|
||||
- Q1末:根据一季度消费数据决定是否加仓
|
||||
- 全年跟踪:月度社零数据、旅游数据`,
|
||||
target_date: '2025-04-30',
|
||||
status: 'pending',
|
||||
created_at: '2025-01-03T10:30:00Z',
|
||||
updated_at: '2025-01-03T10:30:00Z',
|
||||
tags: ['消费', '白酒', '免税'],
|
||||
stocks: ['600519.SH', '000858.SZ', '601888.SH']
|
||||
},
|
||||
|
||||
// ==================== 复盘数据(符合复盘模板) ====================
|
||||
{
|
||||
id: 302,
|
||||
user_id: 1,
|
||||
type: 'review',
|
||||
title: '2024年12月投资复盘 - 白酒板块大涨',
|
||||
content: `【操作回顾】
|
||||
1. 11月底在1550元加仓茅台0.5%仓位,持有至今
|
||||
2. 五粮液持仓未动,从11月初一直持有
|
||||
3. 错过了洋河股份的反弹行情
|
||||
4. 月中短线做了一次泸州老窖,小赚2%出局
|
||||
|
||||
【盈亏分析】
|
||||
- 贵州茅台:获利6.5%,贡献账户收益约0.65%
|
||||
- 五粮液:获利4.2%,贡献账户收益约0.42%
|
||||
- 泸州老窖:短线获利2%,贡献约0.06%
|
||||
- 月度总收益:约1.13%
|
||||
- 同期沪深300涨幅:0.8%,跑赢指数0.33%
|
||||
|
||||
【经验总结】
|
||||
- 消费板块在年底有明显的估值修复行情,这个规律可以记住
|
||||
- 龙头白马股在市场震荡时更具韧性,应该坚定持有
|
||||
- 应该更大胆一些,茅台仓位可以再提高2-3个点
|
||||
- 洋河的机会没把握住,主要是对二线白酒信心不足
|
||||
|
||||
【后续调整】
|
||||
- 继续持有茅台、五粮液,不轻易卖出
|
||||
- 关注春节前的消费旺季催化
|
||||
- 如果有回调,考虑加仓茅台至5%总仓位
|
||||
- 下月开始关注春节消费数据`,
|
||||
target_date: '2024-12-31',
|
||||
status: 'completed',
|
||||
created_at: '2025-01-02T09:00:00Z',
|
||||
updated_at: '2025-01-02T09:00:00Z',
|
||||
tags: ['月度复盘', '白酒', '2024年12月'],
|
||||
stocks: ['600519.SH', '000858.SZ', '000568.SZ']
|
||||
},
|
||||
{
|
||||
id: 304,
|
||||
user_id: 1,
|
||||
type: 'review',
|
||||
title: '2024年全年投资总结 - 收益率25.6%',
|
||||
content: '2024年全年收益率25.6%,跑赢沪深300指数12个百分点。\n\n全年亮点:\n1. 新能源板块贡献最大,年度收益35%\n2. 白酒板块稳健增长,年度收益18%\n3. 半导体板块波动较大,年度收益8%\n\n教训与反思:\n1. 年初追高了一些热门概念股,后续回调损失较大\n2. 止损执行不够坚决,有两次错过最佳止损时机\n3. 仓位管理有待提高,牛市时仓位偏低\n\n2025年目标:\n- 收益率目标:30%\n- 优化仓位管理,提高资金使用效率\n- 严格执行止损纪律\n- 加强行业研究,提前布局',
|
||||
content: `【操作回顾】
|
||||
1. 全年共进行交易52次,其中胜率62%
|
||||
2. 主要盈利来源:新能源(+35%)、白酒(+18%)
|
||||
3. 主要亏损来源:年初追高的概念股(-8%)
|
||||
4. 最成功操作:5月底抄底宁德时代,持有3个月获利45%
|
||||
5. 最失败操作:3月追高机器人概念,亏损12%割肉
|
||||
|
||||
【盈亏分析】
|
||||
- 全年总收益率:25.6%
|
||||
- 沪深300涨幅:13.6%
|
||||
- 超额收益:12个百分点
|
||||
- 最大回撤:-8.5%(3月份)
|
||||
- 夏普比率:约1.8
|
||||
- 各板块贡献:
|
||||
- 新能源:+12.6%
|
||||
- 白酒:+7.2%
|
||||
- 半导体:+3.2%
|
||||
- 其他:+2.6%
|
||||
|
||||
【经验总结】
|
||||
1. 年初追高热门概念股是最大教训,后续回调损失较大
|
||||
2. 止损执行不够坚决,有两次错过最佳止损时机
|
||||
3. 仓位管理有待提高,牛市时仓位偏低(最高才70%)
|
||||
4. 成功的操作都是逆向买入+耐心持有
|
||||
5. 频繁交易并没有带来更好收益
|
||||
|
||||
【后续调整】
|
||||
2025年目标:
|
||||
- 收益率目标:30%
|
||||
- 优化仓位管理,提高资金使用效率至80%+
|
||||
- 严格执行止损纪律,设置自动止损提醒
|
||||
- 加强行业研究,提前布局而非追高
|
||||
- 减少交易频率,提高单次交易质量`,
|
||||
target_date: '2024-12-31',
|
||||
status: 'completed',
|
||||
created_at: '2025-01-01T10:00:00Z',
|
||||
updated_at: '2025-01-01T10:00:00Z',
|
||||
tags: ['年度复盘', '2024年', '总结']
|
||||
tags: ['年度复盘', '2024年', '总结'],
|
||||
stocks: []
|
||||
},
|
||||
{
|
||||
id: 308,
|
||||
user_id: 1,
|
||||
type: 'review',
|
||||
title: '宁德时代波段操作复盘',
|
||||
content: `【操作回顾】
|
||||
- 5月25日:在160元附近建仓3%,理由是估值回到历史低位+储能业务放量预期
|
||||
- 6月10日:加仓2%,价格172元,技术面突破关键阻力
|
||||
- 7月20日:再加仓2%,价格195元,财报预告超预期
|
||||
- 8月15日:开始分批止盈,卖出3%仓位,均价235元
|
||||
- 8月28日:清仓剩余4%仓位,均价228元
|
||||
|
||||
【盈亏分析】
|
||||
- 第一笔:160元买入,平均卖出231.5元,收益率44.7%
|
||||
- 第二笔:172元买入,平均卖出231.5元,收益率34.6%
|
||||
- 第三笔:195元买入,平均卖出231.5元,收益率18.7%
|
||||
- 加权平均收益率:约35%
|
||||
- 持仓时间:约3个月
|
||||
- 年化收益率:约140%
|
||||
|
||||
【经验总结】
|
||||
1. 在估值底部+催化剂出现时建仓是正确的选择
|
||||
2. 金字塔式加仓策略有效控制了成本
|
||||
3. 分批止盈策略让我吃到了大部分涨幅
|
||||
4. 但最后一笔加仓(195元)价格偏高,拉低了整体收益
|
||||
5. 应该在涨幅达到30%时就开始止盈,而非等到40%+
|
||||
|
||||
【后续调整】
|
||||
- 下次操作宁德时代,设置150-170元为合理买入区间
|
||||
- 涨幅达到25%开始分批止盈
|
||||
- 储能业务是长期逻辑,可以保留2%底仓长期持有
|
||||
- 关注Q4业绩和2025年指引`,
|
||||
target_date: '2024-08-31',
|
||||
status: 'completed',
|
||||
created_at: '2024-09-01T10:00:00Z',
|
||||
updated_at: '2024-09-01T10:00:00Z',
|
||||
tags: ['个股复盘', '宁德时代', '波段'],
|
||||
stocks: ['300750.SZ']
|
||||
},
|
||||
{
|
||||
id: 309,
|
||||
user_id: 1,
|
||||
type: 'review',
|
||||
title: '11月第三周交易复盘',
|
||||
content: `【操作回顾】
|
||||
周一:观望,未操作
|
||||
周二:买入比亚迪2%仓位,价格248元
|
||||
周三:加仓比亚迪1%,价格252元;卖出中芯国际1%仓位
|
||||
周四:买入恒瑞医药1.5%仓位,价格42元
|
||||
周五:观望,持仓未动
|
||||
|
||||
【盈亏分析】
|
||||
- 本周账户收益:+0.8%
|
||||
- 比亚迪:浮盈1.2%
|
||||
- 恒瑞医药:浮亏0.5%
|
||||
- 中芯国际:卖出盈利3%
|
||||
- 同期沪深300:+0.5%
|
||||
- 超额收益:+0.3%
|
||||
|
||||
【经验总结】
|
||||
1. 比亚迪买入时机较好,趁回调建仓
|
||||
2. 恒瑞医药买得稍早,本周没有继续下跌但也没涨
|
||||
3. 中芯国际止盈时机把握得不错,避免了后续调整
|
||||
4. 交易频率偏高,手续费成本需要注意
|
||||
|
||||
【后续调整】
|
||||
- 下周继续持有比亚迪和恒瑞,等待催化
|
||||
- 如果恒瑞跌破40元,考虑加仓
|
||||
- 比亚迪如果突破260元,可以继续加仓
|
||||
- 下周计划观察AI板块是否有机会`,
|
||||
target_date: '2024-11-24',
|
||||
status: 'completed',
|
||||
created_at: '2024-11-25T18:00:00Z',
|
||||
updated_at: '2024-11-25T18:00:00Z',
|
||||
tags: ['周度复盘', '11月第三周'],
|
||||
stocks: ['002594.SZ', '600276.SH', '688981.SH']
|
||||
},
|
||||
{
|
||||
id: 310,
|
||||
user_id: 1,
|
||||
type: 'review',
|
||||
title: '机器人概念追高教训复盘',
|
||||
content: `【操作回顾】
|
||||
- 3月5日:看到机器人概念连续大涨,FOMO心态买入机器人ETF 5%仓位
|
||||
- 3月8日:继续上涨,追加3%仓位
|
||||
- 3月12日:见顶回落,犹豫不决
|
||||
- 3月18日:跌破成本价,仍抱有侥幸心理
|
||||
- 3月25日:止损出局,平均亏损12%
|
||||
|
||||
【盈亏分析】
|
||||
- 买入成本:约1.05元(均价)
|
||||
- 卖出价格:约0.92元
|
||||
- 亏损金额:约8%仓位 × 12% = 0.96%账户净值
|
||||
- 这是本年度最大单笔亏损
|
||||
- 教训成本:约5000元
|
||||
|
||||
【经验总结】
|
||||
1. 追高是最大的错误,概念炒作往往来去匆匆
|
||||
2. FOMO心态害死人,看到别人赚钱就想追
|
||||
3. 止损不坚决,跌破成本价时就应该走
|
||||
4. 对机器人行业基本面了解不够,纯粹是跟风
|
||||
5. 仓位太重,首次买入就5%,完全不符合试仓原则
|
||||
|
||||
【后续调整】
|
||||
- 概念炒作坚决不追高,只在调整时考虑
|
||||
- 任何新建仓位首次买入不超过2%
|
||||
- 设置硬性止损-5%,坚决执行
|
||||
- 不熟悉的领域少碰或只做小仓位
|
||||
- 记住这次教训,下次遇到类似情况要克制`,
|
||||
target_date: '2024-03-31',
|
||||
status: 'completed',
|
||||
created_at: '2024-04-01T09:00:00Z',
|
||||
updated_at: '2024-04-01T09:00:00Z',
|
||||
tags: ['教训复盘', '追高', '机器人'],
|
||||
stocks: []
|
||||
},
|
||||
{
|
||||
id: 311,
|
||||
user_id: 1,
|
||||
type: 'review',
|
||||
title: '半导体板块Q3操作复盘',
|
||||
content: `【操作回顾】
|
||||
7月份:
|
||||
- 买入中芯国际3%仓位,价格45元
|
||||
- 买入北方华创2%仓位,价格180元
|
||||
|
||||
8月份:
|
||||
- 中芯国际加仓1%,价格48元
|
||||
- 北方华创持仓不动
|
||||
|
||||
9月份:
|
||||
- 中芯国际在55元分批止盈2%
|
||||
- 北方华创在195元全部止盈
|
||||
- 保留中芯国际2%底仓
|
||||
|
||||
【盈亏分析】
|
||||
- 中芯国际:
|
||||
- 已止盈部分:收益率约20%
|
||||
- 剩余持仓:浮盈约15%
|
||||
- 北方华创:
|
||||
- 全部止盈,收益率约8%
|
||||
- Q3半导体板块总收益:约+3.2%账户净值
|
||||
- 板块贡献排名:第三(仅次于新能源和白酒)
|
||||
|
||||
【经验总结】
|
||||
1. 半导体板块波动大,不适合重仓长持
|
||||
2. 北方华创止盈过早,后来又涨了10%
|
||||
3. 中芯国际的分批止盈策略比较成功
|
||||
4. 应该更多关注设备和材料,而非制造环节
|
||||
5. 华为产业链相关标的值得持续关注
|
||||
|
||||
【后续调整】
|
||||
- Q4继续持有中芯国际底仓
|
||||
- 关注北方华创回调机会
|
||||
- 新增关注标的:长电科技、华虹半导体
|
||||
- 仓位目标:半导体板块不超过10%`,
|
||||
target_date: '2024-09-30',
|
||||
status: 'completed',
|
||||
created_at: '2024-10-08T10:00:00Z',
|
||||
updated_at: '2024-10-08T10:00:00Z',
|
||||
tags: ['季度复盘', '半导体', 'Q3'],
|
||||
stocks: ['688981.SH', '002371.SZ']
|
||||
}
|
||||
];
|
||||
|
||||
@@ -537,6 +896,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,
|
||||
|
||||
@@ -873,14 +873,17 @@ export function generateMockEvents(params = {}) {
|
||||
filteredEvents = filteredEvents.filter(e =>
|
||||
e.title.toLowerCase().includes(query) ||
|
||||
e.description.toLowerCase().includes(query) ||
|
||||
e.keywords.some(k => k.toLowerCase().includes(query))
|
||||
// keywords 是对象数组 { concept, score, ... },需要访问 concept 属性
|
||||
e.keywords.some(k => k.concept && k.concept.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
// 行业筛选
|
||||
if (industry_code) {
|
||||
filteredEvents = filteredEvents.filter(e =>
|
||||
e.industry.includes(industry_code) || e.keywords.includes(industry_code)
|
||||
e.industry.includes(industry_code) ||
|
||||
// keywords 是对象数组 { concept, ... },需要检查 concept 属性
|
||||
e.keywords.some(k => k.concept && k.concept.includes(industry_code))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -893,9 +896,11 @@ export function generateMockEvents(params = {}) {
|
||||
return false;
|
||||
}
|
||||
// 检查事件的 related_stocks 中是否包含该股票代码
|
||||
return e.related_stocks.some(code => {
|
||||
const cleanCode = code.replace(/\.(SH|SZ)$/, '');
|
||||
return cleanCode === cleanStockCode || code === stock_code;
|
||||
// related_stocks 是对象数组 { stock_code, stock_name, ... }
|
||||
return e.related_stocks.some(stock => {
|
||||
const stockCodeStr = stock.stock_code || '';
|
||||
const cleanCode = stockCodeStr.replace(/\.(SH|SZ)$/, '');
|
||||
return cleanCode === cleanStockCode || stockCodeStr === stock_code;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -434,13 +434,8 @@ export const accountHandlers = [
|
||||
http.get('/api/account/calendar/events', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{ success: false, error: '未登录' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
// Mock 模式下允许无登录访问,使用默认用户 id: 1
|
||||
const currentUser = getCurrentUser() || { id: 1 };
|
||||
|
||||
const url = new URL(request.url);
|
||||
const startDate = url.searchParams.get('start_date');
|
||||
@@ -455,8 +450,8 @@ export const accountHandlers = [
|
||||
}
|
||||
|
||||
// 2. 获取投资计划和复盘,转换为日历事件格式
|
||||
// Mock 模式:不过滤 user_id,显示所有 mock 数据(方便开发测试)
|
||||
const investmentPlansAsEvents = mockInvestmentPlans
|
||||
.filter(plan => plan.user_id === currentUser.id)
|
||||
.map(plan => ({
|
||||
id: plan.id,
|
||||
user_id: plan.user_id,
|
||||
@@ -489,10 +484,13 @@ export const accountHandlers = [
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[Mock] 合并后的日历事件数量:', {
|
||||
console.log('[Mock] 日历事件详情:', {
|
||||
currentUserId: currentUser.id,
|
||||
calendarEvents: calendarEvents.length,
|
||||
investmentPlansAsEvents: investmentPlansAsEvents.length,
|
||||
total: filteredEvents.length
|
||||
total: filteredEvents.length,
|
||||
plansCount: filteredEvents.filter(e => e.type === 'plan').length,
|
||||
reviewsCount: filteredEvents.filter(e => e.type === 'review').length
|
||||
});
|
||||
|
||||
return HttpResponse.json({
|
||||
|
||||
@@ -123,12 +123,12 @@ const generateStockList = () => {
|
||||
|
||||
// 股票相关的 Handlers
|
||||
export const stockHandlers = [
|
||||
// 搜索股票(个股中心页面使用)
|
||||
// 搜索股票(个股中心页面使用)- 支持模糊搜索
|
||||
http.get('/api/stocks/search', async ({ request }) => {
|
||||
await delay(200);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const query = url.searchParams.get('q') || '';
|
||||
const query = (url.searchParams.get('q') || '').toLowerCase().trim();
|
||||
const limit = parseInt(url.searchParams.get('limit') || '10');
|
||||
|
||||
console.log('[Mock Stock] 搜索股票:', { query, limit });
|
||||
@@ -136,22 +136,44 @@ export const stockHandlers = [
|
||||
const stocks = generateStockList();
|
||||
|
||||
// 如果没有搜索词,返回空结果
|
||||
if (!query.trim()) {
|
||||
if (!query) {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: []
|
||||
});
|
||||
}
|
||||
|
||||
// 过滤匹配的股票
|
||||
const results = stocks.filter(s =>
|
||||
s.code.includes(query) || s.name.includes(query)
|
||||
).slice(0, limit);
|
||||
// 模糊搜索:代码 + 名称(不区分大小写)
|
||||
const results = stocks.filter(s => {
|
||||
const code = s.code.toLowerCase();
|
||||
const name = s.name.toLowerCase();
|
||||
return code.includes(query) || name.includes(query);
|
||||
});
|
||||
|
||||
// 按相关性排序:完全匹配 > 开头匹配 > 包含匹配
|
||||
results.sort((a, b) => {
|
||||
const aCode = a.code.toLowerCase();
|
||||
const bCode = b.code.toLowerCase();
|
||||
const aName = a.name.toLowerCase();
|
||||
const bName = b.name.toLowerCase();
|
||||
|
||||
// 计算匹配分数
|
||||
const getScore = (code, name) => {
|
||||
if (code === query || name === query) return 100; // 完全匹配
|
||||
if (code.startsWith(query)) return 80; // 代码开头
|
||||
if (name.startsWith(query)) return 60; // 名称开头
|
||||
if (code.includes(query)) return 40; // 代码包含
|
||||
if (name.includes(query)) return 20; // 名称包含
|
||||
return 0;
|
||||
};
|
||||
|
||||
return getScore(bCode, bName) - getScore(aCode, aName);
|
||||
});
|
||||
|
||||
// 返回格式化数据
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: results.map(s => ({
|
||||
data: results.slice(0, limit).map(s => ({
|
||||
stock_code: s.code,
|
||||
stock_name: s.name,
|
||||
market: s.code.startsWith('6') ? 'SH' : 'SZ',
|
||||
|
||||
@@ -1,52 +1,11 @@
|
||||
// src/services/stockService.js
|
||||
// 股票数据服务
|
||||
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || '';
|
||||
// 股票数据服务 - 模糊搜索工具函数
|
||||
// 注意: getAllStocks 已迁移到 Redux (stockSlice.loadAllStocks)
|
||||
|
||||
/**
|
||||
* 股票数据服务
|
||||
*/
|
||||
export const stockService = {
|
||||
/**
|
||||
* 获取所有股票列表
|
||||
* @returns {Promise<{success: boolean, data: Array<{code: string, name: string}>}>}
|
||||
*/
|
||||
async getAllStocks() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/stocklist`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
logger.debug('stockService', 'getAllStocks 成功', {
|
||||
count: data?.length || 0
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data || []
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('stockService', 'getAllStocks', error);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 模糊搜索股票(匹配 code 或 name)
|
||||
* @param {string} query - 搜索关键词
|
||||
|
||||
15
src/store/hooks.ts
Normal file
15
src/store/hooks.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Redux Typed Hooks
|
||||
* 提供类型安全的 useDispatch 和 useSelector hooks
|
||||
*/
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { TypedUseSelectorHook } from 'react-redux';
|
||||
import { store } from './index';
|
||||
|
||||
// 从 store 推断类型
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
||||
// 类型化的 hooks
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch;
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
@@ -63,4 +63,9 @@ export const injectReducer = (key, reducer) => {
|
||||
store.replaceReducer(createRootReducer());
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {typeof store.dispatch} AppDispatch
|
||||
* @typedef {ReturnType<typeof store.getState>} RootState
|
||||
*/
|
||||
|
||||
export default store;
|
||||
|
||||
@@ -152,11 +152,11 @@ export const fetchExpectationScore = createAsyncThunk(
|
||||
);
|
||||
|
||||
/**
|
||||
* 加载用户自选股列表
|
||||
* 加载用户自选股列表(包含完整信息)
|
||||
*/
|
||||
export const loadWatchlist = createAsyncThunk(
|
||||
'stock/loadWatchlist',
|
||||
async (_, { getState }) => {
|
||||
async () => {
|
||||
logger.debug('stockSlice', 'loadWatchlist');
|
||||
|
||||
try {
|
||||
@@ -167,11 +167,15 @@ export const loadWatchlist = createAsyncThunk(
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
const stockCodes = data.data.map(item => item.stock_code);
|
||||
// 返回完整的股票信息,而不仅仅是 stock_code
|
||||
const watchlistData = data.data.map(item => ({
|
||||
stock_code: item.stock_code,
|
||||
stock_name: item.stock_name,
|
||||
}));
|
||||
logger.debug('stockSlice', '自选股列表加载成功', {
|
||||
count: stockCodes.length
|
||||
count: watchlistData.length
|
||||
});
|
||||
return stockCodes;
|
||||
return watchlistData;
|
||||
}
|
||||
|
||||
return [];
|
||||
@@ -182,6 +186,43 @@ export const loadWatchlist = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 加载全部股票列表(用于前端模糊搜索)
|
||||
*/
|
||||
export const loadAllStocks = createAsyncThunk(
|
||||
'stock/loadAllStocks',
|
||||
async (_, { getState }) => {
|
||||
// 检查缓存
|
||||
const cached = getState().stock.allStocks;
|
||||
if (cached && cached.length > 0) {
|
||||
logger.debug('stockSlice', 'allStocks 缓存命中', { count: cached.length });
|
||||
return cached;
|
||||
}
|
||||
|
||||
logger.debug('stockSlice', 'loadAllStocks');
|
||||
|
||||
try {
|
||||
const apiBase = getApiBase();
|
||||
const response = await fetch(`${apiBase}/api/stocklist`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
logger.debug('stockSlice', '全部股票列表加载成功', {
|
||||
count: data.length
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
logger.error('stockSlice', 'loadAllStocks', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 切换自选股状态
|
||||
*/
|
||||
@@ -219,7 +260,7 @@ export const toggleWatchlist = createAsyncThunk(
|
||||
throw new Error(data.error || '操作失败');
|
||||
}
|
||||
|
||||
return { stockCode, isInWatchlist };
|
||||
return { stockCode, stockName, isInWatchlist };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -246,9 +287,12 @@ const stockSlice = createSlice({
|
||||
// 超预期得分缓存 { [eventId]: score }
|
||||
expectationScores: {},
|
||||
|
||||
// 自选股列表 Set<stockCode>
|
||||
// 自选股列表 [{ stock_code, stock_name }]
|
||||
watchlist: [],
|
||||
|
||||
// 全部股票列表(用于前端模糊搜索)[{ code, name }]
|
||||
allStocks: [],
|
||||
|
||||
// 加载状态
|
||||
loading: {
|
||||
stocks: false,
|
||||
@@ -256,7 +300,8 @@ const stockSlice = createSlice({
|
||||
eventDetail: false,
|
||||
historicalEvents: false,
|
||||
chainAnalysis: false,
|
||||
watchlist: false
|
||||
watchlist: false,
|
||||
allStocks: false
|
||||
},
|
||||
|
||||
// 错误信息
|
||||
@@ -383,16 +428,29 @@ const stockSlice = createSlice({
|
||||
state.loading.watchlist = false;
|
||||
})
|
||||
|
||||
// ===== loadAllStocks =====
|
||||
.addCase(loadAllStocks.pending, (state) => {
|
||||
state.loading.allStocks = true;
|
||||
})
|
||||
.addCase(loadAllStocks.fulfilled, (state, action) => {
|
||||
state.allStocks = action.payload;
|
||||
state.loading.allStocks = false;
|
||||
})
|
||||
.addCase(loadAllStocks.rejected, (state) => {
|
||||
state.loading.allStocks = false;
|
||||
})
|
||||
|
||||
// ===== toggleWatchlist =====
|
||||
.addCase(toggleWatchlist.fulfilled, (state, action) => {
|
||||
const { stockCode, isInWatchlist } = action.payload;
|
||||
const { stockCode, stockName, isInWatchlist } = action.payload;
|
||||
if (isInWatchlist) {
|
||||
// 移除
|
||||
state.watchlist = state.watchlist.filter(code => code !== stockCode);
|
||||
state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode);
|
||||
} else {
|
||||
// 添加
|
||||
if (!state.watchlist.includes(stockCode)) {
|
||||
state.watchlist.push(stockCode);
|
||||
const exists = state.watchlist.some(item => item.stock_code === stockCode);
|
||||
if (!exists) {
|
||||
state.watchlist.push({ stock_code: stockCode, stock_name: stockName });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -46,8 +46,8 @@ export interface InvestmentEvent {
|
||||
/** 重要度 (1-5) */
|
||||
importance?: number;
|
||||
|
||||
/** 相关股票代码列表 */
|
||||
stocks?: string[];
|
||||
/** 相关股票列表 */
|
||||
stocks?: Array<{ code: string; name: string } | string>;
|
||||
|
||||
/** 标签列表 */
|
||||
tags?: string[];
|
||||
@@ -85,8 +85,8 @@ export interface PlanFormData {
|
||||
/** 事件类型 */
|
||||
type: EventType;
|
||||
|
||||
/** 相关股票代码列表 */
|
||||
stocks: string[];
|
||||
/** 相关股票列表 */
|
||||
stocks: Array<{ code: string; name: string } | string>;
|
||||
|
||||
/** 标签列表 */
|
||||
tags: string[];
|
||||
@@ -115,11 +115,11 @@ export interface PlanningContextValue {
|
||||
/** 设置加载状态 */
|
||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
/** 当前激活的标签页索引 (0: 日历, 1: 计划, 2: 复盘) */
|
||||
activeTab: number;
|
||||
/** 打开新建计划模态框的触发计数器 */
|
||||
openPlanModalTrigger?: number;
|
||||
|
||||
/** 设置激活的标签页 */
|
||||
setActiveTab: React.Dispatch<React.SetStateAction<number>>;
|
||||
/** 打开新建复盘模态框的触发计数器 */
|
||||
openReviewModalTrigger?: number;
|
||||
|
||||
/** Chakra UI Toast 实例 */
|
||||
toast: {
|
||||
@@ -131,9 +131,6 @@ export interface PlanningContextValue {
|
||||
};
|
||||
|
||||
// 颜色主题变量(基于当前主题模式)
|
||||
/** 背景色 */
|
||||
bgColor: string;
|
||||
|
||||
/** 边框颜色 */
|
||||
borderColor: string;
|
||||
|
||||
@@ -145,4 +142,11 @@ export interface PlanningContextValue {
|
||||
|
||||
/** 卡片背景色 */
|
||||
cardBg: string;
|
||||
|
||||
// 导航方法(用于空状态引导)
|
||||
/** 切换视图模式 */
|
||||
setViewMode: (mode: 'calendar' | 'list') => void;
|
||||
|
||||
/** 切换列表 Tab */
|
||||
setListTab: (tab: number) => void;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import dayjs from 'dayjs';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '@store/slices/industrySlice';
|
||||
import { loadAllStocks } from '@store/slices/stockSlice';
|
||||
import { stockService } from '@services/stockService';
|
||||
import { logger } from '@utils/logger';
|
||||
import TradingTimeFilter from './TradingTimeFilter';
|
||||
@@ -61,9 +62,12 @@ const CompactSearchBox = ({
|
||||
const dispatch = useDispatch();
|
||||
const industryData = useSelector(selectIndustryData);
|
||||
const industryLoading = useSelector(selectIndustryLoading);
|
||||
const reduxAllStocks = useSelector((state) => state.stock.allStocks);
|
||||
|
||||
// 防抖搜索
|
||||
const debouncedSearchRef = useRef(null);
|
||||
// 存储股票选择时的显示值(代码+名称),用于 useEffect 同步时显示完整信息
|
||||
const stockDisplayValueRef = useRef(null);
|
||||
|
||||
const triggerSearch = useCallback((params) => {
|
||||
logger.debug('CompactSearchBox', '触发搜索', { params });
|
||||
@@ -82,16 +86,19 @@ const CompactSearchBox = ({
|
||||
};
|
||||
}, [triggerSearch]);
|
||||
|
||||
// 加载股票数据
|
||||
// 加载股票数据(从 Redux 获取)
|
||||
useEffect(() => {
|
||||
const loadStocks = async () => {
|
||||
const response = await stockService.getAllStocks();
|
||||
if (response.success && response.data) {
|
||||
setAllStocks(response.data);
|
||||
}
|
||||
};
|
||||
loadStocks();
|
||||
}, []);
|
||||
if (!reduxAllStocks || reduxAllStocks.length === 0) {
|
||||
dispatch(loadAllStocks());
|
||||
}
|
||||
}, [dispatch, reduxAllStocks]);
|
||||
|
||||
// 同步 Redux 数据到本地状态
|
||||
useEffect(() => {
|
||||
if (reduxAllStocks && reduxAllStocks.length > 0) {
|
||||
setAllStocks(reduxAllStocks);
|
||||
}
|
||||
}, [reduxAllStocks]);
|
||||
|
||||
// 预加载行业数据(解决第一次点击无数据问题)
|
||||
useEffect(() => {
|
||||
@@ -143,9 +150,17 @@ const CompactSearchBox = ({
|
||||
}
|
||||
|
||||
if (filters.q) {
|
||||
setInputValue(filters.q);
|
||||
// 如果是股票选择触发的搜索,使用存储的显示值(代码+名称)
|
||||
if (stockDisplayValueRef.current && stockDisplayValueRef.current.code === filters.q) {
|
||||
setInputValue(stockDisplayValueRef.current.displayValue);
|
||||
} else {
|
||||
setInputValue(filters.q);
|
||||
// 清除已失效的显示值缓存
|
||||
stockDisplayValueRef.current = null;
|
||||
}
|
||||
} else if (!filters.q) {
|
||||
setInputValue('');
|
||||
stockDisplayValueRef.current = null;
|
||||
}
|
||||
|
||||
const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days;
|
||||
@@ -228,7 +243,7 @@ const CompactSearchBox = ({
|
||||
sort: actualSort,
|
||||
importance: importanceValue,
|
||||
q: (overrides.q ?? filters.q) ?? '',
|
||||
industry_code: overrides.industry_code ?? (industryValue?.[industryValue.length - 1] || ''),
|
||||
industry_code: overrides.industry_code ?? (industryValue?.join(',') || ''),
|
||||
start_date: overrides.start_date ?? (tradingTimeRange?.start_date || ''),
|
||||
end_date: overrides.end_date ?? (tradingTimeRange?.end_date || ''),
|
||||
recent_days: overrides.recent_days ?? (tradingTimeRange?.recent_days || ''),
|
||||
@@ -264,10 +279,13 @@ const CompactSearchBox = ({
|
||||
});
|
||||
}
|
||||
|
||||
setInputValue(`${stockInfo.code} ${stockInfo.name}`);
|
||||
const displayValue = `${stockInfo.code} ${stockInfo.name}`;
|
||||
setInputValue(displayValue);
|
||||
// 存储显示值,供 useEffect 同步时使用
|
||||
stockDisplayValueRef.current = { code: stockInfo.code, displayValue };
|
||||
|
||||
const params = buildFilterParams({
|
||||
q: stockInfo.code,
|
||||
q: stockInfo.code, // 接口只传代码
|
||||
industry_code: ''
|
||||
});
|
||||
triggerSearch(params);
|
||||
@@ -330,7 +348,7 @@ const CompactSearchBox = ({
|
||||
}
|
||||
|
||||
const params = buildFilterParams({
|
||||
industry_code: value?.[value.length - 1] || ''
|
||||
industry_code: value?.join(',') || ''
|
||||
});
|
||||
triggerSearch(params);
|
||||
};
|
||||
|
||||
@@ -469,13 +469,13 @@ const FlowingConcepts = () => {
|
||||
const row3 = concepts.slice(20, 30);
|
||||
|
||||
// 渲染单个概念卡片
|
||||
const renderConceptCard = (concept, globalIdx) => {
|
||||
const renderConceptCard = (concept, globalIdx, uniqueIdx) => {
|
||||
const colors = getColor(concept.change_pct);
|
||||
const isActive = hoveredIdx === globalIdx;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={globalIdx}
|
||||
key={`${globalIdx}-${uniqueIdx}`}
|
||||
flexShrink={0}
|
||||
px={3}
|
||||
py={2}
|
||||
@@ -582,7 +582,7 @@ const FlowingConcepts = () => {
|
||||
>
|
||||
{/* 复制两份实现无缝滚动 */}
|
||||
{[...items, ...items].map((concept, idx) =>
|
||||
renderConceptCard(concept, startIdx + (idx % items.length))
|
||||
renderConceptCard(concept, startIdx + (idx % items.length), idx)
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// src/views/Community/components/StockDetailPanel/hooks/useWatchlist.js
|
||||
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
|
||||
import { useEffect, useCallback, useMemo } from 'react';
|
||||
import { loadWatchlist, toggleWatchlist as toggleWatchlistAction } from '../../../../../store/slices/stockSlice';
|
||||
import { loadWatchlist, toggleWatchlist as toggleWatchlistAction } from '@store/slices/stockSlice';
|
||||
import { message } from 'antd';
|
||||
import { logger } from '../../../../../utils/logger';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* 标准化股票代码为6位格式
|
||||
@@ -41,8 +41,9 @@ export const useWatchlist = (shouldLoad = true) => {
|
||||
const loading = useSelector(state => state.stock.loading.watchlist);
|
||||
|
||||
// 转换为 Set 方便快速查询(标准化为6位代码)
|
||||
// 注意: watchlistArray 现在是 { stock_code, stock_name }[] 格式
|
||||
const watchlistSet = useMemo(() => {
|
||||
return new Set(watchlistArray.map(normalizeStockCode));
|
||||
return new Set(watchlistArray.map(item => normalizeStockCode(item.stock_code)));
|
||||
}, [watchlistArray]);
|
||||
|
||||
// 初始化时加载自选股列表(只在 shouldLoad 为 true 时)
|
||||
|
||||
@@ -273,18 +273,18 @@ export default function CenterDashboard() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box bg={sectionBg} minH="100vh">
|
||||
<Box px={{ base: 4, md: 8 }} py={6} maxW="1400px" mx="auto">
|
||||
<Box bg={sectionBg} minH="100vh" overflowX="hidden">
|
||||
<Box px={{ base: 3, md: 8 }} py={{ base: 4, md: 6 }} maxW="1400px" mx="auto">
|
||||
{/* 主要内容区域 */}
|
||||
<Grid templateColumns={{ base: '1fr', md: '1fr 1fr', lg: 'repeat(3, 1fr)' }} gap={6} mb={8}>
|
||||
<Grid templateColumns={{ base: '1fr', lg: 'repeat(3, 1fr)' }} gap={6} mb={8}>
|
||||
{/* 左列:自选股票 */}
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Card bg={cardBg} shadow="md" height="600px" display="flex" flexDirection="column">
|
||||
<CardHeader pb={4}>
|
||||
<VStack spacing={6} align="stretch" minW={0}>
|
||||
<Card bg={cardBg} shadow="md" maxW="100%" height={{ base: 'auto', md: '600px' }} minH={{ base: '300px', md: '600px' }} display="flex" flexDirection="column">
|
||||
<CardHeader pb={{ base: 2, md: 4 }}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack>
|
||||
<Icon as={FiBarChart2} color="blue.500" boxSize={5} />
|
||||
<Heading size="md">自选股票</Heading>
|
||||
<Icon as={FiBarChart2} color="blue.500" boxSize={{ base: 4, md: 5 }} />
|
||||
<Heading size={{ base: 'sm', md: 'md' }}>自选股票</Heading>
|
||||
<Badge colorScheme="blue" variant="subtle">
|
||||
{watchlist.length}
|
||||
</Badge>
|
||||
@@ -321,7 +321,7 @@ export default function CenterDashboard() {
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{watchlist.slice(0, 10).map((stock) => (
|
||||
<LinkBox
|
||||
key={stock.id}
|
||||
key={stock.stock_code}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: hoverBg }}
|
||||
@@ -388,14 +388,14 @@ export default function CenterDashboard() {
|
||||
</VStack>
|
||||
|
||||
{/* 中列:关注事件 */}
|
||||
<VStack spacing={6} align="stretch">
|
||||
<VStack spacing={6} align="stretch" minW={0}>
|
||||
{/* 关注事件 */}
|
||||
<Card bg={cardBg} shadow="md" height="600px" display="flex" flexDirection="column">
|
||||
<CardHeader pb={4}>
|
||||
<Card bg={cardBg} shadow="md" maxW="100%" height={{ base: 'auto', md: '600px' }} minH={{ base: '300px', md: '600px' }} display="flex" flexDirection="column">
|
||||
<CardHeader pb={{ base: 2, md: 4 }}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack>
|
||||
<Icon as={FiStar} color="yellow.500" boxSize={5} />
|
||||
<Heading size="md">关注事件</Heading>
|
||||
<Icon as={FiStar} color="yellow.500" boxSize={{ base: 4, md: 5 }} />
|
||||
<Heading size={{ base: 'sm', md: 'md' }}>关注事件</Heading>
|
||||
<Badge colorScheme="yellow" variant="subtle">
|
||||
{followingEvents.length}
|
||||
</Badge>
|
||||
@@ -525,14 +525,14 @@ export default function CenterDashboard() {
|
||||
</VStack>
|
||||
|
||||
{/* 右列:我的评论 */}
|
||||
<VStack spacing={6} align="stretch">
|
||||
<VStack spacing={6} align="stretch" minW={0}>
|
||||
{/* 我的评论 */}
|
||||
<Card bg={cardBg} shadow="md" height="600px" display="flex" flexDirection="column">
|
||||
<CardHeader pb={4}>
|
||||
<Card bg={cardBg} shadow="md" maxW="100%" height={{ base: 'auto', md: '600px' }} minH={{ base: '300px', md: '600px' }} display="flex" flexDirection="column">
|
||||
<CardHeader pb={{ base: 2, md: 4 }}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack>
|
||||
<Icon as={FiMessageSquare} color="purple.500" boxSize={5} />
|
||||
<Heading size="md">我的评论</Heading>
|
||||
<Icon as={FiMessageSquare} color="purple.500" boxSize={{ base: 4, md: 5 }} />
|
||||
<Heading size={{ base: 'sm', md: 'md' }}>我的评论</Heading>
|
||||
<Badge colorScheme="purple" variant="subtle">
|
||||
{eventComments.length}
|
||||
</Badge>
|
||||
@@ -568,15 +568,22 @@ export default function CenterDashboard() {
|
||||
<Text fontSize="sm" noOfLines={3}>
|
||||
{comment.content}
|
||||
</Text>
|
||||
<HStack justify="space-between" fontSize="xs" color={secondaryText}>
|
||||
<HStack>
|
||||
<HStack justify="space-between" fontSize="xs" color={secondaryText} spacing={2}>
|
||||
<HStack flexShrink={0}>
|
||||
<Icon as={FiClock} />
|
||||
<Text>{formatDate(comment.created_at)}</Text>
|
||||
</HStack>
|
||||
{comment.event_title && (
|
||||
<Tooltip label={comment.event_title}>
|
||||
<Badge variant="subtle" fontSize="xs">
|
||||
{comment.event_title.slice(0, 20)}...
|
||||
<Badge
|
||||
variant="subtle"
|
||||
fontSize="xs"
|
||||
maxW={{ base: '120px', md: '180px' }}
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{comment.event_title}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -3,44 +3,18 @@
|
||||
* 使用 FullCalendar 展示投资计划、复盘等事件
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, lazy, Suspense, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
Tooltip,
|
||||
Icon,
|
||||
Input,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiPlus,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiStar,
|
||||
FiTrendingUp,
|
||||
} from 'react-icons/fi';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
@@ -50,23 +24,15 @@ import dayjs, { Dayjs } from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import type { InvestmentEvent, EventType } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
import { EventDetailModal } from './EventDetailModal';
|
||||
import type { InvestmentEvent } from '@/types';
|
||||
import './InvestmentCalendar.less';
|
||||
|
||||
// 懒加载投资日历组件
|
||||
const InvestmentCalendar = lazy(() => import('@/views/Community/components/InvestmentCalendar'));
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
/**
|
||||
* 新事件表单数据类型
|
||||
*/
|
||||
interface NewEventForm {
|
||||
title: string;
|
||||
description: string;
|
||||
type: EventType;
|
||||
importance: number;
|
||||
stocks: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FullCalendar 事件类型
|
||||
*/
|
||||
@@ -89,416 +55,149 @@ interface CalendarEvent {
|
||||
export const CalendarPanel: React.FC = () => {
|
||||
const {
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
setActiveTab,
|
||||
toast,
|
||||
borderColor,
|
||||
secondaryText,
|
||||
setViewMode,
|
||||
setListTab,
|
||||
} = usePlanningData();
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure();
|
||||
// 弹窗状态(统一使用 useState)
|
||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||
const [isInvestmentCalendarOpen, setIsInvestmentCalendarOpen] = useState(false);
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
|
||||
const [selectedDateEvents, setSelectedDateEvents] = useState<InvestmentEvent[]>([]);
|
||||
const [newEvent, setNewEvent] = useState<NewEventForm>({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'plan',
|
||||
importance: 3,
|
||||
stocks: '',
|
||||
});
|
||||
|
||||
// 转换数据为 FullCalendar 格式
|
||||
const calendarEvents: CalendarEvent[] = allEvents.map(event => ({
|
||||
...event,
|
||||
id: `${event.source || 'user'}-${event.id}`,
|
||||
title: event.title,
|
||||
start: event.event_date,
|
||||
date: event.event_date,
|
||||
backgroundColor: event.source === 'future' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169',
|
||||
borderColor: event.source === 'future' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169',
|
||||
extendedProps: {
|
||||
// 转换数据为 FullCalendar 格式(使用 useMemo 缓存)
|
||||
const calendarEvents: CalendarEvent[] = useMemo(() =>
|
||||
allEvents.map(event => ({
|
||||
...event,
|
||||
isSystem: event.source === 'future',
|
||||
}
|
||||
}));
|
||||
id: `${event.source || 'user'}-${event.id}`,
|
||||
title: event.title,
|
||||
start: event.event_date,
|
||||
date: event.event_date,
|
||||
backgroundColor: event.source === 'future' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169',
|
||||
borderColor: event.source === 'future' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169',
|
||||
extendedProps: {
|
||||
...event,
|
||||
isSystem: event.source === 'future',
|
||||
}
|
||||
})), [allEvents]);
|
||||
|
||||
// 处理日期点击
|
||||
const handleDateClick = (info: DateClickArg): void => {
|
||||
const clickedDate = dayjs(info.date);
|
||||
// 抽取公共的打开事件详情函数
|
||||
const openEventDetail = useCallback((date: Date | null): void => {
|
||||
if (!date) return;
|
||||
const clickedDate = dayjs(date);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
const dayEvents = allEvents.filter(event =>
|
||||
dayjs(event.event_date).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
};
|
||||
setIsDetailModalOpen(true);
|
||||
}, [allEvents]);
|
||||
|
||||
// 处理日期点击
|
||||
const handleDateClick = useCallback((info: DateClickArg): void => {
|
||||
openEventDetail(info.date);
|
||||
}, [openEventDetail]);
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = (info: EventClickArg): void => {
|
||||
const event = info.event;
|
||||
const clickedDate = dayjs(event.start);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
const dayEvents = allEvents.filter(ev =>
|
||||
dayjs(ev.event_date).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 添加新事件
|
||||
const handleAddEvent = async (): Promise<void> => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const eventData = {
|
||||
...newEvent,
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD')),
|
||||
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
|
||||
};
|
||||
|
||||
const response = await fetch(base + '/api/account/calendar/events', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(eventData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
logger.info('CalendarPanel', '添加事件成功', {
|
||||
eventTitle: eventData.title,
|
||||
eventDate: eventData.event_date
|
||||
});
|
||||
toast({
|
||||
title: '添加成功',
|
||||
description: '投资计划已添加',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
onAddClose();
|
||||
loadAllData();
|
||||
setNewEvent({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'plan',
|
||||
importance: 3,
|
||||
stocks: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('CalendarPanel', 'handleAddEvent', error, {
|
||||
eventTitle: newEvent?.title
|
||||
});
|
||||
toast({
|
||||
title: '添加失败',
|
||||
description: '无法添加投资计划',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除事件
|
||||
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 = (event: InvestmentEvent): void => {
|
||||
if (event.type === 'plan') {
|
||||
setActiveTab(1); // 跳转到"我的计划"标签页
|
||||
} else if (event.type === 'review') {
|
||||
setActiveTab(2); // 跳转到"我的复盘"标签页
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
const handleEventClick = useCallback((info: EventClickArg): void => {
|
||||
openEventDetail(info.event.start);
|
||||
}, [openEventDetail]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Flex justify="flex-end" mb={4}>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => {
|
||||
if (!selectedDate) setSelectedDate(dayjs());
|
||||
onAddOpen();
|
||||
<Box
|
||||
height={{ base: '380px', md: '560px' }}
|
||||
sx={{
|
||||
// FullCalendar 按钮样式覆盖(与日历视图按钮颜色一致)
|
||||
'.fc .fc-button': {
|
||||
backgroundColor: '#805AD5 !important',
|
||||
borderColor: '#805AD5 !important',
|
||||
color: '#fff !important',
|
||||
'&:hover': {
|
||||
backgroundColor: '#6B46C1 !important',
|
||||
borderColor: '#6B46C1 !important',
|
||||
},
|
||||
'&:disabled': {
|
||||
backgroundColor: '#6B46C1 !important',
|
||||
borderColor: '#6B46C1 !important',
|
||||
opacity: '1 !important',
|
||||
},
|
||||
},
|
||||
// 今天日期高亮边框
|
||||
'.fc-daygrid-day.fc-day-today': {
|
||||
border: '2px solid #805AD5 !important',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, interactionPlugin]}
|
||||
initialView="dayGridMonth"
|
||||
locale="zh-cn"
|
||||
headerToolbar={{
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: ''
|
||||
}}
|
||||
>
|
||||
添加计划
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center h="560px">
|
||||
<Spinner size="xl" color="purple.500" />
|
||||
</Center>
|
||||
) : (
|
||||
<Box height={{ base: '500px', md: '600px' }}>
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, interactionPlugin]}
|
||||
initialView="dayGridMonth"
|
||||
locale="zh-cn"
|
||||
headerToolbar={{
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: ''
|
||||
}}
|
||||
events={calendarEvents}
|
||||
dateClick={handleDateClick}
|
||||
eventClick={handleEventClick}
|
||||
height="100%"
|
||||
dayMaxEvents={3}
|
||||
moreLinkText="更多"
|
||||
buttonText={{
|
||||
today: '今天',
|
||||
month: '月',
|
||||
week: '周'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
events={calendarEvents}
|
||||
dateClick={handleDateClick}
|
||||
eventClick={handleEventClick}
|
||||
height="100%"
|
||||
dayMaxEvents={1}
|
||||
moreLinkText="+更多"
|
||||
buttonText={{
|
||||
today: '今天',
|
||||
month: '月',
|
||||
week: '周'
|
||||
}}
|
||||
titleFormat={{ year: 'numeric', month: 'long' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 查看事件详情 Modal */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<EventDetailModal
|
||||
isOpen={isDetailModalOpen}
|
||||
onClose={() => setIsDetailModalOpen(false)}
|
||||
selectedDate={selectedDate}
|
||||
events={selectedDateEvents}
|
||||
borderColor={borderColor}
|
||||
secondaryText={secondaryText}
|
||||
onNavigateToPlan={() => {
|
||||
setViewMode('list');
|
||||
setListTab(0);
|
||||
}}
|
||||
onNavigateToReview={() => {
|
||||
setViewMode('list');
|
||||
setListTab(1);
|
||||
}}
|
||||
onOpenInvestmentCalendar={() => {
|
||||
setIsInvestmentCalendarOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 投资日历 Modal */}
|
||||
{isInvestmentCalendarOpen && (
|
||||
<Modal
|
||||
isOpen={isInvestmentCalendarOpen}
|
||||
onClose={() => setIsInvestmentCalendarOpen(false)}
|
||||
size={{ base: 'full', md: '6xl' }}
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{selectedDate && selectedDate.format('YYYY年MM月DD日')} 的事件
|
||||
</ModalHeader>
|
||||
<ModalContent maxW={{ base: '100%', md: '1200px' }} mx={{ base: 0, md: 4 }}>
|
||||
<ModalHeader fontSize={{ base: 'md', md: 'lg' }} py={{ base: 3, md: 4 }}>投资日历</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{selectedDateEvents.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack>
|
||||
<Text color={secondaryText}>当天没有事件</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onAddOpen();
|
||||
}}
|
||||
>
|
||||
添加投资计划
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{selectedDateEvents.map((event, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={4}
|
||||
borderRadius="md"
|
||||
border="1px"
|
||||
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(event)}
|
||||
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>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
<ModalBody pb={6}>
|
||||
<Suspense fallback={<Center py={{ base: 6, md: 8 }}><Spinner size={{ base: 'lg', md: 'xl' }} color="blue.500" /></Center>}>
|
||||
<InvestmentCalendar />
|
||||
</Suspense>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 添加投资计划 Modal */}
|
||||
{isAddOpen && (
|
||||
<Modal isOpen={isAddOpen} onClose={onAddClose} size="lg" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
添加投资计划
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={newEvent.title}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, title: e.target.value })}
|
||||
placeholder="例如:关注半导体板块"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>描述</FormLabel>
|
||||
<Textarea
|
||||
value={newEvent.description}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, description: e.target.value })}
|
||||
placeholder="详细描述您的投资计划..."
|
||||
rows={3}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>类型</FormLabel>
|
||||
<Select
|
||||
value={newEvent.type}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, type: e.target.value as EventType })}
|
||||
>
|
||||
<option value="plan">投资计划</option>
|
||||
<option value="review">投资复盘</option>
|
||||
<option value="reminder">提醒事项</option>
|
||||
<option value="analysis">分析任务</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>重要度</FormLabel>
|
||||
<Select
|
||||
value={newEvent.importance}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, importance: parseInt(e.target.value) })}
|
||||
>
|
||||
<option value={5}>⭐⭐⭐⭐⭐ 非常重要</option>
|
||||
<option value={4}>⭐⭐⭐⭐ 重要</option>
|
||||
<option value={3}>⭐⭐⭐ 一般</option>
|
||||
<option value={2}>⭐⭐ 次要</option>
|
||||
<option value={1}>⭐ 不重要</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票(用逗号分隔)</FormLabel>
|
||||
<Input
|
||||
value={newEvent.stocks}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, stocks: e.target.value })}
|
||||
placeholder="例如:600519,000858,002415"
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onAddClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="purple"
|
||||
onClick={handleAddEvent}
|
||||
isDisabled={!newEvent.title}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
296
src/views/Dashboard/components/EventCard.tsx
Normal file
296
src/views/Dashboard/components/EventCard.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* EventCard - 统一的投资事件卡片组件
|
||||
*
|
||||
* 通过 variant 属性控制两种显示模式:
|
||||
* - list: 列表模式(EventPanel 中使用,带编辑/删除按钮)
|
||||
* - detail: 详情模式(日历弹窗中使用,显示类型徽章)
|
||||
*
|
||||
* 两种模式都支持:
|
||||
* - 标题显示
|
||||
* - 描述内容展开/收起
|
||||
* - 股票标签显示
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, memo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
Card,
|
||||
CardBody,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Icon,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
Button,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiFileText,
|
||||
FiCalendar,
|
||||
FiTrendingUp,
|
||||
FiChevronDown,
|
||||
FiChevronUp,
|
||||
} from 'react-icons/fi';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import type { InvestmentEvent, EventType, EventSource } from '@/types';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
/**
|
||||
* 卡片变体类型
|
||||
*/
|
||||
export type EventCardVariant = 'list' | 'detail';
|
||||
|
||||
/**
|
||||
* 类型信息接口
|
||||
*/
|
||||
interface TypeInfo {
|
||||
color: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取类型信息
|
||||
*/
|
||||
const getTypeInfo = (type?: EventType, source?: EventSource): TypeInfo => {
|
||||
if (source === 'future') {
|
||||
return { color: 'blue', text: '系统事件' };
|
||||
}
|
||||
if (type === 'plan') {
|
||||
return { color: 'purple', text: '我的计划' };
|
||||
}
|
||||
if (type === 'review') {
|
||||
return { color: 'green', text: '我的复盘' };
|
||||
}
|
||||
return { color: 'gray', text: '未知类型' };
|
||||
};
|
||||
|
||||
/**
|
||||
* EventCard Props
|
||||
*/
|
||||
export interface EventCardProps {
|
||||
/** 事件数据 */
|
||||
event: InvestmentEvent;
|
||||
/** 卡片变体: list(列表模式) | detail(详情模式) */
|
||||
variant?: EventCardVariant;
|
||||
/** 主题颜色(list 模式) */
|
||||
colorScheme?: string;
|
||||
/** 显示标签(用于 aria-label) */
|
||||
label?: string;
|
||||
/** 主要文本颜色 */
|
||||
textColor?: string;
|
||||
/** 次要文本颜色 */
|
||||
secondaryText?: string;
|
||||
/** 卡片背景色(list 模式) */
|
||||
cardBg?: string;
|
||||
/** 边框颜色(detail 模式) */
|
||||
borderColor?: string;
|
||||
/** 编辑回调(list 模式) */
|
||||
onEdit?: (event: InvestmentEvent) => void;
|
||||
/** 删除回调(list 模式) */
|
||||
onDelete?: (id: number) => void;
|
||||
}
|
||||
|
||||
/** 描述最大显示行数 */
|
||||
const MAX_LINES = 3;
|
||||
|
||||
/**
|
||||
* EventCard 组件
|
||||
*/
|
||||
export const EventCard = memo<EventCardProps>(({
|
||||
event,
|
||||
variant = 'list',
|
||||
colorScheme = 'purple',
|
||||
label = '事件',
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
borderColor,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
// 展开/收起状态
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isOverflow, setIsOverflow] = useState(false);
|
||||
const descriptionRef = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
// 默认颜色值(使用 hooks)
|
||||
const defaultTextColor = useColorModeValue('gray.700', 'white');
|
||||
const defaultSecondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
const defaultCardBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const defaultBorderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
// 使用传入的值或默认值
|
||||
const finalTextColor = textColor || defaultTextColor;
|
||||
const finalSecondaryText = secondaryText || defaultSecondaryText;
|
||||
const finalCardBg = cardBg || defaultCardBg;
|
||||
const finalBorderColor = borderColor || defaultBorderColor;
|
||||
|
||||
// 获取描述内容
|
||||
const description = event.description || event.content || '';
|
||||
|
||||
// 检测描述是否溢出
|
||||
useEffect(() => {
|
||||
const el = descriptionRef.current;
|
||||
if (el && description) {
|
||||
const lineHeight = parseInt(getComputedStyle(el).lineHeight) || 20;
|
||||
const maxHeight = lineHeight * MAX_LINES;
|
||||
setIsOverflow(el.scrollHeight > maxHeight + 5);
|
||||
} else {
|
||||
setIsOverflow(false);
|
||||
}
|
||||
}, [description]);
|
||||
|
||||
// 获取类型信息
|
||||
const typeInfo = getTypeInfo(event.type, event.source);
|
||||
|
||||
// 是否为 list 模式
|
||||
const isListMode = variant === 'list';
|
||||
|
||||
// 渲染容器
|
||||
const renderContainer = (children: React.ReactNode) => {
|
||||
if (isListMode) {
|
||||
return (
|
||||
<Card
|
||||
bg={finalCardBg}
|
||||
shadow="sm"
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<CardBody p={{ base: 2, md: 3 }}>
|
||||
{children}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
p={{ base: 3, md: 4 }}
|
||||
borderRadius="md"
|
||||
border="1px"
|
||||
borderColor={finalBorderColor}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return renderContainer(
|
||||
<VStack align="stretch" spacing={{ base: 2, md: 3 }}>
|
||||
{/* 头部区域:标题 + 徽章 + 操作按钮 */}
|
||||
<Flex justify="space-between" align="start" gap={{ base: 1, md: 2 }}>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
{/* 标题行 */}
|
||||
<HStack spacing={{ base: 1, md: 2 }} flexWrap="wrap">
|
||||
<Icon as={FiFileText} color={`${colorScheme}.500`} boxSize={{ base: 4, md: 5 }} />
|
||||
<Text fontWeight="bold" fontSize={{ base: 'md', md: 'lg' }}>
|
||||
{event.title}
|
||||
</Text>
|
||||
{/* detail 模式显示类型徽章 */}
|
||||
{!isListMode && (
|
||||
<Badge colorScheme={typeInfo.color} variant="subtle" fontSize={{ base: 'xs', md: 'sm' }}>
|
||||
{typeInfo.text}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* list 模式显示日期 */}
|
||||
{isListMode && (
|
||||
<HStack spacing={{ base: 1, md: 2 }} flexWrap="wrap">
|
||||
<Icon as={FiCalendar} boxSize={{ base: 2.5, md: 3 }} color={finalSecondaryText} />
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} color={finalSecondaryText}>
|
||||
{dayjs(event.event_date || event.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* list 模式显示编辑/删除按钮 */}
|
||||
{isListMode && (onEdit || onDelete) && (
|
||||
<HStack spacing={{ base: 0, md: 1 }}>
|
||||
{onEdit && (
|
||||
<IconButton
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onEdit(event)}
|
||||
aria-label={`编辑${label}`}
|
||||
/>
|
||||
)}
|
||||
{onDelete && (
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => onDelete(event.id)}
|
||||
aria-label={`删除${label}`}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* 描述内容(可展开/收起) */}
|
||||
{description && (
|
||||
<Box>
|
||||
<Text
|
||||
ref={descriptionRef}
|
||||
fontSize={{ base: 'xs', md: 'sm' }}
|
||||
color={finalTextColor}
|
||||
noOfLines={isExpanded ? undefined : MAX_LINES}
|
||||
whiteSpace="pre-wrap"
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
{isOverflow && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
colorScheme={colorScheme}
|
||||
mt={1}
|
||||
rightIcon={isExpanded ? <FiChevronUp /> : <FiChevronDown />}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? '收起' : '展开'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 股票标签 */}
|
||||
{event.stocks && event.stocks.length > 0 && (
|
||||
<HStack spacing={{ base: 1, md: 2 }} flexWrap="wrap" gap={1}>
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} color={finalSecondaryText}>
|
||||
相关股票:
|
||||
</Text>
|
||||
{event.stocks.map((stock, idx) => {
|
||||
// 兼容两种格式:对象 {code, name} 或字符串
|
||||
const stockCode = typeof stock === 'string' ? stock : stock.code;
|
||||
const stockName = typeof stock === 'string' ? stock : stock.name;
|
||||
const displayText = typeof stock === 'string' ? stock : `${stock.name}(${stock.code})`;
|
||||
return (
|
||||
<Tag key={stockCode || idx} size="sm" colorScheme="blue" variant="subtle">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{displayText}</TagLabel>
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
});
|
||||
|
||||
EventCard.displayName = 'EventCard';
|
||||
|
||||
export default EventCard;
|
||||
99
src/views/Dashboard/components/EventDetailModal.tsx
Normal file
99
src/views/Dashboard/components/EventDetailModal.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* EventDetailModal - 事件详情弹窗组件
|
||||
* 用于展示某一天的所有投资事件
|
||||
* 使用 Ant Design 实现
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal, Space } from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import { EventCard } from './EventCard';
|
||||
import { EventEmptyState } from './EventEmptyState';
|
||||
import type { InvestmentEvent } from '@/types';
|
||||
|
||||
/**
|
||||
* EventDetailModal Props
|
||||
*/
|
||||
export interface EventDetailModalProps {
|
||||
/** 是否打开 */
|
||||
isOpen: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
/** 选中的日期 */
|
||||
selectedDate: Dayjs | null;
|
||||
/** 选中日期的事件列表 */
|
||||
events: InvestmentEvent[];
|
||||
/** 边框颜色 */
|
||||
borderColor?: string;
|
||||
/** 次要文字颜色 */
|
||||
secondaryText?: string;
|
||||
/** 导航到计划列表 */
|
||||
onNavigateToPlan?: () => void;
|
||||
/** 导航到复盘列表 */
|
||||
onNavigateToReview?: () => void;
|
||||
/** 打开投资日历 */
|
||||
onOpenInvestmentCalendar?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* EventDetailModal 组件
|
||||
*/
|
||||
export const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedDate,
|
||||
events,
|
||||
borderColor,
|
||||
secondaryText,
|
||||
onNavigateToPlan,
|
||||
onNavigateToReview,
|
||||
onOpenInvestmentCalendar,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onCancel={onClose}
|
||||
title={`${selectedDate?.format('YYYY年MM月DD日') || ''} 的事件`}
|
||||
footer={null}
|
||||
width={600}
|
||||
maskClosable={false}
|
||||
keyboard={true}
|
||||
centered
|
||||
styles={{
|
||||
body: { paddingTop: 16, paddingBottom: 24 },
|
||||
}}
|
||||
>
|
||||
{events.length === 0 ? (
|
||||
<EventEmptyState
|
||||
onNavigateToPlan={() => {
|
||||
onClose();
|
||||
onNavigateToPlan?.();
|
||||
}}
|
||||
onNavigateToReview={() => {
|
||||
onClose();
|
||||
onNavigateToReview?.();
|
||||
}}
|
||||
onOpenInvestmentCalendar={() => {
|
||||
onClose();
|
||||
onOpenInvestmentCalendar?.();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{events.map((event, idx) => (
|
||||
<EventCard
|
||||
key={idx}
|
||||
event={event}
|
||||
variant="detail"
|
||||
borderColor={borderColor}
|
||||
secondaryText={secondaryText}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetailModal;
|
||||
94
src/views/Dashboard/components/EventEmptyState.tsx
Normal file
94
src/views/Dashboard/components/EventEmptyState.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* EventEmptyState - 事件空状态组件
|
||||
* 用于展示日历无事件时的引导提示
|
||||
* 使用 Ant Design 实现
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Typography, Space, Empty } from 'antd';
|
||||
import { CalendarOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Text, Link } = Typography;
|
||||
|
||||
/**
|
||||
* EventEmptyState Props
|
||||
*/
|
||||
export interface EventEmptyStateProps {
|
||||
/** 空状态提示文字 */
|
||||
message?: string;
|
||||
/** 导航到计划列表 */
|
||||
onNavigateToPlan?: () => void;
|
||||
/** 导航到复盘列表 */
|
||||
onNavigateToReview?: () => void;
|
||||
/** 打开投资日历 */
|
||||
onOpenInvestmentCalendar?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* EventEmptyState 组件
|
||||
*/
|
||||
export const EventEmptyState: React.FC<EventEmptyStateProps> = ({
|
||||
message = '当天暂无事件',
|
||||
onNavigateToPlan,
|
||||
onNavigateToReview,
|
||||
onOpenInvestmentCalendar,
|
||||
}) => {
|
||||
// 是否显示引导链接
|
||||
const showGuide = onNavigateToPlan || onNavigateToReview || onOpenInvestmentCalendar;
|
||||
|
||||
// 渲染描述内容
|
||||
const renderDescription = () => {
|
||||
if (!showGuide) {
|
||||
return <Text type="secondary">{message}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={4} style={{ textAlign: 'center' }}>
|
||||
<Text type="secondary">{message}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
可在
|
||||
{onNavigateToPlan && (
|
||||
<Link
|
||||
style={{ margin: '0 4px', color: '#8B5CF6' }}
|
||||
onClick={onNavigateToPlan}
|
||||
>
|
||||
计划
|
||||
</Link>
|
||||
)}
|
||||
{onNavigateToPlan && onNavigateToReview && '或'}
|
||||
{onNavigateToReview && (
|
||||
<Link
|
||||
style={{ margin: '0 4px', color: '#38A169' }}
|
||||
onClick={onNavigateToReview}
|
||||
>
|
||||
复盘
|
||||
</Link>
|
||||
)}
|
||||
添加
|
||||
{onOpenInvestmentCalendar && (
|
||||
<>
|
||||
,或关注
|
||||
<Link
|
||||
style={{ margin: '0 4px', color: '#3182CE' }}
|
||||
onClick={onOpenInvestmentCalendar}
|
||||
>
|
||||
投资日历
|
||||
</Link>
|
||||
中的未来事件
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Empty
|
||||
image={<CalendarOutlined style={{ fontSize: 48, color: '#d9d9d9' }} />}
|
||||
imageStyle={{ height: 60 }}
|
||||
description={renderDescription()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventEmptyState;
|
||||
198
src/views/Dashboard/components/EventFormModal.less
Normal file
198
src/views/Dashboard/components/EventFormModal.less
Normal file
@@ -0,0 +1,198 @@
|
||||
/* EventFormModal.less - 投资计划/复盘弹窗响应式样式 */
|
||||
|
||||
// ==================== 变量定义 ====================
|
||||
@mobile-breakpoint: 768px;
|
||||
@modal-border-radius-mobile: 12px;
|
||||
@modal-border-radius-desktop: 8px;
|
||||
|
||||
// 间距
|
||||
@spacing-xs: 4px;
|
||||
@spacing-sm: 8px;
|
||||
@spacing-md: 12px;
|
||||
@spacing-lg: 16px;
|
||||
@spacing-xl: 20px;
|
||||
@spacing-xxl: 24px;
|
||||
|
||||
// 字体大小
|
||||
@font-size-xs: 12px;
|
||||
@font-size-sm: 14px;
|
||||
@font-size-md: 16px;
|
||||
|
||||
// 颜色
|
||||
@color-border: #f0f0f0;
|
||||
@color-text-secondary: #999;
|
||||
@color-error: #ff4d4f;
|
||||
|
||||
// ==================== 主样式 ====================
|
||||
.event-form-modal {
|
||||
// Modal 整体
|
||||
.ant-modal-content {
|
||||
border-radius: @modal-border-radius-desktop;
|
||||
}
|
||||
|
||||
// Modal 标题放大加粗
|
||||
.ant-modal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: @spacing-xxl;
|
||||
padding-top: 36px; // 增加标题与表单间距
|
||||
}
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: @spacing-xl;
|
||||
}
|
||||
|
||||
// 表单标签加粗,左对齐
|
||||
.ant-form-item-label {
|
||||
text-align: left !important;
|
||||
|
||||
> label {
|
||||
font-weight: 600 !important;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
// 字符计数样式
|
||||
.ant-input-textarea-show-count::after {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-secondary;
|
||||
}
|
||||
|
||||
// 日期选择器全宽
|
||||
.ant-picker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// 股票标签样式
|
||||
.ant-tag {
|
||||
margin: 2px;
|
||||
border-radius: @spacing-xs;
|
||||
}
|
||||
|
||||
// 模板按钮组
|
||||
.template-buttons {
|
||||
.ant-btn {
|
||||
font-size: @font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
// 底部操作栏布局
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
.ant-btn-loading {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
// 错误状态动画
|
||||
.ant-form-item-has-error {
|
||||
.ant-input,
|
||||
.ant-picker,
|
||||
.ant-select-selector {
|
||||
animation: shake 0.3s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 移动端适配 ====================
|
||||
@media (max-width: @mobile-breakpoint) {
|
||||
.event-form-modal {
|
||||
// Modal 整体尺寸
|
||||
.ant-modal {
|
||||
width: calc(100vw - 32px) !important;
|
||||
max-width: 100% !important;
|
||||
margin: @spacing-lg auto;
|
||||
top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: @modal-border-radius-mobile;
|
||||
}
|
||||
|
||||
// Modal 头部
|
||||
.ant-modal-header {
|
||||
padding: @spacing-md @spacing-lg;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ant-modal-title {
|
||||
font-size: @font-size-md;
|
||||
}
|
||||
|
||||
// Modal 内容区域
|
||||
.ant-modal-body {
|
||||
padding: @spacing-lg;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
// Modal 底部
|
||||
.ant-modal-footer {
|
||||
padding: @spacing-md @spacing-lg;
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid @color-border;
|
||||
}
|
||||
|
||||
// 表单项间距
|
||||
.ant-form-item {
|
||||
margin-bottom: @spacing-lg;
|
||||
}
|
||||
|
||||
// 表单标签
|
||||
.ant-form-item-label > label {
|
||||
font-size: @font-size-sm;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
// 输入框字体 - iOS 防止缩放需要 16px
|
||||
.ant-input,
|
||||
.ant-picker-input > input,
|
||||
.ant-select-selection-search-input {
|
||||
font-size: @font-size-md !important;
|
||||
}
|
||||
|
||||
// 文本域高度
|
||||
.ant-input-textarea textarea {
|
||||
font-size: @font-size-md !important;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
// 模板按钮组
|
||||
.template-buttons .ant-btn {
|
||||
font-size: @font-size-xs;
|
||||
padding: 2px @spacing-sm;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
// 股票选择器
|
||||
.ant-select-selector {
|
||||
min-height: 40px !important;
|
||||
}
|
||||
|
||||
// 底部按钮
|
||||
.ant-modal-footer .ant-btn {
|
||||
font-size: @font-size-md;
|
||||
height: 40px;
|
||||
border-radius: @spacing-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 动画 ====================
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-4px); }
|
||||
75% { transform: translateX(4px); }
|
||||
}
|
||||
577
src/views/Dashboard/components/EventFormModal.tsx
Normal file
577
src/views/Dashboard/components/EventFormModal.tsx
Normal file
@@ -0,0 +1,577 @@
|
||||
/**
|
||||
* EventFormModal - 通用事件表单弹窗组件 (Ant Design 重构版)
|
||||
* 用于新建/编辑投资计划、复盘
|
||||
*
|
||||
* 功能特性:
|
||||
* - 使用 Ant Design 组件
|
||||
* - 简化字段:标题、日期、描述、关联股票
|
||||
* - 计划/复盘模板系统
|
||||
* - 股票多选组件带智能搜索
|
||||
* - Ctrl + Enter 快捷键保存
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
DatePicker,
|
||||
Select,
|
||||
Button,
|
||||
Tag,
|
||||
Divider,
|
||||
message,
|
||||
Space,
|
||||
Spin,
|
||||
} from 'antd';
|
||||
import type { SelectProps } from 'antd';
|
||||
import {
|
||||
BulbOutlined,
|
||||
StarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useAppDispatch } from '@/store/hooks';
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import './EventFormModal.less';
|
||||
import type { InvestmentEvent, EventType } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
import { loadWatchlist, loadAllStocks } from '@/store/slices/stockSlice';
|
||||
import { stockService } from '@/services/stockService';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
/**
|
||||
* 股票选项接口
|
||||
*/
|
||||
interface StockOption {
|
||||
value: string;
|
||||
label: string;
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单数据接口
|
||||
*/
|
||||
interface FormData {
|
||||
title: string;
|
||||
date: dayjs.Dayjs;
|
||||
content: string;
|
||||
stocks: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板类型
|
||||
*/
|
||||
interface Template {
|
||||
label: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计划模板
|
||||
*/
|
||||
const PLAN_TEMPLATES: Template[] = [
|
||||
{
|
||||
label: '目标',
|
||||
content: '【投资目标】\n\n',
|
||||
},
|
||||
{
|
||||
label: '策略',
|
||||
content: '【交易策略】\n\n',
|
||||
},
|
||||
{
|
||||
label: '风险控制',
|
||||
content: '【风险控制】\n- 止损位:\n- 仓位控制:\n',
|
||||
},
|
||||
{
|
||||
label: '时间规划',
|
||||
content: '【时间规划】\n- 建仓时机:\n- 持仓周期:\n',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 复盘模板
|
||||
*/
|
||||
const REVIEW_TEMPLATES: Template[] = [
|
||||
{
|
||||
label: '操作回顾',
|
||||
content: '【操作回顾】\n- 买入操作:\n- 卖出操作:\n',
|
||||
},
|
||||
{
|
||||
label: '盈亏分析',
|
||||
content: '【盈亏分析】\n- 盈亏金额:\n- 收益率:\n- 主要原因:\n',
|
||||
},
|
||||
{
|
||||
label: '经验总结',
|
||||
content: '【经验总结】\n- 做对的地方:\n- 做错的地方:\n',
|
||||
},
|
||||
{
|
||||
label: '后续调整',
|
||||
content: '【后续调整】\n- 策略调整:\n- 仓位调整:\n',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Redux state 类型
|
||||
*/
|
||||
interface RootState {
|
||||
stock: {
|
||||
watchlist: Array<{ stock_code: string; stock_name: string }>;
|
||||
allStocks: Array<{ code: string; name: string }>;
|
||||
loading: {
|
||||
watchlist: boolean;
|
||||
allStocks: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* EventFormModal Props
|
||||
*/
|
||||
export interface EventFormModalProps {
|
||||
/** 弹窗是否打开 */
|
||||
isOpen: boolean;
|
||||
/** 关闭弹窗回调 */
|
||||
onClose: () => void;
|
||||
/** 模式:新建或编辑 */
|
||||
mode: 'create' | 'edit';
|
||||
/** 事件类型(新建时使用) */
|
||||
eventType?: EventType;
|
||||
/** 初始日期(新建时使用,如从日历点击) */
|
||||
initialDate?: string;
|
||||
/** 编辑时的原始事件数据 */
|
||||
editingEvent?: InvestmentEvent | null;
|
||||
/** 保存成功回调 */
|
||||
onSuccess: () => void;
|
||||
/** 主题颜色 */
|
||||
colorScheme?: string;
|
||||
/** 显示标签(如 "计划"、"复盘"、"事件") */
|
||||
label?: string;
|
||||
/** 是否显示日期选择器 */
|
||||
showDatePicker?: boolean;
|
||||
/** 是否显示类型选择 */
|
||||
showTypeSelect?: boolean;
|
||||
/** 是否显示状态选择 */
|
||||
showStatusSelect?: boolean;
|
||||
/** 是否显示重要度选择 */
|
||||
showImportance?: boolean;
|
||||
/** 是否显示标签输入 */
|
||||
showTags?: boolean;
|
||||
/** 股票输入方式:'tag' 为标签形式,'text' 为逗号分隔文本 */
|
||||
stockInputMode?: 'tag' | 'text';
|
||||
/** API 端点 */
|
||||
apiEndpoint?: 'investment-plans' | 'calendar/events';
|
||||
}
|
||||
|
||||
/**
|
||||
* EventFormModal 组件
|
||||
*/
|
||||
export const EventFormModal: React.FC<EventFormModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
mode,
|
||||
eventType = 'plan',
|
||||
initialDate,
|
||||
editingEvent,
|
||||
onSuccess,
|
||||
label = '事件',
|
||||
apiEndpoint = 'investment-plans',
|
||||
}) => {
|
||||
const { loadAllData } = usePlanningData();
|
||||
const dispatch = useAppDispatch();
|
||||
const [form] = Form.useForm<FormData>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [stockOptions, setStockOptions] = useState<StockOption[]>([]);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const modalContentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 从 Redux 获取自选股和全部股票列表
|
||||
const watchlist = useSelector((state: RootState) => state.stock.watchlist);
|
||||
const allStocks = useSelector((state: RootState) => state.stock.allStocks);
|
||||
const watchlistLoading = useSelector((state: RootState) => state.stock.loading.watchlist);
|
||||
const allStocksLoading = useSelector((state: RootState) => state.stock.loading.allStocks);
|
||||
|
||||
// 将自选股转换为 StockOption 格式
|
||||
const watchlistOptions = useMemo<StockOption[]>(() => {
|
||||
return watchlist.map(item => ({
|
||||
value: item.stock_code,
|
||||
label: `${item.stock_name}(${item.stock_code})`,
|
||||
stock_code: item.stock_code,
|
||||
stock_name: item.stock_name,
|
||||
}));
|
||||
}, [watchlist]);
|
||||
|
||||
// 获取模板列表
|
||||
const templates = eventType === 'plan' ? PLAN_TEMPLATES : REVIEW_TEMPLATES;
|
||||
|
||||
// 生成默认模板内容
|
||||
const getDefaultContent = (type: EventType): string => {
|
||||
const templateList = type === 'plan' ? PLAN_TEMPLATES : REVIEW_TEMPLATES;
|
||||
return templateList.map(t => t.content).join('\n');
|
||||
};
|
||||
|
||||
// 弹窗打开时加载数据
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// 加载自选股列表
|
||||
dispatch(loadWatchlist());
|
||||
// 加载全部股票列表(用于模糊搜索)
|
||||
dispatch(loadAllStocks());
|
||||
}
|
||||
}, [isOpen, dispatch]);
|
||||
|
||||
// 初始化表单数据
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (mode === 'edit' && editingEvent) {
|
||||
// 将 stocks 转换为代码数组(兼容对象和字符串格式)
|
||||
const stockCodes = (editingEvent.stocks || []).map(stock =>
|
||||
typeof stock === 'string' ? stock : stock.code
|
||||
);
|
||||
form.setFieldsValue({
|
||||
title: editingEvent.title,
|
||||
date: dayjs(editingEvent.event_date || editingEvent.date),
|
||||
content: editingEvent.description || editingEvent.content || '',
|
||||
stocks: stockCodes,
|
||||
});
|
||||
} else {
|
||||
// 新建模式,重置表单并预填充模板内容
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
date: initialDate ? dayjs(initialDate) : dayjs(),
|
||||
stocks: [],
|
||||
content: getDefaultContent(eventType),
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen, mode, editingEvent, initialDate, form, eventType]);
|
||||
|
||||
// 股票搜索(前端模糊搜索)
|
||||
const handleStockSearch = useCallback((value: string) => {
|
||||
setSearchText(value);
|
||||
|
||||
if (!value || value.length < 1) {
|
||||
// 无搜索词时显示自选股列表
|
||||
setStockOptions(watchlistOptions);
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 stockService.fuzzySearch 进行前端模糊搜索
|
||||
const results = stockService.fuzzySearch(value, allStocks, 10);
|
||||
const options: StockOption[] = results.map(stock => ({
|
||||
value: stock.code,
|
||||
label: `${stock.name}(${stock.code})`,
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name,
|
||||
}));
|
||||
|
||||
setStockOptions(options.length > 0 ? options : watchlistOptions);
|
||||
}, [allStocks, watchlistOptions]);
|
||||
|
||||
// 保存数据
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
|
||||
const base = getApiBase();
|
||||
|
||||
// 将选中的股票代码转换为包含名称的对象数组
|
||||
const stocksWithNames = (values.stocks || []).map((code: string) => {
|
||||
const stockInfo = allStocks.find(s => s.code === code);
|
||||
const watchlistInfo = watchlist.find(s => s.stock_code === code);
|
||||
return {
|
||||
code,
|
||||
name: stockInfo?.name || watchlistInfo?.stock_name || code,
|
||||
};
|
||||
});
|
||||
|
||||
// 构建请求数据
|
||||
const requestData: Record<string, unknown> = {
|
||||
title: values.title,
|
||||
content: values.content,
|
||||
date: values.date.format('YYYY-MM-DD'),
|
||||
type: eventType,
|
||||
stocks: stocksWithNames,
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
// 根据 API 端点调整字段名
|
||||
if (apiEndpoint === 'calendar/events') {
|
||||
requestData.description = values.content;
|
||||
requestData.event_date = values.date.format('YYYY-MM-DD');
|
||||
delete requestData.content;
|
||||
delete requestData.date;
|
||||
}
|
||||
|
||||
const url = mode === 'edit' && editingEvent
|
||||
? `${base}/api/account/${apiEndpoint}/${editingEvent.id}`
|
||||
: `${base}/api/account/${apiEndpoint}`;
|
||||
|
||||
const method = mode === 'edit' ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('EventFormModal', `${mode === 'edit' ? '更新' : '创建'}${label}成功`, {
|
||||
itemId: editingEvent?.id,
|
||||
title: values.title,
|
||||
});
|
||||
message.success(mode === 'edit' ? '修改成功' : '添加成功');
|
||||
onClose();
|
||||
onSuccess();
|
||||
loadAllData();
|
||||
} else {
|
||||
throw new Error('保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message !== '保存失败') {
|
||||
// 表单验证错误,不显示额外提示
|
||||
return;
|
||||
}
|
||||
logger.error('EventFormModal', 'handleSave', error, {
|
||||
itemId: editingEvent?.id,
|
||||
});
|
||||
message.error('保存失败,请稍后重试');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, eventType, apiEndpoint, mode, editingEvent, label, onClose, onSuccess, loadAllData, allStocks, watchlist]);
|
||||
|
||||
// 监听键盘快捷键 Ctrl + Enter
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isOpen && (e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, handleSave]);
|
||||
|
||||
// 插入模板
|
||||
const handleInsertTemplate = (template: Template): void => {
|
||||
const currentContent = form.getFieldValue('content') || '';
|
||||
const newContent = currentContent
|
||||
? `${currentContent}\n\n${template.content}`
|
||||
: template.content;
|
||||
form.setFieldsValue({ content: newContent });
|
||||
};
|
||||
|
||||
// 获取标题 placeholder
|
||||
const getTitlePlaceholder = (): string => {
|
||||
if (eventType === 'plan') {
|
||||
return '例如:关注AI板块';
|
||||
}
|
||||
return '例如:12月操作总结';
|
||||
};
|
||||
|
||||
// 获取内容 placeholder
|
||||
const getContentPlaceholder = (): string => {
|
||||
if (eventType === 'plan') {
|
||||
return '计划模板:\n目标:\n策略:\n风险控制:\n时间规划:';
|
||||
}
|
||||
return '复盘模板:\n操作回顾:\n盈亏分析:\n经验总结:\n后续调整:';
|
||||
};
|
||||
|
||||
// 判断是否显示自选股列表
|
||||
const isShowingWatchlist = !searchText && stockOptions === watchlistOptions;
|
||||
|
||||
// 股票选择器选项配置
|
||||
const selectProps: SelectProps<string[]> = {
|
||||
mode: 'multiple',
|
||||
placeholder: allStocksLoading ? '加载股票列表中...' : '输入股票代码或名称搜索',
|
||||
filterOption: false,
|
||||
onSearch: handleStockSearch,
|
||||
loading: watchlistLoading || allStocksLoading,
|
||||
notFoundContent: allStocksLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '8px' }}>
|
||||
<Spin size="small" />
|
||||
<span style={{ marginLeft: 8 }}>加载中...</span>
|
||||
</div>
|
||||
) : '暂无结果',
|
||||
options: stockOptions,
|
||||
onFocus: () => {
|
||||
if (stockOptions.length === 0) {
|
||||
setStockOptions(watchlistOptions);
|
||||
}
|
||||
},
|
||||
tagRender: (props) => {
|
||||
const { label: tagLabel, closable, onClose: onTagClose } = props;
|
||||
return (
|
||||
<Tag
|
||||
color="blue"
|
||||
closable={closable}
|
||||
onClose={onTagClose}
|
||||
style={{ marginRight: 3 }}
|
||||
>
|
||||
{tagLabel}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
popupRender: (menu) => (
|
||||
<>
|
||||
{isShowingWatchlist && watchlistOptions.length > 0 && (
|
||||
<>
|
||||
<div style={{ padding: '4px 8px 0' }}>
|
||||
<span style={{ fontSize: 12, color: '#999' }}>
|
||||
<StarOutlined style={{ marginRight: 4, color: '#faad14' }} />
|
||||
我的自选股
|
||||
</span>
|
||||
</div>
|
||||
<Divider style={{ margin: '4px 0 0' }} />
|
||||
</>
|
||||
)}
|
||||
{menu}
|
||||
{!isShowingWatchlist && searchText && (
|
||||
<>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<div style={{ padding: '0 8px 4px' }}>
|
||||
<span style={{ fontSize: 12, color: '#999' }}>
|
||||
<BulbOutlined style={{ marginRight: 4 }} />
|
||||
搜索结果(输入代码或名称)
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
// 获取按钮文案
|
||||
const getButtonText = (): string => {
|
||||
if (mode === 'edit') {
|
||||
return eventType === 'plan' ? '更新计划' : '更新复盘';
|
||||
}
|
||||
return eventType === 'plan' ? '创建计划' : '创建复盘';
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`${mode === 'edit' ? '编辑' : '新建'}${eventType === 'plan' ? '投资计划' : '复盘'}`}
|
||||
open={isOpen}
|
||||
onCancel={onClose}
|
||||
width={600}
|
||||
destroyOnClose
|
||||
maskClosable={true}
|
||||
keyboard
|
||||
className="event-form-modal"
|
||||
footer={
|
||||
<div className="modal-footer">
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={saving}
|
||||
>
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div ref={modalContentRef}>
|
||||
{isOpen && <Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
labelCol={{ span: 4 }}
|
||||
wrapperCol={{ span: 20 }}
|
||||
labelAlign="left"
|
||||
requiredMark={false}
|
||||
initialValues={{
|
||||
date: dayjs(),
|
||||
stocks: [],
|
||||
}}
|
||||
>
|
||||
{/* 标题 */}
|
||||
<Form.Item
|
||||
name="title"
|
||||
label={<span style={{ fontWeight: 600 }}>标题 <span style={{ color: '#ff4d4f' }}>*</span></span>}
|
||||
rules={[
|
||||
{ required: true, message: '请输入标题' },
|
||||
{ max: 50, message: '标题不能超过50个字符' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder={getTitlePlaceholder()}
|
||||
maxLength={50}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 日期 */}
|
||||
<Form.Item
|
||||
name="date"
|
||||
label={<span style={{ fontWeight: 600 }}>{eventType === 'plan' ? '计划日期' : '复盘日期'} <span style={{ color: '#ff4d4f' }}>*</span></span>}
|
||||
rules={[{ required: true, message: '请选择日期' }]}
|
||||
>
|
||||
<DatePicker
|
||||
style={{ width: '100%' }}
|
||||
format="YYYY-MM-DD"
|
||||
placeholder="选择日期"
|
||||
allowClear={false}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 描述/内容 - 上下布局 */}
|
||||
<Form.Item
|
||||
name="content"
|
||||
label={<span style={{ fontWeight: 600 }}>{eventType === 'plan' ? '计划详情' : '复盘内容'} <span style={{ color: '#ff4d4f' }}>*</span></span>}
|
||||
rules={[{ required: true, message: '请输入内容' }]}
|
||||
labelCol={{ span: 24 }}
|
||||
wrapperCol={{ span: 24 }}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<TextArea
|
||||
placeholder={getContentPlaceholder()}
|
||||
rows={8}
|
||||
showCount
|
||||
maxLength={2000}
|
||||
style={{ resize: 'vertical' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 模板快捷按钮 - 独立放置在 Form.Item 外部 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Space wrap size="small" className="template-buttons">
|
||||
{templates.map((template) => (
|
||||
<Button
|
||||
key={template.label}
|
||||
size="small"
|
||||
onClick={() => handleInsertTemplate(template)}
|
||||
>
|
||||
{template.label}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 关联股票 */}
|
||||
<Form.Item
|
||||
name="stocks"
|
||||
label={<span style={{ fontWeight: 600 }}>关联股票</span>}
|
||||
>
|
||||
<Select {...selectProps} />
|
||||
</Form.Item>
|
||||
</Form>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventFormModal;
|
||||
187
src/views/Dashboard/components/EventPanel.tsx
Normal file
187
src/views/Dashboard/components/EventPanel.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* EventPanel - 通用事件面板组件
|
||||
* 用于显示、编辑和管理投资计划或复盘
|
||||
*
|
||||
* 通过 props 配置差异化行为:
|
||||
* - type: 'plan' | 'review'
|
||||
* - colorScheme: 主题色
|
||||
* - label: 显示文案
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
VStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiFileText } from 'react-icons/fi';
|
||||
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import { EventFormModal } from './EventFormModal';
|
||||
import { EventCard } from './EventCard';
|
||||
import type { InvestmentEvent } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
|
||||
/**
|
||||
* EventPanel Props
|
||||
*/
|
||||
export interface EventPanelProps {
|
||||
/** 事件类型 */
|
||||
type: 'plan' | 'review';
|
||||
/** 主题颜色 */
|
||||
colorScheme: string;
|
||||
/** 显示标签(如 "计划" 或 "复盘") */
|
||||
label: string;
|
||||
/** 外部触发打开模态框的计数器 */
|
||||
openModalTrigger?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* EventPanel 组件
|
||||
* 通用事件列表面板,显示投资计划或复盘
|
||||
*/
|
||||
export const EventPanel: React.FC<EventPanelProps> = ({
|
||||
type,
|
||||
colorScheme,
|
||||
label,
|
||||
openModalTrigger,
|
||||
}) => {
|
||||
const {
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
toast,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
} = usePlanningData();
|
||||
|
||||
// 弹窗状态
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const [editingItem, setEditingItem] = useState<InvestmentEvent | null>(null);
|
||||
const [modalMode, setModalMode] = useState<'create' | 'edit'>('create');
|
||||
|
||||
// 使用 ref 记录上一次的 trigger 值,避免组件挂载时误触发
|
||||
const prevTriggerRef = useRef<number>(openModalTrigger || 0);
|
||||
|
||||
// 筛选事件列表(按类型过滤,排除系统事件)
|
||||
const events = allEvents.filter(event => event.type === type && event.source !== 'future');
|
||||
|
||||
// 监听外部触发打开新建模态框(修复 bug:只在值变化时触发)
|
||||
useEffect(() => {
|
||||
if (openModalTrigger !== undefined && openModalTrigger > prevTriggerRef.current) {
|
||||
// 只有当 trigger 值增加时才打开弹窗
|
||||
handleOpenModal(null);
|
||||
}
|
||||
prevTriggerRef.current = openModalTrigger || 0;
|
||||
}, [openModalTrigger]);
|
||||
|
||||
// 打开编辑/新建模态框
|
||||
const handleOpenModal = (item: InvestmentEvent | null = null): void => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setModalMode('edit');
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setModalMode('create');
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 关闭弹窗
|
||||
const handleCloseModal = (): void => {
|
||||
setIsModalOpen(false);
|
||||
setEditingItem(null);
|
||||
};
|
||||
|
||||
// 删除数据
|
||||
const handleDelete = async (id: number): Promise<void> => {
|
||||
if (!window.confirm('确定要删除吗?')) return;
|
||||
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('EventPanel', `删除${label}成功`, { itemId: id });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadAllData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('EventPanel', 'handleDelete', error, { itemId: id });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 使用 useCallback 优化回调函数
|
||||
const handleEdit = useCallback((item: InvestmentEvent) => {
|
||||
handleOpenModal(item);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{loading ? (
|
||||
<Center py={8}>
|
||||
<Spinner size="xl" color={`${colorScheme}.500`} />
|
||||
</Center>
|
||||
) : events.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={{ base: 3, md: 4 }}>
|
||||
{events.map(event => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
variant="list"
|
||||
colorScheme={colorScheme}
|
||||
label={label}
|
||||
textColor={textColor}
|
||||
secondaryText={secondaryText}
|
||||
cardBg={cardBg}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* 使用通用弹窗组件 */}
|
||||
<EventFormModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
mode={modalMode}
|
||||
eventType={type}
|
||||
editingEvent={editingItem}
|
||||
onSuccess={loadAllData}
|
||||
label={label}
|
||||
apiEndpoint="investment-plans"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventPanel;
|
||||
@@ -1,87 +0,0 @@
|
||||
/* src/views/Dashboard/components/InvestmentCalendar.css */
|
||||
|
||||
/* FullCalendar 自定义样式 */
|
||||
.fc {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
.fc-theme-standard td,
|
||||
.fc-theme-standard th {
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.fc-button-primary {
|
||||
background-color: #3182ce !important;
|
||||
border-color: #3182ce !important;
|
||||
}
|
||||
|
||||
.fc-button-primary:hover {
|
||||
background-color: #2c5282 !important;
|
||||
border-color: #2c5282 !important;
|
||||
}
|
||||
|
||||
.fc-button-primary:not(:disabled):active,
|
||||
.fc-button-primary:not(:disabled).fc-button-active {
|
||||
background-color: #2c5282 !important;
|
||||
border-color: #2c5282 !important;
|
||||
}
|
||||
|
||||
.fc-daygrid-day-number {
|
||||
color: #2d3748;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fc-daygrid-day.fc-day-today {
|
||||
background-color: #e6f3ff !important;
|
||||
}
|
||||
|
||||
.fc-event {
|
||||
border: none;
|
||||
padding: 2px 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fc-daygrid-event-dot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fc-daygrid-day-events {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.fc-toolbar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fc-toolbar-title {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.fc-button-group {
|
||||
margin: 0.2em;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗色模式支持 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.fc-theme-standard td,
|
||||
.fc-theme-standard th {
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
.fc-daygrid-day-number {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.fc-daygrid-day.fc-day-today {
|
||||
background-color: #2d3748 !important;
|
||||
}
|
||||
|
||||
.fc-col-header-cell-cushion,
|
||||
.fc-daygrid-day-number {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
123
src/views/Dashboard/components/InvestmentCalendar.less
Normal file
123
src/views/Dashboard/components/InvestmentCalendar.less
Normal file
@@ -0,0 +1,123 @@
|
||||
// src/views/Dashboard/components/InvestmentCalendar.less
|
||||
|
||||
// 颜色变量(与日历视图按钮一致的紫色)
|
||||
@primary-color: #805AD5;
|
||||
@primary-hover: #6B46C1;
|
||||
@border-color: #e2e8f0;
|
||||
@text-color: #2d3748;
|
||||
@today-bg: #e6f3ff;
|
||||
|
||||
// 暗色模式颜色
|
||||
@dark-border-color: #4a5568;
|
||||
@dark-text-color: #e2e8f0;
|
||||
@dark-today-bg: #2d3748;
|
||||
|
||||
// FullCalendar 自定义样式
|
||||
.fc {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
// 工具栏按钮紧密排列(提升优先级)
|
||||
.fc .fc-toolbar.fc-header-toolbar {
|
||||
justify-content: flex-start !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.fc .fc-toolbar-chunk:first-child {
|
||||
display: flex !important;
|
||||
gap: 4px !important;
|
||||
}
|
||||
|
||||
.fc-theme-standard {
|
||||
td, th {
|
||||
border-color: @border-color;
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮样式(针对 fc-button-group 内的按钮)
|
||||
.fc .fc-toolbar .fc-button-group .fc-button {
|
||||
background-color: @primary-color !important;
|
||||
border-color: @primary-color !important;
|
||||
color: #fff !important;
|
||||
|
||||
&:hover {
|
||||
background-color: @primary-hover !important;
|
||||
border-color: @primary-hover !important;
|
||||
}
|
||||
|
||||
&:not(:disabled):active,
|
||||
&:not(:disabled).fc-button-active {
|
||||
background-color: @primary-hover !important;
|
||||
border-color: @primary-hover !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 今天按钮样式
|
||||
.fc .fc-toolbar .fc-today-button {
|
||||
background-color: @primary-color !important;
|
||||
border-color: @primary-color !important;
|
||||
color: #fff !important;
|
||||
|
||||
&:hover {
|
||||
background-color: @primary-hover !important;
|
||||
border-color: @primary-hover !important;
|
||||
}
|
||||
|
||||
// 选中状态(disabled 表示当前视图包含今天)
|
||||
&:disabled {
|
||||
background-color: @primary-hover !important;
|
||||
border-color: @primary-hover !important;
|
||||
opacity: 1 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 日期数字
|
||||
.fc-daygrid-day-number {
|
||||
color: @text-color;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 今天高亮
|
||||
.fc-daygrid-day.fc-day-today {
|
||||
background-color: @today-bg !important;
|
||||
border: 2px solid @primary-color !important;
|
||||
}
|
||||
|
||||
// 事件样式
|
||||
.fc-event {
|
||||
border: none;
|
||||
padding: 2px 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fc-daygrid-event-dot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fc-daygrid-day-events {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
// 暗色模式支持
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.fc-theme-standard {
|
||||
td, th {
|
||||
border-color: @dark-border-color;
|
||||
}
|
||||
}
|
||||
|
||||
.fc-daygrid-day-number {
|
||||
color: @dark-text-color;
|
||||
}
|
||||
|
||||
.fc-daygrid-day.fc-day-today {
|
||||
background-color: @dark-today-bg !important;
|
||||
}
|
||||
|
||||
.fc-col-header-cell-cushion,
|
||||
.fc-daygrid-day-number {
|
||||
color: @dark-text-color;
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import TimelineChartModal from '../../../components/StockChart/TimelineChartModal';
|
||||
import KLineChartModal from '../../../components/StockChart/KLineChartModal';
|
||||
import './InvestmentCalendar.css';
|
||||
import './InvestmentCalendar.less';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
|
||||
@@ -1,493 +0,0 @@
|
||||
// src/views/Dashboard/components/InvestmentCalendarChakra.js
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Heading,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
Grid,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
Tooltip,
|
||||
Icon,
|
||||
Input,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
useToast,
|
||||
Spinner,
|
||||
Center,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiCalendar,
|
||||
FiClock,
|
||||
FiStar,
|
||||
FiTrendingUp,
|
||||
FiPlus,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiSave,
|
||||
FiX,
|
||||
} from 'react-icons/fi';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import './InvestmentCalendar.css';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
|
||||
export default function InvestmentCalendarChakra() {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure();
|
||||
const toast = useToast();
|
||||
|
||||
// 颜色主题
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
const [events, setEvents] = useState([]);
|
||||
const [selectedDate, setSelectedDate] = useState(null);
|
||||
const [selectedDateEvents, setSelectedDateEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newEvent, setNewEvent] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'plan',
|
||||
importance: 3,
|
||||
stocks: '',
|
||||
});
|
||||
|
||||
// 加载事件数据
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
|
||||
// 直接加载用户相关的事件(投资计划 + 关注的未来事件)
|
||||
const userResponse = await fetch(base + '/api/account/calendar/events', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (userResponse.ok) {
|
||||
const userData = await userResponse.json();
|
||||
if (userData.success) {
|
||||
const allEvents = (userData.data || []).map(event => ({
|
||||
...event,
|
||||
id: `${event.source || 'user'}-${event.id}`,
|
||||
title: event.title,
|
||||
start: event.event_date,
|
||||
date: event.event_date,
|
||||
backgroundColor: event.source === 'future' ? '#3182CE' : '#8B5CF6',
|
||||
borderColor: event.source === 'future' ? '#3182CE' : '#8B5CF6',
|
||||
extendedProps: {
|
||||
...event,
|
||||
isSystem: event.source === 'future',
|
||||
}
|
||||
}));
|
||||
|
||||
setEvents(allEvents);
|
||||
logger.debug('InvestmentCalendar', '日历事件加载成功', {
|
||||
count: allEvents.length
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentCalendar', 'loadEvents', error);
|
||||
// ❌ 移除数据加载失败 toast(非关键操作)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []); // ✅ 移除 toast 依赖
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
}, [loadEvents]);
|
||||
|
||||
// 根据重要性获取颜色
|
||||
const getEventColor = (importance) => {
|
||||
if (importance >= 5) return '#E53E3E'; // 红色
|
||||
if (importance >= 4) return '#ED8936'; // 橙色
|
||||
if (importance >= 3) return '#ECC94B'; // 黄色
|
||||
if (importance >= 2) return '#48BB78'; // 绿色
|
||||
return '#3182CE'; // 蓝色
|
||||
};
|
||||
|
||||
// 处理日期点击
|
||||
const handleDateClick = (info) => {
|
||||
const clickedDate = moment(info.date);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
// 筛选当天的事件
|
||||
const dayEvents = events.filter(event =>
|
||||
moment(event.start).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = (info) => {
|
||||
const event = info.event;
|
||||
const clickedDate = moment(event.start);
|
||||
setSelectedDate(clickedDate);
|
||||
setSelectedDateEvents([{
|
||||
title: event.title,
|
||||
start: event.start,
|
||||
extendedProps: {
|
||||
...event.extendedProps,
|
||||
},
|
||||
}]);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 添加新事件
|
||||
const handleAddEvent = async () => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
|
||||
const eventData = {
|
||||
...newEvent,
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : moment().format('YYYY-MM-DD')),
|
||||
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
|
||||
};
|
||||
|
||||
const response = await fetch(base + '/api/account/calendar/events', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(eventData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
logger.info('InvestmentCalendar', '添加事件成功', {
|
||||
eventTitle: eventData.title,
|
||||
eventDate: eventData.event_date
|
||||
});
|
||||
toast({
|
||||
title: '添加成功',
|
||||
description: '投资计划已添加',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
onAddClose();
|
||||
loadEvents();
|
||||
setNewEvent({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'plan',
|
||||
importance: 3,
|
||||
stocks: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentCalendar', 'handleAddEvent', error, {
|
||||
eventTitle: newEvent?.title
|
||||
});
|
||||
toast({
|
||||
title: '添加失败',
|
||||
description: '无法添加投资计划',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除用户事件
|
||||
const handleDeleteEvent = async (eventId) => {
|
||||
if (!eventId) {
|
||||
logger.warn('InvestmentCalendar', '删除事件失败', '缺少事件 ID', { eventId });
|
||||
toast({
|
||||
title: '无法删除',
|
||||
description: '缺少事件 ID',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
|
||||
const response = await fetch(base + `/api/account/calendar/events/${eventId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('InvestmentCalendar', '删除事件成功', { eventId });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadEvents();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentCalendar', 'handleDeleteEvent', error, { eventId });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card bg={bgColor} shadow="md">
|
||||
<CardHeader pb={4}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack>
|
||||
<Icon as={FiCalendar} color="orange.500" boxSize={5} />
|
||||
<Heading size="md">投资日历</Heading>
|
||||
</HStack>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => { if (!selectedDate) setSelectedDate(moment()); onAddOpen(); }}
|
||||
>
|
||||
添加计划
|
||||
</Button>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
{loading ? (
|
||||
<Center h="560px">
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
</Center>
|
||||
) : (
|
||||
<Box height={{ base: '500px', md: '600px' }}>
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, interactionPlugin]}
|
||||
initialView="dayGridMonth"
|
||||
locale="zh-cn"
|
||||
headerToolbar={{
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: ''
|
||||
}}
|
||||
events={events}
|
||||
dateClick={handleDateClick}
|
||||
eventClick={handleEventClick}
|
||||
height="100%"
|
||||
dayMaxEvents={3}
|
||||
moreLinkText="更多"
|
||||
buttonText={{
|
||||
today: '今天',
|
||||
month: '月',
|
||||
week: '周'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</CardBody>
|
||||
|
||||
{/* 查看事件详情 Modal - 条件渲染 */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{selectedDate && selectedDate.format('YYYY年MM月DD日')} 的事件
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{selectedDateEvents.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack>
|
||||
<Text color={secondaryText}>当天没有事件</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onAddOpen();
|
||||
}}
|
||||
>
|
||||
添加投资计划
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{selectedDateEvents.map((event, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={4}
|
||||
borderRadius="md"
|
||||
border="1px"
|
||||
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.extendedProps?.isSystem ? (
|
||||
<Badge colorScheme="blue" variant="subtle">系统事件</Badge>
|
||||
) : (
|
||||
<Badge colorScheme="purple" variant="subtle">我的计划</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiStar} color="yellow.500" />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
重要度: {event.extendedProps?.importance || 3}/5
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
{!event.extendedProps?.isSystem && (
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDeleteEvent(event.extendedProps?.id)}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{event.extendedProps?.description && (
|
||||
<Text fontSize="sm" color={secondaryText} mb={2}>
|
||||
{event.extendedProps.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{event.extendedProps?.stocks && event.extendedProps.stocks.length > 0 && (
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Text fontSize="sm" color={secondaryText}>相关股票:</Text>
|
||||
{event.extendedProps.stocks.map((stock, i) => (
|
||||
<Tag key={i} size="sm" colorScheme="blue">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 添加投资计划 Modal - 条件渲染 */}
|
||||
{isAddOpen && (
|
||||
<Modal isOpen={isAddOpen} onClose={onAddClose} size="lg" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
添加投资计划
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={newEvent.title}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, title: e.target.value })}
|
||||
placeholder="例如:关注半导体板块"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>描述</FormLabel>
|
||||
<Textarea
|
||||
value={newEvent.description}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, description: e.target.value })}
|
||||
placeholder="详细描述您的投资计划..."
|
||||
rows={3}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>类型</FormLabel>
|
||||
<Select
|
||||
value={newEvent.type}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, type: e.target.value })}
|
||||
>
|
||||
<option value="plan">投资计划</option>
|
||||
<option value="reminder">提醒事项</option>
|
||||
<option value="analysis">分析任务</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>重要度</FormLabel>
|
||||
<Select
|
||||
value={newEvent.importance}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, importance: parseInt(e.target.value) })}
|
||||
>
|
||||
<option value={5}>⭐⭐⭐⭐⭐ 非常重要</option>
|
||||
<option value={4}>⭐⭐⭐⭐ 重要</option>
|
||||
<option value={3}>⭐⭐⭐ 一般</option>
|
||||
<option value={2}>⭐⭐ 次要</option>
|
||||
<option value={1}>⭐ 不重要</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票(用逗号分隔)</FormLabel>
|
||||
<Input
|
||||
value={newEvent.stocks}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, stocks: e.target.value })}
|
||||
placeholder="例如:600519,000858,002415"
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onAddClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleAddEvent}
|
||||
isDisabled={!newEvent.title}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,14 +3,12 @@
|
||||
*
|
||||
* 性能优化:
|
||||
* - 使用 React.lazy() 懒加载子面板,减少初始加载时间
|
||||
* - 从 1421 行拆分为 5 个独立模块,提升可维护性
|
||||
* - 使用 TypeScript 提供类型安全
|
||||
*
|
||||
* 组件架构:
|
||||
* - InvestmentPlanningCenter (主组件,~200 行)
|
||||
* - InvestmentPlanningCenter (主组件)
|
||||
* - CalendarPanel (日历面板,懒加载)
|
||||
* - PlansPanel (计划面板,懒加载)
|
||||
* - ReviewsPanel (复盘面板,懒加载)
|
||||
* - EventPanel (通用事件面板,用于计划和复盘)
|
||||
* - PlanningContext (数据共享层)
|
||||
*/
|
||||
|
||||
@@ -33,28 +31,29 @@ import {
|
||||
TabPanel,
|
||||
Spinner,
|
||||
Center,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiCalendar,
|
||||
FiTarget,
|
||||
FiFileText,
|
||||
FiList,
|
||||
FiPlus,
|
||||
} from 'react-icons/fi';
|
||||
|
||||
import { PlanningDataProvider } from './PlanningContext';
|
||||
import type { InvestmentEvent, PlanningContextValue } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
import './InvestmentCalendar.css';
|
||||
import './InvestmentCalendar.less';
|
||||
|
||||
// 懒加载子面板组件(实现代码分割)
|
||||
const CalendarPanel = lazy(() =>
|
||||
import('./CalendarPanel').then(module => ({ default: module.CalendarPanel }))
|
||||
);
|
||||
const PlansPanel = lazy(() =>
|
||||
import('./PlansPanel').then(module => ({ default: module.PlansPanel }))
|
||||
);
|
||||
const ReviewsPanel = lazy(() =>
|
||||
import('./ReviewsPanel').then(module => ({ default: module.ReviewsPanel }))
|
||||
const EventPanel = lazy(() =>
|
||||
import('./EventPanel').then(module => ({ default: module.EventPanel }))
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -82,7 +81,10 @@ const InvestmentPlanningCenter: React.FC = () => {
|
||||
// 全局数据状态
|
||||
const [allEvents, setAllEvents] = useState<InvestmentEvent[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [activeTab, setActiveTab] = useState<number>(0);
|
||||
const [viewMode, setViewMode] = useState<'calendar' | 'list'>('list');
|
||||
const [listTab, setListTab] = useState<number>(0); // 0: 我的计划, 1: 我的复盘
|
||||
const [openPlanModalTrigger, setOpenPlanModalTrigger] = useState<number>(0);
|
||||
const [openReviewModalTrigger, setOpenReviewModalTrigger] = useState<number>(0);
|
||||
|
||||
/**
|
||||
* 加载所有事件数据(日历事件 + 计划 + 复盘)
|
||||
@@ -124,14 +126,15 @@ const InvestmentPlanningCenter: React.FC = () => {
|
||||
loadAllData,
|
||||
loading,
|
||||
setLoading,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
openPlanModalTrigger,
|
||||
openReviewModalTrigger,
|
||||
toast,
|
||||
bgColor,
|
||||
borderColor,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
setViewMode,
|
||||
setListTab,
|
||||
};
|
||||
|
||||
// 计算各类型事件数量
|
||||
@@ -141,59 +144,104 @@ 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>
|
||||
{/* 视图切换按钮组 - H5隐藏 */}
|
||||
<ButtonGroup size="sm" isAttached variant="outline" display={{ base: 'none', md: 'flex' }}>
|
||||
<Button
|
||||
leftIcon={<Icon as={FiList} boxSize={4} />}
|
||||
colorScheme={viewMode === 'list' ? 'purple' : 'gray'}
|
||||
variant={viewMode === 'list' ? 'solid' : 'outline'}
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
列表视图
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<Icon as={FiCalendar} boxSize={4} />}
|
||||
colorScheme={viewMode === 'calendar' ? 'purple' : 'gray'}
|
||||
variant={viewMode === 'calendar' ? 'solid' : 'outline'}
|
||||
onClick={() => setViewMode('calendar')}
|
||||
>
|
||||
日历视图
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
<Tabs
|
||||
index={activeTab}
|
||||
onChange={setActiveTab}
|
||||
variant="enclosed"
|
||||
colorScheme="purple"
|
||||
>
|
||||
<TabList>
|
||||
<Tab>
|
||||
<Icon as={FiCalendar} mr={2} />
|
||||
日历视图
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={FiTarget} mr={2} />
|
||||
我的计划 ({planCount})
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={FiFileText} mr={2} />
|
||||
我的复盘 ({reviewCount})
|
||||
</Tab>
|
||||
</TabList>
|
||||
<CardBody pt={0} px={{ base: 3, md: 5 }}>
|
||||
{viewMode === 'calendar' ? (
|
||||
/* 日历视图 */
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<CalendarPanel />
|
||||
</Suspense>
|
||||
) : (
|
||||
/* 列表视图:我的计划 / 我的复盘 切换 */
|
||||
<Tabs
|
||||
index={listTab}
|
||||
onChange={setListTab}
|
||||
variant="enclosed"
|
||||
colorScheme="purple"
|
||||
size={{ base: 'sm', md: 'md' }}
|
||||
>
|
||||
<Flex justify="space-between" align="center" mb={{ base: 2, md: 4 }} flexWrap="nowrap" gap={1}>
|
||||
<TabList mb={0} borderBottom="none" flex="1" minW={0}>
|
||||
<Tab fontSize={{ base: '11px', md: 'sm' }} px={{ base: 1, md: 4 }} whiteSpace="nowrap">
|
||||
<Icon as={FiTarget} mr={1} boxSize={{ base: 3, md: 4 }} />
|
||||
我的计划 ({planCount})
|
||||
</Tab>
|
||||
<Tab fontSize={{ base: '11px', md: 'sm' }} px={{ base: 1, md: 4 }} whiteSpace="nowrap">
|
||||
<Icon as={FiFileText} mr={1} boxSize={{ base: 3, md: 4 }} />
|
||||
我的复盘 ({reviewCount})
|
||||
</Tab>
|
||||
</TabList>
|
||||
<Button
|
||||
size="xs"
|
||||
colorScheme="purple"
|
||||
leftIcon={<Icon as={FiPlus} boxSize={3} />}
|
||||
fontSize={{ base: '11px', md: 'sm' }}
|
||||
flexShrink={0}
|
||||
onClick={() => {
|
||||
if (listTab === 0) {
|
||||
setOpenPlanModalTrigger(prev => prev + 1);
|
||||
} else {
|
||||
setOpenReviewModalTrigger(prev => prev + 1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{listTab === 0 ? '新建计划' : '新建复盘'}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<TabPanels>
|
||||
{/* 日历视图面板 */}
|
||||
<TabPanel px={0}>
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<CalendarPanel />
|
||||
</Suspense>
|
||||
</TabPanel>
|
||||
<TabPanels>
|
||||
{/* 计划列表面板 */}
|
||||
<TabPanel px={0}>
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<EventPanel
|
||||
type="plan"
|
||||
colorScheme="purple"
|
||||
label="计划"
|
||||
openModalTrigger={openPlanModalTrigger}
|
||||
/>
|
||||
</Suspense>
|
||||
</TabPanel>
|
||||
|
||||
{/* 计划列表面板 */}
|
||||
<TabPanel px={0}>
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<PlansPanel />
|
||||
</Suspense>
|
||||
</TabPanel>
|
||||
|
||||
{/* 复盘列表面板 */}
|
||||
<TabPanel px={0}>
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<ReviewsPanel />
|
||||
</Suspense>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
{/* 复盘列表面板 */}
|
||||
<TabPanel px={0}>
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<EventPanel
|
||||
type="review"
|
||||
colorScheme="green"
|
||||
label="复盘"
|
||||
openModalTrigger={openReviewModalTrigger}
|
||||
/>
|
||||
</Suspense>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</PlanningDataProvider>
|
||||
|
||||
@@ -1,587 +0,0 @@
|
||||
// src/views/Dashboard/components/InvestmentPlansAndReviews.js
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Heading,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
Icon,
|
||||
Input,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
useToast,
|
||||
Spinner,
|
||||
Center,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
TagCloseButton,
|
||||
Grid,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiCalendar,
|
||||
FiClock,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiSave,
|
||||
FiPlus,
|
||||
FiFileText,
|
||||
FiTarget,
|
||||
FiTrendingUp,
|
||||
FiHash,
|
||||
FiCheckCircle,
|
||||
FiXCircle,
|
||||
FiAlertCircle,
|
||||
} from 'react-icons/fi';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
|
||||
export default function InvestmentPlansAndReviews({ type = 'both' }) {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const toast = useToast();
|
||||
|
||||
// 颜色主题
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
const cardBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [reviews, setReviews] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
const [stockInput, setStockInput] = useState('');
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
|
||||
// 加载数据
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
|
||||
const response = await fetch(base + '/api/account/investment-plans', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
const allItems = data.data || [];
|
||||
setPlans(allItems.filter(item => item.type === 'plan'));
|
||||
setReviews(allItems.filter(item => item.type === 'review'));
|
||||
logger.debug('InvestmentPlansAndReviews', '数据加载成功', {
|
||||
plansCount: allItems.filter(item => item.type === 'plan').length,
|
||||
reviewsCount: allItems.filter(item => item.type === 'review').length
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentPlansAndReviews', 'loadData', error);
|
||||
// ❌ 移除数据加载失败 toast(非关键操作)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []); // ✅ 移除 toast 依赖
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 打开编辑/新建模态框
|
||||
const handleOpenModal = (item = null, itemType = 'plan') => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
...item,
|
||||
date: moment(item.date).format('YYYY-MM-DD'),
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: itemType,
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 保存数据
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
|
||||
const url = editingItem
|
||||
? base + `/api/account/investment-plans/${editingItem.id}`
|
||||
: base + '/api/account/investment-plans';
|
||||
|
||||
const method = editingItem ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('InvestmentPlansAndReviews', `${editingItem ? '更新' : '创建'}成功`, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData.title,
|
||||
type: formData.type
|
||||
});
|
||||
toast({
|
||||
title: editingItem ? '更新成功' : '创建成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
onClose();
|
||||
loadData();
|
||||
} else {
|
||||
throw new Error('保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentPlansAndReviews', 'handleSave', error, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData?.title
|
||||
});
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: '无法保存数据',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除数据
|
||||
const handleDelete = async (id) => {
|
||||
if (!window.confirm('确定要删除吗?')) return;
|
||||
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
|
||||
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('InvestmentPlansAndReviews', '删除成功', { itemId: id });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentPlansAndReviews', 'handleDelete', error, { itemId: id });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 添加股票
|
||||
const handleAddStock = () => {
|
||||
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
stocks: [...formData.stocks, stockInput.trim()],
|
||||
});
|
||||
setStockInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 添加标签
|
||||
const handleAddTag = () => {
|
||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
tags: [...formData.tags, tagInput.trim()],
|
||||
});
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态图标和颜色
|
||||
const getStatusInfo = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return { icon: FiCheckCircle, color: 'green' };
|
||||
case 'cancelled':
|
||||
return { icon: FiXCircle, color: 'red' };
|
||||
default:
|
||||
return { icon: FiAlertCircle, color: 'blue' };
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染单个卡片
|
||||
const renderCard = (item) => {
|
||||
const statusInfo = getStatusInfo(item.status);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
bg={cardBg}
|
||||
shadow="sm"
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Flex justify="space-between" align="start">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Icon as={item.type === 'plan' ? FiTarget : FiFileText} color="blue.500" />
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{item.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{moment(item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={statusInfo.color}
|
||||
variant="subtle"
|
||||
leftIcon={<Icon as={statusInfo.icon} />}
|
||||
>
|
||||
{item.status === 'active' ? '进行中' :
|
||||
item.status === 'completed' ? '已完成' : '已取消'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<HStack>
|
||||
<IconButton
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleOpenModal(item)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{item.content && (
|
||||
<Text fontSize="sm" color={textColor} noOfLines={3}>
|
||||
{item.content}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{item.stocks && item.stocks.length > 0 && (
|
||||
<>
|
||||
{item.stocks.map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<>
|
||||
{item.tags.map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="purple" variant="subtle">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Tabs variant="enclosed" colorScheme="blue" defaultIndex={type === 'review' ? 1 : 0}>
|
||||
<TabList>
|
||||
<Tab>
|
||||
<Icon as={FiTarget} mr={2} />
|
||||
我的计划 ({plans.length})
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={FiFileText} mr={2} />
|
||||
我的复盘 ({reviews.length})
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* 计划面板 */}
|
||||
<TabPanel px={0}>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null, 'plan')}
|
||||
>
|
||||
新建计划
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center py={8}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
</Center>
|
||||
) : plans.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiTarget} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText}>暂无投资计划</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null, 'plan')}
|
||||
>
|
||||
创建第一个计划
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
||||
{plans.map(renderCard)}
|
||||
</Grid>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* 复盘面板 */}
|
||||
<TabPanel px={0}>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null, 'review')}
|
||||
>
|
||||
新建复盘
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center py={8}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
</Center>
|
||||
) : reviews.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiFileText} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText}>暂无复盘记录</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null, 'review')}
|
||||
>
|
||||
创建第一个复盘
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
||||
{reviews.map(renderCard)}
|
||||
</Grid>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
{/* 编辑/新建模态框 - 条件渲染 */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{editingItem ? '编辑' : '新建'}
|
||||
{formData.type === 'plan' ? '投资计划' : '复盘记录'}
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>日期</FormLabel>
|
||||
<InputGroup>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<Icon as={FiCalendar} color={secondaryText} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder={formData.type === 'plan' ? '例如:布局新能源板块' : '例如:本周交易复盘'}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>内容</FormLabel>
|
||||
<Textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
placeholder={formData.type === 'plan' ?
|
||||
'详细描述您的投资计划...' :
|
||||
'记录您的交易心得和经验教训...'}
|
||||
rows={6}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={stockInput}
|
||||
onChange={(e) => setStockInput(e.target.value)}
|
||||
placeholder="输入股票代码"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
|
||||
/>
|
||||
<Button onClick={handleAddStock}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{formData.stocks.map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
stocks: formData.stocks.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>标签</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
placeholder="输入标签"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
|
||||
/>
|
||||
<Button onClick={handleAddTag}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{formData.tags.map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="purple">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
tags: formData.tags.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>状态</FormLabel>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
|
||||
>
|
||||
<option value="active">进行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleSave}
|
||||
isDisabled={!formData.title || !formData.date}
|
||||
leftIcon={<FiSave />}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,506 +0,0 @@
|
||||
/**
|
||||
* PlansPanel - 投资计划列表面板组件
|
||||
* 显示、编辑和管理投资计划
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
Grid,
|
||||
Card,
|
||||
CardBody,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
Icon,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
TagCloseButton,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiPlus,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiSave,
|
||||
FiTarget,
|
||||
FiCalendar,
|
||||
FiTrendingUp,
|
||||
FiHash,
|
||||
FiCheckCircle,
|
||||
FiXCircle,
|
||||
FiAlertCircle,
|
||||
} from 'react-icons/fi';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import type { InvestmentEvent, PlanFormData, EventStatus } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
/**
|
||||
* 状态信息接口
|
||||
*/
|
||||
interface StatusInfo {
|
||||
icon: React.ComponentType;
|
||||
color: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PlansPanel 组件
|
||||
* 计划列表面板,显示所有投资计划
|
||||
*/
|
||||
export const PlansPanel: React.FC = () => {
|
||||
const {
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
toast,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
borderColor,
|
||||
} = usePlanningData();
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [editingItem, setEditingItem] = useState<InvestmentEvent | null>(null);
|
||||
const [formData, setFormData] = useState<PlanFormData>({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
const [stockInput, setStockInput] = useState<string>('');
|
||||
const [tagInput, setTagInput] = useState<string>('');
|
||||
|
||||
// 筛选计划列表(排除系统事件)
|
||||
const plans = allEvents.filter(event => event.type === 'plan' && event.source !== 'future');
|
||||
|
||||
// 打开编辑/新建模态框
|
||||
const handleOpenModal = (item: InvestmentEvent | null = null): void => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
title: item.title,
|
||||
content: item.description || item.content || '',
|
||||
type: 'plan',
|
||||
stocks: item.stocks || [],
|
||||
tags: item.tags || [],
|
||||
status: item.status || 'active',
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 保存数据
|
||||
const handleSave = async (): Promise<void> => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const url = editingItem
|
||||
? base + `/api/account/investment-plans/${editingItem.id}`
|
||||
: base + '/api/account/investment-plans';
|
||||
|
||||
const method = editingItem ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('PlansPanel', `${editingItem ? '更新' : '创建'}成功`, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData.title,
|
||||
});
|
||||
toast({
|
||||
title: editingItem ? '更新成功' : '创建成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
onClose();
|
||||
loadAllData();
|
||||
} else {
|
||||
throw new Error('保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('PlansPanel', 'handleSave', error, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData?.title
|
||||
});
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: '无法保存数据',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除数据
|
||||
const handleDelete = async (id: number): Promise<void> => {
|
||||
if (!window.confirm('确定要删除吗?')) return;
|
||||
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('PlansPanel', '删除成功', { itemId: id });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadAllData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('PlansPanel', 'handleDelete', error, { itemId: id });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 添加股票
|
||||
const handleAddStock = (): void => {
|
||||
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
stocks: [...formData.stocks, stockInput.trim()],
|
||||
});
|
||||
setStockInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 添加标签
|
||||
const handleAddTag = (): void => {
|
||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
tags: [...formData.tags, tagInput.trim()],
|
||||
});
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态信息
|
||||
const getStatusInfo = (status?: EventStatus): StatusInfo => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return { icon: FiCheckCircle, color: 'green', text: '已完成' };
|
||||
case 'cancelled':
|
||||
return { icon: FiXCircle, color: 'red', text: '已取消' };
|
||||
default:
|
||||
return { icon: FiAlertCircle, color: 'blue', text: '进行中' };
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染单个卡片
|
||||
const renderCard = (item: InvestmentEvent): React.ReactElement => {
|
||||
const statusInfo = getStatusInfo(item.status);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
bg={cardBg}
|
||||
shadow="sm"
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Flex justify="space-between" align="start">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Icon as={FiTarget} color="purple.500" />
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{item.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={statusInfo.color}
|
||||
variant="subtle"
|
||||
>
|
||||
{statusInfo.text}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<HStack>
|
||||
<IconButton
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleOpenModal(item)}
|
||||
aria-label="编辑计划"
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
aria-label="删除计划"
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{(item.content || item.description) && (
|
||||
<Text fontSize="sm" color={textColor} noOfLines={3}>
|
||||
{item.content || item.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{item.stocks && item.stocks.length > 0 && (
|
||||
<>
|
||||
{item.stocks.map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<>
|
||||
{item.tags.map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="purple" variant="subtle">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null)}
|
||||
>
|
||||
新建计划
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center py={8}>
|
||||
<Spinner size="xl" color="purple.500" />
|
||||
</Center>
|
||||
) : plans.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiTarget} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText}>暂无投资计划</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null)}
|
||||
>
|
||||
创建第一个计划
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
||||
{plans.map(renderCard)}
|
||||
</Grid>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* 编辑/新建模态框 */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{editingItem ? '编辑' : '新建'}投资计划
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>日期</FormLabel>
|
||||
<InputGroup>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<Icon as={FiCalendar} color={secondaryText} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="例如:布局新能源板块"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>内容</FormLabel>
|
||||
<Textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
placeholder="详细描述您的投资计划..."
|
||||
rows={6}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={stockInput}
|
||||
onChange={(e) => setStockInput(e.target.value)}
|
||||
placeholder="输入股票代码"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
|
||||
/>
|
||||
<Button onClick={handleAddStock}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{(formData.stocks || []).map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
stocks: formData.stocks.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>标签</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
placeholder="输入标签"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
|
||||
/>
|
||||
<Button onClick={handleAddTag}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{(formData.tags || []).map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="purple">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
tags: formData.tags.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>状态</FormLabel>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as EventStatus })}
|
||||
>
|
||||
<option value="active">进行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="purple"
|
||||
onClick={handleSave}
|
||||
isDisabled={!formData.title || !formData.date}
|
||||
leftIcon={<FiSave />}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,506 +0,0 @@
|
||||
/**
|
||||
* ReviewsPanel - 投资复盘列表面板组件
|
||||
* 显示、编辑和管理投资复盘
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
Grid,
|
||||
Card,
|
||||
CardBody,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
Icon,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
TagCloseButton,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiPlus,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiSave,
|
||||
FiFileText,
|
||||
FiCalendar,
|
||||
FiTrendingUp,
|
||||
FiHash,
|
||||
FiCheckCircle,
|
||||
FiXCircle,
|
||||
FiAlertCircle,
|
||||
} from 'react-icons/fi';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import type { InvestmentEvent, PlanFormData, EventStatus } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
/**
|
||||
* 状态信息接口
|
||||
*/
|
||||
interface StatusInfo {
|
||||
icon: React.ComponentType;
|
||||
color: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReviewsPanel 组件
|
||||
* 复盘列表面板,显示所有投资复盘
|
||||
*/
|
||||
export const ReviewsPanel: React.FC = () => {
|
||||
const {
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
toast,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
borderColor,
|
||||
} = usePlanningData();
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [editingItem, setEditingItem] = useState<InvestmentEvent | null>(null);
|
||||
const [formData, setFormData] = useState<PlanFormData>({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'review',
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
const [stockInput, setStockInput] = useState<string>('');
|
||||
const [tagInput, setTagInput] = useState<string>('');
|
||||
|
||||
// 筛选复盘列表(排除系统事件)
|
||||
const reviews = allEvents.filter(event => event.type === 'review' && event.source !== 'future');
|
||||
|
||||
// 打开编辑/新建模态框
|
||||
const handleOpenModal = (item: InvestmentEvent | null = null): void => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
title: item.title,
|
||||
content: item.description || item.content || '',
|
||||
type: 'review',
|
||||
stocks: item.stocks || [],
|
||||
tags: item.tags || [],
|
||||
status: item.status || 'active',
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'review',
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 保存数据
|
||||
const handleSave = async (): Promise<void> => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const url = editingItem
|
||||
? base + `/api/account/investment-plans/${editingItem.id}`
|
||||
: base + '/api/account/investment-plans';
|
||||
|
||||
const method = editingItem ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('ReviewsPanel', `${editingItem ? '更新' : '创建'}成功`, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData.title,
|
||||
});
|
||||
toast({
|
||||
title: editingItem ? '更新成功' : '创建成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
onClose();
|
||||
loadAllData();
|
||||
} else {
|
||||
throw new Error('保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('ReviewsPanel', 'handleSave', error, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData?.title
|
||||
});
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: '无法保存数据',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除数据
|
||||
const handleDelete = async (id: number): Promise<void> => {
|
||||
if (!window.confirm('确定要删除吗?')) return;
|
||||
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('ReviewsPanel', '删除成功', { itemId: id });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadAllData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('ReviewsPanel', 'handleDelete', error, { itemId: id });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 添加股票
|
||||
const handleAddStock = (): void => {
|
||||
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
stocks: [...formData.stocks, stockInput.trim()],
|
||||
});
|
||||
setStockInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 添加标签
|
||||
const handleAddTag = (): void => {
|
||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
tags: [...formData.tags, tagInput.trim()],
|
||||
});
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态信息
|
||||
const getStatusInfo = (status?: EventStatus): StatusInfo => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return { icon: FiCheckCircle, color: 'green', text: '已完成' };
|
||||
case 'cancelled':
|
||||
return { icon: FiXCircle, color: 'red', text: '已取消' };
|
||||
default:
|
||||
return { icon: FiAlertCircle, color: 'blue', text: '进行中' };
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染单个卡片
|
||||
const renderCard = (item: InvestmentEvent): React.ReactElement => {
|
||||
const statusInfo = getStatusInfo(item.status);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
bg={cardBg}
|
||||
shadow="sm"
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Flex justify="space-between" align="start">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Icon as={FiFileText} color="green.500" />
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{item.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={statusInfo.color}
|
||||
variant="subtle"
|
||||
>
|
||||
{statusInfo.text}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<HStack>
|
||||
<IconButton
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleOpenModal(item)}
|
||||
aria-label="编辑复盘"
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
aria-label="删除复盘"
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{(item.content || item.description) && (
|
||||
<Text fontSize="sm" color={textColor} noOfLines={3}>
|
||||
{item.content || item.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{item.stocks && item.stocks.length > 0 && (
|
||||
<>
|
||||
{item.stocks.map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<>
|
||||
{item.tags.map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="green" variant="subtle">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null)}
|
||||
>
|
||||
新建复盘
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center py={8}>
|
||||
<Spinner size="xl" color="green.500" />
|
||||
</Center>
|
||||
) : reviews.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiFileText} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText}>暂无投资复盘</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null)}
|
||||
>
|
||||
创建第一个复盘
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
||||
{reviews.map(renderCard)}
|
||||
</Grid>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* 编辑/新建模态框 */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{editingItem ? '编辑' : '新建'}投资复盘
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>日期</FormLabel>
|
||||
<InputGroup>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<Icon as={FiCalendar} color={secondaryText} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="例如:本周操作复盘"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>内容</FormLabel>
|
||||
<Textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
placeholder="详细记录您的投资复盘..."
|
||||
rows={6}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={stockInput}
|
||||
onChange={(e) => setStockInput(e.target.value)}
|
||||
placeholder="输入股票代码"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
|
||||
/>
|
||||
<Button onClick={handleAddStock}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{(formData.stocks || []).map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
stocks: formData.stocks.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>标签</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
placeholder="输入标签"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
|
||||
/>
|
||||
<Button onClick={handleAddTag}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{(formData.tags || []).map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="green">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
tags: formData.tags.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>状态</FormLabel>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as EventStatus })}
|
||||
>
|
||||
<option value="active">进行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="green"
|
||||
onClick={handleSave}
|
||||
isDisabled={!formData.title || !formData.date}
|
||||
leftIcon={<FiSave />}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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