update pay ui

This commit is contained in:
2025-12-05 19:40:11 +08:00
parent 89f581aeed
commit 4c7a761324
4 changed files with 214 additions and 104 deletions

96
app.py
View File

@@ -5508,6 +5508,102 @@ def delete_related_stock(stock_id):
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/events/by-stocks', methods=['POST'])
def get_events_by_stocks():
"""
通过股票代码列表获取关联的事件(新闻)
用于概念中心时间轴:聚合概念下所有股票的相关新闻
请求体:
{
"stock_codes": ["000001.SZ", "600000.SH", ...], # 股票代码列表
"start_date": "2024-01-01", # 可选,开始日期
"end_date": "2024-12-31", # 可选,结束日期
"limit": 100 # 可选限制返回数量默认100
}
"""
try:
data = request.get_json()
stock_codes = data.get('stock_codes', [])
start_date_str = data.get('start_date')
end_date_str = data.get('end_date')
limit = data.get('limit', 100)
if not stock_codes:
return jsonify({'success': False, 'error': '缺少股票代码列表'}), 400
# 构建查询:通过 RelatedStock 表找到关联的事件
query = db.session.query(Event).join(
RelatedStock, Event.id == RelatedStock.event_id
).filter(
RelatedStock.stock_code.in_(stock_codes)
)
# 日期过滤
if start_date_str:
try:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
query = query.filter(Event.event_date >= start_date)
except ValueError:
pass
if end_date_str:
try:
end_date = datetime.strptime(end_date_str, '%Y-%m-%d')
# 设置为当天结束
end_date = end_date.replace(hour=23, minute=59, second=59)
query = query.filter(Event.event_date <= end_date)
except ValueError:
pass
# 去重并排序
query = query.distinct().order_by(Event.event_date.desc())
# 限制数量
if limit:
query = query.limit(limit)
events = query.all()
# 构建返回数据
events_data = []
for event in events:
# 获取该事件关联的股票信息(在请求的股票列表中的)
related_stocks_in_list = [
{
'stock_code': rs.stock_code,
'stock_name': rs.stock_name,
'sector': rs.sector
}
for rs in event.related_stocks
if rs.stock_code in stock_codes
]
events_data.append({
'id': event.id,
'title': event.title,
'content': event.content,
'event_date': event.event_date.isoformat() if event.event_date else None,
'published_time': event.event_date.strftime('%Y-%m-%d %H:%M:%S') if event.event_date else None,
'source': 'event', # 标记来源为事件系统
'importance': event.importance,
'view_count': event.view_count,
'like_count': event.like_count,
'related_stocks': related_stocks_in_list,
'cover_image': event.cover_image,
'created_at': event.created_at.isoformat() if event.created_at else None
})
return jsonify({
'success': True,
'data': events_data,
'total': len(events_data)
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/events/<int:event_id>/concepts', methods=['GET'])
def get_related_concepts(event_id):
"""获取相关概念列表"""

View File

@@ -78,11 +78,17 @@ const REPORT_API_URL = process.env.NODE_ENV === 'production'
? '/report-api'
: 'http://111.198.58.126:8811';
// 主应用后端 API 基础 URL用于获取股票关联的事件/新闻)
const MAIN_API_URL = process.env.NODE_ENV === 'production'
? ''
: 'http://111.198.58.126:5001';
const ConceptTimelineModal = ({
isOpen,
onClose,
conceptName,
conceptId
conceptId,
stocks = []
}) => {
const toast = useToast();
@@ -334,61 +340,66 @@ const ConceptTimelineModal = ({
})
);
// 获取新闻(精确匹配最近100天最多100条
// 🔄 添加回退逻辑如果结果不足30条去掉 exact_match 参数重新搜索
// 获取新闻(通过股票代码聚合关联事件
// 新逻辑:每个概念有关联的股票,通过 related_stock 表聚合所有股票的关联新闻/事件
const fetchNews = async () => {
try {
// 第一次尝试:使用精确匹配
const newsParams = new URLSearchParams({
query: conceptName,
exact_match: 1,
start_date: startDateStr,
end_date: endDateStr,
top_k: 100
});
// 提取股票代码列表
const stockCodes = (stocks || [])
.map(s => s.code || s.stock_code)
.filter(Boolean);
const newsUrl = `${NEWS_API_URL}/search_china_news?${newsParams}`;
const res = await fetch(newsUrl);
if (!res.ok) {
const text = await res.text();
logger.error('ConceptTimelineModal', 'fetchTimelineData - News API (exact_match=1)', new Error(`HTTP ${res.status}`), { conceptName, status: res.status, response: text.substring(0, 200) });
if (stockCodes.length === 0) {
logger.warn('ConceptTimelineModal', '概念没有关联股票,无法获取新闻', { conceptName });
return [];
}
const newsResult = await res.json();
const newsArray = Array.isArray(newsResult) ? newsResult : [];
logger.info('ConceptTimelineModal', `通过 ${stockCodes.length} 只股票获取关联新闻`, { conceptName, stockCodes: stockCodes.slice(0, 5) });
// 检查结果数量如果不足30条则进行回退搜索
if (newsArray.length < 30) {
logger.info('ConceptTimelineModal', `新闻精确搜索结果不足30条 (${newsArray.length}),尝试模糊搜索`, { conceptName });
// 第二次尝试:去掉精确匹配参数
const fallbackParams = new URLSearchParams({
query: conceptName,
// 调用后端新 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,
top_k: 100
});
limit: 200
}),
credentials: 'include'
});
const fallbackUrl = `${NEWS_API_URL}/search_china_news?${fallbackParams}`;
const fallbackRes = await fetch(fallbackUrl);
if (!fallbackRes.ok) {
logger.warn('ConceptTimelineModal', '新闻模糊搜索失败,使用精确搜索结果', { conceptName });
return newsArray;
}
const fallbackResult = await fallbackRes.json();
const fallbackArray = Array.isArray(fallbackResult) ? fallbackResult : [];
logger.info('ConceptTimelineModal', `新闻模糊搜索成功,获取 ${fallbackArray.length} 条结果`, { conceptName });
return fallbackArray;
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 - News API', err, { conceptName });
logger.error('ConceptTimelineModal', 'fetchTimelineData - Events by Stocks API', err, { conceptName });
return [];
}
};
@@ -1376,10 +1387,10 @@ const ConceptTimelineModal = ({
<HStack spacing={3} fontSize="sm">
{selectedNews?.source && (
<Badge
bg={selectedNews.source === 'zsxq' ? 'purple.500' : 'blue.500'}
bg={selectedNews.source === 'zsxq' ? 'purple.500' : selectedNews.source === 'event' ? 'cyan.500' : 'blue.500'}
color="white"
>
{selectedNews.source === 'zsxq' ? '知识星球' : selectedNews.source}
{selectedNews.source === 'zsxq' ? '知识星球' : selectedNews.source === 'event' ? '关联事件' : selectedNews.source}
</Badge>
)}
{selectedNews?.time && (
@@ -1411,8 +1422,8 @@ const ConceptTimelineModal = ({
<ModalFooter borderTop="1px solid" borderColor="whiteAlpha.100" bg="rgba(15, 23, 42, 0.98)">
<HStack spacing={3}>
{/* zsxq来源不显示查看原文按钮 */}
{selectedNews?.url && selectedNews?.source !== 'zsxq' && (
{/* zsxq和event来源不显示查看原文按钮 */}
{selectedNews?.url && selectedNews?.source !== 'zsxq' && selectedNews?.source !== 'event' && (
<Button
size="sm"
bg="whiteAlpha.100"

View File

@@ -71,27 +71,27 @@ const glowPulse = keyframes`
50% { opacity: 0.6; transform: scale(1.05); }
`;
// 一级分类颜色映射(基础色 - 半透明玻璃态 - 黑金主题
// 一级分类颜色映射(基础色 - 半透明玻璃态)
const LV1_COLORS = {
'人工智能': '#EAB308', // 金色
'半导体': '#F59E0B', // 琥珀色
'机器人': '#CA8A04', // 深金色
'消费电子': '#FACC15', // 亮金色
'智能驾驶与汽车': '#D97706', // 橙金色
'新能源与电力': '#FDE047', // 浅金色
'空天经济': '#A16207', // 古铜金
'国防军工': '#B45309', // 铜色
'政策与主题': '#EAB308', // 金色
'周期与材料': '#F59E0B', // 琥珀色
'大消费': '#FACC15', // 亮金色
'数字经济与金融科技': '#CA8A04', // 深金色
'全球宏观与贸易': '#D97706', // 橙金色
'医药健康': '#FDE047', // 浅金色
'前沿科技': '#EAB308', // 金色
'人工智能': '#8B5CF6',
'半导体': '#3B82F6',
'机器人': '#10B981',
'消费电子': '#F59E0B',
'智能驾驶与汽车': '#EF4444',
'新能源与电力': '#06B6D4',
'空天经济': '#6366F1',
'国防军工': '#EC4899',
'政策与主题': '#14B8A6',
'周期与材料': '#F97316',
'大消费': '#A855F7',
'数字经济与金融科技': '#22D3EE',
'全球宏观与贸易': '#84CC16',
'医药健康': '#E879F9',
'前沿科技': '#38BDF8',
};
// 根据涨跌幅获取颜色(涨红跌绿 - 玻璃态半透明)
const getChangeColor = (value, baseColor = '#A16207') => {
const getChangeColor = (value, baseColor = '#64748B') => {
if (value === null || value === undefined) return baseColor;
// 涨 - 红色系(调整透明度使其更柔和)
@@ -681,7 +681,7 @@ const ForceGraphView = ({
gapWidth: 4,
borderRadius: 16,
shadowBlur: 20,
shadowColor: 'rgba(234, 179, 8, 0.3)',
shadowColor: 'rgba(139, 92, 246, 0.3)',
},
upperLabel: {
show: true,
@@ -717,7 +717,7 @@ const ForceGraphView = ({
gapWidth: 3,
borderRadius: 12,
shadowBlur: 10,
shadowColor: 'rgba(234, 179, 8, 0.2)',
shadowColor: 'rgba(139, 92, 246, 0.2)',
},
upperLabel: {
show: true,
@@ -810,12 +810,12 @@ const ForceGraphView = ({
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(12, 10, 9, 0.95)',
borderColor: 'rgba(234, 179, 8, 0.4)',
backgroundColor: 'rgba(15, 23, 42, 0.9)',
borderColor: 'rgba(139, 92, 246, 0.4)',
borderWidth: 1,
borderRadius: 16,
padding: [14, 18],
extraCssText: 'backdrop-filter: blur(20px); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);',
extraCssText: 'backdrop-filter: blur(20px); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);',
textStyle: {
color: '#E2E8F0',
fontSize: 13,
@@ -871,11 +871,11 @@ const ForceGraphView = ({
${data.conceptCount ? `<span>📁 ${data.conceptCount} 个概念</span>` : ''}
</div>
${data.level === 'concept' ? `
<div style="color: #FACC15; font-size: 11px; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1);">
<div style="color: #A78BFA; font-size: 11px; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1);">
🔗 点击查看概念详情
</div>
` : data.level !== 'concept' ? `
<div style="color: #A1A1AA; font-size: 11px; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1);">
<div style="color: #64748B; font-size: 11px; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1);">
📂 点击进入查看子分类
</div>
` : ''}
@@ -911,7 +911,7 @@ const ForceGraphView = ({
emphasis: {
itemStyle: {
shadowBlur: 30,
shadowColor: 'rgba(234, 179, 8, 0.5)',
shadowColor: 'rgba(139, 92, 246, 0.5)',
borderColor: 'rgba(255, 255, 255, 0.3)',
},
},
@@ -1014,7 +1014,7 @@ const ForceGraphView = ({
return (
<Center
h="500px"
bg="rgba(12, 10, 9, 0.6)"
bg="rgba(15, 23, 42, 0.6)"
borderRadius="3xl"
backdropFilter="blur(20px)"
border="1px solid"
@@ -1027,13 +1027,13 @@ const ForceGraphView = ({
<Box
position="absolute"
inset={-4}
bg="yellow.500"
bg="purple.500"
borderRadius="full"
filter="blur(20px)"
opacity={0.4}
animation={`${glowPulse} 2s ease-in-out infinite`}
/>
<Spinner size="xl" color="yellow.400" thickness="4px" />
<Spinner size="xl" color="purple.400" thickness="4px" />
</Box>
<Text color="gray.400" fontSize="sm">正在构建矩形树图...</Text>
</VStack>
@@ -1045,7 +1045,7 @@ const ForceGraphView = ({
return (
<Center
h="500px"
bg="rgba(12, 10, 9, 0.6)"
bg="rgba(15, 23, 42, 0.6)"
borderRadius="3xl"
backdropFilter="blur(20px)"
border="1px solid"
@@ -1055,7 +1055,7 @@ const ForceGraphView = ({
<Icon as={FaLayerGroup} boxSize={16} color="gray.600" />
<Text color="gray.400">加载失败{error}</Text>
<Button
colorScheme="yellow"
colorScheme="purple"
size="sm"
onClick={fetchHierarchy}
borderRadius="full"
@@ -1084,26 +1084,26 @@ const ForceGraphView = ({
h={containerHeight}
bg="transparent"
>
{/* 极光背景层 - 黑金主题 */}
{/* 极光背景层 */}
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bg="linear-gradient(135deg, #0C0A09 0%, #1C1917 25%, #292524 50%, #1C1917 75%, #0C0A09 100%)"
bg="linear-gradient(135deg, #0F172A 0%, #1E1B4B 25%, #312E81 50%, #1E1B4B 75%, #0F172A 100%)"
backgroundSize="400% 400%"
animation={`${auroraAnimation} 15s ease infinite`}
/>
{/* 弥散光晕层 - 黑金主题 */}
{/* 弥散光晕层 */}
<Box
position="absolute"
top="20%"
left="10%"
w="300px"
h="300px"
bg="radial-gradient(circle, rgba(234, 179, 8, 0.25) 0%, transparent 70%)"
bg="radial-gradient(circle, rgba(139, 92, 246, 0.3) 0%, transparent 70%)"
filter="blur(60px)"
pointerEvents="none"
animation={`${glowPulse} 4s ease-in-out infinite`}
@@ -1114,7 +1114,7 @@ const ForceGraphView = ({
right="15%"
w="250px"
h="250px"
bg="radial-gradient(circle, rgba(245, 158, 11, 0.2) 0%, transparent 70%)"
bg="radial-gradient(circle, rgba(59, 130, 246, 0.25) 0%, transparent 70%)"
filter="blur(50px)"
pointerEvents="none"
animation={`${glowPulse} 5s ease-in-out infinite 1s`}
@@ -1125,7 +1125,7 @@ const ForceGraphView = ({
right="30%"
w="200px"
h="200px"
bg="radial-gradient(circle, rgba(217, 119, 6, 0.15) 0%, transparent 70%)"
bg="radial-gradient(circle, rgba(236, 72, 153, 0.2) 0%, transparent 70%)"
filter="blur(40px)"
pointerEvents="none"
animation={`${glowPulse} 6s ease-in-out infinite 2s`}
@@ -1159,8 +1159,8 @@ const ForceGraphView = ({
borderColor="whiteAlpha.200"
borderRadius="full"
_hover={{
bg: 'rgba(234, 179, 8, 0.4)',
borderColor: 'yellow.400',
bg: 'rgba(139, 92, 246, 0.4)',
borderColor: 'purple.400',
transform: 'scale(1.05)',
}}
transition="all 0.2s"
@@ -1179,7 +1179,7 @@ const ForceGraphView = ({
borderColor="whiteAlpha.150"
boxShadow="0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1)"
>
<Icon as={FaTh} color="yellow.400" />
<Icon as={FaTh} color="purple.300" />
<Text color="white" fontWeight="bold" fontSize="sm">
概念矩形树图
</Text>
@@ -1202,7 +1202,7 @@ const ForceGraphView = ({
<Icon as={FaChevronRight} color="whiteAlpha.400" boxSize={3} />
)}
<Text
color={index === breadcrumbItems.length - 1 ? 'yellow.400' : 'whiteAlpha.700'}
color={index === breadcrumbItems.length - 1 ? 'purple.300' : 'whiteAlpha.700'}
fontSize="sm"
fontWeight={index === breadcrumbItems.length - 1 ? 'bold' : 'normal'}
cursor={index < breadcrumbItems.length - 1 ? 'pointer' : 'default'}
@@ -1224,7 +1224,7 @@ const ForceGraphView = ({
{/* 右侧控制按钮 */}
<HStack spacing={2} pointerEvents="auto">
{priceLoading && <Spinner size="sm" color="yellow.400" />}
{priceLoading && <Spinner size="sm" color="purple.300" />}
{drillPath && (
<Tooltip label="返回全部" placement="left">
@@ -1239,8 +1239,8 @@ const ForceGraphView = ({
borderColor="whiteAlpha.200"
borderRadius="full"
_hover={{
bg: 'rgba(234, 179, 8, 0.4)',
borderColor: 'yellow.400',
bg: 'rgba(139, 92, 246, 0.4)',
borderColor: 'purple.400',
transform: 'scale(1.05)',
}}
transition="all 0.2s"

View File

@@ -342,6 +342,7 @@ const ConceptCenter = () => {
const [selectedConceptName, setSelectedConceptName] = useState('');
const [isTimelineModalOpen, setIsTimelineModalOpen] = useState(false);
const [selectedConceptId, setSelectedConceptId] = useState('');
const [selectedConceptStocksForTimeline, setSelectedConceptStocksForTimeline] = useState([]);
// 股票行情数据状态
const [stockMarketData, setStockMarketData] = useState({});
const [loadingStockData, setLoadingStockData] = useState(false);
@@ -367,7 +368,7 @@ const ConceptCenter = () => {
return null;
}, []);
// 打开内容模态框(新闻和研报)- 需要Max版权限
const handleViewContent = (e, conceptName, conceptId) => {
const handleViewContent = (e, conceptName, conceptId, stocks = []) => {
e.stopPropagation();
// 检查历史时间轴权限
@@ -383,6 +384,7 @@ const ConceptCenter = () => {
setSelectedConceptForContent(conceptName);
setSelectedConceptId(conceptId);
setSelectedConceptStocksForTimeline(stocks || []);
setIsTimelineModalOpen(true);
};
@@ -1110,7 +1112,7 @@ const ConceptCenter = () => {
bg="purple.500"
color="white"
variant="solid"
onClick={(e) => handleViewContent(e, concept.concept, concept.concept_id)}
onClick={(e) => handleViewContent(e, concept.concept, concept.concept_id, concept.stocks)}
borderRadius="full"
px={{ base: 2, md: 4 }}
fontWeight="medium"
@@ -1296,7 +1298,7 @@ const ConceptCenter = () => {
leftIcon={<FaChartLine />}
bg="purple.500"
color="white"
onClick={(e) => handleViewContent(e, concept.concept, concept.concept_id)}
onClick={(e) => handleViewContent(e, concept.concept, concept.concept_id, concept.stocks)}
borderRadius="full"
boxShadow="0 4px 12px rgba(139, 92, 246, 0.4)"
_hover={{ bg: 'purple.400', boxShadow: '0 6px 16px rgba(139, 92, 246, 0.5)' }}
@@ -1769,12 +1771,12 @@ const ConceptCenter = () => {
setViewMode('force3d');
}
}}
bg={viewMode === 'force3d' ? 'yellow.500' : 'transparent'}
color={viewMode === 'force3d' ? 'gray.900' : 'whiteAlpha.700'}
bg={viewMode === 'force3d' ? 'purple.500' : 'transparent'}
color={viewMode === 'force3d' ? 'white' : 'whiteAlpha.700'}
borderColor="whiteAlpha.300"
_hover={{
bg: viewMode === 'force3d' ? 'yellow.400' : 'whiteAlpha.100',
boxShadow: viewMode === 'force3d' ? '0 0 10px rgba(234, 179, 8, 0.4)' : 'none',
bg: viewMode === 'force3d' ? 'purple.400' : 'whiteAlpha.100',
boxShadow: viewMode === 'force3d' ? '0 0 10px rgba(139, 92, 246, 0.4)' : 'none',
}}
aria-label="概念矩形树图"
/>
@@ -1788,12 +1790,12 @@ const ConceptCenter = () => {
setViewMode('hierarchy');
}
}}
bg={viewMode === 'hierarchy' ? 'yellow.500' : 'transparent'}
color={viewMode === 'hierarchy' ? 'gray.900' : 'whiteAlpha.700'}
bg={viewMode === 'hierarchy' ? 'purple.500' : 'transparent'}
color={viewMode === 'hierarchy' ? 'white' : 'whiteAlpha.700'}
borderColor="whiteAlpha.300"
_hover={{
bg: viewMode === 'hierarchy' ? 'yellow.400' : 'whiteAlpha.100',
boxShadow: viewMode === 'hierarchy' ? '0 0 10px rgba(234, 179, 8, 0.4)' : 'none',
bg: viewMode === 'hierarchy' ? 'purple.400' : 'whiteAlpha.100',
boxShadow: viewMode === 'hierarchy' ? '0 0 10px rgba(139, 92, 246, 0.4)' : 'none',
}}
aria-label="层级图"
/>
@@ -1807,12 +1809,12 @@ const ConceptCenter = () => {
setViewMode('list');
}
}}
bg={viewMode === 'list' ? 'yellow.500' : 'transparent'}
color={viewMode === 'list' ? 'gray.900' : 'whiteAlpha.700'}
bg={viewMode === 'list' ? 'purple.500' : 'transparent'}
color={viewMode === 'list' ? 'white' : 'whiteAlpha.700'}
borderColor="whiteAlpha.300"
_hover={{
bg: viewMode === 'list' ? 'yellow.400' : 'whiteAlpha.100',
boxShadow: viewMode === 'list' ? '0 0 10px rgba(234, 179, 8, 0.4)' : 'none',
bg: viewMode === 'list' ? 'purple.400' : 'whiteAlpha.100',
boxShadow: viewMode === 'list' ? '0 0 10px rgba(139, 92, 246, 0.4)' : 'none',
}}
aria-label="列表视图"
/>
@@ -2057,6 +2059,7 @@ const ConceptCenter = () => {
onClose={() => setIsTimelineModalOpen(false)}
conceptName={selectedConceptForContent}
conceptId={selectedConceptId}
stocks={selectedConceptStocksForTimeline}
/>
{/* 订阅升级Modal */}