diff --git a/src/components/HeroSection/HeroSection.tsx b/src/components/HeroSection/HeroSection.tsx new file mode 100644 index 00000000..495fc7cb --- /dev/null +++ b/src/components/HeroSection/HeroSection.tsx @@ -0,0 +1,124 @@ +/** + * HeroSection 通用模板组件 + * 用于个股中心、概念中心等页面的 Hero 区域 + */ + +import React, { memo, useMemo } from 'react'; +import { Box, VStack } from '@chakra-ui/react'; +import { HeroBackground } from './components/HeroBackground'; +import { HeroTitle } from './components/HeroTitle'; +import { HeroSearch } from './components/HeroSearch'; +import { HeroStats } from './components/HeroStats'; +import { getThemeColors } from './constants'; +import type { HeroSectionProps } from './types'; + +export const HeroSection = memo(({ + // 标题区 + icon, + title, + subtitle, + updateHint, + // 主题 + themePreset = 'purple', + themeColors: customThemeColors, + // 背景装饰 + decorations, + // 搜索 + search, + // 统计 + stats, + // 布局 + fullWidth = false, + maxContentWidth = '4xl', + // 插槽 + afterTitle, + afterSearch, + afterStats, + children, + // Box props + ...boxProps +}) => { + // 合并主题颜色 + const themeColors = useMemo( + () => getThemeColors(themePreset, customThemeColors), + [themePreset, customThemeColors] + ); + + // 全宽模式的负边距 + const fullWidthMargin = fullWidth + ? { base: -4, md: -6, lg: '-80px' } + : undefined; + + return ( + + {/* 背景装饰层 */} + + + {/* 主内容区 - 自适应高度,上下 padding 40px */} + + {children ? ( + // 完全自定义内容 + children + ) : ( + + {/* 标题区 */} + + + {/* 标题后插槽 */} + {afterTitle} + + {/* 统计区(移到搜索框上方) */} + {stats && ( + + )} + + {/* 统计后插槽 */} + {afterStats} + + {/* 搜索区 */} + {search && ( + + )} + + {/* 搜索后插槽 */} + {afterSearch} + + )} + + + ); +}); + +HeroSection.displayName = 'HeroSection'; diff --git a/src/components/HeroSection/components/HeroBackground/AuroraEffect.tsx b/src/components/HeroSection/components/HeroBackground/AuroraEffect.tsx new file mode 100644 index 00000000..b016df28 --- /dev/null +++ b/src/components/HeroSection/components/HeroBackground/AuroraEffect.tsx @@ -0,0 +1,56 @@ +/** + * 极光效果背景装饰 + */ + +import React, { memo } from 'react'; +import { Box } from '@chakra-ui/react'; + +interface AuroraEffectProps { + /** 强度 0-1 */ + intensity?: number; + /** 颜色数组 */ + colors?: string[]; +} + +/** 默认极光颜色 */ +const DEFAULT_COLORS = [ + 'rgba(139, 92, 246, 0.15)', // 紫色 + 'rgba(59, 130, 246, 0.1)', // 蓝色 + 'rgba(236, 72, 153, 0.05)', // 粉色 +]; + +export const AuroraEffect = memo(({ + intensity = 1, + colors = DEFAULT_COLORS, +}) => { + return ( + <> + {/* 紫色极光 */} + + {/* 蓝色极光 */} + + {/* 粉色极光 */} + + + ); +}); + +AuroraEffect.displayName = 'AuroraEffect'; diff --git a/src/components/HeroSection/components/HeroBackground/GlowOrbs.tsx b/src/components/HeroSection/components/HeroBackground/GlowOrbs.tsx new file mode 100644 index 00000000..720a3e92 --- /dev/null +++ b/src/components/HeroSection/components/HeroBackground/GlowOrbs.tsx @@ -0,0 +1,76 @@ +/** + * 发光球体背景装饰 + */ + +import React, { memo } from 'react'; +import { Box } from '@chakra-ui/react'; +import type { GlowOrbConfig } from '../../types'; +import { DEFAULT_DECORATIONS } from '../../constants'; + +interface GlowOrbsProps { + /** 球体配置数组 */ + orbs?: GlowOrbConfig[]; + /** 默认颜色 */ + colors?: string[]; + /** 强度 */ + intensity?: number; +} + +/** 默认球体配置 */ +const DEFAULT_ORBS: GlowOrbConfig[] = [ + { + top: '20%', + right: '10%', + size: '200px', + color: 'rgba(139, 92, 246, 0.4)', + blur: '40px', + }, + { + bottom: '10%', + left: '10%', + size: '250px', + color: 'rgba(59, 130, 246, 0.3)', + blur: '50px', + }, +]; + +export const GlowOrbs = memo(({ + orbs, + colors, + intensity = DEFAULT_DECORATIONS.glowOrbs.opacity, +}) => { + // 使用自定义配置或默认配置 + let orbConfigs = orbs || DEFAULT_ORBS; + + // 如果提供了颜色数组,更新默认球体的颜色 + if (colors && colors.length > 0 && !orbs) { + orbConfigs = DEFAULT_ORBS.map((orb, index) => ({ + ...orb, + color: colors[index % colors.length] || orb.color, + })); + } + + return ( + <> + {orbConfigs.map((orb, index) => ( + + ))} + + ); +}); + +GlowOrbs.displayName = 'GlowOrbs'; diff --git a/src/components/HeroSection/components/HeroBackground/GridPattern.tsx b/src/components/HeroSection/components/HeroBackground/GridPattern.tsx new file mode 100644 index 00000000..f3e4b3e8 --- /dev/null +++ b/src/components/HeroSection/components/HeroBackground/GridPattern.tsx @@ -0,0 +1,38 @@ +/** + * 科幻网格背景装饰 + */ + +import React, { memo } from 'react'; +import { Box } from '@chakra-ui/react'; +import { DEFAULT_DECORATIONS } from '../../constants'; + +interface GridPatternProps { + /** 透明度 0-1 */ + intensity?: number; + /** 网格颜色 */ + color?: string; + /** 网格尺寸 */ + size?: string; +} + +export const GridPattern = memo(({ + intensity = DEFAULT_DECORATIONS.grid.opacity, + color = DEFAULT_DECORATIONS.grid.gridColor, + size = DEFAULT_DECORATIONS.grid.gridSize, +}) => { + return ( + + ); +}); + +GridPattern.displayName = 'GridPattern'; diff --git a/src/components/HeroSection/components/HeroBackground/index.tsx b/src/components/HeroSection/components/HeroBackground/index.tsx new file mode 100644 index 00000000..42b77d5c --- /dev/null +++ b/src/components/HeroSection/components/HeroBackground/index.tsx @@ -0,0 +1,77 @@ +/** + * HeroBackground 背景装饰容器 + * 支持多种装饰效果叠加 + */ + +import React, { memo } from 'react'; +import { Box } from '@chakra-ui/react'; +import { GridPattern } from './GridPattern'; +import { GlowOrbs } from './GlowOrbs'; +import { AuroraEffect } from './AuroraEffect'; +import type { HeroBackgroundProps, BackgroundDecorationConfig } from '../../types'; + +/** 渲染单个装饰 */ +const renderDecoration = (config: BackgroundDecorationConfig, index: number) => { + const { type, intensity, colors, orbs } = config; + + switch (type) { + case 'grid': + return ( + + ); + case 'glowOrbs': + return ( + + ); + case 'aurora': + return ( + + ); + case 'none': + default: + return null; + } +}; + +export const HeroBackground = memo(({ + decorations = [], + themeColors, +}) => { + if (decorations.length === 0) { + return null; + } + + return ( + + {decorations.map((config, index) => renderDecoration(config, index))} + + ); +}); + +HeroBackground.displayName = 'HeroBackground'; + +export { GridPattern } from './GridPattern'; +export { GlowOrbs } from './GlowOrbs'; +export { AuroraEffect } from './AuroraEffect'; diff --git a/src/components/HeroSection/components/HeroSearch/SearchButton.tsx b/src/components/HeroSection/components/HeroSearch/SearchButton.tsx new file mode 100644 index 00000000..11c46c9c --- /dev/null +++ b/src/components/HeroSection/components/HeroSearch/SearchButton.tsx @@ -0,0 +1,51 @@ +/** + * SearchButton 搜索按钮 + * 渐变样式,纯文字 + */ + +import React, { memo } from 'react'; +import { Button } from '@chakra-ui/react'; + +interface SearchButtonProps { + onClick: () => void; + text?: string; + isLoading?: boolean; +} + +export const SearchButton = memo(({ + onClick, + text = '搜索', + isLoading = false, +}) => { + return ( + + ); +}); + +SearchButton.displayName = 'SearchButton'; diff --git a/src/components/HeroSection/components/HeroSearch/SearchDropdown.tsx b/src/components/HeroSection/components/HeroSearch/SearchDropdown.tsx new file mode 100644 index 00000000..d0426d91 --- /dev/null +++ b/src/components/HeroSection/components/HeroSearch/SearchDropdown.tsx @@ -0,0 +1,128 @@ +/** + * SearchDropdown 搜索结果下拉列表 + */ + +import React, { memo } from 'react'; +import { + Box, + VStack, + HStack, + Text, + Tag, + Button, + Spinner, + Center, + Collapse, + List, + ListItem, + Flex, +} from '@chakra-ui/react'; +import { ArrowRight } from 'lucide-react'; +import { GLASS_BLUR } from '@/constants/glassConfig'; +import type { SearchDropdownProps, SearchResultItem } from '../../types'; + +export const SearchDropdown = memo(({ + isOpen, + isSearching, + results, + onSelect, + renderResult, + emptyText = '未找到相关结果', + loadingText = '搜索中...', + themeColors, +}: SearchDropdownProps) => { + return ( + + + {isSearching ? ( +
+ + + {loadingText} + +
+ ) : results.length > 0 ? ( + + {results.map((item, index) => ( + onSelect(item, index)} + borderBottomWidth={index < results.length - 1 ? '1px' : '0'} + borderColor="gray.200" + > + {renderResult ? ( + renderResult(item, index) + ) : ( + + + + {item.label} + + + {item.subLabel && ( + + {item.subLabel} + + )} + {item.extra && ( + + ({item.extra}) + + )} + {item.tags?.map((tag, tagIndex) => ( + + {tag.text} + + ))} + + + + + )} + + ))} + + ) : ( +
+ {emptyText} +
+ )} +
+
+ ); +}) as (props: SearchDropdownProps) => React.ReactElement; + +(SearchDropdown as React.FC).displayName = 'SearchDropdown'; diff --git a/src/components/HeroSection/components/HeroSearch/SearchInput.tsx b/src/components/HeroSection/components/HeroSearch/SearchInput.tsx new file mode 100644 index 00000000..44032bf5 --- /dev/null +++ b/src/components/HeroSection/components/HeroSearch/SearchInput.tsx @@ -0,0 +1,87 @@ +/** + * SearchInput 搜索输入框 + * 浅色样式,带清除按钮 + */ + +import React, { memo } from 'react'; +import { + InputGroup, + InputLeftElement, + Input, + IconButton, + Icon, +} from '@chakra-ui/react'; +import { Search, X } from 'lucide-react'; +import { GLASS_BLUR } from '@/constants/glassConfig'; + +interface SearchInputProps { + value: string; + onChange: (value: string) => void; + onKeyPress: (e: React.KeyboardEvent) => void; + onClear: () => void; + onSearchClick?: () => void; + placeholder: string; + isLoading?: boolean; +} + +export const SearchInput = memo(({ + value, + onChange, + onKeyPress, + onClear, + onSearchClick, + placeholder, + isLoading, +}) => { + return ( + + + + + onChange(e.target.value)} + onKeyPress={onKeyPress} + disabled={isLoading} + /> + {value && ( + } + variant="ghost" + color="gray.500" + borderRadius="full" + _hover={{ color: 'gray.700', bg: 'gray.200' }} + onClick={onClear} + zIndex={1} + /> + )} + + ); +}); + +SearchInput.displayName = 'SearchInput'; diff --git a/src/components/HeroSection/components/HeroSearch/index.tsx b/src/components/HeroSection/components/HeroSearch/index.tsx new file mode 100644 index 00000000..38e96301 --- /dev/null +++ b/src/components/HeroSection/components/HeroSearch/index.tsx @@ -0,0 +1,130 @@ +/** + * HeroSearch 搜索区容器 + * 整合输入框、按钮、下拉结果 + * 支持受控模式和非受控模式 + */ + +import React, { memo, useCallback } from 'react'; +import { Box, Flex } from '@chakra-ui/react'; +import { GLASS_BLUR } from '@/constants/glassConfig'; +import { SearchInput } from './SearchInput'; +import { SearchButton } from './SearchButton'; +import { SearchDropdown } from './SearchDropdown'; +import { useHeroSearch } from '../../hooks/useHeroSearch'; +import type { HeroSearchProps, SearchResultItem } from '../../types'; + +export const HeroSearch = memo(({ + config, + themeColors, +}: HeroSearchProps) => { + // 检测是否受控模式 + const isControlled = config.value !== undefined; + + // 非受控模式使用 hook + const internalSearch = useHeroSearch({ config }); + + // 受控模式值 + const query = isControlled ? (config.value ?? '') : internalSearch.query; + const results = isControlled ? (config.results ?? []) : internalSearch.results; + const isSearching = isControlled ? (config.isSearching ?? false) : internalSearch.isSearching; + const showDropdown = isControlled ? (config.showDropdown ?? false) : internalSearch.showDropdown; + + // 受控模式处理函数 + const handleChange = useCallback((value: string) => { + if (isControlled && config.onChange) { + config.onChange(value); + } else { + internalSearch.setQuery(value); + } + }, [isControlled, config, internalSearch]); + + const handleClear = useCallback(() => { + if (isControlled && config.onClear) { + config.onClear(); + } else { + internalSearch.clearSearch(); + } + }, [isControlled, config, internalSearch]); + + const handleSelect = useCallback((item: T, index: number) => { + config.onResultSelect(item, index); + }, [config]); + + // 触发搜索(回车或点击图标) + const triggerSearch = useCallback(() => { + if (isControlled && config.onSearch) { + config.onSearch(query); + } else if (!isControlled) { + internalSearch.triggerSearch(); + } + }, [isControlled, config, query, internalSearch]); + + const handleKeyPress = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + // 回车触发搜索 + triggerSearch(); + } else if (e.key === 'Escape') { + handleClear(); + } + }, [triggerSearch, handleClear]); + + const handleSearchClick = useCallback(() => { + triggerSearch(); + }, [triggerSearch]); + + const showSearchButton = config.showSearchButton ?? true; + const searchButtonText = config.searchButtonText ?? '搜索'; + + return ( + + + + {showSearchButton && ( + + )} + + + {/* 搜索结果下拉 */} + + + ); +}) as (props: HeroSearchProps) => React.ReactElement; + +(HeroSearch as React.FC).displayName = 'HeroSearch'; + +export { SearchInput } from './SearchInput'; +export { SearchButton } from './SearchButton'; +export { SearchDropdown } from './SearchDropdown'; diff --git a/src/components/HeroSection/components/HeroStats/StatCard.tsx b/src/components/HeroSection/components/HeroStats/StatCard.tsx new file mode 100644 index 00000000..66626e9a --- /dev/null +++ b/src/components/HeroSection/components/HeroStats/StatCard.tsx @@ -0,0 +1,92 @@ +/** + * StatCard 单个统计卡片 + */ + +import React, { memo } from 'react'; +import { + Stat, + StatLabel, + StatNumber, + HStack, + VStack, + Icon, + Text, + Skeleton, +} from '@chakra-ui/react'; +import type { StatCardProps } from '../../types'; + +export const StatCard = memo(({ + item, + themeColors, + showIcon = false, +}) => { + const { label, value, valueColor, icon, iconColor, suffix, isLoading } = item; + + // 格式化显示值 + const displayValue = (() => { + if (isLoading) return null; + if (value === null || value === undefined) return '-'; + if (typeof value === 'number') { + return value.toLocaleString() + (suffix || ''); + } + return value + (suffix || ''); + })(); + + // 带图标的布局(概念中心样式) + if (showIcon && icon) { + return ( + + + + {isLoading ? ( + + ) : ( + + {displayValue} + + )} + + {label} + + + + ); + } + + // 标准卡片布局(个股中心样式)- 紧凑尺寸 + return ( + + + {label} + + {isLoading ? ( + + ) : ( + + {displayValue} + + )} + + ); +}); + +StatCard.displayName = 'StatCard'; diff --git a/src/components/HeroSection/components/HeroStats/index.tsx b/src/components/HeroSection/components/HeroStats/index.tsx new file mode 100644 index 00000000..7f4a561e --- /dev/null +++ b/src/components/HeroSection/components/HeroStats/index.tsx @@ -0,0 +1,44 @@ +/** + * HeroStats 统计区容器 + */ + +import React, { memo } from 'react'; +import { SimpleGrid } from '@chakra-ui/react'; +import { StatCard } from './StatCard'; +import type { HeroStatsProps } from '../../types'; +import { DEFAULT_STATS_COLUMNS } from '../../constants'; + +export const HeroStats = memo(({ + config, + themeColors, +}) => { + const { items, columns, showIcons } = config; + const gridColumns = { ...DEFAULT_STATS_COLUMNS, ...columns }; + + return ( + + {items.map((item) => ( + + ))} + + ); +}); + +HeroStats.displayName = 'HeroStats'; + +export { StatCard } from './StatCard'; diff --git a/src/components/HeroSection/components/HeroTitle/index.tsx b/src/components/HeroSection/components/HeroTitle/index.tsx new file mode 100644 index 00000000..e80ba135 --- /dev/null +++ b/src/components/HeroSection/components/HeroTitle/index.tsx @@ -0,0 +1,68 @@ +/** + * HeroTitle 标题区组件 + * 包含图标、标题、副标题、更新提示 + */ + +import React, { memo } from 'react'; +import { + VStack, + HStack, + Heading, + Text, + Icon, +} from '@chakra-ui/react'; +import type { HeroTitleProps } from '../../types'; + +export const HeroTitle = memo(({ + icon, + title, + subtitle, + updateHint, + themeColors, +}) => { + return ( + + {/* 图标 + 标题 */} + + + + {title} + + + + {/* 更新提示 */} + {updateHint && ( + + {updateHint} + + )} + + {/* 副标题/描述 */} + {subtitle && ( + + {subtitle} + + )} + + ); +}); + +HeroTitle.displayName = 'HeroTitle'; diff --git a/src/components/HeroSection/constants.ts b/src/components/HeroSection/constants.ts new file mode 100644 index 00000000..3d03dff2 --- /dev/null +++ b/src/components/HeroSection/constants.ts @@ -0,0 +1,104 @@ +/** + * HeroSection 主题预设和默认配置 + */ + +import type { HeroThemePreset, HeroThemeColors, HeroSearchConfig } from './types'; + +/** 主题预设配置 */ +export const HERO_THEME_PRESETS: Record = { + purple: { + bgGradient: 'linear(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%)', + primary: '#8B5CF6', + titleGradient: 'linear(to-r, cyan.200, purple.200, pink.200)', + iconColor: 'cyan.300', + borderColor: 'rgba(139, 92, 246, 0.3)', + subtitleColor: 'gray.300', + statCardBg: 'whiteAlpha.100', + statCardBorder: 'rgba(139, 92, 246, 0.3)', + statLabelColor: 'purple.300', + }, + gold: { + bgGradient: 'linear(135deg, #0A0A14 0%, #1A1A2E 50%, #0F0F1A 100%)', + primary: '#D4AF37', + titleGradient: 'linear(to-r, #D4AF37, white)', + iconColor: '#D4AF37', + borderColor: 'rgba(212, 175, 55, 0.3)', + subtitleColor: 'gray.400', + statCardBg: 'rgba(212, 175, 55, 0.1)', + statCardBorder: 'rgba(212, 175, 55, 0.3)', + statLabelColor: '#D4AF37', + }, + blue: { + bgGradient: 'linear(135deg, #0A1628 0%, #1E3A5F 50%, #0F2744 100%)', + primary: '#3B82F6', + titleGradient: 'linear(to-r, blue.200, cyan.200)', + iconColor: 'blue.300', + borderColor: 'rgba(59, 130, 246, 0.3)', + subtitleColor: 'gray.300', + statCardBg: 'rgba(59, 130, 246, 0.1)', + statCardBorder: 'rgba(59, 130, 246, 0.3)', + statLabelColor: 'blue.300', + }, + cyan: { + bgGradient: 'linear(135deg, #0A1A1F 0%, #134E5E 50%, #0F2027 100%)', + primary: '#06B6D4', + titleGradient: 'linear(to-r, cyan.300, teal.200)', + iconColor: 'cyan.400', + borderColor: 'rgba(6, 182, 212, 0.3)', + subtitleColor: 'gray.300', + statCardBg: 'rgba(6, 182, 212, 0.1)', + statCardBorder: 'rgba(6, 182, 212, 0.3)', + statLabelColor: 'cyan.300', + }, + custom: { + bgGradient: 'linear(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%)', + primary: '#8B5CF6', + titleGradient: 'linear(to-r, purple.200, white)', + iconColor: 'purple.300', + borderColor: 'rgba(139, 92, 246, 0.3)', + subtitleColor: 'gray.300', + statCardBg: 'whiteAlpha.100', + statCardBorder: 'rgba(139, 92, 246, 0.3)', + statLabelColor: 'purple.300', + }, +}; + +/** 搜索框默认配置 */ +export const DEFAULT_SEARCH_CONFIG: Partial = { + showSearchButton: true, + searchButtonText: '搜索', + debounceMs: 300, + maxResults: 10, + emptyText: '未找到相关结果', + loadingText: '搜索中...', + maxWidth: '2xl', +}; + +/** 统计区默认列数 */ +export const DEFAULT_STATS_COLUMNS = { + base: 2, + md: 4, +}; + +/** 背景装饰默认配置 */ +export const DEFAULT_DECORATIONS = { + grid: { + opacity: 0.3, + gridSize: '40px', + gridColor: 'rgba(99, 102, 241, 0.1)', + }, + glowOrbs: { + blur: '40px', + opacity: 0.4, + }, +}; + +/** 获取合并后的主题颜色 */ +export function getThemeColors( + preset: HeroThemePreset = 'purple', + customColors?: Partial +): HeroThemeColors { + const baseColors = HERO_THEME_PRESETS[preset]; + if (!customColors) return baseColors; + return { ...baseColors, ...customColors }; +} diff --git a/src/components/HeroSection/hooks/useHeroSearch.ts b/src/components/HeroSection/hooks/useHeroSearch.ts new file mode 100644 index 00000000..5c1c44d7 --- /dev/null +++ b/src/components/HeroSection/hooks/useHeroSearch.ts @@ -0,0 +1,168 @@ +/** + * useHeroSearch Hook + * 处理搜索逻辑:防抖、状态管理、结果过滤 + */ + +import { useState, useCallback, useRef, useEffect } from 'react'; +import type { SearchResultItem, HeroSearchConfig } from '../types'; +import { DEFAULT_SEARCH_CONFIG } from '../constants'; + +interface UseHeroSearchOptions { + config: HeroSearchConfig; +} + +interface UseHeroSearchReturn { + /** 搜索查询值 */ + query: string; + /** 更新查询值 */ + setQuery: (value: string) => void; + /** 搜索结果 */ + results: T[]; + /** 是否正在搜索 */ + isSearching: boolean; + /** 是否显示下拉 */ + showDropdown: boolean; + /** 设置显示下拉 */ + setShowDropdown: (show: boolean) => void; + /** 清空搜索 */ + clearSearch: () => void; + /** 手动触发搜索 */ + triggerSearch: () => void; + /** 选中结果 */ + selectResult: (item: T, index: number) => void; + /** 处理按键事件 */ + handleKeyPress: (e: React.KeyboardEvent) => void; +} + +export function useHeroSearch({ + config, +}: UseHeroSearchOptions): UseHeroSearchReturn { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [showDropdown, setShowDropdown] = useState(false); + + const debounceTimer = useRef(null); + const abortController = useRef(null); + + const debounceMs = config.debounceMs ?? DEFAULT_SEARCH_CONFIG.debounceMs ?? 300; + const maxResults = config.maxResults ?? DEFAULT_SEARCH_CONFIG.maxResults ?? 10; + + // 执行搜索 + const performSearch = useCallback(async (searchQuery: string) => { + if (!searchQuery.trim()) { + setResults([]); + setShowDropdown(false); + return; + } + + // 取消之前的请求 + if (abortController.current) { + abortController.current.abort(); + } + abortController.current = new AbortController(); + + setIsSearching(true); + + try { + const searchResult = await config.onSearch(searchQuery); + const limitedResults = searchResult.slice(0, maxResults); + setResults(limitedResults as T[]); + setShowDropdown(limitedResults.length > 0); + } catch (error) { + // 忽略取消的请求 + if ((error as Error).name !== 'AbortError') { + console.error('Search error:', error); + setResults([]); + } + } finally { + setIsSearching(false); + } + }, [config, maxResults]); + + // 带防抖的查询更新 + const handleQueryChange = useCallback((value: string) => { + setQuery(value); + + // 清除之前的定时器 + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + + if (!value.trim()) { + setResults([]); + setShowDropdown(false); + return; + } + + // 设置新的防抖定时器 + debounceTimer.current = setTimeout(() => { + performSearch(value); + }, debounceMs); + }, [debounceMs, performSearch]); + + // 手动触发搜索(点击搜索按钮) + const triggerSearch = useCallback(() => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + performSearch(query); + }, [query, performSearch]); + + // 清空搜索 + const clearSearch = useCallback(() => { + setQuery(''); + setResults([]); + setShowDropdown(false); + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + }, []); + + // 选中结果 + const selectResult = useCallback((item: T, index: number) => { + config.onResultSelect(item, index); + setShowDropdown(false); + }, [config]); + + // 处理按键事件 + const handleKeyPress = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + // 如果有搜索按钮,Enter 触发搜索 + if (config.showSearchButton) { + triggerSearch(); + } else if (results.length > 0) { + // 否则选中第一个结果 + selectResult(results[0], 0); + } + } else if (e.key === 'Escape') { + setShowDropdown(false); + } + }, [config.showSearchButton, results, triggerSearch, selectResult]); + + // 清理 + useEffect(() => { + return () => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + if (abortController.current) { + abortController.current.abort(); + } + }; + }, []); + + return { + query, + setQuery: handleQueryChange, + results, + isSearching, + showDropdown, + setShowDropdown, + clearSearch, + triggerSearch, + selectResult, + handleKeyPress, + }; +} diff --git a/src/components/HeroSection/index.tsx b/src/components/HeroSection/index.tsx new file mode 100644 index 00000000..d313b59c --- /dev/null +++ b/src/components/HeroSection/index.tsx @@ -0,0 +1,39 @@ +/** + * HeroSection 组件统一导出 + */ + +export { HeroSection } from './HeroSection'; +export { HeroSection as default } from './HeroSection'; + +// 子组件导出 +export { HeroBackground, GridPattern, GlowOrbs, AuroraEffect } from './components/HeroBackground'; +export { HeroTitle } from './components/HeroTitle'; +export { HeroSearch, SearchInput, SearchButton, SearchDropdown } from './components/HeroSearch'; +export { HeroStats, StatCard } from './components/HeroStats'; + +// Hooks 导出 +export { useHeroSearch } from './hooks/useHeroSearch'; + +// 常量导出 +export { HERO_THEME_PRESETS, DEFAULT_SEARCH_CONFIG, DEFAULT_STATS_COLUMNS, getThemeColors } from './constants'; + +// 类型导出 +export type { + HeroSectionProps, + HeroThemePreset, + HeroThemeColors, + BackgroundDecorationType, + BackgroundDecorationConfig, + GlowOrbConfig, + SearchResultItem, + SearchResultTag, + HeroSearchConfig, + HeroStatItem, + HeroStatsConfig, + HeroBackgroundProps, + HeroTitleProps, + HeroSearchProps, + HeroStatsProps, + StatCardProps, + SearchDropdownProps, +} from './types'; diff --git a/src/components/HeroSection/types.ts b/src/components/HeroSection/types.ts new file mode 100644 index 00000000..be5b45dd --- /dev/null +++ b/src/components/HeroSection/types.ts @@ -0,0 +1,184 @@ +/** + * HeroSection 通用模板组件类型定义 + * 用于个股中心、概念中心等页面的 Hero 区域 + */ + +import type { ComponentType, ReactNode } from 'react'; +import type { LucideIcon } from 'lucide-react'; +import type { BoxProps } from '@chakra-ui/react'; + +// ==================== 主题配置 ==================== + +/** 主题预设类型 */ +export type HeroThemePreset = 'purple' | 'gold' | 'blue' | 'cyan' | 'custom'; + +/** 主题颜色配置 */ +export interface HeroThemeColors { + /** 背景渐变 */ + bgGradient: string; + /** 主色调 */ + primary: string; + /** 标题渐变 */ + titleGradient: string; + /** 图标颜色 */ + iconColor: string; + /** 边框颜色 */ + borderColor: string; + /** 副标题/描述文字颜色 */ + subtitleColor: string; + /** 统计卡片背景 */ + statCardBg: string; + /** 统计卡片边框 */ + statCardBorder: string; + /** 统计标签颜色 */ + statLabelColor: string; +} + +// ==================== 背景装饰 ==================== + +/** 背景装饰类型 */ +export type BackgroundDecorationType = 'grid' | 'glowOrbs' | 'aurora' | 'none'; + +/** 发光球体配置 */ +export interface GlowOrbConfig { + top?: string; + bottom?: string; + left?: string; + right?: string; + size: string; + color: string; + blur?: string; +} + +/** 背景装饰配置 */ +export interface BackgroundDecorationConfig { + type: BackgroundDecorationType; + intensity?: number; + colors?: string[]; + orbs?: GlowOrbConfig[]; +} + +// ==================== 搜索配置 ==================== + +/** 搜索结果项标签 */ +export interface SearchResultTag { + text: string; + colorScheme?: string; +} + +/** 搜索结果项基础类型 */ +export interface SearchResultItem { + id: string; + label: string; + subLabel?: string; + tags?: SearchResultTag[]; + extra?: string; + raw?: unknown; +} + +/** 搜索配置 */ +export interface HeroSearchConfig { + placeholder: string; + onSearch: (query: string) => Promise | T[]; + onResultSelect: (item: T, index: number) => void; + showSearchButton?: boolean; + searchButtonText?: string; + debounceMs?: number; + maxResults?: number; + renderResult?: (item: T, index: number) => ReactNode; + emptyText?: string; + loadingText?: string; + maxWidth?: string; + // 受控模式 props + value?: string; + onChange?: (value: string) => void; + results?: T[]; + isSearching?: boolean; + showDropdown?: boolean; + onClear?: () => void; +} + +// ==================== 统计配置 ==================== + +/** 统计项配置 */ +export interface HeroStatItem { + key: string; + label: string; + value: string | number | null | undefined; + valueColor?: string; + icon?: LucideIcon | ComponentType<{ size?: number; color?: string }>; + iconColor?: string; + suffix?: string; + helpText?: string; + isLoading?: boolean; +} + +/** 统计区域配置 */ +export interface HeroStatsConfig { + items: HeroStatItem[]; + columns?: { base?: number; sm?: number; md?: number; lg?: number }; + showIcons?: boolean; +} + +// ==================== 主组件 Props ==================== + +/** HeroSection 组件 Props */ +export interface HeroSectionProps extends Omit { + icon: LucideIcon | ComponentType<{ size?: number; color?: string }>; + title: string; + subtitle?: string; + updateHint?: ReactNode; + themePreset?: HeroThemePreset; + themeColors?: Partial; + decorations?: BackgroundDecorationConfig[]; + search?: HeroSearchConfig; + stats?: HeroStatsConfig; + fullWidth?: boolean; + maxContentWidth?: string; + afterTitle?: ReactNode; + afterSearch?: ReactNode; + afterStats?: ReactNode; + children?: ReactNode; +} + +// ==================== 子组件 Props ==================== + +export interface HeroBackgroundProps { + decorations?: BackgroundDecorationConfig[]; + themeColors: HeroThemeColors; +} + +export interface HeroTitleProps { + icon: LucideIcon | ComponentType<{ size?: number; color?: string }>; + title: string; + subtitle?: string; + updateHint?: ReactNode; + themeColors: HeroThemeColors; +} + +export interface HeroSearchProps { + config: HeroSearchConfig; + themeColors: HeroThemeColors; +} + +export interface HeroStatsProps { + config: HeroStatsConfig; + themeColors: HeroThemeColors; +} + +export interface StatCardProps { + item: HeroStatItem; + themeColors: HeroThemeColors; + showIcon?: boolean; +} + +export interface SearchDropdownProps { + isOpen: boolean; + isSearching: boolean; + results: T[]; + onSelect: (item: T, index: number) => void; + renderResult?: (item: T, index: number) => ReactNode; + emptyText?: string; + loadingText?: string; + themeColors: HeroThemeColors; +}