diff --git a/src/views/StockOverview/components/FlexScreen/components/FlexScreenHeader.tsx b/src/views/StockOverview/components/FlexScreen/components/FlexScreenHeader.tsx new file mode 100644 index 00000000..cd412a54 --- /dev/null +++ b/src/views/StockOverview/components/FlexScreen/components/FlexScreenHeader.tsx @@ -0,0 +1,82 @@ +/** + * FlexScreenHeader - 灵活屏头部组件 + * 包含标题、连接状态、操作按钮 + */ +import React, { memo } from 'react'; +import { + Flex, + HStack, + Heading, + Text, + IconButton, + Icon, + Badge, + Tooltip, + Spacer, +} from '@chakra-ui/react'; +import { Monitor, Wifi, ChevronDown, ChevronUp } from 'lucide-react'; + +import type { FlexScreenHeaderProps } from '../types'; +import { COLORS, actionButtonStyles, collapseButtonStyles } from '../styles'; + +const FlexScreenHeader: React.FC = memo(({ + connectionStatus, + isAnyConnected, + isCollapsed, + onToggleCollapse, + onClearWatchlist, + onResetWatchlist, +}) => { + return ( + + + + + 灵活屏 + + + + + {isAnyConnected ? '实时' : '离线'} + + + + + + {/* 清空列表 */} + + 清空列表 + + {/* 恢复默认 */} + + 恢复默认 + + {/* 折叠按钮 */} + : } + {...collapseButtonStyles} + onClick={onToggleCollapse} + aria-label={isCollapsed ? '展开' : '收起'} + /> + + + ); +}); + +FlexScreenHeader.displayName = 'FlexScreenHeader'; + +export default FlexScreenHeader; diff --git a/src/views/StockOverview/components/FlexScreen/components/HotRecommendations.tsx b/src/views/StockOverview/components/FlexScreen/components/HotRecommendations.tsx new file mode 100644 index 00000000..cb8e9d76 --- /dev/null +++ b/src/views/StockOverview/components/FlexScreen/components/HotRecommendations.tsx @@ -0,0 +1,46 @@ +/** + * HotRecommendations - 热门推荐组件 + * 当自选列表为空时显示热门证券推荐 + */ +import React, { memo } from 'react'; +import { + Box, + Flex, + Text, + Tag, + TagLabel, +} from '@chakra-ui/react'; + +import type { HotRecommendationsProps } from '../types'; +import { COLORS } from '../styles'; +import { HOT_RECOMMENDATIONS } from '../constants'; + +const HotRecommendations: React.FC = memo(({ onAddSecurity }) => { + return ( + + + 热门推荐(点击添加) + + + {HOT_RECOMMENDATIONS.map(item => ( + onAddSecurity(item)} + > + {item.name} + + ))} + + + ); +}); + +HotRecommendations.displayName = 'HotRecommendations'; + +export default HotRecommendations; diff --git a/src/views/StockOverview/components/FlexScreen/components/SearchPanel.tsx b/src/views/StockOverview/components/FlexScreen/components/SearchPanel.tsx new file mode 100644 index 00000000..7eecc4c1 --- /dev/null +++ b/src/views/StockOverview/components/FlexScreen/components/SearchPanel.tsx @@ -0,0 +1,128 @@ +/** + * SearchPanel - 搜索面板组件 + * 包含搜索输入框和搜索结果下拉列表 + */ +import React, { memo } from 'react'; +import { + Box, + VStack, + HStack, + Text, + Input, + InputGroup, + InputLeftElement, + InputRightElement, + IconButton, + Collapse, + List, + ListItem, + Spinner, + Center, + Badge, +} from '@chakra-ui/react'; +import { Search, X, Plus } from 'lucide-react'; + +import type { SearchPanelProps } from '../types'; +import { COLORS, searchInputStyles, searchDropdownStyles } from '../styles'; + +const SearchPanel: React.FC = memo(({ + searchQuery, + onSearchQueryChange, + searchResults, + isSearching, + showResults, + onAddSecurity, + onClearSearch, +}) => { + return ( + + + + + + onSearchQueryChange(e.target.value)} + {...searchInputStyles} + /> + {searchQuery && ( + + } + variant="ghost" + color={COLORS.subText} + _hover={{ bg: COLORS.hoverBg, color: COLORS.text }} + onClick={onClearSearch} + aria-label="清空" + /> + + )} + + + {/* 搜索结果下拉 */} + + + {isSearching ? ( +
+ +
+ ) : searchResults.length > 0 ? ( + + {searchResults.map((stock, index) => ( + onAddSecurity(stock)} + borderBottomWidth={index < searchResults.length - 1 ? '1px' : '0'} + borderColor={COLORS.border} + > + + + + + {stock.stock_name} + + + {stock.isIndex ? '指数' : '股票'} + + + + {stock.stock_code} + + + } + size="xs" + colorScheme="purple" + variant="ghost" + aria-label="添加" + /> + + + ))} + + ) : ( +
+ + 未找到相关证券 + +
+ )} +
+
+
+ ); +}); + +SearchPanel.displayName = 'SearchPanel'; + +export default SearchPanel; diff --git a/src/views/StockOverview/components/FlexScreen/components/index.ts b/src/views/StockOverview/components/FlexScreen/components/index.ts index 4777c0f1..910665c2 100644 --- a/src/views/StockOverview/components/FlexScreen/components/index.ts +++ b/src/views/StockOverview/components/FlexScreen/components/index.ts @@ -5,3 +5,6 @@ export { default as MiniTimelineChart } from './MiniTimelineChart'; export { default as OrderBookPanel } from './OrderBookPanel'; export { default as QuoteTile } from './QuoteTile'; +export { default as FlexScreenHeader } from './FlexScreenHeader'; +export { default as SearchPanel } from './SearchPanel'; +export { default as HotRecommendations } from './HotRecommendations'; diff --git a/src/views/StockOverview/components/FlexScreen/constants.ts b/src/views/StockOverview/components/FlexScreen/constants.ts new file mode 100644 index 00000000..f931413a --- /dev/null +++ b/src/views/StockOverview/components/FlexScreen/constants.ts @@ -0,0 +1,35 @@ +/** + * FlexScreen 配置常量 + */ +import type { WatchlistItem } from './types'; + +/** 本地存储 key */ +export const STORAGE_KEY = 'flexscreen_watchlist'; + +/** 搜索防抖延迟 (ms) */ +export const SEARCH_DEBOUNCE_MS = 300; + +/** 搜索结果数量限制 */ +export const SEARCH_LIMIT = 10; + +/** 收起时显示的项目数量 */ +export const COLLAPSED_ITEMS_COUNT = 4; + +/** 默认自选列表 */ +export const DEFAULT_WATCHLIST: WatchlistItem[] = [ + { code: '000001', name: '上证指数', isIndex: true }, + { code: '399001', name: '深证成指', isIndex: true }, + { code: '399006', name: '创业板指', isIndex: true }, +]; + +/** 热门推荐列表 */ +export 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 }, +]; diff --git a/src/views/StockOverview/components/FlexScreen/index.tsx b/src/views/StockOverview/components/FlexScreen/index.tsx index 9ecca2fe..4859cdf2 100644 --- a/src/views/StockOverview/components/FlexScreen/index.tsx +++ b/src/views/StockOverview/components/FlexScreen/index.tsx @@ -1,5 +1,5 @@ /** - * 灵活屏组件 + * FlexScreen - 灵活屏组件 * 用户可自定义添加关注的指数/个股,实时显示行情 * * 功能: @@ -11,90 +11,44 @@ */ 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, + useToast, } from '@chakra-ui/react'; -import { Search, X, Plus, ChevronDown, ChevronUp, Settings, Monitor, Trash2, RefreshCw, Wifi, AlertCircle } from 'lucide-react'; +import { AlertCircle } from 'lucide-react'; import { useRealtimeQuote } from './hooks'; import { getFullCode } from './hooks/utils'; -import QuoteTile from './components/QuoteTile'; +import { + QuoteTile, + FlexScreenHeader, + SearchPanel, + HotRecommendations, +} from './components'; import { logger } from '@utils/logger'; import { getApiBase } from '@utils/apiConfig'; -import type { WatchlistItem, ConnectionStatus } from './types'; +import type { + WatchlistItem, + SearchResultItem, + SearchApiResponse, + ConnectionStatusInfo, +} from './types'; +import { COLORS } from './styles'; +import { + STORAGE_KEY, + DEFAULT_WATCHLIST, + SEARCH_DEBOUNCE_MS, + SEARCH_LIMIT, + COLLAPSED_ITEMS_COUNT, +} from './constants'; import { GLASS_BLUR } from '@/constants/glassConfig'; -// 本地存储 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 组件 */ @@ -109,18 +63,9 @@ const FlexScreen: React.FC = () => { const [isSearching, setIsSearching] = useState(false); const [showResults, setShowResults] = useState(false); // 面板状态 - const [isCollapsed, setIsCollapsed] = useState(false); + const [isCollapsed, setIsCollapsed] = useState(true); - // 深色主题颜色 - 与 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.12)'; // 调亮搜索框背景 - 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]); @@ -142,7 +87,6 @@ const FlexScreen: React.FC = () => { } catch (e) { logger.warn('FlexScreen', '加载自选列表失败', e); } - // 使用默认列表 setWatchlist(DEFAULT_WATCHLIST); }, []); @@ -167,7 +111,9 @@ const FlexScreen: React.FC = () => { setIsSearching(true); try { - const response = await fetch(`${getApiBase()}/api/stocks/search?q=${encodeURIComponent(query)}&limit=10`); + const response = await fetch( + `${getApiBase()}/api/stocks/search?q=${encodeURIComponent(query)}&limit=${SEARCH_LIMIT}` + ); const data: SearchApiResponse = await response.json(); if (data.success) { @@ -188,7 +134,7 @@ const FlexScreen: React.FC = () => { useEffect(() => { const timer = setTimeout(() => { searchSecurities(searchQuery); - }, 300); + }, SEARCH_DEBOUNCE_MS); return () => clearTimeout(timer); }, [searchQuery, searchSecurities]); @@ -197,13 +143,10 @@ const FlexScreen: React.FC = () => { (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: '已在自选列表中', @@ -214,7 +157,6 @@ const FlexScreen: React.FC = () => { return; } - // 添加到列表 setWatchlist(prev => [...prev, { code, name, isIndex }]); toast({ @@ -224,7 +166,6 @@ const FlexScreen: React.FC = () => { isClosable: true, }); - // 清空搜索 setSearchQuery(''); setShowResults(false); }, @@ -259,7 +200,18 @@ const FlexScreen: React.FC = () => { }); }, [toast]); - // 连接状态指示 + // 切换折叠状态 + const toggleCollapse = useCallback((): void => { + setIsCollapsed(prev => !prev); + }, []); + + // 清空搜索 + const clearSearch = useCallback((): void => { + setSearchQuery(''); + setShowResults(false); + }, []); + + // 连接状态 const isAnyConnected = connected.SSE || connected.SZSE; const connectionStatus = useMemo((): ConnectionStatusInfo => { if (connected.SSE && connected.SZSE) { @@ -274,248 +226,73 @@ const FlexScreen: React.FC = () => { return { color: 'red', text: '未连接' }; }, [connected]); + // 显示的自选列表(收起时只显示前4个) + const displayedWatchlist = useMemo(() => { + return isCollapsed ? watchlist.slice(0, COLLAPSED_ITEMS_COUNT) : watchlist; + }, [isCollapsed, watchlist]); + return ( {/* 头部 */} - - - - - 灵活屏 - - - - - {isAnyConnected ? '实时' : '离线'} - - - - - - {/* 操作菜单 */} - - } - size="sm" - variant="ghost" - color={subTextColor} - _hover={{ bg: hoverBg, color: textColor }} - aria-label="设置" - /> - - } - onClick={resetWatchlist} - bg="transparent" - color={textColor} - _hover={{ bg: hoverBg }} - > - 重置为默认 - - } - onClick={clearWatchlist} - bg="transparent" - color="red.400" - _hover={{ bg: 'rgba(239, 68, 68, 0.1)' }} - > - 清空列表 - - - - {/* 折叠按钮 */} - : } - size="sm" - variant="ghost" - color={subTextColor} - _hover={{ bg: hoverBg, color: textColor }} - onClick={() => setIsCollapsed(!isCollapsed)} - aria-label={isCollapsed ? '展开' : '收起'} - /> - - + - {/* 可折叠内容 */} + {/* 搜索框 - 仅展开时显示 */} - {/* 搜索框 */} - - - - - - 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 && ( - - } - variant="ghost" - color={subTextColor} - _hover={{ bg: hoverBg, color: textColor }} - onClick={() => { - setSearchQuery(''); - setShowResults(false); - }} - aria-label="清空" - /> - - )} - + - {/* 搜索结果下拉 */} - - - {isSearching ? ( -
- -
- ) : searchResults.length > 0 ? ( - - {searchResults.map((stock, index) => ( - addSecurity(stock)} - borderBottomWidth={index < searchResults.length - 1 ? '1px' : '0'} - borderColor={borderColor} - > - - - - - {stock.stock_name} - - - {stock.isIndex ? '指数' : '股票'} - - - - {stock.stock_code} - - - } - size="xs" - colorScheme="purple" - variant="ghost" - aria-label="添加" - /> - - - ))} - - ) : ( -
- - 未找到相关证券 - -
- )} -
-
-
- - {/* 快捷添加 */} + {/* 热门推荐 - 仅展开且列表为空时显示 */} {watchlist.length === 0 && ( - - - 热门推荐(点击添加) - - - {HOT_RECOMMENDATIONS.map(item => ( - addSecurity(item)} - > - {item.name} - - ))} - - - )} - - {/* 自选列表 */} - {watchlist.length > 0 ? ( - - {watchlist.map(item => { - const fullCode = getFullCode(item.code, item.isIndex); - return ( - - ); - })} - - ) : ( -
- - - 自选列表为空,请搜索添加证券 - -
+ )}
+ + {/* 自选列表 */} + {watchlist.length > 0 ? ( + + {displayedWatchlist.map(item => { + const fullCode = getFullCode(item.code, item.isIndex); + return ( + + ); + })} + + ) : !isCollapsed ? ( +
+ + + 自选列表为空,请搜索添加证券 + +
+ ) : null}
); diff --git a/src/views/StockOverview/components/FlexScreen/styles.ts b/src/views/StockOverview/components/FlexScreen/styles.ts new file mode 100644 index 00000000..2299c4de --- /dev/null +++ b/src/views/StockOverview/components/FlexScreen/styles.ts @@ -0,0 +1,78 @@ +/** + * FlexScreen 样式常量 + */ + +/** 颜色常量 - 与 StockOverview 保持一致 */ +export const COLORS = { + // 背景 + cardBg: 'rgba(255, 255, 255, 0.03)', + searchBg: 'rgba(255, 255, 255, 0.12)', + hoverBg: 'rgba(255, 255, 255, 0.08)', + dropdownBg: '#1a1a2e', + + // 边框 + border: 'rgba(255, 255, 255, 0.08)', + + // 文字 + text: 'rgba(255, 255, 255, 0.95)', + subText: 'rgba(255, 255, 255, 0.6)', + + // 主题色 + accent: '#8b5cf6', + accentHover: 'rgba(139, 92, 246, 0.2)', + + // 涨跌颜色 + up: '#ff4d4d', + down: '#22c55e', +} as const; + +/** 搜索输入框样式 */ +export const searchInputStyles = { + bg: COLORS.searchBg, + borderRadius: 'lg', + borderColor: COLORS.border, + color: COLORS.text, + _placeholder: { color: COLORS.subText }, + _hover: { borderColor: COLORS.accent }, + _focus: { + borderColor: COLORS.accent, + boxShadow: `0 0 0 1px ${COLORS.accent}`, + }, +} as const; + +/** 搜索结果下拉框样式 */ +export const searchDropdownStyles = { + position: 'absolute' as const, + top: '100%', + left: 0, + right: 0, + mt: 1, + bg: COLORS.dropdownBg, + borderWidth: '1px', + borderColor: COLORS.border, + borderRadius: 'lg', + boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5)', + maxH: '300px', + overflowY: 'auto' as const, + zIndex: 10, +} as const; + +/** 操作按钮样式 */ +export const actionButtonStyles = { + fontSize: 'xs', + color: COLORS.subText, + cursor: 'pointer', +} as const; + +/** 折叠按钮样式 */ +export const collapseButtonStyles = { + size: 'sm' as const, + variant: 'outline' as const, + color: COLORS.subText, + borderColor: COLORS.border, + _hover: { + bg: COLORS.hoverBg, + color: COLORS.text, + borderColor: COLORS.accent, + }, +} as const; diff --git a/src/views/StockOverview/components/FlexScreen/types.ts b/src/views/StockOverview/components/FlexScreen/types.ts index 5c2eb483..aaa17161 100644 --- a/src/views/StockOverview/components/FlexScreen/types.ts +++ b/src/views/StockOverview/components/FlexScreen/types.ts @@ -373,3 +373,54 @@ export interface UseRealtimeQuoteReturn { /** 获取单只股票快照 (仅深交所) */ getSnapshot: (code: string) => void; } + +// ==================== 搜索相关类型 ==================== + +/** 搜索结果项 */ +export interface SearchResultItem { + stock_code: string; + stock_name: string; + isIndex?: boolean; + code?: string; + name?: string; +} + +/** 搜索 API 响应 */ +export interface SearchApiResponse { + success: boolean; + data?: SearchResultItem[]; +} + +/** 连接状态信息 */ +export interface ConnectionStatusInfo { + color: string; + text: string; +} + +// ==================== 子组件 Props 类型 ==================== + +/** FlexScreenHeader Props */ +export interface FlexScreenHeaderProps { + connectionStatus: ConnectionStatusInfo; + isAnyConnected: boolean; + isCollapsed: boolean; + onToggleCollapse: () => void; + onClearWatchlist: () => void; + onResetWatchlist: () => void; +} + +/** SearchPanel Props */ +export interface SearchPanelProps { + searchQuery: string; + onSearchQueryChange: (query: string) => void; + searchResults: SearchResultItem[]; + isSearching: boolean; + showResults: boolean; + onAddSecurity: (security: SearchResultItem) => void; + onClearSearch: () => void; +} + +/** HotRecommendations Props */ +export interface HotRecommendationsProps { + onAddSecurity: (item: WatchlistItem) => void; +}