Merge branch 'feature_bugfix/251201_py_h5_ui' into feature_2025/251209_stock_pref
* feature_bugfix/251201_py_h5_ui: feat: Company 页面搜索框添加股票模糊搜索功能 update pay ui update pay ui fix: 个股中心bug修复 update pay ui update pay ui update pay ui update pay ui update pay ui update pay ui update pay ui update pay ui update pay ui update pay ui update pay ui update pay ui update pay ui update pay ui feat: 替换公众号文件 update pay ui
This commit is contained in:
@@ -313,12 +313,29 @@ const StockChartAntdModal = ({
|
||||
axisPointer: { type: 'cross' },
|
||||
formatter: function(params) {
|
||||
const d = params[0]?.dataIndex ?? 0;
|
||||
const priceChangePercent = ((prices[d] - prevClose) / prevClose * 100);
|
||||
const avgChangePercent = ((avgPrices[d] - prevClose) / prevClose * 100);
|
||||
const price = prices[d];
|
||||
const avgPrice = avgPrices[d];
|
||||
const volume = volumes[d];
|
||||
|
||||
// 安全计算涨跌幅,处理 undefined/null/0 的情况
|
||||
const safeCalcPercent = (val, base) => {
|
||||
if (val == null || base == null || base === 0) return 0;
|
||||
return ((val - base) / base * 100);
|
||||
};
|
||||
|
||||
const priceChangePercent = safeCalcPercent(price, prevClose);
|
||||
const avgChangePercent = safeCalcPercent(avgPrice, prevClose);
|
||||
const priceColor = priceChangePercent >= 0 ? '#ef5350' : '#26a69a';
|
||||
const avgColor = avgChangePercent >= 0 ? '#ef5350' : '#26a69a';
|
||||
|
||||
return `时间:${times[d]}<br/>现价:<span style="color: ${priceColor}">¥${prices[d]?.toFixed(2)} (${priceChangePercent >= 0 ? '+' : ''}${priceChangePercent.toFixed(2)}%)</span><br/>均价:<span style="color: ${avgColor}">¥${avgPrices[d]?.toFixed(2)} (${avgChangePercent >= 0 ? '+' : ''}${avgChangePercent.toFixed(2)}%)</span><br/>昨收:¥${prevClose?.toFixed(2)}<br/>成交量:${Math.round(volumes[d]/100)}手`;
|
||||
// 安全格式化数字
|
||||
const safeFixed = (val, digits = 2) => (val != null && !isNaN(val)) ? val.toFixed(digits) : '-';
|
||||
const formatPercent = (val) => {
|
||||
if (val == null || isNaN(val)) return '-';
|
||||
return (val >= 0 ? '+' : '') + val.toFixed(2) + '%';
|
||||
};
|
||||
|
||||
return `时间:${times[d] || '-'}<br/>现价:<span style="color: ${priceColor}">¥${safeFixed(price)} (${formatPercent(priceChangePercent)})</span><br/>均价:<span style="color: ${avgColor}">¥${safeFixed(avgPrice)} (${formatPercent(avgChangePercent)})</span><br/>昨收:¥${safeFixed(prevClose)}<br/>成交量:${volume != null ? Math.round(volume/100) + '手' : '-'}`;
|
||||
}
|
||||
},
|
||||
grid: [
|
||||
@@ -337,6 +354,7 @@ const StockChartAntdModal = ({
|
||||
position: 'left',
|
||||
axisLabel: {
|
||||
formatter: function(value) {
|
||||
if (value == null || isNaN(value)) return '-';
|
||||
return (value >= 0 ? '+' : '') + value.toFixed(2) + '%';
|
||||
}
|
||||
},
|
||||
@@ -354,11 +372,12 @@ const StockChartAntdModal = ({
|
||||
position: 'right',
|
||||
axisLabel: {
|
||||
formatter: function(value) {
|
||||
if (value == null || isNaN(value)) return '-';
|
||||
return (value >= 0 ? '+' : '') + value.toFixed(2) + '%';
|
||||
}
|
||||
}
|
||||
},
|
||||
{ type: 'value', gridIndex: 1, scale: true, axisLabel: { formatter: v => Math.round(v/100) + '手' } }
|
||||
{ type: 'value', gridIndex: 1, scale: true, axisLabel: { formatter: v => (v != null && !isNaN(v)) ? Math.round(v/100) + '手' : '-' } }
|
||||
],
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: [0, 1], start: 0, end: 100 },
|
||||
|
||||
@@ -217,27 +217,34 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
if (dataIndex === undefined) return '';
|
||||
|
||||
const item = data[dataIndex];
|
||||
const changeColor = item.change_percent >= 0 ? '#ef5350' : '#26a69a';
|
||||
const changeSign = item.change_percent >= 0 ? '+' : '';
|
||||
if (!item) return '';
|
||||
|
||||
// 安全格式化数字
|
||||
const safeFixed = (val: any, digits = 2) =>
|
||||
val != null && !isNaN(val) ? Number(val).toFixed(digits) : '-';
|
||||
|
||||
const changePercent = item.change_percent ?? 0;
|
||||
const changeColor = changePercent >= 0 ? '#ef5350' : '#26a69a';
|
||||
const changeSign = changePercent >= 0 ? '+' : '';
|
||||
|
||||
return `
|
||||
<div style="padding: 8px;">
|
||||
<div style="font-weight: bold; margin-bottom: 8px;">${item.time}</div>
|
||||
<div style="font-weight: bold; margin-bottom: 8px;">${item.time || '-'}</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span>价格:</span>
|
||||
<span style="color: ${changeColor}; font-weight: bold; margin-left: 20px;">${item.price.toFixed(2)}</span>
|
||||
<span style="color: ${changeColor}; font-weight: bold; margin-left: 20px;">${safeFixed(item.price)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span>均价:</span>
|
||||
<span style="color: #ffa726; margin-left: 20px;">${item.avg_price.toFixed(2)}</span>
|
||||
<span style="color: #ffa726; margin-left: 20px;">${safeFixed(item.avg_price)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span>涨跌幅:</span>
|
||||
<span style="color: ${changeColor}; margin-left: 20px;">${changeSign}${item.change_percent.toFixed(2)}%</span>
|
||||
<span style="color: ${changeColor}; margin-left: 20px;">${changeSign}${safeFixed(changePercent)}%</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span>成交量:</span>
|
||||
<span style="margin-left: 20px;">${(item.volume / 100).toFixed(0)}手</span>
|
||||
<span style="margin-left: 20px;">${item.volume != null ? (item.volume / 100).toFixed(0) : '-'}手</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -314,7 +321,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
axisLabel: {
|
||||
color: '#999',
|
||||
fontSize: isMobile ? 10 : 12,
|
||||
formatter: (value: number) => value.toFixed(2),
|
||||
formatter: (value: number) => (value != null && !isNaN(value)) ? value.toFixed(2) : '-',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -333,6 +340,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
color: '#999',
|
||||
fontSize: isMobile ? 10 : 12,
|
||||
formatter: (value: number) => {
|
||||
if (value == null || isNaN(value)) return '-';
|
||||
if (value >= 10000) {
|
||||
return (value / 10000).toFixed(1) + '万';
|
||||
}
|
||||
|
||||
@@ -346,7 +346,173 @@ export const marketHandlers = [
|
||||
});
|
||||
}),
|
||||
|
||||
// 11. 市场统计数据(个股中心页面使用)
|
||||
// 11. 热点概览数据(大盘分时 + 概念异动)
|
||||
http.get('/api/market/hotspot-overview', async ({ request }) => {
|
||||
await delay(300);
|
||||
const url = new URL(request.url);
|
||||
const date = url.searchParams.get('date');
|
||||
|
||||
const tradeDate = date || new Date().toISOString().split('T')[0];
|
||||
|
||||
// 生成分时数据(240个点,9:30-11:30 + 13:00-15:00)
|
||||
const timeline = [];
|
||||
const basePrice = 3900 + Math.random() * 100; // 基准价格 3900-4000
|
||||
const prevClose = basePrice;
|
||||
let currentPrice = basePrice;
|
||||
let cumulativeVolume = 0;
|
||||
|
||||
// 上午时段 9:30-11:30 (120分钟)
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const hour = 9 + Math.floor((i + 30) / 60);
|
||||
const minute = (i + 30) % 60;
|
||||
const time = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||
|
||||
// 模拟价格波动
|
||||
const volatility = 0.002; // 0.2%波动
|
||||
const drift = (Math.random() - 0.5) * 0.001; // 微小趋势
|
||||
currentPrice = currentPrice * (1 + (Math.random() - 0.5) * volatility + drift);
|
||||
|
||||
const volume = Math.floor(Math.random() * 500000 + 100000); // 成交量
|
||||
cumulativeVolume += volume;
|
||||
|
||||
timeline.push({
|
||||
time,
|
||||
price: parseFloat(currentPrice.toFixed(2)),
|
||||
volume: cumulativeVolume,
|
||||
change_pct: parseFloat(((currentPrice - prevClose) / prevClose * 100).toFixed(2))
|
||||
});
|
||||
}
|
||||
|
||||
// 下午时段 13:00-15:00 (120分钟)
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const hour = 13 + Math.floor(i / 60);
|
||||
const minute = i % 60;
|
||||
const time = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||
|
||||
// 下午波动略小
|
||||
const volatility = 0.0015;
|
||||
const drift = (Math.random() - 0.5) * 0.0008;
|
||||
currentPrice = currentPrice * (1 + (Math.random() - 0.5) * volatility + drift);
|
||||
|
||||
const volume = Math.floor(Math.random() * 400000 + 80000);
|
||||
cumulativeVolume += volume;
|
||||
|
||||
timeline.push({
|
||||
time,
|
||||
price: parseFloat(currentPrice.toFixed(2)),
|
||||
volume: cumulativeVolume,
|
||||
change_pct: parseFloat(((currentPrice - prevClose) / prevClose * 100).toFixed(2))
|
||||
});
|
||||
}
|
||||
|
||||
// 生成概念异动数据
|
||||
const conceptNames = [
|
||||
'人工智能', 'AI眼镜', '机器人', '核电', '国企', '卫星导航',
|
||||
'福建自贸区', '两岸融合', 'CRO', '三季报增长', '百货零售',
|
||||
'人形机器人', '央企', '数据中心', 'CPO', '新能源', '电网设备',
|
||||
'氢能源', '算力租赁', '厦门国资', '乳业', '低空安防', '创新药',
|
||||
'商业航天', '控制权变更', '文化传媒', '海峡两岸'
|
||||
];
|
||||
|
||||
const alertTypes = ['surge_up', 'surge_down', 'volume_spike', 'limit_up', 'rank_jump'];
|
||||
|
||||
// 生成 15-25 个异动
|
||||
const alertCount = Math.floor(Math.random() * 10) + 15;
|
||||
const alerts = [];
|
||||
const usedTimes = new Set();
|
||||
|
||||
for (let i = 0; i < alertCount; i++) {
|
||||
// 随机选择一个时间点
|
||||
let timeIdx;
|
||||
let attempts = 0;
|
||||
do {
|
||||
timeIdx = Math.floor(Math.random() * timeline.length);
|
||||
attempts++;
|
||||
} while (usedTimes.has(timeIdx) && attempts < 50);
|
||||
|
||||
if (attempts >= 50) continue;
|
||||
|
||||
// 同一时间可以有多个异动
|
||||
const time = timeline[timeIdx].time;
|
||||
const conceptName = conceptNames[Math.floor(Math.random() * conceptNames.length)];
|
||||
const alertType = alertTypes[Math.floor(Math.random() * alertTypes.length)];
|
||||
|
||||
// 根据类型生成 alpha
|
||||
let alpha;
|
||||
if (alertType === 'surge_up') {
|
||||
alpha = parseFloat((Math.random() * 3 + 2).toFixed(2)); // +2% ~ +5%
|
||||
} else if (alertType === 'surge_down') {
|
||||
alpha = parseFloat((-Math.random() * 3 - 1.5).toFixed(2)); // -1.5% ~ -4.5%
|
||||
} else {
|
||||
alpha = parseFloat((Math.random() * 4 - 1).toFixed(2)); // -1% ~ +3%
|
||||
}
|
||||
|
||||
const finalScore = Math.floor(Math.random() * 40 + 45); // 45-85分
|
||||
const ruleScore = Math.floor(Math.random() * 30 + 40);
|
||||
const mlScore = Math.floor(Math.random() * 30 + 40);
|
||||
|
||||
alerts.push({
|
||||
concept_id: `CONCEPT_${1000 + i}`,
|
||||
concept_name: conceptName,
|
||||
time,
|
||||
alert_type: alertType,
|
||||
alpha,
|
||||
alpha_delta: parseFloat((Math.random() * 2 - 0.5).toFixed(2)),
|
||||
amt_ratio: parseFloat((Math.random() * 5 + 1).toFixed(2)),
|
||||
limit_up_count: alertType === 'limit_up' ? Math.floor(Math.random() * 5 + 1) : 0,
|
||||
limit_up_ratio: parseFloat((Math.random() * 0.3).toFixed(3)),
|
||||
final_score: finalScore,
|
||||
rule_score: ruleScore,
|
||||
ml_score: mlScore,
|
||||
trigger_reason: finalScore >= 65 ? '规则强信号' : (mlScore >= 70 ? 'ML强信号' : '融合触发'),
|
||||
importance_score: parseFloat((finalScore / 100).toFixed(2)),
|
||||
index_price: timeline[timeIdx].price
|
||||
});
|
||||
}
|
||||
|
||||
// 按时间排序
|
||||
alerts.sort((a, b) => a.time.localeCompare(b.time));
|
||||
|
||||
// 统计异动类型
|
||||
const alertSummary = alerts.reduce((acc, alert) => {
|
||||
acc[alert.alert_type] = (acc[alert.alert_type] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 计算指数统计
|
||||
const prices = timeline.map(t => t.price);
|
||||
const latestPrice = prices[prices.length - 1];
|
||||
const highPrice = Math.max(...prices);
|
||||
const lowPrice = Math.min(...prices);
|
||||
const changePct = ((latestPrice - prevClose) / prevClose * 100);
|
||||
|
||||
console.log('[Mock Market] 获取热点概览数据:', {
|
||||
date: tradeDate,
|
||||
timelinePoints: timeline.length,
|
||||
alertCount: alerts.length
|
||||
});
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
index: {
|
||||
code: '000001.SH',
|
||||
name: '上证指数',
|
||||
latest_price: latestPrice,
|
||||
prev_close: prevClose,
|
||||
high: highPrice,
|
||||
low: lowPrice,
|
||||
change_pct: parseFloat(changePct.toFixed(2)),
|
||||
timeline
|
||||
},
|
||||
alerts,
|
||||
alert_summary: alertSummary
|
||||
},
|
||||
trade_date: tradeDate
|
||||
});
|
||||
}),
|
||||
|
||||
// 12. 市场统计数据(个股中心页面使用)
|
||||
http.get('/api/market/statistics', async ({ request }) => {
|
||||
await delay(200);
|
||||
const url = new URL(request.url);
|
||||
|
||||
@@ -207,9 +207,12 @@ const CompactIndexCard = ({ indexCode, indexName }) => {
|
||||
const raw = chartData.rawData[idx];
|
||||
if (!raw) return '';
|
||||
|
||||
// 安全格式化数字
|
||||
const safeFixed = (val, digits = 2) => (val != null && !isNaN(val)) ? val.toFixed(digits) : '-';
|
||||
|
||||
// 计算涨跌
|
||||
const prevClose = raw.prev_close || (idx > 0 ? chartData.rawData[idx - 1]?.close : raw.open) || raw.open;
|
||||
const changeAmount = raw.close - prevClose;
|
||||
const changeAmount = (raw.close != null && prevClose != null) ? (raw.close - prevClose) : 0;
|
||||
const changePct = prevClose ? ((changeAmount / prevClose) * 100) : 0;
|
||||
const isUp = changeAmount >= 0;
|
||||
const color = isUp ? '#ef5350' : '#26a69a';
|
||||
@@ -218,22 +221,22 @@ const CompactIndexCard = ({ indexCode, indexName }) => {
|
||||
return `
|
||||
<div style="min-width: 180px;">
|
||||
<div style="font-weight: bold; color: #FFD700; margin-bottom: 10px; font-size: 13px; border-bottom: 1px solid rgba(255,215,0,0.2); padding-bottom: 8px;">
|
||||
📅 ${raw.time}
|
||||
📅 ${raw.time || '-'}
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; font-size: 12px;">
|
||||
<span style="color: #999;">开盘</span>
|
||||
<span style="text-align: right; font-family: monospace;">${raw.open.toFixed(2)}</span>
|
||||
<span style="text-align: right; font-family: monospace;">${safeFixed(raw.open)}</span>
|
||||
<span style="color: #999;">收盘</span>
|
||||
<span style="text-align: right; font-weight: bold; color: ${color}; font-family: monospace;">${raw.close.toFixed(2)}</span>
|
||||
<span style="text-align: right; font-weight: bold; color: ${color}; font-family: monospace;">${safeFixed(raw.close)}</span>
|
||||
<span style="color: #999;">最高</span>
|
||||
<span style="text-align: right; color: #ef5350; font-family: monospace;">${raw.high.toFixed(2)}</span>
|
||||
<span style="text-align: right; color: #ef5350; font-family: monospace;">${safeFixed(raw.high)}</span>
|
||||
<span style="color: #999;">最低</span>
|
||||
<span style="text-align: right; color: #26a69a; font-family: monospace;">${raw.low.toFixed(2)}</span>
|
||||
<span style="text-align: right; color: #26a69a; font-family: monospace;">${safeFixed(raw.low)}</span>
|
||||
</div>
|
||||
<div style="margin-top: 10px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.1); display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="color: #999; font-size: 11px;">涨跌幅</span>
|
||||
<span style="color: ${color}; font-weight: bold; font-size: 14px; font-family: monospace;">
|
||||
${sign}${changeAmount.toFixed(2)} (${sign}${changePct.toFixed(2)}%)
|
||||
${sign}${safeFixed(changeAmount)} (${sign}${safeFixed(changePct)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -529,7 +532,7 @@ const FlowingConcepts = () => {
|
||||
color={colors.text}
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{concept.change_pct > 0 ? '+' : ''}{concept.change_pct.toFixed(2)}%
|
||||
{concept.change_pct > 0 ? '+' : ''}{concept.change_pct?.toFixed(2) ?? '-'}%
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 迷你分时图组件
|
||||
* 用于灵活屏中显示证券的日内走势
|
||||
*/
|
||||
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { Box, Spinner, Center, Text } from '@chakra-ui/react';
|
||||
import * as echarts from 'echarts';
|
||||
import type { ECharts, EChartsOption } from 'echarts';
|
||||
|
||||
import type { MiniTimelineChartProps, TimelineDataPoint } from '../types';
|
||||
|
||||
/**
|
||||
* 生成交易时间刻度(用于 X 轴)
|
||||
* A股交易时间:9:30-11:30, 13:00-15:00
|
||||
*/
|
||||
const generateTimeTicks = (): string[] => {
|
||||
const ticks: string[] = [];
|
||||
// 上午
|
||||
for (let h = 9; h <= 11; h++) {
|
||||
for (let m = h === 9 ? 30 : 0; m < 60; m++) {
|
||||
if (h === 11 && m > 30) break;
|
||||
ticks.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
// 下午
|
||||
for (let h = 13; h <= 15; h++) {
|
||||
for (let m = 0; m < 60; m++) {
|
||||
if (h === 15 && m > 0) break;
|
||||
ticks.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
return ticks;
|
||||
};
|
||||
|
||||
const TIME_TICKS = generateTimeTicks();
|
||||
|
||||
/** API 返回的分钟数据结构 */
|
||||
interface MinuteKLineItem {
|
||||
time?: string;
|
||||
timestamp?: string;
|
||||
close?: number;
|
||||
price?: number;
|
||||
}
|
||||
|
||||
/** API 响应结构 */
|
||||
interface KLineApiResponse {
|
||||
success?: boolean;
|
||||
data?: MinuteKLineItem[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MiniTimelineChart 组件
|
||||
*/
|
||||
const MiniTimelineChart: React.FC<MiniTimelineChartProps> = ({
|
||||
code,
|
||||
isIndex = false,
|
||||
prevClose,
|
||||
currentPrice,
|
||||
height = 120,
|
||||
}) => {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const chartInstance = useRef<ECharts | null>(null);
|
||||
const [timelineData, setTimelineData] = useState<TimelineDataPoint[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 获取分钟数据
|
||||
useEffect(() => {
|
||||
if (!code) return;
|
||||
|
||||
const fetchData = async (): Promise<void> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const apiPath = isIndex
|
||||
? `/api/index/${code}/kline?type=minute`
|
||||
: `/api/stock/${code}/kline?type=minute`;
|
||||
|
||||
const response = await fetch(apiPath);
|
||||
const result: KLineApiResponse = await response.json();
|
||||
|
||||
if (result.success !== false && result.data) {
|
||||
// 格式化数据
|
||||
const formatted: TimelineDataPoint[] = result.data.map(item => ({
|
||||
time: item.time || item.timestamp || '',
|
||||
price: item.close || item.price || 0,
|
||||
}));
|
||||
setTimelineData(formatted);
|
||||
} else {
|
||||
setError(result.error || '暂无数据');
|
||||
}
|
||||
} catch (e) {
|
||||
setError('加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
|
||||
// 交易时间内每分钟刷新
|
||||
const now = new Date();
|
||||
const hours = now.getHours();
|
||||
const minutes = now.getMinutes();
|
||||
const currentMinutes = hours * 60 + minutes;
|
||||
const isTrading =
|
||||
(currentMinutes >= 570 && currentMinutes <= 690) ||
|
||||
(currentMinutes >= 780 && currentMinutes <= 900);
|
||||
|
||||
let intervalId: NodeJS.Timeout | undefined;
|
||||
if (isTrading) {
|
||||
intervalId = setInterval(fetchData, 60000); // 1分钟刷新
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
};
|
||||
}, [code, isIndex]);
|
||||
|
||||
// 合并实时价格到数据中
|
||||
const chartData = useMemo((): TimelineDataPoint[] => {
|
||||
if (!timelineData.length) return [];
|
||||
|
||||
const data = [...timelineData];
|
||||
|
||||
// 如果有实时价格,添加到最新点
|
||||
if (currentPrice && data.length > 0) {
|
||||
const now = new Date();
|
||||
const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
const lastItem = data[data.length - 1];
|
||||
|
||||
// 如果实时价格的时间比最后一条数据新,添加新点
|
||||
if (lastItem.time !== timeStr) {
|
||||
data.push({ time: timeStr, price: currentPrice });
|
||||
} else {
|
||||
// 更新最后一条
|
||||
data[data.length - 1] = { ...lastItem, price: currentPrice };
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}, [timelineData, currentPrice]);
|
||||
|
||||
// 渲染图表
|
||||
useEffect(() => {
|
||||
if (!chartRef.current || loading || !chartData.length) return;
|
||||
|
||||
if (!chartInstance.current) {
|
||||
chartInstance.current = echarts.init(chartRef.current);
|
||||
}
|
||||
|
||||
const baseLine = prevClose || chartData[0]?.price || 0;
|
||||
|
||||
// 计算价格范围
|
||||
const prices = chartData.map(d => d.price).filter(p => p > 0);
|
||||
const minPrice = Math.min(...prices, baseLine);
|
||||
const maxPrice = Math.max(...prices, baseLine);
|
||||
const range = Math.max(maxPrice - baseLine, baseLine - minPrice) * 1.1;
|
||||
|
||||
// 准备数据
|
||||
const times = chartData.map(d => d.time);
|
||||
const values = chartData.map(d => d.price);
|
||||
|
||||
// 判断涨跌
|
||||
const lastPrice = values[values.length - 1] || baseLine;
|
||||
const isUp = lastPrice >= baseLine;
|
||||
|
||||
const option: EChartsOption = {
|
||||
grid: {
|
||||
top: 5,
|
||||
right: 5,
|
||||
bottom: 5,
|
||||
left: 5,
|
||||
containLabel: false,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: times,
|
||||
show: false,
|
||||
boundaryGap: false,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: baseLine - range,
|
||||
max: baseLine + range,
|
||||
show: false,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
data: values,
|
||||
smooth: false,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
width: 1.5,
|
||||
color: isUp ? '#ef4444' : '#22c55e',
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: isUp ? 'rgba(239, 68, 68, 0.3)' : 'rgba(34, 197, 94, 0.3)' },
|
||||
{ offset: 1, color: isUp ? 'rgba(239, 68, 68, 0.05)' : 'rgba(34, 197, 94, 0.05)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
data: [
|
||||
{
|
||||
yAxis: baseLine,
|
||||
lineStyle: {
|
||||
color: '#666',
|
||||
type: 'dashed',
|
||||
width: 1,
|
||||
},
|
||||
label: { show: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
animation: false,
|
||||
};
|
||||
|
||||
chartInstance.current.setOption(option);
|
||||
|
||||
return () => {
|
||||
// 不在这里销毁,只在组件卸载时销毁
|
||||
};
|
||||
}, [chartData, prevClose, loading]);
|
||||
|
||||
// 组件卸载时销毁图表
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.dispose();
|
||||
chartInstance.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 窗口 resize 处理
|
||||
useEffect(() => {
|
||||
const handleResize = (): void => {
|
||||
chartInstance.current?.resize();
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Center h={height}>
|
||||
<Spinner size="sm" color="gray.400" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !chartData.length) {
|
||||
return (
|
||||
<Center h={height}>
|
||||
<Text fontSize="xs" color="gray.400">
|
||||
{error || '暂无数据'}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return <Box ref={chartRef} h={`${height}px`} w="100%" />;
|
||||
};
|
||||
|
||||
export default MiniTimelineChart;
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* 盘口行情面板组件
|
||||
* 支持显示 5 档或 10 档买卖盘数据
|
||||
*
|
||||
* 上交所: 5 档行情
|
||||
* 深交所: 10 档行情
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
useColorModeValue,
|
||||
Tooltip,
|
||||
Badge,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import type { OrderBookPanelProps } from '../types';
|
||||
|
||||
/** 格式化价格返回值 */
|
||||
interface FormattedPrice {
|
||||
text: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化成交量
|
||||
*/
|
||||
const formatVolume = (volume: number): string => {
|
||||
if (!volume || volume === 0) return '-';
|
||||
if (volume >= 10000) {
|
||||
return `${(volume / 10000).toFixed(0)}万`;
|
||||
}
|
||||
if (volume >= 1000) {
|
||||
return `${(volume / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return String(volume);
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化价格
|
||||
*/
|
||||
const formatPrice = (price: number, prevClose?: number): FormattedPrice => {
|
||||
if (!price || price === 0) {
|
||||
return { text: '-', color: 'gray.400' };
|
||||
}
|
||||
|
||||
const text = price.toFixed(2);
|
||||
|
||||
if (!prevClose || prevClose === 0) {
|
||||
return { text, color: 'gray.600' };
|
||||
}
|
||||
|
||||
if (price > prevClose) {
|
||||
return { text, color: 'red.500' };
|
||||
}
|
||||
if (price < prevClose) {
|
||||
return { text, color: 'green.500' };
|
||||
}
|
||||
return { text, color: 'gray.600' };
|
||||
};
|
||||
|
||||
/** OrderRow 组件 Props */
|
||||
interface OrderRowProps {
|
||||
label: string;
|
||||
price: number;
|
||||
volume: number;
|
||||
prevClose?: number;
|
||||
isBid: boolean;
|
||||
maxVolume: number;
|
||||
isLimitPrice: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单行盘口
|
||||
*/
|
||||
const OrderRow: React.FC<OrderRowProps> = ({
|
||||
label,
|
||||
price,
|
||||
volume,
|
||||
prevClose,
|
||||
isBid,
|
||||
maxVolume,
|
||||
isLimitPrice,
|
||||
}) => {
|
||||
const bgColor = useColorModeValue(
|
||||
isBid ? 'red.50' : 'green.50',
|
||||
isBid ? 'rgba(239, 68, 68, 0.1)' : 'rgba(34, 197, 94, 0.1)'
|
||||
);
|
||||
const barColor = useColorModeValue(
|
||||
isBid ? 'red.200' : 'green.200',
|
||||
isBid ? 'rgba(239, 68, 68, 0.3)' : 'rgba(34, 197, 94, 0.3)'
|
||||
);
|
||||
const limitColor = useColorModeValue('orange.500', 'orange.300');
|
||||
|
||||
const priceInfo = formatPrice(price, prevClose);
|
||||
const volumeText = formatVolume(volume);
|
||||
|
||||
// 计算成交量条宽度
|
||||
const barWidth = maxVolume > 0 ? Math.min((volume / maxVolume) * 100, 100) : 0;
|
||||
|
||||
return (
|
||||
<HStack
|
||||
spacing={2}
|
||||
py={0.5}
|
||||
px={1}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
fontSize="xs"
|
||||
>
|
||||
{/* 成交量条 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
right={0}
|
||||
top={0}
|
||||
bottom={0}
|
||||
width={`${barWidth}%`}
|
||||
bg={barColor}
|
||||
transition="width 0.2s"
|
||||
/>
|
||||
|
||||
{/* 内容 */}
|
||||
<Text color="gray.500" w="24px" flexShrink={0} zIndex={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<HStack flex={1} justify="flex-end" zIndex={1}>
|
||||
<Text color={isLimitPrice ? limitColor : priceInfo.color} fontWeight="medium">
|
||||
{priceInfo.text}
|
||||
</Text>
|
||||
{isLimitPrice && (
|
||||
<Tooltip label={isBid ? '跌停价' : '涨停价'}>
|
||||
<Badge
|
||||
colorScheme={isBid ? 'green' : 'red'}
|
||||
fontSize="2xs"
|
||||
variant="subtle"
|
||||
>
|
||||
{isBid ? '跌' : '涨'}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
<Text color="gray.600" w="40px" textAlign="right" zIndex={1}>
|
||||
{volumeText}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* OrderBookPanel 组件
|
||||
*/
|
||||
const OrderBookPanel: React.FC<OrderBookPanelProps> = ({
|
||||
bidPrices = [],
|
||||
bidVolumes = [],
|
||||
askPrices = [],
|
||||
askVolumes = [],
|
||||
prevClose,
|
||||
upperLimit,
|
||||
lowerLimit,
|
||||
defaultLevels = 5,
|
||||
}) => {
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const buttonBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const bgColor = useColorModeValue('white', '#1a1a1a');
|
||||
|
||||
// 可切换显示的档位数
|
||||
const maxAvailableLevels = Math.max(bidPrices.length, askPrices.length, 1);
|
||||
const [showLevels, setShowLevels] = useState(Math.min(defaultLevels, maxAvailableLevels));
|
||||
|
||||
// 计算最大成交量(用于条形图比例)
|
||||
const displayBidVolumes = bidVolumes.slice(0, showLevels);
|
||||
const displayAskVolumes = askVolumes.slice(0, showLevels);
|
||||
const allVolumes = [...displayBidVolumes, ...displayAskVolumes].filter(v => v > 0);
|
||||
const maxVolume = allVolumes.length > 0 ? Math.max(...allVolumes) : 0;
|
||||
|
||||
// 判断是否为涨跌停价
|
||||
const isUpperLimit = (price: number): boolean =>
|
||||
!!upperLimit && Math.abs(price - upperLimit) < 0.001;
|
||||
const isLowerLimit = (price: number): boolean =>
|
||||
!!lowerLimit && Math.abs(price - lowerLimit) < 0.001;
|
||||
|
||||
// 卖盘(从卖N到卖1,即价格从高到低)
|
||||
const askRows: React.ReactNode[] = [];
|
||||
for (let i = showLevels - 1; i >= 0; i--) {
|
||||
askRows.push(
|
||||
<OrderRow
|
||||
key={`ask${i + 1}`}
|
||||
label={`卖${i + 1}`}
|
||||
price={askPrices[i]}
|
||||
volume={askVolumes[i]}
|
||||
prevClose={prevClose}
|
||||
isBid={false}
|
||||
maxVolume={maxVolume}
|
||||
isLimitPrice={isUpperLimit(askPrices[i])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 买盘(从买1到买N,即价格从高到低)
|
||||
const bidRows: React.ReactNode[] = [];
|
||||
for (let i = 0; i < showLevels; i++) {
|
||||
bidRows.push(
|
||||
<OrderRow
|
||||
key={`bid${i + 1}`}
|
||||
label={`买${i + 1}`}
|
||||
price={bidPrices[i]}
|
||||
volume={bidVolumes[i]}
|
||||
prevClose={prevClose}
|
||||
isBid={true}
|
||||
maxVolume={maxVolume}
|
||||
isLimitPrice={isLowerLimit(bidPrices[i])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 没有数据时的提示
|
||||
const hasData = bidPrices.length > 0 || askPrices.length > 0;
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
<Box textAlign="center" py={2}>
|
||||
<Text fontSize="xs" color="gray.400">
|
||||
暂无盘口数据
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={0} align="stretch">
|
||||
{/* 档位切换(只有当有超过5档数据时才显示) */}
|
||||
{maxAvailableLevels > 5 && (
|
||||
<HStack justify="flex-end" mb={1}>
|
||||
<ButtonGroup size="xs" isAttached variant="outline">
|
||||
<Button
|
||||
onClick={() => setShowLevels(5)}
|
||||
bg={showLevels === 5 ? buttonBg : 'transparent'}
|
||||
fontWeight={showLevels === 5 ? 'bold' : 'normal'}
|
||||
>
|
||||
5档
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowLevels(10)}
|
||||
bg={showLevels === 10 ? buttonBg : 'transparent'}
|
||||
fontWeight={showLevels === 10 ? 'bold' : 'normal'}
|
||||
>
|
||||
10档
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 卖盘 */}
|
||||
{askRows}
|
||||
|
||||
{/* 分隔线 + 当前价信息 */}
|
||||
<Box h="1px" bg={borderColor} my={1} position="relative">
|
||||
{prevClose && (
|
||||
<Text
|
||||
position="absolute"
|
||||
right={0}
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
fontSize="2xs"
|
||||
color="gray.400"
|
||||
bg={bgColor}
|
||||
px={1}
|
||||
>
|
||||
昨收 {prevClose.toFixed(2)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 买盘 */}
|
||||
{bidRows}
|
||||
|
||||
{/* 涨跌停价信息 */}
|
||||
{(upperLimit || lowerLimit) && (
|
||||
<HStack justify="space-between" mt={1} fontSize="2xs" color="gray.400">
|
||||
{lowerLimit && <Text>跌停 {lowerLimit.toFixed(2)}</Text>}
|
||||
{upperLimit && <Text>涨停 {upperLimit.toFixed(2)}</Text>}
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderBookPanel;
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* 行情瓷砖组件
|
||||
* 单个证券的实时行情展示卡片,包含分时图和五档盘口
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
Collapse,
|
||||
Badge,
|
||||
} from '@chakra-ui/react';
|
||||
import { CloseIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import MiniTimelineChart from './MiniTimelineChart';
|
||||
import OrderBookPanel from './OrderBookPanel';
|
||||
import type { QuoteTileProps, QuoteData } from '../types';
|
||||
|
||||
/**
|
||||
* 格式化价格显示
|
||||
*/
|
||||
const formatPrice = (price?: number): string => {
|
||||
if (!price || isNaN(price)) return '-';
|
||||
return price.toFixed(2);
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化涨跌幅
|
||||
*/
|
||||
const formatChangePct = (pct?: number): string => {
|
||||
if (!pct || isNaN(pct)) return '0.00%';
|
||||
const sign = pct > 0 ? '+' : '';
|
||||
return `${sign}${pct.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化涨跌额
|
||||
*/
|
||||
const formatChange = (change?: number): string => {
|
||||
if (!change || isNaN(change)) return '-';
|
||||
const sign = change > 0 ? '+' : '';
|
||||
return `${sign}${change.toFixed(2)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化成交额
|
||||
*/
|
||||
const formatAmount = (amount?: number): string => {
|
||||
if (!amount || isNaN(amount)) return '-';
|
||||
if (amount >= 100000000) {
|
||||
return `${(amount / 100000000).toFixed(2)}亿`;
|
||||
}
|
||||
if (amount >= 10000) {
|
||||
return `${(amount / 10000).toFixed(0)}万`;
|
||||
}
|
||||
return amount.toFixed(0);
|
||||
};
|
||||
|
||||
/**
|
||||
* QuoteTile 组件
|
||||
*/
|
||||
const QuoteTile: React.FC<QuoteTileProps> = ({
|
||||
code,
|
||||
name,
|
||||
quote = {},
|
||||
isIndex = false,
|
||||
onRemove,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
// 类型断言,确保类型安全
|
||||
const quoteData = quote as Partial<QuoteData>;
|
||||
|
||||
// 颜色主题
|
||||
const cardBg = useColorModeValue('white', '#1a1a1a');
|
||||
const borderColor = useColorModeValue('gray.200', '#333');
|
||||
const hoverBorderColor = useColorModeValue('purple.300', '#666');
|
||||
const textColor = useColorModeValue('gray.800', 'white');
|
||||
const subTextColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
// 涨跌色
|
||||
const { price, prevClose, change, changePct, amount } = quoteData;
|
||||
const priceColor = useColorModeValue(
|
||||
!prevClose || price === prevClose
|
||||
? 'gray.800'
|
||||
: price && price > prevClose
|
||||
? 'red.500'
|
||||
: 'green.500',
|
||||
!prevClose || price === prevClose
|
||||
? 'gray.200'
|
||||
: price && price > prevClose
|
||||
? 'red.400'
|
||||
: 'green.400'
|
||||
);
|
||||
|
||||
// 涨跌幅背景色
|
||||
const changeBgColor = useColorModeValue(
|
||||
!changePct || changePct === 0
|
||||
? 'gray.100'
|
||||
: changePct > 0
|
||||
? 'red.100'
|
||||
: 'green.100',
|
||||
!changePct || changePct === 0
|
||||
? 'gray.700'
|
||||
: changePct > 0
|
||||
? 'rgba(239, 68, 68, 0.2)'
|
||||
: 'rgba(34, 197, 94, 0.2)'
|
||||
);
|
||||
|
||||
// 跳转到详情页
|
||||
const handleNavigate = (): void => {
|
||||
if (isIndex) {
|
||||
// 指数暂无详情页
|
||||
return;
|
||||
}
|
||||
navigate(`/company?scode=${code}`);
|
||||
};
|
||||
|
||||
// 获取盘口数据(带类型安全)
|
||||
const bidPrices = 'bidPrices' in quoteData ? (quoteData.bidPrices as number[]) : [];
|
||||
const bidVolumes = 'bidVolumes' in quoteData ? (quoteData.bidVolumes as number[]) : [];
|
||||
const askPrices = 'askPrices' in quoteData ? (quoteData.askPrices as number[]) : [];
|
||||
const askVolumes = 'askVolumes' in quoteData ? (quoteData.askVolumes as number[]) : [];
|
||||
const upperLimit = 'upperLimit' in quoteData ? (quoteData.upperLimit as number | undefined) : undefined;
|
||||
const lowerLimit = 'lowerLimit' in quoteData ? (quoteData.lowerLimit as number | undefined) : undefined;
|
||||
const openPrice = 'open' in quoteData ? (quoteData.open as number | undefined) : undefined;
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
borderColor: hoverBorderColor,
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<HStack
|
||||
px={3}
|
||||
py={2}
|
||||
borderBottomWidth={expanded ? '1px' : '0'}
|
||||
borderColor={borderColor}
|
||||
cursor="pointer"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{/* 名称和代码 */}
|
||||
<VStack align="start" spacing={0} flex={1} minW={0}>
|
||||
<HStack spacing={2}>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
fontSize="sm"
|
||||
color={textColor}
|
||||
noOfLines={1}
|
||||
cursor="pointer"
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleNavigate();
|
||||
}}
|
||||
>
|
||||
{name || code}
|
||||
</Text>
|
||||
{isIndex && (
|
||||
<Badge colorScheme="purple" fontSize="xs">
|
||||
指数
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<Text fontSize="xs" color={subTextColor}>
|
||||
{code}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* 价格信息 */}
|
||||
<VStack align="end" spacing={0}>
|
||||
<Text fontWeight="bold" fontSize="lg" color={priceColor}>
|
||||
{formatPrice(price)}
|
||||
</Text>
|
||||
<HStack spacing={1}>
|
||||
<Box
|
||||
px={1.5}
|
||||
py={0.5}
|
||||
bg={changeBgColor}
|
||||
borderRadius="sm"
|
||||
fontSize="xs"
|
||||
fontWeight="medium"
|
||||
color={priceColor}
|
||||
>
|
||||
{formatChangePct(changePct)}
|
||||
</Box>
|
||||
<Text fontSize="xs" color={priceColor}>
|
||||
{formatChange(change)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<HStack spacing={1} ml={2}>
|
||||
<IconButton
|
||||
icon={expanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
aria-label={expanded ? '收起' : '展开'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
/>
|
||||
<Tooltip label="移除">
|
||||
<IconButton
|
||||
icon={<CloseIcon />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
aria-label="移除"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove?.(code);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 可折叠内容 */}
|
||||
<Collapse in={expanded} animateOpacity>
|
||||
<Box p={3}>
|
||||
{/* 统计信息 */}
|
||||
<HStack spacing={4} mb={3} fontSize="xs" color={subTextColor}>
|
||||
<HStack>
|
||||
<Text>昨收:</Text>
|
||||
<Text color={textColor}>{formatPrice(prevClose)}</Text>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Text>今开:</Text>
|
||||
<Text color={textColor}>{formatPrice(openPrice)}</Text>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Text>成交额:</Text>
|
||||
<Text color={textColor}>{formatAmount(amount)}</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 分时图 */}
|
||||
<Box mb={3}>
|
||||
<MiniTimelineChart
|
||||
code={code}
|
||||
isIndex={isIndex}
|
||||
prevClose={prevClose}
|
||||
currentPrice={price}
|
||||
height={100}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 盘口(指数没有盘口) */}
|
||||
{!isIndex && (
|
||||
<Box>
|
||||
<Text fontSize="xs" color={subTextColor} mb={1}>
|
||||
盘口 {bidPrices.length > 5 ? '(10档)' : '(5档)'}
|
||||
</Text>
|
||||
<OrderBookPanel
|
||||
bidPrices={bidPrices}
|
||||
bidVolumes={bidVolumes}
|
||||
askPrices={askPrices}
|
||||
askVolumes={askVolumes}
|
||||
prevClose={prevClose}
|
||||
upperLimit={upperLimit}
|
||||
lowerLimit={lowerLimit}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuoteTile;
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 组件导出文件
|
||||
*/
|
||||
|
||||
export { default as MiniTimelineChart } from './MiniTimelineChart';
|
||||
export { default as OrderBookPanel } from './OrderBookPanel';
|
||||
export { default as QuoteTile } from './QuoteTile';
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* WebSocket 配置常量
|
||||
*/
|
||||
|
||||
import type { Exchange } from '../types';
|
||||
|
||||
/**
|
||||
* 获取 WebSocket 配置
|
||||
* - 生产环境 (HTTPS): 通过 Nginx 代理使用 wss://
|
||||
* - 开发环境 (HTTP): 直连 ws://
|
||||
*/
|
||||
const getWsConfig = (): Record<Exchange, string> => {
|
||||
// 服务端渲染或测试环境使用默认配置
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
SSE: 'ws://49.232.185.254:8765',
|
||||
SZSE: 'ws://222.128.1.157:8765',
|
||||
};
|
||||
}
|
||||
|
||||
const isHttps = window.location.protocol === 'https:';
|
||||
const host = window.location.host;
|
||||
|
||||
if (isHttps) {
|
||||
// 生产环境:通过 Nginx 代理
|
||||
return {
|
||||
SSE: `wss://${host}/ws/sse`, // 上交所 - Nginx 代理
|
||||
SZSE: `wss://${host}/ws/szse`, // 深交所 - Nginx 代理
|
||||
};
|
||||
}
|
||||
|
||||
// 开发环境:直连
|
||||
return {
|
||||
SSE: 'ws://49.232.185.254:8765', // 上交所
|
||||
SZSE: 'ws://222.128.1.157:8765', // 深交所
|
||||
};
|
||||
};
|
||||
|
||||
/** WebSocket 服务地址 */
|
||||
export const WS_CONFIG: Record<Exchange, string> = getWsConfig();
|
||||
|
||||
/** 心跳间隔 (ms) */
|
||||
export const HEARTBEAT_INTERVAL = 30000;
|
||||
|
||||
/** 重连间隔 (ms) */
|
||||
export const RECONNECT_INTERVAL = 3000;
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Hooks 导出文件
|
||||
*/
|
||||
|
||||
export { useRealtimeQuote } from './useRealtimeQuote';
|
||||
export * from './constants';
|
||||
export * from './utils';
|
||||
@@ -0,0 +1,722 @@
|
||||
/**
|
||||
* 实时行情 Hook
|
||||
* 管理上交所和深交所 WebSocket 连接,获取实时行情数据
|
||||
*
|
||||
* 连接方式:
|
||||
* - 生产环境 (HTTPS): 通过 Nginx 代理使用 wss:// (如 wss://valuefrontier.cn/ws/sse)
|
||||
* - 开发环境 (HTTP): 直连 ws://
|
||||
*
|
||||
* 上交所 (SSE): 需主动订阅,提供五档行情
|
||||
* 深交所 (SZSE): 自动推送,提供十档行情
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { logger } from '@utils/logger';
|
||||
import { WS_CONFIG, HEARTBEAT_INTERVAL, RECONNECT_INTERVAL } from './constants';
|
||||
import { getExchange, normalizeCode, extractOrderBook, calcChangePct } from './utils';
|
||||
import type {
|
||||
Exchange,
|
||||
ConnectionStatus,
|
||||
QuotesMap,
|
||||
QuoteData,
|
||||
SSEMessage,
|
||||
SSEQuoteItem,
|
||||
SZSEMessage,
|
||||
SZSERealtimeMessage,
|
||||
SZSESnapshotMessage,
|
||||
SZSEStockData,
|
||||
SZSEIndexData,
|
||||
SZSEBondData,
|
||||
SZSEHKStockData,
|
||||
SZSEAfterhoursData,
|
||||
UseRealtimeQuoteReturn,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* 处理上交所消息
|
||||
* 注意:上交所返回的 code 不带后缀,但通过 msg.type 区分 'stock' 和 'index'
|
||||
* 存储时使用带后缀的完整代码作为 key(如 000001.SH)
|
||||
*/
|
||||
const handleSSEMessage = (
|
||||
msg: SSEMessage,
|
||||
subscribedCodes: Set<string>,
|
||||
prevQuotes: QuotesMap
|
||||
): QuotesMap | null => {
|
||||
if (msg.type !== 'stock' && msg.type !== 'index') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = msg.data || {};
|
||||
const updated: QuotesMap = { ...prevQuotes };
|
||||
let hasUpdate = false;
|
||||
const isIndex = msg.type === 'index';
|
||||
|
||||
Object.entries(data).forEach(([code, quote]: [string, SSEQuoteItem]) => {
|
||||
// 生成带后缀的完整代码(上交所统一用 .SH)
|
||||
const fullCode = code.includes('.') ? code : `${code}.SH`;
|
||||
|
||||
if (subscribedCodes.has(code) || subscribedCodes.has(fullCode)) {
|
||||
hasUpdate = true;
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
name: quote.security_name,
|
||||
price: quote.last_price,
|
||||
prevClose: quote.prev_close,
|
||||
open: quote.open_price,
|
||||
high: quote.high_price,
|
||||
low: quote.low_price,
|
||||
volume: quote.volume,
|
||||
amount: quote.amount,
|
||||
change: quote.last_price - quote.prev_close,
|
||||
changePct: calcChangePct(quote.last_price, quote.prev_close),
|
||||
bidPrices: quote.bid_prices || [],
|
||||
bidVolumes: quote.bid_volumes || [],
|
||||
askPrices: quote.ask_prices || [],
|
||||
askVolumes: quote.ask_volumes || [],
|
||||
updateTime: quote.trade_time,
|
||||
exchange: 'SSE',
|
||||
} as QuoteData;
|
||||
}
|
||||
});
|
||||
|
||||
return hasUpdate ? updated : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理深交所实时消息
|
||||
* 注意:深交所返回的 security_id 可能带后缀也可能不带
|
||||
* 存储时统一使用带后缀的完整代码作为 key(如 000001.SZ)
|
||||
*/
|
||||
const handleSZSERealtimeMessage = (
|
||||
msg: SZSERealtimeMessage,
|
||||
subscribedCodes: Set<string>,
|
||||
prevQuotes: QuotesMap
|
||||
): QuotesMap | null => {
|
||||
const { category, data, timestamp } = msg;
|
||||
const rawCode = data.security_id;
|
||||
// 生成带后缀的完整代码(深交所统一用 .SZ)
|
||||
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
||||
|
||||
if (!subscribedCodes.has(rawCode) && !subscribedCodes.has(fullCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updated: QuotesMap = { ...prevQuotes };
|
||||
|
||||
switch (category) {
|
||||
case 'stock': {
|
||||
const stockData = data as SZSEStockData;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rawData = data as any; // 用于检查替代字段名
|
||||
|
||||
// 调试日志:检查深交所返回的盘口原始数据(临时使用 warn 级别方便调试)
|
||||
if (!stockData.bids || stockData.bids.length === 0) {
|
||||
logger.warn('FlexScreen', `SZSE股票数据无盘口 ${fullCode}`, {
|
||||
hasBids: !!stockData.bids,
|
||||
hasAsks: !!stockData.asks,
|
||||
bidsLength: stockData.bids?.length || 0,
|
||||
asksLength: stockData.asks?.length || 0,
|
||||
// 检查替代字段名
|
||||
hasBidPrices: !!rawData.bid_prices,
|
||||
hasAskPrices: !!rawData.ask_prices,
|
||||
dataKeys: Object.keys(stockData), // 查看服务端实际返回了哪些字段
|
||||
});
|
||||
}
|
||||
|
||||
// 优先使用 bids/asks 对象数组格式,如果不存在则尝试 bid_prices/ask_prices 分离数组格式
|
||||
let bidPrices: number[] = [];
|
||||
let bidVolumes: number[] = [];
|
||||
let askPrices: number[] = [];
|
||||
let askVolumes: number[] = [];
|
||||
|
||||
if (stockData.bids && stockData.bids.length > 0) {
|
||||
const extracted = extractOrderBook(stockData.bids);
|
||||
bidPrices = extracted.prices;
|
||||
bidVolumes = extracted.volumes;
|
||||
} else if (rawData.bid_prices && Array.isArray(rawData.bid_prices)) {
|
||||
// 替代格式:bid_prices 和 bid_volumes 分离
|
||||
bidPrices = rawData.bid_prices;
|
||||
bidVolumes = rawData.bid_volumes || [];
|
||||
}
|
||||
|
||||
if (stockData.asks && stockData.asks.length > 0) {
|
||||
const extracted = extractOrderBook(stockData.asks);
|
||||
askPrices = extracted.prices;
|
||||
askVolumes = extracted.volumes;
|
||||
} else if (rawData.ask_prices && Array.isArray(rawData.ask_prices)) {
|
||||
// 替代格式:ask_prices 和 ask_volumes 分离
|
||||
askPrices = rawData.ask_prices;
|
||||
askVolumes = rawData.ask_volumes || [];
|
||||
}
|
||||
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
name: prevQuotes[fullCode]?.name || '',
|
||||
price: stockData.last_px,
|
||||
prevClose: stockData.prev_close,
|
||||
open: stockData.open_px,
|
||||
high: stockData.high_px,
|
||||
low: stockData.low_px,
|
||||
volume: stockData.volume,
|
||||
amount: stockData.amount,
|
||||
numTrades: stockData.num_trades,
|
||||
upperLimit: stockData.upper_limit,
|
||||
lowerLimit: stockData.lower_limit,
|
||||
change: stockData.last_px - stockData.prev_close,
|
||||
changePct: calcChangePct(stockData.last_px, stockData.prev_close),
|
||||
bidPrices,
|
||||
bidVolumes,
|
||||
askPrices,
|
||||
askVolumes,
|
||||
tradingPhase: stockData.trading_phase,
|
||||
updateTime: timestamp,
|
||||
exchange: 'SZSE',
|
||||
} as QuoteData;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'index': {
|
||||
const indexData = data as SZSEIndexData;
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
name: prevQuotes[fullCode]?.name || '',
|
||||
price: indexData.current_index,
|
||||
prevClose: indexData.prev_close,
|
||||
open: indexData.open_index,
|
||||
high: indexData.high_index,
|
||||
low: indexData.low_index,
|
||||
close: indexData.close_index,
|
||||
volume: indexData.volume,
|
||||
amount: indexData.amount,
|
||||
numTrades: indexData.num_trades,
|
||||
change: indexData.current_index - indexData.prev_close,
|
||||
changePct: calcChangePct(indexData.current_index, indexData.prev_close),
|
||||
bidPrices: [],
|
||||
bidVolumes: [],
|
||||
askPrices: [],
|
||||
askVolumes: [],
|
||||
tradingPhase: indexData.trading_phase,
|
||||
updateTime: timestamp,
|
||||
exchange: 'SZSE',
|
||||
} as QuoteData;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'bond': {
|
||||
const bondData = data as SZSEBondData;
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
name: prevQuotes[fullCode]?.name || '',
|
||||
price: bondData.last_px,
|
||||
prevClose: bondData.prev_close,
|
||||
open: bondData.open_px,
|
||||
high: bondData.high_px,
|
||||
low: bondData.low_px,
|
||||
volume: bondData.volume,
|
||||
amount: bondData.amount,
|
||||
numTrades: bondData.num_trades,
|
||||
weightedAvgPx: bondData.weighted_avg_px,
|
||||
change: bondData.last_px - bondData.prev_close,
|
||||
changePct: calcChangePct(bondData.last_px, bondData.prev_close),
|
||||
bidPrices: [],
|
||||
bidVolumes: [],
|
||||
askPrices: [],
|
||||
askVolumes: [],
|
||||
tradingPhase: bondData.trading_phase,
|
||||
updateTime: timestamp,
|
||||
exchange: 'SZSE',
|
||||
isBond: true,
|
||||
} as QuoteData;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'hk_stock': {
|
||||
const hkData = data as SZSEHKStockData;
|
||||
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(hkData.bids);
|
||||
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(hkData.asks);
|
||||
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
name: prevQuotes[fullCode]?.name || '',
|
||||
price: hkData.last_px,
|
||||
prevClose: hkData.prev_close,
|
||||
open: hkData.open_px,
|
||||
high: hkData.high_px,
|
||||
low: hkData.low_px,
|
||||
volume: hkData.volume,
|
||||
amount: hkData.amount,
|
||||
numTrades: hkData.num_trades,
|
||||
nominalPx: hkData.nominal_px,
|
||||
referencePx: hkData.reference_px,
|
||||
change: hkData.last_px - hkData.prev_close,
|
||||
changePct: calcChangePct(hkData.last_px, hkData.prev_close),
|
||||
bidPrices,
|
||||
bidVolumes,
|
||||
askPrices,
|
||||
askVolumes,
|
||||
tradingPhase: hkData.trading_phase,
|
||||
updateTime: timestamp,
|
||||
exchange: 'SZSE',
|
||||
isHK: true,
|
||||
} as QuoteData;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'afterhours_block':
|
||||
case 'afterhours_trading': {
|
||||
const afterhoursData = data as SZSEAfterhoursData;
|
||||
const existing = prevQuotes[fullCode];
|
||||
if (existing) {
|
||||
updated[fullCode] = {
|
||||
...existing,
|
||||
afterhours: {
|
||||
bidPx: afterhoursData.bid_px,
|
||||
bidSize: afterhoursData.bid_size,
|
||||
offerPx: afterhoursData.offer_px,
|
||||
offerSize: afterhoursData.offer_size,
|
||||
volume: afterhoursData.volume,
|
||||
amount: afterhoursData.amount,
|
||||
numTrades: afterhoursData.num_trades || 0,
|
||||
},
|
||||
updateTime: timestamp,
|
||||
} as QuoteData;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理深交所快照消息
|
||||
* 存储时统一使用带后缀的完整代码作为 key
|
||||
*/
|
||||
const handleSZSESnapshotMessage = (
|
||||
msg: SZSESnapshotMessage,
|
||||
subscribedCodes: Set<string>,
|
||||
prevQuotes: QuotesMap
|
||||
): QuotesMap | null => {
|
||||
const { stocks = [], indexes = [], bonds = [] } = msg.data || {};
|
||||
const updated: QuotesMap = { ...prevQuotes };
|
||||
let hasUpdate = false;
|
||||
|
||||
stocks.forEach((s: SZSEStockData) => {
|
||||
const rawCode = s.security_id;
|
||||
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
||||
|
||||
if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) {
|
||||
hasUpdate = true;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rawData = s as any; // 用于检查替代字段名
|
||||
|
||||
// 调试日志:检查快照消息中的盘口数据(无盘口时警告)
|
||||
if (!s.bids || s.bids.length === 0) {
|
||||
logger.warn('FlexScreen', `SZSE快照股票数据无盘口 ${fullCode}`, {
|
||||
hasBids: !!s.bids,
|
||||
hasAsks: !!s.asks,
|
||||
hasBidPrices: !!rawData.bid_prices,
|
||||
hasAskPrices: !!rawData.ask_prices,
|
||||
dataKeys: Object.keys(s),
|
||||
});
|
||||
}
|
||||
|
||||
// 优先使用 bids/asks 对象数组格式,如果不存在则尝试 bid_prices/ask_prices 分离数组格式
|
||||
let bidPrices: number[] = [];
|
||||
let bidVolumes: number[] = [];
|
||||
let askPrices: number[] = [];
|
||||
let askVolumes: number[] = [];
|
||||
|
||||
if (s.bids && s.bids.length > 0) {
|
||||
const extracted = extractOrderBook(s.bids);
|
||||
bidPrices = extracted.prices;
|
||||
bidVolumes = extracted.volumes;
|
||||
} else if (rawData.bid_prices && Array.isArray(rawData.bid_prices)) {
|
||||
bidPrices = rawData.bid_prices;
|
||||
bidVolumes = rawData.bid_volumes || [];
|
||||
}
|
||||
|
||||
if (s.asks && s.asks.length > 0) {
|
||||
const extracted = extractOrderBook(s.asks);
|
||||
askPrices = extracted.prices;
|
||||
askVolumes = extracted.volumes;
|
||||
} else if (rawData.ask_prices && Array.isArray(rawData.ask_prices)) {
|
||||
askPrices = rawData.ask_prices;
|
||||
askVolumes = rawData.ask_volumes || [];
|
||||
}
|
||||
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
name: '',
|
||||
price: s.last_px,
|
||||
prevClose: s.prev_close,
|
||||
open: s.open_px,
|
||||
high: s.high_px,
|
||||
low: s.low_px,
|
||||
volume: s.volume,
|
||||
amount: s.amount,
|
||||
numTrades: s.num_trades,
|
||||
upperLimit: s.upper_limit,
|
||||
lowerLimit: s.lower_limit,
|
||||
change: s.last_px - s.prev_close,
|
||||
changePct: calcChangePct(s.last_px, s.prev_close),
|
||||
bidPrices,
|
||||
bidVolumes,
|
||||
askPrices,
|
||||
askVolumes,
|
||||
exchange: 'SZSE',
|
||||
} as QuoteData;
|
||||
}
|
||||
});
|
||||
|
||||
indexes.forEach((i: SZSEIndexData) => {
|
||||
const rawCode = i.security_id;
|
||||
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
||||
|
||||
if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) {
|
||||
hasUpdate = true;
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
name: '',
|
||||
price: i.current_index,
|
||||
prevClose: i.prev_close,
|
||||
open: i.open_index,
|
||||
high: i.high_index,
|
||||
low: i.low_index,
|
||||
volume: i.volume,
|
||||
amount: i.amount,
|
||||
numTrades: i.num_trades,
|
||||
change: i.current_index - i.prev_close,
|
||||
changePct: calcChangePct(i.current_index, i.prev_close),
|
||||
bidPrices: [],
|
||||
bidVolumes: [],
|
||||
askPrices: [],
|
||||
askVolumes: [],
|
||||
exchange: 'SZSE',
|
||||
} as QuoteData;
|
||||
}
|
||||
});
|
||||
|
||||
bonds.forEach((b: SZSEBondData) => {
|
||||
const rawCode = b.security_id;
|
||||
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
||||
|
||||
if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) {
|
||||
hasUpdate = true;
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
name: '',
|
||||
price: b.last_px,
|
||||
prevClose: b.prev_close,
|
||||
open: b.open_px,
|
||||
high: b.high_px,
|
||||
low: b.low_px,
|
||||
volume: b.volume,
|
||||
amount: b.amount,
|
||||
change: b.last_px - b.prev_close,
|
||||
changePct: calcChangePct(b.last_px, b.prev_close),
|
||||
bidPrices: [],
|
||||
bidVolumes: [],
|
||||
askPrices: [],
|
||||
askVolumes: [],
|
||||
exchange: 'SZSE',
|
||||
isBond: true,
|
||||
} as QuoteData;
|
||||
}
|
||||
});
|
||||
|
||||
return hasUpdate ? updated : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 实时行情 Hook
|
||||
* @param codes - 订阅的证券代码列表
|
||||
*/
|
||||
export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn => {
|
||||
const [quotes, setQuotes] = useState<QuotesMap>({});
|
||||
const [connected, setConnected] = useState<ConnectionStatus>({ SSE: false, SZSE: false });
|
||||
|
||||
const wsRefs = useRef<Record<Exchange, WebSocket | null>>({ SSE: null, SZSE: null });
|
||||
const heartbeatRefs = useRef<Record<Exchange, NodeJS.Timeout | null>>({ SSE: null, SZSE: null });
|
||||
const reconnectRefs = useRef<Record<Exchange, NodeJS.Timeout | null>>({ SSE: null, SZSE: null });
|
||||
const subscribedCodes = useRef<Record<Exchange, Set<string>>>({
|
||||
SSE: new Set(),
|
||||
SZSE: new Set(),
|
||||
});
|
||||
|
||||
const stopHeartbeat = useCallback((exchange: Exchange) => {
|
||||
if (heartbeatRefs.current[exchange]) {
|
||||
clearInterval(heartbeatRefs.current[exchange]!);
|
||||
heartbeatRefs.current[exchange] = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startHeartbeat = useCallback((exchange: Exchange) => {
|
||||
stopHeartbeat(exchange);
|
||||
heartbeatRefs.current[exchange] = setInterval(() => {
|
||||
const ws = wsRefs.current[exchange];
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
const msg = exchange === 'SSE' ? { action: 'ping' } : { type: 'ping' };
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
}, [stopHeartbeat]);
|
||||
|
||||
const handleMessage = useCallback((exchange: Exchange, msg: SSEMessage | SZSEMessage) => {
|
||||
if (msg.type === 'pong') return;
|
||||
|
||||
if (exchange === 'SSE') {
|
||||
const result = handleSSEMessage(
|
||||
msg as SSEMessage,
|
||||
subscribedCodes.current.SSE,
|
||||
{} // Will be merged with current state
|
||||
);
|
||||
if (result) {
|
||||
setQuotes(prev => ({ ...prev, ...result }));
|
||||
}
|
||||
} else {
|
||||
if (msg.type === 'realtime') {
|
||||
setQuotes(prev => {
|
||||
const result = handleSZSERealtimeMessage(
|
||||
msg as SZSERealtimeMessage,
|
||||
subscribedCodes.current.SZSE,
|
||||
prev
|
||||
);
|
||||
return result || prev;
|
||||
});
|
||||
} else if (msg.type === 'snapshot') {
|
||||
setQuotes(prev => {
|
||||
const result = handleSZSESnapshotMessage(
|
||||
msg as SZSESnapshotMessage,
|
||||
subscribedCodes.current.SZSE,
|
||||
prev
|
||||
);
|
||||
return result || prev;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createConnection = useCallback((exchange: Exchange) => {
|
||||
// 防御性检查:确保 HTTPS 页面不会意外连接 ws://(Mixed Content 安全错误)
|
||||
// 正常情况下 WS_CONFIG 会自动根据协议返回正确的 URL,这里是备用保护
|
||||
const isHttps = typeof window !== 'undefined' && window.location.protocol === 'https:';
|
||||
const wsUrl = WS_CONFIG[exchange];
|
||||
const isInsecureWs = wsUrl.startsWith('ws://');
|
||||
|
||||
if (isHttps && isInsecureWs) {
|
||||
logger.warn(
|
||||
'FlexScreen',
|
||||
`${exchange} WebSocket 配置错误:HTTPS 页面尝试连接 ws:// 端点,请检查 Nginx 代理配置`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (wsRefs.current[exchange]) {
|
||||
wsRefs.current[exchange]!.close();
|
||||
}
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRefs.current[exchange] = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
logger.info('FlexScreen', `${exchange} WebSocket 已连接`);
|
||||
setConnected(prev => ({ ...prev, [exchange]: true }));
|
||||
|
||||
if (exchange === 'SSE') {
|
||||
// subscribedCodes 存的是带后缀的完整代码,发送给 WS 需要去掉后缀
|
||||
const fullCodes = Array.from(subscribedCodes.current.SSE);
|
||||
const baseCodes = fullCodes.map(c => normalizeCode(c));
|
||||
if (baseCodes.length > 0) {
|
||||
ws.send(JSON.stringify({
|
||||
action: 'subscribe',
|
||||
channels: ['stock', 'index'],
|
||||
codes: baseCodes,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
startHeartbeat(exchange);
|
||||
};
|
||||
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
handleMessage(exchange, msg);
|
||||
} catch (e) {
|
||||
logger.warn('FlexScreen', `${exchange} 消息解析失败`, e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error: Event) => {
|
||||
logger.error('FlexScreen', `${exchange} WebSocket 错误`, error);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
logger.info('FlexScreen', `${exchange} WebSocket 断开`);
|
||||
setConnected(prev => ({ ...prev, [exchange]: false }));
|
||||
stopHeartbeat(exchange);
|
||||
|
||||
// 自动重连(仅在非 HTTPS + ws:// 场景下)
|
||||
if (!reconnectRefs.current[exchange] && subscribedCodes.current[exchange].size > 0) {
|
||||
reconnectRefs.current[exchange] = setTimeout(() => {
|
||||
reconnectRefs.current[exchange] = null;
|
||||
if (subscribedCodes.current[exchange].size > 0) {
|
||||
createConnection(exchange);
|
||||
}
|
||||
}, RECONNECT_INTERVAL);
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
logger.error('FlexScreen', `${exchange} WebSocket 连接失败`, e);
|
||||
setConnected(prev => ({ ...prev, [exchange]: false }));
|
||||
}
|
||||
}, [startHeartbeat, stopHeartbeat, handleMessage]);
|
||||
|
||||
const subscribe = useCallback((code: string) => {
|
||||
const exchange = getExchange(code);
|
||||
// 确保使用带后缀的完整代码
|
||||
const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`;
|
||||
const baseCode = normalizeCode(code);
|
||||
|
||||
subscribedCodes.current[exchange].add(fullCode);
|
||||
|
||||
const ws = wsRefs.current[exchange];
|
||||
if (exchange === 'SSE' && ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
action: 'subscribe',
|
||||
channels: ['stock', 'index'],
|
||||
codes: [baseCode], // 发送给 WS 用不带后缀的代码
|
||||
}));
|
||||
}
|
||||
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
createConnection(exchange);
|
||||
}
|
||||
}, [createConnection]);
|
||||
|
||||
const unsubscribe = useCallback((code: string) => {
|
||||
const exchange = getExchange(code);
|
||||
// 确保使用带后缀的完整代码
|
||||
const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`;
|
||||
|
||||
subscribedCodes.current[exchange].delete(fullCode);
|
||||
|
||||
setQuotes(prev => {
|
||||
const updated = { ...prev };
|
||||
delete updated[fullCode]; // 删除时也用带后缀的 key
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (subscribedCodes.current[exchange].size === 0) {
|
||||
const ws = wsRefs.current[exchange];
|
||||
if (ws) {
|
||||
ws.close();
|
||||
wsRefs.current[exchange] = null;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始化和 codes 变化处理
|
||||
// 注意:codes 现在是带后缀的完整代码(如 000001.SH)
|
||||
useEffect(() => {
|
||||
if (!codes || codes.length === 0) return;
|
||||
|
||||
// 使用带后缀的完整代码作为内部 key
|
||||
const newSseCodes = new Set<string>();
|
||||
const newSzseCodes = new Set<string>();
|
||||
|
||||
codes.forEach(code => {
|
||||
const exchange = getExchange(code);
|
||||
// 确保代码带后缀
|
||||
const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`;
|
||||
if (exchange === 'SSE') {
|
||||
newSseCodes.add(fullCode);
|
||||
} else {
|
||||
newSzseCodes.add(fullCode);
|
||||
}
|
||||
});
|
||||
|
||||
// 更新上交所订阅
|
||||
const oldSseCodes = subscribedCodes.current.SSE;
|
||||
const sseToAdd = [...newSseCodes].filter(c => !oldSseCodes.has(c));
|
||||
// 发送给 WebSocket 的代码需要去掉后缀
|
||||
const sseToAddBase = sseToAdd.map(c => normalizeCode(c));
|
||||
|
||||
if (sseToAdd.length > 0 || newSseCodes.size !== oldSseCodes.size) {
|
||||
subscribedCodes.current.SSE = newSseCodes;
|
||||
const ws = wsRefs.current.SSE;
|
||||
|
||||
if (ws && ws.readyState === WebSocket.OPEN && sseToAddBase.length > 0) {
|
||||
ws.send(JSON.stringify({
|
||||
action: 'subscribe',
|
||||
channels: ['stock', 'index'],
|
||||
codes: sseToAddBase,
|
||||
}));
|
||||
}
|
||||
|
||||
if (sseToAdd.length > 0 && (!ws || ws.readyState !== WebSocket.OPEN)) {
|
||||
createConnection('SSE');
|
||||
}
|
||||
|
||||
if (newSseCodes.size === 0 && ws) {
|
||||
ws.close();
|
||||
wsRefs.current.SSE = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新深交所订阅
|
||||
const oldSzseCodes = subscribedCodes.current.SZSE;
|
||||
const szseToAdd = [...newSzseCodes].filter(c => !oldSzseCodes.has(c));
|
||||
|
||||
if (szseToAdd.length > 0 || newSzseCodes.size !== oldSzseCodes.size) {
|
||||
subscribedCodes.current.SZSE = newSzseCodes;
|
||||
const ws = wsRefs.current.SZSE;
|
||||
|
||||
if (szseToAdd.length > 0 && (!ws || ws.readyState !== WebSocket.OPEN)) {
|
||||
createConnection('SZSE');
|
||||
}
|
||||
|
||||
if (newSzseCodes.size === 0 && ws) {
|
||||
ws.close();
|
||||
wsRefs.current.SZSE = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 清理已取消订阅的 quotes(使用带后缀的完整代码)
|
||||
const allNewCodes = new Set([...newSseCodes, ...newSzseCodes]);
|
||||
setQuotes(prev => {
|
||||
const updated: QuotesMap = {};
|
||||
Object.keys(prev).forEach(code => {
|
||||
if (allNewCodes.has(code)) {
|
||||
updated[code] = prev[code];
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}, [codes, createConnection]);
|
||||
|
||||
// 清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
(['SSE', 'SZSE'] as Exchange[]).forEach(exchange => {
|
||||
stopHeartbeat(exchange);
|
||||
if (reconnectRefs.current[exchange]) {
|
||||
clearTimeout(reconnectRefs.current[exchange]!);
|
||||
}
|
||||
const ws = wsRefs.current[exchange];
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [stopHeartbeat]);
|
||||
|
||||
return { quotes, connected, subscribe, unsubscribe };
|
||||
};
|
||||
|
||||
export default useRealtimeQuote;
|
||||
148
src/views/StockOverview/components/FlexScreen/hooks/utils.ts
Normal file
148
src/views/StockOverview/components/FlexScreen/hooks/utils.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 实时行情相关工具函数
|
||||
*/
|
||||
|
||||
import type { Exchange, OrderBookLevel } from '../types';
|
||||
|
||||
/**
|
||||
* 判断证券代码属于哪个交易所
|
||||
* @param code - 证券代码(可带或不带后缀)
|
||||
* @param isIndex - 是否为指数(用于区分同代码的指数和股票,如 000001)
|
||||
* @returns 交易所标识
|
||||
*/
|
||||
export const getExchange = (code: string, isIndex?: boolean): Exchange => {
|
||||
// 如果已带后缀,直接判断
|
||||
if (code.includes('.')) {
|
||||
return code.endsWith('.SH') ? 'SSE' : 'SZSE';
|
||||
}
|
||||
|
||||
const baseCode = code;
|
||||
|
||||
// 6开头为上海股票
|
||||
if (baseCode.startsWith('6')) {
|
||||
return 'SSE';
|
||||
}
|
||||
|
||||
// 5开头是上海 ETF
|
||||
if (baseCode.startsWith('5')) {
|
||||
return 'SSE';
|
||||
}
|
||||
|
||||
// 399开头是深证指数
|
||||
if (baseCode.startsWith('399')) {
|
||||
return 'SZSE';
|
||||
}
|
||||
|
||||
// 000开头:如果是指数则为上交所(上证指数000001),否则为深交所(平安银行000001)
|
||||
if (baseCode.startsWith('000')) {
|
||||
return isIndex ? 'SSE' : 'SZSE';
|
||||
}
|
||||
|
||||
// 0、3开头是深圳股票
|
||||
if (baseCode.startsWith('0') || baseCode.startsWith('3')) {
|
||||
return 'SZSE';
|
||||
}
|
||||
|
||||
// 1开头是深圳 ETF/债券
|
||||
if (baseCode.startsWith('1')) {
|
||||
return 'SZSE';
|
||||
}
|
||||
|
||||
// 默认上海
|
||||
return 'SSE';
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取证券代码的完整格式(带交易所后缀)
|
||||
* @param code - 原始代码
|
||||
* @param isIndex - 是否为指数
|
||||
* @returns 带后缀的代码
|
||||
*/
|
||||
export const getFullCode = (code: string, isIndex?: boolean): string => {
|
||||
if (code.includes('.')) {
|
||||
return code; // 已带后缀
|
||||
}
|
||||
const exchange = getExchange(code, isIndex);
|
||||
return `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 标准化证券代码为无后缀格式
|
||||
* @param code - 原始代码
|
||||
* @returns 无后缀代码
|
||||
*/
|
||||
export const normalizeCode = (code: string): string => {
|
||||
return code.split('.')[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* 盘口数据可能的格式(根据不同的 WebSocket 服务端实现)
|
||||
*/
|
||||
type OrderBookInput =
|
||||
| OrderBookLevel[] // 格式1: [{price, volume}, ...]
|
||||
| Array<[number, number]> // 格式2: [[price, volume], ...]
|
||||
| { prices: number[]; volumes: number[] } // 格式3: {prices: [...], volumes: [...]}
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* 从深交所 bids/asks 数组提取价格和量数组
|
||||
* 支持多种可能的数据格式
|
||||
* @param orderBook - 盘口数据,支持多种格式
|
||||
* @returns { prices, volumes }
|
||||
*/
|
||||
export const extractOrderBook = (
|
||||
orderBook: OrderBookInput
|
||||
): { prices: number[]; volumes: number[] } => {
|
||||
if (!orderBook) {
|
||||
return { prices: [], volumes: [] };
|
||||
}
|
||||
|
||||
// 格式3: 已经是 {prices, volumes} 结构
|
||||
if (!Array.isArray(orderBook) && 'prices' in orderBook && 'volumes' in orderBook) {
|
||||
return {
|
||||
prices: orderBook.prices || [],
|
||||
volumes: orderBook.volumes || [],
|
||||
};
|
||||
}
|
||||
|
||||
// 必须是数组才能继续
|
||||
if (!Array.isArray(orderBook) || orderBook.length === 0) {
|
||||
return { prices: [], volumes: [] };
|
||||
}
|
||||
|
||||
const firstItem = orderBook[0];
|
||||
|
||||
// 格式2: [[price, volume], ...]
|
||||
if (Array.isArray(firstItem)) {
|
||||
const prices = orderBook.map((item: unknown) => {
|
||||
const arr = item as [number, number];
|
||||
return arr[0] || 0;
|
||||
});
|
||||
const volumes = orderBook.map((item: unknown) => {
|
||||
const arr = item as [number, number];
|
||||
return arr[1] || 0;
|
||||
});
|
||||
return { prices, volumes };
|
||||
}
|
||||
|
||||
// 格式1: [{price, volume}, ...] (标准格式)
|
||||
if (typeof firstItem === 'object' && firstItem !== null) {
|
||||
const typedBook = orderBook as OrderBookLevel[];
|
||||
const prices = typedBook.map(item => item.price || 0);
|
||||
const volumes = typedBook.map(item => item.volume || 0);
|
||||
return { prices, volumes };
|
||||
}
|
||||
|
||||
return { prices: [], volumes: [] };
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算涨跌幅
|
||||
* @param price - 当前价
|
||||
* @param prevClose - 昨收价
|
||||
* @returns 涨跌幅百分比
|
||||
*/
|
||||
export const calcChangePct = (price: number, prevClose: number): number => {
|
||||
if (!prevClose || prevClose === 0) return 0;
|
||||
return ((price - prevClose) / prevClose) * 100;
|
||||
};
|
||||
507
src/views/StockOverview/components/FlexScreen/index.tsx
Normal file
507
src/views/StockOverview/components/FlexScreen/index.tsx
Normal file
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* 灵活屏组件
|
||||
* 用户可自定义添加关注的指数/个股,实时显示行情
|
||||
*
|
||||
* 功能:
|
||||
* 1. 添加/删除自选证券
|
||||
* 2. 显示实时行情(通过 WebSocket)
|
||||
* 3. 显示分时走势(结合 ClickHouse 历史数据)
|
||||
* 4. 显示五档盘口(上交所5档,深交所10档)
|
||||
* 5. 本地存储自选列表
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardBody,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
InputRightElement,
|
||||
IconButton,
|
||||
SimpleGrid,
|
||||
Flex,
|
||||
Spacer,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Badge,
|
||||
Tooltip,
|
||||
Collapse,
|
||||
List,
|
||||
ListItem,
|
||||
Spinner,
|
||||
Center,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Tag,
|
||||
TagLabel,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
SearchIcon,
|
||||
CloseIcon,
|
||||
AddIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
SettingsIcon,
|
||||
} from '@chakra-ui/icons';
|
||||
import {
|
||||
FaDesktop,
|
||||
FaTrash,
|
||||
FaSync,
|
||||
FaWifi,
|
||||
FaExclamationCircle,
|
||||
} from 'react-icons/fa';
|
||||
|
||||
import { useRealtimeQuote } from './hooks';
|
||||
import { getFullCode } from './hooks/utils';
|
||||
import QuoteTile from './components/QuoteTile';
|
||||
import { logger } from '@utils/logger';
|
||||
import type { WatchlistItem, ConnectionStatus } from './types';
|
||||
|
||||
// 本地存储 key
|
||||
const STORAGE_KEY = 'flexscreen_watchlist';
|
||||
|
||||
// 默认自选列表
|
||||
const DEFAULT_WATCHLIST: WatchlistItem[] = [
|
||||
{ code: '000001', name: '上证指数', isIndex: true },
|
||||
{ code: '399001', name: '深证成指', isIndex: true },
|
||||
{ code: '399006', name: '创业板指', isIndex: true },
|
||||
];
|
||||
|
||||
// 热门推荐
|
||||
const HOT_RECOMMENDATIONS: WatchlistItem[] = [
|
||||
{ code: '000001', name: '上证指数', isIndex: true },
|
||||
{ code: '399001', name: '深证成指', isIndex: true },
|
||||
{ code: '399006', name: '创业板指', isIndex: true },
|
||||
{ code: '399300', name: '沪深300', isIndex: true },
|
||||
{ code: '600519', name: '贵州茅台', isIndex: false },
|
||||
{ code: '000858', name: '五粮液', isIndex: false },
|
||||
{ code: '300750', name: '宁德时代', isIndex: false },
|
||||
{ code: '002594', name: '比亚迪', isIndex: false },
|
||||
];
|
||||
|
||||
/** 搜索结果项 */
|
||||
interface SearchResultItem {
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
isIndex?: boolean;
|
||||
code?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** 搜索 API 响应 */
|
||||
interface SearchApiResponse {
|
||||
success: boolean;
|
||||
data?: SearchResultItem[];
|
||||
}
|
||||
|
||||
/** 连接状态信息 */
|
||||
interface ConnectionStatusInfo {
|
||||
color: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FlexScreen 组件
|
||||
*/
|
||||
const FlexScreen: React.FC = () => {
|
||||
const toast = useToast();
|
||||
|
||||
// 自选列表
|
||||
const [watchlist, setWatchlist] = useState<WatchlistItem[]>([]);
|
||||
// 搜索状态
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
// 面板状态
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
// 颜色主题
|
||||
const cardBg = useColorModeValue('white', '#1a1a1a');
|
||||
const borderColor = useColorModeValue('gray.200', '#333');
|
||||
const textColor = useColorModeValue('gray.800', 'white');
|
||||
const subTextColor = useColorModeValue('gray.600', 'gray.400');
|
||||
const searchBg = useColorModeValue('gray.50', '#2a2a2a');
|
||||
const hoverBg = useColorModeValue('gray.100', '#333');
|
||||
|
||||
// 获取订阅的证券代码列表(带后缀,用于区分上证指数000001.SH和平安银行000001.SZ)
|
||||
const subscribedCodes = useMemo(() => {
|
||||
return watchlist.map(item => getFullCode(item.code, item.isIndex));
|
||||
}, [watchlist]);
|
||||
|
||||
// WebSocket 实时行情
|
||||
const { quotes, connected } = useRealtimeQuote(subscribedCodes);
|
||||
|
||||
// 从本地存储加载自选列表
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as WatchlistItem[];
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
setWatchlist(parsed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('FlexScreen', '加载自选列表失败', e);
|
||||
}
|
||||
// 使用默认列表
|
||||
setWatchlist(DEFAULT_WATCHLIST);
|
||||
}, []);
|
||||
|
||||
// 保存自选列表到本地存储
|
||||
useEffect(() => {
|
||||
if (watchlist.length > 0) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(watchlist));
|
||||
} catch (e) {
|
||||
logger.warn('FlexScreen', '保存自选列表失败', e);
|
||||
}
|
||||
}
|
||||
}, [watchlist]);
|
||||
|
||||
// 搜索证券
|
||||
const searchSecurities = useCallback(async (query: string): Promise<void> => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([]);
|
||||
setShowResults(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const response = await fetch(`/api/stocks/search?q=${encodeURIComponent(query)}&limit=10`);
|
||||
const data: SearchApiResponse = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setSearchResults(data.data || []);
|
||||
setShowResults(true);
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('FlexScreen', '搜索失败', e);
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 防抖搜索
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
searchSecurities(searchQuery);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, searchSecurities]);
|
||||
|
||||
// 添加证券
|
||||
const addSecurity = useCallback(
|
||||
(security: SearchResultItem | WatchlistItem): void => {
|
||||
const code = 'stock_code' in security ? security.stock_code : security.code;
|
||||
const name = 'stock_name' in security ? security.stock_name : security.name;
|
||||
// 优先使用 API 返回的 isIndex 字段
|
||||
const isIndex = security.isIndex === true;
|
||||
|
||||
// 生成唯一标识(带后缀的完整代码)
|
||||
const fullCode = getFullCode(code, isIndex);
|
||||
|
||||
// 检查是否已存在(使用带后缀的代码比较,避免上证指数和平安银行冲突)
|
||||
if (watchlist.some(item => getFullCode(item.code, item.isIndex) === fullCode)) {
|
||||
toast({
|
||||
title: '已在自选列表中',
|
||||
status: 'info',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加到列表
|
||||
setWatchlist(prev => [...prev, { code, name, isIndex }]);
|
||||
|
||||
toast({
|
||||
title: `已添加 ${name}${isIndex ? '(指数)' : ''}`,
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// 清空搜索
|
||||
setSearchQuery('');
|
||||
setShowResults(false);
|
||||
},
|
||||
[watchlist, toast]
|
||||
);
|
||||
|
||||
// 移除证券
|
||||
const removeSecurity = useCallback((code: string): void => {
|
||||
setWatchlist(prev => prev.filter(item => item.code !== code));
|
||||
}, []);
|
||||
|
||||
// 清空自选列表
|
||||
const clearWatchlist = useCallback((): void => {
|
||||
setWatchlist([]);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
toast({
|
||||
title: '已清空自选列表',
|
||||
status: 'info',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
}, [toast]);
|
||||
|
||||
// 重置为默认列表
|
||||
const resetWatchlist = useCallback((): void => {
|
||||
setWatchlist(DEFAULT_WATCHLIST);
|
||||
toast({
|
||||
title: '已重置为默认列表',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
}, [toast]);
|
||||
|
||||
// 连接状态指示
|
||||
const isAnyConnected = connected.SSE || connected.SZSE;
|
||||
const connectionStatus = useMemo((): ConnectionStatusInfo => {
|
||||
if (connected.SSE && connected.SZSE) {
|
||||
return { color: 'green', text: '上交所/深交所 已连接' };
|
||||
}
|
||||
if (connected.SSE) {
|
||||
return { color: 'yellow', text: '上交所 已连接' };
|
||||
}
|
||||
if (connected.SZSE) {
|
||||
return { color: 'yellow', text: '深交所 已连接' };
|
||||
}
|
||||
return { color: 'red', text: '未连接' };
|
||||
}, [connected]);
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} borderWidth="1px" borderColor={borderColor}>
|
||||
<CardBody>
|
||||
{/* 头部 */}
|
||||
<Flex align="center" mb={4}>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FaDesktop} boxSize={6} color="purple.500" />
|
||||
<Heading size="md" color={textColor}>
|
||||
灵活屏
|
||||
</Heading>
|
||||
<Tooltip label={connectionStatus.text}>
|
||||
<Badge
|
||||
colorScheme={connectionStatus.color}
|
||||
variant="subtle"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
>
|
||||
<Icon as={FaWifi} boxSize={3} />
|
||||
{isAnyConnected ? '实时' : '离线'}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
<Spacer />
|
||||
<HStack spacing={2}>
|
||||
{/* 操作菜单 */}
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
icon={<SettingsIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="设置"
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem icon={<FaSync />} onClick={resetWatchlist}>
|
||||
重置为默认
|
||||
</MenuItem>
|
||||
<MenuItem icon={<FaTrash />} onClick={clearWatchlist} color="red.500">
|
||||
清空列表
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
{/* 折叠按钮 */}
|
||||
<IconButton
|
||||
icon={isCollapsed ? <ChevronDownIcon /> : <ChevronUpIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
aria-label={isCollapsed ? '展开' : '收起'}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 可折叠内容 */}
|
||||
<Collapse in={!isCollapsed} animateOpacity>
|
||||
{/* 搜索框 */}
|
||||
<Box position="relative" mb={4}>
|
||||
<InputGroup size="md">
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<SearchIcon color="gray.400" />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="搜索股票/指数代码或名称..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
bg={searchBg}
|
||||
borderRadius="lg"
|
||||
_focus={{
|
||||
borderColor: 'purple.400',
|
||||
boxShadow: '0 0 0 1px var(--chakra-colors-purple-400)',
|
||||
}}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon={<CloseIcon />}
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setShowResults(false);
|
||||
}}
|
||||
aria-label="清空"
|
||||
/>
|
||||
</InputRightElement>
|
||||
)}
|
||||
</InputGroup>
|
||||
|
||||
{/* 搜索结果下拉 */}
|
||||
<Collapse in={showResults} animateOpacity>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="100%"
|
||||
left={0}
|
||||
right={0}
|
||||
mt={1}
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
boxShadow="lg"
|
||||
maxH="300px"
|
||||
overflowY="auto"
|
||||
zIndex={10}
|
||||
>
|
||||
{isSearching ? (
|
||||
<Center p={4}>
|
||||
<Spinner size="sm" color="purple.500" />
|
||||
</Center>
|
||||
) : searchResults.length > 0 ? (
|
||||
<List spacing={0}>
|
||||
{searchResults.map((stock, index) => (
|
||||
<ListItem
|
||||
key={`${stock.stock_code}-${stock.isIndex ? 'index' : 'stock'}`}
|
||||
px={4}
|
||||
py={2}
|
||||
cursor="pointer"
|
||||
_hover={{ bg: hoverBg }}
|
||||
onClick={() => addSecurity(stock)}
|
||||
borderBottomWidth={index < searchResults.length - 1 ? '1px' : '0'}
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<VStack align="start" spacing={0}>
|
||||
<HStack spacing={2}>
|
||||
<Text fontWeight="medium" color={textColor}>
|
||||
{stock.stock_name}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={stock.isIndex ? 'purple' : 'blue'}
|
||||
fontSize="xs"
|
||||
variant="subtle"
|
||||
>
|
||||
{stock.isIndex ? '指数' : '股票'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color={subTextColor}>
|
||||
{stock.stock_code}
|
||||
</Text>
|
||||
</VStack>
|
||||
<IconButton
|
||||
icon={<AddIcon />}
|
||||
size="xs"
|
||||
colorScheme="purple"
|
||||
variant="ghost"
|
||||
aria-label="添加"
|
||||
/>
|
||||
</HStack>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Center p={4}>
|
||||
<Text color={subTextColor} fontSize="sm">
|
||||
未找到相关证券
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* 快捷添加 */}
|
||||
{watchlist.length === 0 && (
|
||||
<Box mb={4}>
|
||||
<Text fontSize="sm" color={subTextColor} mb={2}>
|
||||
热门推荐(点击添加)
|
||||
</Text>
|
||||
<Flex flexWrap="wrap" gap={2}>
|
||||
{HOT_RECOMMENDATIONS.map(item => (
|
||||
<Tag
|
||||
key={item.code}
|
||||
size="md"
|
||||
variant="subtle"
|
||||
colorScheme="purple"
|
||||
cursor="pointer"
|
||||
_hover={{ bg: 'purple.100' }}
|
||||
onClick={() => addSecurity(item)}
|
||||
>
|
||||
<TagLabel>{item.name}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 自选列表 */}
|
||||
{watchlist.length > 0 ? (
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||
{watchlist.map(item => {
|
||||
const fullCode = getFullCode(item.code, item.isIndex);
|
||||
return (
|
||||
<QuoteTile
|
||||
key={fullCode}
|
||||
code={item.code}
|
||||
name={item.name}
|
||||
quote={quotes[fullCode] || {}}
|
||||
isIndex={item.isIndex}
|
||||
onRemove={removeSecurity}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FaExclamationCircle} boxSize={10} color="gray.300" />
|
||||
<Text color={subTextColor}>自选列表为空,请搜索添加证券</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
</Collapse>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlexScreen;
|
||||
322
src/views/StockOverview/components/FlexScreen/types.ts
Normal file
322
src/views/StockOverview/components/FlexScreen/types.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* 灵活屏组件类型定义
|
||||
*/
|
||||
|
||||
// ==================== WebSocket 相关类型 ====================
|
||||
|
||||
/** 交易所标识 */
|
||||
export type Exchange = 'SSE' | 'SZSE';
|
||||
|
||||
/** WebSocket 连接状态 */
|
||||
export interface ConnectionStatus {
|
||||
SSE: boolean;
|
||||
SZSE: boolean;
|
||||
}
|
||||
|
||||
/** 盘口档位数据 */
|
||||
export interface OrderBookLevel {
|
||||
price: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
// ==================== 行情数据类型 ====================
|
||||
|
||||
/** 盘后交易数据 */
|
||||
export interface AfterhoursData {
|
||||
bidPx: number;
|
||||
bidSize: number;
|
||||
offerPx: number;
|
||||
offerSize: number;
|
||||
volume: number;
|
||||
amount: number;
|
||||
numTrades: number;
|
||||
}
|
||||
|
||||
/** 基础行情数据 */
|
||||
export interface BaseQuoteData {
|
||||
code: string;
|
||||
name: string;
|
||||
price: number;
|
||||
prevClose: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
volume: number;
|
||||
amount: number;
|
||||
change: number;
|
||||
changePct: number;
|
||||
updateTime?: string;
|
||||
exchange: Exchange;
|
||||
}
|
||||
|
||||
/** 股票行情数据 */
|
||||
export interface StockQuoteData extends BaseQuoteData {
|
||||
numTrades?: number;
|
||||
upperLimit?: number; // 涨停价
|
||||
lowerLimit?: number; // 跌停价
|
||||
bidPrices: number[];
|
||||
bidVolumes: number[];
|
||||
askPrices: number[];
|
||||
askVolumes: number[];
|
||||
tradingPhase?: string;
|
||||
afterhours?: AfterhoursData; // 盘后交易数据
|
||||
}
|
||||
|
||||
/** 指数行情数据 */
|
||||
export interface IndexQuoteData extends BaseQuoteData {
|
||||
close?: number;
|
||||
numTrades?: number;
|
||||
bidPrices: number[];
|
||||
bidVolumes: number[];
|
||||
askPrices: number[];
|
||||
askVolumes: number[];
|
||||
tradingPhase?: string;
|
||||
}
|
||||
|
||||
/** 债券行情数据 */
|
||||
export interface BondQuoteData extends BaseQuoteData {
|
||||
numTrades?: number;
|
||||
weightedAvgPx?: number;
|
||||
bidPrices: number[];
|
||||
bidVolumes: number[];
|
||||
askPrices: number[];
|
||||
askVolumes: number[];
|
||||
tradingPhase?: string;
|
||||
isBond: true;
|
||||
}
|
||||
|
||||
/** 港股行情数据 */
|
||||
export interface HKStockQuoteData extends BaseQuoteData {
|
||||
numTrades?: number;
|
||||
nominalPx?: number; // 按盘价
|
||||
referencePx?: number; // 参考价
|
||||
bidPrices: number[];
|
||||
bidVolumes: number[];
|
||||
askPrices: number[];
|
||||
askVolumes: number[];
|
||||
tradingPhase?: string;
|
||||
isHK: true;
|
||||
}
|
||||
|
||||
/** 统一行情数据类型 */
|
||||
export type QuoteData = StockQuoteData | IndexQuoteData | BondQuoteData | HKStockQuoteData;
|
||||
|
||||
/** 行情数据字典 */
|
||||
export interface QuotesMap {
|
||||
[code: string]: QuoteData;
|
||||
}
|
||||
|
||||
// ==================== 上交所 WebSocket 消息类型 ====================
|
||||
|
||||
/** 上交所行情数据 */
|
||||
export interface SSEQuoteItem {
|
||||
security_id: string;
|
||||
security_name: string;
|
||||
prev_close: number;
|
||||
open_price: number;
|
||||
high_price: number;
|
||||
low_price: number;
|
||||
last_price: number;
|
||||
close_price: number;
|
||||
volume: number;
|
||||
amount: number;
|
||||
bid_prices?: number[];
|
||||
bid_volumes?: number[];
|
||||
ask_prices?: number[];
|
||||
ask_volumes?: number[];
|
||||
trading_status?: string;
|
||||
trade_time?: string;
|
||||
update_time?: string;
|
||||
}
|
||||
|
||||
/** 上交所消息 */
|
||||
export interface SSEMessage {
|
||||
type: 'stock' | 'index' | 'etf' | 'bond' | 'option' | 'subscribed' | 'pong' | 'error';
|
||||
timestamp?: string;
|
||||
data?: Record<string, SSEQuoteItem>;
|
||||
channels?: string[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ==================== 深交所 WebSocket 消息类型 ====================
|
||||
|
||||
/** 深交所数据类别 */
|
||||
export type SZSECategory =
|
||||
| 'stock' // 300111 股票快照
|
||||
| 'bond' // 300211 债券快照
|
||||
| 'afterhours_block' // 300611 盘后定价大宗交易
|
||||
| 'afterhours_trading' // 303711 盘后定价交易
|
||||
| 'hk_stock' // 306311 港股快照
|
||||
| 'index' // 309011 指数快照
|
||||
| 'volume_stats' // 309111 成交量统计
|
||||
| 'fund_nav'; // 309211 基金净值
|
||||
|
||||
/** 深交所股票行情数据 */
|
||||
export interface SZSEStockData {
|
||||
security_id: string;
|
||||
orig_time?: number;
|
||||
channel_no?: number;
|
||||
trading_phase?: string;
|
||||
last_px: number;
|
||||
open_px: number;
|
||||
high_px: number;
|
||||
low_px: number;
|
||||
prev_close: number;
|
||||
volume: number;
|
||||
amount: number;
|
||||
num_trades?: number;
|
||||
upper_limit?: number;
|
||||
lower_limit?: number;
|
||||
bids?: OrderBookLevel[];
|
||||
asks?: OrderBookLevel[];
|
||||
}
|
||||
|
||||
/** 深交所指数行情数据 */
|
||||
export interface SZSEIndexData {
|
||||
security_id: string;
|
||||
orig_time?: number;
|
||||
channel_no?: number;
|
||||
trading_phase?: string;
|
||||
current_index: number;
|
||||
open_index: number;
|
||||
high_index: number;
|
||||
low_index: number;
|
||||
close_index?: number;
|
||||
prev_close: number;
|
||||
volume: number;
|
||||
amount: number;
|
||||
num_trades?: number;
|
||||
}
|
||||
|
||||
/** 深交所债券行情数据 */
|
||||
export interface SZSEBondData {
|
||||
security_id: string;
|
||||
orig_time?: number;
|
||||
channel_no?: number;
|
||||
trading_phase?: string;
|
||||
last_px: number;
|
||||
open_px: number;
|
||||
high_px: number;
|
||||
low_px: number;
|
||||
prev_close: number;
|
||||
weighted_avg_px?: number;
|
||||
volume: number;
|
||||
amount: number;
|
||||
num_trades?: number;
|
||||
auction_volume?: number;
|
||||
auction_amount?: number;
|
||||
}
|
||||
|
||||
/** 深交所港股行情数据 */
|
||||
export interface SZSEHKStockData {
|
||||
security_id: string;
|
||||
orig_time?: number;
|
||||
channel_no?: number;
|
||||
trading_phase?: string;
|
||||
last_px: number;
|
||||
open_px: number;
|
||||
high_px: number;
|
||||
low_px: number;
|
||||
prev_close: number;
|
||||
nominal_px?: number;
|
||||
reference_px?: number;
|
||||
volume: number;
|
||||
amount: number;
|
||||
num_trades?: number;
|
||||
vcm_start_time?: number;
|
||||
vcm_end_time?: number;
|
||||
bids?: OrderBookLevel[];
|
||||
asks?: OrderBookLevel[];
|
||||
}
|
||||
|
||||
/** 深交所盘后交易数据 */
|
||||
export interface SZSEAfterhoursData {
|
||||
security_id: string;
|
||||
orig_time?: number;
|
||||
channel_no?: number;
|
||||
trading_phase?: string;
|
||||
prev_close: number;
|
||||
bid_px: number;
|
||||
bid_size: number;
|
||||
offer_px: number;
|
||||
offer_size: number;
|
||||
volume: number;
|
||||
amount: number;
|
||||
num_trades?: number;
|
||||
}
|
||||
|
||||
/** 深交所实时消息 */
|
||||
export interface SZSERealtimeMessage {
|
||||
type: 'realtime';
|
||||
category: SZSECategory;
|
||||
msg_type?: number;
|
||||
timestamp: string;
|
||||
data: SZSEStockData | SZSEIndexData | SZSEBondData | SZSEHKStockData | SZSEAfterhoursData;
|
||||
}
|
||||
|
||||
/** 深交所快照消息 */
|
||||
export interface SZSESnapshotMessage {
|
||||
type: 'snapshot';
|
||||
timestamp: string;
|
||||
data: {
|
||||
stocks?: SZSEStockData[];
|
||||
indexes?: SZSEIndexData[];
|
||||
bonds?: SZSEBondData[];
|
||||
};
|
||||
}
|
||||
|
||||
/** 深交所消息类型 */
|
||||
export type SZSEMessage = SZSERealtimeMessage | SZSESnapshotMessage | { type: 'pong' };
|
||||
|
||||
// ==================== 组件 Props 类型 ====================
|
||||
|
||||
/** 自选证券项 */
|
||||
export interface WatchlistItem {
|
||||
code: string;
|
||||
name: string;
|
||||
isIndex: boolean;
|
||||
}
|
||||
|
||||
/** QuoteTile 组件 Props */
|
||||
export interface QuoteTileProps {
|
||||
code: string;
|
||||
name: string;
|
||||
quote: Partial<QuoteData>;
|
||||
isIndex?: boolean;
|
||||
onRemove?: (code: string) => void;
|
||||
}
|
||||
|
||||
/** OrderBookPanel 组件 Props */
|
||||
export interface OrderBookPanelProps {
|
||||
bidPrices?: number[];
|
||||
bidVolumes?: number[];
|
||||
askPrices?: number[];
|
||||
askVolumes?: number[];
|
||||
prevClose?: number;
|
||||
upperLimit?: number;
|
||||
lowerLimit?: number;
|
||||
defaultLevels?: number;
|
||||
}
|
||||
|
||||
/** MiniTimelineChart 组件 Props */
|
||||
export interface MiniTimelineChartProps {
|
||||
code: string;
|
||||
isIndex?: boolean;
|
||||
prevClose?: number;
|
||||
currentPrice?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/** 分时数据点 */
|
||||
export interface TimelineDataPoint {
|
||||
time: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
/** useRealtimeQuote Hook 返回值 */
|
||||
export interface UseRealtimeQuoteReturn {
|
||||
quotes: QuotesMap;
|
||||
connected: ConnectionStatus;
|
||||
subscribe: (code: string) => void;
|
||||
unsubscribe: (code: string) => void;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 异动统计摘要组件
|
||||
* 展示指数统计和异动类型统计
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
Badge,
|
||||
Icon,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
SimpleGrid,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaBolt, FaArrowDown, FaRocket, FaChartLine, FaFire, FaVolumeUp } from 'react-icons/fa';
|
||||
|
||||
/**
|
||||
* 异动类型徽章
|
||||
*/
|
||||
const AlertTypeBadge = ({ type, count }) => {
|
||||
const config = {
|
||||
surge: { label: '急涨', color: 'red', icon: FaBolt },
|
||||
surge_up: { label: '暴涨', color: 'red', icon: FaBolt },
|
||||
surge_down: { label: '暴跌', color: 'green', icon: FaArrowDown },
|
||||
limit_up: { label: '涨停', color: 'orange', icon: FaRocket },
|
||||
rank_jump: { label: '排名跃升', color: 'blue', icon: FaChartLine },
|
||||
volume_spike: { label: '放量', color: 'purple', icon: FaVolumeUp },
|
||||
};
|
||||
|
||||
const cfg = config[type] || { label: type, color: 'gray', icon: FaFire };
|
||||
|
||||
return (
|
||||
<Badge colorScheme={cfg.color} variant="subtle" px={2} py={1} borderRadius="md">
|
||||
<HStack spacing={1}>
|
||||
<Icon as={cfg.icon} boxSize={3} />
|
||||
<Text>{cfg.label}</Text>
|
||||
<Text fontWeight="bold">{count}</Text>
|
||||
</HStack>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 指数统计卡片
|
||||
*/
|
||||
const IndexStatCard = ({ indexData }) => {
|
||||
const cardBg = useColorModeValue('white', '#1a1a1a');
|
||||
const borderColor = useColorModeValue('gray.200', '#333');
|
||||
const subTextColor = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
if (!indexData) return null;
|
||||
|
||||
const changePct = indexData.change_pct || 0;
|
||||
const isUp = changePct >= 0;
|
||||
|
||||
return (
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
|
||||
<Stat size="sm">
|
||||
<StatLabel color={subTextColor}>{indexData.name || '上证指数'}</StatLabel>
|
||||
<StatNumber fontSize="xl" color={isUp ? 'red.500' : 'green.500'}>
|
||||
{indexData.latest_price?.toFixed(2) || '-'}
|
||||
</StatNumber>
|
||||
<StatHelpText mb={0}>
|
||||
<StatArrow type={isUp ? 'increase' : 'decrease'} />
|
||||
{changePct?.toFixed(2)}%
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
|
||||
<Stat size="sm">
|
||||
<StatLabel color={subTextColor}>最高</StatLabel>
|
||||
<StatNumber fontSize="xl" color="red.500">
|
||||
{indexData.high?.toFixed(2) || '-'}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
|
||||
<Stat size="sm">
|
||||
<StatLabel color={subTextColor}>最低</StatLabel>
|
||||
<StatNumber fontSize="xl" color="green.500">
|
||||
{indexData.low?.toFixed(2) || '-'}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
|
||||
<Stat size="sm">
|
||||
<StatLabel color={subTextColor}>振幅</StatLabel>
|
||||
<StatNumber fontSize="xl" color="purple.500">
|
||||
{indexData.high && indexData.low && indexData.prev_close
|
||||
? (((indexData.high - indexData.low) / indexData.prev_close) * 100).toFixed(2) + '%'
|
||||
: '-'}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 异动统计摘要
|
||||
* @param {Object} props
|
||||
* @param {Object} props.indexData - 指数数据
|
||||
* @param {Array} props.alerts - 异动数组
|
||||
* @param {Object} props.alertSummary - 异动类型统计
|
||||
*/
|
||||
const AlertSummary = ({ indexData, alerts = [], alertSummary = {} }) => {
|
||||
const cardBg = useColorModeValue('white', '#1a1a1a');
|
||||
const borderColor = useColorModeValue('gray.200', '#333');
|
||||
|
||||
// 如果没有 alertSummary,从 alerts 中统计
|
||||
const summary = alertSummary && Object.keys(alertSummary).length > 0
|
||||
? alertSummary
|
||||
: alerts.reduce((acc, alert) => {
|
||||
const type = alert.alert_type || 'unknown';
|
||||
acc[type] = (acc[type] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const totalAlerts = alerts.length;
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* 指数统计 */}
|
||||
<IndexStatCard indexData={indexData} />
|
||||
|
||||
{/* 异动统计 */}
|
||||
{totalAlerts > 0 && (
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Text fontSize="sm" color="gray.500" mr={2}>
|
||||
异动 {totalAlerts} 次:
|
||||
</Text>
|
||||
{(summary.surge_up > 0 || summary.surge > 0) && (
|
||||
<AlertTypeBadge type="surge_up" count={(summary.surge_up || 0) + (summary.surge || 0)} />
|
||||
)}
|
||||
{summary.surge_down > 0 && <AlertTypeBadge type="surge_down" count={summary.surge_down} />}
|
||||
{summary.limit_up > 0 && <AlertTypeBadge type="limit_up" count={summary.limit_up} />}
|
||||
{summary.volume_spike > 0 && <AlertTypeBadge type="volume_spike" count={summary.volume_spike} />}
|
||||
{summary.rank_jump > 0 && <AlertTypeBadge type="rank_jump" count={summary.rank_jump} />}
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertSummary;
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* 概念异动列表组件 - V2
|
||||
* 展示当日的概念异动记录,点击可展开显示相关股票
|
||||
*/
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Icon,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
Flex,
|
||||
Collapse,
|
||||
Spinner,
|
||||
Progress,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaArrowUp, FaArrowDown, FaFire, FaChevronDown, FaChevronRight } from 'react-icons/fa';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { getAlertTypeLabel, formatScore, getScoreColor } from '../utils/chartHelpers';
|
||||
|
||||
/**
|
||||
* 紧凑型异动卡片
|
||||
*/
|
||||
const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
|
||||
const navigate = useNavigate();
|
||||
const bgColor = useColorModeValue('white', '#1a1a1a');
|
||||
const hoverBg = useColorModeValue('gray.50', '#252525');
|
||||
const borderColor = useColorModeValue('gray.200', '#333');
|
||||
const expandedBg = useColorModeValue('purple.50', '#1e1e2e');
|
||||
|
||||
const isUp = alert.alert_type !== 'surge_down';
|
||||
const typeColor = isUp ? 'red' : 'green';
|
||||
const isV2 = alert.is_v2;
|
||||
|
||||
// 点击股票跳转
|
||||
const handleStockClick = (e, stockCode) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/company?scode=${stockCode}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={isExpanded ? expandedBg : bgColor}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={isExpanded ? 'purple.400' : borderColor}
|
||||
overflow="hidden"
|
||||
transition="all 0.2s"
|
||||
_hover={{ borderColor: 'purple.300' }}
|
||||
>
|
||||
{/* 主卡片 - 点击展开 */}
|
||||
<Box
|
||||
p={3}
|
||||
cursor="pointer"
|
||||
onClick={onToggle}
|
||||
_hover={{ bg: hoverBg }}
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
{/* 左侧:名称 + 类型 */}
|
||||
<HStack spacing={2} flex={1} minW={0}>
|
||||
<Icon
|
||||
as={isExpanded ? FaChevronDown : FaChevronRight}
|
||||
color="gray.400"
|
||||
boxSize={3}
|
||||
/>
|
||||
<Icon
|
||||
as={isUp ? FaArrowUp : FaArrowDown}
|
||||
color={`${typeColor}.500`}
|
||||
boxSize={3}
|
||||
/>
|
||||
<Text fontWeight="bold" fontSize="sm" noOfLines={1} flex={1}>
|
||||
{alert.concept_name}
|
||||
</Text>
|
||||
{isV2 && (
|
||||
<Badge colorScheme="purple" size="xs" variant="solid" fontSize="9px" px={1}>
|
||||
V2
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 右侧:分数 */}
|
||||
<Badge
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="full"
|
||||
bg={getScoreColor(alert.final_score)}
|
||||
color="white"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
ml={2}
|
||||
>
|
||||
{formatScore(alert.final_score)}分
|
||||
</Badge>
|
||||
</Flex>
|
||||
|
||||
{/* 第二行:时间 + 关键指标 */}
|
||||
<Flex mt={2} justify="space-between" align="center" fontSize="xs">
|
||||
<HStack spacing={2} color="gray.500">
|
||||
<Text>{alert.time}</Text>
|
||||
<Badge colorScheme={typeColor} size="sm" variant="subtle">
|
||||
{getAlertTypeLabel(alert.alert_type)}
|
||||
</Badge>
|
||||
{/* 确认率 */}
|
||||
{isV2 && alert.confirm_ratio != null && (
|
||||
<HStack spacing={1}>
|
||||
<Box w="30px" h="4px" bg="gray.200" borderRadius="full" overflow="hidden">
|
||||
<Box
|
||||
w={`${(alert.confirm_ratio || 0) * 100}%`}
|
||||
h="100%"
|
||||
bg={(alert.confirm_ratio || 0) >= 0.8 ? 'green.500' : 'orange.500'}
|
||||
/>
|
||||
</Box>
|
||||
<Text>{Math.round((alert.confirm_ratio || 0) * 100)}%</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* Alpha + Z-Score 简化显示 */}
|
||||
<HStack spacing={3}>
|
||||
{alert.alpha != null && (
|
||||
<Text color={(alert.alpha || 0) >= 0 ? 'red.500' : 'green.500'} fontWeight="medium">
|
||||
α {(alert.alpha || 0) >= 0 ? '+' : ''}{(alert.alpha || 0).toFixed(2)}%
|
||||
</Text>
|
||||
)}
|
||||
{isV2 && alert.alpha_zscore != null && (
|
||||
<Tooltip label={`Alpha Z-Score: ${(alert.alpha_zscore || 0).toFixed(2)}σ`}>
|
||||
<HStack spacing={0.5}>
|
||||
<Box
|
||||
w="24px"
|
||||
h="4px"
|
||||
bg="gray.200"
|
||||
borderRadius="full"
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
left={(alert.alpha_zscore || 0) >= 0 ? '50%' : undefined}
|
||||
right={(alert.alpha_zscore || 0) < 0 ? '50%' : undefined}
|
||||
w={`${Math.min(Math.abs(alert.alpha_zscore || 0) / 5 * 50, 50)}%`}
|
||||
h="100%"
|
||||
bg={(alert.alpha_zscore || 0) >= 0 ? 'red.500' : 'green.500'}
|
||||
/>
|
||||
</Box>
|
||||
<Text color={(alert.alpha_zscore || 0) >= 0 ? 'red.400' : 'green.400'}>
|
||||
{(alert.alpha_zscore || 0) >= 0 ? '+' : ''}{(alert.alpha_zscore || 0).toFixed(1)}σ
|
||||
</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(alert.limit_up_ratio || 0) > 0.05 && (
|
||||
<HStack spacing={0.5} color="orange.500">
|
||||
<Icon as={FaFire} boxSize={3} />
|
||||
<Text>{Math.round((alert.limit_up_ratio || 0) * 100)}%</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* 展开的股票列表 */}
|
||||
<Collapse in={isExpanded} animateOpacity>
|
||||
<Box
|
||||
borderTopWidth="1px"
|
||||
borderColor={borderColor}
|
||||
p={3}
|
||||
bg={useColorModeValue('gray.50', '#151520')}
|
||||
>
|
||||
{loadingStocks ? (
|
||||
<HStack justify="center" py={4}>
|
||||
<Spinner size="sm" color="purple.500" />
|
||||
<Text fontSize="sm" color="gray.500">加载相关股票...</Text>
|
||||
</HStack>
|
||||
) : stocks && stocks.length > 0 ? (
|
||||
<TableContainer maxH="200px" overflowY="auto">
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead position="sticky" top={0} bg={useColorModeValue('gray.50', '#151520')} zIndex={1}>
|
||||
<Tr>
|
||||
<Th px={2} py={1} fontSize="xs" color="gray.500">股票</Th>
|
||||
<Th px={2} py={1} fontSize="xs" color="gray.500" isNumeric>涨跌</Th>
|
||||
<Th px={2} py={1} fontSize="xs" color="gray.500" maxW="120px">原因</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{stocks.slice(0, 10).map((stock, idx) => {
|
||||
const changePct = stock.change_pct;
|
||||
const hasChange = changePct != null && !isNaN(changePct);
|
||||
return (
|
||||
<Tr
|
||||
key={idx}
|
||||
cursor="pointer"
|
||||
_hover={{ bg: hoverBg }}
|
||||
onClick={(e) => handleStockClick(e, stock.code || stock.stock_code)}
|
||||
>
|
||||
<Td px={2} py={1.5}>
|
||||
<Text fontSize="xs" color="cyan.400" fontWeight="medium">
|
||||
{stock.name || stock.stock_name || '-'}
|
||||
</Text>
|
||||
</Td>
|
||||
<Td px={2} py={1.5} isNumeric>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color={
|
||||
hasChange && changePct > 0 ? 'red.400' :
|
||||
hasChange && changePct < 0 ? 'green.400' : 'gray.400'
|
||||
}
|
||||
>
|
||||
{hasChange
|
||||
? `${changePct > 0 ? '+' : ''}${changePct.toFixed(2)}%`
|
||||
: '-'
|
||||
}
|
||||
</Text>
|
||||
</Td>
|
||||
<Td px={2} py={1.5} maxW="120px">
|
||||
<Text fontSize="xs" color="gray.500" noOfLines={1}>
|
||||
{stock.reason || '-'}
|
||||
</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
{stocks.length > 10 && (
|
||||
<Text fontSize="xs" color="gray.500" textAlign="center" mt={2}>
|
||||
共 {stocks.length} 只相关股票,显示前 10 只
|
||||
</Text>
|
||||
)}
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Text fontSize="sm" color="gray.500" textAlign="center" py={2}>
|
||||
暂无相关股票数据
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 概念异动列表
|
||||
*/
|
||||
const ConceptAlertList = ({ alerts = [], onAlertClick, selectedAlert, maxHeight = '400px' }) => {
|
||||
const [expandedId, setExpandedId] = useState(null);
|
||||
const [conceptStocks, setConceptStocks] = useState({});
|
||||
const [loadingConcepts, setLoadingConcepts] = useState({});
|
||||
|
||||
const subTextColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
// 获取概念相关股票
|
||||
const fetchConceptStocks = useCallback(async (conceptId) => {
|
||||
if (conceptStocks[conceptId] || loadingConcepts[conceptId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingConcepts(prev => ({ ...prev, [conceptId]: true }));
|
||||
|
||||
try {
|
||||
// 调用后端 API 获取概念股票
|
||||
const response = await axios.get(`/api/concept/${conceptId}/stocks`);
|
||||
if (response.data?.success && response.data?.data?.stocks) {
|
||||
setConceptStocks(prev => ({
|
||||
...prev,
|
||||
[conceptId]: response.data.data.stocks
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取概念股票失败:', error);
|
||||
// 如果 API 失败,尝试从 ES 直接获取
|
||||
try {
|
||||
const esResponse = await axios.get(`/api/es/concept/${conceptId}`);
|
||||
if (esResponse.data?.stocks) {
|
||||
setConceptStocks(prev => ({
|
||||
...prev,
|
||||
[conceptId]: esResponse.data.stocks
|
||||
}));
|
||||
}
|
||||
} catch (esError) {
|
||||
console.error('ES 获取也失败:', esError);
|
||||
setConceptStocks(prev => ({ ...prev, [conceptId]: [] }));
|
||||
}
|
||||
} finally {
|
||||
setLoadingConcepts(prev => ({ ...prev, [conceptId]: false }));
|
||||
}
|
||||
}, [conceptStocks, loadingConcepts]);
|
||||
|
||||
// 切换展开状态
|
||||
const handleToggle = useCallback((alert) => {
|
||||
const alertKey = `${alert.concept_id}-${alert.time}`;
|
||||
|
||||
if (expandedId === alertKey) {
|
||||
setExpandedId(null);
|
||||
} else {
|
||||
setExpandedId(alertKey);
|
||||
// 获取股票数据
|
||||
if (alert.concept_id) {
|
||||
fetchConceptStocks(alert.concept_id);
|
||||
}
|
||||
}
|
||||
|
||||
// 通知父组件
|
||||
onAlertClick?.(alert);
|
||||
}, [expandedId, fetchConceptStocks, onAlertClick]);
|
||||
|
||||
if (!alerts || alerts.length === 0) {
|
||||
return (
|
||||
<Box p={4} textAlign="center">
|
||||
<Text color={subTextColor} fontSize="sm">
|
||||
当日暂无概念异动
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 按时间倒序排列
|
||||
const sortedAlerts = [...alerts].sort((a, b) => {
|
||||
const timeA = a.time || '00:00';
|
||||
const timeB = b.time || '00:00';
|
||||
return timeB.localeCompare(timeA);
|
||||
});
|
||||
|
||||
return (
|
||||
<Box maxH={maxHeight} overflowY="auto" pr={1}>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{sortedAlerts.map((alert, idx) => {
|
||||
const alertKey = `${alert.concept_id}-${alert.time}`;
|
||||
return (
|
||||
<AlertCard
|
||||
key={alertKey || idx}
|
||||
alert={alert}
|
||||
isExpanded={expandedId === alertKey}
|
||||
onToggle={() => handleToggle(alert)}
|
||||
stocks={conceptStocks[alert.concept_id]}
|
||||
loadingStocks={loadingConcepts[alert.concept_id]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConceptAlertList;
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 指数分时图组件
|
||||
* 展示大盘分时走势,支持概念异动标注
|
||||
*/
|
||||
import React, { useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Box, useColorModeValue } from '@chakra-ui/react';
|
||||
import * as echarts from 'echarts';
|
||||
import { getAlertMarkPoints } from '../utils/chartHelpers';
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {Object} props.indexData - 指数数据 { timeline, prev_close, name, ... }
|
||||
* @param {Array} props.alerts - 异动数据数组
|
||||
* @param {Function} props.onAlertClick - 点击异动标注的回调
|
||||
* @param {string} props.height - 图表高度
|
||||
*/
|
||||
const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350px' }) => {
|
||||
const chartRef = useRef(null);
|
||||
const chartInstance = useRef(null);
|
||||
|
||||
const textColor = useColorModeValue('gray.800', 'white');
|
||||
const subTextColor = useColorModeValue('gray.600', 'gray.400');
|
||||
const gridLineColor = useColorModeValue('#eee', '#333');
|
||||
|
||||
// 计算图表配置
|
||||
const chartOption = useMemo(() => {
|
||||
if (!indexData || !indexData.timeline || indexData.timeline.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeline = indexData.timeline || [];
|
||||
const times = timeline.map((d) => d.time);
|
||||
const prices = timeline.map((d) => d.price);
|
||||
const volumes = timeline.map((d) => d.volume);
|
||||
const changePcts = timeline.map((d) => d.change_pct);
|
||||
|
||||
// 计算Y轴范围
|
||||
const validPrices = prices.filter(Boolean);
|
||||
if (validPrices.length === 0) return null;
|
||||
|
||||
const priceMin = Math.min(...validPrices);
|
||||
const priceMax = Math.max(...validPrices);
|
||||
const priceRange = priceMax - priceMin;
|
||||
const yAxisMin = priceMin - priceRange * 0.1;
|
||||
const yAxisMax = priceMax + priceRange * 0.25; // 上方留更多空间给标注
|
||||
|
||||
// 准备异动标注
|
||||
const markPoints = getAlertMarkPoints(alerts, times, prices, priceMax);
|
||||
|
||||
// 渐变色 - 根据涨跌
|
||||
const latestChangePct = changePcts[changePcts.length - 1] || 0;
|
||||
const isUp = latestChangePct >= 0;
|
||||
const lineColor = isUp ? '#ff4d4d' : '#22c55e';
|
||||
const areaColorStops = isUp
|
||||
? [
|
||||
{ offset: 0, color: 'rgba(255, 77, 77, 0.4)' },
|
||||
{ offset: 1, color: 'rgba(255, 77, 77, 0.05)' },
|
||||
]
|
||||
: [
|
||||
{ offset: 0, color: 'rgba(34, 197, 94, 0.4)' },
|
||||
{ offset: 1, color: 'rgba(34, 197, 94, 0.05)' },
|
||||
];
|
||||
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
crossStyle: { color: '#999' },
|
||||
},
|
||||
formatter: (params) => {
|
||||
if (!params || params.length === 0) return '';
|
||||
|
||||
const dataIndex = params[0].dataIndex;
|
||||
const time = times[dataIndex];
|
||||
const price = prices[dataIndex];
|
||||
const changePct = changePcts[dataIndex];
|
||||
const volume = volumes[dataIndex];
|
||||
|
||||
let html = `
|
||||
<div style="padding: 8px;">
|
||||
<div style="font-weight: bold; margin-bottom: 4px;">${time}</div>
|
||||
<div>指数: <span style="color: ${changePct >= 0 ? '#ff4d4d' : '#22c55e'}; font-weight: bold;">${price?.toFixed(2)}</span></div>
|
||||
<div>涨跌: <span style="color: ${changePct >= 0 ? '#ff4d4d' : '#22c55e'};">${changePct >= 0 ? '+' : ''}${changePct?.toFixed(2)}%</span></div>
|
||||
<div>成交量: ${(volume / 10000).toFixed(0)}万手</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 检查是否有异动
|
||||
const alertsAtTime = alerts.filter((a) => a.time === time);
|
||||
if (alertsAtTime.length > 0) {
|
||||
html += '<div style="border-top: 1px solid #eee; margin-top: 4px; padding-top: 4px;">';
|
||||
html += '<div style="font-weight: bold; color: #ff6b6b;">概念异动:</div>';
|
||||
alertsAtTime.forEach((alert) => {
|
||||
const typeLabel = {
|
||||
surge: '急涨',
|
||||
surge_up: '暴涨',
|
||||
surge_down: '暴跌',
|
||||
limit_up: '涨停增加',
|
||||
rank_jump: '排名跃升',
|
||||
volume_spike: '放量',
|
||||
}[alert.alert_type] || alert.alert_type;
|
||||
const typeColor = alert.alert_type === 'surge_down' ? '#2ed573' : '#ff6b6b';
|
||||
const alpha = alert.alpha ? ` (α${alert.alpha > 0 ? '+' : ''}${alert.alpha.toFixed(2)}%)` : '';
|
||||
html += `<div style="color: ${typeColor}">• ${alert.concept_name} (${typeLabel}${alpha})</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
return html;
|
||||
},
|
||||
},
|
||||
legend: { show: false },
|
||||
grid: [
|
||||
{ left: '8%', right: '3%', top: '8%', height: '58%' },
|
||||
{ left: '8%', right: '3%', top: '72%', height: '18%' },
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
data: times,
|
||||
axisLine: { lineStyle: { color: gridLineColor } },
|
||||
axisLabel: {
|
||||
color: subTextColor,
|
||||
fontSize: 10,
|
||||
interval: Math.floor(times.length / 6),
|
||||
},
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
gridIndex: 1,
|
||||
data: times,
|
||||
axisLine: { lineStyle: { color: gridLineColor } },
|
||||
axisLabel: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
min: yAxisMin,
|
||||
max: yAxisMax,
|
||||
axisLine: { show: false },
|
||||
axisLabel: {
|
||||
color: subTextColor,
|
||||
fontSize: 10,
|
||||
formatter: (val) => val.toFixed(0),
|
||||
},
|
||||
splitLine: { lineStyle: { color: gridLineColor, type: 'dashed' } },
|
||||
axisPointer: {
|
||||
label: {
|
||||
formatter: (params) => {
|
||||
if (!indexData.prev_close) return params.value.toFixed(2);
|
||||
const pct = ((params.value - indexData.prev_close) / indexData.prev_close) * 100;
|
||||
return `${params.value.toFixed(2)} (${pct >= 0 ? '+' : ''}${pct.toFixed(2)}%)`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
gridIndex: 1,
|
||||
axisLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
// 分时线
|
||||
{
|
||||
name: indexData.name || '上证指数',
|
||||
type: 'line',
|
||||
data: prices,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: lineColor, width: 1.5 },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, areaColorStops),
|
||||
},
|
||||
markPoint: {
|
||||
symbol: 'pin',
|
||||
symbolSize: 40,
|
||||
data: markPoints,
|
||||
animation: true,
|
||||
},
|
||||
},
|
||||
// 成交量
|
||||
{
|
||||
name: '成交量',
|
||||
type: 'bar',
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
data: volumes.map((v, i) => ({
|
||||
value: v,
|
||||
itemStyle: {
|
||||
color: changePcts[i] >= 0 ? 'rgba(255, 77, 77, 0.6)' : 'rgba(34, 197, 94, 0.6)',
|
||||
},
|
||||
})),
|
||||
barWidth: '60%',
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [indexData, alerts, subTextColor, gridLineColor]);
|
||||
|
||||
// 渲染图表
|
||||
const renderChart = useCallback(() => {
|
||||
if (!chartRef.current || !chartOption) return;
|
||||
|
||||
if (!chartInstance.current) {
|
||||
chartInstance.current = echarts.init(chartRef.current);
|
||||
}
|
||||
|
||||
chartInstance.current.setOption(chartOption, true);
|
||||
|
||||
// 点击事件
|
||||
if (onAlertClick) {
|
||||
chartInstance.current.off('click');
|
||||
chartInstance.current.on('click', 'series.line.markPoint', (params) => {
|
||||
if (params.data && params.data.alertData) {
|
||||
onAlertClick(params.data.alertData);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [chartOption, onAlertClick]);
|
||||
|
||||
// 数据变化时重新渲染
|
||||
useEffect(() => {
|
||||
renderChart();
|
||||
}, [renderChart]);
|
||||
|
||||
// 窗口大小变化时重新渲染
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.resize();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.dispose();
|
||||
chartInstance.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!chartOption) {
|
||||
return (
|
||||
<Box h={height} display="flex" alignItems="center" justifyContent="center" color={subTextColor}>
|
||||
暂无数据
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <Box ref={chartRef} h={height} w="100%" />;
|
||||
};
|
||||
|
||||
export default IndexMinuteChart;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as IndexMinuteChart } from './IndexMinuteChart';
|
||||
export { default as ConceptAlertList } from './ConceptAlertList';
|
||||
export { default as AlertSummary } from './AlertSummary';
|
||||
@@ -0,0 +1 @@
|
||||
export { useHotspotData } from './useHotspotData';
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 热点概览数据获取 Hook
|
||||
* 负责获取指数分时数据和概念异动数据
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* @param {Date|null} selectedDate - 选中的交易日期
|
||||
* @returns {Object} 数据和状态
|
||||
*/
|
||||
export const useHotspotData = (selectedDate) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const dateParam = selectedDate
|
||||
? `?date=${selectedDate.toISOString().split('T')[0]}`
|
||||
: '';
|
||||
const response = await fetch(`/api/market/hotspot-overview${dateParam}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError(result.error || '获取数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('useHotspotData', 'fetchData', err);
|
||||
setError('网络请求失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
data,
|
||||
refetch: fetchData,
|
||||
};
|
||||
};
|
||||
|
||||
export default useHotspotData;
|
||||
198
src/views/StockOverview/components/HotspotOverview/index.js
Normal file
198
src/views/StockOverview/components/HotspotOverview/index.js
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 热点概览组件
|
||||
* 展示大盘分时走势 + 概念异动标注
|
||||
*
|
||||
* 模块化结构:
|
||||
* - hooks/useHotspotData.js - 数据获取
|
||||
* - components/IndexMinuteChart.js - 分时图
|
||||
* - components/ConceptAlertList.js - 异动列表
|
||||
* - components/AlertSummary.js - 统计摘要
|
||||
* - utils/chartHelpers.js - 图表辅助函数
|
||||
*/
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardBody,
|
||||
Heading,
|
||||
Text,
|
||||
HStack,
|
||||
VStack,
|
||||
Spinner,
|
||||
Center,
|
||||
Icon,
|
||||
Flex,
|
||||
Spacer,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
Grid,
|
||||
GridItem,
|
||||
Divider,
|
||||
IconButton,
|
||||
Collapse,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaFire, FaList, FaChartArea, FaChevronDown, FaChevronUp } from 'react-icons/fa';
|
||||
import { InfoIcon } from '@chakra-ui/icons';
|
||||
|
||||
import { useHotspotData } from './hooks';
|
||||
import { IndexMinuteChart, ConceptAlertList, AlertSummary } from './components';
|
||||
|
||||
/**
|
||||
* 热点概览主组件
|
||||
* @param {Object} props
|
||||
* @param {Date|null} props.selectedDate - 选中的交易日期
|
||||
*/
|
||||
const HotspotOverview = ({ selectedDate }) => {
|
||||
const [selectedAlert, setSelectedAlert] = useState(null);
|
||||
const [showAlertList, setShowAlertList] = useState(true);
|
||||
|
||||
// 获取数据
|
||||
const { loading, error, data } = useHotspotData(selectedDate);
|
||||
|
||||
// 颜色主题
|
||||
const cardBg = useColorModeValue('white', '#1a1a1a');
|
||||
const borderColor = useColorModeValue('gray.200', '#333333');
|
||||
const textColor = useColorModeValue('gray.800', 'white');
|
||||
const subTextColor = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
// 点击异动标注
|
||||
const handleAlertClick = useCallback((alert) => {
|
||||
setSelectedAlert(alert);
|
||||
// 可以在这里添加滚动到对应位置的逻辑
|
||||
}, []);
|
||||
|
||||
// 渲染加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Card bg={cardBg} borderWidth="1px" borderColor={borderColor}>
|
||||
<CardBody>
|
||||
<Center h="400px">
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" color="purple.500" thickness="4px" />
|
||||
<Text color={subTextColor}>加载热点概览数据...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染错误状态
|
||||
if (error) {
|
||||
return (
|
||||
<Card bg={cardBg} borderWidth="1px" borderColor={borderColor}>
|
||||
<CardBody>
|
||||
<Center h="400px">
|
||||
<VStack spacing={4}>
|
||||
<Icon as={InfoIcon} boxSize={10} color="red.400" />
|
||||
<Text color="red.500">{error}</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 无数据
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { index, alerts, alert_summary } = data;
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} borderWidth="1px" borderColor={borderColor}>
|
||||
<CardBody>
|
||||
{/* 头部 */}
|
||||
<Flex align="center" mb={4}>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FaFire} boxSize={6} color="orange.500" />
|
||||
<Heading size="md" color={textColor}>
|
||||
热点概览
|
||||
</Heading>
|
||||
</HStack>
|
||||
<Spacer />
|
||||
<HStack spacing={2}>
|
||||
<Tooltip label={showAlertList ? '收起异动列表' : '展开异动列表'}>
|
||||
<IconButton
|
||||
icon={showAlertList ? <FaChevronUp /> : <FaList />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowAlertList(!showAlertList)}
|
||||
aria-label="切换异动列表"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="展示大盘走势与概念异动的关联">
|
||||
<Icon as={InfoIcon} color={subTextColor} />
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 统计摘要 */}
|
||||
<Box mb={4}>
|
||||
<AlertSummary indexData={index} alerts={alerts} alertSummary={alert_summary} />
|
||||
</Box>
|
||||
|
||||
<Divider mb={4} />
|
||||
|
||||
{/* 主体内容:图表 + 异动列表 */}
|
||||
<Grid
|
||||
templateColumns={{ base: '1fr', lg: showAlertList ? '1fr 300px' : '1fr' }}
|
||||
gap={4}
|
||||
>
|
||||
{/* 分时图 */}
|
||||
<GridItem>
|
||||
<Box>
|
||||
<HStack spacing={2} mb={2}>
|
||||
<Icon as={FaChartArea} color="purple.500" boxSize={4} />
|
||||
<Text fontSize="sm" fontWeight="medium" color={textColor}>
|
||||
大盘分时走势
|
||||
</Text>
|
||||
</HStack>
|
||||
<IndexMinuteChart
|
||||
indexData={index}
|
||||
alerts={alerts}
|
||||
onAlertClick={handleAlertClick}
|
||||
height="350px"
|
||||
/>
|
||||
</Box>
|
||||
</GridItem>
|
||||
|
||||
{/* 异动列表(可收起) */}
|
||||
<Collapse in={showAlertList} animateOpacity>
|
||||
<GridItem>
|
||||
<Box>
|
||||
<HStack spacing={2} mb={2}>
|
||||
<Icon as={FaList} color="orange.500" boxSize={4} />
|
||||
<Text fontSize="sm" fontWeight="medium" color={textColor}>
|
||||
异动记录
|
||||
</Text>
|
||||
<Text fontSize="xs" color={subTextColor}>
|
||||
({alerts.length})
|
||||
</Text>
|
||||
</HStack>
|
||||
<ConceptAlertList
|
||||
alerts={alerts}
|
||||
onAlertClick={handleAlertClick}
|
||||
selectedAlert={selectedAlert}
|
||||
maxHeight="350px"
|
||||
/>
|
||||
</Box>
|
||||
</GridItem>
|
||||
</Collapse>
|
||||
</Grid>
|
||||
|
||||
{/* 无异动提示 */}
|
||||
{alerts.length === 0 && (
|
||||
<Center py={4}>
|
||||
<Text color={subTextColor} fontSize="sm">
|
||||
当日暂无概念异动数据
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default HotspotOverview;
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 图表辅助函数
|
||||
* 用于处理异动标注等图表相关逻辑
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取异动标注的配色和符号
|
||||
* @param {string} alertType - 异动类型
|
||||
* @param {number} importanceScore - 重要性得分
|
||||
* @returns {Object} { color, symbol, symbolSize }
|
||||
*/
|
||||
export const getAlertStyle = (alertType, importanceScore = 0.5) => {
|
||||
let color = '#ff6b6b';
|
||||
let symbol = 'pin';
|
||||
let symbolSize = 35;
|
||||
|
||||
switch (alertType) {
|
||||
case 'surge_up':
|
||||
case 'surge':
|
||||
color = '#ff4757';
|
||||
symbol = 'triangle';
|
||||
symbolSize = 30 + Math.min(importanceScore * 20, 15);
|
||||
break;
|
||||
case 'surge_down':
|
||||
color = '#2ed573';
|
||||
symbol = 'path://M0,0 L10,0 L5,10 Z'; // 向下三角形
|
||||
symbolSize = 30 + Math.min(importanceScore * 20, 15);
|
||||
break;
|
||||
case 'limit_up':
|
||||
color = '#ff6348';
|
||||
symbol = 'diamond';
|
||||
symbolSize = 28;
|
||||
break;
|
||||
case 'rank_jump':
|
||||
color = '#3742fa';
|
||||
symbol = 'circle';
|
||||
symbolSize = 25;
|
||||
break;
|
||||
case 'volume_spike':
|
||||
color = '#ffa502';
|
||||
symbol = 'rect';
|
||||
symbolSize = 25;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return { color, symbol, symbolSize };
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取异动类型的显示标签
|
||||
* @param {string} alertType - 异动类型
|
||||
* @returns {string} 显示标签
|
||||
*/
|
||||
export const getAlertTypeLabel = (alertType) => {
|
||||
const labels = {
|
||||
surge: '急涨',
|
||||
surge_up: '暴涨',
|
||||
surge_down: '暴跌',
|
||||
limit_up: '涨停增加',
|
||||
rank_jump: '排名跃升',
|
||||
volume_spike: '放量',
|
||||
unknown: '异动',
|
||||
};
|
||||
return labels[alertType] || alertType;
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成图表标注点数据
|
||||
* @param {Array} alerts - 异动数据数组
|
||||
* @param {Array} times - 时间数组
|
||||
* @param {Array} prices - 价格数组
|
||||
* @param {number} priceMax - 最高价格(用于无法匹配时间时的默认位置)
|
||||
* @param {number} maxCount - 最大显示数量
|
||||
* @returns {Array} ECharts markPoint data
|
||||
*/
|
||||
export const getAlertMarkPoints = (alerts, times, prices, priceMax, maxCount = 15) => {
|
||||
if (!alerts || alerts.length === 0) return [];
|
||||
|
||||
// 按重要性排序,限制显示数量
|
||||
const sortedAlerts = [...alerts]
|
||||
.sort((a, b) => (b.final_score || b.importance_score || 0) - (a.final_score || a.importance_score || 0))
|
||||
.slice(0, maxCount);
|
||||
|
||||
return sortedAlerts.map((alert) => {
|
||||
// 找到对应时间的价格
|
||||
const timeIndex = times.indexOf(alert.time);
|
||||
const price = timeIndex >= 0 ? prices[timeIndex] : (alert.index_price || priceMax);
|
||||
|
||||
const { color, symbol, symbolSize } = getAlertStyle(
|
||||
alert.alert_type,
|
||||
alert.final_score / 100 || alert.importance_score || 0.5
|
||||
);
|
||||
|
||||
// 格式化标签
|
||||
let label = alert.concept_name || '';
|
||||
if (label.length > 6) {
|
||||
label = label.substring(0, 5) + '...';
|
||||
}
|
||||
|
||||
// 添加涨停数量(如果有)
|
||||
if (alert.limit_up_count > 0) {
|
||||
label += `\n涨停: ${alert.limit_up_count}`;
|
||||
}
|
||||
|
||||
const isDown = alert.alert_type === 'surge_down';
|
||||
|
||||
return {
|
||||
name: alert.concept_name,
|
||||
coord: [alert.time, price],
|
||||
value: label,
|
||||
symbol,
|
||||
symbolSize,
|
||||
itemStyle: {
|
||||
color,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 1,
|
||||
shadowBlur: 3,
|
||||
shadowColor: 'rgba(0,0,0,0.2)',
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: isDown ? 'bottom' : 'top',
|
||||
formatter: '{b}',
|
||||
fontSize: 9,
|
||||
color: '#333',
|
||||
backgroundColor: isDown ? 'rgba(46, 213, 115, 0.9)' : 'rgba(255,255,255,0.9)',
|
||||
padding: [2, 4],
|
||||
borderRadius: 2,
|
||||
borderColor: color,
|
||||
borderWidth: 1,
|
||||
},
|
||||
alertData: alert, // 存储原始数据
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化分数显示
|
||||
* @param {number} score - 分数
|
||||
* @returns {string} 格式化后的分数
|
||||
*/
|
||||
export const formatScore = (score) => {
|
||||
if (score === null || score === undefined) return '-';
|
||||
return Math.round(score).toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取分数对应的颜色
|
||||
* @param {number} score - 分数 (0-100)
|
||||
* @returns {string} 颜色代码
|
||||
*/
|
||||
export const getScoreColor = (score) => {
|
||||
const s = score || 0;
|
||||
if (s >= 80) return '#ff4757';
|
||||
if (s >= 60) return '#ff6348';
|
||||
if (s >= 40) return '#ffa502';
|
||||
return '#747d8c';
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './chartHelpers';
|
||||
@@ -50,9 +50,11 @@ import {
|
||||
SkeletonText,
|
||||
} from '@chakra-ui/react';
|
||||
import { SearchIcon, CloseIcon, ArrowForwardIcon, TrendingUpIcon, InfoIcon, ChevronRightIcon, CalendarIcon } from '@chakra-ui/icons';
|
||||
import { FaChartLine, FaFire, FaRocket, FaBrain, FaCalendarAlt, FaChevronRight, FaArrowUp, FaArrowDown, FaChartBar } from 'react-icons/fa';
|
||||
import { FaChartLine, FaFire, FaRocket, FaBrain, FaCalendarAlt, FaChevronRight, FaArrowUp, FaArrowDown, FaChartBar, FaTag, FaLayerGroup, FaBolt } from 'react-icons/fa';
|
||||
import ConceptStocksModal from '@components/ConceptStocksModal';
|
||||
import TradeDatePicker from '@components/TradeDatePicker';
|
||||
import HotspotOverview from './components/HotspotOverview';
|
||||
import FlexScreen from './components/FlexScreen';
|
||||
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
|
||||
import * as echarts from 'echarts';
|
||||
import { logger } from '../../utils/logger';
|
||||
@@ -840,6 +842,16 @@ const StockOverview = () => {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 热点概览 - 大盘走势 + 概念异动 */}
|
||||
<Box mb={10}>
|
||||
<HotspotOverview selectedDate={selectedDate} />
|
||||
</Box>
|
||||
|
||||
{/* 灵活屏 - 实时行情监控 */}
|
||||
<Box mb={10}>
|
||||
<FlexScreen />
|
||||
</Box>
|
||||
|
||||
{/* 今日热门概念 */}
|
||||
<Box mb={10}>
|
||||
<Flex align="center" mb={6}>
|
||||
@@ -927,16 +939,65 @@ const StockOverview = () => {
|
||||
|
||||
<CardBody pt={12}>
|
||||
<VStack align="start" spacing={3}>
|
||||
{/* 概念名称 */}
|
||||
<Heading size="md" noOfLines={1} color={textColor}>
|
||||
{concept.concept_name}
|
||||
</Heading>
|
||||
|
||||
{/* 层级信息 */}
|
||||
{concept.hierarchy && (
|
||||
<HStack spacing={1} flexWrap="wrap">
|
||||
<Icon as={FaLayerGroup} boxSize={3} color="gray.400" />
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{[concept.hierarchy.lv1, concept.hierarchy.lv2, concept.hierarchy.lv3]
|
||||
.filter(Boolean)
|
||||
.join(' > ')}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 描述 */}
|
||||
<Text fontSize="sm" color={subTextColor} noOfLines={2}>
|
||||
{concept.description || '暂无描述'}
|
||||
</Text>
|
||||
|
||||
{/* 标签 */}
|
||||
{concept.tags && concept.tags.length > 0 && (
|
||||
<Flex flexWrap="wrap" gap={1}>
|
||||
{concept.tags.slice(0, 4).map((tag, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
borderRadius="full"
|
||||
>
|
||||
<Icon as={FaTag} boxSize={2} mr={1} />
|
||||
<TagLabel fontSize="xs">{tag}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
{concept.tags.length > 4 && (
|
||||
<Tag size="sm" variant="ghost" colorScheme="gray">
|
||||
<TagLabel fontSize="xs">+{concept.tags.length - 4}</TagLabel>
|
||||
</Tag>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* 爆发日期 */}
|
||||
{concept.outbreak_dates && concept.outbreak_dates.length > 0 && (
|
||||
<HStack spacing={2} fontSize="xs" color="orange.500">
|
||||
<Icon as={FaBolt} />
|
||||
<Text>
|
||||
近期爆发: {concept.outbreak_dates.slice(0, 2).join(', ')}
|
||||
{concept.outbreak_dates.length > 2 && ` 等${concept.outbreak_dates.length}次`}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 相关股票 */}
|
||||
<Box
|
||||
w="100%"
|
||||
cursor="pointer"
|
||||
@@ -957,7 +1018,7 @@ const StockOverview = () => {
|
||||
overflow="hidden"
|
||||
maxH="24px"
|
||||
>
|
||||
{concept.stocks.map((stock, idx) => (
|
||||
{concept.stocks.slice(0, 5).map((stock, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
size="sm"
|
||||
@@ -965,9 +1026,14 @@ const StockOverview = () => {
|
||||
variant="subtle"
|
||||
flexShrink={0}
|
||||
>
|
||||
<TagLabel>{stock.stock_name}</TagLabel>
|
||||
<TagLabel>{stock.stock_name || stock.name}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
{concept.stocks.length > 5 && (
|
||||
<Tag size="sm" variant="ghost" colorScheme="gray" flexShrink={0}>
|
||||
<TagLabel>+{concept.stocks.length - 5}</TagLabel>
|
||||
</Tag>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user