From d34c314636fc8dd4bfe3544124e60004fd8d2808 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Thu, 4 Dec 2025 23:14:28 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A6=96=E9=A1=B5/api/favori?= =?UTF-8?q?tes=E6=8E=A5=E5=8F=A3=E9=87=8D=E5=A4=8D=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/page.tsx | 276 ++++++++++++++--------------------------- src/lib/db.client.ts | 289 ++++++++++++++++++++++++------------------- 2 files changed, 256 insertions(+), 309 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 8c82eb5..c11bc16 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,7 +4,7 @@ import { ChevronRight } from 'lucide-react'; import Link from 'next/link'; -import { Suspense, useEffect, useState } from 'react'; +import { Suspense, useCallback, useEffect, useRef, useState } from 'react'; import { BangumiCalendarData, @@ -66,6 +66,7 @@ function HomeClient() { }; const [favoriteItems, setFavoriteItems] = useState([]); + const favoritesFetchedRef = useRef(false); useEffect(() => { const fetchRecommendData = async () => { @@ -109,7 +110,7 @@ function HomeClient() { }, []); // 处理收藏数据更新的函数 - const updateFavoriteItems = async (allFavorites: Record) => { + const updateFavoriteItems = useCallback(async (allFavorites: Record) => { const allPlayRecords = await getAllPlayRecords(); // 根据保存时间排序(从近到远) @@ -138,11 +139,19 @@ function HomeClient() { } as FavoriteItem; }); setFavoriteItems(sorted); - }; + }, []); - // 当切换到收藏夹时加载收藏数据 + // 当切换到收藏夹时加载收藏数据(使用 ref 防止重复加载) useEffect(() => { - if (activeTab !== 'favorites') return; + if (activeTab !== 'favorites') { + favoritesFetchedRef.current = false; + return; + } + + // 已经加载过就不再加载 + if (favoritesFetchedRef.current) return; + + favoritesFetchedRef.current = true; const loadFavorites = async () => { const allFavorites = await getAllFavorites(); @@ -150,8 +159,12 @@ function HomeClient() { }; loadFavorites(); + }, [activeTab, updateFavoriteItems]); + + // 监听收藏更新事件(独立的 useEffect) + useEffect(() => { + if (activeTab !== 'favorites') return; - // 监听收藏更新事件 const unsubscribe = subscribeToDataUpdates( 'favoritesUpdated', (newFavorites: Record) => { @@ -160,7 +173,7 @@ function HomeClient() { ); return unsubscribe; - }, [activeTab]); + }, [activeTab, updateFavoriteItems]); const handleCloseAnnouncement = (announcement: string) => { setShowAnnouncement(false); @@ -248,26 +261,22 @@ function HomeClient() { key={index} className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44' > -
-
-
-
+
+
)) - : // 显示真实数据 - hotMovies.map((movie, index) => ( + : hotMovies.map((movie) => (
))} @@ -290,113 +299,33 @@ function HomeClient() {
{loading - ? // 加载状态显示灰色占位数据 - Array.from({ length: 8 }).map((_, index) => ( + ? Array.from({ length: 8 }).map((_, index) => (
-
-
-
-
+
+
)) - : // 显示真实数据 - hotTvShows.map((show, index) => ( + : hotTvShows.map((tvShow) => (
))} - {/* 每日新番放送 */} -
-
-

- 新番放送 -

