Compare commits

...

20 Commits

Author SHA1 Message Date
zdl
7ae4bc418f feat: 提取交易日期 2025-11-02 16:41:55 +08:00
zdl
0110dc2fdc feat: 添加滚动组件 2025-11-02 16:41:21 +08:00
zdl
e7e2b3bb11 feat: 提交迷你分时图组件 2025-11-02 16:38:44 +08:00
zdl
e22a39c5cd feat: 提交历史事件对比组件 2025-11-02 16:37:46 +08:00
zdl
3b8b749eb1 feat: 添加相关股票模块 2025-11-01 12:19:47 +08:00
zdl
571d5e68bc feat:删除不必要组件 2025-10-31 20:12:05 +08:00
zdl
933932b86d feat:添加mock数据 2025-10-31 20:11:50 +08:00
zdl
fc251ede05 feat: 添加相关概念组件 2025-10-31 20:08:53 +08:00
zdl
57c4c3c959 feat: 添加可折叠模块标题组件 2025-10-31 18:15:39 +08:00
zdl
e1e82555bf feat: 事件滑动面板添加 详情面板 2025-10-31 18:14:05 +08:00
zdl
b44a0ccd39 feat: 添加事件描述组件 2025-10-31 17:50:23 +08:00
zdl
2d936ca1c7 feat: UI调整 2025-10-31 16:29:11 +08:00
zdl
14db374820 style: 优化事件详情和涨跌幅指标的视觉效果
EventHeaderInfo 组件优化:
- "重要性:高"背景色改为浅杏黄色(yellow.100 → orange.50)
- 文字颜色改为深杏色(yellow.700 → orange.800)
- 视觉效果更柔和优雅,不刺眼

StockChangeIndicators 组件优化:
- 改用多颜色梯度(5级分级)
- 上涨:红色系(red.900/700/500)→ 橙色系(orange.600/400)
- 下跌:绿色系(green.900/700/500)→ 青色系(teal.600/400)
- 背景色和边框色跟随数字颜色
- 移除调试 console.log

视觉改进:
- 颜色分级更细腻,从3级增加到5级
- 引入橙色和青色让小幅和大幅波动有明显色系区别
- 5.7% 显示为深红色,1.7% 显示为橙色,视觉区分明显

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 16:00:37 +08:00
zdl
db472620f3 feat: 添加事件详情头部 2025-10-31 15:33:22 +08:00
zdl
37d98203a3 fix: 优化概念中心时间轴弹窗关闭行为,使用条件渲染
问题描述:
- 点击关闭按钮后,弹窗未完全关闭
- 可能存在 DOM 残留或状态问题

优化方案:
- 使用条件渲染替代 isOpen 属性控制
- 当状态为 false 时,Modal 组件完全从 DOM 中卸载
- 确保每次打开都是全新的状态

修改内容:
1. 主时间轴 Modal:添加 {isOpen && <Modal>...</Modal>} 条件渲染
2. 研报详情 Modal:添加 {isReportModalOpen && <Modal>...</Modal>} 条件渲染
3. 新闻详情 Modal:添加 {isNewsModalOpen && <Modal>...</Modal>} 条件渲染

优化效果:
- 弹窗关闭后组件完全卸载,避免残留
- 减少不必要的 DOM 节点,提升性能
- 每次打开都是全新的组件实例

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 15:05:15 +08:00
zdl
2420ff45a4 feat:暂时注释掉市场复盘 2025-10-31 15:01:53 +08:00
zdl
adaebbf800 fix: 修复概念中心历史时间轴"查看详情"按钮无响应问题
问题描述:
- 在历史时间轴弹窗中,点击新闻或研报的"查看详情"按钮无响应
- 导致用户无法查看新闻/研报的详细内容

问题根因:
- 在 onClick 事件处理函数中使用了未定义的变量 `date`
- 应该使用循环中的 `item.date` 变量
- 未定义的变量导致追踪函数报错,阻止了后续代码执行
- Modal 无法正常打开

修复内容:
- 第750行:trackNewsClicked(event, date) → trackNewsClicked(event, item.date)
- 第763行:trackReportClicked(event, date) → trackReportClicked(event, item.date)

影响范围:
- 概念中心历史时间轴功能
- 新闻和研报详情查看功能

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 14:51:53 +08:00
zdl
9fd9fcb731 feat: 添加事件详情面板 2025-10-31 14:38:43 +08:00
zdl
c372832f1f feat: 新增实时要闻·动态追踪与市场复盘功能,优化导航体验
新增功能:
- 实时要闻·动态追踪横向滚动卡片(DynamicNewsCard)
- 动态新闻事件卡片组件(DynamicNewsEventCard)
- 市场复盘卡片组件(MarketReviewCard)
- 股票涨跌幅指标组件(StockChangeIndicators)
- 交易时间工具函数(tradingTimeUtils)
- Mock API 支持动态新闻数据生成

UI 优化:
- EventFollowButton 改用 react-icons 星星图标,实现真正的空心/实心效果
- 关注按钮添加半透明白色背景(whiteAlpha.500),悬停效果更明显
- 事件卡片标题添加右侧留白,防止关注按钮遮挡文字

性能优化:
- 禁用 Router v7_startTransition 特性,解决路由切换延迟 2 秒问题
- 调整导航菜单点击顺序(先跳转后关闭),提升响应速度

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 14:11:03 +08:00
zdl
5d8ad5e442 feat: bugfix 2025-10-31 10:33:53 +08:00
62 changed files with 3547 additions and 3703 deletions

View File

@@ -132,7 +132,6 @@
"prettier": "2.2.1",
"react-error-overlay": "6.0.9",
"sharp": "^0.34.4",
"tailwindcss": "^3.4.17",
"ts-node": "^10.9.2",
"webpack-bundle-analyzer": "^4.10.2",
"yn": "^5.1.0"

View File

