community增加事件详情
This commit is contained in:
234
app.py
234
app.py
@@ -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():
|
||||
"""获取指定日期的事件列表"""
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user