Compare commits

..

8 Commits

Author SHA1 Message Date
zdl
0d05b69601 Merge branch 'feature_bugfix/251217_stock' into feature_2025/251229_stocks
* feature_bugfix/251217_stock:
  style(Auth): 登录弹窗改为黑金主题
  style: 统一个人中心按钮与投资日历按钮样式
2025-12-30 16:55:26 +08:00
zdl
eaf11713e8 refactor(StockOverview): 优化布局与数据展示
- 头部统计卡片从 4 列精简为 3 列,移除冗余下跌家数
- 涨跌家数改为"多空对比"卡片,双色数值 + 进度条
- 各卡片新增环比趋势指示(放量/缩量等)
- 日期选择器移至 HotspotOverview 头部右侧
- 大盘分时图调整至统计卡片上方
- 异动标签支持点击筛选

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-30 15:55:53 +08:00
zdl
91d89fb958 feat(mock): 市场统计 API 增加昨日对比数据
- /api/market/statistics 返回 yesterday 字段
- 包含昨日市值、成交额、涨跌家数
- 支持前端计算环比变化率

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-30 15:55:35 +08:00
zdl
9deb9ff350 feat(StatCard): 新增趋势指示器和多空进度条组件
- 新增 TrendIndicator 组件显示环比变化(箭头+百分比+标签)
- 新增 BullBearBar 组件显示红绿进度条
- 新增 WatermarkIcon 组件支持卡片水印背景
- 支持双色数值显示(如 121/79 红绿分色)
- StatCard 根据配置自动渲染趋势和进度条

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-30 15:55:18 +08:00
zdl
d549eaaf9f feat(HeroSection): 新增趋势指示器、进度条、水印图标类型定义
- 添加 TrendDirection、TrendInfo 类型支持环比/同比变化展示
- 添加 ProgressBarConfig 类型支持多空对比进度条
- 添加 WatermarkIconConfig 类型支持卡片水印背景图标
- HeroStatItem 扩展 trend、progressBar、watermark 可选属性
- index.tsx 导出新增类型

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-30 15:55:01 +08:00
zdl
1e511cb3f5 refactor(Concept): 迁移至 HeroSection 组件
- 使用通用 HeroSection 替换原有 Hero 区域代码
- 配置 purple 主题预设,统计区使用独立卡片样式
- 搜索框宽度设为 140%,支持回车和点击搜索
- 移除搜索按钮 loading 状态绑定,避免页面加载时显示 loading

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-30 11:11:39 +08:00
zdl
bc6d5fd222 refactor(StockOverview): 迁移至 HeroSection 组件
- 使用通用 HeroSection 替换原有 Hero 区域代码
- 配置 purple 主题预设和自定义金色渐变
- 统计区显示市值、成交额、上涨/下跌家数
- 搜索框支持下拉结果选择

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-30 11:11:32 +08:00
zdl
6c10d420a1 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>
2025-12-30 11:11:24 +08:00
20 changed files with 2036 additions and 555 deletions

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

View File

@@ -0,0 +1,56 @@
/**
* 极光效果背景装饰
*/
import React, { memo } from 'react';
import { Box } from '@chakra-ui/react';
interface AuroraEffectProps {
/** 强度 0-1 */
intensity?: number;
/** 颜色数组 */
colors?: string[];
}
/** 默认极光颜色 */
const DEFAULT_COLORS = [
'rgba(139, 92, 246, 0.15)', // 紫色
'rgba(59, 130, 246, 0.1)', // 蓝色
'rgba(236, 72, 153, 0.05)', // 粉色
];
export const AuroraEffect = memo<AuroraEffectProps>(({
intensity = 1,
colors = DEFAULT_COLORS,
}) => {
return (
<>
{/* 紫色极光 */}
<Box
position="absolute"
inset={0}
bgGradient={`radial(ellipse at 30% 20%, ${colors[0] || DEFAULT_COLORS[0]} 0%, transparent 50%)`}
opacity={intensity}
pointerEvents="none"
/>
{/* 蓝色极光 */}
<Box
position="absolute"
inset={0}
bgGradient={`radial(ellipse at 70% 60%, ${colors[1] || DEFAULT_COLORS[1]} 0%, transparent 60%)`}
opacity={intensity}
pointerEvents="none"
/>
{/* 粉色极光 */}
<Box
position="absolute"
inset={0}
bgGradient={`radial(ellipse at 50% 50%, ${colors[2] || DEFAULT_COLORS[2]} 0%, transparent 70%)`}
opacity={intensity}
pointerEvents="none"
/>
</>
);
});
AuroraEffect.displayName = 'AuroraEffect';

