diff --git a/app.py b/app.py index d51c5152..b6775c80 100755 --- a/app.py +++ b/app.py @@ -11126,9 +11126,9 @@ def get_events_by_mainline(): # 批量查询 related_concepts related_concepts_query = db.session.query( - RelatedConcept.event_id, - RelatedConcept.concept - ).filter(RelatedConcept.event_id.in_(event_ids)).all() + RelatedConcepts.event_id, + RelatedConcepts.concept + ).filter(RelatedConcepts.event_id.in_(event_ids)).all() # 构建 event_id -> concepts 映射 event_concepts_map = {} # { event_id: [concept1, concept2, ...] } diff --git a/src/views/Community/components/DynamicNews/layouts/GroupedFourRowGrid.js b/src/views/Community/components/DynamicNews/layouts/GroupedFourRowGrid.js deleted file mode 100644 index d8d3aa66..00000000 --- a/src/views/Community/components/DynamicNews/layouts/GroupedFourRowGrid.js +++ /dev/null @@ -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} { '煤炭': '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} 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 ( - - - - - {lv2} - - - {eventCount} 条事件 - - - - ); -}; - -/** - * 按主线(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 ( -
- - - 数据加载失败, - - } - size="sm" - colorScheme="blue" - variant="ghost" - onClick={onRetry} - aria-label="刷新" - /> - - 刷新 - - -
- ); - }; - - // 底部加载指示器 - const renderLoadingIndicator = () => { - if (!hasMore) { - return ( -
- - 已加载全部内容 - -
- ); - } - if (loading) { - return ( -
- - - - 加载中... - - -
- ); - } - return null; - }; - - return ( - - {/* 分组列表 */} - - {groupedEvents.map((group) => ( - - {/* 分组标题 */} - toggleGroup(group.lv2)} - /> - - {/* 分组内容 */} - - - {group.events.map((event, index) => ( - - { - onEventSelect(clickedEvent); - }} - onTitleClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - onEventSelect(event); - }} - onToggleFollow={() => onToggleFollow?.(event.id)} - timelineStyle={getTimelineBoxStyle?.()} - borderColor={borderColor} - /> - - ))} - - - - ))} - - - {/* 底部指示器 */} - - {error ? renderErrorIndicator() : renderLoadingIndicator()} - - - ); -}); - -GroupedFourRowGridComponent.displayName = 'GroupedFourRowGrid'; - -const GroupedFourRowGrid = React.memo(GroupedFourRowGridComponent); - -export default GroupedFourRowGrid;