feat: 添加合规

This commit is contained in:
zdl
2025-10-20 21:25:33 +08:00
parent e1fe974262
commit 300c0a18a6
42 changed files with 7118 additions and 289 deletions

View File

@@ -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": []

View File

@@ -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 环境标识

View File

@@ -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={{

View File

@@ -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>
);
};

View File

@@ -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} />}
</>
);
}

View 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,
};

View File

@@ -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

View File

@@ -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'
}));
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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: '关注成功'
}
});
}),
];

View File

@@ -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 请求处理

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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
View 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;
};

View File

@@ -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;

View File

@@ -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);

View File

@@ -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'}>

View File

@@ -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' },

View 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;

View File

@@ -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>
{/* 相关股票模态框 */}

View File

@@ -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) {

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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>
);
};

View File

@@ -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',

View File

@@ -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',

View 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>
);
}

View File

@@ -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',

File diff suppressed because it is too large Load Diff

View File

@@ -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',

View 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>
);
}

View File

@@ -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>

View File

@@ -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端口确保和后端一致
// 涨停分析服务

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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 || '获取二维码失败');

File diff suppressed because it is too large Load Diff

View File

@@ -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 = {}) => {

View 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
};
}