diff --git a/app.py b/app.py index 1bcb92fb..d7b76b34 100755 --- a/app.py +++ b/app.py @@ -11829,6 +11829,9 @@ def broadcast_new_event(event): else: print(f'[WebSocket] 已推送新事件到房间: events_all') + # 清除事件列表缓存,确保用户刷新页面时获取最新数据 + clear_events_cache() + print(f'[WebSocket DEBUG] ========== 广播完成 ==========\n') except Exception as e: diff --git a/public/images/services/wechat-app.jpg b/public/images/services/wechat-app.jpg new file mode 100644 index 00000000..e473e0c2 Binary files /dev/null and b/public/images/services/wechat-app.jpg differ diff --git a/src/mocks/data/events.js b/src/mocks/data/events.js index 37f6aed4..162b9b72 100644 --- a/src/mocks/data/events.js +++ b/src/mocks/data/events.js @@ -609,6 +609,49 @@ function generateEventDescription(industry, importance, seed) { return impacts[importance] + details[seed % details.length]; } +// 概念到层级结构的映射(模拟真实 API 的 concept_hierarchy) +const conceptHierarchyMap = { + // 人工智能主线 + '大模型': { lv1: '人工智能', lv1_id: 'AI', lv2: 'AI大模型与应用', lv2_id: 'AI_MODEL_APP', lv3: 'AI大模型', lv3_id: 'AI_LLM' }, + 'AI应用': { lv1: '人工智能', lv1_id: 'AI', lv2: 'AI大模型与应用', lv2_id: 'AI_MODEL_APP', lv3: 'AI应用场景', lv3_id: 'AI_APP' }, + '算力': { lv1: '人工智能', lv1_id: 'AI', lv2: 'AI算力与基础设施', lv2_id: 'AI_INFRA', lv3: 'AI芯片与硬件', lv3_id: 'AI_CHIP' }, + '数据': { lv1: '人工智能', lv1_id: 'AI', lv2: 'AI大模型与应用', lv2_id: 'AI_MODEL_APP', lv3: '数据要素', lv3_id: 'DATA' }, + '机器学习': { lv1: '人工智能', lv1_id: 'AI', lv2: 'AI大模型与应用', lv2_id: 'AI_MODEL_APP', lv3: 'AI算法', lv3_id: 'AI_ALGO' }, + 'AI芯片': { lv1: '人工智能', lv1_id: 'AI', lv2: 'AI算力与基础设施', lv2_id: 'AI_INFRA', lv3: 'AI芯片与硬件', lv3_id: 'AI_CHIP' }, + // 半导体主线 + '芯片': { lv1: '半导体', lv1_id: 'SEMI', lv2: '芯片设计', lv2_id: 'CHIP_DESIGN', lv3: '芯片设计', lv3_id: 'CHIP' }, + '晶圆': { lv1: '半导体', lv1_id: 'SEMI', lv2: '芯片制造', lv2_id: 'CHIP_MFG', lv3: '晶圆代工', lv3_id: 'WAFER' }, + '封测': { lv1: '半导体', lv1_id: 'SEMI', lv2: '封装测试', lv2_id: 'PKG_TEST', lv3: '封装测试', lv3_id: 'PKG' }, + '国产替代': { lv1: '半导体', lv1_id: 'SEMI', lv2: '国产替代', lv2_id: 'DOMESTIC', lv3: '自主可控', lv3_id: 'SELF_CTRL' }, + // 新能源主线 + '电池': { lv1: '新能源', lv1_id: 'ENERGY', lv2: '新能源汽车', lv2_id: 'EV', lv3: '动力电池', lv3_id: 'BATTERY' }, + '光伏': { lv1: '新能源', lv1_id: 'ENERGY', lv2: '光伏产业', lv2_id: 'SOLAR', lv3: '光伏组件', lv3_id: 'PV_MODULE' }, + '储能': { lv1: '新能源', lv1_id: 'ENERGY', lv2: '储能产业', lv2_id: 'ESS', lv3: '电化学储能', lv3_id: 'ESS_CHEM' }, + '新能源车': { lv1: '新能源', lv1_id: 'ENERGY', lv2: '新能源汽车', lv2_id: 'EV', lv3: '整车制造', lv3_id: 'EV_OEM' }, + '锂电': { lv1: '新能源', lv1_id: 'ENERGY', lv2: '新能源汽车', lv2_id: 'EV', lv3: '锂电池材料', lv3_id: 'LI_MATERIAL' }, + // 医药主线 + '创新药': { lv1: '医药生物', lv1_id: 'PHARMA', lv2: '创新药', lv2_id: 'INNOV_DRUG', lv3: '创新药研发', lv3_id: 'DRUG_RD' }, + 'CRO': { lv1: '医药生物', lv1_id: 'PHARMA', lv2: '医药服务', lv2_id: 'PHARMA_SVC', lv3: 'CRO/CDMO', lv3_id: 'CRO' }, + '医疗器械': { lv1: '医药生物', lv1_id: 'PHARMA', lv2: '医疗器械', lv2_id: 'MED_DEVICE', lv3: '高端器械', lv3_id: 'HI_DEVICE' }, + '生物制药': { lv1: '医药生物', lv1_id: 'PHARMA', lv2: '生物制药', lv2_id: 'BIO_PHARMA', lv3: '生物药', lv3_id: 'BIO_DRUG' }, + '仿制药': { lv1: '医药生物', lv1_id: 'PHARMA', lv2: '仿制药', lv2_id: 'GENERIC', lv3: '仿制药', lv3_id: 'GEN_DRUG' }, + // 消费主线 + '白酒': { lv1: '消费', lv1_id: 'CONSUMER', lv2: '食品饮料', lv2_id: 'FOOD_BEV', lv3: '白酒', lv3_id: 'BAIJIU' }, + '食品': { lv1: '消费', lv1_id: 'CONSUMER', lv2: '食品饮料', lv2_id: 'FOOD_BEV', lv3: '食品加工', lv3_id: 'FOOD' }, + '家电': { lv1: '消费', lv1_id: 'CONSUMER', lv2: '家电', lv2_id: 'HOME_APPL', lv3: '白色家电', lv3_id: 'WHITE_APPL' }, + '零售': { lv1: '消费', lv1_id: 'CONSUMER', lv2: '商贸零售', lv2_id: 'RETAIL', lv3: '零售连锁', lv3_id: 'CHAIN' }, + '免税': { lv1: '消费', lv1_id: 'CONSUMER', lv2: '商贸零售', lv2_id: 'RETAIL', lv3: '免税', lv3_id: 'DUTY_FREE' }, + // 通用概念(分配到多个主线) + '政策': { lv1: '宏观政策', lv1_id: 'MACRO', lv2: '产业政策', lv2_id: 'POLICY', lv3: null, lv3_id: null }, + '利好': { lv1: '市场情绪', lv1_id: 'SENTIMENT', lv2: '利好因素', lv2_id: 'POSITIVE', lv3: null, lv3_id: null }, + '业绩': { lv1: '基本面', lv1_id: 'FUNDAMENTAL', lv2: '业绩增长', lv2_id: 'EARNINGS', lv3: null, lv3_id: null }, + '涨停': { lv1: '市场情绪', lv1_id: 'SENTIMENT', lv2: '涨停板', lv2_id: 'LIMIT_UP', lv3: null, lv3_id: null }, + '龙头': { lv1: '投资策略', lv1_id: 'STRATEGY', lv2: '龙头股', lv2_id: 'LEADER', lv3: null, lv3_id: null }, + '突破': { lv1: '技术面', lv1_id: 'TECHNICAL', lv2: '技术突破', lv2_id: 'BREAKOUT', lv3: null, lv3_id: null }, + '合作': { lv1: '公司动态', lv1_id: 'CORP_ACTION', lv2: '战略合作', lv2_id: 'PARTNERSHIP', lv3: null, lv3_id: null }, + '投资': { lv1: '公司动态', lv1_id: 'CORP_ACTION', lv2: '投资并购', lv2_id: 'MA', lv3: null, lv3_id: null }, +}; + // 生成关键词(对象数组格式,包含完整信息) function generateKeywords(industry, seed) { const commonKeywords = ['政策', '利好', '业绩', '涨停', '龙头', '突破', '合作', '投资']; @@ -701,6 +744,16 @@ function generateKeywords(industry, seed) { const score = (70 + Math.floor((seed * 7 + index * 11) % 30)) / 100; // 0.70-0.99的分数 const avgChangePct = (Math.random() * 15 - 5).toFixed(2); // -5% ~ +10% 的涨跌幅 + // 获取概念的层级信息 + const hierarchy = conceptHierarchyMap[name] || { + lv1: industry || '其他', + lv1_id: 'OTHER', + lv2: '未分类', + lv2_id: 'UNCATEGORIZED', + lv3: null, + lv3_id: null + }; + return { concept: name, // 使用 concept 字段而不是 name stock_count: 10 + Math.floor((seed + index) % 30), // 10-39个相关股票 @@ -711,7 +764,8 @@ function generateKeywords(industry, seed) { }, match_type: matchTypes[(seed + index) % 3], // 随机匹配类型 happened_times: generateHappenedTimes(seed + index), // 历史触发时间 - stocks: generateRelatedStocks(name, seed + index) // 核心相关股票 + stocks: generateRelatedStocks(name, seed + index), // 核心相关股票 + hierarchy: hierarchy // 层级信息(用于按主线分组) }; }); } diff --git a/src/mocks/handlers/concept.js b/src/mocks/handlers/concept.js index 8762e072..efa7fe1a 100644 --- a/src/mocks/handlers/concept.js +++ b/src/mocks/handlers/concept.js @@ -1009,6 +1009,24 @@ export const conceptHandlers = [ { id: 'lv2_15_1', name: '国际贸易', concept_count: 15, concepts: ['跨境电商', '出口', '贸易摩擦', '人民币国际化', '中美贸易', '中欧贸易', '东盟贸易'] }, { id: 'lv2_15_2', name: '宏观主题', concept_count: 10, concepts: ['美联储加息', '美债', '汇率', '通胀', '衰退预期', '地缘政治'] } ] + }, + { + id: 'lv1_16', + name: '传统能源与资源', + concept_count: 30, + children: [ + { id: 'lv2_16_1', name: '煤炭石油', concept_count: 15, concepts: ['煤炭', '动力煤', '焦煤', '石油', '天然气', '页岩油', '油服', '油气开采', '煤化工', '石油化工'] }, + { id: 'lv2_16_2', name: '钢铁建材', concept_count: 15, concepts: ['钢铁', '特钢', '铁矿石', '水泥', '玻璃', '建材', '基建', '房地产', '装配式建筑'] } + ] + }, + { + id: 'lv1_17', + name: '公用事业与交运', + concept_count: 25, + children: [ + { id: 'lv2_17_1', name: '公用事业', concept_count: 12, concepts: ['电力', '水务', '燃气', '环保', '垃圾处理', '污水处理', '园林绿化'] }, + { id: 'lv2_17_2', name: '交通运输', concept_count: 13, concepts: ['航空', '机场', '港口', '航运', '铁路', '公路', '物流', '快递', '冷链物流'] } + ] } ]; diff --git a/src/views/Community/components/DynamicNews/DynamicNewsCard.js b/src/views/Community/components/DynamicNews/DynamicNewsCard.js index 7ca96b52..ab9f9f9f 100644 --- a/src/views/Community/components/DynamicNews/DynamicNewsCard.js +++ b/src/views/Community/components/DynamicNews/DynamicNewsCard.js @@ -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: '滚动到顶部查看', diff --git a/src/views/Community/components/DynamicNews/EventScrollList.js b/src/views/Community/components/DynamicNews/EventScrollList.js index c1ebf70d..d9be9205 100644 --- a/src/views/Community/components/DynamicNews/EventScrollList.js +++ b/src/views/Community/components/DynamicNews/EventScrollList.js @@ -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(({ > {/* 平铺网格模式 - 使用虚拟滚动 + 双向无限滚动 */} + {/* 主线分组模式 - 按 lv2 概念分组 */} + + {/* 纵向分栏模式 */} { @@ -20,11 +20,11 @@ const ModeToggleButtons = React.memo(({ mode, onModeChange }) => { 列表 ); diff --git a/src/views/Community/components/DynamicNews/constants.js b/src/views/Community/components/DynamicNews/constants.js index 82f60e85..e8bf56ca 100644 --- a/src/views/Community/components/DynamicNews/constants.js +++ b/src/views/Community/components/DynamicNews/constants.js @@ -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; diff --git a/src/views/Community/components/DynamicNews/hooks/usePagination.js b/src/views/Community/components/DynamicNews/hooks/usePagination.js index 942d5855..edf35b22 100644 --- a/src/views/Community/components/DynamicNews/hooks/usePagination.js +++ b/src/views/Community/components/DynamicNews/hooks/usePagination.js @@ -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 参数 diff --git a/src/views/Community/components/DynamicNews/layouts/GroupedFourRowGrid.js b/src/views/Community/components/DynamicNews/layouts/GroupedFourRowGrid.js new file mode 100644 index 00000000..09765649 --- /dev/null +++ b/src/views/Community/components/DynamicNews/layouts/GroupedFourRowGrid.js @@ -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} { '煤炭': '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 自带的 hierarchy(Mock 数据) + 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 ( + + + + + {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;