Compare commits
8 Commits
feature_bu
...
feature_20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d05b69601 | ||
|
|
eaf11713e8 | ||
|
|
91d89fb958 | ||
|
|
9deb9ff350 | ||
|
|
d549eaaf9f | ||
|
|
1e511cb3f5 | ||
|
|
bc6d5fd222 | ||
|
|
6c10d420a1 |
144
src/components/HeroSection/HeroSection.tsx
Normal file
144
src/components/HeroSection/HeroSection.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 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"
|
||||
color="white"
|
||||
zIndex={1}
|
||||
mx={fullWidthMargin}
|
||||
borderRadius={fullWidth ? undefined : 'xl'}
|
||||
{...boxProps}
|
||||
>
|
||||
{/* 背景容器 - 处理渐变和过渡 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
inset={0}
|
||||
bgGradient={themeColors.bgGradient}
|
||||
overflow="hidden"
|
||||
zIndex={0}
|
||||
>
|
||||
{/* 背景装饰层 */}
|
||||
<HeroBackground
|
||||
decorations={decorations}
|
||||
themeColors={themeColors}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 底部渐变过渡区域 - 创造纵深感 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
h="80px"
|
||||
bgGradient="linear(to-b, transparent, rgba(10, 10, 15, 0.8))"
|
||||
pointerEvents="none"
|
||||
zIndex={1}
|
||||
/>
|
||||
|
||||
{/* 主内容区 - 自适应高度,上下 padding 40px */}
|
||||
<Box
|
||||
px={{ base: 4, md: 6, lg: fullWidth ? '80px' : 6 }}
|
||||
py="40px"
|
||||
pb="60px"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
position="relative"
|
||||
zIndex={2}
|
||||
>
|
||||
{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';
|
||||
242
src/components/HeroSection/components/HeroStats/StatCard.tsx
Normal file
242
src/components/HeroSection/components/HeroStats/StatCard.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* StatCard 单个统计卡片
|
||||
* 支持趋势变化显示和多空进度条
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
HStack,
|
||||
VStack,
|
||||
Icon,
|
||||
Text,
|
||||
Skeleton,
|
||||
Box,
|
||||
Flex,
|
||||
} from '@chakra-ui/react';
|
||||
import { ArrowUp, ArrowDown, Minus } from 'lucide-react';
|
||||
import type { StatCardProps, TrendInfo, ProgressBarConfig, WatermarkIconConfig } from '../../types';
|
||||
|
||||
/** 趋势指示器组件 */
|
||||
const TrendIndicator = memo<{ trend: TrendInfo }>(({ trend }) => {
|
||||
const { direction, percent, label, compareText = '较昨日' } = trend;
|
||||
|
||||
// 根据方向确定颜色和图标
|
||||
const colorMap = {
|
||||
up: '#ff4d4d',
|
||||
down: '#22c55e',
|
||||
flat: 'gray.400',
|
||||
};
|
||||
const IconMap = {
|
||||
up: ArrowUp,
|
||||
down: ArrowDown,
|
||||
flat: Minus,
|
||||
};
|
||||
const color = colorMap[direction];
|
||||
const TrendIcon = IconMap[direction];
|
||||
|
||||
// 格式化百分比
|
||||
const formattedPercent = direction === 'flat' ? '0%' : `${direction === 'up' ? '+' : ''}${percent.toFixed(1)}%`;
|
||||
|
||||
return (
|
||||
<HStack spacing={1} justify="center" mt={1}>
|
||||
<Icon as={TrendIcon} boxSize={3} color={color} />
|
||||
<Text fontSize="xs" color={color} fontWeight="medium">
|
||||
{formattedPercent}
|
||||
</Text>
|
||||
{label && (
|
||||
<Text fontSize="xs" color={color} opacity={0.8}>
|
||||
({label})
|
||||
</Text>
|
||||
)}
|
||||
<Text fontSize="xs" color="whiteAlpha.500">
|
||||
{compareText}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
});
|
||||
|
||||
TrendIndicator.displayName = 'TrendIndicator';
|
||||
|
||||
/** 多空进度条组件 */
|
||||
const BullBearBar = memo<{ config: ProgressBarConfig }>(({ config }) => {
|
||||
const {
|
||||
value,
|
||||
total,
|
||||
positiveColor = '#ff4d4d',
|
||||
negativeColor = '#22c55e',
|
||||
compareLabel,
|
||||
compareValue,
|
||||
} = config;
|
||||
|
||||
// 计算百分比
|
||||
const sum = value + total;
|
||||
const positivePercent = sum > 0 ? (value / sum) * 100 : 50;
|
||||
const negativePercent = 100 - positivePercent;
|
||||
|
||||
return (
|
||||
<VStack spacing={1} w="100%" mt={2}>
|
||||
{/* 进度条 */}
|
||||
<Box
|
||||
w="100%"
|
||||
h="6px"
|
||||
borderRadius="full"
|
||||
overflow="hidden"
|
||||
bg="whiteAlpha.200"
|
||||
>
|
||||
<Flex h="100%">
|
||||
<Box
|
||||
w={`${positivePercent}%`}
|
||||
bg={positiveColor}
|
||||
borderRadius="full"
|
||||
transition="width 0.3s ease"
|
||||
/>
|
||||
<Box
|
||||
w={`${negativePercent}%`}
|
||||
bg={negativeColor}
|
||||
borderRadius="full"
|
||||
transition="width 0.3s ease"
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
{/* 标签(仅当有 compareLabel 时显示) */}
|
||||
{compareLabel && compareValue !== undefined && (
|
||||
<Flex w="100%" justify="space-between" fontSize="xs">
|
||||
<Text color={positiveColor} fontWeight="medium">
|
||||
{value.toLocaleString()}
|
||||
</Text>
|
||||
<Text color={negativeColor} fontWeight="medium">
|
||||
{compareLabel}: {compareValue.toLocaleString()}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
});
|
||||
|
||||
BullBearBar.displayName = 'BullBearBar';
|
||||
|
||||
/** 水印图标组件 */
|
||||
const WatermarkIcon = memo<{ config: WatermarkIconConfig }>(({ config }) => {
|
||||
const { icon: WIcon, color = 'white', opacity = 0.08, size = 48 } = config;
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="50%"
|
||||
right={3}
|
||||
transform="translateY(-50%)"
|
||||
opacity={opacity}
|
||||
pointerEvents="none"
|
||||
zIndex={0}
|
||||
>
|
||||
<Icon as={WIcon} boxSize={`${size}px`} color={color} />
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
WatermarkIcon.displayName = 'WatermarkIcon';
|
||||
|
||||
export const StatCard = memo<StatCardProps>(({
|
||||
item,
|
||||
themeColors,
|
||||
showIcon = false,
|
||||
}) => {
|
||||
const { label, value, valueColor, icon, iconColor, suffix, isLoading, trend, progressBar, watermark } = 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}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 水印背景图标 */}
|
||||
{watermark && <WatermarkIcon config={watermark} />}
|
||||
|
||||
<Box position="relative" zIndex={1}>
|
||||
<StatLabel
|
||||
color={themeColors.statLabelColor}
|
||||
fontWeight="medium"
|
||||
fontSize="xs"
|
||||
>
|
||||
{label}
|
||||
</StatLabel>
|
||||
{isLoading ? (
|
||||
<Skeleton height="24px" width="60px" mx="auto" mt={1} />
|
||||
) : (
|
||||
<>
|
||||
{/* 多空对比特殊渲染:双色显示 */}
|
||||
{progressBar && typeof value === 'string' && value.includes('/') ? (
|
||||
<StatNumber fontSize="lg">
|
||||
<Text as="span" color={progressBar.positiveColor || '#ff4d4d'}>
|
||||
{value.split('/')[0]}
|
||||
</Text>
|
||||
<Text as="span" color="whiteAlpha.500">/</Text>
|
||||
<Text as="span" color={progressBar.negativeColor || '#22c55e'}>
|
||||
{value.split('/')[1]}
|
||||
</Text>
|
||||
</StatNumber>
|
||||
) : (
|
||||
<StatNumber
|
||||
fontSize="lg"
|
||||
color={valueColor || 'white'}
|
||||
>
|
||||
{displayValue}
|
||||
</StatNumber>
|
||||
)}
|
||||
{/* 趋势指示器 */}
|
||||
{trend && <TrendIndicator trend={trend} />}
|
||||
{/* 多空进度条 */}
|
||||
{progressBar && <BullBearBar config={progressBar} />}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</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,
|
||||
};
|
||||
}
|
||||
45
src/components/HeroSection/index.tsx
Normal file
45
src/components/HeroSection/index.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 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,
|
||||
// 新增趋势和进度条类型
|
||||
TrendDirection,
|
||||
TrendInfo,
|
||||
ProgressBarConfig,
|
||||
// 水印图标类型
|
||||
WatermarkIconConfig,
|
||||
} from './types';
|
||||
233
src/components/HeroSection/types.ts
Normal file
233
src/components/HeroSection/types.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 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 type TrendDirection = 'up' | 'down' | 'flat';
|
||||
|
||||
/** 趋势信息配置 */
|
||||
export interface TrendInfo {
|
||||
/** 变化方向 */
|
||||
direction: TrendDirection;
|
||||
/** 变化百分比 */
|
||||
percent: number;
|
||||
/** 自定义描述文字(如:放量、缩量) */
|
||||
label?: string;
|
||||
/** 对比时间描述(如:较昨日、同比) */
|
||||
compareText?: string;
|
||||
}
|
||||
|
||||
/** 进度条配置(用于涨跌家数等多空对比场景) */
|
||||
export interface ProgressBarConfig {
|
||||
/** 当前值 */
|
||||
value: number;
|
||||
/** 总值(或对比的另一个值) */
|
||||
total: number;
|
||||
/** 正向颜色(默认红色) */
|
||||
positiveColor?: string;
|
||||
/** 负向颜色(默认绿色) */
|
||||
negativeColor?: string;
|
||||
/** 对比项的标签 */
|
||||
compareLabel?: string;
|
||||
/** 对比项的值 */
|
||||
compareValue?: number;
|
||||
}
|
||||
|
||||
/** 水印图标配置 */
|
||||
export interface WatermarkIconConfig {
|
||||
/** 图标组件 */
|
||||
icon: LucideIcon | ComponentType<{ size?: number; color?: string }>;
|
||||
/** 图标颜色 */
|
||||
color?: string;
|
||||
/** 透明度(默认 0.08) */
|
||||
opacity?: number;
|
||||
/** 图标大小(默认 48) */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/** 统计项配置 */
|
||||
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;
|
||||
/** 趋势信息(显示环比/同比变化) */
|
||||
trend?: TrendInfo;
|
||||
/** 进度条配置(显示多空对比) */
|
||||
progressBar?: ProgressBarConfig;
|
||||
/** 水印背景图标配置 */
|
||||
watermark?: WatermarkIconConfig;
|
||||
}
|
||||
|
||||
/** 统计区域配置 */
|
||||
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;
|
||||
}
|
||||
@@ -582,19 +582,38 @@ export const marketHandlers = [
|
||||
|
||||
console.log('[Mock Market] 获取市场统计数据:', { date: tradeDate });
|
||||
|
||||
// 生成今日数据
|
||||
const todayMarketCap = parseFloat((Math.random() * 5000 + 80000).toFixed(2));
|
||||
const todayAmount = parseFloat((Math.random() * 3000 + 8000).toFixed(2));
|
||||
const todayRising = Math.floor(Math.random() * 1500 + 1500);
|
||||
const todayFalling = Math.floor(Math.random() * 1500 + 1000);
|
||||
|
||||
// 生成昨日数据(用于对比)
|
||||
const yesterdayMarketCap = parseFloat((todayMarketCap * (0.98 + Math.random() * 0.04)).toFixed(2));
|
||||
const yesterdayAmount = parseFloat((todayAmount * (0.85 + Math.random() * 0.3)).toFixed(2));
|
||||
const yesterdayRising = Math.floor(todayRising * (0.7 + Math.random() * 0.6));
|
||||
const yesterdayFalling = Math.floor(todayFalling * (0.7 + Math.random() * 0.6));
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
summary: {
|
||||
total_market_cap: parseFloat((Math.random() * 5000 + 80000).toFixed(2)), // 80000-85000亿
|
||||
total_amount: parseFloat((Math.random() * 3000 + 8000).toFixed(2)), // 8000-11000亿
|
||||
avg_pe: parseFloat((Math.random() * 5 + 12).toFixed(2)), // 12-17
|
||||
avg_pb: parseFloat((Math.random() * 0.5 + 1.3).toFixed(2)), // 1.3-1.8
|
||||
rising_stocks: Math.floor(Math.random() * 1500 + 1500), // 1500-3000
|
||||
falling_stocks: Math.floor(Math.random() * 1500 + 1000), // 1000-2500
|
||||
unchanged_stocks: Math.floor(Math.random() * 200 + 100) // 100-300
|
||||
total_market_cap: todayMarketCap,
|
||||
total_amount: todayAmount,
|
||||
avg_pe: parseFloat((Math.random() * 5 + 12).toFixed(2)),
|
||||
avg_pb: parseFloat((Math.random() * 0.5 + 1.3).toFixed(2)),
|
||||
rising_stocks: todayRising,
|
||||
falling_stocks: todayFalling,
|
||||
unchanged_stocks: Math.floor(Math.random() * 200 + 100)
|
||||
},
|
||||
// 昨日对比数据
|
||||
yesterday: {
|
||||
total_market_cap: yesterdayMarketCap,
|
||||
total_amount: yesterdayAmount,
|
||||
rising_stocks: yesterdayRising,
|
||||
falling_stocks: yesterdayFalling
|
||||
},
|
||||
trade_date: tradeDate,
|
||||
available_dates: availableDates.slice(0, 20) // 返回最近20个交易日
|
||||
available_dates: availableDates.slice(0, 20)
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -136,6 +136,7 @@ import { useConceptEvents } from './hooks/useConceptEvents';
|
||||
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
import { GLASS_BLUR } from '@/constants/glassConfig';
|
||||
import { HeroSection } from '@components/HeroSection';
|
||||
|
||||
// API配置 - 生产环境通过 api.valuefrontier.cn 代理
|
||||
const API_BASE_URL = process.env.NODE_ENV === 'production'
|
||||
@@ -1572,216 +1573,59 @@ const ConceptCenter = () => {
|
||||
|
||||
{/* 导航栏已由 MainLayout 提供 */}
|
||||
|
||||
{/* Hero Section - 精简版 */}
|
||||
{/* Hero Section - 使用负 margin 抵消 Layout 的 padding 实现全宽背景 */}
|
||||
<Box
|
||||
position="relative"
|
||||
bgGradient="linear(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%)"
|
||||
color="white"
|
||||
overflow="hidden"
|
||||
zIndex={1}
|
||||
mx={{ base: -4, md: -6, lg: '-80px' }}
|
||||
>
|
||||
{/* 科幻网格背景 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
bgImage="linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px)"
|
||||
bgSize="40px 40px"
|
||||
opacity={0.3}
|
||||
/>
|
||||
|
||||
{/* 发光球体 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="20%"
|
||||
right="10%"
|
||||
width="200px"
|
||||
height="200px"
|
||||
borderRadius="full"
|
||||
bgGradient="radial(circle, rgba(139, 92, 246, 0.4), transparent 70%)"
|
||||
filter="blur(40px)"
|
||||
/>
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="10%"
|
||||
left="10%"
|
||||
width="250px"
|
||||
height="250px"
|
||||
borderRadius="full"
|
||||
bgGradient="radial(circle, rgba(59, 130, 246, 0.3), transparent 70%)"
|
||||
filter="blur(50px)"
|
||||
/>
|
||||
|
||||
<Box px={{ base: 4, md: 6, lg: '80px' }} position="relative" py={{ base: 8, md: 12 }}>
|
||||
<VStack spacing={6}>
|
||||
{/* 标题区域 */}
|
||||
<VStack spacing={3} textAlign="center">
|
||||
<HStack spacing={3} justify="center">
|
||||
<Icon as={Brain} boxSize={10} color="cyan.300" filter="drop-shadow(0 0 10px rgba(6, 182, 212, 0.5))" />
|
||||
</HStack>
|
||||
|
||||
<VStack spacing={1}>
|
||||
<Heading
|
||||
as="h1"
|
||||
fontSize={{ base: "3xl", md: "4xl" }}
|
||||
fontWeight="black"
|
||||
bgGradient="linear(to-r, cyan.200, purple.200, pink.200)"
|
||||
bgClip="text"
|
||||
letterSpacing="tight"
|
||||
textShadow="0 0 30px rgba(147, 197, 253, 0.3)"
|
||||
>
|
||||
概念中心
|
||||
</Heading>
|
||||
<HStack spacing={2} justify="center">
|
||||
<Icon as={Clock} boxSize={3} color="cyan.200" />
|
||||
<Text fontSize="xs" fontWeight="medium" opacity={0.8}>
|
||||
数据约下午4点更新
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<Text fontSize="md" opacity={0.7} maxW="2xl">
|
||||
AI驱动的概念板块分析平台 · 实时追踪市场热点 · 智能挖掘投资机会
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* 核心数据展示 */}
|
||||
<HStack
|
||||
spacing={6}
|
||||
divider={<Box w="1px" h="30px" bg="whiteAlpha.300" />}
|
||||
bg="whiteAlpha.100"
|
||||
backdropFilter={GLASS_BLUR.sm}
|
||||
px={8}
|
||||
py={3}
|
||||
borderRadius="full"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.300"
|
||||
boxShadow="0 8px 32px rgba(0, 0, 0, 0.3)"
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={Tags} boxSize={4} color="cyan.300" />
|
||||
<VStack spacing={0} align="start">
|
||||
<Text fontSize="xl" fontWeight="bold" color="cyan.300">500+</Text>
|
||||
<Text fontSize="xs" opacity={0.7}>概念板块</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={LineChart} boxSize={4} color="purple.300" />
|
||||
<VStack spacing={0} align="start">
|
||||
<Text fontSize="xl" fontWeight="bold" color="purple.300">5000+</Text>
|
||||
<Text fontSize="xs" opacity={0.7}>相关个股</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={Zap} boxSize={4} color="yellow.300" />
|
||||
<VStack spacing={0} align="start">
|
||||
<Text fontSize="xl" fontWeight="bold" color="yellow.300">24/7</Text>
|
||||
<Text fontSize="xs" opacity={0.7}>实时监控</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<Box w="100%" maxW="3xl">
|
||||
<VStack spacing={2}>
|
||||
<Box w="100%" 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 rgba(139, 92, 246, 0.2)"
|
||||
transition="all 0.3s"
|
||||
_hover={{
|
||||
boxShadow: '0 12px 40px rgba(139, 92, 246, 0.4), 0 0 0 2px rgba(139, 92, 246, 0.4)',
|
||||
transform: 'translateY(-2px)',
|
||||
}}
|
||||
>
|
||||
<InputGroup size="lg" flex={1}>
|
||||
<InputLeftElement pointerEvents="none" h="full">
|
||||
<Icon as={Search} color="purple.400" boxSize={5} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="搜索概念板块、个股、关键词..."
|
||||
bg="transparent"
|
||||
color="gray.800"
|
||||
border="none"
|
||||
fontSize="md"
|
||||
fontWeight="medium"
|
||||
pl={12}
|
||||
_placeholder={{ color: 'gray.400' }}
|
||||
_focus={{
|
||||
outline: 'none',
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
pr={searchQuery ? "50px" : "16px"}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<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={handleClearSearch}
|
||||
zIndex={1}
|
||||
/>
|
||||
)}
|
||||
</InputGroup>
|
||||
<Button
|
||||
size="lg"
|
||||
borderRadius="0 50px 50px 0"
|
||||
bgGradient="linear(135deg, #667eea 0%, #764ba2 100%)"
|
||||
color="white"
|
||||
leftIcon={<Search size={16} />}
|
||||
_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={handleSearch}
|
||||
isLoading={loading}
|
||||
loadingText="搜索中"
|
||||
px={8}
|
||||
minW="140px"
|
||||
fontWeight="bold"
|
||||
fontSize="md"
|
||||
transition="all 0.2s"
|
||||
border="none"
|
||||
alignSelf="stretch"
|
||||
boxShadow="inset 0 1px 0 rgba(255, 255, 255, 0.2)"
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
{searchQuery && sortBy === '_score' && (
|
||||
<Text fontSize="xs" color="cyan.200" opacity={0.9}>
|
||||
正在搜索 "{searchQuery}",已自动切换到相关度排序
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Hero Section - 使用通用 HeroSection 组件 */}
|
||||
<HeroSection
|
||||
icon={Brain}
|
||||
title="概念中心"
|
||||
subtitle="AI驱动的概念板块分析平台 · 实时追踪市场热点 · 智能挖掘投资机会"
|
||||
updateHint={
|
||||
<HStack spacing={2} justify="center">
|
||||
<Icon as={Clock} boxSize={3} color="cyan.200" />
|
||||
<Text fontSize="xs" fontWeight="medium" opacity={0.8}>
|
||||
数据约下午4点更新
|
||||
</Text>
|
||||
</HStack>
|
||||
}
|
||||
themePreset="purple"
|
||||
fullWidth
|
||||
decorations={[
|
||||
{ type: 'grid', intensity: 0.3 },
|
||||
{ type: 'glowOrbs', intensity: 0.5 },
|
||||
]}
|
||||
search={{
|
||||
placeholder: '搜索概念板块、个股、关键词...',
|
||||
showSearchButton: true,
|
||||
searchButtonText: '搜索',
|
||||
maxWidth: '2xl',
|
||||
// 受控模式
|
||||
value: searchQuery,
|
||||
onChange: setSearchQuery,
|
||||
onClear: handleClearSearch,
|
||||
isSearching: false,
|
||||
showDropdown: false,
|
||||
onSearch: async () => {
|
||||
handleSearch();
|
||||
return [];
|
||||
},
|
||||
onResultSelect: () => {},
|
||||
}}
|
||||
stats={{
|
||||
columns: { base: 3, md: 3 },
|
||||
showIcons: false,
|
||||
items: [
|
||||
{ key: 'concepts', label: '概念板块', value: '500+', icon: Tags, iconColor: 'cyan.300', valueColor: 'cyan.300' },
|
||||
{ key: 'stocks', label: '相关个股', value: '5000+', icon: LineChart, iconColor: 'purple.300', valueColor: 'purple.300' },
|
||||
{ key: 'monitor', label: '实时监控', value: '24/7', icon: Zap, iconColor: 'yellow.300', valueColor: 'yellow.300' },
|
||||
],
|
||||
}}
|
||||
afterSearch={
|
||||
searchQuery && sortBy === '_score' ? (
|
||||
<Text fontSize="xs" color="cyan.200" opacity={0.9}>
|
||||
正在搜索 "{searchQuery}",已自动切换到相关度排序
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 主内容区域 - padding 由 MainLayout 统一设置 */}
|
||||
<Box py={10} position="relative" zIndex={1}>
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* 展示大盘分时走势 + 概念异动标注
|
||||
*
|
||||
* 布局设计:
|
||||
* - 顶部:统计摘要(指数信息 + 异动统计)
|
||||
* - 顶部:标题 + 日期选择器 + 异动数量
|
||||
* - 中部:大尺寸分时图(主要展示区域)
|
||||
* - 下方:统计卡片(指数信息 + 异动统计)
|
||||
* - 底部:异动列表(横向滚动卡片)
|
||||
*/
|
||||
import React, { useState, useCallback } from 'react';
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
import { useHotspotData } from './hooks';
|
||||
import { IndexMinuteChart, AlertDetailDrawer } from './components';
|
||||
import { ALERT_TYPE_CONFIG, getAlertTypeLabel } from './utils/chartHelpers';
|
||||
import TradeDatePicker from '@components/TradeDatePicker';
|
||||
import {
|
||||
glassEffect,
|
||||
colors,
|
||||
@@ -192,10 +194,15 @@ const CompactAlertCard = ({ alert, onClick, isSelected }) => {
|
||||
* 热点概览主组件
|
||||
* @param {Object} props
|
||||
* @param {Date|null} props.selectedDate - 选中的交易日期
|
||||
* @param {Function} props.onDateChange - 日期变更回调
|
||||
* @param {Date} props.minDate - 最小可选日期
|
||||
* @param {Date} props.maxDate - 最大可选日期
|
||||
*/
|
||||
const HotspotOverview = ({ selectedDate }) => {
|
||||
const HotspotOverview = ({ selectedDate, onDateChange, minDate, maxDate }) => {
|
||||
const [selectedAlert, setSelectedAlert] = useState(null);
|
||||
const [drawerAlertData, setDrawerAlertData] = useState(null);
|
||||
// 选中的异动类型过滤器(null 表示全部)
|
||||
const [selectedAlertType, setSelectedAlertType] = useState(null);
|
||||
|
||||
// 右边栏抽屉控制
|
||||
const { isOpen: isDrawerOpen, onOpen: onDrawerOpen, onClose: onDrawerClose } = useDisclosure();
|
||||
@@ -231,6 +238,11 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
onDrawerOpen();
|
||||
}, [onDrawerOpen]);
|
||||
|
||||
// 点击异动类型标签 - 切换过滤器
|
||||
const handleAlertTypeClick = useCallback((type) => {
|
||||
setSelectedAlertType(prevType => prevType === type ? null : type);
|
||||
}, []);
|
||||
|
||||
// 渲染加载状态 - Glassmorphism 风格
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -335,6 +347,11 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
|
||||
const { index, alerts, alert_summary } = data;
|
||||
|
||||
// 根据选中的类型过滤异动列表
|
||||
const filteredAlerts = selectedAlertType
|
||||
? alerts.filter(alert => alert.alert_type === selectedAlertType)
|
||||
: alerts;
|
||||
|
||||
// 计算市场颜色
|
||||
const marketColor = getMarketColor(index?.change_pct || 0);
|
||||
const marketGlow = getMarketGlow(index?.change_pct || 0);
|
||||
@@ -415,6 +432,18 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
</HStack>
|
||||
<Spacer />
|
||||
<HStack spacing={3}>
|
||||
{/* 日期选择器 */}
|
||||
{onDateChange && (
|
||||
<TradeDatePicker
|
||||
value={selectedDate}
|
||||
onChange={onDateChange}
|
||||
latestTradeDate={null}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
isDarkMode={true}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
{alerts.length > 0 && (
|
||||
<HStack
|
||||
spacing={2}
|
||||
@@ -441,7 +470,7 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
<Tooltip label="展示大盘走势与概念异动的关联" hasArrow maxW="200px">
|
||||
<Tooltip label="大盘分时走势与概念异动关联分析" hasArrow maxW="200px">
|
||||
<Box cursor="help" p={2} borderRadius="full" _hover={{ bg: 'rgba(255,255,255,0.05)' }}>
|
||||
<Icon as={Info} color={subTextColor} boxSize={4} />
|
||||
</Box>
|
||||
@@ -449,7 +478,65 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 统计摘要 - Glassmorphism Bento Grid */}
|
||||
{/* 大尺寸分时图 - Glassmorphism(移到统计卡片前面) */}
|
||||
<Box
|
||||
bg={sectionBg}
|
||||
backdropFilter={glassEffect.light.backdropFilter}
|
||||
borderRadius="20px"
|
||||
border={glassEffect.light.border}
|
||||
p={5}
|
||||
mb={5}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 图表区域背景光晕 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="-50px"
|
||||
left="50%"
|
||||
transform="translateX(-50%)"
|
||||
w="60%"
|
||||
h="100px"
|
||||
borderRadius="full"
|
||||
bg="rgba(139, 92, 246, 0.08)"
|
||||
filter="blur(40px)"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<HStack spacing={3} mb={4}>
|
||||
<Box
|
||||
p={2}
|
||||
borderRadius="12px"
|
||||
bg="rgba(139, 92, 246, 0.15)"
|
||||
border="1px solid rgba(139, 92, 246, 0.25)"
|
||||
>
|
||||
<Icon
|
||||
as={LineChart}
|
||||
boxSize={5}
|
||||
color={colors.accent.purple}
|
||||
css={css`filter: drop-shadow(0 0 6px #8b5cf6);`}
|
||||
/>
|
||||
</Box>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={textColor}
|
||||
css={css`text-shadow: 0 0 20px rgba(139, 92, 246, 0.3);`}
|
||||
>
|
||||
大盘分时走势
|
||||
</Text>
|
||||
<Tooltip label="图表上的标记点表示概念异动时刻,点击可查看详情" hasArrow>
|
||||
<Icon as={Info} boxSize={3.5} color={colors.text.muted} cursor="help" />
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
<IndexMinuteChart
|
||||
indexData={index}
|
||||
alerts={alerts}
|
||||
onAlertClick={handleChartAlertClick}
|
||||
height="420px"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 统计摘要 - Glassmorphism Bento Grid(移到分时图后面) */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4} mb={5}>
|
||||
{/* 指数信息卡片 */}
|
||||
<Box
|
||||
@@ -546,15 +633,31 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
}}
|
||||
>
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<Text fontSize="sm" fontWeight="bold" color={textColor}>今日异动</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={colors.accent.purple}
|
||||
fontWeight="bold"
|
||||
css={css`text-shadow: 0 0 10px rgba(139, 92, 246, 0.5);`}
|
||||
>
|
||||
{alerts.length} 次
|
||||
</Text>
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="sm" fontWeight="bold" color={textColor}>今日异动</Text>
|
||||
<Text fontSize="xs" color={colors.text.muted}>(点击筛选)</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
{selectedAlertType && (
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={colors.accent.purple}
|
||||
cursor="pointer"
|
||||
onClick={() => setSelectedAlertType(null)}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
清除筛选
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={colors.accent.purple}
|
||||
fontWeight="bold"
|
||||
css={css`text-shadow: 0 0 10px rgba(139, 92, 246, 0.5);`}
|
||||
>
|
||||
{selectedAlertType ? `${filteredAlerts.length}/${alerts.length}` : alerts.length} 次
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<Flex gap={2} flexWrap="wrap">
|
||||
{Object.entries(alert_summary || {})
|
||||
@@ -563,6 +666,7 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
.map(([type, count]) => {
|
||||
const config = ALERT_TYPE_CONFIG[type];
|
||||
if (!config) return null;
|
||||
const isSelected = selectedAlertType === type;
|
||||
return (
|
||||
<HStack
|
||||
key={type}
|
||||
@@ -570,15 +674,20 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
px={3}
|
||||
py={1.5}
|
||||
borderRadius="full"
|
||||
bg={`${config.color}15`}
|
||||
border={`1px solid ${config.color}25`}
|
||||
bg={isSelected ? `${config.color}35` : `${config.color}15`}
|
||||
border={isSelected ? `2px solid ${config.color}` : `1px solid ${config.color}25`}
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
transform={isSelected ? 'scale(1.05)' : 'scale(1)'}
|
||||
boxShadow={isSelected ? `0 0 20px ${config.color}40` : 'none'}
|
||||
onClick={() => handleAlertTypeClick(type)}
|
||||
_hover={{
|
||||
bg: `${config.color}25`,
|
||||
boxShadow: `0 0 15px ${config.color}30`,
|
||||
transform: 'scale(1.02)',
|
||||
}}
|
||||
>
|
||||
<Text fontSize="xs" color={config.color}>{config.label}</Text>
|
||||
<Text fontSize="xs" color={config.color} fontWeight={isSelected ? 'bold' : 'medium'}>{config.label}</Text>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
@@ -594,64 +703,6 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 大尺寸分时图 - Glassmorphism */}
|
||||
<Box
|
||||
bg={sectionBg}
|
||||
backdropFilter={glassEffect.light.backdropFilter}
|
||||
borderRadius="20px"
|
||||
border={glassEffect.light.border}
|
||||
p={5}
|
||||
mb={5}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 图表区域背景光晕 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="-50px"
|
||||
left="50%"
|
||||
transform="translateX(-50%)"
|
||||
w="60%"
|
||||
h="100px"
|
||||
borderRadius="full"
|
||||
bg="rgba(139, 92, 246, 0.08)"
|
||||
filter="blur(40px)"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<HStack spacing={3} mb={4}>
|
||||
<Box
|
||||
p={2}
|
||||
borderRadius="12px"
|
||||
bg="rgba(139, 92, 246, 0.15)"
|
||||
border="1px solid rgba(139, 92, 246, 0.25)"
|
||||
>
|
||||
<Icon
|
||||
as={LineChart}
|
||||
boxSize={5}
|
||||
color={colors.accent.purple}
|
||||
css={css`filter: drop-shadow(0 0 6px #8b5cf6);`}
|
||||
/>
|
||||
</Box>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={textColor}
|
||||
css={css`text-shadow: 0 0 20px rgba(139, 92, 246, 0.3);`}
|
||||
>
|
||||
大盘分时走势
|
||||
</Text>
|
||||
<Tooltip label="图表上的标记点表示概念异动时刻,点击可查看详情" hasArrow>
|
||||
<Icon as={Info} boxSize={3.5} color={colors.text.muted} cursor="help" />
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
<IndexMinuteChart
|
||||
indexData={index}
|
||||
alerts={alerts}
|
||||
onAlertClick={handleChartAlertClick}
|
||||
height="420px"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 异动列表 - Glassmorphism 横向滚动 */}
|
||||
{alerts.length > 0 && (
|
||||
<Box>
|
||||
@@ -670,7 +721,11 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
/>
|
||||
</Box>
|
||||
<Text fontSize="sm" fontWeight="bold" color={textColor}>异动记录</Text>
|
||||
<Text fontSize="xs" color={colors.text.muted}>(点击卡片查看详情)</Text>
|
||||
<Text fontSize="xs" color={colors.text.muted}>
|
||||
{selectedAlertType
|
||||
? `(已筛选 ${ALERT_TYPE_CONFIG[selectedAlertType]?.label || selectedAlertType},共 ${filteredAlerts.length} 条)`
|
||||
: '(点击卡片查看详情)'}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 横向滚动卡片 */}
|
||||
@@ -688,7 +743,7 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
}}
|
||||
>
|
||||
<HStack spacing={3} pb={1}>
|
||||
{[...alerts]
|
||||
{[...filteredAlerts]
|
||||
.sort((a, b) => (b.time || '').localeCompare(a.time || ''))
|
||||
.map((alert, idx) => (
|
||||
<CompactAlertCard
|
||||
@@ -704,7 +759,7 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
)}
|
||||
|
||||
{/* 无异动提示 - Glassmorphism */}
|
||||
{alerts.length === 0 && (
|
||||
{filteredAlerts.length === 0 && (
|
||||
<Center
|
||||
py={12}
|
||||
bg={sectionBg}
|
||||
@@ -742,7 +797,22 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
css={css`filter: drop-shadow(0 0 10px rgba(139, 92, 246, 0.3));`}
|
||||
/>
|
||||
</Box>
|
||||
<Text color={colors.text.tertiary} fontSize="sm">当日暂无概念异动数据</Text>
|
||||
<Text color={colors.text.tertiary} fontSize="sm">
|
||||
{selectedAlertType
|
||||
? `未找到「${ALERT_TYPE_CONFIG[selectedAlertType]?.label || selectedAlertType}」类型的异动`
|
||||
: '当日暂无概念异动数据'}
|
||||
</Text>
|
||||
{selectedAlertType && (
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={colors.accent.purple}
|
||||
cursor="pointer"
|
||||
onClick={() => setSelectedAlertType(null)}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
查看全部异动
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
@@ -48,11 +48,12 @@ import {
|
||||
Skeleton,
|
||||
SkeletonText,
|
||||
} from '@chakra-ui/react';
|
||||
import { Search, X, ArrowRight, TrendingUp, Info, ChevronRight, Calendar, LineChart, Flame, Rocket, Brain, ArrowUp, ArrowDown, BarChart2, Tag as TagIcon, Layers, Zap } from 'lucide-react';
|
||||
import { Search, X, ArrowRight, TrendingUp, Info, ChevronRight, Calendar, LineChart, Flame, Rocket, Brain, ArrowUp, ArrowDown, BarChart2, Tag as TagIcon, Layers, Zap, Wallet, Banknote, Scale } from 'lucide-react';
|
||||
import ConceptStocksModal from '@components/ConceptStocksModal';
|
||||
import TradeDatePicker from '@components/TradeDatePicker';
|
||||
import HotspotOverview from './components/HotspotOverview';
|
||||
import FlexScreen from './components/FlexScreen';
|
||||
import { HeroSection } from '@components/HeroSection';
|
||||
import { echarts } from '@lib/echarts';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getConceptHtmlUrl } from '../../utils/textUtils';
|
||||
@@ -264,12 +265,14 @@ const StockOverview = () => {
|
||||
const newStats = {
|
||||
...(prevStats || {}), // 先保留所有现有字段(包括 rising_count/falling_count)
|
||||
...data.summary, // 然后覆盖 summary 字段
|
||||
yesterday: data.yesterday, // 保存昨日对比数据
|
||||
date: data.trade_date
|
||||
};
|
||||
return newStats;
|
||||
});
|
||||
const newStats = {
|
||||
...data.summary,
|
||||
yesterday: data.yesterday,
|
||||
date: data.trade_date
|
||||
};
|
||||
// 日期和可选日期列表由 fetchTopConcepts 统一设置,这里不再设置
|
||||
@@ -623,249 +626,119 @@ const StockOverview = () => {
|
||||
zIndex={0}
|
||||
/>
|
||||
|
||||
{/* Hero Section */}
|
||||
<Box
|
||||
position="relative"
|
||||
bgGradient={heroBg}
|
||||
color="white"
|
||||
overflow="visible"
|
||||
pt={{ base: 20, md: 24 }}
|
||||
pb={{ base: 16, md: 20 }}
|
||||
borderBottom={`1px solid rgba(139, 92, 246, 0.3)`}
|
||||
borderRadius="xl"
|
||||
zIndex={1}
|
||||
>
|
||||
{/* 背景装饰 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-20%"
|
||||
right="-10%"
|
||||
width="40%"
|
||||
height="120%"
|
||||
bg={`${goldColor}15`}
|
||||
transform="rotate(12deg)"
|
||||
borderRadius="full"
|
||||
filter="blur(60px)"
|
||||
/>
|
||||
|
||||
<Box px={6} position="relative">
|
||||
<VStack spacing={8} align="center">
|
||||
<VStack spacing={4} textAlign="center" maxW="3xl">
|
||||
<HStack spacing={3}>
|
||||
<Icon as={TrendingUp} boxSize={12} color={colorMode === 'dark' ? goldColor : 'white'} />
|
||||
<Heading
|
||||
as="h1"
|
||||
size="2xl"
|
||||
fontWeight="bold"
|
||||
bgGradient={colorMode === 'dark' ? `linear(to-r, ${goldColor}, white)` : 'none'}
|
||||
bgClip={colorMode === 'dark' ? 'text' : 'none'}
|
||||
>
|
||||
个股中心
|
||||
</Heading>
|
||||
</HStack>
|
||||
|
||||
<Text fontSize="xl" opacity={0.9} color={colorMode === 'dark' ? 'gray.300' : 'white'}>
|
||||
实时追踪市场动态,洞察投资机会
|
||||
</Text>
|
||||
</VStack>
|
||||
{/* Hero Section - 使用通用 HeroSection 组件 */}
|
||||
<HeroSection
|
||||
icon={TrendingUp}
|
||||
title="个股中心"
|
||||
subtitle="实时追踪市场动态,洞察投资机会"
|
||||
themePreset="purple"
|
||||
themeColors={{
|
||||
titleGradient: `linear(to-r, ${goldColor}, white)`,
|
||||
iconColor: goldColor,
|
||||
statLabelColor: goldColor,
|
||||
statCardBorder: `${goldColor}50`,
|
||||
}}
|
||||
decorations={[
|
||||
{
|
||||
type: 'glowOrbs',
|
||||
intensity: 0.4,
|
||||
orbs: [
|
||||
{ top: '-20%', right: '-10%', size: '40%', color: `${goldColor}15`, blur: '60px' },
|
||||
],
|
||||
},
|
||||
]}
|
||||
search={{
|
||||
placeholder: '搜索股票代码、名称或拼音首字母...',
|
||||
showSearchButton: true,
|
||||
searchButtonText: '搜索',
|
||||
maxWidth: '2xl',
|
||||
// 受控模式
|
||||
value: searchQuery,
|
||||
onChange: (value) => {
|
||||
setSearchQuery(value);
|
||||
if (value && !searchQuery) {
|
||||
trackSearchInitiated();
|
||||
}
|
||||
debounceSearch(value);
|
||||
},
|
||||
onClear: handleClearSearch,
|
||||
results: searchResults.map((stock) => ({
|
||||
id: stock.stock_code,
|
||||
label: stock.stock_name,
|
||||
subLabel: stock.stock_code,
|
||||
extra: stock.pinyin_abbr?.toUpperCase(),
|
||||
tags: stock.exchange ? [{ text: stock.exchange }] : [],
|
||||
raw: stock,
|
||||
})),
|
||||
isSearching: isSearching,
|
||||
showDropdown: showResults,
|
||||
onSearch: async () => [],
|
||||
onResultSelect: (item, index) => handleSelectStock(item.raw, index),
|
||||
}}
|
||||
stats={{
|
||||
columns: { base: 1, sm: 3, md: 3 },
|
||||
items: [
|
||||
{
|
||||
key: 'marketCap',
|
||||
label: 'A股总市值',
|
||||
value: marketStats ? `${(marketStats.total_market_cap / 10000).toFixed(1)}万亿` : null,
|
||||
// 市值趋势对比
|
||||
trend: marketStats?.yesterday?.total_market_cap ? (() => {
|
||||
const change = ((marketStats.total_market_cap - marketStats.yesterday.total_market_cap) / marketStats.yesterday.total_market_cap) * 100;
|
||||
return {
|
||||
direction: change > 0.01 ? 'up' : change < -0.01 ? 'down' : 'flat',
|
||||
percent: Math.abs(change),
|
||||
};
|
||||
})() : undefined,
|
||||
// 水印背景图标 - 钱袋图标
|
||||
watermark: { icon: Wallet, color: goldColor, opacity: 0.1 },
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: '今日成交额',
|
||||
value: marketStats ? `${(marketStats.total_amount / 10000).toFixed(1)}万亿` : null,
|
||||
// 成交额趋势对比(放量/缩量)
|
||||
trend: marketStats?.yesterday?.total_amount ? (() => {
|
||||
const change = ((marketStats.total_amount - marketStats.yesterday.total_amount) / marketStats.yesterday.total_amount) * 100;
|
||||
return {
|
||||
direction: change > 0.01 ? 'up' : change < -0.01 ? 'down' : 'flat',
|
||||
percent: Math.abs(change),
|
||||
label: change > 5 ? '放量' : change < -5 ? '缩量' : undefined,
|
||||
};
|
||||
})() : undefined,
|
||||
// 水印背景图标 - 钞票图标
|
||||
watermark: { icon: Banknote, color: goldColor, opacity: 0.1 },
|
||||
},
|
||||
{
|
||||
key: 'rising',
|
||||
label: '多空对比',
|
||||
// 显示为 "上涨/下跌" 格式,使用自定义渲染
|
||||
value: (marketStats?.rising_count && marketStats?.falling_count)
|
||||
? `${marketStats.rising_count}/${marketStats.falling_count}`
|
||||
: marketStats?.rising_count,
|
||||
// 涨跌进度条(不显示底部标签)
|
||||
progressBar: (marketStats?.rising_count && marketStats?.falling_count) ? {
|
||||
value: marketStats.rising_count,
|
||||
total: marketStats.falling_count,
|
||||
positiveColor: '#ff4d4d',
|
||||
negativeColor: '#22c55e',
|
||||
} : undefined,
|
||||
// 水印背景图标 - 天平图标
|
||||
watermark: { icon: Scale, color: goldColor, opacity: 0.1 },
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<Box w="100%" maxW="2xl" position="relative">
|
||||
<InputGroup
|
||||
size="lg"
|
||||
bg={searchBg}
|
||||
borderRadius="full"
|
||||
boxShadow="2xl"
|
||||
border="2px solid"
|
||||
borderColor={colorMode === 'dark' ? goldColor : 'transparent'}
|
||||
>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<Search size={16} color={colorMode === 'dark' ? goldColor : 'gray.400'} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="搜索股票代码、名称或拼音首字母..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
borderRadius="full"
|
||||
border="none"
|
||||
color={textColor}
|
||||
bg="transparent"
|
||||
_placeholder={{ color: colorMode === 'dark' ? 'gray.500' : 'gray.400' }}
|
||||
_focus={{
|
||||
boxShadow: 'none',
|
||||
borderColor: 'transparent',
|
||||
bg: colorMode === 'dark' ? 'whiteAlpha.50' : 'transparent'
|
||||
}}
|
||||
pr={searchQuery ? "3rem" : "1rem"}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon={<X size={16} />}
|
||||
variant="ghost"
|
||||
onClick={handleClearSearch}
|
||||
aria-label="清空搜索"
|
||||
color={colorMode === 'dark' ? goldColor : 'gray.600'}
|
||||
_hover={{
|
||||
bg: colorMode === 'dark' ? 'whiteAlpha.100' : 'gray.100'
|
||||
}}
|
||||
/>
|
||||
</InputRightElement>
|
||||
)}
|
||||
</InputGroup>
|
||||
|
||||
{/* 搜索结果下拉 */}
|
||||
<Collapse in={showResults} animateOpacity>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="100%"
|
||||
left={0}
|
||||
right={0}
|
||||
mt={2}
|
||||
bg={searchBg}
|
||||
borderRadius="xl"
|
||||
boxShadow="2xl"
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
maxH="400px"
|
||||
overflowY="auto"
|
||||
zIndex={10}
|
||||
>
|
||||
{isSearching ? (
|
||||
<Center p={4}>
|
||||
<Spinner color={accentColor} />
|
||||
</Center>
|
||||
) : searchResults.length > 0 ? (
|
||||
<List spacing={0}>
|
||||
{searchResults.map((stock, index) => (
|
||||
<ListItem
|
||||
key={stock.stock_code}
|
||||
p={4}
|
||||
cursor="pointer"
|
||||
_hover={{ bg: hoverBg }}
|
||||
onClick={() => handleSelectStock(stock, index)}
|
||||
borderBottomWidth={index < searchResults.length - 1 ? "1px" : "0"}
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Flex align="center" justify="space-between">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<Text fontWeight="bold" color={textColor}>{stock.stock_name}</Text>
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="sm" color={subTextColor}>{stock.stock_code}</Text>
|
||||
{stock.pinyin_abbr && (
|
||||
<Text fontSize="xs" color={subTextColor}>({stock.pinyin_abbr.toUpperCase()})</Text>
|
||||
)}
|
||||
{stock.exchange && (
|
||||
<Tag
|
||||
size="sm"
|
||||
bg={colorMode === 'dark' ? '#2a2a2a' : 'blue.50'}
|
||||
color={colorMode === 'dark' ? goldColor : 'blue.600'}
|
||||
border="1px solid"
|
||||
borderColor={colorMode === 'dark' ? goldColor : 'blue.200'}
|
||||
>
|
||||
{stock.exchange}
|
||||
</Tag>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Button
|
||||
size="sm"
|
||||
rightIcon={<ArrowRight size={16} />}
|
||||
variant="ghost"
|
||||
colorScheme={colorMode === 'dark' ? 'yellow' : 'purple'}
|
||||
_hover={{
|
||||
bg: colorMode === 'dark' ? 'whiteAlpha.100' : 'purple.50'
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
</Flex>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Center p={4}>
|
||||
<Text color={subTextColor}>未找到相关股票</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* 统计数据 - 使用市场统计API数据 */}
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={6} w="100%" maxW="4xl">
|
||||
<Stat
|
||||
textAlign="center"
|
||||
bg={colorMode === 'dark' ? 'whiteAlpha.100' : 'whiteAlpha.800'}
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
border={colorMode === 'dark' ? `1px solid ${goldColor}50` : 'none'}
|
||||
>
|
||||
<StatLabel color={colorMode === 'dark' ? goldColor : 'purple.700'} fontWeight="bold">A股总市值</StatLabel>
|
||||
<StatNumber fontSize="2xl" color={colorMode === 'dark' ? 'white' : 'purple.800'}>
|
||||
{marketStats ?
|
||||
`${(marketStats.total_market_cap / 10000).toFixed(1)}万亿`
|
||||
: '-'
|
||||
}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
<Stat
|
||||
textAlign="center"
|
||||
bg={colorMode === 'dark' ? 'whiteAlpha.100' : 'whiteAlpha.800'}
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
border={colorMode === 'dark' ? `1px solid ${goldColor}50` : 'none'}
|
||||
>
|
||||
<StatLabel color={colorMode === 'dark' ? goldColor : 'purple.700'} fontWeight="bold">今日成交额</StatLabel>
|
||||
<StatNumber fontSize="2xl" color={colorMode === 'dark' ? 'white' : 'purple.800'}>
|
||||
{marketStats ?
|
||||
`${(marketStats.total_amount / 10000).toFixed(1)}万亿`
|
||||
: '-'
|
||||
}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
<Stat
|
||||
textAlign="center"
|
||||
bg={colorMode === 'dark' ? 'whiteAlpha.100' : 'whiteAlpha.800'}
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
border={colorMode === 'dark' ? `1px solid ${goldColor}50` : 'none'}
|
||||
>
|
||||
<StatLabel color={colorMode === 'dark' ? goldColor : 'purple.700'} fontWeight="bold">上涨家数</StatLabel>
|
||||
<StatNumber fontSize="2xl" color={colorMode === 'dark' ? '#ff4d4d' : 'red.500'}>
|
||||
{marketStats && marketStats.rising_count !== undefined && marketStats.rising_count !== null ?
|
||||
marketStats.rising_count.toLocaleString() : '-'
|
||||
}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
<Stat
|
||||
textAlign="center"
|
||||
bg={colorMode === 'dark' ? 'whiteAlpha.100' : 'whiteAlpha.800'}
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
border={colorMode === 'dark' ? `1px solid ${goldColor}50` : 'none'}
|
||||
>
|
||||
<StatLabel color={colorMode === 'dark' ? goldColor : 'purple.700'} fontWeight="bold">下跌家数</StatLabel>
|
||||
<StatNumber fontSize="2xl" color="green.400">
|
||||
{marketStats && marketStats.falling_count !== undefined && marketStats.falling_count !== null ?
|
||||
marketStats.falling_count.toLocaleString() : '-'
|
||||
}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<Box py={10} px={6} position="relative" zIndex={1}>
|
||||
{/* 日期选择器 */}
|
||||
<Box mb={6}>
|
||||
<Flex align="center" gap={4} flexWrap="wrap">
|
||||
<TradeDatePicker
|
||||
value={selectedDate}
|
||||
onChange={(date) => {
|
||||
{/* 主内容区 - 负 margin 使卡片向上浮动,与 Hero 产生重叠纵深感 */}
|
||||
<Box pt={6} pb={10} px={6} mt={-6} position="relative" zIndex={2}>
|
||||
{/* 热点概览 - 大盘走势 + 概念异动 */}
|
||||
{/* 只在 selectedDate 确定后渲染,避免 null → 日期 的双重请求 */}
|
||||
<Box mb={10}>
|
||||
{selectedDate ? (
|
||||
<HotspotOverview
|
||||
selectedDate={selectedDate}
|
||||
onDateChange={(date) => {
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
const previousDateStr = selectedDate ? selectedDate.toISOString().split('T')[0] : null;
|
||||
trackDateChanged(dateStr, previousDateStr);
|
||||
@@ -874,25 +747,9 @@ const StockOverview = () => {
|
||||
fetchMarketStats(dateStr);
|
||||
fetchTopConcepts(dateStr);
|
||||
}}
|
||||
latestTradeDate={null}
|
||||
minDate={tradingDays.length > 0 ? new Date(tradingDays[0]) : undefined}
|
||||
maxDate={tradingDays.length > 0 ? new Date(tradingDays[tradingDays.length - 1]) : undefined}
|
||||
label="交易日期"
|
||||
isDarkMode={true}
|
||||
/>
|
||||
</Flex>
|
||||
{selectedDate && (
|
||||
<Text fontSize="sm" color={subTextColor} mt={2}>
|
||||
当前显示 {selectedDate.toISOString().split('T')[0]} 的市场数据
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 热点概览 - 大盘走势 + 概念异动 */}
|
||||
{/* 只在 selectedDate 确定后渲染,避免 null → 日期 的双重请求 */}
|
||||
<Box mb={10}>
|
||||
{selectedDate ? (
|
||||
<HotspotOverview selectedDate={selectedDate} />
|
||||
) : (
|
||||
<Card
|
||||
bg={cardBg}
|
||||
|
||||
Reference in New Issue
Block a user