@@ -69,12 +69,13 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
>
高频跟踪
</MenuButton>
<MenuList minW="260px" p={2} onMouseEnter={onHighFreqOpen} onMouseLeave={onHighFreqClose}>
<MenuList minW="260px" p={2} onMouseEnter={onHighFreqOpen}>
<MenuItem
onClick={() => {
// 🎯 追踪菜单项点击
navEvents.trackMenuItemClicked('事件中心', 'dropdown', '/community');
navigate('/community');
onHighFreqClose(); // 跳转后关闭菜单
}}
borderRadius="md"
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
@@ -95,6 +96,7 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
// 🎯 追踪菜单项点击
navEvents.trackMenuItemClicked('概念中心', 'dropdown', '/concepts');
navigate('/concepts');
onHighFreqClose(); // 跳转后关闭菜单
}}
borderRadius="md"
bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'}
@@ -127,9 +129,12 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
>
行情复盘
</MenuButton>
<MenuList minW="260px" p={2} onMouseEnter={onMarketReviewOpen} onMouseLeave={onMarketReviewClose}>
<MenuList minW="260px" p={2} onMouseEnter={onMarketReviewOpen}>
<MenuItem
onClick={() => navigate('/limit-analyse')}
onClick={() => {
navigate('/limit-analyse');
onMarketReviewClose(); // 跳转后关闭菜单
}}
borderRadius="md"
bg={location.pathname.includes('/limit-analyse') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/limit-analyse') ? '3px solid' : 'none'}
@@ -142,7 +147,10 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
</Flex>
</MenuItem>
<MenuItem
onClick={() => navigate('/stocks')}
onClick={() => {
navigate('/stocks');
onMarketReviewClose(); // 跳转后关闭菜单
}}
borderRadius="md"
bg={location.pathname.includes('/stocks') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/stocks') ? '3px solid' : 'none'}
@@ -155,7 +163,10 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
</Flex>
</MenuItem>
<MenuItem
onClick={() => navigate('/trading-simulation')}
onClick={() => {
navigate('/trading-simulation');
onMarketReviewClose(); // 跳转后关闭菜单
}}
borderRadius="md"
bg={location.pathname.includes('/trading-simulation') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/trading-simulation') ? '3px solid' : 'none'}
@@ -181,7 +192,7 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
>
AGENT社群
</MenuButton>
<MenuList minW="300px" p={4} onMouseEnter={onAgentCommunityOpen} onMouseLeave={onAgentCommunityClose}>
<MenuList minW="300px" p={4} onMouseEnter={onAgentCommunityOpen}>
<MenuItem
isDisabled
cursor="not-allowed"
@@ -210,7 +221,7 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
>
联系我们
</MenuButton>
<MenuList minW="260px" p={4} onMouseEnter={onContactUsOpen} onMouseLeave={onContactUsClose}>
<MenuList minW="260px" p={4} onMouseEnter={onContactUsOpen}>
<Text fontSize="sm" color={contactTextColor}>敬请期待</Text>
</MenuList>
</Menu>

View File

@@ -52,11 +52,14 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
>
更多
</MenuButton>
<MenuList minW="300px" p={2} onMouseEnter={onOpen} onMouseLeave={onClose}>
<MenuList minW="300px" p={2} onMouseEnter={onOpen}>
{/* 高频跟踪组 */}
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">高频跟踪</Text>
<MenuItem
onClick={() => navigate('/community')}
onClick={() => {
onClose(); // 先关闭菜单
navigate('/community');
}}
borderRadius="md"
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
>
@@ -69,7 +72,10 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
</Flex>
</MenuItem>
<MenuItem
onClick={() => navigate('/concepts')}
onClick={() => {
onClose(); // 先关闭菜单
navigate('/concepts');
}}
borderRadius="md"
bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'}
>
@@ -84,7 +90,10 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
{/* 行情复盘组 */}
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">行情复盘</Text>
<MenuItem
onClick={() => navigate('/limit-analyse')}
onClick={() => {
onClose(); // 先关闭菜单
navigate('/limit-analyse');
}}
borderRadius="md"
bg={location.pathname.includes('/limit-analyse') ? 'blue.50' : 'transparent'}
>
@@ -94,7 +103,10 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
</Flex>
</MenuItem>
<MenuItem
onClick={() => navigate('/stocks')}
onClick={() => {
onClose(); // 先关闭菜单
navigate('/stocks');
}}
borderRadius="md"
bg={location.pathname.includes('/stocks') ? 'blue.50' : 'transparent'}
>
@@ -104,7 +116,10 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
</Flex>
</MenuItem>
<MenuItem
onClick={() => navigate('/trading-simulation')}
onClick={() => {
onClose(); // 先关闭菜单
navigate('/trading-simulation');
}}
borderRadius="md"
bg={location.pathname.includes('/trading-simulation') ? 'blue.50' : 'transparent'}
>

View File

@@ -57,7 +57,7 @@ const PersonalCenterMenu = memo(({ user, handleLogout }) => {
>
个人中心
</MenuButton>
<MenuList onMouseEnter={onOpen} onMouseLeave={onClose}>
<MenuList onMouseEnter={onOpen}>
{/* 用户信息区 */}
<Box px={3} py={2} borderBottom="1px" borderColor="gray.200">
<Text fontSize="sm" fontWeight="bold">{getDisplayName()}</Text>
@@ -71,24 +71,36 @@ const PersonalCenterMenu = memo(({ user, handleLogout }) => {
</Box>
{/* 前往个人中心 */}
<MenuItem icon={<FiHome />} onClick={() => navigate('/home/center')}>
<MenuItem icon={<FiHome />} onClick={() => {
onClose(); // 先关闭菜单
navigate('/home/center');
}}>
前往个人中心
</MenuItem>
<MenuDivider />
{/* 账户管理组 */}
<MenuItem icon={<FiUser />} onClick={() => navigate('/home/profile')}>
<MenuItem icon={<FiUser />} onClick={() => {
onClose(); // 先关闭菜单
navigate('/home/profile');
}}>
个人资料
</MenuItem>
<MenuItem icon={<FiSettings />} onClick={() => navigate('/home/settings')}>
<MenuItem icon={<FiSettings />} onClick={() => {
onClose(); // 先关闭菜单
navigate('/home/settings');
}}>
账户设置
</MenuItem>
<MenuDivider />
{/* 功能入口组 */}
<MenuItem icon={<FaCrown />} onClick={() => navigate('/home/pages/account/subscription')}>
<MenuItem icon={<FaCrown />} onClick={() => {
onClose(); // 先关闭菜单
navigate('/home/pages/account/subscription');
}}>
订阅管理
</MenuItem>

View File

@@ -0,0 +1,168 @@
// src/components/StockChangeIndicators.js
// 股票涨跌幅指标组件(通用)
import React from 'react';
import { Flex, Box, Text, useColorModeValue } from '@chakra-ui/react';
/**
* 股票涨跌幅指标组件3分天下布局
* @param {Object} props
* @param {number} props.avgChange - 平均涨跌幅
* @param {number} props.maxChange - 最大涨跌幅
* @param {number} props.weekChange - 周涨跌幅
*/
const StockChangeIndicators = ({
avgChange,
maxChange,
weekChange,
}) => {
// 根据涨跌幅获取数字颜色多颜色梯度5级分级
const getNumberColor = (value) => {
if (value == null) {
return useColorModeValue('gray.700', 'gray.400');
}
// 0值使用中性灰色
if (value === 0) {
return 'gray.700';
}
const absValue = Math.abs(value);
const isPositive = value > 0;
if (isPositive) {
// 上涨:红色系 → 橙色系
if (absValue >= 10) return 'red.900'; // 10%以上:最深红
if (absValue >= 5) return 'red.700'; // 5-10%:深红
if (absValue >= 3) return 'red.500'; // 3-5%:中红
if (absValue >= 1) return 'orange.600'; // 1-3%:橙色
return 'orange.400'; // 0-1%:浅橙
} else {
// 下跌:绿色系 → 青色系
if (absValue >= 10) return 'green.900'; // -10%以下:最深绿
if (absValue >= 5) return 'green.700'; // -10% ~ -5%:深绿
if (absValue >= 3) return 'green.500'; // -5% ~ -3%:中绿
if (absValue >= 1) return 'teal.600'; // -3% ~ -1%:青色
return 'teal.400'; // -1% ~ 0%:浅青
}
};
// 根据涨跌幅获取背景色(跟随数字颜色)
const getBgColor = (value) => {
if (value == null) {
return useColorModeValue('gray.100', 'gray.700');
}
// 0值使用中性灰色背景
if (value === 0) {
return useColorModeValue('gray.100', 'gray.700');
}
const absValue = Math.abs(value);
const isPositive = value > 0;
if (isPositive) {
// 上涨背景:红色系 → 橙色系
if (absValue >= 10) return useColorModeValue('red.50', 'red.900');
if (absValue >= 5) return useColorModeValue('red.50', 'red.900');
if (absValue >= 3) return useColorModeValue('red.50', 'red.900');
if (absValue >= 1) return useColorModeValue('orange.50', 'orange.900');
return useColorModeValue('orange.50', 'orange.900');
} else {
// 下跌背景:绿色系 → 青色系
if (absValue >= 10) return useColorModeValue('green.50', 'green.900');
if (absValue >= 5) return useColorModeValue('green.50', 'green.900');
if (absValue >= 3) return useColorModeValue('green.50', 'green.900');
if (absValue >= 1) return useColorModeValue('teal.50', 'teal.900');
return useColorModeValue('teal.50', 'teal.900');
}
};
// 根据涨跌幅获取边框色(跟随数字颜色)
const getBorderColor = (value) => {
if (value == null) {
return useColorModeValue('gray.300', 'gray.600');
}
// 0值使用中性灰色边框
if (value === 0) {
return useColorModeValue('gray.300', 'gray.600');
}
const absValue = Math.abs(value);
const isPositive = value > 0;
if (isPositive) {
// 上涨边框:红色系 → 橙色系
if (absValue >= 10) return useColorModeValue('red.200', 'red.700');
if (absValue >= 5) return useColorModeValue('red.200', 'red.700');
if (absValue >= 3) return useColorModeValue('red.200', 'red.700');
if (absValue >= 1) return useColorModeValue('orange.200', 'orange.700');
return useColorModeValue('orange.200', 'orange.700');
} else {
// 下跌边框:绿色系 → 青色系
if (absValue >= 10) return useColorModeValue('green.200', 'green.700');
if (absValue >= 5) return useColorModeValue('green.200', 'green.700');
if (absValue >= 3) return useColorModeValue('green.200', 'green.700');
if (absValue >= 1) return useColorModeValue('teal.200', 'teal.700');
return useColorModeValue('teal.200', 'teal.700');
}
};
// 渲染单个指标
const renderIndicator = (label, value) => {
if (value == null) return null;
const sign = value > 0 ? '+' : '';
// 0值显示为 "0",其他值显示一位小数
const numStr = value === 0 ? '0' : Math.abs(value).toFixed(1);
const numberColor = getNumberColor(value);
const bgColor = getBgColor(value);
const borderColor = getBorderColor(value);
const labelColor = useColorModeValue('gray.700', 'gray.400');
return (
<Box
bg={bgColor}
borderWidth="2px"
borderColor={borderColor}
borderRadius="md"
px={2}
py={1}
display="flex"
alignItems="center"
justifyContent="center"
>
<Text fontSize="xs" lineHeight="1.2">
<Text as="span" color={labelColor}>
{label}
</Text>
<Text as="span" color={labelColor}>
{sign}
</Text>
<Text as="span" fontWeight="bold" color={numberColor} fontSize="sm">
{value < 0 ? '-' : ''}{numStr}
</Text>
<Text as="span" color={labelColor}>
%
</Text>
</Text>
</Box>
);
};
// 如果没有任何数据,不渲染
if (avgChange == null && maxChange == null && weekChange == null) {
return null;
}
return (
<Flex width="100%" justify="space-between" align="center" gap={1}>
{renderIndicator('平均 ', avgChange)}
{renderIndicator('最大 ', maxChange)}
{renderIndicator('周涨 ', weekChange)}
</Flex>
);
};
export default StockChangeIndicators;

View File

@@ -25,7 +25,12 @@ async function startApp() {
// Render the app with Router wrapper
root.render(
<React.StrictMode>
<Router>
<Router
future={{
// v7_startTransition: true, // 禁用导致路由切换延迟2秒影响用户体验
v7_relativeSplatPath: true,
}}
>
<App />
</Router>
</React.StrictMode>

View File

@@ -1,67 +0,0 @@
// src/layouts/Auth.js
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Box } from '@chakra-ui/react';
import { useAuth } from '../contexts/AuthContext';
import ErrorBoundary from '../components/ErrorBoundary';
// 导入认证相关页面
import SignInIllustration from '../views/Authentication/SignIn/SignInIllustration';
import SignUpIllustration from '../views/Authentication/SignUp/SignUpIllustration';
// 认证路由组件 - 已登录用户不能访问登录页
const AuthRoute = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
// 加载中不做跳转
if (isLoading) {
return children;
}
// 已登录用户跳转到首页
if (isAuthenticated) {
// 检查是否有记录的重定向路径
const redirectPath = localStorage.getItem('redirectPath');
if (redirectPath && redirectPath !== '/auth/signin' && redirectPath !== '/auth/sign-up') {
localStorage.removeItem('redirectPath');
return <Navigate to={redirectPath} replace />;
}
return <Navigate to="/home" replace />;
}
return children;
};
export default function Auth() {
return (
<ErrorBoundary>
<Box minH="100vh">
<Routes>
{/* 登录页面 */}
<Route
path="/signin"
element={
<AuthRoute>
<SignInIllustration />
</AuthRoute>
}
/>
{/* 注册页面 */}
<Route
path="/sign-up"
element={
<AuthRoute>
<SignUpIllustration />
</AuthRoute>
}
/>
{/* 默认重定向到登录页 */}
<Route path="/" element={<Navigate to="/auth/signin" replace />} />
<Route path="*" element={<Navigate to="/auth/signin" replace />} />
</Routes>
</Box>
</ErrorBoundary>
);
}

View File

@@ -1,98 +0,0 @@
// src/layouts/Home.js
import React from "react";
import { Routes, Route } from "react-router-dom";
import { Box } from '@chakra-ui/react';
// 导航栏已由 MainLayout 提供,此处不再导入
// import HomeNavbar from "../components/Navbars/HomeNavbar";
// 导入页面组件
import HomePage from "views/Home/HomePage";
import ProfilePage from "views/Profile/ProfilePage";
import SettingsPage from "views/Settings/SettingsPage";
import CenterDashboard from "views/Dashboard/Center";
import Subscription from "views/Pages/Account/Subscription";
// 懒加载隐私政策、用户协议、微信回调和模拟交易页面
const PrivacyPolicy = React.lazy(() => import("views/Pages/PrivacyPolicy"));
const UserAgreement = React.lazy(() => import("views/Pages/UserAgreement"));
const WechatCallback = React.lazy(() => import("views/Pages/WechatCallback"));
const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
// 导入保护路由组件
import ProtectedRoute from "../components/ProtectedRoute";
export default function Home() {
return (
<Box minH="100vh">
{/* 导航栏已由 MainLayout 提供,此处不再渲染 */}
{/* 主要内容区域 */}
<Box>
<Routes>
{/* 首页默认路由 */}
<Route path="/" element={<HomePage />} />
<Route
path="/center"
element={
<ProtectedRoute>
<CenterDashboard />
</ProtectedRoute>
}
/>
{/* 需要登录保护的页面 */}
<Route
path="/profile"
element={
<ProtectedRoute>
<ProfilePage />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<SettingsPage />
</ProtectedRoute>
}
/>
{/* 订阅管理页面 */}
<Route
path="/pages/account/subscription"
element={
<ProtectedRoute>
<Subscription />
</ProtectedRoute>
}
/>
{/* 模拟盘交易页面 */}
<Route
path="/trading-simulation"
element={
<ProtectedRoute>
<TradingSimulation />
</ProtectedRoute>
}
/>
{/* 隐私政策页面 - 无需登录 */}
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
{/* 用户协议页面 - 无需登录 */}
<Route path="/user-agreement" element={<UserAgreement />} />
{/* 微信授权回调页面 - 无需登录 */}
<Route path="/wechat-callback" element={<WechatCallback />} />
{/* 其他可能的路由 */}
<Route path="*" element={<HomePage />} />
</Routes>
</Box>
</Box>
);
}

View File

@@ -1,13 +1,14 @@
// src/layouts/MainLayout.js
// 主布局组件 - 为所有带导航栏的页面提供统一布局
import React, { memo } from "react";
import { Outlet, useLocation } from "react-router-dom";
import React, { memo, Suspense } from "react";
import { Outlet } from "react-router-dom";
import { Box } from '@chakra-ui/react';
import HomeNavbar from "../components/Navbars/HomeNavbar";
import AppFooter from "./AppFooter";
import BackToTopButton from "./components/BackToTopButton";
import PageTransitionWrapper from "./components/PageTransitionWrapper";
import { ANIMATION_CONFIG, BACK_TO_TOP_CONFIG } from "./config/layoutConfig";
import ErrorBoundary from "../components/ErrorBoundary";
import PageLoader from "../components/Loading/PageLoader";
import { BACK_TO_TOP_CONFIG } from "./config/layoutConfig";
// ✅ P0 性能优化:缓存静态组件,避免路由切换时不必要的重新渲染
// HomeNavbar (1623行) 和 AppFooter 不依赖路由参数,使用 memo 可大幅减少渲染次数
@@ -20,38 +21,27 @@ const MemoizedAppFooter = memo(AppFooter);
* 使用 <Outlet /> 渲染子路由,确保导航栏只渲染一次
* 页面切换时只有 Outlet 内的内容会更新,导航栏保持不变
*
* 架构优化2024-10-30
* - ✅ P0: 组件拆分 - BackToTopButton 独立复用37行 → 独立文件)
* - ✅ P0: 组件拆分 - PageTransitionWrapper 封装复杂逻辑18行 → 独立文件)
* - ✅ P0: 性能优化 - 使用 memo 避免导航栏和页脚重新渲染(性能提升 50%+
* - ✅ P1: 性能优化 - 使用 RAF 节流滚动事件(性能提升 80%
* - ✅ P1: 错误隔离 - ErrorBoundary 包裹页面内容,确保导航栏可用
* - ✅ P2: 用户体验 - 页面过渡动画framer-motion
* - ✅ P2: 配置集中 - layoutConfig 统一管理配置常量
* - ✅ P3: 用户体验 - 返回顶部按钮(滚动 > 300px 显示)
*
* 代码优化成果:
* - 代码量115 行 → 42 行(减少 63%
* - 复杂度:内联组件 → 独立模块
* - 可维护性:配置分散 → 集中管理
* - 可复用性:耦合 → 解耦
* 架构优化:
* - ✅ 组件拆分 - BackToTopButton 独立复用
* - ✅ 性能优化 - 使用 memo 避免导航栏和页脚重新渲染
* - ✅ 错误隔离 - ErrorBoundary 包裹页面内容,确保导航栏可用
* - ✅ 懒加载支持 - Suspense 统一处理懒加载
* - ✅ 布局简化 - 直接内联容器逻辑,减少嵌套层级
*/
export default function MainLayout() {
const location = useLocation();
return (
<Box minH="100vh" display="flex" flexDirection="column">
{/* 导航栏 - 在所有页面间共享memo 后不会在路由切换时重新渲染 */}
<MemoizedHomeNavbar />
{/* 页面内容区域 - 包含动画、错误边界、懒加载 */}
<PageTransitionWrapper
location={location}
animationConfig={ANIMATION_CONFIG.default}
loaderMessage="页面加载中..."
>
<Outlet />
</PageTransitionWrapper>
{/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */}
<Box flex="1" w="100%" position="relative" overflow="hidden">
<ErrorBoundary>
<Suspense fallback={<PageLoader message="页面加载中..." />}>
<Outlet />
</Suspense>
</ErrorBoundary>
</Box>
{/* 页脚 - 在所有页面间共享memo 后不会在路由切换时重新渲染 */}
<MemoizedAppFooter />

View File

@@ -1,66 +0,0 @@
// src/layouts/components/PageTransitionWrapper.js
import React, { Suspense, memo } from 'react';
import { Box } from '@chakra-ui/react';
import { motion, AnimatePresence } from 'framer-motion';
import ErrorBoundary from '../../components/ErrorBoundary';
import PageLoader from '../../components/Loading/PageLoader';
// 创建 motion 包裹的 Box 组件
const MotionBox = motion(Box);
/**
* 页面过渡动画包裹组件
*
* 功能:
* - 页面切换时的过渡动画AnimatePresence
* - 错误边界隔离ErrorBoundary
* - 懒加载支持Suspense
*
* 优化:
* - ✅ 使用 memo 避免不必要的重新渲染
* - ✅ 支持自定义动画配置
* - ✅ 错误隔离,确保导航栏不受影响
*
* @param {React.ReactNode} children - 要渲染的子组件(通常是 <Outlet />
* @param {object} location - 路由位置对象(用于动画 key
* @param {object} animationConfig - 自定义动画配置
* @param {string} loaderMessage - 加载时显示的消息
*/
const PageTransitionWrapper = memo(({
children,
location,
animationConfig = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 },
transition: { duration: 0.2 }
},
loaderMessage = '页面加载中...'
}) => {
return (
<Box flex="1" position="relative" overflow="hidden">
<AnimatePresence mode="wait">
<MotionBox
key={location.pathname}
initial={animationConfig.initial}
animate={animationConfig.animate}
exit={animationConfig.exit}
transition={animationConfig.transition}
style={{ height: '100%' }}
>
{/* 错误边界:隔离页面错误,确保导航栏仍可用 */}
<ErrorBoundary>
{/* Suspense支持 React.lazy() 懒加载 */}
<Suspense fallback={<PageLoader message={loaderMessage} />}>
{children}
</Suspense>
</ErrorBoundary>
</MotionBox>
</AnimatePresence>
</Box>
);
});
PageTransitionWrapper.displayName = 'PageTransitionWrapper';
export default PageTransitionWrapper;

View File

@@ -609,7 +609,7 @@ function generateEventDescription(industry, importance, seed) {
return impacts[importance] + details[seed % details.length];
}
// 生成关键词
// 生成关键词(对象数组格式,包含完整信息)
function generateKeywords(industry, seed) {
const commonKeywords = ['政策', '利好', '业绩', '涨停', '龙头', '突破', '合作', '投资'];
const industryKeywords = {
@@ -620,12 +620,93 @@ function generateKeywords(industry, seed) {
'消费': ['白酒', '食品', '家电', '零售', '免税'],
};
const keywords = [
// 概念描述模板
const descriptionTemplates = {
'政策': '政策性利好消息对相关行业和板块产生积极影响,带动市场情绪和资金流向。',
'利好': '市场积极因素推动相关板块上涨,投资者情绪乐观,资金持续流入。',
'业绩': '公司业绩超预期增长,盈利能力提升,市场给予更高估值预期。',
'涨停': '强势涨停板显示市场热度,短期资金追捧,板块效应明显。',
'龙头': '行业龙头企业具备竞争优势,市场地位稳固,带动板块走势。',
'突破': '技术面或基本面出现重大突破,打开上涨空间,吸引资金关注。',
'合作': '战略合作为公司带来新的增长点,业务协同效应显著。',
'投资': '重大投资项目落地,长期发展空间广阔,市场预期良好。',
'芯片': '国产芯片替代加速,自主可控需求强烈,政策和资金支持力度大。',
'晶圆': '晶圆产能紧张,供需关系改善,相关企业盈利能力提升。',
'封测': '封测环节景气度上行,订单饱满,产能利用率提高。',
'AI芯片': '人工智能快速发展带动AI芯片需求爆发市场空间巨大。',
'国产替代': '国产替代进程加速,政策扶持力度大,进口依赖度降低。',
'电池': '新能源汽车渗透率提升,动力电池需求旺盛,技术迭代加快。',
'光伏': '光伏装机量快速增长,成本持续下降,行业景气度维持高位。',
'储能': '储能市场爆发式增长,政策支持力度大,应用场景不断拓展。',
'新能源车': '新能源汽车销量高增长,渗透率持续提升,产业链受益明显。',
'锂电': '锂电池技术进步,成本优势扩大,下游应用领域持续扩张。',
'大模型': '大语言模型技术突破,商业化进程加速,应用场景广阔。',
'AI应用': '人工智能应用落地加速,垂直领域渗透率提升,市场空间巨大。',
'算力': '算力需求持续增长,数据中心建设加速,相关产业链受益。',
'数据': '数据要素市场化改革推进,数据价值释放,相关企业盈利模式清晰。',
'机器学习': '机器学习技术成熟,应用场景丰富,商业价值逐步显现。',
'创新药': '创新药研发管线丰富,商业化进程顺利,市场给予高估值。',
'CRO': 'CRO行业高景气订单充足盈利能力稳定增长。',
'医疗器械': '医疗器械国产化率提升,技术创新加快,市场份额扩大。',
'生物制药': '生物制药技术突破,产品管线丰富,商业化前景广阔。',
'仿制药': '仿制药集采常态化,质量优势企业市场份额提升。',
'白酒': '白酒消费升级,高端产品量价齐升,龙头企业护城河深厚。',
'食品': '食品饮料需求稳定,品牌力强的企业市场份额持续提升。',
'家电': '家电消费需求回暖,智能化升级带动产品结构优化。',
'零售': '零售行业数字化转型,线上线下融合,运营效率提升。',
'免税': '免税政策优化,消费回流加速,行业景气度上行。'
};
const keywordNames = [
...commonKeywords.slice(seed % 3, seed % 3 + 3),
...(industryKeywords[industry] || []).slice(0, 2)
];
].slice(0, 5);
return keywords.slice(0, 5);
const matchTypes = ['hybrid_knn', 'keyword', 'semantic'];
// 生成历史触发时间3-5个历史日期
const generateHappenedTimes = (baseSeed) => {
const times = [];
const count = 3 + (baseSeed % 3); // 3-5个时间点
for (let i = 0; i < count; i++) {
const daysAgo = 30 + (baseSeed * 7 + i * 11) % 330; // 30-360天前
const date = new Date();
date.setDate(date.getDate() - daysAgo);
times.push(date.toISOString().split('T')[0]);
}
return times.sort().reverse(); // 降序排列
};
// 生成核心相关股票4-6只
const generateRelatedStocks = (conceptName, baseSeed) => {
const stockCount = 4 + (baseSeed % 3); // 4-6只股票
const selectedStocks = [];
for (let i = 0; i < stockCount && i < stockPool.length; i++) {
const stockIndex = (baseSeed + i * 7) % stockPool.length;
const stock = stockPool[stockIndex];
selectedStocks.push({
stock_name: stock.stock_name,
stock_code: stock.stock_code,
reason: relationDescTemplates[(baseSeed + i) % relationDescTemplates.length],
change_pct: (Math.random() * 15 - 5).toFixed(2) // -5% ~ +10%
});
}
return selectedStocks;
};
// 将字符串数组转换为对象数组,包含完整字段
return keywordNames.map((name, index) => ({
name: name,
stock_count: 10 + Math.floor((seed + index) % 30), // 10-39个相关股票
relevance: 70 + Math.floor((seed * 7 + index * 11) % 30), // 70-99的相关度
description: descriptionTemplates[name] || `${name}相关概念,市场关注度较高,具有一定的投资价值。`,
avg_change_pct: (Math.random() * 15 - 5).toFixed(2), // -5% ~ +10% 的涨跌幅
match_type: matchTypes[(seed + index) % 3], // 随机匹配类型
happened_times: generateHappenedTimes(seed + index), // 历史触发时间
stocks: generateRelatedStocks(name, seed + index) // 核心相关股票
}));
}
/**
@@ -650,7 +731,7 @@ export function generateMockEvents(params = {}) {
const allEvents = [];
const importanceLevels = ['S', 'A', 'B', 'C'];
const baseDate = new Date('2025-01-15');
const baseDate = new Date(); // 使用当前日期作为基准
for (let i = 0; i < totalEvents; i++) {
const industry = industries[i % industries.length];
@@ -666,26 +747,85 @@ export function generateMockEvents(params = {}) {
const relatedAvgChg = (Math.random() * 20 - 5).toFixed(2); // -5% 到 15%
const relatedMaxChg = (Math.random() * 30).toFixed(2); // 0% 到 30%
// 生成价格走势数据(前一天、当天、后一天)
const generatePriceTrend = (seed) => {
const basePrice = 10 + (seed % 90); // 基础价格 10-100
const trend = [];
// 前一天5个数据点
let price = basePrice;
for (let i = 0; i < 5; i++) {
price = price + (Math.random() - 0.5) * 0.5;
trend.push(parseFloat(price.toFixed(2)));
}
// 当天5个数据点
for (let i = 0; i < 5; i++) {
price = price + (Math.random() - 0.4) * 0.8; // 轻微上涨趋势
trend.push(parseFloat(price.toFixed(2)));
}
// 后一天5个数据点
for (let i = 0; i < 5; i++) {
price = price + (Math.random() - 0.45) * 1.0;
trend.push(parseFloat(price.toFixed(2)));
}
return trend;
};
// 为每个事件随机选择2-5个相关股票
const relatedStockCount = 2 + (i % 4); // 2-5个股票
const relatedStocks = [];
const industryStocks = stockPool.filter(s => s.industry === industry);
const addedStockCodes = new Set(); // 用于去重
// 优先选择同行业股票
if (industryStocks.length > 0) {
for (let j = 0; j < Math.min(relatedStockCount, industryStocks.length); j++) {
relatedStocks.push(industryStocks[j % industryStocks.length].stock_code);
const stock = industryStocks[j % industryStocks.length];
if (!addedStockCodes.has(stock.stock_code)) {
const dailyChange = (Math.random() * 6 - 2).toFixed(2); // -2% ~ +4%
const weekChange = (Math.random() * 10 - 3).toFixed(2); // -3% ~ +7%
relatedStocks.push({
stock_name: stock.stock_name,
stock_code: stock.stock_code,
relation_desc: relationDescTemplates[(i + j) % relationDescTemplates.length],
daily_change: dailyChange,
week_change: weekChange,
price_trend: generatePriceTrend(i * 100 + j)
});
addedStockCodes.add(stock.stock_code);
}
}
}
// 如果同行业股票不够,从整个 stockPool 中补充
while (relatedStocks.length < relatedStockCount && relatedStocks.length < stockPool.length) {
const randomStock = stockPool[relatedStocks.length % stockPool.length];
if (!relatedStocks.includes(randomStock.stock_code)) {
relatedStocks.push(randomStock.stock_code);
let poolIndex = 0;
while (relatedStocks.length < relatedStockCount && poolIndex < stockPool.length) {
const randomStock = stockPool[poolIndex % stockPool.length];
if (!addedStockCodes.has(randomStock.stock_code)) {
const dailyChange = (Math.random() * 6 - 2).toFixed(2); // -2% ~ +4%
const weekChange = (Math.random() * 10 - 3).toFixed(2); // -3% ~ +7%
relatedStocks.push({
stock_name: randomStock.stock_name,
stock_code: randomStock.stock_code,
relation_desc: relationDescTemplates[(i + poolIndex) % relationDescTemplates.length],
daily_change: dailyChange,
week_change: weekChange,
price_trend: generatePriceTrend(i * 100 + poolIndex)
});
addedStockCodes.add(randomStock.stock_code);
}
poolIndex++;
}
// 计算交易日期模拟下一交易日这里简单地加1天
const tradingDate = new Date(createdAt);
tradingDate.setDate(tradingDate.getDate() + 1);
allEvents.push({
id: i + 1,
title: generateEventTitle(industry, i),
@@ -696,6 +836,7 @@ export function generateMockEvents(params = {}) {
status: 'published',
created_at: createdAt.toISOString(),
updated_at: createdAt.toISOString(),
trading_date: tradingDate.toISOString().split('T')[0], // YYYY-MM-DD 格式
hot_score: hotScore,
view_count: Math.floor(Math.random() * 10000),
related_avg_chg: parseFloat(relatedAvgChg),
@@ -704,6 +845,8 @@ export function generateMockEvents(params = {}) {
is_ai_generated: i % 4 === 0, // 25% 的事件是AI生成
industry: industry,
related_stocks: relatedStocks, // 添加相关股票列表
historical_events: generateHistoricalEvents(industry, i), // 添加历史事件
transmission_chain: generateTransmissionChain(industry, i), // 添加传导链数据
});
}
@@ -816,3 +959,213 @@ export function generatePopularKeywords(limit = 20) {
trend: index % 3 === 0 ? 'up' : index % 3 === 1 ? 'down' : 'stable',
}));
}
/**
* 生成历史事件对比数据
* @param {string} industry - 行业
* @param {number} index - 索引
* @returns {Array} - 历史事件列表
*/
function generateHistoricalEvents(industry, index) {
const historicalCount = 3 + (index % 3); // 3-5个历史事件
const historical = [];
const baseDate = new Date();
for (let i = 0; i < historicalCount; i++) {
// 生成过去1-6个月的随机时间
const monthsAgo = 1 + Math.floor(Math.random() * 6);
const eventDate = new Date(baseDate);
eventDate.setMonth(eventDate.getMonth() - monthsAgo);
const similarityScore = 0.6 + Math.random() * 0.35; // 60%-95%相似度
historical.push({
id: `hist_${industry}_${index}_${i}`,
title: generateEventTitle(industry, i + index * 10),
created_at: eventDate.toISOString(),
related_avg_chg: parseFloat((Math.random() * 15 - 3).toFixed(2)),
related_max_chg: parseFloat((Math.random() * 25).toFixed(2)),
similarity_score: parseFloat(similarityScore.toFixed(2)),
view_count: Math.floor(Math.random() * 3000) + 500,
});
}
// 按相似度排序
historical.sort((a, b) => b.similarity_score - a.similarity_score);
return historical;
}
/**
* 生成传导链数据
* @param {string} industry - 行业
* @param {number} index - 索引
* @returns {Object} - 传导链数据 { nodes, edges }
*/
function generateTransmissionChain(industry, index) {
const nodeTypes = ['event', 'industry', 'company', 'policy', 'technology', 'market'];
const impactTypes = ['positive', 'negative', 'neutral', 'mixed'];
const strengthLevels = ['strong', 'medium', 'weak'];
const nodes = [];
const edges = [];
// 主事件节点
nodes.push({
id: 1,
name: '主事件',
type: 'event',
extra: { is_main_event: true, description: `${industry}重要事件` }
});
// 生成5-8个相关节点
const nodeCount = 5 + (index % 4);
for (let i = 2; i <= nodeCount; i++) {
const nodeType = nodeTypes[i % nodeTypes.length];
const industryStock = stockPool.find(s => s.industry === industry);
let nodeName;
if (nodeType === 'company' && industryStock) {
nodeName = industryStock.name;
} else if (nodeType === 'industry') {
nodeName = `${industry}产业`;
} else if (nodeType === 'policy') {
nodeName = '相关政策';
} else if (nodeType === 'technology') {
nodeName = '技术创新';
} else if (nodeType === 'market') {
nodeName = '市场需求';
} else {
nodeName = `节点${i}`;
}
nodes.push({
id: i,
name: nodeName,
type: nodeType,
extra: { description: `${nodeName}相关信息` }
});
// 创建与主事件或其他节点的连接
const targetId = i === 2 ? 1 : Math.max(1, Math.floor(Math.random() * (i - 1)) + 1);
edges.push({
source: targetId,
target: i,
impact: impactTypes[i % impactTypes.length],
strength: strengthLevels[i % strengthLevels.length],
description: `传导路径${i}`
});
}
return { nodes, edges };
}
/**
* 生成动态新闻事件(实时要闻·动态追踪专用)
* @param {Object} timeRange - 时间范围 { startTime, endTime }
* @param {number} count - 生成事件数量默认30条
* @returns {Array} - 事件列表
*/
export function generateDynamicNewsEvents(timeRange = null, count = 30) {
const events = [];
const importanceLevels = ['S', 'A', 'B', 'C'];
// 如果没有提供时间范围默认生成最近24小时的事件
let startTime, endTime;
if (timeRange) {
startTime = new Date(timeRange.startTime);
endTime = new Date(timeRange.endTime);
} else {
endTime = new Date();
startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000); // 24小时前
}
// 计算时间跨度(毫秒)
const timeSpan = endTime.getTime() - startTime.getTime();
for (let i = 0; i < count; i++) {
const industry = industries[i % industries.length];
const imp = importanceLevels[i % importanceLevels.length];
const eventType = eventTypes[i % eventTypes.length];
// 在时间范围内随机生成事件时间
const randomOffset = Math.random() * timeSpan;
const createdAt = new Date(startTime.getTime() + randomOffset);
// 生成随机热度和收益率
const hotScore = Math.max(60, 100 - i * 1.2); // 动态新闻热度更高
const relatedAvgChg = (Math.random() * 15 - 3).toFixed(2); // -3% 到 12%
const relatedMaxChg = (Math.random() * 25).toFixed(2); // 0% 到 25%
const relatedWeekChg = (Math.random() * 30 - 10).toFixed(2); // -10% 到 20%
// 为每个事件随机选择2-5个相关股票完整对象
const relatedStockCount = 2 + (i % 4);
const relatedStocks = [];
const industryStocks = stockPool.filter(s => s.industry === industry);
const relationDescriptions = [
'直接受益标的',
'产业链上游企业',
'产业链下游企业',
'行业龙头企业',
'潜在受益标的',
'概念相关个股'
];
// 优先选择同行业股票
if (industryStocks.length > 0) {
for (let j = 0; j < Math.min(relatedStockCount, industryStocks.length); j++) {
const stock = industryStocks[j % industryStocks.length];
relatedStocks.push({
stock_code: stock.stock_code,
stock_name: stock.name,
relation_desc: relationDescriptions[j % relationDescriptions.length]
});
}
}
// 如果同行业股票不够,从整个 stockPool 中补充
while (relatedStocks.length < relatedStockCount && relatedStocks.length < stockPool.length) {
const randomStock = stockPool[relatedStocks.length % stockPool.length];
if (!relatedStocks.some(s => s.stock_code === randomStock.stock_code)) {
relatedStocks.push({
stock_code: randomStock.stock_code,
stock_name: randomStock.name,
relation_desc: relationDescriptions[relatedStocks.length % relationDescriptions.length]
});
}
}
events.push({
id: `dynamic_${i + 1}`,
title: generateEventTitle(industry, i),
description: generateEventDescription(industry, imp, i),
content: generateEventDescription(industry, imp, i),
event_type: eventType,
importance: imp,
status: 'published',
created_at: createdAt.toISOString(),
updated_at: createdAt.toISOString(),
hot_score: hotScore,
view_count: Math.floor(Math.random() * 5000) + 1000, // 1000-6000 浏览量
follower_count: Math.floor(Math.random() * 500) + 50, // 50-550 关注数
post_count: Math.floor(Math.random() * 100) + 10, // 10-110 帖子数
related_avg_chg: parseFloat(relatedAvgChg),
related_max_chg: parseFloat(relatedMaxChg),
related_week_chg: parseFloat(relatedWeekChg),
keywords: generateKeywords(industry, i),
is_ai_generated: i % 3 === 0, // 33% 的事件是AI生成
industry: industry,
related_stocks: relatedStocks,
historical_events: generateHistoricalEvents(industry, i),
transmission_chain: generateTransmissionChain(industry, i),
creator: {
username: authorPool[i % authorPool.length],
avatar_url: null
}
});
}
// 按时间倒序排序(最新的在前)
events.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
return events;
}

View File

@@ -2,7 +2,7 @@
// 事件相关的 Mock API Handlers
import { http, HttpResponse } from 'msw';
import { getEventRelatedStocks, generateMockEvents, generateHotEvents, generatePopularKeywords } from '../data/events';
import { getEventRelatedStocks, generateMockEvents, generateHotEvents, generatePopularKeywords, generateDynamicNewsEvents } from '../data/events';
import { getMockFutureEvents, getMockEventCountsForMonth } from '../data/account';
// 模拟网络延迟
@@ -111,6 +111,47 @@ export const eventHandlers = [
}
}),
// 获取动态新闻(实时要闻·动态追踪专用)
http.get('/api/events/dynamic-news', async ({ request }) => {
await delay(400);
const url = new URL(request.url);
const count = parseInt(url.searchParams.get('count') || '30');
const startTime = url.searchParams.get('start_time');
const endTime = url.searchParams.get('end_time');
console.log('[Mock] 获取动态新闻, count:', count, 'startTime:', startTime, 'endTime:', endTime);
try {
let timeRange = null;
if (startTime && endTime) {
timeRange = {
startTime: new Date(startTime),
endTime: new Date(endTime)
};
}
const events = generateDynamicNewsEvents(timeRange, count);
return HttpResponse.json({
success: true,
data: events,
total: events.length,
message: '获取成功'
});
} catch (error) {
console.error('[Mock] 获取动态新闻失败:', error);
return HttpResponse.json(
{
success: false,
error: '获取动态新闻失败',
data: []
},
{ status: 500 }
);
}
}),
// ==================== 事件详情相关 ====================
// 获取事件相关股票

View File

@@ -1,53 +0,0 @@
// src/routes/components/RouteContainer.js
// 路由容器组件 - 提供统一的错误边界、加载状态和主题背景
import React, { Suspense } from 'react';
import { Box, useColorMode } from '@chakra-ui/react';
import ErrorBoundary from '@components/ErrorBoundary';
import PageLoader from '@components/Loading/PageLoader';
/**
* RouteContainer - 路由容器组件
*
* 为路由系统提供统一的外层包装,包含:
* 1. 主题感知的背景色(深色/浅色模式)
* 2. Suspense 懒加载边界(显示加载提示)
* 3. ErrorBoundary 错误边界(隔离路由错误)
*
* 这个组件确保:
* - 所有路由页面都有一致的背景色
* - 懒加载组件有统一的加载提示
* - 单个路由的错误不会导致整个应用崩溃
*
* @param {Object} props
* @param {React.ReactNode} props.children - 子组件(通常是 Routes
* @param {string} [props.loadingMessage='加载页面中...'] - 加载提示文本
*
* @example
* <RouteContainer>
* <Routes>
* <Route path="/" element={<Home />} />
* </Routes>
* </RouteContainer>
*/
export function RouteContainer({
children,
loadingMessage = "加载页面中..."
}) {
const { colorMode } = useColorMode();
return (
<Box
minH="100vh"
bg={colorMode === 'dark' ? 'gray.800' : 'white'}
>
{/* Suspense 统一处理懒加载组件的加载状态 */}
<Suspense fallback={<PageLoader message={loadingMessage} />}>
{/* ErrorBoundary 隔离路由错误,防止整个应用崩溃 */}
<ErrorBoundary>
{children}
</ErrorBoundary>
</Suspense>
</Box>
);
}

View File

@@ -1,4 +0,0 @@
// src/routes/components/index.js
// 统一导出所有路由组件
export { RouteContainer } from './RouteContainer';

View File

@@ -1,5 +1,5 @@
// src/routes/constants/index.js
// 统一导出所有路由常量
export { LAYOUT_COMPONENTS } from './layoutComponents';
export { PROTECTION_WRAPPER_MAP } from './protectionWrappers';
export { PROTECTION_MODES } from './protectionModes';

View File

@@ -1,26 +0,0 @@
// src/routes/constants/layoutComponents.js
// 布局组件映射表
import Auth from '@layouts/Auth';
import HomeLayout from '@layouts/Home';
/**
* 特殊布局组件映射表
*
* 用于将字符串标识符映射到实际的组件。
* 这些是非懒加载的布局组件,在 routeConfig.js 中通过字符串引用。
*
* @example
* // 在 routeConfig.js 中:
* {
* path: 'auth/*',
* component: 'Auth', // 字符串标识符
* ...
* }
*
* // 通过 LAYOUT_COMPONENTS['Auth'] 获取实际组件
*/
export const LAYOUT_COMPONENTS = {
Auth,
HomeLayout,
};

View File

@@ -0,0 +1,14 @@
// src/routes/constants/protectionModes.js
// 路由保护模式常量
/**
* 路由保护模式
* - 'modal': 使用 ProtectedRoute (弹窗模式登录)
* - 'redirect': 使用 ProtectedRouteRedirect (跳转模式登录)
* - 'public': 公开访问,无需登录
*/
export const PROTECTION_MODES = {
MODAL: 'modal',
REDIRECT: 'redirect',
PUBLIC: 'public',
};

View File

@@ -3,7 +3,7 @@
import ProtectedRoute from '@components/ProtectedRoute';
import ProtectedRouteRedirect from '@components/ProtectedRouteRedirect';
import { PROTECTION_MODES } from '../routeConfig';
import { PROTECTION_MODES } from './protectionModes';
/**
* 保护模式包装器映射表

115
src/routes/homeRoutes.js Normal file
View File

@@ -0,0 +1,115 @@
// src/routes/homeRoutes.js
// Home 模块子路由配置
import { lazyComponents } from './lazy-components';
import { PROTECTION_MODES } from './constants/protectionModes';
/**
* Home 模块的子路由配置
* 这些路由将作为 /home/* 的嵌套路由
*
* 注意:
* - 使用相对路径(不带前导斜杠)
* - 空字符串 '' 表示索引路由,匹配 /home
* - 这些路由将通过 Outlet 渲染到父路由中
*/
export const homeRoutes = [
// 首页 - /home
{
path: '',
component: lazyComponents.HomePage,
protection: PROTECTION_MODES.PUBLIC,
meta: {
title: '首页',
description: '价值前沿首页'
}
},
// 个人中心 - /home/center
{
path: 'center',
component: lazyComponents.CenterDashboard,
protection: PROTECTION_MODES.MODAL,
meta: {
title: '个人中心',
description: '用户个人中心'
}
},
// 个人资料 - /home/profile
{
path: 'profile',
component: lazyComponents.ProfilePage,
protection: PROTECTION_MODES.MODAL,
meta: {
title: '个人资料',
description: '用户个人资料'
}
},
// 账户设置 - /home/settings
{
path: 'settings',
component: lazyComponents.SettingsPage,
protection: PROTECTION_MODES.MODAL,
meta: {
title: '账户设置',
description: '用户账户设置'
}
},
// 订阅管理 - /home/pages/account/subscription
{
path: 'pages/account/subscription',
component: lazyComponents.Subscription,
protection: PROTECTION_MODES.MODAL,
meta: {
title: '订阅管理',
description: '管理订阅服务'
}
},
// 隐私政策 - /home/privacy-policy
{
path: 'privacy-policy',
component: lazyComponents.PrivacyPolicy,
protection: PROTECTION_MODES.PUBLIC,
meta: {
title: '隐私政策',
description: '隐私保护政策'
}
},
// 用户协议 - /home/user-agreement
{
path: 'user-agreement',
component: lazyComponents.UserAgreement,
protection: PROTECTION_MODES.PUBLIC,
meta: {
title: '用户协议',
description: '用户使用协议'
}
},
// 微信授权回调 - /home/wechat-callback
{
path: 'wechat-callback',
component: lazyComponents.WechatCallback,
protection: PROTECTION_MODES.PUBLIC,
meta: {
title: '微信授权',
description: '微信授权回调页面'
}
},
// 回退路由 - 匹配任何未定义的 /home/* 路径
{
path: '*',
component: lazyComponents.HomePage,
protection: PROTECTION_MODES.PUBLIC,
meta: {
title: '首页',
description: '价值前沿首页'
}
},
];

View File

@@ -10,9 +10,8 @@ import { getMainLayoutRoutes, getStandaloneRoutes } from './routeConfig';
// 布局组件
import MainLayout from '@layouts/MainLayout';
// 路由工具和组件
// 路由工具
import { renderRoute } from './utils';
import { RouteContainer } from './components';
/**
* AppRoutes - 应用路由组件
@@ -31,7 +30,11 @@ import { RouteContainer } from './components';
* 目录结构:
* - constants/ - 常量配置(布局映射、保护包装器)
* - utils/ - 工具函数renderRoute, wrapWithProtection
* - components/ - 路由组件RouteContainer
* - components/ - 路由相关组件
*
* 注意:
* - Suspense/ErrorBoundary 由 PageTransitionWrapper 统一处理
* - 全屏容器由 MainLayout 提供minH="100vh"
*/
export function AppRoutes() {
// 🎯 性能优化:使用 useMemo 缓存路由计算结果
@@ -39,23 +42,21 @@ export function AppRoutes() {
const standaloneRoutes = useMemo(() => getStandaloneRoutes(), []);
return (
<RouteContainer>
<Routes>
{/* 主布局路由 - 带导航栏和页脚 */}
<Route element={<MainLayout />}>
{mainLayoutRoutes.map(renderRoute)}
</Route>
<Routes>
{/* 主布局路由 - 带导航栏和页脚 */}
<Route element={<MainLayout />}>
{mainLayoutRoutes.map(renderRoute)}
</Route>
{/* 独立路由 - 无布局(如登录页)*/}
{standaloneRoutes.map(renderRoute)}
{/* 独立路由 - 无布局(如登录页)*/}
{standaloneRoutes.map(renderRoute)}
{/* 默认路由 - 重定向到首页 */}
<Route path="/" element={<Navigate to="/home" replace />} />
{/* 默认路由 - 重定向到首页 */}
<Route path="/" element={<Navigate to="/home" replace />} />
{/* 404 页面 - 捕获所有未匹配的路由 */}
<Route path="*" element={<Navigate to="/home" replace />} />
</Routes>
</RouteContainer>
{/* 404 页面 - 捕获所有未匹配的路由 */}
<Route path="*" element={<Navigate to="/home" replace />} />
</Routes>
);
}

View File

@@ -8,6 +8,16 @@ import React from 'react';
* 使用 React.lazy() 实现路由懒加载,大幅减少初始 JS 包大小
*/
export const lazyComponents = {
// Home 模块
HomePage: React.lazy(() => import('../views/Home/HomePage')),
CenterDashboard: React.lazy(() => import('../views/Dashboard/Center')),
ProfilePage: React.lazy(() => import('../views/Profile/ProfilePage')),
SettingsPage: React.lazy(() => import('../views/Settings/SettingsPage')),
Subscription: React.lazy(() => import('../views/Pages/Account/Subscription')),
PrivacyPolicy: React.lazy(() => import('../views/Pages/PrivacyPolicy')),
UserAgreement: React.lazy(() => import('../views/Pages/UserAgreement')),
WechatCallback: React.lazy(() => import('../views/Pages/WechatCallback')),
// 社区/内容模块
Community: React.lazy(() => import('../views/Community')),
ConceptCenter: React.lazy(() => import('../views/Concept')),
@@ -31,6 +41,14 @@ export const lazyComponents = {
* 按需导出单个组件(可选)
*/
export const {
HomePage,
CenterDashboard,
ProfilePage,
SettingsPage,
Subscription,
PrivacyPolicy,
UserAgreement,
WechatCallback,
Community,
ConceptCenter,
StockOverview,

View File

@@ -2,35 +2,30 @@
// 声明式路由配置
import { lazyComponents } from './lazy-components';
import { homeRoutes } from './homeRoutes';
import { PROTECTION_MODES } from './constants/protectionModes';
/**
* 路由保护模式
* - 'modal': 使用 ProtectedRoute (弹窗模式登录)
* - 'redirect': 使用 ProtectedRouteRedirect (跳转模式登录)
* - 'public': 公开访问,无需登录
*/
export const PROTECTION_MODES = {
MODAL: 'modal',
REDIRECT: 'redirect',
PUBLIC: 'public',
};
// 重新导出 PROTECTION_MODES 以保持向后兼容
export { PROTECTION_MODES };
/**
* 路由配置
* 每个路由对象包含:
* - path: 路由路径
* - component: 组件(从 lazyComponents 引用)
* - component: 组件(从 lazyComponents 引用,或设为 null 使用 Outlet)
* - protection: 保护模式 (modal/redirect/public)
* - layout: 布局类型 (main/auth/none)
* - children: 子路由配置数组(可选,用于嵌套路由)
* - meta: 路由元数据(可选,用于面包屑、标题等)
*/
export const routeConfig = [
// ==================== 首页 ====================
{
path: 'home/*',
component: 'HomeLayout', // 非懒加载,直接在 App.js 导入
path: 'home',
component: null, // 使用 Outlet 渲染子路由
protection: PROTECTION_MODES.PUBLIC,
layout: 'main',
children: homeRoutes, // 子路由配置
meta: {
title: '首页',
description: '价值前沿首页'
@@ -107,7 +102,7 @@ export const routeConfig = [
{
path: 'forecast-report',
component: lazyComponents.ForecastReport,
protection: PROTECTION_MODES.REDIRECT,
protection: PROTECTION_MODES.MODAL,
layout: 'main',
meta: {
title: '财报预测',
@@ -115,7 +110,7 @@ export const routeConfig = [
}
},
{
path: 'Financial',
path: 'financial',
component: lazyComponents.FinancialPanorama,
protection: PROTECTION_MODES.MODAL,
layout: 'main',
@@ -154,18 +149,6 @@ export const routeConfig = [
description: '实时市场数据'
}
},
// ==================== 认证模块 ====================
{
path: 'auth/*',
component: 'Auth', // 非懒加载,直接在 App.js 导入
protection: PROTECTION_MODES.PUBLIC,
layout: 'none',
meta: {
title: '登录/注册',
description: '用户认证'
}
},
];
/**

View File

@@ -2,8 +2,7 @@
// 路由渲染工具函数
import React from 'react';
import { Route } from 'react-router-dom';
import { LAYOUT_COMPONENTS } from '../constants';
import { Route, Outlet } from 'react-router-dom';
import { wrapWithProtection } from './wrapWithProtection';
/**
@@ -11,14 +10,16 @@ import { wrapWithProtection } from './wrapWithProtection';
*
* 根据路由配置项生成 React Router 的 Route 组件。
* 处理以下逻辑:
* 1. 解析组件(特殊布局组件 vs 懒加载组件)
* 1. 解析组件(懒加载组件 or Outlet
* 2. 应用路由保护(根据 protection 字段)
* 3. 生成唯一 key
* 3. 处理嵌套路由children 数组)
* 4. 生成唯一 key
*
* @param {Object} routeItem - 路由配置项(来自 routeConfig.js
* @param {string} routeItem.path - 路由路径
* @param {React.ComponentType|string} routeItem.component - 组件或组件标识符
* @param {React.ComponentType|null} routeItem.component - 懒加载组件或 nullnull 表示使用 Outlet
* @param {string} routeItem.protection - 保护模式 (modal/redirect/public)
* @param {Array} [routeItem.children] - 子路由配置数组(可选)
* @param {number} index - 路由索引,用于生成唯一 key
*
* @returns {React.ReactElement} Route 组件
@@ -27,19 +28,41 @@ import { wrapWithProtection } from './wrapWithProtection';
* // 使用示例
* const routes = [
* { path: 'community', component: CommunityComponent, protection: 'modal' },
* { path: 'auth/*', component: 'Auth', protection: 'public' },
* { path: 'home', component: null, protection: 'public', children: [...] },
* ];
*
* routes.map((route, index) => renderRoute(route, index));
*/
export function renderRoute(routeItem, index) {
const { path, component, protection } = routeItem;
const { path, component, protection, children } = routeItem;
// 解析组件:
// - 如果是字符串(如 'Auth', 'HomeLayout'),从 LAYOUT_COMPONENTS 映射表查找
// - 如果是 null使用 <Outlet /> 用于嵌套路由
// - 否则直接使用(懒加载组件)
const Component = LAYOUT_COMPONENTS[component] || component;
let Component;
let isOutletRoute = false;
if (component === null) {
Component = Outlet; // 用于嵌套路由
isOutletRoute = true;
} else {
Component = component; // 直接使用懒加载组件
}
// 如果有子路由,递归渲染
if (children && children.length > 0) {
return (
<Route
key={`${path}-${index}`}
path={path}
element={isOutletRoute ? <Outlet /> : wrapWithProtection(Component, protection)}
>
{children.map((childRoute, childIndex) => renderRoute(childRoute, childIndex))}
</Route>
);
}
// 没有子路由,渲染单个路由
return (
<Route
key={`${path}-${index}`}

View File

@@ -9,7 +9,7 @@
--color-n-6: #252134;
--color-n-7: #15131D;
--color-n-8: #0E0C15;
/* Brainwave 主题色 */
--color-1: #AC6AFF;
--color-2: #FFC876;
@@ -17,7 +17,7 @@
--color-4: #7ADB78;
--color-5: #858DFF;
--color-6: #FF98E2;
/* 描边色 */
--stroke-1: #26242C;
}
@@ -47,37 +47,3 @@
.bg-gradient-to-br {
background-image: linear-gradient(to bottom right, var(--tw-gradient-stops)) !important;
}
.from-n-8 { --tw-gradient-from: var(--color-n-8); --tw-gradient-to: transparent; --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
.via-n-7 { --tw-gradient-to: transparent; --tw-gradient-stops: var(--tw-gradient-from), var(--color-n-7), var(--tw-gradient-to); }
.to-n-6 { --tw-gradient-to: var(--color-n-6); }
/* 文字渐变 */
.bg-gradient-to-r { background-image: linear-gradient(to right, var(--tw-gradient-stops)) !important; }
.from-color-1 { --tw-gradient-from: var(--color-1); --tw-gradient-to: transparent; --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
.to-color-2 { --tw-gradient-to: var(--color-2); }
.from-color-2 { --tw-gradient-from: var(--color-2); --tw-gradient-to: transparent; --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
.to-color-1 { --tw-gradient-to: var(--color-1); }
.bg-clip-text {
-webkit-background-clip: text !important;
background-clip: text !important;
}
.text-transparent {
color: transparent !important;
}
/* 其他样式增强 */
.backdrop-blur-sm { backdrop-filter: blur(8px) !important; }
.backdrop-blur { backdrop-filter: blur(16px) !important; }
/* 确保body有深色背景 */
body {
background-color: var(--color-n-8) !important;
}
/* z-index 修复 */
.z-50 { z-index: 50 !important; }
.z-10 { z-index: 10 !important; }
.z-2 { z-index: 2 !important; }
.z-1 { z-index: 1 !important; }

View File

@@ -32,4 +32,4 @@ body {
@apply md:grid !important;
@apply md:grid-cols-3 md:gap-x-10 md:gap-y-[4.5rem] xl:gap-y-[6rem];
}
}
}

View File

@@ -0,0 +1,175 @@
// src/utils/tradingTimeUtils.js
// 交易时间相关工具函数
import moment from 'moment';
/**
* 获取当前时间应该显示的实时要闻时间范围
* 规则:
* - 15:00 之前:显示昨日 15:00 - 今日 15:00
* - 15:30 之后:显示今日 15:00 - 当前时间
*
* @returns {{ startTime: Date, endTime: Date, description: string }}
*/
export const getCurrentTradingTimeRange = () => {
const now = moment();
const currentHour = now.hour();
const currentMinute = now.minute();
// 计算当前是第几分钟(方便比较)
const currentTimeInMinutes = currentHour * 60 + currentMinute;
const cutoffTime1500 = 15 * 60; // 15:00 = 900分钟
const cutoffTime1530 = 15 * 60 + 30; // 15:30 = 930分钟
let startTime, endTime, description;
if (currentTimeInMinutes < cutoffTime1500) {
// 15:00 之前:显示昨日 15:00 - 今日 15:00
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
description = '昨日15:00 - 今日15:00';
} else if (currentTimeInMinutes >= cutoffTime1530) {
// 15:30 之后:显示今日 15:00 - 当前时间
startTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
endTime = now.toDate();
description = '今日15:00 - 当前时间';
} else {
// 15:00 - 15:30 之间:过渡期,保持显示昨日 15:00 - 今日 15:00
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
description = '昨日15:00 - 今日15:00';
}
return {
startTime,
endTime,
description,
rangeType: currentTimeInMinutes >= cutoffTime1530 ? 'current_day' : 'full_day'
};
};
/**
* 获取市场复盘的时间范围
* 规则:显示最近一个完整的交易日(昨日 15:00 - 今日 15:00
*
* @returns {{ startTime: Date, endTime: Date, description: string }}
*/
export const getMarketReviewTimeRange = () => {
const now = moment();
const currentHour = now.hour();
const currentMinute = now.minute();
// 计算当前是第几分钟
const currentTimeInMinutes = currentHour * 60 + currentMinute;
const cutoffTime1530 = 15 * 60 + 30; // 15:30 = 930分钟
let startTime, endTime, description;
if (currentTimeInMinutes >= cutoffTime1530) {
// 15:30 之后:显示昨日 15:00 - 今日 15:00刚刚完成的交易日
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
description = '昨日15:00 - 今日15:00';
} else {
// 15:30 之前:显示前日 15:00 - 昨日 15:00上一个完整交易日
startTime = moment().subtract(2, 'days').hour(15).minute(0).second(0).millisecond(0).toDate();
endTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
description = '前日15:00 - 昨日15:00';
}
return {
startTime,
endTime,
description,
rangeType: 'market_review'
};
};
/**
* 根据时间范围过滤事件列表
*
* @param {Array} events - 事件列表
* @param {Date} startTime - 开始时间
* @param {Date} endTime - 结束时间
* @returns {Array} 过滤后的事件列表
*/
export const filterEventsByTimeRange = (events, startTime, endTime) => {
if (!events || !Array.isArray(events)) {
return [];
}
if (!startTime || !endTime) {
return events;
}
const startMoment = moment(startTime);
const endMoment = moment(endTime);
return events.filter(event => {
if (!event.created_at) {
return false;
}
const eventTime = moment(event.created_at);
return eventTime.isSameOrAfter(startMoment) && eventTime.isSameOrBefore(endMoment);
});
};
/**
* 判断当前是否应该显示市场复盘模块
* 根据需求:市场复盘模块一直显示
*
* @returns {boolean}
*/
export const shouldShowMarketReview = () => {
// 市场复盘模块始终显示
return true;
};
/**
* 获取时间范围的描述文本
*
* @param {Date} startTime - 开始时间
* @param {Date} endTime - 结束时间
* @returns {string}
*/
export const getTimeRangeDescription = (startTime, endTime) => {
if (!startTime || !endTime) {
return '';
}
const startStr = moment(startTime).format('MM-DD HH:mm');
const endStr = moment(endTime).format('MM-DD HH:mm');
return `${startStr} - ${endStr}`;
};
/**
* 判断是否为交易日(简化版本,只判断周末)
* 注意这里没有考虑节假日如需精确判断需要接入交易日历API
*
* @param {Date} date - 日期
* @returns {boolean}
*/
export const isTradingDay = (date) => {
const day = moment(date).day();
// 0 = 周日, 6 = 周六
return day !== 0 && day !== 6;
};
/**
* 获取上一个交易日(简化版本)
*
* @param {Date} date - 日期
* @returns {Date}
*/
export const getPreviousTradingDay = (date) => {
let prevDay = moment(date).subtract(1, 'day');
// 如果是周末,继续往前找
while (!isTradingDay(prevDay.toDate())) {
prevDay = prevDay.subtract(1, 'day');
}
return prevDay.toDate();
};

View File

@@ -1,160 +0,0 @@
/*!
=========================================================
* Argon Dashboard Chakra PRO - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
* Designed and Coded by Simmmple & Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
// Chakra imports
import React, { useState } from "react";
import {
Box,
Button,
Flex,
FormControl,
FormLabel,
Heading,
Input,
Stack,
useColorModeValue,
Text,
Link,
InputGroup,
InputRightElement,
IconButton,
useToast,
} from "@chakra-ui/react";
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../../../contexts/AuthContext";
export default function SignInBasic() {
const [showPassword, setShowPassword] = useState(false);
const [formData, setFormData] = useState({
email: "",
password: "",
});
const navigate = useNavigate();
const toast = useToast();
const { login, isLoading } = useAuth();
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.email || !formData.password) {
toast({
title: "请填写完整信息",
description: "邮箱和密码都是必填项",
status: "warning",
duration: 3000,
isClosable: true,
});
return;
}
const result = await login(formData.email, formData.password, 'email');
if (result.success) {
// 登录成功,跳转到首页
navigate("/home");
}
};
return (
<Flex minH="100vh" align="center" justify="center" bg={useColorModeValue("gray.50", "gray.900")}>
<Stack spacing={8} mx="auto" maxW="lg" py={12} px={6}>
<Stack align="center">
<Heading style={{minWidth: '140px'}} fontSize="4xl" color="blue.600">
价小前投研
</Heading>
<Text fontSize="lg" color="gray.600">
登录您的账户
</Text>
</Stack>
<Box
rounded="lg"
bg={useColorModeValue("white", "gray.700")}
boxShadow="lg"
p={8}
>
<form onSubmit={handleSubmit}>
<Stack spacing={4}>
<FormControl id="email" isRequired>
<FormLabel>邮箱地址</FormLabel>
<Input
type="email"
name="email"
value={formData.email}
onChange={handleInputChange}
placeholder="请输入您的邮箱"
/>
</FormControl>
<FormControl id="password" isRequired>
<FormLabel>密码</FormLabel>
<InputGroup>
<Input
type={showPassword ? "text" : "password"}
name="password"
value={formData.password}
onChange={handleInputChange}
placeholder="请输入您的密码"
/>
<InputRightElement>
<IconButton
aria-label={showPassword ? "隐藏密码" : "显示密码"}
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
onClick={() => setShowPassword(!showPassword)}
variant="ghost"
size="sm"
/>
</InputRightElement>
</InputGroup>
</FormControl>
<Stack spacing={10}>
<Button
type="submit"
bg="blue.600"
color="white"
_hover={{
bg: "blue.700",
}}
isLoading={isLoading}
loadingText="登录中..."
>
登录
</Button>
</Stack>
<Stack pt={6}>
<Text align="center">
还没有账户?{" "}
<Link color="blue.600" onClick={() => navigate("/auth/signup")}>
立即注册
</Link>
</Text>
</Stack>
</Stack>
</form>
</Box>
</Stack>
</Flex>
);
}

View File

@@ -1,207 +0,0 @@
import React, { useState } from "react";
import {
Box,
Button,
FormControl,
FormLabel,
Input,
VStack,
Heading,
Text,
Link,
useColorMode,
InputGroup,
InputRightElement,
IconButton,
Spinner,
} from "@chakra-ui/react";
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../../../contexts/AuthContext";
export default function SignInCentered() {
const { colorMode } = useColorMode();
const navigate = useNavigate();
const { login, isLoading } = useAuth();
// 表单状态
const [formData, setFormData] = useState({
email: "",
password: "",
});
// UI状态
const [showPassword, setShowPassword] = useState(false);
const [errors, setErrors] = useState({});
// 处理输入变化
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// 清除对应字段的错误
if (errors[name]) {
setErrors(prev => ({
...prev,
[name]: ""
}));
}
};
// 表单验证
const validateForm = () => {
const newErrors = {};
if (!formData.email) {
newErrors.email = "邮箱是必填项";
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = "请输入有效的邮箱地址";
}
if (!formData.password) {
newErrors.password = "密码是必填项";
} else if (formData.password.length < 6) {
newErrors.password = "密码至少需要6个字符";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 处理表单提交
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
const result = await login(formData.email, formData.password);
if (result.success) {
// 登录成功,跳转到首页
navigate("/home");
}
};
return (
<Box
minH="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg={colorMode === "dark" ? "gray.800" : "gray.50"}
p={4}
>
<Box
w="full"
maxW="md"
p={8}
bg={colorMode === "dark" ? "gray.700" : "white"}
borderRadius="lg"
shadow="xl"
>
<VStack spacing={6}>
<Box textAlign="center">
<Heading size="lg" mb={2}>欢迎回来1</Heading>
<Text color="gray.500">请输入您的凭据登录</Text>
</Box>
<form onSubmit={handleSubmit} style={{ width: "100%" }}>
<VStack spacing={4}>
<FormControl isInvalid={!!errors.email}>
<FormLabel>邮箱地址</FormLabel>
<Input
name="email"
type="email"
placeholder="your@email.com"
value={formData.email}
onChange={handleInputChange}
size="lg"
/>
{errors.email && (
<Text color="red.500" fontSize="sm" mt={1}>
{errors.email}
</Text>
)}
</FormControl>
<FormControl isInvalid={!!errors.password}>
<FormLabel>密码</FormLabel>
<InputGroup size="lg">
<Input
name="password"
type={showPassword ? "text" : "password"}
placeholder="********"
value={formData.password}
onChange={handleInputChange}
/>
<InputRightElement>
<IconButton
aria-label={showPassword ? "隐藏密码" : "显示密码"}
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
variant="ghost"
onClick={() => setShowPassword(!showPassword)}
/>
</InputRightElement>
</InputGroup>
{errors.password && (
<Text color="red.500" fontSize="sm" mt={1}>
{errors.password}
</Text>
)}
</FormControl>
<Button
type="submit"
colorScheme="blue"
w="full"
size="lg"
isLoading={isLoading}
loadingText="登录中..."
>
{isLoading ? <Spinner size="sm" /> : "登录"}
</Button>
</VStack>
</form>
<VStack spacing={3}>
<Text fontSize="sm" textAlign="center">
还没有账户{" "}
<Link
color="blue.500"
onClick={() => navigate("/auth/signup")}
_hover={{ textDecoration: "underline" }}
>
立即注册
</Link>
</Text>
<Box textAlign="center">
<Link
color="gray.500"
fontSize="sm"
_hover={{ color: "blue.500" }}
>
忘记密码
</Link>
<Text color="gray.500" fontSize="sm" mt={2}>
还没有账户{" "}
<Link
color="blue.500"
fontWeight="medium"
_hover={{ textDecoration: "underline" }}
onClick={() => navigate('/auth/sign-up')}
>
立即注册
</Link>
</Text>
</Box>
</VStack>
</VStack>
</Box>
</Box>
);
}

View File

@@ -1,223 +0,0 @@
/*!
=========================================================
* Argon Dashboard Chakra PRO - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
* Designed and Coded by Simmmple & Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
// Chakra imports
import {
Box,
Button,
Flex,
FormControl,
FormLabel,
HStack,
Icon,
Input,
Link,
Switch,
Text,
useColorModeValue,
} from "@chakra-ui/react";
// Assets
import CoverImage from "assets/img/CoverImage.png";
import React from "react";
import { FaApple, FaFacebook, FaGoogle } from "react-icons/fa";
import AuthCover from "layouts/AuthCover";
function SignInCover() {
// Chakra color mode
const textColor = useColorModeValue("gray.400", "white");
const bgForm = useColorModeValue("white", "navy.800");
const titleColor = useColorModeValue("gray.700", "blue.500");
const colorIcons = useColorModeValue("gray.700", "white");
const bgIcons = useColorModeValue("trasnparent", "navy.700");
const bgIconsHover = useColorModeValue("gray.50", "whiteAlpha.100");
return (
<AuthCover image={CoverImage}>
<Flex
w="100%"
h="100%"
alignItems="center"
justifyContent="center"
mb="60px"
mt={{ base: "60px", md: "160px" }}
>
<Flex
zIndex="2"
direction="column"
w="445px"
background="transparent"
borderRadius="15px"
p="40px"
mx={{ base: "100px" }}
mb={{ base: "20px", md: "auto" }}
bg={bgForm}
boxShadow={useColorModeValue(
"0px 5px 14px rgba(0, 0, 0, 0.05)",
"unset"
)}
>
<Text
fontSize="xl"
color={textColor}
fontWeight="bold"
textAlign="center"
mb="22px"
>
Sign In with
</Text>
<HStack spacing="15px" justify="center" mb="22px">
<Flex
justify="center"
align="center"
w="75px"
h="75px"
borderRadius="8px"
border={useColorModeValue("1px solid", "0px")}
borderColor="gray.200"
cursor="pointer"
transition="all .25s ease"
bg={bgIcons}
_hover={{ bg: bgIconsHover }}
>
<Link href="#">
<Icon as={FaFacebook} color={colorIcons} w="30px" h="30px" />
</Link>
</Flex>
<Flex
justify="center"
align="center"
w="75px"
h="75px"
borderRadius="8px"
border={useColorModeValue("1px solid", "0px")}
borderColor="gray.200"
cursor="pointer"
transition="all .25s ease"
bg={bgIcons}
_hover={{ bg: bgIconsHover }}
>
<Link href="#">
<Icon
as={FaApple}
color={colorIcons}
w="30px"
h="30px"
_hover={{ filter: "brightness(120%)" }}
/>
</Link>
</Flex>
<Flex
justify="center"
align="center"
w="75px"
h="75px"
borderRadius="8px"
border={useColorModeValue("1px solid", "0px")}
borderColor="gray.200"
cursor="pointer"
transition="all .25s ease"
bg={bgIcons}
_hover={{ bg: bgIconsHover }}
>
<Link href="#">
<Icon
as={FaGoogle}
color={colorIcons}
w="30px"
h="30px"
_hover={{ filter: "brightness(120%)" }}
/>
</Link>
</Flex>
</HStack>
<Text
fontSize="lg"
color="gray.400"
fontWeight="bold"
textAlign="center"
mb="22px"
>
or
</Text>
<FormControl>
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
Name
</FormLabel>
<Input
variant="auth"
fontSize="sm"
ms="4px"
type="text"
placeholder="Your full name"
mb="24px"
size="lg"
/>
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
Password
</FormLabel>
<Input
variant="auth"
fontSize="sm"
ms="4px"
type="password"
placeholder="Your password"
mb="24px"
size="lg"
/>
<FormControl display="flex" alignItems="center" mb="24px">
<Switch id="remember-login" colorScheme="blue" me="10px" />
<FormLabel htmlFor="remember-login" mb="0" fontWeight="normal">
Remember me
</FormLabel>
</FormControl>
<Button
fontSize="10px"
variant="dark"
fontWeight="bold"
w="100%"
h="45"
mb="24px"
>
SIGN IN
</Button>
</FormControl>
<Flex
flexDirection="column"
justifyContent="center"
alignItems="center"
maxW="100%"
mt="0px"
>
<Text color={textColor} fontWeight="medium">
Dont have an account?
<Link
color={titleColor}
as="span"
ms="5px"
href="#"
fontWeight="bold"
>
Sign up
</Link>
</Text>
</Flex>
</Flex>
</Flex>
</AuthCover>
);
}
export default SignInCover;

View File

@@ -1,538 +0,0 @@
// src/views/Authentication/SignIn/SignInIllustration.js - Session版本
import React, { useState, useEffect, useRef } from "react";
import {
Box,
Button,
Flex,
FormControl,
Input,
Text,
Heading,
VStack,
HStack,
useToast,
Icon,
InputGroup,
InputRightElement,
IconButton,
Link as ChakraLink,
Center,
useDisclosure,
FormErrorMessage
} from "@chakra-ui/react";
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
import { FaMobile, FaLock } from "react-icons/fa";
import { useNavigate, useLocation } from "react-router-dom";
import { useAuth } from "../../../contexts/AuthContext";
import PrivacyPolicyModal from "../../../components/PrivacyPolicyModal";
import UserAgreementModal from "../../../components/UserAgreementModal";
import AuthBackground from "../../../components/Auth/AuthBackground";
import AuthHeader from "../../../components/Auth/AuthHeader";
import AuthFooter from "../../../components/Auth/AuthFooter";
import VerificationCodeInput from "../../../components/Auth/VerificationCodeInput";
import WechatRegister from "../../../components/Auth/WechatRegister";
import { logger } from "../../../utils/logger";
export default function SignInIllustration() {
const navigate = useNavigate();
const location = useLocation();
const toast = useToast();
const { login, checkSession } = useAuth();
// 追踪组件挂载状态,防止内存泄漏
const isMountedRef = useRef(true);
// 页面状态
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState({});
// 检查URL参数中的错误信息微信登录失败时
useEffect(() => {
const params = new URLSearchParams(location.search);
const error = params.get('error');
if (error) {
let errorMessage = '登录失败';
switch (error) {
case 'wechat_auth_failed':
errorMessage = '微信授权失败';
break;
case 'session_expired':
errorMessage = '会话已过期,请重新登录';
break;
case 'token_failed':
errorMessage = '获取微信授权失败';
break;
case 'userinfo_failed':
errorMessage = '获取用户信息失败';
break;
case 'login_failed':
errorMessage = '登录处理失败,请重试';
break;
default:
errorMessage = '登录失败,请重试';
}
toast({
title: "登录失败",
description: errorMessage,
status: "error",
duration: 5000,
isClosable: true,
});
// 清除URL参数
const newUrl = window.location.pathname;
window.history.replaceState({}, document.title, newUrl);
}
}, [location, toast]);
// 传统登录数据
// 表单数据初始化
const [formData, setFormData] = useState({
username: "", // 用户名称
email: "", // 邮箱
phone: "", // 电话
password: "", // 密码
verificationCode: "", // 添加验证码字段
});
// 验证码登录状态 是否开启验证码
const [useVerificationCode, setUseVerificationCode] = useState(false);
// 密码展示状态
const [showPassword, setShowPassword] = useState(false);
const [verificationCodeSent, setVerificationCodeSent] = useState(false); // 验证码发送状态
const [sendingCode, setSendingCode] = useState(false); // 发送验证码状态
// 隐私政策弹窗状态
const { isOpen: isPrivacyModalOpen, onOpen: onPrivacyModalOpen, onClose: onPrivacyModalClose } = useDisclosure();
// 用户协议弹窗状态
const { isOpen: isUserAgreementModalOpen, onOpen: onUserAgreementModalOpen, onClose: onUserAgreementModalClose } = useDisclosure();
// 输入框输入
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
// ========== 发送验证码逻辑 =============
// 倒计时效果
const [countdown, setCountdown] = useState(0);
useEffect(() => {
let timer;
let isMounted = true;
if (countdown > 0) {
timer = setInterval(() => {
if (isMounted) {
setCountdown(prev => prev - 1);
}
}, 1000);
} else if (countdown === 0 && isMounted) {
setVerificationCodeSent(false);
}
return () => {
isMounted = false;
if (timer) clearInterval(timer);
};
}, [countdown]);
// 发送验证码
const sendVerificationCode = async () => {
const credential = formData.phone;
const type = 'phone';
if (!credential) {
toast({
title: "请先输入手机号",
status: "warning",
duration: 3000,
});
return;
}
// 基本格式验证
if (!/^1[3-9]\d{9}$/.test(credential)) {
toast({
title: "请输入有效的手机号",
status: "warning",
duration: 3000,
});
return;
}
try {
setSendingCode(true);
const response = await fetch('/api/auth/send-verification-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
credential,
type,
purpose: 'login'
}),
});
// ✅ 安全检查:验证 response 存在
if (!response) {
throw new Error('网络请求失败,请检查网络连接');
}
const data = await response.json();
// 组件卸载后不再执行后续操作
if (!isMountedRef.current) return;
// ✅ 安全检查:验证 data 存在
if (!data) {
throw new Error('服务器响应为空');
}
if (response.ok && data.success) {
toast({
title: "验证码已发送",
description: "验证码已发送到您的手机号",
status: "success",
duration: 3000,
});
setVerificationCodeSent(true);
setCountdown(60); // 60秒倒计时
} else {
throw new Error(data.error || '发送验证码失败');
}
} catch (error) {
if (isMountedRef.current) {
toast({
title: "发送验证码失败",
description: error.message || "请稍后重试",
status: "error",
duration: 3000,
});
}
} finally {
if (isMountedRef.current) {
setSendingCode(false);
}
}
};
// 验证码登录函数
const loginWithVerificationCode = async (credential, verificationCode, authLoginType) => {
try {
const response = await fetch('/api/auth/login-with-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
credential,
verification_code: verificationCode,
login_type: authLoginType
}),
});
// ✅ 安全检查:验证 response 存在
if (!response) {
throw new Error('网络请求失败,请检查网络连接');
}
const data = await response.json();
// 组件卸载后不再执行后续操作
if (!isMountedRef.current) {
return { success: false, error: '操作已取消' };
}
// ✅ 安全检查:验证 data 存在
if (!data) {
throw new Error('服务器响应为空');
}
if (response.ok && data.success) {
// 更新认证状态
await checkSession();
if (isMountedRef.current) {
toast({
title: "登录成功",
description: "欢迎回来!",
status: "success",
duration: 3000,
});
}
return { success: true };
} else {
throw new Error(data.error || '验证码登录失败');
}
} catch (error) {
if (isMountedRef.current) {
toast({
title: "登录失败",
description: error.message || "请检查验证码是否正确",
status: "error",
duration: 3000,
});
}
return { success: false, error: error.message };
}
};
// 传统行业登陆
const handleTraditionalLogin = async (e) => {
e.preventDefault();
setIsLoading(true);
try {
const credential = formData.phone;
const authLoginType = 'phone';
if (useVerificationCode) { // 验证码登陆
if (!credential || !formData.verificationCode) {
toast({
title: "请填写完整信息",
description: "手机号和验证码不能为空",
status: "warning",
duration: 3000,
});
return;
}
const result = await loginWithVerificationCode(credential, formData.verificationCode, authLoginType);
if (result.success) {
navigate("/home");
}
} else { // 密码登陆
if (!credential || !formData.password) {
toast({
title: "请填写完整信息",
description: `手机号和密码不能为空`,
status: "warning",
duration: 3000,
});
return;
}
const result = await login(credential, formData.password, authLoginType);
if (result.success) {
// ✅ 显示成功提示
toast({
title: "登录成功",
description: "欢迎回来!",
status: "success",
duration: 3000,
isClosable: true,
});
navigate("/home");
} else {
// ❌ 显示错误提示
toast({
title: "登录失败",
description: result.error || "请检查您的登录信息",
status: "error",
duration: 3000,
isClosable: true,
});
}
}
} catch (error) {
logger.error('SignInIllustration', 'handleTraditionalLogin', error, {
phone: formData.phone ? formData.phone.substring(0, 3) + '****' + formData.phone.substring(7) : 'N/A',
useVerificationCode,
loginType: 'phone'
});
toast({
title: "登录失败",
description: error.message || "发生未预期的错误,请重试",
status: "error",
duration: 3000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
// 切换登录方式
const handleChangeMethod = () => {
setUseVerificationCode(!useVerificationCode);
// 切换到密码模式时清空验证码
if (useVerificationCode) {
setFormData(prev => ({ ...prev, verificationCode: "" }));
}
};
// 组件卸载时清理
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
return (
<Flex minH="100vh" position="relative" overflow="hidden">
{/* 背景 */}
<AuthBackground />
{/* 主要内容 */}
<Flex width="100%" align="center" justify="center" position="relative" zIndex={1} px={6} py={12}>
{/* 登录卡片 */}
<Box bg="white" borderRadius="2xl" boxShadow="2xl" p={8} width="100%" maxW="800px" backdropFilter="blur(20px)" border="1px solid rgba(255, 255, 255, 0.2)">
{/* 头部区域 */}
<AuthHeader title="欢迎回来" subtitle="登录价值前沿,继续您的投资之旅" />
{/* 左右布局 */}
<HStack spacing={8} align="stretch">
{/* 左侧:手机号登陆 - 80% 宽度 */}
<Box flex="4">
<form onSubmit={handleTraditionalLogin}>
<VStack spacing={4}>
<Heading size="md" color="gray.700" alignSelf="flex-start">
手机号登陆
</Heading>
<FormControl isRequired isInvalid={!!errors.phone}>
<Input
name="phone"
value={formData.phone}
onChange={handleInputChange}
placeholder="请输入11位手机号"
pr="2.5rem"
/>
<FormErrorMessage>{errors.phone}</FormErrorMessage>
</FormControl>
{/* 密码/验证码输入框 */}
{useVerificationCode ? (
<VerificationCodeInput
value={formData.verificationCode}
onChange={handleInputChange}
onSendCode={sendVerificationCode}
countdown={countdown}
isLoading={isLoading}
isSending={sendingCode}
error={errors.verificationCode}
colorScheme="green"
/>
) : (
<FormControl isRequired isInvalid={!!errors.password}>
<InputGroup>
<Input
name="password"
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={handleInputChange}
pr="3rem"
placeholder="请输入密码"
_focus={{
borderColor: "blue.500",
boxShadow: "0 0 0 1px #667eea"
}}
/>
<InputRightElement width="3rem">
<IconButton
size="sm"
variant="ghost"
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? "Hide password" : "Show password"}
/>
</InputRightElement>
</InputGroup>
<FormErrorMessage>{errors.password}</FormErrorMessage>
</FormControl>
)}
<AuthFooter
linkText="还没有账号,"
linkLabel="去注册"
linkTo="/auth/sign-up"
useVerificationCode={useVerificationCode}
onSwitchMethod={handleChangeMethod}
/>
<Button
type="submit"
width="100%"
size="lg"
colorScheme="green"
color="white"
borderRadius="lg"
_hover={{
transform: "translateY(-2px)",
boxShadow: "lg"
}}
_active={{ transform: "translateY(0)" }}
isLoading={isLoading}
loadingText="登录中..."
fontWeight="bold"
cursor={"pointer"}
>
<Icon as={FaLock} mr={2} />登录
</Button>
</VStack>
</form>
</Box>
{/* 右侧:微信登陆 - 20% 宽度 */}
<Box flex="1">
<Center width="100%" bg="gray.50" borderRadius="lg" p={8}>
<WechatRegister />
</Center>
</Box>
</HStack>
{/* 底部链接 */}
<VStack spacing={4} mt={6}>
{/* 协议同意勾选框 */}
<Text fontSize="sm" color="gray.600">
注册登录即表示阅读并同意{" "}
<ChakraLink
color="blue.500"
fontSize="sm"
onClick={onUserAgreementModalOpen}
textDecoration="underline"
_hover={{ color: "blue.600" }}
>
用户协议
</ChakraLink>
{" "}{" "}
<ChakraLink
color="blue.500"
fontSize="sm"
onClick={onPrivacyModalOpen}
textDecoration="underline"
_hover={{ color: "blue.600" }}
>
隐私政策
</ChakraLink>
</Text>
</VStack>
</Box>
</Flex>
{/* 隐私政策弹窗 */}
<PrivacyPolicyModal isOpen={isPrivacyModalOpen} onClose={onPrivacyModalClose} />
{/* 用户协议弹窗 */}
<UserAgreementModal isOpen={isUserAgreementModalOpen} onClose={onUserAgreementModalClose} />
</Flex >
);
}

View File

@@ -1,254 +0,0 @@
/*!
=========================================================
* Argon Dashboard Chakra PRO - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
* Designed and Coded by Simmmple & Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
/*!
=========================================================
* Argon Dashboard Chakra PRO - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
* Designed and Coded by Simmmple & Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
// Chakra imports
import React, { useState } from "react";
import {
Box,
Button,
Flex,
FormControl,
FormLabel,
Heading,
Input,
Stack,
useColorModeValue,
Text,
Link,
InputGroup,
InputRightElement,
IconButton,
useToast,
Checkbox,
} from "@chakra-ui/react";
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
import { useNavigate } from "react-router-dom";
export default function SignUpBasic() {
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState({
username: "",
email: "",
password: "",
confirmPassword: "",
agreeToTerms: false,
});
const navigate = useNavigate();
const toast = useToast();
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === "checkbox" ? checked : value
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
if (formData.password !== formData.confirmPassword) {
toast({
title: "密码不匹配",
description: "请确保两次输入的密码相同",
status: "error",
duration: 3000,
isClosable: true,
});
return;
}
if (!formData.agreeToTerms) {
toast({
title: "请同意条款",
description: "请阅读并同意用户协议和隐私政策",
status: "error",
duration: 3000,
isClosable: true,
});
return;
}
setIsLoading(true);
// 模拟注册过程
setTimeout(() => {
setIsLoading(false);
toast({
title: "注册成功",
description: "欢迎加入价值前沿投资助手",
status: "success",
duration: 3000,
isClosable: true,
});
navigate("/home");
}, 1500);
};
return (
<Flex minH="100vh" align="center" justify="center" bg={useColorModeValue("gray.50", "gray.900")}>
<Stack spacing={8} mx="auto" maxW="lg" py={12} px={6}>
<Stack align="center">
<Heading fontSize="4xl" color="blue.600">
价小前投研
</Heading>
<Text fontSize="lg" color="gray.600">
创建您的账户
</Text>
</Stack>
<Box
rounded="lg"
bg={useColorModeValue("white", "gray.700")}
boxShadow="lg"
p={8}
>
<form onSubmit={handleSubmit}>
<Stack spacing={4}>
<FormControl id="username" isRequired>
<FormLabel>用户名</FormLabel>
<Input
type="text"
name="username"
value={formData.username}
onChange={handleInputChange}
placeholder="请输入您的用户名"
/>
</FormControl>
<FormControl id="email" isRequired>
<FormLabel>邮箱地址</FormLabel>
<Input
type="email"
name="email"
value={formData.email}
onChange={handleInputChange}
placeholder="请输入您的邮箱"
/>
</FormControl>
<FormControl id="password" isRequired>
<FormLabel>密码</FormLabel>
<InputGroup>
<Input
type={showPassword ? "text" : "password"}
name="password"
value={formData.password}
onChange={handleInputChange}
placeholder="请输入您的密码"
/>
<InputRightElement>
<IconButton
aria-label={showPassword ? "隐藏密码" : "显示密码"}
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
onClick={() => setShowPassword(!showPassword)}
variant="ghost"
size="sm"
/>
</InputRightElement>
</InputGroup>
</FormControl>
<FormControl id="confirmPassword" isRequired>
<FormLabel>确认密码</FormLabel>
<InputGroup>
<Input
type={showConfirmPassword ? "text" : "password"}
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
placeholder="请再次输入您的密码"
/>
<InputRightElement>
<IconButton
aria-label={showConfirmPassword ? "隐藏密码" : "显示密码"}
icon={showConfirmPassword ? <ViewOffIcon /> : <ViewIcon />}
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
variant="ghost"
size="sm"
/>
</InputRightElement>
</InputGroup>
</FormControl>
<FormControl id="agreeToTerms">
<Checkbox
name="agreeToTerms"
isChecked={formData.agreeToTerms}
onChange={handleInputChange}
colorScheme="blue"
>
<Text fontSize="sm">
我已阅读并同意{" "}
<Link color="blue.600" href="#" isExternal>
用户协议
</Link>{" "}
{" "}
<Link color="blue.600" href="#" isExternal>
隐私政策
</Link>
</Text>
</Checkbox>
</FormControl>
<Stack spacing={10}>
<Button
type="submit"
bg="blue.600"
color="white"
_hover={{
bg: "blue.700",
}}
isLoading={isLoading}
loadingText="注册中..."
>
注册
</Button>
</Stack>
<Stack pt={6}>
<Text align="center">
已有账户?{" "}
<Link color="blue.600" onClick={() => navigate("/auth/signin")}>
立即登录
</Link>
</Text>
</Stack>
</Stack>
</form>
</Box>
</Stack>
</Flex>
);
}

View File

@@ -1,282 +0,0 @@
import React, { useState } from "react";
import {
Box,
Button,
FormControl,
FormLabel,
Input,
VStack,
Heading,
Text,
Link,
useColorMode,
InputGroup,
InputRightElement,
IconButton,
Spinner,
Checkbox,
HStack,
} from "@chakra-ui/react";
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../../../contexts/AuthContext";
export default function SignUpCentered() {
const { colorMode } = useColorMode();
const navigate = useNavigate();
const { register, isLoading } = useAuth();
// 表单状态
const [formData, setFormData] = useState({
name: "",
email: "",
password: "",
confirmPassword: "",
});
// UI状态
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [errors, setErrors] = useState({});
const [agreedToTerms, setAgreedToTerms] = useState(false);
// 处理输入变化
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// 清除对应字段的错误
if (errors[name]) {
setErrors(prev => ({
...prev,
[name]: ""
}));
}
};
// 表单验证
const validateForm = () => {
const newErrors = {};
if (!formData.name.trim()) {
newErrors.name = "姓名是必填项";
} else if (formData.name.trim().length < 2) {
newErrors.name = "姓名至少需要2个字符";
}
if (!formData.email) {
newErrors.email = "邮箱是必填项";
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = "请输入有效的邮箱地址";
}
if (!formData.password) {
newErrors.password = "密码是必填项";
} else if (formData.password.length < 6) {
newErrors.password = "密码至少需要6个字符";
} else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(formData.password)) {
newErrors.password = "密码必须包含大小写字母和数字";
}
if (!formData.confirmPassword) {
newErrors.confirmPassword = "请确认密码";
} else if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = "两次输入的密码不一致";
}
if (!agreedToTerms) {
newErrors.terms = "请同意服务条款和隐私政策";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 处理表单提交
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
const result = await register(
formData.name, // username
formData.email,
formData.password
);
if (result.success) {
// 注册成功,跳转到首页
navigate("/home");
}
};
return (
<Box
minH="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg={colorMode === "dark" ? "gray.800" : "gray.50"}
p={4}
>
<Box
w="full"
maxW="md"
p={8}
bg={colorMode === "dark" ? "gray.700" : "white"}
borderRadius="lg"
shadow="xl"
>
<VStack spacing={6}>
<Box textAlign="center">
<Heading size="lg" mb={2}>创建账户</Heading>
<Text color="gray.500">加入价值前沿开启智能投资之旅</Text>
</Box>
<form onSubmit={handleSubmit} style={{ width: "100%" }}>
<VStack spacing={4}>
<FormControl isInvalid={!!errors.name}>
<FormLabel>姓名</FormLabel>
<Input
name="name"
placeholder="您的姓名"
value={formData.name}
onChange={handleInputChange}
size="lg"
/>
{errors.name && (
<Text color="red.500" fontSize="sm" mt={1}>
{errors.name}
</Text>
)}
</FormControl>
<FormControl isInvalid={!!errors.email}>
<FormLabel>邮箱地址</FormLabel>
<Input
name="email"
type="email"
placeholder="your@email.com"
value={formData.email}
onChange={handleInputChange}
size="lg"
/>
{errors.email && (
<Text color="red.500" fontSize="sm" mt={1}>
{errors.email}
</Text>
)}
</FormControl>
<FormControl isInvalid={!!errors.password}>
<FormLabel>密码</FormLabel>
<InputGroup size="lg">
<Input
name="password"
type={showPassword ? "text" : "password"}
placeholder="********"
value={formData.password}
onChange={handleInputChange}
/>
<InputRightElement>
<IconButton
aria-label={showPassword ? "隐藏密码" : "显示密码"}
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
variant="ghost"
onClick={() => setShowPassword(!showPassword)}
/>
</InputRightElement>
</InputGroup>
{errors.password && (
<Text color="red.500" fontSize="sm" mt={1}>
{errors.password}
</Text>
)}
</FormControl>
<FormControl isInvalid={!!errors.confirmPassword}>
<FormLabel>确认密码</FormLabel>
<InputGroup size="lg">
<Input
name="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
placeholder="********"
value={formData.confirmPassword}
onChange={handleInputChange}
/>
<InputRightElement>
<IconButton
aria-label={showConfirmPassword ? "隐藏密码" : "显示密码"}
icon={showConfirmPassword ? <ViewOffIcon /> : <ViewIcon />}
variant="ghost"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
/>
</InputRightElement>
</InputGroup>
{errors.confirmPassword && (
<Text color="red.500" fontSize="sm" mt={1}>
{errors.confirmPassword}
</Text>
)}
</FormControl>
<FormControl isInvalid={!!errors.terms}>
<HStack spacing={3}>
<Checkbox
isChecked={agreedToTerms}
onChange={(e) => setAgreedToTerms(e.target.checked)}
colorScheme="blue"
>
<Text fontSize="sm">
我同意{" "}
<Link color="blue.500" _hover={{ textDecoration: "underline" }}>
服务条款
</Link>
{" "}{" "}
<Link color="blue.500" _hover={{ textDecoration: "underline" }}>
隐私政策
</Link>
</Text>
</Checkbox>
</HStack>
{errors.terms && (
<Text color="red.500" fontSize="sm" mt={1}>
{errors.terms}
</Text>
)}
</FormControl>
<Button
type="submit"
colorScheme="blue"
w="full"
size="lg"
isLoading={isLoading}
loadingText="注册中..."
>
{isLoading ? <Spinner size="sm" /> : "创建账户"}
</Button>
</VStack>
</form>
<VStack spacing={3}>
<Text fontSize="sm" textAlign="center">
已有账户{" "}
<Link
color="blue.500"
onClick={() => navigate("/auth/signin")}
_hover={{ textDecoration: "underline" }}
>
立即登录
</Link>
</Text>
</VStack>
</VStack>
</Box>
</Box>
);
}

View File

@@ -1,234 +0,0 @@
/*!
=========================================================
* Argon Dashboard Chakra PRO - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
* Designed and Coded by Simmmple & Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
// Chakra imports
import {
Button,
Flex,
FormControl,
FormLabel,
HStack,
Icon,
Input,
Link,
Switch,
Text,
useColorModeValue,
} from "@chakra-ui/react";
// Assets
import CoverImage from "assets/img/CoverImage.png";
import React from "react";
import { FaApple, FaFacebook, FaGoogle } from "react-icons/fa";
import AuthCover from "layouts/AuthCover";
function SignUpCover() {
// Chakra color mode
const textColor = useColorModeValue("gray.400", "white");
const bgForm = useColorModeValue("white", "navy.800");
const titleColor = useColorModeValue("gray.700", "blue.500");
const colorIcons = useColorModeValue("gray.700", "white");
const bgIcons = useColorModeValue("trasnparent", "navy.700");
const bgIconsHover = useColorModeValue("gray.50", "whiteAlpha.100");
return (
<AuthCover image={CoverImage}>
<Flex
w="100%"
h="100%"
alignItems="center"
justifyContent="center"
mb="60px"
mt={{ base: "60px", md: "160px" }}
>
<Flex
zIndex="2"
direction="column"
w="445px"
background="transparent"
borderRadius="15px"
p="40px"
mx={{ base: "100px" }}
mb={{ base: "20px", md: "auto" }}
bg={bgForm}
boxShadow={useColorModeValue(
"0px 5px 14px rgba(0, 0, 0, 0.05)",
"unset"
)}
>
<Text
fontSize="xl"
color={textColor}
fontWeight="bold"
textAlign="center"
mb="22px"
>
Sign In with
</Text>
<HStack spacing="15px" justify="center" mb="22px">
<Flex
justify="center"
align="center"
w="75px"
h="75px"
borderRadius="8px"
border={useColorModeValue("1px solid", "0px")}
borderColor="gray.200"
cursor="pointer"
transition="all .25s ease"
bg={bgIcons}
_hover={{ bg: bgIconsHover }}
>
<Link href="#">
<Icon as={FaFacebook} color={colorIcons} w="30px" h="30px" />
</Link>
</Flex>
<Flex
justify="center"
align="center"
w="75px"
h="75px"
borderRadius="8px"
border={useColorModeValue("1px solid", "0px")}
borderColor="gray.200"
cursor="pointer"
transition="all .25s ease"
bg={bgIcons}
_hover={{ bg: bgIconsHover }}
>
<Link href="#">
<Icon
as={FaApple}
color={colorIcons}
w="30px"
h="30px"
_hover={{ filter: "brightness(120%)" }}
/>
</Link>
</Flex>
<Flex
justify="center"
align="center"
w="75px"
h="75px"
borderRadius="8px"
border={useColorModeValue("1px solid", "0px")}
borderColor="gray.200"
cursor="pointer"
transition="all .25s ease"
bg={bgIcons}
_hover={{ bg: bgIconsHover }}
>
<Link href="#">
<Icon
as={FaGoogle}
color={colorIcons}
w="30px"
h="30px"
_hover={{ filter: "brightness(120%)" }}
/>
</Link>
</Flex>
</HStack>
<Text
fontSize="lg"
color="gray.400"
fontWeight="bold"
textAlign="center"
mb="22px"
>
or
</Text>
<FormControl>
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
Name
</FormLabel>
<Input
variant="auth"
fontSize="sm"
ms="4px"
type="text"
placeholder="Your full name"
mb="24px"
size="lg"
/>
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
Email
</FormLabel>
<Input
variant="auth"
fontSize="sm"
ms="4px"
type="email"
placeholder="Your full email adress"
mb="24px"
size="lg"
/>
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
Password
</FormLabel>
<Input
variant="auth"
fontSize="sm"
ms="4px"
type="password"
placeholder="Your password"
mb="24px"
size="lg"
/>
<FormControl display="flex" alignItems="center" mb="24px">
<Switch id="remember-login" colorScheme="blue" me="10px" />
<FormLabel htmlFor="remember-login" mb="0" fontWeight="normal">
Remember me
</FormLabel>
</FormControl>
<Button
fontSize="10px"
variant="dark"
fontWeight="bold"
w="100%"
h="45"
mb="24px"
>
SIGN IN
</Button>
</FormControl>
<Flex
flexDirection="column"
justifyContent="center"
alignItems="center"
maxW="100%"
mt="0px"
>
<Text color={textColor} fontWeight="medium">
Dont have an account?
<Link
color={titleColor}
as="span"
ms="5px"
href="#"
fontWeight="bold"
>
Sign up
</Link>
</Text>
</Flex>
</Flex>
</Flex>
</AuthCover>
);
}
export default SignUpCover;

View File

@@ -1,445 +0,0 @@
// src\views\Authentication\SignUp/SignUpIllustration.js
import React, { useState, useEffect, useRef } from "react";
import { getApiBase } from '../../../utils/apiConfig';
import {
Box,
Button,
Flex,
FormControl,
Input,
Text,
Heading,
VStack,
HStack,
useToast,
InputGroup,
InputRightElement,
IconButton,
Center,
FormErrorMessage,
Link as ChakraLink,
useDisclosure
} from "@chakra-ui/react";
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
import { useNavigate } from "react-router-dom";
import axios from "axios";
import AuthBackground from '../../../components/Auth/AuthBackground';
import AuthHeader from '../../../components/Auth/AuthHeader';
import AuthFooter from '../../../components/Auth/AuthFooter';
import VerificationCodeInput from '../../../components/Auth/VerificationCodeInput';
import WechatRegister from '../../../components/Auth/WechatRegister';
import PrivacyPolicyModal from '../../../components/PrivacyPolicyModal';
import UserAgreementModal from '../../../components/UserAgreementModal';
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = getApiBase();
export default function SignUpPage() {
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [countdown, setCountdown] = useState(0);
const [errors, setErrors] = useState({});
const [formData, setFormData] = useState({
username: "",
email: "",
phone: "",
password: "",
confirmPassword: "",
verificationCode: ""
});
const navigate = useNavigate();
const toast = useToast();
// 追踪组件挂载状态,防止内存泄漏
const isMountedRef = useRef(true);
// 隐私政策弹窗状态
const { isOpen: isPrivacyModalOpen, onOpen: onPrivacyModalOpen, onClose: onPrivacyModalClose } = useDisclosure();
// 用户协议弹窗状态
const { isOpen: isUserAgreementModalOpen, onOpen: onUserAgreementModalOpen, onClose: onUserAgreementModalClose } = useDisclosure();
// 验证码登录状态 是否开启验证码
const [useVerificationCode, setUseVerificationCode] = useState(false);
// 切换注册方式
const handleChangeMethod = () => {
setUseVerificationCode(!useVerificationCode);
// 切换到密码模式时清空验证码
if (useVerificationCode) {
setFormData(prev => ({ ...prev, verificationCode: "" }));
}
};
// 发送验证码
const sendVerificationCode = async () => {
const contact = formData.phone;
const endpoint = "send-sms-code";
const fieldName = "phone";
if (!contact) {
toast({
title: "请输入手机号",
status: "warning",
duration: 2000,
});
return;
}
if (!/^1[3-9]\d{9}$/.test(contact)) {
toast({
title: "请输入正确的手机号",
status: "warning",
duration: 2000,
});
return;
}
try {
setIsLoading(true);
const response = await axios.post(`${API_BASE_URL}/api/auth/${endpoint}`, {
[fieldName]: contact
}, {
timeout: 10000 // 添加10秒超时
});
// 组件卸载后不再执行后续操作
if (!isMountedRef.current) return;
// ✅ 安全检查:验证 response 和 data 存在
if (!response || !response.data) {
throw new Error('服务器响应为空');
}
toast({
title: "验证码已发送",
description: "请查收短信",
status: "success",
duration: 3000,
});
setCountdown(60);
} catch (error) {
if (isMountedRef.current) {
toast({
title: "发送失败",
description: error.response?.data?.error || error.message || "请稍后重试",
status: "error",
duration: 3000,
});
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
};
// 倒计时效果
useEffect(() => {
let isMounted = true;
if (countdown > 0) {
const timer = setTimeout(() => {
if (isMounted) {
setCountdown(countdown - 1);
}
}, 1000);
return () => {
isMounted = false;
clearTimeout(timer);
};
}
}, [countdown]);
// 表单验证
const validateForm = () => {
const newErrors = {};
// 手机号验证(两种方式都需要)
if (!formData.phone) {
newErrors.phone = "请输入手机号";
} else if (!/^1[3-9]\d{9}$/.test(formData.phone)) {
newErrors.phone = "请输入正确的手机号";
}
if (useVerificationCode) {
// 验证码注册方式:只验证手机号和验证码
if (!formData.verificationCode) {
newErrors.verificationCode = "请输入验证码";
}
} else {
// 密码注册方式:验证用户名、密码和确认密码
if (!formData.password || formData.password.length < 6) {
newErrors.password = "密码至少6个字符";
}
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = "两次密码不一致";
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 处理注册提交
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setIsLoading(true);
try {
let endpoint, data;
if (useVerificationCode) {
// 验证码注册:只发送手机号和验证码
endpoint = "/api/auth/register/phone-code";
data = {
phone: formData.phone,
code: formData.verificationCode
};
} else {
// 密码注册:发送手机号、用户名和密码
endpoint = "/api/auth/register/phone";
data = {
phone: formData.phone,
username: formData.username,
password: formData.password
};
}
const response = await axios.post(`${API_BASE_URL}${endpoint}`, data, {
timeout: 10000 // 添加10秒超时
});
// 组件卸载后不再执行后续操作
if (!isMountedRef.current) return;
// ✅ 安全检查:验证 response 和 data 存在
if (!response || !response.data) {
throw new Error('注册请求失败:服务器响应为空');
}
toast({
title: "注册成功",
description: "即将跳转到登录页面",
status: "success",
duration: 2000,
});
setTimeout(() => {
if (isMountedRef.current) {
navigate("/auth/sign-in");
}
}, 2000);
} catch (error) {
if (isMountedRef.current) {
toast({
title: "注册失败",
description: error.response?.data?.error || error.message || "请稍后重试",
status: "error",
duration: 3000,
});
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: "" }));
}
};
// 组件卸载时清理
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// 公用的用户名和密码输入框组件
const commonAuthFields = (
<VStack spacing={4} width="100%">
<FormControl isRequired isInvalid={!!errors.password}>
<InputGroup>
<Input
name="password"
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={handleInputChange}
placeholder="设置密码至少6个字符"
pr="3rem"
/>
<InputRightElement width="3rem">
<IconButton
size="sm"
variant="ghost"
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? "Hide password" : "Show password"}
/>
</InputRightElement>
</InputGroup>
<FormErrorMessage>{errors.password}</FormErrorMessage>
</FormControl>
<FormControl isRequired isInvalid={!!errors.confirmPassword}>
<Input
name="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={handleInputChange}
placeholder="再次输入密码"
/>
<FormErrorMessage>{errors.confirmPassword}</FormErrorMessage>
</FormControl>
</VStack>
);
return (
<Flex minH="100vh" position="relative" overflow="hidden">
{/* 背景 */}
<AuthBackground />
{/* 主要内容 */}
<Flex width="100%" align="center" justify="center" position="relative" zIndex={1} px={6} py={12}>
<Box bg="white" borderRadius="2xl" boxShadow="2xl" p={8} width="100%" maxW="800px" backdropFilter="blur(20px)" border="1px solid rgba(255, 255, 255, 0.2)">
{/* 头部区域 */}
<AuthHeader title="创建账户" subtitle="加入价值前沿,开启投资新征程" />
{/* 左右布局 */}
<HStack spacing={8} align="stretch">
{/* 左侧:手机号注册 - 80% 宽度 */}
<Box flex="4">
<form onSubmit={handleSubmit}>
<VStack spacing={4}>
<Heading size="md" color="gray.700" alignSelf="flex-start">
注册
</Heading>
<FormControl isRequired isInvalid={!!errors.phone}>
<Input
name="phone"
value={formData.phone}
onChange={handleInputChange}
placeholder="请输入11位手机号"
pr="2.5rem"
/>
<FormErrorMessage>{errors.phone}</FormErrorMessage>
</FormControl>
{/* 表单字段区域 */}
<Box width="100%">
{
useVerificationCode ? (
<VStack spacing={4} width="100%">
<VerificationCodeInput
value={formData.verificationCode}
onChange={handleInputChange}
onSendCode={sendVerificationCode}
countdown={countdown}
isLoading={isLoading}
isSending={isLoading && countdown === 0}
error={errors.verificationCode}
colorScheme="green"
/>
{/* 隐藏的占位元素,保持与密码模式相同的高度 */}
<Box height="40px" width="100%" visibility="hidden" />
</VStack>
) : (
<>
{commonAuthFields}
</>
)
}
</Box>
<AuthFooter
linkText="已有账号?"
linkLabel="去登录"
linkTo="/auth/sign-in"
useVerificationCode={useVerificationCode}
onSwitchMethod={handleChangeMethod}
/>
<Button
type="submit"
width="100%"
size="lg"
colorScheme="green"
color="white"
borderRadius="lg"
_hover={{
transform: "translateY(-2px)",
boxShadow: "lg"
}}
_active={{ transform: "translateY(0)" }}
isLoading={isLoading}
loadingText="注册中..."
fontWeight="bold"
cursor="pointer"
>
注册
</Button>
{/* 协议同意文本 */}
<Text fontSize="sm" color="gray.600" textAlign="center" width="100%">
注册登录即表示阅读并同意{" "}
<ChakraLink
color="blue.500"
fontSize="sm"
onClick={onUserAgreementModalOpen}
textDecoration="underline"
_hover={{ color: "blue.600" }}
cursor="pointer"
>
用户协议
</ChakraLink>
{" "}{" "}
<ChakraLink
color="blue.500"
fontSize="sm"
onClick={onPrivacyModalOpen}
textDecoration="underline"
_hover={{ color: "blue.600" }}
cursor="pointer"
>
隐私政策
</ChakraLink>
</Text>
</VStack>
</form>
</Box>
{/* 右侧:微信注册 - 20% 宽度 */}
<Box flex="1">
<Center width="100%" bg="gray.50" borderRadius="lg" p={8}>
<WechatRegister />
</Center>
</Box>
</HStack>
</Box>
</Flex>
{/* 隐私政策弹窗 */}
<PrivacyPolicyModal isOpen={isPrivacyModalOpen} onClose={onPrivacyModalClose} />
{/* 用户协议弹窗 */}
<UserAgreementModal isOpen={isUserAgreementModalOpen} onClose={onUserAgreementModalClose} />
</Flex>
);
}

View File

@@ -0,0 +1,259 @@
// src/views/Community/components/DynamicNewsCard.js
// 横向滚动事件卡片组件(实时要闻·动态追踪)
import React, { forwardRef, useRef, useState, useEffect } from 'react';
import {
Card,
CardHeader,
CardBody,
Box,
Flex,
VStack,
HStack,
Heading,
Text,
Badge,
IconButton,
Center,
Spinner,
useColorModeValue
} from '@chakra-ui/react';
import { ChevronLeftIcon, ChevronRightIcon, TimeIcon } from '@chakra-ui/icons';
import DynamicNewsEventCard from './EventCard/DynamicNewsEventCard';
import DynamicNewsDetailPanel from './DynamicNewsDetail';
/**
* 实时要闻·动态追踪 - 横向滚动卡片组件
* @param {Array} events - 事件列表
* @param {boolean} loading - 加载状态
* @param {Date} lastUpdateTime - 最后更新时间
* @param {Function} onEventClick - 事件点击回调
* @param {Function} onViewDetail - 查看详情回调
* @param {Object} ref - 用于滚动的ref
*/
const DynamicNewsCard = forwardRef(({
events,
loading,
lastUpdateTime,
onEventClick,
onViewDetail,
...rest
}, ref) => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const scrollContainerRef = useRef(null);
const [showLeftArrow, setShowLeftArrow] = useState(false);
const [showRightArrow, setShowRightArrow] = useState(true);
const [selectedEvent, setSelectedEvent] = useState(null);
// 默认选中第一个事件
useEffect(() => {
if (events && events.length > 0 && !selectedEvent) {
setSelectedEvent(events[0]);
}
}, [events, selectedEvent]);
// 滚动到左侧
const scrollLeft = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollBy({
left: -400,
behavior: 'smooth'
});
}
};
// 滚动到右侧
const scrollRight = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollBy({
left: 400,
behavior: 'smooth'
});
}
};
// 监听滚动位置,更新箭头显示状态
const handleScroll = (e) => {
const container = e.target;
const scrollLeft = container.scrollLeft;
const scrollWidth = container.scrollWidth;
const clientWidth = container.clientWidth;
setShowLeftArrow(scrollLeft > 0);
setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 10);
};
// 时间轴样式配置
const getTimelineBoxStyle = () => {
return {
bg: useColorModeValue('gray.50', 'gray.700'),
borderColor: useColorModeValue('gray.400', 'gray.500'),
borderWidth: '2px',
textColor: useColorModeValue('blue.600', 'blue.400'),
boxShadow: 'sm',
};
};
return (
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
{/* 标题部分 */}
<CardHeader>
<Flex justify="space-between" align="center">
<VStack align="start" spacing={1}>
<Heading size="md">
<HStack>
<TimeIcon />
<Text>实时要闻·动态追踪</Text>
</HStack>
</Heading>
<HStack fontSize="sm" color="gray.500">
<Badge colorScheme="red">实时</Badge>
<Badge colorScheme="green">盘中</Badge>
<Badge colorScheme="blue">快讯</Badge>
</HStack>
</VStack>
<Text fontSize="xs" color="gray.500">
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
</Text>
</Flex>
</CardHeader>
{/* 主体内容 */}
<CardBody position="relative">
{/* Loading 状态 */}
{loading && (
<Center py={10}>
<VStack>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color="gray.500">正在加载最新事件...</Text>
</VStack>
</Center>
)}
{/* Empty 状态 */}
{!loading && (!events || events.length === 0) && (
<Center py={10}>
<VStack>
<Text fontSize="lg" color="gray.500">暂无事件数据</Text>
</VStack>
</Center>
)}
{/* 横向滚动事件列表 */}
{!loading && events && events.length > 0 && (
<Box position="relative">
{/* 左侧滚动按钮 */}
{showLeftArrow && (
<IconButton
icon={<ChevronLeftIcon boxSize={6} />}
position="absolute"
left="-4"
top="50%"
transform="translateY(-50%)"
zIndex={2}
onClick={scrollLeft}
colorScheme="blue"
variant="solid"
size="md"
borderRadius="full"
shadow="md"
aria-label="向左滚动"
/>
)}
{/* 右侧滚动按钮 */}
{showRightArrow && (
<IconButton
icon={<ChevronRightIcon boxSize={6} />}
position="absolute"
right="-4"
top="50%"
transform="translateY(-50%)"
zIndex={2}
onClick={scrollRight}
colorScheme="blue"
variant="solid"
size="md"
borderRadius="full"
shadow="md"
aria-label="向右滚动"
/>
)}
{/* 横向滚动容器 */}
<Flex
ref={scrollContainerRef}
overflowX="auto"
overflowY="hidden"
gap={4}
py={4}
px={2}
onScroll={handleScroll}
css={{
'&::-webkit-scrollbar': {
height: '8px',
},
'&::-webkit-scrollbar-track': {
background: useColorModeValue('#f1f1f1', '#2D3748'),
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb': {
background: useColorModeValue('#888', '#4A5568'),
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: useColorModeValue('#555', '#718096'),
},
// 平滑滚动
scrollBehavior: 'smooth',
// 触摸设备优化
WebkitOverflowScrolling: 'touch',
}}
>
{events.map((event, index) => (
<Box
key={event.id}
minW="calc((100% - 64px) / 5)"
maxW="calc((100% - 64px) / 5)"
flexShrink={0}
>
<DynamicNewsEventCard
event={event}
index={index}
isFollowing={false}
followerCount={event.follower_count || 0}
onEventClick={(clickedEvent) => {
setSelectedEvent(clickedEvent);
if (onEventClick) onEventClick(clickedEvent);
}}
onTitleClick={(e) => {
e.preventDefault();
e.stopPropagation();
setSelectedEvent(event);
if (onEventClick) onEventClick(event);
}}
onToggleFollow={() => {}}
timelineStyle={getTimelineBoxStyle()}
borderColor={borderColor}
/>
</Box>
))}
</Flex>
</Box>
)}
{/* 详情面板 */}
{!loading && events && events.length > 0 && (
<Box mt={6}>
<DynamicNewsDetailPanel event={selectedEvent} />
</Box>
)}
</CardBody>
</Card>
);
});
DynamicNewsCard.displayName = 'DynamicNewsCard';
export default DynamicNewsCard;

View File

@@ -0,0 +1,60 @@
// src/views/Community/components/DynamicNewsDetail/CollapsibleHeader.js
// 可折叠模块标题组件
import React from 'react';
import {
Flex,
HStack,
Heading,
Badge,
IconButton,
useColorModeValue,
} from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
/**
* 可折叠模块标题组件
* @param {Object} props
* @param {string} props.title - 标题文本
* @param {boolean} props.isOpen - 是否展开
* @param {Function} props.onToggle - 切换展开/收起的回调
* @param {number} props.count - 可选的数量徽章
*/
const CollapsibleHeader = ({ title, isOpen, onToggle, count = null }) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
const hoverBg = useColorModeValue('gray.100', 'gray.700');
const headingColor = useColorModeValue('gray.700', 'gray.200');
return (
<Flex
justify="space-between"
align="center"
cursor="pointer"
onClick={onToggle}
p={3}
bg={sectionBg}
borderRadius="md"
_hover={{ bg: hoverBg }}
transition="background 0.2s"
>
<HStack spacing={2}>
<Heading size="sm" color={headingColor}>
{title}
</Heading>
{count !== null && (
<Badge colorScheme="blue" borderRadius="full">
{count}
</Badge>
)}
</HStack>
<IconButton
icon={isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
size="sm"
variant="ghost"
aria-label={isOpen ? '收起' : '展开'}
/>
</Flex>
);
};
export default CollapsibleHeader;

View File

@@ -0,0 +1,41 @@
// src/views/Community/components/DynamicNewsDetail/CollapsibleSection.js
// 通用可折叠区块组件
import React from 'react';
import {
Box,
Collapse,
useColorModeValue,
} from '@chakra-ui/react';
import CollapsibleHeader from './CollapsibleHeader';
/**
* 通用可折叠区块组件
* @param {Object} props
* @param {string} props.title - 标题文本
* @param {boolean} props.isOpen - 是否展开
* @param {Function} props.onToggle - 切换展开/收起的回调
* @param {number} props.count - 可选的数量徽章
* @param {React.ReactNode} props.children - 子内容
*/
const CollapsibleSection = ({ title, isOpen, onToggle, count = null, children }) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
return (
<Box>
<CollapsibleHeader
title={title}
isOpen={isOpen}
onToggle={onToggle}
count={count}
/>
<Collapse in={isOpen} animateOpacity>
<Box mt={2} bg={sectionBg} p={3} borderRadius="md">
{children}
</Box>
</Collapse>
</Box>
);
};
export default CollapsibleSection;

View File

@@ -0,0 +1,207 @@
// src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js
// 动态新闻详情面板主组件(组装所有子组件)
import React, { useState, useMemo, useCallback } from 'react';
import {
Card,
CardBody,
VStack,
Text,
useColorModeValue,
useToast,
} from '@chakra-ui/react';
import { getImportanceConfig } from '../../../../constants/importanceLevels';
import { eventService } from '../../../../services/eventService';
import EventHeaderInfo from './EventHeaderInfo';
import EventDescriptionSection from './EventDescriptionSection';
import RelatedConceptsSection from './RelatedConceptsSection';
import RelatedStocksSection from './RelatedStocksSection';
import CollapsibleSection from './CollapsibleSection';
import HistoricalEvents from '../../../EventDetail/components/HistoricalEvents';
import TransmissionChainAnalysis from '../../../EventDetail/components/TransmissionChainAnalysis';
/**
* 动态新闻详情面板主组件
* @param {Object} props
* @param {Object} props.event - 事件对象(包含详情数据)
*/
const DynamicNewsDetailPanel = ({ event }) => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.600', 'gray.400');
const toast = useToast();
// 折叠状态管理
const [isStocksOpen, setIsStocksOpen] = useState(true);
const [isHistoricalOpen, setIsHistoricalOpen] = useState(false);
const [isTransmissionOpen, setIsTransmissionOpen] = useState(false);
// 关注状态管理
const [isFollowing, setIsFollowing] = useState(false);
const [followerCount, setFollowerCount] = useState(0);
// 自选股管理(使用 localStorage
const [watchlistSet, setWatchlistSet] = useState(() => {
try {
const saved = localStorage.getItem('stock_watchlist');
return saved ? new Set(JSON.parse(saved)) : new Set();
} catch {
return new Set();
}
});
// 生成模拟行情数据
const quotes = useMemo(() => {
if (!event?.related_stocks) return {};
const quotesData = {};
event.related_stocks.forEach(stock => {
// 优先使用 stock.daily_change否则生成随机涨跌幅
const change = stock.daily_change
? parseFloat(stock.daily_change)
: (Math.random() * 10 - 3); // -3% ~ +7%
quotesData[stock.stock_code] = {
change: change,
price: 10 + Math.random() * 90 // 模拟价格 10-100
};
});
return quotesData;
}, [event?.related_stocks]);
// 切换关注状态
const handleToggleFollow = async () => {
try {
if (isFollowing) {
// 取消关注
await eventService.unfollowEvent(event.id);
setIsFollowing(false);
setFollowerCount(prev => Math.max(0, prev - 1));
} else {
// 添加关注
await eventService.followEvent(event.id);
setIsFollowing(true);
setFollowerCount(prev => prev + 1);
}
} catch (error) {
console.error('切换关注状态失败:', error);
}
};
// 切换自选股
const handleWatchlistToggle = useCallback(async (stockCode, isInWatchlist) => {
try {
const newWatchlist = new Set(watchlistSet);
if (isInWatchlist) {
newWatchlist.delete(stockCode);
toast({
title: '已移除自选股',
status: 'info',
duration: 2000,
isClosable: true,
});
} else {
newWatchlist.add(stockCode);
toast({
title: '已添加至自选股',
status: 'success',
duration: 2000,
isClosable: true,
});
}
setWatchlistSet(newWatchlist);
localStorage.setItem('stock_watchlist', JSON.stringify(Array.from(newWatchlist)));
} catch (error) {
console.error('切换自选股失败:', error);
toast({
title: '操作失败',
description: error.message,
status: 'error',
duration: 3000,
isClosable: true,
});
}
}, [watchlistSet, toast]);
// 空状态
if (!event) {
return (
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
<CardBody>
<Text color={textColor} textAlign="center">
请选择一个事件查看详情
</Text>
</CardBody>
</Card>
);
}
const importance = getImportanceConfig(event.importance);
return (
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
<CardBody>
<VStack align="stretch" spacing={3}>
{/* 头部信息区 */}
<EventHeaderInfo
event={event}
importance={importance}
isFollowing={isFollowing}
followerCount={followerCount}
onToggleFollow={handleToggleFollow}
/>
{/* 事件描述 */}
<EventDescriptionSection description={event.description} />
{/* 相关概念 */}
<RelatedConceptsSection
keywords={event.keywords}
effectiveTradingDate={event.trading_date}
eventTime={event.created_at}
/>
{/* 相关股票(可折叠) */}
<RelatedStocksSection
stocks={event.related_stocks}
quotes={quotes}
eventTime={event.created_at}
watchlistSet={watchlistSet}
isOpen={isStocksOpen}
onToggle={() => setIsStocksOpen(!isStocksOpen)}
onWatchlistToggle={handleWatchlistToggle}
/>
{/* 历史事件对比(可折叠) */}
<CollapsibleSection
title="历史事件对比"
isOpen={isHistoricalOpen}
onToggle={() => setIsHistoricalOpen(!isHistoricalOpen)}
count={event.historical_events?.length || 0}
>
<HistoricalEvents
events={event.historical_events || []}
/>
</CollapsibleSection>
{/* 传导链分析(可折叠) */}
<CollapsibleSection
title="传导链分析"
isOpen={isTransmissionOpen}
onToggle={() => setIsTransmissionOpen(!isTransmissionOpen)}
>
<TransmissionChainAnalysis
eventId={event.id}
eventService={eventService}
/>
</CollapsibleSection>
</VStack>
</CardBody>
</Card>
);
};
export default DynamicNewsDetailPanel;

View File

@@ -0,0 +1,42 @@
// src/views/Community/components/DynamicNewsDetail/EventDescriptionSection.js
// 事件描述区组件
import React from 'react';
import {
Box,
Heading,
Text,
useColorModeValue,
} from '@chakra-ui/react';
/**
* 事件描述区组件
* @param {Object} props
* @param {string} props.description - 事件描述文本
*/
const EventDescriptionSection = ({ description }) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
const headingColor = useColorModeValue('gray.700', 'gray.200');
const textColor = useColorModeValue('gray.600', 'gray.400');
// 如果没有描述,不渲染
if (!description) {
return null;
}
return (
<Box bg={sectionBg} p={3} borderRadius="md">
{/* 事件描述 */}
<Box>
<Heading size="sm" color={headingColor} mb={2}>
事件描述
</Heading>
<Text fontSize="sm" color={textColor} lineHeight="tall">
{description}
</Text>
</Box>
</Box>
);
};
export default EventDescriptionSection;

