feat(HeroSection): 新增通用 Hero 模板组件

- 创建 HeroSection 组件系统,支持个股中心和概念中心复用
- 包含 HeroBackground(背景装饰)、HeroTitle(标题区)、HeroSearch(搜索区)、HeroStats(统计区)
- 支持主题预设(purple/gold/blue/cyan)和自定义主题颜色
- 搜索组件支持受控/非受控模式,回车/点击图标/点击按钮触发搜索
- 统计卡片支持带图标横排和独立卡片两种布局

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-30 11:11:24 +08:00
parent cc4ecf4c76
commit 6c10d420a1
16 changed files with 1466 additions and 0 deletions

View File

@@ -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<HeroSectionProps>(({
// 标题区
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 (
<Box
position="relative"
bgGradient={themeColors.bgGradient}
color="white"
overflow="hidden"
zIndex={1}
mx={fullWidthMargin}
borderRadius={fullWidth ? undefined : 'xl'}
borderBottom={`1px solid ${themeColors.borderColor}`}
{...boxProps}
>
{/* 背景装饰层 */}
<HeroBackground
decorations={decorations}
themeColors={themeColors}
/>
{/* 主内容区 - 自适应高度,上下 padding 40px */}
<Box
px={{ base: 4, md: 6, lg: fullWidth ? '80px' : 6 }}
py="40px"
display="flex"
alignItems="center"
justifyContent="center"
position="relative"
>
{children ? (
// 完全自定义内容
children
) : (
<VStack spacing={5} align="center">
{/* 标题区 */}
<HeroTitle
icon={icon}
title={title}
subtitle={subtitle}
updateHint={updateHint}
themeColors={themeColors}
/>
{/* 标题后插槽 */}
{afterTitle}
{/* 统计区(移到搜索框上方) */}
{stats && (
<HeroStats
config={stats}
themeColors={themeColors}
/>
)}
{/* 统计后插槽 */}
{afterStats}
{/* 搜索区 */}
{search && (
<HeroSearch
config={search}
themeColors={themeColors}
/>
)}
{/* 搜索后插槽 */}
{afterSearch}
</VStack>
)}
</Box>
</Box>
);
});
HeroSection.displayName = 'HeroSection';

View File

@@ -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<AuroraEffectProps>(({
intensity = 1,
colors = DEFAULT_COLORS,
}) => {
return (
<>
{/* 紫色极光 */}
<Box
position="absolute"
inset={0}
bgGradient={`radial(ellipse at 30% 20%, ${colors[0] || DEFAULT_COLORS[0]} 0%, transparent 50%)`}
opacity={intensity}
pointerEvents="none"
/>
{/* 蓝色极光 */}
<Box
position="absolute"
inset={0}
bgGradient={`radial(ellipse at 70% 60%, ${colors[1] || DEFAULT_COLORS[1]} 0%, transparent 60%)`}
opacity={intensity}
pointerEvents="none"
/>
{/* 粉色极光 */}
<Box
position="absolute"
inset={0}
bgGradient={`radial(ellipse at 50% 50%, ${colors[2] || DEFAULT_COLORS[2]} 0%, transparent 70%)`}
opacity={intensity}
pointerEvents="none"
/>
</>
);
});
AuroraEffect.displayName = 'AuroraEffect';

View File

@@ -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<GlowOrbsProps>(({
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) => (
<Box
key={index}
position="absolute"
top={orb.top}
bottom={orb.bottom}
left={orb.left}
right={orb.right}
width={orb.size}
height={orb.size}
borderRadius="full"
bgGradient={`radial(circle, ${orb.color}, transparent 70%)`}
filter={`blur(${orb.blur || DEFAULT_DECORATIONS.glowOrbs.blur})`}
opacity={intensity}
pointerEvents="none"
/>
))}
</>
);
});
GlowOrbs.displayName = 'GlowOrbs';

View File

@@ -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<GridPatternProps>(({
intensity = DEFAULT_DECORATIONS.grid.opacity,
color = DEFAULT_DECORATIONS.grid.gridColor,
size = DEFAULT_DECORATIONS.grid.gridSize,
}) => {
return (
<Box
position="absolute"
top="0"
left="0"
right="0"
bottom="0"
bgImage={`linear-gradient(${color} 1px, transparent 1px), linear-gradient(90deg, ${color} 1px, transparent 1px)`}
bgSize={`${size} ${size}`}
opacity={intensity}
pointerEvents="none"
/>
);
});
GridPattern.displayName = 'GridPattern';