- - 查看更多 - - -
- - {loading - ? // 加载状态显示灰色占位数据 - Array.from({ length: 8 }).map((_, index) => ( -
-
-
-
-
-
- )) - : // 展示当前日期的番剧 - (() => { - // 获取当前日期对应的星期 - const today = new Date(); - const weekdays = [ - 'Sun', - 'Mon', - 'Tue', - 'Wed', - 'Thu', - 'Fri', - 'Sat', - ]; - const currentWeekday = weekdays[today.getDay()]; - - // 找到当前星期对应的番剧数据 - const todayAnimes = - bangumiCalendarData.find( - (item) => item.weekday.en === currentWeekday - )?.items || []; - - return todayAnimes.map((anime, index) => ( -
- -
- )); - })()} -
-
- {/* 热门综艺 */}
@@ -404,7 +333,7 @@ function HomeClient() { 热门综艺 查看更多 @@ -413,100 +342,87 @@ function HomeClient() {
{loading - ? // 加载状态显示灰色占位数据 - Array.from({ length: 8 }).map((_, index) => ( + ? Array.from({ length: 8 }).map((_, index) => (
-
-
-
-
+
+
)) - : // 显示真实数据 - hotVarietyShows.map((show, index) => ( + : hotVarietyShows.map((varietyShow) => (
))}
+ + {/* 番剧时间表 */} + {bangumiCalendarData.length > 0 && ( +
+
+

+ 番剧时间表 +

+
+ {bangumiCalendarData.map((day) => ( +
+

+ {day.weekday.en} +

+ + {day.items.map((item) => ( +
+ +
+ ))} +
+
+ ))} +
+ )} )}
- {announcement && showAnnouncement && ( -
{ - // 如果点击的是背景区域,阻止触摸事件冒泡,防止背景滚动 - if (e.target === e.currentTarget) { - e.preventDefault(); - } - }} - onTouchMove={(e) => { - // 如果触摸的是背景区域,阻止触摸移动,防止背景滚动 - if (e.target === e.currentTarget) { - e.preventDefault(); - e.stopPropagation(); - } - }} - onTouchEnd={(e) => { - // 如果触摸的是背景区域,阻止触摸结束事件,防止背景滚动 - if (e.target === e.currentTarget) { - e.preventDefault(); - } - }} - style={{ - touchAction: 'none', // 禁用所有触摸操作 - }} - > -
{ - // 允许公告内容区域正常滚动,阻止事件冒泡到外层 - e.stopPropagation(); - }} - style={{ - touchAction: 'auto', // 允许内容区域的正常触摸操作 - }} - > -
-

- 提示 -

- -
-
-
-
-

- {announcement} -

-
+ + {/* 公告弹窗 */} + {showAnnouncement && ( +
+
+

+ 公告 +

+
+ {announcement}
diff --git a/src/lib/db.client.ts b/src/lib/db.client.ts index 1bcc861..0325a2c 100644 --- a/src/lib/db.client.ts +++ b/src/lib/db.client.ts @@ -101,6 +101,9 @@ const SEARCH_HISTORY_LIMIT = 20; class HybridCacheManager { private static instance: HybridCacheManager; + // 正在进行的请求 Promise 缓存(彻底防止并发重复请求) + private pendingRequests: Map> = new Map(); + static getInstance(): HybridCacheManager { if (!HybridCacheManager.instance) { HybridCacheManager.instance = new HybridCacheManager(); @@ -108,6 +111,31 @@ class HybridCacheManager { return HybridCacheManager.instance; } + /** + * 获取或创建请求 Promise(防止并发重复请求) + */ + getOrCreateRequest( + key: string, + fetcher: () => Promise + ): Promise { + // 如果已有正在进行的请求,直接返回 + if (this.pendingRequests.has(key)) { + console.log(`[${key}] 复用进行中的请求`); + return this.pendingRequests.get(key)!; + } + + console.log(`[${key}] 创建新请求`); + // 创建新请求 + const promise = fetcher() + .finally(() => { + // 请求完成后清除缓存 + this.pendingRequests.delete(key); + }); + + this.pendingRequests.set(key, promise); + return promise; + } + /** * 获取当前用户名 */ @@ -437,37 +465,40 @@ async function handleDatabaseOperationFailure( triggerGlobalError(`数据库操作失败`); try { - let freshData: any; - let eventName: string; + // 使用 Promise 缓存防止并发重复请求 + await cacheManager.getOrCreateRequest(`recovery-${dataType}`, async () => { + let freshData: any; + let eventName: string; - switch (dataType) { - case 'playRecords': - freshData = await fetchFromApi>( - `/api/playrecords` - ); - cacheManager.cachePlayRecords(freshData); - eventName = 'playRecordsUpdated'; - break; - case 'favorites': - freshData = await fetchFromApi>( - `/api/favorites` - ); - cacheManager.cacheFavorites(freshData); - eventName = 'favoritesUpdated'; - break; - case 'searchHistory': - freshData = await fetchFromApi(`/api/searchhistory`); - cacheManager.cacheSearchHistory(freshData); - eventName = 'searchHistoryUpdated'; - break; - } + switch (dataType) { + case 'playRecords': + freshData = await fetchFromApi>( + `/api/playrecords` + ); + cacheManager.cachePlayRecords(freshData); + eventName = 'playRecordsUpdated'; + break; + case 'favorites': + freshData = await fetchFromApi>( + `/api/favorites` + ); + cacheManager.cacheFavorites(freshData); + eventName = 'favoritesUpdated'; + break; + case 'searchHistory': + freshData = await fetchFromApi(`/api/searchhistory`); + cacheManager.cacheSearchHistory(freshData); + eventName = 'searchHistoryUpdated'; + break; + } - // 触发更新事件通知组件 - window.dispatchEvent( - new CustomEvent(eventName, { - detail: freshData, - }) - ); + // 触发更新事件通知组件 + window.dispatchEvent( + new CustomEvent(eventName, { + detail: freshData, + }) + ); + }); } catch (refreshErr) { console.error(`刷新${dataType}缓存失败:`, refreshErr); triggerGlobalError(`刷新${dataType}缓存失败`); @@ -935,6 +966,12 @@ export async function deleteSearchHistory(keyword: string): Promise { // ---------------- 收藏相关 API ---------------- +// 模块级别的防重复请求机制 +let pendingFavoritesBackgroundRequest: Promise | null = null; +let pendingFavoritesFetchRequest: Promise> | null = null; +let lastFavoritesBackgroundFetchTime = 0; +const MIN_BACKGROUND_FETCH_INTERVAL = 3000; // 3秒内不重复后台请求 + /** * 获取全部收藏。 * 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。 @@ -951,39 +988,55 @@ export async function getAllFavorites(): Promise> { const cachedData = cacheManager.getCachedFavorites(); if (cachedData) { - // 返回缓存数据,同时后台异步更新 - fetchFromApi>(`/api/favorites`) - .then((freshData) => { - // 只有数据真正不同时才更新缓存 - if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) { - cacheManager.cacheFavorites(freshData); - // 触发数据更新事件 - window.dispatchEvent( - new CustomEvent('favoritesUpdated', { - detail: freshData, - }) - ); + // 有缓存:返回缓存,后台异步刷新(带防抖和防重复) + const now = Date.now(); + if (now - lastFavoritesBackgroundFetchTime > MIN_BACKGROUND_FETCH_INTERVAL && !pendingFavoritesBackgroundRequest) { + lastFavoritesBackgroundFetchTime = now; + + pendingFavoritesBackgroundRequest = (async () => { + try { + const freshData = await fetchFromApi>(`/api/favorites`); + // 只有数据真正不同时才更新缓存 + if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) { + cacheManager.cacheFavorites(freshData); + // 触发数据更新事件 + window.dispatchEvent( + new CustomEvent('favoritesUpdated', { + detail: freshData, + }) + ); + } + } catch (err) { + console.warn('后台同步收藏失败:', err); + triggerGlobalError('后台同步收藏失败'); + } finally { + pendingFavoritesBackgroundRequest = null; } - }) - .catch((err) => { - console.warn('后台同步收藏失败:', err); - triggerGlobalError('后台同步收藏失败'); - }); + })(); + } return cachedData; } else { - // 缓存为空,直接从 API 获取并缓存 - try { - const freshData = await fetchFromApi>( - `/api/favorites` - ); - cacheManager.cacheFavorites(freshData); - return freshData; - } catch (err) { - console.error('获取收藏失败:', err); - triggerGlobalError('获取收藏失败'); - return {}; + // 无缓存:直接获取(防重复请求) + if (pendingFavoritesFetchRequest) { + return pendingFavoritesFetchRequest; } + + pendingFavoritesFetchRequest = (async () => { + try { + const freshData = await fetchFromApi>(`/api/favorites`); + cacheManager.cacheFavorites(freshData); + return freshData; + } catch (err) { + console.error('获取收藏失败:', err); + triggerGlobalError('获取收藏失败'); + return {}; + } finally { + pendingFavoritesFetchRequest = null; + } + })(); + + return pendingFavoritesFetchRequest; } } @@ -1132,44 +1185,19 @@ export async function isFavorited( ): Promise { const key = generateStorageKey(source, id); - // 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash) + // 数据库存储模式:直接从缓存读取,不触发后台刷新 + // 后台刷新由 getAllFavorites() 统一管理,避免重复请求 if (STORAGE_TYPE !== 'localstorage') { const cachedFavorites = cacheManager.getCachedFavorites(); if (cachedFavorites) { - // 返回缓存数据,同时后台异步更新 - fetchFromApi>(`/api/favorites`) - .then((freshData) => { - // 只有数据真正不同时才更新缓存 - if (JSON.stringify(cachedFavorites) !== JSON.stringify(freshData)) { - cacheManager.cacheFavorites(freshData); - // 触发数据更新事件 - window.dispatchEvent( - new CustomEvent('favoritesUpdated', { - detail: freshData, - }) - ); - } - }) - .catch((err) => { - console.warn('后台同步收藏失败:', err); - triggerGlobalError('后台同步收藏失败'); - }); - + // 直接返回缓存结果,不触发后台刷新 return !!cachedFavorites[key]; } else { - // 缓存为空,直接从 API 获取并缓存 - try { - const freshData = await fetchFromApi>( - `/api/favorites` - ); - cacheManager.cacheFavorites(freshData); - return !!freshData[key]; - } catch (err) { - console.error('检查收藏状态失败:', err); - triggerGlobalError('检查收藏状态失败'); - return false; - } + // 缓存为空时,调用 getAllFavorites() 来获取并缓存数据 + // 这样可以复用 getAllFavorites() 中的防重复请求机制 + const allFavorites = await getAllFavorites(); + return !!allFavorites[key]; } } @@ -1280,50 +1308,53 @@ export async function refreshAllCache(): Promise { if (STORAGE_TYPE === 'localstorage') return; try { - // 并行刷新所有数据 - const [playRecords, favorites, searchHistory, skipConfigs] = - await Promise.allSettled([ - fetchFromApi>(`/api/playrecords`), - fetchFromApi>(`/api/favorites`), - fetchFromApi(`/api/searchhistory`), - fetchFromApi>(`/api/skipconfigs`), - ]); + // 使用 Promise 缓存防止并发重复刷新 + await cacheManager.getOrCreateRequest('refresh-all-cache', async () => { + // 并行刷新所有数据 + const [playRecords, favorites, searchHistory, skipConfigs] = + await Promise.allSettled([ + fetchFromApi>(`/api/playrecords`), + fetchFromApi>(`/api/favorites`), + fetchFromApi(`/api/searchhistory`), + fetchFromApi>(`/api/skipconfigs`), + ]); - if (playRecords.status === 'fulfilled') { - cacheManager.cachePlayRecords(playRecords.value); - window.dispatchEvent( - new CustomEvent('playRecordsUpdated', { - detail: playRecords.value, - }) - ); - } + if (playRecords.status === 'fulfilled') { + cacheManager.cachePlayRecords(playRecords.value); + window.dispatchEvent( + new CustomEvent('playRecordsUpdated', { + detail: playRecords.value, + }) + ); + } - if (favorites.status === 'fulfilled') { - cacheManager.cacheFavorites(favorites.value); - window.dispatchEvent( - new CustomEvent('favoritesUpdated', { - detail: favorites.value, - }) - ); - } + if (favorites.status === 'fulfilled') { + cacheManager.cacheFavorites(favorites.value); + window.dispatchEvent( + new CustomEvent('favoritesUpdated', { + detail: favorites.value, + }) + ); + } - if (searchHistory.status === 'fulfilled') { - cacheManager.cacheSearchHistory(searchHistory.value); - window.dispatchEvent( - new CustomEvent('searchHistoryUpdated', { - detail: searchHistory.value, - }) - ); - } + if (searchHistory.status === 'fulfilled') { + cacheManager.cacheSearchHistory(searchHistory.value); + window.dispatchEvent( + new CustomEvent('searchHistoryUpdated', { + detail: searchHistory.value, + }) + ); + } - if (skipConfigs.status === 'fulfilled') { - cacheManager.cacheSkipConfigs(skipConfigs.value); - window.dispatchEvent( - new CustomEvent('skipConfigsUpdated', { - detail: skipConfigs.value, - }) - ); - } + if (skipConfigs.status === 'fulfilled') { + cacheManager.cacheSkipConfigs(skipConfigs.value); + window.dispatchEvent( + new CustomEvent('skipConfigsUpdated', { + detail: skipConfigs.value, + }) + ); + } + }); } catch (err) { console.error('刷新缓存失败:', err); triggerGlobalError('刷新缓存失败');