1912 lines
59 KiB
JavaScript
1912 lines
59 KiB
JavaScript
// src/views/Community/components/HeroPanel.js
|
||
// 综合日历面板:融合涨停分析 + 投资日历
|
||
// 点击日期弹出详情弹窗(TAB切换历史涨停/未来事件)
|
||
|
||
import React, { useEffect, useState, useCallback, useMemo, memo } 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';
|
||
|
||
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; }
|
||
}
|
||
|
||
/* 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);
|
||
}
|
||
|
||
/* 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';
|
||
|
||
/**
|
||
* 日历单元格 - 显示涨停数和事件数(加大尺寸)
|
||
*/
|
||
const CalendarCell = memo(({ date, ztData, eventCount, previousZtData, isSelected, isToday, isWeekend, onClick }) => {
|
||
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 || '';
|
||
|
||
// 周末无数据显示"休市"
|
||
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 && (
|
||
<Text fontSize="xs" color={textColors.secondary} noOfLines={1} maxW="70px">
|
||
{topSector}
|
||
</Text>
|
||
)}
|
||
</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 [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);
|
||
|
||
// 板块数据处理 - 必须在条件返回之前调用所有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 || [],
|
||
}))
|
||
.sort((a, b) => b.count - a.count);
|
||
}, [ztDetail]);
|
||
|
||
// 股票详情数据处理
|
||
const stockList = useMemo(() => {
|
||
if (!ztDetail?.stock_infos) return [];
|
||
return ztDetail.stock_infos.map(stock => ({
|
||
...stock,
|
||
key: stock.scode,
|
||
}));
|
||
}, [ztDetail]);
|
||
|
||
// 热门关键词
|
||
const hotKeywords = useMemo(() => {
|
||
if (!ztDetail?.word_freq_data) return [];
|
||
return ztDetail.word_freq_data.slice(0, 12);
|
||
}, [ztDetail]);
|
||
|
||
// 获取六位股票代码(去掉后缀)- 纯函数,不是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: 55,
|
||
align: 'center',
|
||
render: (_, __, index) => (
|
||
<Tag color={index < 3 ? 'gold' : 'default'} style={{ fontWeight: 'bold', margin: 0 }}>
|
||
{index + 1}
|
||
</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '板块',
|
||
dataIndex: 'name',
|
||
key: 'name',
|
||
width: 120,
|
||
render: (name) => (
|
||
<AntText strong style={{ color: '#FFD700' }}>{name}</AntText>
|
||
),
|
||
},
|
||
{
|
||
title: '涨停数',
|
||
dataIndex: 'count',
|
||
key: 'count',
|
||
width: 80,
|
||
align: 'center',
|
||
render: (count) => (
|
||
<Tag color={count >= 8 ? 'red' : count >= 5 ? 'volcano' : count >= 3 ? 'orange' : 'blue'}>
|
||
<FireOutlined style={{ marginRight: 3 }} />
|
||
{count}
|
||
</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '涨停股票',
|
||
dataIndex: 'stocks',
|
||
key: 'stocks',
|
||
render: (stocks) => {
|
||
// 根据股票代码查找股票名称
|
||
const getStockName = (code) => {
|
||
const stockInfo = stockList.find(s => s.scode === code);
|
||
return stockInfo?.sname || code;
|
||
};
|
||
return (
|
||
<Space wrap size={[4, 4]}>
|
||
{stocks.slice(0, 6).map((code) => (
|
||
<Tag
|
||
key={code}
|
||
style={{ cursor: 'pointer', margin: 0 }}
|
||
color="processing"
|
||
>
|
||
<a
|
||
href={`https://valuefrontier.cn/company?scode=${code}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{ color: 'inherit' }}
|
||
>
|
||
{getStockName(code)}
|
||
</a>
|
||
</Tag>
|
||
))}
|
||
{stocks.length > 6 && (
|
||
<Tag style={{ margin: 0 }}>+{stocks.length - 6}</Tag>
|
||
)}
|
||
</Space>
|
||
);
|
||
},
|
||
},
|
||
];
|
||
|
||
// 涨停股票详情表格列
|
||
const ztStockColumns = [
|
||
{
|
||
title: '股票',
|
||
key: 'stock',
|
||
width: 120,
|
||
fixed: 'left',
|
||
render: (_, record) => (
|
||
<a
|
||
href={`https://valuefrontier.cn/company?scode=${record.scode}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{ color: '#FFD700', fontWeight: 'bold' }}
|
||
>
|
||
{record.sname}
|
||
</a>
|
||
),
|
||
},
|
||
{
|
||
title: '涨停时间',
|
||
dataIndex: 'formatted_time',
|
||
key: 'time',
|
||
width: 85,
|
||
align: 'center',
|
||
render: (time) => (
|
||
<Tag color={time <= '09:30:00' ? 'red' : time <= '10:00:00' ? 'volcano' : 'default'}>
|
||
{time?.substring(0, 5) || '-'}
|
||
</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '连板',
|
||
dataIndex: 'continuous_days',
|
||
key: 'continuous',
|
||
width: 75,
|
||
align: 'center',
|
||
render: (text) => {
|
||
if (!text || text === '首板') return <Tag>首板</Tag>;
|
||
const match = text.match(/(\d+)/);
|
||
const days = match ? parseInt(match[1]) : 1;
|
||
return (
|
||
<Tag color={days >= 5 ? 'red' : days >= 3 ? 'volcano' : days >= 2 ? 'orange' : 'default'}>
|
||
{text}
|
||
</Tag>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '核心板块',
|
||
dataIndex: 'core_sectors',
|
||
key: 'sectors',
|
||
width: 180,
|
||
render: (sectors) => (
|
||
<Space wrap size={[2, 2]}>
|
||
{(sectors || []).slice(0, 3).map((sector, idx) => (
|
||
<Tag key={idx} color="gold" style={{ margin: 0, fontSize: '12px' }}>
|
||
{sector}
|
||
</Tag>
|
||
))}
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '涨停简报',
|
||
dataIndex: 'brief',
|
||
key: 'brief',
|
||
ellipsis: true,
|
||
render: (text) => {
|
||
if (!text) return <AntText type="secondary">-</AntText>;
|
||
// 移除HTML标签
|
||
const cleanText = text.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]+>/g, '');
|
||
return (
|
||
<Tooltip title={cleanText} placement="topLeft" overlayStyle={{ maxWidth: 500 }}>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() => showContentDetail(text.replace(/<br\s*\/?>/gi, '\n\n'), '涨停简报')}
|
||
style={{ padding: 0, height: 'auto', whiteSpace: 'normal', textAlign: 'left' }}
|
||
>
|
||
{cleanText.length > 40 ? cleanText.substring(0, 40) + '...' : cleanText}
|
||
</Button>
|
||
</Tooltip>
|
||
);
|
||
},
|
||
},
|
||
];
|
||
|
||
// 事件表格列(参考投资日历)- 去掉相关概念列
|
||
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"
|
||
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">
|
||
{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={4} align="stretch">
|
||
{/* 热门关键词 */}
|
||
{hotKeywords.length > 0 && (
|
||
<Box
|
||
p={3}
|
||
bg="rgba(255, 215, 0, 0.05)"
|
||
borderRadius="lg"
|
||
border="1px solid rgba(255, 215, 0, 0.15)"
|
||
>
|
||
<HStack spacing={2} mb={2}>
|
||
<FireOutlined style={{ color: '#FFD700' }} />
|
||
<Text fontSize="sm" fontWeight="bold" color="gold">
|
||
今日热词
|
||
</Text>
|
||
</HStack>
|
||
<Space wrap size={[6, 6]}>
|
||
{hotKeywords.map((kw, idx) => (
|
||
<Tag
|
||
key={kw.name}
|
||
color={idx < 3 ? 'gold' : idx < 6 ? 'orange' : 'default'}
|
||
style={{
|
||
fontSize: idx < 3 ? '14px' : '13px',
|
||
fontWeight: idx < 3 ? 'bold' : 'normal',
|
||
}}
|
||
>
|
||
{kw.name}
|
||
</Tag>
|
||
))}
|
||
</Space>
|
||
</Box>
|
||
)}
|
||
|
||
{/* 视图切换按钮 */}
|
||
<HStack justify="space-between" align="center">
|
||
<HStack spacing={2}>
|
||
<Button
|
||
size="small"
|
||
type={ztViewMode === 'sector' ? 'primary' : 'default'}
|
||
onClick={() => setZtViewMode('sector')}
|
||
icon={<TagsOutlined />}
|
||
>
|
||
按板块 ({sectorList.length})
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
type={ztViewMode === 'stock' ? 'primary' : 'default'}
|
||
onClick={() => setZtViewMode('stock')}
|
||
icon={<StockOutlined />}
|
||
>
|
||
按个股 ({stockList.length})
|
||
</Button>
|
||
</HStack>
|
||
<Text fontSize="sm" color="whiteAlpha.600">
|
||
共 {ztDetail?.total_stocks || 0} 只涨停
|
||
</Text>
|
||
</HStack>
|
||
|
||
{/* 板块视图 */}
|
||
{ztViewMode === 'sector' && (
|
||
<Table
|
||
dataSource={sectorList}
|
||
columns={sectorColumns}
|
||
rowKey="name"
|
||
size="small"
|
||
pagination={false}
|
||
scroll={{ y: 350 }}
|
||
/>
|
||
)}
|
||
|
||
{/* 个股视图 */}
|
||
{ztViewMode === 'stock' && (
|
||
<Table
|
||
dataSource={stockList}
|
||
columns={ztStockColumns}
|
||
rowKey="scode"
|
||
size="small"
|
||
pagination={false}
|
||
scroll={{ x: 800, y: 350 }}
|
||
/>
|
||
)}
|
||
</VStack>
|
||
) : (
|
||
<Center h="200px">
|
||
<VStack>
|
||
<FireOutlined style={{ fontSize: 48, color: '#666' }} />
|
||
<AntText type="secondary" style={{ fontSize: 16 }}>暂无涨停数据</AntText>
|
||
</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
|
||
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)"
|
||
>
|
||
<DrawerCloseButton color="whiteAlpha.600" />
|
||
<DrawerHeader borderBottom="1px solid rgba(255,215,0,0.2)" color="white">
|
||
{selectedContent?.title}
|
||
</DrawerHeader>
|
||
<DrawerBody py={6}>
|
||
<Box
|
||
className="markdown-content"
|
||
color="whiteAlpha.900"
|
||
fontSize="md"
|
||
lineHeight="1.8"
|
||
sx={{
|
||
'& p': { mb: 4 },
|
||
'& h1, & h2, & h3': { color: 'gold', mb: 3 },
|
||
'& ul, & ol': { pl: 6 },
|
||
'& li': { mb: 2 },
|
||
}}
|
||
>
|
||
<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 maxH="calc(85vh - 120px)" overflowY="auto">
|
||
<Table
|
||
dataSource={selectedEventStocks}
|
||
columns={stockColumns}
|
||
rowKey={(record) => record.code}
|
||
size="middle"
|
||
pagination={false}
|
||
/>
|
||
</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"
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 综合日历组件(无左侧面板,点击弹窗)
|
||
*/
|
||
const CombinedCalendar = () => {
|
||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||
const [selectedDate, setSelectedDate] = useState(null);
|
||
|
||
// 涨停数据
|
||
const [ztDatesData, setZtDatesData] = useState([]);
|
||
const [ztDailyDetails, setZtDailyDetails] = useState({});
|
||
const [selectedZtDetail, setSelectedZtDetail] = useState(null);
|
||
|
||
// 投资日历数据
|
||
const [eventCounts, setEventCounts] = useState([]);
|
||
const [selectedEvents, setSelectedEvents] = useState([]);
|
||
|
||
const [loading, setLoading] = useState(true);
|
||
const [detailLoading, setDetailLoading] = useState(false);
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
|
||
// 加载涨停 dates.json
|
||
useEffect(() => {
|
||
const loadZtDatesData = async () => {
|
||
try {
|
||
const response = await fetch('/data/zt/dates.json');
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
setZtDatesData(data.dates || []);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load zt dates.json:', error);
|
||
}
|
||
};
|
||
loadZtDatesData();
|
||
}, []);
|
||
|
||
// 加载投资日历事件数量
|
||
useEffect(() => {
|
||
const loadEventCounts = async () => {
|
||
try {
|
||
const year = currentMonth.getFullYear();
|
||
const month = currentMonth.getMonth() + 1;
|
||
const response = await eventService.calendar.getEventCounts(year, month);
|
||
if (response.success) {
|
||
setEventCounts(response.data || []);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load event counts:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
loadEventCounts();
|
||
}, [currentMonth]);
|
||
|
||
// 获取当月涨停板块详情
|
||
useEffect(() => {
|
||
const loadMonthZtDetails = async () => {
|
||
const year = currentMonth.getFullYear();
|
||
const month = currentMonth.getMonth();
|
||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||
|
||
const details = {};
|
||
const promises = [];
|
||
|
||
for (let day = 1; day <= daysInMonth; day++) {
|
||
const dateStr = `${year}${String(month + 1).padStart(2, '0')}${String(day).padStart(2, '0')}`;
|
||
const hasData = ztDatesData.some(d => d.date === dateStr);
|
||
if (hasData && !ztDailyDetails[dateStr]) {
|
||
promises.push(
|
||
fetch(`/data/zt/daily/${dateStr}.json`)
|
||
.then(res => res.ok ? res.json() : null)
|
||
.then(data => {
|
||
if (data && data.sector_data) {
|
||
let maxSector = '';
|
||
let maxCount = 0;
|
||
Object.entries(data.sector_data).forEach(([sector, info]) => {
|
||
if (info.count > maxCount) {
|
||
maxCount = info.count;
|
||
maxSector = sector;
|
||
}
|
||
});
|
||
details[dateStr] = { top_sector: maxSector, fullData: data };
|
||
}
|
||
})
|
||
.catch(() => null)
|
||
);
|
||
}
|
||
}
|
||
|
||
if (promises.length > 0) {
|
||
await Promise.all(promises);
|
||
setZtDailyDetails(prev => ({ ...prev, ...details }));
|
||
}
|
||
};
|
||
|
||
if (ztDatesData.length > 0) {
|
||
loadMonthZtDetails();
|
||
}
|
||
}, [currentMonth, ztDatesData]);
|
||
|
||
// 构建日期数据映射
|
||
const ztDataMap = useMemo(() => {
|
||
const map = new Map();
|
||
ztDatesData.forEach(d => {
|
||
const detail = ztDailyDetails[d.date] || {};
|
||
map.set(d.date, {
|
||
date: d.date,
|
||
count: d.count,
|
||
top_sector: detail.top_sector,
|
||
});
|
||
});
|
||
return map;
|
||
}, [ztDatesData, ztDailyDetails]);
|
||
|
||
const eventCountMap = useMemo(() => {
|
||
const map = new Map();
|
||
eventCounts.forEach(d => {
|
||
map.set(d.date, d.count);
|
||
});
|
||
return map;
|
||
}, [eventCounts]);
|
||
|
||
// 生成日历天数
|
||
const days = useMemo(() => {
|
||
const year = currentMonth.getFullYear();
|
||
const month = currentMonth.getMonth();
|
||
const firstDay = new Date(year, month, 1);
|
||
const lastDay = new Date(year, month + 1, 0);
|
||
const daysInMonth = lastDay.getDate();
|
||
const startingDayOfWeek = firstDay.getDay();
|
||
|
||
const result = [];
|
||
for (let i = 0; i < startingDayOfWeek; i++) {
|
||
result.push(null);
|
||
}
|
||
for (let i = 1; i <= daysInMonth; i++) {
|
||
result.push(new Date(year, month, i));
|
||
}
|
||
return result;
|
||
}, [currentMonth]);
|
||
|
||
// 预计算日历格子数据
|
||
const calendarCellsData = useMemo(() => {
|
||
const today = new Date();
|
||
const todayStr = today.toDateString();
|
||
const selectedDateStr = selectedDate?.toDateString();
|
||
|
||
return days.map((date, index) => {
|
||
if (!date) {
|
||
return { key: `empty-${index}`, date: null };
|
||
}
|
||
|
||
const ztDateStr = formatDateStr(date);
|
||
const eventDateStr = dayjs(date).format('YYYY-MM-DD');
|
||
const ztData = ztDataMap.get(ztDateStr) || null;
|
||
const eventCount = eventCountMap.get(eventDateStr) || 0;
|
||
const dayOfWeek = date.getDay();
|
||
|
||
const prevDate = new Date(date);
|
||
prevDate.setDate(prevDate.getDate() - 1);
|
||
const previousZtData = ztDataMap.get(formatDateStr(prevDate)) || null;
|
||
|
||
return {
|
||
key: ztDateStr || `day-${index}`,
|
||
date,
|
||
ztData,
|
||
eventCount,
|
||
previousZtData,
|
||
isSelected: date.toDateString() === selectedDateStr,
|
||
isToday: date.toDateString() === todayStr,
|
||
isWeekend: dayOfWeek === 0 || dayOfWeek === 6,
|
||
};
|
||
});
|
||
}, [days, ztDataMap, eventCountMap, selectedDate]);
|
||
|
||
// 处理日期点击 - 打开弹窗
|
||
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 handlePrevMonth = useCallback(() => {
|
||
setCurrentMonth(prev => new Date(prev.getFullYear(), prev.getMonth() - 1));
|
||
}, []);
|
||
|
||
const handleNextMonth = useCallback(() => {
|
||
setCurrentMonth(prev => new Date(prev.getFullYear(), prev.getMonth() + 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%"
|
||
/>
|
||
|
||
{/* 月份导航 */}
|
||
<HStack justify="space-between" mb={4}>
|
||
<IconButton
|
||
icon={<ChevronLeft size={22} />}
|
||
variant="ghost"
|
||
size="md"
|
||
color={textColors.secondary}
|
||
_hover={{ color: goldColors.primary, bg: 'rgba(255, 255, 255, 0.05)' }}
|
||
onClick={handlePrevMonth}
|
||
aria-label="上个月"
|
||
/>
|
||
<HStack spacing={3}>
|
||
<Icon as={Calendar} boxSize={5} color={goldColors.primary} />
|
||
<Text fontSize="xl" fontWeight="bold" color={textColors.primary}>
|
||
{currentMonth.getFullYear()}年{MONTH_NAMES[currentMonth.getMonth()]}
|
||
</Text>
|
||
<Text fontSize="sm" color={textColors.muted}>
|
||
综合日历
|
||
</Text>
|
||
</HStack>
|
||
<IconButton
|
||
icon={<ChevronRight size={22} />}
|
||
variant="ghost"
|
||
size="md"
|
||
color={textColors.secondary}
|
||
_hover={{ color: goldColors.primary, bg: 'rgba(255, 255, 255, 0.05)' }}
|
||
onClick={handleNextMonth}
|
||
aria-label="下个月"
|
||
/>
|
||
</HStack>
|
||
|
||
{/* 星期标题 */}
|
||
<SimpleGrid columns={7} spacing={2} mb={3}>
|
||
{WEEK_DAYS.map((day, idx) => (
|
||
<Text
|
||
key={day}
|
||
textAlign="center"
|
||
fontSize="md"
|
||
fontWeight="600"
|
||
color={idx === 0 || idx === 6 ? 'orange.300' : textColors.secondary}
|
||
>
|
||
{day}
|
||
</Text>
|
||
))}
|
||
</SimpleGrid>
|
||
|
||
{/* 日历格子 */}
|
||
{loading ? (
|
||
<Center h="300px">
|
||
<Spinner size="xl" color={goldColors.primary} thickness="3px" />
|
||
</Center>
|
||
) : (
|
||
<SimpleGrid columns={7} spacing={2}>
|
||
{calendarCellsData.map((cellData) => (
|
||
<CalendarCell
|
||
key={cellData.key}
|
||
date={cellData.date}
|
||
ztData={cellData.ztData}
|
||
eventCount={cellData.eventCount}
|
||
previousZtData={cellData.previousZtData}
|
||
isSelected={cellData.isSelected}
|
||
isToday={cellData.isToday}
|
||
isWeekend={cellData.isWeekend}
|
||
onClick={handleDateClick}
|
||
/>
|
||
))}
|
||
</SimpleGrid>
|
||
)}
|
||
|
||
{/* 图例 */}
|
||
<HStack spacing={5} mt={4} justify="center" flexWrap="wrap">
|
||
<HStack spacing={2}>
|
||
<Icon as={Flame} boxSize={4} color="purple.400" />
|
||
<Text fontSize="sm" color={textColors.muted}>涨停≥80</Text>
|
||
</HStack>
|
||
<HStack spacing={2}>
|
||
<Icon as={Flame} boxSize={4} color="red.400" />
|
||
<Text fontSize="sm" color={textColors.muted}>涨停≥60</Text>
|
||
</HStack>
|
||
<HStack spacing={2}>
|
||
<Icon as={Flame} boxSize={4} color="orange.400" />
|
||
<Text fontSize="sm" color={textColors.muted}>涨停≥40</Text>
|
||
</HStack>
|
||
<HStack spacing={2}>
|
||
<Icon as={FileText} boxSize={4} color="#22c55e" />
|
||
<Text fontSize="sm" color={textColors.muted}>未来事件</Text>
|
||
</HStack>
|
||
</HStack>
|
||
</Box>
|
||
|
||
{/* 详情弹窗 */}
|
||
<DetailModal
|
||
isOpen={modalOpen}
|
||
onClose={() => setModalOpen(false)}
|
||
selectedDate={selectedDate}
|
||
ztDetail={selectedZtDetail}
|
||
events={selectedEvents}
|
||
loading={detailLoading}
|
||
/>
|
||
</>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 使用说明弹窗组件
|
||
*/
|
||
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>
|
||
|
||
{/* 综合日历 */}
|
||
<CombinedCalendar />
|
||
</CardBody>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
export default HeroPanel;
|