View File

@@ -0,0 +1,76 @@
/**
* 发光球体背景装饰
*/
import React, { memo } from 'react';
import { Box } from '@chakra-ui/react';
import type { GlowOrbConfig } from '../../types';
import { DEFAULT_DECORATIONS } from '../../constants';
interface GlowOrbsProps {
/** 球体配置数组 */
orbs?: GlowOrbConfig[];
/** 默认颜色 */
colors?: string[];
/** 强度 */
intensity?: number;
}
/** 默认球体配置 */
const DEFAULT_ORBS: GlowOrbConfig[] = [
{
top: '20%',
right: '10%',
size: '200px',
color: 'rgba(139, 92, 246, 0.4)',
blur: '40px',
},
{
bottom: '10%',
left: '10%',
size: '250px',
color: 'rgba(59, 130, 246, 0.3)',
blur: '50px',
},
];
export const GlowOrbs = memo<GlowOrbsProps>(({
orbs,
colors,
intensity = DEFAULT_DECORATIONS.glowOrbs.opacity,
}) => {
// 使用自定义配置或默认配置
let orbConfigs = orbs || DEFAULT_ORBS;
// 如果提供了颜色数组,更新默认球体的颜色
if (colors && colors.length > 0 && !orbs) {
orbConfigs = DEFAULT_ORBS.map((orb, index) => ({
...orb,
color: colors[index % colors.length] || orb.color,
}));
}
return (
<>
{orbConfigs.map((orb, index) => (
<Box
key={index}
position="absolute"
top={orb.top}
bottom={orb.bottom}
left={orb.left}
right={orb.right}
width={orb.size}
height={orb.size}
borderRadius="full"
bgGradient={`radial(circle, ${orb.color}, transparent 70%)`}
filter={`blur(${orb.blur || DEFAULT_DECORATIONS.glowOrbs.blur})`}
opacity={intensity}
pointerEvents="none"
/>
))}
</>
);
});
GlowOrbs.displayName = 'GlowOrbs';

View File

@@ -0,0 +1,38 @@
/**
* 科幻网格背景装饰
*/
import React, { memo } from 'react';
import { Box } from '@chakra-ui/react';
import { DEFAULT_DECORATIONS } from '../../constants';
interface GridPatternProps {
/** 透明度 0-1 */
intensity?: number;
/** 网格颜色 */
color?: string;
/** 网格尺寸 */
size?: string;
}
export const GridPattern = memo<GridPatternProps>(({
intensity = DEFAULT_DECORATIONS.grid.opacity,
color = DEFAULT_DECORATIONS.grid.gridColor,
size = DEFAULT_DECORATIONS.grid.gridSize,
}) => {
return (
<Box
position="absolute"
top="0"
left="0"
right="0"
bottom="0"
bgImage={`linear-gradient(${color} 1px, transparent 1px), linear-gradient(90deg, ${color} 1px, transparent 1px)`}
bgSize={`${size} ${size}`}
opacity={intensity}
pointerEvents="none"
/>
);
});
GridPattern.displayName = 'GridPattern';

View File