View File

@@ -0,0 +1,129 @@
// src/views/Community/components/DynamicNewsDetail/EventHeaderInfo.js
// 事件头部信息区组件
import React from 'react';
import {
Box,
Flex,
HStack,
Heading,
Text,
useColorModeValue,
} from '@chakra-ui/react';
import { ViewIcon } from '@chakra-ui/icons';
import moment from 'moment';
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
import EventFollowButton from '../EventCard/EventFollowButton';
/**
* 事件头部信息区组件
* @param {Object} props
* @param {Object} props.event - 事件对象
* @param {Object} props.importance - 重要性配置对象(包含 level, color 等)
* @param {boolean} props.isFollowing - 是否已关注
* @param {number} props.followerCount - 关注数
* @param {Function} props.onToggleFollow - 切换关注回调
*/
const EventHeaderInfo = ({ event, importance, isFollowing, followerCount, onToggleFollow }) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
const headingColor = useColorModeValue('gray.700', 'gray.200');
// 获取重要性文本
const getImportanceText = () => {
const levelMap = {
'S': '极高',
'A': '高',
'B': '中',
'C': '低'
};
return levelMap[importance.level] || '中';
};
// 格式化涨跌幅数字
const formatChange = (value) => {
if (value === null || value === undefined) return '--';
const prefix = value > 0 ? '+' : '';
return `${prefix}${value.toFixed(2)}%`;
};
return (
<Box bg={sectionBg} p={3} borderRadius="md" position="relative">
{/* 粉色圆角标签(左上角绝对定位) */}
{event.related_avg_chg !== null && event.related_avg_chg !== undefined && (
<Box
position="absolute"
top="-8px"
left="-8px"
bg="pink.500"
color="white"
px={3}
py={1}
borderRadius="full"
fontSize="sm"
fontWeight="bold"
boxShadow="md"
zIndex={1}
>
{formatChange(event.related_avg_chg)}
</Box>
)}
{/* 第一行:标题 + 关注按钮 */}
<Flex align="center" justify="space-between" mb={3} gap={4}>
{/* 标题 */}
<Heading size="md" color={headingColor} flex={1}>
{event.title}
</Heading>
{/* 关注按钮 */}
<EventFollowButton
isFollowing={isFollowing}
followerCount={followerCount}
onToggle={onToggleFollow}
size="sm"
showCount={true}
/>
</Flex>
{/* 第二行:浏览数 + 日期 */}
<Flex align="left" mb={3} gap={4}>
{/* 浏览数 */}
<HStack spacing={1}>
<ViewIcon color="gray.400" boxSize={4} />
<Text fontSize="sm" color="gray.400" whiteSpace="nowrap">
{(event.view_count || 0).toLocaleString()}次浏览
</Text>
</HStack>
{/* 日期 */}
<Text fontSize="sm" color="red.500" fontWeight="medium" whiteSpace="nowrap">
{moment(event.created_at).format('YYYY年MM月DD日')}
</Text>
</Flex>
{/* 第三行:涨跌幅指标 + 重要性文本 */}
<HStack spacing={3} align="center">
<Box maxW="500px">
<StockChangeIndicators
avgChange={event.related_avg_chg}
maxChange={event.related_max_chg}
weekChange={event.related_week_chg}
/>
</Box>
{/* 重要性文本 */}
<Box
bg="orange.50"
px={2}
py={1}
borderRadius="md"
>
<Text fontSize="sm" color="orange.800" whiteSpace="nowrap" fontWeight="medium">
重要性{getImportanceText()}
</Text>
</Box>
</HStack>
</Box>
);
};
export default EventHeaderInfo;

