community增加事件详情
This commit is contained in:
234
app.py
234
app.py
@@ -10754,6 +10754,240 @@ def get_calendar_combined_data():
|
|||||||
}), 500
|
}), 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'])
|
@app.route('/api/v1/calendar/events', methods=['GET'])
|
||||||
def get_calendar_events():
|
def get_calendar_events():
|
||||||
"""获取指定日期的事件列表"""
|
"""获取指定日期的事件列表"""
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* ThemeCometChart - 题材流星图(散点图版本)
|
* ThemeCometChart - 题材流星图(散点图版本)
|
||||||
* X轴:辨识度(最高板高度,即板块内最大连板数)
|
* X轴:辨识度(最高板高度)
|
||||||
* Y轴:板块热度(涨停家数)
|
* Y轴:板块热度(涨停家数)
|
||||||
* 散点大小/颜色:表示不同状态(主升、退潮、潜伏、抱团)
|
* 数据由后端 API /api/v1/zt/theme-scatter 提供
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -13,206 +13,88 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
Center,
|
Center,
|
||||||
useToast,
|
useToast,
|
||||||
|
Modal,
|
||||||
|
ModalOverlay,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
useDisclosure,
|
||||||
|
Table,
|
||||||
|
Thead,
|
||||||
|
Tbody,
|
||||||
|
Tr,
|
||||||
|
Th,
|
||||||
|
Td,
|
||||||
|
Badge,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import ReactECharts from 'echarts-for-react';
|
import ReactECharts from 'echarts-for-react';
|
||||||
import { RocketOutlined } from '@ant-design/icons';
|
import { RocketOutlined } from '@ant-design/icons';
|
||||||
|
import { getApiBase } from '@/utils/env';
|
||||||
|
|
||||||
// 板块状态分类
|
// 板块状态配置
|
||||||
const SECTOR_STATUS = {
|
const STATUS_CONFIG = {
|
||||||
RISING: { name: '主升', color: '#FF4D4F', icon: '🔥' }, // 高辨识度+高热度+上升趋势
|
rising: { name: '主升', color: '#FF4D4F' },
|
||||||
DECLINING: { name: '退潮', color: '#52C41A', icon: '❄️' }, // 高辨识度+热度下降
|
declining: { name: '退潮', color: '#52C41A' },
|
||||||
LURKING: { name: '潜伏', color: '#1890FF', icon: '●' }, // 低辨识度+热度上升
|
lurking: { name: '潜伏', color: '#1890FF' },
|
||||||
CLUSTERING: { name: '抱团', color: '#722ED1', icon: '●' }, // 中等辨识度+稳定热度
|
clustering: { name: '抱团', color: '#722ED1' },
|
||||||
};
|
|
||||||
|
|
||||||
// 噪音板块黑名单(过滤无意义的板块)
|
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成 ECharts 配置
|
* 生成 ECharts 配置
|
||||||
*/
|
*/
|
||||||
const generateChartOption = (sectors) => {
|
const generateChartOption = (themes) => {
|
||||||
if (!sectors || sectors.length === 0) return {};
|
if (!themes || themes.length === 0) return {};
|
||||||
|
|
||||||
// 按状态分组
|
// 按状态分组
|
||||||
const groupedData = {
|
const groupedData = {
|
||||||
[SECTOR_STATUS.RISING.name]: [],
|
主升: [],
|
||||||
[SECTOR_STATUS.DECLINING.name]: [],
|
退潮: [],
|
||||||
[SECTOR_STATUS.LURKING.name]: [],
|
潜伏: [],
|
||||||
[SECTOR_STATUS.CLUSTERING.name]: [],
|
抱团: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
sectors.forEach((sector) => {
|
themes.forEach((theme) => {
|
||||||
groupedData[sector.status.name].push({
|
const statusName = STATUS_CONFIG[theme.status]?.name || '抱团';
|
||||||
name: sector.name,
|
groupedData[statusName].push({
|
||||||
value: [sector.x, sector.y],
|
name: theme.label,
|
||||||
trend: sector.trend,
|
value: [theme.x, theme.y],
|
||||||
stockCount: sector.y,
|
countTrend: theme.countTrend,
|
||||||
maxBoard: sector.x,
|
boardTrend: theme.boardTrend,
|
||||||
stocks: sector.stocks,
|
themeColor: theme.color,
|
||||||
|
history: theme.history,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 创建系列
|
// 创建系列
|
||||||
const series = Object.entries(SECTOR_STATUS).map(([key, status]) => ({
|
const series = Object.entries(STATUS_CONFIG).map(([key, config]) => ({
|
||||||
name: status.name,
|
name: config.name,
|
||||||
type: 'scatter',
|
type: 'scatter',
|
||||||
data: groupedData[status.name],
|
data: groupedData[config.name] || [],
|
||||||
symbolSize: (data) => {
|
symbolSize: (data) => Math.max(25, Math.min(70, data[1] * 3.5)),
|
||||||
// 根据热度调整大小
|
|
||||||
const size = Math.max(20, Math.min(60, data[1] * 4));
|
|
||||||
return size;
|
|
||||||
},
|
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: status.color,
|
color: (params) => params.data.themeColor || config.color,
|
||||||
shadowBlur: 10,
|
shadowBlur: 12,
|
||||||
shadowColor: status.color,
|
shadowColor: (params) => params.data.themeColor || config.color,
|
||||||
opacity: 0.8,
|
opacity: 0.85,
|
||||||
},
|
},
|
||||||
emphasis: {
|
emphasis: {
|
||||||
itemStyle: {
|
itemStyle: { opacity: 1, shadowBlur: 25 },
|
||||||
opacity: 1,
|
|
||||||
shadowBlur: 20,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
show: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
show: true,
|
show: true,
|
||||||
formatter: (params) => params.data.name,
|
formatter: (params) => params.data.name,
|
||||||
position: 'right',
|
position: 'right',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontSize: 11,
|
fontSize: 12,
|
||||||
|
fontWeight: 'bold',
|
||||||
textShadowColor: 'rgba(0,0,0,0.8)',
|
textShadowColor: 'rgba(0,0,0,0.8)',
|
||||||
textShadowBlur: 4,
|
textShadowBlur: 4,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 计算坐标轴范围
|
const maxX = Math.max(...themes.map((t) => t.x), 5) + 1;
|
||||||
const maxX = Math.max(...sectors.map((s) => s.x), 5) + 1;
|
const maxY = Math.max(...themes.map((t) => t.y), 10) + 3;
|
||||||
const maxY = Math.max(...sectors.map((s) => s.y), 10) + 2;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
@@ -221,60 +103,58 @@ const generateChartOption = (sectors) => {
|
|||||||
backgroundColor: 'rgba(15, 15, 30, 0.95)',
|
backgroundColor: 'rgba(15, 15, 30, 0.95)',
|
||||||
borderColor: 'rgba(255, 215, 0, 0.3)',
|
borderColor: 'rgba(255, 215, 0, 0.3)',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
textStyle: {
|
textStyle: { color: '#fff' },
|
||||||
color: '#fff',
|
|
||||||
},
|
|
||||||
formatter: (params) => {
|
formatter: (params) => {
|
||||||
const { name, value, trend, stocks } = params.data;
|
const { name, value, countTrend, boardTrend, history } = params.data;
|
||||||
const trendText = trend > 0 ? `+${trend}` : trend;
|
const countTrendText = countTrend > 0 ? `+${countTrend}` : countTrend;
|
||||||
const trendIcon = trend > 0 ? '🔥' : trend < 0 ? '❄️' : '➡️';
|
const boardTrendText = boardTrend > 0 ? `+${boardTrend}` : boardTrend;
|
||||||
|
const countIcon = countTrend > 0 ? '🔥' : countTrend < 0 ? '❄️' : '➡️';
|
||||||
|
|
||||||
// 显示前5只股票
|
const historyText = (history || [])
|
||||||
const topStocks = stocks.slice(0, 5);
|
.slice(0, 5)
|
||||||
const stockList = topStocks
|
.map((h) => `${h.date?.slice(5) || ''}: ${h.count}家/${h.maxBoard}板`)
|
||||||
.map((s) => `${s.sname || s.scode}`)
|
.join('<br>');
|
||||||
.join('、');
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div style="font-weight:bold;margin-bottom:8px;color:#FFD700;font-size:14px;">
|
<div style="font-weight:bold;margin-bottom:8px;color:#FFD700;font-size:14px;">
|
||||||
${name} ${trendIcon}
|
${name} ${countIcon}
|
||||||
</div>
|
</div>
|
||||||
<div style="margin:4px 0;">
|
<div style="margin:4px 0;">
|
||||||
<span style="color:#aaa">涨停家数:</span>
|
<span style="color:#aaa">涨停家数:</span>
|
||||||
<span style="color:#fff;margin-left:8px;">${value[1]}家</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>
|
||||||
<div style="margin:4px 0;">
|
<div style="margin:4px 0;">
|
||||||
<span style="color:#aaa">最高连板:</span>
|
<span style="color:#aaa">最高连板:</span>
|
||||||
<span style="color:#fff;margin-left:8px;">${value[0]}板</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>
|
||||||
<div style="margin:4px 0;color:#aaa;font-size:11px;">
|
<div style="margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.1);">
|
||||||
${stockList}${stocks.length > 5 ? '...' : ''}
|
<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>
|
||||||
|
<div style="margin-top:8px;color:#888;font-size:10px;">点击查看详情</div>
|
||||||
`;
|
`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
show: true,
|
show: true,
|
||||||
top: 10,
|
top: 5,
|
||||||
right: 10,
|
right: 10,
|
||||||
orient: 'horizontal',
|
orient: 'horizontal',
|
||||||
textStyle: {
|
textStyle: { color: 'rgba(255, 255, 255, 0.7)', fontSize: 11 },
|
||||||
color: 'rgba(255, 255, 255, 0.7)',
|
itemWidth: 12,
|
||||||
fontSize: 11,
|
itemHeight: 12,
|
||||||
},
|
data: Object.values(STATUS_CONFIG).map((s) => ({
|
||||||
itemWidth: 14,
|
|
||||||
itemHeight: 14,
|
|
||||||
data: Object.values(SECTOR_STATUS).map((s) => ({
|
|
||||||
name: s.name,
|
name: s.name,
|
||||||
icon: 'circle',
|
icon: 'circle',
|
||||||
itemStyle: { color: s.color },
|
itemStyle: { color: s.color },
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
left: '8%',
|
left: '10%',
|
||||||
right: '5%',
|
right: '8%',
|
||||||
top: '15%',
|
top: '12%',
|
||||||
bottom: '15%',
|
bottom: '15%',
|
||||||
containLabel: true,
|
containLabel: true,
|
||||||
},
|
},
|
||||||
@@ -283,52 +163,25 @@ const generateChartOption = (sectors) => {
|
|||||||
name: '辨识度(最高板)',
|
name: '辨识度(最高板)',
|
||||||
nameLocation: 'middle',
|
nameLocation: 'middle',
|
||||||
nameGap: 30,
|
nameGap: 30,
|
||||||
nameTextStyle: {
|
nameTextStyle: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 12 },
|
||||||
color: 'rgba(255, 255, 255, 0.6)',
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
min: 0,
|
min: 0,
|
||||||
max: maxX,
|
max: maxX,
|
||||||
interval: 1,
|
interval: 1,
|
||||||
axisLine: {
|
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.2)' } },
|
||||||
lineStyle: {
|
axisLabel: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 11, formatter: '{value}板' },
|
||||||
color: 'rgba(255, 255, 255, 0.2)',
|
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.05)' } },
|
||||||
},
|
|
||||||
},
|
|
||||||
axisLabel: {
|
|
||||||
color: 'rgba(255, 255, 255, 0.6)',
|
|
||||||
fontSize: 11,
|
|
||||||
formatter: '{value}板',
|
|
||||||
},
|
|
||||||
splitLine: {
|
|
||||||
lineStyle: {
|
|
||||||
color: 'rgba(255, 255, 255, 0.05)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
name: '板块热度(家数)',
|
name: '板块热度(家数)',
|
||||||
nameLocation: 'middle',
|
nameLocation: 'middle',
|
||||||
nameGap: 40,
|
nameGap: 40,
|
||||||
nameTextStyle: {
|
nameTextStyle: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 12 },
|
||||||
color: 'rgba(255, 255, 255, 0.6)',
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
min: 0,
|
min: 0,
|
||||||
max: maxY,
|
max: maxY,
|
||||||
axisLine: {
|
axisLine: { show: false },
|
||||||
show: false,
|
axisLabel: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 11 },
|
||||||
},
|
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.05)' } },
|
||||||
axisLabel: {
|
|
||||||
color: 'rgba(255, 255, 255, 0.6)',
|
|
||||||
fontSize: 11,
|
|
||||||
},
|
|
||||||
splitLine: {
|
|
||||||
lineStyle: {
|
|
||||||
color: 'rgba(255, 255, 255, 0.05)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
series,
|
series,
|
||||||
};
|
};
|
||||||
@@ -339,28 +192,30 @@ const generateChartOption = (sectors) => {
|
|||||||
*/
|
*/
|
||||||
const ThemeCometChart = ({ onThemeSelect }) => {
|
const ThemeCometChart = ({ onThemeSelect }) => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [sectors, setSectors] = useState([]);
|
const [data, setData] = useState({ themes: [], latestDate: '' });
|
||||||
const [dateInfo, setDateInfo] = useState('');
|
const [selectedTheme, setSelectedTheme] = useState(null);
|
||||||
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
// 加载数据
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await fetchSectorData();
|
const apiBase = getApiBase();
|
||||||
const processed = processSectorData(data);
|
const response = await fetch(`${apiBase}/api/v1/zt/theme-scatter?days=5`);
|
||||||
setSectors(processed);
|
const result = await response.json();
|
||||||
if (data.today) {
|
|
||||||
setDateInfo(data.today.date);
|
if (result.success && result.data) {
|
||||||
|
setData({
|
||||||
|
themes: result.data.themes || [],
|
||||||
|
latestDate: result.data.latestDate || '',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || '加载失败');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载板块数据失败:', error);
|
console.error('加载题材数据失败:', error);
|
||||||
toast({
|
toast({ title: '加载数据失败', description: error.message, status: 'error', duration: 3000 });
|
||||||
title: '加载数据失败',
|
|
||||||
status: 'error',
|
|
||||||
duration: 3000,
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -368,53 +223,38 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [toast]);
|
}, [toast]);
|
||||||
|
|
||||||
// 图表配置
|
const chartOption = useMemo(() => generateChartOption(data.themes), [data.themes]);
|
||||||
const chartOption = useMemo(() => {
|
|
||||||
return generateChartOption(sectors);
|
|
||||||
}, [sectors]);
|
|
||||||
|
|
||||||
// 点击事件
|
|
||||||
const handleChartClick = useCallback(
|
const handleChartClick = useCallback(
|
||||||
(params) => {
|
(params) => {
|
||||||
if (params.data && onThemeSelect) {
|
if (params.data) {
|
||||||
const sector = sectors.find((s) => s.name === params.data.name);
|
const theme = data.themes.find((t) => t.label === params.data.name);
|
||||||
if (sector) {
|
if (theme) {
|
||||||
onThemeSelect({
|
setSelectedTheme(theme);
|
||||||
theme: { label: sector.name, color: sector.status.color },
|
onOpen();
|
||||||
stocks: sector.stocks.map((s) => ({
|
|
||||||
...s,
|
|
||||||
_continuousDays: 1,
|
|
||||||
})),
|
|
||||||
date: dateInfo,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[sectors, dateInfo, onThemeSelect]
|
[data.themes, onOpen]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onChartEvents = useMemo(
|
const onChartEvents = useMemo(() => ({ click: handleChartClick }), [handleChartClick]);
|
||||||
() => ({
|
|
||||||
click: handleChartClick,
|
|
||||||
}),
|
|
||||||
[handleChartClick]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Center h="300px">
|
<Center h="300px">
|
||||||
<VStack spacing={4}>
|
<VStack spacing={4}>
|
||||||
<Spinner size="lg" color="yellow.400" />
|
<Spinner size="lg" color="yellow.400" />
|
||||||
<Text color="whiteAlpha.600">加载板块数据...</Text>
|
<Text color="whiteAlpha.600">加载数据...</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sectors || sectors.length === 0) {
|
if (!data.themes || data.themes.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Center h="300px">
|
<Center h="300px">
|
||||||
<Text color="whiteAlpha.500">暂无板块数据</Text>
|
<Text color="whiteAlpha.500">暂无数据</Text>
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -430,12 +270,7 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
>
|
>
|
||||||
{/* 标题栏 */}
|
{/* 标题栏 */}
|
||||||
<HStack spacing={3} mb={2}>
|
<HStack spacing={3} mb={2}>
|
||||||
<Box
|
<Box p={2} bg="rgba(255,215,0,0.15)" borderRadius="lg" border="1px solid rgba(255,215,0,0.3)">
|
||||||
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' }} />
|
<RocketOutlined style={{ color: '#FFD700', fontSize: '18px' }} />
|
||||||
</Box>
|
</Box>
|
||||||
<VStack align="start" spacing={0}>
|
<VStack align="start" spacing={0}>
|
||||||
@@ -443,7 +278,7 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
AI 舆情 · 时空决策驾驶舱
|
AI 舆情 · 时空决策驾驶舱
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="xs" color="whiteAlpha.500">
|
<Text fontSize="xs" color="whiteAlpha.500">
|
||||||
题材流星图 · {dateInfo}
|
题材流星图 · {data.latestDate}
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
@@ -457,6 +292,102 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
opts={{ renderer: 'canvas' }}
|
opts={{ renderer: 'canvas' }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user