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