View File

@@ -0,0 +1,184 @@
// src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js
import React, { useState, useEffect, useMemo, useRef } from 'react';
import ReactECharts from 'echarts-for-react';
import moment from 'moment';
import {
fetchKlineData,
getCacheKey,
klineDataCache
} from '../StockDetailPanel/utils/klineDataCache';
/**
* 迷你K线图组件
* 显示股票的K线走势蜡烛图支持事件时间标记
*
* @param {string} stockCode - 股票代码
* @param {string} eventTime - 事件时间(可选)
* @param {Function} onClick - 点击回调(可选)
* @returns {JSX.Element}
*/
const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime, onClick }) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const mountedRef = useRef(true);
const loadedRef = useRef(false);
const dataFetchedRef = useRef(false);
// 稳定的事件时间
const stableEventTime = useMemo(() => {
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : '';
}, [eventTime]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
if (!stockCode) {
setData([]);
loadedRef.current = false;
dataFetchedRef.current = false;
return;
}
if (dataFetchedRef.current) {
return;
}
// 检查缓存K线图使用 'daily' 类型)
const cacheKey = getCacheKey(stockCode, stableEventTime, 'daily');
const cachedData = klineDataCache.get(cacheKey);
if (cachedData && cachedData.length > 0) {
setData(cachedData);
loadedRef.current = true;
dataFetchedRef.current = true;
return;
}
dataFetchedRef.current = true;
setLoading(true);
// 获取日K线数据
fetchKlineData(stockCode, stableEventTime, 'daily')
.then((result) => {
if (mountedRef.current) {
setData(result);
setLoading(false);
loadedRef.current = true;
}
})
.catch(() => {
if (mountedRef.current) {
setData([]);
setLoading(false);
loadedRef.current = true;
}
});
}, [stockCode, stableEventTime]);
const chartOption = useMemo(() => {
// 提取K线数据 [open, close, low, high]
const klineData = data
.filter(item => item.open && item.close && item.low && item.high)
.map(item => [item.open, item.close, item.low, item.high]);
// 日K线使用 date 字段
const dates = data.map(item => item.date || item.time);
const hasData = klineData.length > 0;
if (!hasData) {
return {
title: {
text: loading ? '加载中...' : '无数据',
left: 'center',
top: 'middle',
textStyle: { color: '#999', fontSize: 10 }
}
};
}
// 计算事件时间标记
let eventMarkLineData = [];
if (stableEventTime && Array.isArray(dates) && dates.length > 0) {
try {
const eventDate = moment(stableEventTime).format('YYYY-MM-DD');
const eventIdx = dates.findIndex(d => {
const dateStr = typeof d === 'object' ? moment(d).format('YYYY-MM-DD') : String(d);
return dateStr.includes(eventDate);
});
if (eventIdx >= 0) {
eventMarkLineData.push({
xAxis: eventIdx,
lineStyle: { color: '#FFD700', type: 'solid', width: 1.5 },
label: { show: false }
});
}
} catch (e) {
// 忽略异常
}
}
return {
grid: { left: 2, right: 2, top: 2, bottom: 2, containLabel: false },
xAxis: {
type: 'category',
data: dates,
show: false,
boundaryGap: true
},
yAxis: {
type: 'value',
show: false,
scale: true
},
series: [{
type: 'candlestick',
data: klineData,
itemStyle: {
color: '#ef5350', // 涨(阳线)
color0: '#26a69a', // 跌(阴线)
borderColor: '#ef5350', // 涨(边框)
borderColor0: '#26a69a' // 跌(边框)
},
barWidth: '60%',
markLine: {
silent: true,
symbol: 'none',
label: { show: false },
data: eventMarkLineData
}
}],
tooltip: { show: false },
animation: false
};
}, [data, loading, stableEventTime]);
return (
<div
style={{
width: 140,
height: 40,
cursor: onClick ? 'pointer' : 'default'
}}
onClick={onClick}
>
<ReactECharts
option={chartOption}
style={{ width: '100%', height: '100%' }}
notMerge={true}
lazyUpdate={true}
/>
</div>
);
}, (prevProps, nextProps) => {
return prevProps.stockCode === nextProps.stockCode &&
prevProps.eventTime === nextProps.eventTime &&
prevProps.onClick === nextProps.onClick;
});
export default MiniKLineChart;

