update pay ui
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 迷你分时图组件
|
||||
* 用于灵活屏中显示证券的日内走势
|
||||
*/
|
||||
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { Box, Spinner, Center, Text } from '@chakra-ui/react';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
/**
|
||||
* 生成交易时间刻度(用于 X 轴)
|
||||
* A股交易时间:9:30-11:30, 13:00-15:00
|
||||
*/
|
||||
const generateTimeTicks = () => {
|
||||
const ticks = [];
|
||||
// 上午
|
||||
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();
|
||||
|
||||
/**
|
||||
* MiniTimelineChart 组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.code - 证券代码
|
||||
* @param {boolean} props.isIndex - 是否为指数
|
||||
* @param {number} props.prevClose - 昨收价
|
||||
* @param {number} props.currentPrice - 当前价(实时)
|
||||
* @param {number} props.height - 图表高度
|
||||
*/
|
||||
const MiniTimelineChart = ({
|
||||
code,
|
||||
isIndex = false,
|
||||
prevClose,
|
||||
currentPrice,
|
||||
height = 120,
|
||||
}) => {
|
||||
const chartRef = useRef(null);
|
||||
const chartInstance = useRef(null);
|
||||
const [timelineData, setTimelineData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// 获取分钟数据
|
||||
useEffect(() => {
|
||||
if (!code) return;
|
||||
|
||||
const fetchData = async () => {
|
||||
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 = await response.json();
|
||||
|
||||
if (result.success !== false && result.data) {
|
||||
// 格式化数据
|
||||
const formatted = result.data.map(item => ({
|
||||
time: item.time || item.timestamp,
|
||||
price: item.close || item.price,
|
||||
}));
|
||||
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;
|
||||
if (isTrading) {
|
||||
intervalId = setInterval(fetchData, 60000); // 1分钟刷新
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
};
|
||||
}, [code, isIndex]);
|
||||
|
||||
// 合并实时价格到数据中
|
||||
const chartData = useMemo(() => {
|
||||
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 = {
|
||||
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 = () => {
|
||||
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,275 @@
|
||||
/**
|
||||
* 盘口行情面板组件
|
||||
* 支持显示 5 档或 10 档买卖盘数据
|
||||
*
|
||||
* 上交所: 5 档行情
|
||||
* 深交所: 10 档行情
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
useColorModeValue,
|
||||
Tooltip,
|
||||
Badge,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 格式化成交量
|
||||
* @param {number} volume - 成交量(股)
|
||||
* @returns {string} 格式化后的字符串
|
||||
*/
|
||||
const formatVolume = (volume) => {
|
||||
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);
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化价格
|
||||
* @param {number} price - 价格
|
||||
* @param {number} prevClose - 昨收价
|
||||
* @returns {Object} { text, color }
|
||||
*/
|
||||
const formatPrice = (price, prevClose) => {
|
||||
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' };
|
||||
};
|
||||
|
||||
/**
|
||||
* 单行盘口
|
||||
*/
|
||||
const OrderRow = ({ 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 组件
|
||||
* @param {Object} props
|
||||
* @param {number[]} props.bidPrices - 买档价格(最多10档)
|
||||
* @param {number[]} props.bidVolumes - 买档量
|
||||
* @param {number[]} props.askPrices - 卖档价格(最多10档)
|
||||
* @param {number[]} props.askVolumes - 卖档量
|
||||
* @param {number} props.prevClose - 昨收价
|
||||
* @param {number} props.upperLimit - 涨停价
|
||||
* @param {number} props.lowerLimit - 跌停价
|
||||
* @param {number} props.defaultLevels - 默认显示档数(5 或 10)
|
||||
*/
|
||||
const OrderBookPanel = ({
|
||||
bidPrices = [],
|
||||
bidVolumes = [],
|
||||
askPrices = [],
|
||||
askVolumes = [],
|
||||
prevClose,
|
||||
upperLimit,
|
||||
lowerLimit,
|
||||
defaultLevels = 5,
|
||||
}) => {
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const buttonBg = useColorModeValue('gray.100', 'gray.700');
|
||||
|
||||
// 可切换显示的档位数
|
||||
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) => upperLimit && Math.abs(price - upperLimit) < 0.001;
|
||||
const isLowerLimit = (price) => lowerLimit && Math.abs(price - lowerLimit) < 0.001;
|
||||
|
||||
// 卖盘(从卖N到卖1,即价格从高到低)
|
||||
const askRows = [];
|
||||
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 = [];
|
||||
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={useColorModeValue('white', '#1a1a1a')}
|
||||
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,270 @@
|
||||
/**
|
||||
* 行情瓷砖组件
|
||||
* 单个证券的实时行情展示卡片,包含分时图和五档盘口
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
Collapse,
|
||||
Badge,
|
||||
Flex,
|
||||
Spacer,
|
||||
} from '@chakra-ui/react';
|
||||
import { CloseIcon, ChevronDownIcon, ChevronUpIcon, ExternalLinkIcon } from '@chakra-ui/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import MiniTimelineChart from './MiniTimelineChart';
|
||||
import OrderBookPanel from './OrderBookPanel';
|
||||
|
||||
/**
|
||||
* 格式化价格显示
|
||||
*/
|
||||
const formatPrice = (price) => {
|
||||
if (!price || isNaN(price)) return '-';
|
||||
return price.toFixed(2);
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化涨跌幅
|
||||
*/
|
||||
const formatChangePct = (pct) => {
|
||||
if (!pct || isNaN(pct)) return '0.00%';
|
||||
const sign = pct > 0 ? '+' : '';
|
||||
return `${sign}${pct.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化涨跌额
|
||||
*/
|
||||
const formatChange = (change) => {
|
||||
if (!change || isNaN(change)) return '-';
|
||||
const sign = change > 0 ? '+' : '';
|
||||
return `${sign}${change.toFixed(2)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化成交额
|
||||
*/
|
||||
const formatAmount = (amount) => {
|
||||
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 组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.code - 证券代码
|
||||
* @param {string} props.name - 证券名称
|
||||
* @param {Object} props.quote - 实时行情数据
|
||||
* @param {boolean} props.isIndex - 是否为指数
|
||||
* @param {Function} props.onRemove - 移除回调
|
||||
*/
|
||||
const QuoteTile = ({
|
||||
code,
|
||||
name,
|
||||
quote = {},
|
||||
isIndex = false,
|
||||
onRemove,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
// 颜色主题
|
||||
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 } = quote;
|
||||
const priceColor = useColorModeValue(
|
||||
!prevClose || price === prevClose ? 'gray.800' :
|
||||
price > prevClose ? 'red.500' : 'green.500',
|
||||
!prevClose || price === prevClose ? 'gray.200' :
|
||||
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 = () => {
|
||||
if (isIndex) {
|
||||
// 指数暂无详情页
|
||||
return;
|
||||
}
|
||||
navigate(`/company?scode=${code}`);
|
||||
};
|
||||
|
||||
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(quote.open)}</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}>
|
||||
盘口 {quote.bidPrices?.length > 5 ? '(10档)' : '(5档)'}
|
||||
</Text>
|
||||
<OrderBookPanel
|
||||
bidPrices={quote.bidPrices || []}
|
||||
bidVolumes={quote.bidVolumes || []}
|
||||
askPrices={quote.askPrices || []}
|
||||
askVolumes={quote.askVolumes || []}
|
||||
prevClose={prevClose}
|
||||
upperLimit={quote.upperLimit}
|
||||
lowerLimit={quote.lowerLimit}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuoteTile;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as MiniTimelineChart } from './MiniTimelineChart';
|
||||
export { default as OrderBookPanel } from './OrderBookPanel';
|
||||
export { default as QuoteTile } from './QuoteTile';
|
||||
@@ -0,0 +1 @@
|
||||
export { useRealtimeQuote } from './useRealtimeQuote';
|
||||
@@ -0,0 +1,692 @@
|
||||
/**
|
||||
* 实时行情 Hook
|
||||
* 管理上交所和深交所 WebSocket 连接,获取实时行情数据
|
||||
*
|
||||
* 上交所 (SSE): ws://49.232.185.254:8765 - 需主动订阅,提供五档行情
|
||||
* 深交所 (SZSE): ws://222.128.1.157:8765 - 自动推送,提供十档行情
|
||||
*
|
||||
* 深交所支持的数据类型 (category):
|
||||
* - stock (300111): 股票快照,含10档买卖盘
|
||||
* - bond (300211): 债券快照
|
||||
* - afterhours_block (300611): 盘后定价大宗交易
|
||||
* - afterhours_trading (303711): 盘后定价交易
|
||||
* - hk_stock (306311): 港股快照(深港通)
|
||||
* - index (309011): 指数快照
|
||||
* - volume_stats (309111): 成交量统计
|
||||
* - fund_nav (309211): 基金净值
|
||||
*/
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
// WebSocket 地址配置
|
||||
const WS_CONFIG = {
|
||||
SSE: 'ws://49.232.185.254:8765', // 上交所
|
||||
SZSE: 'ws://222.128.1.157:8765', // 深交所
|
||||
};
|
||||
|
||||
// 心跳间隔 (ms)
|
||||
const HEARTBEAT_INTERVAL = 30000;
|
||||
|
||||
// 重连间隔 (ms)
|
||||
const RECONNECT_INTERVAL = 3000;
|
||||
|
||||
/**
|
||||
* 判断证券代码属于哪个交易所
|
||||
* @param {string} code - 证券代码(可带或不带后缀)
|
||||
* @returns {'SSE'|'SZSE'} 交易所标识
|
||||
*/
|
||||
const getExchange = (code) => {
|
||||
const baseCode = code.split('.')[0];
|
||||
|
||||
// 6开头为上海股票
|
||||
if (baseCode.startsWith('6')) {
|
||||
return 'SSE';
|
||||
}
|
||||
|
||||
// 000开头的6位数可能是上证指数或深圳股票
|
||||
if (baseCode.startsWith('000') && baseCode.length === 6) {
|
||||
// 000001-000999 是上证指数范围,但 000001 也是平安银行
|
||||
// 这里需要更精确的判断,暂时把 000 开头当深圳
|
||||
return 'SZSE';
|
||||
}
|
||||
|
||||
// 399开头是深证指数
|
||||
if (baseCode.startsWith('399')) {
|
||||
return 'SZSE';
|
||||
}
|
||||
|
||||
// 0、3开头是深圳股票
|
||||
if (baseCode.startsWith('0') || baseCode.startsWith('3')) {
|
||||
return 'SZSE';
|
||||
}
|
||||
|
||||
// 5开头是上海 ETF
|
||||
if (baseCode.startsWith('5')) {
|
||||
return 'SSE';
|
||||
}
|
||||
|
||||
// 1开头是深圳 ETF/债券
|
||||
if (baseCode.startsWith('1')) {
|
||||
return 'SZSE';
|
||||
}
|
||||
|
||||
// 默认上海
|
||||
return 'SSE';
|
||||
};
|
||||
|
||||
/**
|
||||
* 标准化证券代码为无后缀格式
|
||||
*/
|
||||
const normalizeCode = (code) => {
|
||||
return code.split('.')[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* 从深交所 bids/asks 数组提取价格和量数组
|
||||
* @param {Array} orderBook - [{price, volume}, ...]
|
||||
* @returns {{ prices: number[], volumes: number[] }}
|
||||
*/
|
||||
const extractOrderBook = (orderBook) => {
|
||||
if (!orderBook || !Array.isArray(orderBook)) {
|
||||
return { prices: [], volumes: [] };
|
||||
}
|
||||
const prices = orderBook.map(item => item.price || 0);
|
||||
const volumes = orderBook.map(item => item.volume || 0);
|
||||
return { prices, volumes };
|
||||
};
|
||||
|
||||
/**
|
||||
* 实时行情 Hook
|
||||
* @param {string[]} codes - 订阅的证券代码列表
|
||||
* @returns {Object} { quotes, connected, subscribe, unsubscribe }
|
||||
*/
|
||||
export const useRealtimeQuote = (codes = []) => {
|
||||
// 行情数据 { [code]: QuoteData }
|
||||
const [quotes, setQuotes] = useState({});
|
||||
// 连接状态 { SSE: boolean, SZSE: boolean }
|
||||
const [connected, setConnected] = useState({ SSE: false, SZSE: false });
|
||||
|
||||
// WebSocket 实例引用
|
||||
const wsRefs = useRef({ SSE: null, SZSE: null });
|
||||
// 心跳定时器
|
||||
const heartbeatRefs = useRef({ SSE: null, SZSE: null });
|
||||
// 重连定时器
|
||||
const reconnectRefs = useRef({ SSE: null, SZSE: null });
|
||||
// 当前订阅的代码(按交易所分组)
|
||||
const subscribedCodes = useRef({ SSE: new Set(), SZSE: new Set() });
|
||||
|
||||
/**
|
||||
* 创建 WebSocket 连接
|
||||
*/
|
||||
const createConnection = useCallback((exchange) => {
|
||||
// 清理现有连接
|
||||
if (wsRefs.current[exchange]) {
|
||||
wsRefs.current[exchange].close();
|
||||
}
|
||||
|
||||
const ws = new WebSocket(WS_CONFIG[exchange]);
|
||||
wsRefs.current[exchange] = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
logger.info('FlexScreen', `${exchange} WebSocket 已连接`);
|
||||
setConnected(prev => ({ ...prev, [exchange]: true }));
|
||||
|
||||
// 上交所需要主动订阅
|
||||
if (exchange === 'SSE') {
|
||||
const codes = Array.from(subscribedCodes.current.SSE);
|
||||
if (codes.length > 0) {
|
||||
ws.send(JSON.stringify({
|
||||
action: 'subscribe',
|
||||
channels: ['stock', 'index'],
|
||||
codes: codes,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 启动心跳
|
||||
startHeartbeat(exchange);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
handleMessage(exchange, msg);
|
||||
} catch (e) {
|
||||
logger.warn('FlexScreen', `${exchange} 消息解析失败`, e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
logger.error('FlexScreen', `${exchange} WebSocket 错误`, error);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
logger.info('FlexScreen', `${exchange} WebSocket 断开`);
|
||||
setConnected(prev => ({ ...prev, [exchange]: false }));
|
||||
stopHeartbeat(exchange);
|
||||
|
||||
// 自动重连
|
||||
scheduleReconnect(exchange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 处理 WebSocket 消息
|
||||
*/
|
||||
const handleMessage = useCallback((exchange, msg) => {
|
||||
// 处理 pong
|
||||
if (msg.type === 'pong') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (exchange === 'SSE') {
|
||||
// 上交所消息格式
|
||||
if (msg.type === 'stock' || msg.type === 'index') {
|
||||
const data = msg.data || {};
|
||||
setQuotes(prev => {
|
||||
const updated = { ...prev };
|
||||
Object.entries(data).forEach(([code, quote]) => {
|
||||
// 只更新订阅的代码
|
||||
if (subscribedCodes.current.SSE.has(code)) {
|
||||
updated[code] = {
|
||||
code: quote.security_id,
|
||||
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: quote.prev_close ? ((quote.last_price - quote.prev_close) / quote.prev_close * 100) : 0,
|
||||
bidPrices: quote.bid_prices || [],
|
||||
bidVolumes: quote.bid_volumes || [],
|
||||
askPrices: quote.ask_prices || [],
|
||||
askVolumes: quote.ask_volumes || [],
|
||||
updateTime: quote.trade_time,
|
||||
exchange: 'SSE',
|
||||
};
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
} else if (exchange === 'SZSE') {
|
||||
// 深交所消息格式(更新后的 API)
|
||||
if (msg.type === 'realtime') {
|
||||
const { category, data } = msg;
|
||||
const code = data.security_id;
|
||||
|
||||
// 只更新订阅的代码
|
||||
if (!subscribedCodes.current.SZSE.has(code)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (category === 'stock') {
|
||||
// 股票行情 - 含 10 档买卖盘
|
||||
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(data.bids);
|
||||
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(data.asks);
|
||||
|
||||
setQuotes(prev => ({
|
||||
...prev,
|
||||
[code]: {
|
||||
code: code,
|
||||
name: prev[code]?.name || '',
|
||||
price: data.last_px,
|
||||
prevClose: data.prev_close,
|
||||
open: data.open_px,
|
||||
high: data.high_px,
|
||||
low: data.low_px,
|
||||
volume: data.volume,
|
||||
amount: data.amount,
|
||||
numTrades: data.num_trades,
|
||||
upperLimit: data.upper_limit, // 涨停价
|
||||
lowerLimit: data.lower_limit, // 跌停价
|
||||
change: data.last_px - data.prev_close,
|
||||
changePct: data.prev_close ? ((data.last_px - data.prev_close) / data.prev_close * 100) : 0,
|
||||
bidPrices,
|
||||
bidVolumes,
|
||||
askPrices,
|
||||
askVolumes,
|
||||
tradingPhase: data.trading_phase,
|
||||
updateTime: msg.timestamp,
|
||||
exchange: 'SZSE',
|
||||
},
|
||||
}));
|
||||
} else if (category === 'index') {
|
||||
// 指数行情
|
||||
setQuotes(prev => ({
|
||||
...prev,
|
||||
[code]: {
|
||||
code: code,
|
||||
name: prev[code]?.name || '',
|
||||
price: data.current_index,
|
||||
prevClose: data.prev_close,
|
||||
open: data.open_index,
|
||||
high: data.high_index,
|
||||
low: data.low_index,
|
||||
close: data.close_index,
|
||||
volume: data.volume,
|
||||
amount: data.amount,
|
||||
numTrades: data.num_trades,
|
||||
change: data.current_index - data.prev_close,
|
||||
changePct: data.prev_close ? ((data.current_index - data.prev_close) / data.prev_close * 100) : 0,
|
||||
bidPrices: [],
|
||||
bidVolumes: [],
|
||||
askPrices: [],
|
||||
askVolumes: [],
|
||||
tradingPhase: data.trading_phase,
|
||||
updateTime: msg.timestamp,
|
||||
exchange: 'SZSE',
|
||||
},
|
||||
}));
|
||||
} else if (category === 'bond') {
|
||||
// 债券行情
|
||||
setQuotes(prev => ({
|
||||
...prev,
|
||||
[code]: {
|
||||
code: code,
|
||||
name: prev[code]?.name || '',
|
||||
price: data.last_px,
|
||||
prevClose: data.prev_close,
|
||||
open: data.open_px,
|
||||
high: data.high_px,
|
||||
low: data.low_px,
|
||||
volume: data.volume,
|
||||
amount: data.amount,
|
||||
numTrades: data.num_trades,
|
||||
weightedAvgPx: data.weighted_avg_px,
|
||||
change: data.last_px - data.prev_close,
|
||||
changePct: data.prev_close ? ((data.last_px - data.prev_close) / data.prev_close * 100) : 0,
|
||||
bidPrices: [],
|
||||
bidVolumes: [],
|
||||
askPrices: [],
|
||||
askVolumes: [],
|
||||
tradingPhase: data.trading_phase,
|
||||
updateTime: msg.timestamp,
|
||||
exchange: 'SZSE',
|
||||
isBond: true,
|
||||
},
|
||||
}));
|
||||
} else if (category === 'hk_stock') {
|
||||
// 港股行情(深港通)
|
||||
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(data.bids);
|
||||
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(data.asks);
|
||||
|
||||
setQuotes(prev => ({
|
||||
...prev,
|
||||
[code]: {
|
||||
code: code,
|
||||
name: prev[code]?.name || '',
|
||||
price: data.last_px,
|
||||
prevClose: data.prev_close,
|
||||
open: data.open_px,
|
||||
high: data.high_px,
|
||||
low: data.low_px,
|
||||
volume: data.volume,
|
||||
amount: data.amount,
|
||||
numTrades: data.num_trades,
|
||||
nominalPx: data.nominal_px, // 按盘价
|
||||
referencePx: data.reference_px, // 参考价
|
||||
change: data.last_px - data.prev_close,
|
||||
changePct: data.prev_close ? ((data.last_px - data.prev_close) / data.prev_close * 100) : 0,
|
||||
bidPrices,
|
||||
bidVolumes,
|
||||
askPrices,
|
||||
askVolumes,
|
||||
tradingPhase: data.trading_phase,
|
||||
updateTime: msg.timestamp,
|
||||
exchange: 'SZSE',
|
||||
isHK: true,
|
||||
},
|
||||
}));
|
||||
} else if (category === 'afterhours_block' || category === 'afterhours_trading') {
|
||||
// 盘后交易
|
||||
setQuotes(prev => ({
|
||||
...prev,
|
||||
[code]: {
|
||||
...prev[code],
|
||||
afterhours: {
|
||||
bidPx: data.bid_px,
|
||||
bidSize: data.bid_size,
|
||||
offerPx: data.offer_px,
|
||||
offerSize: data.offer_size,
|
||||
volume: data.volume,
|
||||
amount: data.amount,
|
||||
numTrades: data.num_trades,
|
||||
},
|
||||
updateTime: msg.timestamp,
|
||||
},
|
||||
}));
|
||||
}
|
||||
// fund_nav 和 volume_stats 暂不处理
|
||||
} else if (msg.type === 'snapshot') {
|
||||
// 深交所初始快照
|
||||
const { stocks = [], indexes = [], bonds = [] } = msg.data || {};
|
||||
setQuotes(prev => {
|
||||
const updated = { ...prev };
|
||||
|
||||
stocks.forEach(s => {
|
||||
if (subscribedCodes.current.SZSE.has(s.security_id)) {
|
||||
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(s.bids);
|
||||
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(s.asks);
|
||||
|
||||
updated[s.security_id] = {
|
||||
code: s.security_id,
|
||||
name: s.security_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: s.prev_close ? ((s.last_px - s.prev_close) / s.prev_close * 100) : 0,
|
||||
bidPrices,
|
||||
bidVolumes,
|
||||
askPrices,
|
||||
askVolumes,
|
||||
exchange: 'SZSE',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
indexes.forEach(i => {
|
||||
if (subscribedCodes.current.SZSE.has(i.security_id)) {
|
||||
updated[i.security_id] = {
|
||||
code: i.security_id,
|
||||
name: i.security_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: i.prev_close ? ((i.current_index - i.prev_close) / i.prev_close * 100) : 0,
|
||||
bidPrices: [],
|
||||
bidVolumes: [],
|
||||
askPrices: [],
|
||||
askVolumes: [],
|
||||
exchange: 'SZSE',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
bonds.forEach(b => {
|
||||
if (subscribedCodes.current.SZSE.has(b.security_id)) {
|
||||
updated[b.security_id] = {
|
||||
code: b.security_id,
|
||||
name: b.security_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: b.prev_close ? ((b.last_px - b.prev_close) / b.prev_close * 100) : 0,
|
||||
bidPrices: [],
|
||||
bidVolumes: [],
|
||||
askPrices: [],
|
||||
askVolumes: [],
|
||||
exchange: 'SZSE',
|
||||
isBond: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 启动心跳
|
||||
*/
|
||||
const startHeartbeat = useCallback((exchange) => {
|
||||
stopHeartbeat(exchange);
|
||||
heartbeatRefs.current[exchange] = setInterval(() => {
|
||||
const ws = wsRefs.current[exchange];
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
if (exchange === 'SSE') {
|
||||
ws.send(JSON.stringify({ action: 'ping' }));
|
||||
} else {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 停止心跳
|
||||
*/
|
||||
const stopHeartbeat = useCallback((exchange) => {
|
||||
if (heartbeatRefs.current[exchange]) {
|
||||
clearInterval(heartbeatRefs.current[exchange]);
|
||||
heartbeatRefs.current[exchange] = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 安排重连
|
||||
*/
|
||||
const scheduleReconnect = useCallback((exchange) => {
|
||||
if (reconnectRefs.current[exchange]) {
|
||||
return; // 已有重连计划
|
||||
}
|
||||
|
||||
reconnectRefs.current[exchange] = setTimeout(() => {
|
||||
reconnectRefs.current[exchange] = null;
|
||||
// 只有还有订阅的代码才重连
|
||||
if (subscribedCodes.current[exchange].size > 0) {
|
||||
createConnection(exchange);
|
||||
}
|
||||
}, RECONNECT_INTERVAL);
|
||||
}, [createConnection]);
|
||||
|
||||
/**
|
||||
* 订阅证券
|
||||
*/
|
||||
const subscribe = useCallback((code) => {
|
||||
const baseCode = normalizeCode(code);
|
||||
const exchange = getExchange(code);
|
||||
|
||||
// 添加到订阅列表
|
||||
subscribedCodes.current[exchange].add(baseCode);
|
||||
|
||||
// 如果连接已建立,发送订阅消息(仅上交所需要)
|
||||
const ws = wsRefs.current[exchange];
|
||||
if (exchange === 'SSE' && ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
action: 'subscribe',
|
||||
channels: ['stock', 'index'],
|
||||
codes: [baseCode],
|
||||
}));
|
||||
}
|
||||
|
||||
// 如果连接未建立,创建连接
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
createConnection(exchange);
|
||||
}
|
||||
}, [createConnection]);
|
||||
|
||||
/**
|
||||
* 取消订阅
|
||||
*/
|
||||
const unsubscribe = useCallback((code) => {
|
||||
const baseCode = normalizeCode(code);
|
||||
const exchange = getExchange(code);
|
||||
|
||||
// 从订阅列表移除
|
||||
subscribedCodes.current[exchange].delete(baseCode);
|
||||
|
||||
// 从 quotes 中移除
|
||||
setQuotes(prev => {
|
||||
const updated = { ...prev };
|
||||
delete updated[baseCode];
|
||||
return updated;
|
||||
});
|
||||
|
||||
// 如果该交易所没有订阅了,关闭连接
|
||||
if (subscribedCodes.current[exchange].size === 0) {
|
||||
const ws = wsRefs.current[exchange];
|
||||
if (ws) {
|
||||
ws.close();
|
||||
wsRefs.current[exchange] = null;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 初始化订阅
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!codes || codes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 按交易所分组
|
||||
const sseCodesSet = new Set();
|
||||
const szseCodesSet = new Set();
|
||||
|
||||
codes.forEach(code => {
|
||||
const baseCode = normalizeCode(code);
|
||||
const exchange = getExchange(code);
|
||||
if (exchange === 'SSE') {
|
||||
sseCodesSet.add(baseCode);
|
||||
} else {
|
||||
szseCodesSet.add(baseCode);
|
||||
}
|
||||
});
|
||||
|
||||
// 更新订阅列表
|
||||
subscribedCodes.current.SSE = sseCodesSet;
|
||||
subscribedCodes.current.SZSE = szseCodesSet;
|
||||
|
||||
// 建立连接
|
||||
if (sseCodesSet.size > 0) {
|
||||
createConnection('SSE');
|
||||
}
|
||||
if (szseCodesSet.size > 0) {
|
||||
createConnection('SZSE');
|
||||
}
|
||||
|
||||
// 清理
|
||||
return () => {
|
||||
['SSE', 'SZSE'].forEach(exchange => {
|
||||
stopHeartbeat(exchange);
|
||||
if (reconnectRefs.current[exchange]) {
|
||||
clearTimeout(reconnectRefs.current[exchange]);
|
||||
}
|
||||
const ws = wsRefs.current[exchange];
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
};
|
||||
}, []); // 只在挂载时执行
|
||||
|
||||
/**
|
||||
* 处理 codes 变化
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!codes) return;
|
||||
|
||||
// 计算新的订阅列表
|
||||
const newSseCodes = new Set();
|
||||
const newSzseCodes = new Set();
|
||||
|
||||
codes.forEach(code => {
|
||||
const baseCode = normalizeCode(code);
|
||||
const exchange = getExchange(code);
|
||||
if (exchange === 'SSE') {
|
||||
newSseCodes.add(baseCode);
|
||||
} else {
|
||||
newSzseCodes.add(baseCode);
|
||||
}
|
||||
});
|
||||
|
||||
// 找出需要新增和删除的代码
|
||||
const oldSseCodes = subscribedCodes.current.SSE;
|
||||
const oldSzseCodes = subscribedCodes.current.SZSE;
|
||||
|
||||
// 更新上交所订阅
|
||||
const sseToAdd = [...newSseCodes].filter(c => !oldSseCodes.has(c));
|
||||
const sseToRemove = [...oldSseCodes].filter(c => !newSseCodes.has(c));
|
||||
|
||||
if (sseToAdd.length > 0 || sseToRemove.length > 0) {
|
||||
subscribedCodes.current.SSE = newSseCodes;
|
||||
|
||||
const ws = wsRefs.current.SSE;
|
||||
if (ws && ws.readyState === WebSocket.OPEN && sseToAdd.length > 0) {
|
||||
ws.send(JSON.stringify({
|
||||
action: 'subscribe',
|
||||
channels: ['stock', 'index'],
|
||||
codes: sseToAdd,
|
||||
}));
|
||||
}
|
||||
|
||||
// 如果新增了代码但连接未建立
|
||||
if (sseToAdd.length > 0 && (!ws || ws.readyState !== WebSocket.OPEN)) {
|
||||
createConnection('SSE');
|
||||
}
|
||||
|
||||
// 如果没有订阅了,关闭连接
|
||||
if (newSseCodes.size === 0 && ws) {
|
||||
ws.close();
|
||||
wsRefs.current.SSE = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新深交所订阅
|
||||
const szseToAdd = [...newSzseCodes].filter(c => !oldSzseCodes.has(c));
|
||||
const szseToRemove = [...oldSzseCodes].filter(c => !newSzseCodes.has(c));
|
||||
|
||||
if (szseToAdd.length > 0 || szseToRemove.length > 0) {
|
||||
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 removedCodes = [...sseToRemove, ...szseToRemove];
|
||||
if (removedCodes.length > 0) {
|
||||
setQuotes(prev => {
|
||||
const updated = { ...prev };
|
||||
removedCodes.forEach(code => {
|
||||
delete updated[code];
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}, [codes, createConnection]);
|
||||
|
||||
return {
|
||||
quotes,
|
||||
connected,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
};
|
||||
};
|
||||
|
||||
export default useRealtimeQuote;
|
||||
463
src/views/StockOverview/components/FlexScreen/index.js
Normal file
463
src/views/StockOverview/components/FlexScreen/index.js
Normal file
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* 灵活屏组件
|
||||
* 用户可自定义添加关注的指数/个股,实时显示行情
|
||||
*
|
||||
* 功能:
|
||||
* 1. 添加/删除自选证券
|
||||
* 2. 显示实时行情(通过 WebSocket)
|
||||
* 3. 显示分时走势(结合 ClickHouse 历史数据)
|
||||
* 4. 显示五档盘口(上交所完整五档,深交所买一卖一)
|
||||
* 5. 本地存储自选列表
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardBody,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
InputRightElement,
|
||||
IconButton,
|
||||
Button,
|
||||
SimpleGrid,
|
||||
Flex,
|
||||
Spacer,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Badge,
|
||||
Tooltip,
|
||||
Collapse,
|
||||
List,
|
||||
ListItem,
|
||||
Spinner,
|
||||
Center,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Divider,
|
||||
Tag,
|
||||
TagLabel,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
SearchIcon,
|
||||
CloseIcon,
|
||||
AddIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
SettingsIcon,
|
||||
} from '@chakra-ui/icons';
|
||||
import { FaDesktop, FaPlus, FaTrash, FaSync, FaWifi, FaExclamationCircle } from 'react-icons/fa';
|
||||
|
||||
import { useRealtimeQuote } from './hooks';
|
||||
import { QuoteTile } from './components';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
// 本地存储 key
|
||||
const STORAGE_KEY = 'flexscreen_watchlist';
|
||||
|
||||
// 默认自选列表
|
||||
const DEFAULT_WATCHLIST = [
|
||||
{ code: '000001', name: '上证指数', isIndex: true },
|
||||
{ code: '399001', name: '深证成指', isIndex: true },
|
||||
{ code: '399006', name: '创业板指', isIndex: true },
|
||||
];
|
||||
|
||||
// 热门推荐
|
||||
const HOT_RECOMMENDATIONS = [
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
/**
|
||||
* FlexScreen 组件
|
||||
*/
|
||||
const FlexScreen = () => {
|
||||
const toast = useToast();
|
||||
|
||||
// 自选列表
|
||||
const [watchlist, setWatchlist] = useState([]);
|
||||
// 搜索状态
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
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');
|
||||
|
||||
// 获取订阅的证券代码列表
|
||||
const subscribedCodes = useMemo(() => {
|
||||
return watchlist.map(item => item.code);
|
||||
}, [watchlist]);
|
||||
|
||||
// WebSocket 实时行情
|
||||
const { quotes, connected } = useRealtimeQuote(subscribedCodes);
|
||||
|
||||
// 从本地存储加载自选列表
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
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) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([]);
|
||||
setShowResults(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const response = await fetch(`/api/stocks/search?q=${encodeURIComponent(query)}&limit=10`);
|
||||
const data = 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) => {
|
||||
const code = security.stock_code || security.code;
|
||||
const name = security.stock_name || security.name;
|
||||
const isIndex = security.isIndex || code.startsWith('000') || code.startsWith('399');
|
||||
|
||||
// 检查是否已存在
|
||||
if (watchlist.some(item => item.code === code)) {
|
||||
toast({
|
||||
title: '已在自选列表中',
|
||||
status: 'info',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加到列表
|
||||
setWatchlist(prev => [...prev, { code, name, isIndex }]);
|
||||
|
||||
toast({
|
||||
title: `已添加 ${name}`,
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// 清空搜索
|
||||
setSearchQuery('');
|
||||
setShowResults(false);
|
||||
}, [watchlist, toast]);
|
||||
|
||||
// 移除证券
|
||||
const removeSecurity = useCallback((code) => {
|
||||
setWatchlist(prev => prev.filter(item => item.code !== code));
|
||||
}, []);
|
||||
|
||||
// 清空自选列表
|
||||
const clearWatchlist = useCallback(() => {
|
||||
setWatchlist([]);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
toast({
|
||||
title: '已清空自选列表',
|
||||
status: 'info',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
}, [toast]);
|
||||
|
||||
// 重置为默认列表
|
||||
const resetWatchlist = useCallback(() => {
|
||||
setWatchlist(DEFAULT_WATCHLIST);
|
||||
toast({
|
||||
title: '已重置为默认列表',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
}, [toast]);
|
||||
|
||||
// 连接状态指示
|
||||
const isAnyConnected = connected.SSE || connected.SZSE;
|
||||
const connectionStatus = useMemo(() => {
|
||||
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}
|
||||
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}>
|
||||
<Text fontWeight="medium" color={textColor}>
|
||||
{stock.stock_name}
|
||||
</Text>
|
||||
<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) => (
|
||||
<QuoteTile
|
||||
key={item.code}
|
||||
code={item.code}
|
||||
name={item.name}
|
||||
quote={quotes[item.code] || {}}
|
||||
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;
|
||||
Reference in New Issue
Block a user