View File

@@ -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 (
<GridPattern
key={`grid-${index}`}
intensity={intensity}
color={colors?.[0]}
/>
);
case 'glowOrbs':
return (
<GlowOrbs
key={`orbs-${index}`}
orbs={orbs}
colors={colors}
intensity={intensity}
/>
);
case 'aurora':
return (
<AuroraEffect
key={`aurora-${index}`}
intensity={intensity}
colors={colors}
/>
);
case 'none':
default:
return null;
}
};
export const HeroBackground = memo<HeroBackgroundProps>(({
decorations = [],
themeColors,
}) => {
if (decorations.length === 0) {
return null;
}
return (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
overflow="hidden"
pointerEvents="none"
zIndex={0}
>
{decorations.map((config, index) => renderDecoration(config, index))}
</Box>
);
});
HeroBackground.displayName = 'HeroBackground';
export { GridPattern } from './GridPattern';
export { GlowOrbs } from './GlowOrbs';
export { AuroraEffect } from './AuroraEffect';

View File

@@ -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<SearchButtonProps>(({
onClick,
text = '搜索',
isLoading = false,
}) => {
return (
<Button
size="lg"
borderRadius="0 50px 50px 0"
bgGradient="linear(135deg, #667eea 0%, #764ba2 100%)"
color="white"
_hover={{
bgGradient: 'linear(135deg, #5568d3 0%, #663a8e 100%)',
transform: 'scale(1.02)',
}}
_active={{
bgGradient: 'linear(135deg, #4a5abf 0%, #58327a 100%)',
transform: 'scale(0.98)',
}}
onClick={onClick}
isLoading={isLoading}
loadingText="搜索中"
px={6}
minW="100px"
fontWeight="bold"
fontSize="md"
transition="all 0.2s"
border="none"
alignSelf="stretch"
boxShadow="inset 0 1px 0 rgba(255, 255, 255, 0.2)"
>
{text}
</Button>
);
});
SearchButton.displayName = 'SearchButton';

View File

@@ -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(<T extends SearchResultItem>({
isOpen,
isSearching,
results,
onSelect,
renderResult,
emptyText = '未找到相关结果',
loadingText = '搜索中...',
themeColors,
}: SearchDropdownProps<T>) => {
return (
<Collapse in={isOpen} animateOpacity>
<Box
position="absolute"
top="100%"
left={0}
right={0}
mt={2}
bg="rgba(255, 255, 255, 0.95)"
backdropFilter={GLASS_BLUR.sm}
borderRadius="xl"
boxShadow="0 8px 32px rgba(0, 0, 0, 0.3)"
border="1px solid"
borderColor="rgba(139, 92, 246, 0.2)"
maxH="400px"
overflowY="auto"
zIndex={10}
>
{isSearching ? (
<Center p={4}>
<HStack spacing={2}>
<Spinner size="sm" color={themeColors.primary} />
<Text color="gray.500" fontSize="sm">{loadingText}</Text>
</HStack>
</Center>
) : results.length > 0 ? (
<List spacing={0}>
{results.map((item, index) => (
<ListItem
key={item.id}
p={4}
cursor="pointer"
_hover={{ bg: 'gray.100' }}
onClick={() => onSelect(item, index)}
borderBottomWidth={index < results.length - 1 ? '1px' : '0'}
borderColor="gray.200"
>
{renderResult ? (
renderResult(item, index)
) : (
<Flex align="center" justify="space-between">
<VStack align="start" spacing={1} flex={1}>
<Text fontWeight="bold" color="gray.800">
{item.label}
</Text>
<HStack spacing={2}>
{item.subLabel && (
<Text fontSize="sm" color="gray.500">
{item.subLabel}
</Text>
)}
{item.extra && (
<Text fontSize="xs" color="gray.400">
({item.extra})
</Text>
)}
{item.tags?.map((tag, tagIndex) => (
<Tag
key={tagIndex}
size="sm"
bg="gray.100"
color={themeColors.primary}
border="1px solid"
borderColor={themeColors.primary}
>
{tag.text}
</Tag>
))}
</HStack>
</VStack>
<Button
size="sm"
rightIcon={<ArrowRight size={16} />}
variant="ghost"
color={themeColors.primary}
_hover={{ bg: 'purple.50' }}
>
</Button>
</Flex>
)}
</ListItem>
))}
</List>
) : (
<Center p={4}>
<Text color="gray.500" fontSize="sm">{emptyText}</Text>
</Center>
)}
</Box>
</Collapse>
);
}) as <T extends SearchResultItem>(props: SearchDropdownProps<T>) => React.ReactElement;
(SearchDropdown as React.FC).displayName = 'SearchDropdown';