@@ -0,0 +1,77 @@
/**
* HeroBackground 背景装饰容器
* 支持多种装饰效果叠加
*/
import React, { memo } from 'react';
import { Box } from '@chakra-ui/react';
import { GridPattern } from './GridPattern';
import { GlowOrbs } from './GlowOrbs';
import { AuroraEffect } from './AuroraEffect';
import type { HeroBackgroundProps, BackgroundDecorationConfig } from '../../types';
/** 渲染单个装饰 */
const renderDecoration = (config: BackgroundDecorationConfig, index: number) => {
const { type, intensity, colors, orbs } = config;
switch (type) {
case 'grid':
return (
<GridPattern
key={`grid-${index}`}
intensity={intensity}
color={colors?.[0]}
/>
);
case 'glowOrbs':
return (
<GlowOrbs
key={`orbs-${index}`}
orbs={orbs}
colors={colors}
intensity={intensity}
/>
);
case 'aurora':
return (
<AuroraEffect
key={`aurora-${index}`}
intensity={intensity}
colors={colors}
/>
);
case 'none':
default:
return null;
}
};
export const HeroBackground = memo<HeroBackgroundProps>(({
decorations = [],
themeColors,
}) => {
if (decorations.length === 0) {
return null;
}
return (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
overflow="hidden"
pointerEvents="none"
zIndex={0}
>
{decorations.map((config, index) => renderDecoration(config, index))}
</Box>
);
});
HeroBackground.displayName = 'HeroBackground';
export { GridPattern } from './GridPattern';
export { GlowOrbs } from './GlowOrbs';
export { AuroraEffect } from './AuroraEffect';

View File

@@ -0,0 +1,51 @@
/**
* SearchButton 搜索按钮
* 渐变样式,纯文字
*/
import React, { memo } from 'react';
import { Button } from '@chakra-ui/react';
interface SearchButtonProps {
onClick: () => void;
text?: string;
isLoading?: boolean;
}
export const SearchButton = memo<SearchButtonProps>(({
onClick,
text = '搜索',
isLoading = false,
}) => {
return (
<Button
size="lg"
borderRadius="0 50px 50px 0"
bgGradient="linear(135deg, #667eea 0%, #764ba2 100%)"
color="white"
_hover={{
bgGradient: 'linear(135deg, #5568d3 0%, #663a8e 100%)',
transform: 'scale(1.02)',
}}
_active={{
bgGradient: 'linear(135deg, #4a5abf 0%, #58327a 100%)',
transform: 'scale(0.98)',
}}
onClick={onClick}
isLoading={isLoading}
loadingText="搜索中"
px={6}
minW="100px"
fontWeight="bold"
fontSize="md"
transition="all 0.2s"
border="none"
alignSelf="stretch"
boxShadow="inset 0 1px 0 rgba(255, 255, 255, 0.2)"
>
{text}
</Button>
);
});
SearchButton.displayName = 'SearchButton';

View File

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

View File

@@ -0,0 +1,87 @@
/**
* SearchInput 搜索输入框
* 浅色样式,带清除按钮
*/
import React, { memo } from 'react';
import {
InputGroup,
InputLeftElement,
Input,
IconButton,
Icon,
} from '@chakra-ui/react';
import { Search, X } from 'lucide-react';
import { GLASS_BLUR } from '@/constants/glassConfig';
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
onKeyPress: (e: React.KeyboardEvent) => void;
onClear: () => void;
onSearchClick?: () => void;
placeholder: string;
isLoading?: boolean;
}
export const SearchInput = memo<SearchInputProps>(({
value,
onChange,
onKeyPress,
onClear,
onSearchClick,
placeholder,
isLoading,
}) => {
return (
<InputGroup size="lg" flex={1}>
<InputLeftElement
h="full"
cursor={onSearchClick ? 'pointer' : 'default'}
onClick={onSearchClick}
_hover={onSearchClick ? { opacity: 0.7 } : undefined}
transition="opacity 0.2s"
>
<Icon as={Search} color="purple.400" boxSize={5} />
</InputLeftElement>
<Input
placeholder={placeholder}
bg="transparent"
color="gray.800"
border="none"
fontSize="md"
fontWeight="medium"
pl={12}
pr={value ? '50px' : '16px'}
_placeholder={{ color: 'gray.400' }}
_focus={{
outline: 'none',
boxShadow: 'none',
}}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyPress={onKeyPress}
disabled={isLoading}
/>
{value && (
<IconButton
position="absolute"
right="10px"
top="50%"
transform="translateY(-50%)"
size="sm"
aria-label="清除搜索"
icon={<X size={16} />}
variant="ghost"
color="gray.500"
borderRadius="full"
_hover={{ color: 'gray.700', bg: 'gray.200' }}
onClick={onClear}
zIndex={1}
/>
)}
</InputGroup>
);
});
SearchInput.displayName = 'SearchInput';

