Files
vf_react/src/views/StockOverview/components/FlexScreen/index.tsx
2025-12-11 10:07:17 +08:00

537 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 灵活屏组件
* 用户可自定义添加关注的指数/个股,实时显示行情
*
* 功能:
* 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;