community增加事件详情

This commit is contained in:
2026-01-06 19:01:17 +08:00
parent 6fde1b90ba
commit ce0c7a6177

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/ConceptSector/index.js // src/views/Company/components/ConceptSector/index.js
// 个股详情页 - 概念板块 Tab 组件 // 个股详情页 - 概念板块 Tab 组件(黑金主题)
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, memo } from 'react';
import { import {
Box, Box,
VStack, VStack,
@@ -13,8 +13,6 @@ import {
CardBody, CardBody,
Skeleton, Skeleton,
SkeletonText, SkeletonText,
Alert,
AlertIcon,
Divider, Divider,
Button, Button,
Tooltip, Tooltip,
@@ -22,10 +20,11 @@ import {
TagLabel, TagLabel,
Wrap, Wrap,
WrapItem, WrapItem,
useColorModeValue,
Icon, Icon,
Flex, Flex,
Spacer, Spacer,
Spinner,
Center,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { import {
Layers, Layers,
@@ -35,12 +34,42 @@ import {
Calendar, Calendar,
Hash, Hash,
Sparkles, Sparkles,
AlertCircle,
RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { logger } from '@utils/logger'; import { logger } from '@utils/logger';
import { getApiBase } from '@utils/apiConfig'; import { getApiBase } from '@utils/apiConfig';
import { getConceptHtmlUrl } from '@utils/textUtils'; import { getConceptHtmlUrl } from '@utils/textUtils';
// ============================================
// 黑金主题配置(与其他 Tab 保持一致)
// ============================================
const THEME = {
bg: '#0A0E17',
cardBg: '#1A1F2E',
cardHoverBg: '#212633',
cardBorder: 'rgba(212, 175, 55, 0.2)',
cardHoverBorder: '#F4D03F',
textPrimary: '#E8E9ED',
textSecondary: '#A0A4B8',
textMuted: '#6B7280',
gold: '#F4D03F',
goldLight: '#FFD54F',
// 涨跌色
positive: '#EF4444',
negativE: '#22C55E',
positiveBg: 'rgba(239, 68, 68, 0.15)',
negativeBg: 'rgba(34, 197, 94, 0.15)',
// 标签色
tagBg: 'rgba(212, 175, 55, 0.15)',
tagColor: '#F4D03F',
// 按钮
buttonBg: '#D4AF37',
buttonText: '#0A0E17',
buttonHoverBg: '#FFD54F',
};
// API 配置 // API 配置
const API_BASE_URL = const API_BASE_URL =
process.env.NODE_ENV === 'production' process.env.NODE_ENV === 'production'
@@ -48,182 +77,234 @@ const API_BASE_URL =
: 'http://111.198.58.126:16801'; : 'http://111.198.58.126:16801';
/** /**
* 单个概念卡片组件 * 单个概念卡片组件(黑金主题)
*/ */
const ConceptCard = ({ concept, onClick }) => { const ConceptCard = memo(({ concept, onClick }) => {
const cardBg = useColorModeValue('white', 'rgba(26, 32, 44, 0.8)'); // 涨跌幅
const borderColor = useColorModeValue('gray.200', 'rgba(255, 195, 0, 0.2)');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
// 涨跌幅颜色
const changePct = concept.price_info?.avg_change_pct; const changePct = concept.price_info?.avg_change_pct;
const hasChange = changePct !== null && changePct !== undefined; const hasChange = changePct !== null && changePct !== undefined;
const isPositive = changePct > 0; const isPositive = changePct > 0;
const changeColor = isPositive ? 'red.500' : changePct < 0 ? 'green.500' : 'gray.500'; const isNegative = changePct < 0;
const changeBgColor = isPositive
? 'rgba(239, 68, 68, 0.1)'
: changePct < 0
? 'rgba(34, 197, 94, 0.1)'
: 'rgba(128, 128, 128, 0.1)';
// 层级信息 // 层级路径
const hierarchy = concept.hierarchy; const hierarchy = concept.hierarchy;
const hierarchyPath = hierarchy const hierarchyPath = hierarchy
? [hierarchy.lv1, hierarchy.lv2, hierarchy.lv3].filter(Boolean).join(' > ') ? [hierarchy.lv1, hierarchy.lv2, hierarchy.lv3].filter(Boolean).join(' > ')
: null; : null;
return ( return (
<Card <Box
bg={cardBg} bg={THEME.cardBg}
borderWidth="1px" borderWidth="1px"
borderColor={borderColor} borderColor={THEME.cardBorder}
borderRadius="xl" borderRadius="lg"
overflow="hidden" p={4}
cursor="pointer" cursor="pointer"
transition="all 0.3s" transition="all 0.2s ease"
_hover={{ _hover={{
transform: 'translateY(-4px)', bg: THEME.cardHoverBg,
boxShadow: '0 12px 24px rgba(0, 0, 0, 0.15)', borderColor: THEME.cardHoverBorder,
borderColor: 'blue.400', transform: 'translateY(-2px)',
boxShadow: `0 4px 20px rgba(212, 175, 55, 0.15)`,
}} }}
onClick={onClick} onClick={onClick}
> >
<CardBody p={4}> <VStack align="stretch" spacing={3}>
<VStack align="stretch" spacing={3}> {/* 头部:概念名称 + 涨跌幅 */}
{/* 头部:概念名称 + 涨跌幅 */} <HStack justify="space-between" align="flex-start">
<HStack justify="space-between" align="flex-start"> <VStack align="start" spacing={1} flex={1} minW={0}>
<VStack align="start" spacing={1} flex={1}> <HStack spacing={2}>
<HStack spacing={2}> <Icon as={Layers} boxSize={4} color={THEME.gold} />
<Icon as={Layers} boxSize={4} color="blue.400" /> <Text
<Text fontSize="md" fontWeight="bold" color="blue.400" noOfLines={1}> fontSize="md"
{concept.concept} fontWeight="bold"
</Text> color={THEME.gold}
</HStack> noOfLines={1}
{hierarchyPath && ( title={concept.concept}
<Text fontSize="xs" color={textSecondary} noOfLines={1}>
{hierarchyPath}
</Text>
)}
</VStack>
{hasChange && (
<Box
bg={changeBgColor}
px={3}
py={1}
borderRadius="md"
minW="70px"
textAlign="center"
> >
<HStack spacing={1} justify="center"> {concept.concept}
<Icon </Text>
as={isPositive ? TrendingUp : TrendingDown}
boxSize={3}
color={changeColor}
/>
<Text fontSize="sm" fontWeight="bold" color={changeColor}>
{isPositive ? '+' : ''}
{changePct.toFixed(2)}%
</Text>
</HStack>
</Box>
)}
</HStack>
{/* 描述 */}
{concept.description && (
<Text fontSize="sm" color={textSecondary} noOfLines={2} lineHeight="1.6">
{concept.description}
</Text>
)}
{/* 标签 */}
{concept.tags && concept.tags.length > 0 && (
<Wrap spacing={1}>
{concept.tags.slice(0, 4).map((tag, idx) => (
<WrapItem key={idx}>
<Tag size="sm" variant="subtle" colorScheme="purple" borderRadius="full">
<TagLabel fontSize="xs">{tag}</TagLabel>
</Tag>
</WrapItem>
))}
{concept.tags.length > 4 && (
<WrapItem>
<Tag size="sm" variant="outline" colorScheme="gray" borderRadius="full">
<TagLabel fontSize="xs">+{concept.tags.length - 4}</TagLabel>
</Tag>
</WrapItem>
)}
</Wrap>
)}
<Divider />
{/* 底部信息 */}
<HStack justify="space-between" fontSize="xs" color={textSecondary}>
<HStack spacing={1}>
<Icon as={Hash} boxSize={3} />
<Text>{concept.stock_count || 0} 只成分股</Text>
</HStack> </HStack>
{concept.price_info?.trade_date && ( {hierarchyPath && (
<HStack spacing={1}> <Text fontSize="xs" color={THEME.textMuted} noOfLines={1}>
<Icon as={Calendar} boxSize={3} /> {hierarchyPath}
<Text>{concept.price_info.trade_date}</Text> </Text>
</HStack>
)} )}
</VStack>
{hasChange && (
<Box
bg={isPositive ? THEME.positiveBg : isNegative ? THEME.negativeBg : 'rgba(107, 114, 128, 0.15)'}
px={3}
py={1.5}
borderRadius="md"
minW="75px"
textAlign="center"
>
<HStack spacing={1} justify="center">
<Icon
as={isPositive ? TrendingUp : TrendingDown}
boxSize={3.5}
color={isPositive ? THEME.positive : isNegative ? '#22C55E' : THEME.textMuted}
/>
<Text
fontSize="sm"
fontWeight="bold"
color={isPositive ? THEME.positive : isNegative ? '#22C55E' : THEME.textMuted}
>
{isPositive ? '+' : ''}
{changePct.toFixed(2)}%
</Text>
</HStack>
</Box>
)}
</HStack>
{/* 描述 */}
{concept.description && (
<Text fontSize="sm" color={THEME.textSecondary} noOfLines={2} lineHeight="1.6">
{concept.description}
</Text>
)}
{/* 标签 */}
{concept.tags && concept.tags.length > 0 && (
<Wrap spacing={1.5}>
{concept.tags.slice(0, 4).map((tag, idx) => (
<WrapItem key={idx}>
<Tag
size="sm"
bg={THEME.tagBg}
color={THEME.tagColor}
borderRadius="full"
px={2}
>
<TagLabel fontSize="xs">{tag}</TagLabel>
</Tag>
</WrapItem>
))}
{concept.tags.length > 4 && (
<WrapItem>
<Tag
size="sm"
bg="rgba(107, 114, 128, 0.15)"
color={THEME.textMuted}
borderRadius="full"
px={2}
>
<TagLabel fontSize="xs">+{concept.tags.length - 4}</TagLabel>
</Tag>
</WrapItem>
)}
</Wrap>
)}
<Divider borderColor={THEME.cardBorder} />
{/* 底部信息 */}
<HStack justify="space-between" fontSize="xs" color={THEME.textMuted}>
<HStack spacing={1}>
<Icon as={Hash} boxSize={3} />
<Text>{concept.stock_count || 0} 只成分股</Text>
</HStack> </HStack>
</VStack> {concept.price_info?.trade_date && (
</CardBody> <HStack spacing={1}>
</Card> <Icon as={Calendar} boxSize={3} />
<Text>{concept.price_info.trade_date}</Text>
</HStack>
)}
</HStack>
</VStack>
</Box>
); );
}; });
ConceptCard.displayName = 'ConceptCard';
/** /**
* 骨架屏加载组件 * 骨架屏加载组件(黑金主题)
*/ */
const ConceptSkeleton = () => ( const ConceptSkeleton = () => (
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}> <SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{[...Array(6)].map((_, i) => ( {[...Array(6)].map((_, i) => (
<Card key={i} borderRadius="xl" overflow="hidden"> <Box
<CardBody p={4}> key={i}
<VStack align="stretch" spacing={3}> bg={THEME.cardBg}
<HStack justify="space-between"> borderWidth="1px"
<Skeleton height="24px" width="120px" /> borderColor={THEME.cardBorder}
<Skeleton height="28px" width="70px" borderRadius="md" /> borderRadius="lg"
</HStack> p={4}
<SkeletonText noOfLines={2} spacing={2} /> >
<HStack spacing={2}> <VStack align="stretch" spacing={3}>
<Skeleton height="20px" width="50px" borderRadius="full" /> <HStack justify="space-between">
<Skeleton height="20px" width="50px" borderRadius="full" /> <Skeleton height="24px" width="120px" startColor="#2D3748" endColor="#4A5568" />
</HStack> <Skeleton height="32px" width="75px" borderRadius="md" startColor="#2D3748" endColor="#4A5568" />
<Divider /> </HStack>
<HStack justify="space-between"> <SkeletonText noOfLines={2} spacing={2} startColor="#2D3748" endColor="#4A5568" />
<Skeleton height="16px" width="80px" /> <HStack spacing={2}>
<Skeleton height="16px" width="80px" /> <Skeleton height="22px" width="50px" borderRadius="full" startColor="#2D3748" endColor="#4A5568" />
</HStack> <Skeleton height="22px" width="50px" borderRadius="full" startColor="#2D3748" endColor="#4A5568" />
</VStack> </HStack>
</CardBody> <Divider borderColor={THEME.cardBorder} />
</Card> <HStack justify="space-between">
<Skeleton height="16px" width="80px" startColor="#2D3748" endColor="#4A5568" />
<Skeleton height="16px" width="80px" startColor="#2D3748" endColor="#4A5568" />
</HStack>
</VStack>
</Box>
))} ))}
</SimpleGrid> </SimpleGrid>
); );
/** /**
* 概念板块组件 * 空状态组件
*/
const EmptyState = () => (
<Center py={12}>
<VStack spacing={3}>
<Icon as={AlertCircle} boxSize={12} color={THEME.textMuted} />
<Text color={THEME.textSecondary} fontSize="md">
暂无关联概念数据
</Text>
</VStack>
</Center>
);
/**
* 错误状态组件
*/
const ErrorState = ({ onRetry }) => (
<Center py={12}>
<VStack spacing={4}>
<Icon as={AlertCircle} boxSize={12} color={THEME.positive} />
<Text color={THEME.textSecondary} fontSize="md">
获取概念数据失败
</Text>
<Button
size="sm"
bg={THEME.buttonBg}
color={THEME.buttonText}
_hover={{ bg: THEME.buttonHoverBg }}
leftIcon={<RefreshCw size={14} />}
onClick={onRetry}
>
重试
</Button>
</VStack>
</Center>
);
/**
* 概念板块组件(黑金主题)
* @param {Object} props * @param {Object} props
* @param {string} props.stockCode - 股票代码 * @param {string} props.stockCode - 股票代码
*/ */
const ConceptSector = ({ stockCode }) => { const ConceptSector = memo(({ stockCode }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [concepts, setConcepts] = useState([]); const [concepts, setConcepts] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const bgColor = useColorModeValue('gray.50', 'transparent');
const headerBg = useColorModeValue('white', 'rgba(26, 32, 44, 0.6)');
const textColor = useColorModeValue('gray.800', 'white');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
/** /**
* 获取股票关联的概念 * 获取股票关联的概念
*/ */
@@ -244,7 +325,7 @@ const ConceptSector = ({ stockCode }) => {
setConcepts(data.concepts || []); setConcepts(data.concepts || []);
} catch (err) { } catch (err) {
logger.error('ConceptSector', 'fetchStockConcepts', err, { stockCode }); logger.error('ConceptSector', 'fetchStockConcepts', err, { stockCode });
setError('获取概念数据失败,请稍后重试'); setError('获取概念数据失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -257,14 +338,10 @@ const ConceptSector = ({ stockCode }) => {
/** /**
* 点击概念跳转 * 点击概念跳转
*/ */
const handleConceptClick = useCallback( const handleConceptClick = useCallback((concept) => {
(concept) => { const url = getConceptHtmlUrl(concept.concept);
// 跳转到概念详情页(外部链接) window.open(url, '_blank');
const url = getConceptHtmlUrl(concept.concept); }, []);
window.open(url, '_blank');
},
[]
);
/** /**
* 跳转到概念中心 * 跳转到概念中心
@@ -273,7 +350,7 @@ const ConceptSector = ({ stockCode }) => {
navigate('/concept'); navigate('/concept');
}, [navigate]); }, [navigate]);
// 按涨跌幅排序概念 // 按涨跌幅排序
const sortedConcepts = [...concepts].sort((a, b) => { const sortedConcepts = [...concepts].sort((a, b) => {
const aChange = a.price_info?.avg_change_pct ?? -Infinity; const aChange = a.price_info?.avg_change_pct ?? -Infinity;
const bChange = b.price_info?.avg_change_pct ?? -Infinity; const bChange = b.price_info?.avg_change_pct ?? -Infinity;
@@ -282,48 +359,53 @@ const ConceptSector = ({ stockCode }) => {
// 统计信息 // 统计信息
const totalConcepts = concepts.length; const totalConcepts = concepts.length;
const positiveConcepts = concepts.filter( const positiveConcepts = concepts.filter((c) => c.price_info?.avg_change_pct > 0).length;
(c) => c.price_info?.avg_change_pct > 0 const negativeConcepts = concepts.filter((c) => c.price_info?.avg_change_pct < 0).length;
).length;
const negativeConcepts = concepts.filter(
(c) => c.price_info?.avg_change_pct < 0
).length;
return ( return (
<Box bg={bgColor} minH="400px" p={{ base: 3, md: 4 }}> <Card bg={THEME.cardBg} shadow="md" borderColor={THEME.cardBorder} borderWidth="1px">
{/* 头部统计信息 */} <CardBody p={4}>
<Card bg={headerBg} borderRadius="xl" mb={4} boxShadow="sm"> <VStack spacing={4} align="stretch">
<CardBody py={3} px={4}> {/* 头部统计信息 */}
<Flex align="center" wrap="wrap" gap={4}> <Flex align="center" wrap="wrap" gap={4} pb={3} borderBottom="1px solid" borderColor={THEME.cardBorder}>
<HStack spacing={3}> <HStack spacing={3}>
<Icon as={Sparkles} boxSize={5} color="yellow.400" /> <Icon as={Sparkles} boxSize={5} color={THEME.gold} />
<Text fontSize="lg" fontWeight="bold" color={textColor}> <Text fontSize="lg" fontWeight="bold" color={THEME.textPrimary}>
概念板块 概念板块
</Text> </Text>
</HStack> </HStack>
<HStack spacing={4} flex={1} justify="center"> <HStack spacing={4} flex={1} justify="center">
<HStack spacing={2}> <HStack spacing={2}>
<Text fontSize="sm" color={textSecondary}> <Text fontSize="sm" color={THEME.textSecondary}>
</Text> </Text>
<Badge colorScheme="blue" fontSize="sm" px={2}> <Badge
bg={THEME.tagBg}
color={THEME.tagColor}
fontSize="sm"
px={2}
borderRadius="md"
>
{totalConcepts} {totalConcepts}
</Badge> </Badge>
<Text fontSize="sm" color={textSecondary}> <Text fontSize="sm" color={THEME.textSecondary}>
个概念 个概念
</Text> </Text>
</HStack> </HStack>
<Divider orientation="vertical" h="20px" />
<Divider orientation="vertical" h="20px" borderColor={THEME.cardBorder} />
<HStack spacing={1}> <HStack spacing={1}>
<Icon as={TrendingUp} boxSize={4} color="red.400" /> <Icon as={TrendingUp} boxSize={4} color={THEME.positive} />
<Text fontSize="sm" color="red.400" fontWeight="medium"> <Text fontSize="sm" color={THEME.positive} fontWeight="medium">
{positiveConcepts} {positiveConcepts}
</Text> </Text>
</HStack> </HStack>
<HStack spacing={1}> <HStack spacing={1}>
<Icon as={TrendingDown} boxSize={4} color="green.400" /> <Icon as={TrendingDown} boxSize={4} color="#22C55E" />
<Text fontSize="sm" color="green.400" fontWeight="medium"> <Text fontSize="sm" color="#22C55E" fontWeight="medium">
{negativeConcepts} {negativeConcepts}
</Text> </Text>
</HStack> </HStack>
@@ -331,11 +413,12 @@ const ConceptSector = ({ stockCode }) => {
<Spacer display={{ base: 'none', md: 'block' }} /> <Spacer display={{ base: 'none', md: 'block' }} />
<Tooltip label="前往概念中心查看更多"> <Tooltip label="前往概念中心查看更多" bg={THEME.cardBg} color={THEME.textPrimary}>
<Button <Button
size="sm" size="sm"
variant="outline" bg={THEME.buttonBg}
colorScheme="blue" color={THEME.buttonText}
_hover={{ bg: THEME.buttonHoverBg }}
rightIcon={<ChevronRight size={16} />} rightIcon={<ChevronRight size={16} />}
onClick={handleGoToConceptCenter} onClick={handleGoToConceptCenter}
> >
@@ -343,38 +426,31 @@ const ConceptSector = ({ stockCode }) => {
</Button> </Button>
</Tooltip> </Tooltip>
</Flex> </Flex>
</CardBody>
</Card>
{/* 内容区域 */} {/* 内容区域 */}
{loading ? ( {loading ? (
<ConceptSkeleton /> <ConceptSkeleton />
) : error ? ( ) : error ? (
<Alert status="error" borderRadius="xl"> <ErrorState onRetry={fetchStockConcepts} />
<AlertIcon /> ) : concepts.length === 0 ? (
{error} <EmptyState />
<Button size="sm" ml={4} onClick={fetchStockConcepts}> ) : (
重试 <SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
</Button> {sortedConcepts.map((concept, index) => (
</Alert> <ConceptCard
) : concepts.length === 0 ? ( key={concept.concept_id || index}
<Alert status="info" borderRadius="xl"> concept={concept}
<AlertIcon /> onClick={() => handleConceptClick(concept)}
暂无关联概念数据 />
</Alert> ))}
) : ( </SimpleGrid>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}> )}
{sortedConcepts.map((concept, index) => ( </VStack>
<ConceptCard </CardBody>
key={concept.concept_id || index} </Card>
concept={concept}
onClick={() => handleConceptClick(concept)}
/>
))}
</SimpleGrid>
)}
</Box>
); );
}; });
ConceptSector.displayName = 'ConceptSector';
export default ConceptSector; export default ConceptSector;