View File

@@ -0,0 +1,130 @@
/**
* HeroSearch 搜索区容器
* 整合输入框、按钮、下拉结果
* 支持受控模式和非受控模式
*/
import React, { memo, useCallback } from 'react';
import { Box, Flex } from '@chakra-ui/react';
import { GLASS_BLUR } from '@/constants/glassConfig';
import { SearchInput } from './SearchInput';
import { SearchButton } from './SearchButton';
import { SearchDropdown } from './SearchDropdown';
import { useHeroSearch } from '../../hooks/useHeroSearch';
import type { HeroSearchProps, SearchResultItem } from '../../types';
export const HeroSearch = memo(<T extends SearchResultItem>({
config,
themeColors,
}: HeroSearchProps<T>) => {
// 检测是否受控模式
const isControlled = config.value !== undefined;
// 非受控模式使用 hook
const internalSearch = useHeroSearch({ config });
// 受控模式值
const query = isControlled ? (config.value ?? '') : internalSearch.query;
const results = isControlled ? (config.results ?? []) : internalSearch.results;
const isSearching = isControlled ? (config.isSearching ?? false) : internalSearch.isSearching;
const showDropdown = isControlled ? (config.showDropdown ?? false) : internalSearch.showDropdown;
// 受控模式处理函数
const handleChange = useCallback((value: string) => {
if (isControlled && config.onChange) {
config.onChange(value);
} else {
internalSearch.setQuery(value);
}
}, [isControlled, config, internalSearch]);
const handleClear = useCallback(() => {
if (isControlled && config.onClear) {
config.onClear();
} else {
internalSearch.clearSearch();
}
}, [isControlled, config, internalSearch]);
const handleSelect = useCallback((item: T, index: number) => {
config.onResultSelect(item, index);
}, [config]);
// 触发搜索(回车或点击图标)
const triggerSearch = useCallback(() => {
if (isControlled && config.onSearch) {
config.onSearch(query);
} else if (!isControlled) {
internalSearch.triggerSearch();
}
}, [isControlled, config, query, internalSearch]);
const handleKeyPress = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
// 回车触发搜索
triggerSearch();
} else if (e.key === 'Escape') {
handleClear();
}
}, [triggerSearch, handleClear]);
const handleSearchClick = useCallback(() => {
triggerSearch();
}, [triggerSearch]);
const showSearchButton = config.showSearchButton ?? true;
const searchButtonText = config.searchButtonText ?? '搜索';
return (
<Box w="140%" position="relative">
<Flex
align="center"
bg="rgba(255, 255, 255, 0.95)"
backdropFilter={GLASS_BLUR.sm}
borderRadius="full"
overflow="hidden"
boxShadow={`0 8px 32px rgba(0, 0, 0, 0.3), 0 0 0 1px ${themeColors.primary}33`}
transition="all 0.3s"
_hover={{
boxShadow: `0 12px 40px ${themeColors.primary}66, 0 0 0 2px ${themeColors.primary}66`,
transform: 'translateY(-2px)',
}}
>
<SearchInput
value={query}
onChange={handleChange}
onKeyPress={handleKeyPress}
onClear={handleClear}
onSearchClick={handleSearchClick}
placeholder={config.placeholder}
isLoading={isSearching}
/>
{showSearchButton && (
<SearchButton
onClick={handleSearchClick}
text={searchButtonText}
isLoading={isSearching}
/>
)}
</Flex>
{/* 搜索结果下拉 */}
<SearchDropdown
isOpen={showDropdown}
isSearching={isSearching}
results={results}
onSelect={handleSelect}
renderResult={config.renderResult}
emptyText={config.emptyText}
loadingText={config.loadingText}
themeColors={themeColors}
/>
</Box>
);
}) as <T extends SearchResultItem>(props: HeroSearchProps<T>) => React.ReactElement;
(HeroSearch as React.FC).displayName = 'HeroSearch';
export { SearchInput } from './SearchInput';
export { SearchButton } from './SearchButton';
export { SearchDropdown } from './SearchDropdown';

