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:
124
src/components/HeroSection/HeroSection.tsx
Normal file
124
src/components/HeroSection/HeroSection.tsx
Normal 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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
130
src/components/HeroSection/components/HeroSearch/index.tsx
Normal file
130
src/components/HeroSection/components/HeroSearch/index.tsx
Normal 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';
|
||||
92
src/components/HeroSection/components/HeroStats/StatCard.tsx
Normal file
92
src/components/HeroSection/components/HeroStats/StatCard.tsx
Normal 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';
|
||||
44
src/components/HeroSection/components/HeroStats/index.tsx
Normal file
44
src/components/HeroSection/components/HeroStats/index.tsx
Normal 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';
|
||||
68
src/components/HeroSection/components/HeroTitle/index.tsx
Normal file
68
src/components/HeroSection/components/HeroTitle/index.tsx
Normal 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';
|
||||
104
src/components/HeroSection/constants.ts
Normal file
104
src/components/HeroSection/constants.ts
Normal 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 };
|
||||
}
|
||||
168
src/components/HeroSection/hooks/useHeroSearch.ts
Normal file
168
src/components/HeroSection/hooks/useHeroSearch.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
39
src/components/HeroSection/index.tsx
Normal file
39
src/components/HeroSection/index.tsx
Normal 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';
|
||||
184
src/components/HeroSection/types.ts
Normal file
184
src/components/HeroSection/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user