Compare commits
20 Commits
f05daa3a78
...
7ae4bc418f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ae4bc418f | ||
|
|
0110dc2fdc | ||
|
|
e7e2b3bb11 | ||
|
|
e22a39c5cd | ||
|
|
3b8b749eb1 | ||
|
|
571d5e68bc | ||
|
|
933932b86d | ||
|
|
fc251ede05 | ||
|
|
57c4c3c959 | ||
|
|
e1e82555bf | ||
|
|
b44a0ccd39 | ||
|
|
2d936ca1c7 | ||
|
|
14db374820 | ||
|
|
db472620f3 | ||
|
|
37d98203a3 | ||
|
|
2420ff45a4 | ||
|
|
adaebbf800 | ||
|
|
9fd9fcb731 | ||
|
|
c372832f1f | ||
|
|
5d8ad5e442 |
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
168
src/components/StockChangeIndicators.js
Normal file
168
src/components/StockChangeIndicators.js
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// ==================== 事件详情相关 ====================
|
||||
|
||||
// 获取事件相关股票
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// src/routes/components/index.js
|
||||
// 统一导出所有路由组件
|
||||
|
||||
export { RouteContainer } from './RouteContainer';
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
14
src/routes/constants/protectionModes.js
Normal file
14
src/routes/constants/protectionModes.js
Normal 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',
|
||||
};
|
||||
@@ -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
115
src/routes/homeRoutes.js
Normal 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: '价值前沿首页'
|
||||
}
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '用户认证'
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 - 懒加载组件或 null(null 表示使用 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}`}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
175
src/utils/tradingTimeUtils.js
Normal file
175
src/utils/tradingTimeUtils.js
Normal 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();
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
Don’t have an account?
|
||||
<Link
|
||||
color={titleColor}
|
||||
as="span"
|
||||
ms="5px"
|
||||
href="#"
|
||||
fontWeight="bold"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</AuthCover>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignInCover;
|
||||
@@ -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 >
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
Don’t have an account?
|
||||
<Link
|
||||
color={titleColor}
|
||||
as="span"
|
||||
ms="5px"
|
||||
href="#"
|
||||
fontWeight="bold"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</AuthCover>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignUpCover;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
259
src/views/Community/components/DynamicNewsCard.js
Normal file
259
src/views/Community/components/DynamicNewsCard.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
// 获取涨跌幅数据(优先使用 quote,fallback 到 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;
|
||||
@@ -0,0 +1,5 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/index.js
|
||||
// 统一导出 DynamicNewsDetailPanel 组件
|
||||
|
||||
export { default } from './DynamicNewsDetailPanel';
|
||||
export { default as DynamicNewsDetailPanel } from './DynamicNewsDetailPanel';
|
||||
141
src/views/Community/components/EventCard/DynamicNewsEventCard.js
Normal file
141
src/views/Community/components/EventCard/DynamicNewsEventCard.js
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
300
src/views/Community/components/MarketReviewCard.js
Normal file
300
src/views/Community/components/MarketReviewCard.js
Normal 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;
|
||||
@@ -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) => {
|
||||
// 自定义比较函数,只有当stockCode或eventTime变化时才重新渲染
|
||||
// 自定义比较函数,只有当stockCode、eventTime或onClick变化时才重新渲染
|
||||
return prevProps.stockCode === nextProps.stockCode &&
|
||||
prevProps.eventTime === nextProps.eventTime;
|
||||
prevProps.eventTime === nextProps.eventTime &&
|
||||
prevProps.onClick === nextProps.onClick;
|
||||
});
|
||||
|
||||
export default MiniTimelineChart;
|
||||
|
||||
@@ -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);
|
||||
// 如果有旧缓存,返回旧数据
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 事件弹窗 */}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)",
|
||||
},
|
||||
});
|
||||
}),
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user