View File

@@ -0,0 +1,94 @@
// src/views/Community/components/DynamicNewsDetail/MiniLineChart.js
// Mini 折线图组件(用于股票卡片)
import React from 'react';
import { Box } from '@chakra-ui/react';
/**
* Mini 折线图组件
* @param {Object} props
* @param {Array<number>} props.data - 价格走势数据数组15个数据点前5+中5+后5
* @param {number} props.width - 图表宽度默认180
* @param {number} props.height - 图表高度默认60
*/
const MiniLineChart = ({ data = [], width = 180, height = 60 }) => {
if (!data || data.length === 0) {
return null;
}
// 计算最大值和最小值,用于归一化
const max = Math.max(...data);
const min = Math.min(...data);
const range = max - min || 1; // 防止除以0
// 将数据点转换为 SVG 路径坐标
const points = data.map((value, index) => {
const x = (index / (data.length - 1)) * width;
const y = height - ((value - min) / range) * height;
return `${x.toFixed(2)},${y.toFixed(2)}`;
});
// 构建 SVG 路径字符串
const pathD = `M ${points.join(' L ')}`;
// 判断整体趋势(比较第一个和最后一个值)
const isPositive = data[data.length - 1] >= data[0];
const strokeColor = isPositive ? '#48BB78' : '#F56565'; // 绿色上涨,红色下跌
// 创建渐变填充区域路径
const fillPathD = `${pathD} L ${width},${height} L 0,${height} Z`;
return (
<Box width={`${width}px`} height={`${height}px`}>
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
<defs>
<linearGradient id={`gradient-${isPositive ? 'up' : 'down'}`} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.3" />
<stop offset="100%" stopColor={strokeColor} stopOpacity="0.05" />
</linearGradient>
</defs>
{/* 填充区域 */}
<path
d={fillPathD}
fill={`url(#gradient-${isPositive ? 'up' : 'down'})`}
/>
{/* 折线 */}
<path
d={pathD}
fill="none"
stroke={strokeColor}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* 垂直分隔线(标记三个时间段) */}
{/* 前一天和当天之间 */}
<line
x1={width / 3}
y1={0}
x2={width / 3}
y2={height}
stroke="#E2E8F0"
strokeWidth="1"
strokeDasharray="2,2"
/>
{/* 当天和后一天之间 */}
<line
x1={(width * 2) / 3}
y1={0}
x2={(width * 2) / 3}
y2={height}
stroke="#E2E8F0"
strokeWidth="1"
strokeDasharray="2,2"
/>
</svg>
</Box>
);
};
export default MiniLineChart;

