feat: 事件详情权限加上权限校验

This commit is contained in:
zdl
2025-11-05 21:31:02 +08:00
parent 25a6ff164b
commit ed24a14fbf
4 changed files with 274 additions and 31 deletions

View 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;

View File

@@ -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}

View File

@@ -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>
);
};

View File

@@ -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>
{/* 右侧:相关度标签 */}
<Box
bg={relevanceColors.bg}
color={relevanceColors.color}
px={3}
py={1}
borderRadius="md"
flexShrink={0}
>
<Text fontSize="xs" fontWeight="medium" whiteSpace="nowrap">
相关度: {relevanceScore}%
</Text>
</Box>
</Flex>
{/* 第二行:相关度 + 涨跌幅 */}
<Flex justify="space-between" align="center" gap={2} flexWrap="wrap">
{/* 相关度标签 */}
<Box
bg={relevanceColors.bg}
color={relevanceColors.color}
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>
);
};