横向布局

This commit is contained in:
2026-01-05 08:18:48 +08:00
parent 22daf4ad39
commit 1c6bdc31cb

View File

@@ -1,35 +1,79 @@
// src/components/EventDetailPanel/RelatedConceptsSection/index.js
// 相关概念区组件 - 便当盒网格布局
// 相关概念区组件 - 增强版,展示涨跌幅、股票数量等详细信息
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { useSelector } from 'react-redux';
import {
Box,
Flex,
Heading,
Center,
Spinner,
Text,
Badge,
SimpleGrid,
VStack,
HStack,
Icon,
Tooltip,
useColorModeValue,
Skeleton,
} from '@chakra-ui/react';
import { useNavigate } from 'react-router-dom';
import { TrendingUp, TrendingDown, BarChart2, Layers } from 'lucide-react';
import { logger } from '@utils/logger';
import { getApiBase } from '@utils/apiConfig';
import { selectSubscriptionInfo } from '@store/slices/subscriptionSlice';
// 获取 concept-api 基础 URL
const getConceptApiBase = () => {
return process.env.NODE_ENV === 'production'
? `${getApiBase()}/concept-api`
: 'http://111.198.58.126:16801';
};
/**
* 单个概念卡片组件(便当盒样式)
* 格式化涨跌幅
*/
const ConceptCard = ({ concept, onNavigate, isLocked, onLockedClick }) => {
// 深色主题固定颜色
const cardBg = 'rgba(252, 129, 129, 0.15)'; // 浅红色背景
const cardHoverBg = 'rgba(252, 129, 129, 0.25)';
const borderColor = 'rgba(252, 129, 129, 0.3)';
const conceptColor = '#fc8181'; // 红色文字(与股票涨色一致)
const formatChange = (value) => {
if (value === null || value === undefined) return '--';
const formatted = Math.abs(value).toFixed(2);
return value > 0 ? `+${formatted}%` : value < 0 ? `-${formatted}%` : '0.00%';
};
/**
* 获取涨跌幅颜色
*/
const getChangeColor = (value) => {
if (value === null || value === undefined) return 'gray.400';
if (value > 0) return 'red.400';
if (value < 0) return 'green.400';
return 'gray.400';
};
/**
* 获取涨跌幅背景色
*/
const getChangeBg = (value) => {
if (value === null || value === undefined) return 'whiteAlpha.100';
if (value > 0) return 'rgba(252, 129, 129, 0.15)';
if (value < 0) return 'rgba(72, 187, 120, 0.15)';
return 'whiteAlpha.100';
};
/**
* 单个概念卡片组件 - 增强版
*/
const EnhancedConceptCard = ({ concept, onNavigate, isLocked, onLockedClick, index }) => {
const changePct = concept.price_info?.avg_change_pct;
const stockCount = concept.stock_count || 0;
const hierarchy = concept.hierarchy;
// 根据涨跌决定边框和背景色
const borderColor = changePct > 0
? 'rgba(252, 129, 129, 0.4)'
: changePct < 0
? 'rgba(72, 187, 120, 0.4)'
: 'whiteAlpha.200';
const cardBg = getChangeBg(changePct);
const handleClick = () => {
if (isLocked && onLockedClick) {
@@ -40,49 +84,153 @@ const ConceptCard = ({ concept, onNavigate, isLocked, onLockedClick }) => {
};
return (
<Tooltip
label={concept.reason || concept.concept}
placement="top"
hasArrow
bg="gray.800"
color="white"
p={2}
borderRadius="md"
maxW="300px"
fontSize="xs"
<Box
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="lg"
p={3}
cursor="pointer"
onClick={handleClick}
_hover={{
transform: 'translateY(-2px)',
boxShadow: 'lg',
borderColor: changePct > 0 ? 'red.400' : changePct < 0 ? 'green.400' : 'blue.400',
}}
transition="all 0.2s ease"
position="relative"
>
<Box
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="lg"
px={3}
py={2}
cursor="pointer"
onClick={handleClick}
_hover={{
bg: cardHoverBg,
transform: 'translateY(-1px)',
boxShadow: 'sm',
}}
transition="all 0.15s ease"
textAlign="center"
>
<Text
fontSize="sm"
fontWeight="semibold"
color={conceptColor}
noOfLines={1}
{/* 排名徽章前3名 */}
{index < 3 && (
<Badge
position="absolute"
top="-8px"
left="-8px"
bg={index === 0 ? 'yellow.500' : index === 1 ? 'orange.400' : 'red.400'}
color="white"
borderRadius="full"
w="20px"
h="20px"
display="flex"
alignItems="center"
justifyContent="center"
fontSize="xs"
fontWeight="bold"
>
{concept.concept}
</Text>
</Box>
</Tooltip>
{index + 1}
</Badge>
)}
<VStack align="stretch" spacing={2}>
{/* 概念名称 */}
<Tooltip
label={concept.description || concept.reason || concept.concept}
placement="top"
hasArrow
bg="gray.800"
color="white"
p={2}
borderRadius="md"
maxW="300px"
fontSize="xs"
>
<Text
fontSize="sm"
fontWeight="bold"
color="white"
noOfLines={1}
>
{concept.concept}
</Text>
</Tooltip>
{/* 涨跌幅 */}
<HStack justify="space-between" align="center">
<HStack spacing={1}>
<Icon
as={changePct > 0 ? TrendingUp : changePct < 0 ? TrendingDown : BarChart2}
boxSize={3}
color={getChangeColor(changePct)}
/>
<Text
fontSize="md"
fontWeight="bold"
color={getChangeColor(changePct)}
>
{formatChange(changePct)}
</Text>
</HStack>
{/* 股票数量 */}
<HStack spacing={1}>
<Icon as={BarChart2} boxSize={2.5} color="whiteAlpha.600" />
<Text fontSize="xs" color="whiteAlpha.600">
{stockCount}
</Text>
</HStack>
</HStack>
{/* 层级标签 */}
{hierarchy && (
<HStack spacing={1} flexWrap="wrap">
{hierarchy.lv1 && (
<Badge
bg="whiteAlpha.200"
color="whiteAlpha.800"
fontSize="10px"
px={1.5}
py={0.5}
borderRadius="sm"
>
{hierarchy.lv1}
</Badge>
)}
{hierarchy.lv2 && (
<Badge
bg="whiteAlpha.100"
color="whiteAlpha.700"
fontSize="10px"
px={1.5}
py={0.5}
borderRadius="sm"
>
{hierarchy.lv2}
</Badge>
)}
</HStack>
)}
</VStack>
</Box>
);
};
/**
* 相关概念区组件
* 骨架屏卡片
*/
const SkeletonCard = () => (
<Box
bg="whiteAlpha.100"
borderWidth="1px"
borderColor="whiteAlpha.200"
borderRadius="lg"
p={3}
>
<VStack align="stretch" spacing={2}>
<Skeleton height="16px" width="80%" startColor="whiteAlpha.100" endColor="whiteAlpha.200" />
<HStack justify="space-between">
<Skeleton height="20px" width="60px" startColor="whiteAlpha.100" endColor="whiteAlpha.200" />
<Skeleton height="14px" width="40px" startColor="whiteAlpha.100" endColor="whiteAlpha.200" />
</HStack>
<HStack>
<Skeleton height="16px" width="40px" startColor="whiteAlpha.100" endColor="whiteAlpha.200" />
<Skeleton height="16px" width="50px" startColor="whiteAlpha.100" endColor="whiteAlpha.200" />
</HStack>
</VStack>
</Box>
);
/**
* 相关概念区组件 - 增强版
* @param {Object} props
* @param {number} props.eventId - 事件ID用于获取 related_concepts 表数据)
* @param {string} props.eventTitle - 事件标题(备用)
@@ -99,6 +247,7 @@ const RelatedConceptsSection = ({
}) => {
const [concepts, setConcepts] = useState([]);
const [loading, setLoading] = useState(true);
const [enriching, setEnriching] = useState(false);
const [error, setError] = useState(null);
const navigate = useNavigate();
@@ -106,14 +255,82 @@ const RelatedConceptsSection = ({
const subscriptionInfo = useSelector(selectSubscriptionInfo);
const isSubscriptionExpired = subscriptionInfo.type !== 'free' && !subscriptionInfo.is_active;
// 颜色配置 - 使用深色主题固定颜色
const sectionBg = 'transparent';
const headingColor = '#e2e8f0';
// 颜色配置
const textColor = '#a0aec0';
const countBadgeBg = '#3182ce';
const countBadgeColor = '#ffffff';
// 获取相关概念 - 如果被锁定或会员过期则跳过 API 请求
// 从 concept-api 获取概念详情
const enrichConceptsWithDetails = useCallback(async (basicConcepts) => {
if (!basicConcepts || basicConcepts.length === 0) return [];
setEnriching(true);
try {
const conceptApiBase = getConceptApiBase();
const conceptNames = basicConcepts.map(c => c.concept);
// 为每个概念调用 search API 获取详情
const enrichedConcepts = await Promise.all(
conceptNames.map(async (conceptName) => {
try {
const response = await fetch(`${conceptApiBase}/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: conceptName,
size: 1,
page: 1,
sort_by: '_score'
})
});
if (response.ok) {
const data = await response.json();
if (data.results && data.results.length > 0) {
const result = data.results[0];
// 确保名称精确匹配
if (result.concept === conceptName) {
return {
...result,
reason: basicConcepts.find(c => c.concept === conceptName)?.reason
};
}
}
}
} catch (err) {
logger.warn('RelatedConceptsSection', `获取概念 ${conceptName} 详情失败`, err);
}
// 如果获取失败,返回基础数据
const basicConcept = basicConcepts.find(c => c.concept === conceptName);
return {
concept: conceptName,
concept_id: basicConcept?.id,
reason: basicConcept?.reason,
stock_count: 0,
price_info: null,
hierarchy: null
};
})
);
// 按涨跌幅排序(有数据的排前面)
return enrichedConcepts.sort((a, b) => {
const aChange = a.price_info?.avg_change_pct;
const bChange = b.price_info?.avg_change_pct;
if (aChange === null || aChange === undefined) return 1;
if (bChange === null || bChange === undefined) return -1;
return bChange - aChange;
});
} catch (err) {
logger.error('RelatedConceptsSection', 'enrichConceptsWithDetails', err);
return basicConcepts;
} finally {
setEnriching(false);
}
}, []);
// 获取相关概念
useEffect(() => {
const fetchConcepts = async () => {
if (!eventId) {
@@ -132,6 +349,7 @@ const RelatedConceptsSection = ({
setLoading(true);
setError(null);
// 1. 先获取事件关联的概念名称列表
const apiUrl = `${getApiBase()}/api/events/${eventId}/concepts`;
const response = await fetch(apiUrl, {
method: 'GET',
@@ -149,8 +367,14 @@ const RelatedConceptsSection = ({
}
const data = await response.json();
if (data.success && Array.isArray(data.data)) {
if (data.success && Array.isArray(data.data) && data.data.length > 0) {
// 先显示基础数据
setConcepts(data.data);
setLoading(false);
// 2. 异步获取详细数据(涨跌幅、股票数量等)
const enrichedConcepts = await enrichConceptsWithDetails(data.data);
setConcepts(enrichedConcepts);
} else {
setConcepts([]);
}
@@ -165,7 +389,7 @@ const RelatedConceptsSection = ({
};
fetchConcepts();
}, [eventId, isLocked, isSubscriptionExpired]);
}, [eventId, isLocked, isSubscriptionExpired, enrichConceptsWithDetails]);
// 跳转到概念中心
const handleNavigate = (concept) => {
@@ -175,11 +399,24 @@ const RelatedConceptsSection = ({
// 加载中状态
if (loading) {
return (
<Box bg={sectionBg} p={3} borderRadius="md">
<Center py={4}>
<Spinner size="sm" color="blue.500" mr={2} />
<Text color={textColor} fontSize="sm">加载相关概念中...</Text>
</Center>
<Box p={3} borderRadius="md">
<Flex justify="space-between" align="center" mb={3}>
<HStack spacing={2}>
<Heading size="sm" color="#e2e8f0">
相关概念
</Heading>
{subscriptionBadge}
</HStack>
</Flex>
<Box
display="grid"
gridTemplateColumns={{ base: 'repeat(2, 1fr)', md: 'repeat(3, 1fr)' }}
gap={3}
>
{[1, 2, 3, 4, 5, 6].map((i) => (
<SkeletonCard key={i} />
))}
</Box>
</Box>
);
}
@@ -187,17 +424,18 @@ const RelatedConceptsSection = ({
const hasNoConcepts = !concepts || concepts.length === 0;
return (
<Box bg={sectionBg} p={3} borderRadius="md">
<Box p={3} borderRadius="md">
{/* 标题栏 */}
<Flex justify="space-between" align="center" mb={3}>
<HStack spacing={2}>
<Heading size="sm" color={headingColor}>
<Icon as={Layers} color="blue.400" boxSize={4} />
<Heading size="sm" color="#e2e8f0">
相关概念
</Heading>
{!hasNoConcepts && (
<Badge
bg={countBadgeBg}
color={countBadgeColor}
bg="blue.500"
color="white"
fontSize="xs"
px={2}
py={0.5}
@@ -206,11 +444,14 @@ const RelatedConceptsSection = ({
{concepts.length}
</Badge>
)}
{enriching && (
<Spinner size="xs" color="blue.400" />
)}
{subscriptionBadge}
</HStack>
</Flex>
{/* 概念列表 - 便当盒网格布局 */}
{/* 概念列表 - 网格布局 */}
{hasNoConcepts ? (
<Box py={2}>
{error ? (
@@ -220,17 +461,22 @@ const RelatedConceptsSection = ({
)}
</Box>
) : (
<SimpleGrid columns={{ base: 2, sm: 3, md: 4 }} spacing={2}>
<Box
display="grid"
gridTemplateColumns={{ base: 'repeat(2, 1fr)', md: 'repeat(3, 1fr)' }}
gap={3}
>
{concepts.map((concept, index) => (
<ConceptCard
key={concept.id || index}
<EnhancedConceptCard
key={concept.concept_id || concept.id || index}
concept={concept}
onNavigate={handleNavigate}
isLocked={isLocked}
onLockedClick={onLockedClick}
index={index}
/>
))}
</SimpleGrid>
</Box>
)}
</Box>
);