1. ✅ Profile 和 Settings 页面(2个文件) 2. ✅ EventDetail 页面(1个文件) 3. ✅ 身份验证组件(WechatRegister.js) 4. ✅ Company 页面(CompanyOverview, index, FinancialPanorama, MarketDataView) 5. ✅ Concept 页面(ConceptTimelineModal, ConceptStatsPanel, index)
919 lines
39 KiB
JavaScript
919 lines
39 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
||
import { logger } from '../../../utils/logger';
|
||
import {
|
||
Box,
|
||
SimpleGrid,
|
||
Card,
|
||
CardBody,
|
||
CardHeader,
|
||
Heading,
|
||
Text,
|
||
VStack,
|
||
HStack,
|
||
Badge,
|
||
Icon,
|
||
Skeleton,
|
||
SkeletonText,
|
||
Tooltip,
|
||
Button,
|
||
ButtonGroup,
|
||
Input,
|
||
useColorModeValue,
|
||
Divider,
|
||
Flex,
|
||
Avatar,
|
||
StatLabel,
|
||
StatNumber,
|
||
Stat,
|
||
StatHelpText,
|
||
StatArrow,
|
||
Tabs,
|
||
TabList,
|
||
TabPanels,
|
||
Tab,
|
||
TabPanel,
|
||
useToast,
|
||
} from '@chakra-ui/react';
|
||
import {
|
||
FaArrowUp,
|
||
FaArrowDown,
|
||
FaFire,
|
||
FaChartLine,
|
||
FaNewspaper,
|
||
FaRocket,
|
||
FaBolt,
|
||
FaEye,
|
||
FaCrown,
|
||
FaThermometerHalf,
|
||
} from 'react-icons/fa';
|
||
import { BsLightningFill, BsGraphUp, BsGraphDown } from 'react-icons/bs';
|
||
|
||
const ConceptStatsPanel = ({ apiBaseUrl, onConceptClick }) => {
|
||
// 获取正确的API基础URL
|
||
const conceptApiBaseUrl = process.env.NODE_ENV === 'production'
|
||
? '/concept-api'
|
||
: 'http://111.198.58.126:16801';
|
||
|
||
const [statsData, setStatsData] = useState({});
|
||
const [loading, setLoading] = useState(true);
|
||
const [activeTab, setActiveTab] = useState(0);
|
||
const [timeRange, setTimeRange] = useState(7); // 默认7天
|
||
const [customStartDate, setCustomStartDate] = useState('');
|
||
const [customEndDate, setCustomEndDate] = useState('');
|
||
const [useCustomRange, setUseCustomRange] = useState(false);
|
||
const toast = useToast();
|
||
|
||
const bg = useColorModeValue('white', 'gray.800');
|
||
const cardBg = useColorModeValue('gray.50', 'gray.700');
|
||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||
|
||
// 获取统计数据
|
||
const fetchStatsData = async (days = timeRange, startDate = null, endDate = null) => {
|
||
setLoading(true);
|
||
try {
|
||
// 构建查询参数
|
||
let queryParams = 'min_stock_count=3';
|
||
if (useCustomRange && startDate && endDate) {
|
||
queryParams += `&start_date=${startDate}&end_date=${endDate}`;
|
||
} else {
|
||
queryParams += `&days=${days}`;
|
||
}
|
||
|
||
let response;
|
||
let result;
|
||
|
||
try {
|
||
// 优先尝试concept-api路由(通过nginx代理)
|
||
response = await fetch(`${conceptApiBaseUrl}/statistics?${queryParams}`);
|
||
if (response.ok) {
|
||
result = await response.json();
|
||
if (result.success && result.data) {
|
||
setStatsData(result.data);
|
||
return; // 成功获取数据,直接返回
|
||
} else {
|
||
throw new Error(result.note || 'Concept API返回错误');
|
||
}
|
||
} else {
|
||
throw new Error(`Concept API错误: ${response.status}`);
|
||
}
|
||
} catch (conceptApiError) {
|
||
logger.warn('ConceptStatsPanel', 'concept-api路由失败,尝试直接访问', { error: conceptApiError.message, days, startDate, endDate });
|
||
|
||
// 备用方案:直接访问concept_api服务(开发环境回退)
|
||
try {
|
||
response = await fetch(`http://111.198.58.126:16801/statistics?${queryParams}`);
|
||
if (response.ok) {
|
||
result = await response.json();
|
||
if (result.success && result.data) {
|
||
setStatsData(result.data);
|
||
logger.info('ConceptStatsPanel', '统计数据加载成功(备用API)', { dataKeys: Object.keys(result.data) });
|
||
return;
|
||
} else {
|
||
throw new Error(result.note || '直接API返回错误');
|
||
}
|
||
} else {
|
||
throw new Error(`直接API错误: ${response.status}`);
|
||
}
|
||
} catch (directError) {
|
||
logger.error('ConceptStatsPanel', '所有API都失败', directError, { days, startDate, endDate });
|
||
throw new Error('无法访问概念统计API');
|
||
}
|
||
}
|
||
|
||
} catch (error) {
|
||
logger.error('ConceptStatsPanel', 'fetchStatsData', error, { days, startDate, endDate });
|
||
|
||
// ❌ 移除获取统计数据失败toast
|
||
// toast({ title: '获取统计数据失败', description: '正在使用默认数据,请稍后刷新重试', status: 'warning', duration: 3000, isClosable: true });
|
||
|
||
// 使用简化的默认数据作为最后的fallback
|
||
setStatsData({
|
||
hot_concepts: [
|
||
{ name: '小米大模型', change_pct: 18.76, stock_count: 12, news_count: 35 },
|
||
{ name: '人工智能', change_pct: 15.67, stock_count: 45, news_count: 23 },
|
||
{ name: '新能源汽车', change_pct: 12.34, stock_count: 38, news_count: 18 },
|
||
{ name: '芯片概念', change_pct: 9.87, stock_count: 52, news_count: 31 },
|
||
{ name: '5G通信', change_pct: 8.45, stock_count: 29, news_count: 15 },
|
||
],
|
||
cold_concepts: [
|
||
{ name: '房地产', change_pct: -8.76, stock_count: 33, news_count: 12 },
|
||
{ name: '煤炭开采', change_pct: -6.54, stock_count: 25, news_count: 8 },
|
||
{ name: '传统零售', change_pct: -5.43, stock_count: 19, news_count: 6 },
|
||
{ name: '钢铁冶炼', change_pct: -4.21, stock_count: 28, news_count: 9 },
|
||
{ name: '纺织服装', change_pct: -3.98, stock_count: 15, news_count: 4 },
|
||
],
|
||
active_concepts: [
|
||
{ name: '人工智能', news_count: 89, report_count: 15, total_mentions: 104 },
|
||
{ name: '芯片概念', news_count: 76, report_count: 12, total_mentions: 88 },
|
||
{ name: '新能源汽车', news_count: 65, report_count: 18, total_mentions: 83 },
|
||
{ name: '生物医药', news_count: 54, report_count: 9, total_mentions: 63 },
|
||
{ name: '量子科技', news_count: 41, report_count: 7, total_mentions: 48 },
|
||
],
|
||
volatile_concepts: [
|
||
{ name: '区块链', volatility: 23.45, avg_change: 3.21, max_change: 12.34 },
|
||
{ name: '元宇宙', volatility: 21.87, avg_change: 2.98, max_change: 11.76 },
|
||
{ name: '虚拟现实', volatility: 19.65, avg_change: -1.23, max_change: 9.87 },
|
||
{ name: '游戏概念', volatility: 18.32, avg_change: 4.56, max_change: 10.45 },
|
||
{ name: '在线教育', volatility: 17.89, avg_change: -2.11, max_change: 8.76 },
|
||
],
|
||
momentum_concepts: [
|
||
{ name: '数字经济', consecutive_days: 5, total_change: 18.76, avg_daily: 3.75 },
|
||
{ name: '云计算', consecutive_days: 4, total_change: 14.32, avg_daily: 3.58 },
|
||
{ name: '物联网', consecutive_days: 4, total_change: 12.89, avg_daily: 3.22 },
|
||
{ name: '大数据', consecutive_days: 3, total_change: 11.45, avg_daily: 3.82 },
|
||
{ name: '工业互联网', consecutive_days: 3, total_change: 9.87, avg_daily: 3.29 },
|
||
],
|
||
});
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 处理时间范围变化
|
||
const handleTimeRangeChange = (newRange) => {
|
||
if (newRange === 'custom') {
|
||
setUseCustomRange(true);
|
||
// 默认设置最近7天的自定义范围
|
||
const today = new Date();
|
||
const weekAgo = new Date(today);
|
||
weekAgo.setDate(today.getDate() - 6);
|
||
|
||
setCustomEndDate(today.toISOString().split('T')[0]);
|
||
setCustomStartDate(weekAgo.toISOString().split('T')[0]);
|
||
} else {
|
||
setUseCustomRange(false);
|
||
setTimeRange(parseInt(newRange));
|
||
fetchStatsData(parseInt(newRange));
|
||
}
|
||
};
|
||
|
||
// 应用自定义日期范围
|
||
const applyCustomRange = () => {
|
||
if (customStartDate && customEndDate) {
|
||
if (new Date(customStartDate) > new Date(customEndDate)) {
|
||
toast({
|
||
title: '日期选择错误',
|
||
description: '开始日期不能晚于结束日期',
|
||
status: 'error',
|
||
duration: 3000,
|
||
});
|
||
return;
|
||
}
|
||
fetchStatsData(null, customStartDate, customEndDate);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchStatsData();
|
||
}, []);
|
||
|
||
// 当自定义范围状态改变时重新获取数据
|
||
useEffect(() => {
|
||
if (useCustomRange && customStartDate && customEndDate) {
|
||
fetchStatsData(null, customStartDate, customEndDate);
|
||
}
|
||
}, [useCustomRange]);
|
||
|
||
// 格式化涨跌幅
|
||
const formatChange = (value) => {
|
||
if (!value) return '0.00%';
|
||
const formatted = Math.abs(value).toFixed(2);
|
||
return value > 0 ? `+${formatted}%` : `-${formatted}%`;
|
||
};
|
||
|
||
// 获取涨跌幅颜色
|
||
const getChangeColor = (value) => {
|
||
if (value > 0) return 'red.500';
|
||
if (value < 0) return 'green.500';
|
||
return 'gray.500';
|
||
};
|
||
|
||
// 统计卡片组件 - 美化版
|
||
const StatsCard = ({ title, icon, color, data, renderItem, isLoading }) => (
|
||
<Box p={4}>
|
||
{isLoading ? (
|
||
<VStack spacing={3} align="stretch">
|
||
{[1, 2, 3, 4, 5].map((i) => (
|
||
<HStack key={i} justify="space-between" p={3} bg="gray.50" borderRadius="lg">
|
||
<HStack spacing={2} flex={1}>
|
||
<Skeleton height="20px" width="20px" borderRadius="full" />
|
||
<VStack align="start" spacing={1} flex={1}>
|
||
<Skeleton height="14px" width="80%" />
|
||
<Skeleton height="12px" width="60%" />
|
||
</VStack>
|
||
</HStack>
|
||
<Skeleton height="20px" width="50px" borderRadius="md" />
|
||
</HStack>
|
||
))}
|
||
</VStack>
|
||
) : (
|
||
<VStack spacing={2} align="stretch" maxH="400px" overflowY="auto"
|
||
css={{
|
||
'&::-webkit-scrollbar': {
|
||
width: '4px',
|
||
},
|
||
'&::-webkit-scrollbar-track': {
|
||
background: '#f1f1f1',
|
||
borderRadius: '10px',
|
||
},
|
||
'&::-webkit-scrollbar-thumb': {
|
||
background: '#c1c1c1',
|
||
borderRadius: '10px',
|
||
},
|
||
'&::-webkit-scrollbar-thumb:hover': {
|
||
background: '#a8a8a8',
|
||
},
|
||
}}
|
||
>
|
||
{data?.map((item, index) => (
|
||
<Box key={index}>
|
||
{renderItem(item, index)}
|
||
</Box>
|
||
))}
|
||
</VStack>
|
||
)}
|
||
</Box>
|
||
);
|
||
|
||
const tabsData = [
|
||
{
|
||
label: '涨幅榜',
|
||
icon: FaArrowUp,
|
||
color: 'red',
|
||
data: statsData.hot_concepts,
|
||
renderItem: (item, index) => (
|
||
<Flex
|
||
justify="space-between"
|
||
align="center"
|
||
p={3}
|
||
borderRadius="xl"
|
||
bg={index < 3 ? 'red.50' : 'gray.50'}
|
||
border="1px solid"
|
||
borderColor={index < 3 ? 'red.100' : 'gray.200'}
|
||
_hover={{
|
||
transform: 'translateY(-1px)',
|
||
shadow: 'md',
|
||
cursor: 'pointer',
|
||
bg: index < 3 ? 'red.100' : 'gray.100'
|
||
}}
|
||
transition="all 0.2s"
|
||
onClick={() => onConceptClick?.(null, item.name)}
|
||
>
|
||
<HStack spacing={3} flex={1}>
|
||
<Box position="relative">
|
||
<Badge
|
||
colorScheme={index === 0 ? 'yellow' : index === 1 ? 'orange' : index === 2 ? 'red' : 'gray'}
|
||
variant="solid"
|
||
borderRadius="full"
|
||
minW="24px"
|
||
h="24px"
|
||
textAlign="center"
|
||
fontSize="xs"
|
||
fontWeight="bold"
|
||
display="flex"
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
>
|
||
{index + 1}
|
||
</Badge>
|
||
{index === 0 && (
|
||
<Icon
|
||
as={FaCrown}
|
||
position="absolute"
|
||
top="-8px"
|
||
right="-8px"
|
||
color="yellow.500"
|
||
boxSize={3}
|
||
/>
|
||
)}
|
||
</Box>
|
||
<VStack align="start" spacing={0} flex={1}>
|
||
<Text fontSize="sm" fontWeight="bold" noOfLines={1} color="gray.800">
|
||
{item.name}
|
||
</Text>
|
||
<HStack spacing={2} fontSize="xs" color="gray.600">
|
||
<HStack spacing={1}>
|
||
<Icon as={FaChartLine} boxSize={2.5} />
|
||
<Text>{item.stock_count}股</Text>
|
||
</HStack>
|
||
<Text>·</Text>
|
||
<HStack spacing={1}>
|
||
<Icon as={FaNewspaper} boxSize={2.5} />
|
||
<Text>{item.news_count}讯</Text>
|
||
</HStack>
|
||
</HStack>
|
||
</VStack>
|
||
</HStack>
|
||
<Badge
|
||
colorScheme="red"
|
||
variant="solid"
|
||
borderRadius="lg"
|
||
fontSize="xs"
|
||
fontWeight="bold"
|
||
px={2}
|
||
py={1}
|
||
>
|
||
<Icon as={FaArrowUp} boxSize={2} mr={1} />
|
||
{formatChange(item.change_pct)}
|
||
</Badge>
|
||
</Flex>
|
||
),
|
||
},
|
||
{
|
||
label: '跌幅榜',
|
||
icon: FaArrowDown,
|
||
color: 'green',
|
||
data: statsData.cold_concepts,
|
||
renderItem: (item, index) => (
|
||
<Flex
|
||
justify="space-between"
|
||
align="center"
|
||
p={3}
|
||
borderRadius="xl"
|
||
bg={index < 3 ? 'green.50' : 'gray.50'}
|
||
border="1px solid"
|
||
borderColor={index < 3 ? 'green.100' : 'gray.200'}
|
||
_hover={{
|
||
transform: 'translateY(-1px)',
|
||
shadow: 'md',
|
||
cursor: 'pointer',
|
||
bg: index < 3 ? 'green.100' : 'gray.100'
|
||
}}
|
||
transition="all 0.2s"
|
||
onClick={() => onConceptClick?.(null, item.name)}
|
||
>
|
||
<HStack spacing={3} flex={1}>
|
||
<Badge
|
||
colorScheme={index < 3 ? 'green' : 'gray'}
|
||
variant="solid"
|
||
borderRadius="full"
|
||
minW="24px"
|
||
h="24px"
|
||
textAlign="center"
|
||
fontSize="xs"
|
||
fontWeight="bold"
|
||
display="flex"
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
>
|
||
{index + 1}
|
||
</Badge>
|
||
<VStack align="start" spacing={0} flex={1}>
|
||
<Text fontSize="sm" fontWeight="bold" noOfLines={1} color="gray.800">
|
||
{item.name}
|
||
</Text>
|
||
<HStack spacing={2} fontSize="xs" color="gray.600">
|
||
<HStack spacing={1}>
|
||
<Icon as={FaChartLine} boxSize={2.5} />
|
||
<Text>{item.stock_count}股</Text>
|
||
</HStack>
|
||
<Text>·</Text>
|
||
<HStack spacing={1}>
|
||
<Icon as={FaNewspaper} boxSize={2.5} />
|
||
<Text>{item.news_count}讯</Text>
|
||
</HStack>
|
||
</HStack>
|
||
</VStack>
|
||
</HStack>
|
||
<Badge
|
||
colorScheme="green"
|
||
variant="solid"
|
||
borderRadius="lg"
|
||
fontSize="xs"
|
||
fontWeight="bold"
|
||
px={2}
|
||
py={1}
|
||
>
|
||
<Icon as={FaArrowDown} boxSize={2} mr={1} />
|
||
{formatChange(item.change_pct)}
|
||
</Badge>
|
||
</Flex>
|
||
),
|
||
},
|
||
{
|
||
label: '活跃榜',
|
||
icon: FaFire,
|
||
color: 'orange',
|
||
data: statsData.active_concepts,
|
||
renderItem: (item, index) => (
|
||
<Flex
|
||
justify="space-between"
|
||
align="center"
|
||
p={3}
|
||
borderRadius="xl"
|
||
bg={index < 3 ? 'orange.50' : 'gray.50'}
|
||
border="1px solid"
|
||
borderColor={index < 3 ? 'orange.100' : 'gray.200'}
|
||
_hover={{
|
||
transform: 'translateY(-1px)',
|
||
shadow: 'md',
|
||
cursor: 'pointer',
|
||
bg: index < 3 ? 'orange.100' : 'gray.100'
|
||
}}
|
||
transition="all 0.2s"
|
||
onClick={() => onConceptClick?.(null, item.name)}
|
||
>
|
||
<HStack spacing={3} flex={1}>
|
||
<Box position="relative">
|
||
<Badge
|
||
colorScheme={index === 0 ? 'orange' : index < 3 ? 'yellow' : 'gray'}
|
||
variant="solid"
|
||
borderRadius="full"
|
||
minW="24px"
|
||
h="24px"
|
||
textAlign="center"
|
||
fontSize="xs"
|
||
fontWeight="bold"
|
||
display="flex"
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
>
|
||
{index + 1}
|
||
</Badge>
|
||
{index === 0 && (
|
||
<Icon
|
||
as={FaFire}
|
||
position="absolute"
|
||
top="-8px"
|
||
right="-8px"
|
||
color="orange.500"
|
||
boxSize={3}
|
||
/>
|
||
)}
|
||
</Box>
|
||
<VStack align="start" spacing={0} flex={1}>
|
||
<Text fontSize="sm" fontWeight="bold" noOfLines={1} color="gray.800">
|
||
{item.name}
|
||
</Text>
|
||
<HStack spacing={2} fontSize="xs" color="gray.600">
|
||
<HStack spacing={1}>
|
||
<Icon as={FaNewspaper} boxSize={2.5} />
|
||
<Text>{item.news_count}</Text>
|
||
</HStack>
|
||
<Text>·</Text>
|
||
<HStack spacing={1}>
|
||
<Icon as={FaEye} boxSize={2.5} />
|
||
<Text>{item.report_count}</Text>
|
||
</HStack>
|
||
</HStack>
|
||
</VStack>
|
||
</HStack>
|
||
<Badge
|
||
colorScheme="orange"
|
||
variant="solid"
|
||
borderRadius="lg"
|
||
fontSize="xs"
|
||
fontWeight="bold"
|
||
px={2}
|
||
py={1}
|
||
>
|
||
<Icon as={FaFire} boxSize={2} mr={1} />
|
||
{item.total_mentions}
|
||
</Badge>
|
||
</Flex>
|
||
),
|
||
},
|
||
{
|
||
label: '波动榜',
|
||
icon: BsLightningFill,
|
||
color: 'purple',
|
||
data: statsData.volatile_concepts,
|
||
renderItem: (item, index) => (
|
||
<Flex
|
||
justify="space-between"
|
||
align="center"
|
||
p={3}
|
||
borderRadius="xl"
|
||
bg={index < 3 ? 'purple.50' : 'gray.50'}
|
||
border="1px solid"
|
||
borderColor={index < 3 ? 'purple.100' : 'gray.200'}
|
||
_hover={{
|
||
transform: 'translateY(-1px)',
|
||
shadow: 'md',
|
||
cursor: 'pointer',
|
||
bg: index < 3 ? 'purple.100' : 'gray.100'
|
||
}}
|
||
transition="all 0.2s"
|
||
onClick={() => onConceptClick?.(null, item.name)}
|
||
>
|
||
<HStack spacing={3} flex={1}>
|
||
<Box position="relative">
|
||
<Badge
|
||
colorScheme={index < 3 ? 'purple' : 'gray'}
|
||
variant="solid"
|
||
borderRadius="full"
|
||
minW="24px"
|
||
h="24px"
|
||
textAlign="center"
|
||
fontSize="xs"
|
||
fontWeight="bold"
|
||
display="flex"
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
>
|
||
{index + 1}
|
||
</Badge>
|
||
{index === 0 && (
|
||
<Icon
|
||
as={BsLightningFill}
|
||
position="absolute"
|
||
top="-8px"
|
||
right="-8px"
|
||
color="purple.500"
|
||
boxSize={3}
|
||
/>
|
||
)}
|
||
</Box>
|
||
<VStack align="start" spacing={0} flex={1}>
|
||
<Text fontSize="sm" fontWeight="bold" noOfLines={1} color="gray.800">
|
||
{item.name}
|
||
</Text>
|
||
<Text fontSize="xs" color="gray.600">
|
||
均幅 {formatChange(item.avg_change)}
|
||
</Text>
|
||
</VStack>
|
||
</HStack>
|
||
<Badge
|
||
colorScheme="purple"
|
||
variant="solid"
|
||
borderRadius="lg"
|
||
fontSize="xs"
|
||
fontWeight="bold"
|
||
px={2}
|
||
py={1}
|
||
>
|
||
<Icon as={BsLightningFill} boxSize={2} mr={1} />
|
||
{item.volatility?.toFixed(1)}%
|
||
</Badge>
|
||
</Flex>
|
||
),
|
||
},
|
||
{
|
||
label: '连涨榜',
|
||
icon: FaRocket,
|
||
color: 'cyan',
|
||
data: statsData.momentum_concepts,
|
||
renderItem: (item, index) => (
|
||
<Flex
|
||
justify="space-between"
|
||
align="center"
|
||
p={3}
|
||
borderRadius="xl"
|
||
bg={index < 3 ? 'cyan.50' : 'gray.50'}
|
||
border="1px solid"
|
||
borderColor={index < 3 ? 'cyan.100' : 'gray.200'}
|
||
_hover={{
|
||
transform: 'translateY(-1px)',
|
||
shadow: 'md',
|
||
cursor: 'pointer',
|
||
bg: index < 3 ? 'cyan.100' : 'gray.100'
|
||
}}
|
||
transition="all 0.2s"
|
||
onClick={() => onConceptClick?.(null, item.name)}
|
||
>
|
||
<HStack spacing={3} flex={1}>
|
||
<Box position="relative">
|
||
<Badge
|
||
colorScheme={index === 0 ? 'cyan' : index < 3 ? 'blue' : 'gray'}
|
||
variant="solid"
|
||
borderRadius="full"
|
||
minW="24px"
|
||
h="24px"
|
||
textAlign="center"
|
||
fontSize="xs"
|
||
fontWeight="bold"
|
||
display="flex"
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
>
|
||
{index + 1}
|
||
</Badge>
|
||
{index === 0 && (
|
||
<Icon
|
||
as={FaRocket}
|
||
position="absolute"
|
||
top="-8px"
|
||
right="-8px"
|
||
color="cyan.500"
|
||
boxSize={3}
|
||
/>
|
||
)}
|
||
</Box>
|
||
<VStack align="start" spacing={0} flex={1}>
|
||
<Text fontSize="sm" fontWeight="bold" noOfLines={1} color="gray.800">
|
||
{item.name}
|
||
</Text>
|
||
<Text fontSize="xs" color="gray.600">
|
||
累计 {formatChange(item.total_change)}
|
||
</Text>
|
||
</VStack>
|
||
</HStack>
|
||
<Badge
|
||
colorScheme="cyan"
|
||
variant="solid"
|
||
borderRadius="lg"
|
||
fontSize="xs"
|
||
fontWeight="bold"
|
||
px={2}
|
||
py={1}
|
||
>
|
||
<Icon as={FaRocket} boxSize={2} mr={1} />
|
||
{item.consecutive_days}天
|
||
</Badge>
|
||
</Flex>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<Box>
|
||
{/* 顶部标题卡片 */}
|
||
<Box
|
||
bg="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||
p={4}
|
||
borderRadius="xl"
|
||
mb={4}
|
||
position="relative"
|
||
overflow="hidden"
|
||
>
|
||
{/* 背景装饰 */}
|
||
<Box
|
||
position="absolute"
|
||
top="-20px"
|
||
right="-20px"
|
||
width="80px"
|
||
height="80px"
|
||
borderRadius="full"
|
||
bg="whiteAlpha.200"
|
||
filter="blur(10px)"
|
||
/>
|
||
<Box
|
||
position="absolute"
|
||
bottom="-10px"
|
||
left="-10px"
|
||
width="60px"
|
||
height="60px"
|
||
borderRadius="full"
|
||
bg="whiteAlpha.100"
|
||
filter="blur(8px)"
|
||
/>
|
||
|
||
<VStack align="start" spacing={3} position="relative" w="full">
|
||
<Flex justify="space-between" align="center" w="full">
|
||
<HStack spacing={2}>
|
||
<Box p={2} bg="whiteAlpha.200" borderRadius="lg">
|
||
<Icon as={FaChartLine} color="white" boxSize={4} />
|
||
</Box>
|
||
<VStack align="start" spacing={0}>
|
||
<Heading size="sm" color="white" fontWeight="bold">
|
||
概念统计中心
|
||
</Heading>
|
||
<Text fontSize="xs" color="whiteAlpha.800">
|
||
{statsData.summary?.date_range ?
|
||
`统计范围: ${statsData.summary.date_range}` :
|
||
'实时追踪热门概念动态'
|
||
}
|
||
</Text>
|
||
</VStack>
|
||
</HStack>
|
||
|
||
<HStack spacing={2}>
|
||
<Button
|
||
size="xs"
|
||
bg="whiteAlpha.200"
|
||
color="white"
|
||
_hover={{ bg: 'whiteAlpha.300' }}
|
||
onClick={() => fetchStatsData(timeRange, customStartDate, customEndDate)}
|
||
isLoading={loading}
|
||
loadingText="刷新中"
|
||
borderRadius="full"
|
||
px={3}
|
||
>
|
||
🔄 刷新
|
||
</Button>
|
||
</HStack>
|
||
</Flex>
|
||
|
||
{/* 时间范围选择控件 */}
|
||
<Flex gap={2} wrap="wrap" align="center">
|
||
<Text fontSize="xs" color="whiteAlpha.900" fontWeight="medium">
|
||
📅 统计周期:
|
||
</Text>
|
||
<ButtonGroup size="xs" isAttached variant="solid">
|
||
<Button
|
||
bg={!useCustomRange && timeRange === 3 ? 'whiteAlpha.400' : 'whiteAlpha.200'}
|
||
color="white"
|
||
_hover={{ bg: 'whiteAlpha.300' }}
|
||
onClick={() => handleTimeRangeChange('3')}
|
||
borderRadius="md"
|
||
>
|
||
3天
|
||
</Button>
|
||
<Button
|
||
bg={!useCustomRange && timeRange === 7 ? 'whiteAlpha.400' : 'whiteAlpha.200'}
|
||
color="white"
|
||
_hover={{ bg: 'whiteAlpha.300' }}
|
||
onClick={() => handleTimeRangeChange('7')}
|
||
borderRadius="md"
|
||
>
|
||
7天
|
||
</Button>
|
||
<Button
|
||
bg={!useCustomRange && timeRange === 14 ? 'whiteAlpha.400' : 'whiteAlpha.200'}
|
||
color="white"
|
||
_hover={{ bg: 'whiteAlpha.300' }}
|
||
onClick={() => handleTimeRangeChange('14')}
|
||
borderRadius="md"
|
||
>
|
||
14天
|
||
</Button>
|
||
<Button
|
||
bg={!useCustomRange && timeRange === 30 ? 'whiteAlpha.400' : 'whiteAlpha.200'}
|
||
color="white"
|
||
_hover={{ bg: 'whiteAlpha.300' }}
|
||
onClick={() => handleTimeRangeChange('30')}
|
||
borderRadius="md"
|
||
>
|
||
30天
|
||
</Button>
|
||
<Button
|
||
bg={useCustomRange ? 'whiteAlpha.400' : 'whiteAlpha.200'}
|
||
color="white"
|
||
_hover={{ bg: 'whiteAlpha.300' }}
|
||
onClick={() => handleTimeRangeChange('custom')}
|
||
borderRadius="md"
|
||
>
|
||
自定义
|
||
</Button>
|
||
</ButtonGroup>
|
||
</Flex>
|
||
|
||
{/* 自定义日期范围输入 */}
|
||
{useCustomRange && (
|
||
<Flex gap={2} wrap="wrap" align="center">
|
||
<Input
|
||
type="date"
|
||
value={customStartDate}
|
||
onChange={(e) => setCustomStartDate(e.target.value)}
|
||
size="xs"
|
||
bg="whiteAlpha.200"
|
||
color="white"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.300"
|
||
_hover={{ borderColor: 'whiteAlpha.400' }}
|
||
_focus={{ borderColor: 'whiteAlpha.500', boxShadow: 'none' }}
|
||
w="auto"
|
||
maxW="130px"
|
||
sx={{
|
||
'&::-webkit-calendar-picker-indicator': {
|
||
filter: 'invert(1)',
|
||
}
|
||
}}
|
||
/>
|
||
<Text fontSize="xs" color="whiteAlpha.800">至</Text>
|
||
<Input
|
||
type="date"
|
||
value={customEndDate}
|
||
onChange={(e) => setCustomEndDate(e.target.value)}
|
||
size="xs"
|
||
bg="whiteAlpha.200"
|
||
color="white"
|
||
border="1px solid"
|
||
borderColor="whiteAlpha.300"
|
||
_hover={{ borderColor: 'whiteAlpha.400' }}
|
||
_focus={{ borderColor: 'whiteAlpha.500', boxShadow: 'none' }}
|
||
w="auto"
|
||
maxW="130px"
|
||
sx={{
|
||
'&::-webkit-calendar-picker-indicator': {
|
||
filter: 'invert(1)',
|
||
}
|
||
}}
|
||
/>
|
||
<Button
|
||
size="xs"
|
||
bg="whiteAlpha.300"
|
||
color="white"
|
||
_hover={{ bg: 'whiteAlpha.400' }}
|
||
onClick={applyCustomRange}
|
||
borderRadius="md"
|
||
px={3}
|
||
>
|
||
应用
|
||
</Button>
|
||
</Flex>
|
||
)}
|
||
</VStack>
|
||
</Box>
|
||
|
||
{/* 主内容卡片 */}
|
||
<Box bg={bg} borderRadius="xl" border="1px" borderColor={borderColor} shadow="sm" overflow="hidden">
|
||
<Tabs index={activeTab} onChange={setActiveTab} variant="unstyled" size="sm">
|
||
<TabList
|
||
bg="gray.50"
|
||
borderBottom="1px"
|
||
borderColor={borderColor}
|
||
overflowX="auto"
|
||
overflowY="hidden"
|
||
flexWrap="nowrap"
|
||
css={{
|
||
'&::-webkit-scrollbar': {
|
||
display: 'none',
|
||
},
|
||
}}
|
||
>
|
||
{tabsData.map((tab, index) => (
|
||
<Tab
|
||
key={index}
|
||
minW="fit-content"
|
||
fontSize="xs"
|
||
px={3}
|
||
py={3}
|
||
whiteSpace="nowrap"
|
||
_selected={{
|
||
bg: `${tab.color}.500`,
|
||
color: 'white',
|
||
borderRadius: '0',
|
||
position: 'relative',
|
||
_after: {
|
||
content: '""',
|
||
position: 'absolute',
|
||
bottom: '-1px',
|
||
left: '0',
|
||
right: '0',
|
||
height: '2px',
|
||
bg: `${tab.color}.500`,
|
||
}
|
||
}}
|
||
_hover={{ bg: `${tab.color}.50` }}
|
||
transition="all 0.2s"
|
||
>
|
||
<HStack spacing={1}>
|
||
<Icon as={tab.icon} boxSize={3} />
|
||
<Text fontWeight="medium">{tab.label}</Text>
|
||
</HStack>
|
||
</Tab>
|
||
))}
|
||
</TabList>
|
||
|
||
<TabPanels>
|
||
{tabsData.map((tab, index) => (
|
||
<TabPanel key={index} px={0} py={0}>
|
||
<StatsCard
|
||
title={tab.label}
|
||
icon={tab.icon}
|
||
color={tab.color}
|
||
data={tab.data}
|
||
renderItem={tab.renderItem}
|
||
isLoading={loading}
|
||
/>
|
||
</TabPanel>
|
||
))}
|
||
</TabPanels>
|
||
</Tabs>
|
||
</Box>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export default ConceptStatsPanel; |