refactor(StockOverview): 优化布局与数据展示

- 头部统计卡片从 4 列精简为 3 列,移除冗余下跌家数
- 涨跌家数改为"多空对比"卡片,双色数值 + 进度条
- 各卡片新增环比趋势指示(放量/缩量等)
- 日期选择器移至 HotspotOverview 头部右侧
- 大盘分时图调整至统计卡片上方
- 异动标签支持点击筛选

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-30 15:55:53 +08:00
parent 91d89fb958
commit eaf11713e8
2 changed files with 196 additions and 113 deletions

View File

@@ -3,8 +3,9 @@
* 展示大盘分时走势 + 概念异动标注 * 展示大盘分时走势 + 概念异动标注
* *
* 布局设计: * 布局设计:
* - 顶部:统计摘要(指数信息 + 异动统计) * - 顶部:标题 + 日期选择器 + 异动数量
* - 中部:大尺寸分时图(主要展示区域) * - 中部:大尺寸分时图(主要展示区域)
* - 下方:统计卡片(指数信息 + 异动统计)
* - 底部:异动列表(横向滚动卡片) * - 底部:异动列表(横向滚动卡片)
*/ */
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
@@ -39,6 +40,7 @@ import {
import { useHotspotData } from './hooks'; import { useHotspotData } from './hooks';
import { IndexMinuteChart, AlertDetailDrawer } from './components'; import { IndexMinuteChart, AlertDetailDrawer } from './components';
import { ALERT_TYPE_CONFIG, getAlertTypeLabel } from './utils/chartHelpers'; import { ALERT_TYPE_CONFIG, getAlertTypeLabel } from './utils/chartHelpers';
import TradeDatePicker from '@components/TradeDatePicker';
import { import {
glassEffect, glassEffect,
colors, colors,
@@ -192,10 +194,15 @@ const CompactAlertCard = ({ alert, onClick, isSelected }) => {
* 热点概览主组件 * 热点概览主组件
* @param {Object} props * @param {Object} props
* @param {Date|null} props.selectedDate - 选中的交易日期 * @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 [selectedAlert, setSelectedAlert] = useState(null);
const [drawerAlertData, setDrawerAlertData] = useState(null); const [drawerAlertData, setDrawerAlertData] = useState(null);
// 选中的异动类型过滤器null 表示全部)
const [selectedAlertType, setSelectedAlertType] = useState(null);
// 右边栏抽屉控制 // 右边栏抽屉控制
const { isOpen: isDrawerOpen, onOpen: onDrawerOpen, onClose: onDrawerClose } = useDisclosure(); const { isOpen: isDrawerOpen, onOpen: onDrawerOpen, onClose: onDrawerClose } = useDisclosure();
@@ -231,6 +238,11 @@ const HotspotOverview = ({ selectedDate }) => {
onDrawerOpen(); onDrawerOpen();
}, [onDrawerOpen]); }, [onDrawerOpen]);
// 点击异动类型标签 - 切换过滤器
const handleAlertTypeClick = useCallback((type) => {
setSelectedAlertType(prevType => prevType === type ? null : type);
}, []);
// 渲染加载状态 - Glassmorphism 风格 // 渲染加载状态 - Glassmorphism 风格
if (loading) { if (loading) {
return ( return (
@@ -335,6 +347,11 @@ const HotspotOverview = ({ selectedDate }) => {
const { index, alerts, alert_summary } = data; 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 marketColor = getMarketColor(index?.change_pct || 0);
const marketGlow = getMarketGlow(index?.change_pct || 0); const marketGlow = getMarketGlow(index?.change_pct || 0);
@@ -415,6 +432,18 @@ const HotspotOverview = ({ selectedDate }) => {
</HStack> </HStack>
<Spacer /> <Spacer />
<HStack spacing={3}> <HStack spacing={3}>
{/* 日期选择器 */}
{onDateChange && (
<TradeDatePicker
value={selectedDate}
onChange={onDateChange}
latestTradeDate={null}
minDate={minDate}
maxDate={maxDate}
isDarkMode={true}
size="sm"
/>
)}
{alerts.length > 0 && ( {alerts.length > 0 && (
<HStack <HStack
spacing={2} spacing={2}
@@ -441,7 +470,7 @@ const HotspotOverview = ({ selectedDate }) => {
</Text> </Text>
</HStack> </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)' }}> <Box cursor="help" p={2} borderRadius="full" _hover={{ bg: 'rgba(255,255,255,0.05)' }}>
<Icon as={Info} color={subTextColor} boxSize={4} /> <Icon as={Info} color={subTextColor} boxSize={4} />
</Box> </Box>
@@ -449,7 +478,65 @@ const HotspotOverview = ({ selectedDate }) => {
</HStack> </HStack>
</Flex> </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}> <SimpleGrid columns={{ base: 1, md: 2 }} spacing={4} mb={5}>
{/* 指数信息卡片 */} {/* 指数信息卡片 */}
<Box <Box
@@ -546,16 +633,32 @@ const HotspotOverview = ({ selectedDate }) => {
}} }}
> >
<HStack justify="space-between" mb={3}> <HStack justify="space-between" mb={3}>
<HStack spacing={2}>
<Text fontSize="sm" fontWeight="bold" color={textColor}>今日异动</Text> <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 <Text
fontSize="sm" fontSize="sm"
color={colors.accent.purple} color={colors.accent.purple}
fontWeight="bold" fontWeight="bold"
css={css`text-shadow: 0 0 10px rgba(139, 92, 246, 0.5);`} css={css`text-shadow: 0 0 10px rgba(139, 92, 246, 0.5);`}
> >
{alerts.length} {selectedAlertType ? `${filteredAlerts.length}/${alerts.length}` : alerts.length}
</Text> </Text>
</HStack> </HStack>
</HStack>
<Flex gap={2} flexWrap="wrap"> <Flex gap={2} flexWrap="wrap">
{Object.entries(alert_summary || {}) {Object.entries(alert_summary || {})
.filter(([_, count]) => count > 0) .filter(([_, count]) => count > 0)
@@ -563,6 +666,7 @@ const HotspotOverview = ({ selectedDate }) => {
.map(([type, count]) => { .map(([type, count]) => {
const config = ALERT_TYPE_CONFIG[type]; const config = ALERT_TYPE_CONFIG[type];
if (!config) return null; if (!config) return null;
const isSelected = selectedAlertType === type;
return ( return (
<HStack <HStack
key={type} key={type}
@@ -570,15 +674,20 @@ const HotspotOverview = ({ selectedDate }) => {
px={3} px={3}
py={1.5} py={1.5}
borderRadius="full" borderRadius="full"
bg={`${config.color}15`} bg={isSelected ? `${config.color}35` : `${config.color}15`}
border={`1px solid ${config.color}25`} border={isSelected ? `2px solid ${config.color}` : `1px solid ${config.color}25`}
cursor="pointer"
transition="all 0.2s" transition="all 0.2s"
transform={isSelected ? 'scale(1.05)' : 'scale(1)'}
boxShadow={isSelected ? `0 0 20px ${config.color}40` : 'none'}
onClick={() => handleAlertTypeClick(type)}
_hover={{ _hover={{
bg: `${config.color}25`, bg: `${config.color}25`,
boxShadow: `0 0 15px ${config.color}30`, 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 <Text
fontSize="xs" fontSize="xs"
fontWeight="bold" fontWeight="bold"
@@ -594,64 +703,6 @@ const HotspotOverview = ({ selectedDate }) => {
</Box> </Box>
</SimpleGrid> </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 横向滚动 */} {/* 异动列表 - Glassmorphism 横向滚动 */}
{alerts.length > 0 && ( {alerts.length > 0 && (
<Box> <Box>
@@ -670,7 +721,11 @@ const HotspotOverview = ({ selectedDate }) => {
/> />
</Box> </Box>
<Text fontSize="sm" fontWeight="bold" color={textColor}>异动记录</Text> <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> </HStack>
{/* 横向滚动卡片 */} {/* 横向滚动卡片 */}
@@ -688,7 +743,7 @@ const HotspotOverview = ({ selectedDate }) => {
}} }}
> >
<HStack spacing={3} pb={1}> <HStack spacing={3} pb={1}>
{[...alerts] {[...filteredAlerts]
.sort((a, b) => (b.time || '').localeCompare(a.time || '')) .sort((a, b) => (b.time || '').localeCompare(a.time || ''))
.map((alert, idx) => ( .map((alert, idx) => (
<CompactAlertCard <CompactAlertCard
@@ -704,7 +759,7 @@ const HotspotOverview = ({ selectedDate }) => {
)} )}
{/* 无异动提示 - Glassmorphism */} {/* 无异动提示 - Glassmorphism */}
{alerts.length === 0 && ( {filteredAlerts.length === 0 && (
<Center <Center
py={12} py={12}
bg={sectionBg} bg={sectionBg}
@@ -742,7 +797,22 @@ const HotspotOverview = ({ selectedDate }) => {
css={css`filter: drop-shadow(0 0 10px rgba(139, 92, 246, 0.3));`} css={css`filter: drop-shadow(0 0 10px rgba(139, 92, 246, 0.3));`}
/> />
</Box> </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> </VStack>
</Center> </Center>
)} )}

View File

@@ -48,7 +48,7 @@ import {
Skeleton, Skeleton,
SkeletonText, SkeletonText,
} from '@chakra-ui/react'; } 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 ConceptStocksModal from '@components/ConceptStocksModal';
import TradeDatePicker from '@components/TradeDatePicker'; import TradeDatePicker from '@components/TradeDatePicker';
import HotspotOverview from './components/HotspotOverview'; import HotspotOverview from './components/HotspotOverview';
@@ -265,12 +265,14 @@ const StockOverview = () => {
const newStats = { const newStats = {
...(prevStats || {}), // 先保留所有现有字段(包括 rising_count/falling_count ...(prevStats || {}), // 先保留所有现有字段(包括 rising_count/falling_count
...data.summary, // 然后覆盖 summary 字段 ...data.summary, // 然后覆盖 summary 字段
yesterday: data.yesterday, // 保存昨日对比数据
date: data.trade_date date: data.trade_date
}; };
return newStats; return newStats;
}); });
const newStats = { const newStats = {
...data.summary, ...data.summary,
yesterday: data.yesterday,
date: data.trade_date date: data.trade_date
}; };
// 日期和可选日期列表由 fetchTopConcepts 统一设置,这里不再设置 // 日期和可选日期列表由 fetchTopConcepts 统一设置,这里不再设置
@@ -674,42 +676,69 @@ const StockOverview = () => {
onResultSelect: (item, index) => handleSelectStock(item.raw, index), onResultSelect: (item, index) => handleSelectStock(item.raw, index),
}} }}
stats={{ stats={{
columns: { base: 2, md: 4 }, columns: { base: 1, sm: 3, md: 3 },
items: [ items: [
{ {
key: 'marketCap', key: 'marketCap',
label: 'A股总市值', label: 'A股总市值',
value: marketStats ? `${(marketStats.total_market_cap / 10000).toFixed(1)}万亿` : null, 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', key: 'amount',
label: '今日成交额', label: '今日成交额',
value: marketStats ? `${(marketStats.total_amount / 10000).toFixed(1)}万亿` : null, 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', key: 'rising',
label: '上涨家数', label: '多空对比',
value: marketStats?.rising_count, // 显示为 "上涨/下跌" 格式,使用自定义渲染
valueColor: '#ff4d4d', value: (marketStats?.rising_count && marketStats?.falling_count)
}, ? `${marketStats.rising_count}/${marketStats.falling_count}`
{ : marketStats?.rising_count,
key: 'falling', // 涨跌进度条(不显示底部标签)
label: '下跌家数', progressBar: (marketStats?.rising_count && marketStats?.falling_count) ? {
value: marketStats?.falling_count, value: marketStats.rising_count,
valueColor: 'green.400', total: marketStats.falling_count,
positiveColor: '#ff4d4d',
negativeColor: '#22c55e',
} : undefined,
// 水印背景图标 - 天平图标
watermark: { icon: Scale, color: goldColor, opacity: 0.1 },
}, },
], ],
}} }}
/> />
{/* 主内容区 */} {/* 主内容区 - 负 margin 使卡片向上浮动,与 Hero 产生重叠纵深感 */}
<Box py={10} px={6} position="relative" zIndex={1}> <Box pt={6} pb={10} px={6} mt={-6} position="relative" zIndex={2}>
{/* 日期选择器 */} {/* 热点概览 - 大盘走势 + 概念异动 */}
<Box mb={6}> {/* 只在 selectedDate 确定后渲染,避免 null → 日期 的双重请求 */}
<Flex align="center" gap={4} flexWrap="wrap"> <Box mb={10}>
<TradeDatePicker {selectedDate ? (
value={selectedDate} <HotspotOverview
onChange={(date) => { selectedDate={selectedDate}
onDateChange={(date) => {
const dateStr = date.toISOString().split('T')[0]; const dateStr = date.toISOString().split('T')[0];
const previousDateStr = selectedDate ? selectedDate.toISOString().split('T')[0] : null; const previousDateStr = selectedDate ? selectedDate.toISOString().split('T')[0] : null;
trackDateChanged(dateStr, previousDateStr); trackDateChanged(dateStr, previousDateStr);
@@ -718,25 +747,9 @@ const StockOverview = () => {
fetchMarketStats(dateStr); fetchMarketStats(dateStr);
fetchTopConcepts(dateStr); fetchTopConcepts(dateStr);
}} }}
latestTradeDate={null}
minDate={tradingDays.length > 0 ? new Date(tradingDays[0]) : undefined} minDate={tradingDays.length > 0 ? new Date(tradingDays[0]) : undefined}
maxDate={tradingDays.length > 0 ? new Date(tradingDays[tradingDays.length - 1]) : 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 <Card
bg={cardBg} bg={cardBg}