community增加事件详情

This commit is contained in:
2026-01-08 16:42:37 +08:00
parent 73f52ee73a
commit 6bd83cd133
2 changed files with 442 additions and 277 deletions

234
app.py
View File

@@ -10754,6 +10754,240 @@ def get_calendar_combined_data():
}), 500
@app.route('/api/v1/zt/theme-scatter', methods=['GET'])
def get_zt_theme_scatter():
"""获取题材流星图数据(散点图)
基于词频关键词合并同义概念统计最近5个交易日数据
返回:
{
success: true,
data: {
latestDate: "2026-01-07",
themes: [
{
label: "光刻胶",
color: "#F59E0B",
x: 5, // 最高连板数(辨识度)
y: 12, // 涨停家数(热度)
countTrend: 3, // 较前日变化
boardTrend: 1, // 连板变化
status: "rising", // rising/declining/lurking/clustering
history: [...], // 5日历史数据
stocks: [...], // 今日涨停股票
matchedSectors: [] // 匹配的原始板块名
},
...
]
}
}
"""
import os
import json
import re
# 核心主题词配置(基于词频,合并同义概念)
THEME_KEYWORDS = [
{'keyword': '光刻', 'aliases': ['光刻胶', '光刻机'], 'label': '光刻胶', 'color': '#F59E0B'},
{'keyword': '航天', 'aliases': ['商业航天', '卫星导航', '卫星互联', '低轨卫星'], 'label': '商业航天', 'color': '#FF6B6B'},
{'keyword': '脑机', 'aliases': ['脑机接口', '人脑工程', '脑科学'], 'label': '脑机接口', 'color': '#A78BFA'},
{'keyword': '算力', 'aliases': ['算力', 'AI算力', '智算中心', '东数西算'], 'label': 'AI算力', 'color': '#3B82F6'},
{'keyword': '机器人', 'aliases': ['机器人', '人形机器', '智能机器'], 'label': '机器人', 'color': '#4ECDC4'},
{'keyword': '液冷', 'aliases': ['液冷', '液冷服务器', '液冷技术'], 'label': 'AI液冷', 'color': '#06B6D4'},
{'keyword': '军工', 'aliases': ['军工', '军工信息', '国防军工'], 'label': '军工', 'color': '#EF4444'},
{'keyword': '', 'aliases': ['核聚变', '可控核变', '核电', '核能'], 'label': '核聚变', 'color': '#EC4899'},
{'keyword': '人工智能', 'aliases': ['人工智能', 'AI应用', 'AIGC', 'AI+'], 'label': '人工智能', 'color': '#8B5CF6'},
{'keyword': '国产替代', 'aliases': ['国产替代', '国产软件', '信创'], 'label': '国产替代', 'color': '#10B981'},
{'keyword': '固态电池', 'aliases': ['固态电池', '锂电池', '钠电池'], 'label': '固态电池', 'color': '#22C55E'},
{'keyword': '数据中心', 'aliases': ['数据中心', 'IDC', '算力中心'], 'label': '数据中心', 'color': '#0EA5E9'},
]
# 噪音板块黑名单
NOISE_SECTORS = ['公告', '业绩预告', '重大合同', '重组', '股权激励', '其他',
'增持', '回购', '解禁', '减持', '限售股', '转债']
try:
days = request.args.get('days', 5, type=int)
days = min(max(days, 1), 10) # 限制1-10天
# 查找数据目录
possible_paths = [
os.path.join(app.static_folder or 'static', 'data', 'zt'),
os.path.join('public', 'data', 'zt'),
os.path.join('data', 'zt'),
'/var/www/vf_react/data/zt',
]
zt_dir = None
for path in possible_paths:
if os.path.isdir(path):
zt_dir = path
break
if not zt_dir:
return jsonify({'success': False, 'error': '找不到涨停数据目录'}), 404
# 读取日期列表
dates_file = os.path.join(zt_dir, 'dates.json')
if not os.path.exists(dates_file):
return jsonify({'success': False, 'error': '找不到日期数据'}), 404
with open(dates_file, 'r', encoding='utf-8') as f:
dates_data = json.load(f)
recent_dates = dates_data.get('dates', [])[:days]
if not recent_dates:
return jsonify({'success': False, 'error': '无可用日期数据'}), 404
# 读取每日数据
daily_data_list = []
daily_dir = os.path.join(zt_dir, 'daily')
for date_info in recent_dates:
date_str = date_info.get('date', '')
daily_file = os.path.join(daily_dir, f'{date_str}.json')
if os.path.exists(daily_file):
try:
with open(daily_file, 'r', encoding='utf-8') as f:
data = json.load(f)
daily_data_list.append({
'date': date_str,
'formatted_date': date_info.get('formatted_date', ''),
'sector_data': data.get('sector_data', {}),
'stocks': data.get('stocks', []),
'word_freq': data.get('word_freq_data', []),
})
except Exception as e:
print(f"[theme-scatter] 读取 {daily_file} 失败: {e}")
if not daily_data_list:
return jsonify({'success': False, 'error': '无法读取每日数据'}), 404
def match_sectors_by_keyword(sector_data, theme_config):
"""基于关键词匹配板块"""
matched = []
for sector_name, sector_info in sector_data.items():
# 过滤噪音板块
if any(noise in sector_name for noise in NOISE_SECTORS):
continue
# 检查是否匹配任何别名
is_match = any(
alias in sector_name or sector_name in alias
for alias in theme_config['aliases']
)
if is_match:
matched.append({
'name': sector_name,
'stock_codes': sector_info.get('stock_codes', []),
'count': sector_info.get('count', 0),
})
return matched
def get_continuous_days(stock):
"""提取连板天数"""
cd = stock.get('continuous_days', '1')
match = re.search(r'(\d+)', str(cd))
return int(match.group(1)) if match else 1
# 处理每个主题
themes = []
latest_date = daily_data_list[0]['formatted_date']
for theme_config in THEME_KEYWORDS:
# 收集每日数据
history = []
for day_data in daily_data_list:
matched_sectors = match_sectors_by_keyword(day_data['sector_data'], theme_config)
# 合并所有匹配板块的股票(去重)
all_stock_codes = set()
for sector in matched_sectors:
all_stock_codes.update(sector['stock_codes'])
# 计算最高连板数
max_board = 1
stock_details = []
for code in all_stock_codes:
stock_info = next((s for s in day_data['stocks'] if s.get('scode') == code), None)
if stock_info:
days_val = get_continuous_days(stock_info)
max_board = max(max_board, days_val)
stock_details.append({
'scode': stock_info.get('scode', code),
'sname': stock_info.get('sname', code),
'continuous_days': days_val,
'brief': stock_info.get('brief', '')[:100] if stock_info.get('brief') else '',
})
else:
stock_details.append({'scode': code, 'sname': code, 'continuous_days': 1, 'brief': ''})
# 按连板数排序
stock_details.sort(key=lambda x: x['continuous_days'], reverse=True)
history.append({
'date': day_data['formatted_date'],
'count': len(all_stock_codes),
'maxBoard': max_board,
'stocks': stock_details,
'matchedSectors': [s['name'] for s in matched_sectors],
})
# 跳过无数据的主题
if not history or history[0]['count'] == 0:
continue
latest = history[0]
previous = history[1] if len(history) > 1 else {'count': 0, 'maxBoard': 1}
count_trend = latest['count'] - previous['count']
board_trend = latest['maxBoard'] - previous['maxBoard']
# 判断状态
if latest['maxBoard'] >= 3 and count_trend > 0:
status = 'rising'
elif latest['maxBoard'] >= 3 and count_trend < 0:
status = 'declining'
elif latest['maxBoard'] < 3 and count_trend > 0:
status = 'lurking'
else:
status = 'clustering'
themes.append({
'label': theme_config['label'],
'color': theme_config['color'],
'x': latest['maxBoard'],
'y': latest['count'],
'countTrend': count_trend,
'boardTrend': board_trend,
'status': status,
'history': history,
'stocks': latest['stocks'],
'matchedSectors': latest['matchedSectors'],
})
# 按热度排序
themes.sort(key=lambda x: x['y'], reverse=True)
return jsonify({
'success': True,
'data': {
'latestDate': latest_date,
'themes': themes,
}
})
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({
'success': False,
'error': str(e)
}), 500
@app.route('/api/v1/calendar/events', methods=['GET'])
def get_calendar_events():
"""获取指定日期的事件列表"""

