- 将「涨3%+」图例边框颜色从 orange.400 改为 red.500 - 与日历中高涨幅事件背景色 (#F56565) 统一 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1505 lines
75 KiB
JavaScript
1505 lines
75 KiB
JavaScript
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; |