update pay ui

This commit is contained in:
2025-12-10 11:02:09 +08:00
parent d9daaeed19
commit e501ac3819
21 changed files with 5514 additions and 151 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
export { default as MiniTimelineChart } from './MiniTimelineChart';
export { default as OrderBookPanel } from './OrderBookPanel';
export { default as QuoteTile } from './QuoteTile';

View File

@@ -0,0 +1 @@
export { useRealtimeQuote } from './useRealtimeQuote';

View File

@@ -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;

View 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;

View File

@@ -18,6 +18,69 @@ import {
import { FaBolt, FaArrowUp, FaArrowDown, FaChartLine, FaFire, FaVolumeUp } from 'react-icons/fa';
import { getAlertTypeLabel, formatScore, getScoreColor } from '../utils/chartHelpers';
/**
* Z-Score 指示器组件
*/
const ZScoreIndicator = ({ value, label, tooltip }) => {
if (value === null || value === undefined) return null;
// Z-Score 颜色:越大越红,越小越绿
const getZScoreColor = (z) => {
const absZ = Math.abs(z);
if (absZ >= 3) return z > 0 ? 'red.600' : 'green.600';
if (absZ >= 2) return z > 0 ? 'red.500' : 'green.500';
if (absZ >= 1) return z > 0 ? 'orange.400' : 'teal.400';
return 'gray.400';
};
// Z-Score 强度条宽度(最大 5σ
const barWidth = Math.min(Math.abs(value) / 5 * 100, 100);
return (
<Tooltip label={tooltip || `${label}: ${value.toFixed(2)}σ`} placement="left">
<HStack spacing={1} fontSize="xs">
<Text color="gray.500" w="20px">{label}</Text>
<Box position="relative" w="40px" h="6px" bg="gray.200" borderRadius="full" overflow="hidden">
<Box
position="absolute"
left={value >= 0 ? '50%' : `${50 - barWidth / 2}%`}
w={`${barWidth / 2}%`}
h="100%"
bg={getZScoreColor(value)}
borderRadius="full"
/>
</Box>
<Text color={getZScoreColor(value)} fontWeight="medium" w="28px" textAlign="right">
{value >= 0 ? '+' : ''}{value.toFixed(1)}
</Text>
</HStack>
</Tooltip>
);
};
/**
* 持续确认率指示器
*/
const ConfirmRatioIndicator = ({ ratio }) => {
if (ratio === null || ratio === undefined) return null;
const percent = Math.round(ratio * 100);
const color = percent >= 80 ? 'green' : percent >= 60 ? 'orange' : 'red';
return (
<Tooltip label={`持续确认率: ${percent}%5分钟窗口内超标比例`}>
<HStack spacing={1}>
<Box position="relative" w="32px" h="6px" bg="gray.200" borderRadius="full" overflow="hidden">
<Box w={`${percent}%`} h="100%" bg={`${color}.500`} borderRadius="full" />
</Box>
<Text fontSize="xs" color={`${color}.500`} fontWeight="medium">
{percent}%
</Text>
</HStack>
</Tooltip>
);
};
/**
* 单个异动项组件
*/
@@ -29,6 +92,7 @@ const AlertItem = ({ alert, onClick, isSelected }) => {
const isUp = alert.alert_type !== 'surge_down';
const typeColor = isUp ? 'red' : 'green';
const isV2 = alert.is_v2;
// 获取异动类型图标
const getTypeIcon = (type) => {
@@ -69,13 +133,30 @@ const AlertItem = ({ alert, onClick, isSelected }) => {
<Text fontWeight="bold" fontSize="sm" noOfLines={1}>
{alert.concept_name}
</Text>
{isV2 && (
<Badge colorScheme="purple" size="xs" variant="subtle" fontSize="10px">
V2
</Badge>
)}
</HStack>
<HStack spacing={2} fontSize="xs" color="gray.500">
<Text>{alert.time}</Text>
<Badge colorScheme={typeColor} size="sm" variant="subtle">
{getAlertTypeLabel(alert.alert_type)}
</Badge>
{/* V2: 持续确认率 */}
{isV2 && alert.confirm_ratio !== undefined && (
<ConfirmRatioIndicator ratio={alert.confirm_ratio} />
)}
</HStack>
{/* V2: Z-Score 指标行 */}
{isV2 && (alert.alpha_zscore !== undefined || alert.amt_zscore !== undefined) && (
<HStack spacing={3} mt={1}>
<ZScoreIndicator value={alert.alpha_zscore} label="α" tooltip={`Alpha Z-Score: ${alert.alpha_zscore?.toFixed(2)}σ(相对于历史同时段)`} />
<ZScoreIndicator value={alert.amt_zscore} label="量" tooltip={`成交额 Z-Score: ${alert.amt_zscore?.toFixed(2)}σ`} />
</HStack>
)}
</VStack>
{/* 右侧:分数和关键指标 */}
@@ -104,12 +185,29 @@ const AlertItem = ({ alert, onClick, isSelected }) => {
</Text>
)}
{/* 涨停数量 */}
{alert.limit_up_count > 0 && (
{/* V2: 动量指标 */}
{isV2 && alert.momentum_5m !== undefined && Math.abs(alert.momentum_5m) > 0.3 && (
<HStack spacing={1}>
<Icon
as={alert.momentum_5m > 0 ? FaArrowUp : FaArrowDown}
color={alert.momentum_5m > 0 ? 'red.400' : 'green.400'}
boxSize={3}
/>
<Text fontSize="xs" color={alert.momentum_5m > 0 ? 'red.400' : 'green.400'}>
动量 {alert.momentum_5m > 0 ? '+' : ''}{alert.momentum_5m.toFixed(2)}
</Text>
</HStack>
)}
{/* 涨停数量 / 涨停比例 */}
{(alert.limit_up_count > 0 || (alert.limit_up_ratio > 0.05 && isV2)) && (
<HStack spacing={1}>
<Icon as={FaFire} color="orange.500" boxSize={3} />
<Text fontSize="xs" color="orange.500">
涨停 {alert.limit_up_count}
{alert.limit_up_count > 0
? `涨停 ${alert.limit_up_count}`
: `涨停 ${Math.round(alert.limit_up_ratio * 100)}%`
}
</Text>
</HStack>
)}

View File

@@ -54,6 +54,7 @@ import { FaChartLine, FaFire, FaRocket, FaBrain, FaCalendarAlt, FaChevronRight,
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';
@@ -846,6 +847,11 @@ const StockOverview = () => {
<HotspotOverview selectedDate={selectedDate} />
</Box>
{/* 灵活屏 - 实时行情监控 */}
<Box mb={10}>
<FlexScreen />
</Box>
{/* 今日热门概念 */}
<Box mb={10}>
<Flex align="center" mb={6}>