Files
vf_react/src/views/Concept/ConceptTimelineModal.js
zdl fba95a6701 fix(Concept): 修复历史时间轴图例颜色与日历不一致
- 将「涨3%+」图例边框颜色从 orange.400 改为 red.500
- 与日历中高涨幅事件背景色 (#F56565) 统一

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 13:14:16 +08:00

1505 lines
75 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import React, { useState, useEffect, useMemo } from 'react';
import { logger } from '../../utils/logger';
import { useConceptTimelineEvents } from './hooks/useConceptTimelineEvents';
import RiskDisclaimer from '../../components/RiskDisclaimer';
import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
Box,
VStack,
HStack,
Text,
Button,
Badge,
Icon,
Flex,
Spinner,
Center,
Collapse,
Divider,
useToast,
useDisclosure,
SimpleGrid,
Tooltip,
useBreakpointValue,
} from '@chakra-ui/react';
import {
ChevronDownIcon,
ChevronRightIcon,
ExternalLinkIcon,
ViewIcon,
CalendarIcon,
} from '@chakra-ui/icons';
import {
FaChartLine,
FaArrowUp,
FaArrowDown,
FaHistory,
FaNewspaper,
FaFileAlt,
FaClock,
} from 'react-icons/fa';
import { keyframes } from '@emotion/react';
dayjs.locale('zh-cn');
// 动画定义
const pulseAnimation = keyframes`
0% { transform: translate(-50%, -50%) scale(1); opacity: 0.5; }
50% { transform: translate(-50%, -50%) scale(1.3); opacity: 0.2; }
100% { transform: translate(-50%, -50%) scale(1); opacity: 0.5; }
`;
const shimmerAnimation = keyframes`
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
`;
// 格式化URL确保有协议前缀
const formatUrl = (url) => {
if (!url) return null;
// 如果已经有协议前缀,直接返回
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// 如果是 // 开头的协议相对URL
if (url.startsWith('//')) {
return 'https:' + url;
}
// 否则添加 https:// 前缀
return 'https://' + url;
};
import { getApiBase } from '@utils/apiConfig';
// API配置 - 生产环境通过 api.valuefrontier.cn 代理
const API_BASE_URL = process.env.NODE_ENV === 'production'
? `${getApiBase()}/concept-api`
: 'http://111.198.58.126:16801';
const NEWS_API_URL = process.env.NODE_ENV === 'production'
? `${getApiBase()}/news-api`
: 'http://111.198.58.126:21891';
const REPORT_API_URL = process.env.NODE_ENV === 'production'
? `${getApiBase()}/report-api`
: 'http://111.198.58.126:8811';
// 主应用后端 API 基础 URL用于获取股票关联的事件/新闻)
const MAIN_API_URL = getApiBase();
const ConceptTimelineModal = ({
isOpen,
onClose,
conceptName,
conceptId,
stocks = []
}) => {
const toast = useToast();
// 🎯 PostHog 事件追踪
const {
trackDateToggled,
trackNewsClicked,
trackNewsDetailOpened,
trackReportClicked,
trackReportDetailOpened,
trackModalClosed,
} = useConceptTimelineEvents({ conceptName, conceptId, isOpen });
const [timelineData, setTimelineData] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedDate, setSelectedDate] = useState(null);
const [selectedDateData, setSelectedDateData] = useState(null);
// 日期详情Modal
const { isOpen: isDateDetailOpen, onOpen: onDateDetailOpen, onClose: onDateDetailClose } = useDisclosure();
// 研报全文Modal相关状态
const [selectedReport, setSelectedReport] = useState(null);
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
// 新闻全文Modal相关状态
const [selectedNews, setSelectedNews] = useState(null);
const [isNewsModalOpen, setIsNewsModalOpen] = useState(false);
// 响应式配置
const isMobile = useBreakpointValue({ base: true, md: false }, { fallback: 'md' });
// 辅助函数:格式化日期显示(包含年份)
const formatDateDisplay = (dateStr) => {
const date = new Date(dateStr);
const today = new Date();
const diffTime = today - date;
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const formatted = `${year}-${month}-${day}`;
if (diffDays === 0) return `今天 ${formatted}`;
if (diffDays === 1) return `昨天 ${formatted}`;
if (diffDays < 7) return `${diffDays}天前 ${formatted}`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)}周前 ${formatted}`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)}月前 ${formatted}`;
return formatted;
};
// 辅助函数格式化完整时间YYYY-MM-DD HH:mm
const formatDateTime = (dateTimeStr) => {
if (!dateTimeStr) return '-';
const normalized = typeof dateTimeStr === 'string' ? dateTimeStr.replace(' ', 'T') : dateTimeStr;
const dt = new Date(normalized);
if (isNaN(dt.getTime())) return '-';
const y = dt.getFullYear();
const m = String(dt.getMonth() + 1).padStart(2, '0');
const d = String(dt.getDate()).padStart(2, '0');
const hh = String(dt.getHours()).padStart(2, '0');
const mm = String(dt.getMinutes()).padStart(2, '0');
return `${y}-${m}-${d} ${hh}:${mm}`;
};
// 辅助函数:获取涨跌幅颜色和图标
const getPriceInfo = (price) => {
if (!price || price.avg_change_pct === null) {
return { color: 'gray', icon: null, text: '无数据' };
}
const value = price.avg_change_pct;
if (value > 0) {
return {
color: 'red',
icon: FaArrowUp,
text: `+${value.toFixed(2)}%`
};
} else if (value < 0) {
return {
color: 'green',
icon: FaArrowDown,
text: `${value.toFixed(2)}%`
};
} else {
return {
color: 'gray',
icon: null,
text: '0.00%'
};
}
};
// 转换时间轴数据为日历事件格式(一天拆分为多个独立事件)
const calendarEvents = useMemo(() => {
const events = [];
timelineData.forEach(item => {
const priceInfo = getPriceInfo(item.price);
const newsCount = (item.events || []).filter(e => e.type === 'news').length;
const reportCount = (item.events || []).filter(e => e.type === 'report').length;
const hasPriceData = item.price && item.price.avg_change_pct !== null;
// 如果有新闻,添加新闻事件
if (newsCount > 0) {
events.push({
id: `${item.date}-news`,
title: `📰 ${newsCount} 条新闻`,
date: item.date,
start: item.date,
backgroundColor: '#9F7AEA',
borderColor: '#9F7AEA',
extendedProps: {
eventType: 'news',
count: newsCount,
originalData: item,
}
});
}
// 如果有研报,添加研报事件
if (reportCount > 0) {
events.push({
id: `${item.date}-report`,
title: `📊 ${reportCount} 篇研报`,
date: item.date,
start: item.date,
backgroundColor: '#805AD5',
borderColor: '#805AD5',
extendedProps: {
eventType: 'report',
count: reportCount,
originalData: item,
}
});
}
// 如果有价格数据,添加价格事件
if (hasPriceData) {
const changePercent = item.price.avg_change_pct;
const isSignificantRise = changePercent >= 3; // 涨幅 >= 3% 为重大利好
let bgColor = '#e2e8f0';
let title = priceInfo.text;
if (priceInfo.color === 'red') {
if (isSignificantRise) {
// 涨幅 >= 3%,使用醒目的橙红色 + 火焰图标
bgColor = '#F56565'; // 更深的红色
title = `🔥 ${priceInfo.text}`;
} else {
bgColor = '#FC8181'; // 普通红色(上涨)
}
} else if (priceInfo.color === 'green') {
bgColor = '#68D391'; // 绿色(下跌)
}
events.push({
id: `${item.date}-price`,
title: title,
date: item.date,
start: item.date,
backgroundColor: bgColor,
borderColor: isSignificantRise ? '#C53030' : bgColor, // 深红色边框强调
extendedProps: {
eventType: 'price',
priceInfo,
originalData: item,
isSignificantRise, // 标记重大涨幅
}
});
}
});
return events;
}, [timelineData]);
// 处理日期点击
const handleDateClick = (info) => {
const clickedDate = info.dateStr;
const dateData = timelineData.find(item => item.date === clickedDate);
if (dateData) {
setSelectedDate(clickedDate);
setSelectedDateData(dateData);
onDateDetailOpen();
// 追踪日期点击
trackDateToggled(clickedDate, true);
}
};
// 处理事件点击
const handleEventClick = (info) => {
// 从事件的 extendedProps 中获取原始数据
const dateData = info.event.extendedProps?.originalData;
if (dateData) {
setSelectedDate(dateData.date);
setSelectedDateData(dateData);
onDateDetailOpen();
}
};
// 获取时间轴数据
const fetchTimelineData = async () => {
setLoading(true);
try {
// 获取今天的日期(确保不是未来日期)
const today = new Date();
// 重置时间到当天开始
today.setHours(0, 0, 0, 0);
// 计算日期范围最近300天与原代码保持一致
const endDate = new Date(today);
const startDate = new Date(today);
startDate.setDate(startDate.getDate() - 100); // 使用100天与原代码一致
// 确保日期格式正确
const formatDate = (date) => {
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 startDateStr = formatDate(startDate);
const endDateStr = formatDate(endDate);
// 并行请求:涨跌幅数据、新闻、研报
const promises = [];
// 获取涨跌幅时间序列
promises.push(
fetch(`${API_BASE_URL}/concept/${conceptId}/price-timeseries?` +
`start_date=${startDateStr}&` +
`end_date=${endDateStr}`
).then(res => {
if (!res.ok) {
logger.error('ConceptTimelineModal', 'fetchTimelineData - Price API', new Error(`HTTP ${res.status}`), { conceptId, startDateStr, endDateStr });
throw new Error(`Price API error: ${res.status}`);
}
return res.json();
})
.catch(err => {
logger.error('ConceptTimelineModal', 'fetchTimelineData - Price API', err, { conceptId });
return { timeseries: [] };
})
);
// 获取新闻(通过股票代码聚合关联事件)
// 新逻辑:每个概念有关联的股票,通过 related_stock 表聚合所有股票的关联新闻/事件
const fetchNews = async () => {
try {
// 提取股票代码列表
let stockCodes = (stocks || [])
.map(s => s.code || s.stock_code)
.filter(Boolean);
// 如果 stocks 为空但有 conceptId尝试从概念详情接口获取股票列表
if (stockCodes.length === 0 && conceptId) {
logger.info('ConceptTimelineModal', '股票列表为空,尝试从概念详情接口获取', { conceptId, conceptName });
try {
const detailRes = await fetch(`${API_BASE_URL}/concept/${encodeURIComponent(conceptId)}`);
if (detailRes.ok) {
const detailData = await detailRes.json();
const detailStocks = detailData.stocks || [];
stockCodes = detailStocks
.map(s => s.code || s.stock_code)
.filter(Boolean);
logger.info('ConceptTimelineModal', `从概念详情获取到 ${stockCodes.length} 只股票`, { conceptId });
} else {
logger.warn('ConceptTimelineModal', '获取概念详情失败', { conceptId, status: detailRes.status });
}
} catch (detailErr) {
logger.error('ConceptTimelineModal', '获取概念详情异常', detailErr, { conceptId });
}
}
if (stockCodes.length === 0) {
logger.warn('ConceptTimelineModal', '概念没有关联股票,无法获取新闻', { conceptName });
return [];
}
logger.info('ConceptTimelineModal', `通过 ${stockCodes.length} 只股票获取关联新闻`, { conceptName, stockCodes: stockCodes.slice(0, 5) });
// 调用后端新 API 获取股票关联的事件
const res = await fetch(`${MAIN_API_URL}/api/events/by-stocks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
stock_codes: stockCodes,
start_date: startDateStr,
end_date: endDateStr,
limit: 200
}),
credentials: 'include'
});
if (!res.ok) {
const text = await res.text();
logger.error('ConceptTimelineModal', 'fetchTimelineData - Events by Stocks API', new Error(`HTTP ${res.status}`), { conceptName, status: res.status, response: text.substring(0, 200) });
return [];
}
const result = await res.json();
if (!result.success) {
logger.warn('ConceptTimelineModal', '获取股票关联事件失败', { conceptName, error: result.error });
return [];
}
// 转换为新闻格式
const events = result.data || [];
const newsArray = events.map(event => ({
title: event.title,
detail: event.description || event.summary || '',
description: event.description || event.summary || '',
published_time: event.event_date || event.created_at,
source: 'event', // 标记为事件来源
url: null, // 事件没有外链
related_stocks: event.related_stocks || [] // 保留关联股票信息
}));
logger.info('ConceptTimelineModal', `获取到 ${newsArray.length} 条股票关联事件`, { conceptName });
return newsArray;
} catch (err) {
logger.error('ConceptTimelineModal', 'fetchTimelineData - Events by Stocks API', err, { conceptName });
return [];
}
};
promises.push(fetchNews());
// 获取研报文本模式、精确匹配最近100天最多30条
// 🔄 添加回退逻辑如果结果不足10条去掉 exact_match 参数重新搜索
const fetchReports = async () => {
try {
// 第一次尝试:使用精确匹配
const reportParams = new URLSearchParams({
query: conceptName,
mode: 'text',
exact_match: 1,
size: 30,
start_date: startDateStr
});
const reportUrl = `${REPORT_API_URL}/search?${reportParams}`;
const res = await fetch(reportUrl);
if (!res.ok) {
const text = await res.text();
logger.error('ConceptTimelineModal', 'fetchTimelineData - Report API (exact_match=1)', new Error(`HTTP ${res.status}`), { conceptName, status: res.status, response: text.substring(0, 200) });
return { results: [] };
}
const reportResult = await res.json();
const reports = (reportResult.data && Array.isArray(reportResult.data.results))
? reportResult.data.results
: (Array.isArray(reportResult.results) ? reportResult.results : []);
// 检查结果数量如果不足10条则进行回退搜索
if (reports.length < 10) {
logger.info('ConceptTimelineModal', `研报精确搜索结果不足10条 (${reports.length}),尝试模糊搜索`, { conceptName });
// 第二次尝试:去掉精确匹配参数
const fallbackParams = new URLSearchParams({
query: conceptName,
mode: 'text',
size: 30,
start_date: startDateStr
});
const fallbackUrl = `${REPORT_API_URL}/search?${fallbackParams}`;
const fallbackRes = await fetch(fallbackUrl);
if (!fallbackRes.ok) {
logger.warn('ConceptTimelineModal', '研报模糊搜索失败,使用精确搜索结果', { conceptName });
return { results: reports };
}
const fallbackResult = await fallbackRes.json();
const fallbackReports = (fallbackResult.data && Array.isArray(fallbackResult.data.results))
? fallbackResult.data.results
: (Array.isArray(fallbackResult.results) ? fallbackResult.results : []);
logger.info('ConceptTimelineModal', `研报模糊搜索成功,获取 ${fallbackReports.length} 条结果`, { conceptName });
return { results: fallbackReports };
}
return { results: reports };
} catch (err) {
logger.error('ConceptTimelineModal', 'fetchTimelineData - Report API', err, { conceptName });
return { results: [] };
}
};
promises.push(fetchReports());
const [priceResult, newsResult, reportResult] = await Promise.all(promises);
// 处理价格数据
const priceMap = {};
if (priceResult && priceResult.timeseries) {
priceResult.timeseries.forEach(item => {
const dateStr = item.trade_date;
priceMap[dateStr] = {
avg_change_pct: item.avg_change_pct,
stock_count: item.stock_count
};
});
}
// 合并和分组事件数据
const events = [];
// 处理新闻(按时间降序排序,最新的在前)
if (newsResult && Array.isArray(newsResult)) {
// 先排序
const sortedNews = newsResult.sort((a, b) => {
const dateA = new Date(a.published_time || 0);
const dateB = new Date(b.published_time || 0);
return dateB - dateA; // 降序
});
sortedNews.forEach(news => {
if (news.published_time) {
// 提取日期部分YYYY-MM-DD
let dateOnly;
const fullTime = news.published_time;
// 处理不同的日期格式
if (fullTime.includes('T')) {
// ISO格式: 2024-12-24T09:47:30
dateOnly = fullTime.split('T')[0];
} else if (fullTime.includes(' ')) {
// 空格分隔格式: 2024-12-24 09:47:30
dateOnly = fullTime.split(' ')[0];
} else {
// 已经是日期格式: 2024-12-24
dateOnly = fullTime;
}
events.push({
type: 'news',
date: dateOnly, // 只用日期部分做分组
time: fullTime, // 保留完整时间用于显示
title: news.title,
content: news.detail || news.description,
source: news.source,
url: news.url
});
}
});
}
// 处理研报(按时间降序排序,最新的在前),兼容 data.results 与 results
if (reportResult) {
const reports = (reportResult.data && Array.isArray(reportResult.data.results))
? reportResult.data.results
: (Array.isArray(reportResult.results) ? reportResult.results : []);
if (reports.length > 0) {
const sortedReports = reports.sort((a, b) => {
const dateA = new Date((a.declare_date || '').replace(' ', 'T'));
const dateB = new Date((b.declare_date || '').replace(' ', 'T'));
return dateB - dateA; // 降序
});
sortedReports.forEach(report => {
const rawDate = report.declare_date || '';
if (rawDate) {
const dateOnly = rawDate.includes('T') ? rawDate.split('T')[0]
: rawDate.includes(' ') ? rawDate.split(' ')[0]
: rawDate;
events.push({
type: 'report',
date: dateOnly,
time: rawDate,
title: report.report_title,
content: report.content,
publisher: report.publisher,
author: report.author,
rating: report.rating,
security_name: report.security_name,
content_url: report.content_url
});
}
});
}
}
// 按日期分组
const groupedEvents = {};
events.forEach(event => {
const date = event.date;
if (!groupedEvents[date]) {
groupedEvents[date] = [];
}
groupedEvents[date].push(event);
});
// 创建时间轴数据
const allDates = new Set([
...Object.keys(priceMap),
...Object.keys(groupedEvents)
]);
const timeline = Array.from(allDates)
.sort((a, b) => new Date(b) - new Date(a))
.map(date => ({
date,
price: priceMap[date] || null,
events: groupedEvents[date] || []
}));
setTimelineData(timeline);
logger.info('ConceptTimelineModal', '时间轴数据加载成功', { conceptId, conceptName, timelineCount: timeline.length });
} catch (error) {
logger.error('ConceptTimelineModal', 'fetchTimelineData', error, { conceptId, conceptName });
// ❌ 移除获取数据失败toast
// toast({ title: '获取数据失败', description: error.message, status: 'error', duration: 3000, isClosable: true });
} finally {
setLoading(false);
}
};
// 组件挂载时获取数据
useEffect(() => {
if (isOpen && conceptId) {
fetchTimelineData();
}
}, [isOpen, conceptId]);
return (
<>
{isOpen && (
<Modal
isOpen={isOpen}
onClose={onClose}
size="full"
scrollBehavior="inside"
isCentered
>
<ModalOverlay bg="blackAlpha.800" backdropFilter="blur(8px)" />
<ModalContent
maxW="1400px"
m={{ base: 0, md: 'auto' }}
mx="auto"
bg="rgba(15, 23, 42, 0.95)"
backdropFilter="blur(20px)"
border="1px solid"
borderColor="whiteAlpha.100"
>
<ModalHeader
bg="rgba(15, 23, 42, 0.98)"
borderBottom="1px solid"
borderColor="whiteAlpha.100"
color="white"
position="sticky"
top={0}
zIndex={10}
py={{ base: 3, md: 6 }}
px={{ base: 3, md: 6 }}
boxShadow="lg"
>
<HStack spacing={{ base: 2, md: 4 }} flexWrap="wrap">
<Icon
as={FaChartLine}
boxSize={{ base: 4, md: 6 }}
color="cyan.400"
/>
<Text
fontSize={{ base: 'md', md: 'xl' }}
fontWeight="bold"
color="white"
noOfLines={1}
maxW={{ base: '120px', md: 'none' }}
>
{conceptName} - 历史时间轴
</Text>
<Badge
bg="purple.500"
color="white"
px={{ base: 2, md: 3 }}
py={1}
borderRadius="full"
fontSize={{ base: 'xs', md: 'sm' }}
>
最近100天
</Badge>
<Badge
bg="whiteAlpha.100"
color="whiteAlpha.800"
px={{ base: 2, md: 3 }}
py={1}
borderRadius="full"
fontSize="xs"
display={{ base: 'none', sm: 'flex' }}
>
🔥 Max版功能
</Badge>
</HStack>
</ModalHeader>
<ModalCloseButton
color="white"
size="lg"
top={{ base: 2, md: 4 }}
right={{ base: 2, md: 4 }}
_hover={{ bg: 'whiteAlpha.300' }}
zIndex={20}
/>
<ModalBody
py={{ base: 2, md: 6 }}
px={{ base: 0, md: 6 }}
bg="transparent"
css={{
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: 'rgba(255,255,255,0.05)',
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb': {
background: 'rgba(255,255,255,0.2)',
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: 'rgba(255,255,255,0.3)',
},
}}
>
{loading ? (
<Center py={20}>
<VStack spacing={4}>
<Spinner size="xl" color="cyan.400" thickness="4px" />
<Text color="whiteAlpha.700">正在加载时间轴数据...</Text>
</VStack>
</Center>
) : timelineData.length > 0 ? (
<Box position="relative" maxW="1200px" mx="auto" px={{ base: 2, md: 4 }}>
{/* 图例说明 - H5端保持一行 */}
<Flex
justify="center"
mb={{ base: 3, md: 6 }}
flexWrap={{ base: 'nowrap', md: 'wrap' }}
gap={{ base: 1, md: 4 }}
overflowX={{ base: 'auto', md: 'visible' }}
pb={{ base: 2, md: 0 }}
css={{
'&::-webkit-scrollbar': { display: 'none' },
scrollbarWidth: 'none',
}}
>
<HStack
spacing={{ base: 1, md: 2 }}
px={{ base: 2, md: 4 }}
py={{ base: 1, md: 2 }}
bg="whiteAlpha.100"
borderRadius="lg"
border="1px solid"
borderColor="purple.500"
flexShrink={0}
>
<Box w={{ base: 2, md: 3 }} h={{ base: 2, md: 3 }} bg="#9F7AEA" borderRadius="full" />
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="whiteAlpha.900" whiteSpace="nowrap">📰 新闻</Text>
</HStack>
<HStack
spacing={{ base: 1, md: 2 }}
px={{ base: 2, md: 4 }}
py={{ base: 1, md: 2 }}
bg="whiteAlpha.100"
borderRadius="lg"
border="1px solid"
borderColor="purple.600"
flexShrink={0}
>
<Box w={{ base: 2, md: 3 }} h={{ base: 2, md: 3 }} bg="#805AD5" borderRadius="full" />
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="whiteAlpha.900" whiteSpace="nowrap">📊 研报</Text>
</HStack>
<HStack
spacing={{ base: 1, md: 2 }}
px={{ base: 2, md: 4 }}
py={{ base: 1, md: 2 }}
bg="whiteAlpha.100"
borderRadius="lg"
border="1px solid"
borderColor="red.400"
flexShrink={0}
>
<Icon as={FaArrowUp} color="red.400" boxSize={{ base: 2, md: 3 }} />
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="whiteAlpha.900" whiteSpace="nowrap">上涨</Text>
</HStack>
<HStack
spacing={{ base: 1, md: 2 }}
px={{ base: 2, md: 4 }}
py={{ base: 1, md: 2 }}
bg="whiteAlpha.100"
borderRadius="lg"
border="1px solid"
borderColor="green.400"
flexShrink={0}
>
<Icon as={FaArrowDown} color="green.400" boxSize={{ base: 2, md: 3 }} />
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="whiteAlpha.900" whiteSpace="nowrap">下跌</Text>
</HStack>
<HStack
spacing={{ base: 1, md: 2 }}
px={{ base: 2, md: 4 }}
py={{ base: 1, md: 2 }}
bg="whiteAlpha.100"
borderRadius="lg"
border="1px solid"
borderColor="red.500"
flexShrink={0}
>
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="bold">🔥</Text>
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="whiteAlpha.900" whiteSpace="nowrap">涨3%+</Text>
</HStack>
</Flex>
{/* FullCalendar 日历组件 */}
<Box
height={{ base: '500px', md: '700px' }}
bg="rgba(15, 23, 42, 0.6)"
borderRadius={{ base: 'none', md: 'xl' }}
border="1px solid"
borderColor="whiteAlpha.100"
p={{ base: 1, md: 4 }}
sx={{
// FullCalendar 深色主题样式定制
'.fc': {
height: '100%',
},
'.fc-header-toolbar': {
marginBottom: { base: '0.5rem', md: '1.5rem' },
padding: { base: '0 4px', md: '0' },
flexWrap: 'nowrap',
gap: { base: '4px', md: '8px' },
},
'.fc-toolbar-chunk': {
display: 'flex',
alignItems: 'center',
},
'.fc-toolbar-title': {
fontSize: { base: '1rem', md: '1.5rem' },
fontWeight: 'bold',
color: 'white',
},
'.fc-button': {
backgroundColor: 'rgba(139, 92, 246, 0.6)',
borderColor: 'rgba(139, 92, 246, 0.8)',
color: 'white',
padding: { base: '4px 8px', md: '6px 12px' },
fontSize: { base: '12px', md: '14px' },
'&:hover': {
backgroundColor: 'rgba(139, 92, 246, 0.8)',
borderColor: 'rgba(139, 92, 246, 1)',
},
'&:active, &:focus': {
backgroundColor: 'rgba(139, 92, 246, 1)',
borderColor: 'rgba(139, 92, 246, 1)',
boxShadow: 'none',
},
},
'.fc-button-active': {
backgroundColor: 'rgba(139, 92, 246, 1)',
borderColor: 'rgba(139, 92, 246, 1)',
},
// 深色主题 - 表格边框和背景
'.fc-theme-standard td, .fc-theme-standard th': {
borderColor: 'rgba(255, 255, 255, 0.1)',
},
'.fc-theme-standard .fc-scrollgrid': {
borderColor: 'rgba(255, 255, 255, 0.1)',
},
'.fc-col-header-cell': {
backgroundColor: 'rgba(15, 23, 42, 0.8)',
},
'.fc-col-header-cell-cushion': {
color: 'rgba(255, 255, 255, 0.8)',
fontSize: { base: '0.75rem', md: '0.875rem' },
padding: { base: '4px 2px', md: '8px' },
},
'.fc-daygrid-day': {
cursor: 'pointer',
transition: 'all 0.2s',
backgroundColor: 'transparent',
'&:hover': {
backgroundColor: 'rgba(139, 92, 246, 0.2)',
},
},
'.fc-daygrid-day-number': {
color: 'rgba(255, 255, 255, 0.9)',
padding: { base: '2px', md: '4px' },
fontSize: { base: '0.75rem', md: '0.875rem' },
},
'.fc-day-today': {
backgroundColor: 'rgba(139, 92, 246, 0.15) !important',
},
'.fc-day-other .fc-daygrid-day-number': {
color: 'rgba(255, 255, 255, 0.4)',
},
'.fc-event': {
cursor: 'pointer',
border: 'none',
padding: { base: '1px 2px', md: '2px 4px' },
fontSize: { base: '0.65rem', md: '0.75rem' },
fontWeight: 'bold',
borderRadius: '4px',
transition: 'all 0.2s',
'&:hover': {
transform: 'scale(1.05)',
boxShadow: '0 2px 8px rgba(0,0,0,0.4)',
},
},
'.fc-daygrid-event-harness': {
marginBottom: { base: '1px', md: '2px' },
},
'.fc-more-link': {
color: 'rgba(255, 255, 255, 0.8)',
},
// H5 端隐藏事件文字,只显示色块
'@media (max-width: 768px)': {
'.fc-event-title': {
fontSize: '0.6rem',
},
},
}}
>
<FullCalendar
plugins={[dayGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
locale="zh-cn"
headerToolbar={{
left: 'prev,next today',
center: 'title',
right: '',
}}
events={calendarEvents}
dateClick={handleDateClick}
eventClick={handleEventClick}
height="100%"
dayMaxEvents={3}
moreLinkText="更多"
buttonText={{
today: '今天',
month: '月',
week: '周',
}}
eventDisplay="block"
displayEventTime={false}
/>
</Box>
</Box>
) : (
<Center py={24}>
<VStack
spacing={6}
bg="rgba(15, 23, 42, 0.6)"
p={12}
borderRadius="2xl"
border="2px dashed"
borderColor="whiteAlpha.200"
>
<Icon
as={FaHistory}
boxSize={24}
color="purple.400"
opacity={0.6}
/>
<VStack spacing={2}>
<Text fontSize="2xl" fontWeight="bold" color="white">
暂无历史数据
</Text>
<Text fontSize="md" color="whiteAlpha.600">
该概念在最近100天内没有相关事件记录
</Text>
</VStack>
</VStack>
</Center>
)}
{/* 风险提示 */}
<Box px={{ base: 2, md: 6 }}>
<RiskDisclaimer variant="default" />
</Box>
</ModalBody>
</ModalContent>
</Modal>
)}
{/* 日期详情 Modal */}
{isDateDetailOpen && selectedDateData && (
<Modal
isOpen={isDateDetailOpen}
onClose={onDateDetailClose}
size="4xl"
scrollBehavior="inside"
>
<ModalOverlay bg="blackAlpha.800" backdropFilter="blur(8px)" />
<ModalContent
borderRadius="2xl"
overflow="hidden"
bg="rgba(15, 23, 42, 0.95)"
border="1px solid"
borderColor="whiteAlpha.100"
>
<ModalHeader
bg="rgba(15, 23, 42, 0.98)"
borderBottom="1px solid"
borderColor="whiteAlpha.100"
color="white"
py={6}
position="relative"
>
<VStack align="start" spacing={3} position="relative" zIndex={1}>
<HStack spacing={3}>
<Icon
as={CalendarIcon}
boxSize={6}
color="cyan.400"
/>
<Text fontSize="xl" fontWeight="bold" color="white">
{formatDateDisplay(selectedDate)}
</Text>
</HStack>
{selectedDateData.price && (
<HStack spacing={4} fontSize="sm" flexWrap="wrap">
<Badge
bg={getPriceInfo(selectedDateData.price).color === 'red' ? 'red.500' :
getPriceInfo(selectedDateData.price).color === 'green' ? 'green.500' : 'whiteAlpha.300'}
color="white"
px={4}
py={2}
borderRadius="full"
fontSize="md"
>
<HStack spacing={2}>
{getPriceInfo(selectedDateData.price).icon && (
<Icon
as={getPriceInfo(selectedDateData.price).icon}
boxSize={4}
/>
)}
<Text fontWeight="bold">
{getPriceInfo(selectedDateData.price).text}
</Text>
</HStack>
</Badge>
{selectedDateData.price.stock_count && (
<HStack
spacing={1}
bg="whiteAlpha.100"
px={3}
py={1}
borderRadius="full"
>
<Text fontWeight="medium" color="whiteAlpha.800">📊 统计股票:</Text>
<Text fontWeight="bold" color="white">{selectedDateData.price.stock_count} </Text>
</HStack>
)}
</HStack>
)}
</VStack>
</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody py={6} bg="transparent">
{selectedDateData.events && selectedDateData.events.length > 0 ? (
<VStack align="stretch" spacing={6}>
{/* 统计卡片 */}
<HStack spacing={4} justifyContent="center">
<Box
px={6}
py={3}
bg="whiteAlpha.100"
borderRadius="lg"
border="1px solid"
borderColor="blue.500"
>
<HStack spacing={2}>
<Icon as={FaNewspaper} color="blue.400" boxSize={5} />
<Text fontWeight="bold" fontSize="lg" color="blue.300">
{selectedDateData.events.filter(e => e.type === 'news').length}
</Text>
<Text fontSize="sm" color="whiteAlpha.700">条新闻</Text>
</HStack>
</Box>
<Box
px={6}
py={3}
bg="whiteAlpha.100"
borderRadius="lg"
border="1px solid"
borderColor="green.500"
>
<HStack spacing={2}>
<Icon as={FaFileAlt} color="green.400" boxSize={5} />
<Text fontWeight="bold" fontSize="lg" color="green.300">
{selectedDateData.events.filter(e => e.type === 'report').length}
</Text>
<Text fontSize="sm" color="whiteAlpha.700">篇研报</Text>
</HStack>
</Box>
</HStack>
{/* 事件列表 */}
<VStack align="stretch" spacing={5}>
{selectedDateData.events.map((event, eventIdx) => (
<Box
key={eventIdx}
p={5}
bg="whiteAlpha.50"
borderRadius="xl"
borderLeft="5px solid"
borderLeftColor={event.type === 'news' ? 'blue.400' : 'green.400'}
border="1px solid"
borderColor="whiteAlpha.100"
_hover={{
transform: 'translateX(6px) translateY(-2px)',
bg: 'whiteAlpha.100',
borderLeftColor: event.type === 'news' ? 'blue.300' : 'green.300',
}}
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
cursor="pointer"
position="relative"
overflow="hidden"
>
<VStack align="start" spacing={3}>
<HStack spacing={2} flexWrap="wrap">
<Badge
bg={event.type === 'news' ? 'blue.500' : 'green.500'}
color="white"
fontSize="sm"
px={3}
py={1}
borderRadius="full"
>
{event.type === 'news' ? '📰 新闻' : '📊 研报'}
</Badge>
{event.publisher && (
<Badge
bg="purple.500"
color="white"
fontSize="xs"
px={2}
py={1}
borderRadius="md"
>
{event.publisher}
</Badge>
)}
{event.rating && (
<Badge
bg="orange.500"
color="white"
fontSize="xs"
px={2}
py={1}
borderRadius="md"
>
{event.rating}
</Badge>
)}
{event.security_name && (
<Badge
bg="cyan.600"
color="white"
fontSize="xs"
px={2}
py={1}
borderRadius="md"
>
🏢 {event.security_name}
</Badge>
)}
</HStack>
<Text
fontWeight="bold"
fontSize="lg"
color="white"
lineHeight="1.4"
>
{event.title}
</Text>
<Text
fontSize="sm"
color="whiteAlpha.700"
noOfLines={3}
lineHeight="1.6"
>
{event.content || '暂无内容'}
</Text>
<HStack spacing={4} w="100%" justify="space-between" align="center" mt={1}>
{event.time && (
<HStack spacing={1}>
<Icon as={FaClock} color="whiteAlpha.500" boxSize={3} />
<Text fontSize="xs" color="whiteAlpha.500" fontWeight="medium">
{formatDateTime(event.time)}
</Text>
</HStack>
)}
<Button
size="sm"
colorScheme={event.type === 'news' ? 'blue' : 'green'}
variant="solid"
leftIcon={<ViewIcon />}
boxShadow="sm"
_hover={{
transform: 'scale(1.05)',
boxShadow: 'md',
}}
transition="all 0.2s"
onClick={() => {
if (event.type === 'news') {
trackNewsClicked(event, selectedDate);
trackNewsDetailOpened(event);
setSelectedNews({
title: event.title,
content: event.content,
source: event.source,
time: event.time,
url: event.url,
});
setIsNewsModalOpen(true);
} else if (event.type === 'report') {
trackReportClicked(event, selectedDate);
trackReportDetailOpened(event);
setSelectedReport({
title: event.title,
content: event.content,
publisher: event.publisher,
author: event.author,
time: event.time,
rating: event.rating,
security_name: event.security_name,
content_url: event.content_url,
});
setIsReportModalOpen(true);
}
}}
>
查看详情
</Button>
</HStack>
</VStack>
</Box>
))}
</VStack>
</VStack>
) : (
<Center py={16}>
<VStack
spacing={5}
bg="whiteAlpha.50"
p={10}
borderRadius="2xl"
border="2px dashed"
borderColor="whiteAlpha.200"
>
<Icon
as={FaHistory}
boxSize={20}
color="purple.400"
opacity={0.6}
/>
<VStack spacing={2}>
<Text fontSize="xl" fontWeight="bold" color="white">
当日无新闻或研报
</Text>
{selectedDateData.price && (
<Text fontSize="md" color="whiteAlpha.600">
仅有涨跌幅数据
</Text>
)}
</VStack>
</VStack>
</Center>
)}
</ModalBody>
<ModalFooter
borderTop="1px solid"
borderColor="whiteAlpha.100"
bg="rgba(15, 23, 42, 0.98)"
py={4}
>
<Button
bg="purple.500"
color="white"
size="lg"
px={8}
onClick={onDateDetailClose}
_hover={{
bg: 'purple.400',
transform: 'translateY(-2px)',
}}
transition="all 0.2s"
>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
{/* 研报全文Modal */}
{isReportModalOpen && (
<Modal
isOpen={isReportModalOpen}
onClose={() => setIsReportModalOpen(false)}
size="4xl"
scrollBehavior="inside"
>
<ModalOverlay bg="blackAlpha.800" backdropFilter="blur(8px)" />
<ModalContent bg="rgba(15, 23, 42, 0.95)" border="1px solid" borderColor="whiteAlpha.100">
<ModalHeader bg="rgba(15, 23, 42, 0.98)" borderBottom="1px solid" borderColor="whiteAlpha.100" color="white">
<VStack align="start" spacing={1}>
<Text fontSize="lg" color="white">{selectedReport?.title}</Text>
<HStack spacing={3} fontSize="sm">
{selectedReport?.publisher && (
<Badge bg="green.500" color="white">
{selectedReport.publisher}
</Badge>
)}
{selectedReport?.author && (
<Text color="whiteAlpha.700">{selectedReport.author}</Text>
)}
{selectedReport?.time && (
<Text color="whiteAlpha.700">{formatDateTime(selectedReport.time)}</Text>
)}
{selectedReport?.rating && (
<Badge bg="orange.500" color="white">
{selectedReport.rating}
</Badge>
)}
{selectedReport?.security_name && (
<Badge bg="cyan.600" color="white">
{selectedReport.security_name}
</Badge>
)}
</HStack>
</VStack>
</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody py={6}>
<Box
bg="whiteAlpha.50"
p={6}
borderRadius="md"
border="1px solid"
borderColor="whiteAlpha.100"
>
<Text
whiteSpace="pre-wrap"
fontSize="sm"
lineHeight="tall"
color="whiteAlpha.800"
>
{selectedReport?.content || '暂无内容'}
</Text>
</Box>
</ModalBody>
<ModalFooter borderTop="1px solid" borderColor="whiteAlpha.100" bg="rgba(15, 23, 42, 0.98)">
<HStack spacing={3}>
{selectedReport?.content_url && (
<Button
size="sm"
bg="whiteAlpha.100"
color="white"
leftIcon={<ExternalLinkIcon />}
onClick={() => {
const url = formatUrl(selectedReport.content_url);
if (url) {
const newWindow = window.open(url, '_blank');
if (!newWindow) {
toast({
title: '请允许弹窗',
description: '浏览器可能阻止了新窗口,请检查地址栏',
status: 'warning',
duration: 3000,
});
}
} else {
toast({
title: '链接无效',
description: '该研报暂无有效原文链接',
status: 'error',
duration: 3000,
});
}
}}
_hover={{ bg: 'whiteAlpha.200' }}
>
查看原文
</Button>
)}
<Button size="sm" bg="green.500" color="white" onClick={() => setIsReportModalOpen(false)} _hover={{ bg: 'green.400' }}>
关闭
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
)}
{/* 新闻全文Modal */}
{isNewsModalOpen && (
<Modal
isOpen={isNewsModalOpen}
onClose={() => setIsNewsModalOpen(false)}
size="4xl"
scrollBehavior="inside"
>
<ModalOverlay bg="blackAlpha.800" backdropFilter="blur(8px)" />
<ModalContent bg="rgba(15, 23, 42, 0.95)" border="1px solid" borderColor="whiteAlpha.100">
<ModalHeader bg="rgba(15, 23, 42, 0.98)" borderBottom="1px solid" borderColor="whiteAlpha.100" color="white">
<VStack align="start" spacing={1}>
<Text fontSize="lg" color="white">{selectedNews?.title}</Text>
<HStack spacing={3} fontSize="sm">
{selectedNews?.source && (
<Badge
bg={selectedNews.source === 'zsxq' ? 'purple.500' : selectedNews.source === 'event' ? 'cyan.500' : 'blue.500'}
color="white"
>
{selectedNews.source === 'zsxq' ? '知识星球' : selectedNews.source === 'event' ? '关联事件' : selectedNews.source}
</Badge>
)}
{selectedNews?.time && (
<Text color="whiteAlpha.700">{formatDateTime(selectedNews.time)}</Text>
)}
</HStack>
</VStack>
</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody py={6}>
<Box
bg="whiteAlpha.50"
p={6}
borderRadius="md"
border="1px solid"
borderColor="whiteAlpha.100"
>
<Text
whiteSpace="pre-wrap"
fontSize="sm"
lineHeight="tall"
color="whiteAlpha.800"
>
{selectedNews?.content || '暂无内容'}
</Text>
</Box>
</ModalBody>
<ModalFooter borderTop="1px solid" borderColor="whiteAlpha.100" bg="rgba(15, 23, 42, 0.98)">
<HStack spacing={3}>
{/* zsxq和event来源不显示查看原文按钮 */}
{selectedNews?.url && selectedNews?.source !== 'zsxq' && selectedNews?.source !== 'event' && (
<Button
size="sm"
bg="whiteAlpha.100"
color="white"
leftIcon={<ExternalLinkIcon />}
onClick={() => window.open(selectedNews.url, '_blank')}
_hover={{ bg: 'whiteAlpha.200' }}
>
查看原文
</Button>
)}
<Button size="sm" bg="blue.500" color="white" onClick={() => setIsNewsModalOpen(false)} _hover={{ bg: 'blue.400' }}>
关闭
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
)}
</>
);
};
export default ConceptTimelineModal;