更新Company页面的UI为FUI风格
This commit is contained in:
3
app.py
3
app.py
@@ -11829,6 +11829,9 @@ def broadcast_new_event(event):
|
|||||||
else:
|
else:
|
||||||
print(f'[WebSocket] 已推送新事件到房间: events_all')
|
print(f'[WebSocket] 已推送新事件到房间: events_all')
|
||||||
|
|
||||||
|
# 清除事件列表缓存,确保用户刷新页面时获取最新数据
|
||||||
|
clear_events_cache()
|
||||||
|
|
||||||
print(f'[WebSocket DEBUG] ========== 广播完成 ==========\n')
|
print(f'[WebSocket DEBUG] ========== 广播完成 ==========\n')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
BIN
public/images/services/wechat-app.jpg
Normal file
BIN
public/images/services/wechat-app.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -609,6 +609,49 @@ function generateEventDescription(industry, importance, seed) {
|
|||||||
return impacts[importance] + details[seed % details.length];
|
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) {
|
function generateKeywords(industry, seed) {
|
||||||
const commonKeywords = ['政策', '利好', '业绩', '涨停', '龙头', '突破', '合作', '投资'];
|
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 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 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 {
|
return {
|
||||||
concept: name, // 使用 concept 字段而不是 name
|
concept: name, // 使用 concept 字段而不是 name
|
||||||
stock_count: 10 + Math.floor((seed + index) % 30), // 10-39个相关股票
|
stock_count: 10 + Math.floor((seed + index) % 30), // 10-39个相关股票
|
||||||
@@ -711,7 +764,8 @@ function generateKeywords(industry, seed) {
|
|||||||
},
|
},
|
||||||
match_type: matchTypes[(seed + index) % 3], // 随机匹配类型
|
match_type: matchTypes[(seed + index) % 3], // 随机匹配类型
|
||||||
happened_times: generateHappenedTimes(seed + index), // 历史触发时间
|
happened_times: generateHappenedTimes(seed + index), // 历史触发时间
|
||||||
stocks: generateRelatedStocks(name, seed + index) // 核心相关股票
|
stocks: generateRelatedStocks(name, seed + index), // 核心相关股票
|
||||||
|
hierarchy: hierarchy // 层级信息(用于按主线分组)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1009,6 +1009,24 @@ export const conceptHandlers = [
|
|||||||
{ id: 'lv2_15_1', name: '国际贸易', concept_count: 15, concepts: ['跨境电商', '出口', '贸易摩擦', '人民币国际化', '中美贸易', '中欧贸易', '东盟贸易'] },
|
{ id: 'lv2_15_1', name: '国际贸易', concept_count: 15, concepts: ['跨境电商', '出口', '贸易摩擦', '人民币国际化', '中美贸易', '中欧贸易', '东盟贸易'] },
|
||||||
{ id: 'lv2_15_2', name: '宏观主题', concept_count: 10, 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: ['航空', '机场', '港口', '航运', '铁路', '公路', '物流', '快递', '冷链物流'] }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -113,9 +113,9 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
|||||||
|
|
||||||
// 根据模式选择数据源(使用 useMemo 缓存,避免重复计算)
|
// 根据模式选择数据源(使用 useMemo 缓存,避免重复计算)
|
||||||
// 纵向模式:data 是页码映射 { 1: [...], 2: [...] }
|
// 纵向模式:data 是页码映射 { 1: [...], 2: [...] }
|
||||||
// 平铺模式:data 是数组 [...]
|
// 平铺模式 / 主线模式:data 是数组 [...] (共用 fourRowData)
|
||||||
const modeData = useMemo(
|
const modeData = useMemo(
|
||||||
() => currentMode === 'four-row' ? fourRowData : verticalData,
|
() => (currentMode === 'four-row' || currentMode === 'mainline') ? fourRowData : verticalData,
|
||||||
[currentMode, fourRowData, verticalData]
|
[currentMode, fourRowData, verticalData]
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
@@ -134,7 +134,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
|||||||
[currentMode, data]
|
[currentMode, data]
|
||||||
);
|
);
|
||||||
const allCachedEvents = useMemo(
|
const allCachedEvents = useMemo(
|
||||||
() => currentMode === 'four-row' ? data : undefined,
|
() => (currentMode === 'four-row' || currentMode === 'mainline') ? data : undefined,
|
||||||
[currentMode, data]
|
[currentMode, data]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -249,14 +249,14 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
|||||||
} else {
|
} else {
|
||||||
console.log(`[DynamicNewsCard] 纵向模式 + 第${state.currentPage}页 → 不刷新(避免打断用户)`);
|
console.log(`[DynamicNewsCard] 纵向模式 + 第${state.currentPage}页 → 不刷新(避免打断用户)`);
|
||||||
}
|
}
|
||||||
} else if (mode === 'four-row') {
|
} else if (mode === 'four-row' || mode === 'mainline') {
|
||||||
// ========== 平铺模式 ==========
|
// ========== 平铺模式 / 主线模式 ==========
|
||||||
// 检查滚动位置,只有在顶部时才刷新
|
// 检查滚动位置,只有在顶部时才刷新
|
||||||
const scrollPos = virtualizedGridRef.current?.getScrollPosition();
|
const scrollPos = virtualizedGridRef.current?.getScrollPosition();
|
||||||
|
|
||||||
if (scrollPos?.isNearTop) {
|
if (scrollPos?.isNearTop) {
|
||||||
// 用户在顶部 10% 区域,安全刷新
|
// 用户在顶部 10% 区域,安全刷新
|
||||||
console.log('[DynamicNewsCard] 平铺模式 + 滚动在顶部 → 刷新列表');
|
console.log(`[DynamicNewsCard] ${mode === 'mainline' ? '主线' : '平铺'}模式 + 滚动在顶部 → 刷新列表`);
|
||||||
handlePageChange(1); // 清空并刷新
|
handlePageChange(1); // 清空并刷新
|
||||||
toast({
|
toast({
|
||||||
title: '检测到新事件,已刷新',
|
title: '检测到新事件,已刷新',
|
||||||
@@ -266,7 +266,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 用户不在顶部,显示提示但不自动刷新
|
// 用户不在顶部,显示提示但不自动刷新
|
||||||
console.log('[DynamicNewsCard] 平铺模式 + 滚动不在顶部 → 仅提示,不刷新');
|
console.log(`[DynamicNewsCard] ${mode === 'mainline' ? '主线' : '平铺'}模式 + 滚动不在顶部 → 仅提示,不刷新`);
|
||||||
toast({
|
toast({
|
||||||
title: '有新事件发布',
|
title: '有新事件发布',
|
||||||
description: '滚动到顶部查看',
|
description: '滚动到顶部查看',
|
||||||
|
|||||||
@@ -7,15 +7,16 @@ import {
|
|||||||
useColorModeValue
|
useColorModeValue
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import VirtualizedFourRowGrid from './layouts/VirtualizedFourRowGrid';
|
import VirtualizedFourRowGrid from './layouts/VirtualizedFourRowGrid';
|
||||||
|
import GroupedFourRowGrid from './layouts/GroupedFourRowGrid';
|
||||||
import VerticalModeLayout from './layouts/VerticalModeLayout';
|
import VerticalModeLayout from './layouts/VerticalModeLayout';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 事件列表组件 - 支持纵向和平铺两种展示模式
|
* 事件列表组件 - 支持纵向、平铺、主线三种展示模式
|
||||||
* @param {Array} events - 当前页的事件列表(服务端已分页)
|
* @param {Array} events - 当前页的事件列表(服务端已分页)
|
||||||
* @param {Array} displayEvents - 累积显示的事件列表(平铺模式用)
|
* @param {Array} displayEvents - 累积显示的事件列表(平铺/主线模式用)
|
||||||
* @param {Function} loadNextPage - 加载下一页(无限滚动)
|
* @param {Function} loadNextPage - 加载下一页(无限滚动)
|
||||||
* @param {Function} loadPrevPage - 加载上一页(双向无限滚动)
|
* @param {Function} loadPrevPage - 加载上一页(双向无限滚动)
|
||||||
* @param {Function} onFourRowEventClick - 平铺模式事件点击回调(打开弹窗)
|
* @param {Function} onFourRowEventClick - 平铺/主线模式事件点击回调(打开弹窗)
|
||||||
* @param {Object} selectedEvent - 当前选中的事件
|
* @param {Object} selectedEvent - 当前选中的事件
|
||||||
* @param {Function} onEventSelect - 事件选择回调
|
* @param {Function} onEventSelect - 事件选择回调
|
||||||
* @param {string} borderColor - 边框颜色
|
* @param {string} borderColor - 边框颜色
|
||||||
@@ -24,11 +25,11 @@ import VerticalModeLayout from './layouts/VerticalModeLayout';
|
|||||||
* @param {Function} onPageChange - 页码改变回调
|
* @param {Function} onPageChange - 页码改变回调
|
||||||
* @param {boolean} loading - 全局加载状态
|
* @param {boolean} loading - 全局加载状态
|
||||||
* @param {Object} error - 错误状态
|
* @param {Object} error - 错误状态
|
||||||
* @param {string} mode - 展示模式:'vertical'(纵向分栏)| 'four-row'(平铺网格)
|
* @param {string} mode - 展示模式:'vertical'(纵向分栏)| 'four-row'(平铺网格)| 'mainline'(主线分组)
|
||||||
* @param {boolean} hasMore - 是否还有更多数据
|
* @param {boolean} hasMore - 是否还有更多数据
|
||||||
* @param {Object} eventFollowStatus - 事件关注状态 { [eventId]: { isFollowing, followerCount } }
|
* @param {Object} eventFollowStatus - 事件关注状态 { [eventId]: { isFollowing, followerCount } }
|
||||||
* @param {Function} onToggleFollow - 关注按钮回调
|
* @param {Function} onToggleFollow - 关注按钮回调
|
||||||
* @param {React.Ref} virtualizedGridRef - VirtualizedFourRowGrid 的 ref(用于获取滚动位置)
|
* @param {React.Ref} virtualizedGridRef - VirtualizedFourRowGrid/GroupedFourRowGrid 的 ref(用于获取滚动位置)
|
||||||
*/
|
*/
|
||||||
const EventScrollList = React.memo(({
|
const EventScrollList = React.memo(({
|
||||||
events,
|
events,
|
||||||
@@ -87,7 +88,7 @@ const EventScrollList = React.memo(({
|
|||||||
h="100%"
|
h="100%"
|
||||||
pt={0}
|
pt={0}
|
||||||
pb={4}
|
pb={4}
|
||||||
px={mode === 'four-row' ? 0 : { base: 0, md: 2 }}
|
px={mode === 'four-row' || mode === 'mainline' ? 0 : { base: 0, md: 2 }}
|
||||||
position="relative"
|
position="relative"
|
||||||
data-scroll-container="true"
|
data-scroll-container="true"
|
||||||
css={{
|
css={{
|
||||||
@@ -113,7 +114,7 @@ const EventScrollList = React.memo(({
|
|||||||
>
|
>
|
||||||
{/* 平铺网格模式 - 使用虚拟滚动 + 双向无限滚动 */}
|
{/* 平铺网格模式 - 使用虚拟滚动 + 双向无限滚动 */}
|
||||||
<VirtualizedFourRowGrid
|
<VirtualizedFourRowGrid
|
||||||
ref={virtualizedGridRef} // ⚡ 传递 ref(用于获取滚动位置)
|
ref={mode === 'four-row' ? virtualizedGridRef : null}
|
||||||
display={mode === 'four-row' ? 'block' : 'none'}
|
display={mode === 'four-row' ? 'block' : 'none'}
|
||||||
columnsPerRow={4} // 每行显示4列
|
columnsPerRow={4} // 每行显示4列
|
||||||
events={displayEvents || events} // 使用累积列表(如果有)
|
events={displayEvents || events} // 使用累积列表(如果有)
|
||||||
@@ -131,6 +132,25 @@ const EventScrollList = React.memo(({
|
|||||||
onRetry={handleRetry} // 重试回调
|
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
|
<VerticalModeLayout
|
||||||
display={mode === 'vertical' ? 'flex' : 'none'}
|
display={mode === 'vertical' ? 'flex' : 'none'}
|
||||||
|
|||||||
@@ -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 - 模式切换回调
|
* @param {Function} onModeChange - 模式切换回调
|
||||||
*/
|
*/
|
||||||
const ModeToggleButtons = React.memo(({ mode, onModeChange }) => {
|
const ModeToggleButtons = React.memo(({ mode, onModeChange }) => {
|
||||||
@@ -20,11 +20,11 @@ const ModeToggleButtons = React.memo(({ mode, onModeChange }) => {
|
|||||||
列表
|
列表
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onModeChange('four-row')}
|
onClick={() => onModeChange('mainline')}
|
||||||
colorScheme="blue"
|
colorScheme="blue"
|
||||||
variant={mode === 'four-row' ? 'solid' : 'outline'}
|
variant={mode === 'mainline' ? 'solid' : 'outline'}
|
||||||
>
|
>
|
||||||
平铺
|
主线
|
||||||
</Button>
|
</Button>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const PAGINATION_CONFIG = {
|
|||||||
export const DISPLAY_MODES = {
|
export const DISPLAY_MODES = {
|
||||||
FOUR_ROW: 'four-row', // 平铺网格模式
|
FOUR_ROW: 'four-row', // 平铺网格模式
|
||||||
VERTICAL: 'vertical', // 纵向分栏模式
|
VERTICAL: 'vertical', // 纵向分栏模式
|
||||||
|
MAINLINE: 'mainline', // 主线分组模式(按 lv2 概念分组)
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_MODE = DISPLAY_MODES.VERTICAL;
|
export const DEFAULT_MODE = DISPLAY_MODES.VERTICAL;
|
||||||
|
|||||||
@@ -49,9 +49,11 @@ export const usePagination = ({
|
|||||||
filtersRef.current = filters;
|
filtersRef.current = filters;
|
||||||
|
|
||||||
// 根据模式决定每页显示数量
|
// 根据模式决定每页显示数量
|
||||||
|
// mainline 模式复用 four-row 的分页配置
|
||||||
const pageSize = (() => {
|
const pageSize = (() => {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case DISPLAY_MODES.FOUR_ROW:
|
case DISPLAY_MODES.FOUR_ROW:
|
||||||
|
case DISPLAY_MODES.MAINLINE:
|
||||||
return PAGINATION_CONFIG.FOUR_ROW_PAGE_SIZE;
|
return PAGINATION_CONFIG.FOUR_ROW_PAGE_SIZE;
|
||||||
case DISPLAY_MODES.VERTICAL:
|
case DISPLAY_MODES.VERTICAL:
|
||||||
return PAGINATION_CONFIG.VERTICAL_PAGE_SIZE;
|
return PAGINATION_CONFIG.VERTICAL_PAGE_SIZE;
|
||||||
@@ -73,15 +75,15 @@ export const usePagination = ({
|
|||||||
// 纵向模式:从页码映射获取当前页
|
// 纵向模式:从页码映射获取当前页
|
||||||
return allCachedEventsByPage?.[currentPage] || [];
|
return allCachedEventsByPage?.[currentPage] || [];
|
||||||
} else {
|
} else {
|
||||||
// 平铺模式:返回全部累积数据
|
// 平铺模式 / 主线模式:返回全部累积数据
|
||||||
return allCachedEvents || [];
|
return allCachedEvents || [];
|
||||||
}
|
}
|
||||||
}, [mode, allCachedEventsByPage, allCachedEvents, currentPage]);
|
}, [mode, allCachedEventsByPage, allCachedEvents, currentPage]);
|
||||||
|
|
||||||
// 当前显示的事件列表
|
// 当前显示的事件列表
|
||||||
const displayEvents = useMemo(() => {
|
const displayEvents = useMemo(() => {
|
||||||
if (mode === DISPLAY_MODES.FOUR_ROW) {
|
if (mode === DISPLAY_MODES.FOUR_ROW || mode === DISPLAY_MODES.MAINLINE) {
|
||||||
// 平铺模式:返回全部累积数据
|
// 平铺模式 / 主线模式:返回全部累积数据
|
||||||
return allCachedEvents || [];
|
return allCachedEvents || [];
|
||||||
} else {
|
} else {
|
||||||
// 纵向模式:返回当前页数据
|
// 纵向模式:返回当前页数据
|
||||||
@@ -122,8 +124,11 @@ export const usePagination = ({
|
|||||||
filters: filtersRef.current
|
filters: filtersRef.current
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// mainline 模式使用 four-row 的 API 模式(共用同一份数据)
|
||||||
|
const apiMode = mode === DISPLAY_MODES.MAINLINE ? DISPLAY_MODES.FOUR_ROW : mode;
|
||||||
|
|
||||||
const result = await dispatch(fetchDynamicNews({
|
const result = await dispatch(fetchDynamicNews({
|
||||||
mode: mode, // 传递 mode 参数
|
mode: apiMode, // 传递 API mode 参数(mainline 映射为 four-row)
|
||||||
per_page: pageSize,
|
per_page: pageSize,
|
||||||
pageSize: pageSize,
|
pageSize: pageSize,
|
||||||
clearCache: clearCache, // 传递 clearCache 参数
|
clearCache: clearCache, // 传递 clearCache 参数
|
||||||
|
|||||||
@@ -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 自带的 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 (
|
||||||
|
<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