Compare commits
11 Commits
feature_20
...
76360102bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76360102bb | ||
|
|
1a3987afe0 | ||
|
|
a512f3bd7e | ||
|
|
ffa6c2f761 | ||
|
|
64a441b717 | ||
|
|
5b9155a30c | ||
|
|
6e5eaa9089 | ||
|
|
8ed65b062b | ||
|
|
868b4ccebc | ||
|
|
67981f21a2 | ||
|
|
0a10270ab0 |
BIN
public/badge.png
Normal file
BIN
public/badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
BIN
public/logo192.png
Normal file
BIN
public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -4,8 +4,24 @@
|
|||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.png",
|
"src": "favicon.png",
|
||||||
"sizes": "64x64 32x32 24x24 16x16",
|
"sizes": "32x32",
|
||||||
"type": "image/x-icon"
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "badge.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "badge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "apple-icon.png",
|
||||||
|
"sizes": "32x32",
|
||||||
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": "./index.html",
|
"start_url": "./index.html",
|
||||||
|
|||||||
92
public/service-worker.js
Normal file
92
public/service-worker.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// public/service-worker.js
|
||||||
|
/**
|
||||||
|
* Service Worker for Browser Notifications
|
||||||
|
* 主要功能:支持浏览器通知的稳定运行
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CACHE_NAME = 'valuefrontier-v1';
|
||||||
|
|
||||||
|
// Service Worker 安装事件
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
console.log('[Service Worker] Installing...');
|
||||||
|
// 跳过等待,立即激活
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Service Worker 激活事件
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
console.log('[Service Worker] Activating...');
|
||||||
|
// 立即接管所有页面
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
|
|
||||||
|
// 通知点击事件
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
console.log('[Service Worker] Notification clicked:', event.notification.tag);
|
||||||
|
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
// 获取通知数据中的链接
|
||||||
|
const urlToOpen = event.notification.data?.link;
|
||||||
|
|
||||||
|
if (urlToOpen) {
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: 'window', includeUncontrolled: true })
|
||||||
|
.then((windowClients) => {
|
||||||
|
// 查找是否已有打开的窗口
|
||||||
|
for (let client of windowClients) {
|
||||||
|
if (client.url.includes(window.location.origin) && 'focus' in client) {
|
||||||
|
// 聚焦现有窗口并导航到目标页面
|
||||||
|
return client.focus().then(client => {
|
||||||
|
return client.navigate(urlToOpen);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果没有打开的窗口,打开新窗口
|
||||||
|
if (clients.openWindow) {
|
||||||
|
return clients.openWindow(urlToOpen);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 通知关闭事件
|
||||||
|
self.addEventListener('notificationclose', (event) => {
|
||||||
|
console.log('[Service Worker] Notification closed:', event.notification.tag);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch 事件 - 基础的网络优先策略
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
// 对于通知相关的资源,使用网络优先策略
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.catch(() => {
|
||||||
|
// 网络失败时,尝试从缓存获取
|
||||||
|
return caches.match(event.request);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 推送消息事件(预留,用于未来的 Push API 集成)
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
console.log('[Service Worker] Push message received:', event);
|
||||||
|
|
||||||
|
if (event.data) {
|
||||||
|
const data = event.data.json();
|
||||||
|
const options = {
|
||||||
|
body: data.body || '您有新消息',
|
||||||
|
icon: data.icon || '/favicon.png',
|
||||||
|
badge: '/favicon.png',
|
||||||
|
data: data.data || {},
|
||||||
|
requireInteraction: data.requireInteraction || false,
|
||||||
|
tag: data.tag || `notification_${Date.now()}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title || '价值前沿', options)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Service Worker] Loaded successfully');
|
||||||
37
src/index.js
37
src/index.js
@@ -11,6 +11,40 @@ import './styles/brainwave-colors.css';
|
|||||||
// Import the main App component
|
// Import the main App component
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
|
// 注册 Service Worker(用于支持浏览器通知)
|
||||||
|
function registerServiceWorker() {
|
||||||
|
// 仅在支持 Service Worker 的浏览器中注册
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
// 在页面加载完成后注册
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register('/service-worker.js')
|
||||||
|
.then((registration) => {
|
||||||
|
console.log('[App] Service Worker registered successfully:', registration.scope);
|
||||||
|
|
||||||
|
// 监听更新
|
||||||
|
registration.addEventListener('updatefound', () => {
|
||||||
|
const newWorker = registration.installing;
|
||||||
|
console.log('[App] Service Worker update found');
|
||||||
|
|
||||||
|
if (newWorker) {
|
||||||
|
newWorker.addEventListener('statechange', () => {
|
||||||
|
if (newWorker.state === 'activated') {
|
||||||
|
console.log('[App] Service Worker activated');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[App] Service Worker registration failed:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn('[App] Service Worker is not supported in this browser');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 启动 Mock Service Worker(如果启用)
|
// 启动 Mock Service Worker(如果启用)
|
||||||
async function startApp() {
|
async function startApp() {
|
||||||
// 只在开发环境启动 MSW
|
// 只在开发环境启动 MSW
|
||||||
@@ -35,6 +69,9 @@ async function startApp() {
|
|||||||
</Router>
|
</Router>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 注册 Service Worker
|
||||||
|
registerServiceWorker();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动应用
|
// 启动应用
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/views/Community/components/DynamicNewsCard.js
|
// src/views/Community/components/DynamicNewsCard.js
|
||||||
// 横向滚动事件卡片组件(实时要闻·动态追踪)
|
// 横向滚动事件卡片组件(实时要闻·动态追踪)
|
||||||
|
|
||||||
import React, { forwardRef, useState, useEffect, useMemo, useCallback } from 'react';
|
import React, { forwardRef, useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -24,6 +24,8 @@ import EventScrollList from './DynamicNewsCard/EventScrollList';
|
|||||||
import DynamicNewsDetailPanel from './DynamicNewsDetail';
|
import DynamicNewsDetailPanel from './DynamicNewsDetail';
|
||||||
import UnifiedSearchBox from './UnifiedSearchBox';
|
import UnifiedSearchBox from './UnifiedSearchBox';
|
||||||
import { fetchDynamicNews, toggleEventFollow, selectEventFollowStatus } from '../../../store/slices/communityDataSlice';
|
import { fetchDynamicNews, toggleEventFollow, selectEventFollowStatus } from '../../../store/slices/communityDataSlice';
|
||||||
|
import { usePagination } from './DynamicNewsCard/hooks/usePagination';
|
||||||
|
import { PAGINATION_CONFIG } from './DynamicNewsCard/constants';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 实时要闻·动态追踪 - 事件展示卡片组件
|
* 实时要闻·动态追踪 - 事件展示卡片组件
|
||||||
@@ -69,290 +71,63 @@ const DynamicNewsCard = forwardRef(({
|
|||||||
|
|
||||||
// 本地状态
|
// 本地状态
|
||||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||||
const [mode, setMode] = useState('carousel'); // 'carousel' 或 'grid',默认单排
|
|
||||||
const [currentPage, setCurrentPage] = useState(1); // 当前页码
|
|
||||||
const [loadingPage, setLoadingPage] = useState(null); // 正在加载的目标页码(用于 UX 提示)
|
|
||||||
|
|
||||||
// 根据模式决定每页显示数量
|
// 初始化标记 - 确保初始加载只执行一次
|
||||||
const pageSize = mode === 'carousel' ? 5 : 10;
|
const hasInitialized = useRef(false);
|
||||||
|
|
||||||
// 计算总页数(基于服务端总数据量)
|
// 使用分页 Hook
|
||||||
const totalPages = Math.ceil(total / pageSize) || 1;
|
const {
|
||||||
|
currentPage,
|
||||||
|
mode,
|
||||||
|
loadingPage,
|
||||||
|
pageSize,
|
||||||
|
totalPages,
|
||||||
|
hasMore,
|
||||||
|
currentPageEvents,
|
||||||
|
handlePageChange,
|
||||||
|
handleModeToggle
|
||||||
|
} = usePagination({
|
||||||
|
allCachedEvents,
|
||||||
|
total,
|
||||||
|
cachedCount,
|
||||||
|
dispatch,
|
||||||
|
toast
|
||||||
|
});
|
||||||
|
|
||||||
// 检查是否还有更多数据
|
// 初始加载 - 只在组件首次挂载且未初始化时执行
|
||||||
const hasMore = cachedCount < total;
|
|
||||||
|
|
||||||
// 从缓存中切片获取当前页数据(过滤 null 占位符)
|
|
||||||
const currentPageEvents = useMemo(() => {
|
|
||||||
const startIndex = (currentPage - 1) * pageSize;
|
|
||||||
const endIndex = startIndex + pageSize;
|
|
||||||
return allCachedEvents.slice(startIndex, endIndex).filter(event => event !== null);
|
|
||||||
}, [allCachedEvents, currentPage, pageSize]);
|
|
||||||
|
|
||||||
// 翻页处理(智能预加载)
|
|
||||||
const handlePageChange = useCallback(async (newPage) => {
|
|
||||||
// 🔍 诊断日志 - 记录翻页开始状态
|
|
||||||
console.log('[handlePageChange] 开始翻页', {
|
|
||||||
currentPage,
|
|
||||||
newPage,
|
|
||||||
pageSize,
|
|
||||||
totalPages,
|
|
||||||
hasMore,
|
|
||||||
total,
|
|
||||||
allCachedEventsLength: allCachedEvents.length,
|
|
||||||
cachedCount
|
|
||||||
});
|
|
||||||
|
|
||||||
// 0. 首先检查目标页数据是否已完整缓存
|
|
||||||
const targetPageStartIndex = (newPage - 1) * pageSize;
|
|
||||||
const targetPageEndIndex = targetPageStartIndex + pageSize;
|
|
||||||
const targetPageData = allCachedEvents.slice(targetPageStartIndex, targetPageEndIndex);
|
|
||||||
const validTargetData = targetPageData.filter(e => e !== null);
|
|
||||||
const expectedCount = Math.min(pageSize, total - targetPageStartIndex);
|
|
||||||
const isTargetPageCached = validTargetData.length >= expectedCount;
|
|
||||||
|
|
||||||
console.log('[handlePageChange] 目标页缓存检查', {
|
|
||||||
newPage,
|
|
||||||
targetPageStartIndex,
|
|
||||||
targetPageEndIndex,
|
|
||||||
targetPageDataLength: targetPageData.length,
|
|
||||||
validTargetDataLength: validTargetData.length,
|
|
||||||
expectedCount,
|
|
||||||
isTargetPageCached
|
|
||||||
});
|
|
||||||
|
|
||||||
// 1. 判断翻页类型:连续翻页(上一页/下一页)还是跳转翻页(点击页码/输入跳转)
|
|
||||||
const isSequentialNavigation = Math.abs(newPage - currentPage) === 1;
|
|
||||||
|
|
||||||
// 2. 计算预加载范围
|
|
||||||
let preloadRange;
|
|
||||||
if (isSequentialNavigation) {
|
|
||||||
// 连续翻页:前后各2页(共5页)
|
|
||||||
const start = Math.max(1, newPage - 2);
|
|
||||||
const end = Math.min(totalPages, newPage + 2);
|
|
||||||
preloadRange = Array.from(
|
|
||||||
{ length: end - start + 1 },
|
|
||||||
(_, i) => start + i
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// 跳转翻页:只加载当前页
|
|
||||||
preloadRange = [newPage];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 检查哪些页面的数据还未缓存(检查是否包含 null 或超出数组长度)
|
|
||||||
const missingPages = preloadRange.filter(page => {
|
|
||||||
const pageStartIndex = (page - 1) * pageSize;
|
|
||||||
const pageEndIndex = pageStartIndex + pageSize;
|
|
||||||
|
|
||||||
// 如果该页超出数组范围,说明未缓存
|
|
||||||
if (pageEndIndex > allCachedEvents.length) {
|
|
||||||
console.log(`[missingPages] 页面${page}超出数组范围`, {
|
|
||||||
pageStartIndex,
|
|
||||||
pageEndIndex,
|
|
||||||
allCachedEventsLength: allCachedEvents.length
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查该页的数据是否包含 null 占位符或数据不足
|
|
||||||
const pageData = allCachedEvents.slice(pageStartIndex, pageEndIndex);
|
|
||||||
const validData = pageData.filter(e => e !== null);
|
|
||||||
const expectedCount = Math.min(pageSize, total - pageStartIndex);
|
|
||||||
const hasNullOrIncomplete = validData.length < expectedCount;
|
|
||||||
|
|
||||||
console.log(`[missingPages] 页面${page}检查`, {
|
|
||||||
pageStartIndex,
|
|
||||||
pageEndIndex,
|
|
||||||
pageDataLength: pageData.length,
|
|
||||||
validDataLength: validData.length,
|
|
||||||
expectedCount,
|
|
||||||
hasNullOrIncomplete
|
|
||||||
});
|
|
||||||
|
|
||||||
return hasNullOrIncomplete;
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[handlePageChange] 缺失页面检测完成', {
|
|
||||||
preloadRange,
|
|
||||||
missingPages,
|
|
||||||
missingPagesCount: missingPages.length
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. 如果目标页已缓存,立即切换页码,然后在后台静默预加载其他页
|
|
||||||
if (isTargetPageCached && missingPages.length > 0 && hasMore) {
|
|
||||||
console.log('[DynamicNewsCard] 目标页已缓存,立即切换', {
|
|
||||||
currentPage,
|
|
||||||
newPage,
|
|
||||||
缺失页面: missingPages,
|
|
||||||
目标页已缓存: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// 立即切换页码(用户无感知延迟)
|
|
||||||
setCurrentPage(newPage);
|
|
||||||
|
|
||||||
// 在后台静默预加载其他缺失页面(拆分为单页请求)
|
|
||||||
try {
|
|
||||||
console.log('[DynamicNewsCard] 开始后台预加载', {
|
|
||||||
缺失页面: missingPages,
|
|
||||||
每页数量: pageSize
|
|
||||||
});
|
|
||||||
|
|
||||||
// 拆分为单页请求,避免 per_page 动态值导致后端返回空数据
|
|
||||||
for (const page of missingPages) {
|
|
||||||
await dispatch(fetchDynamicNews({
|
|
||||||
page: page,
|
|
||||||
per_page: pageSize, // 固定值(5或10),不使用动态计算
|
|
||||||
pageSize: pageSize,
|
|
||||||
clearCache: false
|
|
||||||
})).unwrap();
|
|
||||||
|
|
||||||
console.log(`[DynamicNewsCard] 后台预加载第 ${page} 页完成`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[DynamicNewsCard] 后台预加载全部完成', {
|
|
||||||
预加载页面: missingPages
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[DynamicNewsCard] 后台预加载失败', error);
|
|
||||||
// 静默失败,不影响用户体验
|
|
||||||
}
|
|
||||||
|
|
||||||
return; // 提前返回,不执行下面的加载逻辑
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 如果目标页未缓存,显示 loading 并等待加载完成
|
|
||||||
if (missingPages.length > 0 && hasMore) {
|
|
||||||
console.log('[DynamicNewsCard] 目标页未缓存,显示loading', {
|
|
||||||
currentPage,
|
|
||||||
newPage,
|
|
||||||
翻页类型: isSequentialNavigation ? '连续翻页' : '跳转翻页',
|
|
||||||
预加载范围: preloadRange,
|
|
||||||
缺失页面: missingPages,
|
|
||||||
每页数量: pageSize,
|
|
||||||
目标页已缓存: false
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 设置加载状态(显示"正在加载第X页...")
|
|
||||||
setLoadingPage(newPage);
|
|
||||||
|
|
||||||
// 拆分为单页请求,避免 per_page 动态值导致后端返回空数据
|
|
||||||
for (const page of missingPages) {
|
|
||||||
console.log(`[DynamicNewsCard] 开始加载第 ${page} 页`);
|
|
||||||
|
|
||||||
await dispatch(fetchDynamicNews({
|
|
||||||
page: page,
|
|
||||||
per_page: pageSize, // 固定值(5或10),不使用动态计算
|
|
||||||
pageSize: pageSize, // 传递原始 pageSize,用于正确计算索引
|
|
||||||
clearCache: false
|
|
||||||
})).unwrap();
|
|
||||||
|
|
||||||
console.log(`[DynamicNewsCard] 第 ${page} 页加载完成`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[DynamicNewsCard] 所有缺失页面加载完成', {
|
|
||||||
缺失页面: missingPages
|
|
||||||
});
|
|
||||||
|
|
||||||
// 数据加载成功后才更新当前页码
|
|
||||||
setCurrentPage(newPage);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[DynamicNewsCard] 翻页加载失败', error);
|
|
||||||
|
|
||||||
// 显示错误提示
|
|
||||||
toast({
|
|
||||||
title: '加载失败',
|
|
||||||
description: `无法加载第 ${newPage} 页数据,请稍后重试`,
|
|
||||||
status: 'error',
|
|
||||||
duration: 3000,
|
|
||||||
isClosable: true,
|
|
||||||
position: 'top'
|
|
||||||
});
|
|
||||||
|
|
||||||
// 加载失败时不更新页码,保持在当前页
|
|
||||||
} finally {
|
|
||||||
// 清除加载状态
|
|
||||||
setLoadingPage(null);
|
|
||||||
}
|
|
||||||
} else if (missingPages.length === 0) {
|
|
||||||
// 只有在确实不需要加载时才直接切换
|
|
||||||
console.log('[handlePageChange] 无需加载,直接切换', {
|
|
||||||
currentPage,
|
|
||||||
newPage,
|
|
||||||
preloadRange,
|
|
||||||
missingPages,
|
|
||||||
reason: '所有页面均已缓存'
|
|
||||||
});
|
|
||||||
setCurrentPage(newPage);
|
|
||||||
} else {
|
|
||||||
// 理论上不应该到这里(missingPages.length > 0 但 hasMore=false)
|
|
||||||
console.warn('[handlePageChange] 意外分支:有缺失页面但无法加载', {
|
|
||||||
missingPages,
|
|
||||||
hasMore,
|
|
||||||
currentPage,
|
|
||||||
newPage,
|
|
||||||
total,
|
|
||||||
cachedCount
|
|
||||||
});
|
|
||||||
|
|
||||||
// 尝试切换页码,但可能会显示空数据
|
|
||||||
setCurrentPage(newPage);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: '数据不完整',
|
|
||||||
description: `第 ${newPage} 页数据可能不完整`,
|
|
||||||
status: 'warning',
|
|
||||||
duration: 2000,
|
|
||||||
isClosable: true,
|
|
||||||
position: 'top'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [currentPage, allCachedEvents, pageSize, totalPages, hasMore, dispatch, total, toast, cachedCount]);
|
|
||||||
|
|
||||||
// 模式切换处理
|
|
||||||
const handleModeToggle = useCallback((newMode) => {
|
|
||||||
if (newMode === mode) return;
|
|
||||||
|
|
||||||
setMode(newMode);
|
|
||||||
setCurrentPage(1);
|
|
||||||
|
|
||||||
const newPageSize = newMode === 'carousel' ? 5 : 10;
|
|
||||||
|
|
||||||
// 检查第1页的数据是否完整(排除 null)
|
|
||||||
const firstPageData = allCachedEvents.slice(0, newPageSize);
|
|
||||||
const validFirstPageCount = firstPageData.filter(e => e !== null).length;
|
|
||||||
const needsRefetch = validFirstPageCount < Math.min(newPageSize, total);
|
|
||||||
|
|
||||||
if (needsRefetch) {
|
|
||||||
// 第1页数据不完整,清空缓存重新请求
|
|
||||||
dispatch(fetchDynamicNews({
|
|
||||||
page: 1,
|
|
||||||
per_page: newPageSize,
|
|
||||||
pageSize: newPageSize, // 传递 pageSize 确保索引计算一致
|
|
||||||
clearCache: true
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
// 如果第1页数据完整,不发起请求,直接切换
|
|
||||||
}, [mode, allCachedEvents, total, dispatch]);
|
|
||||||
|
|
||||||
// 初始加载
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (allCachedEvents.length === 0) {
|
if (!hasInitialized.current && allCachedEvents.length === 0) {
|
||||||
|
hasInitialized.current = true;
|
||||||
dispatch(fetchDynamicNews({
|
dispatch(fetchDynamicNews({
|
||||||
page: 1,
|
page: PAGINATION_CONFIG.INITIAL_PAGE,
|
||||||
per_page: 5,
|
per_page: PAGINATION_CONFIG.CAROUSEL_PAGE_SIZE,
|
||||||
pageSize: 5, // 传递 pageSize 确保索引计算一致
|
pageSize: PAGINATION_CONFIG.CAROUSEL_PAGE_SIZE, // 传递 pageSize 确保索引计算一致
|
||||||
clearCache: true
|
clearCache: true
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [dispatch, allCachedEvents.length]);
|
}, [dispatch, allCachedEvents.length]);
|
||||||
|
|
||||||
// 默认选中第一个事件
|
// 默认选中第一个事件 - 只在当前选中的事件不在当前页时重置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentPageEvents.length > 0 && !selectedEvent) {
|
if (currentPageEvents.length > 0) {
|
||||||
setSelectedEvent(currentPageEvents[0]);
|
// 检查当前选中的事件是否在当前页中
|
||||||
|
const selectedEventInCurrentPage = currentPageEvents.find(
|
||||||
|
e => e.id === selectedEvent?.id
|
||||||
|
);
|
||||||
|
|
||||||
|
// 如果选中的事件不在当前页,则选中当前页第一个事件
|
||||||
|
if (!selectedEventInCurrentPage) {
|
||||||
|
setSelectedEvent(currentPageEvents[0]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [currentPageEvents, selectedEvent]);
|
}, [currentPageEvents, selectedEvent?.id]);
|
||||||
|
|
||||||
|
// 组件卸载时清理选中状态
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setSelectedEvent(null);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
|
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
|
||||||
|
|||||||
24
src/views/Community/components/DynamicNewsCard/constants.js
Normal file
24
src/views/Community/components/DynamicNewsCard/constants.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// src/views/Community/components/DynamicNewsCard/constants.js
|
||||||
|
// 动态新闻卡片组件 - 常量配置
|
||||||
|
|
||||||
|
// ========== 分页配置常量 ==========
|
||||||
|
export const PAGINATION_CONFIG = {
|
||||||
|
CAROUSEL_PAGE_SIZE: 5, // 单排模式每页数量
|
||||||
|
GRID_PAGE_SIZE: 10, // 双排模式每页数量
|
||||||
|
INITIAL_PAGE: 1, // 初始页码
|
||||||
|
PRELOAD_RANGE: 2, // 预加载范围(前后各N页)
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 显示模式常量 ==========
|
||||||
|
export const DISPLAY_MODES = {
|
||||||
|
CAROUSEL: 'carousel', // 单排轮播模式
|
||||||
|
GRID: 'grid', // 双排网格模式
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_MODE = DISPLAY_MODES.CAROUSEL;
|
||||||
|
|
||||||
|
// ========== Toast 提示配置 ==========
|
||||||
|
export const TOAST_CONFIG = {
|
||||||
|
DURATION_ERROR: 3000, // 错误提示持续时间(毫秒)
|
||||||
|
DURATION_WARNING: 2000, // 警告提示持续时间(毫秒)
|
||||||
|
};
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
// src/views/Community/components/DynamicNewsCard/hooks/usePagination.js
|
||||||
|
// 分页逻辑自定义 Hook
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { fetchDynamicNews } from '../../../../../store/slices/communityDataSlice';
|
||||||
|
import { logger } from '../../../../../utils/logger';
|
||||||
|
import {
|
||||||
|
PAGINATION_CONFIG,
|
||||||
|
DISPLAY_MODES,
|
||||||
|
DEFAULT_MODE,
|
||||||
|
TOAST_CONFIG
|
||||||
|
} from '../constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页逻辑自定义 Hook
|
||||||
|
* @param {Object} options - Hook 配置选项
|
||||||
|
* @param {Array} options.allCachedEvents - 完整缓存事件列表
|
||||||
|
* @param {number} options.total - 服务端总数量
|
||||||
|
* @param {number} options.cachedCount - 已缓存数量
|
||||||
|
* @param {Function} options.dispatch - Redux dispatch 函数
|
||||||
|
* @param {Function} options.toast - Toast 通知函数
|
||||||
|
* @returns {Object} 分页状态和方法
|
||||||
|
*/
|
||||||
|
export const usePagination = ({ allCachedEvents, total, cachedCount, dispatch, toast }) => {
|
||||||
|
// 组件挂载状态跟踪 - 用于防止内存泄漏
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
|
// 本地状态
|
||||||
|
const [currentPage, setCurrentPage] = useState(PAGINATION_CONFIG.INITIAL_PAGE);
|
||||||
|
const [loadingPage, setLoadingPage] = useState(null);
|
||||||
|
const [mode, setMode] = useState(DEFAULT_MODE);
|
||||||
|
|
||||||
|
// 组件卸载时更新挂载状态
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 根据模式决定每页显示数量
|
||||||
|
const pageSize = mode === DISPLAY_MODES.CAROUSEL
|
||||||
|
? PAGINATION_CONFIG.CAROUSEL_PAGE_SIZE
|
||||||
|
: PAGINATION_CONFIG.GRID_PAGE_SIZE;
|
||||||
|
|
||||||
|
// 计算总页数(基于服务端总数据量)
|
||||||
|
const totalPages = Math.ceil(total / pageSize) || 1;
|
||||||
|
|
||||||
|
// 检查是否还有更多数据
|
||||||
|
const hasMore = cachedCount < total;
|
||||||
|
|
||||||
|
// 从缓存中切片获取当前页数据(过滤 null 占位符)
|
||||||
|
const currentPageEvents = useMemo(() => {
|
||||||
|
const startIndex = (currentPage - 1) * pageSize;
|
||||||
|
const endIndex = startIndex + pageSize;
|
||||||
|
return allCachedEvents.slice(startIndex, endIndex).filter(event => event !== null);
|
||||||
|
}, [allCachedEvents, currentPage, pageSize]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子函数1: 检查目标页缓存状态
|
||||||
|
* @param {number} targetPage - 目标页码
|
||||||
|
* @returns {Object} { isTargetPageCached, targetPageInfo }
|
||||||
|
*/
|
||||||
|
const checkTargetPageCache = useCallback((targetPage) => {
|
||||||
|
const targetPageStartIndex = (targetPage - 1) * pageSize;
|
||||||
|
const targetPageEndIndex = targetPageStartIndex + pageSize;
|
||||||
|
const targetPageData = allCachedEvents.slice(targetPageStartIndex, targetPageEndIndex);
|
||||||
|
const validTargetData = targetPageData.filter(e => e !== null);
|
||||||
|
const expectedCount = Math.min(pageSize, total - targetPageStartIndex);
|
||||||
|
const isTargetPageCached = validTargetData.length >= expectedCount;
|
||||||
|
|
||||||
|
logger.debug('DynamicNewsCard', '目标页缓存检查', {
|
||||||
|
targetPage,
|
||||||
|
targetPageStartIndex,
|
||||||
|
targetPageEndIndex,
|
||||||
|
targetPageDataLength: targetPageData.length,
|
||||||
|
validTargetDataLength: validTargetData.length,
|
||||||
|
expectedCount,
|
||||||
|
isTargetPageCached
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isTargetPageCached,
|
||||||
|
targetPageInfo: {
|
||||||
|
startIndex: targetPageStartIndex,
|
||||||
|
endIndex: targetPageEndIndex,
|
||||||
|
validCount: validTargetData.length,
|
||||||
|
expectedCount
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [allCachedEvents, pageSize, total]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子函数2: 计算预加载范围
|
||||||
|
* @param {number} targetPage - 目标页码
|
||||||
|
* @param {number} fromPage - 来源页码
|
||||||
|
* @returns {Array<number>} 预加载页码数组
|
||||||
|
*/
|
||||||
|
const calculatePreloadRange = useCallback((targetPage, fromPage) => {
|
||||||
|
const isSequentialNavigation = Math.abs(targetPage - fromPage) === 1;
|
||||||
|
|
||||||
|
let preloadRange;
|
||||||
|
if (isSequentialNavigation) {
|
||||||
|
// 连续翻页:前后各N页(N = PRELOAD_RANGE)
|
||||||
|
const start = Math.max(1, targetPage - PAGINATION_CONFIG.PRELOAD_RANGE);
|
||||||
|
const end = Math.min(totalPages, targetPage + PAGINATION_CONFIG.PRELOAD_RANGE);
|
||||||
|
preloadRange = Array.from(
|
||||||
|
{ length: end - start + 1 },
|
||||||
|
(_, i) => start + i
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 跳转翻页:只加载当前页
|
||||||
|
preloadRange = [targetPage];
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('DynamicNewsCard', '计算预加载范围', {
|
||||||
|
targetPage,
|
||||||
|
fromPage,
|
||||||
|
isSequentialNavigation,
|
||||||
|
preloadRange
|
||||||
|
});
|
||||||
|
|
||||||
|
return preloadRange;
|
||||||
|
}, [totalPages]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子函数3: 查找缺失页面
|
||||||
|
* @param {Array<number>} preloadRange - 预加载范围
|
||||||
|
* @returns {Array<number>} 缺失页码数组
|
||||||
|
*/
|
||||||
|
const findMissingPages = useCallback((preloadRange) => {
|
||||||
|
const missingPages = preloadRange.filter(page => {
|
||||||
|
const pageStartIndex = (page - 1) * pageSize;
|
||||||
|
const pageEndIndex = pageStartIndex + pageSize;
|
||||||
|
|
||||||
|
// 如果该页超出数组范围,说明未缓存
|
||||||
|
if (pageEndIndex > allCachedEvents.length) {
|
||||||
|
logger.debug('DynamicNewsCard', `页面${page}超出数组范围`, {
|
||||||
|
pageStartIndex,
|
||||||
|
pageEndIndex,
|
||||||
|
allCachedEventsLength: allCachedEvents.length
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查该页的数据是否包含 null 占位符或数据不足
|
||||||
|
const pageData = allCachedEvents.slice(pageStartIndex, pageEndIndex);
|
||||||
|
const validData = pageData.filter(e => e !== null);
|
||||||
|
const expectedCount = Math.min(pageSize, total - pageStartIndex);
|
||||||
|
const hasNullOrIncomplete = validData.length < expectedCount;
|
||||||
|
|
||||||
|
logger.debug('DynamicNewsCard', `页面${page}数据检查`, {
|
||||||
|
pageStartIndex,
|
||||||
|
pageEndIndex,
|
||||||
|
pageDataLength: pageData.length,
|
||||||
|
validDataLength: validData.length,
|
||||||
|
expectedCount,
|
||||||
|
hasNullOrIncomplete
|
||||||
|
});
|
||||||
|
|
||||||
|
return hasNullOrIncomplete;
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('DynamicNewsCard', '缺失页面检测完成', {
|
||||||
|
preloadRange,
|
||||||
|
missingPages,
|
||||||
|
missingPagesCount: missingPages.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return missingPages;
|
||||||
|
}, [allCachedEvents, pageSize, total]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子函数4: 加载页面数据
|
||||||
|
* @param {Array<number>} missingPages - 缺失页码数组
|
||||||
|
* @param {number} targetPage - 目标页码
|
||||||
|
* @param {boolean} silentMode - 静默模式(后台预加载)
|
||||||
|
* @returns {Promise<boolean>} 是否加载成功
|
||||||
|
*/
|
||||||
|
const loadPages = useCallback(async (missingPages, targetPage, silentMode = false) => {
|
||||||
|
// 检查组件是否已卸载
|
||||||
|
if (!isMountedRef.current) {
|
||||||
|
logger.debug('DynamicNewsCard', '组件已卸载,取消加载');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!silentMode) {
|
||||||
|
// 显示 loading 状态
|
||||||
|
setLoadingPage(targetPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.debug('DynamicNewsCard', '开始加载页面数据', {
|
||||||
|
missingPages,
|
||||||
|
targetPage,
|
||||||
|
silentMode,
|
||||||
|
pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
// 拆分为单页请求,避免 per_page 动态值导致后端返回空数据
|
||||||
|
for (const page of missingPages) {
|
||||||
|
// 每次请求前检查组件是否已卸载
|
||||||
|
if (!isMountedRef.current) {
|
||||||
|
logger.debug('DynamicNewsCard', '组件已卸载,中止加载');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('DynamicNewsCard', `开始加载第 ${page} 页`);
|
||||||
|
|
||||||
|
await dispatch(fetchDynamicNews({
|
||||||
|
page: page,
|
||||||
|
per_page: pageSize, // 固定值(5或10),不使用动态计算
|
||||||
|
pageSize: pageSize,
|
||||||
|
clearCache: false
|
||||||
|
})).unwrap();
|
||||||
|
|
||||||
|
logger.debug('DynamicNewsCard', `第 ${page} 页加载完成`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('DynamicNewsCard', '所有页面加载完成', {
|
||||||
|
missingPages,
|
||||||
|
silentMode
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('DynamicNewsCard', 'loadPages', error, {
|
||||||
|
targetPage,
|
||||||
|
silentMode,
|
||||||
|
missingPages
|
||||||
|
});
|
||||||
|
|
||||||
|
// 只在组件仍挂载时显示错误提示
|
||||||
|
if (!silentMode && isMountedRef.current) {
|
||||||
|
toast({
|
||||||
|
title: '加载失败',
|
||||||
|
description: `无法加载第 ${targetPage} 页数据,请稍后重试`,
|
||||||
|
status: 'error',
|
||||||
|
duration: TOAST_CONFIG.DURATION_ERROR,
|
||||||
|
isClosable: true,
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
// 只在组件仍挂载时清除加载状态
|
||||||
|
if (!silentMode && isMountedRef.current) {
|
||||||
|
setLoadingPage(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [dispatch, pageSize, toast]);
|
||||||
|
|
||||||
|
// 翻页处理(智能预加载)- 使用子函数重构
|
||||||
|
const handlePageChange = useCallback(async (newPage) => {
|
||||||
|
// 检查组件是否已卸载
|
||||||
|
if (!isMountedRef.current) {
|
||||||
|
logger.debug('DynamicNewsCard', '组件已卸载,取消翻页');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔍 诊断日志 - 记录翻页开始状态
|
||||||
|
logger.debug('DynamicNewsCard', '开始翻页', {
|
||||||
|
currentPage,
|
||||||
|
newPage,
|
||||||
|
pageSize,
|
||||||
|
totalPages,
|
||||||
|
hasMore,
|
||||||
|
total,
|
||||||
|
allCachedEventsLength: allCachedEvents.length,
|
||||||
|
cachedCount
|
||||||
|
});
|
||||||
|
|
||||||
|
// 步骤1: 检查目标页缓存状态
|
||||||
|
const { isTargetPageCached } = checkTargetPageCache(newPage);
|
||||||
|
|
||||||
|
// 步骤2: 计算预加载范围
|
||||||
|
const preloadRange = calculatePreloadRange(newPage, currentPage);
|
||||||
|
|
||||||
|
// 步骤3: 查找缺失页面
|
||||||
|
const missingPages = findMissingPages(preloadRange);
|
||||||
|
|
||||||
|
// 步骤4: 根据情况加载数据
|
||||||
|
if (isTargetPageCached && missingPages.length > 0 && hasMore) {
|
||||||
|
// 场景A: 目标页已缓存,立即切换,后台静默预加载其他页
|
||||||
|
logger.debug('DynamicNewsCard', '目标页已缓存,立即切换 + 后台预加载', {
|
||||||
|
currentPage,
|
||||||
|
newPage,
|
||||||
|
缺失页面: missingPages
|
||||||
|
});
|
||||||
|
|
||||||
|
// 只在组件仍挂载时更新状态
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setCurrentPage(newPage);
|
||||||
|
}
|
||||||
|
await loadPages(missingPages, newPage, true); // 静默模式
|
||||||
|
} else if (missingPages.length > 0 && hasMore) {
|
||||||
|
// 场景B: 目标页未缓存,显示 loading 并等待加载完成
|
||||||
|
logger.debug('DynamicNewsCard', '目标页未缓存,显示 loading', {
|
||||||
|
currentPage,
|
||||||
|
newPage,
|
||||||
|
缺失页面: missingPages
|
||||||
|
});
|
||||||
|
|
||||||
|
const success = await loadPages(missingPages, newPage, false); // 非静默模式
|
||||||
|
// 只在加载成功且组件仍挂载时更新状态
|
||||||
|
if (success && isMountedRef.current) {
|
||||||
|
setCurrentPage(newPage);
|
||||||
|
}
|
||||||
|
} else if (missingPages.length === 0) {
|
||||||
|
// 场景C: 所有页面均已缓存,直接切换
|
||||||
|
logger.debug('DynamicNewsCard', '无需加载,直接切换', {
|
||||||
|
currentPage,
|
||||||
|
newPage,
|
||||||
|
reason: '所有页面均已缓存'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 只在组件仍挂载时更新状态
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setCurrentPage(newPage);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 场景D: 意外分支(有缺失页面但 hasMore=false)
|
||||||
|
logger.warn('DynamicNewsCard', '意外分支:有缺失页面但无法加载', {
|
||||||
|
missingPages,
|
||||||
|
hasMore,
|
||||||
|
currentPage,
|
||||||
|
newPage,
|
||||||
|
total,
|
||||||
|
cachedCount
|
||||||
|
});
|
||||||
|
|
||||||
|
// 只在组件仍挂载时更新状态
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setCurrentPage(newPage);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: '数据不完整',
|
||||||
|
description: `第 ${newPage} 页数据可能不完整`,
|
||||||
|
status: 'warning',
|
||||||
|
duration: TOAST_CONFIG.DURATION_WARNING,
|
||||||
|
isClosable: true,
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
currentPage,
|
||||||
|
pageSize,
|
||||||
|
totalPages,
|
||||||
|
hasMore,
|
||||||
|
total,
|
||||||
|
allCachedEvents.length,
|
||||||
|
cachedCount,
|
||||||
|
checkTargetPageCache,
|
||||||
|
calculatePreloadRange,
|
||||||
|
findMissingPages,
|
||||||
|
loadPages,
|
||||||
|
toast
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 模式切换处理
|
||||||
|
const handleModeToggle = useCallback((newMode) => {
|
||||||
|
if (newMode === mode) return;
|
||||||
|
|
||||||
|
setMode(newMode);
|
||||||
|
setCurrentPage(PAGINATION_CONFIG.INITIAL_PAGE);
|
||||||
|
|
||||||
|
const newPageSize = newMode === DISPLAY_MODES.CAROUSEL
|
||||||
|
? PAGINATION_CONFIG.CAROUSEL_PAGE_SIZE
|
||||||
|
: PAGINATION_CONFIG.GRID_PAGE_SIZE;
|
||||||
|
|
||||||
|
// 检查第1页的数据是否完整(排除 null)
|
||||||
|
const firstPageData = allCachedEvents.slice(0, newPageSize);
|
||||||
|
const validFirstPageCount = firstPageData.filter(e => e !== null).length;
|
||||||
|
const needsRefetch = validFirstPageCount < Math.min(newPageSize, total);
|
||||||
|
|
||||||
|
if (needsRefetch) {
|
||||||
|
// 第1页数据不完整,清空缓存重新请求
|
||||||
|
dispatch(fetchDynamicNews({
|
||||||
|
page: 1,
|
||||||
|
per_page: newPageSize,
|
||||||
|
pageSize: newPageSize, // 传递 pageSize 确保索引计算一致
|
||||||
|
clearCache: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// 如果第1页数据完整,不发起请求,直接切换
|
||||||
|
}, [mode, allCachedEvents, total, dispatch]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
currentPage,
|
||||||
|
mode,
|
||||||
|
loadingPage,
|
||||||
|
pageSize,
|
||||||
|
totalPages,
|
||||||
|
hasMore,
|
||||||
|
currentPageEvents,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
handlePageChange,
|
||||||
|
handleModeToggle
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
|
||||||
HStack,
|
HStack,
|
||||||
Text,
|
Text,
|
||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
@@ -18,8 +17,6 @@ import moment from 'moment';
|
|||||||
* @param {string|Object} props.eventTime - 事件发生时间
|
* @param {string|Object} props.eventTime - 事件发生时间
|
||||||
*/
|
*/
|
||||||
const TradingDateInfo = ({ effectiveTradingDate, eventTime }) => {
|
const TradingDateInfo = ({ effectiveTradingDate, eventTime }) => {
|
||||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
|
||||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
|
||||||
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
|
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
|
||||||
|
|
||||||
if (!effectiveTradingDate) {
|
if (!effectiveTradingDate) {
|
||||||
@@ -27,19 +24,17 @@ const TradingDateInfo = ({ effectiveTradingDate, eventTime }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box mb={4} p={3} bg={sectionBg} borderRadius="md">
|
<HStack spacing={2}>
|
||||||
<HStack spacing={2}>
|
<FaCalendarAlt color="gray" size={12} />
|
||||||
<FaCalendarAlt color="gray" />
|
<Text fontSize="xs" color={stockCountColor}>
|
||||||
<Text fontSize="sm" color={headingColor}>
|
涨跌幅数据:{effectiveTradingDate}
|
||||||
涨跌幅数据日期:{effectiveTradingDate}
|
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && (
|
||||||
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && (
|
<Text as="span" ml={2} fontSize="xs" color={stockCountColor}>
|
||||||
<Text as="span" ml={2} fontSize="xs" color={stockCountColor}>
|
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : moment(eventTime).format('YYYY-MM-DD HH:mm')},显示下一交易日数据)
|
||||||
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : moment(eventTime).format('YYYY-MM-DD HH:mm')},显示下一交易日数据)
|
</Text>
|
||||||
</Text>
|
)}
|
||||||
)}
|
</Text>
|
||||||
</Text>
|
</HStack>
|
||||||
</HStack>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -204,9 +204,16 @@ const RelatedConceptsSection = ({ eventTitle, effectiveTradingDate, eventTime })
|
|||||||
<Box bg={sectionBg} p={3} borderRadius="md">
|
<Box bg={sectionBg} p={3} borderRadius="md">
|
||||||
{/* 标题栏 */}
|
{/* 标题栏 */}
|
||||||
<Flex justify="space-between" align="center" mb={3}>
|
<Flex justify="space-between" align="center" mb={3}>
|
||||||
<Heading size="sm" color={headingColor}>
|
<Flex align="center" gap={3}>
|
||||||
相关概念
|
<Heading size="sm" color={headingColor}>
|
||||||
</Heading>
|
相关概念
|
||||||
|
</Heading>
|
||||||
|
{/* 交易日期信息 */}
|
||||||
|
<TradingDateInfo
|
||||||
|
effectiveTradingDate={effectiveTradingDate}
|
||||||
|
eventTime={eventTime}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -229,12 +236,7 @@ const RelatedConceptsSection = ({ eventTitle, effectiveTradingDate, eventTime })
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* 交易日期信息 */}
|
|
||||||
<TradingDateInfo
|
|
||||||
effectiveTradingDate={effectiveTradingDate}
|
|
||||||
eventTime={eventTime}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 详细模式:卡片网格(可折叠) */}
|
{/* 详细模式:卡片网格(可折叠) */}
|
||||||
<Collapse in={isExpanded} animateOpacity>
|
<Collapse in={isExpanded} animateOpacity>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const UnifiedSearchBox = ({
|
|||||||
|
|
||||||
// 筛选条件状态
|
// 筛选条件状态
|
||||||
const [sort, setSort] = useState('new'); // 排序方式
|
const [sort, setSort] = useState('new'); // 排序方式
|
||||||
const [importance, setImportance] = useState('all'); // 重要性
|
const [importance, setImportance] = useState([]); // 重要性(数组,支持多选)
|
||||||
const [dateRange, setDateRange] = useState(null); // 日期范围
|
const [dateRange, setDateRange] = useState(null); // 日期范围
|
||||||
|
|
||||||
// ✅ 本地输入状态 - 管理用户的实时输入
|
// ✅ 本地输入状态 - 管理用户的实时输入
|
||||||
@@ -129,9 +129,22 @@ const UnifiedSearchBox = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!filters) return;
|
if (!filters) return;
|
||||||
|
|
||||||
// 初始化排序和重要性
|
// 初始化排序
|
||||||
if (filters.sort) setSort(filters.sort);
|
if (filters.sort) setSort(filters.sort);
|
||||||
if (filters.importance) setImportance(filters.importance);
|
|
||||||
|
// 初始化重要性(字符串解析为数组)
|
||||||
|
if (filters.importance) {
|
||||||
|
const importanceArray = filters.importance === 'all'
|
||||||
|
? [] // 'all' 对应空数组(不显示任何选中)
|
||||||
|
: filters.importance.split(',').map(v => v.trim()).filter(Boolean);
|
||||||
|
setImportance(importanceArray);
|
||||||
|
logger.debug('UnifiedSearchBox', '初始化重要性', {
|
||||||
|
filters_importance: filters.importance,
|
||||||
|
importanceArray
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setImportance([]);
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ 初始化日期范围
|
// ✅ 初始化日期范围
|
||||||
if (filters.date_range) {
|
if (filters.date_range) {
|
||||||
@@ -247,30 +260,28 @@ const UnifiedSearchBox = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ✅ 重要性变化(使用防抖)
|
// ✅ 重要性变化(立即执行)- 支持多选
|
||||||
const handleImportanceChange = (value) => {
|
const handleImportanceChange = (value) => {
|
||||||
logger.debug('UnifiedSearchBox', '【1/5】重要性值改变', {
|
logger.debug('UnifiedSearchBox', '重要性值改变', {
|
||||||
oldValue: importance,
|
oldValue: importance,
|
||||||
newValue: value
|
newValue: value
|
||||||
});
|
});
|
||||||
|
|
||||||
setImportance(value);
|
setImportance(value);
|
||||||
|
|
||||||
// ⚠️ 注意:setState是异步的,此时importance仍是旧值
|
// 取消之前的防抖搜索
|
||||||
logger.debug('UnifiedSearchBox', '【2/5】调用buildFilterParams前的状态', {
|
|
||||||
importance: importance, // 旧值
|
|
||||||
sort: sort,
|
|
||||||
dateRange: dateRange,
|
|
||||||
industryValue: industryValue
|
|
||||||
});
|
|
||||||
|
|
||||||
// 使用防抖搜索
|
|
||||||
const params = buildFilterParams({ importance: value });
|
|
||||||
logger.debug('UnifiedSearchBox', '【3/5】buildFilterParams返回的参数', params);
|
|
||||||
|
|
||||||
if (debouncedSearchRef.current) {
|
if (debouncedSearchRef.current) {
|
||||||
logger.debug('UnifiedSearchBox', '【4/5】调用防抖函数(300ms延迟)');
|
debouncedSearchRef.current.cancel();
|
||||||
debouncedSearchRef.current(params);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 转换为逗号分隔字符串传给后端(空数组表示"全部")
|
||||||
|
const importanceStr = value.length === 0 ? 'all' : value.join(',');
|
||||||
|
|
||||||
|
// 立即触发搜索
|
||||||
|
const params = buildFilterParams({ importance: importanceStr });
|
||||||
|
logger.debug('UnifiedSearchBox', '重要性改变,立即触发搜索', params);
|
||||||
|
|
||||||
|
triggerSearch(params);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ✅ 排序变化(使用防抖)
|
// ✅ 排序变化(使用防抖)
|
||||||
@@ -388,10 +399,31 @@ const UnifiedSearchBox = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 处理排序参数 - 将 returns_avg/returns_week 转换为 sort=returns + return_type
|
||||||
|
const sortValue = overrides.sort ?? sort;
|
||||||
|
let actualSort = sortValue;
|
||||||
|
let returnType;
|
||||||
|
|
||||||
|
if (sortValue === 'returns_avg') {
|
||||||
|
actualSort = 'returns';
|
||||||
|
returnType = 'avg';
|
||||||
|
} else if (sortValue === 'returns_week') {
|
||||||
|
actualSort = 'returns';
|
||||||
|
returnType = 'week';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理重要性参数:数组转换为逗号分隔字符串
|
||||||
|
let importanceValue = overrides.importance ?? importance;
|
||||||
|
if (Array.isArray(importanceValue)) {
|
||||||
|
importanceValue = importanceValue.length === 0
|
||||||
|
? 'all'
|
||||||
|
: importanceValue.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
// 基础参数(overrides 优先级高于本地状态)
|
// 基础参数(overrides 优先级高于本地状态)
|
||||||
sort: overrides.sort ?? sort,
|
sort: actualSort,
|
||||||
importance: overrides.importance ?? importance,
|
importance: importanceValue,
|
||||||
date_range: dateRange ? `${dateRange[0].format('YYYY-MM-DD')} 至 ${dateRange[1].format('YYYY-MM-DD')}` : '',
|
date_range: dateRange ? `${dateRange[0].format('YYYY-MM-DD')} 至 ${dateRange[1].format('YYYY-MM-DD')}` : '',
|
||||||
page: 1,
|
page: 1,
|
||||||
|
|
||||||
@@ -404,6 +436,11 @@ const UnifiedSearchBox = ({
|
|||||||
...overrides
|
...overrides
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 添加 return_type 参数(如果需要)
|
||||||
|
if (returnType) {
|
||||||
|
result.return_type = returnType;
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输出结果', result);
|
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输出结果', result);
|
||||||
return result;
|
return result;
|
||||||
}, [sort, importance, dateRange, filters.q, industryValue]);
|
}, [sort, importance, dateRange, filters.q, industryValue]);
|
||||||
@@ -427,7 +464,7 @@ const UnifiedSearchBox = ({
|
|||||||
setStockOptions([]);
|
setStockOptions([]);
|
||||||
setIndustryValue([]);
|
setIndustryValue([]);
|
||||||
setSort('new');
|
setSort('new');
|
||||||
setImportance('all');
|
setImportance([]); // 改为空数组
|
||||||
setDateRange(null);
|
setDateRange(null);
|
||||||
|
|
||||||
// 输出重置后的完整参数
|
// 输出重置后的完整参数
|
||||||
@@ -435,7 +472,7 @@ const UnifiedSearchBox = ({
|
|||||||
q: '',
|
q: '',
|
||||||
industry_code: '',
|
industry_code: '',
|
||||||
sort: 'new',
|
sort: 'new',
|
||||||
importance: 'all',
|
importance: 'all', // 传给后端时转为'all'
|
||||||
date_range: '',
|
date_range: '',
|
||||||
page: 1
|
page: 1
|
||||||
};
|
};
|
||||||
@@ -465,14 +502,20 @@ const UnifiedSearchBox = ({
|
|||||||
tags.push({ key: 'date_range', label: `日期: ${dateLabel}` });
|
tags.push({ key: 'date_range', label: `日期: ${dateLabel}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重要性标签(排除默认值 'all')
|
// 重要性标签(多选合并显示为单个标签)
|
||||||
if (importance && importance !== 'all') {
|
if (importance && importance.length > 0) {
|
||||||
tags.push({ key: 'importance', label: `重要性: ${importance}级` });
|
const importanceLabel = importance.map(imp => `${imp}级`).join(', ');
|
||||||
|
tags.push({ key: 'importance', label: `重要性: ${importanceLabel}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 排序标签(排除默认值 'new')
|
// 排序标签(排除默认值 'new')
|
||||||
if (sort && sort !== 'new') {
|
if (sort && sort !== 'new') {
|
||||||
const sortLabel = sort === 'hot' ? '最热' : sort === 'importance' ? '重要性' : sort;
|
let sortLabel;
|
||||||
|
if (sort === 'hot') sortLabel = '最热';
|
||||||
|
else if (sort === 'importance') sortLabel = '重要性';
|
||||||
|
else if (sort === 'returns_avg') sortLabel = '平均收益率';
|
||||||
|
else if (sort === 'returns_week') sortLabel = '周收益率';
|
||||||
|
else sortLabel = sort;
|
||||||
tags.push({ key: 'sort', label: `排序: ${sortLabel}` });
|
tags.push({ key: 'sort', label: `排序: ${sortLabel}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,6 +526,11 @@ const UnifiedSearchBox = ({
|
|||||||
const handleRemoveTag = (key) => {
|
const handleRemoveTag = (key) => {
|
||||||
logger.debug('UnifiedSearchBox', '移除标签', { key });
|
logger.debug('UnifiedSearchBox', '移除标签', { key });
|
||||||
|
|
||||||
|
// 取消所有待执行的防抖搜索(避免旧的防抖覆盖删除操作)
|
||||||
|
if (debouncedSearchRef.current) {
|
||||||
|
debouncedSearchRef.current.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
if (key === 'search') {
|
if (key === 'search') {
|
||||||
// 清除搜索关键词和输入框,立即触发搜索
|
// 清除搜索关键词和输入框,立即触发搜索
|
||||||
setInputValue(''); // 清空输入框
|
setInputValue(''); // 清空输入框
|
||||||
@@ -500,8 +548,8 @@ const UnifiedSearchBox = ({
|
|||||||
const params = buildFilterParams({ date_range: '' });
|
const params = buildFilterParams({ date_range: '' });
|
||||||
triggerSearch(params);
|
triggerSearch(params);
|
||||||
} else if (key === 'importance') {
|
} else if (key === 'importance') {
|
||||||
// 重置重要性为默认值
|
// 重置重要性为空数组(传给后端为'all')
|
||||||
setImportance('all');
|
setImportance([]);
|
||||||
const params = buildFilterParams({ importance: 'all' });
|
const params = buildFilterParams({ importance: 'all' });
|
||||||
triggerSearch(params);
|
triggerSearch(params);
|
||||||
} else if (key === 'sort') {
|
} else if (key === 'sort') {
|
||||||
@@ -601,12 +649,14 @@ const UnifiedSearchBox = ({
|
|||||||
<Space size="small">
|
<Space size="small">
|
||||||
<span style={{ fontSize: 14, color: '#666' }}>重要性:</span>
|
<span style={{ fontSize: 14, color: '#666' }}>重要性:</span>
|
||||||
<AntSelect
|
<AntSelect
|
||||||
|
mode="multiple"
|
||||||
value={importance}
|
value={importance}
|
||||||
onChange={handleImportanceChange}
|
onChange={handleImportanceChange}
|
||||||
style={{ width: 100 }}
|
style={{ width: 150 }}
|
||||||
size="middle"
|
size="middle"
|
||||||
|
placeholder="全部"
|
||||||
|
maxTagCount={3}
|
||||||
>
|
>
|
||||||
<Option value="all">全部</Option>
|
|
||||||
<Option value="S">S级</Option>
|
<Option value="S">S级</Option>
|
||||||
<Option value="A">A级</Option>
|
<Option value="A">A级</Option>
|
||||||
<Option value="B">B级</Option>
|
<Option value="B">B级</Option>
|
||||||
@@ -663,6 +713,8 @@ const UnifiedSearchBox = ({
|
|||||||
<Option value="new">最新</Option>
|
<Option value="new">最新</Option>
|
||||||
<Option value="hot">最热</Option>
|
<Option value="hot">最热</Option>
|
||||||
<Option value="importance">重要性</Option>
|
<Option value="importance">重要性</Option>
|
||||||
|
<Option value="returns_avg">平均收益率</Option>
|
||||||
|
<Option value="returns_week">周收益率</Option>
|
||||||
</AntSelect>
|
</AntSelect>
|
||||||
</Space>
|
</Space>
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
Reference in New Issue
Block a user