View File

@@ -1,8 +1,8 @@
/**
* ThemeCometChart - 题材流星图(散点图版本)
* X轴辨识度最高板高度,即板块内最大连板数
* X轴辨识度最高板高度
* Y轴板块热度涨停家数
* 散点大小/颜色:表示不同状态(主升、退潮、潜伏、抱团)
* 数据由后端 API /api/v1/zt/theme-scatter 提供
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import {
@@ -13,206 +13,88 @@ import {
Spinner,
Center,
useToast,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Badge,
} from '@chakra-ui/react';
import ReactECharts from 'echarts-for-react';
import { RocketOutlined } from '@ant-design/icons';
import { getApiBase } from '@/utils/env';
// 板块状态分类
const SECTOR_STATUS = {
RISING: { name: '主升', color: '#FF4D4F', icon: '🔥' }, // 高辨识度+高热度+上升趋势
DECLINING: { name: '退潮', color: '#52C41A', icon: '❄️' }, // 高辨识度+热度下降
LURKING: { name: '潜伏', color: '#1890FF', icon: '●' }, // 低辨识度+热度上升
CLUSTERING: { name: '抱团', color: '#722ED1', icon: '●' }, // 中等辨识度+稳定热度
};
// 噪音板块黑名单(过滤无意义的板块)
const NOISE_SECTORS = [
'公告', '业绩预告', '重大合同签署', '重组', '股权激励',
'增持', '回购', '解禁', '减持', '限售股解禁',
];
/**
* 从 public/data/zt 读取最近两天数据用于比较趋势
*/
const fetchSectorData = async () => {
try {
// 获取日期列表
const datesRes = await fetch('/data/zt/dates.json');
const datesData = await datesRes.json();
const recentDates = datesData.dates.slice(0, 2); // 最近两天
if (recentDates.length === 0) return { today: null, yesterday: null };
// 获取今日数据
const todayRes = await fetch(`/data/zt/daily/${recentDates[0].date}.json`);
const todayData = await todayRes.json();
// 获取昨日数据(用于计算趋势)
let yesterdayData = null;
if (recentDates.length > 1) {
try {
const yesterdayRes = await fetch(`/data/zt/daily/${recentDates[1].date}.json`);
yesterdayData = await yesterdayRes.json();
} catch {
// 忽略
}
}
return {
today: {
date: recentDates[0].formatted_date,
sectorData: todayData.sector_data || {},
stocks: todayData.stocks || [],
},
yesterday: yesterdayData ? {
date: recentDates[1].formatted_date,
sectorData: yesterdayData.sector_data || {},
stocks: yesterdayData.stocks || [],
} : null,
};
} catch (error) {
console.error('获取板块数据失败:', error);
return { today: null, yesterday: null };
}
};
/**
* 处理板块数据,计算散点图坐标
*/
const processSectorData = (data) => {
if (!data.today) return [];
const { sectorData, stocks } = data.today;
const yesterdaySectors = data.yesterday?.sectorData || {};
// 构建股票代码到连板数的映射
const stockContinuousDays = {};
stocks.forEach((stock) => {
// continuous_days 可能是 "1板" 或数字
let days = 1;
if (stock.continuous_days) {
const match = String(stock.continuous_days).match(/(\d+)/);
if (match) days = parseInt(match[1]);
}
stockContinuousDays[stock.scode] = days;
});
const sectors = [];
Object.entries(sectorData).forEach(([sectorName, sectorInfo]) => {
// 过滤噪音板块
if (NOISE_SECTORS.some((noise) => sectorName.includes(noise))) return;
const stockCodes = sectorInfo.stock_codes || [];
const count = sectorInfo.count || stockCodes.length;
// 计算该板块的最高连板数(辨识度)
let maxContinuousDays = 1;
stockCodes.forEach((code) => {
const days = stockContinuousDays[code] || 1;
if (days > maxContinuousDays) maxContinuousDays = days;
});
// 获取昨日数据计算趋势
const yesterdayCount = yesterdaySectors[sectorName]?.count || 0;
const trend = count - yesterdayCount;
// 判断板块状态
let status;
if (maxContinuousDays >= 3 && trend > 0) {
status = SECTOR_STATUS.RISING; // 主升:高辨识度+上升
} else if (maxContinuousDays >= 3 && trend < 0) {
status = SECTOR_STATUS.DECLINING; // 退潮:高辨识度+下降
} else if (maxContinuousDays < 3 && trend > 0) {
status = SECTOR_STATUS.LURKING; // 潜伏:低辨识度+上升
} else {
status = SECTOR_STATUS.CLUSTERING; // 抱团:其他
}
sectors.push({
name: sectorName,
x: maxContinuousDays, // X轴最高板高度
y: count, // Y轴涨停家数
trend,
status,
stockCodes,
stocks: stockCodes.map((code) => {
const stockInfo = stocks.find((s) => s.scode === code);
return stockInfo || { scode: code, sname: code };
}),
});
});
// 按热度排序
sectors.sort((a, b) => b.y - a.y);
return sectors;
// 板块状态配置
const STATUS_CONFIG = {
rising: { name: '主升', color: '#FF4D4F' },
declining: { name: '退潮', color: '#52C41A' },
lurking: { name: '潜伏', color: '#1890FF' },
clustering: { name: '抱团', color: '#722ED1' },
};
/**
* 生成 ECharts 配置
*/
const generateChartOption = (sectors) => {
if (!sectors || sectors.length === 0) return {};
const generateChartOption = (themes) => {
if (!themes || themes.length === 0) return {};
// 按状态分组
const groupedData = {
[SECTOR_STATUS.RISING.name]: [],
[SECTOR_STATUS.DECLINING.name]: [],
[SECTOR_STATUS.LURKING.name]: [],
[SECTOR_STATUS.CLUSTERING.name]: [],
主升: [],
退潮: [],
潜伏: [],
抱团: [],
};
sectors.forEach((sector) => {
groupedData[sector.status.name].push({
name: sector.name,
value: [sector.x, sector.y],
trend: sector.trend,
stockCount: sector.y,
maxBoard: sector.x,
stocks: sector.stocks,
themes.forEach((theme) => {
const statusName = STATUS_CONFIG[theme.status]?.name || '抱团';
groupedData[statusName].push({
name: theme.label,
value: [theme.x, theme.y],
countTrend: theme.countTrend,
boardTrend: theme.boardTrend,
themeColor: theme.color,
history: theme.history,
});
});
// 创建系列
const series = Object.entries(SECTOR_STATUS).map(([key, status]) => ({
name: status.name,
const series = Object.entries(STATUS_CONFIG).map(([key, config]) => ({
name: config.name,
type: 'scatter',
data: groupedData[status.name],
symbolSize: (data) => {
// 根据热度调整大小
const size = Math.max(20, Math.min(60, data[1] * 4));
return size;
},
data: groupedData[config.name] || [],
symbolSize: (data) => Math.max(25, Math.min(70, data[1] * 3.5)),
itemStyle: {
color: status.color,
shadowBlur: 10,
shadowColor: status.color,
opacity: 0.8,
color: (params) => params.data.themeColor || config.color,
shadowBlur: 12,
shadowColor: (params) => params.data.themeColor || config.color,
opacity: 0.85,
},
emphasis: {
itemStyle: {
opacity: 1,
shadowBlur: 20,
},
label: {
show: true,
},
itemStyle: { opacity: 1, shadowBlur: 25 },
},
label: {
show: true,
formatter: (params) => params.data.name,
position: 'right',
color: '#fff',
fontSize: 11,
fontSize: 12,
fontWeight: 'bold',
textShadowColor: 'rgba(0,0,0,0.8)',
textShadowBlur: 4,
},
}));
// 计算坐标轴范围
const maxX = Math.max(...sectors.map((s) => s.x), 5) + 1;
const maxY = Math.max(...sectors.map((s) => s.y), 10) + 2;
const maxX = Math.max(...themes.map((t) => t.x), 5) + 1;
const maxY = Math.max(...themes.map((t) => t.y), 10) + 3;
return {
backgroundColor: 'transparent',
@@ -221,60 +103,58 @@ const generateChartOption = (sectors) => {
backgroundColor: 'rgba(15, 15, 30, 0.95)',
borderColor: 'rgba(255, 215, 0, 0.3)',
borderWidth: 1,
textStyle: {
color: '#fff',
},
textStyle: { color: '#fff' },
formatter: (params) => {
const { name, value, trend, stocks } = params.data;
const trendText = trend > 0 ? `+${trend}` : trend;
const trendIcon = trend > 0 ? '🔥' : trend < 0 ? '❄️' : '➡️';
const { name, value, countTrend, boardTrend, history } = params.data;
const countTrendText = countTrend > 0 ? `+${countTrend}` : countTrend;
const boardTrendText = boardTrend > 0 ? `+${boardTrend}` : boardTrend;
const countIcon = countTrend > 0 ? '🔥' : countTrend < 0 ? '❄️' : '➡️';
// 显示前5只股票
const topStocks = stocks.slice(0, 5);
const stockList = topStocks
.map((s) => `${s.sname || s.scode}`)
.join('、');
const historyText = (history || [])
.slice(0, 5)
.map((h) => `${h.date?.slice(5) || ''}: ${h.count}家/${h.maxBoard}`)
.join('<br>');
return `
<div style="font-weight:bold;margin-bottom:8px;color:#FFD700;font-size:14px;">
${name} ${trendIcon}
${name} ${countIcon}
</div>
<div style="margin:4px 0;">
<span style="color:#aaa">涨停家数:</span>
<span style="color:#fff;margin-left:8px;">${value[1]}家</span>
<span style="color:${trend >= 0 ? '#52c41a' : '#ff4d4f'};margin-left:8px;">(${trendText})</span>
<span style="color:${countTrend >= 0 ? '#52c41a' : '#ff4d4f'};margin-left:8px;">(${countTrendText})</span>
</div>
<div style="margin:4px 0;">
<span style="color:#aaa">最高连板:</span>
<span style="color:#fff;margin-left:8px;">${value[0]}板</span>
<span style="color:${boardTrend >= 0 ? '#52c41a' : '#ff4d4f'};margin-left:8px;">(${boardTrendText})</span>
</div>
<div style="margin:4px 0;color:#aaa;font-size:11px;">
${stockList}${stocks.length > 5 ? '...' : ''}
<div style="margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.1);">
<div style="color:#aaa;font-size:11px;margin-bottom:4px;">近5日趋势:</div>
<div style="font-size:11px;line-height:1.6;">${historyText}</div>
</div>
<div style="margin-top:8px;color:#888;font-size:10px;">点击查看详情</div>
`;
},
},
legend: {
show: true,
top: 10,
top: 5,
right: 10,
orient: 'horizontal',
textStyle: {
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 11,
},
itemWidth: 14,
itemHeight: 14,
data: Object.values(SECTOR_STATUS).map((s) => ({
textStyle: { color: 'rgba(255, 255, 255, 0.7)', fontSize: 11 },
itemWidth: 12,
itemHeight: 12,
data: Object.values(STATUS_CONFIG).map((s) => ({
name: s.name,
icon: 'circle',
itemStyle: { color: s.color },
})),
},
grid: {
left: '8%',
right: '5%',
top: '15%',
left: '10%',
right: '8%',
top: '12%',
bottom: '15%',
containLabel: true,
},
@@ -283,52 +163,25 @@ const generateChartOption = (sectors) => {
name: '辨识度(最高板)',
nameLocation: 'middle',
nameGap: 30,
nameTextStyle: {
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 12,
},
nameTextStyle: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 12 },
min: 0,
max: maxX,
interval: 1,
axisLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.2)',
},
},
axisLabel: {
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 11,
formatter: '{value}板',
},
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.05)',
},
},
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.2)' } },
axisLabel: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 11, formatter: '{value}板' },
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.05)' } },
},
yAxis: {
type: 'value',
name: '板块热度(家数)',
nameLocation: 'middle',
nameGap: 40,
nameTextStyle: {
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 12,
},
nameTextStyle: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 12 },
min: 0,
max: maxY,
axisLine: {
show: false,
},
axisLabel: {
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 11,
},
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.05)',
},
},
axisLine: { show: false },
axisLabel: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 11 },
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.05)' } },
},
series,
};
@@ -339,28 +192,30 @@ const generateChartOption = (sectors) => {
*/
const ThemeCometChart = ({ onThemeSelect }) => {
const [loading, setLoading] = useState(true);
const [sectors, setSectors] = useState([]);
const [dateInfo, setDateInfo] = useState('');
const [data, setData] = useState({ themes: [], latestDate: '' });
const [selectedTheme, setSelectedTheme] = useState(null);
const { isOpen, onOpen, onClose } = useDisclosure();
const toast = useToast();
// 加载数据
useEffect(() => {
const loadData = async () => {
setLoading(true);
try {
const data = await fetchSectorData();
const processed = processSectorData(data);
setSectors(processed);
if (data.today) {
setDateInfo(data.today.date);
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/api/v1/zt/theme-scatter?days=5`);
const result = await response.json();
if (result.success && result.data) {
setData({
themes: result.data.themes || [],
latestDate: result.data.latestDate || '',
});
} else {
throw new Error(result.error || '加载失败');
}
} catch (error) {
console.error('加载板块数据失败:', error);
toast({
title: '加载数据失败',
status: 'error',
duration: 3000,
});
console.error('加载题材数据失败:', error);
toast({ title: '加载数据失败', description: error.message, status: 'error', duration: 3000 });
} finally {
setLoading(false);
}
@@ -368,53 +223,38 @@ const ThemeCometChart = ({ onThemeSelect }) => {
loadData();
}, [toast]);
// 图表配置
const chartOption = useMemo(() => {
return generateChartOption(sectors);
}, [sectors]);
const chartOption = useMemo(() => generateChartOption(data.themes), [data.themes]);
// 点击事件
const handleChartClick = useCallback(
(params) => {
if (params.data && onThemeSelect) {
const sector = sectors.find((s) => s.name === params.data.name);
if (sector) {
onThemeSelect({
theme: { label: sector.name, color: sector.status.color },
stocks: sector.stocks.map((s) => ({
...s,
_continuousDays: 1,
})),
date: dateInfo,
});
if (params.data) {
const theme = data.themes.find((t) => t.label === params.data.name);
if (theme) {
setSelectedTheme(theme);
onOpen();
}
}
},
[sectors, dateInfo, onThemeSelect]
[data.themes, onOpen]
);
const onChartEvents = useMemo(
() => ({
click: handleChartClick,
}),
[handleChartClick]
);
const onChartEvents = useMemo(() => ({ click: handleChartClick }), [handleChartClick]);
if (loading) {
return (
<Center h="300px">
<VStack spacing={4}>
<Spinner size="lg" color="yellow.400" />
<Text color="whiteAlpha.600">加载板块数据...</Text>
<Text color="whiteAlpha.600">加载数据...</Text>
</VStack>
</Center>
);
}
if (!sectors || sectors.length === 0) {
if (!data.themes || data.themes.length === 0) {
return (
<Center h="300px">
<Text color="whiteAlpha.500">暂无板块数据</Text>
<Text color="whiteAlpha.500">暂无数据</Text>
</Center>
);
}
@@ -430,12 +270,7 @@ const ThemeCometChart = ({ onThemeSelect }) => {
>
{/* 标题栏 */}
<HStack spacing={3} mb={2}>
<Box
p={2}
bg="rgba(255,215,0,0.15)"
borderRadius="lg"
border="1px solid rgba(255,215,0,0.3)"
>
<Box p={2} bg="rgba(255,215,0,0.15)" borderRadius="lg" border="1px solid rgba(255,215,0,0.3)">
<RocketOutlined style={{ color: '#FFD700', fontSize: '18px' }} />
</Box>
<VStack align="start" spacing={0}>
@@ -443,7 +278,7 @@ const ThemeCometChart = ({ onThemeSelect }) => {
AI 舆情 · 时空决策驾驶舱
</Text>
<Text fontSize="xs" color="whiteAlpha.500">
题材流星图 · {dateInfo}
题材流星图 · {data.latestDate}
</Text>
</VStack>
</HStack>
@@ -457,6 +292,102 @@ const ThemeCometChart = ({ onThemeSelect }) => {
opts={{ renderer: 'canvas' }}
/>
</Box>
{/* 详情弹窗 */}
<Modal isOpen={isOpen} onClose={onClose} size="xl" isCentered>
<ModalOverlay bg="blackAlpha.700" />
<ModalContent bg="gray.900" border="1px solid" borderColor="yellow.500" maxH="80vh">
<ModalHeader color="yellow.400">
{selectedTheme?.label} - 近5日趋势
</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody pb={6} overflowY="auto">
{selectedTheme && (
<VStack spacing={4} align="stretch">
{/* 趋势表格 */}
<Box>
<Text color="whiteAlpha.700" fontSize="sm" mb={2}>历史数据</Text>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th color="whiteAlpha.600">日期</Th>
<Th color="whiteAlpha.600" isNumeric>涨停家数</Th>
<Th color="whiteAlpha.600" isNumeric>最高连板</Th>
<Th color="whiteAlpha.600">变化</Th>
</Tr>
</Thead>
<Tbody>
{selectedTheme.history?.map((h, idx) => {
const prev = selectedTheme.history[idx + 1];
const countChange = prev ? h.count - prev.count : 0;
return (
<Tr key={h.date}>
<Td color="white">{h.date}</Td>
<Td color="white" isNumeric>{h.count}</Td>
<Td color="white" isNumeric>{h.maxBoard}</Td>
<Td>
{countChange !== 0 && (
<Badge colorScheme={countChange > 0 ? 'green' : 'red'}>
{countChange > 0 ? '+' : ''}{countChange}
</Badge>
)}
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</Box>
{/* 今日涨停股票 */}
<Box>
<Text color="whiteAlpha.700" fontSize="sm" mb={2}>
今日涨停股票{selectedTheme.stocks?.length || 0}
</Text>
<Box maxH="200px" overflowY="auto">
<Table size="sm" variant="simple">
<Thead position="sticky" top={0} bg="gray.900">
<Tr>
<Th color="whiteAlpha.600">代码</Th>
<Th color="whiteAlpha.600">名称</Th>
<Th color="whiteAlpha.600">连板</Th>
</Tr>
</Thead>
<Tbody>
{selectedTheme.stocks?.slice(0, 20).map((stock) => (
<Tr key={stock.scode}>
<Td color="whiteAlpha.800" fontSize="xs">{stock.scode}</Td>
<Td color="white">{stock.sname}</Td>
<Td>
<Badge colorScheme={stock.continuous_days >= 3 ? 'red' : 'gray'}>
{stock.continuous_days}
</Badge>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
</Box>
{/* 匹配的板块 */}
{selectedTheme.matchedSectors?.length > 0 && (
<Box>
<Text color="whiteAlpha.700" fontSize="sm" mb={2}>匹配板块</Text>
<HStack flexWrap="wrap" spacing={2}>
{selectedTheme.matchedSectors.map((sector) => (
<Badge key={sector} colorScheme="purple" variant="subtle">
{sector}
</Badge>
))}
</HStack>
</Box>
)}
</VStack>
)}
</ModalBody>
</ModalContent>
</Modal>
</Box>
);
};