feat: 事件详情权限加上权限校验
This commit is contained in:
97
src/components/SubscriptionBadge.js
Normal file
97
src/components/SubscriptionBadge.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// src/components/SubscriptionBadge.js
|
||||
// 会员专享标签组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Badge,
|
||||
HStack,
|
||||
Icon,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaStar, FaCrown } from 'react-icons/fa';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* 会员专享标签组件
|
||||
* @param {Object} props
|
||||
* @param {'pro' | 'max'} props.tier - 会员等级:pro 或 max
|
||||
* @param {'sm' | 'md'} props.size - 标签尺寸
|
||||
*/
|
||||
const SubscriptionBadge = ({ tier = 'pro', size = 'sm' }) => {
|
||||
// 🔍 调试:SubscriptionBadge 被渲染(强制输出)
|
||||
console.error('🔴 [DEBUG] SubscriptionBadge 组件被渲染 - 这不应该出现(max 会员)', {
|
||||
tier,
|
||||
size,
|
||||
调用位置: new Error().stack
|
||||
});
|
||||
|
||||
// 🔍 调试:SubscriptionBadge 被渲染(logger)
|
||||
logger.debug('SubscriptionBadge', '组件被渲染', {
|
||||
tier,
|
||||
size,
|
||||
调用栈: new Error().stack
|
||||
});
|
||||
|
||||
// PRO 和 MAX 配置
|
||||
const config = {
|
||||
pro: {
|
||||
label: 'PRO专享',
|
||||
icon: FaStar,
|
||||
bgGradient: 'linear(to-r, blue.400, purple.500)',
|
||||
color: 'white',
|
||||
},
|
||||
max: {
|
||||
label: 'MAX专享',
|
||||
icon: FaCrown,
|
||||
bgGradient: 'linear(to-r, pink.400, red.500)',
|
||||
color: 'white',
|
||||
},
|
||||
};
|
||||
|
||||
const tierConfig = config[tier] || config.pro;
|
||||
|
||||
// 尺寸配置
|
||||
const sizeConfig = {
|
||||
sm: {
|
||||
fontSize: 'xs',
|
||||
iconSize: 2.5,
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
},
|
||||
md: {
|
||||
fontSize: 'sm',
|
||||
iconSize: 3,
|
||||
px: 3,
|
||||
py: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const currentSize = sizeConfig[size] || sizeConfig.sm;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
bgGradient={tierConfig.bgGradient}
|
||||
color={tierConfig.color}
|
||||
borderRadius="full"
|
||||
px={currentSize.px}
|
||||
py={currentSize.py}
|
||||
fontSize={currentSize.fontSize}
|
||||
fontWeight="bold"
|
||||
boxShadow="sm"
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
transform: 'scale(1.05)',
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
>
|
||||
<Icon as={tierConfig.icon} boxSize={currentSize.iconSize} />
|
||||
<Text>{tierConfig.label}</Text>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionBadge;
|
||||
@@ -16,21 +16,43 @@ import CollapsibleHeader from './CollapsibleHeader';
|
||||
* @param {boolean} props.isOpen - 是否展开
|
||||
* @param {Function} props.onToggle - 切换展开/收起的回调
|
||||
* @param {number} props.count - 可选的数量徽章
|
||||
* @param {React.ReactNode} props.subscriptionBadge - 可选的会员标签组件
|
||||
* @param {boolean} props.isLocked - 是否锁定(不可展开)
|
||||
* @param {Function} props.onLockedClick - 锁定时点击的回调
|
||||
* @param {React.ReactNode} props.children - 子内容
|
||||
*/
|
||||
const CollapsibleSection = ({ title, isOpen, onToggle, count = null, children }) => {
|
||||
const CollapsibleSection = ({
|
||||
title,
|
||||
isOpen,
|
||||
onToggle,
|
||||
count = null,
|
||||
subscriptionBadge = null,
|
||||
isLocked = false,
|
||||
onLockedClick = null,
|
||||
children
|
||||
}) => {
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
|
||||
// 处理点击:如果锁定则触发锁定回调,否则触发正常切换
|
||||
const handleToggle = () => {
|
||||
if (isLocked && onLockedClick) {
|
||||
onLockedClick();
|
||||
} else if (!isLocked) {
|
||||
onToggle();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<CollapsibleHeader
|
||||
title={title}
|
||||
isOpen={isOpen}
|
||||
onToggle={onToggle}
|
||||
onToggle={handleToggle}
|
||||
count={count}
|
||||
subscriptionBadge={subscriptionBadge}
|
||||
/>
|
||||
<Collapse
|
||||
in={isOpen}
|
||||
in={isOpen && !isLocked}
|
||||
animateOpacity
|
||||
unmountOnExit={false}
|
||||
startingHeight={0}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
import { eventService } from '../../../../services/eventService';
|
||||
import { useEventStocks } from '../StockDetailPanel/hooks/useEventStocks';
|
||||
import { toggleEventFollow, selectEventFollowStatus } from '../../../../store/slices/communityDataSlice';
|
||||
import { useAuth } from '../../../../contexts/AuthContext';
|
||||
import EventHeaderInfo from './EventHeaderInfo';
|
||||
import EventDescriptionSection from './EventDescriptionSection';
|
||||
import RelatedConceptsSection from './RelatedConceptsSection';
|
||||
@@ -24,6 +25,8 @@ import RelatedStocksSection from './RelatedStocksSection';
|
||||
import CollapsibleSection from './CollapsibleSection';
|
||||
import HistoricalEvents from '../../../EventDetail/components/HistoricalEvents';
|
||||
import TransmissionChainAnalysis from '../../../EventDetail/components/TransmissionChainAnalysis';
|
||||
import SubscriptionBadge from '../../../../components/SubscriptionBadge';
|
||||
import SubscriptionUpgradeModal from '../../../../components/SubscriptionUpgradeModal';
|
||||
|
||||
/**
|
||||
* 动态新闻详情面板主组件
|
||||
@@ -32,16 +35,34 @@ import TransmissionChainAnalysis from '../../../EventDetail/components/Transmiss
|
||||
*/
|
||||
const DynamicNewsDetailPanel = ({ event }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { user } = useAuth();
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||||
const toast = useToast();
|
||||
|
||||
// 获取用户会员等级(修复:字段名从 subscription_tier 改为 subscription_type)
|
||||
const userTier = user?.subscription_type || 'free';
|
||||
|
||||
// 从 Redux 读取关注状态
|
||||
const eventFollowStatus = useSelector(selectEventFollowStatus);
|
||||
const isFollowing = event?.id ? (eventFollowStatus[event.id]?.isFollowing || false) : false;
|
||||
const followerCount = event?.id ? (eventFollowStatus[event.id]?.followerCount || event.follower_count || 0) : 0;
|
||||
|
||||
// 权限判断函数
|
||||
const hasAccess = useCallback((requiredTier) => {
|
||||
const tierLevel = { free: 0, pro: 1, max: 2 };
|
||||
const result = tierLevel[userTier] >= tierLevel[requiredTier];
|
||||
return result;
|
||||
}, [userTier]);
|
||||
|
||||
// 升级弹窗状态
|
||||
const [upgradeModal, setUpgradeModal] = useState({
|
||||
isOpen: false,
|
||||
requiredLevel: 'pro',
|
||||
featureName: ''
|
||||
});
|
||||
|
||||
// 使用 Hook 获取实时数据(禁用自动加载,改为手动触发)
|
||||
const {
|
||||
stocks,
|
||||
@@ -55,10 +76,19 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
loadChainAnalysis
|
||||
} = useEventStocks(event?.id, event?.created_at, { autoLoad: false });
|
||||
|
||||
// 子区块折叠状态管理(默认折叠)+ 加载追踪
|
||||
const [isStocksOpen, setIsStocksOpen] = useState(false);
|
||||
// 相关股票、相关概念、历史事件和传导链的权限
|
||||
const canAccessStocks = hasAccess('pro');
|
||||
const canAccessConcepts = hasAccess('pro');
|
||||
const canAccessHistorical = hasAccess('pro');
|
||||
const canAccessTransmission = hasAccess('max');
|
||||
|
||||
// 子区块折叠状态管理 + 加载追踪
|
||||
// PRO 会员的相关股票默认展开
|
||||
const [isStocksOpen, setIsStocksOpen] = useState(canAccessStocks && userTier === 'pro');
|
||||
const [hasLoadedStocks, setHasLoadedStocks] = useState(false);
|
||||
|
||||
const [isConceptsOpen, setIsConceptsOpen] = useState(false);
|
||||
|
||||
const [isHistoricalOpen, setIsHistoricalOpen] = useState(false);
|
||||
const [hasLoadedHistorical, setHasLoadedHistorical] = useState(false);
|
||||
|
||||
@@ -75,7 +105,25 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 相关股票 - 展开时加载
|
||||
// 锁定点击处理 - 弹出升级弹窗
|
||||
const handleLockedClick = useCallback((featureName, requiredLevel) => {
|
||||
setUpgradeModal({
|
||||
isOpen: true,
|
||||
requiredLevel,
|
||||
featureName
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 关闭升级弹窗
|
||||
const handleCloseUpgradeModal = useCallback(() => {
|
||||
setUpgradeModal({
|
||||
isOpen: false,
|
||||
requiredLevel: 'pro',
|
||||
featureName: ''
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 相关股票 - 展开时加载(需要 PRO 权限)
|
||||
const handleStocksToggle = useCallback(() => {
|
||||
const newState = !isStocksOpen;
|
||||
setIsStocksOpen(newState);
|
||||
@@ -87,6 +135,11 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
}
|
||||
}, [isStocksOpen, hasLoadedStocks, loadStocksData, event?.id]);
|
||||
|
||||
// 相关概念 - 展开/收起(无需加载)
|
||||
const handleConceptsToggle = useCallback(() => {
|
||||
setIsConceptsOpen(!isConceptsOpen);
|
||||
}, [isConceptsOpen]);
|
||||
|
||||
// 历史事件对比 - 展开时加载
|
||||
const handleHistoricalToggle = useCallback(() => {
|
||||
const newState = !isHistoricalOpen;
|
||||
@@ -114,13 +167,25 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
// 事件切换时重置所有子模块状态
|
||||
useEffect(() => {
|
||||
console.log('%c🔄 [事件切换] 重置所有子模块状态', 'color: #F59E0B; font-weight: bold;', { eventId: event?.id });
|
||||
setIsStocksOpen(false);
|
||||
|
||||
// PRO 会员的相关股票默认展开,其他情况收起
|
||||
const shouldOpenStocks = canAccessStocks && userTier === 'pro';
|
||||
setIsStocksOpen(shouldOpenStocks);
|
||||
setHasLoadedStocks(false);
|
||||
|
||||
// PRO 会员默认展开时,自动加载股票数据
|
||||
if (shouldOpenStocks) {
|
||||
console.log('%c📊 [PRO会员] 自动加载相关股票数据', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
|
||||
loadStocksData();
|
||||
setHasLoadedStocks(true);
|
||||
}
|
||||
|
||||
setIsConceptsOpen(false);
|
||||
setIsHistoricalOpen(false);
|
||||
setHasLoadedHistorical(false);
|
||||
setIsTransmissionOpen(false);
|
||||
setHasLoadedTransmission(false);
|
||||
}, [event?.id]);
|
||||
}, [event?.id, canAccessStocks, userTier, loadStocksData]);
|
||||
|
||||
// 切换关注状态
|
||||
const handleToggleFollow = useCallback(async () => {
|
||||
@@ -196,12 +261,20 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
{/* 事件描述 */}
|
||||
<EventDescriptionSection description={event.description} />
|
||||
|
||||
{/* 相关股票(可折叠) - 懒加载 */}
|
||||
{/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 */}
|
||||
<CollapsibleSection
|
||||
title="相关股票"
|
||||
isOpen={isStocksOpen}
|
||||
onToggle={handleStocksToggle}
|
||||
count={stocks?.length || 0}
|
||||
subscriptionBadge={(() => {
|
||||
if (!canAccessStocks) {
|
||||
return <SubscriptionBadge tier="pro" size="sm" />;
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
isLocked={!canAccessStocks}
|
||||
onLockedClick={() => handleLockedClick('相关股票', 'pro')}
|
||||
>
|
||||
{loading.stocks || loading.quotes ? (
|
||||
<Center py={4}>
|
||||
@@ -219,19 +292,27 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 相关概念 */}
|
||||
{/* 相关概念(可折叠) - 需要 PRO 权限 */}
|
||||
<RelatedConceptsSection
|
||||
eventTitle={event.title}
|
||||
effectiveTradingDate={event.trading_date || event.created_at}
|
||||
eventTime={event.created_at}
|
||||
isOpen={isConceptsOpen}
|
||||
onToggle={handleConceptsToggle}
|
||||
subscriptionBadge={!canAccessConcepts ? <SubscriptionBadge tier="pro" size="sm" /> : null}
|
||||
isLocked={!canAccessConcepts}
|
||||
onLockedClick={() => handleLockedClick('相关概念', 'pro')}
|
||||
/>
|
||||
|
||||
{/* 历史事件对比(可折叠) - 懒加载 */}
|
||||
{/* 历史事件对比(可折叠) - 懒加载 - 需要 PRO 权限 */}
|
||||
<CollapsibleSection
|
||||
title="历史事件对比"
|
||||
isOpen={isHistoricalOpen}
|
||||
onToggle={handleHistoricalToggle}
|
||||
count={historicalEvents?.length || 0}
|
||||
subscriptionBadge={!canAccessHistorical ? <SubscriptionBadge tier="pro" size="sm" /> : null}
|
||||
isLocked={!canAccessHistorical}
|
||||
onLockedClick={() => handleLockedClick('历史事件对比', 'pro')}
|
||||
>
|
||||
{loading.historicalEvents ? (
|
||||
<Center py={4}>
|
||||
@@ -246,11 +327,14 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 传导链分析(可折叠) - 懒加载 */}
|
||||
{/* 传导链分析(可折叠) - 懒加载 - 需要 MAX 权限 */}
|
||||
<CollapsibleSection
|
||||
title="传导链分析"
|
||||
isOpen={isTransmissionOpen}
|
||||
onToggle={handleTransmissionToggle}
|
||||
subscriptionBadge={!canAccessTransmission ? <SubscriptionBadge tier="max" size="sm" /> : null}
|
||||
isLocked={!canAccessTransmission}
|
||||
onLockedClick={() => handleLockedClick('传导链分析', 'max')}
|
||||
>
|
||||
<TransmissionChainAnalysis
|
||||
eventId={event.id}
|
||||
@@ -259,6 +343,17 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
</CollapsibleSection>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
|
||||
{/* 升级弹窗 */}
|
||||
{upgradeModal.isOpen ? (
|
||||
<SubscriptionUpgradeModal
|
||||
isOpen={upgradeModal.isOpen}
|
||||
onClose={handleCloseUpgradeModal}
|
||||
requiredLevel={upgradeModal.requiredLevel}
|
||||
featureName={upgradeModal.featureName}
|
||||
currentLevel={userTier}
|
||||
/>
|
||||
): null }
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Flex,
|
||||
VStack,
|
||||
Box,
|
||||
Text,
|
||||
Badge,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
@@ -27,10 +29,15 @@ const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
|
||||
const relevanceScore = Math.round((concept.score || 0) * 100);
|
||||
const relevanceColors = getRelevanceColor(relevanceScore);
|
||||
|
||||
// 涨跌幅数据
|
||||
const changePct = concept.price_info?.avg_change_pct ? parseFloat(concept.price_info.avg_change_pct) : null;
|
||||
const changeColor = changePct !== null ? (changePct > 0 ? 'red' : changePct < 0 ? 'green' : 'gray') : null;
|
||||
const changeSymbol = changePct !== null && changePct > 0 ? '+' : '';
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
<VStack
|
||||
align="stretch"
|
||||
spacing={2}
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
@@ -46,28 +53,50 @@ const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
|
||||
}}
|
||||
onClick={() => onClick(concept)}
|
||||
>
|
||||
{/* 左侧:概念名 + 数量 */}
|
||||
<Text fontSize="sm" fontWeight="normal" color={conceptNameColor} mr={3}>
|
||||
{/* 第一行:概念名 + 数量(允许折行) */}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="normal"
|
||||
color={conceptNameColor}
|
||||
wordBreak="break-word"
|
||||
lineHeight="1.4"
|
||||
>
|
||||
{concept.concept}{' '}
|
||||
<Text as="span" color="gray.500">
|
||||
({concept.stock_count})
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
{/* 右侧:相关度标签 */}
|
||||
{/* 第二行:相关度 + 涨跌幅 */}
|
||||
<Flex justify="space-between" align="center" gap={2} flexWrap="wrap">
|
||||
{/* 相关度标签 */}
|
||||
<Box
|
||||
bg={relevanceColors.bg}
|
||||
color={relevanceColors.color}
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="sm"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text fontSize="xs" fontWeight="medium" whiteSpace="nowrap">
|
||||
相关度: {relevanceScore}%
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* 涨跌幅数据 */}
|
||||
{changePct !== null && (
|
||||
<Badge
|
||||
colorScheme={changeColor}
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={0.5}
|
||||
flexShrink={0}
|
||||
>
|
||||
{changeSymbol}{changePct.toFixed(2)}%
|
||||
</Badge>
|
||||
)}
|
||||
</Flex>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user