diff --git a/public/index.html b/public/index.html index f8c1236a..b20b1514 100755 --- a/public/index.html +++ b/public/index.html @@ -4,7 +4,7 @@ diff --git a/src/bytedesk-integration/components/BytedeskWidget.jsx b/src/bytedesk-integration/components/BytedeskWidget.jsx index 58127331..af2eddcf 100644 --- a/src/bytedesk-integration/components/BytedeskWidget.jsx +++ b/src/bytedesk-integration/components/BytedeskWidget.jsx @@ -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'; diff --git a/src/bytedesk-integration/config/bytedesk.config.js b/src/bytedesk-integration/config/bytedesk.config.js index 9d12247e..f18ea799 100644 --- a/src/bytedesk-integration/config/bytedesk.config.js +++ b/src/bytedesk-integration/config/bytedesk.config.js @@ -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配置对象 */ diff --git a/src/views/StockOverview/components/ConceptStocksModal.tsx b/src/components/ConceptStocksModal/index.tsx similarity index 83% rename from src/views/StockOverview/components/ConceptStocksModal.tsx rename to src/components/ConceptStocksModal/index.tsx index 998f217e..2541cd7d 100644 --- a/src/views/StockOverview/components/ConceptStocksModal.tsx +++ b/src/components/ConceptStocksModal/index.tsx @@ -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 = ({ 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 = ({ - + @@ -156,14 +164,15 @@ const ConceptStocksModal: React.FC = ({ )} - - + +
- - - - + + + + + @@ -210,6 +219,9 @@ const ConceptStocksModal: React.FC = ({ '-' )} + ); })} @@ -219,12 +231,6 @@ const ConceptStocksModal: React.FC = ({ )} - - - - ); diff --git a/src/components/ErrorPage/index.tsx b/src/components/ErrorPage/index.tsx new file mode 100644 index 00000000..14edb446 --- /dev/null +++ b/src/components/ErrorPage/index.tsx @@ -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 = ({ + title = '加载失败', + description, + detail, + detailLabel = 'ID', + showRetry = false, + onRetry, + showBack = false, + onBack, + fullScreen = true, +}) => { + const hasButtons = (showRetry && onRetry) || (showBack && onBack); + + return ( + + + {/* 金色圆形图标 + 黑色感叹号 */} + + + + + {/* 金色标题 */} + + {title} + + + {/* 描述信息 */} + {description && ( + + {description} + + )} + + {/* 详情 */} + {detail && ( + + {detailLabel}: {detail} + + )} + + {/* 按钮组 */} + {hasButtons && ( + + {showBack && onBack && ( + + )} + {showRetry && onRetry && ( + + )} + + )} + + + ); +}; + +export default ErrorPage; diff --git a/src/components/Navbars/HomeNavbar.js b/src/components/Navbars/HomeNavbar.js index 288b0ca0..ab43deaf 100644 --- a/src/components/Navbars/HomeNavbar.js +++ b/src/components/Navbars/HomeNavbar.js @@ -161,7 +161,7 @@ export default function HomeNavbar() { borderColor={navbarBorder} py={{ base: 2, md: 3 }} > - + {/* Logo - 价小前投研 */} diff --git a/src/components/Navbars/components/FeatureMenus/FollowingEventsMenu.js b/src/components/Navbars/components/FeatureMenus/FollowingEventsMenu.js index 50e93c96..a062c7c2 100644 --- a/src/components/Navbars/components/FeatureMenus/FollowingEventsMenu.js +++ b/src/components/Navbars/components/FeatureMenus/FollowingEventsMenu.js @@ -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(() => { navigate(`/event-detail/${ev.id}`)} + onClick={() => navigate(getEventDetailUrl(ev.id))} > diff --git a/src/components/TradeDatePicker/index.tsx b/src/components/TradeDatePicker/index.tsx new file mode 100644 index 00000000..362093b5 --- /dev/null +++ b/src/components/TradeDatePicker/index.tsx @@ -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 = ({ + 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) => { + 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 ( + <> + {/* 标签 */} + + {showIcon && } + + {label}: + + + + {/* 日期输入框 */} + + + {/* 最新交易日期提示 */} + {latestTradeDate && ( + + + + + 最新: {latestTradeDate.toLocaleDateString('zh-CN')} + + + + )} + + ); +}; + +export default TradeDatePicker; diff --git a/src/contexts/NotificationContext.js b/src/contexts/NotificationContext.js index 73acc176..0ab4a93b 100644 --- a/src/contexts/NotificationContext.js +++ b/src/contexts/NotificationContext.js @@ -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, diff --git a/src/hooks/useHomeResponsive.ts b/src/hooks/useHomeResponsive.ts index 5a2f7f76..c803e7df 100644 --- a/src/hooks/useHomeResponsive.ts +++ b/src/hooks/useHomeResponsive.ts @@ -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({ diff --git a/src/hooks/useWatchlist.js b/src/hooks/useWatchlist.js index 5a120dd4..4c17e812 100644 --- a/src/hooks/useWatchlist.js +++ b/src/hooks/useWatchlist.js @@ -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' }, diff --git a/src/layouts/AppFooter.js b/src/layouts/AppFooter.js index ec1ed50e..33499fa7 100644 --- a/src/layouts/AppFooter.js +++ b/src/layouts/AppFooter.js @@ -23,7 +23,13 @@ const AppFooter = () => { > 京公网安备11010802046286号 - 京ICP备2025107343号-1 + + 京ICP备2025107343号-1 + diff --git a/src/layouts/MainLayout.js b/src/layouts/MainLayout.js index 372b34ed..dbb64f09 100644 --- a/src/layouts/MainLayout.js +++ b/src/layouts/MainLayout.js @@ -30,12 +30,12 @@ const MemoizedAppFooter = memo(AppFooter); */ export default function MainLayout() { return ( - + {/* 导航栏 - 在所有页面间共享,memo 后不会在路由切换时重新渲染 */} {/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */} - + }> diff --git a/src/mocks/handlers/account.js b/src/mocks/handlers/account.js index 2e4d46c8..a415b318 100644 --- a/src/mocks/handlers/account.js +++ b/src/mocks/handlers/account.js @@ -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(); diff --git a/src/mocks/handlers/event.js b/src/mocks/handlers/event.js index 1fe1e1c8..a328d079 100644 --- a/src/mocks/handlers/event.js +++ b/src/mocks/handlers/event.js @@ -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); diff --git a/src/mocks/handlers/stock.js b/src/mocks/handlers/stock.js index 701c97f0..eb3ef037 100644 --- a/src/mocks/handlers/stock.js +++ b/src/mocks/handlers/stock.js @@ -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 } + ); + } + }), ]; diff --git a/src/routes/routeConfig.js b/src/routes/routeConfig.js index 17d3fd24..220e11d2 100644 --- a/src/routes/routeConfig.js +++ b/src/routes/routeConfig.js @@ -98,7 +98,7 @@ export const routeConfig = [ // ==================== 事件模块 ==================== { - path: 'event-detail/:eventId', + path: 'event-detail', component: lazyComponents.EventDetail, protection: PROTECTION_MODES.REDIRECT, layout: 'main', diff --git a/src/styles/bytedesk-override.css b/src/styles/bytedesk-override.css index 884a6651..d2e1eea5 100644 --- a/src/styles/bytedesk-override.css +++ b/src/styles/bytedesk-override.css @@ -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 覆盖层(如果存在) */ diff --git a/src/theme/theme.js b/src/theme/theme.js index e8d34aff..48471c58 100755 --- a/src/theme/theme.js +++ b/src/theme/theme.js @@ -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 背景透明 ); diff --git a/src/utils/idEncoder.ts b/src/utils/idEncoder.ts new file mode 100644 index 00000000..0f7d85d5 --- /dev/null +++ b/src/utils/idEncoder.ts @@ -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)}`; +}; diff --git a/src/views/Community/components/EventDetailModal.less b/src/views/Community/components/EventDetailModal.less index c448bed8..e2378b3f 100644 --- a/src/views/Community/components/EventDetailModal.less +++ b/src/views/Community/components/EventDetailModal.less @@ -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; - } } diff --git a/src/views/Community/components/EventDetailModal.tsx b/src/views/Community/components/EventDetailModal.tsx index acd94f50..315e6846 100644 --- a/src/views/Community/components/EventDetailModal.tsx +++ b/src/views/Community/components/EventDetailModal.tsx @@ -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 = ({ open, @@ -25,23 +26,35 @@ const EventDetailModal: React.FC = ({ const isMobile = useSelector(selectIsMobile); return ( - + } 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 && } - + ); }; diff --git a/src/views/Community/components/HotEvents.js b/src/views/Community/components/HotEvents.js index cd403793..ec5a3395 100644 --- a/src/views/Community/components/HotEvents.js +++ b/src/views/Community/components/HotEvents.js @@ -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 */}
- + {isMobile ? ( {event.title} - + ) : ( + + + {event.title} + + + )} {renderPriceChange(event.related_avg_chg)}
- + {isMobile ? (
{event.description}
-
+ ) : ( + +
+ {event.description} +
+
+ )}
{event.creator?.username || 'Anonymous'} diff --git a/src/views/Community/hooks/useEventFilters.js b/src/views/Community/hooks/useEventFilters.js index be2ff8ae..51206708 100644 --- a/src/views/Community/hooks/useEventFilters.js +++ b/src/views/Community/hooks/useEventFilters.js @@ -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]); diff --git a/src/views/Concept/ConceptTimelineModal.js b/src/views/Concept/ConceptTimelineModal.js index ac271ee7..f002838a 100644 --- a/src/views/Concept/ConceptTimelineModal.js +++ b/src/views/Concept/ConceptTimelineModal.js @@ -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 > - + - + {conceptName} - 历史时间轴 最近100天 @@ -640,20 +648,29 @@ const ConceptTimelineModal = ({ 🔥 Max版功能 - + ) : timelineData.length > 0 ? ( - - {/* 图例说明 */} - + + {/* 图例说明 - H5端保持一行 */} + - - 📰 新闻 + + 📰 新闻 - - 📊 研报 + + 📊 研报 - - 上涨 + + 上涨 - - 下跌 + + 下跌 - 🔥 - 涨3%+ + 🔥 + 涨3%+ {/* FullCalendar 日历组件 */} @@ -882,32 +924,11 @@ const ConceptTimelineModal = ({ )} {/* 风险提示 */} - + - - - )} diff --git a/src/views/Concept/index.js b/src/views/Concept/index.js index cfcb9464..ad159e46 100644 --- a/src/views/Concept/index.js +++ b/src/views/Concept/index.js @@ -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 暂无相关股票数据; - } - - 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 ( - - {loadingStockData && ( - - - - 正在获取行情数据... - - - )} - - -
股票名称股票代码现价涨跌幅股票名称股票代码现价当日涨跌幅板块原因
+ {stock.reason || '-'} +
- - - {orderedFields.map(field => ( - - ))} - - - - {selectedConceptStocks.map((stock, idx) => { - const marketData = stockMarketData[stock.stock_code]; - const companyLink = generateCompanyLink(stock.stock_code); - - return ( - - {orderedFields.map(field => { - let cellContent = stock[field] || '-'; - let cellProps = {}; - - // 处理特殊字段 - if (field === 'current_price') { - cellContent = marketData ? formatPrice(marketData.close) : (loadingStockData ? : '-'); - } 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 ? : '-'; - } - } else if (field === 'stock_name' || field === 'stock_code') { - // 添加超链接 - cellContent = ( - - {stock[field] || '-'} - - ); - } - - return ( - - ); - })} - - ); - })} - -
- {field === 'stock_name' ? '股票名称' : - field === 'stock_code' ? '股票代码' : - field === 'current_price' ? '现价' : - field === 'change_percent' ? '当日涨跌幅' : field} -
- {cellContent} -
-
- - ); - }; - // 格式化添加日期显示 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)" > {/* 毛玻璃涨幅数字背景 */} - + {/* 渐变背景层 */} { top="50%" left="50%" transform="translate(-50%, -50%)" - width="120px" - height="120px" + width={logoSize} + height={logoSize} opacity={0.15} > { - - + + {/* 概念名称 */} { {concept.concept} - {/* 描述信息 */} - + {/* 描述信息 - H5端显示1行 */} + {concept.description || '暂无描述信息'} {concept.stocks && concept.stocks.length > 0 && ( { > - + 热门个股 @@ -942,20 +846,20 @@ const ConceptCenter = () => { )} - + {formatAddedDate(concept)} - - {latestTradeDate && ( - - - - - 最新: {latestTradeDate.toLocaleDateString('zh-CN')} - - - - )} ); @@ -1598,7 +1483,7 @@ const ConceptCenter = () => { )} {loading ? ( - + {[...Array(12)].map((_, i) => ( ))} @@ -1606,7 +1491,7 @@ const ConceptCenter = () => { ) : concepts.length > 0 ? ( <> {viewMode === 'grid' ? ( - + {concepts.map((concept, index) => ( @@ -1758,32 +1643,15 @@ const ConceptCenter = () => { - {/* 股票详情Modal */} - setIsStockModalOpen(false)} - size="6xl" - scrollBehavior="inside" - > - - - - - - {selectedConceptName} - 相关个股 - - - - - {renderStockTable()} - - - - - - + concept={{ + concept_name: selectedConceptName, + stocks: selectedConceptStocks + }} + /> {/* 时间轴Modal */} {event.title} diff --git a/src/views/EventDetail/index.js b/src/views/EventDetail/index.js index ba8e988c..c6ac3ab5 100644 --- a/src/views/EventDetail/index.js +++ b/src/views/EventDetail/index.js @@ -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 ( - - - - - {label} - {value} - - - - ); -}; - -// 帖子组件 -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 ( - - {/* 帖子头部 */} - - - - - {post.user?.username || '匿名用户'} - - {format(new Date(post.created_at), 'yyyy-MM-dd HH:mm', { locale: zhCN })} - - - - } - variant="ghost" - size="sm" - onClick={handleDelete} - /> - - - {/* 帖子内容 */} - {post.title && ( - - {post.title} - - )} - - {post.content} - - - {/* 操作栏 */} - - - - - - {/* 评论区 */} - - - {/* 评论输入 */} - -