Files
vf_react/src/views/Community/components/HeroPanel.js

3165 lines
114 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/views/Community/components/HeroPanel.js
// 综合日历面板:融合涨停分析 + 投资日历
// 点击日期弹出详情弹窗TAB切换历史涨停/未来事件)
import React, { useEffect, useState, useCallback, useMemo, memo, lazy, Suspense } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { loadWatchlist, toggleWatchlist } from '@store/slices/stockSlice';
import {
Box,
Card,
CardBody,
Flex,
VStack,
HStack,
Text,
Heading,
useColorModeValue,
useDisclosure,
Icon,
Spinner,
Center,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
Tooltip,
Badge,
SimpleGrid,
IconButton,
Drawer,
DrawerOverlay,
DrawerContent,
DrawerHeader,
DrawerBody,
DrawerCloseButton,
} from '@chakra-ui/react';
import { Table, Tabs, Tag, Space, Button, Spin, Typography, message } from 'antd';
import {
CalendarOutlined,
StarFilled,
LinkOutlined,
StockOutlined,
TagsOutlined,
ClockCircleOutlined,
RobotOutlined,
FireOutlined,
LineChartOutlined,
StarOutlined,
} from '@ant-design/icons';
import { AlertCircle, Clock, Info, Calendar, ChevronLeft, ChevronRight, Flame, TrendingUp, TrendingDown, FileText, Star } from 'lucide-react';
import { GLASS_BLUR } from '@/constants/glassConfig';
import { eventService } from '@services/eventService';
import { getApiBase } from '@utils/apiConfig';
import ReactMarkdown from 'react-markdown';
import dayjs from 'dayjs';
import KLineChartModal from '@components/StockChart/KLineChartModal';
// 懒加载 FullCalendar约 60KB gzip延迟加载提升首屏性能
const FullCalendarPro = lazy(() =>
import('@components/Calendar').then(module => ({ default: module.FullCalendarPro }))
);
import ThemeCometChart from './ThemeCometChart';
import EventDailyStats from './EventDailyStats';
const { TabPane } = Tabs;
const { Text: AntText } = Typography;
// 定义动画和深色主题样式
const animations = `
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(1.1); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* Chakra Drawer 滚动修复 */
.chakra-modal__content-container {
overflow: hidden !important;
}
.hero-detail-drawer .chakra-modal__body {
overflow-y: auto !important;
max-height: calc(100vh - 80px) !important;
}
.hero-detail-drawer .chakra-modal__body::-webkit-scrollbar {
width: 8px;
}
.hero-detail-drawer .chakra-modal__body::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
.hero-detail-drawer .chakra-modal__body::-webkit-scrollbar-thumb {
background: rgba(255, 215, 0, 0.4);
border-radius: 4px;
}
.hero-detail-drawer .chakra-modal__body::-webkit-scrollbar-thumb:hover {
background: rgba(255, 215, 0, 0.6);
}
/* Ant Design 深色主题覆盖 - 弹窗专用 */
.hero-panel-modal .ant-tabs {
color: rgba(255, 255, 255, 0.85);
}
.hero-panel-modal .ant-tabs-nav::before {
border-color: rgba(255, 215, 0, 0.2) !important;
}
.hero-panel-modal .ant-tabs-tab {
color: rgba(255, 255, 255, 0.65) !important;
font-size: 15px !important;
}
.hero-panel-modal .ant-tabs-tab:hover {
color: #FFD700 !important;
}
.hero-panel-modal .ant-tabs-tab-active .ant-tabs-tab-btn {
color: #FFD700 !important;
}
.hero-panel-modal .ant-tabs-ink-bar {
background: linear-gradient(90deg, #FFD700, #FFA500) !important;
}
/* 表格深色主题 */
.hero-panel-modal .ant-table {
background: transparent !important;
color: rgba(255, 255, 255, 0.85) !important;
}
.hero-panel-modal .ant-table-thead > tr > th {
background: rgba(255, 215, 0, 0.1) !important;
color: #FFD700 !important;
border-bottom: 1px solid rgba(255, 215, 0, 0.2) !important;
font-weight: 600 !important;
font-size: 14px !important;
}
.hero-panel-modal .ant-table-tbody > tr > td {
background: transparent !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
color: rgba(255, 255, 255, 0.85) !important;
font-size: 14px !important;
}
.hero-panel-modal .ant-table-tbody > tr:hover > td {
background: rgba(255, 215, 0, 0.08) !important;
}
.hero-panel-modal .ant-table-tbody > tr.ant-table-row:hover > td {
background: rgba(255, 215, 0, 0.1) !important;
}
.hero-panel-modal .ant-table-cell-row-hover {
background: rgba(255, 215, 0, 0.08) !important;
}
.hero-panel-modal .ant-table-placeholder {
background: transparent !important;
}
.hero-panel-modal .ant-empty-description {
color: rgba(255, 255, 255, 0.45) !important;
}
/* 滚动条样式 */
.hero-panel-modal .ant-table-body::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.hero-panel-modal .ant-table-body::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
}
.hero-panel-modal .ant-table-body::-webkit-scrollbar-thumb {
background: rgba(255, 215, 0, 0.3);
border-radius: 3px;
}
.hero-panel-modal .ant-table-body::-webkit-scrollbar-thumb:hover {
background: rgba(255, 215, 0, 0.5);
}
/* 板块股票表格滚动 - 针对 Ant Design 5.x */
.sector-stocks-table-wrapper {
max-height: 450px;
overflow: hidden;
}
.sector-stocks-table-wrapper .ant-table-wrapper,
.sector-stocks-table-wrapper .ant-table,
.sector-stocks-table-wrapper .ant-table-container {
max-height: 100%;
}
.sector-stocks-table-wrapper .ant-table-body {
max-height: 380px !important;
overflow-y: auto !important;
scrollbar-width: thin;
scrollbar-color: rgba(255, 215, 0, 0.4) rgba(255, 255, 255, 0.05);
}
/* 相关股票表格滚动 */
.related-stocks-table-wrapper .ant-table-body {
scrollbar-width: thin;
scrollbar-color: rgba(255, 215, 0, 0.4) rgba(255, 255, 255, 0.05);
}
/* Tag 样式优化 */
.hero-panel-modal .ant-tag {
border-radius: 4px !important;
}
/* Button link 样式 */
.hero-panel-modal .ant-btn-link {
color: #FFD700 !important;
}
.hero-panel-modal .ant-btn-link:hover {
color: #FFA500 !important;
}
.hero-panel-modal .ant-btn-link:disabled {
color: rgba(255, 255, 255, 0.25) !important;
}
/* Typography 样式 */
.hero-panel-modal .ant-typography {
color: rgba(255, 255, 255, 0.85) !important;
}
.hero-panel-modal .ant-typography-secondary {
color: rgba(255, 255, 255, 0.45) !important;
}
/* Spin 加载样式 */
.hero-panel-modal .ant-spin-text {
color: #FFD700 !important;
}
.hero-panel-modal .ant-spin-dot-item {
background-color: #FFD700 !important;
}
`;
// 注入样式
if (typeof document !== 'undefined') {
const styleId = 'hero-panel-animations';
if (!document.getElementById(styleId)) {
const styleSheet = document.createElement('style');
styleSheet.id = styleId;
styleSheet.innerText = animations;
document.head.appendChild(styleSheet);
}
}
/**
* 判断当前是否在交易时间内
*/
const isInTradingTime = () => {
const now = new Date();
const timeInMinutes = now.getHours() * 60 + now.getMinutes();
return timeInMinutes >= 570 && timeInMinutes <= 900;
};
// 主题色配置
const goldColors = {
primary: '#D4AF37',
light: '#F4D03F',
dark: '#B8860B',
glow: 'rgba(212, 175, 55, 0.4)',
};
const textColors = {
primary: '#ffffff',
secondary: 'rgba(255, 255, 255, 0.85)',
muted: 'rgba(255, 255, 255, 0.5)',
};
// 热度级别配置
const HEAT_LEVELS = [
{ key: 'high', threshold: 80, colors: { bg: 'rgba(147, 51, 234, 0.55)', text: '#d8b4fe', border: 'rgba(147, 51, 234, 0.65)' } },
{ key: 'medium', threshold: 60, colors: { bg: 'rgba(239, 68, 68, 0.50)', text: '#fca5a5', border: 'rgba(239, 68, 68, 0.60)' } },
{ key: 'low', threshold: 40, colors: { bg: 'rgba(251, 146, 60, 0.45)', text: '#fed7aa', border: 'rgba(251, 146, 60, 0.55)' } },
{ key: 'cold', threshold: 0, colors: { bg: 'rgba(59, 130, 246, 0.35)', text: '#93c5fd', border: 'rgba(59, 130, 246, 0.45)' } },
];
const DEFAULT_HEAT_COLORS = {
bg: 'rgba(60, 60, 70, 0.12)',
text: textColors.muted,
border: 'transparent',
};
const getHeatColor = (count) => {
if (!count) return DEFAULT_HEAT_COLORS;
const level = HEAT_LEVELS.find((l) => count >= l.threshold);
return level?.colors || DEFAULT_HEAT_COLORS;
};
// 日期格式化
const formatDateStr = (date) => {
if (!date) return '';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}${month}${day}`;
};
const WEEK_DAYS = ['日', '一', '二', '三', '四', '五', '六'];
const MONTH_NAMES = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
/**
* 趋势图标
*/
const TrendIcon = memo(({ current, previous }) => {
if (!current || !previous) return null;
const diff = current - previous;
if (diff === 0) return null;
const isUp = diff > 0;
return (
<Icon
as={isUp ? TrendingUp : TrendingDown}
boxSize={3}
color={isUp ? '#22c55e' : '#ef4444'}
/>
);
});
TrendIcon.displayName = 'TrendIcon';
/**
* 日历单元格 - 显示涨停数和事件数(加大尺寸)
* 新增连续概念连接展示connectLeft/connectRight 表示与左右格子是否同一概念)
*/
const CalendarCell = memo(({ date, ztData, eventCount, previousZtData, isSelected, isToday, isWeekend, onClick, connectLeft, connectRight }) => {
if (!date) {
return <Box minH="75px" />;
}
const hasZtData = !!ztData;
const hasEventData = eventCount > 0;
const ztCount = ztData?.count || 0;
const heatColors = getHeatColor(ztCount);
const topSector = ztData?.top_sector || '';
// 是否有连接线(连续概念)
const hasConnection = connectLeft || connectRight;
// 周末无数据显示"休市"
if (isWeekend && !hasZtData && !hasEventData) {
return (
<Box
p={2}
borderRadius="10px"
bg="rgba(30, 30, 40, 0.3)"
border="1px solid rgba(255, 255, 255, 0.03)"
textAlign="center"
minH="75px"
display="flex"
flexDirection="column"
justifyContent="center"
alignItems="center"
>
<Text fontSize="md" fontWeight="400" color="rgba(255, 255, 255, 0.25)">
{date.getDate()}
</Text>
<Text fontSize="xs" color="rgba(255, 255, 255, 0.2)">
休市
</Text>
</Box>
);
}
// 正常日期
return (
<Tooltip
label={
<VStack spacing={1} align="start" p={1}>
<Text fontWeight="bold" fontSize="md">{`${date.getMonth() + 1}${date.getDate()}`}</Text>
{hasZtData && <Text>涨停: {ztCount} {topSector && `| ${topSector}`}</Text>}
{hasEventData && <Text>未来事件: {eventCount}</Text>}
{!hasZtData && !hasEventData && <Text color="gray.400">暂无数据</Text>}
</VStack>
}
placement="top"
hasArrow
bg="rgba(15, 15, 22, 0.95)"
border="1px solid rgba(212, 175, 55, 0.3)"
borderRadius="10px"
>
<Box
as="button"
p={2}
borderRadius="10px"
bg={hasZtData ? heatColors.bg : hasEventData ? 'rgba(34, 197, 94, 0.2)' : 'rgba(40, 40, 50, 0.3)'}
border={
isSelected
? `2px solid ${goldColors.primary}`
: isToday
? `2px solid ${goldColors.light}`
: hasZtData
? `1px solid ${heatColors.border}`
: hasEventData
? '1px solid rgba(34, 197, 94, 0.4)'
: '1px solid rgba(255, 255, 255, 0.08)'
}
boxShadow={isSelected ? `0 0 15px ${goldColors.glow}` : isToday ? `0 0 10px ${goldColors.glow}` : 'none'}
position="relative"
cursor="pointer"
transition="all 0.2s"
_hover={{
transform: 'scale(1.05)',
boxShadow: '0 6px 20px rgba(0, 0, 0, 0.4)',
borderColor: goldColors.primary,
}}
onClick={() => onClick && onClick(date)}
w="full"
minH="75px"
>
{/* 今天标记 */}
{isToday && (
<Badge
position="absolute"
top="2px"
right="2px"
bg="rgba(239, 68, 68, 0.9)"
color="white"
fontSize="9px"
px={1}
borderRadius="sm"
>
今天
</Badge>
)}
<VStack spacing={0.5} align="center">
{/* 日期 */}
<Text
fontSize="lg"
fontWeight={isSelected || isToday ? 'bold' : '600'}
color={isSelected ? goldColors.primary : isToday ? goldColors.light : textColors.primary}
>
{date.getDate()}
</Text>
{/* 涨停数 + 趋势 */}
{hasZtData && (
<HStack spacing={1} justify="center">
<Icon as={Flame} boxSize={3} color={heatColors.text} />
<Text fontSize="sm" fontWeight="bold" color={heatColors.text}>
{ztCount}
</Text>
<TrendIcon current={ztCount} previous={previousZtData?.count} />
</HStack>
)}
{/* 事件数 */}
{hasEventData && (
<HStack spacing={1} justify="center">
<Icon as={FileText} boxSize={3} color="#22c55e" />
<Text fontSize="sm" fontWeight="bold" color="#22c55e">
{eventCount}
</Text>
</HStack>
)}
{/* 主要板块 - 连续概念用连接样式 */}
{hasZtData && topSector && (
<Box position="relative" w="full" display="flex" justifyContent="center" alignItems="center">
{/* 左连接线 */}
{connectLeft && (
<Box
position="absolute"
left="-12px"
top="50%"
transform="translateY(-50%)"
w="12px"
h="2px"
bgGradient="linear(to-r, rgba(212,175,55,0.6), rgba(212,175,55,0.3))"
/>
)}
<Text
fontSize="xs"
color={hasConnection ? goldColors.primary : textColors.secondary}
fontWeight={hasConnection ? 'bold' : 'normal'}
noOfLines={1}
maxW="70px"
bg={hasConnection ? 'rgba(212,175,55,0.15)' : 'transparent'}
px={hasConnection ? 1.5 : 0}
py={hasConnection ? 0.5 : 0}
borderRadius={hasConnection ? 'full' : 'none'}
border={hasConnection ? '1px solid rgba(212,175,55,0.3)' : 'none'}
>
{topSector}
</Text>
{/* 右连接线 */}
{connectRight && (
<Box
position="absolute"
right="-12px"
top="50%"
transform="translateY(-50%)"
w="12px"
h="2px"
bgGradient="linear(to-l, rgba(212,175,55,0.6), rgba(212,175,55,0.3))"
/>
)}
</Box>
)}
</VStack>
</Box>
</Tooltip>
);
});
CalendarCell.displayName = 'CalendarCell';
/**
* 详情弹窗组件 - 完整展示涨停分析和事件详情
*/
const DetailModal = ({ isOpen, onClose, selectedDate, ztDetail, events, loading }) => {
const dispatch = useDispatch();
const reduxWatchlist = useSelector(state => state.stock.watchlist);
const [detailDrawerVisible, setDetailDrawerVisible] = useState(false);
const [selectedContent, setSelectedContent] = useState(null);
const [ztViewMode, setZtViewMode] = useState('sector'); // 'sector' | 'stock'
const [sectorStocksModalVisible, setSectorStocksModalVisible] = useState(false); // 板块股票弹窗
const [selectedSectorInfo, setSelectedSectorInfo] = useState(null); // 选中的板块信息
const [selectedSectorFilter, setSelectedSectorFilter] = useState(null); // 按个股视图的板块筛选
const [stocksDrawerVisible, setStocksDrawerVisible] = useState(false);
const [selectedEventStocks, setSelectedEventStocks] = useState([]);
const [selectedEventTime, setSelectedEventTime] = useState(null);
const [selectedEventTitle, setSelectedEventTitle] = useState('');
const [stockQuotes, setStockQuotes] = useState({});
const [stockQuotesLoading, setStockQuotesLoading] = useState(false);
const [expandedReasons, setExpandedReasons] = useState({});
const [klineModalVisible, setKlineModalVisible] = useState(false);
const [selectedKlineStock, setSelectedKlineStock] = useState(null);
// 关联事件弹窗状态
const [relatedEventsModalVisible, setRelatedEventsModalVisible] = useState(false);
const [selectedRelatedEvents, setSelectedRelatedEvents] = useState({ sectorName: '', events: [] });
// 板块数据处理 - 必须在条件返回之前调用所有hooks
const sectorList = useMemo(() => {
if (!ztDetail?.sector_data) return [];
return Object.entries(ztDetail.sector_data)
.filter(([name]) => name !== '其他')
.map(([name, data]) => ({
name,
count: data.count,
stocks: data.stock_codes || [],
// 新增:关联事件数据(涨停归因)
related_events: data.related_events || [],
}))
.sort((a, b) => b.count - a.count);
}, [ztDetail]);
// 股票详情数据处理 - 支持两种字段名stocks 和 stock_infos
// 按连板天数降序排列(高连板在前)
const stockList = useMemo(() => {
const stocksData = ztDetail?.stocks || ztDetail?.stock_infos;
if (!stocksData) return [];
// 解析连板天数的辅助函数
const parseContinuousDays = (text) => {
if (!text || text === '首板') return 1;
const match = text.match(/(\d+)/);
return match ? parseInt(match[1]) : 1;
};
return stocksData
.map(stock => ({
...stock,
key: stock.scode,
_continuousDays: parseContinuousDays(stock.continuous_days), // 用于排序
}))
.sort((a, b) => b._continuousDays - a._continuousDays); // 降序排列
}, [ztDetail]);
// 筛选后的股票列表(按板块筛选)
const filteredStockList = useMemo(() => {
if (!selectedSectorFilter) return stockList;
// 根据选中板块筛选
const sectorData = ztDetail?.sector_data?.[selectedSectorFilter];
if (!sectorData?.stock_codes) return stockList;
const sectorStockCodes = new Set(sectorData.stock_codes);
return stockList.filter(stock => sectorStockCodes.has(stock.scode));
}, [stockList, selectedSectorFilter, ztDetail]);
// 热门关键词
const hotKeywords = useMemo(() => {
if (!ztDetail?.word_freq_data) return [];
return ztDetail.word_freq_data.slice(0, 12);
}, [ztDetail]);
// 涨停统计数据
const ztStats = useMemo(() => {
if (!stockList.length) return null;
// 连板分布统计
const continuousStats = { '首板': 0, '2连板': 0, '3连板': 0, '4连板+': 0 };
// 涨停时间分布统计
const timeStats = { '秒板': 0, '早盘': 0, '盘中': 0, '尾盘': 0 };
// 公告驱动统计
let announcementCount = 0;
stockList.forEach(stock => {
// 连板统计
const days = stock.continuous_days || '首板';
if (days === '首板' || days.includes('1')) {
continuousStats['首板']++;
} else {
const match = days.match(/(\d+)/);
const num = match ? parseInt(match[1]) : 1;
if (num === 2) continuousStats['2连板']++;
else if (num === 3) continuousStats['3连板']++;
else if (num >= 4) continuousStats['4连板+']++;
else continuousStats['首板']++;
}
// 时间统计
const time = stock.formatted_time || '15:00:00';
if (time <= '09:30:00') timeStats['秒板']++;
else if (time <= '10:00:00') timeStats['早盘']++;
else if (time <= '14:00:00') timeStats['盘中']++;
else timeStats['尾盘']++;
// 公告驱动
if (stock.is_announcement) announcementCount++;
});
return {
total: stockList.length,
continuousStats,
timeStats,
announcementCount,
announcementRatio: stockList.length > 0 ? Math.round(announcementCount / stockList.length * 100) : 0
};
}, [stockList]);
// 获取六位股票代码(去掉后缀)- 纯函数不是hook
const getSixDigitCode = (code) => {
if (!code) return code;
return code.split('.')[0];
};
// 检查股票是否已在自选中 - 必须在条件返回之前
const isStockInWatchlist = useCallback((stockCode) => {
const sixDigitCode = getSixDigitCode(stockCode);
return reduxWatchlist?.some(item =>
getSixDigitCode(item.stock_code) === sixDigitCode
);
}, [reduxWatchlist]);
// 条件返回必须在所有hooks之后
if (!selectedDate) return null;
const dateStr = `${selectedDate.getFullYear()}${selectedDate.getMonth() + 1}${selectedDate.getDate()}`;
const isPastDate = selectedDate < new Date(new Date().setHours(0, 0, 0, 0));
// 渲染重要性星级
const renderStars = (star) => {
const stars = [];
for (let i = 1; i <= 5; i++) {
stars.push(
<StarFilled
key={i}
style={{
color: i <= star ? '#faad14' : '#d9d9d9',
fontSize: '14px'
}}
/>
);
}
return <span>{stars}</span>;
};
// 显示内容详情
const showContentDetail = (content, title) => {
setSelectedContent({ content, title });
setDetailDrawerVisible(true);
};
// 加载股票行情
const loadStockQuotes = async (stocks) => {
if (!stocks || stocks.length === 0) return;
setStockQuotesLoading(true);
const quotes = {};
for (const stock of stocks) {
const code = getSixDigitCode(stock.code);
try {
const response = await fetch(`${getApiBase()}/api/market/trade/${code}?days=1`);
if (response.ok) {
const data = await response.json();
if (data.success && data.data && data.data.length > 0) {
const latest = data.data[data.data.length - 1];
quotes[stock.code] = {
price: latest.close,
change: latest.change_amount,
changePercent: latest.change_percent
};
}
}
} catch (err) {
console.error('加载股票行情失败:', code, err);
}
}
setStockQuotes(quotes);
setStockQuotesLoading(false);
};
// 显示相关股票
const showRelatedStocks = (stocks, eventTime, eventTitle) => {
if (!stocks || stocks.length === 0) return;
// 归一化股票数据格式
const normalizedStocks = stocks.map(stock => {
if (typeof stock === 'object' && !Array.isArray(stock)) {
return {
code: stock.code || stock.stock_code || '',
name: stock.name || stock.stock_name || '',
description: stock.description || stock.relation_desc || '',
score: stock.score || 0,
report: stock.report || null,
};
}
if (Array.isArray(stock)) {
return {
code: stock[0] || '',
name: stock[1] || '',
description: stock[2] || '',
score: stock[3] || 0,
report: null,
};
}
return null;
}).filter(Boolean);
// 按相关度排序
const sortedStocks = normalizedStocks.sort((a, b) => (b.score || 0) - (a.score || 0));
setSelectedEventStocks(sortedStocks);
setSelectedEventTime(eventTime);
setSelectedEventTitle(eventTitle);
setStocksDrawerVisible(true);
setExpandedReasons({});
loadStockQuotes(sortedStocks);
};
// 添加交易所后缀
const addExchangeSuffix = (code) => {
const sixDigitCode = getSixDigitCode(code);
if (code.includes('.')) return code;
if (sixDigitCode.startsWith('6')) {
return `${sixDigitCode}.SH`;
} else if (sixDigitCode.startsWith('0') || sixDigitCode.startsWith('3')) {
return `${sixDigitCode}.SZ`;
}
return sixDigitCode;
};
// 显示K线图
const showKline = (stock) => {
const code = stock.code;
const name = stock.name;
const stockCode = addExchangeSuffix(code);
setSelectedKlineStock({
stock_code: stockCode,
stock_name: name,
});
setKlineModalVisible(true);
};
// 添加单只股票到自选
const addSingleToWatchlist = async (stock) => {
const code = stock.code;
const name = stock.name;
const stockCode = getSixDigitCode(code);
if (isStockInWatchlist(code)) {
message.info(`${name} 已在自选中`);
return;
}
try {
await dispatch(toggleWatchlist({
stockCode,
stockName: name,
isInWatchlist: false
})).unwrap();
message.success(`已将 ${name}(${stockCode}) 添加到自选`);
} catch (error) {
console.error('添加自选失败:', error);
message.error('添加失败,请重试');
}
};
// 相关股票表格列定义(和投资日历保持一致)
const stockColumns = [
{
title: '代码',
dataIndex: 'code',
key: 'code',
width: 90,
render: (code) => {
const sixDigitCode = getSixDigitCode(code);
return (
<a
href={`https://valuefrontier.cn/company?scode=${sixDigitCode}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#3B82F6' }}
>
{sixDigitCode}
</a>
);
}
},
{
title: '名称',
dataIndex: 'name',
key: 'name',
width: 100,
render: (name, record) => {
const sixDigitCode = getSixDigitCode(record.code);
return (
<a
href={`https://valuefrontier.cn/company?scode=${sixDigitCode}`}
target="_blank"
rel="noopener noreferrer"
>
<AntText strong>{name}</AntText>
</a>
);
}
},
{
title: '现价',
key: 'price',
width: 80,
render: (_, record) => {
const quote = stockQuotes[record.code];
if (quote && quote.price !== undefined) {
return (
<AntText type={quote.change > 0 ? 'danger' : 'success'}>
{quote.price?.toFixed(2)}
</AntText>
);
}
return <AntText>-</AntText>;
}
},
{
title: '涨跌幅',
key: 'change',
width: 100,
render: (_, record) => {
const quote = stockQuotes[record.code];
if (quote && quote.changePercent !== undefined) {
const changePercent = quote.changePercent || 0;
return (
<Tag color={changePercent > 0 ? 'red' : changePercent < 0 ? 'green' : 'default'}>
{changePercent > 0 ? '+' : ''}{changePercent.toFixed(2)}%
</Tag>
);
}
return <AntText>-</AntText>;
}
},
{
title: '关联理由',
dataIndex: 'description',
key: 'reason',
render: (description, record) => {
const stockCode = record.code;
const isExpanded = expandedReasons[stockCode] || false;
const reason = typeof description === 'string' ? description : '';
const shouldTruncate = reason && reason.length > 80;
const toggleExpanded = () => {
setExpandedReasons(prev => ({
...prev,
[stockCode]: !prev[stockCode]
}));
};
return (
<div>
<AntText style={{ fontSize: '13px', lineHeight: '1.6' }}>
{isExpanded || !shouldTruncate
? reason || '-'
: `${reason?.slice(0, 80)}...`
}
</AntText>
{shouldTruncate && (
<Button
type="link"
size="small"
onClick={toggleExpanded}
style={{ padding: 0, marginLeft: 4, fontSize: '12px' }}
>
({isExpanded ? '收起' : '展开'})
</Button>
)}
{reason && (
<div style={{ marginTop: 4 }}>
<AntText type="secondary" style={{ fontSize: '11px' }}>(AI合成)</AntText>
</div>
)}
</div>
);
}
},
{
title: '研报引用',
dataIndex: 'report',
key: 'report',
width: 180,
render: (report) => {
if (!report || !report.title) {
return <AntText type="secondary">-</AntText>;
}
return (
<div style={{ fontSize: '12px' }}>
<Tooltip title={report.sentences || report.title}>
<div>
<AntText strong style={{ display: 'block', marginBottom: 2 }}>
{report.title.length > 18 ? `${report.title.slice(0, 18)}...` : report.title}
</AntText>
{report.author && (
<AntText type="secondary" style={{ display: 'block', fontSize: '11px' }}>
{report.author}
</AntText>
)}
{report.declare_date && (
<AntText type="secondary" style={{ fontSize: '11px' }}>
{dayjs(report.declare_date).format('YYYY-MM-DD')}
</AntText>
)}
{report.match_score && (
<Tag color={report.match_score === '好' ? 'green' : 'blue'} style={{ marginLeft: 4, fontSize: '10px' }}>
匹配度: {report.match_score}
</Tag>
)}
</div>
</Tooltip>
</div>
);
}
},
{
title: 'K线图',
key: 'kline',
width: 80,
render: (_, record) => (
<Button
type="primary"
size="small"
icon={<LineChartOutlined />}
onClick={() => showKline(record)}
>
查看
</Button>
)
},
{
title: '操作',
key: 'action',
width: 90,
render: (_, record) => {
const inWatchlist = isStockInWatchlist(record.code);
return (
<Button
type={inWatchlist ? 'primary' : 'default'}
size="small"
icon={inWatchlist ? <StarFilled /> : <StarOutlined />}
onClick={() => addSingleToWatchlist(record)}
disabled={inWatchlist}
>
{inWatchlist ? '已添加' : '加自选'}
</Button>
);
}
},
];
// 涨停板块表格列 - 精致风格设计
const sectorColumns = [
{
title: '排名',
key: 'rank',
width: 60,
align: 'center',
render: (_, __, index) => {
const getRankStyle = (idx) => {
if (idx === 0) return { background: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)', color: '#000', fontWeight: 'bold' };
if (idx === 1) return { background: 'linear-gradient(135deg, #C0C0C0 0%, #A8A8A8 100%)', color: '#000', fontWeight: 'bold' };
if (idx === 2) return { background: 'linear-gradient(135deg, #CD7F32 0%, #A0522D 100%)', color: '#fff', fontWeight: 'bold' };
return { background: 'rgba(255,255,255,0.1)', color: '#888' };
};
const style = getRankStyle(index);
return (
<div style={{
width: 28,
height: 28,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
margin: '0 auto',
...style
}}>
{index + 1}
</div>
);
},
},
{
title: '板块名称',
dataIndex: 'name',
key: 'name',
width: 130,
render: (name, record, index) => (
<HStack spacing={2}>
<Box
w="4px"
h="24px"
borderRadius="full"
bg={index < 3 ? 'linear-gradient(180deg, #FFD700 0%, #FF8C00 100%)' : 'whiteAlpha.300'}
/>
<AntText strong style={{
color: index < 3 ? '#FFD700' : '#E0E0E0',
fontSize: '14px'
}}>
{name}
</AntText>
</HStack>
),
},
{
title: '涨停数',
dataIndex: 'count',
key: 'count',
width: 90,
align: 'center',
render: (count) => {
const getCountColor = (c) => {
if (c >= 8) return { bg: '#ff4d4f', text: '#fff' };
if (c >= 5) return { bg: '#fa541c', text: '#fff' };
if (c >= 3) return { bg: '#fa8c16', text: '#fff' };
return { bg: 'rgba(255,215,0,0.2)', text: '#FFD700' };
};
const colors = getCountColor(count);
return (
<HStack justify="center" spacing={1}>
<Box
px={3}
py={1}
borderRadius="full"
bg={colors.bg}
display="flex"
alignItems="center"
gap={1}
>
<FireOutlined style={{ color: colors.text, fontSize: '12px' }} />
<span style={{ color: colors.text, fontWeight: 'bold', fontSize: '14px' }}>{count}</span>
</Box>
</HStack>
);
},
},
{
title: '涨停股票',
dataIndex: 'stocks',
key: 'stocks',
render: (stocks, record) => {
// 根据股票代码查找股票详情,并按连板天数排序
const getStockInfoList = () => {
return stocks
.map(code => {
const stockInfo = stockList.find(s => s.scode === code);
return stockInfo || { sname: code, scode: code, _continuousDays: 1 };
})
.sort((a, b) => (b._continuousDays || 1) - (a._continuousDays || 1));
};
const stockInfoList = getStockInfoList();
const displayStocks = stockInfoList.slice(0, 4);
const handleShowAll = (e) => {
e.stopPropagation();
setSelectedSectorInfo({
name: record.name,
count: record.count,
stocks: stockInfoList,
});
setSectorStocksModalVisible(true);
};
return (
<HStack spacing={1} flexWrap="wrap" align="center">
{displayStocks.map((info) => (
<Tooltip
key={info.scode}
title={
<Box>
<div style={{ fontWeight: 'bold', marginBottom: 4 }}>{info.sname}</div>
<div style={{ fontSize: '12px', color: '#888' }}>{info.scode}</div>
{info.continuous_days && (
<div style={{ fontSize: '12px', marginTop: 4, color: '#fa8c16' }}>
{info.continuous_days}
</div>
)}
</Box>
}
placement="top"
>
<Tag
style={{
cursor: 'pointer',
margin: '2px',
background: info._continuousDays >= 3
? 'rgba(255, 77, 79, 0.2)'
: info._continuousDays >= 2
? 'rgba(250, 140, 22, 0.2)'
: 'rgba(59, 130, 246, 0.15)',
border: info._continuousDays >= 3
? '1px solid rgba(255, 77, 79, 0.4)'
: info._continuousDays >= 2
? '1px solid rgba(250, 140, 22, 0.4)'
: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '6px',
}}
>
<a
href={`https://valuefrontier.cn/company?scode=${info.scode}`}
target="_blank"
rel="noopener noreferrer"
style={{
color: info._continuousDays >= 3 ? '#ff4d4f' : info._continuousDays >= 2 ? '#fa8c16' : '#60A5FA',
fontSize: '13px'
}}
>
{info.sname}
{info._continuousDays > 1 && (
<span style={{ fontSize: '10px', marginLeft: 2 }}>
({info._continuousDays})
</span>
)}
</a>
</Tag>
</Tooltip>
))}
{stocks.length > 4 && (
<Button
type="link"
size="small"
onClick={handleShowAll}
style={{
padding: '0 4px',
height: 'auto',
fontSize: '12px',
color: '#FFD700',
}}
>
查看全部 {stocks.length}
</Button>
)}
</HStack>
);
},
},
{
title: '涨停归因',
dataIndex: 'related_events',
key: 'related_events',
width: 280,
render: (events, record) => {
if (!events || events.length === 0) {
return <AntText style={{ color: '#666', fontSize: '12px' }}>-</AntText>;
}
// 取相关度最高的事件
const sortedEvents = [...events].sort((a, b) => (b.relevance_score || 0) - (a.relevance_score || 0));
const topEvent = sortedEvents[0];
// 相关度颜色
const getRelevanceColor = (score) => {
if (score >= 80) return '#10B981';
if (score >= 60) return '#F59E0B';
return '#6B7280';
};
// 点击打开事件详情弹窗
const handleClick = (e) => {
e.stopPropagation();
setSelectedRelatedEvents({
sectorName: record.name,
events: sortedEvents,
count: record.count,
});
setRelatedEventsModalVisible(true);
};
return (
<VStack align="start" spacing={1}>
<Box
cursor="pointer"
p={1.5}
borderRadius="md"
bg="rgba(96, 165, 250, 0.1)"
_hover={{ bg: 'rgba(96, 165, 250, 0.2)', transform: 'translateY(-1px)' }}
transition="all 0.2s"
maxW="260px"
onClick={handleClick}
>
<HStack spacing={1.5} align="start">
<FileText size={14} color="#60A5FA" style={{ flexShrink: 0, marginTop: 2 }} />
<VStack align="start" spacing={0.5} flex={1}>
<AntText
style={{
color: '#E0E0E0',
fontSize: '12px',
lineHeight: '1.3',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{topEvent.title}
</AntText>
<HStack spacing={2}>
<Tag
style={{
fontSize: '10px',
padding: '0 6px',
background: `${getRelevanceColor(topEvent.relevance_score || 0)}20`,
border: 'none',
color: getRelevanceColor(topEvent.relevance_score || 0),
borderRadius: '4px',
}}
>
相关度 {topEvent.relevance_score || 0}
</Tag>
{events.length > 1 && (
<AntText style={{ fontSize: '10px', color: '#888' }}>
+{events.length - 1}
</AntText>
)}
</HStack>
</VStack>
</HStack>
</Box>
</VStack>
);
},
},
];
// 涨停股票详情表格列 - 精致风格 + K线图 + 加自选
const ztStockColumns = [
{
title: '股票信息',
key: 'stock',
width: 140,
fixed: 'left',
render: (_, record) => (
<VStack align="start" spacing={0}>
<a
href={`https://valuefrontier.cn/company?scode=${record.scode}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#FFD700', fontWeight: 'bold', fontSize: '14px' }}
>
{record.sname}
</a>
<AntText style={{ color: '#60A5FA', fontSize: '12px' }}>{record.scode}</AntText>
</VStack>
),
},
{
title: '涨停时间',
dataIndex: 'formatted_time',
key: 'time',
width: 90,
align: 'center',
render: (time) => {
const getTimeStyle = (t) => {
if (t <= '09:30:00') return { bg: '#ff4d4f', text: '#fff', label: '秒板' };
if (t <= '09:35:00') return { bg: '#fa541c', text: '#fff', label: '早板' };
if (t <= '10:00:00') return { bg: '#fa8c16', text: '#fff', label: '盘初' };
if (t <= '11:00:00') return { bg: '#52c41a', text: '#fff', label: '盘中' };
return { bg: 'rgba(255,255,255,0.1)', text: '#888', label: '尾盘' };
};
const style = getTimeStyle(time || '15:00:00');
return (
<VStack spacing={0}>
<Box
px={2}
py={0.5}
borderRadius="md"
bg={style.bg}
fontSize="13px"
fontWeight="bold"
color={style.text}
>
{time?.substring(0, 5) || '-'}
</Box>
<AntText style={{ fontSize: '10px', color: '#888' }}>{style.label}</AntText>
</VStack>
);
},
},
{
title: '连板',
dataIndex: 'continuous_days',
key: 'continuous',
width: 70,
align: 'center',
render: (text) => {
if (!text || text === '首板') {
return (
<Box
px={2}
py={0.5}
borderRadius="md"
bg="rgba(255,255,255,0.1)"
fontSize="12px"
color="#888"
>
首板
</Box>
);
}
const match = text.match(/(\d+)/);
const days = match ? parseInt(match[1]) : 1;
const getDaysStyle = (d) => {
if (d >= 5) return { bg: 'linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%)', text: '#fff' };
if (d >= 3) return { bg: 'linear-gradient(135deg, #fa541c 0%, #ff7a45 100%)', text: '#fff' };
if (d >= 2) return { bg: 'linear-gradient(135deg, #fa8c16 0%, #ffc53d 100%)', text: '#fff' };
return { bg: 'rgba(255,255,255,0.1)', text: '#888' };
};
const style = getDaysStyle(days);
return (
<Box
px={2}
py={0.5}
borderRadius="md"
bg={style.bg}
fontSize="13px"
fontWeight="bold"
color={style.text}
>
{text}
</Box>
);
},
},
{
title: '核心板块',
dataIndex: 'core_sectors',
key: 'sectors',
width: 200,
render: (sectors) => (
<HStack spacing={1} flexWrap="wrap">
{(sectors || []).slice(0, 3).map((sector, idx) => (
<Tag
key={idx}
style={{
margin: '2px',
background: idx === 0
? 'linear-gradient(135deg, rgba(255,215,0,0.25) 0%, rgba(255,165,0,0.15) 100%)'
: 'rgba(255,215,0,0.1)',
border: idx === 0 ? '1px solid rgba(255,215,0,0.5)' : '1px solid rgba(255,215,0,0.2)',
borderRadius: '6px',
color: idx === 0 ? '#FFD700' : '#D4A84B',
fontSize: '12px',
fontWeight: idx === 0 ? 'bold' : 'normal',
}}
>
{sector}
</Tag>
))}
</HStack>
),
},
{
title: '涨停简报',
dataIndex: 'brief',
key: 'brief',
width: 200,
render: (text, record) => {
if (!text) return <AntText type="secondary">-</AntText>;
// 移除HTML标签
const cleanText = text.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]+>/g, '');
return (
<Tooltip
title={
<Box maxW="400px" p={2}>
<div style={{ fontWeight: 'bold', marginBottom: 8, color: '#FFD700' }}>
{record.sname} 涨停简报
</div>
<div style={{ fontSize: '13px', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>
{cleanText}
</div>
</Box>
}
placement="topLeft"
overlayStyle={{ maxWidth: 450 }}
>
<Button
type="link"
size="small"
onClick={() => showContentDetail(text.replace(/<br\s*\/?>/gi, '\n\n'), `${record.sname} 涨停简报`)}
style={{
padding: 0,
height: 'auto',
whiteSpace: 'normal',
textAlign: 'left',
color: '#60A5FA',
fontSize: '13px'
}}
>
{cleanText.length > 30 ? cleanText.substring(0, 30) + '...' : cleanText}
</Button>
</Tooltip>
);
},
},
{
title: 'K线图',
key: 'kline',
width: 80,
align: 'center',
render: (_, record) => (
<Button
type="primary"
size="small"
icon={<LineChartOutlined />}
onClick={() => {
// 添加交易所后缀
const code = record.scode;
let stockCode = code;
if (!code.includes('.')) {
if (code.startsWith('6')) stockCode = `${code}.SH`;
else if (code.startsWith('0') || code.startsWith('3')) stockCode = `${code}.SZ`;
else if (code.startsWith('688')) stockCode = `${code}.SH`;
}
setSelectedKlineStock({
stock_code: stockCode,
stock_name: record.sname
});
setKlineModalVisible(true);
}}
style={{
background: 'linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%)',
border: 'none',
borderRadius: '6px',
}}
>
查看
</Button>
),
},
{
title: '操作',
key: 'action',
width: 90,
align: 'center',
render: (_, record) => {
const code = record.scode;
const inWatchlist = isStockInWatchlist(code);
return (
<Button
type={inWatchlist ? 'primary' : 'default'}
size="small"
icon={inWatchlist ? <StarFilled /> : <StarOutlined />}
onClick={() => addSingleToWatchlist({ code, name: record.sname })}
disabled={inWatchlist}
style={inWatchlist ? {
background: 'linear-gradient(135deg, #faad14 0%, #fa8c16 100%)',
border: 'none',
borderRadius: '6px',
} : {
background: 'rgba(255,255,255,0.1)',
border: '1px solid rgba(255,215,0,0.3)',
borderRadius: '6px',
color: '#FFD700',
}}
>
{inWatchlist ? '已添加' : '加自选'}
</Button>
);
},
},
];
// 事件表格列(参考投资日历)- 去掉相关概念列
const eventColumns = [
{
title: '时间',
dataIndex: 'calendar_time',
key: 'time',
width: 80,
render: (time) => (
<Space>
<ClockCircleOutlined />
<AntText>{dayjs(time).format('HH:mm')}</AntText>
</Space>
),
},
{
title: '重要度',
dataIndex: 'star',
key: 'star',
width: 120,
render: renderStars,
},
{
title: '标题',
dataIndex: 'title',
key: 'title',
ellipsis: true,
render: (text) => (
<Tooltip title={text}>
<AntText strong style={{ fontSize: '14px' }}>{text}</AntText>
</Tooltip>
),
},
{
title: '背景',
dataIndex: 'former',
key: 'former',
width: 80,
render: (text) => (
<Button
type="link"
size="small"
icon={<LinkOutlined />}
onClick={() => showContentDetail(text, '事件背景')}
disabled={!text}
>
查看
</Button>
),
},
{
title: '未来推演',
dataIndex: 'forecast',
key: 'forecast',
width: 90,
render: (text) => (
<Button
type="link"
size="small"
icon={<RobotOutlined />}
onClick={() => showContentDetail(text, '未来推演')}
disabled={!text}
>
{text ? '查看' : '无'}
</Button>
),
},
{
title: '相关股票',
dataIndex: 'related_stocks',
key: 'stocks',
width: 120,
render: (stocks, record) => {
const hasStocks = stocks && stocks.length > 0;
if (!hasStocks) {
return <AntText type="secondary"></AntText>;
}
return (
<Button
type="link"
size="small"
icon={<StockOutlined />}
onClick={() => showRelatedStocks(stocks, record.calendar_time, record.title)}
>
{stocks.length}
</Button>
);
},
},
];
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
size="6xl"
scrollBehavior="inside"
>
<ModalOverlay bg="blackAlpha.700" backdropFilter={GLASS_BLUR.sm} />
<ModalContent
maxW="1300px"
maxH="90vh"
bg="linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)"
border="1px solid rgba(255,215,0,0.2)"
borderRadius="2xl"
>
<ModalHeader borderBottom="1px solid rgba(255,215,0,0.2)" py={4}>
<HStack spacing={4}>
<Box
p={2}
bg="rgba(255,215,0,0.15)"
borderRadius="lg"
border="1px solid rgba(255,215,0,0.3)"
>
<Icon as={Calendar} color="gold" boxSize={6} />
</Box>
<VStack align="start" spacing={0}>
<Text fontSize="xl" fontWeight="bold" color="white">
{dateStr}
</Text>
<HStack spacing={2}>
<Badge colorScheme={isPastDate ? 'purple' : 'green'} fontSize="sm">
{isPastDate ? '历史数据' : '未来事件'}
</Badge>
{ztDetail && (
<Badge colorScheme="red" fontSize="sm">
涨停 {ztDetail.total_stocks || 0}
</Badge>
)}
{events?.length > 0 && (
<Badge colorScheme="blue" fontSize="sm">
事件 {events.length}
</Badge>
)}
</HStack>
</VStack>
</HStack>
</ModalHeader>
<ModalCloseButton color="whiteAlpha.600" _hover={{ color: 'white' }} size="lg" />
<ModalBody py={6} px={6} className="hero-panel-modal" overflow="auto">
{loading ? (
<Center h="300px">
<Spin size="large" tip="加载中..." />
</Center>
) : (
<Tabs
defaultActiveKey={isPastDate ? 'zt' : 'event'}
size="large"
tabBarStyle={{ marginBottom: 24 }}
>
{/* 涨停分析 Tab */}
<TabPane
tab={
<span style={{ fontSize: '16px' }}>
<FireOutlined style={{ marginRight: 8 }} />
涨停分析 ({ztDetail?.total_stocks || 0})
</span>
}
key="zt"
disabled={!ztDetail}
>
{(sectorList.length > 0 || stockList.length > 0) ? (
<VStack spacing={5} align="stretch">
{/* 热门关键词 - 更精致的词云展示 */}
{hotKeywords.length > 0 && (
<Box
p={4}
bg="linear-gradient(135deg, rgba(255, 215, 0, 0.08) 0%, rgba(255, 140, 0, 0.05) 100%)"
borderRadius="xl"
border="1px solid rgba(255, 215, 0, 0.2)"
position="relative"
overflow="hidden"
>
{/* 装饰线 */}
<Box
position="absolute"
top={0}
left={0}
right={0}
h="2px"
bgGradient="linear(to-r, transparent, #FFD700, #FF8C00, #FFD700, transparent)"
/>
<HStack spacing={2} mb={3}>
<Box
p={1.5}
bg="rgba(255,215,0,0.2)"
borderRadius="md"
>
<FireOutlined style={{ color: '#FFD700', fontSize: '16px' }} />
</Box>
<Text fontSize="md" fontWeight="bold" color="gold">
今日热词
</Text>
<Text fontSize="xs" color="whiteAlpha.500">
词频越高排名越前
</Text>
</HStack>
<HStack spacing={2} flexWrap="wrap">
{hotKeywords.map((kw, idx) => {
// 根据排名计算样式
const getKeywordStyle = (index) => {
if (index < 3) return {
fontSize: '15px',
fontWeight: 'bold',
background: 'linear-gradient(135deg, rgba(255,215,0,0.3) 0%, rgba(255,165,0,0.2) 100%)',
border: '1px solid rgba(255,215,0,0.5)',
color: '#FFD700',
px: 3,
py: 1.5,
};
if (index < 6) return {
fontSize: '14px',
fontWeight: 'semibold',
background: 'rgba(255,215,0,0.15)',
border: '1px solid rgba(255,215,0,0.3)',
color: '#D4A84B',
px: 2.5,
py: 1,
};
return {
fontSize: '13px',
fontWeight: 'normal',
background: 'rgba(255,255,255,0.08)',
border: '1px solid rgba(255,255,255,0.15)',
color: '#888',
px: 2,
py: 0.5,
};
};
const style = getKeywordStyle(idx);
return (
<Box
key={kw.name}
px={style.px}
py={style.py}
borderRadius="full"
bg={style.background}
border={style.border}
fontSize={style.fontSize}
fontWeight={style.fontWeight}
color={style.color}
transition="all 0.2s"
_hover={{
transform: 'scale(1.05)',
boxShadow: '0 0 10px rgba(255,215,0,0.3)',
}}
>
{kw.name}
</Box>
);
})}
</HStack>
</Box>
)}
{/* 涨停统计卡片 */}
{ztStats && (
<HStack spacing={4} flexWrap="wrap">
{/* 连板分布 */}
<Box
flex="1"
minW="200px"
p={3}
bg="rgba(255,255,255,0.03)"
borderRadius="xl"
border="1px solid rgba(255,255,255,0.08)"
>
<Text fontSize="xs" color="whiteAlpha.500" mb={2}>连板分布</Text>
<HStack spacing={3} flexWrap="wrap">
{Object.entries(ztStats.continuousStats).map(([key, value]) => (
<VStack key={key} spacing={0} align="center">
<Text
fontSize="lg"
fontWeight="bold"
color={key === '4连板+' ? '#ff4d4f' : key === '3连板' ? '#fa541c' : key === '2连板' ? '#fa8c16' : '#52c41a'}
>
{value}
</Text>
<Text fontSize="10px" color="whiteAlpha.500">{key}</Text>
</VStack>
))}
</HStack>
</Box>
{/* 涨停时间分布 */}
<Box
flex="1"
minW="200px"
p={3}
bg="rgba(255,255,255,0.03)"
borderRadius="xl"
border="1px solid rgba(255,255,255,0.08)"
>
<Text fontSize="xs" color="whiteAlpha.500" mb={2}>封板时间</Text>
<HStack spacing={3} flexWrap="wrap">
{Object.entries(ztStats.timeStats).map(([key, value]) => (
<VStack key={key} spacing={0} align="center">
<Text
fontSize="lg"
fontWeight="bold"
color={key === '秒板' ? '#ff4d4f' : key === '早盘' ? '#fa8c16' : key === '盘中' ? '#52c41a' : '#888'}
>
{value}
</Text>
<Text fontSize="10px" color="whiteAlpha.500">{key}</Text>
</VStack>
))}
</HStack>
</Box>
{/* 公告驱动 */}
<Box
p={3}
bg="rgba(255,255,255,0.03)"
borderRadius="xl"
border="1px solid rgba(255,255,255,0.08)"
minW="120px"
>
<Text fontSize="xs" color="whiteAlpha.500" mb={2}>公告驱动</Text>
<HStack spacing={2} align="baseline">
<Text fontSize="xl" fontWeight="bold" color="#A855F7">
{ztStats.announcementCount}
</Text>
<Text fontSize="xs" color="whiteAlpha.500">
({ztStats.announcementRatio}%)
</Text>
</HStack>
</Box>
</HStack>
)}
{/* 视图切换按钮 - 更精致的样式 */}
<HStack justify="space-between" align="center" px={1}>
<HStack spacing={3}>
<Box
as="button"
px={4}
py={2}
borderRadius="lg"
bg={ztViewMode === 'sector'
? 'linear-gradient(135deg, rgba(255,215,0,0.25) 0%, rgba(255,165,0,0.15) 100%)'
: 'rgba(255,255,255,0.05)'}
border={ztViewMode === 'sector'
? '1px solid rgba(255,215,0,0.5)'
: '1px solid rgba(255,255,255,0.1)'}
color={ztViewMode === 'sector' ? '#FFD700' : '#888'}
fontWeight={ztViewMode === 'sector' ? 'bold' : 'normal'}
onClick={() => setZtViewMode('sector')}
transition="all 0.2s"
_hover={{ bg: 'rgba(255,215,0,0.15)' }}
display="flex"
alignItems="center"
gap={2}
>
<TagsOutlined />
<span>按板块 ({sectorList.length})</span>
</Box>
<Box
as="button"
px={4}
py={2}
borderRadius="lg"
bg={ztViewMode === 'stock'
? 'linear-gradient(135deg, rgba(59,130,246,0.25) 0%, rgba(139,92,246,0.15) 100%)'
: 'rgba(255,255,255,0.05)'}
border={ztViewMode === 'stock'
? '1px solid rgba(59,130,246,0.5)'
: '1px solid rgba(255,255,255,0.1)'}
color={ztViewMode === 'stock' ? '#60A5FA' : '#888'}
fontWeight={ztViewMode === 'stock' ? 'bold' : 'normal'}
onClick={() => setZtViewMode('stock')}
transition="all 0.2s"
_hover={{ bg: 'rgba(59,130,246,0.15)' }}
display="flex"
alignItems="center"
gap={2}
>
<StockOutlined />
<span>按个股 ({stockList.length})</span>
</Box>
</HStack>
<HStack spacing={2}>
<Box
px={3}
py={1}
borderRadius="full"
bg="rgba(255,77,79,0.15)"
border="1px solid rgba(255,77,79,0.3)"
>
<HStack spacing={1}>
<FireOutlined style={{ color: '#ff4d4f', fontSize: '12px' }} />
<Text fontSize="sm" color="#ff4d4f" fontWeight="bold">
{ztDetail?.total_stocks || 0}
</Text>
<Text fontSize="xs" color="whiteAlpha.600">只涨停</Text>
</HStack>
</Box>
</HStack>
</HStack>
{/* 板块视图 */}
{ztViewMode === 'sector' && (
<Box
borderRadius="xl"
border="1px solid rgba(255,215,0,0.15)"
overflow="hidden"
>
<Table
dataSource={sectorList}
columns={sectorColumns}
rowKey="name"
size="middle"
pagination={false}
scroll={{ y: 380 }}
/>
</Box>
)}
{/* 个股视图 */}
{ztViewMode === 'stock' && (
<VStack spacing={3} align="stretch">
{/* 板块筛选器 */}
<Box>
<HStack spacing={2} mb={2}>
<Text fontSize="xs" color="whiteAlpha.500">板块筛选</Text>
<Box
as="button"
px={3}
py={1}
borderRadius="full"
bg={!selectedSectorFilter ? 'rgba(255,215,0,0.2)' : 'rgba(255,255,255,0.05)'}
border={!selectedSectorFilter ? '1px solid rgba(255,215,0,0.4)' : '1px solid rgba(255,255,255,0.1)'}
color={!selectedSectorFilter ? '#FFD700' : '#888'}
fontSize="xs"
onClick={() => setSelectedSectorFilter(null)}
transition="all 0.2s"
_hover={{ bg: 'rgba(255,215,0,0.15)' }}
>
全部 ({stockList.length})
</Box>
</HStack>
<HStack spacing={2} flexWrap="wrap">
{sectorList.slice(0, 10).map((sector) => (
<Box
key={sector.name}
as="button"
px={2.5}
py={1}
borderRadius="full"
bg={selectedSectorFilter === sector.name
? 'rgba(59,130,246,0.2)'
: 'rgba(255,255,255,0.03)'}
border={selectedSectorFilter === sector.name
? '1px solid rgba(59,130,246,0.4)'
: '1px solid rgba(255,255,255,0.08)'}
color={selectedSectorFilter === sector.name ? '#60A5FA' : '#888'}
fontSize="xs"
onClick={() => setSelectedSectorFilter(
selectedSectorFilter === sector.name ? null : sector.name
)}
transition="all 0.2s"
_hover={{ bg: 'rgba(59,130,246,0.1)' }}
>
{sector.name} ({sector.count})
</Box>
))}
</HStack>
</Box>
{/* 筛选结果提示 */}
{selectedSectorFilter && (
<HStack
px={3}
py={2}
bg="rgba(59,130,246,0.1)"
borderRadius="lg"
border="1px solid rgba(59,130,246,0.2)"
>
<TagsOutlined style={{ color: '#60A5FA' }} />
<Text fontSize="sm" color="#60A5FA">
当前筛选<strong>{selectedSectorFilter}</strong>
</Text>
<Text fontSize="sm" color="whiteAlpha.600">
{filteredStockList.length}
</Text>
<Button
type="link"
size="small"
onClick={() => setSelectedSectorFilter(null)}
style={{ color: '#888', fontSize: '12px' }}
>
清除筛选
</Button>
</HStack>
)}
<Box
borderRadius="xl"
border="1px solid rgba(59,130,246,0.15)"
overflow="hidden"
>
<Table
dataSource={filteredStockList}
columns={ztStockColumns}
rowKey="scode"
size="middle"
pagination={false}
scroll={{ x: 950, y: selectedSectorFilter ? 320 : 380 }}
/>
</Box>
</VStack>
)}
</VStack>
) : (
<Center h="250px">
<VStack spacing={4}>
<Box
p={4}
borderRadius="full"
bg="rgba(255,255,255,0.05)"
>
<FireOutlined style={{ fontSize: 48, color: '#444' }} />
</Box>
<VStack spacing={1}>
<AntText style={{ fontSize: 16, color: '#666' }}>暂无涨停数据</AntText>
<AntText style={{ fontSize: 13, color: '#444' }}>该日期没有涨停股票记录</AntText>
</VStack>
</VStack>
</Center>
)}
</TabPane>
{/* 未来事件 Tab */}
<TabPane
tab={
<span style={{ fontSize: '16px' }}>
<CalendarOutlined style={{ marginRight: 8 }} />
未来事件 ({events?.length || 0})
</span>
}
key="event"
disabled={!events?.length}
>
{events?.length > 0 ? (
<Table
dataSource={events}
columns={eventColumns}
rowKey="id"
size="small"
pagination={false}
scroll={{ x: 900, y: 420 }}
/>
) : (
<Center h="200px">
<VStack>
<CalendarOutlined style={{ fontSize: 48, color: '#666' }} />
<AntText type="secondary" style={{ fontSize: 16 }}>暂无事件数据</AntText>
</VStack>
</Center>
)}
</TabPane>
</Tabs>
)}
</ModalBody>
</ModalContent>
</Modal>
{/* 内容详情抽屉 */}
<Drawer
isOpen={detailDrawerVisible}
placement="right"
size="lg"
onClose={() => setDetailDrawerVisible(false)}
>
<DrawerOverlay bg="blackAlpha.600" />
<DrawerContent
className="hero-detail-drawer"
bg="linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)"
borderLeft="1px solid rgba(255,215,0,0.2)"
maxH="100vh"
h="100vh"
display="flex"
flexDirection="column"
overflow="hidden"
>
<DrawerCloseButton color="whiteAlpha.600" />
<DrawerHeader borderBottom="1px solid rgba(255,215,0,0.2)" color="white" flexShrink={0}>
{selectedContent?.title}
</DrawerHeader>
<DrawerBody p={0} overflow="hidden">
<Box
className="markdown-content"
color="whiteAlpha.900"
fontSize="md"
lineHeight="1.8"
p={6}
h="calc(100vh - 80px)"
overflowY="auto"
style={{
overflowY: 'auto',
maxHeight: 'calc(100vh - 80px)',
}}
sx={{
// 滚动条样式
'&::-webkit-scrollbar': { width: '8px' },
'&::-webkit-scrollbar-track': { background: 'rgba(255,255,255,0.05)', borderRadius: '4px' },
'&::-webkit-scrollbar-thumb': { background: 'rgba(255,215,0,0.4)', borderRadius: '4px' },
'&::-webkit-scrollbar-thumb:hover': { background: 'rgba(255,215,0,0.6)' },
// 确保所有文本元素都使用浅色
'& *': { color: 'inherit' },
'& p': { mb: 4, color: 'whiteAlpha.900' },
'& h1, & h2, & h3': { color: 'gold', mb: 3 },
'& h4, & h5, & h6': { color: 'whiteAlpha.900', mb: 2 },
'& ul, & ol': { pl: 6, color: 'whiteAlpha.900' },
'& li': { mb: 2, color: 'whiteAlpha.900' },
'& a': { color: 'cyan.300', textDecoration: 'underline' },
'& strong, & b': { color: 'gold', fontWeight: 'bold' },
'& em, & i': { color: 'whiteAlpha.800' },
'& code': {
color: 'orange.300',
bg: 'whiteAlpha.100',
px: 1,
borderRadius: 'sm',
fontSize: 'sm',
},
'& pre': {
bg: 'whiteAlpha.100',
p: 3,
borderRadius: 'md',
overflow: 'auto',
'& code': { bg: 'transparent', p: 0 },
},
'& blockquote': {
borderLeft: '3px solid',
borderColor: 'gold',
pl: 4,
ml: 0,
color: 'whiteAlpha.700',
fontStyle: 'italic',
},
'& table': {
width: '100%',
'& th': { color: 'gold', borderBottom: '1px solid rgba(255,215,0,0.3)', p: 2 },
'& td': { color: 'whiteAlpha.900', borderBottom: '1px solid rgba(255,255,255,0.1)', p: 2 },
},
'& hr': { borderColor: 'whiteAlpha.200', my: 4 },
}}
>
<ReactMarkdown>
{typeof selectedContent?.content === 'string'
? selectedContent.content
: selectedContent?.content?.data
? selectedContent.content.data.map(item => item.sentence || '').join('\n\n')
: '暂无内容'}
</ReactMarkdown>
<Text fontSize="sm" color="whiteAlpha.500" mt={6} fontStyle="italic">
(AI合成内容)
</Text>
</Box>
</DrawerBody>
</DrawerContent>
</Drawer>
{/* 相关股票弹窗 */}
<Modal
isOpen={stocksDrawerVisible}
onClose={() => {
setStocksDrawerVisible(false);
setExpandedReasons({});
}}
size="6xl"
scrollBehavior="inside"
>
<ModalOverlay bg="blackAlpha.700" backdropFilter={GLASS_BLUR.sm} />
<ModalContent
maxW="1100px"
maxH="85vh"
bg="linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)"
border="1px solid rgba(255,215,0,0.2)"
borderRadius="2xl"
>
<ModalHeader borderBottom="1px solid rgba(255,215,0,0.2)" py={4}>
<HStack spacing={3}>
<StockOutlined style={{ color: '#FFD700', fontSize: '20px' }} />
<VStack align="start" spacing={0}>
<Text fontSize="lg" color="white">相关股票</Text>
{selectedEventTitle && (
<Text fontSize="sm" color="whiteAlpha.600" fontWeight="normal" noOfLines={1} maxW="800px">
{selectedEventTitle}
</Text>
)}
</VStack>
<Badge colorScheme="blue" ml={2}>
{selectedEventStocks?.length || 0}
</Badge>
{stockQuotesLoading && <Spin size="small" />}
</HStack>
</ModalHeader>
<ModalCloseButton color="whiteAlpha.600" />
<ModalBody py={4} className="hero-panel-modal">
{selectedEventStocks && selectedEventStocks.length > 0 ? (
<Box
className="related-stocks-table-wrapper"
sx={{
/* Ant Design Table 内部滚动条样式 */
'.ant-table-body': {
'&::-webkit-scrollbar': { width: '8px', height: '8px' },
'&::-webkit-scrollbar-track': { background: 'rgba(255,255,255,0.05)', borderRadius: '4px' },
'&::-webkit-scrollbar-thumb': { background: 'rgba(255,215,0,0.4)', borderRadius: '4px' },
'&::-webkit-scrollbar-thumb:hover': { background: 'rgba(255,215,0,0.6)' },
},
}}
>
<Table
dataSource={selectedEventStocks}
columns={stockColumns}
rowKey={(record) => record.code}
size="middle"
pagination={false}
scroll={{ y: 500 }}
/>
</Box>
) : (
<Center h="200px">
<Text color="whiteAlpha.500">暂无相关股票</Text>
</Center>
)}
</ModalBody>
</ModalContent>
</Modal>
{/* K线图弹窗 */}
{selectedKlineStock && (
<KLineChartModal
isOpen={klineModalVisible}
onClose={() => {
setKlineModalVisible(false);
setSelectedKlineStock(null);
}}
stock={selectedKlineStock}
eventTime={selectedEventTime}
size="5xl"
/>
)}
{/* 板块股票弹窗 */}
<Modal
isOpen={sectorStocksModalVisible}
onClose={() => {
setSectorStocksModalVisible(false);
setSelectedSectorInfo(null);
}}
size="4xl"
scrollBehavior="inside"
>
<ModalOverlay bg="blackAlpha.700" backdropFilter={GLASS_BLUR.sm} />
<ModalContent
maxW="900px"
maxH="85vh"
bg="linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)"
border="1px solid rgba(255,215,0,0.2)"
borderRadius="2xl"
>
<ModalHeader borderBottom="1px solid rgba(255,215,0,0.2)" py={4}>
<HStack spacing={3}>
<Box
p={2}
bg="rgba(255,215,0,0.15)"
borderRadius="lg"
border="1px solid rgba(255,215,0,0.3)"
>
<TagsOutlined style={{ color: '#FFD700', fontSize: '18px' }} />
</Box>
<VStack align="start" spacing={0}>
<HStack spacing={2}>
<Text fontSize="lg" fontWeight="bold" color="#FFD700">
{selectedSectorInfo?.name}
</Text>
<Badge
colorScheme="red"
fontSize="sm"
px={2}
borderRadius="full"
>
{selectedSectorInfo?.count} 只涨停
</Badge>
</HStack>
<Text fontSize="xs" color="whiteAlpha.500">
按连板天数降序排列
</Text>
</VStack>
</HStack>
</ModalHeader>
<ModalCloseButton color="whiteAlpha.600" _hover={{ color: 'white' }} />
<ModalBody py={4} className="hero-panel-modal" overflowY="auto">
{selectedSectorInfo?.stocks?.length > 0 ? (
<VStack spacing={3} align="stretch">
{/* 快速统计 */}
<HStack spacing={4} flexWrap="wrap">
{(() => {
const stats = { '首板': 0, '2连板': 0, '3连板': 0, '4连板+': 0 };
selectedSectorInfo.stocks.forEach(s => {
const days = s._continuousDays || 1;
if (days === 1) stats['首板']++;
else if (days === 2) stats['2连板']++;
else if (days === 3) stats['3连板']++;
else stats['4连板+']++;
});
return Object.entries(stats).map(([key, value]) => (
value > 0 && (
<Box
key={key}
px={3}
py={1}
borderRadius="full"
bg={key === '4连板+' ? 'rgba(255,77,79,0.15)' : key === '3连板' ? 'rgba(250,84,28,0.15)' : key === '2连板' ? 'rgba(250,140,22,0.15)' : 'rgba(255,255,255,0.05)'}
border={`1px solid ${key === '4连板+' ? 'rgba(255,77,79,0.3)' : key === '3连板' ? 'rgba(250,84,28,0.3)' : key === '2连板' ? 'rgba(250,140,22,0.3)' : 'rgba(255,255,255,0.1)'}`}
>
<Text
fontSize="sm"
color={key === '4连板+' ? '#ff4d4f' : key === '3连板' ? '#fa541c' : key === '2连板' ? '#fa8c16' : '#888'}
>
{key}: <strong>{value}</strong>
</Text>
</Box>
)
));
})()}
</HStack>
{/* 股票列表 - 使用 Ant Design Table 内置滚动 */}
<Box
borderRadius="xl"
border="1px solid rgba(255,215,0,0.15)"
overflow="hidden"
className="sector-stocks-table-wrapper"
sx={{
/* Ant Design Table 内部滚动条样式 */
'.ant-table-body': {
'&::-webkit-scrollbar': { width: '8px', height: '8px' },
'&::-webkit-scrollbar-track': { background: 'rgba(255,255,255,0.05)', borderRadius: '4px' },
'&::-webkit-scrollbar-thumb': { background: 'rgba(255,215,0,0.4)', borderRadius: '4px' },
'&::-webkit-scrollbar-thumb:hover': { background: 'rgba(255,215,0,0.6)' },
},
}}
>
<Table
dataSource={selectedSectorInfo.stocks}
columns={[
{
title: '股票',
key: 'stock',
width: 130,
render: (_, record) => (
<VStack align="start" spacing={0}>
<a
href={`https://valuefrontier.cn/company?scode=${record.scode}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#FFD700', fontWeight: 'bold', fontSize: '14px' }}
>
{record.sname}
</a>
<AntText style={{ color: '#60A5FA', fontSize: '12px' }}>{record.scode}</AntText>
</VStack>
),
},
{
title: '连板',
dataIndex: 'continuous_days',
key: 'continuous',
width: 90,
align: 'center',
render: (text, record) => {
const days = record._continuousDays || 1;
const getDaysStyle = (d) => {
if (d >= 5) return { bg: 'linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%)', text: '#fff' };
if (d >= 3) return { bg: 'linear-gradient(135deg, #fa541c 0%, #ff7a45 100%)', text: '#fff' };
if (d >= 2) return { bg: 'linear-gradient(135deg, #fa8c16 0%, #ffc53d 100%)', text: '#fff' };
return { bg: 'rgba(255,255,255,0.1)', text: '#888' };
};
const style = getDaysStyle(days);
return (
<Box
px={3}
py={1}
borderRadius="md"
bg={style.bg}
fontSize="13px"
fontWeight="bold"
color={style.text}
display="inline-block"
>
{text || '首板'}
</Box>
);
},
},
{
title: '涨停时间',
dataIndex: 'formatted_time',
key: 'time',
width: 90,
align: 'center',
render: (time) => {
const getTimeStyle = (t) => {
if (t <= '09:30:00') return { bg: '#ff4d4f', text: '#fff' };
if (t <= '09:35:00') return { bg: '#fa541c', text: '#fff' };
if (t <= '10:00:00') return { bg: '#fa8c16', text: '#fff' };
return { bg: 'rgba(255,255,255,0.1)', text: '#888' };
};
const style = getTimeStyle(time || '15:00:00');
return (
<Box
px={2}
py={0.5}
borderRadius="md"
bg={style.bg}
fontSize="12px"
color={style.text}
>
{time?.substring(0, 5) || '-'}
</Box>
);
},
},
{
title: '核心板块',
dataIndex: 'core_sectors',
key: 'sectors',
render: (sectors) => (
<HStack spacing={1} flexWrap="wrap">
{(sectors || []).slice(0, 2).map((sector, idx) => (
<Tag
key={idx}
style={{
margin: '2px',
background: 'rgba(255,215,0,0.1)',
border: '1px solid rgba(255,215,0,0.2)',
borderRadius: '4px',
color: '#D4A84B',
fontSize: '11px',
}}
>
{sector}
</Tag>
))}
</HStack>
),
},
{
title: 'K线图',
key: 'kline',
width: 80,
align: 'center',
render: (_, record) => (
<Button
type="primary"
size="small"
icon={<LineChartOutlined />}
onClick={() => {
const code = record.scode;
let stockCode = code;
if (!code.includes('.')) {
if (code.startsWith('6')) stockCode = `${code}.SH`;
else if (code.startsWith('0') || code.startsWith('3')) stockCode = `${code}.SZ`;
}
setSelectedKlineStock({
stock_code: stockCode,
stock_name: record.sname
});
setKlineModalVisible(true);
}}
style={{
background: 'linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%)',
border: 'none',
borderRadius: '6px',
fontSize: '12px',
}}
>
查看
</Button>
),
},
{
title: '操作',
key: 'action',
width: 90,
align: 'center',
render: (_, record) => {
const code = record.scode;
const inWatchlist = isStockInWatchlist(code);
return (
<Button
type={inWatchlist ? 'primary' : 'default'}
size="small"
icon={inWatchlist ? <StarFilled /> : <StarOutlined />}
onClick={() => addSingleToWatchlist({ code, name: record.sname })}
disabled={inWatchlist}
style={inWatchlist ? {
background: 'linear-gradient(135deg, #faad14 0%, #fa8c16 100%)',
border: 'none',
borderRadius: '6px',
fontSize: '12px',
} : {
background: 'rgba(255,255,255,0.1)',
border: '1px solid rgba(255,215,0,0.3)',
borderRadius: '6px',
color: '#FFD700',
fontSize: '12px',
}}
>
{inWatchlist ? '已添加' : '加自选'}
</Button>
);
},
},
]}
rowKey="scode"
size="small"
pagination={false}
scroll={{ x: 650, y: 450 }}
/>
</Box>
</VStack>
) : (
<Center h="200px">
<Text color="whiteAlpha.500">暂无股票数据</Text>
</Center>
)}
</ModalBody>
</ModalContent>
</Modal>
{/* 关联事件弹窗 - 涨停归因详情 */}
<Modal
isOpen={relatedEventsModalVisible}
onClose={() => {
setRelatedEventsModalVisible(false);
setSelectedRelatedEvents({ sectorName: '', events: [] });
}}
size="xl"
scrollBehavior="inside"
>
<ModalOverlay bg="blackAlpha.700" backdropFilter={GLASS_BLUR.sm} />
<ModalContent
maxW="700px"
maxH="80vh"
bg="linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)"
border="1px solid rgba(96,165,250,0.3)"
borderRadius="2xl"
>
<ModalHeader borderBottom="1px solid rgba(96,165,250,0.2)" py={4}>
<HStack spacing={3}>
<Box
p={2}
bg="rgba(96,165,250,0.15)"
borderRadius="lg"
border="1px solid rgba(96,165,250,0.3)"
>
<FileText size={18} color="#60A5FA" />
</Box>
<VStack align="start" spacing={0}>
<HStack spacing={2}>
<Text fontSize="lg" fontWeight="bold" color="#60A5FA">
{selectedRelatedEvents.sectorName} - 涨停归因
</Text>
</HStack>
<HStack spacing={3}>
<Text fontSize="xs" color="whiteAlpha.500">
涨停 <Text as="span" color="#EF4444" fontWeight="bold">{selectedRelatedEvents.count || 0}</Text>
</Text>
<Text fontSize="xs" color="whiteAlpha.500">
关联事件 <Text as="span" color="#60A5FA" fontWeight="bold">{selectedRelatedEvents.events?.length || 0}</Text>
</Text>
</HStack>
</VStack>
</HStack>
</ModalHeader>
<ModalCloseButton color="whiteAlpha.600" _hover={{ color: 'white' }} />
<ModalBody py={4} overflowY="auto">
{selectedRelatedEvents.events?.length > 0 ? (
<VStack spacing={3} align="stretch">
{selectedRelatedEvents.events.map((event, idx) => {
const getRelevanceColor = (score) => {
if (score >= 80) return '#10B981';
if (score >= 60) return '#F59E0B';
return '#6B7280';
};
const relevanceColor = getRelevanceColor(event.relevance_score || 0);
return (
<Box
key={event.event_id || idx}
p={4}
bg="rgba(30,30,50,0.8)"
borderRadius="xl"
border="1px solid rgba(255,255,255,0.06)"
cursor="pointer"
onClick={() => {
// 跳转到事件详情页
window.open(`/community?event_id=${event.event_id}`, '_blank');
}}
_hover={{
bg: 'rgba(40,40,70,0.9)',
borderColor: 'rgba(96,165,250,0.3)',
transform: 'translateY(-2px)',
}}
transition="all 0.2s"
>
<VStack align="stretch" spacing={3}>
{/* 标题 */}
<HStack justify="space-between" align="start">
<HStack spacing={2} flex={1}>
<FileText size={16} color="#60A5FA" style={{ flexShrink: 0 }} />
<Text fontSize="sm" fontWeight="600" color="#E0E0E0" noOfLines={2}>
{event.title}
</Text>
</HStack>
<Badge
bg={`${relevanceColor}20`}
color={relevanceColor}
fontSize="xs"
px={2}
py={0.5}
borderRadius="md"
>
相关度 {event.relevance_score || 0}
</Badge>
</HStack>
{/* 相关原因 */}
{event.relevance_reason && (
<Text fontSize="xs" color="whiteAlpha.600" lineHeight="1.6">
{event.relevance_reason}
</Text>
)}
{/* 匹配概念 */}
{event.matched_concepts?.length > 0 && (
<Box>
<Text fontSize="xs" color="whiteAlpha.400" mb={1.5}>
匹配概念
</Text>
<HStack spacing={1.5} flexWrap="wrap">
{event.matched_concepts.slice(0, 6).map((concept, i) => (
<Tag
key={i}
style={{
fontSize: '10px',
margin: '2px',
background: 'rgba(139, 92, 246, 0.15)',
border: 'none',
color: '#A78BFA',
borderRadius: '4px',
padding: '2px 8px',
}}
>
{concept}
</Tag>
))}
{event.matched_concepts.length > 6 && (
<Tag
style={{
fontSize: '10px',
margin: '2px',
background: 'rgba(255,255,255,0.1)',
border: 'none',
color: '#888',
borderRadius: '4px',
padding: '2px 8px',
}}
>
+{event.matched_concepts.length - 6}
</Tag>
)}
</HStack>
</Box>
)}
</VStack>
</Box>
);
})}
</VStack>
) : (
<Center h="200px">
<Text color="whiteAlpha.500">暂无关联事件</Text>
</Center>
)}
</ModalBody>
</ModalContent>
</Modal>
</>
);
};
/**
* 综合日历组件 - 使用 FullCalendarPro 实现跨天事件条效果
*/
const CombinedCalendar = () => {
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectedDate, setSelectedDate] = useState(null);
// 日历综合数据(涨停 + 事件 + 上证涨跌幅)- 使用新的综合 API
const [calendarData, setCalendarData] = useState([]);
const [ztDailyDetails, setZtDailyDetails] = useState({});
const [selectedZtDetail, setSelectedZtDetail] = useState(null);
const [selectedEvents, setSelectedEvents] = useState([]);
const [detailLoading, setDetailLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
// 加载日历综合数据(一次 API 调用获取所有数据)
useEffect(() => {
const loadCalendarCombinedData = async () => {
try {
const year = currentMonth.getFullYear();
const month = currentMonth.getMonth() + 1;
const response = await fetch(`${getApiBase()}/api/v1/calendar/combined-data?year=${year}&month=${month}`);
if (response.ok) {
const result = await response.json();
if (result.success && result.data) {
// 转换为 FullCalendarPro 需要的格式
const formattedData = result.data.map(item => ({
date: item.date,
count: item.zt_count || 0,
topSector: item.top_sector || '',
eventCount: item.event_count || 0,
indexChange: item.index_change,
}));
console.log('[HeroPanel] 加载日历综合数据成功,数据条数:', formattedData.length);
setCalendarData(formattedData);
}
}
} catch (error) {
console.error('Failed to load calendar combined data:', error);
}
};
loadCalendarCombinedData();
}, [currentMonth]);
// 处理日期点击 - 打开弹窗
const handleDateClick = useCallback(async (date) => {
setSelectedDate(date);
setModalOpen(true);
setDetailLoading(true);
const ztDateStr = formatDateStr(date);
const eventDateStr = dayjs(date).format('YYYY-MM-DD');
// 加载涨停详情
const detail = ztDailyDetails[ztDateStr];
if (detail?.fullData) {
setSelectedZtDetail(detail.fullData);
} else {
try {
const response = await fetch(`/data/zt/daily/${ztDateStr}.json`);
if (response.ok) {
const data = await response.json();
setSelectedZtDetail(data);
setZtDailyDetails(prev => ({
...prev,
[ztDateStr]: { ...prev[ztDateStr], fullData: data }
}));
} else {
setSelectedZtDetail(null);
}
} catch {
setSelectedZtDetail(null);
}
}
// 加载事件详情
try {
const response = await eventService.calendar.getEventsForDate(eventDateStr);
if (response.success) {
setSelectedEvents(response.data || []);
} else {
setSelectedEvents([]);
}
} catch {
setSelectedEvents([]);
}
setDetailLoading(false);
}, [ztDailyDetails]);
// 月份变化回调
const handleMonthChange = useCallback((year, month) => {
setCurrentMonth(new Date(year, month - 1, 1));
}, []);
return (
<>
<Box
bg="rgba(15, 15, 22, 0.6)"
backdropFilter={GLASS_BLUR.md}
borderRadius="20px"
border="1px solid rgba(212, 175, 55, 0.2)"
p={5}
position="relative"
overflow="hidden"
>
{/* 顶部装饰条 */}
<Box
position="absolute"
top={0}
left={0}
right={0}
h="3px"
bgGradient="linear(to-r, transparent, #D4AF37, #F4D03F, #D4AF37, transparent)"
animation="shimmer 3s linear infinite"
backgroundSize="200% 100%"
/>
{/* FullCalendar Pro - 炫酷跨天事件条日历(懒加载) */}
<Suspense fallback={
<Center h="650px">
<VStack spacing={4}>
<Spinner size="xl" color="gold" thickness="3px" />
<Text color="whiteAlpha.600" fontSize="sm">加载日历组件...</Text>
</VStack>
</Center>
}>
<FullCalendarPro
data={calendarData}
currentMonth={currentMonth}
onDateClick={handleDateClick}
onMonthChange={handleMonthChange}
height="650px"
/>
</Suspense>
{/* 图例说明 */}
<HStack spacing={4} mt={4} justify="center" flexWrap="wrap">
<HStack spacing={2}>
<Box w="20px" h="10px" borderRadius="md" bgGradient="linear(135deg, #FFD700 0%, #FFA500 100%)" />
<Text fontSize="xs" color={textColors.muted}>连续热门概念</Text>
</HStack>
<HStack spacing={2}>
<Icon as={Flame} boxSize={3} color="#EF4444" />
<Text fontSize="xs" color={textColors.muted}>涨停60</Text>
</HStack>
<HStack spacing={2}>
<Icon as={Flame} boxSize={3} color="#F59E0B" />
<Text fontSize="xs" color={textColors.muted}>涨停&lt;60</Text>
</HStack>
<HStack spacing={2}>
<Box
w="14px"
h="14px"
borderRadius="full"
bg="linear-gradient(135deg, #22C55E 0%, #16A34A 100%)"
display="flex"
alignItems="center"
justifyContent="center"
>
<Text fontSize="8px" fontWeight="bold" color="white">N</Text>
</Box>
<Text fontSize="xs" color={textColors.muted}>未来事件数</Text>
</HStack>
<HStack spacing={2}>
<Text fontSize="xs" fontWeight="600" color="#EF4444">+0.5%</Text>
<Text fontSize="xs" color={textColors.muted}>/</Text>
<Text fontSize="xs" fontWeight="600" color="#22C55E">-0.5%</Text>
<Text fontSize="xs" color={textColors.muted}>上证涨跌</Text>
</HStack>
</HStack>
</Box>
{/* 详情弹窗 */}
<DetailModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
selectedDate={selectedDate}
ztDetail={selectedZtDetail}
events={selectedEvents}
loading={detailLoading}
/>
</>
);
};
/**
* 右侧 Tab 面板 - HeroUI 风格毛玻璃
*/
const RightPanelTabs = () => {
// 默认显示日历
const [activeTab, setActiveTab] = useState('calendar');
return (
<Box
bg="linear-gradient(135deg, rgba(10, 10, 20, 0.9) 0%, rgba(20, 20, 40, 0.95) 50%, rgba(15, 15, 30, 0.9) 100%)"
backdropFilter="blur(20px)"
borderRadius="2xl"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.08)"
h="100%"
display="flex"
flexDirection="column"
overflow="hidden"
boxShadow="0 8px 32px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255,255,255,0.05)"
position="relative"
>
{/* 背景光效 */}
<Box
position="absolute"
top="-50px"
left="-50px"
w="200px"
h="200px"
bg="radial-gradient(circle, rgba(6, 182, 212, 0.1) 0%, transparent 60%)"
pointerEvents="none"
filter="blur(40px)"
/>
{/* Tab 切换头 */}
<HStack
px={4}
py={3}
borderBottom="1px solid rgba(255,255,255,0.06)"
bg="rgba(0,0,0,0.2)"
spacing={2}
>
<Box
px={4}
py={2}
cursor="pointer"
borderRadius="xl"
bg={activeTab === 'calendar' ? 'rgba(124, 58, 237, 0.2)' : 'transparent'}
color={activeTab === 'calendar' ? '#A78BFA' : 'whiteAlpha.500'}
fontWeight={activeTab === 'calendar' ? 'bold' : 'normal'}
fontSize="sm"
transition="all 0.3s ease"
border={activeTab === 'calendar' ? '1px solid rgba(124, 58, 237, 0.3)' : '1px solid transparent'}
_hover={{ color: '#A78BFA', bg: 'rgba(124, 58, 237, 0.1)' }}
onClick={() => setActiveTab('calendar')}
>
<HStack spacing={2}>
<CalendarOutlined />
<Text>涨停与未来日历</Text>
</HStack>
</Box>
<Box
px={4}
py={2}
cursor="pointer"
borderRadius="xl"
bg={activeTab === 'comet' ? 'rgba(6, 182, 212, 0.2)' : 'transparent'}
color={activeTab === 'comet' ? '#22D3EE' : 'whiteAlpha.500'}
fontWeight={activeTab === 'comet' ? 'bold' : 'normal'}
fontSize="sm"
transition="all 0.3s ease"
border={activeTab === 'comet' ? '1px solid rgba(6, 182, 212, 0.3)' : '1px solid transparent'}
_hover={{ color: '#22D3EE', bg: 'rgba(6, 182, 212, 0.1)' }}
onClick={() => setActiveTab('comet')}
>
<HStack spacing={2}>
<FireOutlined />
<Text>连板情绪监测</Text>
</HStack>
</Box>
</HStack>
{/* Tab 内容区域 */}
<Box flex="1" p={3} overflow="hidden">
{activeTab === 'comet' ? (
<Box h="100%">
<ThemeCometChart />
</Box>
) : (
<Box h="100%">
<CombinedCalendar />
</Box>
)}
</Box>
</Box>
);
};
/**
* 使用说明弹窗组件
*/
const InfoModal = () => {
const { isOpen, onOpen, onClose } = useDisclosure();
return (
<>
<HStack
spacing={1.5}
px={3}
py={1.5}
bg="rgba(255,215,0,0.08)"
border="1px solid rgba(255,215,0,0.2)"
borderRadius="full"
cursor="pointer"
transition="all 0.2s"
_hover={{
bg: 'rgba(255,215,0,0.15)',
borderColor: 'rgba(255,215,0,0.4)',
transform: 'scale(1.02)',
}}
onClick={onOpen}
>
<Icon as={Info} color="gold" boxSize={4} />
<Text fontSize="sm" color="gold" fontWeight="medium">
使用说明
</Text>
</HStack>
<Modal isOpen={isOpen} onClose={onClose} size="lg" isCentered motionPreset="slideInBottom">
<ModalOverlay bg="blackAlpha.700" backdropFilter={GLASS_BLUR.sm} />
<ModalContent
bg="linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)"
border="1px solid rgba(255,215,0,0.3)"
borderRadius="2xl"
boxShadow="0 25px 80px rgba(0,0,0,0.8)"
maxW="550px"
mx={4}
>
<ModalHeader borderBottom="1px solid rgba(255,215,0,0.2)" pb={4}>
<HStack spacing={3}>
<Box p={2} bg="rgba(255,215,0,0.15)" borderRadius="lg" border="1px solid rgba(255,215,0,0.3)">
<Icon as={Info} color="gold" boxSize={5} />
</Box>
<Text fontSize="xl" fontWeight="bold" bgGradient="linear(to-r, #FFD700, #FFA500)" bgClip="text">
事件中心使用指南
</Text>
</HStack>
</ModalHeader>
<ModalCloseButton color="whiteAlpha.600" _hover={{ color: 'white' }} />
<ModalBody py={6} px={6}>
<VStack align="stretch" spacing={5}>
<Box>
<Text fontSize="md" fontWeight="bold" color="whiteAlpha.900" mb={2}>📅 综合日历</Text>
<Text fontSize="md" color="whiteAlpha.800" lineHeight="1.8">
日历同时展示<Text as="span" color="purple.300" fontWeight="bold">历史涨停数据</Text>
<Text as="span" color="green.300" fontWeight="bold">未来事件</Text>
点击日期查看详细信息
</Text>
</Box>
<Box>
<Text fontSize="md" fontWeight="bold" color="whiteAlpha.900" mb={2}>🔥 涨停板块</Text>
<Text fontSize="md" color="whiteAlpha.800" lineHeight="1.8">
点击历史日期查看当日涨停板块排行涨停数量涨停股票代码帮助理解市场主线
</Text>
</Box>
<Box>
<Text fontSize="md" fontWeight="bold" color="whiteAlpha.900" mb={2}>📊 未来事件</Text>
<Text fontSize="md" color="whiteAlpha.800" lineHeight="1.8">
点击未来日期查看事件详情包括<Text as="span" color="cyan.300" fontWeight="bold">背景分析</Text>
<Text as="span" color="orange.300" fontWeight="bold">未来推演</Text>
<Text as="span" color="green.300" fontWeight="bold">相关股票</Text>
</Text>
</Box>
<Box pt={3} borderTop="1px solid rgba(255,215,0,0.2)">
<Text fontSize="md" color="yellow.300" textAlign="center" fontWeight="medium">
💡 颜色越深表示涨停数越多 · 绿色标记表示有未来事件
</Text>
</Box>
</VStack>
</ModalBody>
</ModalContent>
</Modal>
</>
);
};
/**
* 顶部说明面板主组件
*/
const HeroPanel = () => {
const gradientBg = 'linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 25%, #16213e 50%, #1a1a2e 75%, #0a0a0a 100%)';
const borderColor = useColorModeValue('rgba(255, 215, 0, 0.3)', 'rgba(255, 215, 0, 0.25)');
return (
<Card
bg={gradientBg}
borderColor={borderColor}
borderWidth="1px"
boxShadow="0 20px 60px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,215,0,0.1) inset"
mb={6}
overflow="hidden"
position="relative"
>
{/* 装饰性光晕 */}
<Box
position="absolute"
top="-50%"
right="-30%"
width="600px"
height="600px"
borderRadius="full"
bg="radial-gradient(circle, rgba(255,215,0,0.08) 0%, transparent 70%)"
pointerEvents="none"
filter="blur(40px)"
/>
<Box
position="absolute"
bottom="-40%"
left="-20%"
width="400px"
height="400px"
borderRadius="full"
bg="radial-gradient(circle, rgba(100,150,255,0.05) 0%, transparent 70%)"
pointerEvents="none"
filter="blur(50px)"
/>
<CardBody p={{ base: 4, md: 6 }}>
{/* 标题行 */}
<Flex align="center" justify="space-between" mb={5} wrap="wrap" gap={3}>
<HStack spacing={4}>
<Heading size="xl">
<Text
bgGradient="linear(to-r, #FFD700, #FFA500, #FFD700)"
bgClip="text"
backgroundSize="200% 100%"
animation="shimmer 3s linear infinite"
fontWeight="extrabold"
>
事件中心
</Text>
</Heading>
<InfoModal />
{isInTradingTime() && (
<HStack
spacing={2}
px={3}
py={1.5}
borderRadius="full"
bg="rgba(0,218,60,0.1)"
border="1px solid rgba(0,218,60,0.3)"
>
<Box
w="8px"
h="8px"
borderRadius="full"
bg="#00da3c"
animation="pulse 1.5s infinite"
boxShadow="0 0 10px #00da3c"
/>
<Text fontSize="sm" color="#00da3c" fontWeight="bold">
交易中
</Text>
</HStack>
)}
</HStack>
</Flex>
{/* AI舆情时空决策驾驶舱 - 左侧今日统计(2/5)右侧Tab切换(3/5) */}
<Flex gap={5} align="stretch" h="720px">
{/* 左侧:今日事件统计 */}
<Box flex="2" minW="0" h="100%">
<EventDailyStats />
</Box>
{/* 右侧:连板情绪 / 日历 Tab 切换 */}
<Box flex="3" minW="0" h="100%">
<RightPanelTabs />
</Box>
</Flex>
</CardBody>
</Card>
);
};
export default HeroPanel;