537 lines
16 KiB
TypeScript
537 lines
16 KiB
TypeScript
/**
|
||
* 灵活屏组件
|
||
* 用户可自定义添加关注的指数/个股,实时显示行情
|
||
*
|
||
* 功能:
|
||
* 1. 添加/删除自选证券
|
||
* 2. 显示实时行情(通过 WebSocket)
|
||
* 3. 显示分时走势(结合 ClickHouse 历史数据)
|
||
* 4. 显示五档盘口(上交所5档,深交所10档)
|
||
* 5. 本地存储自选列表
|
||
*/
|
||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||
import {
|
||
Box,
|
||
Card,
|
||
CardBody,
|
||
VStack,
|
||
HStack,
|
||
Heading,
|
||
Text,
|
||
Input,
|
||
InputGroup,
|
||
InputLeftElement,
|
||
InputRightElement,
|
||
IconButton,
|
||
SimpleGrid,
|
||
Flex,
|
||
Spacer,
|
||
Icon,
|
||
useToast,
|
||
Badge,
|
||
Tooltip,
|
||
Collapse,
|
||
List,
|
||
ListItem,
|
||
Spinner,
|
||
Center,
|
||
Menu,
|
||
MenuButton,
|
||
MenuList,
|
||
MenuItem,
|
||
Tag,
|
||
TagLabel,
|
||
} from '@chakra-ui/react';
|
||
import {
|
||
SearchIcon,
|
||
CloseIcon,
|
||
AddIcon,
|
||
ChevronDownIcon,
|
||
ChevronUpIcon,
|
||
SettingsIcon,
|
||
} from '@chakra-ui/icons';
|
||
import {
|
||
FaDesktop,
|
||
FaTrash,
|
||
FaSync,
|
||
FaWifi,
|
||
FaExclamationCircle,
|
||
} from 'react-icons/fa';
|
||
|
||
import { useRealtimeQuote } from './hooks';
|
||
import { getFullCode } from './hooks/utils';
|
||
import QuoteTile from './components/QuoteTile';
|
||
import { logger } from '@utils/logger';
|
||
import type { WatchlistItem, ConnectionStatus } from './types';
|
||
|
||
// 本地存储 key
|
||
const STORAGE_KEY = 'flexscreen_watchlist';
|
||
|
||
// 默认自选列表
|
||
const DEFAULT_WATCHLIST: WatchlistItem[] = [
|
||
{ code: '000001', name: '上证指数', isIndex: true },
|
||
{ code: '399001', name: '深证成指', isIndex: true },
|
||
{ code: '399006', name: '创业板指', isIndex: true },
|
||
];
|
||
|
||
// 热门推荐
|
||
const HOT_RECOMMENDATIONS: WatchlistItem[] = [
|
||
{ code: '000001', name: '上证指数', isIndex: true },
|
||
{ code: '399001', name: '深证成指', isIndex: true },
|
||
{ code: '399006', name: '创业板指', isIndex: true },
|
||
{ code: '399300', name: '沪深300', isIndex: true },
|
||
{ code: '600519', name: '贵州茅台', isIndex: false },
|
||
{ code: '000858', name: '五粮液', isIndex: false },
|
||
{ code: '300750', name: '宁德时代', isIndex: false },
|
||
{ code: '002594', name: '比亚迪', isIndex: false },
|
||
];
|
||
|
||
/** 搜索结果项 */
|
||
interface SearchResultItem {
|
||
stock_code: string;
|
||
stock_name: string;
|
||
isIndex?: boolean;
|
||
code?: string;
|
||
name?: string;
|
||
}
|
||
|
||
/** 搜索 API 响应 */
|
||
interface SearchApiResponse {
|
||
success: boolean;
|
||
data?: SearchResultItem[];
|
||
}
|
||
|
||
/** 连接状态信息 */
|
||
interface ConnectionStatusInfo {
|
||
color: string;
|
||
text: string;
|
||
}
|
||
|
||
/**
|
||
* FlexScreen 组件
|
||
*/
|
||
const FlexScreen: React.FC = () => {
|
||
const toast = useToast();
|
||
|
||
// 自选列表
|
||
const [watchlist, setWatchlist] = useState<WatchlistItem[]>([]);
|
||
// 搜索状态
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
|
||
const [isSearching, setIsSearching] = useState(false);
|
||
const [showResults, setShowResults] = useState(false);
|
||
// 面板状态
|
||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||
|
||
// 深色主题颜色 - 与 StockOverview 保持一致
|
||
const cardBg = 'rgba(255, 255, 255, 0.03)';
|
||
const borderColor = 'rgba(255, 255, 255, 0.08)';
|
||
const textColor = 'rgba(255, 255, 255, 0.95)';
|
||
const subTextColor = 'rgba(255, 255, 255, 0.6)';
|
||
const searchBg = 'rgba(255, 255, 255, 0.05)';
|
||
const hoverBg = 'rgba(255, 255, 255, 0.08)';
|
||
const accentColor = '#8b5cf6';
|
||
|
||
// 获取订阅的证券代码列表(带后缀,用于区分上证指数000001.SH和平安银行000001.SZ)
|
||
const subscribedCodes = useMemo(() => {
|
||
return watchlist.map(item => getFullCode(item.code, item.isIndex));
|
||
}, [watchlist]);
|
||
|
||
// WebSocket 实时行情
|
||
const { quotes, connected } = useRealtimeQuote(subscribedCodes);
|
||
|
||
// 从本地存储加载自选列表
|
||
useEffect(() => {
|
||
try {
|
||
const saved = localStorage.getItem(STORAGE_KEY);
|
||
if (saved) {
|
||
const parsed = JSON.parse(saved) as WatchlistItem[];
|
||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||
setWatchlist(parsed);
|
||
return;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
logger.warn('FlexScreen', '加载自选列表失败', e);
|
||
}
|
||
// 使用默认列表
|
||
setWatchlist(DEFAULT_WATCHLIST);
|
||
}, []);
|
||
|
||
// 保存自选列表到本地存储
|
||
useEffect(() => {
|
||
if (watchlist.length > 0) {
|
||
try {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(watchlist));
|
||
} catch (e) {
|
||
logger.warn('FlexScreen', '保存自选列表失败', e);
|
||
}
|
||
}
|
||
}, [watchlist]);
|
||
|
||
// 搜索证券
|
||
const searchSecurities = useCallback(async (query: string): Promise<void> => {
|
||
if (!query.trim()) {
|
||
setSearchResults([]);
|
||
setShowResults(false);
|
||
return;
|
||
}
|
||
|
||
setIsSearching(true);
|
||
try {
|
||
const response = await fetch(`/api/stocks/search?q=${encodeURIComponent(query)}&limit=10`);
|
||
const data: SearchApiResponse = await response.json();
|
||
|
||
if (data.success) {
|
||
setSearchResults(data.data || []);
|
||
setShowResults(true);
|
||
} else {
|
||
setSearchResults([]);
|
||
}
|
||
} catch (e) {
|
||
logger.error('FlexScreen', '搜索失败', e);
|
||
setSearchResults([]);
|
||
} finally {
|
||
setIsSearching(false);
|
||
}
|
||
}, []);
|
||
|
||
// 防抖搜索
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => {
|
||
searchSecurities(searchQuery);
|
||
}, 300);
|
||
return () => clearTimeout(timer);
|
||
}, [searchQuery, searchSecurities]);
|
||
|
||
// 添加证券
|
||
const addSecurity = useCallback(
|
||
(security: SearchResultItem | WatchlistItem): void => {
|
||
const code = 'stock_code' in security ? security.stock_code : security.code;
|
||
const name = 'stock_name' in security ? security.stock_name : security.name;
|
||
// 优先使用 API 返回的 isIndex 字段
|
||
const isIndex = security.isIndex === true;
|
||
|
||
// 生成唯一标识(带后缀的完整代码)
|
||
const fullCode = getFullCode(code, isIndex);
|
||
|
||
// 检查是否已存在(使用带后缀的代码比较,避免上证指数和平安银行冲突)
|
||
if (watchlist.some(item => getFullCode(item.code, item.isIndex) === fullCode)) {
|
||
toast({
|
||
title: '已在自选列表中',
|
||
status: 'info',
|
||
duration: 2000,
|
||
isClosable: true,
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 添加到列表
|
||
setWatchlist(prev => [...prev, { code, name, isIndex }]);
|
||
|
||
toast({
|
||
title: `已添加 ${name}${isIndex ? '(指数)' : ''}`,
|
||
status: 'success',
|
||
duration: 2000,
|
||
isClosable: true,
|
||
});
|
||
|
||
// 清空搜索
|
||
setSearchQuery('');
|
||
setShowResults(false);
|
||
},
|
||
[watchlist, toast]
|
||
);
|
||
|
||
// 移除证券
|
||
const removeSecurity = useCallback((code: string): void => {
|
||
setWatchlist(prev => prev.filter(item => item.code !== code));
|
||
}, []);
|
||
|
||
// 清空自选列表
|
||
const clearWatchlist = useCallback((): void => {
|
||
setWatchlist([]);
|
||
localStorage.removeItem(STORAGE_KEY);
|
||
toast({
|
||
title: '已清空自选列表',
|
||
status: 'info',
|
||
duration: 2000,
|
||
isClosable: true,
|
||
});
|
||
}, [toast]);
|
||
|
||
// 重置为默认列表
|
||
const resetWatchlist = useCallback((): void => {
|
||
setWatchlist(DEFAULT_WATCHLIST);
|
||
toast({
|
||
title: '已重置为默认列表',
|
||
status: 'success',
|
||
duration: 2000,
|
||
isClosable: true,
|
||
});
|
||
}, [toast]);
|
||
|
||
// 连接状态指示
|
||
const isAnyConnected = connected.SSE || connected.SZSE;
|
||
const connectionStatus = useMemo((): ConnectionStatusInfo => {
|
||
if (connected.SSE && connected.SZSE) {
|
||
return { color: 'green', text: '上交所/深交所 已连接' };
|
||
}
|
||
if (connected.SSE) {
|
||
return { color: 'yellow', text: '上交所 已连接' };
|
||
}
|
||
if (connected.SZSE) {
|
||
return { color: 'yellow', text: '深交所 已连接' };
|
||
}
|
||
return { color: 'red', text: '未连接' };
|
||
}, [connected]);
|
||
|
||
return (
|
||
<Card
|
||
bg={cardBg}
|
||
borderWidth="1px"
|
||
borderColor={borderColor}
|
||
backdropFilter="blur(20px)"
|
||
borderRadius="16px"
|
||
>
|
||
<CardBody>
|
||
{/* 头部 */}
|
||
<Flex align="center" mb={4}>
|
||
<HStack spacing={3}>
|
||
<Icon as={FaDesktop} boxSize={6} color={accentColor} />
|
||
<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"
|
||
color={subTextColor}
|
||
_hover={{ bg: hoverBg, color: textColor }}
|
||
aria-label="设置"
|
||
/>
|
||
<MenuList bg="#1a1a2e" borderColor={borderColor}>
|
||
<MenuItem
|
||
icon={<FaSync />}
|
||
onClick={resetWatchlist}
|
||
bg="transparent"
|
||
color={textColor}
|
||
_hover={{ bg: hoverBg }}
|
||
>
|
||
重置为默认
|
||
</MenuItem>
|
||
<MenuItem
|
||
icon={<FaTrash />}
|
||
onClick={clearWatchlist}
|
||
bg="transparent"
|
||
color="red.400"
|
||
_hover={{ bg: 'rgba(239, 68, 68, 0.1)' }}
|
||
>
|
||
清空列表
|
||
</MenuItem>
|
||
</MenuList>
|
||
</Menu>
|
||
{/* 折叠按钮 */}
|
||
<IconButton
|
||
icon={isCollapsed ? <ChevronDownIcon /> : <ChevronUpIcon />}
|
||
size="sm"
|
||
variant="ghost"
|
||
color={subTextColor}
|
||
_hover={{ bg: hoverBg, color: textColor }}
|
||
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={subTextColor} />
|
||
</InputLeftElement>
|
||
<Input
|
||
placeholder="搜索股票/指数代码或名称..."
|
||
value={searchQuery}
|
||
onChange={e => setSearchQuery(e.target.value)}
|
||
bg={searchBg}
|
||
borderRadius="lg"
|
||
borderColor={borderColor}
|
||
color={textColor}
|
||
_placeholder={{ color: subTextColor }}
|
||
_hover={{ borderColor: accentColor }}
|
||
_focus={{
|
||
borderColor: accentColor,
|
||
boxShadow: `0 0 0 1px ${accentColor}`,
|
||
}}
|
||
/>
|
||
{searchQuery && (
|
||
<InputRightElement>
|
||
<IconButton
|
||
size="sm"
|
||
icon={<CloseIcon />}
|
||
variant="ghost"
|
||
color={subTextColor}
|
||
_hover={{ bg: hoverBg, color: textColor }}
|
||
onClick={() => {
|
||
setSearchQuery('');
|
||
setShowResults(false);
|
||
}}
|
||
aria-label="清空"
|
||
/>
|
||
</InputRightElement>
|
||
)}
|
||
</InputGroup>
|
||
|
||
{/* 搜索结果下拉 */}
|
||
<Collapse in={showResults} animateOpacity>
|
||
<Box
|
||
position="absolute"
|
||
top="100%"
|
||
left={0}
|
||
right={0}
|
||
mt={1}
|
||
bg="#1a1a2e"
|
||
borderWidth="1px"
|
||
borderColor={borderColor}
|
||
borderRadius="lg"
|
||
boxShadow="0 8px 32px rgba(0, 0, 0, 0.5)"
|
||
maxH="300px"
|
||
overflowY="auto"
|
||
zIndex={10}
|
||
>
|
||
{isSearching ? (
|
||
<Center p={4}>
|
||
<Spinner size="sm" color={accentColor} />
|
||
</Center>
|
||
) : searchResults.length > 0 ? (
|
||
<List spacing={0}>
|
||
{searchResults.map((stock, index) => (
|
||
<ListItem
|
||
key={`${stock.stock_code}-${stock.isIndex ? 'index' : 'stock'}`}
|
||
px={4}
|
||
py={2}
|
||
cursor="pointer"
|
||
_hover={{ bg: hoverBg }}
|
||
onClick={() => addSecurity(stock)}
|
||
borderBottomWidth={index < searchResults.length - 1 ? '1px' : '0'}
|
||
borderColor={borderColor}
|
||
>
|
||
<HStack justify="space-between">
|
||
<VStack align="start" spacing={0}>
|
||
<HStack spacing={2}>
|
||
<Text fontWeight="medium" color={textColor}>
|
||
{stock.stock_name}
|
||
</Text>
|
||
<Badge
|
||
colorScheme={stock.isIndex ? 'purple' : 'blue'}
|
||
fontSize="xs"
|
||
variant="subtle"
|
||
>
|
||
{stock.isIndex ? '指数' : '股票'}
|
||
</Badge>
|
||
</HStack>
|
||
<Text fontSize="xs" color={subTextColor}>
|
||
{stock.stock_code}
|
||
</Text>
|
||
</VStack>
|
||
<IconButton
|
||
icon={<AddIcon />}
|
||
size="xs"
|
||
colorScheme="purple"
|
||
variant="ghost"
|
||
aria-label="添加"
|
||
/>
|
||
</HStack>
|
||
</ListItem>
|
||
))}
|
||
</List>
|
||
) : (
|
||
<Center p={4}>
|
||
<Text color={subTextColor} fontSize="sm">
|
||
未找到相关证券
|
||
</Text>
|
||
</Center>
|
||
)}
|
||
</Box>
|
||
</Collapse>
|
||
</Box>
|
||
|
||
{/* 快捷添加 */}
|
||
{watchlist.length === 0 && (
|
||
<Box mb={4}>
|
||
<Text fontSize="sm" color={subTextColor} mb={2}>
|
||
热门推荐(点击添加)
|
||
</Text>
|
||
<Flex flexWrap="wrap" gap={2}>
|
||
{HOT_RECOMMENDATIONS.map(item => (
|
||
<Tag
|
||
key={item.code}
|
||
size="md"
|
||
variant="outline"
|
||
borderColor={accentColor}
|
||
color={textColor}
|
||
cursor="pointer"
|
||
_hover={{ bg: 'rgba(139, 92, 246, 0.2)' }}
|
||
onClick={() => addSecurity(item)}
|
||
>
|
||
<TagLabel>{item.name}</TagLabel>
|
||
</Tag>
|
||
))}
|
||
</Flex>
|
||
</Box>
|
||
)}
|
||
|
||
{/* 自选列表 */}
|
||
{watchlist.length > 0 ? (
|
||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||
{watchlist.map(item => {
|
||
const fullCode = getFullCode(item.code, item.isIndex);
|
||
return (
|
||
<QuoteTile
|
||
key={fullCode}
|
||
code={item.code}
|
||
name={item.name}
|
||
quote={quotes[fullCode] || {}}
|
||
isIndex={item.isIndex}
|
||
onRemove={removeSecurity}
|
||
/>
|
||
);
|
||
})}
|
||
</SimpleGrid>
|
||
) : (
|
||
<Center py={8}>
|
||
<VStack spacing={3}>
|
||
<Icon as={FaExclamationCircle} boxSize={10} color={subTextColor} />
|
||
<Text color={subTextColor}>自选列表为空,请搜索添加证券</Text>
|
||
</VStack>
|
||
</Center>
|
||
)}
|
||
</Collapse>
|
||
</CardBody>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
export default FlexScreen;
|