View File

@@ -0,0 +1,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';

View File

@@ -0,0 +1,44 @@
/**
* HeroStats 统计区容器
*/
import React, { memo } from 'react';
import { SimpleGrid } from '@chakra-ui/react';
import { StatCard } from './StatCard';
import type { HeroStatsProps } from '../../types';
import { DEFAULT_STATS_COLUMNS } from '../../constants';
export const HeroStats = memo<HeroStatsProps>(({
config,
themeColors,
}) => {
const { items, columns, showIcons } = config;
const gridColumns = { ...DEFAULT_STATS_COLUMNS, ...columns };
return (
<SimpleGrid
columns={{
base: gridColumns.base ?? 2,
sm: gridColumns.sm ?? undefined,
md: gridColumns.md ?? 4,
lg: gridColumns.lg ?? undefined,
}}
spacing={6}
w="100%"
maxW="4xl"
>
{items.map((item) => (
<StatCard
key={item.key}
item={item}
themeColors={themeColors}
showIcon={showIcons}
/>
))}
</SimpleGrid>
);
});
HeroStats.displayName = 'HeroStats';
export { StatCard } from './StatCard';

View File

@@ -0,0 +1,68 @@
/**
* HeroTitle 标题区组件
* 包含图标、标题、副标题、更新提示
*/
import React, { memo } from 'react';
import {
VStack,
HStack,
Heading,
Text,
Icon,
} from '@chakra-ui/react';
import type { HeroTitleProps } from '../../types';
export const HeroTitle = memo<HeroTitleProps>(({
icon,
title,
subtitle,
updateHint,
themeColors,
}) => {
return (
<VStack spacing={1} textAlign="center">
{/* 图标 + 标题 */}
<HStack spacing={2} justify="center">
<Icon
as={icon}
boxSize={7}
color={themeColors.iconColor}
filter={`drop-shadow(0 0 8px ${themeColors.primary}80)`}
/>
<Heading
as="h1"
fontSize={{ base: '2xl', md: '3xl' }}
fontWeight="black"
bgGradient={themeColors.titleGradient}
bgClip="text"
letterSpacing="tight"
textShadow={`0 0 20px ${themeColors.primary}30`}
>
{title}
</Heading>
</HStack>
{/* 更新提示 */}
{updateHint && (
<HStack spacing={1} justify="center" opacity={0.8}>
{updateHint}
</HStack>
)}
{/* 副标题/描述 */}
{subtitle && (
<Text
fontSize="sm"
color={themeColors.subtitleColor}
opacity={0.7}
maxW="2xl"
>
{subtitle}
</Text>
)}
</VStack>
);
});
HeroTitle.displayName = 'HeroTitle';

View File

