- 新增 isOpen, onToggle props 支持外部控制展开状态(受控模式) - 添加 hasNoConcepts 判断,优化空数据处理逻辑 - 改进精简模式和详细模式的空状态显示 - 增强点击处理逻辑,支持受控/非受控两种模式 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
306 lines
10 KiB
JavaScript
306 lines
10 KiB
JavaScript
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/index.js
|
||
// 相关概念区组件(主组件)
|
||
|
||
import React, { useState, useEffect } from 'react';
|
||
import {
|
||
Box,
|
||
SimpleGrid,
|
||
Flex,
|
||
Button,
|
||
Collapse,
|
||
Heading,
|
||
Center,
|
||
Spinner,
|
||
Text,
|
||
useColorModeValue,
|
||
} from '@chakra-ui/react';
|
||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import moment from 'moment';
|
||
import SimpleConceptCard from './SimpleConceptCard';
|
||
import DetailedConceptCard from './DetailedConceptCard';
|
||
import TradingDateInfo from './TradingDateInfo';
|
||
import { logger } from '../../../../../utils/logger';
|
||
|
||
/**
|
||
* 相关概念区组件
|
||
* @param {Object} props
|
||
* @param {string} props.eventTitle - 事件标题(用于搜索概念)
|
||
* @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期)
|
||
* @param {string|Object} props.eventTime - 事件发生时间
|
||
* @param {React.ReactNode} props.subscriptionBadge - 订阅徽章组件(可选)
|
||
* @param {boolean} props.isLocked - 是否锁定详细模式(需要付费)
|
||
* @param {Function} props.onLockedClick - 锁定时的点击回调(触发付费弹窗)
|
||
*/
|
||
const RelatedConceptsSection = ({
|
||
eventTitle,
|
||
effectiveTradingDate,
|
||
eventTime,
|
||
subscriptionBadge = null,
|
||
isLocked = false,
|
||
onLockedClick = null,
|
||
isOpen = undefined, // 新增:受控模式(外部控制展开状态)
|
||
onToggle = undefined // 新增:受控模式(外部控制展开回调)
|
||
}) => {
|
||
// 使用外部 isOpen,如果没有则使用内部 useState
|
||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||
const isExpanded = onToggle !== undefined ? isOpen : internalExpanded;
|
||
const [concepts, setConcepts] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
const navigate = useNavigate();
|
||
|
||
// 颜色配置
|
||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||
|
||
console.log('[RelatedConceptsSection] 组件渲染', {
|
||
eventTitle,
|
||
effectiveTradingDate,
|
||
eventTime,
|
||
loading,
|
||
conceptsCount: concepts?.length || 0,
|
||
error
|
||
});
|
||
|
||
// 搜索相关概念
|
||
useEffect(() => {
|
||
const searchConcepts = async () => {
|
||
console.log('[RelatedConceptsSection] useEffect 触发', {
|
||
eventTitle,
|
||
effectiveTradingDate
|
||
});
|
||
|
||
if (!eventTitle || !effectiveTradingDate) {
|
||
console.log('[RelatedConceptsSection] 缺少必要参数,跳过搜索', {
|
||
hasEventTitle: !!eventTitle,
|
||
hasEffectiveTradingDate: !!effectiveTradingDate
|
||
});
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
// 格式化交易日期 - 统一使用 moment 处理
|
||
let formattedTradeDate;
|
||
try {
|
||
// 不管传入的是什么格式,都用 moment 解析并格式化为 YYYY-MM-DD
|
||
formattedTradeDate = moment(effectiveTradingDate).format('YYYY-MM-DD');
|
||
|
||
// 验证日期是否有效
|
||
if (!moment(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) {
|
||
console.warn('[RelatedConceptsSection] 无效日期,使用当前日期');
|
||
formattedTradeDate = moment().format('YYYY-MM-DD');
|
||
}
|
||
} catch (error) {
|
||
console.warn('[RelatedConceptsSection] 日期格式化失败,使用当前日期', error);
|
||
formattedTradeDate = moment().format('YYYY-MM-DD');
|
||
}
|
||
|
||
const requestBody = {
|
||
query: eventTitle,
|
||
size: 5,
|
||
page: 1,
|
||
sort_by: "_score",
|
||
trade_date: formattedTradeDate
|
||
};
|
||
|
||
console.log('[RelatedConceptsSection] 发送请求', {
|
||
url: '/concept-api/search',
|
||
requestBody
|
||
});
|
||
logger.debug('RelatedConceptsSection', '搜索概念', requestBody);
|
||
|
||
const response = await fetch('/concept-api/search', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(requestBody)
|
||
});
|
||
|
||
console.log('[RelatedConceptsSection] 响应状态', {
|
||
ok: response.ok,
|
||
status: response.status,
|
||
statusText: response.statusText
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
console.log('[RelatedConceptsSection] 响应数据', {
|
||
hasResults: !!data.results,
|
||
resultsCount: data.results?.length || 0,
|
||
hasDataConcepts: !!(data.data && data.data.concepts),
|
||
data: data
|
||
});
|
||
logger.debug('RelatedConceptsSection', '概念搜索响应', {
|
||
hasResults: !!data.results,
|
||
resultsCount: data.results?.length || 0
|
||
});
|
||
|
||
// 设置概念数据
|
||
if (data.results && Array.isArray(data.results)) {
|
||
console.log('[RelatedConceptsSection] 设置概念数据 (results)', data.results);
|
||
setConcepts(data.results);
|
||
} else if (data.data && data.data.concepts) {
|
||
// 向后兼容
|
||
console.log('[RelatedConceptsSection] 设置概念数据 (data.concepts)', data.data.concepts);
|
||
setConcepts(data.data.concepts);
|
||
} else {
|
||
console.log('[RelatedConceptsSection] 没有找到概念数据,设置为空数组');
|
||
setConcepts([]);
|
||
}
|
||
} catch (err) {
|
||
console.error('[RelatedConceptsSection] 搜索概念失败', err);
|
||
logger.error('RelatedConceptsSection', 'searchConcepts', err);
|
||
setError('加载概念数据失败');
|
||
setConcepts([]);
|
||
} finally {
|
||
console.log('[RelatedConceptsSection] 加载完成');
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
searchConcepts();
|
||
}, [eventTitle, effectiveTradingDate]);
|
||
|
||
// 加载中状态
|
||
if (loading) {
|
||
return (
|
||
<Box bg={sectionBg} p={3} borderRadius="md">
|
||
<Center py={4}>
|
||
<Spinner size="md" color="blue.500" mr={2} />
|
||
<Text color={textColor} fontSize="sm">加载相关概念中...</Text>
|
||
</Center>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// 判断是否有数据
|
||
const hasNoConcepts = !concepts || concepts.length === 0;
|
||
|
||
/**
|
||
* 根据相关度获取颜色(浅色背景 + 深色文字)
|
||
* @param {number} relevance - 相关度(0-100)
|
||
* @returns {Object} 包含背景色和文字色
|
||
*/
|
||
const getRelevanceColor = (relevance) => {
|
||
if (relevance >= 90) {
|
||
return { bg: 'purple.50', color: 'purple.800' }; // 极高相关
|
||
} else if (relevance >= 80) {
|
||
return { bg: 'pink.50', color: 'pink.800' }; // 高相关
|
||
} else if (relevance >= 70) {
|
||
return { bg: 'orange.50', color: 'orange.800' }; // 中等相关
|
||
} else {
|
||
return { bg: 'gray.100', color: 'gray.700' }; // 低相关
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 处理概念点击
|
||
* @param {Object} concept - 概念对象
|
||
*/
|
||
const handleConceptClick = (concept) => {
|
||
// 跳转到概念中心,并搜索该概念
|
||
navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`);
|
||
};
|
||
|
||
return (
|
||
<Box bg={sectionBg} p={3} borderRadius="md">
|
||
{/* 标题栏 - 两行布局 */}
|
||
<Box mb={3}>
|
||
{/* 第一行:标题 + Badge + 按钮 */}
|
||
<Flex justify="space-between" align="center" mb={2}>
|
||
<Flex align="center" gap={2}>
|
||
<Heading size="sm" color={headingColor}>
|
||
相关概念
|
||
</Heading>
|
||
{/* 订阅徽章 */}
|
||
{subscriptionBadge}
|
||
</Flex>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
colorScheme="blue"
|
||
rightIcon={isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||
onClick={() => {
|
||
// 如果被锁定且有回调函数,触发付费弹窗
|
||
if (isLocked && onLockedClick) {
|
||
onLockedClick();
|
||
} else if (onToggle !== undefined) {
|
||
// 受控模式:调用外部回调
|
||
onToggle();
|
||
} else {
|
||
// 非受控模式:使用内部状态
|
||
setInternalExpanded(!internalExpanded);
|
||
}
|
||
}}
|
||
>
|
||
{isExpanded ? '收起' : '查看详细描述'}
|
||
</Button>
|
||
</Flex>
|
||
{/* 第二行:交易日期信息 */}
|
||
<TradingDateInfo
|
||
effectiveTradingDate={effectiveTradingDate}
|
||
eventTime={eventTime}
|
||
/>
|
||
</Box>
|
||
|
||
{/* 简单模式:横向卡片列表(总是显示) */}
|
||
{hasNoConcepts ? (
|
||
<Box mb={isExpanded ? 3 : 0}>
|
||
{error ? (
|
||
<Text color="red.500" fontSize="sm">{error}</Text>
|
||
) : (
|
||
<Text color={textColor} fontSize="sm">暂无相关概念数据</Text>
|
||
)}
|
||
</Box>
|
||
) : (
|
||
<Flex gap={2} flexWrap="wrap" mb={isExpanded ? 3 : 0}>
|
||
{concepts.map((concept, index) => (
|
||
<SimpleConceptCard
|
||
key={index}
|
||
concept={concept}
|
||
onClick={handleConceptClick}
|
||
getRelevanceColor={getRelevanceColor}
|
||
/>
|
||
))}
|
||
</Flex>
|
||
)}
|
||
|
||
{/* 详细模式:卡片网格(可折叠) */}
|
||
<Collapse in={isExpanded} animateOpacity>
|
||
{hasNoConcepts ? (
|
||
<Box py={4}>
|
||
{error ? (
|
||
<Text color="red.500" fontSize="sm" textAlign="center">{error}</Text>
|
||
) : (
|
||
<Text color={textColor} fontSize="sm" textAlign="center">暂无详细数据</Text>
|
||
)}
|
||
</Box>
|
||
) : (
|
||
/* 详细概念卡片网格 */
|
||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||
{concepts.map((concept, index) => (
|
||
<DetailedConceptCard
|
||
key={index}
|
||
concept={concept}
|
||
onClick={handleConceptClick}
|
||
/>
|
||
))}
|
||
</SimpleGrid>
|
||
)}
|
||
</Collapse>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export default RelatedConceptsSection;
|