From 2f27083eb54a5d3db24abf281ade17bfdbff05ea Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 30 Oct 2025 11:13:09 +0800 Subject: [PATCH 001/107] =?UTF-8?q?feat:=20=E6=8B=86=E5=88=86=20EventList.?= =?UTF-8?q?js/=E6=8F=90=E5=8F=96=E4=BB=B7=E6=A0=BC=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=87=BD=E6=95=B0=E5=88=B0=20utils/priceForm?= =?UTF-8?q?atters.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- src/constants/animations.js | 72 ++++++++++++++ src/utils/priceFormatters.js | 105 ++++++++++++++++++++ src/views/Community/components/EventList.js | 97 ++---------------- 4 files changed, 190 insertions(+), 87 deletions(-) create mode 100644 src/constants/animations.js create mode 100644 src/utils/priceFormatters.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 95814f11..8e92b5b9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,8 @@ "Bash(npm run start:mock)", "Bash(npm install fsevents@latest --save-optional --force)", "Bash(python -m py_compile:*)", - "Bash(ps -p 20502,53360 -o pid,command)" + "Bash(ps -p 20502,53360 -o pid,command)", + "Bash(mkdir -p /Users/qiye/Desktop/jzqy/vf_react/docs/graduation)" ], "deny": [], "ask": [] diff --git a/src/constants/animations.js b/src/constants/animations.js new file mode 100644 index 00000000..92c6d27d --- /dev/null +++ b/src/constants/animations.js @@ -0,0 +1,72 @@ +// src/constants/animations.js +// 通用动画定义 - 使用 @emotion/react 的 keyframes + +import { keyframes } from '@emotion/react'; + +/** + * 脉冲动画 - 用于S/A级重要性标签 + * 从中心向外扩散的阴影效果 + */ +export const pulseAnimation = keyframes` + 0% { + box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.7); + } + 70% { + box-shadow: 0 0 0 10px rgba(255, 77, 79, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(255, 77, 79, 0); + } +`; + +/** + * 渐入动画 + */ +export const fadeIn = keyframes` + from { + opacity: 0; + } + to { + opacity: 1; + } +`; + +/** + * 从下往上滑入动画 + */ +export const slideInUp = keyframes` + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +`; + +/** + * 缩放进入动画 + */ +export const scaleIn = keyframes` + from { + transform: scale(0.9); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +`; + +/** + * 旋转动画(用于Loading Spinner) + */ +export const spin = keyframes` + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +`; diff --git a/src/utils/priceFormatters.js b/src/utils/priceFormatters.js new file mode 100644 index 00000000..e93d4dfb --- /dev/null +++ b/src/utils/priceFormatters.js @@ -0,0 +1,105 @@ +// src/utils/priceFormatters.js +// 价格相关的工具函数 - 中国A股配色:红涨绿跌 + +import React from 'react'; +import { TriangleUpIcon, TriangleDownIcon } from '@chakra-ui/icons'; + +/** + * 获取价格变化的文字颜色 + * @param {number|null|undefined} value - 涨跌幅百分比 + * @returns {string} Chakra UI 颜色值 + */ +export const getPriceChangeColor = (value) => { + if (value === null || value === undefined) return 'gray.500'; + + const absValue = Math.abs(value); + + if (value > 0) { + // 上涨用红色,根据涨幅大小使用不同深浅 + if (absValue >= 3) return 'red.600'; // 深红色:3%以上 + if (absValue >= 1) return 'red.500'; // 中红色:1-3% + return 'red.400'; // 浅红色:0-1% + } else if (value < 0) { + // 下跌用绿色,根据跌幅大小使用不同深浅 + if (absValue >= 3) return 'green.600'; // 深绿色:3%以上 + if (absValue >= 1) return 'green.500'; // 中绿色:1-3% + return 'green.400'; // 浅绿色:0-1% + } + return 'gray.500'; +}; + +/** + * 获取价格变化的背景颜色 + * @param {number|null|undefined} value - 涨跌幅百分比 + * @returns {string} Chakra UI 颜色值 + */ +export const getPriceChangeBg = (value) => { + if (value === null || value === undefined) return 'gray.50'; + + const absValue = Math.abs(value); + + if (value > 0) { + // 上涨背景色 + if (absValue >= 3) return 'red.100'; // 深色背景:3%以上 + if (absValue >= 1) return 'red.50'; // 中色背景:1-3% + return 'red.50'; // 浅色背景:0-1% + } else if (value < 0) { + // 下跌背景色 + if (absValue >= 3) return 'green.100'; // 深色背景:3%以上 + if (absValue >= 1) return 'green.50'; // 中色背景:1-3% + return 'green.50'; // 浅色背景:0-1% + } + return 'gray.50'; +}; + +/** + * 获取价格变化的边框颜色 + * @param {number|null|undefined} value - 涨跌幅百分比 + * @returns {string} Chakra UI 颜色值 + */ +export const getPriceChangeBorderColor = (value) => { + if (value === null || value === undefined) return 'gray.300'; + + const absValue = Math.abs(value); + + if (value > 0) { + // 上涨边框色 + if (absValue >= 3) return 'red.500'; // 深边框:3%以上 + if (absValue >= 1) return 'red.400'; // 中边框:1-3% + return 'red.300'; // 浅边框:0-1% + } else if (value < 0) { + // 下跌边框色 + if (absValue >= 3) return 'green.500'; // 深边框:3%以上 + if (absValue >= 1) return 'green.400'; // 中边框:1-3% + return 'green.300'; // 浅边框:0-1% + } + return 'gray.300'; +}; + +/** + * 格式化价格变化为字符串 + * @param {number|null|undefined} value - 涨跌幅百分比 + * @param {number} decimals - 小数位数,默认2位 + * @returns {string} 格式化后的字符串,例如 "+5.23%" 或 "-2.10%" + */ +export const formatPriceChange = (value, decimals = 2) => { + if (value === null || value === undefined) return '--%'; + + const sign = value > 0 ? '+' : ''; + return `${sign}${value.toFixed(decimals)}%`; +}; + +/** + * 价格涨跌箭头组件 + * @param {Object} props + * @param {number|null|undefined} props.value - 涨跌幅百分比 + * @returns {JSX.Element|null} + */ +export const PriceArrow = ({ value }) => { + if (value === null || value === undefined) return null; + + const Icon = value > 0 ? TriangleUpIcon : TriangleDownIcon; + const color = value > 0 ? 'red.500' : 'green.500'; + + return ; +}; diff --git a/src/views/Community/components/EventList.js b/src/views/Community/components/EventList.js index 71a31750..9208c8de 100644 --- a/src/views/Community/components/EventList.js +++ b/src/views/Community/components/EventList.js @@ -1,6 +1,5 @@ // src/views/Community/components/EventList.js import React, { useState, useEffect } from 'react'; -import { keyframes } from '@emotion/react'; import { Box, VStack, @@ -45,14 +44,14 @@ import { WarningIcon, WarningTwoIcon, CheckCircleIcon, - TriangleUpIcon, - TriangleDownIcon, ArrowForwardIcon, ExternalLinkIcon, ViewOffIcon, } from '@chakra-ui/icons'; import { useNavigate } from 'react-router-dom'; import moment from 'moment'; + +// 导入工具函数和常量 import { logger } from '../../../utils/logger'; import { getApiBase } from '../../../utils/apiConfig'; import { useEventNotifications } from '../../../hooks/useEventNotifications'; @@ -60,90 +59,16 @@ import { getImportanceConfig, getAllImportanceLevels } from '../../../constants/ import { browserNotificationService } from '../../../services/browserNotificationService'; import { useNotification } from '../../../contexts/NotificationContext'; -// ========== 动画定义 ========== -// 脉冲动画 - 用于S/A级重要性标签 -const pulseAnimation = keyframes` - 0% { - box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.7); - } - 70% { - box-shadow: 0 0 0 10px rgba(255, 77, 79, 0); - } - 100% { - box-shadow: 0 0 0 0 rgba(255, 77, 79, 0); - } -`; +// 导入价格相关工具函数 +import { + getPriceChangeColor, + getPriceChangeBg, + getPriceChangeBorderColor, + PriceArrow, +} from '../../../utils/priceFormatters'; -// ========== 工具函数定义在组件外部 ========== -// 涨跌颜色配置(中国A股配色:红涨绿跌)- 分档次显示 -const getPriceChangeColor = (value) => { - if (value === null || value === undefined) return 'gray.500'; - - const absValue = Math.abs(value); - - if (value > 0) { - // 上涨用红色,根据涨幅大小使用不同深浅 - if (absValue >= 3) return 'red.600'; // 深红色:3%以上 - if (absValue >= 1) return 'red.500'; // 中红色:1-3% - return 'red.400'; // 浅红色:0-1% - } else if (value < 0) { - // 下跌用绿色,根据跌幅大小使用不同深浅 - if (absValue >= 3) return 'green.600'; // 深绿色:3%以上 - if (absValue >= 1) return 'green.500'; // 中绿色:1-3% - return 'green.400'; // 浅绿色:0-1% - } - return 'gray.500'; -}; - -const getPriceChangeBg = (value) => { - if (value === null || value === undefined) return 'gray.50'; - - const absValue = Math.abs(value); - - if (value > 0) { - // 上涨背景色 - if (absValue >= 3) return 'red.100'; // 深色背景:3%以上 - if (absValue >= 1) return 'red.50'; // 中色背景:1-3% - return 'red.50'; // 浅色背景:0-1% - } else if (value < 0) { - // 下跌背景色 - if (absValue >= 3) return 'green.100'; // 深色背景:3%以上 - if (absValue >= 1) return 'green.50'; // 中色背景:1-3% - return 'green.50'; // 浅色背景:0-1% - } - return 'gray.50'; -}; - -const getPriceChangeBorderColor = (value) => { - if (value === null || value === undefined) return 'gray.300'; - - const absValue = Math.abs(value); - - if (value > 0) { - // 上涨边框色 - if (absValue >= 3) return 'red.500'; // 深边框:3%以上 - if (absValue >= 1) return 'red.400'; // 中边框:1-3% - return 'red.300'; // 浅边框:0-1% - } else if (value < 0) { - // 下跌边框色 - if (absValue >= 3) return 'green.500'; // 深边框:3%以上 - if (absValue >= 1) return 'green.400'; // 中边框:1-3% - return 'green.300'; // 浅边框:0-1% - } - return 'gray.300'; -}; - -// 重要性等级配置已移至 src/constants/importanceLevels.js - -// 自定义的涨跌箭头组件(修复颜色问题) -const PriceArrow = ({ value }) => { - if (value === null || value === undefined) return null; - - const Icon = value > 0 ? TriangleUpIcon : TriangleDownIcon; - const color = value > 0 ? 'red.500' : 'green.500'; - - return ; -}; +// 导入动画定义 +import { pulseAnimation } from '../../../constants/animations'; // ========== 主组件 ========== const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetail }) => { From d2c8c4689b4748cfa9ebee68b91eb5576febc207 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 30 Oct 2025 12:14:27 +0800 Subject: [PATCH 002/107] =?UTF-8?q?feat:=20=E5=88=9B=E5=BB=BA=E5=8E=9F?= =?UTF-8?q?=E5=AD=90=E7=BB=84=E4=BB=B6=EF=BC=88Atoms=EF=BC=89=20-=20EventT?= =?UTF-8?q?imeline:=20=E6=97=B6=E9=97=B4=E8=BD=B4=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=EF=BC=8860=E8=A1=8C=EF=BC=89=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=E2=94=82=20=E2=94=82=20=E2=94=82=20=E2=94=82=20-=20EventImport?= =?UTF-8?q?anceBadge:=20=E9=87=8D=E8=A6=81=E6=80=A7=E7=AD=89=E7=BA=A7?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=EF=BC=88100=E8=A1=8C=EF=BC=89=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=E2=94=82=20=E2=94=82?= =?UTF-8?q?=20=E2=94=82=20=E2=94=82=20-=20EventStats:=20=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E7=BB=84=E4=BB=B6=EF=BC=8860=E8=A1=8C?= =?UTF-8?q?=EF=BC=89=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=E2=94=82=20=E2=94=82?= =?UTF-8?q?=20=E2=94=82=20=E2=94=82=20-=20EventFollowButton:=20=E5=85=B3?= =?UTF-8?q?=E6=B3=A8=E6=8C=89=E9=92=AE=EF=BC=8840=E8=A1=8C=EF=BC=89=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=E2=94=82=20=E2=94=82=20=E2=94=82=20=E2=94=82?= =?UTF-8?q?=20-=20EventPriceDisplay:=20=E4=BB=B7=E6=A0=BC=E5=8F=98?= =?UTF-8?q?=E5=8A=A8=E6=98=BE=E7=A4=BA=EF=BC=88130=E8=A1=8C=EF=BC=89=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=E2=94=82=20=E2=94=82=20=E2=94=82=20=E2=94=82=20-=20EventDes?= =?UTF-8?q?cription:=20=E6=8F=8F=E8=BF=B0=E6=96=87=E6=9C=AC=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=EF=BC=8860=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/EventCard/EventDescription.js | 56 ++++++++ .../components/EventCard/EventFollowButton.js | 43 +++++++ .../EventCard/EventImportanceBadge.js | 96 ++++++++++++++ .../components/EventCard/EventPriceDisplay.js | 121 ++++++++++++++++++ .../components/EventCard/EventStats.js | 58 +++++++++ .../components/EventCard/EventTimeline.js | 62 +++++++++ 6 files changed, 436 insertions(+) create mode 100644 src/views/Community/components/EventCard/EventDescription.js create mode 100644 src/views/Community/components/EventCard/EventFollowButton.js create mode 100644 src/views/Community/components/EventCard/EventImportanceBadge.js create mode 100644 src/views/Community/components/EventCard/EventPriceDisplay.js create mode 100644 src/views/Community/components/EventCard/EventStats.js create mode 100644 src/views/Community/components/EventCard/EventTimeline.js diff --git a/src/views/Community/components/EventCard/EventDescription.js b/src/views/Community/components/EventCard/EventDescription.js new file mode 100644 index 00000000..0554bfe0 --- /dev/null +++ b/src/views/Community/components/EventCard/EventDescription.js @@ -0,0 +1,56 @@ +// src/views/Community/components/EventCard/EventDescription.js +import React, { useState } from 'react'; +import { Box, Text, Button } from '@chakra-ui/react'; + +/** + * 事件描述组件(支持展开/收起) + * @param {Object} props + * @param {string} props.description - 描述文本 + * @param {string} props.textColor - 文字颜色 + * @param {number} props.minLength - 触发展开/收起的最小长度(默认 120) + * @param {number} props.noOfLines - 未展开时显示的行数(默认 3) + */ +const EventDescription = ({ + description, + textColor, + minLength = 120, + noOfLines = 3 +}) => { + const [isExpanded, setIsExpanded] = useState(false); + + // 如果没有描述,不渲染 + if (!description) { + return null; + } + + const handleToggle = (e) => { + e.stopPropagation(); + setIsExpanded(!isExpanded); + }; + + return ( + + + {description} + + {description.length > minLength && ( + + )} + + ); +}; + +export default EventDescription; diff --git a/src/views/Community/components/EventCard/EventFollowButton.js b/src/views/Community/components/EventCard/EventFollowButton.js new file mode 100644 index 00000000..9b7a3e3c --- /dev/null +++ b/src/views/Community/components/EventCard/EventFollowButton.js @@ -0,0 +1,43 @@ +// src/views/Community/components/EventCard/EventFollowButton.js +import React from 'react'; +import { Button } from '@chakra-ui/react'; +import { StarIcon } from '@chakra-ui/icons'; + +/** + * 事件关注按钮组件 + * @param {Object} props + * @param {boolean} props.isFollowing - 是否已关注 + * @param {number} props.followerCount - 关注数 + * @param {Function} props.onToggle - 切换关注状态的回调函数 + * @param {string} props.size - 按钮尺寸('xs' | 'sm' | 'md',默认 'sm') + * @param {boolean} props.showCount - 是否显示关注数(默认 true) + */ +const EventFollowButton = ({ + isFollowing, + followerCount = 0, + onToggle, + size = 'sm', + showCount = true +}) => { + const iconSize = size === 'xs' ? '10px' : '12px'; + + const handleClick = (e) => { + e.stopPropagation(); + onToggle?.(); + }; + + return ( + + ); +}; + +export default EventFollowButton; diff --git a/src/views/Community/components/EventCard/EventImportanceBadge.js b/src/views/Community/components/EventCard/EventImportanceBadge.js new file mode 100644 index 00000000..7470f795 --- /dev/null +++ b/src/views/Community/components/EventCard/EventImportanceBadge.js @@ -0,0 +1,96 @@ +// src/views/Community/components/EventCard/EventImportanceBadge.js +import React from 'react'; +import { Badge, Tooltip, VStack, HStack, Text, Divider, Circle } from '@chakra-ui/react'; +import { InfoIcon } from '@chakra-ui/icons'; +import { getImportanceConfig, getAllImportanceLevels } from '../../../../constants/importanceLevels'; + +/** + * 事件重要性等级标签组件 + * @param {Object} props + * @param {string} props.importance - 重要性等级(S/A/B/C/D) + * @param {boolean} props.showTooltip - 是否显示详细提示框(默认 false) + * @param {boolean} props.showIcon - 是否显示信息图标(默认 false) + * @param {string} props.size - 标签大小(xs/sm/md/lg,默认 xs) + */ +const EventImportanceBadge = ({ + importance, + showTooltip = false, + showIcon = false, + size = 'xs' +}) => { + const importanceConfig = getImportanceConfig(importance); + + // 简单模式:只显示标签 + if (!showTooltip) { + return ( + + {importance || 'C'}级 + + ); + } + + // 详细模式:带提示框的标签 + return ( + + + 重要性等级说明 + + + {getAllImportanceLevels().map((level) => ( + + + + {level.level}级 + {level.description} + + + ))} + + } + placement="top" + hasArrow + bg="white" + color="gray.800" + fontSize="md" + p={3} + borderRadius="lg" + borderWidth="1px" + borderColor="gray.200" + boxShadow="lg" + > + + {showIcon && } + {importance || 'C'}级 + + + ); +}; + +export default EventImportanceBadge; diff --git a/src/views/Community/components/EventCard/EventPriceDisplay.js b/src/views/Community/components/EventCard/EventPriceDisplay.js new file mode 100644 index 00000000..54371cec --- /dev/null +++ b/src/views/Community/components/EventCard/EventPriceDisplay.js @@ -0,0 +1,121 @@ +// src/views/Community/components/EventCard/EventPriceDisplay.js +import React from 'react'; +import { HStack, Badge, Text, Tooltip } from '@chakra-ui/react'; +import { PriceArrow } from '../../../../utils/priceFormatters'; + +/** + * 事件价格变动显示组件 + * @param {Object} props + * @param {number|null} props.avgChange - 平均涨跌幅 + * @param {number|null} props.maxChange - 最大涨跌幅 + * @param {number|null} props.weekChange - 周涨跌幅 + * @param {boolean} props.compact - 是否为紧凑模式(只显示平均值,默认 false) + * @param {boolean} props.inline - 是否内联显示(默认 false) + */ +const EventPriceDisplay = ({ + avgChange, + maxChange, + weekChange, + compact = false, + inline = false +}) => { + // 获取颜色方案 + const getColorScheme = (value) => { + if (value == null) return 'gray'; + return value > 0 ? 'red' : value < 0 ? 'green' : 'gray'; + }; + + // 格式化百分比 + const formatPercent = (value) => { + if (value == null) return '--'; + return `${value > 0 ? '+' : ''}${value.toFixed(2)}%`; + }; + + // 紧凑模式:只显示平均值,内联在标题后 + if (compact && avgChange != null) { + return ( + + + + {formatPercent(avgChange)} + + + ); + } + + // 详细模式:显示所有价格变动 + return ( + + {/* 平均涨幅 - 始终显示,无数据时显示 -- */} + + + 平均 + + {formatPercent(avgChange)} + + + + + {/* 最大涨幅 - 始终显示,无数据时显示 -- */} + + + 最大 + + {formatPercent(maxChange)} + + + + + {/* 周涨幅 - 始终显示,无数据时显示 -- */} + + + + {weekChange != null && } + + {formatPercent(weekChange)} + + + + + ); +}; + +export default EventPriceDisplay; diff --git a/src/views/Community/components/EventCard/EventStats.js b/src/views/Community/components/EventCard/EventStats.js new file mode 100644 index 00000000..f97cdce4 --- /dev/null +++ b/src/views/Community/components/EventCard/EventStats.js @@ -0,0 +1,58 @@ +// src/views/Community/components/EventCard/EventStats.js +import React from 'react'; +import { HStack, Text, Tooltip } from '@chakra-ui/react'; +import { ViewIcon, ChatIcon, StarIcon } from '@chakra-ui/icons'; + +/** + * 事件统计信息组件(浏览量、帖子数、关注数) + * @param {Object} props + * @param {number} props.viewCount - 浏览量 + * @param {number} props.postCount - 帖子数 + * @param {number} props.followerCount - 关注数 + * @param {string} props.size - 尺寸('sm' | 'md',默认 'sm') + * @param {number} props.spacing - 间距(默认 3) + * @param {Object} props.display - 响应式显示控制(默认 { base: 'none', md: 'flex' }) + * @param {string} props.mutedColor - 文字颜色(可选) + */ +const EventStats = ({ + viewCount = 0, + postCount = 0, + followerCount = 0, + size = 'sm', + spacing = 3, + display = { base: 'none', md: 'flex' }, + mutedColor +}) => { + const fontSize = size === 'sm' ? 'xs' : 'sm'; + const iconSize = size === 'sm' ? '12px' : '16px'; + + return ( + + {/* 浏览量 */} + + + + {viewCount} + + + + {/* 帖子数 */} + + + + {postCount} + + + + {/* 关注数 */} + + + + {followerCount} + + + + ); +}; + +export default EventStats; diff --git a/src/views/Community/components/EventCard/EventTimeline.js b/src/views/Community/components/EventCard/EventTimeline.js new file mode 100644 index 00000000..d2bfdce0 --- /dev/null +++ b/src/views/Community/components/EventCard/EventTimeline.js @@ -0,0 +1,62 @@ +// src/views/Community/components/EventCard/EventTimeline.js +import React from 'react'; +import { Box, VStack, Text, useColorModeValue } from '@chakra-ui/react'; +import moment from 'moment'; + +/** + * 事件时间轴组件 + * @param {Object} props + * @param {string} props.createdAt - 事件创建时间 + * @param {Object} props.timelineStyle - 时间轴样式配置 + * @param {string} props.borderColor - 竖线边框颜色 + * @param {string} props.minHeight - 竖线最小高度(例如:'40px' 或 '80px') + */ +const EventTimeline = ({ createdAt, timelineStyle, borderColor, minHeight = '40px' }) => { + return ( + + {/* 时间长方形卡片 */} + + {/* 日期 YYYY-MM-DD */} + + {moment(createdAt).format('YYYY-MM-DD')} + + {/* 时间 HH:mm */} + + {moment(createdAt).format('HH:mm')} + + + {/* 时间轴竖线 */} + + + ); +}; + +export default EventTimeline; From b6c1b448554905512c127d3f194e25761d583d34 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 30 Oct 2025 12:15:03 +0800 Subject: [PATCH 003/107] =?UTF-8?q?feat:=20=E5=88=9B=E5=BB=BA=E7=BB=84?= =?UTF-8?q?=E5=90=88=E7=BB=84=E4=BB=B6=EF=BC=88Molecules=EF=BC=89=20=20-?= =?UTF-8?q?=20EventHeader:=20=E6=A0=87=E9=A2=98=E5=A4=B4=E9=83=A8=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=EF=BC=88100=E8=A1=8C=EF=BC=89=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=E2=94=82=20=E2=94=82=20=E2=94=82=20=E2=94=82=20-=20CompactE?= =?UTF-8?q?ventCard:=20=E7=B4=A7=E5=87=91=E6=A8=A1=E5=BC=8F=E5=8D=A1?= =?UTF-8?q?=E7=89=87=EF=BC=88160=E8=A1=8C=EF=BC=89=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=E2=94=82=20?= =?UTF-8?q?=E2=94=82=20=E2=94=82=20=E2=94=82=20-=20DetailedEventCard:=20?= =?UTF-8?q?=E8=AF=A6=E7=BB=86=E6=A8=A1=E5=BC=8F=E5=8D=A1=E7=89=87=EF=BC=88?= =?UTF-8?q?170=E8=A1=8C=EF=BC=89=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=E2=94=82=20=E2=94=82=20=E2=94=82?= =?UTF-8?q?=20=E2=94=82=20-=20index.js:=20EventCard=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=8860=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/EventCard/CompactEventCard.js | 151 ++++++++++++++++++ .../components/EventCard/DetailedEventCard.js | 151 ++++++++++++++++++ .../components/EventCard/EventHeader.js | 100 ++++++++++++ .../Community/components/EventCard/index.js | 67 ++++++++ 4 files changed, 469 insertions(+) create mode 100644 src/views/Community/components/EventCard/CompactEventCard.js create mode 100644 src/views/Community/components/EventCard/DetailedEventCard.js create mode 100644 src/views/Community/components/EventCard/EventHeader.js create mode 100644 src/views/Community/components/EventCard/index.js diff --git a/src/views/Community/components/EventCard/CompactEventCard.js b/src/views/Community/components/EventCard/CompactEventCard.js new file mode 100644 index 00000000..469f4a1b --- /dev/null +++ b/src/views/Community/components/EventCard/CompactEventCard.js @@ -0,0 +1,151 @@ +// src/views/Community/components/EventCard/CompactEventCard.js +import React from 'react'; +import { + HStack, + Card, + CardBody, + VStack, + Flex, + Box, + Button, + Text, + useColorModeValue, +} from '@chakra-ui/react'; +import moment from 'moment'; +import { getImportanceConfig } from '../../../../constants/importanceLevels'; + +// 导入子组件 +import EventTimeline from './EventTimeline'; +import EventHeader from './EventHeader'; +import EventStats from './EventStats'; +import EventFollowButton from './EventFollowButton'; + +/** + * 紧凑模式事件卡片组件 + * @param {Object} props + * @param {Object} props.event - 事件对象 + * @param {number} props.index - 事件索引 + * @param {boolean} props.isFollowing - 是否已关注 + * @param {number} props.followerCount - 关注数 + * @param {Function} props.onEventClick - 卡片点击事件 + * @param {Function} props.onTitleClick - 标题点击事件 + * @param {Function} props.onViewDetail - 查看详情事件 + * @param {Function} props.onToggleFollow - 切换关注事件 + * @param {Object} props.timelineStyle - 时间轴样式配置 + * @param {string} props.borderColor - 边框颜色 + */ +const CompactEventCard = ({ + event, + index, + isFollowing, + followerCount, + onEventClick, + onTitleClick, + onViewDetail, + onToggleFollow, + timelineStyle, + borderColor, +}) => { + const importance = getImportanceConfig(event.importance); + const cardBg = useColorModeValue('white', 'gray.800'); + const linkColor = useColorModeValue('blue.600', 'blue.400'); + const mutedColor = useColorModeValue('gray.500', 'gray.400'); + + const handleViewDetailClick = (e) => { + e.stopPropagation(); + onViewDetail?.(event.id); + }; + + return ( + + {/* 左侧时间轴 */} + + + {/* 右侧内容卡片 */} + onEventClick?.(event)} + mb={2} + > + + + {/* 第一行:标题(2行)+ 标签(内联)+ 按钮(右侧) */} + + {/* 标题区域:标题+标签(内联) */} + onTitleClick?.(e, event)} + linkColor={linkColor} + compact={true} + avgChange={event.related_avg_chg} + size="md" + /> + + {/* 操作按钮 - 固定右侧 */} + + + onToggleFollow?.(event.id)} + size="xs" + showCount={true} + /> + + + + {/* 第二行:统计数据(左) + 作者时间(右) */} + + {/* 左侧:统计数据 */} + + + {/* 右侧:作者 + 时间 */} + + @{event.creator?.username || 'Anonymous'} + + + {moment(event.created_at).format('YYYY-MM-DD HH:mm')} + + + + + + + + ); +}; + +export default CompactEventCard; diff --git a/src/views/Community/components/EventCard/DetailedEventCard.js b/src/views/Community/components/EventCard/DetailedEventCard.js new file mode 100644 index 00000000..080bfb3b --- /dev/null +++ b/src/views/Community/components/EventCard/DetailedEventCard.js @@ -0,0 +1,151 @@ +// src/views/Community/components/EventCard/DetailedEventCard.js +import React from 'react'; +import { + HStack, + Card, + CardBody, + VStack, + Flex, + Text, + useColorModeValue, +} from '@chakra-ui/react'; +import moment from 'moment'; +import { getImportanceConfig } from '../../../../constants/importanceLevels'; + +// 导入子组件 +import EventTimeline from './EventTimeline'; +import EventHeader from './EventHeader'; +import EventStats from './EventStats'; +import EventFollowButton from './EventFollowButton'; +import EventPriceDisplay from './EventPriceDisplay'; +import EventDescription from './EventDescription'; + +/** + * 详细模式事件卡片组件 + * @param {Object} props + * @param {Object} props.event - 事件对象 + * @param {boolean} props.isFollowing - 是否已关注 + * @param {number} props.followerCount - 关注数 + * @param {Function} props.onEventClick - 卡片点击事件 + * @param {Function} props.onTitleClick - 标题点击事件 + * @param {Function} props.onToggleFollow - 切换关注事件 + * @param {Object} props.timelineStyle - 时间轴样式配置 + * @param {string} props.borderColor - 边框颜色 + */ +const DetailedEventCard = ({ + event, + isFollowing, + followerCount, + onEventClick, + onTitleClick, + onToggleFollow, + timelineStyle, + borderColor, +}) => { + const importance = getImportanceConfig(event.importance); + const cardBg = useColorModeValue('white', 'gray.800'); + const linkColor = useColorModeValue('blue.600', 'blue.400'); + const mutedColor = useColorModeValue('gray.500', 'gray.400'); + const textColor = useColorModeValue('gray.700', 'gray.200'); + + return ( + + {/* 左侧时间轴 */} + + + {/* 事件卡片 */} + onEventClick?.(event)} + mb={3} + > + + + {/* 第一行:标题+优先级 | 统计+关注 */} + + {/* 左侧:标题 + 优先级标签 */} + onTitleClick?.(e, event)} + linkColor={linkColor} + compact={false} + size="md" + /> + + {/* 右侧:统计数据 + 关注按钮 */} + + {/* 统计数据 */} + + + {/* 关注按钮 */} + onToggleFollow?.(event.id)} + size="sm" + showCount={false} + /> + + + + {/* 第二行:价格标签 | 时间+作者 */} + + {/* 左侧:价格标签 */} + + + {/* 右侧:时间 + 作者 */} + + + {moment(event.created_at).format('YYYY-MM-DD HH:mm')} + + + @{event.creator?.username || 'Anonymous'} + + + + {/* 第三行:描述文字 + 展开/收起 */} + + + + + + ); +}; + +export default DetailedEventCard; diff --git a/src/views/Community/components/EventCard/EventHeader.js b/src/views/Community/components/EventCard/EventHeader.js new file mode 100644 index 00000000..d5573e9a --- /dev/null +++ b/src/views/Community/components/EventCard/EventHeader.js @@ -0,0 +1,100 @@ +// src/views/Community/components/EventCard/EventHeader.js +import React from 'react'; +import { Box, Text, Heading, Tooltip, HStack } from '@chakra-ui/react'; +import EventImportanceBadge from './EventImportanceBadge'; +import EventPriceDisplay from './EventPriceDisplay'; + +/** + * 事件标题头部组件 + * @param {Object} props + * @param {string} props.title - 事件标题 + * @param {string} props.importance - 重要性等级 + * @param {Function} props.onTitleClick - 标题点击事件 + * @param {string} props.linkColor - 链接颜色 + * @param {boolean} props.compact - 是否紧凑模式(默认 false) + * @param {number|null} props.avgChange - 平均涨跌幅(紧凑模式下使用) + * @param {string} props.size - 标题大小('sm' | 'md' | 'lg',默认 'md') + */ +const EventHeader = ({ + title, + importance, + onTitleClick, + linkColor, + compact = false, + avgChange = null, + size = 'md' +}) => { + const handleClick = (e) => { + e.preventDefault(); + e.stopPropagation(); + onTitleClick?.(e); + }; + + // 紧凑模式:标题 + 标签内联 + if (compact) { + return ( + + + {title} + + {' '} + {/* 重要性标签 - 内联 */} + + {' '} + {/* 价格标签 - 内联 */} + {avgChange != null && ( + + )} + + ); + } + + // 详细模式:标题 + 提示框的重要性标签 + return ( + + + + {title} + + + + + + ); +}; + +export default EventHeader; diff --git a/src/views/Community/components/EventCard/index.js b/src/views/Community/components/EventCard/index.js new file mode 100644 index 00000000..7c3efc00 --- /dev/null +++ b/src/views/Community/components/EventCard/index.js @@ -0,0 +1,67 @@ +// src/views/Community/components/EventCard/index.js +import React from 'react'; +import CompactEventCard from './CompactEventCard'; +import DetailedEventCard from './DetailedEventCard'; + +/** + * 事件卡片统一入口组件 + * 根据 isCompactMode 自动选择紧凑模式或详细模式 + * + * @param {Object} props + * @param {Object} props.event - 事件对象 + * @param {number} props.index - 事件索引(紧凑模式下用于交替背景色) + * @param {boolean} props.isCompactMode - 是否为紧凑模式 + * @param {boolean} props.isFollowing - 是否已关注 + * @param {number} props.followerCount - 关注数 + * @param {Function} props.onEventClick - 卡片点击事件 + * @param {Function} props.onTitleClick - 标题点击事件 + * @param {Function} props.onViewDetail - 查看详情事件(仅紧凑模式) + * @param {Function} props.onToggleFollow - 切换关注事件 + * @param {Object} props.timelineStyle - 时间轴样式配置 + * @param {string} props.borderColor - 边框颜色 + */ +const EventCard = ({ + event, + index, + isCompactMode, + isFollowing, + followerCount, + onEventClick, + onTitleClick, + onViewDetail, + onToggleFollow, + timelineStyle, + borderColor, +}) => { + if (isCompactMode) { + return ( + + ); + } + + return ( + + ); +}; + +export default EventCard; From 0991abb18617db14025a064f769fd450d31011fe Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 30 Oct 2025 12:15:55 +0800 Subject: [PATCH 004/107] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E4=B8=BB?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=E2=94=82=20=E2=94=82=20=E2=94=82=20=E2=94=82=20-=20=E2=9D=8C?= =?UTF-8?q?=20=E7=A7=BB=E9=99=A4=20renderPriceChange=20=E5=87=BD=E6=95=B0?= =?UTF-8?q?=EF=BC=8860=E8=A1=8C=EF=BC=89=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=E2=94=82=20?= =?UTF-8?q?=E2=94=82=20=E2=94=82=20=E2=94=82=20-=20=E2=9D=8C=20=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=20renderCompactEvent=20=E5=87=BD=E6=95=B0=EF=BC=88200?= =?UTF-8?q?=E8=A1=8C=EF=BC=89=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=E2=94=82=20=E2=94=82=20=E2=94=82?= =?UTF-8?q?=20=E2=94=82=20-=20=E2=9D=8C=20=E7=A7=BB=E9=99=A4=20renderDetai?= =?UTF-8?q?ledEvent=20=E5=87=BD=E6=95=B0=EF=BC=88300=E8=A1=8C=EF=BC=89=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=E2=94=82=20=E2=94=82=20=E2=94=82=20=E2=94=82=20-=20?= =?UTF-8?q?=E2=9D=8C=20=E7=A7=BB=E9=99=A4=20expandedDescriptions=20state?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=E2=94=82=20=E2=94=82=20=E2=94=82?= =?UTF-8?q?=20=E2=94=82=20-=20=E2=9D=8C=20=E7=B2=BE=E7=AE=80=20Chakra=20UI?= =?UTF-8?q?=20=E5=AF=BC=E5=85=A5=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=E2=94=82=20=E2=94=82=20=E2=94=82=20?= =?UTF-8?q?=E2=94=82=20-=20=E2=9C=85=20=E4=BD=BF=E7=94=A8=20EventCard=20?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E7=BB=9F=E4=B8=80=E6=B8=B2=E6=9F=93=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=E2=94=82=20=E2=94=82=20?= =?UTF-8?q?=E2=94=82=20=E2=94=82=20-=20=E2=9C=85=20=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E6=89=80=E6=9C=89=E4=B8=9A=E5=8A=A1=E9=80=BB=E8=BE=91=EF=BC=88?= =?UTF-8?q?WebSocket=E3=80=81=E9=80=9A=E7=9F=A5=E3=80=81=E5=85=B3=E6=B3=A8?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/Community/components/EventList.js | 634 +------------------- 1 file changed, 18 insertions(+), 616 deletions(-) diff --git a/src/views/Community/components/EventList.js b/src/views/Community/components/EventList.js index 9208c8de..2555548f 100644 --- a/src/views/Community/components/EventList.js +++ b/src/views/Community/components/EventList.js @@ -7,68 +7,28 @@ import { Text, Button, Badge, - Tag, - TagLabel, - TagLeftIcon, Flex, - Avatar, - Tooltip, - IconButton, - Divider, Container, useColorModeValue, - Circle, - Stat, - StatNumber, - StatHelpText, - StatArrow, - ButtonGroup, - Heading, - SimpleGrid, - Card, - CardBody, - Center, - Link, - Spacer, Switch, FormControl, FormLabel, useToast, + Center, } from '@chakra-ui/react'; -import { - ViewIcon, - ChatIcon, - StarIcon, - TimeIcon, - InfoIcon, - WarningIcon, - WarningTwoIcon, - CheckCircleIcon, - ArrowForwardIcon, - ExternalLinkIcon, - ViewOffIcon, -} from '@chakra-ui/icons'; +import { InfoIcon } from '@chakra-ui/icons'; import { useNavigate } from 'react-router-dom'; -import moment from 'moment'; // 导入工具函数和常量 import { logger } from '../../../utils/logger'; import { getApiBase } from '../../../utils/apiConfig'; import { useEventNotifications } from '../../../hooks/useEventNotifications'; -import { getImportanceConfig, getAllImportanceLevels } from '../../../constants/importanceLevels'; import { browserNotificationService } from '../../../services/browserNotificationService'; import { useNotification } from '../../../contexts/NotificationContext'; +import { getImportanceConfig } from '../../../constants/importanceLevels'; -// 导入价格相关工具函数 -import { - getPriceChangeColor, - getPriceChangeBg, - getPriceChangeBorderColor, - PriceArrow, -} from '../../../utils/priceFormatters'; - -// 导入动画定义 -import { pulseAnimation } from '../../../constants/animations'; +// 导入子组件 +import EventCard from './EventCard'; // ========== 主组件 ========== const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetail }) => { @@ -78,7 +38,6 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai const [followingMap, setFollowingMap] = useState({}); const [followCountMap, setFollowCountMap] = useState({}); const [localEvents, setLocalEvents] = useState(events); // 用于实时更新的本地事件列表 - const [expandedDescriptions, setExpandedDescriptions] = useState({}); // 描述展开状态映射 // 从 NotificationContext 获取推送权限相关状态和方法 const { browserPermission, requestBrowserPermission } = useNotification(); @@ -265,67 +224,6 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai const linkColor = useColorModeValue('blue.600', 'blue.400'); const hoverBg = useColorModeValue('gray.50', 'gray.700'); - const renderPriceChange = (value, label) => { - if (value === null || value === undefined) { - return ( - - {label}: -- - - ); - } - - const absValue = Math.abs(value); - const isPositive = value > 0; - - // 根据涨跌幅大小选择不同的颜色深浅 - let colorScheme = 'gray'; - let variant = 'solid'; - - if (isPositive) { - // 上涨用红色系 - if (absValue >= 3) { - colorScheme = 'red'; - variant = 'solid'; // 深色 - } else if (absValue >= 1) { - colorScheme = 'red'; - variant = 'subtle'; // 中等 - } else { - colorScheme = 'red'; - variant = 'outline'; // 浅色 - } - } else { - // 下跌用绿色系 - if (absValue >= 3) { - colorScheme = 'green'; - variant = 'solid'; // 深色 - } else if (absValue >= 1) { - colorScheme = 'green'; - variant = 'subtle'; // 中等 - } else { - colorScheme = 'green'; - variant = 'outline'; // 浅色 - } - } - - const Icon = isPositive ? TriangleUpIcon : TriangleDownIcon; - - return ( - - - - {label}: {isPositive ? '+' : ''}{value.toFixed(2)}% - - - ); - }; const handleTitleClick = (e, event) => { e.preventDefault(); @@ -349,511 +247,6 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai }; }; - // 精简模式的事件渲染(优化版:标题2行+标签内联+按钮右侧) - const renderCompactEvent = (event, index) => { - const importance = getImportanceConfig(event.importance); - const isFollowing = !!followingMap[event.id]; - const followerCount = followCountMap[event.id] ?? (event.follower_count || 0); - const timelineStyle = getTimelineBoxStyle(); - - return ( - - {/* 左侧时间轴 - 动态样式 */} - - {/* 时间长方形卡片 */} - - {/* 日期 YYYY-MM-DD */} - - {moment(event.created_at).format('YYYY-MM-DD')} - - {/* 时间 HH:mm */} - - {moment(event.created_at).format('HH:mm')} - - - {/* 时间轴竖线 */} - - - - {/* 右侧内容卡片 */} - onEventClick(event)} - mb={2} - > - - - {/* 第一行:标题(2行)+ 标签(内联)+ 按钮(右侧) */} - - {/* 标题区域:标题+标签(内联) */} - - handleTitleClick(e, event)} - cursor="pointer" - > - {event.title} - - {' '} - {/* 重要性标签 - 内联 */} - - {event.importance || 'C'}级 - - {' '} - {/* 涨跌幅标签 - 内联 */} - {event.related_avg_chg != null && ( - - 0 ? 'red' : 'green'} - fontSize="xs" - px={2} - py={1} - borderRadius="md" - fontWeight="bold" - display="inline-flex" - alignItems="center" - gap={1} - verticalAlign="middle" - > - - {event.related_avg_chg > 0 ? '+' : ''}{event.related_avg_chg.toFixed(2)}% - - - )} - - - {/* 操作按钮 - 固定右侧 */} - - - - - - - {/* 第二行:统计数据(左) + 作者时间(右) */} - - {/* 左侧:统计数据 */} - - - - - {event.view_count || 0} - - - - - - - {event.post_count || 0} - - - - - - - {followerCount} - - - - - {/* 右侧:作者 + 时间(统一格式 YYYY-MM-DD HH:mm) */} - - @{event.creator?.username || 'Anonymous'} - - - {moment(event.created_at).format('YYYY-MM-DD HH:mm')} - - - - - - - - ); - }; - - // 详细模式的事件渲染(原有的渲染方式,但修复了箭头颜色) - const renderDetailedEvent = (event) => { - const importance = getImportanceConfig(event.importance); - const isFollowing = !!followingMap[event.id]; - const followerCount = followCountMap[event.id] ?? (event.follower_count || 0); - const timelineStyle = getTimelineBoxStyle(); - - return ( - - {/* 左侧时间轴 - 动态样式 */} - - {/* 时间长方形卡片 */} - - {/* 日期 YYYY-MM-DD */} - - {moment(event.created_at).format('YYYY-MM-DD')} - - {/* 时间 HH:mm */} - - {moment(event.created_at).format('HH:mm')} - - - {/* 时间轴竖线 */} - - - - {/* 事件卡片 */} - onEventClick(event)} - mb={3} - > - - - {/* 第一行:标题+优先级 | 统计+关注 */} - - {/* 左侧:标题 + 优先级标签 */} - - - handleTitleClick(e, event)} - cursor="pointer" - > - {event.title} - - - - - - 重要性等级说明 - - - {getAllImportanceLevels().map((level) => ( - - - - {level.level}级 - {level.description} - - - ))} - - } - placement="top" - hasArrow - bg="white" - color="gray.800" - fontSize="md" - p={3} - borderRadius="lg" - borderWidth="1px" - borderColor="gray.200" - boxShadow="lg" - > - - - {event.importance || 'C'}级 - - - - - {/* 右侧:统计数据 + 关注按钮 */} - - {/* 统计数据 */} - - - - - {event.view_count || 0} - - - - - - {event.post_count || 0} - - - - - - {followerCount} - - - - - {/* 关注按钮 */} - - - - - {/* 第二行:价格标签 | 时间+作者 */} - - {/* 左侧:价格标签 */} - - {/* 平均涨幅 - 始终显示,无数据时显示 -- */} - 0 ? 'red' : event.related_avg_chg < 0 ? 'green' : 'gray') - : 'gray'} - fontSize="xs" - px={2} - py={0.5} - borderRadius="md" - cursor="pointer" - _hover={{ transform: 'scale(1.05)', boxShadow: 'md' }} - transition="all 0.2s" - > - - 平均 - - {event.related_avg_chg != null - ? `${event.related_avg_chg > 0 ? '+' : ''}${event.related_avg_chg.toFixed(2)}%` - : '--'} - - - - - {/* 最大涨幅 - 始终显示,无数据时显示 -- */} - 0 ? 'red' : event.related_max_chg < 0 ? 'green' : 'gray') - : 'gray'} - fontSize="xs" - px={2} - py={0.5} - borderRadius="md" - cursor="pointer" - _hover={{ transform: 'scale(1.05)', boxShadow: 'md' }} - transition="all 0.2s" - > - - 最大 - - {event.related_max_chg != null - ? `${event.related_max_chg > 0 ? '+' : ''}${event.related_max_chg.toFixed(2)}%` - : '--'} - - - - - {/* 周涨幅 - 始终显示,无数据时显示 -- */} - 0 ? 'red' : event.related_week_chg < 0 ? 'green' : 'gray') - : 'gray'} - fontSize="xs" - px={2} - py={0.5} - borderRadius="md" - cursor="pointer" - _hover={{ transform: 'scale(1.05)', boxShadow: 'md' }} - transition="all 0.2s" - > - - - {event.related_week_chg != null && } - - {event.related_week_chg != null - ? `${event.related_week_chg > 0 ? '+' : ''}${event.related_week_chg.toFixed(2)}%` - : '--'} - - - - - - {/* 右侧:时间 + 作者 */} - - - {moment(event.created_at).format('YYYY-MM-DD HH:mm')} - - - @{event.creator?.username || 'Anonymous'} - - - - {/* 第三行:描述文字 + 展开/收起 */} - {event.description && ( - - - {event.description} - - {event.description.length > 120 && ( - - )} - - )} - - - - - ); - }; - // 分页组件 const Pagination = ({ current, total, pageSize, onChange }) => { const totalPages = Math.ceil(total / pageSize); @@ -1062,10 +455,19 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai {localEvents.map((event, index) => ( - {isCompactMode - ? renderCompactEvent(event, index) - : renderDetailedEvent(event) - } + ))} From b825fa044d17adc19d3d636dbfb81ad835badadc Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 30 Oct 2025 12:19:37 +0800 Subject: [PATCH 005/107] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20EventList.?= =?UTF-8?q?js=20=E9=87=8D=E6=9E=84=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 197 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 196 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c093980e..7ff19cea 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,198 @@ # vf_react -前端 \ No newline at end of file +前端 + +--- + +## 📚 重构记录 + +### 2025-10-30: EventList.js 组件化重构 + +#### 🎯 重构目标 +将 Community 社区页面的 `EventList.js` 组件(1095行)拆分为多个可复用的子组件,提高代码可维护性和复用性。 + +#### 📊 重构成果 +- **重构前**: 1095 行 +- **重构后**: 497 行 +- **减少**: 598 行 (-54.6%) + +--- + +### 📁 新增目录结构 + +``` +src/views/Community/components/EventCard/ +├── index.js (60行) - EventCard 统一入口,智能路由紧凑/详细模式 +│ +├── ────────────────────────────────────────────────────────── +│ 原子组件 (Atoms) - 7个基础UI组件 +├── ────────────────────────────────────────────────────────── +│ +├── EventTimeline.js (60行) - 时间轴显示组件 +│ └── Props: createdAt, timelineStyle, borderColor, minHeight +│ +├── EventImportanceBadge.js (100行) - 重要性等级标签 (S/A/B/C/D) +│ └── Props: importance, showTooltip, showIcon, size +│ +├── EventStats.js (60行) - 统计信息 (浏览/帖子/关注) +│ └── Props: viewCount, postCount, followerCount, size, spacing +│ +├── EventFollowButton.js (40行) - 关注按钮 +│ └── Props: isFollowing, followerCount, onToggle, size, showCount +│ +├── EventPriceDisplay.js (130行) - 价格变动显示 (平均/最大/周) +│ └── Props: avgChange, maxChange, weekChange, compact, inline +│ +├── EventDescription.js (60行) - 描述文本 (支持展开/收起) +│ └── Props: description, textColor, minLength, noOfLines +│ +├── EventHeader.js (100行) - 事件标题头部 +│ └── Props: title, importance, onTitleClick, linkColor, compact +│ +├── ────────────────────────────────────────────────────────── +│ 组合组件 (Molecules) - 2个卡片组件 +├── ────────────────────────────────────────────────────────── +│ +├── CompactEventCard.js (160行) - 紧凑模式事件卡片 +│ ├── 使用: EventTimeline, EventHeader, EventStats, EventFollowButton +│ └── Props: event, index, isFollowing, followerCount, callbacks... +│ +└── DetailedEventCard.js (170行) - 详细模式事件卡片 + ├── 使用: EventTimeline, EventHeader, EventStats, EventFollowButton, + │ EventPriceDisplay, EventDescription + └── Props: event, isFollowing, followerCount, callbacks... +``` + +**总计**: 10个文件,940行代码 + +--- + +### 🔧 重构的文件 + +#### `src/views/Community/components/EventList.js` + +**移除的内容**: +- ❌ `renderPriceChange` 函数 (~60行) +- ❌ `renderCompactEvent` 函数 (~200行) +- ❌ `renderDetailedEvent` 函数 (~300行) +- ❌ `expandedDescriptions` state(展开状态管理移至子组件) +- ❌ 冗余的 Chakra UI 导入 + +**保留的功能**: +- ✅ WebSocket 实时推送 +- ✅ 浏览器原生通知 +- ✅ 关注状态管理 (followingMap, followCountMap) +- ✅ 分页控制 +- ✅ 视图模式切换(紧凑/详细) +- ✅ 推送权限管理 + +**新增引入**: +```javascript +import EventCard from './EventCard'; +``` + +--- + +### 🏗️ 架构改进 + +#### 重构前(单体架构) +``` +EventList.js (1095行) +├── 业务逻辑 (WebSocket, 关注, 通知) +├── renderCompactEvent (200行) +│ └── 所有UI代码内联 +├── renderDetailedEvent (300行) +│ └── 所有UI代码内联 +└── renderPriceChange (60行) +``` + +#### 重构后(组件化架构) +``` +EventList.js (497行) - 容器组件 +├── 业务逻辑 (WebSocket, 关注, 通知) +└── 渲染逻辑 + └── EventCard (智能路由) + ├── CompactEventCard (紧凑模式) + │ ├── EventTimeline + │ ├── EventHeader (compact) + │ ├── EventStats + │ └── EventFollowButton + └── DetailedEventCard (详细模式) + ├── EventTimeline + ├── EventHeader (detailed) + ├── EventStats + ├── EventFollowButton + ├── EventPriceDisplay + └── EventDescription +``` + +--- + +### ✨ 优势 + +1. **可维护性** ⬆️ + - 每个组件职责单一(单一职责原则) + - 代码行数减少 54.6% + - 组件边界清晰,易于理解 + +2. **可复用性** ⬆️ + - 原子组件可在其他页面复用 + - 例如:EventImportanceBadge 可用于任何需要显示事件等级的地方 + +3. **可测试性** ⬆️ + - 小组件更容易编写单元测试 + - 可独立测试每个组件的渲染和交互 + +4. **性能优化** ⬆️ + - React 可以更精确地追踪变化 + - 减少不必要的重渲染 + - 每个子组件可独立优化(useMemo, React.memo) + +5. **开发效率** ⬆️ + - 新增功能时只需修改对应的子组件 + - 代码审查更高效 + - 降低了代码冲突的概率 + +--- + +### 📦 依赖工具函数 + +本次重构使用了之前提取的工具函数: + +``` +src/utils/priceFormatters.js (105行) +├── getPriceChangeColor(value) - 获取价格变化文字颜色 +├── getPriceChangeBg(value) - 获取价格变化背景颜色 +├── getPriceChangeBorderColor(value) - 获取价格变化边框颜色 +├── formatPriceChange(value) - 格式化价格为字符串 +└── PriceArrow({ value }) - 价格涨跌箭头组件 + +src/constants/animations.js (72行) +├── pulseAnimation - 脉冲动画(S/A级标签) +├── fadeIn - 渐入动画 +├── slideInUp - 从下往上滑入 +├── scaleIn - 缩放进入 +└── spin - 旋转动画(Loading) +``` + +--- + +### 🚀 下一步优化计划 + +Phase 1 已完成,后续可继续优化: + +- **Phase 2**: 拆分 StockDetailPanel.js (1067行 → ~250行) +- **Phase 3**: 拆分 InvestmentCalendar.js (827行 → ~200行) +- **Phase 4**: 拆分 MidjourneyHeroSection.js (813行 → ~200行) +- **Phase 5**: 拆分 UnifiedSearchBox.js (679行 → ~180行) + +--- + +### 🔗 相关提交 + +- `feat: 拆分 EventList.js/提取价格相关工具函数到 utils/priceFormatters.js` +- `feat(EventList): 创建事件卡片原子组件` +- `feat(EventList): 创建事件卡片组合组件` +- `refactor(EventList): 使用组件化架构替换内联渲染函数` + +--- \ No newline at end of file From 9335ef5f8c5f870486152122952038aa3703a07e Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 30 Oct 2025 12:24:12 +0800 Subject: [PATCH 006/107] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=20EventList.?= =?UTF-8?q?js=20=E7=BC=BA=E5=B0=91=20Tooltip=20=E5=AF=BC=E5=85=A5=E7=9A=84?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/Community/components/EventList.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/views/Community/components/EventList.js b/src/views/Community/components/EventList.js index 2555548f..0eeffae6 100644 --- a/src/views/Community/components/EventList.js +++ b/src/views/Community/components/EventList.js @@ -15,6 +15,7 @@ import { FormLabel, useToast, Center, + Tooltip, } from '@chakra-ui/react'; import { InfoIcon } from '@chakra-ui/icons'; import { useNavigate } from 'react-router-dom'; From 35adabb87e237eff16b6e7bac82a345566c10072 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 30 Oct 2025 12:54:32 +0800 Subject: [PATCH 007/107] =?UTF-8?q?feat:=20=E5=B0=86=20IndustryProvider=20?= =?UTF-8?q?(176=E8=A1=8C)=20=E5=AE=8C=E6=95=B4=E8=BF=81=E7=A7=BB=E5=88=B0?= =?UTF-8?q?=20Redux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.js | 11 +- src/contexts/IndustryContext.js | 176 ----------------- src/store/index.js | 2 + src/store/slices/industrySlice.js | 178 ++++++++++++++++++ .../Community/components/IndustryCascader.js | 17 +- .../Community/components/UnifiedSearchBox.js | 16 +- 6 files changed, 207 insertions(+), 193 deletions(-) delete mode 100644 src/contexts/IndustryContext.js create mode 100644 src/store/slices/industrySlice.js diff --git a/src/App.js b/src/App.js index 33695b9c..7ff206da 100755 --- a/src/App.js +++ b/src/App.js @@ -48,7 +48,6 @@ import { store } from './store'; import { AuthProvider } from "contexts/AuthContext"; import { AuthModalProvider } from "contexts/AuthModalContext"; import { NotificationProvider, useNotification } from "contexts/NotificationContext"; -import { IndustryProvider } from "contexts/IndustryContext"; // Components import ProtectedRoute from "components/ProtectedRoute"; @@ -321,12 +320,10 @@ export default function App() { - - - - - - + + + + diff --git a/src/contexts/IndustryContext.js b/src/contexts/IndustryContext.js deleted file mode 100644 index 42780420..00000000 --- a/src/contexts/IndustryContext.js +++ /dev/null @@ -1,176 +0,0 @@ -// src/contexts/IndustryContext.js -// 行业分类数据全局上下文 - 使用API获取 + 缓存机制 - -import React, { createContext, useContext, useState, useEffect, useRef } from 'react'; -import { industryData as staticIndustryData } from '../data/industryData'; -import { industryService } from '../services/industryService'; -import { logger } from '../utils/logger'; - -const IndustryContext = createContext(); - -// 缓存配置 -const CACHE_KEY = 'industry_classifications_cache'; -const CACHE_DURATION = 24 * 60 * 60 * 1000; // 1天(24小时) - -/** - * useIndustry Hook - * 在任何组件中使用行业数据 - */ -export const useIndustry = () => { - const context = useContext(IndustryContext); - if (!context) { - throw new Error('useIndustry must be used within IndustryProvider'); - } - return context; -}; - -/** - * 从 localStorage 读取缓存 - */ -const loadFromCache = () => { - try { - const cached = localStorage.getItem(CACHE_KEY); - if (!cached) return null; - - const { data, timestamp } = JSON.parse(cached); - const now = Date.now(); - - // 检查缓存是否过期(1天) - if (now - timestamp > CACHE_DURATION) { - localStorage.removeItem(CACHE_KEY); - logger.debug('IndustryContext', '缓存已过期,清除缓存'); - return null; - } - - logger.debug('IndustryContext', '从缓存加载行业数据', { - count: data?.length || 0, - age: Math.round((now - timestamp) / 1000 / 60) + ' 分钟前' - }); - return data; - } catch (error) { - logger.error('IndustryContext', 'loadFromCache', error); - return null; - } -}; - -/** - * 保存到 localStorage - */ -const saveToCache = (data) => { - try { - localStorage.setItem(CACHE_KEY, JSON.stringify({ - data, - timestamp: Date.now() - })); - logger.debug('IndustryContext', '行业数据已缓存', { - count: data?.length || 0 - }); - } catch (error) { - logger.error('IndustryContext', 'saveToCache', error); - } -}; - -/** - * IndustryProvider 组件 - * 提供全局行业数据管理 - 使用API获取 + 缓存机制 - */ -export const IndustryProvider = ({ children }) => { - const [industryData, setIndustryData] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const hasLoadedRef = useRef(false); - const isLoadingRef = useRef(false); - - /** - * 加载行业数据 - */ - const loadIndustryData = async () => { - // 防止重复加载(处理 StrictMode 双重调用) - if (hasLoadedRef.current || isLoadingRef.current) { - logger.debug('IndustryContext', '跳过重复加载', { - hasLoaded: hasLoadedRef.current, - isLoading: isLoadingRef.current - }); - return industryData; - } - - try { - isLoadingRef.current = true; - setLoading(true); - setError(null); - - logger.debug('IndustryContext', '开始加载行业数据'); - - // 1. 先尝试从缓存加载 - const cachedData = loadFromCache(); - if (cachedData && cachedData.length > 0) { - setIndustryData(cachedData); - hasLoadedRef.current = true; - return cachedData; - } - - // 2. 缓存不存在或过期,调用 API - logger.debug('IndustryContext', '缓存无效,调用API获取数据'); - const response = await industryService.getClassifications(); - - if (response.success && response.data && response.data.length > 0) { - setIndustryData(response.data); - saveToCache(response.data); - hasLoadedRef.current = true; - - logger.debug('IndustryContext', 'API数据加载成功', { - count: response.data.length - }); - - return response.data; - } else { - throw new Error('API返回数据为空'); - } - } catch (err) { - // 3. API 失败,回退到静态数据 - logger.warn('IndustryContext', 'API加载失败,使用静态数据', { - error: err.message - }); - - setError(err.message); - setIndustryData(staticIndustryData); - hasLoadedRef.current = true; - - return staticIndustryData; - } finally { - setLoading(false); - isLoadingRef.current = false; - } - }; - - /** - * 刷新行业数据(清除缓存并重新加载) - */ - const refreshIndustryData = async () => { - logger.debug('IndustryContext', '刷新行业数据,清除缓存'); - localStorage.removeItem(CACHE_KEY); - hasLoadedRef.current = false; - isLoadingRef.current = false; - return loadIndustryData(); - }; - - // 组件挂载时自动加载数据 - useEffect(() => { - loadIndustryData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const value = { - industryData, - loading, - error, - loadIndustryData, - refreshIndustryData - }; - - return ( - - {children} - - ); -}; diff --git a/src/store/index.js b/src/store/index.js index 33789148..4d2ae544 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -2,12 +2,14 @@ import { configureStore } from '@reduxjs/toolkit'; import communityDataReducer from './slices/communityDataSlice'; import posthogReducer from './slices/posthogSlice'; +import industryReducer from './slices/industrySlice'; import posthogMiddleware from './middleware/posthogMiddleware'; export const store = configureStore({ reducer: { communityData: communityDataReducer, posthog: posthogReducer, // ✅ PostHog Redux 状态管理 + industry: industryReducer, // ✅ 行业分类数据管理 }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ diff --git a/src/store/slices/industrySlice.js b/src/store/slices/industrySlice.js new file mode 100644 index 00000000..61935bc7 --- /dev/null +++ b/src/store/slices/industrySlice.js @@ -0,0 +1,178 @@ +// src/store/slices/industrySlice.js +// 行业分类数据 Redux Slice - 从 IndustryContext 迁移 + +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import { industryData as staticIndustryData } from '../../data/industryData'; +import { industryService } from '../../services/industryService'; +import { logger } from '../../utils/logger'; + +// 缓存配置 +const CACHE_KEY = 'industry_classifications_cache'; +const CACHE_DURATION = 24 * 60 * 60 * 1000; // 1天(24小时) + +/** + * 从 localStorage 读取缓存 + */ +const loadFromCache = () => { + try { + const cached = localStorage.getItem(CACHE_KEY); + if (!cached) return null; + + const { data, timestamp } = JSON.parse(cached); + const now = Date.now(); + + // 检查缓存是否过期(1天) + if (now - timestamp > CACHE_DURATION) { + localStorage.removeItem(CACHE_KEY); + logger.debug('industrySlice', '缓存已过期,清除缓存'); + return null; + } + + logger.debug('industrySlice', '从缓存加载行业数据', { + count: data?.length || 0, + age: Math.round((now - timestamp) / 1000 / 60) + ' 分钟前' + }); + return data; + } catch (error) { + logger.error('industrySlice', 'loadFromCache', error); + return null; + } +}; + +/** + * 保存到 localStorage + */ +const saveToCache = (data) => { + try { + localStorage.setItem(CACHE_KEY, JSON.stringify({ + data, + timestamp: Date.now() + })); + logger.debug('industrySlice', '行业数据已缓存', { + count: data?.length || 0 + }); + } catch (error) { + logger.error('industrySlice', 'saveToCache', error); + } +}; + +/** + * 异步 Thunk: 加载行业数据 + * 策略:缓存 -> API -> 静态数据 + */ +export const fetchIndustryData = createAsyncThunk( + 'industry/fetchData', + async (_, { rejectWithValue }) => { + try { + logger.debug('industrySlice', '开始加载行业数据'); + + // 1. 先尝试从缓存加载 + const cachedData = loadFromCache(); + if (cachedData && cachedData.length > 0) { + logger.debug('industrySlice', '使用缓存数据', { count: cachedData.length }); + return { data: cachedData, source: 'cache' }; + } + + // 2. 缓存不存在或过期,调用 API + logger.debug('industrySlice', '缓存无效,调用API获取数据'); + const response = await industryService.getClassifications(); + + if (response.success && response.data && response.data.length > 0) { + saveToCache(response.data); + logger.debug('industrySlice', 'API数据加载成功', { + count: response.data.length + }); + return { data: response.data, source: 'api' }; + } else { + throw new Error('API返回数据为空'); + } + } catch (error) { + // 3. API 失败,回退到静态数据 + logger.warn('industrySlice', 'API加载失败,使用静态数据', { + error: error.message + }); + return { data: staticIndustryData, source: 'static', error: error.message }; + } + } +); + +/** + * 异步 Thunk: 刷新行业数据(清除缓存并重新加载) + */ +export const refreshIndustryData = createAsyncThunk( + 'industry/refresh', + async (_, { dispatch }) => { + logger.debug('industrySlice', '刷新行业数据,清除缓存'); + localStorage.removeItem(CACHE_KEY); + return dispatch(fetchIndustryData()); + } +); + +// Industry Slice +const industrySlice = createSlice({ + name: 'industry', + initialState: { + data: null, // 行业数据数组 + loading: false, // 加载状态 + error: null, // 错误信息 + source: null, // 数据来源: 'cache' | 'api' | 'static' + lastFetchTime: null, // 最后加载时间 + }, + reducers: { + // 清除缓存 + clearCache: (state) => { + localStorage.removeItem(CACHE_KEY); + logger.debug('industrySlice', '手动清除缓存'); + }, + // 重置状态 + resetState: (state) => { + state.data = null; + state.loading = false; + state.error = null; + state.source = null; + state.lastFetchTime = null; + }, + }, + extraReducers: (builder) => { + builder + // fetchIndustryData + .addCase(fetchIndustryData.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchIndustryData.fulfilled, (state, action) => { + state.loading = false; + state.data = action.payload.data; + state.source = action.payload.source; + state.lastFetchTime = Date.now(); + if (action.payload.error) { + state.error = action.payload.error; + } + }) + .addCase(fetchIndustryData.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message; + // 确保总有数据可用 + if (!state.data) { + state.data = staticIndustryData; + state.source = 'static'; + } + }) + // refreshIndustryData + .addCase(refreshIndustryData.pending, (state) => { + state.loading = true; + }); + }, +}); + +// 导出 actions +export const { clearCache, resetState } = industrySlice.actions; + +// 导出 selectors +export const selectIndustryData = (state) => state.industry.data; +export const selectIndustryLoading = (state) => state.industry.loading; +export const selectIndustryError = (state) => state.industry.error; +export const selectIndustrySource = (state) => state.industry.source; + +// 导出 reducer +export default industrySlice.reducer; diff --git a/src/views/Community/components/IndustryCascader.js b/src/views/Community/components/IndustryCascader.js index abd742ec..d5f0f41b 100644 --- a/src/views/Community/components/IndustryCascader.js +++ b/src/views/Community/components/IndustryCascader.js @@ -1,22 +1,25 @@ // src/views/Community/components/IndustryCascader.js -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import { Card, Form, Cascader } from 'antd'; -import { useIndustry } from '../../../contexts/IndustryContext'; +import { useSelector, useDispatch } from 'react-redux'; +import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '../../../store/slices/industrySlice'; import { logger } from '../../../utils/logger'; const IndustryCascader = ({ onFilterChange, loading }) => { const [industryCascaderValue, setIndustryCascaderValue] = useState([]); - // 使用全局行业数据 - const { industryData, loadIndustryData, loading: industryLoading } = useIndustry(); + // 使用 Redux 获取行业数据 + const dispatch = useDispatch(); + const industryData = useSelector(selectIndustryData); + const industryLoading = useSelector(selectIndustryLoading); // Cascader 获得焦点时加载数据 - const handleCascaderFocus = async () => { + const handleCascaderFocus = useCallback(async () => { if (!industryData || industryData.length === 0) { logger.debug('IndustryCascader', 'Cascader 获得焦点,开始加载行业数据'); - await loadIndustryData(); + await dispatch(fetchIndustryData()); } - }; + }, [dispatch, industryData]); // Cascader 选择变化 const handleIndustryCascaderChange = (value, selectedOptions) => { diff --git a/src/views/Community/components/UnifiedSearchBox.js b/src/views/Community/components/UnifiedSearchBox.js index 429428b9..1f6c3ed5 100644 --- a/src/views/Community/components/UnifiedSearchBox.js +++ b/src/views/Community/components/UnifiedSearchBox.js @@ -11,7 +11,8 @@ import moment from 'moment'; import dayjs from 'dayjs'; import locale from 'antd/es/date-picker/locale/zh_CN'; import debounce from 'lodash/debounce'; -import { useIndustry } from '../../../contexts/IndustryContext'; +import { useSelector, useDispatch } from 'react-redux'; +import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '../../../store/slices/industrySlice'; import { stockService } from '../../../services/stockService'; import { logger } from '../../../utils/logger'; import PopularKeywords from './PopularKeywords'; @@ -39,8 +40,17 @@ const UnifiedSearchBox = ({ // ✅ 本地输入状态 - 管理用户的实时输入 const [inputValue, setInputValue] = useState(''); - // 使用全局行业数据 - const { industryData, loadIndustryData, loading: industryLoading } = useIndustry(); + // 使用 Redux 获取行业数据 + const dispatch = useDispatch(); + const industryData = useSelector(selectIndustryData); + const industryLoading = useSelector(selectIndustryLoading); + + // 加载行业数据函数 + const loadIndustryData = useCallback(() => { + if (!industryData) { + dispatch(fetchIndustryData()); + } + }, [dispatch, industryData]); // 搜索触发函数 const triggerSearch = useCallback((params) => { From b6e9ea40d13944a70d48ecf04a8d9bac31260154 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 30 Oct 2025 13:03:31 +0800 Subject: [PATCH 008/107] =?UTF-8?q?feat:=20=E6=8F=90=E4=BA=A4=20Redux=20Sl?= =?UTF-8?q?ice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/store/index.js | 4 + src/store/slices/stockSlice.js | 479 +++++++++++++++++++++++++++++++++ 2 files changed, 483 insertions(+) create mode 100644 src/store/slices/stockSlice.js diff --git a/src/store/index.js b/src/store/index.js index 4d2ae544..d983721f 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -3,6 +3,7 @@ import { configureStore } from '@reduxjs/toolkit'; import communityDataReducer from './slices/communityDataSlice'; import posthogReducer from './slices/posthogSlice'; import industryReducer from './slices/industrySlice'; +import stockReducer from './slices/stockSlice'; import posthogMiddleware from './middleware/posthogMiddleware'; export const store = configureStore({ @@ -10,6 +11,7 @@ export const store = configureStore({ communityData: communityDataReducer, posthog: posthogReducer, // ✅ PostHog Redux 状态管理 industry: industryReducer, // ✅ 行业分类数据管理 + stock: stockReducer, // ✅ 股票和事件数据管理 }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ @@ -19,6 +21,8 @@ export const store = configureStore({ 'communityData/fetchPopularKeywords/fulfilled', 'communityData/fetchHotEvents/fulfilled', 'posthog/trackEvent/fulfilled', // ✅ PostHog 事件追踪 + 'stock/fetchEventStocks/fulfilled', + 'stock/fetchStockQuotes/fulfilled', ], }, }).concat(posthogMiddleware), // ✅ PostHog 自动追踪中间件 diff --git a/src/store/slices/stockSlice.js b/src/store/slices/stockSlice.js new file mode 100644 index 00000000..a33185ba --- /dev/null +++ b/src/store/slices/stockSlice.js @@ -0,0 +1,479 @@ +// src/store/slices/stockSlice.js +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import { eventService, stockService } from '../../services/eventService'; +import { logger } from '../../utils/logger'; +import { localCacheManager, CACHE_EXPIRY_STRATEGY } from '../../utils/CacheManager'; +import { getApiBase } from '../../utils/apiConfig'; + +// ==================== 常量定义 ==================== + +// 缓存键名 +const CACHE_KEYS = { + EVENT_STOCKS: 'event_stocks_', + EVENT_DETAIL: 'event_detail_', + HISTORICAL_EVENTS: 'historical_events_', + CHAIN_ANALYSIS: 'chain_analysis_', + EXPECTATION_SCORE: 'expectation_score_', + WATCHLIST: 'user_watchlist' +}; + +// 请求去重:缓存正在进行的请求 +const pendingRequests = new Map(); + +// ==================== Async Thunks ==================== + +/** + * 获取事件相关股票(三级缓存) + */ +export const fetchEventStocks = createAsyncThunk( + 'stock/fetchEventStocks', + async ({ eventId, forceRefresh = false }, { getState }) => { + logger.debug('stockSlice', 'fetchEventStocks', { eventId, forceRefresh }); + + // 1. Redux 状态缓存 + if (!forceRefresh) { + const cached = getState().stock.eventStocksCache[eventId]; + if (cached && cached.length > 0) { + logger.debug('stockSlice', 'Redux 缓存命中', { eventId }); + return { eventId, stocks: cached }; + } + } + + // 2. LocalStorage 缓存 + if (!forceRefresh) { + const localCached = localCacheManager.get(CACHE_KEYS.EVENT_STOCKS + eventId); + if (localCached) { + logger.debug('stockSlice', 'LocalStorage 缓存命中', { eventId }); + return { eventId, stocks: localCached }; + } + } + + // 3. API 请求 + const res = await eventService.getRelatedStocks(eventId); + if (res.success && res.data) { + logger.debug('stockSlice', 'API 请求成功', { + eventId, + stockCount: res.data.length + }); + localCacheManager.set( + CACHE_KEYS.EVENT_STOCKS + eventId, + res.data, + CACHE_EXPIRY_STRATEGY.LONG // 1小时 + ); + return { eventId, stocks: res.data }; + } + + throw new Error(res.error || '获取股票数据失败'); + } +); + +/** + * 获取股票行情 + */ +export const fetchStockQuotes = createAsyncThunk( + 'stock/fetchStockQuotes', + async ({ codes, eventTime }) => { + logger.debug('stockSlice', 'fetchStockQuotes', { + codeCount: codes.length, + eventTime + }); + + const quotes = await stockService.getQuotes(codes, eventTime); + return quotes; + } +); + +/** + * 获取事件详情 + */ +export const fetchEventDetail = createAsyncThunk( + 'stock/fetchEventDetail', + async ({ eventId, forceRefresh = false }, { getState }) => { + logger.debug('stockSlice', 'fetchEventDetail', { eventId }); + + // Redux 缓存 + if (!forceRefresh) { + const cached = getState().stock.eventDetailsCache[eventId]; + if (cached) { + logger.debug('stockSlice', 'Redux 缓存命中 - eventDetail', { eventId }); + return { eventId, detail: cached }; + } + } + + // LocalStorage 缓存 + if (!forceRefresh) { + const localCached = localCacheManager.get(CACHE_KEYS.EVENT_DETAIL + eventId); + if (localCached) { + logger.debug('stockSlice', 'LocalStorage 缓存命中 - eventDetail', { eventId }); + return { eventId, detail: localCached }; + } + } + + // API 请求 + const res = await eventService.getEventDetail(eventId); + if (res.success && res.data) { + localCacheManager.set( + CACHE_KEYS.EVENT_DETAIL + eventId, + res.data, + CACHE_EXPIRY_STRATEGY.LONG + ); + return { eventId, detail: res.data }; + } + + throw new Error(res.error || '获取事件详情失败'); + } +); + +/** + * 获取历史事件对比 + */ +export const fetchHistoricalEvents = createAsyncThunk( + 'stock/fetchHistoricalEvents', + async ({ eventId, forceRefresh = false }, { getState }) => { + logger.debug('stockSlice', 'fetchHistoricalEvents', { eventId }); + + // Redux 缓存 + if (!forceRefresh) { + const cached = getState().stock.historicalEventsCache[eventId]; + if (cached) { + return { eventId, events: cached }; + } + } + + // LocalStorage 缓存 + if (!forceRefresh) { + const localCached = localCacheManager.get(CACHE_KEYS.HISTORICAL_EVENTS + eventId); + if (localCached) { + return { eventId, events: localCached }; + } + } + + // API 请求 + const res = await eventService.getHistoricalEvents(eventId); + if (res.success && res.data) { + localCacheManager.set( + CACHE_KEYS.HISTORICAL_EVENTS + eventId, + res.data, + CACHE_EXPIRY_STRATEGY.LONG + ); + return { eventId, events: res.data }; + } + + return { eventId, events: [] }; + } +); + +/** + * 获取传导链分析 + */ +export const fetchChainAnalysis = createAsyncThunk( + 'stock/fetchChainAnalysis', + async ({ eventId, forceRefresh = false }, { getState }) => { + logger.debug('stockSlice', 'fetchChainAnalysis', { eventId }); + + // Redux 缓存 + if (!forceRefresh) { + const cached = getState().stock.chainAnalysisCache[eventId]; + if (cached) { + return { eventId, analysis: cached }; + } + } + + // LocalStorage 缓存 + if (!forceRefresh) { + const localCached = localCacheManager.get(CACHE_KEYS.CHAIN_ANALYSIS + eventId); + if (localCached) { + return { eventId, analysis: localCached }; + } + } + + // API 请求 + const res = await eventService.getTransmissionChainAnalysis(eventId); + if (res.success && res.data) { + localCacheManager.set( + CACHE_KEYS.CHAIN_ANALYSIS + eventId, + res.data, + CACHE_EXPIRY_STRATEGY.LONG + ); + return { eventId, analysis: res.data }; + } + + return { eventId, analysis: null }; + } +); + +/** + * 获取超预期得分 + */ +export const fetchExpectationScore = createAsyncThunk( + 'stock/fetchExpectationScore', + async ({ eventId }) => { + logger.debug('stockSlice', 'fetchExpectationScore', { eventId }); + + if (eventService.getExpectationScore) { + const res = await eventService.getExpectationScore(eventId); + if (res.success && res.data) { + return { eventId, score: res.data.score }; + } + } + + return { eventId, score: null }; + } +); + +/** + * 加载用户自选股列表 + */ +export const loadWatchlist = createAsyncThunk( + 'stock/loadWatchlist', + async (_, { getState }) => { + logger.debug('stockSlice', 'loadWatchlist'); + + try { + const apiBase = getApiBase(); + const response = await fetch(`${apiBase}/api/account/watchlist`, { + credentials: 'include' + }); + const data = await response.json(); + + if (data.success && data.data) { + const stockCodes = data.data.map(item => item.stock_code); + logger.debug('stockSlice', '自选股列表加载成功', { + count: stockCodes.length + }); + return stockCodes; + } + + return []; + } catch (error) { + logger.error('stockSlice', 'loadWatchlist', error); + return []; + } + } +); + +/** + * 切换自选股状态 + */ +export const toggleWatchlist = createAsyncThunk( + 'stock/toggleWatchlist', + async ({ stockCode, stockName, isInWatchlist }) => { + logger.debug('stockSlice', 'toggleWatchlist', { + stockCode, + stockName, + isInWatchlist + }); + + const apiBase = getApiBase(); + let response; + + if (isInWatchlist) { + // 移除自选股 + response = await fetch(`${apiBase}/api/account/watchlist/${stockCode}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include' + }); + } else { + // 添加自选股 + response = await fetch(`${apiBase}/api/account/watchlist`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ stock_code: stockCode, stock_name: stockName }) + }); + } + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || '操作失败'); + } + + return { stockCode, isInWatchlist }; + } +); + +// ==================== Slice ==================== + +const stockSlice = createSlice({ + name: 'stock', + initialState: { + // 事件相关股票缓存 { [eventId]: stocks[] } + eventStocksCache: {}, + + // 股票行情 { [stockCode]: quote } + quotes: {}, + + // 事件详情缓存 { [eventId]: detail } + eventDetailsCache: {}, + + // 历史事件缓存 { [eventId]: events[] } + historicalEventsCache: {}, + + // 传导链分析缓存 { [eventId]: analysis } + chainAnalysisCache: {}, + + // 超预期得分缓存 { [eventId]: score } + expectationScores: {}, + + // 自选股列表 Set + watchlist: [], + + // 加载状态 + loading: { + stocks: false, + quotes: false, + eventDetail: false, + historicalEvents: false, + chainAnalysis: false, + watchlist: false + }, + + // 错误信息 + error: null + }, + reducers: { + /** + * 更新单个股票行情 + */ + updateQuote: (state, action) => { + const { stockCode, quote } = action.payload; + state.quotes[stockCode] = quote; + }, + + /** + * 批量更新股票行情 + */ + updateQuotes: (state, action) => { + state.quotes = { ...state.quotes, ...action.payload }; + }, + + /** + * 清空行情数据 + */ + clearQuotes: (state) => { + state.quotes = {}; + }, + + /** + * 清空指定事件的缓存 + */ + clearEventCache: (state, action) => { + const { eventId } = action.payload; + delete state.eventStocksCache[eventId]; + delete state.eventDetailsCache[eventId]; + delete state.historicalEventsCache[eventId]; + delete state.chainAnalysisCache[eventId]; + delete state.expectationScores[eventId]; + } + }, + extraReducers: (builder) => { + builder + // ===== fetchEventStocks ===== + .addCase(fetchEventStocks.pending, (state) => { + state.loading.stocks = true; + state.error = null; + }) + .addCase(fetchEventStocks.fulfilled, (state, action) => { + const { eventId, stocks } = action.payload; + state.eventStocksCache[eventId] = stocks; + state.loading.stocks = false; + }) + .addCase(fetchEventStocks.rejected, (state, action) => { + state.loading.stocks = false; + state.error = action.error.message; + }) + + // ===== fetchStockQuotes ===== + .addCase(fetchStockQuotes.pending, (state) => { + state.loading.quotes = true; + }) + .addCase(fetchStockQuotes.fulfilled, (state, action) => { + state.quotes = { ...state.quotes, ...action.payload }; + state.loading.quotes = false; + }) + .addCase(fetchStockQuotes.rejected, (state) => { + state.loading.quotes = false; + }) + + // ===== fetchEventDetail ===== + .addCase(fetchEventDetail.pending, (state) => { + state.loading.eventDetail = true; + }) + .addCase(fetchEventDetail.fulfilled, (state, action) => { + const { eventId, detail } = action.payload; + state.eventDetailsCache[eventId] = detail; + state.loading.eventDetail = false; + }) + .addCase(fetchEventDetail.rejected, (state) => { + state.loading.eventDetail = false; + }) + + // ===== fetchHistoricalEvents ===== + .addCase(fetchHistoricalEvents.pending, (state) => { + state.loading.historicalEvents = true; + }) + .addCase(fetchHistoricalEvents.fulfilled, (state, action) => { + const { eventId, events } = action.payload; + state.historicalEventsCache[eventId] = events; + state.loading.historicalEvents = false; + }) + .addCase(fetchHistoricalEvents.rejected, (state) => { + state.loading.historicalEvents = false; + }) + + // ===== fetchChainAnalysis ===== + .addCase(fetchChainAnalysis.pending, (state) => { + state.loading.chainAnalysis = true; + }) + .addCase(fetchChainAnalysis.fulfilled, (state, action) => { + const { eventId, analysis } = action.payload; + state.chainAnalysisCache[eventId] = analysis; + state.loading.chainAnalysis = false; + }) + .addCase(fetchChainAnalysis.rejected, (state) => { + state.loading.chainAnalysis = false; + }) + + // ===== fetchExpectationScore ===== + .addCase(fetchExpectationScore.fulfilled, (state, action) => { + const { eventId, score } = action.payload; + state.expectationScores[eventId] = score; + }) + + // ===== loadWatchlist ===== + .addCase(loadWatchlist.pending, (state) => { + state.loading.watchlist = true; + }) + .addCase(loadWatchlist.fulfilled, (state, action) => { + state.watchlist = action.payload; + state.loading.watchlist = false; + }) + .addCase(loadWatchlist.rejected, (state) => { + state.loading.watchlist = false; + }) + + // ===== toggleWatchlist ===== + .addCase(toggleWatchlist.fulfilled, (state, action) => { + const { stockCode, isInWatchlist } = action.payload; + if (isInWatchlist) { + // 移除 + state.watchlist = state.watchlist.filter(code => code !== stockCode); + } else { + // 添加 + if (!state.watchlist.includes(stockCode)) { + state.watchlist.push(stockCode); + } + } + }); + } +}); + +export const { + updateQuote, + updateQuotes, + clearQuotes, + clearEventCache +} = stockSlice.actions; + +export default stockSlice.reducer; From a1288f52dcc47cc2da0644b8b548c734780a87ba Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 30 Oct 2025 13:04:42 +0800 Subject: [PATCH 009/107] =?UTF-8?q?feat:=20=E6=8F=90=E4=BA=A4=20Custom=20H?= =?UTF-8?q?ooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StockDetailPanel/hooks/useEventStocks.js | 135 ++++++++++++++++++ .../StockDetailPanel/hooks/useWatchlist.js | 131 +++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 src/views/Community/components/StockDetailPanel/hooks/useEventStocks.js create mode 100644 src/views/Community/components/StockDetailPanel/hooks/useWatchlist.js diff --git a/src/views/Community/components/StockDetailPanel/hooks/useEventStocks.js b/src/views/Community/components/StockDetailPanel/hooks/useEventStocks.js new file mode 100644 index 00000000..90f596ff --- /dev/null +++ b/src/views/Community/components/StockDetailPanel/hooks/useEventStocks.js @@ -0,0 +1,135 @@ +// src/views/Community/components/StockDetailPanel/hooks/useEventStocks.js +import { useSelector, useDispatch } from 'react-redux'; +import { useEffect, useCallback, useMemo } from 'react'; +import { + fetchEventStocks, + fetchStockQuotes, + fetchEventDetail, + fetchHistoricalEvents, + fetchChainAnalysis, + fetchExpectationScore +} from '../../../../../store/slices/stockSlice'; +import { logger } from '../../../../../utils/logger'; + +/** + * 事件股票数据 Hook + * 封装事件相关的所有数据加载逻辑 + * + * @param {string} eventId - 事件ID + * @param {string} eventTime - 事件时间 + * @returns {Object} 事件数据和加载状态 + */ +export const useEventStocks = (eventId, eventTime) => { + const dispatch = useDispatch(); + + // 从 Redux 获取数据 + const stocks = useSelector(state => + eventId ? (state.stock.eventStocksCache[eventId] || []) : [] + ); + const quotes = useSelector(state => state.stock.quotes); + const eventDetail = useSelector(state => + eventId ? state.stock.eventDetailsCache[eventId] : null + ); + const historicalEvents = useSelector(state => + eventId ? (state.stock.historicalEventsCache[eventId] || []) : [] + ); + const chainAnalysis = useSelector(state => + eventId ? state.stock.chainAnalysisCache[eventId] : null + ); + const expectationScore = useSelector(state => + eventId ? state.stock.expectationScores[eventId] : null + ); + + // 加载状态 + const loading = useSelector(state => state.stock.loading); + + // 加载所有数据 + const loadAllData = useCallback(() => { + if (!eventId) { + logger.warn('useEventStocks', 'eventId 为空,跳过数据加载'); + return; + } + + logger.debug('useEventStocks', '开始加载事件数据', { eventId }); + + // 并发加载所有数据 + dispatch(fetchEventStocks({ eventId })); + dispatch(fetchEventDetail({ eventId })); + dispatch(fetchHistoricalEvents({ eventId })); + dispatch(fetchChainAnalysis({ eventId })); + dispatch(fetchExpectationScore({ eventId })); + }, [dispatch, eventId]); + + // 强制刷新所有数据 + const refreshAllData = useCallback(() => { + if (!eventId) return; + + logger.debug('useEventStocks', '强制刷新事件数据', { eventId }); + + dispatch(fetchEventStocks({ eventId, forceRefresh: true })); + dispatch(fetchEventDetail({ eventId, forceRefresh: true })); + dispatch(fetchHistoricalEvents({ eventId, forceRefresh: true })); + dispatch(fetchChainAnalysis({ eventId, forceRefresh: true })); + dispatch(fetchExpectationScore({ eventId })); + }, [dispatch, eventId]); + + // 只刷新行情数据 + const refreshQuotes = useCallback(() => { + if (stocks.length === 0) return; + + const codes = stocks.map(s => s.stock_code); + logger.debug('useEventStocks', '刷新行情数据', { + stockCount: codes.length, + eventTime + }); + + dispatch(fetchStockQuotes({ codes, eventTime })); + }, [dispatch, stocks, eventTime]); + + // 自动加载事件数据 + useEffect(() => { + if (eventId) { + loadAllData(); + } + }, [loadAllData]); + + // 自动加载行情数据 + useEffect(() => { + if (stocks.length > 0) { + refreshQuotes(); + } + }, [stocks.length, eventId]); // 注意:这里不依赖 refreshQuotes,避免重复请求 + + // 计算股票行情合并数据 + const stocksWithQuotes = useMemo(() => { + return stocks.map(stock => ({ + ...stock, + quote: quotes[stock.stock_code] || null + })); + }, [stocks, quotes]); + + return { + // 数据 + stocks, + stocksWithQuotes, + quotes, + eventDetail, + historicalEvents, + chainAnalysis, + expectationScore, + + // 加载状态 + loading: { + stocks: loading.stocks, + quotes: loading.quotes, + eventDetail: loading.eventDetail, + historicalEvents: loading.historicalEvents, + chainAnalysis: loading.chainAnalysis + }, + + // 方法 + loadAllData, + refreshAllData, + refreshQuotes + }; +}; diff --git a/src/views/Community/components/StockDetailPanel/hooks/useWatchlist.js b/src/views/Community/components/StockDetailPanel/hooks/useWatchlist.js new file mode 100644 index 00000000..c1b8f031 --- /dev/null +++ b/src/views/Community/components/StockDetailPanel/hooks/useWatchlist.js @@ -0,0 +1,131 @@ +// src/views/Community/components/StockDetailPanel/hooks/useWatchlist.js +import { useSelector, useDispatch } from 'react-redux'; +import { useEffect, useCallback, useMemo } from 'react'; +import { loadWatchlist, toggleWatchlist as toggleWatchlistAction } from '../../../../../store/slices/stockSlice'; +import { message } from 'antd'; +import { logger } from '../../../../../utils/logger'; + +/** + * 自选股管理 Hook + * 封装自选股的加载、添加、移除逻辑 + * + * @returns {Object} 自选股数据和操作方法 + */ +export const useWatchlist = () => { + const dispatch = useDispatch(); + + // 从 Redux 获取自选股列表 + const watchlistArray = useSelector(state => state.stock.watchlist); + const loading = useSelector(state => state.stock.loading.watchlist); + + // 转换为 Set 方便快速查询 + const watchlistSet = useMemo(() => { + return new Set(watchlistArray); + }, [watchlistArray]); + + // 初始化时加载自选股列表 + useEffect(() => { + dispatch(loadWatchlist()); + }, [dispatch]); + + /** + * 检查股票是否在自选股中 + * @param {string} stockCode - 股票代码 + * @returns {boolean} + */ + const isInWatchlist = useCallback((stockCode) => { + return watchlistSet.has(stockCode); + }, [watchlistSet]); + + /** + * 切换自选股状态 + * @param {string} stockCode - 股票代码 + * @param {string} stockName - 股票名称 + * @returns {Promise} 操作是否成功 + */ + const toggleWatchlist = useCallback(async (stockCode, stockName) => { + const wasInWatchlist = watchlistSet.has(stockCode); + + logger.debug('useWatchlist', '切换自选股状态', { + stockCode, + stockName, + wasInWatchlist + }); + + try { + await dispatch(toggleWatchlistAction({ + stockCode, + stockName, + isInWatchlist: wasInWatchlist + })).unwrap(); + + message.success(wasInWatchlist ? '已从自选股移除' : '已加入自选股'); + return true; + } catch (error) { + logger.error('useWatchlist', '切换自选股失败', error, { + stockCode, + stockName + }); + message.error(error.message || '操作失败,请稍后重试'); + return false; + } + }, [dispatch, watchlistSet]); + + /** + * 批量添加到自选股 + * @param {Array<{code: string, name: string}>} stocks - 股票列表 + * @returns {Promise} 成功添加的数量 + */ + const batchAddToWatchlist = useCallback(async (stocks) => { + logger.debug('useWatchlist', '批量添加自选股', { + count: stocks.length + }); + + let successCount = 0; + const promises = stocks.map(async ({ code, name }) => { + if (!watchlistSet.has(code)) { + try { + await dispatch(toggleWatchlistAction({ + stockCode: code, + stockName: name, + isInWatchlist: false + })).unwrap(); + successCount++; + } catch (error) { + logger.error('useWatchlist', '添加失败', error, { code, name }); + } + } + }); + + await Promise.all(promises); + + if (successCount > 0) { + message.success(`成功添加 ${successCount} 只股票到自选股`); + } + + return successCount; + }, [dispatch, watchlistSet]); + + /** + * 刷新自选股列表 + */ + const refresh = useCallback(() => { + logger.debug('useWatchlist', '刷新自选股列表'); + dispatch(loadWatchlist()); + }, [dispatch]); + + return { + // 数据 + watchlist: watchlistArray, + watchlistSet, + loading, + + // 查询方法 + isInWatchlist, + + // 操作方法 + toggleWatchlist, + batchAddToWatchlist, + refresh + }; +}; From 7bb328e4e00f8692881486b3312627d586b9d337 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 30 Oct 2025 13:06:48 +0800 Subject: [PATCH 010/107] =?UTF-8?q?feat:=20=E5=88=9B=E5=BB=BA=E7=AC=AC?= =?UTF-8?q?=E4=B8=89=E4=B8=AA=20Hook=20-=20useStockMonitoring.js=EF=BC=88?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E7=9B=91=E6=8E=A7=E5=8A=9F=E8=83=BD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/useStockMonitoring.js | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 src/views/Community/components/StockDetailPanel/hooks/useStockMonitoring.js diff --git a/src/views/Community/components/StockDetailPanel/hooks/useStockMonitoring.js b/src/views/Community/components/StockDetailPanel/hooks/useStockMonitoring.js new file mode 100644 index 00000000..9f988c4f --- /dev/null +++ b/src/views/Community/components/StockDetailPanel/hooks/useStockMonitoring.js @@ -0,0 +1,159 @@ +// src/views/Community/components/StockDetailPanel/hooks/useStockMonitoring.js +import { useSelector, useDispatch } from 'react-redux'; +import { useState, useEffect, useRef, useCallback } from 'react'; +import { fetchStockQuotes } from '../../../../../store/slices/stockSlice'; +import { message } from 'antd'; +import { logger } from '../../../../../utils/logger'; + +/** + * 股票实时监控 Hook + * 提供定时刷新股票行情的功能 + * + * @param {Array} stocks - 股票列表 + * @param {string} eventTime - 事件时间 + * @param {number} interval - 刷新间隔(毫秒),默认 5000ms + * @returns {Object} 监控状态和控制方法 + */ +export const useStockMonitoring = (stocks = [], eventTime = null, interval = 5000) => { + const dispatch = useDispatch(); + const [isMonitoring, setIsMonitoring] = useState(false); + const monitoringIntervalRef = useRef(null); + + // 从 Redux 获取行情数据和加载状态 + const quotes = useSelector(state => state.stock.quotes); + const quotesLoading = useSelector(state => state.stock.loading.quotes); + + /** + * 执行一次行情更新 + */ + const updateQuotes = useCallback(() => { + if (stocks.length === 0) { + logger.warn('useStockMonitoring', '股票列表为空,跳过更新'); + return; + } + + const codes = stocks.map(s => s.stock_code); + logger.debug('useStockMonitoring', '更新行情数据', { + stockCount: codes.length, + eventTime, + timestamp: new Date().toISOString() + }); + + dispatch(fetchStockQuotes({ codes, eventTime })); + }, [dispatch, stocks, eventTime]); + + /** + * 开启实时监控 + */ + const startMonitoring = useCallback(() => { + if (isMonitoring) { + logger.warn('useStockMonitoring', '监控已经在运行中'); + return; + } + + if (stocks.length === 0) { + message.warning('暂无股票数据,无法开启监控'); + return; + } + + logger.info('useStockMonitoring', '开启实时监控', { + interval, + stockCount: stocks.length + }); + + setIsMonitoring(true); + message.success(`已开启实时监控,每${interval / 1000}秒自动更新`); + + // 立即执行一次 + updateQuotes(); + }, [isMonitoring, stocks, interval, updateQuotes]); + + /** + * 停止实时监控 + */ + const stopMonitoring = useCallback(() => { + if (!isMonitoring) { + return; + } + + logger.info('useStockMonitoring', '停止实时监控'); + + setIsMonitoring(false); + message.info('已停止实时监控'); + }, [isMonitoring]); + + /** + * 切换监控状态 + */ + const toggleMonitoring = useCallback(() => { + if (isMonitoring) { + stopMonitoring(); + } else { + startMonitoring(); + } + }, [isMonitoring, startMonitoring, stopMonitoring]); + + /** + * 手动刷新一次 + */ + const manualRefresh = useCallback(() => { + logger.debug('useStockMonitoring', '手动刷新行情'); + updateQuotes(); + }, [updateQuotes]); + + // 监控定时器效果 + useEffect(() => { + // 清理旧的定时器 + if (monitoringIntervalRef.current) { + clearInterval(monitoringIntervalRef.current); + monitoringIntervalRef.current = null; + } + + if (isMonitoring && stocks.length > 0) { + // 设置定时器 + monitoringIntervalRef.current = setInterval(() => { + updateQuotes(); + }, interval); + + logger.debug('useStockMonitoring', '定时器已设置', { + interval, + stockCount: stocks.length + }); + } + + // 清理函数 + return () => { + if (monitoringIntervalRef.current) { + clearInterval(monitoringIntervalRef.current); + monitoringIntervalRef.current = null; + logger.debug('useStockMonitoring', '定时器已清理'); + } + }; + }, [isMonitoring, stocks.length, interval]); // 注意:不依赖 updateQuotes,避免重复创建定时器 + + // 组件卸载时自动停止监控 + useEffect(() => { + return () => { + if (isMonitoring) { + logger.debug('useStockMonitoring', '组件卸载,自动停止监控'); + setIsMonitoring(false); + } + }; + }, []); // 只在卸载时执行 + + return { + // 状态 + isMonitoring, + quotes, + quotesLoading, + + // 控制方法 + startMonitoring, + stopMonitoring, + toggleMonitoring, + manualRefresh, + + // 工具方法 + setIsMonitoring + }; +}; From 795e404fdd2cec27c00ffd4c9407af41952cd05d Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 30 Oct 2025 13:09:41 +0800 Subject: [PATCH 011/107] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=E6=A0=8F=20Max=20=E4=BC=9A=E5=91=98=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E6=98=BE=E7=A4=BA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 HomeNavbar 中 useEffect 执行顺序导致订阅信息不加载的问题 - 移除 ref 检查逻辑,改为直接根据登录状态加载订阅信息 - 增强订阅相关的调试日志输出(getCurrentUser, API handler, HomeNavbar) - 优化用户数据获取的日志格式,便于问题排查 --- src/components/Navbars/HomeNavbar.js | 76 ++++++++++++++-------------- src/mocks/data/users.js | 10 +++- src/mocks/handlers/account.js | 11 +++- 3 files changed, 56 insertions(+), 41 deletions(-) diff --git a/src/components/Navbars/HomeNavbar.js b/src/components/Navbars/HomeNavbar.js index 288b3b0c..5a1dc1f4 100644 --- a/src/components/Navbars/HomeNavbar.js +++ b/src/components/Navbars/HomeNavbar.js @@ -788,47 +788,47 @@ export default function HomeNavbar() { // 加载订阅信息 React.useEffect(() => { - const userIdChanged = prevUserIdRef.current !== userId; - const authChanged = prevIsAuthenticatedRef.current !== isAuthenticated; - - if (userIdChanged || authChanged) { - if (isAuthenticated && user) { - const loadSubscriptionInfo = async () => { - try { - const base = getApiBase(); - const response = await fetch(base + '/api/subscription/current', { - credentials: 'include', - }); - if (response.ok) { - const data = await response.json(); - if (data.success && data.data) { - // 数据标准化处理:确保type字段是小写的 'free', 'pro', 或 'max' - const normalizedData = { - type: (data.data.type || data.data.subscription_type || 'free').toLowerCase(), - status: data.data.status || 'active', - days_left: data.data.days_left || 0, - is_active: data.data.is_active !== false, - end_date: data.data.end_date || null - }; - setSubscriptionInfo(normalizedData); - } + // ✅ 移除 ref 检查,直接根据登录状态加载 + if (isAuthenticated && user) { + const loadSubscriptionInfo = async () => { + try { + const base = getApiBase(); + logger.debug('HomeNavbar', '开始加载订阅信息', { user_id: user?.id }); + const response = await fetch(base + '/api/subscription/current', { + credentials: 'include', + }); + if (response.ok) { + const data = await response.json(); + logger.debug('HomeNavbar', 'API 返回订阅数据', data); + if (data.success && data.data) { + // 数据标准化处理:确保type字段是小写的 'free', 'pro', 或 'max' + const normalizedData = { + type: (data.data.type || data.data.subscription_type || 'free').toLowerCase(), + status: data.data.status || 'active', + days_left: data.data.days_left || 0, + is_active: data.data.is_active !== false, + end_date: data.data.end_date || null + }; + logger.info('HomeNavbar', '订阅信息已更新', normalizedData); + setSubscriptionInfo(normalizedData); } - } catch (error) { - logger.error('HomeNavbar', '加载订阅信息失败', error); } - }; - loadSubscriptionInfo(); - } else { - // 用户未登录时,重置为免费版 - setSubscriptionInfo({ - type: 'free', - status: 'active', - days_left: 0, - is_active: true - }); - } + } catch (error) { + logger.error('HomeNavbar', '加载订阅信息失败', error); + } + }; + loadSubscriptionInfo(); + } else { + // 用户未登录时,重置为免费版 + logger.debug('HomeNavbar', '用户未登录,重置订阅信息为免费版'); + setSubscriptionInfo({ + type: 'free', + status: 'active', + days_left: 0, + is_active: true + }); } - }, [isAuthenticated, userId, user]); // ⚡ 使用 userId,防重复通过 ref 判断 + }, [isAuthenticated, userId, user]); // ✅ React 会自动去重,不会造成无限循环 return ( <> diff --git a/src/mocks/data/users.js b/src/mocks/data/users.js index 45d832f0..5eeda785 100644 --- a/src/mocks/data/users.js +++ b/src/mocks/data/users.js @@ -112,12 +112,20 @@ export function getCurrentUser() { const stored = localStorage.getItem('mock_current_user'); if (stored) { const user = JSON.parse(stored); - console.log('[Mock State] 获取当前登录用户:', user); + console.log('[Mock State] 获取当前登录用户:', { + id: user.id, + phone: user.phone, + nickname: user.nickname, + subscription_type: user.subscription_type, + subscription_status: user.subscription_status, + subscription_days_left: user.subscription_days_left + }); return user; } } catch (error) { console.error('[Mock State] 解析用户数据失败:', error); } + console.log('[Mock State] 未找到当前登录用户'); return null; } diff --git a/src/mocks/handlers/account.js b/src/mocks/handlers/account.js index 75cc2e75..f24ab6b9 100644 --- a/src/mocks/handlers/account.js +++ b/src/mocks/handlers/account.js @@ -594,14 +594,13 @@ export const accountHandlers = [ const currentUser = getCurrentUser(); if (!currentUser) { + console.warn('[Mock API] 获取订阅详情失败: 用户未登录'); return HttpResponse.json( { success: false, error: '未登录' }, { status: 401 } ); } - console.log('[Mock] 获取当前订阅详情'); - // 基于当前用户的订阅类型返回详情 const userSubscriptionType = (currentUser.subscription_type || 'free').toLowerCase(); @@ -614,6 +613,14 @@ export const accountHandlers = [ end_date: currentUser.subscription_end_date || null }; + console.log('[Mock API] 获取当前订阅详情:', { + user_id: currentUser.id, + phone: currentUser.phone, + subscription_type: userSubscriptionType, + subscription_status: subscriptionDetails.status, + days_left: subscriptionDetails.days_left + }); + return HttpResponse.json({ success: true, data: subscriptionDetails From ff573e4eef41d439f6dba179848b52044111efef Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 30 Oct 2025 13:22:45 +0800 Subject: [PATCH 012/107] =?UTF-8?q?feat:=20=E5=B0=86=20AuthModalProvider?= =?UTF-8?q?=20=E8=BF=81=E7=A7=BB=E5=88=B0=20Redux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 主要改动 ### 新增 - 创建 `store/slices/authModalSlice.js` - Redux Slice 管理认证弹窗状态 - 创建 `hooks/useAuthModal.js` - 自定义 Hook,组合 Redux 状态和业务逻辑 ### 修改 - 更新 `store/index.js` - 添加 authModal reducer - 更新 `App.js` - 移除 AuthModalProvider 包裹层 - 更新 5 个组件的 import 路径: - AuthFormContent.js - AuthModalManager.js - WechatRegister.js - HomeNavbar.js - ProtectedRoute.js ### 删除 - 删除 `contexts/AuthModalContext.js` - 旧的 Context 实现 ## 迁移效果 - ✅ 减少 Provider 嵌套层级(4层 → 3层) - ✅ 统一状态管理架构(Redux) - ✅ 更好的调试体验(Redux DevTools) - ✅ 保持 API 兼容性(无破坏性修改) ## 技术细节 - 使用 `useRef` 存储 `onSuccessCallback`(函数不可序列化) - 保持与 AuthContext 的依赖关系(AuthProvider 暂未迁移) - 所有业务逻辑保持不变,仅改变状态管理方式 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/App.js | 11 +-- src/components/Auth/AuthFormContent.js | 2 +- src/components/Auth/AuthModalManager.js | 2 +- src/components/Auth/WechatRegister.js | 2 +- src/components/Navbars/HomeNavbar.js | 2 +- src/components/ProtectedRoute.js | 2 +- src/contexts/AuthModalContext.js | 110 ---------------------- src/hooks/useAuthModal.js | 116 ++++++++++++++++++++++++ src/store/index.js | 2 + src/store/slices/authModalSlice.js | 56 ++++++++++++ 10 files changed, 183 insertions(+), 122 deletions(-) delete mode 100644 src/contexts/AuthModalContext.js create mode 100644 src/hooks/useAuthModal.js create mode 100644 src/store/slices/authModalSlice.js diff --git a/src/App.js b/src/App.js index 7ff206da..bb7b11fb 100755 --- a/src/App.js +++ b/src/App.js @@ -46,7 +46,6 @@ import { store } from './store'; // Contexts import { AuthProvider } from "contexts/AuthContext"; -import { AuthModalProvider } from "contexts/AuthModalContext"; import { NotificationProvider, useNotification } from "contexts/NotificationContext"; // Components @@ -319,12 +318,10 @@ export default function App() { - - - - - - + + + + diff --git a/src/components/Auth/AuthFormContent.js b/src/components/Auth/AuthFormContent.js index 8435c597..6e808ddf 100644 --- a/src/components/Auth/AuthFormContent.js +++ b/src/components/Auth/AuthFormContent.js @@ -29,7 +29,7 @@ import { } from "@chakra-ui/react"; import { FaLock, FaWeixin } from "react-icons/fa"; import { useAuth } from "../../contexts/AuthContext"; -import { useAuthModal } from "../../contexts/AuthModalContext"; +import { useAuthModal } from "../../hooks/useAuthModal"; import { useNotification } from "../../contexts/NotificationContext"; import { authService } from "../../services/authService"; import AuthHeader from './AuthHeader'; diff --git a/src/components/Auth/AuthModalManager.js b/src/components/Auth/AuthModalManager.js index 40e38cd6..d1314ea4 100644 --- a/src/components/Auth/AuthModalManager.js +++ b/src/components/Auth/AuthModalManager.js @@ -8,7 +8,7 @@ import { ModalCloseButton, useBreakpointValue } from '@chakra-ui/react'; -import { useAuthModal } from '../../contexts/AuthModalContext'; +import { useAuthModal } from '../../hooks/useAuthModal'; import AuthFormContent from './AuthFormContent'; /** diff --git a/src/components/Auth/WechatRegister.js b/src/components/Auth/WechatRegister.js index 1c144ae5..a6f38dec 100644 --- a/src/components/Auth/WechatRegister.js +++ b/src/components/Auth/WechatRegister.js @@ -15,7 +15,7 @@ import { FaQrcode } from "react-icons/fa"; import { FiAlertCircle } from "react-icons/fi"; import { useNavigate } from "react-router-dom"; import { authService, WECHAT_STATUS, STATUS_MESSAGES } from "../../services/authService"; -import { useAuthModal } from "../../contexts/AuthModalContext"; +import { useAuthModal } from "../../hooks/useAuthModal"; import { useAuth } from "../../contexts/AuthContext"; import { logger } from "../../utils/logger"; import { useAuthEvents } from "../../hooks/useAuthEvents"; diff --git a/src/components/Navbars/HomeNavbar.js b/src/components/Navbars/HomeNavbar.js index 5a1dc1f4..55b3ce0c 100644 --- a/src/components/Navbars/HomeNavbar.js +++ b/src/components/Navbars/HomeNavbar.js @@ -44,7 +44,7 @@ import { FiStar, FiCalendar, FiUser, FiSettings, FiHome, FiLogOut } from 'react- import { FaCrown } from 'react-icons/fa'; import { useNavigate, useLocation } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { useAuthModal } from '../../contexts/AuthModalContext'; +import { useAuthModal } from '../../hooks/useAuthModal'; import { logger } from '../../utils/logger'; import { getApiBase } from '../../utils/apiConfig'; import SubscriptionButton from '../Subscription/SubscriptionButton'; diff --git a/src/components/ProtectedRoute.js b/src/components/ProtectedRoute.js index a415a172..cb9716f0 100755 --- a/src/components/ProtectedRoute.js +++ b/src/components/ProtectedRoute.js @@ -2,7 +2,7 @@ import React, { useEffect, useRef } from 'react'; import { Box, VStack, Spinner, Text } from '@chakra-ui/react'; import { useAuth } from '../contexts/AuthContext'; -import { useAuthModal } from '../contexts/AuthModalContext'; +import { useAuthModal } from '../hooks/useAuthModal'; const ProtectedRoute = ({ children }) => { const { isAuthenticated, isLoading, user } = useAuth(); diff --git a/src/contexts/AuthModalContext.js b/src/contexts/AuthModalContext.js deleted file mode 100644 index 1269619f..00000000 --- a/src/contexts/AuthModalContext.js +++ /dev/null @@ -1,110 +0,0 @@ -// src/contexts/AuthModalContext.js -import { createContext, useContext, useState, useCallback } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useAuth } from './AuthContext'; -import { logger } from '../utils/logger'; - -const AuthModalContext = createContext(); - -/** - * 自定义Hook:获取弹窗上下文 - */ -export const useAuthModal = () => { - const context = useContext(AuthModalContext); - if (!context) { - throw new Error('useAuthModal must be used within AuthModalProvider'); - } - return context; -}; - -/** - * 认证弹窗提供者组件 - * 管理统一的认证弹窗状态(登录/注册合并) - */ -export const AuthModalProvider = ({ children }) => { - const navigate = useNavigate(); - const { isAuthenticated } = useAuth(); - - // 弹窗状态(统一的认证弹窗) - const [isAuthModalOpen, setIsAuthModalOpen] = useState(false); - - // 重定向URL(认证成功后跳转) - const [redirectUrl, setRedirectUrl] = useState(null); - - // 成功回调函数 - const [onSuccessCallback, setOnSuccessCallback] = useState(null); - - /** - * 打开认证弹窗(统一的登录/注册入口) - * @param {string} url - 认证成功后的重定向URL(可选) - * @param {function} callback - 认证成功后的回调函数(可选) - */ - const openAuthModal = useCallback((url = null, callback = null) => { - setRedirectUrl(url); - setOnSuccessCallback(() => callback); - setIsAuthModalOpen(true); - }, []); - - /** - * 关闭认证弹窗 - * 如果用户未登录,跳转到首页 - */ - const closeModal = useCallback(() => { - setIsAuthModalOpen(false); - setRedirectUrl(null); - setOnSuccessCallback(null); - - // ⭐ 如果用户关闭弹窗时仍未登录,跳转到首页 - if (!isAuthenticated) { - navigate('/home'); - } - }, [isAuthenticated, navigate]); - - /** - * 登录/注册成功处理 - * @param {object} user - 用户信息 - */ - const handleLoginSuccess = useCallback((user) => { - // 执行自定义回调(如果有) - if (onSuccessCallback) { - try { - onSuccessCallback(user); - } catch (error) { - logger.error('AuthModalContext', 'handleLoginSuccess', error, { - userId: user?.id, - hasCallback: !!onSuccessCallback - }); - } - } - - // ⭐ 登录成功后,只关闭弹窗,留在当前页面(不跳转) - // 移除了原有的 redirectUrl 跳转逻辑 - setIsAuthModalOpen(false); - setRedirectUrl(null); - setOnSuccessCallback(null); - }, [onSuccessCallback]); - - /** - * 提供给子组件的上下文值 - */ - const value = { - // 状态 - isAuthModalOpen, - redirectUrl, - - // 打开弹窗方法 - openAuthModal, - - // 关闭弹窗方法 - closeModal, - - // 成功处理方法 - handleLoginSuccess, - }; - - return ( - - {children} - - ); -}; diff --git a/src/hooks/useAuthModal.js b/src/hooks/useAuthModal.js new file mode 100644 index 00000000..956d5d24 --- /dev/null +++ b/src/hooks/useAuthModal.js @@ -0,0 +1,116 @@ +// src/hooks/useAuthModal.js +// 认证弹窗自定义 Hook - 组合 Redux 状态和业务逻辑 + +import { useCallback, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +import { + openModal, + closeModal, + selectAuthModalOpen, + selectRedirectUrl +} from '../store/slices/authModalSlice'; +import { useAuth } from '../contexts/AuthContext'; +import { logger } from '../utils/logger'; + +/** + * 认证弹窗自定义 Hook + * + * 功能: + * - 管理认证弹窗的开关状态 + * - 处理登录成功后的回调和跳转 + * - 未登录时关闭弹窗自动跳转到首页 + * + * 注意: + * - onSuccessCallback 使用 ref 存储(函数不可序列化,不能放 Redux) + * - 依赖 AuthContext 读取 isAuthenticated(AuthProvider 暂未迁移) + * + * @returns {object} 弹窗状态和操作方法 + */ +export const useAuthModal = () => { + const dispatch = useDispatch(); + const navigate = useNavigate(); + + // Redux 状态 + const isAuthModalOpen = useSelector(selectAuthModalOpen); + const redirectUrl = useSelector(selectRedirectUrl); + + // AuthContext 状态(暂未迁移到 Redux) + const { isAuthenticated } = useAuth(); + + // 使用 ref 存储回调函数(不能放 Redux,因为函数不可序列化) + const onSuccessCallbackRef = useRef(null); + + /** + * 打开认证弹窗(统一的登录/注册入口) + * @param {string} url - 认证成功后的重定向URL(可选) + * @param {function} callback - 认证成功后的回调函数(可选) + */ + const openAuthModal = useCallback((url = null, callback = null) => { + onSuccessCallbackRef.current = callback; + dispatch(openModal({ redirectUrl: url })); + + logger.debug('useAuthModal', '打开认证弹窗', { + redirectUrl: url || '无', + hasCallback: !!callback + }); + }, [dispatch]); + + /** + * 关闭认证弹窗 + * 如果用户未登录,跳转到首页 + */ + const closeAuthModal = useCallback(() => { + dispatch(closeModal()); + onSuccessCallbackRef.current = null; + + // ⭐ 如果用户关闭弹窗时仍未登录,跳转到首页 + if (!isAuthenticated) { + navigate('/home'); + logger.debug('useAuthModal', '未登录关闭弹窗,跳转到首页'); + } else { + logger.debug('useAuthModal', '关闭认证弹窗'); + } + }, [dispatch, isAuthenticated, navigate]); + + /** + * 登录/注册成功处理 + * @param {object} user - 用户信息 + */ + const handleLoginSuccess = useCallback((user) => { + // 执行自定义回调(如果有) + if (onSuccessCallbackRef.current) { + try { + onSuccessCallbackRef.current(user); + logger.debug('useAuthModal', '执行成功回调', { + userId: user?.id + }); + } catch (error) { + logger.error('useAuthModal', 'handleLoginSuccess 回调执行失败', error, { + userId: user?.id, + hasCallback: !!onSuccessCallbackRef.current + }); + } + } + + // ⭐ 登录成功后,只关闭弹窗,留在当前页面(不跳转) + // 移除了原有的 redirectUrl 跳转逻辑 + dispatch(closeModal()); + onSuccessCallbackRef.current = null; + + logger.debug('useAuthModal', '登录成功,关闭弹窗', { + userId: user?.id + }); + }, [dispatch]); + + return { + // 状态 + isAuthModalOpen, + redirectUrl, + + // 方法 + openAuthModal, + closeModal: closeAuthModal, + handleLoginSuccess, + }; +}; diff --git a/src/store/index.js b/src/store/index.js index d983721f..9adafda6 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -4,6 +4,7 @@ import communityDataReducer from './slices/communityDataSlice'; import posthogReducer from './slices/posthogSlice'; import industryReducer from './slices/industrySlice'; import stockReducer from './slices/stockSlice'; +import authModalReducer from './slices/authModalSlice'; import posthogMiddleware from './middleware/posthogMiddleware'; export const store = configureStore({ @@ -12,6 +13,7 @@ export const store = configureStore({ posthog: posthogReducer, // ✅ PostHog Redux 状态管理 industry: industryReducer, // ✅ 行业分类数据管理 stock: stockReducer, // ✅ 股票和事件数据管理 + authModal: authModalReducer, // ✅ 认证弹窗状态管理 }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ diff --git a/src/store/slices/authModalSlice.js b/src/store/slices/authModalSlice.js new file mode 100644 index 00000000..e8ff1696 --- /dev/null +++ b/src/store/slices/authModalSlice.js @@ -0,0 +1,56 @@ +// src/store/slices/authModalSlice.js +// 认证弹窗状态管理 Redux Slice - 从 AuthModalContext 迁移 + +import { createSlice } from '@reduxjs/toolkit'; +import { logger } from '../../utils/logger'; + +/** + * AuthModal Slice + * 管理统一的认证弹窗状态(登录/注册合并) + */ +const authModalSlice = createSlice({ + name: 'authModal', + initialState: { + isOpen: false, // 弹窗开关状态 + redirectUrl: null, // 认证成功后的重定向URL(可选) + }, + reducers: { + /** + * 打开认证弹窗 + * @param {object} action.payload - { redirectUrl?: string } + */ + openModal: (state, action) => { + state.isOpen = true; + state.redirectUrl = action.payload?.redirectUrl || null; + logger.debug('authModalSlice', '打开认证弹窗', { + redirectUrl: action.payload?.redirectUrl || '无' + }); + }, + + /** + * 关闭认证弹窗 + */ + closeModal: (state) => { + state.isOpen = false; + state.redirectUrl = null; + logger.debug('authModalSlice', '关闭认证弹窗'); + }, + + /** + * 设置重定向URL(不打开弹窗) + */ + setRedirectUrl: (state, action) => { + state.redirectUrl = action.payload; + }, + }, +}); + +// 导出 actions +export const { openModal, closeModal, setRedirectUrl } = authModalSlice.actions; + +// 导出 selectors +export const selectAuthModalOpen = (state) => state.authModal.isOpen; +export const selectRedirectUrl = (state) => state.authModal.redirectUrl; + +// 导出 reducer +export default authModalSlice.reducer; From 0015231333e657fd557f929a5599267792c5d2e5 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 30 Oct 2025 14:37:20 +0800 Subject: [PATCH 013/107] =?UTF-8?q?feat:=20=E5=88=9B=E5=BB=BA=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=E5=BC=8F=E8=B7=AF=E7=94=B1=E9=85=8D=E7=BD=AE=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/index.js | 101 ++++++++++++++++++ src/routes/lazy-components.js | 44 ++++++++ src/routes/routeConfig.js | 190 ++++++++++++++++++++++++++++++++++ 3 files changed, 335 insertions(+) create mode 100644 src/routes/index.js create mode 100644 src/routes/lazy-components.js create mode 100644 src/routes/routeConfig.js diff --git a/src/routes/index.js b/src/routes/index.js new file mode 100644 index 00000000..f6688c75 --- /dev/null +++ b/src/routes/index.js @@ -0,0 +1,101 @@ +// src/routes/index.js +// 路由渲染器 - 根据配置自动生成 Routes + +import React from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import { Box, useColorMode } from '@chakra-ui/react'; + +// 路由配置 +import { routeConfig, PROTECTION_MODES, getMainLayoutRoutes, getStandaloneRoutes } from './routeConfig'; + +// 布局组件 (非懒加载) +import Auth from '../layouts/Auth'; +import HomeLayout from '../layouts/Home'; +import MainLayout from '../layouts/MainLayout'; + +// 保护路由组件 +import ProtectedRoute from '../components/ProtectedRoute'; +import ProtectedRouteRedirect from '../components/ProtectedRouteRedirect'; + +/** + * 根据保护模式包装组件 + */ +function wrapWithProtection(Component, protection) { + if (protection === PROTECTION_MODES.MODAL) { + return ( + + + + ); + } + + if (protection === PROTECTION_MODES.REDIRECT) { + return ( + + + + ); + } + + // PUBLIC - 无保护 + return ; +} + +/** + * 渲染单个路由 + */ +function renderRoute(routeItem, index) { + const { path, component, protection } = routeItem; + + // 处理特殊组件(非懒加载) + let Component; + if (component === 'Auth') { + Component = Auth; + } else if (component === 'HomeLayout') { + Component = HomeLayout; + } else { + Component = component; + } + + return ( + + ); +} + +/** + * AppRoutes - 应用路由组件 + * 替代 App.js 中的 Routes 部分 + */ +export function AppRoutes() { + const { colorMode } = useColorMode(); + + // 分离路由 + const mainLayoutRoutes = getMainLayoutRoutes(); + const standaloneRoutes = getStandaloneRoutes(); + + return ( + + + {/* 带导航栏的主布局 - 所有需要导航栏的页面都在这里 */} + }> + {mainLayoutRoutes.map((route, index) => renderRoute(route, index))} + + + {/* 不使用布局的路由 */} + {standaloneRoutes.map((route, index) => renderRoute(route, index))} + + {/* 默认重定向到首页 */} + } /> + + {/* 404 页面 */} + } /> + + + ); +} + +export default AppRoutes; diff --git a/src/routes/lazy-components.js b/src/routes/lazy-components.js new file mode 100644 index 00000000..dc1e668a --- /dev/null +++ b/src/routes/lazy-components.js @@ -0,0 +1,44 @@ +// src/routes/lazy-components.js +// 集中管理所有懒加载组件 + +import React from 'react'; + +/** + * 懒加载组件配置 + * 使用 React.lazy() 实现路由懒加载,大幅减少初始 JS 包大小 + */ +export const lazyComponents = { + // 社区/内容模块 + Community: React.lazy(() => import('../views/Community')), + ConceptCenter: React.lazy(() => import('../views/Concept')), + StockOverview: React.lazy(() => import('../views/StockOverview')), + LimitAnalyse: React.lazy(() => import('../views/LimitAnalyse')), + + // 交易模块 + TradingSimulation: React.lazy(() => import('../views/TradingSimulation')), + + // 事件模块 + EventDetail: React.lazy(() => import('../views/EventDetail')), + + // 公司相关模块 + CompanyIndex: React.lazy(() => import('../views/Company')), + ForecastReport: React.lazy(() => import('../views/Company/ForecastReport')), + FinancialPanorama: React.lazy(() => import('../views/Company/FinancialPanorama')), + MarketDataView: React.lazy(() => import('../views/Company/MarketDataView')), +}; + +/** + * 按需导出单个组件(可选) + */ +export const { + Community, + ConceptCenter, + StockOverview, + LimitAnalyse, + TradingSimulation, + EventDetail, + CompanyIndex, + ForecastReport, + FinancialPanorama, + MarketDataView, +} = lazyComponents; diff --git a/src/routes/routeConfig.js b/src/routes/routeConfig.js new file mode 100644 index 00000000..aa668b3d --- /dev/null +++ b/src/routes/routeConfig.js @@ -0,0 +1,190 @@ +// src/routes/routeConfig.js +// 声明式路由配置 + +import { lazyComponents } from './lazy-components'; + +/** + * 路由保护模式 + * - 'modal': 使用 ProtectedRoute (弹窗模式登录) + * - 'redirect': 使用 ProtectedRouteRedirect (跳转模式登录) + * - 'public': 公开访问,无需登录 + */ +export const PROTECTION_MODES = { + MODAL: 'modal', + REDIRECT: 'redirect', + PUBLIC: 'public', +}; + +/** + * 路由配置 + * 每个路由对象包含: + * - path: 路由路径 + * - component: 组件(从 lazyComponents 引用) + * - protection: 保护模式 (modal/redirect/public) + * - layout: 布局类型 (main/auth/none) + * - meta: 路由元数据(可选,用于面包屑、标题等) + */ +export const routeConfig = [ + // ==================== 首页 ==================== + { + path: 'home/*', + component: 'HomeLayout', // 非懒加载,直接在 App.js 导入 + protection: PROTECTION_MODES.PUBLIC, + layout: 'main', + meta: { + title: '首页', + description: '价值前沿首页' + } + }, + + // ==================== 社区/内容模块 ==================== + { + path: 'community', + component: lazyComponents.Community, + protection: PROTECTION_MODES.MODAL, + layout: 'main', + meta: { + title: '社区', + description: '投资社区交流' + } + }, + { + path: 'concepts', + component: lazyComponents.ConceptCenter, + protection: PROTECTION_MODES.MODAL, + layout: 'main', + meta: { + title: '概念中心', + description: '热门概念追踪' + } + }, + { + path: 'stocks', + component: lazyComponents.StockOverview, + protection: PROTECTION_MODES.MODAL, + layout: 'main', + meta: { + title: '股票概览', + description: '全市场股票概览' + } + }, + { + path: 'limit-analyse', + component: lazyComponents.LimitAnalyse, + protection: PROTECTION_MODES.MODAL, + layout: 'main', + meta: { + title: '涨停分析', + description: '涨停板数据分析' + } + }, + + // ==================== 交易模块 ==================== + { + path: 'trading-simulation', + component: lazyComponents.TradingSimulation, + protection: PROTECTION_MODES.MODAL, + layout: 'main', + meta: { + title: '模拟交易', + description: '模拟盘交易系统' + } + }, + + // ==================== 事件模块 ==================== + { + path: 'event-detail/:eventId', + component: lazyComponents.EventDetail, + protection: PROTECTION_MODES.REDIRECT, + layout: 'main', + meta: { + title: '事件详情', + description: '事件详细信息' + } + }, + + // ==================== 公司相关模块 ==================== + { + path: 'forecast-report', + component: lazyComponents.ForecastReport, + protection: PROTECTION_MODES.REDIRECT, + layout: 'main', + meta: { + title: '财报预测', + description: '上市公司财报预测' + } + }, + { + path: 'Financial', + component: lazyComponents.FinancialPanorama, + protection: PROTECTION_MODES.MODAL, + layout: 'main', + meta: { + title: '财务全景', + description: '公司财务全景分析' + } + }, + { + path: 'company', + component: lazyComponents.CompanyIndex, + protection: PROTECTION_MODES.MODAL, + layout: 'main', + meta: { + title: '公司', + description: '上市公司信息' + } + }, + { + path: 'company/:code', + component: lazyComponents.CompanyIndex, + protection: PROTECTION_MODES.REDIRECT, + layout: 'main', + meta: { + title: '公司详情', + description: '公司详细信息' + } + }, + { + path: 'market-data', + component: lazyComponents.MarketDataView, + protection: PROTECTION_MODES.MODAL, + layout: 'main', + meta: { + title: '市场数据', + description: '实时市场数据' + } + }, + + // ==================== 认证模块 ==================== + { + path: 'auth/*', + component: 'Auth', // 非懒加载,直接在 App.js 导入 + protection: PROTECTION_MODES.PUBLIC, + layout: 'none', + meta: { + title: '登录/注册', + description: '用户认证' + } + }, +]; + +/** + * 获取所有需要 MainLayout 的路由 + */ +export function getMainLayoutRoutes() { + return routeConfig.filter(route => route.layout === 'main'); +} + +/** + * 获取不需要布局的路由 + */ +export function getStandaloneRoutes() { + return routeConfig.filter(route => route.layout === 'none'); +} + +/** + * 根据路径查找路由配置 + */ +export function findRouteByPath(path) { + return routeConfig.find(route => route.path === path); +} From 441fba7e5a08b0fcf399bf2789d86dad42c71abc Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 30 Oct 2025 14:42:54 +0800 Subject: [PATCH 014/107] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=20App.js?= =?UTF-8?q?=20=E4=BD=BF=E7=94=A8=E5=A3=B0=E6=98=8E=E5=BC=8F=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 140+ 行路由定义 JSX,改用 AppRoutes 组件 - 移除 10 个懒加载组件声明 (已迁移到 routes/lazy-components.js) - 移除 ProtectedRoute/ProtectedRouteRedirect 导入 (路由系统内部处理) - 简化 AppContent 组件,只保留核心逻辑 效果: - App.js 从 330 行减少到 165 行 (-50%) - 代码职责更清晰,易于维护 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/App.js | 181 +++-------------------------------------------------- 1 file changed, 8 insertions(+), 173 deletions(-) diff --git a/src/App.js b/src/App.js index bb7b11fb..c5a9da3e 100755 --- a/src/App.js +++ b/src/App.js @@ -9,36 +9,14 @@ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Visionware. */ -import React, { Suspense, useEffect } from "react"; +import React, { useEffect } from "react"; import { ChakraProvider } from '@chakra-ui/react'; -import { Routes, Route, Navigate } from "react-router-dom"; - -// Chakra imports -import { Box, useColorMode } from '@chakra-ui/react'; // Core Components import theme from "theme/theme.js"; -// Loading Component -import PageLoader from "components/Loading/PageLoader"; - -// Layouts - 保持同步导入(需要立即加载) -import Auth from "layouts/Auth"; -import HomeLayout from "layouts/Home"; -import MainLayout from "layouts/MainLayout"; - -// ⚡ 使用 React.lazy() 实现路由懒加载 -// 首屏不需要的组件按需加载,大幅减少初始 JS 包大小 -const Community = React.lazy(() => import("views/Community")); -const LimitAnalyse = React.lazy(() => import("views/LimitAnalyse")); -const ForecastReport = React.lazy(() => import("views/Company/ForecastReport")); -const ConceptCenter = React.lazy(() => import("views/Concept")); -const FinancialPanorama = React.lazy(() => import("views/Company/FinancialPanorama")); -const CompanyIndex = React.lazy(() => import("views/Company")); -const MarketDataView = React.lazy(() => import("views/Company/MarketDataView")); -const StockOverview = React.lazy(() => import("views/StockOverview")); -const EventDetail = React.lazy(() => import("views/EventDetail")); -const TradingSimulation = React.lazy(() => import("views/TradingSimulation")); +// Routes +import AppRoutes from './routes'; // Redux import { Provider as ReduxProvider } from 'react-redux'; @@ -46,11 +24,10 @@ import { store } from './store'; // Contexts import { AuthProvider } from "contexts/AuthContext"; +import { AuthModalProvider } from "contexts/AuthModalContext"; import { NotificationProvider, useNotification } from "contexts/NotificationContext"; // Components -import ProtectedRoute from "components/ProtectedRoute"; -import ProtectedRouteRedirect from "components/ProtectedRouteRedirect"; import ErrorBoundary from "components/ErrorBoundary"; import AuthModalManager from "components/Auth/AuthModalManager"; import NotificationContainer from "components/NotificationContainer"; @@ -109,7 +86,6 @@ function ConnectionStatusBarWrapper() { } function AppContent() { - const { colorMode } = useColorMode(); const dispatch = useDispatch(); // 🎯 PostHog Redux 初始化 @@ -119,157 +95,16 @@ function AppContent() { }, [dispatch]); return ( - + <> {/* Socket 连接状态条 */} {/* 路由切换时自动滚动到顶部 */} - - {/* 带导航栏的主布局 - 所有需要导航栏的页面都在这里 */} - {/* MainLayout 内部有 Suspense,确保导航栏始终可见 */} - }> - {/* 首页路由 */} - } /> - {/* Community页面路由 - 需要登录 */} - - - - } - /> - - {/* 概念中心路由 - 需要登录 */} - - - - } - /> - - - - } - /> - - {/* 股票概览页面路由 - 需要登录 */} - - - - } - /> - - - - } - /> - - {/* Limit Analyse页面路由 - 需要登录 */} - - - - } - /> - - {/* 模拟盘交易系统路由 - 需要登录 */} - - - - } - /> - - {/* 事件详情独立页面路由 - 需要登录(跳转模式) */} - - - - } - /> - - {/* 公司相关页面 */} - {/* 财报预测 - 需要登录(跳转模式) */} - - - - } - /> - - {/* 财务全景 - 需要登录(弹窗模式) */} - - - - } - /> - - {/* 公司页面 - 需要登录(弹窗模式) */} - - - - } - /> - - {/* 公司详情 - 需要登录(跳转模式) */} - - - - } - /> - - {/* 市场数据 - 需要登录(弹窗模式) */} - - - - } - /> - - - {/* 认证页面路由 - 不使用 MainLayout */} - } /> - - {/* 默认重定向到首页 */} - } /> - - {/* 404 页面 */} - } /> - - + {/* 应用路由 - 从 routes/index.js 导入 */} + + ); } From 1066e84e3ba4ab2cd2ea1ca94dd5c6d7fcd775c6 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 30 Oct 2025 14:43:39 +0800 Subject: [PATCH 015/107] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E5=88=AB=E5=90=8D,=E7=BB=9F=E4=B8=80=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 /concept 别名路由,统一使用 /concepts - 删除 /stock-overview 别名路由 (死代码,从未使用) - 修改 StockOverview 中的链接: /concept → /concepts 优化收益: - 路由配置从 18 个减少到 16 个 - 每个页面只有一个标准路径,避免混淆 - 统一使用复数形式 (concepts, stocks) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/views/StockOverview/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/StockOverview/index.js b/src/views/StockOverview/index.js index 0237059e..a751fbd5 100644 --- a/src/views/StockOverview/index.js +++ b/src/views/StockOverview/index.js @@ -903,7 +903,7 @@ const StockOverview = () => { size="sm" variant="ghost" rightIcon={} - onClick={() => navigate('/concept')} + onClick={() => navigate('/concepts')} color={colorMode === 'dark' ? goldColor : 'purple.600'} _hover={{ bg: colorMode === 'dark' ? 'whiteAlpha.100' : 'purple.50' From 39f2de58d167b48ac672d67002fb92f8079d3b41 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 30 Oct 2025 14:51:22 +0800 Subject: [PATCH 016/107] =?UTF-8?q?docs:=20=E5=B0=86=E6=89=80=E6=9C=89?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E8=BF=81=E7=A7=BB=E5=88=B0=20docs/=20?= =?UTF-8?q?=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移动42个文档文件到 docs/ 目录 - 更新 .gitignore 允许 docs/ 下的 .md 文件 - 删除根目录下的重复文档文件 📁 文档分类: - StockDetailPanel 重构文档(3个) - PostHog 集成文档(6个) - 系统架构和API文档(33个) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 1 + docs/API_DOCS_profile_completeness.md | 458 ++++ docs/API_ENDPOINTS.md | 415 ++++ docs/AUTHENTICATION_SYSTEM_GUIDE.md | 1879 +++++++++++++++++ docs/AUTH_LOGIC_ANALYSIS.md | 431 ++++ docs/BUILD_OPTIMIZATION.md | 212 ++ docs/CENTER_PAGE_FLOW_ANALYSIS.md | 1812 ++++++++++++++++ docs/CRASH_FIX_REPORT.md | 500 +++++ docs/DARK_MODE_TEST.md | 307 +++ docs/DEPLOYMENT.md | 648 ++++++ docs/DEPLOYMENT_QUICKSTART.md | 70 + docs/ENHANCED_FEATURES_GUIDE.md | 626 ++++++ docs/ENVIRONMENT_SETUP.md | 376 ++++ docs/ERROR_FIX_REPORT.md | 364 ++++ docs/FIX_SUMMARY.md | 422 ++++ docs/HOMEPAGE_FIX.md | 327 +++ docs/IMAGE_OPTIMIZATION_REPORT.md | 393 ++++ docs/LOGIN_MODAL_REFACTOR_PLAN.md | 947 +++++++++ docs/LOGIN_MODAL_REFACTOR_SUMMARY.md | 420 ++++ docs/MESSAGE_PUSH_INTEGRATION_TEST.md | 370 ++++ docs/MOCK_API_DOCS.md | 322 +++ docs/MOCK_DATA_CENTER_SUPPLEMENT.md | 695 ++++++ docs/MOCK_GUIDE.md | 405 ++++ docs/NOTIFICATION_OPTIMIZATION_SUMMARY.md | 280 +++ docs/NOTIFICATION_SYSTEM.md | 1551 ++++++++++++++ docs/OPTIMIZATION_RESULTS.md | 390 ++++ docs/PERFORMANCE_ANALYSIS.md | 454 ++++ docs/PERFORMANCE_TEST_RESULTS.md | 539 +++++ docs/POSTHOG_DASHBOARD_GUIDE.md | 614 ++++++ docs/POSTHOG_EVENT_TRACKING.md | 841 ++++++++ docs/POSTHOG_INTEGRATION.md | 255 +++ docs/POSTHOG_REDUX_INTEGRATION.md | 439 ++++ docs/POSTHOG_TESTING_GUIDE.md | 476 +++++ docs/POSTHOG_TRACKING_GUIDE.md | 561 +++++ docs/QUICK_TEST_CHECKLIST.md | 149 ++ docs/StockDetailPanel_BUSINESS_LOGIC.md | 825 ++++++++ ...StockDetailPanel_REFACTORING_COMPARISON.md | 740 +++++++ docs/StockDetailPanel_USER_FLOW_COMPARISON.md | 1705 +++++++++++++++ docs/TEST_GUIDE.md | 338 +++ docs/TEST_RESULTS.md | 117 + docs/TRACKING_VALIDATION_CHECKLIST.md | 484 +++++ docs/WEBSOCKET_INTEGRATION_GUIDE.md | 546 +++++ docs/final_trading_simulation_fixes.md | 146 ++ 43 files changed, 23850 insertions(+) create mode 100644 docs/API_DOCS_profile_completeness.md create mode 100644 docs/API_ENDPOINTS.md create mode 100644 docs/AUTHENTICATION_SYSTEM_GUIDE.md create mode 100644 docs/AUTH_LOGIC_ANALYSIS.md create mode 100644 docs/BUILD_OPTIMIZATION.md create mode 100644 docs/CENTER_PAGE_FLOW_ANALYSIS.md create mode 100644 docs/CRASH_FIX_REPORT.md create mode 100644 docs/DARK_MODE_TEST.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/DEPLOYMENT_QUICKSTART.md create mode 100644 docs/ENHANCED_FEATURES_GUIDE.md create mode 100644 docs/ENVIRONMENT_SETUP.md create mode 100644 docs/ERROR_FIX_REPORT.md create mode 100644 docs/FIX_SUMMARY.md create mode 100644 docs/HOMEPAGE_FIX.md create mode 100644 docs/IMAGE_OPTIMIZATION_REPORT.md create mode 100644 docs/LOGIN_MODAL_REFACTOR_PLAN.md create mode 100644 docs/LOGIN_MODAL_REFACTOR_SUMMARY.md create mode 100644 docs/MESSAGE_PUSH_INTEGRATION_TEST.md create mode 100644 docs/MOCK_API_DOCS.md create mode 100644 docs/MOCK_DATA_CENTER_SUPPLEMENT.md create mode 100644 docs/MOCK_GUIDE.md create mode 100644 docs/NOTIFICATION_OPTIMIZATION_SUMMARY.md create mode 100644 docs/NOTIFICATION_SYSTEM.md create mode 100644 docs/OPTIMIZATION_RESULTS.md create mode 100644 docs/PERFORMANCE_ANALYSIS.md create mode 100644 docs/PERFORMANCE_TEST_RESULTS.md create mode 100644 docs/POSTHOG_DASHBOARD_GUIDE.md create mode 100644 docs/POSTHOG_EVENT_TRACKING.md create mode 100644 docs/POSTHOG_INTEGRATION.md create mode 100644 docs/POSTHOG_REDUX_INTEGRATION.md create mode 100644 docs/POSTHOG_TESTING_GUIDE.md create mode 100644 docs/POSTHOG_TRACKING_GUIDE.md create mode 100644 docs/QUICK_TEST_CHECKLIST.md create mode 100644 docs/StockDetailPanel_BUSINESS_LOGIC.md create mode 100644 docs/StockDetailPanel_REFACTORING_COMPARISON.md create mode 100644 docs/StockDetailPanel_USER_FLOW_COMPARISON.md create mode 100644 docs/TEST_GUIDE.md create mode 100644 docs/TEST_RESULTS.md create mode 100644 docs/TRACKING_VALIDATION_CHECKLIST.md create mode 100644 docs/WEBSOCKET_INTEGRATION_GUIDE.md create mode 100644 docs/final_trading_simulation_fixes.md diff --git a/.gitignore b/.gitignore index 392de6f7..30d6cee1 100644 --- a/.gitignore +++ b/.gitignore @@ -45,5 +45,6 @@ Thumbs.db *.md !README.md !CLAUDE.md +!docs/**/*.md src/assets/img/original-backup/ diff --git a/docs/API_DOCS_profile_completeness.md b/docs/API_DOCS_profile_completeness.md new file mode 100644 index 00000000..1a1e7780 --- /dev/null +++ b/docs/API_DOCS_profile_completeness.md @@ -0,0 +1,458 @@ +# 用户资料完整度 API 文档 + +## 接口概述 + +**接口名称**:获取用户资料完整度 +**接口路径**:`/api/account/profile-completeness` +**请求方法**:`GET` +**接口描述**:获取当前登录用户的资料完整度信息,包括各项必填信息的完成状态、完整度百分比、缺失项列表等。 +**业务场景**:用于在用户登录后提醒用户完善个人资料,提升平台服务质量。 + +--- + +## 请求参数 + +### Headers + +| 参数名 | 类型 | 必填 | 描述 | +|--------|------|------|------| +| `Cookie` | string | 是 | 包含用户会话信息(session cookie),用于身份认证 | + +### Query Parameters + +无 + +### Body Parameters + +无(GET 请求无 Body) + +--- + +## 响应格式 + +### 成功响应 (200 OK) + +**Content-Type**: `application/json` + +```json +{ + "success": true, + "data": { + "completeness": { + "hasPassword": true, + "hasPhone": true, + "hasEmail": false, + "isWechatUser": false + }, + "completenessPercentage": 66, + "needsAttention": false, + "missingItems": ["邮箱"], + "isComplete": false, + "showReminder": false + } +} +``` + +### 响应字段说明 + +#### 顶层字段 + +| 字段名 | 类型 | 描述 | +|--------|------|------| +| `success` | boolean | 请求是否成功,`true` 表示成功 | +| `data` | object | 资料完整度数据对象 | + +#### `data` 对象字段 + +| 字段名 | 类型 | 描述 | +|--------|------|------| +| `completeness` | object | 各项资料的完成状态详情 | +| `completenessPercentage` | number | 资料完整度百分比(0-100) | +| `needsAttention` | boolean | 是否需要用户注意(提醒用户完善) | +| `missingItems` | array[string] | 缺失项的中文描述列表 | +| `isComplete` | boolean | 资料是否完全完整(100%) | +| `showReminder` | boolean | 是否显示提醒横幅(同 `needsAttention`) | + +#### `completeness` 对象字段 + +| 字段名 | 类型 | 描述 | +|--------|------|------| +| `hasPassword` | boolean | 是否已设置登录密码 | +| `hasPhone` | boolean | 是否已绑定手机号 | +| `hasEmail` | boolean | 是否已设置有效邮箱(排除临时邮箱) | +| `isWechatUser` | boolean | 是否为微信登录用户 | + +--- + +## 业务逻辑说明 + +### 资料完整度计算规则 + +1. **必填项**(共 3 项): + - 登录密码(`hasPassword`) + - 手机号(`hasPhone`) + - 邮箱(`hasEmail`) + +2. **完整度计算公式**: + ``` + completenessPercentage = (已完成项数 / 3) × 100 + ``` + 示例: + - 已完成 2 项 → 66% + - 已完成 3 项 → 100% + +3. **邮箱有效性判断**: + - 必须包含 `@` 符号 + - 不能是临时邮箱(如 `*@valuefrontier.temp`) + +### 提醒逻辑(`needsAttention`) + +**仅对微信登录用户进行提醒**,需同时满足以下条件: + +1. `isWechatUser === true`(微信登录用户) +2. `completenessPercentage < 100`(资料不完整) + +**后端额外的智能提醒策略**(Mock 模式未实现): + +- 新用户(注册 7 天内):更容易触发提醒 +- 每 7 天最多提醒一次(通过 session 记录) +- 完整度低于 50% 时优先提醒 + +### 缺失项列表(`missingItems`) + +根据 `completeness` 对象生成中文描述: + +| 条件 | 添加到 `missingItems` | +|------|----------------------| +| `!hasPassword` | `"登录密码"` | +| `!hasPhone` | `"手机号"` | +| `!hasEmail` | `"邮箱"` | + +--- + +## 响应示例 + +### 示例 1:手机号登录用户,资料完整 + +**场景**:手机号登录,已设置密码和邮箱 + +```json +{ + "success": true, + "data": { + "completeness": { + "hasPassword": true, + "hasPhone": true, + "hasEmail": true, + "isWechatUser": false + }, + "completenessPercentage": 100, + "needsAttention": false, + "missingItems": [], + "isComplete": true, + "showReminder": false + } +} +``` + +### 示例 2:微信登录用户,未绑定手机号 + +**场景**:微信登录,未设置密码和手机号,触发提醒 + +```json +{ + "success": true, + "data": { + "completeness": { + "hasPassword": false, + "hasPhone": false, + "hasEmail": true, + "isWechatUser": true + }, + "completenessPercentage": 33, + "needsAttention": true, + "missingItems": ["登录密码", "手机号"], + "isComplete": false, + "showReminder": true + } +} +``` + +### 示例 3:微信登录用户,只缺邮箱 + +**场景**:微信登录,已设置密码和手机号,只缺邮箱 + +```json +{ + "success": true, + "data": { + "completeness": { + "hasPassword": true, + "hasPhone": true, + "hasEmail": false, + "isWechatUser": true + }, + "completenessPercentage": 66, + "needsAttention": true, + "missingItems": ["邮箱"], + "isComplete": false, + "showReminder": true + } +} +``` + +--- + +## 错误响应 + +### 401 Unauthorized - 未登录 + +**场景**:用户未登录或 Session 已过期 + +```json +{ + "success": false, + "error": "用户未登录" +} +``` + +**HTTP 状态码**:`401` + +### 500 Internal Server Error - 服务器错误 + +**场景**:服务器内部错误(如数据库连接失败) + +```json +{ + "success": false, + "error": "获取资料完整性错误: [错误详情]" +} +``` + +**HTTP 状态码**:`500` + +--- + +## 调用示例 + +### JavaScript (Fetch API) + +```javascript +async function checkProfileCompleteness() { + try { + const response = await fetch('/api/account/profile-completeness', { + method: 'GET', + credentials: 'include', // 重要:携带 Cookie + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + if (data.success) { + console.log('资料完整度:', data.data.completenessPercentage + '%'); + console.log('是否需要提醒:', data.data.needsAttention); + + if (data.data.needsAttention) { + console.log('缺失项:', data.data.missingItems.join('、')); + } + } + } catch (error) { + console.error('检查资料完整性失败:', error); + } +} +``` + +### cURL + +```bash +curl -X GET 'http://localhost:5001/api/account/profile-completeness' \ + -H 'Cookie: session=your_session_cookie_here' \ + -H 'Content-Type: application/json' +``` + +### Axios + +```javascript +import axios from 'axios'; + +async function checkProfileCompleteness() { + try { + const { data } = await axios.get('/api/account/profile-completeness', { + withCredentials: true // 携带 Cookie + }); + + if (data.success) { + return data.data; + } + } catch (error) { + if (error.response?.status === 401) { + console.error('用户未登录'); + } else { + console.error('检查失败:', error.message); + } + } +} +``` + +--- + +## 调用时机建议 + +### ✅ 推荐调用场景 + +1. **用户登录后**:首次登录或刷新页面后检查一次 +2. **资料更新后**:用户修改个人资料后重新检查 +3. **手动触发**:用户点击"检查资料完整度"按钮 + +### ❌ 避免的场景 + +1. **导航栏每次 render 时**:会导致频繁请求 +2. **组件重新渲染时**:应使用缓存或标志位避免重复 +3. **轮询调用**:此接口不需要轮询,用户资料变化频率低 + +### 最佳实践 + +```javascript +// 使用 React Hooks 的最佳实践 +function useProfileCompleteness() { + const [completeness, setCompleteness] = useState(null); + const hasChecked = useRef(false); + const { isAuthenticated, user } = useAuth(); + + const check = useCallback(async () => { + // 避免重复检查 + if (hasChecked.current) return; + + try { + const response = await fetch('/api/account/profile-completeness', { + credentials: 'include' + }); + + if (response.ok) { + const data = await response.json(); + if (data.success) { + setCompleteness(data.data); + hasChecked.current = true; // 标记已检查 + } + } + } catch (error) { + console.warn('检查失败:', error); + } + }, []); + + // 仅在登录后检查一次 + useEffect(() => { + if (isAuthenticated && user && !hasChecked.current) { + check(); + } + }, [isAuthenticated, user, check]); + + // 提供手动刷新方法 + const refresh = useCallback(() => { + hasChecked.current = false; + check(); + }, [check]); + + return { completeness, refresh }; +} +``` + +--- + +## Mock 模式说明 + +在 Mock 模式下(`REACT_APP_ENABLE_MOCK=true`),此接口由 MSW (Mock Service Worker) 拦截: + +### Mock 实现位置 + +- **Handler**: `src/mocks/handlers/account.js` +- **数据源**: `src/mocks/data/users.js` (getCurrentUser) + +### Mock 特点 + +1. **真实计算**:基于当前登录用户的实际数据计算完整度 +2. **状态同步**:与登录状态同步,登录后才返回真实用户数据 +3. **未登录返回 401**:模拟真实后端行为 +4. **延迟模拟**:300ms 网络延迟,模拟真实请求 + +### Mock 测试数据 + +| 测试账号 | 手机号 | 密码 | 邮箱 | 微信 | 完整度 | +|---------|--------|------|------|------|--------| +| 测试用户 | 13800138000 | ✅ | ❌ | ❌ | 66% | +| 投资达人 | 13900139000 | ✅ | ✅ | ✅ | 100% | + +--- + +## 前端集成示例 + +### 显示资料完整度横幅 + +```jsx +import { useProfileCompleteness } from './hooks/useProfileCompleteness'; + +function App() { + const { completeness } = useProfileCompleteness(); + + return ( + <> + {/* 资料完整度提醒横幅 */} + {completeness?.showReminder && ( + + + + 完善资料,享受更好服务 + + 您还需要设置:{completeness.missingItems.join('、')} + ({completeness.completenessPercentage}% 完成) + + + + + )} + + {/* 主要内容 */} + + + ); +} +``` + +--- + +## 版本历史 + +| 版本 | 日期 | 变更说明 | +|------|------|----------| +| v1.0 | 2024-10-17 | 初始版本,支持资料完整度检查和智能提醒 | + +--- + +## 相关接口 + +- `GET /api/auth/session` - 检查登录状态 +- `GET /api/account/profile` - 获取完整用户资料 +- `PUT /api/account/profile` - 更新用户资料 +- `POST /api/auth/logout` - 退出登录 + +--- + +## 技术支持 + +如有问题,请联系开发团队或查看: +- **Mock 配置指南**: [MOCK_GUIDE.md](./MOCK_GUIDE.md) +- **项目文档**: [CLAUDE.md](./CLAUDE.md) + +--- + +**文档生成日期**:2024-10-17 +**API 版本**:v1.0 +**Mock 支持**:✅ 已实现 diff --git a/docs/API_ENDPOINTS.md b/docs/API_ENDPOINTS.md new file mode 100644 index 00000000..632cbc54 --- /dev/null +++ b/docs/API_ENDPOINTS.md @@ -0,0 +1,415 @@ +# API 接口文档 + +本文档记录了项目中所有 API 接口的详细信息。 + +## 目录 +- [认证相关 API](#认证相关-api) +- [个人中心相关 API](#个人中心相关-api) +- [事件相关 API](#事件相关-api) +- [股票相关 API](#股票相关-api) +- [公司相关 API](#公司相关-api) +- [订阅/支付相关 API](#订阅支付相关-api) + +--- + +## 认证相关 API + +### POST /api/auth/send-verification-code +发送验证码到手机号或邮箱 + +**请求参数**: +```json +{ + "credential": "13800138000", // 手机号或邮箱 + "type": "phone", // 'phone' | 'email' + "purpose": "login" // 'login' | 'register' +} +``` + +**响应示例**: +```json +{ + "success": true, + "message": "验证码已发送到 13800138000", + "dev_code": "123456" // 仅开发环境返回 +} +``` + +**错误响应**: +```json +{ + "success": false, + "error": "发送验证码失败" +} +``` + +**Mock 数据**: ✅ `src/mocks/handlers/auth.js` 行 21-44 + +**涉及文件**: +- `src/components/Auth/AuthFormContent.js` 行 164-207 + +--- + +### POST /api/auth/login-with-code +使用验证码登录(支持自动注册新用户) + +**请求参数**: +```json +{ + "credential": "13800138000", + "verification_code": "123456", + "login_type": "phone" // 'phone' | 'email' +} +``` + +**响应示例**: +```json +{ + "success": true, + "message": "登录成功", + "isNewUser": false, + "user": { + "id": 1, + "phone": "13800138000", + "nickname": "用户昵称", + "email": null, + "avatar_url": "https://...", + "has_wechat": false + }, + "token": "mock_token_1_1234567890" +} +``` + +**错误响应**: +```json +{ + "success": false, + "error": "验证码错误" +} +``` + +**Mock 数据**: ✅ `src/mocks/handlers/auth.js` 行 47-115 + +**涉及文件**: +- `src/components/Auth/AuthFormContent.js` 行 252-327 + +**注意事项**: +- 后端需要支持自动注册新用户(当用户不存在时) +- 前端已添加 `.trim()` 防止空格问题 + +--- + +### GET /api/auth/session +检查当前登录状态 + +**响应示例**: +```json +{ + "success": true, + "isAuthenticated": true, + "user": { + "id": 1, + "phone": "13800138000", + "nickname": "用户昵称" + } +} +``` + +**Mock 数据**: ✅ `src/mocks/handlers/auth.js` 行 269-290 + +--- + +### POST /api/auth/logout +退出登录 + +**响应示例**: +```json +{ + "success": true, + "message": "退出成功" +} +``` + +**Mock 数据**: ✅ `src/mocks/handlers/auth.js` 行 317-329 + +--- + +## 个人中心相关 API + +### GET /api/account/watchlist +获取用户自选股列表 + +**响应示例**: +```json +{ + "success": true, + "data": [ + { + "id": 1, + "stock_code": "000001.SZ", + "stock_name": "平安银行", + "added_at": "2024-01-01T00:00:00Z" + } + ] +} +``` + +**Mock 数据**: ❌ 待创建 `src/mocks/handlers/account.js` + +**涉及文件**: +- `src/views/Dashboard/Center.js` 行 94 + +--- + +### GET /api/account/watchlist/realtime +获取自选股实时行情 + +**响应示例**: +```json +{ + "success": true, + "data": { + "000001.SZ": { + "price": 12.34, + "change": 0.56, + "change_percent": 4.76, + "volume": 123456789 + } + } +} +``` + +**Mock 数据**: ❌ 待创建 + +**涉及文件**: +- `src/views/Dashboard/Center.js` 行 133 + +--- + +### GET /api/account/events/following +获取用户关注的事件列表 + +**响应示例**: +```json +{ + "success": true, + "data": [ + { + "id": 1, + "title": "事件标题", + "followed_at": "2024-01-01T00:00:00Z" + } + ] +} +``` + +**Mock 数据**: ❌ 待创建 + +**涉及文件**: +- `src/views/Dashboard/Center.js` 行 95 + +--- + +### GET /api/account/events/comments +获取用户的事件评论 + +**响应示例**: +```json +{ + "success": true, + "data": [ + { + "id": 1, + "event_id": 123, + "content": "评论内容", + "created_at": "2024-01-01T00:00:00Z" + } + ] +} +``` + +**Mock 数据**: ❌ 待创建 + +**涉及文件**: +- `src/views/Dashboard/Center.js` 行 96 + +--- + +### GET /api/subscription/current +获取当前订阅信息 + +**响应示例**: +```json +{ + "success": true, + "data": { + "plan": "premium", + "expires_at": "2025-01-01T00:00:00Z", + "auto_renew": true + } +} +``` + +**Mock 数据**: ❌ 待创建 `src/mocks/handlers/subscription.js` + +**涉及文件**: +- `src/views/Dashboard/Center.js` 行 97 + +--- + +## 事件相关 API + +### GET /api/events +获取事件列表 + +**查询参数**: +- `page`: 页码(默认 1) +- `per_page`: 每页数量(默认 10) +- `sort`: 排序方式 ('new' | 'hot' | 'returns') +- `importance`: 重要性筛选 ('all' | 'high' | 'medium' | 'low') +- `date_range`: 日期范围 +- `q`: 搜索关键词 +- `industry_classification`: 行业分类 +- `industry_code`: 行业代码 + +**响应示例**: +```json +{ + "success": true, + "data": { + "events": [ + { + "id": 1, + "title": "事件标题", + "importance": "high", + "created_at": "2024-01-01T00:00:00Z" + } + ], + "pagination": { + "page": 1, + "per_page": 10, + "total": 100 + } + } +} +``` + +**Mock 数据**: ⚠️ 部分实现(需完善) + +**涉及文件**: +- `src/views/Community/index.js` 行 148 + +--- + +### GET /api/events/:id +获取事件详情 + +**响应示例**: +```json +{ + "success": true, + "data": { + "id": 1, + "title": "事件标题", + "content": "事件内容", + "importance": "high", + "created_at": "2024-01-01T00:00:00Z" + } +} +``` + +**Mock 数据**: ❌ 待创建 + +--- + +### GET /api/events/:id/stocks +获取事件相关股票 + +**响应示例**: +```json +{ + "success": true, + "data": [ + { + "stock_code": "000001.SZ", + "stock_name": "平安银行", + "correlation": 0.85 + } + ] +} +``` + +**Mock 数据**: ✅ `src/mocks/handlers/event.js` 行 12-38 + +--- + +### GET /api/events/popular-keywords +获取热门关键词 + +**查询参数**: +- `limit`: 返回数量(默认 20) + +**响应示例**: +```json +{ + "success": true, + "data": [ + { + "keyword": "人工智能", + "count": 123, + "trend": "up" + } + ] +} +``` + +**Mock 数据**: ❌ 待创建 + +**涉及文件**: +- `src/views/Community/index.js` 行 180 + +--- + +### GET /api/events/hot +获取热点事件 + +**查询参数**: +- `days`: 天数范围(默认 5) +- `limit`: 返回数量(默认 4) + +**响应示例**: +```json +{ + "success": true, + "data": [ + { + "id": 1, + "title": "热点事件标题", + "heat_score": 95.5 + } + ] +} +``` + +**Mock 数据**: ❌ 待创建 + +**涉及文件**: +- `src/views/Community/index.js` 行 192 + +--- + +## 待补充 API + +以下 API 将在重构其他文件时逐步添加: + +- 股票相关 API +- 公司相关 API +- 订阅/支付相关 API +- 用户资料相关 API +- 行业分类相关 API + +--- + +## 更新日志 + +- 2024-XX-XX: 创建文档,记录认证和个人中心相关 API diff --git a/docs/AUTHENTICATION_SYSTEM_GUIDE.md b/docs/AUTHENTICATION_SYSTEM_GUIDE.md new file mode 100644 index 00000000..c01738c4 --- /dev/null +++ b/docs/AUTHENTICATION_SYSTEM_GUIDE.md @@ -0,0 +1,1879 @@ +# 价值前沿认证系统完整文档 + +> **版本**: 2.0 +> **更新日期**: 2025-01-16 +> **作者**: Claude Code +> **适用范围**: 前端 React + 后端 Flask + +--- + +## 📖 目录 + +1. [系统架构概览](#1-系统架构概览) +2. [认证流程详解](#2-认证流程详解) + - [2.1 手机验证码登录](#21-手机验证码登录) + - [2.2 微信PC扫码登录](#22-微信pc扫码登录) + - [2.3 微信H5网页授权](#23-微信h5网页授权) +3. [路由配置与跳转逻辑](#3-路由配置与跳转逻辑) +4. [API接口文档](#4-api接口文档) +5. [前端组件架构](#5-前端组件架构) +6. [状态管理机制](#6-状态管理机制) +7. [Session持久化](#7-session持久化) +8. [错误处理策略](#8-错误处理策略) +9. [安全机制](#9-安全机制) +10. [调试指南](#10-调试指南) + +--- + +## 1. 系统架构概览 + +### 1.1 技术栈 + +**前端**: +- React 18.3.1 +- Chakra UI 2.8.2 +- React Router 6.x +- Context API (状态管理) + +**后端**: +- Flask (Python) +- Session-based Authentication +- HttpOnly Cookies +- Flask-Session + +**认证方式**: +1. **手机验证码登录** (短信验证码) +2. **微信PC扫码登录** (二维码扫码) +3. **微信H5网页授权** (移动端跳转授权) + +### 1.2 架构图 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 用户界面层 (UI) │ +│ ┌───────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ HomeNavbar │ │ AuthModal │ │ProtectedRoute│ │ +│ │ (登录按钮) │ │ (认证弹窗) │ │ (路由保护) │ │ +│ └───────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 状态管理层 (Context) │ +│ ┌──────────────────────┐ ┌───────────────────────┐ │ +│ │ AuthContext │ │ AuthModalContext │ │ +│ │ - user │ │ - isAuthModalOpen │ │ +│ │ - isAuthenticated │ │ - openAuthModal() │ │ +│ │ - checkSession() │ │ - handleLoginSuccess()│ │ +│ └──────────────────────┘ └───────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 业务逻辑层 (Components) │ +│ ┌──────────────────┐ ┌─────────────┐ ┌────────────┐ │ +│ │ AuthFormContent │ │WechatRegister│ │VerifyCodeInput│ │ +│ │ (认证表单) │ │ (微信扫码) │ │ (验证码输入)│ │ +│ └──────────────────┘ └─────────────┘ └────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 服务层 (API Service) │ +│ ┌──────────────┐ │ +│ │ authService │ │ +│ │ - getWechatQRCode() │ +│ │ - checkWechatStatus() │ +│ │ - loginWithWechat() │ +│ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 后端 API (Flask) │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ /api/auth/session - Session检查 │ │ +│ │ /api/auth/send-verification-code - 发送验证码 │ │ +│ │ /api/auth/login-with-code - 验证码登录 │ │ +│ │ /api/auth/wechat/qrcode - 获取微信二维码 │ │ +│ │ /api/auth/wechat/check - 检查扫码状态 │ │ +│ │ /api/auth/login/wechat - 微信登录 │ │ +│ │ /api/auth/wechat/h5-auth - 微信H5授权 │ │ +│ │ /api/auth/wechat/h5-callback - 微信H5回调 │ │ +│ │ /api/auth/logout - 退出登录 │ │ +│ └─────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 数据存储层 (Database) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ User表 │ │ Session存储 │ │ 验证码缓存 │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. 认证流程详解 + +### 2.1 手机验证码登录 + +**适用场景**: PC端和移动端通用登录方式 + +#### 流程图 + +``` +用户点击"登录/注册"按钮 + ↓ +打开 AuthModal 弹窗 + ↓ +输入手机号 → 点击"获取验证码" + ↓ +前端: AuthFormContent.sendVerificationCode() + ↓ +POST /api/auth/send-verification-code + Body: { + credential: "13800138000", + type: "phone", + purpose: "login" + } + ↓ +后端: 生成6位验证码 → 发送短信 → 存入Session/Redis (5分钟有效期) + ↓ +返回: { success: true } + ↓ +前端显示: "验证码已发送" + 60秒倒计时 + ↓ +用户输入验证码 → 点击"登录/注册" + ↓ +前端: AuthFormContent.handleSubmit() + ↓ +POST /api/auth/login-with-code + Body: { + credential: "13800138000", + verification_code: "123456", + login_type: "phone" + } + ↓ +后端验证: + 1. 验证码是否存在 ✓ + 2. 验证码是否过期 ✓ + 3. 验证码是否匹配 ✓ + 4. 查询用户是否存在 + - 存在: 登录 (isNewUser: false) + - 不存在: 自动注册 (isNewUser: true) + ↓ +设置 Session Cookie (HttpOnly) + ↓ +返回: { + success: true, + isNewUser: true/false, + user: { id, phone, nickname, ... } +} + ↓ +前端: + 1. checkSession() 更新全局状态 + 2. 显示成功提示 + 3. 如果 isNewUser=true → 显示昵称设置引导 + 4. 关闭弹窗,留在当前页面 + ↓ +登录完成 ✅ +``` + +#### 关键代码位置 + +**前端**: +- `src/components/Auth/AuthFormContent.js:130` - 发送验证码 +- `src/components/Auth/AuthFormContent.js:207` - 提交登录 +- `src/components/Auth/VerificationCodeInput.js` - 验证码输入组件 + +**后端**: +- `app.py:1826` - POST /api/auth/send-verification-code +- `app.py:1884` - POST /api/auth/login-with-code + +--- + +### 2.2 微信PC扫码登录 + +**适用场景**: PC端桌面浏览器 + +#### 流程图 + +``` +用户打开登录弹窗 (桌面端) + ↓ +右侧显示微信二维码区域 (WechatRegister组件) + ↓ +初始状态: 灰色二维码图标 + "获取二维码"按钮 + ↓ +用户点击"获取二维码" + ↓ +前端: WechatRegister.getWechatQRCode() + ↓ +GET /api/auth/wechat/qrcode + ↓ +后端: + 1. 生成唯一 session_id (UUID) + 2. 构建微信开放平台授权URL + 3. 存储到临时状态 (5分钟有效期) + ↓ +返回: { + code: 0, + data: { + auth_url: "https://open.weixin.qq.com/connect/qrconnect?...", + session_id: "uuid-xxxxx" + } +} + ↓ +前端: + 1. 在 iframe 中显示微信二维码 + 2. 启动轮询: 每2秒检查扫码状态 + 3. 启动备用轮询: 每3秒检查 (防止丢失) + 4. 设置超时: 5分钟后二维码过期 + ↓ +【轮询检查】 +POST /api/auth/wechat/check + Body: { session_id: "uuid-xxxxx" } + ↓ +后端返回状态: + - waiting: 等待扫码 + - scanned: 已扫码,等待确认 + - authorized: 已授权 + - login_success: 登录成功 (老用户) + - register_success: 注册成功 (新用户) + - expired: 二维码过期 + ↓ +如果状态 = login_success / register_success: + ↓ +前端: WechatRegister.handleLoginSuccess() + ↓ +POST /api/auth/login/wechat + Body: { session_id: "uuid-xxxxx" } + ↓ +后端: + 1. 从临时状态获取微信用户信息 + 2. 查询数据库是否存在该微信用户 + - 存在: 登录 + - 不存在: 自动注册 + 3. 设置 Session Cookie + ↓ +返回: { + success: true, + user: { id, nickname, avatar_url, ... }, + token: "optional-token" +} + ↓ +前端: + 1. 停止轮询 + 2. checkSession() 更新状态 + 3. 显示成功提示 + 4. 1秒后跳转 /home + ↓ +登录完成 ✅ +``` + +#### 关键代码位置 + +**前端**: +- `src/components/Auth/WechatRegister.js:199` - 获取二维码 +- `src/components/Auth/WechatRegister.js:120` - 检查扫码状态 +- `src/components/Auth/WechatRegister.js:85` - 登录成功处理 +- `src/services/authService.js:69` - API服务 + +**后端**: +- `app.py:2487` - GET /api/auth/wechat/qrcode +- `app.py:2560` - POST /api/auth/wechat/check +- `app.py:2743` - POST /api/auth/login/wechat + +--- + +### 2.3 微信H5网页授权 + +**适用场景**: 移动端浏览器中打开 + +#### 流程图 + +``` +移动端用户点击验证码输入框下方的微信图标 + ↓ +前端: AuthFormContent.handleWechatH5Login() + ↓ +构建回调URL: https://yourdomain.com/home/wechat-callback + ↓ +POST /api/auth/wechat/h5-auth + Body: { redirect_url: "https://..." } + ↓ +后端: + 1. 构建微信网页授权URL (snsapi_userinfo) + 2. 生成 state 参数防止CSRF + ↓ +返回: { + auth_url: "https://open.weixin.qq.com/connect/oauth2/authorize?..." +} + ↓ +前端: 延迟500ms后跳转到微信授权页面 + ↓ +window.location.href = auth_url + ↓ +【用户在微信授权页面确认】 + ↓ +微信回调: https://yourdomain.com/home/wechat-callback?code=xxx&state=yyy + ↓ +前端: WechatCallback 组件接收回调 + ↓ +POST /api/auth/wechat/h5-callback + Body: { code: "xxx", state: "yyy" } + ↓ +后端: + 1. 验证 state 参数 + 2. 使用 code 换取 access_token + 3. 使用 access_token 获取微信用户信息 + 4. 查询数据库 + - 存在: 登录 + - 不存在: 自动注册 + 5. 设置 Session Cookie + ↓ +返回: { + success: true, + user: { ... }, + token: "optional" +} + ↓ +前端: + 1. 存储 token (可选) + 2. checkSession() 更新状态 + 3. 显示"登录成功" + 4. 1.5秒后跳转 /home + ↓ +登录完成 ✅ +``` + +#### 关键代码位置 + +**前端**: +- `src/components/Auth/AuthFormContent.js:308` - 发起H5授权 +- `src/views/Pages/WechatCallback.js:34` - 处理回调 +- `src/services/authService.js:78` - H5授权API +- `src/services/authService.js:91` - H5回调API + +**后端**: +- `app.py:2487+` - POST /api/auth/wechat/h5-auth (需确认) +- `app.py:2610+` - POST /api/auth/wechat/h5-callback (需确认) + +--- + +## 3. 路由配置与跳转逻辑 + +### 3.1 路由结构 + +```javascript +// src/App.js + + + {/* 公开路由 - 无需登录 */} + } /> + } /> + + {/* 受保护路由 - 需要登录 */} + } /> + + + +// src/layouts/Home.js (公开路由) + + } /> + } /> + } /> + } /> + + {/* 需要登录的页面 */} + + + + } /> + + + + } /> + +``` + +### 3.2 路由保护机制 + +**ProtectedRoute 组件** (`src/components/ProtectedRoute.js`) + +```javascript +const ProtectedRoute = ({ children }) => { + const { isAuthenticated, isLoading, user } = useAuth(); + const { openAuthModal, isAuthModalOpen } = useAuthModal(); + + // 未登录时自动弹出认证窗口 + useEffect(() => { + if (!isLoading && !isAuthenticated && !user && !isAuthModalOpen) { + openAuthModal(currentPath); // 记录当前路径 + } + }, [isAuthenticated, user, isLoading]); + + // 加载中: 显示 Spinner + if (isLoading) { + return ; + } + + // 未登录: 显示页面 + 自动打开弹窗 (非阻断式) + // 已登录: 正常显示页面 + return children; +}; +``` + +**特点**: +- ✅ 非阻断式保护 (弹窗而非重定向) +- ✅ 记录原始路径 (登录后可返回) +- ✅ 避免白屏 (先显示页面骨架) + +### 3.3 跳转逻辑总结 + +| 场景 | 触发位置 | 跳转目标 | 说明 | +|------|---------|---------|------| +| 未登录访问受保护页面 | ProtectedRoute | 留在当前页 + 弹窗 | 非阻断式 | +| 点击导航栏登录按钮 | HomeNavbar:740 | 打开 AuthModal | 弹窗形式 | +| 手机验证码登录成功 | AuthFormContent:284 | 留在当前页 | 关闭弹窗即可 | +| 微信扫码登录成功 | WechatRegister:106 | 跳转 /home | 1秒延迟 | +| 微信H5授权成功 | WechatCallback:69 | 跳转 /home | 1.5秒延迟 | +| 用户点击退出登录 | HomeNavbar:227 | 跳转 /home | 清除Session | +| 关闭弹窗未登录 | AuthModalContext:58 | 跳转 /home | 防止停留受保护页 | + +--- + +## 4. API接口文档 + +### 4.1 Session检查 + +**接口**: `GET /api/auth/session` + +**用途**: 检查当前用户登录状态 + +**请求**: +```http +GET /api/auth/session HTTP/1.1 +Cookie: session=xxx +``` + +**响应**: +```json +{ + "isAuthenticated": true, + "user": { + "id": 123, + "phone": "13800138000", + "nickname": "价小前用户", + "email": "user@example.com", + "avatar_url": "https://...", + "has_wechat": true + } +} +``` + +**前端调用**: +```javascript +// src/contexts/AuthContext.js:85 +const checkSession = async () => { + const response = await fetch('/api/auth/session', { + credentials: 'include' + }); + const data = await response.json(); + if (data.isAuthenticated) { + setUser(data.user); + setIsAuthenticated(true); + } +}; +``` + +--- + +### 4.2 发送验证码 + +**接口**: `POST /api/auth/send-verification-code` + +**用途**: 发送手机验证码 + +**请求**: +```http +POST /api/auth/send-verification-code HTTP/1.1 +Content-Type: application/json + +{ + "credential": "13800138000", + "type": "phone", + "purpose": "login" +} +``` + +**响应**: +```json +{ + "success": true, + "message": "验证码已发送" +} +``` + +**错误响应**: +```json +{ + "success": false, + "error": "手机号格式不正确" +} +``` + +**前端调用**: +```javascript +// src/components/Auth/AuthFormContent.js:153 +const response = await fetch('/api/auth/send-verification-code', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + credential: phone, + type: 'phone', + purpose: 'login' + }) +}); +``` + +**限流规则**: +- 同一手机号: 60秒内只能发送1次 +- 验证码有效期: 5分钟 + +--- + +### 4.3 验证码登录 + +**接口**: `POST /api/auth/login-with-code` + +**用途**: 使用验证码登录/注册 + +**请求**: +```http +POST /api/auth/login-with-code HTTP/1.1 +Content-Type: application/json + +{ + "credential": "13800138000", + "verification_code": "123456", + "login_type": "phone" +} +``` + +**响应 (登录成功)**: +```json +{ + "success": true, + "isNewUser": false, + "user": { + "id": 123, + "phone": "13800138000", + "nickname": "价小前用户" + } +} +``` + +**响应 (注册成功)**: +```json +{ + "success": true, + "isNewUser": true, + "user": { + "id": 124, + "phone": "13900139000", + "nickname": "用户13900139000" + } +} +``` + +**错误响应**: +```json +{ + "success": false, + "error": "验证码已过期或不存在" +} +``` + +**自动注册逻辑**: +- 如果手机号不存在 → 自动创建新用户 +- 默认昵称: `用户{手机号}` +- isNewUser 标记用于前端引导设置昵称 + +--- + +### 4.4 获取微信二维码 + +**接口**: `GET /api/auth/wechat/qrcode` + +**用途**: 获取微信PC扫码登录二维码 + +**请求**: +```http +GET /api/auth/wechat/qrcode HTTP/1.1 +``` + +**响应**: +```json +{ + "code": 0, + "data": { + "auth_url": "https://open.weixin.qq.com/connect/qrconnect?appid=xxx&redirect_uri=xxx&response_type=code&scope=snsapi_login&state=yyy#wechat_redirect", + "session_id": "550e8400-e29b-41d4-a716-446655440000" + }, + "message": "success" +} +``` + +**前端调用**: +```javascript +// src/services/authService.js:69 +getWechatQRCode: async () => { + return await apiRequest('/api/auth/wechat/qrcode'); +} +``` + +**二维码有效期**: 5分钟 + +--- + +### 4.5 检查微信扫码状态 + +**接口**: `POST /api/auth/wechat/check` + +**用途**: 轮询检查微信扫码状态 + +**请求**: +```http +POST /api/auth/wechat/check HTTP/1.1 +Content-Type: application/json + +{ + "session_id": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +**响应状态**: + +| status | 说明 | 前端行为 | +|--------|------|---------| +| waiting | 等待扫码 | 继续轮询 | +| scanned | 已扫码,等待确认 | 显示提示 + 继续轮询 | +| authorized | 已授权 | 继续轮询 | +| login_success | 登录成功 (老用户) | 停止轮询 + 调用登录接口 | +| register_success | 注册成功 (新用户) | 停止轮询 + 调用登录接口 | +| expired | 二维码过期 | 停止轮询 + 显示刷新按钮 | + +**响应示例**: +```json +{ + "status": "login_success", + "user_info": { + "openid": "xxx", + "nickname": "微信用户", + "avatar_url": "https://..." + } +} +``` + +**前端轮询**: +```javascript +// src/components/Auth/WechatRegister.js:180 +pollIntervalRef.current = setInterval(() => { + checkWechatStatus(); +}, 2000); // 每2秒检查一次 +``` + +--- + +### 4.6 微信登录 + +**接口**: `POST /api/auth/login/wechat` + +**用途**: 使用微信 session_id 完成登录 + +**请求**: +```http +POST /api/auth/login/wechat HTTP/1.1 +Content-Type: application/json + +{ + "session_id": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +**响应**: +```json +{ + "success": true, + "user": { + "id": 123, + "nickname": "微信用户", + "avatar_url": "https://...", + "has_wechat": true + }, + "token": "optional-token" +} +``` + +**前端调用**: +```javascript +// src/components/Auth/WechatRegister.js:87 +const response = await authService.loginWithWechat(sessionId); +if (response?.success) { + // 存储用户信息 + if (response.user) { + localStorage.setItem('user', JSON.stringify(response.user)); + } + // 跳转首页 + navigate('/home'); +} +``` + +--- + +### 4.7 微信H5授权 + +**接口**: `POST /api/auth/wechat/h5-auth` + +**用途**: 获取微信H5网页授权链接 + +**请求**: +```http +POST /api/auth/wechat/h5-auth HTTP/1.1 +Content-Type: application/json + +{ + "redirect_url": "https://yourdomain.com/home/wechat-callback" +} +``` + +**响应**: +```json +{ + "auth_url": "https://open.weixin.qq.com/connect/oauth2/authorize?appid=xxx&redirect_uri=xxx&response_type=code&scope=snsapi_userinfo&state=yyy#wechat_redirect" +} +``` + +**前端调用**: +```javascript +// src/components/Auth/AuthFormContent.js:323 +const response = await authService.getWechatH5AuthUrl(redirectUrl); +if (response?.auth_url) { + window.location.href = response.auth_url; +} +``` + +--- + +### 4.8 微信H5回调 + +**接口**: `POST /api/auth/wechat/h5-callback` + +**用途**: 处理微信H5授权回调 + +**请求**: +```http +POST /api/auth/wechat/h5-callback HTTP/1.1 +Content-Type: application/json + +{ + "code": "wechat-code-xxx", + "state": "csrf-state-yyy" +} +``` + +**响应**: +```json +{ + "success": true, + "user": { + "id": 123, + "nickname": "微信用户", + "avatar_url": "https://..." + }, + "token": "optional-token" +} +``` + +**前端调用**: +```javascript +// src/views/Pages/WechatCallback.js:46 +const response = await authService.handleWechatH5Callback(code, state); +if (response?.success) { + await checkSession(); + navigate('/home'); +} +``` + +--- + +### 4.9 退出登录 + +**接口**: `POST /api/auth/logout` + +**用途**: 清除用户Session + +**请求**: +```http +POST /api/auth/logout HTTP/1.1 +Cookie: session=xxx +``` + +**响应**: +```json +{ + "success": true, + "message": "已退出登录" +} +``` + +**前端调用**: +```javascript +// src/contexts/AuthContext.js:120 +const logout = async () => { + await fetch('/api/auth/logout', { + method: 'POST', + credentials: 'include' + }); + setUser(null); + setIsAuthenticated(false); + navigate('/home'); +}; +``` + +--- + +## 5. 前端组件架构 + +### 5.1 核心组件关系图 + +``` +App.js (根组件) + └── AuthProvider (全局认证状态) + └── AuthModalProvider (弹窗管理) + ├── HomeNavbar (导航栏) + │ └── Button onClick={openAuthModal} (登录按钮) + │ + ├── AuthModalManager (弹窗管理器) + │ └── Modal + │ └── AuthFormContent (认证表单) + │ ├── VerificationCodeInput (验证码输入) + │ └── WechatRegister (微信二维码) + │ + └── ProtectedRoute (路由保护) + └── 受保护的页面组件 +``` + +### 5.2 组件详解 + +#### AuthContext (`src/contexts/AuthContext.js`) + +**职责**: 全局认证状态管理 + +**状态**: +```javascript +{ + user: { + id: 123, + phone: "13800138000", + nickname: "价小前用户", + email: "user@example.com", + avatar_url: "https://...", + has_wechat: true + }, + isAuthenticated: true, + isLoading: false +} +``` + +**方法**: +- `checkSession()` - 检查当前登录状态 +- `logout()` - 退出登录 + +**使用**: +```javascript +import { useAuth } from '../../contexts/AuthContext'; + +const { user, isAuthenticated, isLoading, checkSession, logout } = useAuth(); +``` + +--- + +#### AuthModalContext (`src/contexts/AuthModalContext.js`) + +**职责**: 认证弹窗状态管理 + +**状态**: +```javascript +{ + isAuthModalOpen: false, + redirectUrl: null, // 登录成功后跳转的URL (可选) +} +``` + +**方法**: +- `openAuthModal(url, callback)` - 打开认证弹窗 +- `closeModal()` - 关闭弹窗 (未登录则跳转首页) +- `handleLoginSuccess(user)` - 登录成功处理 + +**使用**: +```javascript +import { useAuthModal } from '../../contexts/AuthModalContext'; + +const { isAuthModalOpen, openAuthModal, handleLoginSuccess } = useAuthModal(); + +// 打开弹窗 +openAuthModal(); + +// 登录成功 +handleLoginSuccess(userData); +``` + +--- + +#### AuthModalManager (`src/components/Auth/AuthModalManager.js`) + +**职责**: 渲染认证弹窗 UI + +**特点**: +- 响应式尺寸 (移动端 md, 桌面端 xl) +- 条件渲染 (仅在打开时渲染,避免不必要的Portal) +- 半透明背景 + 模糊效果 +- 禁止点击背景关闭 (防止误操作) + +**响应式配置**: +```javascript +const modalSize = useBreakpointValue({ + base: "md", // 移动端 + md: "lg", // 中屏 + lg: "xl" // 大屏 +}); + +const modalMaxW = useBreakpointValue({ + base: "90%", // 移动端占90%宽度 + md: "700px" // 桌面端固定700px +}); +``` + +--- + +#### AuthFormContent (`src/components/Auth/AuthFormContent.js`) + +**职责**: 认证表单主体内容 + +**功能**: +1. 手机号输入 +2. 验证码输入 + 发送验证码 +3. 登录/注册按钮 +4. 微信扫码 (桌面端右侧) +5. 微信H5登录 (移动端验证码下方图标) +6. 隐私协议链接 + +**布局**: +``` +桌面端 (Desktop): +┌─────────────────────────────────────┐ +│ 价值前沿 - 开启您的投资之旅 │ +├──────────────────┬──────────────────┤ +│ 登陆/注册 │ 微信扫码 │ +│ │ │ +│ 手机号输入框 │ [二维码区域] │ +│ 验证码输入框 │ │ +│ [获取验证码] │ 请使用微信扫码 │ +│ │ │ +│ [登录/注册按钮] │ │ +│ │ │ +│ 《隐私政策》 │ │ +└──────────────────┴──────────────────┘ + +移动端 (Mobile): +┌─────────────────────┐ +│ 价值前沿 │ +│ 开启您的投资之旅 │ +├─────────────────────┤ +│ 登陆/注册 │ +│ │ +│ 手机号输入框 │ +│ │ +│ 验证码输入框 │ +│ [获取验证码] │ +│ 其他登录方式: [微信] │ +│ │ +│ [登录/注册按钮] │ +│ │ +│ 《隐私政策》 │ +└─────────────────────┘ +``` + +**关键逻辑**: +```javascript +// 发送验证码 (60秒倒计时) +const sendVerificationCode = async () => { + const response = await fetch('/api/auth/send-verification-code', { + method: 'POST', + credentials: 'include', + body: JSON.stringify({ + credential: phone, + type: 'phone', + purpose: 'login' + }) + }); + + if (response.ok) { + setCountdown(60); // 启动60秒倒计时 + } +}; + +// 提交登录 +const handleSubmit = async (e) => { + e.preventDefault(); + + const response = await fetch('/api/auth/login-with-code', { + method: 'POST', + credentials: 'include', + body: JSON.stringify({ + credential: phone, + verification_code: verificationCode, + login_type: 'phone' + }) + }); + + if (response.ok) { + await checkSession(); // 更新全局状态 + + if (data.isNewUser) { + // 新用户引导设置昵称 + setShowNicknamePrompt(true); + } else { + handleLoginSuccess(); // 关闭弹窗 + } + } +}; +``` + +--- + +#### WechatRegister (`src/components/Auth/WechatRegister.js`) + +**职责**: 微信PC扫码登录组件 + +**状态机**: +``` +NONE (初始) → 点击"获取二维码" + ↓ + WAITING (显示二维码 + 轮询) + ↓ + SCANNED (提示"请在手机上确认") + ↓ + LOGIN_SUCCESS / REGISTER_SUCCESS + ↓ + 调用登录接口 → 跳转首页 + + EXPIRED (二维码过期 → 显示"点击刷新") +``` + +**轮询机制**: +```javascript +// 主轮询: 每2秒检查 +pollIntervalRef.current = setInterval(() => { + checkWechatStatus(); +}, 2000); + +// 备用轮询: 每3秒检查 (防止主轮询失败) +backupPollIntervalRef.current = setInterval(() => { + checkWechatStatus().catch(error => { + console.warn('备用轮询检查失败(静默处理):', error); + }); +}, 3000); + +// 超时机制: 5分钟后停止轮询 +timeoutRef.current = setTimeout(() => { + clearTimers(); + setWechatStatus(WECHAT_STATUS.EXPIRED); +}, 300000); +``` + +**二维码缩放**: +```javascript +// 使用 ResizeObserver 监听容器尺寸变化 +const calculateScale = () => { + const { width, height } = containerRef.current.getBoundingClientRect(); + + const scaleX = width / 300; // 二维码原始宽度300px + const scaleY = height / 350; // 二维码原始高度350px + const newScale = Math.min(scaleX, scaleY, 1.0); + + setScale(Math.max(newScale, 0.3)); // 最小0.3,最大1.0 +}; + +// iframe 缩放 +