更新Company页面的UI为FUI风格

This commit is contained in:
2025-12-21 19:29:42 +08:00
parent d74162b7ce
commit b61f7a5048
10 changed files with 736 additions and 23 deletions

View File

@@ -113,9 +113,9 @@ const [currentMode, setCurrentMode] = useState('vertical');
// 根据模式选择数据源(使用 useMemo 缓存,避免重复计算)
// 纵向模式data 是页码映射 { 1: [...], 2: [...] }
// 平铺模式data 是数组 [...]
// 平铺模式 / 主线模式data 是数组 [...] (共用 fourRowData
const modeData = useMemo(
() => currentMode === 'four-row' ? fourRowData : verticalData,
() => (currentMode === 'four-row' || currentMode === 'mainline') ? fourRowData : verticalData,
[currentMode, fourRowData, verticalData]
);
const {
@@ -134,7 +134,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
[currentMode, data]
);
const allCachedEvents = useMemo(
() => currentMode === 'four-row' ? data : undefined,
() => (currentMode === 'four-row' || currentMode === 'mainline') ? data : undefined,
[currentMode, data]
);
@@ -249,14 +249,14 @@ const [currentMode, setCurrentMode] = useState('vertical');
} else {
console.log(`[DynamicNewsCard] 纵向模式 + 第${state.currentPage}页 → 不刷新(避免打断用户)`);
}
} else if (mode === 'four-row') {
// ========== 平铺模式 ==========
} else if (mode === 'four-row' || mode === 'mainline') {
// ========== 平铺模式 / 主线模式 ==========
// 检查滚动位置,只有在顶部时才刷新
const scrollPos = virtualizedGridRef.current?.getScrollPosition();
if (scrollPos?.isNearTop) {
// 用户在顶部 10% 区域,安全刷新
console.log('[DynamicNewsCard] 平铺模式 + 滚动在顶部 → 刷新列表');
console.log(`[DynamicNewsCard] ${mode === 'mainline' ? '主线' : '平铺'}模式 + 滚动在顶部 → 刷新列表`);
handlePageChange(1); // 清空并刷新
toast({
title: '检测到新事件,已刷新',
@@ -266,7 +266,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
});
} else {
// 用户不在顶部,显示提示但不自动刷新
console.log('[DynamicNewsCard] 平铺模式 + 滚动不在顶部 → 仅提示,不刷新');
console.log(`[DynamicNewsCard] ${mode === 'mainline' ? '主线' : '平铺'}模式 + 滚动不在顶部 → 仅提示,不刷新`);
toast({
title: '有新事件发布',
description: '滚动到顶部查看',

View File

@@ -7,15 +7,16 @@ import {
useColorModeValue
} from '@chakra-ui/react';
import VirtualizedFourRowGrid from './layouts/VirtualizedFourRowGrid';
import GroupedFourRowGrid from './layouts/GroupedFourRowGrid';
import VerticalModeLayout from './layouts/VerticalModeLayout';
/**
* 事件列表组件 - 支持纵向平铺种展示模式
* 事件列表组件 - 支持纵向平铺、主线三种展示模式
* @param {Array} events - 当前页的事件列表(服务端已分页)
* @param {Array} displayEvents - 累积显示的事件列表(平铺模式用)
* @param {Array} displayEvents - 累积显示的事件列表(平铺/主线模式用)
* @param {Function} loadNextPage - 加载下一页(无限滚动)
* @param {Function} loadPrevPage - 加载上一页(双向无限滚动)
* @param {Function} onFourRowEventClick - 平铺模式事件点击回调(打开弹窗)
* @param {Function} onFourRowEventClick - 平铺/主线模式事件点击回调(打开弹窗)
* @param {Object} selectedEvent - 当前选中的事件
* @param {Function} onEventSelect - 事件选择回调
* @param {string} borderColor - 边框颜色
@@ -24,11 +25,11 @@ import VerticalModeLayout from './layouts/VerticalModeLayout';
* @param {Function} onPageChange - 页码改变回调
* @param {boolean} loading - 全局加载状态
* @param {Object} error - 错误状态
* @param {string} mode - 展示模式:'vertical'(纵向分栏)| 'four-row'(平铺网格)
* @param {string} mode - 展示模式:'vertical'(纵向分栏)| 'four-row'(平铺网格)| 'mainline'(主线分组)
* @param {boolean} hasMore - 是否还有更多数据
* @param {Object} eventFollowStatus - 事件关注状态 { [eventId]: { isFollowing, followerCount } }
* @param {Function} onToggleFollow - 关注按钮回调
* @param {React.Ref} virtualizedGridRef - VirtualizedFourRowGrid 的 ref用于获取滚动位置
* @param {React.Ref} virtualizedGridRef - VirtualizedFourRowGrid/GroupedFourRowGrid 的 ref用于获取滚动位置
*/
const EventScrollList = React.memo(({
events,
@@ -87,7 +88,7 @@ const EventScrollList = React.memo(({
h="100%"
pt={0}
pb={4}
px={mode === 'four-row' ? 0 : { base: 0, md: 2 }}
px={mode === 'four-row' || mode === 'mainline' ? 0 : { base: 0, md: 2 }}
position="relative"
data-scroll-container="true"
css={{
@@ -113,7 +114,7 @@ const EventScrollList = React.memo(({
>
{/* 平铺网格模式 - 使用虚拟滚动 + 双向无限滚动 */}
<VirtualizedFourRowGrid
ref={virtualizedGridRef} // ⚡ 传递 ref用于获取滚动位置
ref={mode === 'four-row' ? virtualizedGridRef : null}
display={mode === 'four-row' ? 'block' : 'none'}
columnsPerRow={4} // 每行显示4列
events={displayEvents || events} // 使用累积列表(如果有)
@@ -131,6 +132,25 @@ const EventScrollList = React.memo(({
onRetry={handleRetry} // 重试回调
/>
{/* 主线分组模式 - 按 lv2 概念分组 */}
<GroupedFourRowGrid
ref={mode === 'mainline' ? virtualizedGridRef : null}
display={mode === 'mainline' ? 'block' : 'none'}
columnsPerRow={4}
events={displayEvents || events}
selectedEvent={selectedEvent}
onEventSelect={onFourRowEventClick}
eventFollowStatus={eventFollowStatus}
onToggleFollow={onToggleFollow}
getTimelineBoxStyle={getTimelineBoxStyle}
borderColor={borderColor}
loadNextPage={loadNextPage}
hasMore={hasMore}
loading={loading}
error={error}
onRetry={handleRetry}
/>
{/* 纵向分栏模式 */}
<VerticalModeLayout
display={mode === 'vertical' ? 'flex' : 'none'}

View File

@@ -6,7 +6,7 @@ import { Button, ButtonGroup } from '@chakra-ui/react';
/**
* 事件列表模式切换按钮组
* @param {string} mode - 当前模式 'vertical' | 'four-row'
* @param {string} mode - 当前模式 'vertical' | 'four-row' | 'mainline'
* @param {Function} onModeChange - 模式切换回调
*/
const ModeToggleButtons = React.memo(({ mode, onModeChange }) => {
@@ -20,11 +20,11 @@ const ModeToggleButtons = React.memo(({ mode, onModeChange }) => {
列表
</Button>
<Button
onClick={() => onModeChange('four-row')}
onClick={() => onModeChange('mainline')}
colorScheme="blue"
variant={mode === 'four-row' ? 'solid' : 'outline'}
variant={mode === 'mainline' ? 'solid' : 'outline'}
>
平铺
主线
</Button>
</ButtonGroup>
);

View File

@@ -29,6 +29,7 @@ export const PAGINATION_CONFIG = {
export const DISPLAY_MODES = {
FOUR_ROW: 'four-row', // 平铺网格模式
VERTICAL: 'vertical', // 纵向分栏模式
MAINLINE: 'mainline', // 主线分组模式(按 lv2 概念分组)
};
export const DEFAULT_MODE = DISPLAY_MODES.VERTICAL;

View File

@@ -49,9 +49,11 @@ export const usePagination = ({
filtersRef.current = filters;
// 根据模式决定每页显示数量
// mainline 模式复用 four-row 的分页配置
const pageSize = (() => {
switch (mode) {
case DISPLAY_MODES.FOUR_ROW:
case DISPLAY_MODES.MAINLINE:
return PAGINATION_CONFIG.FOUR_ROW_PAGE_SIZE;
case DISPLAY_MODES.VERTICAL:
return PAGINATION_CONFIG.VERTICAL_PAGE_SIZE;
@@ -73,15 +75,15 @@ export const usePagination = ({
// 纵向模式:从页码映射获取当前页
return allCachedEventsByPage?.[currentPage] || [];
} else {
// 平铺模式:返回全部累积数据
// 平铺模式 / 主线模式:返回全部累积数据
return allCachedEvents || [];
}
}, [mode, allCachedEventsByPage, allCachedEvents, currentPage]);
// 当前显示的事件列表
const displayEvents = useMemo(() => {
if (mode === DISPLAY_MODES.FOUR_ROW) {
// 平铺模式:返回全部累积数据
if (mode === DISPLAY_MODES.FOUR_ROW || mode === DISPLAY_MODES.MAINLINE) {
// 平铺模式 / 主线模式:返回全部累积数据
return allCachedEvents || [];
} else {
// 纵向模式:返回当前页数据
@@ -122,8 +124,11 @@ export const usePagination = ({
filters: filtersRef.current
});
// mainline 模式使用 four-row 的 API 模式(共用同一份数据)
const apiMode = mode === DISPLAY_MODES.MAINLINE ? DISPLAY_MODES.FOUR_ROW : mode;
const result = await dispatch(fetchDynamicNews({
mode: mode, // 传递 mode 参数
mode: apiMode, // 传递 API mode 参数mainline 映射为 four-row
per_page: pageSize,
pageSize: pageSize,
clearCache: clearCache, // 传递 clearCache 参数

View File

@@ -0,0 +1,612 @@
// src/views/Community/components/DynamicNews/layouts/GroupedFourRowGrid.js
// 按主线lv2分组的网格布局组件
import React, { useRef, useMemo, useEffect, forwardRef, useImperativeHandle, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
Box,
Grid,
Spinner,
Text,
VStack,
Center,
HStack,
IconButton,
useBreakpointValue,
useColorModeValue,
Flex,
Badge,
Icon,
Collapse,
} from '@chakra-ui/react';
import { RepeatIcon, ChevronDownIcon, ChevronRightIcon } from '@chakra-ui/icons';
import DynamicNewsEventCard from '../../EventCard/DynamicNewsEventCard';
import { getApiBase } from '@utils/apiConfig';
// ============ 概念层级映射缓存 ============
// 全局缓存,避免重复请求
let conceptHierarchyCache = null;
let conceptHierarchyPromise = null;
/**
* 获取概念层级映射(概念名称 -> lv2
* @returns {Promise<Object>} { '煤炭': 'lv2名称', ... }
*/
const fetchConceptHierarchy = async () => {
// 如果已有缓存,直接返回
if (conceptHierarchyCache) {
return conceptHierarchyCache;
}
// 如果正在请求中,等待结果
if (conceptHierarchyPromise) {
return conceptHierarchyPromise;
}
// 发起请求
conceptHierarchyPromise = (async () => {
try {
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/concept-api/hierarchy`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// 构建概念名称 -> lv2 映射
const mapping = {};
const hierarchy = data.hierarchy || [];
/**
* 递归添加概念到映射
* @param {Array} concepts - 概念数组(字符串或对象)
* @param {string} lv1Name - lv1 名称
* @param {string} lv2Name - lv2 名称
* @param {string} lv3Name - lv3 名称(可选)
*/
const addConceptsToMapping = (concepts, lv1Name, lv2Name, lv3Name = null) => {
if (!concepts || !Array.isArray(concepts)) return;
concepts.forEach(concept => {
const conceptName = typeof concept === 'string' ? concept : (concept.name || concept.concept);
if (conceptName) {
mapping[conceptName] = { lv1: lv1Name, lv2: lv2Name, lv3: lv3Name };
}
});
};
hierarchy.forEach(lv1 => {
const lv1Name = lv1.name;
const lv2List = lv1.children || [];
lv2List.forEach(lv2 => {
const lv2Name = lv2.name;
const lv3List = lv2.children || [];
// 情况1: lv2 直接包含 concepts无 lv3
if (lv2.concepts) {
addConceptsToMapping(lv2.concepts, lv1Name, lv2Name, null);
}
// 情况2: lv2 包含 lv3 children
lv3List.forEach(lv3 => {
const lv3Name = lv3.name;
// lv3 下的 concepts 或 leaf_concepts
const leafConcepts = lv3.concepts || lv3.leaf_concepts || [];
addConceptsToMapping(leafConcepts, lv1Name, lv2Name, lv3Name);
});
});
});
console.log('[GroupedFourRowGrid] 概念层级映射加载完成,共', Object.keys(mapping).length, '个概念');
conceptHierarchyCache = mapping;
return mapping;
} catch (error) {
console.error('[GroupedFourRowGrid] 获取概念层级失败:', error);
conceptHierarchyPromise = null; // 允许重试
return {};
}
})();
return conceptHierarchyPromise;
};
/**
* 自定义 Hook获取概念层级映射
*/
const useConceptHierarchy = () => {
const [hierarchyMap, setHierarchyMap] = useState(conceptHierarchyCache || {});
const [loading, setLoading] = useState(!conceptHierarchyCache);
useEffect(() => {
if (!conceptHierarchyCache) {
fetchConceptHierarchy().then(map => {
setHierarchyMap(map);
setLoading(false);
});
}
}, []);
return { hierarchyMap, loading };
};
/**
* 从事件的 keywords (related_concepts) 中提取主要的 lv2 分类
* @param {Object} event - 事件对象
* @param {Object} hierarchyMap - 概念层级映射 { 概念名: { lv1, lv2, lv3 } }
* @returns {string} lv2 分类名称
*/
const getEventMainLine = (event, hierarchyMap = {}) => {
// keywords 即 related_concepts
// 真实数据结构: [{ concept: '煤炭', reason: '...' }, ...]
// Mock 数据可能有 hierarchy 字段
const keywords = event.keywords || event.related_concepts || [];
if (!keywords.length) {
return '其他';
}
// 统计各 lv2 出现次数,取出现最多的
const lv2Counts = {};
keywords.forEach(keyword => {
// 优先使用 keyword 自带的 hierarchyMock 数据)
let lv2 = keyword.hierarchy?.lv2;
// 如果没有,从映射表查找(真实数据)
if (!lv2) {
const conceptName = keyword.concept || keyword.name || keyword;
const hierarchy = hierarchyMap[conceptName];
lv2 = hierarchy?.lv2;
}
if (lv2) {
lv2Counts[lv2] = (lv2Counts[lv2] || 0) + 1;
}
});
// 找出出现次数最多的 lv2
let maxCount = 0;
let mainLv2 = '其他';
Object.entries(lv2Counts).forEach(([lv2, count]) => {
if (count > maxCount) {
maxCount = count;
mainLv2 = lv2;
}
});
return mainLv2;
};
/**
* 按 lv2 分组事件
* @param {Array} events - 事件列表
* @param {Object} hierarchyMap - 概念层级映射
* @returns {Array} 分组后的数组 [{ lv2, events: [] }, ...]
*/
const groupEventsByLv2 = (events, hierarchyMap = {}) => {
const groups = {};
events.forEach(event => {
const lv2 = getEventMainLine(event, hierarchyMap);
if (!groups[lv2]) {
groups[lv2] = [];
}
groups[lv2].push(event);
});
// 转换为数组并按事件数量排序(多的在前)
return Object.entries(groups)
.map(([lv2, groupEvents]) => ({
lv2,
events: groupEvents,
eventCount: groupEvents.length,
}))
.sort((a, b) => b.eventCount - a.eventCount);
};
/**
* 单个分组的标题组件
*/
const GroupHeader = ({ lv2, eventCount, isExpanded, onToggle, colorScheme }) => {
const headerBg = useColorModeValue('gray.50', 'gray.700');
const headerHoverBg = useColorModeValue('gray.100', 'gray.650');
const textColor = useColorModeValue('gray.700', 'gray.200');
const borderColor = useColorModeValue('gray.200', 'gray.600');
// 根据主线类型获取配色
const getColorScheme = (lv2Name) => {
const colorMap = {
// 人工智能相关
'AI基础设施': 'purple',
'AI模型与软件': 'purple',
'AI应用': 'purple',
// 半导体相关
'半导体设备': 'blue',
'半导体材料': 'blue',
'芯片设计与制造': 'blue',
'先进封装': 'blue',
// 机器人相关
'人形机器人整机': 'pink',
'机器人核心零部件': 'pink',
'其他类型机器人': 'pink',
// 消费电子相关
'智能终端': 'cyan',
'XR与空间计算': 'cyan',
'华为产业链': 'cyan',
// 智能驾驶相关
'自动驾驶解决方案': 'teal',
'智能汽车产业链': 'teal',
'车路协同': 'teal',
// 新能源相关
'新型电池技术': 'green',
'电力设备与电网': 'green',
'清洁能源': 'green',
// 低空与航天
'低空经济': 'orange',
'商业航天': 'orange',
// 国防军工
'无人作战与信息化': 'red',
'海军装备': 'red',
'军贸出海': 'red',
// 医药健康
'创新药': 'messenger',
'医疗器械': 'messenger',
'中医药': 'messenger',
// 消费相关
'食品饮料': 'yellow',
'消费服务': 'yellow',
// 传统能源与资源
'煤炭石油': 'blackAlpha',
'钢铁建材': 'blackAlpha',
// 公用事业与交运
'公用事业': 'gray',
'交通运输': 'gray',
// 其他
'国家战略': 'red',
'区域发展': 'red',
'有色金属': 'orange',
'化工材料': 'orange',
'金融科技': 'linkedin',
'数字化转型': 'linkedin',
'量子科技': 'purple',
'脑机接口': 'purple',
'国际贸易': 'facebook',
'宏观主题': 'facebook',
};
return colorMap[lv2Name] || 'gray';
};
const scheme = colorScheme || getColorScheme(lv2);
return (
<Flex
align="center"
justify="space-between"
px={4}
py={3}
bg={headerBg}
borderRadius="md"
borderWidth="1px"
borderColor={borderColor}
cursor="pointer"
_hover={{ bg: headerHoverBg }}
onClick={onToggle}
transition="all 0.2s"
mb={2}
>
<HStack spacing={3}>
<Icon
as={isExpanded ? ChevronDownIcon : ChevronRightIcon}
boxSize={5}
color={textColor}
transition="transform 0.2s"
/>
<Text fontWeight="semibold" fontSize="md" color={textColor}>
{lv2}
</Text>
<Badge colorScheme={scheme} fontSize="xs" borderRadius="full" px={2}>
{eventCount} 条事件
</Badge>
</HStack>
</Flex>
);
};
/**
* 按主线lv2分组的网格布局组件
*/
const GroupedFourRowGridComponent = forwardRef(({
display = 'block',
events,
columnsPerRow = 4,
CardComponent = DynamicNewsEventCard,
selectedEvent,
onEventSelect,
eventFollowStatus,
onToggleFollow,
getTimelineBoxStyle,
borderColor,
loadNextPage,
onRefreshFirstPage,
hasMore,
loading,
error,
onRetry,
}, ref) => {
const parentRef = useRef(null);
const isLoadingMore = useRef(false);
const lastRefreshTime = useRef(0);
// 获取概念层级映射(从 /concept-api/hierarchy 接口)
const { hierarchyMap, loading: hierarchyLoading } = useConceptHierarchy();
// 记录每个分组的展开状态
const [expandedGroups, setExpandedGroups] = useState({});
// 滚动条颜色(主题适配)
const scrollbarTrackBg = useColorModeValue('#f1f1f1', '#2D3748');
const scrollbarThumbBg = useColorModeValue('#888', '#4A5568');
const scrollbarThumbHoverBg = useColorModeValue('#555', '#718096');
// 响应式列数
const responsiveColumns = useBreakpointValue({
base: 1,
sm: 2,
md: 2,
lg: 3,
xl: 4,
});
const actualColumnsPerRow = responsiveColumns || columnsPerRow;
// 按 lv2 分组事件(使用层级映射)
const groupedEvents = useMemo(() => {
return groupEventsByLv2(events, hierarchyMap);
}, [events, hierarchyMap]);
// 初始化展开状态(默认全部展开)
useEffect(() => {
const initialExpanded = {};
groupedEvents.forEach(group => {
// 只初始化新的分组,保留已有的状态
if (expandedGroups[group.lv2] === undefined) {
initialExpanded[group.lv2] = true;
}
});
if (Object.keys(initialExpanded).length > 0) {
setExpandedGroups(prev => ({ ...prev, ...initialExpanded }));
}
}, [groupedEvents]);
// 切换分组展开/折叠
const toggleGroup = (lv2) => {
setExpandedGroups(prev => ({
...prev,
[lv2]: !prev[lv2]
}));
};
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
getScrollPosition: () => {
const scrollElement = parentRef.current;
if (!scrollElement) return null;
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
const isNearTop = scrollTop < clientHeight * 0.1;
return {
scrollTop,
scrollHeight,
clientHeight,
isNearTop,
scrollPercentage: ((scrollTop + clientHeight) / scrollHeight) * 100,
};
},
}), []);
// 滚动事件处理(无限滚动 + 顶部刷新)
useEffect(() => {
if (display === 'none') return;
const scrollElement = parentRef.current;
if (!scrollElement) return;
const handleScroll = async () => {
if (isLoadingMore.current || loading) return;
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
// 向下滚动:加载更多
if (loadNextPage && hasMore && scrollPercentage > 0.9) {
isLoadingMore.current = true;
await loadNextPage();
isLoadingMore.current = false;
}
// 向上滚动到顶部:刷新
if (onRefreshFirstPage && scrollTop < clientHeight * 0.1) {
const now = Date.now();
if (now - lastRefreshTime.current >= 30000) {
isLoadingMore.current = true;
lastRefreshTime.current = now;
await onRefreshFirstPage();
isLoadingMore.current = false;
}
}
};
scrollElement.addEventListener('scroll', handleScroll);
return () => scrollElement.removeEventListener('scroll', handleScroll);
}, [display, loadNextPage, onRefreshFirstPage, hasMore, loading]);
// 内容不足时主动加载
useEffect(() => {
if (display === 'none') return;
const scrollElement = parentRef.current;
if (!scrollElement || !loadNextPage) return;
const timer = setTimeout(() => {
if (isLoadingMore.current || !hasMore || loading) return;
const { scrollHeight, clientHeight } = scrollElement;
if (scrollHeight <= clientHeight) {
isLoadingMore.current = true;
loadNextPage().finally(() => {
isLoadingMore.current = false;
});
}
}, 500);
return () => clearTimeout(timer);
}, [display, events.length, hasMore, loading, loadNextPage]);
// 错误指示器
const renderErrorIndicator = () => {
if (!error) return null;
return (
<Center py={6}>
<HStack spacing={2}>
<Text color="gray.500" fontSize="sm">
数据加载失败
</Text>
<IconButton
icon={<RepeatIcon />}
size="sm"
colorScheme="blue"
variant="ghost"
onClick={onRetry}
aria-label="刷新"
/>
<Text
color="blue.500"
fontSize="sm"
fontWeight="medium"
cursor="pointer"
onClick={onRetry}
_hover={{ textDecoration: 'underline' }}
>
刷新
</Text>
</HStack>
</Center>
);
};
// 底部加载指示器
const renderLoadingIndicator = () => {
if (!hasMore) {
return (
<Center py={6}>
<Text color="gray.500" fontSize="sm">
已加载全部内容
</Text>
</Center>
);
}
if (loading) {
return (
<Center py={6}>
<VStack spacing={2}>
<Spinner size="md" color="blue.500" thickness="3px" />
<Text color="gray.500" fontSize="sm">
加载中...
</Text>
</VStack>
</Center>
);
}
return null;
};
return (
<Box
ref={parentRef}
display={display}
overflowY="auto"
overflowX="hidden"
minH="800px"
maxH="800px"
w="100%"
position="relative"
px={2}
css={{
'&::-webkit-scrollbar': { width: '4px' },
'&::-webkit-scrollbar-track': {
background: scrollbarTrackBg,
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb': {
background: scrollbarThumbBg,
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: scrollbarThumbHoverBg,
},
scrollBehavior: 'smooth',
WebkitOverflowScrolling: 'touch',
}}
>
{/* 分组列表 */}
<VStack spacing={4} align="stretch" py={2}>
{groupedEvents.map((group) => (
<Box key={group.lv2}>
{/* 分组标题 */}
<GroupHeader
lv2={group.lv2}
eventCount={group.eventCount}
isExpanded={expandedGroups[group.lv2]}
onToggle={() => toggleGroup(group.lv2)}
/>
{/* 分组内容 */}
<Collapse in={expandedGroups[group.lv2]} animateOpacity>
<Grid
templateColumns={`repeat(${actualColumnsPerRow}, 1fr)`}
gap={actualColumnsPerRow === 1 ? 3 : 4}
w="100%"
px={1}
>
{group.events.map((event, index) => (
<Box key={event.id} w="100%" minW={0}>
<CardComponent
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>
</Collapse>
</Box>
))}
</VStack>
{/* 底部指示器 */}
<Box pt={4}>
{error ? renderErrorIndicator() : renderLoadingIndicator()}
</Box>
</Box>
);
});
GroupedFourRowGridComponent.displayName = 'GroupedFourRowGrid';
const GroupedFourRowGrid = React.memo(GroupedFourRowGridComponent);
export default GroupedFourRowGrid;