本次提交实现了滚动列表和事件详情的关注按钮状态同步: ✅ Redux 状态管理 - communityDataSlice.js: 添加 eventFollowStatus state - 新增 toggleEventFollow AsyncThunk(复用 EventList.js 逻辑) - 新增 setEventFollowStatus reducer 和 selectEventFollowStatus selector ✅ 组件集成 - DynamicNewsCard.js: 从 Redux 读取关注状态并传递给子组件 - EventScrollList.js: 接收并传递关注状态给事件卡片 - DynamicNewsDetailPanel.js: 移除本地 state,使用 Redux 状态 ✅ Mock API 支持 - event.js: 添加 POST /api/events/:eventId/follow 处理器 - 返回 { is_following, follower_count } 模拟数据 ✅ Bug 修复 - EventDetail/index.js: 添加 useRef 导入 - concept.js: 导出 generatePopularConcepts 函数 - event.js: 添加 /api/events/:eventId/concepts 处理器 功能: - 点击滚动列表的关注按钮,详情面板的关注状态自动同步 - 点击详情面板的关注按钮,滚动列表的关注状态自动同步 - 关注人数实时更新 - 状态在整个应用中保持一致 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
280 lines
8.8 KiB
JavaScript
280 lines
8.8 KiB
JavaScript
// src/views/Community/components/DynamicNewsCard/EventScrollList.js
|
|
// 横向滚动事件列表组件
|
|
|
|
import React, { useRef } from 'react';
|
|
import {
|
|
Box,
|
|
Flex,
|
|
Grid,
|
|
IconButton,
|
|
Button,
|
|
ButtonGroup,
|
|
Center,
|
|
VStack,
|
|
Spinner,
|
|
Text,
|
|
useColorModeValue
|
|
} from '@chakra-ui/react';
|
|
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
|
import DynamicNewsEventCard from '../EventCard/DynamicNewsEventCard';
|
|
import PaginationControl from './PaginationControl';
|
|
|
|
/**
|
|
* 事件列表组件 - 支持两种展示模式
|
|
* @param {Array} events - 当前页的事件列表(服务端已分页)
|
|
* @param {Object} selectedEvent - 当前选中的事件
|
|
* @param {Function} onEventSelect - 事件选择回调
|
|
* @param {string} borderColor - 边框颜色
|
|
* @param {number} currentPage - 当前页码
|
|
* @param {number} totalPages - 总页数(由服务端返回)
|
|
* @param {Function} onPageChange - 页码改变回调
|
|
* @param {boolean} loading - 加载状态
|
|
* @param {string} mode - 展示模式:'carousel'(单排轮播)| 'grid'(双排网格)
|
|
* @param {Function} onModeChange - 模式切换回调
|
|
* @param {boolean} hasMore - 是否还有更多数据
|
|
* @param {Object} eventFollowStatus - 事件关注状态 { [eventId]: { isFollowing, followerCount } }
|
|
* @param {Function} onToggleFollow - 关注按钮回调
|
|
*/
|
|
const EventScrollList = ({
|
|
events,
|
|
selectedEvent,
|
|
onEventSelect,
|
|
borderColor,
|
|
currentPage,
|
|
totalPages,
|
|
onPageChange,
|
|
loading = false,
|
|
mode = 'carousel',
|
|
onModeChange,
|
|
hasMore = true,
|
|
eventFollowStatus = {},
|
|
onToggleFollow
|
|
}) => {
|
|
const scrollContainerRef = useRef(null);
|
|
|
|
// 时间轴样式配置
|
|
const getTimelineBoxStyle = () => {
|
|
return {
|
|
bg: useColorModeValue('gray.50', 'gray.700'),
|
|
borderColor: useColorModeValue('gray.400', 'gray.500'),
|
|
borderWidth: '2px',
|
|
textColor: useColorModeValue('blue.600', 'blue.400'),
|
|
boxShadow: 'sm',
|
|
};
|
|
};
|
|
|
|
return (
|
|
<Box>
|
|
{/* 顶部控制栏:模式切换按钮(左)+ 分页控制器(右) */}
|
|
<Flex justify="space-between" align="center" mb={2}>
|
|
{/* 模式切换按钮 */}
|
|
<ButtonGroup size="sm" isAttached>
|
|
<Button
|
|
onClick={() => onModeChange('carousel')}
|
|
colorScheme="blue"
|
|
variant={mode === 'carousel' ? 'solid' : 'outline'}
|
|
>
|
|
单排
|
|
</Button>
|
|
<Button
|
|
onClick={() => onModeChange('grid')}
|
|
colorScheme="blue"
|
|
variant={mode === 'grid' ? 'solid' : 'outline'}
|
|
>
|
|
双排
|
|
</Button>
|
|
</ButtonGroup>
|
|
|
|
{/* 分页控制器 */}
|
|
{totalPages > 1 && (
|
|
<PaginationControl
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
onPageChange={onPageChange}
|
|
/>
|
|
)}
|
|
</Flex>
|
|
|
|
{/* 横向滚动区域 */}
|
|
<Box position="relative">
|
|
|
|
{/* 左侧翻页按钮 - 上一页 */}
|
|
{currentPage > 1 && (
|
|
<IconButton
|
|
icon={<ChevronLeftIcon boxSize={6} color="blue.500" />}
|
|
position="absolute"
|
|
left="0"
|
|
top="50%"
|
|
transform="translateY(-50%)"
|
|
zIndex={2}
|
|
onClick={() => onPageChange(currentPage - 1)}
|
|
variant="ghost"
|
|
size="md"
|
|
w="40px"
|
|
h="40px"
|
|
minW="40px"
|
|
borderRadius="full"
|
|
bg={useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.6)')}
|
|
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
|
|
_hover={{
|
|
bg: useColorModeValue('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0.8)'),
|
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
|
transform: 'translateY(-50%) scale(1.05)'
|
|
}}
|
|
aria-label="上一页"
|
|
title="上一页"
|
|
/>
|
|
)}
|
|
|
|
{/* 右侧翻页按钮 - 下一页 */}
|
|
{currentPage < totalPages && hasMore && (
|
|
<IconButton
|
|
icon={<ChevronRightIcon boxSize={6} color="blue.500" />}
|
|
position="absolute"
|
|
right="0"
|
|
top="50%"
|
|
transform="translateY(-50%)"
|
|
zIndex={2}
|
|
onClick={() => onPageChange(currentPage + 1)}
|
|
variant="ghost"
|
|
size="md"
|
|
w="40px"
|
|
h="40px"
|
|
minW="40px"
|
|
borderRadius="full"
|
|
bg={useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.6)')}
|
|
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
|
|
_hover={{
|
|
bg: useColorModeValue('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0.8)'),
|
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
|
transform: 'translateY(-50%) scale(1.05)'
|
|
}}
|
|
isDisabled={currentPage >= totalPages && !hasMore}
|
|
aria-label="下一页"
|
|
title="下一页"
|
|
/>
|
|
)}
|
|
|
|
{/* 事件卡片容器 */}
|
|
<Box
|
|
ref={scrollContainerRef}
|
|
overflowX={mode === 'carousel' ? 'auto' : 'hidden'}
|
|
overflowY="hidden"
|
|
pt={0}
|
|
pb={4}
|
|
px={2}
|
|
position="relative"
|
|
css={mode === 'carousel' ? {
|
|
'&::-webkit-scrollbar': {
|
|
height: '8px',
|
|
},
|
|
'&::-webkit-scrollbar-track': {
|
|
background: useColorModeValue('#f1f1f1', '#2D3748'),
|
|
borderRadius: '10px',
|
|
},
|
|
'&::-webkit-scrollbar-thumb': {
|
|
background: useColorModeValue('#888', '#4A5568'),
|
|
borderRadius: '10px',
|
|
},
|
|
'&::-webkit-scrollbar-thumb:hover': {
|
|
background: useColorModeValue('#555', '#718096'),
|
|
},
|
|
scrollBehavior: 'smooth',
|
|
WebkitOverflowScrolling: 'touch',
|
|
} : {}}
|
|
>
|
|
{/* 加载遮罩 */}
|
|
{loading && (
|
|
<Center
|
|
position="absolute"
|
|
top={0}
|
|
left={0}
|
|
right={0}
|
|
bottom={0}
|
|
bg={useColorModeValue('whiteAlpha.800', 'blackAlpha.700')}
|
|
backdropFilter="blur(2px)"
|
|
zIndex={10}
|
|
borderRadius="md"
|
|
>
|
|
<VStack>
|
|
<Spinner size="lg" color="blue.500" thickness="3px" />
|
|
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.300')}>
|
|
加载中...
|
|
</Text>
|
|
</VStack>
|
|
</Center>
|
|
)}
|
|
|
|
{/* 模式1: 单排轮播模式 */}
|
|
{mode === 'carousel' && (
|
|
<Flex gap={4}>
|
|
{events.map((event, index) => (
|
|
<Box
|
|
key={event.id}
|
|
minW="calc((100% - 64px) / 5)"
|
|
maxW="calc((100% - 64px) / 5)"
|
|
flexShrink={0}
|
|
>
|
|
<DynamicNewsEventCard
|
|
event={event}
|
|
index={index}
|
|
isFollowing={eventFollowStatus[event.id]?.isFollowing || false}
|
|
followerCount={eventFollowStatus[event.id]?.followerCount || event.follower_count || 0}
|
|
isSelected={selectedEvent?.id === event.id}
|
|
onEventClick={(clickedEvent) => {
|
|
onEventSelect(clickedEvent);
|
|
}}
|
|
onTitleClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onEventSelect(event);
|
|
}}
|
|
onToggleFollow={() => onToggleFollow?.(event.id)}
|
|
timelineStyle={getTimelineBoxStyle()}
|
|
borderColor={borderColor}
|
|
/>
|
|
</Box>
|
|
))}
|
|
</Flex>
|
|
)}
|
|
|
|
{/* 模式2: 双排网格模式 */}
|
|
{mode === 'grid' && (
|
|
<Grid
|
|
templateRows="repeat(2, 1fr)"
|
|
templateColumns="repeat(5, 1fr)"
|
|
gap={4}
|
|
autoFlow="column"
|
|
>
|
|
{events.map((event, index) => (
|
|
<Box key={event.id}>
|
|
<DynamicNewsEventCard
|
|
event={event}
|
|
index={index}
|
|
isFollowing={eventFollowStatus[event.id]?.isFollowing || false}
|
|
followerCount={eventFollowStatus[event.id]?.followerCount || event.follower_count || 0}
|
|
isSelected={selectedEvent?.id === event.id}
|
|
onEventClick={(clickedEvent) => {
|
|
onEventSelect(clickedEvent);
|
|
}}
|
|
onTitleClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onEventSelect(event);
|
|
}}
|
|
onToggleFollow={() => onToggleFollow?.(event.id)}
|
|
timelineStyle={getTimelineBoxStyle()}
|
|
borderColor={borderColor}
|
|
/>
|
|
</Box>
|
|
))}
|
|
</Grid>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default EventScrollList;
|