3165 lines
114 KiB
JavaScript
3165 lines
114 KiB
JavaScript
// 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}>涨停<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;
|