@@ -0,0 +1,104 @@
/**
* HeroSection 主题预设和默认配置
*/
import type { HeroThemePreset, HeroThemeColors, HeroSearchConfig } from './types';
/** 主题预设配置 */
export const HERO_THEME_PRESETS: Record<HeroThemePreset, HeroThemeColors> = {
purple: {
bgGradient: 'linear(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%)',
primary: '#8B5CF6',
titleGradient: 'linear(to-r, cyan.200, purple.200, pink.200)',
iconColor: 'cyan.300',
borderColor: 'rgba(139, 92, 246, 0.3)',
subtitleColor: 'gray.300',
statCardBg: 'whiteAlpha.100',
statCardBorder: 'rgba(139, 92, 246, 0.3)',
statLabelColor: 'purple.300',
},
gold: {
bgGradient: 'linear(135deg, #0A0A14 0%, #1A1A2E 50%, #0F0F1A 100%)',
primary: '#D4AF37',
titleGradient: 'linear(to-r, #D4AF37, white)',
iconColor: '#D4AF37',
borderColor: 'rgba(212, 175, 55, 0.3)',
subtitleColor: 'gray.400',
statCardBg: 'rgba(212, 175, 55, 0.1)',
statCardBorder: 'rgba(212, 175, 55, 0.3)',
statLabelColor: '#D4AF37',
},
blue: {
bgGradient: 'linear(135deg, #0A1628 0%, #1E3A5F 50%, #0F2744 100%)',
primary: '#3B82F6',
titleGradient: 'linear(to-r, blue.200, cyan.200)',
iconColor: 'blue.300',
borderColor: 'rgba(59, 130, 246, 0.3)',
subtitleColor: 'gray.300',
statCardBg: 'rgba(59, 130, 246, 0.1)',
statCardBorder: 'rgba(59, 130, 246, 0.3)',
statLabelColor: 'blue.300',
},
cyan: {
bgGradient: 'linear(135deg, #0A1A1F 0%, #134E5E 50%, #0F2027 100%)',
primary: '#06B6D4',
titleGradient: 'linear(to-r, cyan.300, teal.200)',
iconColor: 'cyan.400',
borderColor: 'rgba(6, 182, 212, 0.3)',
subtitleColor: 'gray.300',
statCardBg: 'rgba(6, 182, 212, 0.1)',
statCardBorder: 'rgba(6, 182, 212, 0.3)',
statLabelColor: 'cyan.300',
},
custom: {
bgGradient: 'linear(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%)',
primary: '#8B5CF6',
titleGradient: 'linear(to-r, purple.200, white)',
iconColor: 'purple.300',
borderColor: 'rgba(139, 92, 246, 0.3)',
subtitleColor: 'gray.300',
statCardBg: 'whiteAlpha.100',
statCardBorder: 'rgba(139, 92, 246, 0.3)',
statLabelColor: 'purple.300',
},
};
/** 搜索框默认配置 */
export const DEFAULT_SEARCH_CONFIG: Partial<HeroSearchConfig> = {
showSearchButton: true,
searchButtonText: '搜索',
debounceMs: 300,
maxResults: 10,
emptyText: '未找到相关结果',
loadingText: '搜索中...',
maxWidth: '2xl',
};
/** 统计区默认列数 */
export const DEFAULT_STATS_COLUMNS = {
base: 2,
md: 4,
};
/** 背景装饰默认配置 */
export const DEFAULT_DECORATIONS = {
grid: {
opacity: 0.3,
gridSize: '40px',
gridColor: 'rgba(99, 102, 241, 0.1)',
},
glowOrbs: {
blur: '40px',
opacity: 0.4,
},
};
/** 获取合并后的主题颜色 */
export function getThemeColors(
preset: HeroThemePreset = 'purple',
customColors?: Partial<HeroThemeColors>
): HeroThemeColors {
const baseColors = HERO_THEME_PRESETS[preset];
if (!customColors) return baseColors;
return { ...baseColors, ...customColors };
}

View File

