update pay ui
This commit is contained in:
491
src/views/StockOverview/components/FlexScreen/index.tsx
Normal file
491
src/views/StockOverview/components/FlexScreen/index.tsx
Normal file
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* 灵活屏组件
|
||||
* 用户可自定义添加关注的指数/个股,实时显示行情
|
||||
*
|
||||
* 功能:
|
||||
* 1. 添加/删除自选证券
|
||||
* 2. 显示实时行情(通过 WebSocket)
|
||||
* 3. 显示分时走势(结合 ClickHouse 历史数据)
|
||||
* 4. 显示五档盘口(上交所5档,深交所10档)
|
||||
* 5. 本地存储自选列表
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardBody,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
InputRightElement,
|
||||
IconButton,
|
||||
SimpleGrid,
|
||||
Flex,
|
||||
Spacer,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Badge,
|
||||
Tooltip,
|
||||
Collapse,
|
||||
List,
|
||||
ListItem,
|
||||
Spinner,
|
||||
Center,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Tag,
|
||||
TagLabel,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
SearchIcon,
|
||||
CloseIcon,
|
||||
AddIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
SettingsIcon,
|
||||
} from '@chakra-ui/icons';
|
||||
import {
|
||||
FaDesktop,
|
||||
FaTrash,
|
||||
FaSync,
|
||||
FaWifi,
|
||||
FaExclamationCircle,
|
||||
} from 'react-icons/fa';
|
||||
|
||||
import { useRealtimeQuote } from './hooks';
|
||||
import QuoteTile from './components/QuoteTile';
|
||||
import { logger } from '@utils/logger';
|
||||
import type { WatchlistItem, ConnectionStatus } from './types';
|
||||
|
||||
// 本地存储 key
|
||||
const STORAGE_KEY = 'flexscreen_watchlist';
|
||||
|
||||
// 默认自选列表
|
||||
const DEFAULT_WATCHLIST: WatchlistItem[] = [
|
||||
{ code: '000001', name: '上证指数', isIndex: true },
|
||||
{ code: '399001', name: '深证成指', isIndex: true },
|
||||
{ code: '399006', name: '创业板指', isIndex: true },
|
||||
];
|
||||
|
||||
// 热门推荐
|
||||
const HOT_RECOMMENDATIONS: WatchlistItem[] = [
|
||||
{ code: '000001', name: '上证指数', isIndex: true },
|
||||
{ code: '399001', name: '深证成指', isIndex: true },
|
||||
{ code: '399006', name: '创业板指', isIndex: true },
|
||||
{ code: '399300', name: '沪深300', isIndex: true },
|
||||
{ code: '600519', name: '贵州茅台', isIndex: false },
|
||||
{ code: '000858', name: '五粮液', isIndex: false },
|
||||
{ code: '300750', name: '宁德时代', isIndex: false },
|
||||
{ code: '002594', name: '比亚迪', isIndex: false },
|
||||
];
|
||||
|
||||
/** 搜索结果项 */
|
||||
interface SearchResultItem {
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
isIndex?: boolean;
|
||||
code?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** 搜索 API 响应 */
|
||||
interface SearchApiResponse {
|
||||
success: boolean;
|
||||
data?: SearchResultItem[];
|
||||
}
|
||||
|
||||
/** 连接状态信息 */
|
||||
interface ConnectionStatusInfo {
|
||||
color: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FlexScreen 组件
|
||||
*/
|
||||
const FlexScreen: React.FC = () => {
|
||||
const toast = useToast();
|
||||
|
||||
// 自选列表
|
||||
const [watchlist, setWatchlist] = useState<WatchlistItem[]>([]);
|
||||
// 搜索状态
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
// 面板状态
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
// 颜色主题
|
||||
const cardBg = useColorModeValue('white', '#1a1a1a');
|
||||
const borderColor = useColorModeValue('gray.200', '#333');
|
||||
const textColor = useColorModeValue('gray.800', 'white');
|
||||
const subTextColor = useColorModeValue('gray.600', 'gray.400');
|
||||
const searchBg = useColorModeValue('gray.50', '#2a2a2a');
|
||||
const hoverBg = useColorModeValue('gray.100', '#333');
|
||||
|
||||
// 获取订阅的证券代码列表
|
||||
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) 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;
|
||||
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: string): void => {
|
||||
setWatchlist(prev => prev.filter(item => item.code !== code));
|
||||
}, []);
|
||||
|
||||
// 清空自选列表
|
||||
const clearWatchlist = useCallback((): void => {
|
||||
setWatchlist([]);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
toast({
|
||||
title: '已清空自选列表',
|
||||
status: 'info',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
}, [toast]);
|
||||
|
||||
// 重置为默认列表
|
||||
const resetWatchlist = useCallback((): void => {
|
||||
setWatchlist(DEFAULT_WATCHLIST);
|
||||
toast({
|
||||
title: '已重置为默认列表',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
}, [toast]);
|
||||
|
||||
// 连接状态指示
|
||||
const isAnyConnected = connected.SSE || connected.SZSE;
|
||||
const connectionStatus = useMemo((): ConnectionStatusInfo => {
|
||||
if (connected.SSE && connected.SZSE) {
|
||||
return { color: 'green', text: '上交所/深交所 已连接' };
|
||||
}
|
||||
if (connected.SSE) {
|
||||
return { color: 'yellow', text: '上交所 已连接' };
|
||||
}
|
||||
if (connected.SZSE) {
|
||||
return { color: 'yellow', text: '深交所 已连接' };
|
||||
}
|
||||
return { color: 'red', text: '未连接' };
|
||||
}, [connected]);
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} borderWidth="1px" borderColor={borderColor}>
|
||||
<CardBody>
|
||||
{/* 头部 */}
|
||||
<Flex align="center" mb={4}>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FaDesktop} boxSize={6} color="purple.500" />
|
||||
<Heading size="md" color={textColor}>
|
||||
灵活屏
|
||||
</Heading>
|
||||
<Tooltip label={connectionStatus.text}>
|
||||
<Badge
|
||||
colorScheme={connectionStatus.color}
|
||||
variant="subtle"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
>
|
||||
<Icon as={FaWifi} boxSize={3} />
|
||||
{isAnyConnected ? '实时' : '离线'}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
<Spacer />
|
||||
<HStack spacing={2}>
|
||||
{/* 操作菜单 */}
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
icon={<SettingsIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="设置"
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem icon={<FaSync />} onClick={resetWatchlist}>
|
||||
重置为默认
|
||||
</MenuItem>
|
||||
<MenuItem icon={<FaTrash />} onClick={clearWatchlist} color="red.500">
|
||||
清空列表
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
{/* 折叠按钮 */}
|
||||
<IconButton
|
||||
icon={isCollapsed ? <ChevronDownIcon /> : <ChevronUpIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
aria-label={isCollapsed ? '展开' : '收起'}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 可折叠内容 */}
|
||||
<Collapse in={!isCollapsed} animateOpacity>
|
||||
{/* 搜索框 */}
|
||||
<Box position="relative" mb={4}>
|
||||
<InputGroup size="md">
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<SearchIcon color="gray.400" />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="搜索股票/指数代码或名称..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
bg={searchBg}
|
||||
borderRadius="lg"
|
||||
_focus={{
|
||||
borderColor: 'purple.400',
|
||||
boxShadow: '0 0 0 1px var(--chakra-colors-purple-400)',
|
||||
}}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon={<CloseIcon />}
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setShowResults(false);
|
||||
}}
|
||||
aria-label="清空"
|
||||
/>
|
||||
</InputRightElement>
|
||||
)}
|
||||
</InputGroup>
|
||||
|
||||
{/* 搜索结果下拉 */}
|
||||
<Collapse in={showResults} animateOpacity>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="100%"
|
||||
left={0}
|
||||
right={0}
|
||||
mt={1}
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
boxShadow="lg"
|
||||
maxH="300px"
|
||||
overflowY="auto"
|
||||
zIndex={10}
|
||||
>
|
||||
{isSearching ? (
|
||||
<Center p={4}>
|
||||
<Spinner size="sm" color="purple.500" />
|
||||
</Center>
|
||||
) : searchResults.length > 0 ? (
|
||||
<List spacing={0}>
|
||||
{searchResults.map((stock, index) => (
|
||||
<ListItem
|
||||
key={stock.stock_code}
|
||||
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