横向布局
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user