View File

@@ -0,0 +1,65 @@
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/ConceptStockItem.js
// 概念股票列表项组件
import React from 'react';
import {
Box,
HStack,
Text,
Badge,
useColorModeValue,
} from '@chakra-ui/react';
/**
* 概念股票列表项组件
* @param {Object} props
* @param {Object} props.stock - 股票对象
* - stock_name: 股票名称
* - stock_code: 股票代码
* - change_pct: 涨跌幅
* - reason: 关联原因
*/
const ConceptStockItem = ({ stock }) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
const conceptNameColor = useColorModeValue('gray.800', 'gray.100');
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
const stockChangePct = parseFloat(stock.change_pct);
const stockChangeColor = stockChangePct > 0 ? 'red' : stockChangePct < 0 ? 'green' : 'gray';
const stockChangeSymbol = stockChangePct > 0 ? '+' : '';
return (
<Box
p={2}
borderRadius="md"
bg={sectionBg}
fontSize="xs"
>
<HStack justify="space-between" mb={1}>
<HStack spacing={2}>
<Text fontWeight="semibold" color={conceptNameColor}>
{stock.stock_name}
</Text>
<Badge size="sm" variant="outline">
{stock.stock_code}
</Badge>
</HStack>
{stock.change_pct && (
<Badge
colorScheme={stockChangeColor}
fontSize="xs"
>
{stockChangeSymbol}{stockChangePct.toFixed(2)}%
</Badge>
)}
</HStack>
{stock.reason && (
<Text fontSize="xs" color={stockCountColor} mt={1} noOfLines={2}>
{stock.reason}
</Text>
)}
</Box>
);
};
export default ConceptStockItem;

View File

@@ -0,0 +1,150 @@
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/DetailedConceptCard.js
// 详细概念卡片组件
import React from 'react';
import {
Box,
HStack,
VStack,
Text,
Badge,
Card,
CardBody,
Divider,
SimpleGrid,
useColorModeValue,
} from '@chakra-ui/react';
import ConceptStockItem from './ConceptStockItem';
/**
* 详细概念卡片组件
* @param {Object} props
* @param {Object} props.concept - 概念对象
* - name: 概念名称
* - stock_count: 相关股票数量
* - relevance: 相关度0-100
* - avg_change_pct: 平均涨跌幅
* - description: 概念描述
* - happened_times: 历史触发时间数组
* - stocks: 相关股票数组
* @param {Function} props.onClick - 点击回调
*/
const DetailedConceptCard = ({ concept, onClick }) => {
const cardBg = useColorModeValue('white', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const headingColor = useColorModeValue('gray.700', 'gray.200');
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
// 计算涨跌幅颜色
const changePct = parseFloat(concept.avg_change_pct);
const changeColor = changePct > 0 ? 'red' : changePct < 0 ? 'green' : 'gray';
const changeSymbol = changePct > 0 ? '+' : '';
return (
<Card
bg={cardBg}
borderColor={borderColor}
borderWidth="2px"
cursor="pointer"
transition="all 0.3s"
_hover={{
transform: 'translateY(-2px)',
boxShadow: 'xl',
borderColor: 'blue.400'
}}
onClick={() => onClick(concept)}
>
<CardBody p={4}>
<VStack spacing={3} align="stretch">
{/* 头部信息 */}
<HStack justify="space-between" align="flex-start">
{/* 左侧:概念名称 + Badge */}
<VStack align="start" spacing={2} flex={1}>
<Text fontSize="md" fontWeight="bold" color="blue.600">
{concept.name}
</Text>
<HStack spacing={2} flexWrap="wrap">
<Badge colorScheme="purple" fontSize="xs">
相关度: {concept.relevance}%
</Badge>
<Badge colorScheme="orange" fontSize="xs">
{concept.stock_count} 只股票
</Badge>
</HStack>
</VStack>
{/* 右侧:涨跌幅 */}
{concept.avg_change_pct && (
<Box textAlign="right">
<Text fontSize="xs" color={stockCountColor} mb={1}>
平均涨跌幅
</Text>
<Badge
size="lg"
colorScheme={changeColor}
fontSize="md"
px={3}
py={1}
>
{changeSymbol}{changePct.toFixed(2)}%
</Badge>
</Box>
)}
</HStack>
<Divider />
{/* 概念描述 */}
{concept.description && (
<Text
fontSize="sm"
color={stockCountColor}
lineHeight="1.6"
noOfLines={3}
>
{concept.description}
</Text>
)}
{/* 历史触发时间 */}
{concept.happened_times && concept.happened_times.length > 0 && (
<Box>
<Text fontSize="xs" fontWeight="semibold" mb={2} color={stockCountColor}>
历史触发时间
</Text>
<HStack spacing={2} flexWrap="wrap">
{concept.happened_times.map((time, idx) => (
<Badge key={idx} variant="subtle" colorScheme="gray" fontSize="xs">
{time}
</Badge>
))}
</HStack>
</Box>
)}
{/* 核心相关股票 */}
{concept.stocks && concept.stocks.length > 0 && (
<Box>
<HStack justify="space-between" mb={2}>
<Text fontSize="sm" fontWeight="semibold" color={headingColor}>
核心相关股票
</Text>
<Text fontSize="xs" color={stockCountColor}>
{concept.stock_count}
</Text>
</HStack>
<SimpleGrid columns={{ base: 1 }} spacing={2}>
{concept.stocks.slice(0, 4).map((stock, idx) => (
<ConceptStockItem key={idx} stock={stock} />
))}
</SimpleGrid>
</Box>
)}
</VStack>
</CardBody>
</Card>
);
};
export default DetailedConceptCard;

View File

@@ -0,0 +1,73 @@
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/SimpleConceptCard.js
// 简单概念卡片组件(横向卡片)
import React from 'react';
import {
Flex,
Box,
Text,
useColorModeValue,
} from '@chakra-ui/react';
/**
* 简单概念卡片组件
* @param {Object} props
* @param {Object} props.concept - 概念对象
* - name: 概念名称
* - stock_count: 相关股票数量
* - relevance: 相关度0-100
* @param {Function} props.onClick - 点击回调
* @param {Function} props.getRelevanceColor - 获取相关度颜色的函数
*/
const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
const cardBg = useColorModeValue('white', 'gray.700');
const conceptNameColor = useColorModeValue('gray.800', 'gray.100');
const borderColor = useColorModeValue('gray.300', 'gray.600');
const relevanceColors = getRelevanceColor(concept.relevance);
return (
<Flex
align="center"
justify="space-between"
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
px={4}
py={2}
cursor="pointer"
transition="all 0.2s"
minW="200px"
_hover={{
transform: 'translateY(-1px)',
boxShadow: 'md',
}}
onClick={() => onClick(concept)}
>
{/* 左侧:概念名 + 数量 */}
<Text fontSize="sm" fontWeight="normal" color={conceptNameColor} mr={3}>
{concept.name}{' '}
<Text as="span" color="gray.500">
({concept.stock_count})
</Text>
</Text>
{/* 右侧:相关度标签 */}
<Box
bg={relevanceColors.bg}
color={relevanceColors.color}
px={3}
py={1}
borderRadius="md"
flexShrink={0}
>
<Text fontSize="xs" fontWeight="medium" whiteSpace="nowrap">
相关度: {concept.relevance}%
</Text>
</Box>
</Flex>
);
};
export default SimpleConceptCard;

View File

@@ -0,0 +1,46 @@
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/TradingDateInfo.js
// 交易日期信息提示组件
import React from 'react';
import {
Box,
HStack,
Text,
useColorModeValue,
} from '@chakra-ui/react';
import { FaCalendarAlt } from 'react-icons/fa';
import moment from 'moment';
/**
* 交易日期信息提示组件
* @param {Object} props
* @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期)
* @param {string|Object} props.eventTime - 事件发生时间
*/
const TradingDateInfo = ({ effectiveTradingDate, eventTime }) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
const headingColor = useColorModeValue('gray.700', 'gray.200');
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
if (!effectiveTradingDate) {
return null;
}
return (
<Box mb={4} p={3} bg={sectionBg} borderRadius="md">
<HStack spacing={2}>
<FaCalendarAlt color="gray" />
<Text fontSize="sm" color={headingColor}>
涨跌幅数据日期{effectiveTradingDate}
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && (
<Text as="span" ml={2} fontSize="xs" color={stockCountColor}>
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : moment(eventTime).format('YYYY-MM-DD HH:mm')}显示下一交易日数据)
</Text>
)}
</Text>
</HStack>
</Box>
);
};
export default TradingDateInfo;

View File

@@ -0,0 +1,122 @@
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/index.js
// 相关概念区组件(主组件)
import React, { useState } from 'react';
import {
Box,
SimpleGrid,
Flex,
Button,
Collapse,
Heading,
useColorModeValue,
} from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
import { useNavigate } from 'react-router-dom';
import SimpleConceptCard from './SimpleConceptCard';
import DetailedConceptCard from './DetailedConceptCard';
import TradingDateInfo from './TradingDateInfo';
/**
* 相关概念区组件
* @param {Object} props
* @param {Array<Object>} props.keywords - 相关概念数组
* - name: 概念名称
* - stock_count: 相关股票数量
* - relevance: 相关度0-100
* @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期)
* @param {string|Object} props.eventTime - 事件发生时间
*/
const RelatedConceptsSection = ({ keywords, effectiveTradingDate, eventTime }) => {
const [isExpanded, setIsExpanded] = useState(false);
const navigate = useNavigate();
// 颜色配置
const sectionBg = useColorModeValue('gray.50', 'gray.750');
const headingColor = useColorModeValue('gray.700', 'gray.200');
// 如果没有关键词,不渲染
if (!keywords || keywords.length === 0) {
return null;
}
/**
* 根据相关度获取颜色(浅色背景 + 深色文字)
* @param {number} relevance - 相关度0-100
* @returns {Object} 包含背景色和文字色
*/
const getRelevanceColor = (relevance) => {
if (relevance >= 90) {
return { bg: 'purple.50', color: 'purple.800' }; // 极高相关
} else if (relevance >= 80) {
return { bg: 'pink.50', color: 'pink.800' }; // 高相关
} else if (relevance >= 70) {
return { bg: 'orange.50', color: 'orange.800' }; // 中等相关
} else {
return { bg: 'gray.100', color: 'gray.700' }; // 低相关
}
};
/**
* 处理概念点击
* @param {Object} concept - 概念对象
*/
const handleConceptClick = (concept) => {
// 跳转到概念详情页
navigate(`/concept/${concept.name}`);
};
return (
<Box bg={sectionBg} p={3} borderRadius="md">
{/* 标题栏 */}
<Flex justify="space-between" align="center" mb={3}>
<Heading size="sm" color={headingColor}>
相关概念
</Heading>
<Button
size="sm"
variant="ghost"
colorScheme="blue"
rightIcon={isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? '收起' : '查看详细描述'}
</Button>
</Flex>
{/* 简单模式:横向卡片列表(总是显示) */}
<Flex gap={2} flexWrap="wrap" mb={isExpanded ? 3 : 0}>
{keywords.map((concept, index) => (
<SimpleConceptCard
key={index}
concept={concept}
onClick={handleConceptClick}
getRelevanceColor={getRelevanceColor}
/>
))}
</Flex>
{/* 交易日期信息 */}
<TradingDateInfo
effectiveTradingDate={effectiveTradingDate}
eventTime={eventTime}
/>
{/* 详细模式:卡片网格(可折叠) */}
<Collapse in={isExpanded} animateOpacity>
{/* 详细概念卡片网格 */}
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{keywords.map((concept, index) => (
<DetailedConceptCard
key={index}
concept={concept}
onClick={handleConceptClick}
/>
))}
</SimpleGrid>
</Collapse>
</Box>
);
};
export default RelatedConceptsSection;

View File

@@ -0,0 +1,66 @@
// src/views/Community/components/DynamicNewsDetail/RelatedStocksSection.js
// 相关股票列表区组件(可折叠,网格布局)
import React from 'react';
import {
Box,
SimpleGrid,
Collapse,
} from '@chakra-ui/react';
import CollapsibleHeader from './CollapsibleHeader';
import StockListItem from './StockListItem';
/**
* 相关股票列表区组件
* @param {Object} props
* @param {Array<Object>} props.stocks - 股票数组
* @param {Object} props.quotes - 股票行情字典 { [stockCode]: { change: number } }
* @param {string} props.eventTime - 事件时间
* @param {Set} props.watchlistSet - 自选股代码集合
* @param {boolean} props.isOpen - 是否展开
* @param {Function} props.onToggle - 切换展开/收起的回调
* @param {Function} props.onWatchlistToggle - 切换自选股回调
*/
const RelatedStocksSection = ({
stocks,
quotes = {},
eventTime = null,
watchlistSet = new Set(),
isOpen,
onToggle,
onWatchlistToggle
}) => {
// 如果没有股票数据,不渲染
if (!stocks || stocks.length === 0) {
return null;
}
return (
<Box>
<CollapsibleHeader
title="相关股票"
isOpen={isOpen}
onToggle={onToggle}
count={stocks.length}
/>
<Collapse in={isOpen} animateOpacity>
<Box mt={3}>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{stocks.map((stock, index) => (
<StockListItem
key={index}
stock={stock}
quote={quotes[stock.stock_code]}
eventTime={eventTime}
isInWatchlist={watchlistSet.has(stock.stock_code)}
onWatchlistToggle={onWatchlistToggle}
/>
))}
</SimpleGrid>
</Box>
</Collapse>
</Box>
);
};
export default RelatedStocksSection;

View File

@@ -0,0 +1,239 @@
// src/views/Community/components/DynamicNewsDetail/StockListItem.js
// 股票卡片组件(融合表格功能的卡片样式)
import React, { useState } from 'react';
import {
Box,
Flex,
VStack,
SimpleGrid,
Text,
Button,
IconButton,
Collapse,
useColorModeValue,
} from '@chakra-ui/react';
import { StarIcon } from '@chakra-ui/icons';
import MiniTimelineChart from '../StockDetailPanel/components/MiniTimelineChart';
import MiniKLineChart from './MiniKLineChart';
import StockChartModal from '../../../../components/StockChart/StockChartModal';
/**
* 股票卡片组件
* @param {Object} props
* @param {Object} props.stock - 股票对象
* @param {string} props.stock.stock_name - 股票名称
* @param {string} props.stock.stock_code - 股票代码
* @param {string} props.stock.relation_desc - 关联描述
* @param {Object} props.quote - 股票行情数据(可选)
* @param {number} props.quote.change - 涨跌幅
* @param {string} props.eventTime - 事件时间(可选)
* @param {boolean} props.isInWatchlist - 是否在自选股中
* @param {Function} props.onWatchlistToggle - 切换自选股回调
*/
const StockListItem = ({
stock,
quote = null,
eventTime = null,
isInWatchlist = false,
onWatchlistToggle
}) => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const codeColor = useColorModeValue('blue.600', 'blue.300');
const nameColor = useColorModeValue('gray.700', 'gray.300');
const descColor = useColorModeValue('gray.600', 'gray.400');
const dividerColor = useColorModeValue('gray.200', 'gray.600');
const [isDescExpanded, setIsDescExpanded] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const handleViewDetail = () => {
const stockCode = stock.stock_code.split('.')[0];
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
};
const handleWatchlistClick = (e) => {
e.stopPropagation();
onWatchlistToggle?.(stock.stock_code, isInWatchlist);
};
// 格式化涨跌幅显示
const formatChange = (value) => {
if (value === null || value === undefined || isNaN(value)) return '--';
const prefix = value > 0 ? '+' : '';
return `${prefix}${parseFloat(value).toFixed(2)}%`;
};
// 获取涨跌幅颜色
const getChangeColor = (value) => {
const num = parseFloat(value);
if (isNaN(num) || num === 0) return 'gray.500';
return num > 0 ? 'red.500' : 'green.500';
};
// 获取涨跌幅数据(优先使用 quotefallback 到 stock
const change = quote?.change ?? stock.daily_change ?? null;
// 处理关联描述
const getRelationDesc = () => {
const relationDesc = stock.relation_desc;
if (!relationDesc) return '--';
if (typeof relationDesc === 'string') {
return relationDesc;
} else if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
// 新格式:{data: [{query_part: "...", sentences: "..."}]}
return relationDesc.data
.map(item => item.query_part || item.sentences || '')
.filter(s => s)
.join('') || '--';
}
return '--';
};
const relationText = getRelationDesc();
const maxLength = 50; // 收缩时显示的最大字符数
const needTruncate = relationText && relationText !== '--' && relationText.length > maxLength;
return (
<>
<Box
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
p={4}
_hover={{
boxShadow: 'md',
borderColor: 'blue.300',
}}
transition="all 0.2s"
>
<VStack align="stretch" spacing={3}>
{/* 顶部:股票代码 + 名称 + 操作按钮 */}
<Flex justify="space-between" align="center">
{/* 左侧:代码 + 名称 */}
<Flex align="baseline" gap={2}>
<Text
fontSize="md"
fontWeight="bold"
color={codeColor}
cursor="pointer"
onClick={handleViewDetail}
_hover={{ textDecoration: 'underline' }}
>
{stock.stock_code}
</Text>
<Text fontSize="sm" color={nameColor}>
{stock.stock_name}
</Text>
<Text
fontSize="sm"
fontWeight="semibold"
color={getChangeColor(change)}
>
{formatChange(change)}
</Text>
</Flex>
{/* 右侧:操作按钮 */}
<Flex gap={2}>
{onWatchlistToggle && (
<IconButton
size="sm"
variant={isInWatchlist ? 'solid' : 'outline'}
colorScheme={isInWatchlist ? 'yellow' : 'gray'}
icon={<StarIcon />}
onClick={handleWatchlistClick}
aria-label={isInWatchlist ? '已关注' : '加自选'}
title={isInWatchlist ? '已关注' : '加自选'}
/>
)}
<Button
size="sm"
colorScheme="blue"
onClick={handleViewDetail}
>
查看
</Button>
</Flex>
</Flex>
{/* 分隔线 */}
<Box borderTop="1px solid" borderColor={dividerColor} />
{/* 分时图 & K线图 - 左右布局 */}
<Box>
<SimpleGrid columns={2} spacing={3}>
{/* 左侧:分时图 */}
<Box>
<Text fontSize="xs" color={descColor} mb={1} textAlign="center">
分时图
</Text>
<MiniTimelineChart
stockCode={stock.stock_code}
eventTime={eventTime}
onClick={() => setIsModalOpen(true)}
/>
</Box>
{/* 右侧K线图 */}
<Box>
<Text fontSize="xs" color={descColor} mb={1} textAlign="center">
日K线
</Text>
<MiniKLineChart
stockCode={stock.stock_code}
eventTime={eventTime}
onClick={() => setIsModalOpen(true)}
/>
</Box>
</SimpleGrid>
</Box>
{/* 分隔线 */}
<Box borderTop="1px solid" borderColor={dividerColor} />
{/* 关联描述 */}
{relationText && relationText !== '--' && (
<Box>
<Text fontSize="xs" color={descColor} mb={1}>
关联描述
</Text>
<Collapse in={isDescExpanded} startingHeight={40}>
<Text fontSize="sm" color={nameColor} lineHeight="1.6">
{relationText}
</Text>
</Collapse>
{needTruncate && (
<Button
size="xs"
variant="link"
colorScheme="blue"
onClick={() => setIsDescExpanded(!isDescExpanded)}
mt={1}
>
{isDescExpanded ? '收起' : '展开'}
</Button>
)}
</Box>
)}
</VStack>
</Box>
{/* 股票详情弹窗 */}
<StockChartModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
stock={stock}
eventTime={eventTime}
size="6xl"
/>
</>
);
};
export default StockListItem;

View File

@@ -0,0 +1,5 @@
// src/views/Community/components/DynamicNewsDetail/index.js
// 统一导出 DynamicNewsDetailPanel 组件
export { default } from './DynamicNewsDetailPanel';
export { default as DynamicNewsDetailPanel } from './DynamicNewsDetailPanel';

View File

@@ -0,0 +1,141 @@
// src/views/Community/components/EventCard/DynamicNewsEventCard.js
// 动态新闻事件卡片组件(纵向布局,时间在上)
import React from 'react';
import {
VStack,
Card,
CardBody,
Box,
Text,
useColorModeValue,
} from '@chakra-ui/react';
import moment from 'moment';
import { getImportanceConfig } from '../../../../constants/importanceLevels';
// 导入子组件
import EventFollowButton from './EventFollowButton';
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
/**
* 动态新闻事件卡片组件(极简版)
* @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.onToggleFollow - 切换关注事件
* @param {Object} props.timelineStyle - 时间轴样式配置
* @param {string} props.borderColor - 边框颜色
*/
const DynamicNewsEventCard = ({
event,
index,
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');
return (
<VStack align="stretch" spacing={2} w="full">
{/* 时间标签 - 在卡片上方 */}
<Box
{...(timelineStyle.bgGradient ? { bgGradient: timelineStyle.bgGradient } : { bg: timelineStyle.bg })}
borderWidth={timelineStyle.borderWidth}
borderColor={timelineStyle.borderColor}
borderRadius="md"
px={3}
py={1.5}
textAlign="center"
boxShadow={timelineStyle.boxShadow}
transition="all 0.3s ease"
>
<Text
fontSize="xs"
fontWeight="bold"
color={timelineStyle.textColor}
lineHeight="1.3"
>
{moment(event.created_at).format('YYYY-MM-DD HH:mm')}
</Text>
</Box>
{/* 事件卡片 */}
<Card
position="relative"
bg={index % 2 === 0 ? cardBg : useColorModeValue('gray.50', 'gray.750')}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
boxShadow="sm"
_hover={{
boxShadow: 'lg',
transform: 'translateY(-2px)',
borderColor: importance.color,
}}
transition="all 0.3s ease"
cursor="pointer"
onClick={() => onEventClick?.(event)}
>
<CardBody p={3}>
{/* 关注按钮 - 绝对定位在右上角 */}
<Box position="absolute" top={2} right={2} zIndex={1}>
<EventFollowButton
isFollowing={isFollowing}
followerCount={followerCount}
onToggle={() => onToggleFollow?.(event.id)}
size="xs"
showCount={false}
/>
</Box>
<VStack align="stretch" spacing={2.5}>
{/* 第一行:标题 + 重要性(行内文字) */}
<Box
cursor="pointer"
onClick={(e) => onTitleClick?.(e, event)}
paddingRight="10px"
>
<Text
fontSize="md"
fontWeight="semibold"
color={linkColor}
lineHeight="1.4"
_hover={{ textDecoration: 'underline' }}
>
{event.title}
<Text
as="span"
fontSize="sm"
fontWeight="bold"
color={importance.color}
ml={2}
>
[{importance.level}]
</Text>
</Text>
</Box>
{/* 第二行:涨跌幅数据 */}
<StockChangeIndicators
avgChange={event.related_avg_chg}
maxChange={event.related_max_chg}
weekChange={event.related_week_chg}
/>
</VStack>
</CardBody>
</Card>
</VStack>
);
};
export default DynamicNewsEventCard;

View File

@@ -1,7 +1,7 @@
// src/views/Community/components/EventCard/EventFollowButton.js
import React from 'react';
import { Button } from '@chakra-ui/react';
import { StarIcon } from '@chakra-ui/icons';
import { IconButton, Box } from '@chakra-ui/react';
import { AiFillStar, AiOutlineStar } from 'react-icons/ai';
/**
* 事件关注按钮组件
@@ -19,7 +19,7 @@ const EventFollowButton = ({
size = 'sm',
showCount = true
}) => {
const iconSize = size === 'xs' ? '10px' : '12px';
const iconSize = size === 'xs' ? '16px' : size === 'sm' ? '18px' : '22px';
const handleClick = (e) => {
e.stopPropagation();
@@ -27,16 +27,38 @@ const EventFollowButton = ({
};
return (
<Button
size={size}
colorScheme="yellow"
variant={isFollowing ? 'solid' : 'outline'}
leftIcon={<StarIcon boxSize={iconSize} />}
onClick={handleClick}
>
{isFollowing ? '已关注' : '关注'}
{showCount && followerCount > 0 && `(${followerCount})`}
</Button>
<Box display="inline-flex" alignItems="center" gap={1}>
<IconButton
size={size}
colorScheme="yellow"
variant="ghost"
bg="whiteAlpha.500"
boxShadow="sm"
_hover={{
bg: 'whiteAlpha.800',
boxShadow: 'md'
}}
icon={
isFollowing ? (
<AiFillStar
size={iconSize}
color="gold"
/>
) : (
<AiOutlineStar
size={iconSize}
color="#718096"
strokeWidth="1"
/>
)
}
onClick={handleClick}
aria-label={isFollowing ? '取消关注' : '关注'}
/>
{/* <Box fontSize="xs" color="gray.500">
{followerCount || 0}
</Box> */}
</Box>
);
};

View File