View File

@@ -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<SearchInputProps>(({
value,
onChange,
onKeyPress,
onClear,
onSearchClick,
placeholder,
isLoading,
}) => {
return (
<InputGroup size="lg" flex={1}>
<InputLeftElement
h="full"
cursor={onSearchClick ? 'pointer' : 'default'}
onClick={onSearchClick}
_hover={onSearchClick ? { opacity: 0.7 } : undefined}
transition="opacity 0.2s"
>
<Icon as={Search} color="purple.400" boxSize={5} />
</InputLeftElement>
<Input
placeholder={placeholder}
bg="transparent"
color="gray.800"
border="none"
fontSize="md"
fontWeight="medium"
pl={12}
pr={value ? '50px' : '16px'}
_placeholder={{ color: 'gray.400' }}
_focus={{
outline: 'none',
boxShadow: 'none',
}}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyPress={onKeyPress}
disabled={isLoading}
/>
{value && (
<IconButton
position="absolute"
right="10px"
top="50%"
transform="translateY(-50%)"
size="sm"
aria-label="清除搜索"
icon={<X size={16} />}
variant="ghost"
color="gray.500"
borderRadius="full"
_hover={{ color: 'gray.700', bg: 'gray.200' }}
onClick={onClear}
zIndex={1}
/>
)}
</InputGroup>
);
});
SearchInput.displayName = 'SearchInput';

View File

@@ -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(<T extends SearchResultItem>({
config,
themeColors,
}: HeroSearchProps<T>) => {
// 检测是否受控模式
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 (
<Box w="140%" position="relative">
<Flex
align="center"
bg="rgba(255, 255, 255, 0.95)"
backdropFilter={GLASS_BLUR.sm}
borderRadius="full"
overflow="hidden"
boxShadow={`0 8px 32px rgba(0, 0, 0, 0.3), 0 0 0 1px ${themeColors.primary}33`}
transition="all 0.3s"
_hover={{
boxShadow: `0 12px 40px ${themeColors.primary}66, 0 0 0 2px ${themeColors.primary}66`,
transform: 'translateY(-2px)',
}}
>
<SearchInput
value={query}
onChange={handleChange}
onKeyPress={handleKeyPress}
onClear={handleClear}
onSearchClick={handleSearchClick}
placeholder={config.placeholder}
isLoading={isSearching}
/>
{showSearchButton && (
<SearchButton
onClick={handleSearchClick}
text={searchButtonText}
isLoading={isSearching}
/>
)}
</Flex>
{/* 搜索结果下拉 */}
<SearchDropdown
isOpen={showDropdown}
isSearching={isSearching}
results={results}
onSelect={handleSelect}
renderResult={config.renderResult}
emptyText={config.emptyText}
loadingText={config.loadingText}
themeColors={themeColors}
/>
</Box>
);
}) as <T extends SearchResultItem>(props: HeroSearchProps<T>) => React.ReactElement;
(HeroSearch as React.FC).displayName = 'HeroSearch';
export { SearchInput } from './SearchInput';
export { SearchButton } from './SearchButton';
export { SearchDropdown } from './SearchDropdown';

View File

@@ -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<StatCardProps>(({
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 (
<HStack spacing={2}>
<Icon as={icon} boxSize={4} color={iconColor || themeColors.primary} />
<VStack spacing={0} align="start">
{isLoading ? (
<Skeleton height="24px" width="60px" />
) : (
<Text
fontSize="xl"
fontWeight="bold"
color={valueColor || iconColor || themeColors.primary}
>
{displayValue}
</Text>
)}
<Text fontSize="xs" opacity={0.7} color="white">
{label}
</Text>
</VStack>
</HStack>
);
}
// 标准卡片布局(个股中心样式)- 紧凑尺寸
return (
<Stat
textAlign="center"
bg={themeColors.statCardBg}
px={3}
py={2}
borderRadius="md"
border="1px solid"
borderColor={themeColors.statCardBorder}
>
<StatLabel
color={themeColors.statLabelColor}
fontWeight="medium"
fontSize="xs"
>
{label}
</StatLabel>
{isLoading ? (
<Skeleton height="24px" width="60px" mx="auto" mt={1} />
) : (
<StatNumber
fontSize="lg"
color={valueColor || 'white'}
>
{displayValue}
</StatNumber>
)}
</Stat>
);
});
StatCard.displayName = 'StatCard';

