更新Company页面的UI为FUI风格
This commit is contained in:
6
app.py
6
app.py
@@ -11126,9 +11126,9 @@ def get_events_by_mainline():
|
|||||||
|
|
||||||
# 批量查询 related_concepts
|
# 批量查询 related_concepts
|
||||||
related_concepts_query = db.session.query(
|
related_concepts_query = db.session.query(
|
||||||
RelatedConcept.event_id,
|
RelatedConcepts.event_id,
|
||||||
RelatedConcept.concept
|
RelatedConcepts.concept
|
||||||
).filter(RelatedConcept.event_id.in_(event_ids)).all()
|
).filter(RelatedConcepts.event_id.in_(event_ids)).all()
|
||||||
|
|
||||||
# 构建 event_id -> concepts 映射
|
# 构建 event_id -> concepts 映射
|
||||||
event_concepts_map = {} # { event_id: [concept1, concept2, ...] }
|
event_concepts_map = {} # { event_id: [concept1, concept2, ...] }
|
||||||
|
|||||||
@@ -1,701 +0,0 @@
|
|||||||
// 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 url = `${apiBase}/concept-api/hierarchy`;
|
|
||||||
console.log('[GroupedFourRowGrid] 🔄 正在请求概念层级:', url);
|
|
||||||
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
console.log('[GroupedFourRowGrid] 📦 API 响应数据:', {
|
|
||||||
hasHierarchy: !!data.hierarchy,
|
|
||||||
hierarchyLength: data.hierarchy?.length,
|
|
||||||
keys: Object.keys(data),
|
|
||||||
sample: data.hierarchy?.[0] // 打印第一个 lv1 作为样本
|
|
||||||
});
|
|
||||||
|
|
||||||
// 构建概念名称 -> lv2 映射
|
|
||||||
const mapping = {};
|
|
||||||
const hierarchy = data.hierarchy || data.data?.hierarchy || data || [];
|
|
||||||
|
|
||||||
// 如果 hierarchy 不是数组,尝试其他格式
|
|
||||||
const hierarchyArray = Array.isArray(hierarchy) ? 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 };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
hierarchyArray.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, '个概念');
|
|
||||||
console.log('[GroupedFourRowGrid] 📋 映射样本:', Object.entries(mapping).slice(0, 10));
|
|
||||||
|
|
||||||
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 };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在映射表中查找概念(支持精确匹配和模糊匹配)
|
|
||||||
* @param {string} conceptName - 概念名称
|
|
||||||
* @param {Object} hierarchyMap - 概念层级映射
|
|
||||||
* @returns {Object|null} 匹配到的层级信息
|
|
||||||
*/
|
|
||||||
const findConceptInMap = (conceptName, hierarchyMap) => {
|
|
||||||
if (!conceptName || !hierarchyMap) return null;
|
|
||||||
|
|
||||||
// 1. 精确匹配
|
|
||||||
if (hierarchyMap[conceptName]) {
|
|
||||||
return hierarchyMap[conceptName];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 模糊匹配:遍历映射表,查找包含关系
|
|
||||||
const conceptKeys = Object.keys(hierarchyMap);
|
|
||||||
for (const key of conceptKeys) {
|
|
||||||
// 概念名包含映射表中的关键词,或映射表中的关键词包含概念名
|
|
||||||
if (conceptName.includes(key) || key.includes(conceptName)) {
|
|
||||||
return hierarchyMap[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从事件的 keywords (related_concepts) 中提取所有相关的 lv2 分类
|
|
||||||
* @param {Object} event - 事件对象
|
|
||||||
* @param {Object} hierarchyMap - 概念层级映射 { 概念名: { lv1, lv2, lv3 } }
|
|
||||||
* @returns {Array<string>} lv2 分类名称数组(去重)
|
|
||||||
*/
|
|
||||||
const getEventLv2List = (event, hierarchyMap = {}) => {
|
|
||||||
// keywords 即 related_concepts
|
|
||||||
// 真实数据结构: [{ concept: '煤炭', reason: '...' }, ...]
|
|
||||||
// Mock 数据可能有 hierarchy 字段
|
|
||||||
const keywords = event.keywords || event.related_concepts || [];
|
|
||||||
|
|
||||||
if (!keywords.length) {
|
|
||||||
return ['其他'];
|
|
||||||
}
|
|
||||||
|
|
||||||
const lv2Set = new Set();
|
|
||||||
const unmatchedConcepts = []; // 记录未匹配的概念
|
|
||||||
|
|
||||||
keywords.forEach(keyword => {
|
|
||||||
// 优先使用 keyword 自带的 hierarchy(Mock 数据)
|
|
||||||
let lv2 = keyword.hierarchy?.lv2;
|
|
||||||
|
|
||||||
// 如果没有,从映射表查找(真实数据)
|
|
||||||
if (!lv2) {
|
|
||||||
const conceptName = keyword.concept || keyword.name || keyword;
|
|
||||||
// 使用模糊匹配
|
|
||||||
const hierarchy = findConceptInMap(conceptName, hierarchyMap);
|
|
||||||
lv2 = hierarchy?.lv2;
|
|
||||||
|
|
||||||
// 记录未匹配的概念(用于调试)
|
|
||||||
if (!lv2 && conceptName) {
|
|
||||||
unmatchedConcepts.push(conceptName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lv2) {
|
|
||||||
lv2Set.add(lv2);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 调试:输出未匹配的概念(只在有未匹配时输出)
|
|
||||||
if (unmatchedConcepts.length > 0 && lv2Set.size === 0) {
|
|
||||||
console.log(`[GroupedFourRowGrid] 事件 "${event.title?.substring(0, 30)}..." 全部 ${unmatchedConcepts.length} 个概念未匹配:`, unmatchedConcepts);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没有匹配到任何 lv2,返回"其他"
|
|
||||||
if (lv2Set.size === 0) {
|
|
||||||
return ['其他'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(lv2Set);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按 lv2 分组事件(一个事件可以属于多个 lv2 分组)
|
|
||||||
* @param {Array} events - 事件列表
|
|
||||||
* @param {Object} hierarchyMap - 概念层级映射
|
|
||||||
* @returns {Array} 分组后的数组 [{ lv2, events: [] }, ...]
|
|
||||||
*/
|
|
||||||
const groupEventsByLv2 = (events, hierarchyMap = {}) => {
|
|
||||||
const groups = {};
|
|
||||||
|
|
||||||
// 调试:检查 hierarchyMap 是否有数据
|
|
||||||
const mapSize = Object.keys(hierarchyMap).length;
|
|
||||||
console.log(`[GroupedFourRowGrid] 概念映射表大小: ${mapSize}`, mapSize > 0 ? '✅' : '❌ 映射表为空!');
|
|
||||||
|
|
||||||
events.forEach(event => {
|
|
||||||
// 获取该事件对应的所有 lv2 分类
|
|
||||||
const lv2List = getEventLv2List(event, hierarchyMap);
|
|
||||||
|
|
||||||
// 将事件添加到每个相关的 lv2 分组中
|
|
||||||
lv2List.forEach(lv2 => {
|
|
||||||
if (!groups[lv2]) {
|
|
||||||
groups[lv2] = [];
|
|
||||||
}
|
|
||||||
groups[lv2].push(event);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 转换为数组并按事件数量排序(多的在前)
|
|
||||||
const result = Object.entries(groups)
|
|
||||||
.map(([lv2, groupEvents]) => ({
|
|
||||||
lv2,
|
|
||||||
events: groupEvents,
|
|
||||||
eventCount: groupEvents.length,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.eventCount - a.eventCount);
|
|
||||||
|
|
||||||
console.log(`[GroupedFourRowGrid] 分组结果:`, result.map(g => `${g.lv2}(${g.eventCount})`).join(', '));
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 单个分组的标题组件
|
|
||||||
*/
|
|
||||||
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) => {
|
|
||||||
if (!lv2Name) return 'gray';
|
|
||||||
|
|
||||||
const name = lv2Name.toLowerCase();
|
|
||||||
|
|
||||||
// AI / 人工智能相关 - 紫色系
|
|
||||||
if (name.includes('ai') || name.includes('人工智能') || name.includes('算力') ||
|
|
||||||
name.includes('大模型') || name.includes('智能体')) {
|
|
||||||
return 'purple';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 半导体 / 芯片相关 - 蓝色系
|
|
||||||
if (name.includes('半导体') || name.includes('芯片') || name.includes('封装') ||
|
|
||||||
name.includes('光刻') || name.includes('硅')) {
|
|
||||||
return 'blue';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 机器人相关 - 粉色系
|
|
||||||
if (name.includes('机器人') || name.includes('人形')) {
|
|
||||||
return 'pink';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 消费电子 / 手机 / XR - 青色系
|
|
||||||
if (name.includes('消费电子') || name.includes('手机') || name.includes('xr') ||
|
|
||||||
name.includes('华为') || name.includes('苹果') || name.includes('终端')) {
|
|
||||||
return 'cyan';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 汽车 / 智能驾驶 - 蓝绿色系
|
|
||||||
if (name.includes('汽车') || name.includes('驾驶') || name.includes('新能源车') ||
|
|
||||||
name.includes('电动车') || name.includes('车路')) {
|
|
||||||
return 'teal';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新能源 / 电力 / 光伏 - 绿色系
|
|
||||||
if (name.includes('新能源') || name.includes('电力') || name.includes('光伏') ||
|
|
||||||
name.includes('储能') || name.includes('电池') || name.includes('风电') ||
|
|
||||||
name.includes('清洁能源')) {
|
|
||||||
return 'green';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 低空 / 航天 / 卫星 - 橙色系
|
|
||||||
if (name.includes('低空') || name.includes('航天') || name.includes('卫星') ||
|
|
||||||
name.includes('无人机') || name.includes('飞行')) {
|
|
||||||
return 'orange';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 军工 / 国防 - 红色系
|
|
||||||
if (name.includes('军工') || name.includes('国防') || name.includes('军事') ||
|
|
||||||
name.includes('武器') || name.includes('海军')) {
|
|
||||||
return 'red';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 医药 / 医疗 - messenger蓝
|
|
||||||
if (name.includes('医药') || name.includes('医疗') || name.includes('生物') ||
|
|
||||||
name.includes('创新药') || name.includes('器械')) {
|
|
||||||
return 'messenger';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 消费 / 食品 / 零售 - 黄色系
|
|
||||||
if (name.includes('消费') || name.includes('食品') || name.includes('零售') ||
|
|
||||||
name.includes('白酒') || name.includes('饮料')) {
|
|
||||||
return 'yellow';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 传统能源 / 煤炭 / 石油 - 深灰色
|
|
||||||
if (name.includes('煤炭') || name.includes('石油') || name.includes('天然气') ||
|
|
||||||
name.includes('钢铁') || name.includes('有色')) {
|
|
||||||
return 'blackAlpha';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 金融 / 银行 / 券商 - linkedin蓝
|
|
||||||
if (name.includes('金融') || name.includes('银行') || name.includes('券商') ||
|
|
||||||
name.includes('保险') || name.includes('证券')) {
|
|
||||||
return 'linkedin';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 政策 / 国家战略 - 红色
|
|
||||||
if (name.includes('政策') || name.includes('战略') || name.includes('国产替代')) {
|
|
||||||
return 'red';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 市场风格 / 题材 - 灰色
|
|
||||||
if (name.includes('市场') || name.includes('风格') || name.includes('题材')) {
|
|
||||||
return 'gray';
|
|
||||||
}
|
|
||||||
|
|
||||||
return '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;
|
|
||||||
Reference in New Issue
Block a user