Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_bugfix/251201_py_h5_ui
* feature_bugfix/251201_vf_h5_ui: fix: 事件详情唔错页面UI调整 fix: 调整事件详情页面 feat: 事件详情页 URL ID 加密,防止用户遍历 style: 首页整体尺寸缩小约 67% fix: 调整客服弹窗 将 PC 端聊天窗口从 380×640 调整为 450×750。 H5 端:宽度占满,高度根据宽度等比缩放 fix: ICP 备案号现在可以点击跳转到 https://beian.miit.gov.cn/ feat: 田间mock数据 feat: 个股中心复用 TradeDatePicker 日期选择器组件 feat: 概念中心历史时间轴弹窗UI调整 feat: 提取日历选择器组件 refactor: 提取 ConceptStocksModal 为通用组件,统一概念中心和个股中心弹窗 refactor: 事件详情弹窗改用 Drawer 组件从底部弹出 fix: 在 viewport meta 标签中添加了 viewport-fit=cover,这样浏览器会将页面内容延伸到曲面屏边缘,同时启用 safe-area-inset-* CSS 环境变量 在普通设备上保持至少 16px 的右侧内边距 在华为 MATE70 PRO 等曲面屏设备上,使用系统提供的安全区域值,避免右侧导航被遮挡 fix: 概念中心H5端卡片尺寸优化,一屏可显示更多内容 fix: 修复自选股添加失败 405 错误 fix: H5端热门事件移除Tooltip避免黑色悬浮框无法消除
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
|
||||
@@ -75,9 +75,11 @@ const BytedeskWidget = ({
|
||||
const rightVal = parseInt(style.right);
|
||||
const bottomVal = parseInt(style.bottom);
|
||||
if (rightVal >= 0 && rightVal < 100 && bottomVal >= 0 && bottomVal < 100) {
|
||||
// H5 端设置按钮尺寸为 48x48(只执行一次)
|
||||
// H5 端设置按钮尺寸为 48x48 并降低 z-index(只执行一次)
|
||||
if (isMobile && !el.dataset.bytedeskStyled) {
|
||||
el.dataset.bytedeskStyled = 'true';
|
||||
// 降低 z-index,避免遮挡页面内的发布按钮等交互元素
|
||||
el.style.zIndex = 10;
|
||||
const button = el.querySelector('button');
|
||||
if (button) {
|
||||
button.style.width = '48px';
|
||||
|
||||
@@ -55,10 +55,17 @@ export const bytedeskConfig = {
|
||||
t: '1', // 类型: 1=人工客服, 2=机器人
|
||||
sid: 'df_wg_uid', // 工作组ID
|
||||
},
|
||||
|
||||
window: window.innerWidth <= 768 ? {
|
||||
width: window.innerWidth - 1,
|
||||
height: Math.min(window.innerWidth * 640/380, window.innerHeight - 200)
|
||||
} : { width: 380, height: 640 }
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取Bytedesk配置(根据环境自动切换)
|
||||
* - H5 端:宽度占满,高度根据宽度等比缩放
|
||||
* - PC 端:固定宽高 380x640
|
||||
*
|
||||
* @returns {Object} Bytedesk配置对象
|
||||
*/
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Button,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
@@ -22,6 +20,7 @@ import {
|
||||
Icon,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
useBreakpointValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaTable } from 'react-icons/fa';
|
||||
import marketService from '@services/marketService';
|
||||
@@ -31,6 +30,8 @@ import { logger } from '@utils/logger';
|
||||
interface StockInfo {
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
reason?: string;
|
||||
change_pct?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -72,6 +73,12 @@ const ConceptStocksModal: React.FC<ConceptStocksModalProps> = ({
|
||||
const cardBg = useColorModeValue('white', '#1a1a1a');
|
||||
const hoverBg = useColorModeValue('gray.50', '#2a2a2a');
|
||||
|
||||
// 响应式配置 - 添加 fallback 避免首次渲染时返回 undefined 导致弹窗异常
|
||||
const isMobile = useBreakpointValue({ base: true, md: false }, { fallback: 'md' });
|
||||
// H5 使用 xl 而非 full,配合 maxH 限制高度
|
||||
const modalSize = useBreakpointValue({ base: 'xl', md: '4xl' }, { fallback: 'md' });
|
||||
const tableMaxH = useBreakpointValue({ base: '45vh', md: '60vh' }, { fallback: 'md' });
|
||||
|
||||
// 批量获取股票行情数据
|
||||
const fetchStockMarketData = useCallback(async (stocks: StockInfo[]) => {
|
||||
if (!stocks || stocks.length === 0) return;
|
||||
@@ -131,11 +138,12 @@ const ConceptStocksModal: React.FC<ConceptStocksModalProps> = ({
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="4xl"
|
||||
size={modalSize}
|
||||
scrollBehavior="inside"
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent bg={cardBg}>
|
||||
<ModalContent bg={cardBg} maxH={isMobile ? '70vh' : undefined}>
|
||||
<ModalHeader bg="purple.500" color="white" borderTopRadius="md">
|
||||
<HStack>
|
||||
<Icon as={FaTable} />
|
||||
@@ -156,14 +164,15 @@ const ConceptStocksModal: React.FC<ConceptStocksModalProps> = ({
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
<TableContainer maxH="60vh" overflowY="auto">
|
||||
<Table variant="simple" size="sm">
|
||||
<TableContainer maxH={tableMaxH} overflowY="auto" overflowX="auto">
|
||||
<Table variant="simple" size="sm" minW={isMobile ? '600px' : undefined}>
|
||||
<Thead position="sticky" top={0} bg={cardBg} zIndex={1}>
|
||||
<Tr>
|
||||
<Th>股票名称</Th>
|
||||
<Th>股票代码</Th>
|
||||
<Th isNumeric>现价</Th>
|
||||
<Th isNumeric>涨跌幅</Th>
|
||||
<Th whiteSpace="nowrap">股票名称</Th>
|
||||
<Th whiteSpace="nowrap">股票代码</Th>
|
||||
<Th isNumeric whiteSpace="nowrap">现价</Th>
|
||||
<Th isNumeric whiteSpace="nowrap">当日涨跌幅</Th>
|
||||
<Th whiteSpace="nowrap" minW="200px">板块原因</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
@@ -210,6 +219,9 @@ const ConceptStocksModal: React.FC<ConceptStocksModalProps> = ({
|
||||
'-'
|
||||
)}
|
||||
</Td>
|
||||
<Td fontSize="xs" color="gray.600" maxW="300px">
|
||||
<Text noOfLines={2}>{stock.reason || '-'}</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
@@ -219,12 +231,6 @@ const ConceptStocksModal: React.FC<ConceptStocksModalProps> = ({
|
||||
</Box>
|
||||
)}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button colorScheme="purple" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
138
src/components/ErrorPage/index.tsx
Normal file
138
src/components/ErrorPage/index.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* ErrorPage - 通用错误页面组件
|
||||
* 用于显示加载失败、网络错误等异常状态
|
||||
* 设计风格:黑色背景 + 金色边框
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Center,
|
||||
Circle,
|
||||
Text,
|
||||
Button,
|
||||
VStack,
|
||||
HStack,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { WarningIcon } from '@chakra-ui/icons';
|
||||
|
||||
// 主题色
|
||||
const GOLD_COLOR = '#D4A574';
|
||||
const BG_COLOR = '#1A202C'; // 与页面背景一致
|
||||
|
||||
interface ErrorPageProps {
|
||||
/** 错误标题,默认"加载失败" */
|
||||
title?: string;
|
||||
/** 错误描述信息 */
|
||||
description?: string;
|
||||
/** 详细信息(如事件ID、订单号等) */
|
||||
detail?: string;
|
||||
/** 详细信息标签,默认"ID" */
|
||||
detailLabel?: string;
|
||||
/** 是否显示重试按钮 */
|
||||
showRetry?: boolean;
|
||||
/** 重试回调函数 */
|
||||
onRetry?: () => void;
|
||||
/** 是否显示返回按钮 */
|
||||
showBack?: boolean;
|
||||
/** 返回回调函数 */
|
||||
onBack?: () => void;
|
||||
/** 是否全屏显示,默认 true */
|
||||
fullScreen?: boolean;
|
||||
}
|
||||
|
||||
const ErrorPage: React.FC<ErrorPageProps> = ({
|
||||
title = '加载失败',
|
||||
description,
|
||||
detail,
|
||||
detailLabel = 'ID',
|
||||
showRetry = false,
|
||||
onRetry,
|
||||
showBack = false,
|
||||
onBack,
|
||||
fullScreen = true,
|
||||
}) => {
|
||||
const hasButtons = (showRetry && onRetry) || (showBack && onBack);
|
||||
|
||||
return (
|
||||
<Box
|
||||
h={fullScreen ? '100vh' : '60vh'}
|
||||
w="100%"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Box
|
||||
bg={BG_COLOR}
|
||||
border="1px solid"
|
||||
borderColor={GOLD_COLOR}
|
||||
borderRadius="lg"
|
||||
p={8}
|
||||
maxW="400px"
|
||||
w="90%"
|
||||
textAlign="center"
|
||||
>
|
||||
{/* 金色圆形图标 + 黑色感叹号 */}
|
||||
<Circle size="50px" bg={GOLD_COLOR} mx="auto" mb={4}>
|
||||
<Icon as={WarningIcon} color={BG_COLOR} boxSize={5} />
|
||||
</Circle>
|
||||
|
||||
{/* 金色标题 */}
|
||||
<Text color={GOLD_COLOR} fontSize="lg" fontWeight="medium" mb={2}>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{/* 描述信息 */}
|
||||
{description && (
|
||||
<Text color="gray.400" fontSize="sm" mb={2}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 详情 */}
|
||||
{detail && (
|
||||
<Text color="gray.500" fontSize="sm" mb={4}>
|
||||
{detailLabel}: {detail}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 按钮组 */}
|
||||
{hasButtons && (
|
||||
<HStack justify="center" spacing={3} mt={4}>
|
||||
{showBack && onBack && (
|
||||
<Button
|
||||
variant="outline"
|
||||
borderColor={GOLD_COLOR}
|
||||
color={GOLD_COLOR}
|
||||
size="sm"
|
||||
px={6}
|
||||
_hover={{ bg: GOLD_COLOR, color: 'black' }}
|
||||
onClick={onBack}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
)}
|
||||
{showRetry && onRetry && (
|
||||
<Button
|
||||
bg={GOLD_COLOR}
|
||||
color={BG_COLOR}
|
||||
borderColor={GOLD_COLOR}
|
||||
border="1px solid"
|
||||
size="sm"
|
||||
px={6}
|
||||
fontWeight="medium"
|
||||
_hover={{ bg: '#C49A6C' }}
|
||||
onClick={onRetry}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorPage;
|
||||
@@ -161,7 +161,7 @@ export default function HomeNavbar() {
|
||||
borderColor={navbarBorder}
|
||||
py={{ base: 2, md: 3 }}
|
||||
>
|
||||
<Container maxW="container.xl" px={{ base: 3, md: 4 }}>
|
||||
<Container maxW="container.xl" px={{ base: 3, md: 4 }} style={{ paddingRight: 'max(16px, env(safe-area-inset-right))' }}>
|
||||
<Flex justify="space-between" align="center">
|
||||
{/* Logo - 价小前投研 */}
|
||||
<BrandLogo />
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ChevronDownIcon } from '@chakra-ui/icons';
|
||||
import { FiCalendar } from 'react-icons/fi';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useFollowingEvents } from '../../../../hooks/useFollowingEvents';
|
||||
import { getEventDetailUrl } from '@/utils/idEncoder';
|
||||
|
||||
/**
|
||||
* 关注事件下拉菜单组件
|
||||
@@ -86,7 +87,7 @@ const FollowingEventsMenu = memo(() => {
|
||||
<MenuItem
|
||||
key={ev.id}
|
||||
_hover={{ bg: 'gray.50' }}
|
||||
onClick={() => navigate(`/event-detail/${ev.id}`)}
|
||||
onClick={() => navigate(getEventDetailUrl(ev.id))}
|
||||
>
|
||||
<HStack justify="space-between" w="100%">
|
||||
<Box flex={1} minW={0}>
|
||||
|
||||
135
src/components/TradeDatePicker/index.tsx
Normal file
135
src/components/TradeDatePicker/index.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
HStack,
|
||||
Input,
|
||||
Text,
|
||||
Icon,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { InfoIcon } from '@chakra-ui/icons';
|
||||
import { FaCalendarAlt } from 'react-icons/fa';
|
||||
|
||||
export interface TradeDatePickerProps {
|
||||
/** 当前选中的日期 */
|
||||
value: Date | null;
|
||||
/** 日期变化回调 */
|
||||
onChange: (date: Date) => void;
|
||||
/** 默认日期(组件初始化时使用) */
|
||||
defaultDate?: Date;
|
||||
/** 最新交易日期(用于显示提示) */
|
||||
latestTradeDate?: Date | null;
|
||||
/** 最小可选日期 */
|
||||
minDate?: Date;
|
||||
/** 最大可选日期,默认今天 */
|
||||
maxDate?: Date;
|
||||
/** 标签文字,默认"交易日期" */
|
||||
label?: string;
|
||||
/** 输入框宽度 */
|
||||
inputWidth?: string | object;
|
||||
/** 是否显示标签图标 */
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 交易日期选择器组件
|
||||
*
|
||||
* 提供日期输入框和最新交易日期提示,供概念中心、个股中心等页面复用。
|
||||
* 快捷按钮(今天、昨天等)由各页面自行实现。
|
||||
*/
|
||||
const TradeDatePicker: React.FC<TradeDatePickerProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
defaultDate,
|
||||
latestTradeDate,
|
||||
minDate,
|
||||
maxDate,
|
||||
label = '交易日期',
|
||||
inputWidth = { base: '100%', lg: '200px' },
|
||||
showIcon = true,
|
||||
}) => {
|
||||
// 颜色主题
|
||||
const labelColor = useColorModeValue('purple.700', 'purple.300');
|
||||
const iconColor = useColorModeValue('purple.500', 'purple.400');
|
||||
const inputBorderColor = useColorModeValue('purple.200', 'purple.600');
|
||||
const tipBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const tipBorderColor = useColorModeValue('blue.200', 'blue.600');
|
||||
const tipTextColor = useColorModeValue('blue.600', 'blue.200');
|
||||
const tipIconColor = useColorModeValue('blue.500', 'blue.300');
|
||||
|
||||
// 使用默认日期初始化(仅在 value 为 null 且有 defaultDate 时)
|
||||
React.useEffect(() => {
|
||||
if (value === null && defaultDate) {
|
||||
onChange(defaultDate);
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 处理日期变化
|
||||
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const dateStr = e.target.value;
|
||||
if (dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
onChange(date);
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化日期为 YYYY-MM-DD
|
||||
const formatDateValue = (date: Date | null): string => {
|
||||
if (!date) return '';
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
// 计算日期范围
|
||||
const minDateStr = minDate ? formatDateValue(minDate) : undefined;
|
||||
const maxDateStr = maxDate
|
||||
? formatDateValue(maxDate)
|
||||
: new Date().toISOString().split('T')[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 标签 */}
|
||||
<HStack spacing={3}>
|
||||
{showIcon && <Icon as={FaCalendarAlt} color={iconColor} boxSize={5} />}
|
||||
<Text fontWeight="bold" color={labelColor}>
|
||||
{label}:
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 日期输入框 */}
|
||||
<Input
|
||||
type="date"
|
||||
value={formatDateValue(value)}
|
||||
onChange={handleDateChange}
|
||||
min={minDateStr}
|
||||
max={maxDateStr}
|
||||
width={inputWidth}
|
||||
focusBorderColor="purple.500"
|
||||
borderColor={inputBorderColor}
|
||||
borderRadius="lg"
|
||||
fontWeight="medium"
|
||||
/>
|
||||
|
||||
{/* 最新交易日期提示 */}
|
||||
{latestTradeDate && (
|
||||
<Tooltip label="数据库中最新的交易日期">
|
||||
<HStack
|
||||
spacing={2}
|
||||
bg={tipBg}
|
||||
px={3}
|
||||
py={1.5}
|
||||
borderRadius="full"
|
||||
border="1px solid"
|
||||
borderColor={tipBorderColor}
|
||||
>
|
||||
<Icon as={InfoIcon} color={tipIconColor} boxSize={3} />
|
||||
<Text fontSize="sm" color={tipTextColor} fontWeight="medium">
|
||||
最新: {latestTradeDate.toLocaleDateString('zh-CN')}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeDatePicker;
|
||||
@@ -19,6 +19,7 @@ import { notificationMetricsService } from '../services/notificationMetricsServi
|
||||
import { notificationHistoryService } from '../services/notificationHistoryService';
|
||||
import { PRIORITY_LEVELS, NOTIFICATION_CONFIG, NOTIFICATION_TYPES } from '../constants/notificationTypes';
|
||||
import { usePermissionGuide, GUIDE_TYPES } from '../hooks/usePermissionGuide';
|
||||
import { getEventDetailUrl } from '@/utils/idEncoder';
|
||||
|
||||
// 连接状态枚举
|
||||
const CONNECTION_STATUS = {
|
||||
@@ -460,7 +461,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
timestamp: Date.now(),
|
||||
isAIGenerated: event.is_ai_generated || false,
|
||||
clickable: true,
|
||||
link: `/event-detail/${event.id}`,
|
||||
link: getEventDetailUrl(event.id),
|
||||
autoClose: autoClose,
|
||||
extra: {
|
||||
eventId: event.id,
|
||||
|
||||
@@ -18,21 +18,21 @@ export const useHomeResponsive = (): ResponsiveConfig => {
|
||||
});
|
||||
|
||||
const headingSize = useBreakpointValue({
|
||||
base: 'xl',
|
||||
md: '3xl',
|
||||
lg: '4xl'
|
||||
base: 'lg',
|
||||
md: 'xl',
|
||||
lg: '2xl'
|
||||
});
|
||||
|
||||
const headingLetterSpacing = useBreakpointValue({
|
||||
base: '-1px',
|
||||
md: '-1.5px',
|
||||
lg: '-2px'
|
||||
base: '-0.5px',
|
||||
md: '-1px',
|
||||
lg: '-1.5px'
|
||||
});
|
||||
|
||||
const heroTextSize = useBreakpointValue({
|
||||
base: 'md',
|
||||
md: 'lg',
|
||||
lg: 'xl'
|
||||
base: 'xs',
|
||||
md: 'sm',
|
||||
lg: 'md'
|
||||
});
|
||||
|
||||
const containerPx = useBreakpointValue({
|
||||
|
||||
@@ -118,7 +118,7 @@ export const useWatchlist = () => {
|
||||
const handleAddToWatchlist = useCallback(async (stockCode, stockName) => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const resp = await fetch(base + '/api/account/watchlist/add', {
|
||||
const resp = await fetch(base + '/api/account/watchlist', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -23,7 +23,13 @@ const AppFooter = () => {
|
||||
>
|
||||
京公网安备11010802046286号
|
||||
</Link>
|
||||
<Text>京ICP备2025107343号-1</Text>
|
||||
<Link
|
||||
href="https://beian.miit.gov.cn/"
|
||||
isExternal
|
||||
_hover={{ color: 'gray.600' }}
|
||||
>
|
||||
京ICP备2025107343号-1
|
||||
</Link>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Container>
|
||||
|
||||
@@ -30,12 +30,12 @@ const MemoizedAppFooter = memo(AppFooter);
|
||||
*/
|
||||
export default function MainLayout() {
|
||||
return (
|
||||
<Box flex="1" h="100vh" w="100%" position="relative" display="flex" flexDirection="column">
|
||||
<Box flex="1" h="100vh" w="100%" position="relative" display="flex" flexDirection="column" bg="#1A202C">
|
||||
{/* 导航栏 - 在所有页面间共享,memo 后不会在路由切换时重新渲染 */}
|
||||
<MemoizedHomeNavbar />
|
||||
|
||||
{/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */}
|
||||
<Box flex="1" pt="60px">
|
||||
<Box flex="1" pt="60px" bg="#1A202C">
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<PageLoader message="页面加载中..." />}>
|
||||
<Outlet />
|
||||
|
||||
@@ -159,7 +159,7 @@ export const accountHandlers = [
|
||||
}),
|
||||
|
||||
// 6. 添加自选股
|
||||
http.post('/api/account/watchlist/add', async ({ request }) => {
|
||||
http.post('/api/account/watchlist', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
@@ -255,6 +255,48 @@ export const eventHandlers = [
|
||||
|
||||
// ==================== 事件详情相关 ====================
|
||||
|
||||
// 获取事件详情
|
||||
http.get('/api/events/:eventId', async ({ params }) => {
|
||||
await delay(200);
|
||||
|
||||
const { eventId } = params;
|
||||
|
||||
console.log('[Mock] 获取事件详情, eventId:', eventId);
|
||||
|
||||
try {
|
||||
// 返回模拟的事件详情数据
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: parseInt(eventId),
|
||||
title: `测试事件 ${eventId} - 重大政策发布`,
|
||||
description: '这是一个模拟的事件描述,用于开发测试。该事件涉及重要政策变化,可能对相关板块产生显著影响。建议关注后续发展动态。',
|
||||
importance: ['S', 'A', 'B', 'C'][Math.floor(Math.random() * 4)],
|
||||
created_at: new Date().toISOString(),
|
||||
trading_date: new Date().toISOString().split('T')[0],
|
||||
event_type: ['政策', '财报', '行业', '宏观'][Math.floor(Math.random() * 4)],
|
||||
related_avg_chg: parseFloat((Math.random() * 10 - 5).toFixed(2)),
|
||||
follower_count: Math.floor(Math.random() * 500) + 50,
|
||||
view_count: Math.floor(Math.random() * 5000) + 100,
|
||||
is_following: false,
|
||||
post_count: Math.floor(Math.random() * 50),
|
||||
expectation_surprise_score: parseFloat((Math.random() * 100).toFixed(1)),
|
||||
},
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] 获取事件详情失败:', error);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '获取事件详情失败',
|
||||
data: null
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取事件相关股票
|
||||
http.get('/api/events/:eventId/stocks', async ({ params }) => {
|
||||
await delay(300);
|
||||
|
||||
@@ -123,6 +123,45 @@ const generateStockList = () => {
|
||||
|
||||
// 股票相关的 Handlers
|
||||
export const stockHandlers = [
|
||||
// 搜索股票(个股中心页面使用)
|
||||
http.get('/api/stocks/search', async ({ request }) => {
|
||||
await delay(200);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const query = url.searchParams.get('q') || '';
|
||||
const limit = parseInt(url.searchParams.get('limit') || '10');
|
||||
|
||||
console.log('[Mock Stock] 搜索股票:', { query, limit });
|
||||
|
||||
const stocks = generateStockList();
|
||||
|
||||
// 如果没有搜索词,返回空结果
|
||||
if (!query.trim()) {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: []
|
||||
});
|
||||
}
|
||||
|
||||
// 过滤匹配的股票
|
||||
const results = stocks.filter(s =>
|
||||
s.code.includes(query) || s.name.includes(query)
|
||||
).slice(0, limit);
|
||||
|
||||
// 返回格式化数据
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: results.map(s => ({
|
||||
stock_code: s.code,
|
||||
stock_name: s.name,
|
||||
market: s.code.startsWith('6') ? 'SH' : 'SZ',
|
||||
industry: ['银行', '证券', '保险', '白酒', '医药', '科技', '新能源', '汽车', '地产', '家电'][Math.floor(Math.random() * 10)],
|
||||
change_pct: parseFloat((Math.random() * 10 - 3).toFixed(2)),
|
||||
price: parseFloat((Math.random() * 100 + 5).toFixed(2))
|
||||
}))
|
||||
});
|
||||
}),
|
||||
|
||||
// 获取所有股票列表
|
||||
http.get('/api/stocklist', async () => {
|
||||
await delay(200);
|
||||
@@ -279,4 +318,74 @@ export const stockHandlers = [
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取股票报价(批量)
|
||||
http.post('/api/stock/quotes', async ({ request }) => {
|
||||
await delay(200);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { codes, event_time } = body;
|
||||
|
||||
console.log('[Mock Stock] 获取股票报价:', {
|
||||
stockCount: codes?.length,
|
||||
eventTime: event_time
|
||||
});
|
||||
|
||||
if (!codes || !Array.isArray(codes) || codes.length === 0) {
|
||||
return HttpResponse.json(
|
||||
{ success: false, error: '股票代码列表不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 生成股票列表用于查找名称
|
||||
const stockList = generateStockList();
|
||||
const stockMap = {};
|
||||
stockList.forEach(s => {
|
||||
stockMap[s.code] = s.name;
|
||||
});
|
||||
|
||||
// 为每只股票生成报价数据
|
||||
const quotesData = {};
|
||||
codes.forEach(stockCode => {
|
||||
// 生成基础价格(10-200之间)
|
||||
const basePrice = parseFloat((Math.random() * 190 + 10).toFixed(2));
|
||||
// 涨跌幅(-10% 到 +10%)
|
||||
const changePercent = parseFloat((Math.random() * 20 - 10).toFixed(2));
|
||||
// 涨跌额
|
||||
const change = parseFloat((basePrice * changePercent / 100).toFixed(2));
|
||||
// 昨收
|
||||
const prevClose = parseFloat((basePrice - change).toFixed(2));
|
||||
|
||||
quotesData[stockCode] = {
|
||||
code: stockCode,
|
||||
name: stockMap[stockCode] || `股票${stockCode}`,
|
||||
price: basePrice,
|
||||
change: change,
|
||||
change_percent: changePercent,
|
||||
prev_close: prevClose,
|
||||
open: parseFloat((prevClose * (1 + (Math.random() * 0.02 - 0.01))).toFixed(2)),
|
||||
high: parseFloat((basePrice * (1 + Math.random() * 0.05)).toFixed(2)),
|
||||
low: parseFloat((basePrice * (1 - Math.random() * 0.05)).toFixed(2)),
|
||||
volume: Math.floor(Math.random() * 100000000),
|
||||
amount: parseFloat((Math.random() * 10000000000).toFixed(2)),
|
||||
market: stockCode.startsWith('6') ? 'SH' : 'SZ',
|
||||
update_time: new Date().toISOString()
|
||||
};
|
||||
});
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: quotesData,
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock Stock] 获取股票报价失败:', error);
|
||||
return HttpResponse.json(
|
||||
{ success: false, error: '获取股票报价失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -98,7 +98,7 @@ export const routeConfig = [
|
||||
|
||||
// ==================== 事件模块 ====================
|
||||
{
|
||||
path: 'event-detail/:eventId',
|
||||
path: 'event-detail',
|
||||
component: lazyComponents.EventDetail,
|
||||
protection: PROTECTION_MODES.REDIRECT,
|
||||
layout: 'main',
|
||||
|
||||
@@ -21,12 +21,9 @@
|
||||
iframe[src*="bytedesk"],
|
||||
iframe[src*="/chat/"],
|
||||
iframe[src*="/visitor/"] {
|
||||
position: fixed !important;
|
||||
z-index: 999999 !important;
|
||||
max-height: 80vh !important; /* 限制最大高度为视口的80% */
|
||||
max-width: 40vh !important; /* 限制最大高度为视口的80% */
|
||||
bottom: 10px !important; /* 确保底部有足够空间 */
|
||||
right: 10px !important; /* 右侧边距 */
|
||||
width: 100% !important; /* 填满外层容器 */
|
||||
height: 100% !important; /* 填满外层容器 */
|
||||
}
|
||||
|
||||
/* Bytedesk 覆盖层(如果存在) */
|
||||
|
||||
@@ -27,6 +27,18 @@ import { MainPanelComponent } from "./additions/layout/MainPanel";
|
||||
import { PanelContentComponent } from "./additions/layout/PanelContent";
|
||||
import { PanelContainerComponent } from "./additions/layout/PanelContainer";
|
||||
// import { mode } from "@chakra-ui/theme-tools";
|
||||
|
||||
// Container 组件样式覆盖 - 移除默认背景色
|
||||
const ContainerComponent = {
|
||||
components: {
|
||||
Container: {
|
||||
baseStyle: {
|
||||
bg: "1A202C",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default extendTheme(
|
||||
{ breakpoints }, // Breakpoints
|
||||
globalStyles,
|
||||
@@ -37,5 +49,6 @@ export default extendTheme(
|
||||
CardComponent, // Card component
|
||||
MainPanelComponent, // Main Panel component
|
||||
PanelContentComponent, // Panel Content component
|
||||
PanelContainerComponent // Panel Container component
|
||||
PanelContainerComponent, // Panel Container component
|
||||
ContainerComponent // Container 背景透明
|
||||
);
|
||||
|
||||
55
src/utils/idEncoder.ts
Normal file
55
src/utils/idEncoder.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* ID 加密/解密工具
|
||||
* 用于隐藏 URL 中的真实 ID,防止用户猜测遍历
|
||||
*
|
||||
* 使用 Base64 编码 + 前缀混淆
|
||||
* 例如: 15901 -> "ZXYtMTU5MDE"
|
||||
*/
|
||||
|
||||
const SECRET_PREFIX = 'ev-';
|
||||
|
||||
/**
|
||||
* 加密事件 ID
|
||||
* @param id - 原始 ID
|
||||
* @returns 加密后的字符串
|
||||
*/
|
||||
export const encodeEventId = (id: number | string): string => {
|
||||
if (id === null || id === undefined) return '';
|
||||
return btoa(SECRET_PREFIX + String(id));
|
||||
};
|
||||
|
||||
/**
|
||||
* 解密事件 ID
|
||||
* @param encoded - 加密后的字符串
|
||||
* @returns 原始 ID,解密失败返回 null
|
||||
*/
|
||||
export const decodeEventId = (encoded: string): string | null => {
|
||||
if (!encoded) return null;
|
||||
|
||||
try {
|
||||
const decoded = atob(encoded);
|
||||
if (decoded.startsWith(SECRET_PREFIX)) {
|
||||
return decoded.slice(SECRET_PREFIX.length);
|
||||
}
|
||||
// 兼容:如果是纯数字(旧链接),直接返回
|
||||
if (/^\d+$/.test(encoded)) {
|
||||
return encoded;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
// Base64 解码失败,可能是旧的纯数字链接
|
||||
if (/^\d+$/.test(encoded)) {
|
||||
return encoded;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成事件详情页 URL
|
||||
* @param eventId - 事件 ID
|
||||
* @returns 完整路径
|
||||
*/
|
||||
export const getEventDetailUrl = (eventId: number | string): string => {
|
||||
return `/event-detail?id=${encodeEventId(eventId)}`;
|
||||
};
|
||||
@@ -1,36 +1,8 @@
|
||||
.event-detail-modal {
|
||||
top: 20% !important;
|
||||
margin: 0 auto !important;
|
||||
padding-bottom: 0 !important;
|
||||
|
||||
.ant-modal-content {
|
||||
border-radius: 24px !important;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// 标题样式 - 深色文字(白色背景)
|
||||
.ant-modal-title {
|
||||
// 事件详情抽屉样式(从底部弹出)
|
||||
// 注意:大部分样式已在 TSX 的 styles 属性中配置,这里只保留必要的覆盖
|
||||
.event-detail-drawer {
|
||||
// 标题样式
|
||||
.ant-drawer-title {
|
||||
color: #1A202C;
|
||||
}
|
||||
|
||||
// 关闭按钮样式 - 深色(白色背景)
|
||||
.ant-modal-close {
|
||||
color: #4A5568;
|
||||
|
||||
&:hover {
|
||||
color: #1A202C;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 自底向上滑入动画
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Modal } from 'antd';
|
||||
import { Drawer } from 'antd';
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import { selectIsMobile } from '@store/slices/deviceSlice';
|
||||
import DynamicNewsDetailPanel from './DynamicNewsDetail/DynamicNewsDetailPanel';
|
||||
import './EventDetailModal.less';
|
||||
@@ -15,7 +16,7 @@ interface EventDetailModalProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件详情弹窗组件
|
||||
* 事件详情抽屉组件(从底部弹出)
|
||||
*/
|
||||
const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
||||
open,
|
||||
@@ -25,23 +26,35 @@ const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
||||
const isMobile = useSelector(selectIsMobile);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<Drawer
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
onClose={onClose}
|
||||
placement="bottom"
|
||||
height={isMobile ? 'calc(100vh - 60px)' : 'calc(100vh - 100px)'}
|
||||
width={isMobile ? '100%' : '70vw'}
|
||||
title={event?.title || '事件详情'}
|
||||
width='100vw'
|
||||
destroyOnClose
|
||||
className="event-detail-modal"
|
||||
destroyOnHidden
|
||||
rootClassName="event-detail-drawer"
|
||||
closeIcon={null}
|
||||
extra={
|
||||
<CloseOutlined
|
||||
onClick={onClose}
|
||||
style={{ cursor: 'pointer', fontSize: 16, color: '#4A5568' }}
|
||||
/>
|
||||
}
|
||||
styles={{
|
||||
mask: { background: 'transparent' },
|
||||
content: { borderRadius: 24, padding: 0, maxWidth: 1400, background: 'transparent', margin: '0 auto', maxHeight: '80vh', display: 'flex', flexDirection: 'column' },
|
||||
header: { background: '#FFFFFF', borderBottom: '1px solid #E2E8F0', padding: '16px 24px', borderRadius: '24px 24px 0 0', margin: 0, flexShrink: 0 },
|
||||
body: { padding: 0, overflowY: 'auto', flex: 1 },
|
||||
wrapper: isMobile ? {} : {
|
||||
maxWidth: 1400,
|
||||
margin: '0 auto',
|
||||
borderRadius: '16px 16px 0 0',
|
||||
},
|
||||
content: { borderRadius: '16px 16px 0 0' },
|
||||
header: { background: '#FFFFFF', borderBottom: '1px solid #E2E8F0', padding: '16px 24px' },
|
||||
body: { padding: 0, background: '#FFFFFF' },
|
||||
}}
|
||||
>
|
||||
{event && <DynamicNewsDetailPanel event={event} showHeader={false} />}
|
||||
</Modal>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Badge, Tag, Empty, Carousel, Tooltip } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { useDisclosure } from '@chakra-ui/react';
|
||||
import { useDisclosure, useBreakpointValue } from '@chakra-ui/react';
|
||||
import EventDetailModal from './EventDetailModal';
|
||||
import dayjs from 'dayjs';
|
||||
import './HotEvents.css';
|
||||
@@ -31,6 +31,8 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure();
|
||||
const [modalEvent, setModalEvent] = useState(null);
|
||||
// H5 端不显示 Tooltip(避免触摸触发后无法消除的黑色悬浮框)
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
|
||||
const renderPriceChange = (value) => {
|
||||
if (value === null || value === undefined) {
|
||||
@@ -154,21 +156,33 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
|
||||
>
|
||||
{/* Custom layout without Card.Meta */}
|
||||
<div className="event-header">
|
||||
<Tooltip title={event.title}>
|
||||
{isMobile ? (
|
||||
<span className="event-title">
|
||||
{event.title}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title={event.title}>
|
||||
<span className="event-title">
|
||||
{event.title}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="event-tag">
|
||||
{renderPriceChange(event.related_avg_chg)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Tooltip title={event.description}>
|
||||
{isMobile ? (
|
||||
<div className="event-description">
|
||||
{event.description}
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title={event.description}>
|
||||
<div className="event-description">
|
||||
{event.description}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className="event-footer">
|
||||
<span className="creator">{event.creator?.username || 'Anonymous'}</span>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
import { getEventDetailUrl } from '@/utils/idEncoder';
|
||||
|
||||
/**
|
||||
* 事件筛选逻辑 Hook
|
||||
@@ -145,7 +146,7 @@ export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {
|
||||
});
|
||||
|
||||
if (navigate) {
|
||||
navigate(`/event-detail/${eventId}`);
|
||||
navigate(getEventDetailUrl(eventId));
|
||||
}
|
||||
}, [navigate, track]);
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
useDisclosure,
|
||||
SimpleGrid,
|
||||
Tooltip,
|
||||
useBreakpointValue,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
@@ -111,6 +112,9 @@ const ConceptTimelineModal = ({
|
||||
const [selectedNews, setSelectedNews] = useState(null);
|
||||
const [isNewsModalOpen, setIsNewsModalOpen] = useState(false);
|
||||
|
||||
// 响应式配置
|
||||
const isMobile = useBreakpointValue({ base: true, md: false }, { fallback: 'md' });
|
||||
|
||||
// 辅助函数:格式化日期显示(包含年份)
|
||||
const formatDateDisplay = (dateStr) => {
|
||||
const date = new Date(dateStr);
|
||||
@@ -602,37 +606,41 @@ const ConceptTimelineModal = ({
|
||||
onClose={onClose}
|
||||
size="full"
|
||||
scrollBehavior="inside"
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="1400px" m={4}>
|
||||
<ModalContent maxW="1400px" m={{ base: 0, md: 'auto' }} mx="auto">
|
||||
<ModalHeader
|
||||
bgGradient="linear(135deg, purple.600 0%, purple.500 50%, pink.500 100%)"
|
||||
color="white"
|
||||
position="sticky"
|
||||
top={0}
|
||||
zIndex={10}
|
||||
py={6}
|
||||
py={{ base: 3, md: 6 }}
|
||||
px={{ base: 3, md: 6 }}
|
||||
boxShadow="lg"
|
||||
>
|
||||
<HStack spacing={4} flexWrap="wrap">
|
||||
<HStack spacing={{ base: 2, md: 4 }} flexWrap="wrap">
|
||||
<Icon
|
||||
as={FaChartLine}
|
||||
boxSize={6}
|
||||
boxSize={{ base: 4, md: 6 }}
|
||||
filter="drop-shadow(0 2px 4px rgba(0,0,0,0.2))"
|
||||
/>
|
||||
<Text
|
||||
fontSize="xl"
|
||||
fontSize={{ base: 'md', md: 'xl' }}
|
||||
fontWeight="bold"
|
||||
textShadow="0 2px 4px rgba(0,0,0,0.2)"
|
||||
noOfLines={1}
|
||||
maxW={{ base: '120px', md: 'none' }}
|
||||
>
|
||||
{conceptName} - 历史时间轴
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme="yellow"
|
||||
px={3}
|
||||
px={{ base: 2, md: 3 }}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
fontSize={{ base: 'xs', md: 'sm' }}
|
||||
boxShadow="md"
|
||||
>
|
||||
最近100天
|
||||
@@ -640,20 +648,29 @@ const ConceptTimelineModal = ({
|
||||
<Badge
|
||||
bg="whiteAlpha.300"
|
||||
color="white"
|
||||
px={3}
|
||||
px={{ base: 2, md: 3 }}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
fontSize="xs"
|
||||
backdropFilter="blur(10px)"
|
||||
display={{ base: 'none', sm: 'flex' }}
|
||||
>
|
||||
🔥 Max版功能
|
||||
</Badge>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton color="white" />
|
||||
<ModalCloseButton
|
||||
color="white"
|
||||
size="lg"
|
||||
top={{ base: 2, md: 4 }}
|
||||
right={{ base: 2, md: 4 }}
|
||||
_hover={{ bg: 'whiteAlpha.300' }}
|
||||
zIndex={20}
|
||||
/>
|
||||
|
||||
<ModalBody
|
||||
py={6}
|
||||
py={{ base: 2, md: 6 }}
|
||||
px={{ base: 0, md: 6 }}
|
||||
bg="gray.50"
|
||||
css={{
|
||||
'&::-webkit-scrollbar': {
|
||||
@@ -680,103 +697,116 @@ const ConceptTimelineModal = ({
|
||||
</VStack>
|
||||
</Center>
|
||||
) : timelineData.length > 0 ? (
|
||||
<Box position="relative" maxW="1200px" mx="auto" px={4}>
|
||||
{/* 图例说明 */}
|
||||
<Flex justify="center" mb={6} flexWrap="wrap" gap={4}>
|
||||
<Box position="relative" maxW="1200px" mx="auto" px={{ base: 2, md: 4 }}>
|
||||
{/* 图例说明 - H5端保持一行 */}
|
||||
<Flex
|
||||
justify="center"
|
||||
mb={{ base: 3, md: 6 }}
|
||||
flexWrap={{ base: 'nowrap', md: 'wrap' }}
|
||||
gap={{ base: 1, md: 4 }}
|
||||
overflowX={{ base: 'auto', md: 'visible' }}
|
||||
pb={{ base: 2, md: 0 }}
|
||||
css={{
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
scrollbarWidth: 'none',
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
spacing={2}
|
||||
px={4}
|
||||
py={2}
|
||||
spacing={{ base: 1, md: 2 }}
|
||||
px={{ base: 2, md: 4 }}
|
||||
py={{ base: 1, md: 2 }}
|
||||
bg="purple.50"
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor="purple.200"
|
||||
boxShadow="sm"
|
||||
transition="all 0.2s"
|
||||
_hover={{ transform: 'translateY(-2px)', boxShadow: 'md' }}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Box w={3} h={3} bg="#9F7AEA" borderRadius="full" boxShadow="sm" />
|
||||
<Text fontSize="sm" fontWeight="medium" color="gray.700">📰 新闻</Text>
|
||||
<Box w={{ base: 2, md: 3 }} h={{ base: 2, md: 3 }} bg="#9F7AEA" borderRadius="full" />
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="gray.700" whiteSpace="nowrap">📰 新闻</Text>
|
||||
</HStack>
|
||||
<HStack
|
||||
spacing={2}
|
||||
px={4}
|
||||
py={2}
|
||||
spacing={{ base: 1, md: 2 }}
|
||||
px={{ base: 2, md: 4 }}
|
||||
py={{ base: 1, md: 2 }}
|
||||
bg="purple.50"
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor="purple.300"
|
||||
boxShadow="sm"
|
||||
transition="all 0.2s"
|
||||
_hover={{ transform: 'translateY(-2px)', boxShadow: 'md' }}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Box w={3} h={3} bg="#805AD5" borderRadius="full" boxShadow="sm" />
|
||||
<Text fontSize="sm" fontWeight="medium" color="gray.700">📊 研报</Text>
|
||||
<Box w={{ base: 2, md: 3 }} h={{ base: 2, md: 3 }} bg="#805AD5" borderRadius="full" />
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="gray.700" whiteSpace="nowrap">📊 研报</Text>
|
||||
</HStack>
|
||||
<HStack
|
||||
spacing={2}
|
||||
px={4}
|
||||
py={2}
|
||||
spacing={{ base: 1, md: 2 }}
|
||||
px={{ base: 2, md: 4 }}
|
||||
py={{ base: 1, md: 2 }}
|
||||
bg="red.50"
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor="red.200"
|
||||
boxShadow="sm"
|
||||
transition="all 0.2s"
|
||||
_hover={{ transform: 'translateY(-2px)', boxShadow: 'md' }}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Icon as={FaArrowUp} color="red.500" boxSize={3} />
|
||||
<Text fontSize="sm" fontWeight="medium" color="gray.700">上涨</Text>
|
||||
<Icon as={FaArrowUp} color="red.500" boxSize={{ base: 2, md: 3 }} />
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="gray.700" whiteSpace="nowrap">上涨</Text>
|
||||
</HStack>
|
||||
<HStack
|
||||
spacing={2}
|
||||
px={4}
|
||||
py={2}
|
||||
spacing={{ base: 1, md: 2 }}
|
||||
px={{ base: 2, md: 4 }}
|
||||
py={{ base: 1, md: 2 }}
|
||||
bg="green.50"
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor="green.200"
|
||||
boxShadow="sm"
|
||||
transition="all 0.2s"
|
||||
_hover={{ transform: 'translateY(-2px)', boxShadow: 'md' }}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Icon as={FaArrowDown} color="green.500" boxSize={3} />
|
||||
<Text fontSize="sm" fontWeight="medium" color="gray.700">下跌</Text>
|
||||
<Icon as={FaArrowDown} color="green.500" boxSize={{ base: 2, md: 3 }} />
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="gray.700" whiteSpace="nowrap">下跌</Text>
|
||||
</HStack>
|
||||
<HStack
|
||||
spacing={2}
|
||||
px={4}
|
||||
py={2}
|
||||
spacing={{ base: 1, md: 2 }}
|
||||
px={{ base: 2, md: 4 }}
|
||||
py={{ base: 1, md: 2 }}
|
||||
bg="orange.50"
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor="orange.200"
|
||||
boxShadow="sm"
|
||||
transition="all 0.2s"
|
||||
_hover={{ transform: 'translateY(-2px)', boxShadow: 'md' }}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text fontSize="sm" fontWeight="bold">🔥</Text>
|
||||
<Text fontSize="sm" fontWeight="medium" color="gray.700">涨3%+</Text>
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="bold">🔥</Text>
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="gray.700" whiteSpace="nowrap">涨3%+</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* FullCalendar 日历组件 */}
|
||||
<Box
|
||||
height={{ base: '600px', md: '700px' }}
|
||||
height={{ base: '500px', md: '700px' }}
|
||||
bg="white"
|
||||
borderRadius="xl"
|
||||
boxShadow="lg"
|
||||
p={4}
|
||||
borderRadius={{ base: 'none', md: 'xl' }}
|
||||
boxShadow={{ base: 'none', md: 'lg' }}
|
||||
p={{ base: 1, md: 4 }}
|
||||
sx={{
|
||||
// FullCalendar 样式定制
|
||||
'.fc': {
|
||||
height: '100%',
|
||||
},
|
||||
'.fc-header-toolbar': {
|
||||
marginBottom: '1.5rem',
|
||||
marginBottom: { base: '0.5rem', md: '1.5rem' },
|
||||
padding: { base: '0 4px', md: '0' },
|
||||
flexWrap: 'nowrap',
|
||||
gap: { base: '4px', md: '8px' },
|
||||
},
|
||||
'.fc-toolbar-chunk': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
'.fc-toolbar-title': {
|
||||
fontSize: '1.5rem',
|
||||
fontSize: { base: '1rem', md: '1.5rem' },
|
||||
fontWeight: 'bold',
|
||||
color: 'purple.600',
|
||||
},
|
||||
@@ -784,6 +814,8 @@ const ConceptTimelineModal = ({
|
||||
backgroundColor: '#9F7AEA',
|
||||
borderColor: '#9F7AEA',
|
||||
color: 'white',
|
||||
padding: { base: '4px 8px', md: '6px 12px' },
|
||||
fontSize: { base: '12px', md: '14px' },
|
||||
'&:hover': {
|
||||
backgroundColor: '#805AD5',
|
||||
borderColor: '#805AD5',
|
||||
@@ -806,14 +838,18 @@ const ConceptTimelineModal = ({
|
||||
},
|
||||
},
|
||||
'.fc-daygrid-day-number': {
|
||||
padding: '4px',
|
||||
fontSize: '0.875rem',
|
||||
padding: { base: '2px', md: '4px' },
|
||||
fontSize: { base: '0.75rem', md: '0.875rem' },
|
||||
},
|
||||
'.fc-col-header-cell-cushion': {
|
||||
fontSize: { base: '0.75rem', md: '0.875rem' },
|
||||
padding: { base: '4px 2px', md: '8px' },
|
||||
},
|
||||
'.fc-event': {
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
padding: '2px 4px',
|
||||
fontSize: '0.75rem',
|
||||
padding: { base: '1px 2px', md: '2px 4px' },
|
||||
fontSize: { base: '0.65rem', md: '0.75rem' },
|
||||
fontWeight: 'bold',
|
||||
borderRadius: '4px',
|
||||
transition: 'all 0.2s',
|
||||
@@ -823,7 +859,13 @@ const ConceptTimelineModal = ({
|
||||
},
|
||||
},
|
||||
'.fc-daygrid-event-harness': {
|
||||
marginBottom: '2px',
|
||||
marginBottom: { base: '1px', md: '2px' },
|
||||
},
|
||||
// H5 端隐藏事件文字,只显示色块
|
||||
'@media (max-width: 768px)': {
|
||||
'.fc-event-title': {
|
||||
fontSize: '0.6rem',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -882,32 +924,11 @@ const ConceptTimelineModal = ({
|
||||
)}
|
||||
|
||||
{/* 风险提示 */}
|
||||
<Box px={6}>
|
||||
<Box px={{ base: 2, md: 6 }}>
|
||||
<RiskDisclaimer variant="default" />
|
||||
</Box>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter
|
||||
borderTop="2px solid"
|
||||
borderColor="purple.100"
|
||||
bg="gray.50"
|
||||
py={4}
|
||||
>
|
||||
<Button
|
||||
colorScheme="purple"
|
||||
size="lg"
|
||||
px={8}
|
||||
onClick={onClose}
|
||||
boxShadow="md"
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 'lg',
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Collapse,
|
||||
useBreakpointValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { SearchIcon, ViewIcon, CalendarIcon, ExternalLinkIcon, StarIcon, ChevronDownIcon, InfoIcon, CloseIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
||||
import { FaThLarge, FaList, FaTags, FaChartLine, FaRobot, FaTable, FaHistory, FaBrain, FaLightbulb, FaRocket, FaShieldAlt, FaCalendarAlt, FaArrowUp, FaArrowDown, FaNewspaper, FaFileAlt, FaExpand, FaCompress, FaClock, FaLock } from 'react-icons/fa';
|
||||
@@ -85,6 +86,8 @@ import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import ConceptTimelineModal from './ConceptTimelineModal';
|
||||
import ConceptStatsPanel from './components/ConceptStatsPanel';
|
||||
import ConceptStocksModal from '@components/ConceptStocksModal';
|
||||
import TradeDatePicker from '@components/TradeDatePicker';
|
||||
// 导航栏已由 MainLayout 提供,无需在此导入
|
||||
// 导入订阅权限管理
|
||||
import { useSubscription } from '../../hooks/useSubscription';
|
||||
@@ -527,109 +530,6 @@ const ConceptCenter = () => {
|
||||
return `https://valuefrontier.cn/company?scode=${seccode}`;
|
||||
};
|
||||
|
||||
// 渲染动态表格列
|
||||
const renderStockTable = () => {
|
||||
if (!selectedConceptStocks || selectedConceptStocks.length === 0) {
|
||||
return <Text>暂无相关股票数据</Text>;
|
||||
}
|
||||
|
||||
const allFields = new Set();
|
||||
selectedConceptStocks.forEach(stock => {
|
||||
Object.keys(stock).forEach(key => allFields.add(key));
|
||||
});
|
||||
|
||||
// 定义固定的列顺序,包含新增的现价和涨跌幅列
|
||||
const orderedFields = ['stock_name', 'stock_code', 'current_price', 'change_percent'];
|
||||
allFields.forEach(field => {
|
||||
if (!orderedFields.includes(field)) {
|
||||
orderedFields.push(field);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{loadingStockData && (
|
||||
<Box mb={4} textAlign="center">
|
||||
<HStack justify="center" spacing={2}>
|
||||
<Spinner size="sm" color="purple.500" />
|
||||
<Text fontSize="sm" color="gray.600">正在获取行情数据...</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<TableContainer maxH="60vh" overflowY="auto">
|
||||
<Table variant="simple" size="sm">
|
||||
<Thead position="sticky" top={0} bg="white" zIndex={1}>
|
||||
<Tr>
|
||||
{orderedFields.map(field => (
|
||||
<Th key={field}>
|
||||
{field === 'stock_name' ? '股票名称' :
|
||||
field === 'stock_code' ? '股票代码' :
|
||||
field === 'current_price' ? '现价' :
|
||||
field === 'change_percent' ? '当日涨跌幅' : field}
|
||||
</Th>
|
||||
))}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{selectedConceptStocks.map((stock, idx) => {
|
||||
const marketData = stockMarketData[stock.stock_code];
|
||||
const companyLink = generateCompanyLink(stock.stock_code);
|
||||
|
||||
return (
|
||||
<Tr key={idx} _hover={{ bg: 'gray.50' }}>
|
||||
{orderedFields.map(field => {
|
||||
let cellContent = stock[field] || '-';
|
||||
let cellProps = {};
|
||||
|
||||
// 处理特殊字段
|
||||
if (field === 'current_price') {
|
||||
cellContent = marketData ? formatPrice(marketData.close) : (loadingStockData ? <Spinner size="xs" /> : '-');
|
||||
} else if (field === 'change_percent') {
|
||||
if (marketData) {
|
||||
cellContent = formatStockChangePercent(marketData.change_percent);
|
||||
cellProps.color = `${getStockChangeColor(marketData.change_percent)}.500`;
|
||||
cellProps.fontWeight = 'bold';
|
||||
} else {
|
||||
cellContent = loadingStockData ? <Spinner size="xs" /> : '-';
|
||||
}
|
||||
} else if (field === 'stock_name' || field === 'stock_code') {
|
||||
// 添加超链接
|
||||
cellContent = (
|
||||
<Text
|
||||
as="a"
|
||||
href={companyLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color="blue.600"
|
||||
textDecoration="underline"
|
||||
_hover={{
|
||||
color: 'blue.800',
|
||||
textDecoration: 'underline'
|
||||
}}
|
||||
cursor="pointer"
|
||||
>
|
||||
{stock[field] || '-'}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Td key={field} {...cellProps}>
|
||||
{cellContent}
|
||||
</Td>
|
||||
);
|
||||
})}
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 格式化添加日期显示
|
||||
const formatAddedDate = (concept) => {
|
||||
// 优先使用 created_at 或 added_date 字段
|
||||
@@ -672,6 +572,10 @@ const ConceptCenter = () => {
|
||||
const changePercent = concept.price_info?.avg_change_pct;
|
||||
const changeColor = getChangeColor(changePercent);
|
||||
const hasChange = changePercent !== null && changePercent !== undefined;
|
||||
// H5 端使用更紧凑的尺寸
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
const coverHeight = useBreakpointValue({ base: '100px', md: '180px' });
|
||||
const logoSize = useBreakpointValue({ base: '60px', md: '120px' });
|
||||
|
||||
// 生成随机涨幅数字背景
|
||||
const generateNumbersBackground = () => {
|
||||
@@ -705,7 +609,7 @@ const ConceptCenter = () => {
|
||||
boxShadow="0 4px 12px rgba(0, 0, 0, 0.1)"
|
||||
>
|
||||
{/* 毛玻璃涨幅数字背景 */}
|
||||
<Box position="relative" height="180px" overflow="hidden">
|
||||
<Box position="relative" height={coverHeight} overflow="hidden">
|
||||
{/* 渐变背景层 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
@@ -757,8 +661,8 @@ const ConceptCenter = () => {
|
||||
top="50%"
|
||||
left="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
width="120px"
|
||||
height="120px"
|
||||
width={logoSize}
|
||||
height={logoSize}
|
||||
opacity={0.15}
|
||||
>
|
||||
<Image
|
||||
@@ -849,11 +753,11 @@ const ConceptCenter = () => {
|
||||
</Badge>
|
||||
</Box>
|
||||
|
||||
<CardBody p={4}>
|
||||
<VStack align="start" spacing={2}>
|
||||
<CardBody p={{ base: 3, md: 4 }}>
|
||||
<VStack align="start" spacing={{ base: 1, md: 2 }}>
|
||||
{/* 概念名称 */}
|
||||
<Heading
|
||||
size="sm"
|
||||
size={{ base: 'xs', md: 'sm' }}
|
||||
color="gray.800"
|
||||
noOfLines={1}
|
||||
bgGradient="linear(to-r, purple.600, pink.600)"
|
||||
@@ -863,15 +767,15 @@ const ConceptCenter = () => {
|
||||
{concept.concept}
|
||||
</Heading>
|
||||
|
||||
{/* 描述信息 */}
|
||||
<Text color="gray.600" fontSize="xs" noOfLines={2} minH="32px">
|
||||
{/* 描述信息 - H5端显示1行 */}
|
||||
<Text color="gray.600" fontSize="xs" noOfLines={isMobile ? 1 : 2} minH={{ base: '16px', md: '32px' }}>
|
||||
{concept.description || '暂无描述信息'}
|
||||
</Text>
|
||||
|
||||
{concept.stocks && concept.stocks.length > 0 && (
|
||||
<Box
|
||||
width="100%"
|
||||
p={3}
|
||||
p={{ base: 2, md: 3 }}
|
||||
bg="linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(168, 85, 247, 0.05) 100%)"
|
||||
borderRadius="lg"
|
||||
cursor="pointer"
|
||||
@@ -886,7 +790,7 @@ const ConceptCenter = () => {
|
||||
>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Box flex={1}>
|
||||
<HStack spacing={2} mb={2}>
|
||||
<HStack spacing={2} mb={{ base: 1, md: 2 }}>
|
||||
<Icon as={FaChartLine} boxSize={3} color="purple.500" />
|
||||
<Text fontSize="xs" color="purple.700" fontWeight="bold">
|
||||
热门个股
|
||||
@@ -942,20 +846,20 @@ const ConceptCenter = () => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider borderColor="purple.100" />
|
||||
<Divider borderColor="purple.100" my={{ base: 1, md: 0 }} />
|
||||
|
||||
<Flex width="100%" justify="space-between" align="center">
|
||||
{formatAddedDate(concept)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
size={{ base: 'xs', md: 'sm' }}
|
||||
leftIcon={<FaHistory />}
|
||||
bgGradient="linear(to-r, purple.500, pink.500)"
|
||||
color="white"
|
||||
variant="solid"
|
||||
onClick={(e) => handleViewContent(e, concept.concept, concept.concept_id)}
|
||||
borderRadius="full"
|
||||
px={4}
|
||||
px={{ base: 2, md: 4 }}
|
||||
fontWeight="medium"
|
||||
boxShadow="0 4px 12px rgba(139, 92, 246, 0.3)"
|
||||
_hover={{
|
||||
@@ -1179,23 +1083,23 @@ const ConceptCenter = () => {
|
||||
align={{ base: 'stretch', lg: 'center' }}
|
||||
gap={4}
|
||||
>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FaCalendarAlt} color="purple.500" boxSize={5} />
|
||||
<Text fontWeight="bold" color="purple.700">交易日期:</Text>
|
||||
</HStack>
|
||||
|
||||
<Input
|
||||
type="date"
|
||||
value={selectedDate ? selectedDate.toISOString().split('T')[0] : ''}
|
||||
onChange={handleDateChange}
|
||||
max={new Date().toISOString().split('T')[0]}
|
||||
width={{ base: '100%', lg: '200px' }}
|
||||
focusBorderColor="purple.500"
|
||||
borderColor="purple.200"
|
||||
borderRadius="lg"
|
||||
fontWeight="medium"
|
||||
{/* 使用通用日期选择器组件 */}
|
||||
<TradeDatePicker
|
||||
value={selectedDate}
|
||||
onChange={(date) => {
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
const previousDate = selectedDate ? selectedDate.toISOString().split('T')[0] : null;
|
||||
trackFilterApplied('date', dateStr, previousDate);
|
||||
setSelectedDate(date);
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ date: dateStr, page: 1 });
|
||||
fetchConcepts(searchQuery, 1, date, sortBy);
|
||||
}}
|
||||
latestTradeDate={latestTradeDate}
|
||||
label="交易日期"
|
||||
/>
|
||||
|
||||
{/* 快捷按钮保留在页面内 */}
|
||||
<ButtonGroup size="sm" variant="outline" flexWrap="wrap">
|
||||
<Button
|
||||
onClick={() => handleQuickDateSelect(0)}
|
||||
@@ -1246,25 +1150,6 @@ const ConceptCenter = () => {
|
||||
一月前
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
{latestTradeDate && (
|
||||
<Tooltip label="数据库中最新的交易日期">
|
||||
<HStack
|
||||
spacing={2}
|
||||
bg="blue.50"
|
||||
px={3}
|
||||
py={1.5}
|
||||
borderRadius="full"
|
||||
border="1px solid"
|
||||
borderColor="blue.200"
|
||||
>
|
||||
<Icon as={InfoIcon} color="blue.500" boxSize={3} />
|
||||
<Text fontSize="sm" color="blue.600" fontWeight="medium">
|
||||
最新: {latestTradeDate.toLocaleDateString('zh-CN')}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
@@ -1598,7 +1483,7 @@ const ConceptCenter = () => {
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
|
||||
<SimpleGrid columns={{ base: 2, md: 2, lg: 3 }} spacing={{ base: 3, md: 6 }}>
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
@@ -1606,7 +1491,7 @@ const ConceptCenter = () => {
|
||||
) : concepts.length > 0 ? (
|
||||
<>
|
||||
{viewMode === 'grid' ? (
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6} className="concept-grid">
|
||||
<SimpleGrid columns={{ base: 2, md: 2, lg: 3 }} spacing={{ base: 3, md: 6 }} className="concept-grid">
|
||||
{concepts.map((concept, index) => (
|
||||
<Box key={concept.concept_id} className="concept-item" role="group">
|
||||
<ConceptCard concept={concept} position={index} />
|
||||
@@ -1758,32 +1643,15 @@ const ConceptCenter = () => {
|
||||
</Flex>
|
||||
</Container>
|
||||
|
||||
{/* 股票详情Modal */}
|
||||
<Modal
|
||||
{/* 股票详情Modal - 复用通用组件 */}
|
||||
<ConceptStocksModal
|
||||
isOpen={isStockModalOpen}
|
||||
onClose={() => setIsStockModalOpen(false)}
|
||||
size="6xl"
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader bg="purple.500" color="white">
|
||||
<HStack>
|
||||
<Icon as={FaTable} />
|
||||
<Text>{selectedConceptName} - 相关个股</Text>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton color="white" />
|
||||
<ModalBody py={6}>
|
||||
{renderStockTable()}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button colorScheme="purple" onClick={() => setIsStockModalOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
concept={{
|
||||
concept_name: selectedConceptName,
|
||||
stocks: selectedConceptStocks
|
||||
}}
|
||||
/>
|
||||
{/* 时间轴Modal */}
|
||||
<ConceptTimelineModal
|
||||
isOpen={isTimelineModalOpen}
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
} from 'react-icons/fi';
|
||||
import MyFutureEvents from './components/MyFutureEvents';
|
||||
import InvestmentPlanningCenter from './components/InvestmentPlanningCenter';
|
||||
import { getEventDetailUrl } from '@/utils/idEncoder';
|
||||
|
||||
export default function CenterDashboard() {
|
||||
const { user } = useAuth();
|
||||
@@ -441,7 +442,7 @@ export default function CenterDashboard() {
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<LinkOverlay
|
||||
as={Link}
|
||||
to={`/event-detail/${event.id}`}
|
||||
to={getEventDetailUrl(event.id)}
|
||||
>
|
||||
<Text fontWeight="medium" fontSize="md" noOfLines={2}>
|
||||
{event.title}
|
||||
|
||||
@@ -1,909 +1,88 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useLocation } from 'react-router-dom';
|
||||
/**
|
||||
* EventDetail - 事件详情页面
|
||||
* 使用 DynamicNewsDetailPanel 组件展示事件详情
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
VStack,
|
||||
HStack,
|
||||
Spinner,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Flex,
|
||||
useColorModeValue,
|
||||
Grid,
|
||||
GridItem,
|
||||
Icon,
|
||||
Text,
|
||||
Badge,
|
||||
Divider,
|
||||
useDisclosure,
|
||||
Button,
|
||||
Heading,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
SimpleGrid,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Textarea,
|
||||
Avatar,
|
||||
IconButton,
|
||||
Input,
|
||||
Collapse,
|
||||
Center,
|
||||
useToast,
|
||||
Skeleton,
|
||||
Box,
|
||||
Spinner,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiLock } from 'react-icons/fi';
|
||||
import {
|
||||
FiTrendingUp,
|
||||
FiActivity,
|
||||
FiMessageSquare,
|
||||
FiClock,
|
||||
FiBarChart2,
|
||||
FiLink,
|
||||
FiZap,
|
||||
FiGlobe,
|
||||
FiHeart,
|
||||
FiTrash2,
|
||||
FiChevronDown,
|
||||
FiChevronUp,
|
||||
} from 'react-icons/fi';
|
||||
import { FaHeart, FaRegHeart, FaComment } from 'react-icons/fa';
|
||||
import { format } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
|
||||
// 导入新建的业务组件
|
||||
import EventHeader from './components/EventHeader';
|
||||
import RelatedConcepts from './components/RelatedConcepts';
|
||||
import HistoricalEvents from './components/HistoricalEvents';
|
||||
import RelatedStocks from './components/RelatedStocks';
|
||||
// Navigation bar now provided by MainLayout
|
||||
// import HomeNavbar from '../../components/Navbars/HomeNavbar';
|
||||
import SubscriptionUpgradeModal from '../../components/SubscriptionUpgradeModal';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useSubscription } from '../../hooks/useSubscription';
|
||||
import TransmissionChainAnalysis from './components/TransmissionChainAnalysis';
|
||||
|
||||
// 导入你的 Flask API 服务
|
||||
import { eventService } from '../../services/eventService';
|
||||
import { debugEventService } from '../../utils/debugEventService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useEventDetailEvents } from './hooks/useEventDetailEvents';
|
||||
|
||||
// 临时调试代码 - 生产环境测试后请删除
|
||||
if (typeof window !== 'undefined') {
|
||||
logger.debug('EventDetail', '调试 eventService');
|
||||
debugEventService();
|
||||
}
|
||||
|
||||
// 统计卡片组件 - 更简洁的设计
|
||||
const StatCard = ({ icon, label, value, color }) => {
|
||||
const bg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const iconColor = useColorModeValue(`${color}.500`, `${color}.300`);
|
||||
|
||||
return (
|
||||
<Stat
|
||||
p={6}
|
||||
bg={bg}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<HStack spacing={3} align="flex-start">
|
||||
<Icon as={icon} boxSize={5} color={iconColor} mt={1} />
|
||||
<Box flex={1}>
|
||||
<StatLabel color="gray.500" fontSize="sm">{label}</StatLabel>
|
||||
<StatNumber fontSize="2xl" color={iconColor}>{value}</StatNumber>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Stat>
|
||||
);
|
||||
};
|
||||
|
||||
// 帖子组件
|
||||
const PostItem = ({ post, onRefresh, eventEvents }) => {
|
||||
const [showComments, setShowComments] = useState(false);
|
||||
const [comments, setComments] = useState([]);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [liked, setLiked] = useState(post.liked || false);
|
||||
const [likesCount, setLikesCount] = useState(post.likes_count || 0);
|
||||
const toast = useToast();
|
||||
const { user } = useAuth();
|
||||
const bg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
const loadComments = async () => {
|
||||
if (!showComments) {
|
||||
setShowComments(true);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await eventService.getPostComments(post.id);
|
||||
if (result.success) {
|
||||
setComments(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('PostItem', 'loadComments', error, { postId: post.id });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
setShowComments(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLike = async () => {
|
||||
try {
|
||||
const result = await eventService.likePost(post.id);
|
||||
if (result.success) {
|
||||
const newLikedState = result.liked;
|
||||
setLiked(newLikedState);
|
||||
setLikesCount(result.likes_count);
|
||||
|
||||
// 🎯 追踪评论点赞
|
||||
if (eventEvents && eventEvents.trackCommentLiked) {
|
||||
eventEvents.trackCommentLiked(post.id, newLikedState);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '操作失败',
|
||||
status: 'error',
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddComment = async () => {
|
||||
if (!newComment.trim()) return;
|
||||
|
||||
try {
|
||||
const result = await eventService.addPostComment(post.id, {
|
||||
content: newComment,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 🎯 追踪添加评论
|
||||
if (eventEvents && eventEvents.trackCommentAdded) {
|
||||
eventEvents.trackCommentAdded(
|
||||
result.data?.id || post.id,
|
||||
newComment.length
|
||||
);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: '评论发表成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
setNewComment('');
|
||||
// 重新加载评论
|
||||
const commentsResult = await eventService.getPostComments(post.id);
|
||||
if (commentsResult.success) {
|
||||
setComments(commentsResult.data);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '评论失败',
|
||||
status: 'error',
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (window.confirm('确定要删除这个帖子吗?')) {
|
||||
try {
|
||||
const result = await eventService.deletePost(post.id);
|
||||
if (result.success) {
|
||||
// 🎯 追踪删除评论
|
||||
if (eventEvents && eventEvents.trackCommentDeleted) {
|
||||
eventEvents.trackCommentDeleted(post.id);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
onRefresh();
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
p={6}
|
||||
mb={4}
|
||||
>
|
||||
{/* 帖子头部 */}
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<HStack spacing={3}>
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={post.user?.username}
|
||||
src={post.user?.avatar_url}
|
||||
/>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="medium">{post.user?.username || '匿名用户'}</Text>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
{format(new Date(post.created_at), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* 帖子内容 */}
|
||||
{post.title && (
|
||||
<Heading size="md" mb={2}>
|
||||
{post.title}
|
||||
</Heading>
|
||||
)}
|
||||
<Text mb={4} whiteSpace="pre-wrap">
|
||||
{post.content}
|
||||
</Text>
|
||||
|
||||
{/* 操作栏 */}
|
||||
<HStack spacing={4}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={liked ? <FaHeart /> : <FaRegHeart />}
|
||||
color={liked ? 'red.500' : 'gray.500'}
|
||||
onClick={handleLike}
|
||||
>
|
||||
{likesCount}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<FaComment />}
|
||||
rightIcon={showComments ? <FiChevronUp /> : <FiChevronDown />}
|
||||
onClick={loadComments}
|
||||
>
|
||||
{post.comments_count || 0} 评论
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* 评论区 */}
|
||||
<Collapse in={showComments} animateOpacity>
|
||||
<Box mt={4} pt={4} borderTopWidth="1px" borderColor={borderColor}>
|
||||
{/* 评论输入 */}
|
||||
<HStack mb={4}>
|
||||
<Textarea
|
||||
placeholder="写下你的评论..."
|
||||
size="sm"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="sm"
|
||||
onClick={handleAddComment}
|
||||
isDisabled={!newComment.trim()}
|
||||
>
|
||||
评论
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* 评论列表 */}
|
||||
{isLoading ? (
|
||||
<Center py={4}>
|
||||
<Spinner size="sm" />
|
||||
</Center>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{comments.map((comment) => (
|
||||
<Box key={comment.id} pl={4} borderLeftWidth="2px" borderColor="gray.200">
|
||||
<HStack mb={1}>
|
||||
<Text fontWeight="medium" fontSize="sm">
|
||||
{comment.user?.username || '匿名用户'}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{format(new Date(comment.created_at), 'MM-dd HH:mm')}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm">{comment.content}</Text>
|
||||
</Box>
|
||||
))}
|
||||
{comments.length === 0 && (
|
||||
<Text color="gray.500" textAlign="center" py={2}>
|
||||
暂无评论
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
import { decodeEventId } from '@/utils/idEncoder';
|
||||
import { eventService } from '@/services/eventService';
|
||||
import { DynamicNewsDetailPanel } from '@/views/Community/components/DynamicNewsDetail';
|
||||
import { logger } from '@/utils/logger';
|
||||
import ErrorPage from '@/components/ErrorPage';
|
||||
|
||||
const EventDetail = () => {
|
||||
const { eventId } = useParams();
|
||||
const location = useLocation();
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const toast = useToast();
|
||||
const { eventId: pathEventId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// 用户认证和权限控制
|
||||
const { user } = useAuth();
|
||||
const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription();
|
||||
// 优先从查询参数获取加密 ID,兼容旧的路径参数
|
||||
const encodedId = searchParams.get('id');
|
||||
const eventId = encodedId ? decodeEventId(encodedId) : pathEventId;
|
||||
|
||||
// 滚动位置管理
|
||||
const scrollPositionRef = useRef(0);
|
||||
|
||||
// State hooks
|
||||
const [eventData, setEventData] = useState(null);
|
||||
const [relatedStocks, setRelatedStocks] = useState([]);
|
||||
const [relatedConcepts, setRelatedConcepts] = useState([]);
|
||||
const [historicalEvents, setHistoricalEvents] = useState([]);
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [postsLoading, setPostsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
// 🎯 初始化事件详情埋点Hook(传入event对象)
|
||||
const eventEvents = useEventDetailEvents({
|
||||
event: eventData ? {
|
||||
id: eventData.id,
|
||||
title: eventData.title,
|
||||
importance: eventData.importance
|
||||
} : null
|
||||
});
|
||||
const [newPostContent, setNewPostContent] = useState('');
|
||||
const [newPostTitle, setNewPostTitle] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [upgradeModal, setUpgradeModal] = useState({ isOpen: false, feature: '功能', required: 'pro' });
|
||||
|
||||
// 从URL路径中提取eventId(处理多种URL格式)
|
||||
const getEventIdFromPath = () => {
|
||||
const pathParts = location.pathname.split('/');
|
||||
const lastPart = pathParts[pathParts.length - 1];
|
||||
const secondLastPart = pathParts[pathParts.length - 2];
|
||||
|
||||
if (!isNaN(lastPart) && lastPart) {
|
||||
return lastPart;
|
||||
}
|
||||
if (!isNaN(secondLastPart) && secondLastPart) {
|
||||
return secondLastPart;
|
||||
}
|
||||
return eventId;
|
||||
};
|
||||
|
||||
const actualEventId = getEventIdFromPath();
|
||||
|
||||
// 保存当前滚动位置
|
||||
const saveScrollPosition = () => {
|
||||
scrollPositionRef.current = window.scrollY || window.pageYOffset;
|
||||
};
|
||||
|
||||
// 恢复滚动位置
|
||||
const restoreScrollPosition = () => {
|
||||
window.scrollTo(0, scrollPositionRef.current);
|
||||
};
|
||||
// 状态
|
||||
const [eventData, setEventData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// 加载事件基础数据
|
||||
useEffect(() => {
|
||||
const loadEventData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
if (!eventId) {
|
||||
setError('无效的事件ID');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载基本事件信息(免费用户也可以访问)
|
||||
const eventResponse = await eventService.getEventDetail(actualEventId);
|
||||
setEventData(eventResponse.data);
|
||||
|
||||
// 总是尝试加载相关股票(权限在组件内部检查)
|
||||
let stocksCount = 0;
|
||||
try {
|
||||
const stocksResponse = await eventService.getRelatedStocks(actualEventId);
|
||||
setRelatedStocks(stocksResponse.data || []);
|
||||
stocksCount = stocksResponse.data?.length || 0;
|
||||
} catch (e) {
|
||||
logger.warn('EventDetail', '加载相关股票失败', { eventId: actualEventId, error: e.message });
|
||||
setRelatedStocks([]);
|
||||
}
|
||||
|
||||
// 根据权限决定是否加载相关概念
|
||||
if (hasFeatureAccess('related_concepts')) {
|
||||
try {
|
||||
const conceptsResponse = await eventService.getRelatedConcepts(actualEventId);
|
||||
setRelatedConcepts(conceptsResponse.data || []);
|
||||
} catch (e) {
|
||||
logger.warn('EventDetail', '加载相关概念失败', { eventId: actualEventId, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 历史事件所有用户都可以访问,但免费用户只看到前2条
|
||||
let timelineCount = 0;
|
||||
try {
|
||||
const eventsResponse = await eventService.getHistoricalEvents(actualEventId);
|
||||
setHistoricalEvents(eventsResponse.data || []);
|
||||
timelineCount = eventsResponse.data?.length || 0;
|
||||
} catch (e) {
|
||||
logger.warn('EventDetail', '历史事件加载失败', { eventId: actualEventId, error: e.message });
|
||||
}
|
||||
|
||||
// 🎯 追踪事件分析内容查看(数据加载完成后)
|
||||
if (eventResponse.data && eventEvents) {
|
||||
eventEvents.trackEventAnalysisViewed({
|
||||
type: 'overview',
|
||||
relatedStockCount: stocksCount,
|
||||
timelineEventCount: timelineCount,
|
||||
marketImpact: eventResponse.data.market_impact
|
||||
});
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
logger.error('EventDetail', 'loadEventData', err, { eventId: actualEventId });
|
||||
setError(err.message || '加载事件数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await eventService.getEventDetail(eventId);
|
||||
setEventData(response.data);
|
||||
} catch (err) {
|
||||
logger.error('EventDetail', 'loadEventData', err, { eventId });
|
||||
setError(err.message || '加载事件数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refetchStocks = async () => {
|
||||
if (!hasFeatureAccess('related_stocks')) return;
|
||||
try {
|
||||
const stocksResponse = await eventService.getRelatedStocks(actualEventId);
|
||||
setRelatedStocks(stocksResponse.data);
|
||||
} catch (err) {
|
||||
logger.error('EventDetail', 'refetchStocks', err, { eventId: actualEventId });
|
||||
}
|
||||
};
|
||||
loadEventData();
|
||||
}, [eventId]);
|
||||
|
||||
const handleFollowToggle = async () => {
|
||||
try {
|
||||
await eventService.toggleFollow(actualEventId, eventData.is_following);
|
||||
|
||||
setEventData(prev => ({
|
||||
...prev,
|
||||
is_following: !prev.is_following,
|
||||
follower_count: prev.is_following
|
||||
? prev.follower_count - 1
|
||||
: prev.follower_count + 1
|
||||
}));
|
||||
} catch (err) {
|
||||
logger.error('EventDetail', 'handleFollowToggle', err, { eventId: actualEventId });
|
||||
}
|
||||
};
|
||||
|
||||
// 加载帖子列表
|
||||
const loadPosts = async () => {
|
||||
setPostsLoading(true);
|
||||
try {
|
||||
const result = await eventService.getPosts(actualEventId);
|
||||
if (result.success) {
|
||||
setPosts(result.data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('EventDetail', 'loadPosts', err, { eventId: actualEventId });
|
||||
} finally {
|
||||
setPostsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 创建新帖子
|
||||
const handleCreatePost = async () => {
|
||||
if (!newPostContent.trim()) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await eventService.createPost(actualEventId, {
|
||||
title: newPostTitle.trim(),
|
||||
content: newPostContent.trim(),
|
||||
content_type: 'text',
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: '帖子发布成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
setNewPostContent('');
|
||||
setNewPostTitle('');
|
||||
loadPosts();
|
||||
// 更新帖子数
|
||||
setEventData(prev => ({
|
||||
...prev,
|
||||
post_count: (prev.post_count || 0) + 1
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: '发布失败',
|
||||
description: err.message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Effect hook - must be called after all state hooks
|
||||
useEffect(() => {
|
||||
if (actualEventId) {
|
||||
// 保存当前滚动位置
|
||||
saveScrollPosition();
|
||||
|
||||
loadEventData();
|
||||
loadPosts();
|
||||
|
||||
// 数据加载完成后恢复滚动位置
|
||||
// 使用 setTimeout 确保 DOM 已更新
|
||||
const timer = setTimeout(() => {
|
||||
restoreScrollPosition();
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setError('无效的事件ID');
|
||||
setLoading(false);
|
||||
}
|
||||
}, [actualEventId, location.pathname]);
|
||||
|
||||
// 加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Box bg={bgColor} minH="100vh" w="100%" p={4}>
|
||||
<Container maxW="7xl" py={8}>
|
||||
<VStack spacing={6}>
|
||||
<Skeleton height="150px" borderRadius="lg" />
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4} w="100%">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} height="80px" borderRadius="md" />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Grid templateColumns={{ base: "1fr", lg: "1fr 1fr" }} gap={6} w="100%">
|
||||
<Skeleton height="300px" borderRadius="lg" />
|
||||
<Skeleton height="300px" borderRadius="lg" />
|
||||
</Grid>
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (error) {
|
||||
return (
|
||||
<Box bg={bgColor} minH="100vh" w="100%" p={4}>
|
||||
<Container maxW="7xl" py={8}>
|
||||
<Center minH="60vh">
|
||||
<Alert
|
||||
status="error"
|
||||
borderRadius="lg"
|
||||
maxW="md"
|
||||
flexDirection="column"
|
||||
textAlign="center"
|
||||
p={6}
|
||||
>
|
||||
<AlertIcon boxSize="40px" mr={0} />
|
||||
<AlertTitle mt={4} mb={2} fontSize="lg">
|
||||
加载失败
|
||||
</AlertTitle>
|
||||
<AlertDescription maxWidth="sm">
|
||||
{error}
|
||||
{actualEventId && (
|
||||
<Text mt={2} fontSize="sm" color="gray.500">
|
||||
事件ID: {actualEventId}
|
||||
</Text>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</Center>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 主要内容
|
||||
// 加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Box bg={bgColor} minH="100vh" w="100%">
|
||||
{/* Navigation bar provided by MainLayout */}
|
||||
<Container maxW="7xl" py={8}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 事件基本信息 */}
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={6}
|
||||
>
|
||||
<EventHeader
|
||||
event={eventData}
|
||||
onFollowToggle={handleFollowToggle}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 统计数据 */}
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
|
||||
<StatCard
|
||||
icon={FiTrendingUp}
|
||||
label="关注度"
|
||||
value={eventData?.follower_count || 0}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
icon={hasFeatureAccess('related_stocks') ? FiActivity : FiLock}
|
||||
label="相关标的"
|
||||
value={hasFeatureAccess('related_stocks') ? relatedStocks.length : '🔒需Pro'}
|
||||
color={hasFeatureAccess('related_stocks') ? "green" : "orange"}
|
||||
/>
|
||||
<StatCard
|
||||
icon={FiZap}
|
||||
label="预期偏离度"
|
||||
value={`${(eventData?.expectation_surprise_score || 0).toFixed(1)}%`}
|
||||
color="purple"
|
||||
/>
|
||||
<StatCard
|
||||
icon={FiMessageSquare}
|
||||
label="讨论数"
|
||||
value={eventData?.post_count || 0}
|
||||
color="orange"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 主要内容标签页 */}
|
||||
<Tabs colorScheme="blue" size="md">
|
||||
<TabList>
|
||||
<Tab>
|
||||
相关标的
|
||||
{!hasFeatureAccess('related_stocks') && (
|
||||
<Icon as={FiLock} ml={1} boxSize={3} color="orange.400" />
|
||||
)}
|
||||
</Tab>
|
||||
<Tab>
|
||||
相关概念
|
||||
{!hasFeatureAccess('related_concepts') && (
|
||||
<Icon as={FiLock} ml={1} boxSize={3} color="orange.400" />
|
||||
)}
|
||||
</Tab>
|
||||
<Tab>历史事件</Tab>
|
||||
<Tab>
|
||||
传导链分析
|
||||
{!hasFeatureAccess('transmission_chain') && (
|
||||
<Icon as={FiLock} ml={1} boxSize={3} color="purple.400" />
|
||||
)}
|
||||
</Tab>
|
||||
<Tab>讨论区</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* 相关标的标签页 */}
|
||||
<TabPanel px={0}>
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={6}
|
||||
>
|
||||
{!hasFeatureAccess('related_stocks') ? (
|
||||
<VStack spacing={3} align="center" py={8}>
|
||||
<Icon as={FiLock} boxSize={8} color="orange.400" />
|
||||
<Text>该功能为Pro专享,请升级订阅后查看相关标的。</Text>
|
||||
<Button colorScheme="blue" onClick={() => setUpgradeModal({ isOpen: true, feature: '相关标的', required: 'pro' })}>升级到Pro版</Button>
|
||||
</VStack>
|
||||
) : (
|
||||
<RelatedStocks
|
||||
eventId={actualEventId}
|
||||
eventTime={eventData?.created_at}
|
||||
stocks={relatedStocks}
|
||||
loading={false}
|
||||
error={null}
|
||||
onStockAdded={refetchStocks}
|
||||
onStockDeleted={refetchStocks}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* 相关概念标签页 */}
|
||||
<TabPanel px={0}>
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={6}
|
||||
>
|
||||
{!hasFeatureAccess('related_concepts') ? (
|
||||
<VStack spacing={3} align="center" py={8}>
|
||||
<Icon as={FiLock} boxSize={8} color="orange.400" />
|
||||
<Text>该功能为Pro专享,请升级订阅后查看相关概念。</Text>
|
||||
<Button colorScheme="blue" onClick={() => setUpgradeModal({ isOpen: true, feature: '相关概念', required: 'pro' })}>升级到Pro版</Button>
|
||||
</VStack>
|
||||
) : (
|
||||
<RelatedConcepts
|
||||
eventTitle={eventData?.title}
|
||||
eventTime={eventData?.created_at}
|
||||
eventId={actualEventId}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* 历史事件标签页 */}
|
||||
<TabPanel px={0}>
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={6}
|
||||
>
|
||||
<HistoricalEvents
|
||||
events={historicalEvents}
|
||||
expectationScore={eventData?.expectation_surprise_score}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
{!hasFeatureAccess('historical_events_full') && historicalEvents.length > 0 && (
|
||||
<Box mt={4} p={3} bg="orange.50" borderRadius="md" border="1px solid" borderColor="orange.200">
|
||||
<HStack>
|
||||
<Icon as={FiLock} color="orange.400" />
|
||||
<Text color="orange.700" fontSize="sm">
|
||||
免费版仅展示前2条历史事件,
|
||||
<Button
|
||||
variant="link"
|
||||
colorScheme="orange"
|
||||
size="sm"
|
||||
onClick={() => setUpgradeModal({ isOpen: true, feature: '完整历史事件', required: 'pro' })}
|
||||
>
|
||||
升级Pro版
|
||||
</Button>
|
||||
可查看全部。
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* 传导链分析标签页 */}
|
||||
<TabPanel px={0}>
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={6}
|
||||
>
|
||||
{!hasFeatureAccess('transmission_chain') ? (
|
||||
<VStack spacing={3} align="center" py={8}>
|
||||
<Icon as={FiLock} boxSize={8} color="purple.400" />
|
||||
<Text>传导链分析为Max专享,请升级订阅后查看。</Text>
|
||||
<Button colorScheme="purple" onClick={() => setUpgradeModal({ isOpen: true, feature: '传导链分析', required: 'max' })}>升级到Max版</Button>
|
||||
</VStack>
|
||||
) : (
|
||||
<TransmissionChainAnalysis
|
||||
eventId={actualEventId}
|
||||
eventService={eventService}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* 讨论区标签页 */}
|
||||
<TabPanel px={0}>
|
||||
<VStack spacing={6}>
|
||||
{/* 发布新帖子 */}
|
||||
{user && (
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={6}
|
||||
w="100%"
|
||||
>
|
||||
<VStack spacing={4}>
|
||||
<Input
|
||||
placeholder="帖子标题(可选)"
|
||||
value={newPostTitle}
|
||||
onChange={(e) => setNewPostTitle(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="分享你的想法..."
|
||||
value={newPostContent}
|
||||
onChange={(e) => setNewPostContent(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
<HStack w="100%" justify="flex-end">
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleCreatePost}
|
||||
isLoading={submitting}
|
||||
isDisabled={!newPostContent.trim()}
|
||||
>
|
||||
发布
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 帖子列表 */}
|
||||
<Box w="100%">
|
||||
{postsLoading ? (
|
||||
<VStack spacing={4}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} height="120px" w="100%" borderRadius="lg" />
|
||||
))}
|
||||
</VStack>
|
||||
) : posts.length > 0 ? (
|
||||
posts.map((post) => (
|
||||
<PostItem
|
||||
key={post.id}
|
||||
post={post}
|
||||
onRefresh={loadPosts}
|
||||
eventEvents={eventEvents}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={8}
|
||||
textAlign="center"
|
||||
>
|
||||
<Text color="gray.500">还没有讨论,来发布第一个帖子吧!</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</VStack>
|
||||
</Container>
|
||||
|
||||
{/* 升级弹窗 */}
|
||||
<SubscriptionUpgradeModal
|
||||
isOpen={upgradeModal.isOpen}
|
||||
onClose={() => setUpgradeModal({ isOpen: false, feature: '功能', required: 'pro' })}
|
||||
requiredLevel={upgradeModal.required}
|
||||
featureName={upgradeModal.feature}
|
||||
currentLevel={user?.subscription_type || 'free'}
|
||||
/>
|
||||
</Box>
|
||||
<Box minH="100vh" w="100%">
|
||||
<Center py={20}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (!error) {
|
||||
return (
|
||||
<ErrorPage
|
||||
title="页面找不到了"
|
||||
description={error}
|
||||
detail={eventId}
|
||||
detailLabel="事件ID"
|
||||
showRetry
|
||||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 主内容
|
||||
return (
|
||||
<Box maxW="7xl" mx="auto"><DynamicNewsDetailPanel event={eventData} showHeader={true} /></Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetail;
|
||||
export default EventDetail;
|
||||
|
||||
@@ -91,7 +91,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
<Container maxW="7xl" position="relative" zIndex={30} px={containerPx}>
|
||||
<VStack
|
||||
spacing={{ base: 8, md: 12, lg: 16 }}
|
||||
spacing={{ base: 5, md: 8, lg: 10 }}
|
||||
align="stretch"
|
||||
minH={heroHeight}
|
||||
justify="center"
|
||||
@@ -104,8 +104,8 @@ const HomePage: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* 核心功能面板 */}
|
||||
<Box pb={{ base: 8, md: 12 }}>
|
||||
<VStack spacing={{ base: 6, md: 8 }}>
|
||||
<Box pb={{ base: 5, md: 8 }}>
|
||||
<VStack spacing={{ base: 4, md: 5 }}>
|
||||
{/* 特色功能卡片 - 新闻中心 */}
|
||||
<FeaturedFeatureCard
|
||||
feature={featuredFeature}
|
||||
@@ -115,7 +115,7 @@ const HomePage: React.FC = () => {
|
||||
{/* 其他功能卡片 */}
|
||||
<SimpleGrid
|
||||
columns={{ base: 1, md: 2, lg: 3 }}
|
||||
spacing={{ base: 4, md: 5, lg: 6 }}
|
||||
spacing={{ base: 2, md: 3, lg: 4 }}
|
||||
w="100%"
|
||||
>
|
||||
{regularFeatures.map((feature) => (
|
||||
|
||||
@@ -34,51 +34,51 @@ export const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||
backdropFilter="blur(10px)"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.200"
|
||||
borderRadius={{ base: 'xl', md: '2xl' }}
|
||||
borderRadius={{ base: 'lg', md: 'xl' }}
|
||||
transition="all 0.3s ease"
|
||||
_hover={{
|
||||
bg: 'whiteAlpha.200',
|
||||
borderColor: `${feature.color}.400`,
|
||||
transform: 'translateY(-4px)',
|
||||
shadow: '2xl'
|
||||
transform: 'translateY(-3px)',
|
||||
shadow: 'xl'
|
||||
}}
|
||||
_active={{
|
||||
bg: 'whiteAlpha.200',
|
||||
borderColor: `${feature.color}.400`,
|
||||
transform: 'translateY(-2px)'
|
||||
transform: 'translateY(-1px)'
|
||||
}}
|
||||
onClick={() => onClick(feature)}
|
||||
minH={{ base: 'auto', md: '180px' }}
|
||||
minH={{ base: 'auto', md: '120px' }}
|
||||
cursor="pointer"
|
||||
>
|
||||
<CardBody p={{ base: 5, md: 6 }}>
|
||||
<VStack spacing={{ base: 3, md: 4 }} align="start" h="100%">
|
||||
<CardBody p={{ base: 3, md: 4 }}>
|
||||
<VStack spacing={{ base: 2, md: 2.5 }} align="start" h="100%">
|
||||
<HStack>
|
||||
<Box
|
||||
p={{ base: 2, md: 3 }}
|
||||
borderRadius="lg"
|
||||
p={{ base: 1.5, md: 2 }}
|
||||
borderRadius="md"
|
||||
bg={`${feature.color}.50`}
|
||||
border="1px solid"
|
||||
borderColor={`${feature.color}.200`}
|
||||
>
|
||||
<Text fontSize={{ base: 'xl', md: '2xl' }}>{feature.icon}</Text>
|
||||
<Text fontSize={{ base: 'md', md: 'lg' }}>{feature.icon}</Text>
|
||||
</Box>
|
||||
<Badge
|
||||
colorScheme={feature.color}
|
||||
variant="solid"
|
||||
fontSize={{ base: 'xs', md: 'sm' }}
|
||||
fontSize={{ base: '2xs', md: 'xs' }}
|
||||
>
|
||||
{feature.badge}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<VStack align="start" spacing={{ base: 1, md: 2 }} flex={1}>
|
||||
<Heading size={{ base: 'md', md: 'lg' }} color="white">
|
||||
<VStack align="start" spacing={{ base: 0.5, md: 1 }} flex={1}>
|
||||
<Heading size={{ base: 'sm', md: 'md' }} color="white">
|
||||
{feature.title}
|
||||
</Heading>
|
||||
<Text
|
||||
color="whiteAlpha.800"
|
||||
fontSize={{ base: 'xs', md: 'sm' }}
|
||||
fontSize={{ base: '2xs', md: 'xs' }}
|
||||
lineHeight="tall"
|
||||
>
|
||||
{feature.description}
|
||||
@@ -87,11 +87,11 @@ export const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||
|
||||
<Button
|
||||
colorScheme={feature.color}
|
||||
size={{ base: 'md', md: 'sm' }}
|
||||
size={{ base: 'sm', md: 'xs' }}
|
||||
variant="outline"
|
||||
alignSelf="flex-end"
|
||||
w={{ base: '100%', md: 'auto' }}
|
||||
minH="44px"
|
||||
minH="32px"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(feature);
|
||||
|
||||
@@ -34,7 +34,7 @@ export const FeaturedFeatureCard: React.FC<FeaturedFeatureCardProps> = ({
|
||||
bg="transparent"
|
||||
border="2px solid"
|
||||
borderColor="yellow.400"
|
||||
borderRadius={{ base: '2xl', md: '3xl' }}
|
||||
borderRadius={{ base: 'xl', md: '2xl' }}
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
shadow="2xl"
|
||||
@@ -50,35 +50,35 @@ export const FeaturedFeatureCard: React.FC<FeaturedFeatureCardProps> = ({
|
||||
zIndex: 0
|
||||
}}
|
||||
>
|
||||
<CardBody p={{ base: 6, md: 8 }} position="relative" zIndex={1}>
|
||||
<CardBody p={{ base: 4, md: 5 }} position="relative" zIndex={1}>
|
||||
<Flex
|
||||
direction={{ base: 'column', md: 'row' }}
|
||||
align={{ base: 'stretch', md: 'center' }}
|
||||
justify={{ base: 'flex-start', md: 'space-between' }}
|
||||
gap={{ base: 4, md: 6 }}
|
||||
gap={{ base: 3, md: 4 }}
|
||||
>
|
||||
<Flex align="center" gap={{ base: 4, md: 6 }} flex={1}>
|
||||
<Flex align="center" gap={{ base: 3, md: 4 }} flex={1}>
|
||||
<Box
|
||||
p={{ base: 3, md: 4 }}
|
||||
borderRadius={{ base: 'lg', md: 'xl' }}
|
||||
p={{ base: 2, md: 2.5 }}
|
||||
borderRadius={{ base: 'md', md: 'lg' }}
|
||||
bg="yellow.400"
|
||||
color="black"
|
||||
>
|
||||
<Text fontSize={{ base: '2xl', md: '3xl' }}>{feature.icon}</Text>
|
||||
<Text fontSize={{ base: 'xl', md: '2xl' }}>{feature.icon}</Text>
|
||||
</Box>
|
||||
<VStack align="start" spacing={{ base: 1, md: 2 }} flex={1}>
|
||||
<VStack align="start" spacing={{ base: 0.5, md: 1 }} flex={1}>
|
||||
<HStack>
|
||||
<Heading size={{ base: 'lg', md: 'xl' }} color="white">
|
||||
<Heading size={{ base: 'md', md: 'lg' }} color="white">
|
||||
{feature.title}
|
||||
</Heading>
|
||||
<Badge colorScheme="yellow" variant="solid" fontSize={{ base: 'xs', md: 'sm' }}>
|
||||
<Badge colorScheme="yellow" variant="solid" fontSize={{ base: '2xs', md: 'xs' }}>
|
||||
{feature.badge}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text
|
||||
color="whiteAlpha.800"
|
||||
fontSize={{ base: 'md', md: 'lg' }}
|
||||
maxW={{ md: 'md' }}
|
||||
fontSize={{ base: 'xs', md: 'sm' }}
|
||||
maxW={{ md: 'sm' }}
|
||||
lineHeight="tall"
|
||||
>
|
||||
{feature.description}
|
||||
@@ -87,12 +87,12 @@ export const FeaturedFeatureCard: React.FC<FeaturedFeatureCardProps> = ({
|
||||
</Flex>
|
||||
<Button
|
||||
colorScheme="yellow"
|
||||
size={{ base: 'md', md: 'lg' }}
|
||||
size={{ base: 'sm', md: 'md' }}
|
||||
borderRadius="full"
|
||||
fontWeight="bold"
|
||||
w={{ base: '100%', md: 'auto' }}
|
||||
onClick={() => onClick(feature)}
|
||||
minH="44px"
|
||||
minH="36px"
|
||||
flexShrink={0}
|
||||
>
|
||||
进入功能 →
|
||||
|
||||
@@ -21,9 +21,9 @@ export const HeroHeader: React.FC<HeroHeaderProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<VStack
|
||||
spacing={{ base: 4, md: 5, lg: 6 }}
|
||||
spacing={{ base: 2, md: 3, lg: 4 }}
|
||||
textAlign="center"
|
||||
pt={{ base: 4, md: 6, lg: 8 }}
|
||||
pt={{ base: 2, md: 4, lg: 5 }}
|
||||
>
|
||||
<Heading
|
||||
size={headingSize}
|
||||
@@ -37,9 +37,9 @@ export const HeroHeader: React.FC<HeroHeaderProps> = ({
|
||||
<Text
|
||||
fontSize={heroTextSize}
|
||||
color="whiteAlpha.800"
|
||||
maxW={{ base: '100%', md: '2xl', lg: '3xl' }}
|
||||
maxW={{ base: '100%', md: 'xl', lg: '2xl' }}
|
||||
lineHeight="tall"
|
||||
px={{ base: 4, md: 0 }}
|
||||
px={{ base: 2, md: 0 }}
|
||||
>
|
||||
专业投资研究工具,助您把握市场机遇
|
||||
</Text>
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
Spacer,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
useColorMode,
|
||||
useToast,
|
||||
Spinner,
|
||||
Center,
|
||||
@@ -49,14 +48,11 @@ import {
|
||||
TagLabel,
|
||||
Skeleton,
|
||||
SkeletonText,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverBody,
|
||||
} from '@chakra-ui/react';
|
||||
import { SearchIcon, CloseIcon, ArrowForwardIcon, TrendingUpIcon, InfoIcon, ChevronRightIcon, MoonIcon, SunIcon, CalendarIcon } from '@chakra-ui/icons';
|
||||
import { SearchIcon, CloseIcon, ArrowForwardIcon, TrendingUpIcon, InfoIcon, ChevronRightIcon, CalendarIcon } from '@chakra-ui/icons';
|
||||
import { FaChartLine, FaFire, FaRocket, FaBrain, FaCalendarAlt, FaChevronRight, FaArrowUp, FaArrowDown, FaChartBar } from 'react-icons/fa';
|
||||
import ConceptStocksModal from './components/ConceptStocksModal';
|
||||
import ConceptStocksModal from '@components/ConceptStocksModal';
|
||||
import TradeDatePicker from '@components/TradeDatePicker';
|
||||
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
|
||||
import * as echarts from 'echarts';
|
||||
import { logger } from '../../utils/logger';
|
||||
@@ -71,7 +67,7 @@ const tradingDaysSet = new Set(tradingDays);
|
||||
const StockOverview = () => {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const { colorMode, toggleColorMode } = useColorMode();
|
||||
const colorMode = 'light'; // 固定为 light 模式
|
||||
const heatmapRef = useRef(null);
|
||||
const heatmapChart = useRef(null);
|
||||
|
||||
@@ -101,7 +97,6 @@ const StockOverview = () => {
|
||||
const [selectedDate, setSelectedDate] = useState(null);
|
||||
const [marketStats, setMarketStats] = useState(null);
|
||||
const [availableDates, setAvailableDates] = useState([]);
|
||||
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
||||
|
||||
// 个股列表弹窗状态
|
||||
const [isStockModalOpen, setIsStockModalOpen] = useState(false);
|
||||
@@ -190,7 +185,7 @@ const StockOverview = () => {
|
||||
if (data.success) {
|
||||
setTopConcepts(data.data);
|
||||
// 使用概念接口的日期作为统一数据源(数据最新)
|
||||
setSelectedDate(data.trade_date);
|
||||
setSelectedDate(new Date(data.trade_date));
|
||||
// 基于交易日历生成可选日期列表
|
||||
if (data.trade_date && tradingDays.length > 0) {
|
||||
// 找到当前日期或最近的交易日
|
||||
@@ -518,20 +513,6 @@ const StockOverview = () => {
|
||||
window.open(htmlPath, '_blank');
|
||||
};
|
||||
|
||||
// 处理日期选择
|
||||
const handleDateChange = (date) => {
|
||||
const previousDate = selectedDate;
|
||||
|
||||
// 🎯 追踪日期变化
|
||||
trackDateChanged(date, previousDate);
|
||||
|
||||
setSelectedDate(date);
|
||||
setIsCalendarOpen(false);
|
||||
// 重新获取数据
|
||||
fetchHeatmapData(date);
|
||||
fetchMarketStats(date);
|
||||
fetchTopConcepts(date);
|
||||
};
|
||||
|
||||
// 格式化涨跌幅
|
||||
const formatChangePercent = (value) => {
|
||||
@@ -620,25 +601,6 @@ const StockOverview = () => {
|
||||
filter="blur(40px)"
|
||||
/>
|
||||
|
||||
{/* 日夜模式切换按钮 */}
|
||||
<Box position="absolute" top={4} right={4}>
|
||||
<IconButton
|
||||
aria-label="Toggle color mode"
|
||||
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
|
||||
onClick={toggleColorMode}
|
||||
size="lg"
|
||||
bg={colorMode === 'dark' ? '#1a1a1a' : 'white'}
|
||||
color={colorMode === 'dark' ? goldColor : 'purple.600'}
|
||||
border="2px solid"
|
||||
borderColor={colorMode === 'dark' ? goldColor : 'purple.200'}
|
||||
_hover={{
|
||||
bg: colorMode === 'dark' ? '#2a2a2a' : 'purple.50',
|
||||
transform: 'scale(1.1)'
|
||||
}}
|
||||
transition="all 0.3s"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Container maxW="container.xl" position="relative">
|
||||
<VStack spacing={8} align="center">
|
||||
<VStack spacing={4} textAlign="center" maxW="3xl">
|
||||
@@ -853,60 +815,27 @@ const StockOverview = () => {
|
||||
<Container maxW="container.xl" py={10}>
|
||||
{/* 日期选择器 */}
|
||||
<Box mb={6}>
|
||||
<Popover isOpen={isCalendarOpen} onClose={() => setIsCalendarOpen(false)}>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
leftIcon={<CalendarIcon />}
|
||||
onClick={() => setIsCalendarOpen(!isCalendarOpen)}
|
||||
variant="outline"
|
||||
size="md"
|
||||
colorScheme={colorMode === 'dark' ? 'yellow' : 'purple'}
|
||||
bg={colorMode === 'dark' ? '#1a1a1a' : 'white'}
|
||||
border="2px solid"
|
||||
borderColor={colorMode === 'dark' ? goldColor : 'purple.300'}
|
||||
_hover={{
|
||||
bg: colorMode === 'dark' ? '#2a2a2a' : 'purple.50'
|
||||
}}
|
||||
>
|
||||
{selectedDate ?
|
||||
`交易日期: ${selectedDate}` :
|
||||
'选择交易日期'
|
||||
}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent bg={cardBg} borderColor={borderColor} boxShadow="xl">
|
||||
<PopoverBody p={4}>
|
||||
<VStack align="start" spacing={3}>
|
||||
<Text fontWeight="bold" color={textColor}>选择交易日期</Text>
|
||||
<Divider />
|
||||
{availableDates.length > 0 ? (
|
||||
<VStack align="stretch" maxH="300px" overflowY="auto" spacing={1} w="100%">
|
||||
{availableDates.map((date) => (
|
||||
<Button
|
||||
key={date}
|
||||
size="sm"
|
||||
variant={selectedDate === date ? 'solid' : 'ghost'}
|
||||
colorScheme={selectedDate === date ? (colorMode === 'dark' ? 'yellow' : 'purple') : 'gray'}
|
||||
onClick={() => handleDateChange(date)}
|
||||
justifyContent="start"
|
||||
w="100%"
|
||||
>
|
||||
{date}
|
||||
</Button>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<Text fontSize="sm" color={subTextColor}>
|
||||
暂无可用日期
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Flex align="center" gap={4} flexWrap="wrap">
|
||||
<TradeDatePicker
|
||||
value={selectedDate}
|
||||
onChange={(date) => {
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
const previousDateStr = selectedDate ? selectedDate.toISOString().split('T')[0] : null;
|
||||
trackDateChanged(dateStr, previousDateStr);
|
||||
setSelectedDate(date);
|
||||
fetchHeatmapData(dateStr);
|
||||
fetchMarketStats(dateStr);
|
||||
fetchTopConcepts(dateStr);
|
||||
}}
|
||||
latestTradeDate={null}
|
||||
minDate={tradingDays.length > 0 ? new Date(tradingDays[0]) : undefined}
|
||||
maxDate={tradingDays.length > 0 ? new Date(tradingDays[tradingDays.length - 1]) : undefined}
|
||||
label="交易日期"
|
||||
/>
|
||||
</Flex>
|
||||
{selectedDate && (
|
||||
<Text fontSize="sm" color={subTextColor} mt={2}>
|
||||
当前显示 {selectedDate} 的市场数据
|
||||
当前显示 {selectedDate.toISOString().split('T')[0]} 的市场数据
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user