@@ -0,0 +1,168 @@
/**
* useHeroSearch Hook
* 处理搜索逻辑:防抖、状态管理、结果过滤
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import type { SearchResultItem, HeroSearchConfig } from '../types';
import { DEFAULT_SEARCH_CONFIG } from '../constants';
interface UseHeroSearchOptions<T extends SearchResultItem> {
config: HeroSearchConfig<T>;
}
interface UseHeroSearchReturn<T extends SearchResultItem> {
/** 搜索查询值 */
query: string;
/** 更新查询值 */
setQuery: (value: string) => void;
/** 搜索结果 */
results: T[];
/** 是否正在搜索 */
isSearching: boolean;
/** 是否显示下拉 */
showDropdown: boolean;
/** 设置显示下拉 */
setShowDropdown: (show: boolean) => void;
/** 清空搜索 */
clearSearch: () => void;
/** 手动触发搜索 */
triggerSearch: () => void;
/** 选中结果 */
selectResult: (item: T, index: number) => void;
/** 处理按键事件 */
handleKeyPress: (e: React.KeyboardEvent) => void;
}
export function useHeroSearch<T extends SearchResultItem = SearchResultItem>({
config,
}: UseHeroSearchOptions<T>): UseHeroSearchReturn<T> {
const [query, setQuery] = useState('');
const [results, setResults] = useState<T[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
const abortController = useRef<AbortController | null>(null);
const debounceMs = config.debounceMs ?? DEFAULT_SEARCH_CONFIG.debounceMs ?? 300;
const maxResults = config.maxResults ?? DEFAULT_SEARCH_CONFIG.maxResults ?? 10;
// 执行搜索
const performSearch = useCallback(async (searchQuery: string) => {
if (!searchQuery.trim()) {
setResults([]);
setShowDropdown(false);
return;
}
// 取消之前的请求
if (abortController.current) {
abortController.current.abort();
}
abortController.current = new AbortController();
setIsSearching(true);
try {
const searchResult = await config.onSearch(searchQuery);
const limitedResults = searchResult.slice(0, maxResults);
setResults(limitedResults as T[]);
setShowDropdown(limitedResults.length > 0);
} catch (error) {
// 忽略取消的请求
if ((error as Error).name !== 'AbortError') {
console.error('Search error:', error);
setResults([]);
}
} finally {
setIsSearching(false);
}
}, [config, maxResults]);
// 带防抖的查询更新
const handleQueryChange = useCallback((value: string) => {
setQuery(value);
// 清除之前的定时器
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
if (!value.trim()) {
setResults([]);
setShowDropdown(false);
return;
}
// 设置新的防抖定时器
debounceTimer.current = setTimeout(() => {
performSearch(value);
}, debounceMs);
}, [debounceMs, performSearch]);
// 手动触发搜索(点击搜索按钮)
const triggerSearch = useCallback(() => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
performSearch(query);
}, [query, performSearch]);
// 清空搜索
const clearSearch = useCallback(() => {
setQuery('');
setResults([]);
setShowDropdown(false);
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
}, []);
// 选中结果
const selectResult = useCallback((item: T, index: number) => {
config.onResultSelect(item, index);
setShowDropdown(false);
}, [config]);
// 处理按键事件
const handleKeyPress = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
// 如果有搜索按钮Enter 触发搜索
if (config.showSearchButton) {
triggerSearch();
} else if (results.length > 0) {
// 否则选中第一个结果
selectResult(results[0], 0);
}
} else if (e.key === 'Escape') {
setShowDropdown(false);
}
}, [config.showSearchButton, results, triggerSearch, selectResult]);
// 清理
useEffect(() => {
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
if (abortController.current) {
abortController.current.abort();
}
};
}, []);
return {
query,
setQuery: handleQueryChange,
results,
isSearching,
showDropdown,
setShowDropdown,
clearSearch,
triggerSearch,
selectResult,
handleKeyPress,
};
}

View File

@@ -0,0 +1,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';

View 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;
}

View File

@@ -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)
});
}),
];

View File

@@ -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}>

View File

@@ -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>
)}

View File

@@ -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}