@@ -349,12 +349,13 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
<Container maxW="container.xl">
<Flex justify="space-between" align="center">
{/* 左侧占位 */}
<Box flex="1" />
<Box key="left-spacer" flex="1" />
{/* 中间:分页器 */}
{pagination.total > 0 && localEvents.length > 0 ? (
<Flex align="center" gap={2}>
<Flex key="pagination-controls" align="center" gap={2}>
<Button
key="prev-page"
size="xs"
variant="outline"
onClick={() => onPageChange(pagination.current - 1)}
@@ -362,10 +363,11 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
>
上一页
</Button>
<Text fontSize="xs" color={mutedColor} px={2} whiteSpace="nowrap">
<Text key="page-info" fontSize="xs" color={mutedColor} px={2} whiteSpace="nowrap">
{pagination.current} / {Math.ceil(pagination.total / pagination.pageSize)}
</Text>
<Button
key="next-page"
size="xs"
variant="outline"
onClick={() => onPageChange(pagination.current + 1)}
@@ -373,18 +375,19 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
>
下一页
</Button>
<Text fontSize="xs" color={mutedColor} ml={2} whiteSpace="nowrap">
<Text key="total-count" fontSize="xs" color={mutedColor} ml={2} whiteSpace="nowrap">
{pagination.total}
</Text>
</Flex>
) : (
<Box flex="1" />
<Box key="center-spacer" flex="1" />
)}
{/* 右侧:控制按钮 */}
<Flex align="center" gap={3} flex="1" justify="flex-end">
<Flex key="right-controls" align="center" gap={3} flex="1" justify="flex-end">
{/* WebSocket 连接状态 */}
<Badge
key="websocket-status"
colorScheme={isConnected ? 'green' : 'red'}
fontSize="xs"
px={2}
@@ -395,7 +398,7 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
</Badge>
{/* 桌面推送开关 */}
<FormControl display="flex" alignItems="center" w="auto">
<FormControl key="push-notification" display="flex" alignItems="center" w="auto">
<FormLabel htmlFor="push-notification" mb="0" fontSize="xs" color={textColor} mr={2}>
推送
</FormLabel>
@@ -420,7 +423,7 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
</FormControl>
{/* 视图切换控制 */}
<FormControl display="flex" alignItems="center" w="auto">
<FormControl key="compact-mode" display="flex" alignItems="center" w="auto">
<FormLabel htmlFor="compact-mode" mb="0" fontSize="xs" color={textColor} mr={2}>
精简
</FormLabel>
@@ -440,7 +443,7 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
{/* 事件列表内容 */}
<Container maxW="container.xl">
{localEvents.length > 0 ? (
<VStack align="stretch" spacing={0}>
<VStack key="event-list" align="stretch" spacing={0}>
{localEvents.map((event, index) => (
<Box key={event.id} position="relative">
<EventCard
@@ -460,10 +463,10 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
))}
</VStack>
) : (
<Center h="300px">
<Center key="empty-state" h="300px">
<VStack spacing={4}>
<InfoIcon boxSize={12} color={mutedColor} />
<Text color={mutedColor} fontSize="lg">
<InfoIcon key="empty-icon" boxSize={12} color={mutedColor} />
<Text key="empty-text" color={mutedColor} fontSize="lg">
暂无事件数据
</Text>
</VStack>
@@ -472,6 +475,7 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
{pagination.total > 0 && (
<Pagination
key="bottom-pagination"
current={pagination.current}
total={pagination.total}
pageSize={pagination.pageSize}

View File

@@ -0,0 +1,300 @@
// src/views/Community/components/MarketReviewCard.js
// 市场复盘组件(左右布局:事件列表 | 事件详情)
import React, { forwardRef, useState } from 'react';
import {
Card,
CardHeader,
CardBody,
Box,
Flex,
VStack,
HStack,
Heading,
Text,
Badge,
Center,
Spinner,
useColorModeValue,
Grid,
GridItem,
} from '@chakra-ui/react';
import { TimeIcon, InfoIcon } from '@chakra-ui/icons';
import moment from 'moment';
import CompactEventCard from './EventCard/CompactEventCard';
import EventHeader from './EventCard/EventHeader';
import EventStats from './EventCard/EventStats';
import EventFollowButton from './EventCard/EventFollowButton';
import EventPriceDisplay from './EventCard/EventPriceDisplay';
import EventDescription from './EventCard/EventDescription';
import { getImportanceConfig } from '../../../constants/importanceLevels';
/**
* 市场复盘 - 左右布局卡片组件
* @param {Array} events - 事件列表
* @param {boolean} loading - 加载状态
* @param {Date} lastUpdateTime - 最后更新时间
* @param {Function} onEventClick - 事件点击回调
* @param {Function} onViewDetail - 查看详情回调
* @param {Function} onToggleFollow - 切换关注回调
* @param {Object} ref - 用于滚动的ref
*/
const MarketReviewCard = forwardRef(({
events,
loading,
lastUpdateTime,
onEventClick,
onViewDetail,
onToggleFollow,
...rest
}, ref) => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const linkColor = useColorModeValue('blue.600', 'blue.400');
const mutedColor = useColorModeValue('gray.500', 'gray.400');
const textColor = useColorModeValue('gray.700', 'gray.200');
const selectedBg = useColorModeValue('blue.50', 'blue.900');
// 选中的事件
const [selectedEvent, setSelectedEvent] = useState(null);
// 时间轴样式配置
const getTimelineBoxStyle = () => {
return {
bg: useColorModeValue('gray.50', 'gray.700'),
borderColor: useColorModeValue('gray.400', 'gray.500'),
borderWidth: '2px',
textColor: useColorModeValue('blue.600', 'blue.400'),
boxShadow: 'sm',
};
};
// 处理事件点击
const handleEventClick = (event) => {
setSelectedEvent(event);
if (onEventClick) {
onEventClick(event);
}
};
// 渲染右侧事件详情
const renderEventDetail = () => {
if (!selectedEvent) {
return (
<Center h="full" minH="400px">
<VStack spacing={4}>
<InfoIcon boxSize={12} color={mutedColor} />
<Text color={mutedColor} fontSize="lg">
请从左侧选择事件查看详情
</Text>
</VStack>
</Center>
);
}
const importance = getImportanceConfig(selectedEvent.importance);
return (
<Card
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
boxShadow="md"
h="full"
>
<CardBody p={6}>
<VStack align="stretch" spacing={4}>
{/* 第一行:标题+优先级 | 统计+关注 */}
<Flex align="center" justify="space-between" gap={3}>
{/* 左侧:标题 + 优先级标签 */}
<EventHeader
title={selectedEvent.title}
importance={selectedEvent.importance}
onTitleClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (onViewDetail) {
onViewDetail(e, selectedEvent.id);
}
}}
linkColor={linkColor}
compact={false}
size="lg"
/>
{/* 右侧:统计数据 + 关注按钮 */}
<HStack spacing={4} flexShrink={0}>
{/* 统计数据 */}
<EventStats
viewCount={selectedEvent.view_count}
postCount={selectedEvent.post_count}
followerCount={selectedEvent.follower_count}
size="md"
spacing={4}
display="flex"
mutedColor={mutedColor}
/>
{/* 关注按钮 */}
<EventFollowButton
isFollowing={false}
followerCount={selectedEvent.follower_count}
onToggle={() => onToggleFollow && onToggleFollow(selectedEvent.id)}
size="sm"
showCount={false}
/>
</HStack>
</Flex>
{/* 第二行:价格标签 | 时间+作者 */}
<Flex justify="space-between" align="center" wrap="wrap" gap={3}>
{/* 左侧:价格标签 */}
<EventPriceDisplay
avgChange={selectedEvent.related_avg_chg}
maxChange={selectedEvent.related_max_chg}
weekChange={selectedEvent.related_week_chg}
compact={false}
/>
{/* 右侧:时间 + 作者 */}
<HStack spacing={2} fontSize="sm" flexShrink={0}>
<Text fontWeight="bold" color={linkColor}>
{moment(selectedEvent.created_at).format('YYYY-MM-DD HH:mm')}
</Text>
<Text color={mutedColor}></Text>
<Text color={mutedColor}>@{selectedEvent.creator?.username || 'Anonymous'}</Text>
</HStack>
</Flex>
{/* 第三行:描述文字 */}
<EventDescription
description={selectedEvent.description}
textColor={textColor}
minLength={200}
noOfLines={10}
/>
</VStack>
</CardBody>
</Card>
);
};
return (
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
{/* 标题部分 */}
<CardHeader>
<Flex justify="space-between" align="center">
<VStack align="start" spacing={1}>
<Heading size="md">
<HStack>
<TimeIcon />
<Text>市场复盘</Text>
</HStack>
</Heading>
<HStack fontSize="sm" color="gray.500">
<Badge colorScheme="orange">复盘</Badge>
<Badge colorScheme="purple">总结</Badge>
<Badge colorScheme="gray">完整</Badge>
</HStack>
</VStack>
<Text fontSize="xs" color="gray.500">
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
</Text>
</Flex>
</CardHeader>
{/* 主体内容 */}
<CardBody>
{/* Loading 状态 */}
{loading && (
<Center py={10}>
<VStack>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color="gray.500">正在加载复盘数据...</Text>
</VStack>
</Center>
)}
{/* Empty 状态 */}
{!loading && (!events || events.length === 0) && (
<Center py={10}>
<VStack>
<Text fontSize="lg" color="gray.500">暂无复盘数据</Text>
</VStack>
</Center>
)}
{/* 左右布局:事件列表 | 事件详情 */}
{!loading && events && events.length > 0 && (
<Grid templateColumns="1fr 2fr" gap={6} minH="500px">
{/* 左侧:事件列表 (33.3%) */}
<GridItem>
<Box
overflowY="auto"
maxH="600px"
pr={2}
css={{
'&::-webkit-scrollbar': {
width: '6px',
},
'&::-webkit-scrollbar-track': {
background: useColorModeValue('#f1f1f1', '#2D3748'),
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb': {
background: useColorModeValue('#888', '#4A5568'),
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: useColorModeValue('#555', '#718096'),
},
}}
>
<VStack align="stretch" spacing={2}>
{events.map((event, index) => (
<Box
key={event.id}
onClick={() => handleEventClick(event)}
cursor="pointer"
bg={selectedEvent?.id === event.id ? selectedBg : 'transparent'}
borderRadius="md"
transition="all 0.2s"
_hover={{ bg: selectedBg }}
>
<CompactEventCard
event={event}
index={index}
isFollowing={false}
followerCount={event.follower_count || 0}
onEventClick={() => handleEventClick(event)}
onTitleClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleEventClick(event);
}}
onViewDetail={onViewDetail}
onToggleFollow={() => {}}
timelineStyle={getTimelineBoxStyle()}
borderColor={borderColor}
/>
</Box>
))}
</VStack>
</Box>
</GridItem>
{/* 右侧:事件详情 (66.7%) */}
<GridItem>
{renderEventDetail()}
</GridItem>
</Grid>
)}
</CardBody>
</Card>
);
});
MarketReviewCard.displayName = 'MarketReviewCard';
export default MarketReviewCard;

View File

@@ -15,9 +15,10 @@ import {
*
* @param {string} stockCode - 股票代码
* @param {string} eventTime - 事件时间(可选)
* @param {Function} onClick - 点击回调(可选)
* @returns {JSX.Element}
*/
const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eventTime }) {
const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eventTime, onClick }) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const mountedRef = useRef(true);
@@ -162,7 +163,14 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
}, [data, loading, stableEventTime]);
return (
<div style={{ width: 140, height: 40 }}>
<div
style={{
width: 140,
height: 40,
cursor: onClick ? 'pointer' : 'default'
}}
onClick={onClick}
>
<ReactECharts
option={chartOption}
style={{ width: '100%', height: '100%' }}
@@ -172,9 +180,10 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
</div>
);
}, (prevProps, nextProps) => {
// 自定义比较函数只有当stockCodeeventTime变化时才重新渲染
// 自定义比较函数只有当stockCodeeventTime或onClick变化时才重新渲染
return prevProps.stockCode === nextProps.stockCode &&
prevProps.eventTime === nextProps.eventTime;
prevProps.eventTime === nextProps.eventTime &&
prevProps.onClick === nextProps.onClick;
});
export default MiniTimelineChart;

View File

