feat: 添加合规
This commit is contained in:
@@ -1,18 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm test:*)",
|
||||
"Bash(xargs ls:*)",
|
||||
"Bash(awk:*)",
|
||||
"Bash(npm start)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(rm -rf /Users/qiye/Desktop/jzqy/vf_react/src/views/Applications)",
|
||||
"Bash(rm -rf /Users/qiye/Desktop/jzqy/vf_react/src/views/Ecommerce)",
|
||||
"Bash(rm /Users/qiye/Desktop/jzqy/vf_react/src/views/Dashboard/Automotive.js)",
|
||||
"Bash(rm /Users/qiye/Desktop/jzqy/vf_react/src/views/Dashboard/CRM.js)",
|
||||
"Bash(rm /Users/qiye/Desktop/jzqy/vf_react/src/views/Dashboard/SmartHome.js)",
|
||||
"Bash(rm /Users/qiye/Desktop/jzqy/vf_react/src/views/Dashboard/Landing.js)",
|
||||
"mcp__ide__getDiagnostics"
|
||||
"Bash(find src -name \"*.js\" -type f -exec grep -l \"process.env.REACT_APP_API_URL || [''''\"\"]http\" {})"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
21
.env.mock
21
.env.mock
@@ -1,5 +1,20 @@
|
||||
# ========================================
|
||||
# Mock 测试环境配置
|
||||
# ========================================
|
||||
# 使用方式: npm run start:mock
|
||||
#
|
||||
# 工作原理:
|
||||
# 1. 通过 env-cmd 加载此配置文件
|
||||
# 2. REACT_APP_ENABLE_MOCK=true 会在 src/index.js 中启动 MSW (Mock Service Worker)
|
||||
# 3. MSW 在浏览器层面拦截所有 HTTP 请求
|
||||
# 4. 根据 src/mocks/handlers/* 中定义的规则返回 mock 数据
|
||||
# 5. 未定义 mock 的接口会继续请求真实后端
|
||||
#
|
||||
# 适用场景:
|
||||
# - 前端独立开发,无需后端支持
|
||||
# - 测试特定接口的 UI 表现
|
||||
# - 后端接口未就绪时的快速原型开发
|
||||
# ========================================
|
||||
|
||||
# React 构建优化配置
|
||||
GENERATE_SOURCEMAP=false
|
||||
@@ -10,10 +25,12 @@ IMAGE_INLINE_SIZE_LIMIT=10000
|
||||
NODE_OPTIONS=--max_old_space_size=4096
|
||||
|
||||
# API 配置
|
||||
# Mock 模式下不需要真实的后端地址
|
||||
REACT_APP_API_URL=http://localhost:3000
|
||||
# Mock 模式下使用空字符串,让请求使用相对路径
|
||||
# MSW 会在浏览器层拦截这些请求,不需要真实的后端地址
|
||||
REACT_APP_API_URL=
|
||||
|
||||
# 启用 Mock 数据(核心配置)
|
||||
# 此配置会触发 src/index.js 中的 MSW 初始化
|
||||
REACT_APP_ENABLE_MOCK=true
|
||||
|
||||
# Mock 环境标识
|
||||
|
||||
@@ -116,6 +116,8 @@ const CitationMark = ({ citationId, citation }) => {
|
||||
overlayInnerStyle={{ maxWidth: 340, padding: '8px' }}
|
||||
open={popoverVisible}
|
||||
onOpenChange={setPopoverVisible}
|
||||
zIndex={2000}
|
||||
getPopupContainer={(trigger) => trigger.parentElement || document.body}
|
||||
>
|
||||
<sup
|
||||
style={{
|
||||
|
||||
@@ -45,46 +45,63 @@ const CitedContent = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
// 判断是否显示标题栏(内联模式:title为空且不显示AI徽章)
|
||||
const showHeader = title || showAIBadge;
|
||||
|
||||
// 根据是否显示标题栏决定容器样式
|
||||
const defaultContainerStyle = showHeader ? {
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: 6,
|
||||
padding: 16
|
||||
} : {};
|
||||
|
||||
// 检查是否为内联模式
|
||||
const isInlineMode = containerStyle?.display && containerStyle.display.includes('inline');
|
||||
|
||||
// 根据内联模式选择容器元素类型
|
||||
const ContainerTag = isInlineMode ? 'span' : 'div';
|
||||
const ContentTag = isInlineMode ? 'span' : 'div';
|
||||
|
||||
return (
|
||||
<div
|
||||
<ContainerTag
|
||||
style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: 6,
|
||||
padding: 16,
|
||||
...defaultContainerStyle,
|
||||
...containerStyle
|
||||
}}
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<Space
|
||||
style={{
|
||||
width: '100%',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<FileSearchOutlined style={{ color: '#1890ff', fontSize: 16 }} />
|
||||
<Text strong style={{ fontSize: 14 }}>
|
||||
{title}
|
||||
</Text>
|
||||
{/* 标题栏 - 仅在需要时显示 */}
|
||||
{showHeader && (
|
||||
<Space
|
||||
style={{
|
||||
width: '100%',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<FileSearchOutlined style={{ color: '#1890ff', fontSize: 16 }} />
|
||||
<Text strong style={{ fontSize: 14 }}>
|
||||
{title}
|
||||
</Text>
|
||||
</Space>
|
||||
{showAIBadge && (
|
||||
<Tag
|
||||
icon={<RobotOutlined />}
|
||||
color="purple"
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
AI 生成
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
{showAIBadge && (
|
||||
<Tag
|
||||
icon={<RobotOutlined />}
|
||||
color="purple"
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
AI 生成
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{/* 带引用的文本内容 */}
|
||||
<div style={{ lineHeight: 1.8 }}>
|
||||
<ContentTag style={{ lineHeight: isInlineMode ? 'inherit' : 1.8 }}>
|
||||
{processed.segments.map((segment, index) => (
|
||||
<React.Fragment key={`segment-${segment.citationId}`}>
|
||||
{/* 文本片段 */}
|
||||
<Text style={{ fontSize: 14 }}>
|
||||
<Text style={{ fontSize: 14, display: 'inline' }}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
|
||||
@@ -96,12 +113,12 @@ const CitedContent = ({
|
||||
|
||||
{/* 在片段之间添加逗号分隔符(最后一个不加) */}
|
||||
{index < processed.segments.length - 1 && (
|
||||
<Text style={{ fontSize: 14 }}>,</Text>
|
||||
<Text style={{ fontSize: 14, display: 'inline' }}>,</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ContentTag>
|
||||
</ContainerTag>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -33,16 +33,18 @@ import {
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, HamburgerIcon, SunIcon, MoonIcon } from '@chakra-ui/icons';
|
||||
import { FiStar, FiCalendar } from 'react-icons/fi';
|
||||
import { FiStar, FiCalendar, FiUser, FiSettings, FiHome, FiLogOut } from 'react-icons/fi';
|
||||
import { FaCrown } from 'react-icons/fa';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useAuthModal } from '../../contexts/AuthModalContext';
|
||||
import { logger } from '../../utils/logger';
|
||||
import SubscriptionBadge from '../Subscription/SubscriptionBadge';
|
||||
import { getApiBase } from '../../utils/apiConfig';
|
||||
import SubscriptionButton from '../Subscription/SubscriptionButton';
|
||||
import SubscriptionModal from '../Subscription/SubscriptionModal';
|
||||
|
||||
/** 二级导航栏组件 - 显示当前一级菜单下的所有二级菜单项 */
|
||||
const SecondaryNav = () => {
|
||||
const SecondaryNav = ({ showCompletenessAlert }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const navbarBg = useColorModeValue('gray.50', 'gray.700');
|
||||
@@ -107,7 +109,7 @@ const SecondaryNav = () => {
|
||||
borderColor={useColorModeValue('gray.200', 'gray.600')}
|
||||
py={2}
|
||||
position="sticky"
|
||||
top="60px"
|
||||
top={showCompletenessAlert ? "120px" : "60px"}
|
||||
zIndex={100}
|
||||
>
|
||||
<Container maxW="container.xl" px={4}>
|
||||
@@ -352,9 +354,6 @@ const NavItems = ({ isAuthenticated, user }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 计算 API 基础地址(移到组件外部,避免每次 render 重新创建)
|
||||
const getApiBase = () => (process.env.NODE_ENV === 'production' ? '' : (process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001'));
|
||||
|
||||
export default function HomeNavbar() {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const navigate = useNavigate();
|
||||
@@ -762,50 +761,42 @@ export default function HomeNavbar() {
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{/* 订阅状态徽章 - 仅登录用户可见 */}
|
||||
{isAuthenticated && user && (
|
||||
<>
|
||||
<SubscriptionBadge
|
||||
subscriptionInfo={subscriptionInfo}
|
||||
onClick={() => setIsSubscriptionModalOpen(true)}
|
||||
/>
|
||||
<SubscriptionModal
|
||||
isOpen={isSubscriptionModalOpen}
|
||||
onClose={() => setIsSubscriptionModalOpen(false)}
|
||||
subscriptionInfo={subscriptionInfo}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 显示加载状态 */}
|
||||
{isLoading ? (
|
||||
<HStack spacing={2}>
|
||||
<Spinner size="sm" />
|
||||
<Text fontSize="sm" color="gray.500">检查登录状态...</Text>
|
||||
</HStack>
|
||||
<Spinner size="sm" color="blue.500" />
|
||||
) : isAuthenticated && user ? (
|
||||
// 已登录状态 - 用户菜单 + 功能菜单排列
|
||||
<HStack spacing={3}>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
bg="gray.800"
|
||||
color="white"
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
_hover={{ bg: 'gray.700' }}
|
||||
leftIcon={
|
||||
<Avatar
|
||||
size="xs"
|
||||
name={getDisplayName()}
|
||||
src={user.avatar_url}
|
||||
bg="blue.500"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{getDisplayName()}
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{/* 用户头像+订阅徽章组合 */}
|
||||
<HStack spacing={2} align="center">
|
||||
{/* 用户头像菜单 */}
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
icon={
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={getDisplayName()}
|
||||
src={user.avatar_url}
|
||||
bg="blue.500"
|
||||
border={subscriptionInfo.type !== 'free' ? '2px solid' : 'none'}
|
||||
borderColor={
|
||||
subscriptionInfo.type === 'max'
|
||||
? 'purple.500'
|
||||
: subscriptionInfo.type === 'pro'
|
||||
? 'blue.500'
|
||||
: 'transparent'
|
||||
}
|
||||
/>
|
||||
}
|
||||
bg="transparent"
|
||||
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
|
||||
borderRadius="full"
|
||||
position="relative"
|
||||
aria-label="用户菜单"
|
||||
>
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<Box px={3} py={2} borderBottom="1px" borderColor="gray.200">
|
||||
<Text fontSize="sm" fontWeight="bold">{getDisplayName()}</Text>
|
||||
<Text fontSize="xs" color="gray.500">{user.email}</Text>
|
||||
@@ -816,24 +807,52 @@ export default function HomeNavbar() {
|
||||
<Badge size="sm" colorScheme="green" mt={1}>微信已绑定</Badge>
|
||||
)}
|
||||
</Box>
|
||||
<MenuItem onClick={() => navigate('/home/profile')}>
|
||||
👤 个人资料
|
||||
{/* 账户管理组 */}
|
||||
<MenuItem icon={<FiUser />} onClick={() => navigate('/home/profile')}>
|
||||
个人资料
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => navigate('/home/pages/account/subscription')}>
|
||||
💎 订阅管理
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => navigate('/home/settings')}>
|
||||
⚙️ 账户设置
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => navigate('/home/center')}>
|
||||
🏠 个人中心
|
||||
<MenuItem icon={<FiSettings />} onClick={() => navigate('/home/settings')}>
|
||||
账户设置
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
<MenuItem onClick={handleLogout} color="red.500">
|
||||
🚪 退出登录
|
||||
{/* 功能入口组 */}
|
||||
<MenuItem icon={<FaCrown />} onClick={() => navigate('/home/pages/account/subscription')}>
|
||||
订阅管理
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
{/* 退出 */}
|
||||
<MenuItem icon={<FiLogOut />} onClick={handleLogout} color="red.500">
|
||||
退出登录
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Menu>
|
||||
|
||||
{/* 订阅徽章按钮 - 点击打开订阅弹窗 */}
|
||||
<SubscriptionButton
|
||||
subscriptionInfo={subscriptionInfo}
|
||||
onClick={() => setIsSubscriptionModalOpen(true)}
|
||||
/>
|
||||
|
||||
{/* 订阅管理弹窗 - 只在打开时渲染 */}
|
||||
{isSubscriptionModalOpen && (
|
||||
<SubscriptionModal
|
||||
isOpen={isSubscriptionModalOpen}
|
||||
onClose={() => setIsSubscriptionModalOpen(false)}
|
||||
subscriptionInfo={subscriptionInfo}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 个人中心快捷按钮 */}
|
||||
<IconButton
|
||||
icon={<FiHome />}
|
||||
size="sm"
|
||||
colorScheme="gray"
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/home/center')}
|
||||
aria-label="个人中心"
|
||||
_hover={{ bg: 'gray.700' }}
|
||||
/>
|
||||
|
||||
{/* 自选股 - 头像右侧 */}
|
||||
<Menu onOpen={loadWatchlistQuotes}>
|
||||
@@ -1261,7 +1280,7 @@ export default function HomeNavbar() {
|
||||
</Box>
|
||||
|
||||
{/* 二级导航栏 - 显示当前页面所属的二级菜单 */}
|
||||
{!isMobile && <SecondaryNav />}
|
||||
{!isMobile && <SecondaryNav showCompletenessAlert={showCompletenessAlert} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
209
src/components/Subscription/SubscriptionButton.js
Normal file
209
src/components/Subscription/SubscriptionButton.js
Normal file
@@ -0,0 +1,209 @@
|
||||
// src/components/Subscription/SubscriptionButton.js
|
||||
import React from 'react';
|
||||
import { Box, VStack, HStack, Text, Tooltip, Divider, useColorModeValue } from '@chakra-ui/react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* 订阅徽章按钮组件 - 用于导航栏头像旁边
|
||||
* 简洁显示订阅等级,hover 显示详细卡片式 Tooltip
|
||||
*/
|
||||
export default function SubscriptionButton({ subscriptionInfo, onClick }) {
|
||||
const tooltipBg = useColorModeValue('white', 'gray.800');
|
||||
const tooltipBorder = useColorModeValue('gray.200', 'gray.600');
|
||||
const tooltipText = useColorModeValue('gray.700', 'gray.100');
|
||||
const dividerColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
// 根据订阅类型返回样式配置
|
||||
const getButtonStyles = () => {
|
||||
if (subscriptionInfo.type === 'max') {
|
||||
return {
|
||||
bg: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
icon: '👑',
|
||||
label: 'Max',
|
||||
shadow: '0 4px 12px rgba(118, 75, 162, 0.4)',
|
||||
hoverShadow: '0 6px 16px rgba(118, 75, 162, 0.5)',
|
||||
border: 'none',
|
||||
accentColor: '#764ba2',
|
||||
};
|
||||
}
|
||||
if (subscriptionInfo.type === 'pro') {
|
||||
return {
|
||||
bg: 'linear-gradient(135deg, #667eea 0%, #3182CE 100%)',
|
||||
color: 'white',
|
||||
icon: '💎',
|
||||
label: 'Pro',
|
||||
shadow: '0 4px 12px rgba(49, 130, 206, 0.4)',
|
||||
hoverShadow: '0 6px 16px rgba(49, 130, 206, 0.5)',
|
||||
border: 'none',
|
||||
accentColor: '#3182CE',
|
||||
};
|
||||
}
|
||||
// 基础版
|
||||
return {
|
||||
bg: 'transparent',
|
||||
color: useColorModeValue('gray.600', 'gray.400'),
|
||||
icon: '✨',
|
||||
label: '基础版',
|
||||
shadow: 'none',
|
||||
hoverShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
border: '1.5px solid',
|
||||
borderColor: useColorModeValue('gray.300', 'gray.600'),
|
||||
accentColor: useColorModeValue('#718096', '#A0AEC0'),
|
||||
};
|
||||
};
|
||||
|
||||
const styles = getButtonStyles();
|
||||
|
||||
// 增强的卡片式 Tooltip 内容
|
||||
const TooltipContent = () => {
|
||||
const { type, days_left, is_active } = subscriptionInfo;
|
||||
|
||||
// 基础版用户
|
||||
if (type === 'free') {
|
||||
return (
|
||||
<VStack spacing={2} align="stretch" minW="200px">
|
||||
<Text fontSize="md" fontWeight="bold" color={tooltipText}>
|
||||
✨ 基础版用户
|
||||
</Text>
|
||||
<Divider borderColor={dividerColor} />
|
||||
<Text fontSize="sm" color={tooltipText} opacity={0.8}>
|
||||
解锁更多高级功能
|
||||
</Text>
|
||||
<Box
|
||||
mt={1}
|
||||
px={3}
|
||||
py={2}
|
||||
borderRadius="md"
|
||||
bg="linear-gradient(135deg, #667eea 0%, #3182CE 100%)"
|
||||
color="white"
|
||||
textAlign="center"
|
||||
fontWeight="600"
|
||||
fontSize="sm"
|
||||
cursor="pointer"
|
||||
_hover={{ transform: 'scale(1.02)' }}
|
||||
transition="transform 0.2s"
|
||||
>
|
||||
🚀 立即升级
|
||||
</Box>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
// 付费用户
|
||||
const isExpired = !is_active;
|
||||
const isUrgent = days_left < 7;
|
||||
const isWarning = days_left < 30;
|
||||
|
||||
return (
|
||||
<VStack spacing={2} align="stretch" minW="200px">
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="md" fontWeight="bold" color={tooltipText}>
|
||||
{type === 'pro' ? '💎 Pro 会员' : '👑 Max 会员'}
|
||||
</Text>
|
||||
{isExpired && <Text fontSize="xs" color="red.500">已过期</Text>}
|
||||
</HStack>
|
||||
|
||||
<Divider borderColor={dividerColor} />
|
||||
|
||||
{/* 状态信息 */}
|
||||
{isExpired ? (
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="sm" color="red.500">❌</Text>
|
||||
<Text fontSize="sm" color={tooltipText}>
|
||||
会员已过期,续费恢复权益
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<VStack spacing={1} align="stretch">
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="sm" color={tooltipText}>
|
||||
{isUrgent ? '⚠️' : isWarning ? '⏰' : '📅'}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={tooltipText}>
|
||||
{isUrgent && <Text as="span" color="red.500" fontWeight="600">紧急!</Text>}
|
||||
{' '}还有 <Text as="span" fontWeight="600">{days_left}</Text> 天到期
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color={tooltipText} opacity={0.7} pl={6}>
|
||||
享受全部高级功能
|
||||
</Text>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* 行动按钮 */}
|
||||
<Box
|
||||
mt={1}
|
||||
px={3}
|
||||
py={2}
|
||||
borderRadius="md"
|
||||
bg={isExpired || isUrgent ? 'linear-gradient(135deg, #FC8181 0%, #F56565 100%)' : `linear-gradient(135deg, ${styles.accentColor} 0%, ${styles.accentColor}dd 100%)`}
|
||||
color="white"
|
||||
textAlign="center"
|
||||
fontWeight="600"
|
||||
fontSize="sm"
|
||||
cursor="pointer"
|
||||
_hover={{ transform: 'scale(1.02)' }}
|
||||
transition="transform 0.2s"
|
||||
>
|
||||
{isExpired ? '💳 立即续费' : isUrgent ? '⚡ 紧急续费' : '💼 管理订阅'}
|
||||
</Box>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={<TooltipContent />}
|
||||
hasArrow
|
||||
placement="bottom"
|
||||
bg={tooltipBg}
|
||||
color={tooltipText}
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor={tooltipBorder}
|
||||
boxShadow="lg"
|
||||
p={3}
|
||||
>
|
||||
<Box
|
||||
as="button"
|
||||
onClick={onClick}
|
||||
px={3}
|
||||
py={2}
|
||||
minW="60px"
|
||||
h="40px"
|
||||
borderRadius="lg"
|
||||
bg={styles.bg}
|
||||
color={styles.color}
|
||||
border={styles.border}
|
||||
borderColor={styles.borderColor}
|
||||
cursor="pointer"
|
||||
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
boxShadow={styles.shadow}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: styles.hoverShadow,
|
||||
}}
|
||||
_active={{
|
||||
transform: 'translateY(0)',
|
||||
}}
|
||||
>
|
||||
<Text fontSize="sm" fontWeight="600" lineHeight="1">
|
||||
{styles.icon} {styles.label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
SubscriptionButton.propTypes = {
|
||||
subscriptionInfo: PropTypes.shape({
|
||||
type: PropTypes.oneOf(['free', 'pro', 'max']).isRequired,
|
||||
days_left: PropTypes.number,
|
||||
is_active: PropTypes.bool,
|
||||
}).isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
@@ -21,6 +21,14 @@ import {
|
||||
Image,
|
||||
Progress,
|
||||
Divider,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Heading,
|
||||
Collapse,
|
||||
} from '@chakra-ui/react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { logger } from '../../utils/logger';
|
||||
@@ -35,6 +43,11 @@ import {
|
||||
FaClock,
|
||||
FaRedo,
|
||||
FaCrown,
|
||||
FaStar,
|
||||
FaTimes,
|
||||
FaInfinity,
|
||||
FaChevronDown,
|
||||
FaChevronUp,
|
||||
} from 'react-icons/fa';
|
||||
|
||||
export default function SubscriptionContent() {
|
||||
@@ -61,6 +74,7 @@ export default function SubscriptionContent() {
|
||||
const [checkingPayment, setCheckingPayment] = useState(false);
|
||||
const [autoCheckInterval, setAutoCheckInterval] = useState(null);
|
||||
const [forceUpdating, setForceUpdating] = useState(false);
|
||||
const [openFaqIndex, setOpenFaqIndex] = useState(null);
|
||||
|
||||
// 加载订阅套餐数据
|
||||
useEffect(() => {
|
||||
@@ -420,8 +434,34 @@ export default function SubscriptionContent() {
|
||||
return `年付节省 ${percentage}%`;
|
||||
};
|
||||
|
||||
// 统一的功能列表定义 - 基于商业定价(10月15日)文档
|
||||
const allFeatures = [
|
||||
// 新闻催化分析模块
|
||||
{ name: '新闻信息流', free: true, pro: true, max: true },
|
||||
{ name: '历史事件对比', free: 'TOP3', pro: true, max: true },
|
||||
{ name: '事件传导链分析(AI)', free: '有限体验', pro: true, max: true },
|
||||
{ name: '事件-相关标的分析', free: false, pro: true, max: true },
|
||||
{ name: '相关概念展示', free: false, pro: true, max: true },
|
||||
{ name: '板块深度分析(AI)', free: false, pro: false, max: true },
|
||||
|
||||
// 个股中心模块
|
||||
{ name: 'AI复盘功能', free: true, pro: true, max: true },
|
||||
{ name: '企业概览', free: '限制预览', pro: true, max: true },
|
||||
{ name: '个股深度分析(AI)', free: '10家/月', pro: '50家/月', max: true },
|
||||
{ name: '高效数据筛选工具', free: false, pro: true, max: true },
|
||||
|
||||
// 概念中心模块
|
||||
{ name: '概念中心(548大概念)', free: 'TOP5', pro: true, max: true },
|
||||
{ name: '历史时间轴查询', free: false, pro: '100天', max: true },
|
||||
{ name: '概念高频更新', free: false, pro: false, max: true },
|
||||
|
||||
// 涨停分析模块
|
||||
{ name: '涨停板块数据分析', free: true, pro: true, max: true },
|
||||
{ name: '个股涨停分析', free: true, pro: true, max: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<VStack spacing={6} align="stretch" w="100%">
|
||||
<VStack spacing={6} align="stretch" w="100%" py={{base: 4, md: 6}}>
|
||||
{/* 当前订阅状态 */}
|
||||
{user && (
|
||||
<Box
|
||||
@@ -432,23 +472,12 @@ export default function SubscriptionContent() {
|
||||
borderColor={borderColor}
|
||||
shadow="sm"
|
||||
>
|
||||
<Flex justify="space-between" align="center" mb={4}>
|
||||
<Text fontSize="lg" fontWeight="bold" color={textColor}>
|
||||
当前订阅状态
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<Icon as={FaRedo} />}
|
||||
onClick={handleRefreshUserStatus}
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Box>
|
||||
<HStack spacing={2} mb={2}>
|
||||
<Flex align="center" justify="space-between" flexWrap="wrap" gap={3}>
|
||||
{/* 左侧:当前订阅状态标签 */}
|
||||
<HStack spacing={3}>
|
||||
<Text fontSize="md" fontWeight="bold" color={textColor}>
|
||||
当前订阅:
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={
|
||||
user.subscription_type === 'max' ? 'purple' :
|
||||
@@ -460,33 +489,36 @@ export default function SubscriptionContent() {
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
>
|
||||
1SubscriptionContent {user.subscription_type}
|
||||
{user.subscription_type === 'free' ? '基础版' :
|
||||
user.subscription_type === 'pro' ? 'Pro 专业版' : 'Max 旗舰版'}
|
||||
</Badge>
|
||||
<Badge
|
||||
colorScheme={user.subscription_status === 'active' ? 'green' : 'red'}
|
||||
variant="subtle"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
>
|
||||
{user.subscription_status === 'active' ? '已激活' : '未激活'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Badge
|
||||
colorScheme={user.subscription_status === 'active' ? 'green' : 'red'}
|
||||
variant="subtle"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
>
|
||||
{user.subscription_status === 'active' ? '已激活' : '未激活'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
{/* 右侧:到期时间和图标 */}
|
||||
<HStack spacing={4}>
|
||||
{user.subscription_end_date && (
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
到期时间: {new Date(user.subscription_end_date).toLocaleDateString('zh-CN')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{user.subscription_status === 'active' && user.subscription_type !== 'free' && (
|
||||
<Icon
|
||||
as={user.subscription_type === 'max' ? FaCrown : FaGem}
|
||||
color={user.subscription_type === 'max' ? 'purple.400' : 'blue.400'}
|
||||
boxSize={8}
|
||||
/>
|
||||
)}
|
||||
{user.subscription_status === 'active' && user.subscription_type !== 'free' && (
|
||||
<Icon
|
||||
as={user.subscription_type === 'max' ? FaCrown : FaGem}
|
||||
color={user.subscription_type === 'max' ? 'purple.400' : 'blue.400'}
|
||||
boxSize={6}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
@@ -525,15 +557,119 @@ export default function SubscriptionContent() {
|
||||
|
||||
{/* 订阅套餐 */}
|
||||
<Grid
|
||||
templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }}
|
||||
templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }}
|
||||
gap={6}
|
||||
>
|
||||
{subscriptionPlans.length === 0 ? (
|
||||
<Box gridColumn={{ base: '1', md: '1 / -1' }} textAlign="center" py={8}>
|
||||
<Box gridColumn={{ base: '1', md: '1 / -1', lg: '1 / -1' }} textAlign="center" py={8}>
|
||||
<Text color={secondaryText}>正在加载订阅套餐...</Text>
|
||||
</Box>
|
||||
) : (
|
||||
subscriptionPlans.filter(plan => plan && plan.name).map((plan) => (
|
||||
<>
|
||||
{/* 免费版套餐 */}
|
||||
<Box
|
||||
position="relative"
|
||||
borderRadius="2xl"
|
||||
overflow="hidden"
|
||||
border="2px solid"
|
||||
borderColor="gray.300"
|
||||
bg={bgCard}
|
||||
transition="all 0.3s ease"
|
||||
_hover={{
|
||||
transform: 'translateY(-4px)',
|
||||
shadow: 'xl',
|
||||
}}
|
||||
>
|
||||
<VStack
|
||||
spacing={4}
|
||||
align="stretch"
|
||||
p={6}
|
||||
>
|
||||
{/* 套餐头部 - 图标与标题同行 */}
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack spacing={3}>
|
||||
<Icon
|
||||
as={FaStar}
|
||||
boxSize={8}
|
||||
color="gray.400"
|
||||
/>
|
||||
<Text fontSize="xl" fontWeight="bold" color={textColor}>
|
||||
基础版
|
||||
</Text>
|
||||
</HStack>
|
||||
<Badge
|
||||
colorScheme="gray"
|
||||
variant="outline"
|
||||
px={4}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
fontSize="md"
|
||||
fontWeight="bold"
|
||||
>
|
||||
免费
|
||||
</Badge>
|
||||
</Flex>
|
||||
<Text fontSize="xs" color={secondaryText} pl={11}>
|
||||
免费体验核心功能,7项实用工具
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 功能列表 */}
|
||||
<VStack spacing={3} align="stretch" minH="200px">
|
||||
{allFeatures.map((feature, index) => {
|
||||
const hasFreeAccess = feature.free === true || typeof feature.free === 'string';
|
||||
const freeLimit = typeof feature.free === 'string' ? feature.free : null;
|
||||
|
||||
return (
|
||||
<HStack key={index} spacing={3} align="start">
|
||||
<Icon
|
||||
as={hasFreeAccess ? FaCheck : FaTimes}
|
||||
color={hasFreeAccess ? 'blue.500' : 'gray.300'}
|
||||
boxSize={4}
|
||||
mt={0.5}
|
||||
/>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={hasFreeAccess ? textColor : secondaryText}
|
||||
flex={1}
|
||||
>
|
||||
{feature.name}
|
||||
{freeLimit && (
|
||||
<Text as="span" fontSize="xs" color="blue.500" ml={1}>
|
||||
({freeLimit})
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
|
||||
{/* 订阅按钮 */}
|
||||
<Button
|
||||
size="lg"
|
||||
colorScheme="gray"
|
||||
variant="solid"
|
||||
isDisabled={true}
|
||||
_hover={{
|
||||
transform: 'scale(1.02)',
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
{user?.subscription_type === 'free' &&
|
||||
user?.subscription_status === 'active'
|
||||
? '✓ 当前套餐'
|
||||
: '免费使用'
|
||||
}
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* 付费套餐 */}
|
||||
{subscriptionPlans.filter(plan => plan && plan.name).map((plan) => (
|
||||
<Box
|
||||
key={plan.id}
|
||||
position="relative"
|
||||
@@ -558,6 +694,7 @@ export default function SubscriptionContent() {
|
||||
bg="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
py={1}
|
||||
textAlign="center"
|
||||
zIndex={1}
|
||||
>
|
||||
<Text color="white" fontSize="xs" fontWeight="bold">
|
||||
🔥 最受欢迎
|
||||
@@ -566,61 +703,77 @@ export default function SubscriptionContent() {
|
||||
)}
|
||||
|
||||
<VStack
|
||||
spacing={5}
|
||||
spacing={4}
|
||||
align="stretch"
|
||||
p={6}
|
||||
pt={plan.name === 'max' ? 10 : 6}
|
||||
>
|
||||
{/* 套餐头部 */}
|
||||
<VStack spacing={2} align="center">
|
||||
<Icon
|
||||
as={plan.name === 'pro' ? FaGem : FaCrown}
|
||||
boxSize={12}
|
||||
color={plan.name === 'pro' ? 'blue.400' : 'purple.400'}
|
||||
/>
|
||||
<Text fontSize="2xl" fontWeight="bold" color={textColor}>
|
||||
{plan.display_name}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={secondaryText} textAlign="center" minH="40px">
|
||||
{plan.description}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* 价格 */}
|
||||
<VStack spacing={2}>
|
||||
<HStack justify="center" align="baseline" spacing={1}>
|
||||
<Text fontSize="sm" color={secondaryText}>¥</Text>
|
||||
<Text fontSize="4xl" fontWeight="bold" color={textColor}>
|
||||
{getCurrentPrice(plan).toFixed(0)}
|
||||
{/* 套餐头部 - 图标与标题同行 */}
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack spacing={3}>
|
||||
<Icon
|
||||
as={plan.name === 'pro' ? FaGem : FaCrown}
|
||||
boxSize={8}
|
||||
color={plan.name === 'pro' ? 'blue.400' : 'purple.400'}
|
||||
/>
|
||||
<Text fontSize="xl" fontWeight="bold" color={textColor}>
|
||||
{plan.display_name}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={0} align="baseline">
|
||||
<Text fontSize="md" color={secondaryText}>¥</Text>
|
||||
<Text fontSize="2xl" fontWeight="extrabold" color={plan.name === 'pro' ? 'blue.500' : 'purple.500'}>
|
||||
{getCurrentPrice(plan).toFixed(0)}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
/{selectedCycle === 'monthly' ? '月' : '年'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text fontSize="xs" color={secondaryText} pl={11}>
|
||||
{plan.description}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
/ {selectedCycle === 'monthly' ? '月' : '年'}
|
||||
</Text>
|
||||
</HStack>
|
||||
{getSavingsText(plan) && (
|
||||
<Badge colorScheme="green" fontSize="xs" px={2} py={1}>
|
||||
{getSavingsText(plan)}
|
||||
</Badge>
|
||||
)}
|
||||
{getSavingsText(plan) && (
|
||||
<Badge colorScheme="green" fontSize="xs" px={2} py={1}>
|
||||
{getSavingsText(plan)}
|
||||
</Badge>
|
||||
)}
|
||||
</Flex>
|
||||
</VStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 功能列表 */}
|
||||
<VStack spacing={3} align="stretch" minH="200px">
|
||||
{plan.features.map((feature, index) => (
|
||||
<HStack key={index} spacing={3} align="start">
|
||||
<Icon
|
||||
as={FaCheck}
|
||||
color={plan.name === 'max' ? 'purple.400' : 'blue.400'}
|
||||
boxSize={4}
|
||||
mt={0.5}
|
||||
/>
|
||||
<Text fontSize="sm" color={textColor} flex={1}>
|
||||
{feature}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
{allFeatures.map((feature, index) => {
|
||||
const featureValue = feature[plan.name];
|
||||
const isSupported = featureValue === true || typeof featureValue === 'string';
|
||||
const limitText = typeof featureValue === 'string' ? featureValue : null;
|
||||
|
||||
return (
|
||||
<HStack key={index} spacing={3} align="start">
|
||||
<Icon
|
||||
as={isSupported ? FaCheck : FaTimes}
|
||||
color={isSupported ? 'blue.500' : 'gray.300'}
|
||||
boxSize={4}
|
||||
mt={0.5}
|
||||
/>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={isSupported ? textColor : secondaryText}
|
||||
flex={1}
|
||||
>
|
||||
{feature.name}
|
||||
{limitText && (
|
||||
<Text as="span" fontSize="xs" color={plan.name === 'pro' ? 'blue.500' : 'purple.500'} ml={1}>
|
||||
({limitText})
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
|
||||
{/* 订阅按钮 */}
|
||||
@@ -646,9 +799,197 @@ export default function SubscriptionContent() {
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
)))}
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* FAQ 常见问题 */}
|
||||
<Box
|
||||
mt={12}
|
||||
p={6}
|
||||
borderRadius="xl"
|
||||
bg={bgCard}
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
shadow="sm"
|
||||
>
|
||||
<Heading size="lg" mb={6} textAlign="center" color={textColor}>
|
||||
常见问题
|
||||
</Heading>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* FAQ 1 */}
|
||||
<Box
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Flex
|
||||
p={4}
|
||||
cursor="pointer"
|
||||
onClick={() => setOpenFaqIndex(openFaqIndex === 0 ? null : 0)}
|
||||
bg={openFaqIndex === 0 ? bgAccent : 'transparent'}
|
||||
_hover={{ bg: bgAccent }}
|
||||
transition="all 0.2s"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<Text fontWeight="semibold" color={textColor}>
|
||||
如何取消订阅?
|
||||
</Text>
|
||||
<Icon
|
||||
as={openFaqIndex === 0 ? FaChevronUp : FaChevronDown}
|
||||
color={textColor}
|
||||
/>
|
||||
</Flex>
|
||||
<Collapse in={openFaqIndex === 0}>
|
||||
<Box p={4} pt={0} color={secondaryText}>
|
||||
<Text>
|
||||
您可以随时在账户设置中取消订阅。取消后,您的订阅将在当前计费周期结束时到期,期间您仍可继续使用付费功能。取消后不会立即扣款,也不会自动续费。
|
||||
</Text>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* FAQ 2 */}
|
||||
<Box
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Flex
|
||||
p={4}
|
||||
cursor="pointer"
|
||||
onClick={() => setOpenFaqIndex(openFaqIndex === 1 ? null : 1)}
|
||||
bg={openFaqIndex === 1 ? bgAccent : 'transparent'}
|
||||
_hover={{ bg: bgAccent }}
|
||||
transition="all 0.2s"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<Text fontWeight="semibold" color={textColor}>
|
||||
支持哪些支付方式?
|
||||
</Text>
|
||||
<Icon
|
||||
as={openFaqIndex === 1 ? FaChevronUp : FaChevronDown}
|
||||
color={textColor}
|
||||
/>
|
||||
</Flex>
|
||||
<Collapse in={openFaqIndex === 1}>
|
||||
<Box p={4} pt={0} color={secondaryText}>
|
||||
<Text>
|
||||
我们目前支持微信支付。扫描支付二维码后,系统会自动检测支付状态并激活您的订阅。支付过程安全可靠,所有交易都经过加密处理。
|
||||
</Text>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* FAQ 3 */}
|
||||
<Box
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Flex
|
||||
p={4}
|
||||
cursor="pointer"
|
||||
onClick={() => setOpenFaqIndex(openFaqIndex === 2 ? null : 2)}
|
||||
bg={openFaqIndex === 2 ? bgAccent : 'transparent'}
|
||||
_hover={{ bg: bgAccent }}
|
||||
transition="all 0.2s"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<Text fontWeight="semibold" color={textColor}>
|
||||
可以在月付和年付之间切换吗?
|
||||
</Text>
|
||||
<Icon
|
||||
as={openFaqIndex === 2 ? FaChevronUp : FaChevronDown}
|
||||
color={textColor}
|
||||
/>
|
||||
</Flex>
|
||||
<Collapse in={openFaqIndex === 2}>
|
||||
<Box p={4} pt={0} color={secondaryText}>
|
||||
<Text>
|
||||
可以。您可以随时更改计费周期。如果从月付切换到年付,系统会计算剩余价值并应用到新的订阅中。年付用户可享受20%的折扣优惠。
|
||||
</Text>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* FAQ 4 */}
|
||||
<Box
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Flex
|
||||
p={4}
|
||||
cursor="pointer"
|
||||
onClick={() => setOpenFaqIndex(openFaqIndex === 3 ? null : 3)}
|
||||
bg={openFaqIndex === 3 ? bgAccent : 'transparent'}
|
||||
_hover={{ bg: bgAccent }}
|
||||
transition="all 0.2s"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<Text fontWeight="semibold" color={textColor}>
|
||||
是否提供退款?
|
||||
</Text>
|
||||
<Icon
|
||||
as={openFaqIndex === 3 ? FaChevronUp : FaChevronDown}
|
||||
color={textColor}
|
||||
/>
|
||||
</Flex>
|
||||
<Collapse in={openFaqIndex === 3}>
|
||||
<Box p={4} pt={0} color={secondaryText}>
|
||||
<Text>
|
||||
我们提供7天无理由退款保证。如果您在订阅后7天内对服务不满意,可以申请全额退款。超过7天后,我们将根据实际使用情况进行评估。
|
||||
</Text>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* FAQ 5 */}
|
||||
<Box
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Flex
|
||||
p={4}
|
||||
cursor="pointer"
|
||||
onClick={() => setOpenFaqIndex(openFaqIndex === 4 ? null : 4)}
|
||||
bg={openFaqIndex === 4 ? bgAccent : 'transparent'}
|
||||
_hover={{ bg: bgAccent }}
|
||||
transition="all 0.2s"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<Text fontWeight="semibold" color={textColor}>
|
||||
Pro版和Max版有什么区别?
|
||||
</Text>
|
||||
<Icon
|
||||
as={openFaqIndex === 4 ? FaChevronUp : FaChevronDown}
|
||||
color={textColor}
|
||||
/>
|
||||
</Flex>
|
||||
<Collapse in={openFaqIndex === 4}>
|
||||
<Box p={4} pt={0} color={secondaryText}>
|
||||
<Text>
|
||||
Pro版适合个人专业用户,提供高级图表、历史数据分析等功能。Max版则是为团队和企业设计,额外提供实时数据推送、API访问、无限制的数据存储和团队协作功能,并享有优先技术支持。
|
||||
</Text>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* 支付模态框 */}
|
||||
{isPaymentModalOpen && (
|
||||
<Modal
|
||||
|
||||
@@ -326,6 +326,177 @@ export const mockInvestmentPlans = [
|
||||
|
||||
// ==================== 投资日历事件数据 ====================
|
||||
|
||||
// ==================== 未来事件数据(用于投资日历) ====================
|
||||
|
||||
export const mockFutureEvents = [
|
||||
{
|
||||
id: 501,
|
||||
data_id: 501,
|
||||
title: '美联储FOMC会议',
|
||||
calendar_time: '2025-10-20T14:00:00Z',
|
||||
type: 'event',
|
||||
star: 5,
|
||||
former: {
|
||||
data: [
|
||||
{
|
||||
author: '美联储官网',
|
||||
sentences: '本次会议将重点讨论通胀控制和利率调整策略,美联储将评估当前经济形势,包括就业市场、物价水平和金融稳定性等关键指标,以决定是否调整联邦基金利率目标区间',
|
||||
query_part: '本次会议将重点讨论通胀控制和利率调整策略',
|
||||
report_title: 'FOMC会议议程公告',
|
||||
declare_date: '2025-10-15T00:00:00',
|
||||
match_score: '好'
|
||||
},
|
||||
{
|
||||
author: '彭博社',
|
||||
sentences: '市场普遍预期美联储将维持当前利率水平,根据对50位经济学家的调查,超过80%的受访者认为美联储将在本次会议上保持利率不变,等待更多经济数据以评估政策效果',
|
||||
query_part: '市场普遍预期美联储将维持当前利率水平',
|
||||
report_title: '美联储利率决议前瞻:经济学家调查报告',
|
||||
declare_date: '2025-10-18T00:00:00',
|
||||
match_score: '好'
|
||||
},
|
||||
{
|
||||
author: '路透社',
|
||||
sentences: '鲍威尔的讲话将释放未来货币政策方向的重要信号,市场将密切关注其对经济前景的评估,特别是关于通胀回落速度、就业市场韧性以及未来降息时点的表述',
|
||||
query_part: '鲍威尔的讲话将释放未来货币政策方向的重要信号',
|
||||
report_title: '鲍威尔讲话要点预测',
|
||||
declare_date: '2025-10-19T00:00:00',
|
||||
match_score: '好'
|
||||
}
|
||||
]
|
||||
},
|
||||
forecast: '预计维持利率不变,关注鲍威尔讲话基调',
|
||||
fact: null,
|
||||
related_stocks: [
|
||||
[
|
||||
'600036',
|
||||
'招商银行',
|
||||
{
|
||||
data: [
|
||||
{
|
||||
author: '中信证券',
|
||||
sentences: '作为国内领先的商业银行,招商银行对利率变化敏感度高,美联储货币政策调整将通过汇率、资本流动等渠道影响国内货币政策,进而影响银行净息差和资产质量',
|
||||
query_part: '美联储政策通过汇率和资本流动影响国内银行业',
|
||||
report_title: '美联储政策对中国银行业影响分析',
|
||||
declare_date: '2025-10-18T00:00:00',
|
||||
match_score: '好'
|
||||
}
|
||||
]
|
||||
},
|
||||
0.85
|
||||
],
|
||||
[
|
||||
'601398',
|
||||
'工商银行',
|
||||
{
|
||||
data: [
|
||||
{
|
||||
author: '招商证券',
|
||||
sentences: '工商银行作为国有大行,其经营业绩与宏观经济和货币政策高度相关,美联储利率决策将影响全球流动性和人民币汇率,对大型商业银行的跨境业务和外汇敞口产生直接影响',
|
||||
query_part: '美联储决策影响全球流动性和大行跨境业务',
|
||||
report_title: '货币政策对银行业影响专题研究',
|
||||
declare_date: '2025-10-17T00:00:00',
|
||||
match_score: '好'
|
||||
}
|
||||
]
|
||||
},
|
||||
0.80
|
||||
]
|
||||
],
|
||||
concepts: ['货币政策', '利率', '美联储'],
|
||||
is_following: false
|
||||
},
|
||||
{
|
||||
id: 502,
|
||||
data_id: 502,
|
||||
title: '央行货币政策委员会例会',
|
||||
calendar_time: '2025-10-20T09:00:00Z',
|
||||
type: 'event',
|
||||
star: 4,
|
||||
former: '本次例会将总结前期货币政策执行情况,研究部署下一阶段工作。重点关注经济增长、通胀水平和金融稳定等方面的形势变化。\n\n(AI合成)',
|
||||
forecast: '可能释放适度宽松信号',
|
||||
fact: null,
|
||||
related_stocks: [],
|
||||
concepts: ['货币政策', '央行', '宏观经济'],
|
||||
is_following: true
|
||||
},
|
||||
{
|
||||
id: 503,
|
||||
data_id: 503,
|
||||
title: '宁德时代业绩快报',
|
||||
calendar_time: '2025-10-20T16:00:00Z',
|
||||
type: 'data',
|
||||
star: 5,
|
||||
former: {
|
||||
data: [
|
||||
{
|
||||
author: 'SNE Research',
|
||||
sentences: '公司Q3动力电池装机量持续保持全球第一,市场份额达到37.8%,较去年同期提升2.3个百分点,在全球动力电池市场继续保持领先地位,主要得益于国内新能源汽车市场的强劲增长以及海外客户订单的持续放量',
|
||||
query_part: '公司Q3动力电池装机量持续保持全球第一',
|
||||
report_title: '全球动力电池市场装机量统计报告',
|
||||
declare_date: '2025-10-10T00:00:00',
|
||||
match_score: '好'
|
||||
},
|
||||
{
|
||||
author: '宁德时代',
|
||||
sentences: '储能业务订单饱满,预计全年营收同比增长超过60%,公司储能产品已应用于全球多个大型储能项目,在用户侧储能、电网侧储能等领域均实现突破,随着全球能源转型加速,储能市场需求持续旺盛',
|
||||
query_part: '储能业务订单饱满,预计全年营收同比增长超过60%',
|
||||
report_title: '宁德时代2024年业绩预告',
|
||||
declare_date: '2025-09-30T00:00:00',
|
||||
match_score: '好'
|
||||
}
|
||||
]
|
||||
},
|
||||
forecast: '预计营收和净利润双增长',
|
||||
fact: null,
|
||||
related_stocks: [
|
||||
[
|
||||
'300750',
|
||||
'宁德时代',
|
||||
{
|
||||
data: [
|
||||
{
|
||||
author: '宁德时代公告',
|
||||
sentences: '公司Q3动力电池装机量持续保持全球第一,市场份额达到37.8%,较去年同期提升2.3个百分点,在全球动力电池市场继续保持领先地位,主要得益于国内新能源汽车市场的强劲增长以及海外客户订单的持续放量',
|
||||
query_part: '动力电池装机量全球第一,市场份额37.8%',
|
||||
report_title: '宁德时代2024年Q3业绩快报',
|
||||
declare_date: '2025-10-15T00:00:00',
|
||||
match_score: '优'
|
||||
},
|
||||
{
|
||||
author: '国泰君安证券',
|
||||
sentences: '储能业务订单饱满,预计全年营收同比增长超过60%,公司储能产品已应用于全球多个大型储能项目,在用户侧储能、电网侧储能等领域均实现突破',
|
||||
query_part: '储能业务营收同比增长超60%',
|
||||
report_title: '宁德时代储能业务深度报告',
|
||||
declare_date: '2025-10-12T00:00:00',
|
||||
match_score: '优'
|
||||
}
|
||||
]
|
||||
},
|
||||
0.95
|
||||
],
|
||||
[
|
||||
'002466',
|
||||
'天齐锂业',
|
||||
{
|
||||
data: [
|
||||
{
|
||||
author: '天风证券',
|
||||
sentences: '天齐锂业作为宁德时代的核心供应商,将直接受益于下游动力电池需求的增长,公司锂资源储量丰富,随着宁德时代产能扩张,锂盐需求持续旺盛,公司业绩增长确定性强',
|
||||
query_part: '核心锂供应商直接受益于下游需求增长',
|
||||
report_title: '天齐锂业:受益动力电池产业链景气',
|
||||
declare_date: '2025-10-14T00:00:00',
|
||||
match_score: '好'
|
||||
}
|
||||
]
|
||||
},
|
||||
0.82
|
||||
]
|
||||
],
|
||||
concepts: ['新能源', '动力电池', '储能'],
|
||||
is_following: false
|
||||
}
|
||||
];
|
||||
|
||||
export const mockCalendarEvents = [
|
||||
{
|
||||
id: 401,
|
||||
@@ -497,3 +668,52 @@ export function getCalendarEventsByDateRange(userId, startDate, endDate) {
|
||||
return eventDate >= start && eventDate <= end;
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 未来事件(投资日历)辅助函数 ====================
|
||||
|
||||
/**
|
||||
* 获取指定日期的未来事件列表
|
||||
* @param {string} dateStr - 日期字符串 'YYYY-MM-DD'
|
||||
* @param {string} type - 事件类型 'event' | 'data' | 'all'
|
||||
* @returns {Array} 事件列表
|
||||
*/
|
||||
export function getMockFutureEvents(dateStr, type = 'all') {
|
||||
const targetDate = new Date(dateStr);
|
||||
|
||||
return mockFutureEvents.filter(event => {
|
||||
const eventDate = new Date(event.calendar_time);
|
||||
const isSameDate =
|
||||
eventDate.getFullYear() === targetDate.getFullYear() &&
|
||||
eventDate.getMonth() === targetDate.getMonth() &&
|
||||
eventDate.getDate() === targetDate.getDate();
|
||||
|
||||
if (!isSameDate) return false;
|
||||
|
||||
if (type === 'all') return true;
|
||||
return event.type === type;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定月份的事件统计
|
||||
* @param {number} year - 年份
|
||||
* @param {number} month - 月份 (1-12)
|
||||
* @returns {Array} 事件统计数组
|
||||
*/
|
||||
export function getMockEventCountsForMonth(year, month) {
|
||||
const counts = {};
|
||||
|
||||
mockFutureEvents.forEach(event => {
|
||||
const eventDate = new Date(event.calendar_time);
|
||||
if (eventDate.getFullYear() === year && eventDate.getMonth() + 1 === month) {
|
||||
const dateStr = eventDate.toISOString().split('T')[0];
|
||||
counts[dateStr] = (counts[dateStr] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.entries(counts).map(([date, count]) => ({
|
||||
date,
|
||||
count,
|
||||
className: count >= 3 ? 'high-activity' : count >= 2 ? 'medium-activity' : 'low-activity'
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -91,8 +91,18 @@ export function generateWechatSessionId() {
|
||||
// 设置当前登录用户
|
||||
export function setCurrentUser(user) {
|
||||
if (user) {
|
||||
localStorage.setItem('mock_current_user', JSON.stringify(user));
|
||||
console.log('[Mock State] 设置当前登录用户:', user);
|
||||
// 数据兼容处理:确保用户数据包含订阅信息字段
|
||||
const normalizedUser = {
|
||||
...user,
|
||||
// 如果缺少订阅信息,添加默认值
|
||||
subscription_type: user.subscription_type || 'free',
|
||||
subscription_status: user.subscription_status || 'active',
|
||||
subscription_end_date: user.subscription_end_date || null,
|
||||
is_subscription_active: user.is_subscription_active !== false,
|
||||
subscription_days_left: user.subscription_days_left || 0
|
||||
};
|
||||
localStorage.setItem('mock_current_user', JSON.stringify(normalizedUser));
|
||||
console.log('[Mock State] 设置当前登录用户:', normalizedUser);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,13 @@ export const authHandlers = [
|
||||
email: null,
|
||||
avatar_url: `https://i.pravatar.cc/150?img=${id}`,
|
||||
has_wechat: false,
|
||||
created_at: new Date().toISOString()
|
||||
created_at: new Date().toISOString(),
|
||||
// 默认订阅信息 - 免费用户
|
||||
subscription_type: 'free',
|
||||
subscription_status: 'active',
|
||||
subscription_end_date: null,
|
||||
is_subscription_active: true,
|
||||
subscription_days_left: 0
|
||||
};
|
||||
mockUsers[credential] = user;
|
||||
console.log('[Mock] 创建新用户:', user);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { getEventRelatedStocks } from '../data/events';
|
||||
import { getMockFutureEvents, getMockEventCountsForMonth } from '../data/account';
|
||||
|
||||
// 模拟网络延迟
|
||||
const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
@@ -36,4 +37,718 @@ export const eventHandlers = [
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取事件传导链分析数据
|
||||
http.get('/api/events/:eventId/transmission', async ({ params }) => {
|
||||
await delay(500);
|
||||
|
||||
const { eventId } = params;
|
||||
|
||||
console.log('[Mock] 获取事件传导链分析, eventId:', eventId);
|
||||
|
||||
// Mock数据:事件传导链
|
||||
const mockTransmissionData = {
|
||||
success: true,
|
||||
data: {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: '主要事件',
|
||||
category: '事件',
|
||||
value: 50,
|
||||
extra: {
|
||||
node_type: 'event',
|
||||
description: '这是主要事件节点',
|
||||
importance_score: 50,
|
||||
is_main_event: true
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '半导体行业',
|
||||
category: '行业',
|
||||
value: 40,
|
||||
extra: {
|
||||
node_type: 'industry',
|
||||
description: '受影响的半导体行业',
|
||||
importance_score: 40,
|
||||
is_main_event: false
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '芯片制造',
|
||||
category: '行业',
|
||||
value: 35,
|
||||
extra: {
|
||||
node_type: 'industry',
|
||||
description: '芯片制造产业链',
|
||||
importance_score: 35,
|
||||
is_main_event: false
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'A公司',
|
||||
category: '公司',
|
||||
value: 30,
|
||||
extra: {
|
||||
node_type: 'company',
|
||||
description: '龙头企业A',
|
||||
importance_score: 30,
|
||||
stock_code: '600000',
|
||||
is_main_event: false
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'B公司',
|
||||
category: '公司',
|
||||
value: 25,
|
||||
extra: {
|
||||
node_type: 'company',
|
||||
description: '龙头企业B',
|
||||
importance_score: 25,
|
||||
stock_code: '600001',
|
||||
is_main_event: false
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: '相关政策',
|
||||
category: '政策',
|
||||
value: 30,
|
||||
extra: {
|
||||
node_type: 'policy',
|
||||
description: '国家产业政策支持',
|
||||
importance_score: 30,
|
||||
is_main_event: false
|
||||
}
|
||||
}
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
source: '1',
|
||||
target: '2',
|
||||
value: 0.8,
|
||||
extra: {
|
||||
transmission_strength: 0.8,
|
||||
transmission_type: '直接影响',
|
||||
description: '主事件对半导体行业的直接影响'
|
||||
}
|
||||
},
|
||||
{
|
||||
source: '2',
|
||||
target: '3',
|
||||
value: 0.7,
|
||||
extra: {
|
||||
transmission_strength: 0.7,
|
||||
transmission_type: '产业链传导',
|
||||
description: '半导体到芯片制造的传导'
|
||||
}
|
||||
},
|
||||
{
|
||||
source: '3',
|
||||
target: '4',
|
||||
value: 0.6,
|
||||
extra: {
|
||||
transmission_strength: 0.6,
|
||||
transmission_type: '企业影响',
|
||||
description: '对龙头企业A的影响'
|
||||
}
|
||||
},
|
||||
{
|
||||
source: '3',
|
||||
target: '5',
|
||||
value: 0.5,
|
||||
extra: {
|
||||
transmission_strength: 0.5,
|
||||
transmission_type: '企业影响',
|
||||
description: '对龙头企业B的影响'
|
||||
}
|
||||
},
|
||||
{
|
||||
source: '6',
|
||||
target: '1',
|
||||
value: 0.7,
|
||||
extra: {
|
||||
transmission_strength: 0.7,
|
||||
transmission_type: '政策驱动',
|
||||
description: '政策对主事件的推动作用'
|
||||
}
|
||||
},
|
||||
{
|
||||
source: '6',
|
||||
target: '2',
|
||||
value: 0.6,
|
||||
extra: {
|
||||
transmission_strength: 0.6,
|
||||
transmission_type: '政策支持',
|
||||
description: '政策对行业的支持'
|
||||
}
|
||||
}
|
||||
],
|
||||
categories: ['事件', '行业', '公司', '政策', '技术', '市场', '其他']
|
||||
},
|
||||
message: '获取成功'
|
||||
};
|
||||
|
||||
return HttpResponse.json(mockTransmissionData);
|
||||
}),
|
||||
|
||||
// 获取桑基图数据
|
||||
http.get('/api/events/:eventId/sankey-data', async ({ params }) => {
|
||||
await delay(300);
|
||||
const { eventId } = params;
|
||||
console.log('[Mock] 获取桑基图数据, eventId:', eventId);
|
||||
|
||||
const mockSankeyData = {
|
||||
success: true,
|
||||
data: {
|
||||
nodes: [
|
||||
{
|
||||
name: '相关政策',
|
||||
type: 'policy',
|
||||
level: 0,
|
||||
color: '#10ac84'
|
||||
},
|
||||
{
|
||||
name: '主要事件',
|
||||
type: 'event',
|
||||
level: 0,
|
||||
color: '#ff4757'
|
||||
},
|
||||
{
|
||||
name: '半导体行业',
|
||||
type: 'industry',
|
||||
level: 1,
|
||||
color: '#00d2d3'
|
||||
},
|
||||
{
|
||||
name: '芯片制造',
|
||||
type: 'industry',
|
||||
level: 2,
|
||||
color: '#00d2d3'
|
||||
},
|
||||
{
|
||||
name: 'A公司',
|
||||
type: 'company',
|
||||
level: 3,
|
||||
color: '#54a0ff'
|
||||
},
|
||||
{
|
||||
name: 'B公司',
|
||||
type: 'company',
|
||||
level: 3,
|
||||
color: '#54a0ff'
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{ source: 0, target: 1, value: 7 }, // 相关政策 -> 主要事件
|
||||
{ source: 0, target: 2, value: 6 }, // 相关政策 -> 半导体行业
|
||||
{ source: 1, target: 2, value: 8 }, // 主要事件 -> 半导体行业
|
||||
{ source: 2, target: 3, value: 7 }, // 半导体行业 -> 芯片制造
|
||||
{ source: 3, target: 4, value: 6 }, // 芯片制造 -> A公司
|
||||
{ source: 3, target: 5, value: 5 } // 芯片制造 -> B公司
|
||||
]
|
||||
},
|
||||
message: '获取成功'
|
||||
};
|
||||
|
||||
return HttpResponse.json(mockSankeyData);
|
||||
}),
|
||||
|
||||
// 获取传导链节点详情
|
||||
http.get('/api/events/:eventId/chain-node/:nodeId', async ({ params }) => {
|
||||
await delay(300);
|
||||
|
||||
const { eventId, nodeId } = params;
|
||||
|
||||
console.log('[Mock] 获取节点详情, eventId:', eventId, 'nodeId:', nodeId);
|
||||
|
||||
// 根据节点ID返回不同的详细信息
|
||||
const nodeDetailsMap = {
|
||||
'1': {
|
||||
success: true,
|
||||
data: {
|
||||
node: {
|
||||
id: '1',
|
||||
name: '主要事件',
|
||||
type: 'event',
|
||||
description: '这是影响整个产业链的重大事件,涉及政策调整和技术突破,对下游产业产生深远影响。',
|
||||
importance_score: 50,
|
||||
total_connections: 2,
|
||||
incoming_connections: 1,
|
||||
outgoing_connections: 1
|
||||
},
|
||||
parents: [
|
||||
{
|
||||
id: '6',
|
||||
name: '相关政策',
|
||||
transmission_mechanism: {
|
||||
data: [
|
||||
{
|
||||
author: "国务院",
|
||||
sentences: "为加快实施创新驱动发展战略,推动产业转型升级,国家将对重点领域给予财政补贴支持,单个项目最高补贴金额可达5000万元,同时享受研发费用加计扣除175%的税收优惠政策",
|
||||
query_part: "国家财政补贴最高5000万元,研发费用加计扣除175%",
|
||||
match_score: "好",
|
||||
declare_date: "2024-01-15T00:00:00",
|
||||
report_title: "关于促进产业高质量发展的若干政策措施"
|
||||
},
|
||||
{
|
||||
author: "工信部",
|
||||
sentences: "根据《重点产业扶持目录》,对符合条件的企业和项目,将优先纳入政府采购名单,并提供专项资金支持,确保政策红利直接惠及实体经济",
|
||||
query_part: "政府采购优先支持,专项资金直达企业",
|
||||
match_score: "好",
|
||||
declare_date: "2024-01-20T00:00:00",
|
||||
report_title: "工业和信息化部关于落实产业扶持政策的通知"
|
||||
}
|
||||
]
|
||||
},
|
||||
direction: 'positive',
|
||||
strength: 70,
|
||||
is_circular: false
|
||||
}
|
||||
],
|
||||
children: [
|
||||
{
|
||||
id: '2',
|
||||
name: '半导体行业',
|
||||
transmission_mechanism: {
|
||||
data: [
|
||||
{
|
||||
author: "中国电子信息产业发展研究院",
|
||||
sentences: "在技术突破和应用场景快速扩张的双重驱动下,国内半导体市场呈现爆发式增长态势。据统计,2024年上半年半导体市场规模达到1.2万亿元,同比增长32%,其中新能源汽车和AI算力芯片需求贡献了超过60%的增量",
|
||||
query_part: "技术突破和需求激增推动半导体市场增长32%",
|
||||
match_score: "好",
|
||||
declare_date: "2024-07-10T00:00:00",
|
||||
report_title: "2024年上半年中国半导体产业发展报告"
|
||||
},
|
||||
{
|
||||
author: "工信部电子信息司",
|
||||
sentences: "随着5G、人工智能、物联网等新一代信息技术的快速发展,半导体作为数字经济的基石,正迎来前所未有的发展机遇。预计未来三年,国内半导体市场年均增速将保持在25%以上",
|
||||
query_part: "新兴技术推动半导体产业高速增长",
|
||||
match_score: "好",
|
||||
declare_date: "2024-05-20T00:00:00",
|
||||
report_title: "新一代信息技术产业发展白皮书"
|
||||
}
|
||||
]
|
||||
},
|
||||
direction: 'positive',
|
||||
strength: 80,
|
||||
is_circular: false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
'2': {
|
||||
success: true,
|
||||
data: {
|
||||
node: {
|
||||
id: '2',
|
||||
name: '半导体行业',
|
||||
type: 'industry',
|
||||
description: '半导体行业是现代科技产业的基础,受到主事件和政策的双重推动,迎来新一轮发展机遇。',
|
||||
importance_score: 40,
|
||||
total_connections: 3,
|
||||
incoming_connections: 2,
|
||||
outgoing_connections: 1
|
||||
},
|
||||
parents: [
|
||||
{
|
||||
id: '1',
|
||||
name: '主要事件',
|
||||
transmission_mechanism: {
|
||||
data: [
|
||||
{
|
||||
author: "中国半导体行业协会",
|
||||
sentences: "受益于新能源汽车、5G通信等新兴应用领域的爆发式增长,国内半导体市场需求持续旺盛,2024年Q1市场规模同比增长28%,创历史新高",
|
||||
query_part: "新兴应用推动半导体需求增长28%",
|
||||
match_score: "好",
|
||||
declare_date: "2024-04-05T00:00:00",
|
||||
report_title: "2024年Q1中国半导体行业景气度报告"
|
||||
}
|
||||
]
|
||||
},
|
||||
direction: 'positive',
|
||||
strength: 80,
|
||||
is_circular: false
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: '相关政策',
|
||||
transmission_mechanism: {
|
||||
data: [
|
||||
{
|
||||
author: "国家发改委",
|
||||
sentences: "《国家集成电路产业发展推进纲要》明确提出,到2025年半导体产业自给率要达到70%以上,国家将设立专项基金规模超过3000亿元,重点支持半导体设备、材料、设计等关键环节",
|
||||
query_part: "半导体自给率目标70%,专项基金3000亿",
|
||||
match_score: "好",
|
||||
declare_date: "2024-02-01T00:00:00",
|
||||
report_title: "国家集成电路产业发展推进纲要(2024-2030)"
|
||||
}
|
||||
]
|
||||
},
|
||||
direction: 'positive',
|
||||
strength: 60,
|
||||
is_circular: false
|
||||
}
|
||||
],
|
||||
children: [
|
||||
{
|
||||
id: '3',
|
||||
name: '芯片制造',
|
||||
transmission_mechanism: {
|
||||
data: [
|
||||
{
|
||||
author: "张明",
|
||||
sentences: "在半导体行业景气度持续提升的背景下,下游芯片制造企业订单饱满,产能利用率达到历史新高,预计2024年产能扩张将达到30%以上,技术工艺也将从28nm向14nm升级",
|
||||
query_part: "半导体行业繁荣带动芯片制造产能扩张30%",
|
||||
match_score: "好",
|
||||
declare_date: "2024-03-15T00:00:00",
|
||||
report_title: "半导体行业深度报告:产业链景气度传导分析"
|
||||
},
|
||||
{
|
||||
author: "李华",
|
||||
sentences: "芯片制造环节作为半导体产业链核心,受益于上游材料供应稳定和下游应用需求旺盛,技术迭代速度明显加快,先进制程占比持续提升",
|
||||
query_part: "技术迭代加快,先进制程占比提升",
|
||||
match_score: "好",
|
||||
declare_date: "2024-02-28T00:00:00",
|
||||
report_title: "芯片制造行业跟踪报告"
|
||||
}
|
||||
]
|
||||
},
|
||||
direction: 'positive',
|
||||
strength: 70,
|
||||
is_circular: false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
'3': {
|
||||
success: true,
|
||||
data: {
|
||||
node: {
|
||||
id: '3',
|
||||
name: '芯片制造',
|
||||
type: 'industry',
|
||||
description: '芯片制造作为半导体产业链的核心环节,在上游需求推动下,产能利用率提升,技术迭代加快。',
|
||||
importance_score: 35,
|
||||
total_connections: 3,
|
||||
incoming_connections: 1,
|
||||
outgoing_connections: 2
|
||||
},
|
||||
parents: [
|
||||
{
|
||||
id: '2',
|
||||
name: '半导体行业',
|
||||
transmission_mechanism: {
|
||||
data: [
|
||||
{
|
||||
author: "张明",
|
||||
sentences: "在半导体行业景气度持续提升的背景下,下游芯片制造企业订单饱满,产能利用率达到历史新高,预计2024年产能扩张将达到30%以上,技术工艺也将从28nm向14nm升级",
|
||||
query_part: "半导体行业繁荣带动芯片制造产能扩张30%",
|
||||
match_score: "好",
|
||||
declare_date: "2024-03-15T00:00:00",
|
||||
report_title: "半导体行业深度报告:产业链景气度传导分析"
|
||||
},
|
||||
{
|
||||
author: "李华",
|
||||
sentences: "芯片制造环节作为半导体产业链核心,受益于上游材料供应稳定和下游应用需求旺盛,技术迭代速度明显加快,先进制程占比持续提升",
|
||||
query_part: "技术迭代加快,先进制程占比提升",
|
||||
match_score: "好",
|
||||
declare_date: "2024-02-28T00:00:00",
|
||||
report_title: "芯片制造行业跟踪报告"
|
||||
}
|
||||
]
|
||||
},
|
||||
direction: 'positive',
|
||||
strength: 70,
|
||||
is_circular: false
|
||||
}
|
||||
],
|
||||
children: [
|
||||
{
|
||||
id: '4',
|
||||
name: 'A公司',
|
||||
transmission_mechanism: {
|
||||
data: [
|
||||
{
|
||||
author: "王芳",
|
||||
sentences: "A公司作为国内芯片制造龙头企业,在手订单已排至2024年Q4,预计全年营收增长45%,净利润增长60%以上。公司28nm及以下先进制程产能占比已达到40%,技术实力行业领先",
|
||||
query_part: "A公司在手订单充足,预计营收增长45%",
|
||||
match_score: "好",
|
||||
declare_date: "2024-04-10T00:00:00",
|
||||
report_title: "A公司深度研究:受益芯片制造景气周期"
|
||||
}
|
||||
]
|
||||
},
|
||||
direction: 'positive',
|
||||
strength: 60,
|
||||
is_circular: false
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'B公司',
|
||||
transmission_mechanism: {
|
||||
data: [
|
||||
{
|
||||
author: "赵强",
|
||||
sentences: "随着芯片制造产能的大规模扩张,上游设备和材料供应商迎来历史性机遇。B公司作为核心配套企业,订单量同比增长55%,产品供不应求,预计2024年营收将突破百亿大关。公司在封装测试领域的市场份额已提升至国内第二位",
|
||||
query_part: "B公司订单增长55%,营收将破百亿",
|
||||
match_score: "好",
|
||||
declare_date: "2024-05-08T00:00:00",
|
||||
report_title: "B公司跟踪报告:芯片产业链配套龙头崛起"
|
||||
},
|
||||
{
|
||||
author: "国信证券",
|
||||
sentences: "B公司深度受益于芯片制造产业链的景气度传导。公司凭借先进的封装技术和完善的产能布局,成功绑定多家头部芯片制造企业,形成稳定的供应关系。随着下游客户产能持续扩张,公司业绩增长确定性强",
|
||||
query_part: "B公司受益产业链景气度,业绩增长确定性强",
|
||||
match_score: "好",
|
||||
declare_date: "2024-06-01T00:00:00",
|
||||
report_title: "半导体封装测试行业专题:产业链景气度传导分析"
|
||||
}
|
||||
]
|
||||
},
|
||||
direction: 'positive',
|
||||
strength: 50,
|
||||
is_circular: false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
'4': {
|
||||
success: true,
|
||||
data: {
|
||||
node: {
|
||||
id: '4',
|
||||
name: 'A公司',
|
||||
type: 'company',
|
||||
description: 'A公司是行业龙头企业,拥有先进的芯片制造技术和完整的产业链布局,在本轮产业升级中占据有利位置。',
|
||||
importance_score: 30,
|
||||
stock_code: '600000',
|
||||
total_connections: 1,
|
||||
incoming_connections: 1,
|
||||
outgoing_connections: 0
|
||||
},
|
||||
parents: [
|
||||
{
|
||||
id: '3',
|
||||
name: '芯片制造',
|
||||
transmission_mechanism: {
|
||||
data: [
|
||||
{
|
||||
author: "王芳",
|
||||
sentences: "A公司作为国内芯片制造龙头企业,在手订单已排至2024年Q4,预计全年营收增长45%,净利润增长60%以上。公司28nm及以下先进制程产能占比已达到40%,技术实力行业领先",
|
||||
query_part: "A公司在手订单充足,预计营收增长45%",
|
||||
match_score: "好",
|
||||
declare_date: "2024-04-10T00:00:00",
|
||||
report_title: "A公司深度研究:受益芯片制造景气周期"
|
||||
}
|
||||
]
|
||||
},
|
||||
direction: 'positive',
|
||||
strength: 60,
|
||||
is_circular: false
|
||||
}
|
||||
],
|
||||
children: []
|
||||
}
|
||||
},
|
||||
'5': {
|
||||
success: true,
|
||||
data: {
|
||||
node: {
|
||||
id: '5',
|
||||
name: 'B公司',
|
||||
type: 'company',
|
||||
description: 'B公司专注于芯片封装测试领域,随着上游制造产能释放,公司订单饱满,业绩稳步增长。',
|
||||
importance_score: 25,
|
||||
stock_code: '600001',
|
||||
total_connections: 1,
|
||||
incoming_connections: 1,
|
||||
outgoing_connections: 0
|
||||
},
|
||||
parents: [
|
||||
{
|
||||
id: '3',
|
||||
name: '芯片制造',
|
||||
transmission_mechanism: {
|
||||
data: [
|
||||
{
|
||||
author: "赵强",
|
||||
sentences: "随着芯片制造产能的大规模扩张,上游设备和材料供应商迎来历史性机遇。B公司作为核心配套企业,订单量同比增长55%,产品供不应求,预计2024年营收将突破百亿大关",
|
||||
query_part: "B公司订单增长55%,营收将破百亿",
|
||||
match_score: "好",
|
||||
declare_date: "2024-05-08T00:00:00",
|
||||
report_title: "B公司跟踪报告:芯片产业链配套龙头崛起"
|
||||
}
|
||||
]
|
||||
},
|
||||
direction: 'positive',
|
||||
strength: 50,
|
||||
is_circular: false
|
||||
}
|
||||
],
|
||||
children: []
|
||||
}
|
||||
},
|
||||
'6': {
|
||||
success: true,
|
||||
data: {
|
||||
node: {
|
||||
id: '6',
|
||||
name: '相关政策',
|
||||
type: 'policy',
|
||||
description: '国家出台了一系列产业扶持政策,包括财政补贴、税收减免和研发支持,旨在推动产业自主创新和进口替代。',
|
||||
importance_score: 30,
|
||||
total_connections: 2,
|
||||
incoming_connections: 0,
|
||||
outgoing_connections: 2
|
||||
},
|
||||
parents: [],
|
||||
children: [
|
||||
{
|
||||
id: '1',
|
||||
name: '主要事件',
|
||||
transmission_mechanism: {
|
||||
data: [
|
||||
{
|
||||
author: "国务院",
|
||||
sentences: "为加快实施创新驱动发展战略,推动产业转型升级,国家将对重点领域给予财政补贴支持,单个项目最高补贴金额可达5000万元,同时享受研发费用加计扣除175%的税收优惠政策",
|
||||
query_part: "国家财政补贴最高5000万元,研发费用加计扣除175%",
|
||||
match_score: "好",
|
||||
declare_date: "2024-01-15T00:00:00",
|
||||
report_title: "关于促进产业高质量发展的若干政策措施"
|
||||
},
|
||||
{
|
||||
author: "工信部",
|
||||
sentences: "将重点支持关键核心技术攻关和产业化应用,建立产业发展专项基金,规模达到1000亿元,引导社会资本共同参与产业发展",
|
||||
query_part: "设立1000亿元产业发展专项基金",
|
||||
match_score: "好",
|
||||
declare_date: "2024-02-01T00:00:00",
|
||||
report_title: "产业发展专项基金管理办法"
|
||||
}
|
||||
]
|
||||
},
|
||||
direction: 'positive',
|
||||
strength: 70,
|
||||
is_circular: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '半导体行业',
|
||||
transmission_mechanism: {
|
||||
data: [
|
||||
{
|
||||
author: "国家发改委",
|
||||
sentences: "《国家集成电路产业发展推进纲要》明确提出,到2025年半导体产业自给率要达到70%以上,国家将设立专项基金规模超过3000亿元,重点支持半导体设备、材料、设计等关键环节。同时,通过进口替代战略,加快培育本土产业链",
|
||||
query_part: "半导体自给率目标70%,专项基金3000亿",
|
||||
match_score: "好",
|
||||
declare_date: "2024-02-01T00:00:00",
|
||||
report_title: "国家集成电路产业发展推进纲要(2024-2030)"
|
||||
},
|
||||
{
|
||||
author: "工信部",
|
||||
sentences: "将重点支持关键核心技术攻关和产业化应用,建立产业发展专项基金,规模达到1000亿元,引导社会资本共同参与产业发展。通过税收优惠、研发补贴等政策工具,为半导体行业创造良好的发展环境",
|
||||
query_part: "设立1000亿元产业发展专项基金",
|
||||
match_score: "好",
|
||||
declare_date: "2024-02-01T00:00:00",
|
||||
report_title: "产业发展专项基金管理办法"
|
||||
}
|
||||
]
|
||||
},
|
||||
direction: 'positive',
|
||||
strength: 60,
|
||||
is_circular: false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 返回对应节点的详情,如果不存在则返回默认数据
|
||||
const nodeDetail = nodeDetailsMap[nodeId] || {
|
||||
success: true,
|
||||
data: {
|
||||
node: {
|
||||
id: nodeId,
|
||||
name: '未知节点',
|
||||
type: 'other',
|
||||
description: '该节点暂无详细信息',
|
||||
importance_score: 0,
|
||||
total_connections: 0,
|
||||
incoming_connections: 0,
|
||||
outgoing_connections: 0
|
||||
},
|
||||
parents: [],
|
||||
children: []
|
||||
}
|
||||
};
|
||||
|
||||
return HttpResponse.json(nodeDetail);
|
||||
}),
|
||||
|
||||
// ==================== 投资日历相关 ====================
|
||||
|
||||
// 获取月度事件统计
|
||||
http.get('/api/v1/calendar/event-counts', async ({ request }) => {
|
||||
await delay(300);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const year = parseInt(url.searchParams.get('year'));
|
||||
const month = parseInt(url.searchParams.get('month'));
|
||||
|
||||
console.log('[Mock] 获取月度事件统计:', { year, month });
|
||||
|
||||
const eventCounts = getMockEventCountsForMonth(year, month);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: eventCounts
|
||||
});
|
||||
}),
|
||||
|
||||
// 获取指定日期的事件列表
|
||||
http.get('/api/v1/calendar/events', async ({ request }) => {
|
||||
await delay(300);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const dateStr = url.searchParams.get('date');
|
||||
const type = url.searchParams.get('type') || 'all';
|
||||
|
||||
console.log('[Mock] 获取日历事件列表:', { date: dateStr, type });
|
||||
|
||||
if (!dateStr) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: 'Date parameter required'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const events = getMockFutureEvents(dateStr, type);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: events
|
||||
});
|
||||
}),
|
||||
|
||||
// 切换未来事件关注状态
|
||||
http.post('/api/v1/calendar/events/:eventId/follow', async ({ params }) => {
|
||||
await delay(300);
|
||||
|
||||
const { eventId } = params;
|
||||
|
||||
console.log('[Mock] 切换事件关注状态, eventId:', eventId);
|
||||
|
||||
// 简单返回成功,实际状态管理可以后续完善
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
is_following: true,
|
||||
message: '关注成功'
|
||||
}
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getApiBase } from '../utils/apiConfig';
|
||||
// src/services/authService.js
|
||||
/**
|
||||
* 认证服务层 - 处理所有认证相关的 API 调用
|
||||
@@ -6,7 +7,7 @@
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const API_BASE_URL = isProduction ? "" : process.env.REACT_APP_API_URL;
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
/**
|
||||
* 统一的 API 请求处理
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getApiBase } from '../utils/apiConfig';
|
||||
// src/services/financialService.js
|
||||
/**
|
||||
* 完整的财务数据服务层
|
||||
@@ -7,7 +8,7 @@
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const API_BASE_URL = isProduction ? "" : process.env.REACT_APP_API_URL;
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
const apiRequest = async (url, options = {}) => {
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getApiBase } from '../utils/apiConfig';
|
||||
// src/services/industryService.js
|
||||
import axios from 'axios';
|
||||
|
||||
@@ -5,7 +6,7 @@ import axios from 'axios';
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
|
||||
const API_BASE_URL = isProduction ? "" : process.env.REACT_APP_API_URL;
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
// 配置 axios 默认包含 credentials
|
||||
axios.defaults.withCredentials = true;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getApiBase } from '../utils/apiConfig';
|
||||
// src/services/marketService.js
|
||||
/**
|
||||
* 完整的市场行情数据服务层
|
||||
@@ -7,7 +8,7 @@
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const API_BASE_URL = isProduction ? "" : process.env.REACT_APP_API_URL;
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
const apiRequest = async (url, options = {}) => {
|
||||
try {
|
||||
|
||||
56
src/utils/apiConfig.js
Normal file
56
src/utils/apiConfig.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* API 配置工具
|
||||
* 提供统一的 API 基础地址获取方法
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取 API 基础 URL
|
||||
*
|
||||
* 工作原理:
|
||||
* - 生产环境: 返回空字符串,使用相对路径
|
||||
* - Mock 模式 (REACT_APP_API_URL=""): 返回空字符串,让 MSW 拦截请求
|
||||
* - 开发模式: 返回后端服务器地址
|
||||
*
|
||||
* @returns {string} API 基础地址
|
||||
*
|
||||
* @example
|
||||
* const response = await fetch(getApiBase() + '/api/users');
|
||||
*/
|
||||
export const getApiBase = () => {
|
||||
// 生产环境使用相对路径
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 检查是否定义了 REACT_APP_API_URL(包括空字符串)
|
||||
// 使用 !== undefined 而不是 || 运算符,正确处理空字符串
|
||||
const apiUrl = process.env.REACT_APP_API_URL;
|
||||
if (apiUrl !== undefined) {
|
||||
return apiUrl; // Mock 模式下返回 '',其他情况返回配置的值
|
||||
}
|
||||
|
||||
// 未配置时的默认后端地址
|
||||
return 'http://49.232.185.254:5001';
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否处于 Mock 模式
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isMockMode = () => {
|
||||
return process.env.REACT_APP_ENABLE_MOCK === 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取完整的 API URL
|
||||
* @param {string} path - API 路径,应以 / 开头
|
||||
* @returns {string} 完整的 URL
|
||||
*
|
||||
* @example
|
||||
* const url = getApiUrl('/api/users');
|
||||
* // Mock 模式: '/api/users'
|
||||
* // 开发模式: 'http://49.232.185.254:5001/api/users'
|
||||
*/
|
||||
export const getApiUrl = (path) => {
|
||||
return getApiBase() + path;
|
||||
};
|
||||
@@ -2,13 +2,14 @@
|
||||
// Axios 全局配置和拦截器
|
||||
|
||||
import axios from 'axios';
|
||||
import { getApiBase } from './apiConfig';
|
||||
import { logger } from './logger';
|
||||
|
||||
// 判断当前是否是生产环境
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
// 配置基础 URL
|
||||
const API_BASE_URL = isProduction ? '' : process.env.REACT_APP_API_URL;
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
// 配置 axios 默认值
|
||||
axios.defaults.baseURL = API_BASE_URL;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// src\views\Authentication\SignUp/SignUpIllustration.js
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -31,7 +32,7 @@ import PrivacyPolicyModal from '../../../components/PrivacyPolicyModal';
|
||||
import UserAgreementModal from '../../../components/UserAgreementModal';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const API_BASE_URL = isProduction ? "" : process.env.REACT_APP_API_URL;
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
export default function SignUpPage() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
@@ -67,6 +67,28 @@ const EventDetailModal = ({ visible, event, onClose }) => {
|
||||
return colors[importance] || 'default';
|
||||
};
|
||||
|
||||
const getRelationDesc = (relationDesc) => {
|
||||
// 处理空值
|
||||
if (!relationDesc) return '';
|
||||
|
||||
// 如果是字符串,直接返回
|
||||
if (typeof relationDesc === 'string') {
|
||||
return relationDesc;
|
||||
}
|
||||
|
||||
// 如果是对象且包含data数组
|
||||
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
|
||||
const firstItem = relationDesc.data[0];
|
||||
if (firstItem) {
|
||||
// 优先使用 query_part,其次使用 sentences
|
||||
return firstItem.query_part || firstItem.sentences || '';
|
||||
}
|
||||
}
|
||||
|
||||
// 其他情况返回空字符串
|
||||
return '';
|
||||
};
|
||||
|
||||
const renderPriceTag = (value, label) => {
|
||||
if (value === null || value === undefined) return `${label}: --`;
|
||||
|
||||
@@ -176,7 +198,7 @@ const EventDetailModal = ({ visible, event, onClose }) => {
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={`${stock.stock_name} (${stock.stock_code})`}
|
||||
description={stock.relation_desc ? `${stock.relation_desc}(AI合成)` : ''}
|
||||
description={getRelationDesc(stock.relation_desc) ? `${getRelationDesc(stock.relation_desc)}(AI合成)` : ''}
|
||||
/>
|
||||
{stock.change !== null && (
|
||||
<Tag color={stock.change > 0 ? 'red' : 'green'}>
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
|
||||
// ========== 工具函数定义在组件外部 ==========
|
||||
// 涨跌颜色配置(中国A股配色:红涨绿跌)- 分档次显示
|
||||
@@ -180,7 +181,7 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
|
||||
|
||||
const loadFollowing = async () => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const base = getApiBase();
|
||||
const res = await fetch(base + '/api/account/events/following', { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
@@ -201,7 +202,7 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
|
||||
|
||||
const toggleFollow = async (eventId) => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const base = getApiBase();
|
||||
const res = await fetch(base + `/api/events/${eventId}/follow`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
818
src/views/Community/components/EventList.js.bak
Normal file
818
src/views/Community/components/EventList.js.bak
Normal file
@@ -0,0 +1,818 @@
|
||||
// src/views/Community/components/EventList.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Badge,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
Flex,
|
||||
Avatar,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Divider,
|
||||
Container,
|
||||
useColorModeValue,
|
||||
Circle,
|
||||
Stat,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
ButtonGroup,
|
||||
Heading,
|
||||
SimpleGrid,
|
||||
Card,
|
||||
CardBody,
|
||||
Center,
|
||||
Link,
|
||||
Spacer,
|
||||
Switch,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
ViewIcon,
|
||||
ChatIcon,
|
||||
StarIcon,
|
||||
TimeIcon,
|
||||
InfoIcon,
|
||||
WarningIcon,
|
||||
WarningTwoIcon,
|
||||
CheckCircleIcon,
|
||||
TriangleUpIcon,
|
||||
TriangleDownIcon,
|
||||
ArrowForwardIcon,
|
||||
ExternalLinkIcon,
|
||||
ViewOffIcon,
|
||||
} from '@chakra-ui/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
// ========== 工具函数定义在组件外部 ==========
|
||||
// 涨跌颜色配置(中国A股配色:红涨绿跌)- 分档次显示
|
||||
const getPriceChangeColor = (value) => {
|
||||
if (value === null || value === undefined) return 'gray.500';
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (value > 0) {
|
||||
// 上涨用红色,根据涨幅大小使用不同深浅
|
||||
if (absValue >= 3) return 'red.600'; // 深红色:3%以上
|
||||
if (absValue >= 1) return 'red.500'; // 中红色:1-3%
|
||||
return 'red.400'; // 浅红色:0-1%
|
||||
} else if (value < 0) {
|
||||
// 下跌用绿色,根据跌幅大小使用不同深浅
|
||||
if (absValue >= 3) return 'green.600'; // 深绿色:3%以上
|
||||
if (absValue >= 1) return 'green.500'; // 中绿色:1-3%
|
||||
return 'green.400'; // 浅绿色:0-1%
|
||||
}
|
||||
return 'gray.500';
|
||||
};
|
||||
|
||||
const getPriceChangeBg = (value) => {
|
||||
if (value === null || value === undefined) return 'gray.50';
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (value > 0) {
|
||||
// 上涨背景色
|
||||
if (absValue >= 3) return 'red.100'; // 深色背景:3%以上
|
||||
if (absValue >= 1) return 'red.50'; // 中色背景:1-3%
|
||||
return 'red.50'; // 浅色背景:0-1%
|
||||
} else if (value < 0) {
|
||||
// 下跌背景色
|
||||
if (absValue >= 3) return 'green.100'; // 深色背景:3%以上
|
||||
if (absValue >= 1) return 'green.50'; // 中色背景:1-3%
|
||||
return 'green.50'; // 浅色背景:0-1%
|
||||
}
|
||||
return 'gray.50';
|
||||
};
|
||||
|
||||
const getPriceChangeBorderColor = (value) => {
|
||||
if (value === null || value === undefined) return 'gray.300';
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (value > 0) {
|
||||
// 上涨边框色
|
||||
if (absValue >= 3) return 'red.500'; // 深边框:3%以上
|
||||
if (absValue >= 1) return 'red.400'; // 中边框:1-3%
|
||||
return 'red.300'; // 浅边框:0-1%
|
||||
} else if (value < 0) {
|
||||
// 下跌边框色
|
||||
if (absValue >= 3) return 'green.500'; // 深边框:3%以上
|
||||
if (absValue >= 1) return 'green.400'; // 中边框:1-3%
|
||||
return 'green.300'; // 浅边框:0-1%
|
||||
}
|
||||
return 'gray.300';
|
||||
};
|
||||
|
||||
// 重要性等级配置 - 金融配色方案
|
||||
const importanceLevels = {
|
||||
'S': {
|
||||
color: 'purple.600',
|
||||
bgColor: 'purple.50',
|
||||
borderColor: 'purple.200',
|
||||
icon: WarningIcon,
|
||||
label: '极高',
|
||||
dotBg: 'purple.500',
|
||||
},
|
||||
'A': {
|
||||
color: 'red.600',
|
||||
bgColor: 'red.50',
|
||||
borderColor: 'red.200',
|
||||
icon: WarningTwoIcon,
|
||||
label: '高',
|
||||
dotBg: 'red.500',
|
||||
},
|
||||
'B': {
|
||||
color: 'orange.600',
|
||||
bgColor: 'orange.50',
|
||||
borderColor: 'orange.200',
|
||||
icon: InfoIcon,
|
||||
label: '中',
|
||||
dotBg: 'orange.500',
|
||||
},
|
||||
'C': {
|
||||
color: 'green.600',
|
||||
bgColor: 'green.50',
|
||||
borderColor: 'green.200',
|
||||
icon: CheckCircleIcon,
|
||||
label: '低',
|
||||
dotBg: 'green.500',
|
||||
}
|
||||
};
|
||||
|
||||
const getImportanceConfig = (importance) => {
|
||||
return importanceLevels[importance] || importanceLevels['C'];
|
||||
};
|
||||
|
||||
// 自定义的涨跌箭头组件(修复颜色问题)
|
||||
const PriceArrow = ({ value }) => {
|
||||
if (value === null || value === undefined) return null;
|
||||
|
||||
const Icon = value > 0 ? TriangleUpIcon : TriangleDownIcon;
|
||||
const color = value > 0 ? 'red.500' : 'green.500';
|
||||
|
||||
return <Icon color={color} boxSize="16px" />;
|
||||
};
|
||||
|
||||
// ========== 主组件 ==========
|
||||
const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetail }) => {
|
||||
const navigate = useNavigate();
|
||||
const [isCompactMode, setIsCompactMode] = useState(false); // 新增:紧凑模式状态
|
||||
const [followingMap, setFollowingMap] = useState({});
|
||||
const [followCountMap, setFollowCountMap] = useState({});
|
||||
|
||||
// 初始化关注状态与计数
|
||||
useEffect(() => {
|
||||
// 初始化计数映射
|
||||
const initCounts = {};
|
||||
events.forEach(ev => {
|
||||
initCounts[ev.id] = ev.follower_count || 0;
|
||||
});
|
||||
setFollowCountMap(initCounts);
|
||||
|
||||
const loadFollowing = async () => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const res = await fetch(base + '/api/account/events/following', { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
const map = {};
|
||||
(data.data || []).forEach(ev => { map[ev.id] = true; });
|
||||
setFollowingMap(map);
|
||||
logger.debug('EventList', '关注状态加载成功', {
|
||||
followingCount: Object.keys(map).length
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('EventList', '加载关注状态失败', { error: e.message });
|
||||
}
|
||||
};
|
||||
loadFollowing();
|
||||
// 仅在 events 更新时重跑
|
||||
}, [events]);
|
||||
|
||||
const toggleFollow = async (eventId) => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const res = await fetch(base + `/api/events/${eventId}/follow`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) throw new Error(data.error || '操作失败');
|
||||
const isFollowing = data.data?.is_following;
|
||||
const count = data.data?.follower_count ?? 0;
|
||||
setFollowingMap(prev => ({ ...prev, [eventId]: isFollowing }));
|
||||
setFollowCountMap(prev => ({ ...prev, [eventId]: count }));
|
||||
logger.debug('EventList', '关注状态切换成功', {
|
||||
eventId,
|
||||
isFollowing,
|
||||
followerCount: count
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn('EventList', '关注操作失败', {
|
||||
eventId,
|
||||
error: e.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 专业的金融配色方案
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const linkColor = useColorModeValue('blue.600', 'blue.400');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
const renderPriceChange = (value, label) => {
|
||||
if (value === null || value === undefined) {
|
||||
return (
|
||||
<Tag size="lg" colorScheme="gray" borderRadius="full" variant="subtle">
|
||||
<TagLabel fontSize="sm" fontWeight="medium">{label}: --</TagLabel>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
const isPositive = value > 0;
|
||||
|
||||
// 根据涨跌幅大小选择不同的颜色深浅
|
||||
let colorScheme = 'gray';
|
||||
let variant = 'solid';
|
||||
|
||||
if (isPositive) {
|
||||
// 上涨用红色系
|
||||
if (absValue >= 3) {
|
||||
colorScheme = 'red';
|
||||
variant = 'solid'; // 深色
|
||||
} else if (absValue >= 1) {
|
||||
colorScheme = 'red';
|
||||
variant = 'subtle'; // 中等
|
||||
} else {
|
||||
colorScheme = 'red';
|
||||
variant = 'outline'; // 浅色
|
||||
}
|
||||
} else {
|
||||
// 下跌用绿色系
|
||||
if (absValue >= 3) {
|
||||
colorScheme = 'green';
|
||||
variant = 'solid'; // 深色
|
||||
} else if (absValue >= 1) {
|
||||
colorScheme = 'green';
|
||||
variant = 'subtle'; // 中等
|
||||
} else {
|
||||
colorScheme = 'green';
|
||||
variant = 'outline'; // 浅色
|
||||
}
|
||||
}
|
||||
|
||||
const Icon = isPositive ? TriangleUpIcon : TriangleDownIcon;
|
||||
|
||||
return (
|
||||
<Tag
|
||||
size="lg"
|
||||
colorScheme={colorScheme}
|
||||
borderRadius="full"
|
||||
variant={variant}
|
||||
boxShadow="sm"
|
||||
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<TagLeftIcon as={Icon} boxSize="16px" />
|
||||
<TagLabel fontSize="sm" fontWeight="bold">
|
||||
{label}: {isPositive ? '+' : ''}{value.toFixed(2)}%
|
||||
</TagLabel>
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const handleTitleClick = (e, event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEventClick(event);
|
||||
};
|
||||
|
||||
const handleViewDetailClick = (e, eventId) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/event-detail/${eventId}`);
|
||||
};
|
||||
|
||||
// 精简模式的事件渲染
|
||||
const renderCompactEvent = (event) => {
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
const isFollowing = !!followingMap[event.id];
|
||||
const followerCount = followCountMap[event.id] ?? (event.follower_count || 0);
|
||||
|
||||
return (
|
||||
<HStack align="stretch" spacing={4} w="full">
|
||||
{/* 时间线和重要性标记 */}
|
||||
<VStack spacing={0} align="center">
|
||||
<Circle
|
||||
size="32px"
|
||||
bg={importance.dotBg}
|
||||
color="white"
|
||||
fontWeight="bold"
|
||||
fontSize="sm"
|
||||
boxShadow="sm"
|
||||
border="2px solid"
|
||||
borderColor={cardBg}
|
||||
>
|
||||
{event.importance || 'C'}
|
||||
</Circle>
|
||||
<Box
|
||||
w="2px"
|
||||
flex="1"
|
||||
bg={borderColor}
|
||||
minH="60px"
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
{/* 精简事件卡片 */}
|
||||
<Card
|
||||
flex="1"
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
_hover={{
|
||||
boxShadow: 'md',
|
||||
transform: 'translateY(-1px)',
|
||||
borderColor: importance.color,
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
cursor="pointer"
|
||||
onClick={() => onEventClick(event)}
|
||||
mb={3}
|
||||
>
|
||||
<CardBody p={4}>
|
||||
<Flex align="center" justify="space-between" wrap="wrap" gap={3}>
|
||||
{/* 左侧:标题和时间 */}
|
||||
<VStack align="start" spacing={2} flex="1" minW="200px">
|
||||
<Heading
|
||||
size="sm"
|
||||
color={linkColor}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
onClick={(e) => handleTitleClick(e, event)}
|
||||
cursor="pointer"
|
||||
noOfLines={1}
|
||||
>
|
||||
{event.title}
|
||||
</Heading>
|
||||
<HStack spacing={2} fontSize="xs" color={mutedColor}>
|
||||
<TimeIcon />
|
||||
<Text>{moment(event.created_at).format('MM-DD HH:mm')}</Text>
|
||||
<Text>•</Text>
|
||||
<Text>{event.creator?.username || 'Anonymous'}</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 右侧:涨跌幅指标 */}
|
||||
<HStack spacing={3}>
|
||||
<Tooltip label="平均涨幅" placement="top">
|
||||
<Box
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
bg={getPriceChangeBg(event.related_avg_chg)}
|
||||
borderWidth="1px"
|
||||
borderColor={getPriceChangeBorderColor(event.related_avg_chg)}
|
||||
>
|
||||
<HStack spacing={1}>
|
||||
<PriceArrow value={event.related_avg_chg} />
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={getPriceChangeColor(event.related_avg_chg)}
|
||||
>
|
||||
{event.related_avg_chg != null
|
||||
? `${event.related_avg_chg > 0 ? '+' : ''}${event.related_avg_chg.toFixed(2)}%`
|
||||
: '--'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
onClick={(e) => handleViewDetailClick(e, event.id)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isFollowing ? 'solid' : 'outline'}
|
||||
colorScheme="yellow"
|
||||
leftIcon={<StarIcon />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFollow(event.id);
|
||||
}}
|
||||
>
|
||||
{isFollowing ? '已关注' : '关注'} {followerCount ? `(${followerCount})` : ''}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
// 详细模式的事件渲染(原有的渲染方式,但修复了箭头颜色)
|
||||
const renderDetailedEvent = (event) => {
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
const isFollowing = !!followingMap[event.id];
|
||||
const followerCount = followCountMap[event.id] ?? (event.follower_count || 0);
|
||||
|
||||
return (
|
||||
<HStack align="stretch" spacing={4} w="full">
|
||||
{/* 时间线和重要性标记 */}
|
||||
<VStack spacing={0} align="center">
|
||||
<Circle
|
||||
size="40px"
|
||||
bg={importance.dotBg}
|
||||
color="white"
|
||||
fontWeight="bold"
|
||||
fontSize="lg"
|
||||
boxShadow="md"
|
||||
border="3px solid"
|
||||
borderColor={cardBg}
|
||||
>
|
||||
{event.importance || 'C'}
|
||||
</Circle>
|
||||
<Box
|
||||
w="2px"
|
||||
flex="1"
|
||||
bg={borderColor}
|
||||
minH="100px"
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
{/* 事件卡片 */}
|
||||
<Card
|
||||
flex="1"
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
_hover={{
|
||||
boxShadow: 'md',
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: importance.color,
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
cursor="pointer"
|
||||
onClick={() => onEventClick(event)}
|
||||
mb={4}
|
||||
>
|
||||
<CardBody p={5}>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 标题和重要性标签 */}
|
||||
<Flex align="center" justify="space-between">
|
||||
<Tooltip
|
||||
label="点击查看事件详情"
|
||||
placement="top"
|
||||
hasArrow
|
||||
openDelay={500}
|
||||
>
|
||||
<Heading
|
||||
size="md"
|
||||
color={linkColor}
|
||||
_hover={{ textDecoration: 'underline', color: 'blue.500' }}
|
||||
onClick={(e) => handleTitleClick(e, event)}
|
||||
cursor="pointer"
|
||||
>
|
||||
{event.title}
|
||||
</Heading>
|
||||
</Tooltip>
|
||||
<Badge
|
||||
colorScheme={importance.color.split('.')[0]}
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
>
|
||||
{importance.label}优先级
|
||||
</Badge>
|
||||
</Flex>
|
||||
|
||||
{/* 元信息 */}
|
||||
<HStack spacing={4} fontSize="sm">
|
||||
<HStack
|
||||
bg="blue.50"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
color="blue.700"
|
||||
fontWeight="medium"
|
||||
>
|
||||
<TimeIcon />
|
||||
<Text>{moment(event.created_at).format('YYYY-MM-DD HH:mm')}</Text>
|
||||
</HStack>
|
||||
<Text color={mutedColor}>•</Text>
|
||||
<Text color={mutedColor}>{event.creator?.username || 'Anonymous'}</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 描述 */}
|
||||
<Text color={textColor} fontSize="sm" lineHeight="tall" noOfLines={3}>
|
||||
{event.description}
|
||||
</Text>
|
||||
|
||||
{/* 价格变化指标 */}
|
||||
<Box
|
||||
bg={useColorModeValue('gradient.subtle', 'gray.700')}
|
||||
bgGradient="linear(to-r, gray.50, white)"
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
boxShadow="sm"
|
||||
>
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3}>
|
||||
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
|
||||
<Box
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
bg={getPriceChangeBg(event.related_avg_chg)}
|
||||
borderWidth="2px"
|
||||
borderColor={getPriceChangeBorderColor(event.related_avg_chg)}
|
||||
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Stat size="sm">
|
||||
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
|
||||
平均涨幅
|
||||
</StatHelpText>
|
||||
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_avg_chg)}>
|
||||
{event.related_avg_chg != null ? (
|
||||
<HStack spacing={1}>
|
||||
<PriceArrow value={event.related_avg_chg} />
|
||||
<Text fontWeight="bold">
|
||||
{event.related_avg_chg > 0 ? '+' : ''}{event.related_avg_chg.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text color="gray.400">--</Text>
|
||||
)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
|
||||
<Box
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
bg={getPriceChangeBg(event.related_max_chg)}
|
||||
borderWidth="2px"
|
||||
borderColor={getPriceChangeBorderColor(event.related_max_chg)}
|
||||
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Stat size="sm">
|
||||
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
|
||||
最大涨幅
|
||||
</StatHelpText>
|
||||
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_max_chg)}>
|
||||
{event.related_max_chg != null ? (
|
||||
<HStack spacing={1}>
|
||||
<PriceArrow value={event.related_max_chg} />
|
||||
<Text fontWeight="bold">
|
||||
{event.related_max_chg > 0 ? '+' : ''}{event.related_max_chg.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text color="gray.400">--</Text>
|
||||
)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
|
||||
<Box
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
bg={getPriceChangeBg(event.related_week_chg)}
|
||||
borderWidth="2px"
|
||||
borderColor={getPriceChangeBorderColor(event.related_week_chg)}
|
||||
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Stat size="sm">
|
||||
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
|
||||
周涨幅
|
||||
</StatHelpText>
|
||||
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_week_chg)}>
|
||||
{event.related_week_chg != null ? (
|
||||
<HStack spacing={1}>
|
||||
<PriceArrow value={event.related_week_chg} />
|
||||
<Text fontWeight="bold">
|
||||
{event.related_week_chg > 0 ? '+' : ''}{event.related_week_chg.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text color="gray.400">--</Text>
|
||||
)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 统计信息和操作按钮 */}
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap={3}>
|
||||
<HStack spacing={6}>
|
||||
<Tooltip label="浏览量" placement="top">
|
||||
<HStack spacing={1} color={mutedColor}>
|
||||
<ViewIcon />
|
||||
<Text fontSize="sm">{event.view_count || 0}</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
<Tooltip label="帖子数" placement="top">
|
||||
<HStack spacing={1} color={mutedColor}>
|
||||
<ChatIcon />
|
||||
<Text fontSize="sm">{event.post_count || 0}</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
<Tooltip label="关注数" placement="top">
|
||||
<HStack spacing={1} color={mutedColor}>
|
||||
<StarIcon />
|
||||
<Text fontSize="sm">{followerCount}</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
|
||||
<ButtonGroup size="sm" spacing={2}>
|
||||
<Button
|
||||
variant="outline"
|
||||
colorScheme="gray"
|
||||
leftIcon={<ViewIcon />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEventClick(event);
|
||||
}}
|
||||
>
|
||||
快速查看
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
leftIcon={<ExternalLinkIcon />}
|
||||
onClick={(e) => handleViewDetailClick(e, event.id)}
|
||||
>
|
||||
详细信息
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="yellow"
|
||||
variant={isFollowing ? 'solid' : 'outline'}
|
||||
leftIcon={<StarIcon />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFollow(event.id);
|
||||
}}
|
||||
>
|
||||
{isFollowing ? '已关注' : '关注'}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
// 分页组件
|
||||
const Pagination = ({ current, total, pageSize, onChange }) => {
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<Flex justify="center" align="center" mt={8} gap={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onChange(current - 1)}
|
||||
isDisabled={current === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
|
||||
<HStack spacing={1}>
|
||||
{[...Array(Math.min(5, totalPages))].map((_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
size="sm"
|
||||
variant={current === pageNum ? 'solid' : 'ghost'}
|
||||
colorScheme={current === pageNum ? 'blue' : 'gray'}
|
||||
onClick={() => onChange(pageNum)}
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{totalPages > 5 && <Text>...</Text>}
|
||||
{totalPages > 5 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={current === totalPages ? 'solid' : 'ghost'}
|
||||
colorScheme={current === totalPages ? 'blue' : 'gray'}
|
||||
onClick={() => onChange(totalPages)}
|
||||
>
|
||||
{totalPages}
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onChange(current + 1)}
|
||||
isDisabled={current === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
|
||||
<Text fontSize="sm" color={mutedColor} ml={4}>
|
||||
共 {total} 条
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg={bgColor} minH="100vh" py={8}>
|
||||
<Container maxW="container.xl">
|
||||
{/* 视图切换控制 */}
|
||||
<Flex justify="flex-end" mb={6}>
|
||||
<FormControl display="flex" alignItems="center" w="auto">
|
||||
<FormLabel htmlFor="compact-mode" mb="0" fontSize="sm" color={textColor}>
|
||||
精简模式
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="compact-mode"
|
||||
isChecked={isCompactMode}
|
||||
onChange={(e) => setIsCompactMode(e.target.checked)}
|
||||
colorScheme="blue"
|
||||
/>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
|
||||
{events.length > 0 ? (
|
||||
<VStack align="stretch" spacing={0}>
|
||||
{events.map((event, index) => (
|
||||
<Box key={event.id} position="relative">
|
||||
{isCompactMode
|
||||
? renderCompactEvent(event)
|
||||
: renderDetailedEvent(event)
|
||||
}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<Center h="300px">
|
||||
<VStack spacing={4}>
|
||||
<InfoIcon boxSize={12} color={mutedColor} />
|
||||
<Text color={mutedColor} fontSize="lg">
|
||||
暂无事件数据
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{pagination.total > 0 && (
|
||||
<Pagination
|
||||
current={pagination.current}
|
||||
total={pagination.total}
|
||||
pageSize={pagination.pageSize}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventList;
|
||||
@@ -15,6 +15,7 @@ import StockChartAntdModal from '../../../components/StockChart/StockChartAntdMo
|
||||
import { useSubscription } from '../../../hooks/useSubscription';
|
||||
import SubscriptionUpgradeModal from '../../../components/SubscriptionUpgradeModal';
|
||||
import CitationMark from '../../../components/Citation/CitationMark';
|
||||
import CitedContent from '../../../components/Citation/CitedContent';
|
||||
import { processCitationData } from '../../../utils/citationUtils';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import './InvestmentCalendar.css';
|
||||
@@ -194,9 +195,49 @@ const InvestmentCalendar = () => {
|
||||
return <span>{stars}</span>;
|
||||
};
|
||||
|
||||
// 显示内容详情
|
||||
/**
|
||||
* 显示内容详情
|
||||
* 支持两种数据格式:
|
||||
* 1. 字符串格式:直接显示文本,自动添加"(AI合成)"标识
|
||||
* 例如:showContentDetail("这是事件背景内容", "事件背景")
|
||||
*
|
||||
* 2. 引用格式:使用CitedContent组件渲染,显示引用来源
|
||||
* 例如:showContentDetail({
|
||||
* data: [
|
||||
* { sentence: "第一句话", citation: { source: "来源1", url: "..." } },
|
||||
* { sentence: "第二句话", citation: { source: "来源2", url: "..." } }
|
||||
* ]
|
||||
* }, "事件背景")
|
||||
*
|
||||
* 后端API返回数据格式说明:
|
||||
* - 字符串格式:former字段直接返回字符串
|
||||
* - 引用格式:former字段返回 { data: [...] } 对象,其中data是引用数组
|
||||
*/
|
||||
const showContentDetail = (content, title) => {
|
||||
setSelectedDetail({ content, title });
|
||||
let processedContent;
|
||||
|
||||
// 判断content类型:字符串或引用格式
|
||||
if (typeof content === 'string') {
|
||||
// 字符串类型:添加AI合成标识
|
||||
processedContent = {
|
||||
type: 'text',
|
||||
content: content + (content ? '\n\n(AI合成)' : '')
|
||||
};
|
||||
} else if (content && content.data && Array.isArray(content.data)) {
|
||||
// 引用格式:使用CitedContent渲染
|
||||
processedContent = {
|
||||
type: 'citation',
|
||||
content: content
|
||||
};
|
||||
} else {
|
||||
// 其他情况:转为字符串并添加AI标识
|
||||
processedContent = {
|
||||
type: 'text',
|
||||
content: String(content || '') + '\n\n(AI合成)'
|
||||
};
|
||||
}
|
||||
|
||||
setSelectedDetail({ content: processedContent, title });
|
||||
setDetailDrawerVisible(true);
|
||||
};
|
||||
|
||||
@@ -332,7 +373,7 @@ const InvestmentCalendar = () => {
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
onClick={() => showContentDetail(text + (text ? '\n\n(AI合成)' : ''), '事件背景')}
|
||||
onClick={() => showContentDetail(text, '事件背景')}
|
||||
disabled={!text}
|
||||
>
|
||||
查看
|
||||
@@ -495,8 +536,8 @@ const InvestmentCalendar = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
// 检查是否有引用数据(可能在 record.reason_citation 或 record[4])
|
||||
const citationData = record.reason;
|
||||
// 检查是否有引用数据(reason 就是 record[2])
|
||||
const citationData = reason;
|
||||
const hasCitation = citationData && citationData.data && Array.isArray(citationData.data);
|
||||
|
||||
if (hasCitation) {
|
||||
@@ -694,9 +735,17 @@ const InvestmentCalendar = () => {
|
||||
onClose={() => setDetailDrawerVisible(false)}
|
||||
visible={detailDrawerVisible}
|
||||
>
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown>{selectedDetail?.content || '暂无内容'}</ReactMarkdown>
|
||||
</div>
|
||||
{selectedDetail?.content?.type === 'citation' ? (
|
||||
<CitedContent
|
||||
data={selectedDetail.content.content}
|
||||
title={selectedDetail.title || '事件背景'}
|
||||
showAIBadge={true}
|
||||
/>
|
||||
) : (
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown>{selectedDetail?.content?.content || '暂无内容'}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* 相关股票模态框 */}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useSubscription } from '../../../hooks/useSubscription';
|
||||
import SubscriptionUpgradeModal from '../../../components/SubscriptionUpgradeModal';
|
||||
import moment from 'moment';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TabPane } = Tabs;
|
||||
@@ -394,7 +395,7 @@ function StockDetailPanel({ visible, event, onClose }) {
|
||||
const loadWatchlist = useCallback(async () => {
|
||||
try {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const apiBase = isProduction ? '' : process.env.REACT_APP_API_URL || '';
|
||||
const apiBase = getApiBase();
|
||||
const response = await fetch(`${apiBase}/api/account/watchlist`, {
|
||||
credentials: 'include' // 确保发送cookies
|
||||
});
|
||||
@@ -415,7 +416,7 @@ function StockDetailPanel({ visible, event, onClose }) {
|
||||
const handleWatchlistToggle = async (stockCode, isInWatchlist) => {
|
||||
try {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const apiBase = isProduction ? '' : process.env.REACT_APP_API_URL || '';
|
||||
const apiBase = getApiBase();
|
||||
|
||||
let response;
|
||||
if (isInWatchlist) {
|
||||
|
||||
@@ -37,9 +37,10 @@ import {
|
||||
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getApiBase } from '../../utils/apiConfig';
|
||||
|
||||
// API配置
|
||||
const API_BASE_URL = process.env.NODE_ENV === 'production' ? "" : (process.env.REACT_APP_API_URL || 'http://localhost:5001');
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
// 格式化工具
|
||||
const formatUtils = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// src/views/Market/MarketDataPro.jsx
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getApiBase } from '../../utils/apiConfig';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
@@ -97,8 +98,7 @@ import ReactECharts from 'echarts-for-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
// API服务配置
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const API_BASE_URL = isProduction ? "" : (process.env.REACT_APP_API_URL || 'http://localhost:5001');
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
// 主题配置
|
||||
const themes = {
|
||||
|
||||
@@ -27,9 +27,9 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { SearchIcon, MoonIcon, SunIcon, StarIcon } from '@chakra-ui/icons';
|
||||
import { FaChartLine, FaMoneyBillWave, FaChartBar, FaInfoCircle } from 'react-icons/fa';
|
||||
import HomeNavbar from '../../components/Navbars/HomeNavbar';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getApiBase } from '../../utils/apiConfig';
|
||||
import FinancialPanorama from './FinancialPanorama';
|
||||
import ForecastReport from './ForecastReport';
|
||||
import MarketDataView from './MarketDataView';
|
||||
@@ -46,8 +46,7 @@ const CompanyIndex = () => {
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const tabBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const activeBg = useColorModeValue('blue.500', 'blue.400');
|
||||
|
||||
const getApiBase = () => (process.env.NODE_ENV === 'production' ? '' : (process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001'));
|
||||
|
||||
const [isInWatchlist, setIsInWatchlist] = useState(false);
|
||||
const [isWatchlistLoading, setIsWatchlistLoading] = useState(false);
|
||||
|
||||
@@ -153,9 +152,7 @@ const CompanyIndex = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<HomeNavbar />
|
||||
<Container maxW="container.xl" py={5}>
|
||||
<Container maxW="container.xl" py={5}>
|
||||
{/* 页面标题和股票搜索 */}
|
||||
<VStack align="stretch" spacing={5}>
|
||||
<Card bg={bgColor} shadow="md">
|
||||
@@ -308,8 +305,7 @@ const CompanyIndex = () => {
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
</Container>
|
||||
</>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// src/views/Dashboard/Center.js
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getApiBase } from '../../utils/apiConfig';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
@@ -85,7 +86,7 @@ export default function CenterDashboard() {
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const base = getApiBase();
|
||||
const ts = Date.now();
|
||||
const [w, e, c] = await Promise.all([
|
||||
fetch(base + `/api/account/watchlist?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
|
||||
@@ -118,7 +119,7 @@ export default function CenterDashboard() {
|
||||
const loadRealtimeQuotes = useCallback(async () => {
|
||||
try {
|
||||
setQuotesLoading(true);
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const base = getApiBase();
|
||||
const response = await fetch(base + '/api/account/watchlist/realtime', {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
|
||||
@@ -55,6 +55,7 @@ import interactionPlugin from '@fullcalendar/interaction';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import './InvestmentCalendar.css';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
@@ -86,7 +87,7 @@ export default function InvestmentCalendarChakra() {
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const base = getApiBase();
|
||||
|
||||
// 直接加载用户相关的事件(投资计划 + 关注的未来事件)
|
||||
const userResponse = await fetch(base + '/api/account/calendar/events', {
|
||||
@@ -168,7 +169,7 @@ export default function InvestmentCalendarChakra() {
|
||||
// 添加新事件
|
||||
const handleAddEvent = async () => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const base = getApiBase();
|
||||
|
||||
const eventData = {
|
||||
...newEvent,
|
||||
@@ -235,7 +236,7 @@ export default function InvestmentCalendarChakra() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/calendar/events/${eventId}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
493
src/views/Dashboard/components/InvestmentCalendarChakra.js.bak
Normal file
493
src/views/Dashboard/components/InvestmentCalendarChakra.js.bak
Normal file
@@ -0,0 +1,493 @@
|
||||
// src/views/Dashboard/components/InvestmentCalendarChakra.js
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Heading,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
Grid,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
Tooltip,
|
||||
Icon,
|
||||
Input,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
useToast,
|
||||
Spinner,
|
||||
Center,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiCalendar,
|
||||
FiClock,
|
||||
FiStar,
|
||||
FiTrendingUp,
|
||||
FiPlus,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiSave,
|
||||
FiX,
|
||||
} from 'react-icons/fi';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import './InvestmentCalendar.css';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
|
||||
export default function InvestmentCalendarChakra() {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure();
|
||||
const toast = useToast();
|
||||
|
||||
// 颜色主题
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
const [events, setEvents] = useState([]);
|
||||
const [selectedDate, setSelectedDate] = useState(null);
|
||||
const [selectedDateEvents, setSelectedDateEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newEvent, setNewEvent] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'plan',
|
||||
importance: 3,
|
||||
stocks: '',
|
||||
});
|
||||
|
||||
// 加载事件数据
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
|
||||
// 直接加载用户相关的事件(投资计划 + 关注的未来事件)
|
||||
const userResponse = await fetch(base + '/api/account/calendar/events', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (userResponse.ok) {
|
||||
const userData = await userResponse.json();
|
||||
if (userData.success) {
|
||||
const allEvents = (userData.data || []).map(event => ({
|
||||
...event,
|
||||
id: `${event.source || 'user'}-${event.id}`,
|
||||
title: event.title,
|
||||
start: event.event_date,
|
||||
date: event.event_date,
|
||||
backgroundColor: event.source === 'future' ? '#3182CE' : '#8B5CF6',
|
||||
borderColor: event.source === 'future' ? '#3182CE' : '#8B5CF6',
|
||||
extendedProps: {
|
||||
...event,
|
||||
isSystem: event.source === 'future',
|
||||
}
|
||||
}));
|
||||
|
||||
setEvents(allEvents);
|
||||
logger.debug('InvestmentCalendar', '日历事件加载成功', {
|
||||
count: allEvents.length
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentCalendar', 'loadEvents', error);
|
||||
// ❌ 移除数据加载失败 toast(非关键操作)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []); // ✅ 移除 toast 依赖
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
}, [loadEvents]);
|
||||
|
||||
// 根据重要性获取颜色
|
||||
const getEventColor = (importance) => {
|
||||
if (importance >= 5) return '#E53E3E'; // 红色
|
||||
if (importance >= 4) return '#ED8936'; // 橙色
|
||||
if (importance >= 3) return '#ECC94B'; // 黄色
|
||||
if (importance >= 2) return '#48BB78'; // 绿色
|
||||
return '#3182CE'; // 蓝色
|
||||
};
|
||||
|
||||
// 处理日期点击
|
||||
const handleDateClick = (info) => {
|
||||
const clickedDate = moment(info.date);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
// 筛选当天的事件
|
||||
const dayEvents = events.filter(event =>
|
||||
moment(event.start).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = (info) => {
|
||||
const event = info.event;
|
||||
const clickedDate = moment(event.start);
|
||||
setSelectedDate(clickedDate);
|
||||
setSelectedDateEvents([{
|
||||
title: event.title,
|
||||
start: event.start,
|
||||
extendedProps: {
|
||||
...event.extendedProps,
|
||||
},
|
||||
}]);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 添加新事件
|
||||
const handleAddEvent = async () => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
|
||||
const eventData = {
|
||||
...newEvent,
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : moment().format('YYYY-MM-DD')),
|
||||
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
|
||||
};
|
||||
|
||||
const response = await fetch(base + '/api/account/calendar/events', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(eventData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
logger.info('InvestmentCalendar', '添加事件成功', {
|
||||
eventTitle: eventData.title,
|
||||
eventDate: eventData.event_date
|
||||
});
|
||||
toast({
|
||||
title: '添加成功',
|
||||
description: '投资计划已添加',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
onAddClose();
|
||||
loadEvents();
|
||||
setNewEvent({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'plan',
|
||||
importance: 3,
|
||||
stocks: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentCalendar', 'handleAddEvent', error, {
|
||||
eventTitle: newEvent?.title
|
||||
});
|
||||
toast({
|
||||
title: '添加失败',
|
||||
description: '无法添加投资计划',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除用户事件
|
||||
const handleDeleteEvent = async (eventId) => {
|
||||
if (!eventId) {
|
||||
logger.warn('InvestmentCalendar', '删除事件失败', '缺少事件 ID', { eventId });
|
||||
toast({
|
||||
title: '无法删除',
|
||||
description: '缺少事件 ID',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
|
||||
const response = await fetch(base + `/api/account/calendar/events/${eventId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('InvestmentCalendar', '删除事件成功', { eventId });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadEvents();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentCalendar', 'handleDeleteEvent', error, { eventId });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card bg={bgColor} shadow="md">
|
||||
<CardHeader pb={4}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack>
|
||||
<Icon as={FiCalendar} color="orange.500" boxSize={5} />
|
||||
<Heading size="md">投资日历</Heading>
|
||||
</HStack>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => { if (!selectedDate) setSelectedDate(moment()); onAddOpen(); }}
|
||||
>
|
||||
添加计划
|
||||
</Button>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
{loading ? (
|
||||
<Center h="560px">
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
</Center>
|
||||
) : (
|
||||
<Box height={{ base: '500px', md: '600px' }}>
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, interactionPlugin]}
|
||||
initialView="dayGridMonth"
|
||||
locale="zh-cn"
|
||||
headerToolbar={{
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: ''
|
||||
}}
|
||||
events={events}
|
||||
dateClick={handleDateClick}
|
||||
eventClick={handleEventClick}
|
||||
height="100%"
|
||||
dayMaxEvents={3}
|
||||
moreLinkText="更多"
|
||||
buttonText={{
|
||||
today: '今天',
|
||||
month: '月',
|
||||
week: '周'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</CardBody>
|
||||
|
||||
{/* 查看事件详情 Modal - 条件渲染 */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{selectedDate && selectedDate.format('YYYY年MM月DD日')} 的事件
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{selectedDateEvents.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack>
|
||||
<Text color={secondaryText}>当天没有事件</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onAddOpen();
|
||||
}}
|
||||
>
|
||||
添加投资计划
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{selectedDateEvents.map((event, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={4}
|
||||
borderRadius="md"
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Flex justify="space-between" align="start" mb={2}>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{event.title}
|
||||
</Text>
|
||||
{event.extendedProps?.isSystem ? (
|
||||
<Badge colorScheme="blue" variant="subtle">系统事件</Badge>
|
||||
) : (
|
||||
<Badge colorScheme="purple" variant="subtle">我的计划</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiStar} color="yellow.500" />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
重要度: {event.extendedProps?.importance || 3}/5
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
{!event.extendedProps?.isSystem && (
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDeleteEvent(event.extendedProps?.id)}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{event.extendedProps?.description && (
|
||||
<Text fontSize="sm" color={secondaryText} mb={2}>
|
||||
{event.extendedProps.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{event.extendedProps?.stocks && event.extendedProps.stocks.length > 0 && (
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Text fontSize="sm" color={secondaryText}>相关股票:</Text>
|
||||
{event.extendedProps.stocks.map((stock, i) => (
|
||||
<Tag key={i} size="sm" colorScheme="blue">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 添加投资计划 Modal - 条件渲染 */}
|
||||
{isAddOpen && (
|
||||
<Modal isOpen={isAddOpen} onClose={onAddClose} size="lg" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
添加投资计划
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={newEvent.title}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, title: e.target.value })}
|
||||
placeholder="例如:关注半导体板块"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>描述</FormLabel>
|
||||
<Textarea
|
||||
value={newEvent.description}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, description: e.target.value })}
|
||||
placeholder="详细描述您的投资计划..."
|
||||
rows={3}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>类型</FormLabel>
|
||||
<Select
|
||||
value={newEvent.type}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, type: e.target.value })}
|
||||
>
|
||||
<option value="plan">投资计划</option>
|
||||
<option value="reminder">提醒事项</option>
|
||||
<option value="analysis">分析任务</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>重要度</FormLabel>
|
||||
<Select
|
||||
value={newEvent.importance}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, importance: parseInt(e.target.value) })}
|
||||
>
|
||||
<option value={5}>⭐⭐⭐⭐⭐ 非常重要</option>
|
||||
<option value={4}>⭐⭐⭐⭐ 重要</option>
|
||||
<option value={3}>⭐⭐⭐ 一般</option>
|
||||
<option value={2}>⭐⭐ 次要</option>
|
||||
<option value={1}>⭐ 不重要</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票(用逗号分隔)</FormLabel>
|
||||
<Input
|
||||
value={newEvent.stocks}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, stocks: e.target.value })}
|
||||
placeholder="例如:600519,000858,002415"
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onAddClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleAddEvent}
|
||||
isDisabled={!newEvent.title}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -69,6 +69,7 @@ import interactionPlugin from '@fullcalendar/interaction';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import '../components/InvestmentCalendar.css';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
@@ -95,7 +96,7 @@ export default function InvestmentPlanningCenter() {
|
||||
const loadAllData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + '/api/account/calendar/events', {
|
||||
credentials: 'include'
|
||||
@@ -257,7 +258,7 @@ function CalendarPanel() {
|
||||
// 添加新事件
|
||||
const handleAddEvent = async () => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const base = getApiBase();
|
||||
|
||||
const eventData = {
|
||||
...newEvent,
|
||||
@@ -324,7 +325,7 @@ function CalendarPanel() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/calendar/events/${eventId}`, {
|
||||
method: 'DELETE',
|
||||
@@ -658,7 +659,7 @@ function PlansPanel() {
|
||||
// 保存数据
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const base = getApiBase();
|
||||
|
||||
const url = editingItem
|
||||
? base + `/api/account/investment-plans/${editingItem.id}`
|
||||
@@ -709,7 +710,7 @@ function PlansPanel() {
|
||||
if (!window.confirm('确定要删除吗?')) return;
|
||||
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
@@ -1082,7 +1083,7 @@ function ReviewsPanel() {
|
||||
// 保存数据
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const base = getApiBase();
|
||||
|
||||
const url = editingItem
|
||||
? base + `/api/account/investment-plans/${editingItem.id}`
|
||||
@@ -1133,7 +1134,7 @@ function ReviewsPanel() {
|
||||
if (!window.confirm('确定要删除吗?')) return;
|
||||
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
1419
src/views/Dashboard/components/InvestmentPlanningCenter.js.bak
Normal file
1419
src/views/Dashboard/components/InvestmentPlanningCenter.js.bak
Normal file
File diff suppressed because it is too large
Load Diff
@@ -63,6 +63,7 @@ import {
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
|
||||
@@ -97,7 +98,7 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + '/api/account/investment-plans', {
|
||||
credentials: 'include'
|
||||
@@ -153,7 +154,7 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
|
||||
// 保存数据
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const base = getApiBase();
|
||||
|
||||
const url = editingItem
|
||||
? base + `/api/account/investment-plans/${editingItem.id}`
|
||||
@@ -205,7 +206,7 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
|
||||
if (!window.confirm('确定要删除吗?')) return;
|
||||
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
587
src/views/Dashboard/components/InvestmentPlansAndReviews.js.bak
Normal file
587
src/views/Dashboard/components/InvestmentPlansAndReviews.js.bak
Normal file
@@ -0,0 +1,587 @@
|
||||
// src/views/Dashboard/components/InvestmentPlansAndReviews.js
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Heading,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
Icon,
|
||||
Input,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
useToast,
|
||||
Spinner,
|
||||
Center,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
TagCloseButton,
|
||||
Grid,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiCalendar,
|
||||
FiClock,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiSave,
|
||||
FiPlus,
|
||||
FiFileText,
|
||||
FiTarget,
|
||||
FiTrendingUp,
|
||||
FiHash,
|
||||
FiCheckCircle,
|
||||
FiXCircle,
|
||||
FiAlertCircle,
|
||||
} from 'react-icons/fi';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
|
||||
export default function InvestmentPlansAndReviews({ type = 'both' }) {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const toast = useToast();
|
||||
|
||||
// 颜色主题
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
const cardBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [reviews, setReviews] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
const [stockInput, setStockInput] = useState('');
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
|
||||
// 加载数据
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
|
||||
const response = await fetch(base + '/api/account/investment-plans', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
const allItems = data.data || [];
|
||||
setPlans(allItems.filter(item => item.type === 'plan'));
|
||||
setReviews(allItems.filter(item => item.type === 'review'));
|
||||
logger.debug('InvestmentPlansAndReviews', '数据加载成功', {
|
||||
plansCount: allItems.filter(item => item.type === 'plan').length,
|
||||
reviewsCount: allItems.filter(item => item.type === 'review').length
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentPlansAndReviews', 'loadData', error);
|
||||
// ❌ 移除数据加载失败 toast(非关键操作)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []); // ✅ 移除 toast 依赖
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 打开编辑/新建模态框
|
||||
const handleOpenModal = (item = null, itemType = 'plan') => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
...item,
|
||||
date: moment(item.date).format('YYYY-MM-DD'),
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: itemType,
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 保存数据
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
|
||||
const url = editingItem
|
||||
? base + `/api/account/investment-plans/${editingItem.id}`
|
||||
: base + '/api/account/investment-plans';
|
||||
|
||||
const method = editingItem ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('InvestmentPlansAndReviews', `${editingItem ? '更新' : '创建'}成功`, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData.title,
|
||||
type: formData.type
|
||||
});
|
||||
toast({
|
||||
title: editingItem ? '更新成功' : '创建成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
onClose();
|
||||
loadData();
|
||||
} else {
|
||||
throw new Error('保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentPlansAndReviews', 'handleSave', error, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData?.title
|
||||
});
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: '无法保存数据',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除数据
|
||||
const handleDelete = async (id) => {
|
||||
if (!window.confirm('确定要删除吗?')) return;
|
||||
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
|
||||
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('InvestmentPlansAndReviews', '删除成功', { itemId: id });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentPlansAndReviews', 'handleDelete', error, { itemId: id });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 添加股票
|
||||
const handleAddStock = () => {
|
||||
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
stocks: [...formData.stocks, stockInput.trim()],
|
||||
});
|
||||
setStockInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 添加标签
|
||||
const handleAddTag = () => {
|
||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
tags: [...formData.tags, tagInput.trim()],
|
||||
});
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态图标和颜色
|
||||
const getStatusInfo = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return { icon: FiCheckCircle, color: 'green' };
|
||||
case 'cancelled':
|
||||
return { icon: FiXCircle, color: 'red' };
|
||||
default:
|
||||
return { icon: FiAlertCircle, color: 'blue' };
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染单个卡片
|
||||
const renderCard = (item) => {
|
||||
const statusInfo = getStatusInfo(item.status);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
bg={cardBg}
|
||||
shadow="sm"
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Flex justify="space-between" align="start">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Icon as={item.type === 'plan' ? FiTarget : FiFileText} color="blue.500" />
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{item.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{moment(item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={statusInfo.color}
|
||||
variant="subtle"
|
||||
leftIcon={<Icon as={statusInfo.icon} />}
|
||||
>
|
||||
{item.status === 'active' ? '进行中' :
|
||||
item.status === 'completed' ? '已完成' : '已取消'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<HStack>
|
||||
<IconButton
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleOpenModal(item)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{item.content && (
|
||||
<Text fontSize="sm" color={textColor} noOfLines={3}>
|
||||
{item.content}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{item.stocks && item.stocks.length > 0 && (
|
||||
<>
|
||||
{item.stocks.map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<>
|
||||
{item.tags.map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="purple" variant="subtle">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Tabs variant="enclosed" colorScheme="blue" defaultIndex={type === 'review' ? 1 : 0}>
|
||||
<TabList>
|
||||
<Tab>
|
||||
<Icon as={FiTarget} mr={2} />
|
||||
我的计划 ({plans.length})
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={FiFileText} mr={2} />
|
||||
我的复盘 ({reviews.length})
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* 计划面板 */}
|
||||
<TabPanel px={0}>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null, 'plan')}
|
||||
>
|
||||
新建计划
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center py={8}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
</Center>
|
||||
) : plans.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiTarget} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText}>暂无投资计划</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null, 'plan')}
|
||||
>
|
||||
创建第一个计划
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
||||
{plans.map(renderCard)}
|
||||
</Grid>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* 复盘面板 */}
|
||||
<TabPanel px={0}>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null, 'review')}
|
||||
>
|
||||
新建复盘
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center py={8}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
</Center>
|
||||
) : reviews.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiFileText} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText}>暂无复盘记录</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null, 'review')}
|
||||
>
|
||||
创建第一个复盘
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
||||
{reviews.map(renderCard)}
|
||||
</Grid>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
{/* 编辑/新建模态框 - 条件渲染 */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{editingItem ? '编辑' : '新建'}
|
||||
{formData.type === 'plan' ? '投资计划' : '复盘记录'}
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>日期</FormLabel>
|
||||
<InputGroup>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<Icon as={FiCalendar} color={secondaryText} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder={formData.type === 'plan' ? '例如:布局新能源板块' : '例如:本周交易复盘'}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>内容</FormLabel>
|
||||
<Textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
placeholder={formData.type === 'plan' ?
|
||||
'详细描述您的投资计划...' :
|
||||
'记录您的交易心得和经验教训...'}
|
||||
rows={6}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={stockInput}
|
||||
onChange={(e) => setStockInput(e.target.value)}
|
||||
placeholder="输入股票代码"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
|
||||
/>
|
||||
<Button onClick={handleAddStock}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{formData.stocks.map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
stocks: formData.stocks.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>标签</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
placeholder="输入标签"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
|
||||
/>
|
||||
<Button onClick={handleAddTag}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{formData.tags.map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="purple">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
tags: formData.tags.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>状态</FormLabel>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
|
||||
>
|
||||
<option value="active">进行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleSave}
|
||||
isDisabled={!formData.title || !formData.date}
|
||||
leftIcon={<FiSave />}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -149,11 +149,34 @@ const HistoricalEvents = ({
|
||||
return `${Math.floor(diffDays / 365)}年前`;
|
||||
};
|
||||
|
||||
// 处理关联描述字段的辅助函数
|
||||
const getRelationDesc = (relationDesc) => {
|
||||
// 处理空值
|
||||
if (!relationDesc) return '';
|
||||
|
||||
// 如果是字符串,直接返回
|
||||
if (typeof relationDesc === 'string') {
|
||||
return relationDesc;
|
||||
}
|
||||
|
||||
// 如果是对象且包含data数组
|
||||
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
|
||||
const firstItem = relationDesc.data[0];
|
||||
if (firstItem) {
|
||||
// 优先使用 query_part,其次使用 sentences
|
||||
return firstItem.query_part || firstItem.sentences || '';
|
||||
}
|
||||
}
|
||||
|
||||
// 其他情况返回空字符串
|
||||
return '';
|
||||
};
|
||||
|
||||
// 可展开的文本组件
|
||||
const ExpandableText = ({ text, maxLength = 20 }) => {
|
||||
const { isOpen, onToggle } = useDisclosure();
|
||||
const [shouldTruncate, setShouldTruncate] = useState(false);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (text && text.length > maxLength) {
|
||||
setShouldTruncate(true);
|
||||
@@ -161,22 +184,22 @@ const HistoricalEvents = ({
|
||||
setShouldTruncate(false);
|
||||
}
|
||||
}, [text, maxLength]);
|
||||
|
||||
|
||||
if (!text) return <Text fontSize="xs">--</Text>;
|
||||
|
||||
const displayText = shouldTruncate && !isOpen
|
||||
? text.substring(0, maxLength) + '...'
|
||||
|
||||
const displayText = shouldTruncate && !isOpen
|
||||
? text.substring(0, maxLength) + '...'
|
||||
: text;
|
||||
|
||||
|
||||
return (
|
||||
<VStack align="flex-start" spacing={1}>
|
||||
<Text fontSize="xs" noOfLines={isOpen ? undefined : 2} maxW="300px">
|
||||
{displayText}{text.includes('AI合成') ? '' : '(AI合成)'}
|
||||
</Text>
|
||||
{shouldTruncate && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="link"
|
||||
<Button
|
||||
size="xs"
|
||||
variant="link"
|
||||
color="blue.500"
|
||||
onClick={onToggle}
|
||||
height="auto"
|
||||
@@ -444,13 +467,36 @@ const HistoricalEvents = ({
|
||||
// 股票列表子组件
|
||||
const StocksList = ({ stocks, eventTradingDate }) => {
|
||||
const textSecondary = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
|
||||
// 处理股票代码,移除.SZ/.SH后缀
|
||||
const formatStockCode = (stockCode) => {
|
||||
if (!stockCode) return '';
|
||||
return stockCode.replace(/\.(SZ|SH)$/i, '');
|
||||
};
|
||||
|
||||
// 处理关联描述字段的辅助函数
|
||||
const getRelationDesc = (relationDesc) => {
|
||||
// 处理空值
|
||||
if (!relationDesc) return '';
|
||||
|
||||
// 如果是字符串,直接返回
|
||||
if (typeof relationDesc === 'string') {
|
||||
return relationDesc;
|
||||
}
|
||||
|
||||
// 如果是对象且包含data数组
|
||||
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
|
||||
const firstItem = relationDesc.data[0];
|
||||
if (firstItem) {
|
||||
// 优先使用 query_part,其次使用 sentences
|
||||
return firstItem.query_part || firstItem.sentences || '';
|
||||
}
|
||||
}
|
||||
|
||||
// 其他情况返回空字符串
|
||||
return '';
|
||||
};
|
||||
|
||||
if (!stocks || stocks.length === 0) {
|
||||
return (
|
||||
<Box textAlign="center" py={8} color={textSecondary}>
|
||||
@@ -527,7 +573,7 @@ const StocksList = ({ stocks, eventTradingDate }) => {
|
||||
<Td>
|
||||
<VStack align="flex-start" spacing={1}>
|
||||
<Text fontSize="xs" noOfLines={2} maxW="300px">
|
||||
{stock.relation_desc ? `${stock.relation_desc}(AI合成)` : '--'}
|
||||
{getRelationDesc(stock.relation_desc) ? `${getRelationDesc(stock.relation_desc)}(AI合成)` : '--'}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Td>
|
||||
|
||||
@@ -65,6 +65,7 @@ import ReactECharts from 'echarts-for-react';
|
||||
import HomeNavbar from '../../../components/Navbars/HomeNavbar';
|
||||
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
|
||||
// 板块关联TOP10数据计算
|
||||
function getSectorRelationTop10(sectorData) {
|
||||
@@ -177,7 +178,7 @@ function getTop10Sectors(chartData) {
|
||||
}
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
const API_BASE = isProduction ? "" : process.env.REACT_APP_API_URL;
|
||||
const API_BASE = getApiBase();
|
||||
// const API_BASE = 'http://49.232.185.254:5001'; // 改回5001端口,确保和后端一致
|
||||
|
||||
// 涨停分析服务
|
||||
|
||||
@@ -206,6 +206,28 @@ const RelatedStocks = ({
|
||||
return `股票${stock.stock_code.split('.')[0]}`;
|
||||
};
|
||||
|
||||
const getRelationDesc = (relationDesc) => {
|
||||
// 处理空值
|
||||
if (!relationDesc) return '--';
|
||||
|
||||
// 如果是字符串,直接返回
|
||||
if (typeof relationDesc === 'string') {
|
||||
return relationDesc;
|
||||
}
|
||||
|
||||
// 如果是对象且包含data数组
|
||||
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
|
||||
const firstItem = relationDesc.data[0];
|
||||
if (firstItem) {
|
||||
// 优先使用 query_part,其次使用 sentences
|
||||
return firstItem.query_part || firstItem.sentences || '--';
|
||||
}
|
||||
}
|
||||
|
||||
// 其他情况返回默认值
|
||||
return '--';
|
||||
};
|
||||
|
||||
// ==================== 数据处理 ====================
|
||||
const filteredAndSortedStocks = stocksData
|
||||
.filter(stock => {
|
||||
@@ -442,7 +464,7 @@ const RelatedStocks = ({
|
||||
{/* 关联描述 */}
|
||||
<Td maxW="200px">
|
||||
<Text fontSize="xs" noOfLines={2}>
|
||||
{stock.relation_desc || '--'}
|
||||
{getRelationDesc(stock.relation_desc)}
|
||||
</Text>
|
||||
</Td>
|
||||
|
||||
|
||||
@@ -405,6 +405,14 @@ const TransmissionChainAnalysis = ({ eventId }) => {
|
||||
const modalBgColor = useColorModeValue('white', 'gray.800');
|
||||
const modalBorderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
// 关闭弹窗并清空状态
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedNode(null);
|
||||
setNodeDetail(null);
|
||||
setTransmissionPath([]);
|
||||
};
|
||||
|
||||
// 延迟初始化图表,确保DOM容器准备好
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -771,9 +779,10 @@ const TransmissionChainAnalysis = ({ eventId }) => {
|
||||
)}
|
||||
|
||||
{/* 节点详情弹窗 */}
|
||||
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxH="80vh" bg={modalBgColor}>
|
||||
{isModalOpen && (
|
||||
<Modal isOpen={isModalOpen} onClose={handleCloseModal} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxH="80vh" bg={modalBgColor}>
|
||||
<ModalHeader borderBottom="1px solid" borderColor={modalBorderColor}>
|
||||
<HStack justify="space-between">
|
||||
<Text>{selectedNode ? '节点详情' : '传导链分析'}</Text>
|
||||
@@ -845,9 +854,9 @@ const TransmissionChainAnalysis = ({ eventId }) => {
|
||||
{selectedNode.extra?.description && (
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2} color="blue.600">描述</Text>
|
||||
<Box
|
||||
fontSize="sm"
|
||||
color="gray.600"
|
||||
<Box
|
||||
fontSize="sm"
|
||||
color="gray.600"
|
||||
borderLeft="3px solid"
|
||||
borderColor="blue.200"
|
||||
pl={3}
|
||||
@@ -856,7 +865,19 @@ const TransmissionChainAnalysis = ({ eventId }) => {
|
||||
borderRadius="md"
|
||||
fontStyle="italic"
|
||||
>
|
||||
{selectedNode.extra.description}(AI合成)
|
||||
{selectedNode.extra.description?.data ? (
|
||||
<>
|
||||
<CitedContent
|
||||
data={selectedNode.extra.description}
|
||||
title=""
|
||||
showAIBadge={false}
|
||||
containerStyle={{ backgroundColor: 'transparent', padding: 0, display: 'inline-block', verticalAlign: 'baseline' }}
|
||||
/>
|
||||
(AI合成)
|
||||
</>
|
||||
) : (
|
||||
`${selectedNode.extra.description}(AI合成)`
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
@@ -899,16 +920,17 @@ const TransmissionChainAnalysis = ({ eventId }) => {
|
||||
<HStack justify="space-between" align="flex-start">
|
||||
<VStack align="stretch" spacing={1} flex={1}>
|
||||
<Text fontWeight="bold" fontSize="sm">{parent.name}</Text>
|
||||
{parent.transmission_mechanism_citation?.data ? (
|
||||
<Box fontSize="xs">
|
||||
{parent.transmission_mechanism?.data ? (
|
||||
<Text fontSize="xs">
|
||||
<Text as="span" fontWeight="bold">机制: </Text>
|
||||
<CitedContent
|
||||
data={parent.transmission_mechanism_citation.data}
|
||||
data={parent.transmission_mechanism}
|
||||
title=""
|
||||
showAIBadge={false}
|
||||
containerStyle={{ backgroundColor: 'transparent', padding: 0, display: 'inline' }}
|
||||
containerStyle={{ backgroundColor: 'transparent', padding: 0, display: 'inline-block', verticalAlign: 'baseline' }}
|
||||
/>
|
||||
</Box>
|
||||
(AI合成)
|
||||
</Text>
|
||||
) : parent.transmission_mechanism ? (
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
机制: {parent.transmission_mechanism}(AI合成)
|
||||
@@ -950,15 +972,16 @@ const TransmissionChainAnalysis = ({ eventId }) => {
|
||||
<VStack align="stretch" spacing={1} flex={1}>
|
||||
<Text fontWeight="bold" fontSize="sm">{child.name}</Text>
|
||||
{child.transmission_mechanism?.data ? (
|
||||
<Box fontSize="xs">
|
||||
<Text fontSize="xs">
|
||||
<Text as="span" fontWeight="bold">机制: </Text>
|
||||
<CitedContent
|
||||
data={child.transmission_mechanism.data}
|
||||
data={child.transmission_mechanism}
|
||||
title=""
|
||||
showAIBadge={false}
|
||||
containerStyle={{ backgroundColor: 'transparent', padding: 0, display: 'inline' }}
|
||||
containerStyle={{ backgroundColor: 'transparent', padding: 0, display: 'inline-block', verticalAlign: 'baseline' }}
|
||||
/>
|
||||
</Box>
|
||||
(AI合成)
|
||||
</Text>
|
||||
) : child.transmission_mechanism ? (
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
机制: {child.transmission_mechanism}(AI合成)
|
||||
@@ -991,10 +1014,11 @@ const TransmissionChainAnalysis = ({ eventId }) => {
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter borderTop="1px solid" borderColor={modalBorderColor}>
|
||||
<Button onClick={() => setIsModalOpen(false)}>关闭</Button>
|
||||
<Button onClick={handleCloseModal}>关闭</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,39 @@
|
||||
import { Flex } from '@chakra-ui/react';
|
||||
import {
|
||||
Flex,
|
||||
Container,
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
Text
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronRightIcon } from '@chakra-ui/icons';
|
||||
import React from 'react';
|
||||
import SubscriptionContent from 'components/Subscription/SubscriptionContent';
|
||||
|
||||
function Subscription() {
|
||||
return (
|
||||
<Flex direction='column' pt={{ base: '120px', md: '75px' }} px={{ base: '20px', md: '40px' }}>
|
||||
<SubscriptionContent />
|
||||
<Flex direction='column'>
|
||||
<Container maxW="container.xl" px={{ base: 4, md: 6 }} py={{ base: 4, md: 6 }}>
|
||||
{/* 面包屑导航 */}
|
||||
<Breadcrumb
|
||||
spacing='8px'
|
||||
separator={<ChevronRightIcon color='gray.500' />}
|
||||
mb={6}
|
||||
fontSize='sm'
|
||||
>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href='/home'>首页</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href='/home/pages/account'>个人中心</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbItem isCurrentPage>
|
||||
<Text color='gray.500'>订阅管理</Text>
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
|
||||
<SubscriptionContent />
|
||||
</Container>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
} from '@chakra-ui/icons';
|
||||
import { FaWeixin, FaMobile, FaEnvelope } from 'react-icons/fa';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { getApiBase } from '../../utils/apiConfig';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -118,9 +119,7 @@ export default function SettingsPage() {
|
||||
// 获取密码状态
|
||||
const fetchPasswordStatus = async () => {
|
||||
try {
|
||||
const API_BASE_URL = process.env.NODE_ENV === 'production'
|
||||
? ""
|
||||
: process.env.REACT_APP_API_URL || "http://49.232.185.254:5001";
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
logger.api.request('GET', '/api/account/password-status', null);
|
||||
|
||||
@@ -191,9 +190,7 @@ export default function SettingsPage() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 调用后端API修改密码
|
||||
const API_BASE_URL = process.env.NODE_ENV === 'production'
|
||||
? ""
|
||||
: process.env.REACT_APP_API_URL || "http://49.232.185.254:5001";
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/account/change-password`, {
|
||||
method: 'POST',
|
||||
@@ -257,7 +254,7 @@ export default function SettingsPage() {
|
||||
const url = '/api/account/phone/send-code';
|
||||
logger.api.request('POST', url, { phone: phoneForm.phone.substring(0, 3) + '****' });
|
||||
|
||||
const res = await fetch((process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001') + url, {
|
||||
const res = await fetch(getApiBase() + url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
@@ -272,7 +269,7 @@ export default function SettingsPage() {
|
||||
logger.api.request('POST', url, { email: emailForm.email.substring(0, 3) + '***@***' });
|
||||
|
||||
// 使用绑定邮箱的验证码API
|
||||
const res = await fetch((process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001') + url, {
|
||||
const res = await fetch(getApiBase() + url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
@@ -305,7 +302,7 @@ export default function SettingsPage() {
|
||||
const handlePhoneBind = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch((process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001') + '/api/account/phone/bind', {
|
||||
const res = await fetch(getApiBase() + '/api/account/phone/bind', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
@@ -346,7 +343,7 @@ export default function SettingsPage() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 调用真实的邮箱绑定API
|
||||
const res = await fetch((process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001') + '/api/account/email/bind', {
|
||||
const res = await fetch(getApiBase() + '/api/account/email/bind', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
@@ -559,7 +556,7 @@ export default function SettingsPage() {
|
||||
onClick={async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch((process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001') + '/api/account/phone/unbind', {
|
||||
const res = await fetch(getApiBase() + '/api/account/phone/unbind', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
@@ -641,7 +638,7 @@ export default function SettingsPage() {
|
||||
onClick={async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch((process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001') + '/api/account/wechat/unbind', {
|
||||
const res = await fetch(getApiBase() + '/api/account/wechat/unbind', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
@@ -662,7 +659,7 @@ export default function SettingsPage() {
|
||||
<Button leftIcon={<LinkIcon />} colorScheme="green" onClick={async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const base = getApiBase();
|
||||
const res = await fetch(base + '/api/account/wechat/qrcode', { method: 'GET', credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || '获取二维码失败');
|
||||
|
||||
1330
src/views/Settings/SettingsPage.js.bak
Normal file
1330
src/views/Settings/SettingsPage.js.bak
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,10 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
|
||||
// API 基础URL - 修复HTTPS混合内容问题
|
||||
const API_BASE_URL = process.env.NODE_ENV === 'production'
|
||||
? '' // 生产环境使用相对路径(通过nginx代理)
|
||||
: (process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
// API 请求封装
|
||||
const apiRequest = async (url, options = {}) => {
|
||||
|
||||
384
src/views/TradingSimulation/hooks/useTradingAccount.js.bak
Normal file
384
src/views/TradingSimulation/hooks/useTradingAccount.js.bak
Normal file
@@ -0,0 +1,384 @@
|
||||
// src/views/TradingSimulation/hooks/useTradingAccount.js - 模拟盘账户管理 Hook
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
// API 基础URL - 修复HTTPS混合内容问题
|
||||
const API_BASE_URL = process.env.NODE_ENV === 'production'
|
||||
? '' // 生产环境使用相对路径(通过nginx代理)
|
||||
: (process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
|
||||
// API 请求封装
|
||||
const apiRequest = async (url, options = {}) => {
|
||||
const response = await fetch(`${API_BASE_URL}${url}`, {
|
||||
credentials: 'include', // 包含session cookie
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
},
|
||||
...options
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// 数据字段映射函数
|
||||
const mapAccountData = (backendData) => {
|
||||
return {
|
||||
id: backendData.account_id,
|
||||
accountName: backendData.account_name,
|
||||
initialCash: backendData.initial_capital,
|
||||
availableCash: backendData.available_cash,
|
||||
frozenCash: backendData.frozen_cash,
|
||||
marketValue: backendData.position_value,
|
||||
totalAssets: backendData.total_assets,
|
||||
totalProfit: backendData.total_profit,
|
||||
totalProfitPercent: backendData.total_profit_rate,
|
||||
dailyProfit: backendData.daily_profit,
|
||||
dailyProfitRate: backendData.daily_profit_rate,
|
||||
riskLevel: 'MEDIUM', // 默认值
|
||||
marginBalance: 0,
|
||||
shortBalance: 0,
|
||||
lastUpdated: backendData.updated_at,
|
||||
createdAt: backendData.created_at
|
||||
};
|
||||
};
|
||||
|
||||
const mapPositionData = (backendPositions) => {
|
||||
return backendPositions.map(pos => ({
|
||||
id: pos.id,
|
||||
stockCode: pos.stock_code,
|
||||
stockName: pos.stock_name,
|
||||
quantity: pos.position_qty,
|
||||
availableQuantity: pos.available_qty,
|
||||
frozenQuantity: pos.frozen_qty,
|
||||
avgPrice: pos.avg_cost,
|
||||
currentPrice: pos.current_price,
|
||||
totalCost: pos.position_qty * pos.avg_cost,
|
||||
marketValue: pos.market_value,
|
||||
profit: pos.profit,
|
||||
profitRate: pos.profit_rate,
|
||||
todayProfit: pos.today_profit,
|
||||
todayProfitRate: pos.today_profit_rate,
|
||||
updatedAt: pos.updated_at
|
||||
}));
|
||||
};
|
||||
|
||||
const mapOrderData = (backendOrders) => {
|
||||
return backendOrders.map(order => ({
|
||||
id: order.id,
|
||||
orderId: order.order_no,
|
||||
stockCode: order.stock_code,
|
||||
stockName: order.stock_name,
|
||||
type: order.order_type, // 添加 type 字段
|
||||
orderType: order.order_type,
|
||||
priceType: order.price_type,
|
||||
orderPrice: order.order_price,
|
||||
quantity: order.order_qty,
|
||||
filledQuantity: order.filled_qty,
|
||||
price: order.filled_price, // 添加 price 字段
|
||||
filledPrice: order.filled_price,
|
||||
totalAmount: order.filled_amount, // 添加 totalAmount 字段
|
||||
filledAmount: order.filled_amount,
|
||||
commission: order.commission,
|
||||
stampTax: order.stamp_tax,
|
||||
transferFee: order.transfer_fee,
|
||||
totalFee: order.total_fee,
|
||||
status: order.status,
|
||||
rejectReason: order.reject_reason,
|
||||
createdAt: order.order_time,
|
||||
filledAt: order.filled_time
|
||||
}));
|
||||
};
|
||||
|
||||
export function useTradingAccount() {
|
||||
const { user } = useAuth();
|
||||
const [account, setAccount] = useState(null);
|
||||
const [positions, setPositions] = useState([]);
|
||||
const [tradingHistory, setTradingHistory] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [stockQuotes, setStockQuotes] = useState({});
|
||||
|
||||
// 搜索股票
|
||||
const searchStocks = useCallback(async (keyword) => {
|
||||
// 调试模式:返回模拟数据
|
||||
if (!user || user.id === 'demo') {
|
||||
logger.debug('useTradingAccount', '调试模式:模拟股票搜索', { keyword });
|
||||
const mockStocks = [
|
||||
{
|
||||
stock_code: '000001',
|
||||
stock_name: '平安银行',
|
||||
current_price: 12.50,
|
||||
pinyin_abbr: 'payh',
|
||||
security_type: 'A股',
|
||||
exchange: '深交所'
|
||||
},
|
||||
{
|
||||
stock_code: '600036',
|
||||
stock_name: '招商银行',
|
||||
current_price: 42.80,
|
||||
pinyin_abbr: 'zsyh',
|
||||
security_type: 'A股',
|
||||
exchange: '上交所'
|
||||
},
|
||||
{
|
||||
stock_code: '688256',
|
||||
stock_name: '寒武纪',
|
||||
current_price: 1394.94,
|
||||
pinyin_abbr: 'hwj',
|
||||
security_type: 'A股',
|
||||
exchange: '上交所科创板'
|
||||
}
|
||||
];
|
||||
|
||||
return mockStocks.filter(stock =>
|
||||
stock.stock_code.includes(keyword) ||
|
||||
stock.stock_name.includes(keyword) ||
|
||||
stock.pinyin_abbr.includes(keyword.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiRequest(`/api/stocks/search?q=${encodeURIComponent(keyword)}&limit=10`);
|
||||
return response.data || [];
|
||||
} catch (error) {
|
||||
logger.error('useTradingAccount', 'searchStocks', error, { keyword });
|
||||
return [];
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// 刷新账户数据
|
||||
const refreshAccount = useCallback(async () => {
|
||||
// 调试模式:使用模拟数据(因为后端API可能有CORS问题)
|
||||
if (!user || user.id === 'demo') {
|
||||
logger.debug('useTradingAccount', '调试模式:使用模拟账户数据', { userId: user?.id });
|
||||
setAccount({
|
||||
id: 'demo',
|
||||
accountName: '演示账户',
|
||||
initialCash: 1000000,
|
||||
availableCash: 950000,
|
||||
frozenCash: 0,
|
||||
marketValue: 50000,
|
||||
totalAssets: 1000000,
|
||||
totalProfit: 0,
|
||||
totalProfitPercent: 0,
|
||||
dailyProfit: 0,
|
||||
dailyProfitRate: 0,
|
||||
riskLevel: 'MEDIUM',
|
||||
marginBalance: 0,
|
||||
shortBalance: 0,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
setPositions([]);
|
||||
setTradingHistory([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const accountResponse = await apiRequest('/api/simulation/account');
|
||||
setAccount(mapAccountData(accountResponse.data));
|
||||
|
||||
// 获取持仓信息
|
||||
const positionsResponse = await apiRequest('/api/simulation/positions');
|
||||
setPositions(mapPositionData(positionsResponse.data || []));
|
||||
|
||||
// 获取交易历史
|
||||
const ordersResponse = await apiRequest('/api/simulation/orders?limit=100');
|
||||
setTradingHistory(mapOrderData(ordersResponse.data || []));
|
||||
|
||||
} catch (err) {
|
||||
logger.error('useTradingAccount', 'refreshAccount', err, { userId: user?.id });
|
||||
setError('加载账户数据失败: ' + err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// 买入股票
|
||||
const buyStock = useCallback(async (stockCode, quantity, orderType = 'MARKET', limitPrice = null) => {
|
||||
if (!account) {
|
||||
throw new Error('账户未初始化');
|
||||
}
|
||||
|
||||
// 调试模式:模拟买入成功
|
||||
if (!user || user.id === 'demo') {
|
||||
logger.debug('useTradingAccount', '调试模式:模拟买入', { stockCode, quantity, orderType });
|
||||
return { success: true, orderId: 'demo_' + Date.now() };
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const requestData = {
|
||||
stock_code: stockCode,
|
||||
order_type: 'BUY',
|
||||
order_qty: quantity,
|
||||
price_type: orderType
|
||||
};
|
||||
|
||||
const response = await apiRequest('/api/simulation/place-order', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
// 刷新账户数据
|
||||
await refreshAccount();
|
||||
|
||||
return { success: true, orderId: response.data.order_no };
|
||||
|
||||
} catch (err) {
|
||||
throw new Error('买入失败: ' + err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [account, user, refreshAccount]);
|
||||
|
||||
// 卖出股票
|
||||
const sellStock = useCallback(async (stockCode, quantity, orderType = 'MARKET', limitPrice = null) => {
|
||||
if (!account) {
|
||||
throw new Error('账户未初始化');
|
||||
}
|
||||
|
||||
// 调试模式:模拟卖出成功
|
||||
if (!user || user.id === 'demo') {
|
||||
logger.debug('useTradingAccount', '调试模式:模拟卖出', { stockCode, quantity, orderType });
|
||||
return { success: true, orderId: 'demo_' + Date.now() };
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const requestData = {
|
||||
stock_code: stockCode,
|
||||
order_type: 'SELL',
|
||||
order_qty: quantity,
|
||||
price_type: orderType
|
||||
};
|
||||
|
||||
const response = await apiRequest('/api/simulation/place-order', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
// 刷新账户数据
|
||||
await refreshAccount();
|
||||
|
||||
return { success: true, orderId: response.data.order_no };
|
||||
|
||||
} catch (err) {
|
||||
throw new Error('卖出失败: ' + err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [account, user, refreshAccount]);
|
||||
|
||||
// 撤销订单
|
||||
const cancelOrder = useCallback(async (orderId) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await apiRequest(`/api/simulation/cancel-order/${orderId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
// 刷新交易历史
|
||||
const ordersResponse = await apiRequest('/api/simulation/orders?limit=100');
|
||||
setTradingHistory(mapOrderData(ordersResponse.data || []));
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
throw new Error('撤单失败: ' + err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 获取交易记录
|
||||
const getTransactions = useCallback(async (options = {}) => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (options.limit) params.append('limit', options.limit);
|
||||
if (options.date) params.append('date', options.date);
|
||||
|
||||
const response = await apiRequest(`/api/simulation/transactions?${params.toString()}`);
|
||||
return response.data || [];
|
||||
} catch (error) {
|
||||
logger.error('useTradingAccount', 'getTransactions', error, options);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 获取资产历史
|
||||
const getAssetHistory = useCallback(async (days = 30) => {
|
||||
// 调试模式:demo用户返回模拟数据,避免CORS
|
||||
if (!user || user.id === 'demo') {
|
||||
const now = Date.now();
|
||||
const data = Array.from({ length: days }, (_, i) => {
|
||||
const date = new Date(now - (days - 1 - i) * 24 * 3600 * 1000);
|
||||
// 简单生成一条平滑的收益曲线
|
||||
const value = Math.sin(i / 5) * 0.01 + 0.001 * i;
|
||||
return { date: date.toISOString().slice(0, 10), value };
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiRequest(`/api/simulation/statistics?days=${days}`);
|
||||
return response.data?.daily_returns || [];
|
||||
} catch (error) {
|
||||
logger.error('useTradingAccount', 'getAssetHistory', error, { days, userId: user?.id });
|
||||
return [];
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// 获取股票实时行情(如果需要的话)
|
||||
const getStockQuotes = useCallback(async (stockCodes) => {
|
||||
try {
|
||||
// 这里可以调用自选股实时行情接口
|
||||
const response = await apiRequest('/api/account/watchlist/quotes');
|
||||
if (response.success) {
|
||||
const quotes = {};
|
||||
response.data.forEach(item => {
|
||||
quotes[item.stock_code] = {
|
||||
name: item.stock_name,
|
||||
price: item.current_price,
|
||||
change: item.change,
|
||||
changePercent: item.change_percent
|
||||
};
|
||||
});
|
||||
setStockQuotes(quotes);
|
||||
return quotes;
|
||||
}
|
||||
return {};
|
||||
} catch (error) {
|
||||
logger.error('useTradingAccount', 'getStockQuotes', error, { stockCodes });
|
||||
return {};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
account,
|
||||
positions,
|
||||
tradingHistory,
|
||||
isLoading,
|
||||
error,
|
||||
stockQuotes,
|
||||
buyStock,
|
||||
sellStock,
|
||||
cancelOrder,
|
||||
refreshAccount,
|
||||
searchStocks,
|
||||
getTransactions,
|
||||
getAssetHistory,
|
||||
getStockQuotes
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user