diff --git a/src/components/ErrorPage/README.md b/src/components/ErrorPage/README.md new file mode 100644 index 00000000..197ab2a2 --- /dev/null +++ b/src/components/ErrorPage/README.md @@ -0,0 +1,335 @@ +# ErrorPage 通用错误页面组件 + +通用错误页面组件,用于显示加载失败、网络错误、404 等异常状态。 + +**设计风格**:黑色背景 (`#1A202C`) + 金色边框 (`#D4A574`) + +## 效果预览 + +``` +┌─────────────────────────────────────┐ +│ ╭──────────╮ │ +│ │ ⚠️ │ (金色圆形) │ +│ ╰──────────╯ │ +│ │ +│ 事件走丢了 │ +│ ID: ZXY-101 │ +│ │ +│ ┌──────────────────────────────┐ │ +│ │ 抱歉,我们找不到您请求的事件... │ │ +│ │ │ │ +│ │ 🔍 事件ID输入错误 │ │ +│ │ 请检查URL中的事件ID是否正确 │ │ +│ │ │ │ +│ │ 🗑️ 该事件已被删除或下架 │ │ +│ │ 该事件可能因过期而被移除 │ │ +│ │ │ │ +│ │ 🔄 系统暂时无法访问该事件 │ │ +│ │ 请稍后重试或联系技术支持 │ │ +│ │ │ │ +│ │ [查看技术信息 ▼] │ │ +│ └──────────────────────────────┘ │ +│ │ +│ [返回] [重试] │ +│ │ +│ 点击右下角联系客服 │ +└─────────────────────────────────────┘ +``` + +## 快速开始 + +### 基础用法 + +```tsx +import ErrorPage from '@/components/ErrorPage'; + +// 最简单的用法 - 使用所有默认配置 + + +// 自定义标题和描述 + +``` + +### 完整配置示例 + +```tsx + 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; // 自定义字段 +} +``` + +### 操作按钮配置 + +#### 快捷配置 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `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 + location.reload() }, + { label: '返回列表', variant: 'outline', href: '/events' }, + ]} +/> +``` + +### 布局配置 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `fullScreen` | `boolean` | `true` | 是否全屏显示 | +| `maxWidth` | `string` | `'500px'` | 最大宽度 | + +### 功能增强 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `checkOffline` | `boolean` | `true` | 是否检查网络状态并显示离线提示 | +| `enableBuiltInReport` | `boolean` | `true` | 是否启用内置 PostHog 错误上报 | +| `onErrorReport` | `(errorInfo: Record) => 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 + +``` + +**自定义上报回调示例:** + +```tsx + { + // 自定义上报逻辑(与内置 PostHog 上报同时生效) + customAnalytics.track('custom_error_event', errorInfo); + }} +/> +``` + +## 场景示例 + +### 1. 事件详情 404 页面 + +```tsx + window.location.reload()} + showBack + showHome + homePath="/community" +/> +``` + +### 2. 网络错误页面 + +```tsx + window.location.reload()} +/> +``` + +### 3. 简洁模式(无原因列表) + +```tsx + +``` + +## 类型导出 + +组件导出以下 TypeScript 类型,方便外部使用: + +```typescript +import ErrorPage, { + ErrorPageProps, + ErrorReasonItem, + ActionButton, + TechDetails, +} from '@/components/ErrorPage'; +``` + +## 设计说明 + +- **配色**:黑色背景 (`#1A202C`) + 金色边框/按钮 (`#D4A574`) +- **图标**:金色圆形背景 (50px) + 黑色感叹号 +- **布局**:居中卡片式布局,最大宽度 500px +- **底部提示**:"点击右下角联系客服"(纯文本,无链接) diff --git a/src/components/ErrorPage/index.tsx b/src/components/ErrorPage/index.tsx index 14edb446..568b4c81 100644 --- a/src/components/ErrorPage/index.tsx +++ b/src/components/ErrorPage/index.tsx @@ -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; +} + +// 完整的 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) => void; } +// 默认错误原因 +const DEFAULT_REASONS: ErrorReasonItem[] = [ + { + icon: '🔍', + title: 'ID 可能输入错误', + description: '请检查 URL 中的 ID 是否正确', + }, + { + icon: '🗑️', + title: '内容可能已被删除', + description: '该内容可能因过期或调整而被下架', + }, + { + icon: '🔄', + title: '系统暂时无法访问', + description: '请稍后重试或联系技术支持', + }, +]; + const ErrorPage: React.FC = ({ 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 ( = ({ alignItems="center" justifyContent="center" > - - {/* 金色圆形图标 + 黑色感叹号 */} - - - + + {/* 金色圆形感叹号图标 */} + + + - {/* 金色标题 */} - - {title} + {/* 金色标题 */} + + {title} + + + {/* 副标题(ID 显示) */} + {(subtitle || detail) && ( + + {subtitle || `${detailLabel}: ${detail}`} + )} - {/* 描述信息 */} - {description && ( - + {/* 离线提示 */} + {checkOffline && isOffline && ( + + + 当前处于离线状态,请检查网络连接 + + + )} + + {/* 错误原因列表 */} + {reasons.length > 0 && ( + + {description} - )} - {/* 详情 */} - {detail && ( - - {detailLabel}: {detail} - - )} + + {reasons.map((reason, index) => ( + + + {typeof reason.icon === 'string' ? reason.icon : reason.icon} + + + + {reason.title} + + + {reason.description} + + + + ))} + + + )} - {/* 按钮组 */} - {hasButtons && ( - - {showBack && onBack && ( - - )} - {showRetry && onRetry && ( - - )} - - )} - + {/* 技术详情(可展开) */} + {techDetails && ( + + + + + {techDetails.requestUrl && ( + 请求URL: {techDetails.requestUrl} + )} + {techDetails.errorType && ( + 错误类型: {techDetails.errorType} + )} + {techDetails.errorMessage && ( + 错误信息: {techDetails.errorMessage} + )} + {techDetails.timestamp && ( + 时间戳: {techDetails.timestamp} + )} + {techDetails.relatedId && ( + 相关ID: {techDetails.relatedId} + )} + {techDetails.customFields && + Object.entries(techDetails.customFields).map(([key, value]) => ( + + {key}: {value} + + ))} + + + + )} + + {/* 按钮组 */} + {hasButtons && ( + + {actionButtons.map((btn, index) => ( + + ))} + + )} + + {/* 底部帮助提示 */} + + 点击右下角 + + 联系客服 + + + ); }; diff --git a/src/components/StockChart/KLineChartModal.tsx b/src/components/StockChart/KLineChartModal.tsx index 68bfa502..c303d517 100644 --- a/src/components/StockChart/KLineChartModal.tsx +++ b/src/components/StockChart/KLineChartModal.tsx @@ -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 diff --git a/src/components/StockChart/TimelineChartModal.tsx b/src/components/StockChart/TimelineChartModal.tsx index f38302cb..5328e551 100644 --- a/src/components/StockChart/TimelineChartModal.tsx +++ b/src/components/StockChart/TimelineChartModal.tsx @@ -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 diff --git a/src/mocks/data/account.js b/src/mocks/data/account.js index a5cf2e89..73b7fc7e 100644 --- a/src/mocks/data/account.js +++ b/src/mocks/data/account.js @@ -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, diff --git a/src/mocks/data/events.js b/src/mocks/data/events.js index 87ae97a7..6e73297f 100644 --- a/src/mocks/data/events.js +++ b/src/mocks/data/events.js @@ -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; }); }); } diff --git a/src/mocks/handlers/account.js b/src/mocks/handlers/account.js index a415b318..3394e0df 100644 --- a/src/mocks/handlers/account.js +++ b/src/mocks/handlers/account.js @@ -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({ diff --git a/src/mocks/handlers/stock.js b/src/mocks/handlers/stock.js index eb3ef037..c8219b70 100644 --- a/src/mocks/handlers/stock.js +++ b/src/mocks/handlers/stock.js @@ -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', diff --git a/src/services/stockService.js b/src/services/stockService.js index 5b1b4681..593d168a 100644 --- a/src/services/stockService.js +++ b/src/services/stockService.js @@ -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 - 搜索关键词 diff --git a/src/store/hooks.ts b/src/store/hooks.ts new file mode 100644 index 00000000..2cf94e3b --- /dev/null +++ b/src/store/hooks.ts @@ -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; +export type AppDispatch = typeof store.dispatch; + +// 类型化的 hooks +export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/store/index.js b/src/store/index.js index 191439a2..26a796b0 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -63,4 +63,9 @@ export const injectReducer = (key, reducer) => { store.replaceReducer(createRootReducer()); }; +/** + * @typedef {typeof store.dispatch} AppDispatch + * @typedef {ReturnType} RootState + */ + export default store; diff --git a/src/store/slices/stockSlice.js b/src/store/slices/stockSlice.js index 0b6982f4..37622694 100644 --- a/src/store/slices/stockSlice.js +++ b/src/store/slices/stockSlice.js @@ -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 + // 自选股列表 [{ 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 }); } } }); diff --git a/src/types/investment.ts b/src/types/investment.ts index 643dc1ad..7b8854af 100644 --- a/src/types/investment.ts +++ b/src/types/investment.ts @@ -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>; - /** 当前激活的标签页索引 (0: 日历, 1: 计划, 2: 复盘) */ - activeTab: number; + /** 打开新建计划模态框的触发计数器 */ + openPlanModalTrigger?: number; - /** 设置激活的标签页 */ - setActiveTab: React.Dispatch>; + /** 打开新建复盘模态框的触发计数器 */ + 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; } diff --git a/src/views/Community/components/CompactSearchBox.js b/src/views/Community/components/CompactSearchBox.js index 95dc54c1..9caa76db 100644 --- a/src/views/Community/components/CompactSearchBox.js +++ b/src/views/Community/components/CompactSearchBox.js @@ -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); }; diff --git a/src/views/Community/components/HeroPanel.js b/src/views/Community/components/HeroPanel.js index c30352c4..eb0a8cc4 100644 --- a/src/views/Community/components/HeroPanel.js +++ b/src/views/Community/components/HeroPanel.js @@ -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 ( { > {/* 复制两份实现无缝滚动 */} {[...items, ...items].map((concept, idx) => - renderConceptCard(concept, startIdx + (idx % items.length)) + renderConceptCard(concept, startIdx + (idx % items.length), idx) )} diff --git a/src/views/Community/components/StockDetailPanel/hooks/useWatchlist.js b/src/views/Community/components/StockDetailPanel/hooks/useWatchlist.js index 92e7c031..41aa38d7 100644 --- a/src/views/Community/components/StockDetailPanel/hooks/useWatchlist.js +++ b/src/views/Community/components/StockDetailPanel/hooks/useWatchlist.js @@ -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 时) diff --git a/src/views/Dashboard/Center.js b/src/views/Dashboard/Center.js index 2eb31105..3e34c48e 100644 --- a/src/views/Dashboard/Center.js +++ b/src/views/Dashboard/Center.js @@ -273,18 +273,18 @@ export default function CenterDashboard() { } return ( - - + + {/* 主要内容区域 */} - + {/* 左列:自选股票 */} - - - + + + - - 自选股票 + + 自选股票 {watchlist.length} @@ -321,7 +321,7 @@ export default function CenterDashboard() { {watchlist.slice(0, 10).map((stock) => ( {/* 中列:关注事件 */} - + {/* 关注事件 */} - - + + - - 关注事件 + + 关注事件 {followingEvents.length} @@ -525,14 +525,14 @@ export default function CenterDashboard() { {/* 右列:我的评论 */} - + {/* 我的评论 */} - - + + - - 我的评论 + + 我的评论 {eventComments.length} @@ -568,15 +568,22 @@ export default function CenterDashboard() { {comment.content} - - + + {formatDate(comment.created_at)} {comment.event_title && ( - - {comment.event_title.slice(0, 20)}... + + {comment.event_title} )} diff --git a/src/views/Dashboard/components/CalendarPanel.tsx b/src/views/Dashboard/components/CalendarPanel.tsx index 4d2a57c3..f8a8f11e 100644 --- a/src/views/Dashboard/components/CalendarPanel.tsx +++ b/src/views/Dashboard/components/CalendarPanel.tsx @@ -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(null); const [selectedDateEvents, setSelectedDateEvents] = useState([]); - const [newEvent, setNewEvent] = useState({ - 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 => { - 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 => { - 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 ( - - - - - {loading ? ( -
- -
- ) : ( - - - - )} + events={calendarEvents} + dateClick={handleDateClick} + eventClick={handleEventClick} + height="100%" + dayMaxEvents={1} + moreLinkText="+更多" + buttonText={{ + today: '今天', + month: '月', + week: '周' + }} + titleFormat={{ year: 'numeric', month: 'long' }} + /> +
{/* 查看事件详情 Modal */} - {isOpen && ( - + 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 && ( + setIsInvestmentCalendarOpen(false)} + size={{ base: 'full', md: '6xl' }} + > - - - {selectedDate && selectedDate.format('YYYY年MM月DD日')} 的事件 - + + 投资日历 - - {selectedDateEvents.length === 0 ? ( -
- - 当天没有事件 - - -
- ) : ( - - {selectedDateEvents.map((event, idx) => ( - - - - - - {event.title} - - {event.source === 'future' ? ( - 系统事件 - ) : event.type === 'plan' ? ( - 我的计划 - ) : ( - 我的复盘 - )} - - {event.importance && ( - - - - 重要度: {event.importance}/5 - - - )} - - - {!event.source || event.source === 'user' ? ( - <> - - } - size="sm" - variant="ghost" - colorScheme="blue" - onClick={() => handleViewDetails(event)} - aria-label="查看详情" - /> - - } - size="sm" - variant="ghost" - colorScheme="red" - onClick={() => handleDeleteEvent(event.id)} - aria-label="删除事件" - /> - - ) : null} - - - - {event.description && ( - - {event.description} - - )} - - {event.stocks && event.stocks.length > 0 && ( - - 相关股票: - {event.stocks.map((stock, i) => ( - - - {stock} - - ))} - - )} - - ))} - - )} + + }> + + - - -
)} - {/* 添加投资计划 Modal */} - {isAddOpen && ( - - - - - 添加投资计划 - - - - - - 标题 - setNewEvent({ ...newEvent, title: e.target.value })} - placeholder="例如:关注半导体板块" - /> - - - - 描述 -