View File

@@ -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<HeroStatsProps>(({
config,
themeColors,
}) => {
const { items, columns, showIcons } = config;
const gridColumns = { ...DEFAULT_STATS_COLUMNS, ...columns };
return (
<SimpleGrid
columns={{
base: gridColumns.base ?? 2,
sm: gridColumns.sm ?? undefined,
md: gridColumns.md ?? 4,
lg: gridColumns.lg ?? undefined,
}}
spacing={6}
w="100%"
maxW="4xl"
>
{items.map((item) => (
<StatCard
key={item.key}
item={item}
themeColors={themeColors}
showIcon={showIcons}
/>
))}
</SimpleGrid>
);
});
HeroStats.displayName = 'HeroStats';
export { StatCard } from './StatCard';

View File

@@ -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<HeroTitleProps>(({
icon,
title,
subtitle,
updateHint,
themeColors,
}) => {
return (
<VStack spacing={1} textAlign="center">
{/* 图标 + 标题 */}
<HStack spacing={2} justify="center">
<Icon
as={icon}
boxSize={7}
color={themeColors.iconColor}
filter={`drop-shadow(0 0 8px ${themeColors.primary}80)`}
/>
<Heading
as="h1"
fontSize={{ base: '2xl', md: '3xl' }}
fontWeight="black"
bgGradient={themeColors.titleGradient}
bgClip="text"
letterSpacing="tight"
textShadow={`0 0 20px ${themeColors.primary}30`}
>
{title}
</Heading>
</HStack>
{/* 更新提示 */}
{updateHint && (
<HStack spacing={1} justify="center" opacity={0.8}>
{updateHint}
</HStack>
)}
{/* 副标题/描述 */}
{subtitle && (
<Text
fontSize="sm"
color={themeColors.subtitleColor}
opacity={0.7}
maxW="2xl"
>
{subtitle}
</Text>
)}
</VStack>
);
});
HeroTitle.displayName = 'HeroTitle';

View File

@@ -0,0 +1,104 @@
/**
* HeroSection 主题预设和默认配置
*/
import type { HeroThemePreset, HeroThemeColors, HeroSearchConfig } from './types';
/** 主题预设配置 */
export const HERO_THEME_PRESETS: Record<HeroThemePreset, HeroThemeColors> = {
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<HeroSearchConfig> = {
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>
): HeroThemeColors {
const baseColors = HERO_THEME_PRESETS[preset];
if (!customColors) return baseColors;
return { ...baseColors, ...customColors };
}

View File

@@ -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<T extends SearchResultItem> {
config: HeroSearchConfig<T>;
}
interface UseHeroSearchReturn<T extends SearchResultItem> {
/** 搜索查询值 */
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<T extends SearchResultItem = SearchResultItem>({
config,
}: UseHeroSearchOptions<T>): UseHeroSearchReturn<T> {
const [query, setQuery] = useState('');
const [results, setResults] = useState<T[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
const abortController = useRef<AbortController | null>(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,
};
}

View File

@@ -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';

View File

@@ -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<T extends SearchResultItem = SearchResultItem> {
placeholder: string;
onSearch: (query: string) => Promise<T[]> | 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<BoxProps, 'title'> {
icon: LucideIcon | ComponentType<{ size?: number; color?: string }>;
title: string;
subtitle?: string;
updateHint?: ReactNode;
themePreset?: HeroThemePreset;
themeColors?: Partial<HeroThemeColors>;
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<T extends SearchResultItem = SearchResultItem> {
config: HeroSearchConfig<T>;
themeColors: HeroThemeColors;
}
export interface HeroStatsProps {
config: HeroStatsConfig;
themeColors: HeroThemeColors;
}
export interface StatCardProps {
item: HeroStatItem;
themeColors: HeroThemeColors;
showIcon?: boolean;
}
export interface SearchDropdownProps<T extends SearchResultItem = SearchResultItem> {
isOpen: boolean;
isSearching: boolean;
results: T[];
onSelect: (item: T, index: number) => void;
renderResult?: (item: T, index: number) => ReactNode;
emptyText?: string;
loadingText?: string;
themeColors: HeroThemeColors;
}