@@ -15,11 +15,12 @@ const REQUEST_INTERVAL = 30000; // 30秒内不重复请求同一只股票的数
* 获取缓存键
* @param {string} stockCode - 股票代码
* @param {string} eventTime - 事件时间
* @param {string} chartType - 图表类型timeline/daily
* @returns {string} 缓存键
*/
export const getCacheKey = (stockCode, eventTime) => {
export const getCacheKey = (stockCode, eventTime, chartType = 'timeline') => {
const date = eventTime ? moment(eventTime).format('YYYY-MM-DD') : moment().format('YYYY-MM-DD');
return `${stockCode}|${date}`;
return `${stockCode}|${date}|${chartType}`;
};
/**
@@ -52,10 +53,11 @@ export const shouldRefreshData = (cacheKey) => {
* 获取K线数据带缓存和防重复请求
* @param {string} stockCode - 股票代码
* @param {string} eventTime - 事件时间
* @param {string} chartType - 图表类型timeline/daily
* @returns {Promise<Array>} K线数据
*/
export const fetchKlineData = async (stockCode, eventTime) => {
const cacheKey = getCacheKey(stockCode, eventTime);
export const fetchKlineData = async (stockCode, eventTime, chartType = 'timeline') => {
const cacheKey = getCacheKey(stockCode, eventTime, chartType);
// 1. 检查缓存
if (klineDataCache.has(cacheKey)) {
@@ -73,10 +75,10 @@ export const fetchKlineData = async (stockCode, eventTime) => {
}
// 3. 发起新请求
logger.debug('klineDataCache', '发起新K线数据请求', { cacheKey });
logger.debug('klineDataCache', '发起新K线数据请求', { cacheKey, chartType });
const normalizedEventTime = eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : undefined;
const requestPromise = stockService
.getKlineData(stockCode, 'timeline', normalizedEventTime)
.getKlineData(stockCode, chartType, normalizedEventTime)
.then((res) => {
const data = Array.isArray(res?.data) ? res.data : [];
// 更新缓存
@@ -86,12 +88,13 @@ export const fetchKlineData = async (stockCode, eventTime) => {
pendingRequests.delete(cacheKey);
logger.debug('klineDataCache', 'K线数据请求完成并缓存', {
cacheKey,
chartType,
dataPoints: data.length
});
return data;
})
.catch((error) => {
logger.error('klineDataCache', 'fetchKlineData', error, { stockCode, cacheKey });
logger.error('klineDataCache', 'fetchKlineData', error, { stockCode, chartType, cacheKey });
// 清除pending状态
pendingRequests.delete(cacheKey);
// 如果有旧缓存,返回旧数据

View File

@@ -1,5 +1,5 @@
// src/views/Community/index.js
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import { fetchPopularKeywords, fetchHotEvents } from '../../store/slices/communityDataSlice';
@@ -11,6 +11,8 @@ import {
// 导入组件
import EventTimelineCard from './components/EventTimelineCard';
import DynamicNewsCard from './components/DynamicNewsCard';
import MarketReviewCard from './components/MarketReviewCard';
import HotEventsSection from './components/HotEventsSection';
import EventModals from './components/EventModals';
@@ -19,6 +21,13 @@ import { useEventData } from './hooks/useEventData';
import { useEventFilters } from './hooks/useEventFilters';
import { useCommunityEvents } from './hooks/useCommunityEvents';
// 导入时间工具函数
import {
getCurrentTradingTimeRange,
getMarketReviewTimeRange,
filterEventsByTimeRange
} from '../../utils/tradingTimeUtils';
import { logger } from '../../utils/logger';
import { useNotification } from '../../contexts/NotificationContext';
import { usePostHogTrack } from '../../hooks/usePostHogRedux';
@@ -48,6 +57,10 @@ const Community = () => {
const [selectedEvent, setSelectedEvent] = useState(null);
const [selectedEventForStock, setSelectedEventForStock] = useState(null);
// 动态新闻数据状态
const [dynamicNewsEvents, setDynamicNewsEvents] = useState([]);
const [dynamicNewsLoading, setDynamicNewsLoading] = useState(true);
// 🎯 初始化Community埋点Hook
const communityEvents = useCommunityEvents({ navigate });
@@ -60,12 +73,91 @@ const Community = () => {
const { events, pagination, loading, lastUpdateTime } = useEventData(filters);
// 计算市场复盘的时间范围和过滤后的事件
const marketReviewData = useMemo(() => {
const timeRange = getMarketReviewTimeRange();
const filteredEvents = filterEventsByTimeRange(events, timeRange.startTime, timeRange.endTime);
logger.debug('Community', '市场复盘时间范围', {
description: timeRange.description,
rangeType: timeRange.rangeType,
eventCount: filteredEvents.length
});
return {
events: filteredEvents,
timeRange
};
}, [events]);
// 加载热门关键词和热点事件使用Redux内部有缓存判断
useEffect(() => {
dispatch(fetchPopularKeywords());
dispatch(fetchHotEvents());
}, [dispatch]);
// 加载动态新闻数据
useEffect(() => {
const fetchDynamicNews = async () => {
setDynamicNewsLoading(true);
try {
// 检查是否使用 mock 模式
// 开发阶段默认使用 mock 数据
const useMock = true; // TODO: 生产环境改为环境变量控制
// const useMock = process.env.REACT_APP_USE_MOCK === 'true' ||
// localStorage.getItem('use_mock_data') === 'true';
if (useMock) {
// 使用 mock 数据
const { generateMockEvents } = await import('../../mocks/data/events');
const mockData = generateMockEvents({ page: 1, per_page: 30 });
// 调试:检查第一个事件的 related_stocks 和 historical_events 数据
if (mockData.events[0]) {
console.log('Mock 数据第一个事件的股票:', mockData.events[0].related_stocks);
console.log('Mock 数据第一个事件的历史事件:', mockData.events[0].historical_events);
}
setDynamicNewsEvents(mockData.events);
logger.info('Community', '动态新闻(Mock)加载成功', {
count: mockData.events.length,
mode: 'mock',
firstEventStocks: mockData.events[0]?.related_stocks?.length || 0
});
} else {
// 使用真实 API
const timeRange = getCurrentTradingTimeRange();
const response = await fetch(
`/api/events/dynamic-news?start_time=${timeRange.startTime.toISOString()}&end_time=${timeRange.endTime.toISOString()}&count=30`,
{ credentials: 'include' }
);
const data = await response.json();
if (data.success && data.data) {
setDynamicNewsEvents(data.data);
logger.info('Community', '动态新闻加载成功', {
count: data.data.length,
timeRange: timeRange.description,
mode: 'api'
});
} else {
logger.warn('Community', '动态新闻加载失败', data);
setDynamicNewsEvents([]);
}
}
} catch (error) {
logger.error('Community', '动态新闻加载异常', error);
setDynamicNewsEvents([]);
} finally {
setDynamicNewsLoading(false);
}
};
fetchDynamicNews();
// 每5分钟刷新一次动态新闻
const interval = setInterval(fetchDynamicNews, 5 * 60 * 1000);
return () => clearInterval(interval);
}, []);
// 🎯 PostHog 追踪:页面浏览
// useEffect(() => {
// track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
@@ -120,8 +212,29 @@ const Community = () => {
{/* 热点事件区域 */}
<HotEventsSection events={hotEvents} />
{/* 实时事件 */}
<EventTimelineCard
{/* 实时要闻·动态追踪 - 横向滚动 */}
<DynamicNewsCard
mt={6}
events={dynamicNewsEvents}
loading={dynamicNewsLoading}
lastUpdateTime={lastUpdateTime}
onEventClick={handleEventClick}
onViewDetail={handleViewDetail}
/>
{/* 市场复盘 - 左右布局 */}
{/* <MarketReviewCard
mt={6}
events={marketReviewData.events}
loading={loading}
lastUpdateTime={lastUpdateTime}
onEventClick={handleEventClick}
onViewDetail={handleViewDetail}
onToggleFollow={() => {}}
/> */}
{/* 实时事件 - 原纵向列表 */}
{/* <EventTimelineCard
ref={eventTimelineRef}
mt={6}
events={events}
@@ -135,7 +248,7 @@ const Community = () => {
onPageChange={handlePageChange}
onEventClick={handleEventClick}
onViewDetail={handleViewDetail}
/>
/> */}
</Container>
{/* 事件弹窗 */}

View File

@@ -406,12 +406,13 @@ const ConceptTimelineModal = ({
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
size="full"
scrollBehavior="inside"
>
{isOpen && (
<Modal
isOpen={isOpen}
onClose={onClose}
size="full"
scrollBehavior="inside"
>
<ModalOverlay />
<ModalContent maxW="1400px" m={4}>
<ModalHeader
@@ -747,7 +748,7 @@ const ConceptTimelineModal = ({
onClick={() => {
if (event.type === 'news') {
// 🎯 追踪新闻点击和详情打开
trackNewsClicked(event, date);
trackNewsClicked(event, item.date);
trackNewsDetailOpened(event);
setSelectedNews({
@@ -760,7 +761,7 @@ const ConceptTimelineModal = ({
setIsNewsModalOpen(true);
} else if (event.type === 'report') {
// 🎯 追踪研报点击和详情打开
trackReportClicked(event, date);
trackReportClicked(event, item.date);
trackReportDetailOpened(event);
setSelectedReport({
@@ -840,14 +841,16 @@ const ConceptTimelineModal = ({
</ModalFooter>
</ModalContent>
</Modal>
)}
{/* 研报全文Modal */}
<Modal
isOpen={isReportModalOpen}
onClose={() => setIsReportModalOpen(false)}
size="4xl"
scrollBehavior="inside"
>
{isReportModalOpen && (
<Modal
isOpen={isReportModalOpen}
onClose={() => setIsReportModalOpen(false)}
size="4xl"
scrollBehavior="inside"
>
<ModalOverlay />
<ModalContent>
<ModalHeader bg="green.500" color="white">
@@ -919,14 +922,16 @@ const ConceptTimelineModal = ({
</ModalFooter>
</ModalContent>
</Modal>
)}
{/* 新闻全文Modal */}
<Modal
isOpen={isNewsModalOpen}
onClose={() => setIsNewsModalOpen(false)}
size="4xl"
scrollBehavior="inside"
>
{isNewsModalOpen && (
<Modal
isOpen={isNewsModalOpen}
onClose={() => setIsNewsModalOpen(false)}
size="4xl"
scrollBehavior="inside"
>
<ModalOverlay />
<ModalContent>
<ModalHeader bg="blue.500" color="white">
@@ -989,6 +994,7 @@ const ConceptTimelineModal = ({
</ModalFooter>
</ModalContent>
</Modal>
)}
</>
);
};

View File

@@ -283,7 +283,7 @@ export default function CenterDashboard() {
icon={<FiPlus />}
variant="ghost"
size="sm"
onClick={() => navigate('/stock-analysis/overview')}
onClick={() => navigate('/stocks')}
aria-label="添加自选股"
/>
</Flex>
@@ -300,7 +300,7 @@ export default function CenterDashboard() {
size="sm"
variant="outline"
colorScheme="blue"
onClick={() => navigate('/stock-analysis/overview')}
onClick={() => navigate('/stocks')}
>
添加自选股
</Button>
@@ -321,7 +321,7 @@ export default function CenterDashboard() {
<VStack align="start" spacing={0}>
<LinkOverlay
as={Link}
to={`/stock-analysis/company?scode=${stock.stock_code}`}
to={`/company/${stock.stock_code}`}
>
<Text fontWeight="medium" fontSize="sm">
{stock.stock_name || stock.stock_code}
@@ -365,7 +365,7 @@ export default function CenterDashboard() {
<Button
size="sm"
variant="ghost"
onClick={() => navigate('/stock-analysis/overview')}
onClick={() => navigate('/stocks')}
>
查看全部 ({watchlist.length})
</Button>

View File

@@ -8,7 +8,6 @@ import {
Badge,
Button,
Collapse,
useDisclosure,
Skeleton,
Alert,
AlertIcon,
@@ -19,13 +18,6 @@ import {
Icon,
useColorModeValue,
Tooltip,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
Spinner,
Table,
Thead,
@@ -43,7 +35,9 @@ import {
FaChartLine,
FaEye,
FaTimes,
FaInfoCircle
FaInfoCircle,
FaChevronDown,
FaChevronUp
} from 'react-icons/fa';
import { stockService } from '../../../services/eventService';
import { logger } from '../../../utils/logger';
@@ -57,11 +51,9 @@ const HistoricalEvents = ({
// 所有 useState/useEffect/useContext/useRef/useCallback/useMemo 必须在组件顶层、顺序一致
// 不要在 if/循环/回调中调用 Hook
const [expandedEvents, setExpandedEvents] = useState(new Set());
const [selectedEvent, setSelectedEvent] = useState(null);
const [expandedStocks, setExpandedStocks] = useState(new Set()); // 追踪哪些事件的股票列表被展开
const [eventStocks, setEventStocks] = useState({});
const [loadingStocks, setLoadingStocks] = useState(false);
const { isOpen, onOpen, onClose } = useDisclosure();
const [loadingStocks, setLoadingStocks] = useState({});
// 颜色主题
const timelineBg = useColorModeValue('#D4AF37', '#B8860B');
@@ -80,36 +72,48 @@ const HistoricalEvents = ({
setExpandedEvents(newExpanded);
};
// 显示事件相关股票
const showEventStocks = async (event) => {
setSelectedEvent(event);
setLoadingStocks(true);
onOpen();
// 切换股票列表展开状态
const toggleStocksExpansion = async (event) => {
const eventId = event.id;
const newExpanded = new Set(expandedStocks);
// 如果正在收起,直接更新状态
if (newExpanded.has(eventId)) {
newExpanded.delete(eventId);
setExpandedStocks(newExpanded);
return;
}
// 如果正在展开,先展开再加载数据
newExpanded.add(eventId);
setExpandedStocks(newExpanded);
// 如果已经加载过该事件的股票数据,不再重复加载
if (eventStocks[eventId]) {
return;
}
// 标记为加载中
setLoadingStocks(prev => ({ ...prev, [eventId]: true }));
try {
// 如果已经加载过该事件的股票数据,直接使用缓存
if (eventStocks[event.id]) {
setLoadingStocks(false);
return;
}
// 调用API获取历史事件相关股票
const response = await stockService.getHistoricalEventStocks(event.id);
const response = await stockService.getHistoricalEventStocks(eventId);
setEventStocks(prev => ({
...prev,
[event.id]: response.data || []
[eventId]: response.data || []
}));
} catch (err) {
logger.error('HistoricalEvents', 'showEventStocks', err, {
eventId: event.id,
logger.error('HistoricalEvents', 'toggleStocksExpansion', err, {
eventId: eventId,
eventTitle: event.title
});
setEventStocks(prev => ({
...prev,
[event.id]: []
[eventId]: []
}));
} finally {
setLoadingStocks(false);
setLoadingStocks(prev => ({ ...prev, [eventId]: false }));
}
};
@@ -377,7 +381,8 @@ const HistoricalEvents = ({
<Button
size="sm"
leftIcon={<Icon as={FaChartLine} />}
onClick={() => showEventStocks(event)}
rightIcon={<Icon as={expandedStocks.has(event.id) ? FaChevronUp : FaChevronDown} />}
onClick={() => toggleStocksExpansion(event)}
colorScheme="blue"
variant="outline"
>
@@ -416,6 +421,31 @@ const HistoricalEvents = ({
</VStack>
</Box>
</Collapse>
{/* 相关股票列表 Collapse */}
<Collapse in={expandedStocks.has(event.id)} animateOpacity>
<Box
mt={3}
pt={3}
borderTop="1px solid"
borderTopColor={borderColor}
bg={useColorModeValue('gray.50', 'gray.750')}
p={3}
borderRadius="md"
>
{loadingStocks[event.id] ? (
<VStack spacing={4} py={8}>
<Spinner size="lg" color="blue.500" />
<Text color={textSecondary}>加载相关股票数据...</Text>
</VStack>
) : (
<StocksList
stocks={eventStocks[event.id] || []}
eventTradingDate={event.event_date}
/>
)}
</Box>
</Collapse>
</VStack>
</CardBody>
</Card>
@@ -426,40 +456,6 @@ const HistoricalEvents = ({
</VStack>
</Box>
</VStack>
{/* 事件相关股票模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
<ModalOverlay />
<ModalContent maxW="80vw" maxH="85vh">
<ModalHeader>
<VStack align="flex-start" spacing={1}>
<Text>{selectedEvent?.title || '历史事件'}</Text>
<Text fontSize="sm" color={textSecondary} fontWeight="normal">
相关股票信息
</Text>
</VStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody overflowY="auto" maxH="calc(85vh - 180px)">
{loadingStocks ? (
<VStack spacing={4} py={8}>
<Spinner size="lg" color="blue.500" />
<Text color={textSecondary}>加载相关股票数据...</Text>
</VStack>
) : (
<StocksList
stocks={selectedEvent ? eventStocks[selectedEvent.id] || [] : []}
eventTradingDate={selectedEvent ? selectedEvent.event_date : null}
/>
)}
</ModalBody>
<ModalFooter>
<Button onClick={onClose}>关闭</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
};

View File

@@ -1,629 +0,0 @@
// src/views/Home/HomePage.js
import React, { useState, useEffect } from 'react';
import {
Box,
Flex,
Text,
Button,
Container,
VStack,
HStack,
Icon,
Heading,
useBreakpointValue,
Link,
SimpleGrid,
Divider
} from '@chakra-ui/react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; // 添加这个导入来调试
import heroBg from 'assets/img/BackgroundCard1.png';
import teamWorkingImg from 'assets/img/background-card-reports.png';
export default function HomePage() {
const navigate = useNavigate();
const { user, isAuthenticated, isLoading } = useAuth(); // 添加这行来调试
// 添加调试信息
useEffect(() => {
console.log('🏠 HomePage AuthContext 状态:', {
user,
isAuthenticated,
isLoading,
hasUser: !!user,
userInfo: user ? {
id: user.id,
username: user.username,
nickname: user.nickname
} : null
});
}, [user?.id, isAuthenticated, isLoading]); // 只依赖 user.id,避免无限循环
// 统计数据动画
const [stats, setStats] = useState({
dataSize: 0,
dataSources: 0,
researchTargets: 0
});
useEffect(() => {
const targetStats = {
dataSize: 17,
dataSources: 300,
researchTargets: 45646
};
// 动画效果
const animateStats = () => {
const duration = 2000; // 2秒动画
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
setStats({
dataSize: Math.floor(targetStats.dataSize * progress),
dataSources: Math.floor(targetStats.dataSources * progress),
researchTargets: Math.floor(targetStats.researchTargets * progress)
});
if (progress < 1) {
requestAnimationFrame(animate);
}
};
animate();
};
const timer = setTimeout(animateStats, 500);
return () => clearTimeout(timer);
}, []);
return (
<Box>
{/* 临时调试信息栏 - 完成调试后可以删除 */}
{process.env.NODE_ENV === 'development' && (
<Box bg="yellow.100" p={2} fontSize="sm" borderBottom="1px solid" borderColor="yellow.300">
<Text fontWeight="bold">🐛 调试信息:</Text>
<Text>认证状态: {isAuthenticated ? '✅ 已登录' : '❌ 未登录'}</Text>
<Text>加载状态: {isLoading ? '⏳ 加载中' : '✅ 加载完成'}</Text>
<Text>用户信息: {user ? `👤 ${user.nickname || user.username} (ID: ${user.id})` : '❌ 无用户信息'}</Text>
<Text>localStorage: {localStorage.getItem('user') ? '✅ 有数据' : '❌ 无数据'}</Text>
</Box>
)}
{/* Hero Section - Brainwave风格 */}
<Box
position="relative"
minH="100vh"
bg="linear-gradient(135deg, #0E0C15 0%, #15131D 50%, #252134 100%)"
display="flex"
alignItems="center"
justifyContent="center"
overflow="hidden"
>
{/* 同心圆背景装饰 */}
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
w="78rem"
h="78rem"
border="1px solid"
borderColor="brainwave.n6"
borderRadius="50%"
opacity={0.3}
zIndex={0}
>
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
w="65rem"
h="65rem"
border="1px solid"
borderColor="brainwave.n6"
borderRadius="50%"
opacity={0.2}
/>
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
w="51rem"
h="51rem"
border="1px solid"
borderColor="brainwave.n6"
borderRadius="50%"
opacity={0.1}
/>
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
w="36rem"
h="36rem"
border="1px solid"
borderColor="brainwave.n6"
borderRadius="50%"
opacity={0.1}
/>
</Box>
{/* 动态装饰点 */}
<Box
position="absolute"
top="30%"
right="20%"
w="4"
h="4"
bg="linear-gradient(135deg, #AC6AFF, #1A1A32)"
borderRadius="50%"
animation="float 3s ease-in-out infinite"
zIndex={1}
/>
<Box
position="absolute"
bottom="40%"
left="15%"
w="6"
h="6"
bg="linear-gradient(135deg, #7ADB78, #1A1A32)"
borderRadius="50%"
animation="float 4s ease-in-out infinite 0.5s"
zIndex={1}
/>
<Box
position="absolute"
top="20%"
left="25%"
w="3"
h="3"
bg="linear-gradient(135deg, #FFC876, #1A1A32)"
borderRadius="50%"
animation="float 2.5s ease-in-out infinite 1s"
zIndex={1}
/>
{/* 主要内容 */}
<Container maxW="container.lg" position="relative" zIndex={2}>
<VStack spacing={8} textAlign="center" color="brainwave.n1">
<Heading
as="h1"
fontSize={{ base: "3xl", md: "5xl", lg: "6xl" }}
fontWeight="bold"
lineHeight={1.1}
letterSpacing="tight"
>
探索
<Text as="span" bgGradient="linear(to-r, brainwave.purple, brainwave.orange)" bgClip="text">
人工智能
</Text>
的无限可能
<br />
<Text as="span" fontSize={{ base: "2xl", md: "4xl", lg: "5xl" }} color="brainwave.n2">
价值前沿 AI 投研助手
</Text>
</Heading>
<Text
fontSize={{ base: "lg", md: "xl" }}
color="brainwave.n2"
maxW="600px"
lineHeight={1.6}
>
释放AI的力量升级您的投研效率
体验超越ChatGPT的专业投资分析平台
</Text>
<Button
size="lg"
bg="linear-gradient(135deg, #AC6AFF, #FFC876)"
color="white"
_hover={{
transform: "translateY(-2px)",
boxShadow: "0 8px 32px rgba(172, 106, 255, 0.3)"
}}
_active={{ transform: "translateY(0)" }}
transition="all 0.2s"
borderRadius="full"
px={8}
py={6}
fontSize="md"
fontWeight="semibold"
onClick={() => navigate('/community')}
>
开始体验
</Button>
</VStack>
</Container>
{/* 渐变底部 */}
<Box
position="absolute"
bottom={0}
left={0}
right={0}
height="100px"
zIndex={2}
bg="linear-gradient(to top, white 0%, transparent 100%)"
/>
{/* CSS动画定义 */}
<style jsx>{`
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
}
`}</style>
</Box>
{/* 统计数据区域 - 玻璃拟态效果 */}
<Box py={12} position="relative" mt={-20} zIndex={3}>
<Container maxW="container.lg">
<Box
bg="rgba(255, 255, 255, 0.1)"
backdropFilter="blur(20px)"
borderRadius="xl"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.2)"
boxShadow="0 25px 50px rgba(0, 0, 0, 0.25)"
p={8}
position="relative"
_before={{
content: '""',
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: "xl",
background: "linear-gradient(135deg, rgba(172, 106, 255, 0.1) 0%, rgba(255, 200, 118, 0.1) 100%)",
zIndex: -1
}}
>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={8}>
<VStack textAlign="center" spacing={4}>
<Heading
size="2xl"
bgGradient="linear(to-r, brainwave.purple, brainwave.orange)"
bgClip="text"
fontWeight="bold"
>
{stats.dataSize}TB
</Heading>
<Heading size="lg" color="brainwave.n1" fontWeight="semibold">基础数据</Heading>
<Text color="brainwave.n2" fontSize="sm" lineHeight={1.6}>
我们收集来自全世界的各类数据打造您的专属智能投资助手
</Text>
</VStack>
<VStack textAlign="center" spacing={4}>
<Heading
size="2xl"
bgGradient="linear(to-r, brainwave.green, brainwave.blue)"
bgClip="text"
fontWeight="bold"
>
{stats.dataSources}+
</Heading>
<Heading size="lg" color="brainwave.n1" fontWeight="semibold">数据源</Heading>
<Text color="brainwave.n2" fontSize="sm" lineHeight={1.6}>
我们即时采集来自300多家数据源的实时数据随时满足您的投研需求
</Text>
</VStack>
<VStack textAlign="center" spacing={4}>
<Heading
size="2xl"
bgGradient="linear(to-r, brainwave.pink, brainwave.red)"
bgClip="text"
fontWeight="bold"
>
{stats.researchTargets.toLocaleString()}
</Heading>
<Heading size="lg" color="brainwave.n1" fontWeight="semibold">研究标的</Heading>
<Text color="brainwave.n2" fontSize="sm" lineHeight={1.6}>
我们的研究范围涵盖全球主流市场包括股票外汇大宗等交易类型给您足够宏观的视角
</Text>
</VStack>
</SimpleGrid>
</Box>
</Container>
</Box>
{/* 特色功能介绍 - Brainwave深色风格 */}
<Box as="section" py={20} bg="brainwave.n8" position="relative" overflow="hidden">
{/* 背景装饰几何图形 */}
<Box
position="absolute"
top="10%"
right="-5%"
w="300px"
h="300px"
border="1px solid"
borderColor="brainwave.n6"
borderRadius="50%"
opacity={0.1}
zIndex={0}
/>
<Box
position="absolute"
bottom="10%"
left="-5%"
w="200px"
h="200px"
border="1px solid"
borderColor="brainwave.n6"
borderRadius="50%"
opacity={0.1}
zIndex={0}
/>
<Container maxW="container.xl" position="relative" zIndex={1}>
<Flex align="center" gap={16}>
{/* 左侧功能介绍 - 深色主题版本 */}
<Box flex="1" ml="auto">
{/* 第一行 */}
<SimpleGrid columns={2} spacing={8} mb={12}>
<Box>
<VStack align="start" spacing={4}>
<Box className="icon icon-sm">
<Icon viewBox="0 0 40 44" w="25px" h="25px" color="brainwave.purple">
<path fill="currentColor" d="M40,40 L36.3636364,40 L36.3636364,3.63636364 L5.45454545,3.63636364 L5.45454545,0 L38.1818182,0 C39.1854545,0 40,0.814545455 40,1.81818182 L40,40 Z" opacity="0.603585379"/>
<path fill="currentColor" d="M30.9090909,7.27272727 L1.81818182,7.27272727 C0.814545455,7.27272727 0,8.08727273 0,9.09090909 L0,41.8181818 C0,42.8218182 0.814545455,43.6363636 1.81818182,43.6363636 L30.9090909,43.6363636 C31.9127273,43.6363636 32.7272727,42.8218182 32.7272727,41.8181818 L32.7272727,9.09090909 C32.7272727,8.08727273 31.9127273,7.27272727 30.9090909,7.27272727 Z M18.1818182,34.5454545 L7.27272727,34.5454545 L7.27272727,30.9090909 L18.1818182,30.9090909 L18.1818182,34.5454545 Z M25.4545455,27.2727273 L7.27272727,27.2727273 L7.27272727,23.6363636 L25.4545455,23.6363636 L25.4545455,27.2727273 Z M25.4545455,20 L7.27272727,20 L7.27272727,16.3636364 L25.4545455,16.3636364 L25.4545455,20 Z"/>
</Icon>
</Box>
<Heading size="md" fontWeight="bold" mt={3} color="brainwave.n1">人工智能驱动</Heading>
<Text color="brainwave.n2" fontSize="sm" pr="5" lineHeight={1.8}>
收集海量投研资料和数据确保信息全面丰富<br/>
训练专注于投研的大语言模型专业度领先<br/>
在金融投资领域表现卓越优于市面其他模型
</Text>
</VStack>
</Box>
<Box>
<VStack align="start" spacing={4}>
<Box className="icon icon-sm">
<Icon viewBox="0 0 45 40" w="25px" h="25px" color="brainwave.orange">
<path fill="currentColor" d="M46.7199583,10.7414583 L40.8449583,0.949791667 C40.4909749,0.360605034 39.8540131,0 39.1666667,0 L7.83333333,0 C7.1459869,0 6.50902508,0.360605034 6.15504167,0.949791667 L0.280041667,10.7414583 C0.0969176761,11.0460037 -1.23209662e-05,11.3946378 -1.23209662e-05,11.75 C-0.00758042603,16.0663731 3.48367543,19.5725301 7.80004167,19.5833333 L7.81570833,19.5833333 C9.75003686,19.5882688 11.6168794,18.8726691 13.0522917,17.5760417 C16.0171492,20.2556967 20.5292675,20.2556967 23.494125,17.5760417 C26.4604562,20.2616016 30.9794188,20.2616016 33.94575,17.5760417 C36.2421905,19.6477597 39.5441143,20.1708521 42.3684437,18.9103691 C45.1927731,17.649886 47.0084685,14.8428276 47.0000295,11.75 C47.0000295,11.3946378 46.9030823,11.0460037 46.7199583,10.7414583 Z" opacity="0.598981585"/>
<path fill="currentColor" d="M39.198,22.4912623 C37.3776246,22.4928106 35.5817531,22.0149171 33.951625,21.0951667 L33.92225,21.1107282 C31.1430221,22.6838032 27.9255001,22.9318916 24.9844167,21.7998837 C24.4750389,21.605469 23.9777983,21.3722567 23.4960833,21.1018359 L23.4745417,21.1129513 C20.6961809,22.6871153 17.4786145,22.9344611 14.5386667,21.7998837 C14.029926,21.6054643 13.533337,21.3722507 13.0522917,21.1018359 C11.4250962,22.0190609 9.63246555,22.4947009 7.81570833,22.4912623 C7.16510551,22.4842162 6.51607673,22.4173045 5.875,22.2911849 L5.875,44.7220845 C5.875,45.9498589 6.7517757,46.9451667 7.83333333,46.9451667 L19.5833333,46.9451667 L19.5833333,33.6066734 L27.4166667,33.6066734 L27.4166667,46.9451667 L39.1666667,46.9451667 C40.2482243,46.9451667 41.125,45.9498589 41.125,44.7220845 L41.125,22.2822926 C40.4887822,22.4116582 39.8442868,22.4815492 39.198,22.4912623 Z"/>
</Icon>
</Box>
<Heading size="md" fontWeight="bold" mt={3} color="brainwave.n1">投研数据湖</Heading>
<Text color="brainwave.n2" fontSize="sm" pr="3" lineHeight={1.8}>
AI Agent 24/7 全天候采集全球数据确保实时更新<br/>
整合多种数据源覆盖范围广泛信息丰富<br/>
构建独特数据湖提供业内无可比拟的数据深度
</Text>
</VStack>
</Box>
</SimpleGrid>
{/* 第二行 */}
<SimpleGrid columns={2} spacing={8}>
<Box mt={3}>
<VStack align="start" spacing={4}>
<Box className="icon icon-sm">
<Icon viewBox="0 0 42 44" w="25px" h="25px" color="brainwave.green">
<path fill="currentColor" d="M18.8086957,4.70034783 C15.3814926,0.343541521 9.0713063,-0.410050841 4.7145,3.01715217 C0.357693695,6.44435519 -0.395898667,12.7545415 3.03130435,17.1113478 C5.53738466,10.3360568 11.6337901,5.54042955 18.8086957,4.70034783 L18.8086957,4.70034783 Z" opacity="0.6"/>
<path fill="currentColor" d="M38.9686957,17.1113478 C42.3958987,12.7545415 41.6423063,6.44435519 37.2855,3.01715217 C32.9286937,-0.410050841 26.6185074,0.343541521 23.1913043,4.70034783 C30.3662099,5.54042955 36.4626153,10.3360568 38.9686957,17.1113478 Z" opacity="0.6"/>
<path fill="currentColor" d="M34.3815652,34.7668696 C40.2057958,27.7073059 39.5440671,17.3375603 32.869743,11.0755718 C26.1954189,4.81358341 15.8045811,4.81358341 9.13025701,11.0755718 C2.45593289,17.3375603 1.79420418,27.7073059 7.61843478,34.7668696 L3.9753913,40.0506522 C3.58549114,40.5871271 3.51710058,41.2928217 3.79673036,41.8941824 C4.07636014,42.4955431 4.66004722,42.8980248 5.32153275,42.9456105 C5.98301828,42.9931963 6.61830436,42.6784048 6.98113043,42.1232609 L10.2744783,37.3434783 C16.5555112,42.3298213 25.4444888,42.3298213 31.7255217,37.3434783 L35.0188696,42.1196087 C35.6014207,42.9211577 36.7169135,43.1118605 37.53266,42.5493622 C38.3484064,41.9868639 38.5667083,40.8764423 38.0246087,40.047 L34.3815652,34.7668696 Z M30.1304348,25.5652174 L21,25.5652174 C20.49574,25.5652174 20.0869565,25.1564339 20.0869565,24.6521739 L20.0869565,15.5217391 C20.0869565,15.0174791 20.49574,14.6086957 21,14.6086957 C21.50426,14.6086957 21.9130435,15.0174791 21.9130435,15.5217391 L21.9130435,23.7391304 L30.1304348,23.7391304 C30.6346948,23.7391304 31.0434783,24.1479139 31.0434783,24.6521739 C31.0434783,25.1564339 30.6346948,25.5652174 30.1304348,25.5652174 Z"/>
</Icon>
</Box>
<Heading size="md" fontWeight="bold" mt={3} color="brainwave.n1">投研Agent</Heading>
<Text color="brainwave.n2" fontSize="sm" pr="5" lineHeight={1.8}>
采用 AI 模拟人类分析师智能化程度高<br/>
具备独特的全球视角全面审视各类资产<br/>
提供最佳投资建议支持科学决策
</Text>
</VStack>
</Box>
<Box mt={3}>
<VStack align="start" spacing={4}>
<Box className="icon icon-sm">
<Icon viewBox="0 0 42 42" w="25px" h="25px" color="brainwave.blue">
<path fill="currentColor" d="M12.25,17.5 L8.75,17.5 L8.75,1.75 C8.75,0.78225 9.53225,0 10.5,0 L31.5,0 C32.46775,0 33.25,0.78225 33.25,1.75 L33.25,12.25 L29.75,12.25 L29.75,3.5 L12.25,3.5 L12.25,17.5 Z" opacity="0.6"/>
<path fill="currentColor" d="M40.25,14 L24.5,14 C23.53225,14 22.75,14.78225 22.75,15.75 L22.75,38.5 L19.25,38.5 L19.25,22.75 C19.25,21.78225 18.46775,21 17.5,21 L1.75,21 C0.78225,21 0,21.78225 0,22.75 L0,40.25 C0,41.21775 0.78225,42 1.75,42 L40.25,42 C41.21775,42 42,41.21775 42,40.25 L42,15.75 C42,14.78225 41.21775,14 40.25,14 Z M12.25,36.75 L7,36.75 L7,33.25 L12.25,33.25 L12.25,36.75 Z M12.25,29.75 L7,29.75 L7,26.25 L12.25,26.25 L12.25,29.75 Z M35,36.75 L29.75,36.75 L29.75,33.25 L35,33.25 L35,36.75 Z M35,29.75 L29.75,29.75 L29.75,26.25 L35,26.25 L35,29.75 Z M35,22.75 L29.75,22.75 L29.75,19.25 L35,19.25 L35,22.75 Z"/>
</Icon>
</Box>
<Heading size="md" fontWeight="bold" mt={3} color="brainwave.n1">新闻事件驱动</Heading>
<Text color="brainwave.n2" fontSize="sm" pr="3" lineHeight={1.8}>
基于AI的信息挖掘技术<br/>
Agent 赋能的未来推演和数据关联<br/>
自由交流我们相信集体的力量
</Text>
</VStack>
</Box>
{/* 深研系统 → 盈利预测报表 入口 */}
<Box mt={3}>
<VStack align="start" spacing={3}>
<Heading size="md" fontWeight="bold" mt={1} color="brainwave.n1">深研系统</Heading>
<Button
size="sm"
colorScheme="purple"
variant="ghost"
onClick={() => navigate('/admin/stock-analysis/forecast-report')}
>
盈利预测报表
</Button>
</VStack>
</Box>
</SimpleGrid>
</Box>
{/* 右侧卡片 - 完全按照原网站设计 */}
<Box flex="0 0 auto" w="400px" p={4}>
<Box
position="relative"
borderRadius="xl"
overflow="hidden"
transform="perspective(1000px) rotateY(-5deg)"
boxShadow="2xl"
bgImage={`url(${teamWorkingImg})`}
bgSize="cover"
bgPosition="center"
h="400px"
>
{/* 黑色遮罩 */}
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bg="blackAlpha.600"
/>
{/* 内容 */}
<Flex
direction="column"
align="center"
justify="center"
h="full"
position="relative"
zIndex={1}
color="white"
textAlign="center"
pt={7}
>
{/* 3D盒子图标 */}
<Box className="icon icon-lg up" mb={3} mt={3}>
<Icon viewBox="0 0 42 42" w="50px" h="50px" color="white">
<path fill="currentColor" d="M22.7597136,19.3090182 L38.8987031,11.2395234 C39.3926816,10.9925342 39.592906,10.3918611 39.3459167,9.89788265 C39.249157,9.70436312 39.0922432,9.5474453 38.8987261,9.45068056 L20.2741875,0.1378125 L20.2741875,0.1378125 C19.905375,-0.04725 19.469625,-0.04725 19.0995,0.1378125 L3.1011696,8.13815822 C2.60720568,8.38517662 2.40701679,8.98586148 2.6540352,9.4798254 C2.75080129,9.67332903 2.90771305,9.83023153 3.10122239,9.9269862 L21.8652864,19.3090182 C22.1468139,19.4497819 22.4781861,19.4497819 22.7597136,19.3090182 Z"/>
<path fill="currentColor" d="M23.625,22.429159 L23.625,39.8805372 C23.625,40.4328219 24.0727153,40.8805372 24.625,40.8805372 C24.7802551,40.8805372 24.9333778,40.8443874 25.0722402,40.7749511 L41.2741875,32.673375 L41.2741875,32.673375 C41.719125,32.4515625 42,31.9974375 42,31.5 L42,14.241659 C42,13.6893742 41.5522847,13.241659 41,13.241659 C40.8447549,13.241659 40.6916418,13.2778041 40.5527864,13.3472318 L24.1777864,21.5347318 C23.8390024,21.7041238 23.625,22.0503869 23.625,22.429159 Z" opacity="0.7"/>
<path fill="currentColor" d="M20.4472136,21.5347318 L1.4472136,12.0347318 C0.953235098,11.7877425 0.352562058,11.9879669 0.105572809,12.4819454 C0.0361450918,12.6208008 6.47121774e-16,12.7739139 0,12.929159 L0,30.1875 L0,30.1875 C0,30.6849375 0.280875,31.1390625 0.7258125,31.3621875 L19.5528096,40.7750766 C20.0467945,41.0220531 20.6474623,40.8218132 20.8944388,40.3278283 C20.963859,40.1889789 21,40.0358742 21,39.8806379 L21,22.429159 C21,22.0503869 20.7859976,21.7041238 20.4472136,21.5347318 Z" opacity="0.7"/>
</Icon>
</Box>
<Heading size="xl" color="brainwave.n1" className="up" mb={0} lineHeight="1.2">
<Text as="span" bgGradient="linear(to-r, brainwave.purple, brainwave.orange)" bgClip="text">
事件催化
</Text><br />
让成功有迹可循
</Heading>
<Button
onClick={() => navigate('/community')}
bg="linear-gradient(135deg, #AC6AFF, #FFC876)"
color="white"
size="lg"
mt={3}
className="up btn-round"
borderRadius="full"
border="none"
_hover={{
transform: "translateY(-2px)",
boxShadow: "0 8px 32px rgba(172, 106, 255, 0.3)"
}}
_active={{ transform: "translateY(0)" }}
transition="all 0.2s"
>
访问新闻催化分析
</Button>
</Flex>
</Box>
</Box>
</Flex>
</Container>
</Box>
{/* AI投研专题应用区域 - Brainwave风格 */}
<Box as="section" py={20} bg="brainwave.n7" position="relative">
<Container maxW="container.xl">
<VStack spacing={12} textAlign="center">
<VStack spacing={2}>
<Heading size="xl" color="brainwave.n1" mb={0}>
AI投研专题应用
</Heading>
<Heading
size="xl"
bgGradient="linear(to-r, brainwave.orange, brainwave.purple)"
bgClip="text"
fontWeight="bold"
>
By 价小前投研
</Heading>
<Text fontSize="lg" color="brainwave.n2" fontWeight="medium">
人工智能+专业投研流程最强投资AI助手
</Text>
</VStack>
</VStack>
</Container>
</Box>
{/* 页脚 - Brainwave深色主题 */}
<Box as="footer" bg="brainwave.n8" color="brainwave.n1" py={16}>
<Container maxW="container.xl">
<SimpleGrid columns={{ base: 2, md: 5 }} spacing={8}>
{/* 价值前沿 */}
<VStack align="start" spacing={4}>
<Heading size="md" bgGradient="linear(to-r, brainwave.purple, brainwave.orange)" bgClip="text">价值前沿</Heading>
<Text fontSize="sm" color="brainwave.n2" fontWeight="bold">
更懂投资者的AI投研平台
</Text>
</VStack>
{/* 关于我们 */}
<VStack align="start" spacing={4}>
<Heading size="sm" color="brainwave.orange">关于我们</Heading>
<VStack align="start" spacing={2}>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.orange" }}>公司介绍</Link>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.orange" }}>团队架构</Link>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.orange" }}>联系方式</Link>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.orange" }}>反馈评价</Link>
</VStack>
</VStack>
{/* 免费资源 */}
<VStack align="start" spacing={4}>
<Heading size="sm" color="brainwave.green">免费资源</Heading>
<VStack align="start" spacing={2}>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.green" }}>投研日报</Link>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.green" }}>资讯速递</Link>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.green" }}>免费试用</Link>
</VStack>
</VStack>
{/* 产品介绍 */}
<VStack align="start" spacing={4}>
<Heading size="sm" color="brainwave.blue">产品介绍</Heading>
<VStack align="start" spacing={2}>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.blue" }}>行情复盘</Link>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.blue" }}>高频跟踪</Link>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.blue" }}>深研系统</Link>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.blue" }}>了解更多</Link>
</VStack>
</VStack>
{/* 产品下载 */}
<VStack align="start" spacing={4}>
<Heading size="sm" color="brainwave.pink">产品下载</Heading>
<VStack align="start" spacing={2}>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.pink" }}>手机APP</Link>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.pink" }}>Win终端</Link>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.pink" }}>Mac终端</Link>
</VStack>
</VStack>
</SimpleGrid>
{/* 版权信息 */}
<Divider my={8} />
<Text textAlign="center" fontSize="sm" color="brainwave.n4">
All rights reserved. Copyright © {new Date().getFullYear()} 投研系统 by{' '}
<Link color="brainwave.orange" _hover={{ textDecoration: "underline" }}>
价值前沿
</Link>
.
</Text>
</Container>
</Box>
</Box>
);
}

View File

@@ -1,135 +0,0 @@
/** @type {import('tailwindcss').Config} */
import { fontFamily } from "tailwindcss/defaultTheme";
import plugin from "tailwindcss/plugin";
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
"./src/templates/**/*.{js,jsx,ts,tsx}",
"./src/components/**/*.{js,jsx,ts,tsx}",
"./src/views/**/*.{js,jsx,ts,tsx}",
"./public/index.html",
],
theme: {
extend: {
colors: {
color: {
1: "#AC6AFF",
2: "#FFC876",
3: "#FF776F",
4: "#7ADB78",
5: "#858DFF",
6: "#FF98E2",
},
stroke: {
1: "#26242C",
},
n: {
1: "#FFFFFF",
2: "#CAC6DD",
3: "#ADA8C3",
4: "#757185",
5: "#3F3A52",
6: "#252134",
7: "#15131D",
8: "#0E0C15",
},
},
fontFamily: {
sans: ["var(--font-sora)", ...fontFamily.sans],
code: "var(--font-code)",
grotesk: "var(--font-grotesk)",
},
letterSpacing: {
tagline: ".15em",
},
spacing: {
0.25: "0.0625rem",
7.5: "1.875rem",
15: "3.75rem",
},
opacity: {
15: ".15",
},
transitionDuration: {
DEFAULT: "200ms",
},
transitionTimingFunction: {
DEFAULT: "linear",
},
zIndex: {
1: "1",
2: "2",
3: "3",
4: "4",
5: "5",
},
borderWidth: {
DEFAULT: "0.0625rem",
},
backgroundImage: {
"radial-gradient": "radial-gradient(var(--tw-gradient-stops))",
"conic-gradient":
"conic-gradient(from 225deg, #FFC876, #79FFF7, #9F53FF, #FF98E2, #FFC876)",
},
},
},
plugins: [
plugin(function ({ addBase, addComponents, addUtilities }) {
addBase({});
addComponents({
".container": {
"@apply max-w-[77.5rem] mx-auto px-5 md:px-10 lg:px-15 xl:max-w-[87.5rem]":
{},
},
".h1": {
"@apply font-semibold text-[2.5rem] leading-[3.25rem] md:text-[2.75rem] md:leading-[3.75rem] lg:text-[3.25rem] lg:leading-[4.0625rem] xl:text-[3.75rem] xl:leading-[4.5rem]":
{},
},
".h2": {
"@apply text-[1.75rem] leading-[2.5rem] md:text-[2rem] md:leading-[2.5rem] lg:text-[2.5rem] lg:leading-[3.5rem] xl:text-[3rem] xl:leading-tight":
{},
},
".h3": {
"@apply text-[2rem] leading-normal md:text-[2.5rem]": {},
},
".h4": {
"@apply text-[2rem] leading-normal": {},
},
".h5": {
"@apply text-2xl leading-normal": {},
},
".h6": {
"@apply font-semibold text-lg leading-8": {},
},
".body-1": {
"@apply text-[0.875rem] leading-[1.5rem] md:text-[1rem] md:leading-[1.75rem] lg:text-[1.25rem] lg:leading-8":
{},
},
".body-2": {
"@apply font-light text-[0.875rem] leading-6 md:text-base":
{},
},
".caption": {
"@apply text-sm": {},
},
".tagline": {
"@apply font-grotesk font-light text-xs tracking-tagline uppercase":
{},
},
".quote": {
"@apply font-code text-lg leading-normal": {},
},
".button": {
"@apply font-code text-xs font-bold uppercase tracking-wider":
{},
},
});
addUtilities({
".tap-highlight-color": {
"-webkit-tap-highlight-color": "rgba(0, 0, 0, 0)",
},
});
}),
],
};