update pay ui

This commit is contained in:
2025-12-10 11:19:02 +08:00
parent e501ac3819
commit da44dcd522
12 changed files with 1229 additions and 820 deletions

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