修复首页/api/favorites接口重复加载
This commit is contained in:
276
src/app/page.tsx
276
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<FavoriteItem[]>([]);
|
||||
const favoritesFetchedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRecommendData = async () => {
|
||||
@@ -109,7 +110,7 @@ function HomeClient() {
|
||||
}, []);
|
||||
|
||||
// 处理收藏数据更新的函数
|
||||
const updateFavoriteItems = async (allFavorites: Record<string, any>) => {
|
||||
const updateFavoriteItems = useCallback(async (allFavorites: Record<string, any>) => {
|
||||
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<string, any>) => {
|
||||
@@ -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'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
<div className='aspect-[2/3] bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse mb-2' />
|
||||
<div className='h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-3/4' />
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
hotMovies.map((movie, index) => (
|
||||
: hotMovies.map((movie) => (
|
||||
<div
|
||||
key={index}
|
||||
key={movie.id}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<VideoCard
|
||||
from='douban'
|
||||
title={movie.title}
|
||||
id={movie.id}
|
||||
poster={movie.poster}
|
||||
douban_id={Number(movie.id)}
|
||||
rate={movie.rate}
|
||||
title={movie.title}
|
||||
year={movie.year}
|
||||
type='movie'
|
||||
from='douban'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -290,113 +299,33 @@ function HomeClient() {
|
||||
</div>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
? Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
<div className='aspect-[2/3] bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse mb-2' />
|
||||
<div className='h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-3/4' />
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
hotTvShows.map((show, index) => (
|
||||
: hotTvShows.map((tvShow) => (
|
||||
<div
|
||||
key={index}
|
||||
key={tvShow.id}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<VideoCard
|
||||
id={tvShow.id}
|
||||
poster={tvShow.poster}
|
||||
title={tvShow.title}
|
||||
year={tvShow.year}
|
||||
type='tv'
|
||||
from='douban'
|
||||
title={show.title}
|
||||
poster={show.poster}
|
||||
douban_id={Number(show.id)}
|
||||
rate={show.rate}
|
||||
year={show.year}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
|
||||
{/* 每日新番放送 */}
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
新番放送
|
||||
</h2>
|
||||
<Link
|
||||
href='/douban?type=anime'
|
||||
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
>
|
||||
查看更多
|
||||
<ChevronRight className='w-4 h-4 ml-1' />
|
||||
</Link>
|
||||
</div>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
</div>
|
||||
))
|
||||
: // 展示当前日期的番剧
|
||||
(() => {
|
||||
// 获取当前日期对应的星期
|
||||
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) => (
|
||||
<div
|
||||
key={`${anime.id}-${index}`}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<VideoCard
|
||||
from='douban'
|
||||
title={anime.name_cn || anime.name}
|
||||
poster={
|
||||
anime.images?.large ||
|
||||
anime.images?.common ||
|
||||
anime.images?.medium ||
|
||||
anime.images?.small ||
|
||||
anime.images?.grid ||
|
||||
''
|
||||
}
|
||||
douban_id={anime.id}
|
||||
rate={anime.rating?.score?.toFixed(1) || ''}
|
||||
year={anime.air_date?.split('-')?.[0] || ''}
|
||||
isBangumi={true}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
|
||||
{/* 热门综艺 */}
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
@@ -404,7 +333,7 @@ function HomeClient() {
|
||||
热门综艺
|
||||
</h2>
|
||||
<Link
|
||||
href='/douban?type=show'
|
||||
href='/douban?type=tv&category=show'
|
||||
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
>
|
||||
查看更多
|
||||
@@ -413,100 +342,87 @@ function HomeClient() {
|
||||
</div>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
? Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
<div className='aspect-[2/3] bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse mb-2' />
|
||||
<div className='h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-3/4' />
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
hotVarietyShows.map((show, index) => (
|
||||
: hotVarietyShows.map((varietyShow) => (
|
||||
<div
|
||||
key={index}
|
||||
key={varietyShow.id}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<VideoCard
|
||||
id={varietyShow.id}
|
||||
poster={varietyShow.poster}
|
||||
title={varietyShow.title}
|
||||
year={varietyShow.year}
|
||||
type='tv'
|
||||
from='douban'
|
||||
title={show.title}
|
||||
poster={show.poster}
|
||||
douban_id={Number(show.id)}
|
||||
rate={show.rate}
|
||||
year={show.year}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
|
||||
{/* 番剧时间表 */}
|
||||
{bangumiCalendarData.length > 0 && (
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
番剧时间表
|
||||
</h2>
|
||||
</div>
|
||||
{bangumiCalendarData.map((day) => (
|
||||
<div key={day.weekday.en} className='mb-6'>
|
||||
<h3 className='text-sm text-gray-600 dark:text-gray-400 mb-3'>
|
||||
{day.weekday.en}
|
||||
</h3>
|
||||
<ScrollableRow>
|
||||
{day.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<VideoCard
|
||||
id={String(item.id)}
|
||||
poster={item.images.large}
|
||||
title={item.name}
|
||||
year={item.air_date || ''}
|
||||
type='tv'
|
||||
from='douban'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{announcement && showAnnouncement && (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm dark:bg-black/70 p-4 transition-opacity duration-300 ${showAnnouncement ? '' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
onTouchStart={(e) => {
|
||||
// 如果点击的是背景区域,阻止触摸事件冒泡,防止背景滚动
|
||||
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', // 禁用所有触摸操作
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-gray-900 transform transition-all duration-300 hover:shadow-2xl'
|
||||
onTouchMove={(e) => {
|
||||
// 允许公告内容区域正常滚动,阻止事件冒泡到外层
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
touchAction: 'auto', // 允许内容区域的正常触摸操作
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-between items-start mb-4'>
|
||||
<h3 className='text-2xl font-bold tracking-tight text-gray-800 dark:text-white border-b border-green-500 pb-1'>
|
||||
提示
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => handleCloseAnnouncement(announcement)}
|
||||
className='text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-white transition-colors'
|
||||
aria-label='关闭'
|
||||
></button>
|
||||
</div>
|
||||
<div className='mb-6'>
|
||||
<div className='relative overflow-hidden rounded-lg mb-4 bg-green-50 dark:bg-green-900/20'>
|
||||
<div className='absolute inset-y-0 left-0 w-1.5 bg-green-500 dark:bg-green-400'></div>
|
||||
<p className='ml-4 text-gray-600 dark:text-gray-300 leading-relaxed'>
|
||||
{announcement}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 公告弹窗 */}
|
||||
{showAnnouncement && (
|
||||
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'>
|
||||
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6'>
|
||||
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3'>
|
||||
公告
|
||||
</h3>
|
||||
<div className='text-gray-700 dark:text-gray-300 mb-4 whitespace-pre-wrap'>
|
||||
{announcement}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCloseAnnouncement(announcement)}
|
||||
className='w-full rounded-lg bg-gradient-to-r from-green-600 to-green-700 px-4 py-3 text-white font-medium shadow-md hover:shadow-lg hover:from-green-700 hover:to-green-800 dark:from-green-600 dark:to-green-700 dark:hover:from-green-700 dark:hover:to-green-800 transition-all duration-300 transform hover:-translate-y-0.5'
|
||||
onClick={() => handleCloseAnnouncement(announcement || '')}
|
||||
className='w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors'
|
||||
>
|
||||
我知道了
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -101,6 +101,9 @@ const SEARCH_HISTORY_LIMIT = 20;
|
||||
class HybridCacheManager {
|
||||
private static instance: HybridCacheManager;
|
||||
|
||||
// 正在进行的请求 Promise 缓存(彻底防止并发重复请求)
|
||||
private pendingRequests: Map<string, Promise<any>> = new Map();
|
||||
|
||||
static getInstance(): HybridCacheManager {
|
||||
if (!HybridCacheManager.instance) {
|
||||
HybridCacheManager.instance = new HybridCacheManager();
|
||||
@@ -108,6 +111,31 @@ class HybridCacheManager {
|
||||
return HybridCacheManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建请求 Promise(防止并发重复请求)
|
||||
*/
|
||||
getOrCreateRequest<T>(
|
||||
key: string,
|
||||
fetcher: () => Promise<T>
|
||||
): Promise<T> {
|
||||
// 如果已有正在进行的请求,直接返回
|
||||
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<Record<string, PlayRecord>>(
|
||||
`/api/playrecords`
|
||||
);
|
||||
cacheManager.cachePlayRecords(freshData);
|
||||
eventName = 'playRecordsUpdated';
|
||||
break;
|
||||
case 'favorites':
|
||||
freshData = await fetchFromApi<Record<string, Favorite>>(
|
||||
`/api/favorites`
|
||||
);
|
||||
cacheManager.cacheFavorites(freshData);
|
||||
eventName = 'favoritesUpdated';
|
||||
break;
|
||||
case 'searchHistory':
|
||||
freshData = await fetchFromApi<string[]>(`/api/searchhistory`);
|
||||
cacheManager.cacheSearchHistory(freshData);
|
||||
eventName = 'searchHistoryUpdated';
|
||||
break;
|
||||
}
|
||||
switch (dataType) {
|
||||
case 'playRecords':
|
||||
freshData = await fetchFromApi<Record<string, PlayRecord>>(
|
||||
`/api/playrecords`
|
||||
);
|
||||
cacheManager.cachePlayRecords(freshData);
|
||||
eventName = 'playRecordsUpdated';
|
||||
break;
|
||||
case 'favorites':
|
||||
freshData = await fetchFromApi<Record<string, Favorite>>(
|
||||
`/api/favorites`
|
||||
);
|
||||
cacheManager.cacheFavorites(freshData);
|
||||
eventName = 'favoritesUpdated';
|
||||
break;
|
||||
case 'searchHistory':
|
||||
freshData = await fetchFromApi<string[]>(`/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<void> {
|
||||
|
||||
// ---------------- 收藏相关 API ----------------
|
||||
|
||||
// 模块级别的防重复请求机制
|
||||
let pendingFavoritesBackgroundRequest: Promise<void> | null = null;
|
||||
let pendingFavoritesFetchRequest: Promise<Record<string, Favorite>> | null = null;
|
||||
let lastFavoritesBackgroundFetchTime = 0;
|
||||
const MIN_BACKGROUND_FETCH_INTERVAL = 3000; // 3秒内不重复后台请求
|
||||
|
||||
/**
|
||||
* 获取全部收藏。
|
||||
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||
@@ -951,39 +988,55 @@ export async function getAllFavorites(): Promise<Record<string, Favorite>> {
|
||||
const cachedData = cacheManager.getCachedFavorites();
|
||||
|
||||
if (cachedData) {
|
||||
// 返回缓存数据,同时后台异步更新
|
||||
fetchFromApi<Record<string, Favorite>>(`/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<Record<string, Favorite>>(`/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<Record<string, Favorite>>(
|
||||
`/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<Record<string, Favorite>>(`/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<boolean> {
|
||||
const key = generateStorageKey(source, id);
|
||||
|
||||
// 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash)
|
||||
// 数据库存储模式:直接从缓存读取,不触发后台刷新
|
||||
// 后台刷新由 getAllFavorites() 统一管理,避免重复请求
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
const cachedFavorites = cacheManager.getCachedFavorites();
|
||||
|
||||
if (cachedFavorites) {
|
||||
// 返回缓存数据,同时后台异步更新
|
||||
fetchFromApi<Record<string, Favorite>>(`/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<Record<string, Favorite>>(
|
||||
`/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<void> {
|
||||
if (STORAGE_TYPE === 'localstorage') return;
|
||||
|
||||
try {
|
||||
// 并行刷新所有数据
|
||||
const [playRecords, favorites, searchHistory, skipConfigs] =
|
||||
await Promise.allSettled([
|
||||
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`),
|
||||
fetchFromApi<Record<string, Favorite>>(`/api/favorites`),
|
||||
fetchFromApi<string[]>(`/api/searchhistory`),
|
||||
fetchFromApi<Record<string, SkipConfig>>(`/api/skipconfigs`),
|
||||
]);
|
||||
// 使用 Promise 缓存防止并发重复刷新
|
||||
await cacheManager.getOrCreateRequest('refresh-all-cache', async () => {
|
||||
// 并行刷新所有数据
|
||||
const [playRecords, favorites, searchHistory, skipConfigs] =
|
||||
await Promise.allSettled([
|
||||
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`),
|
||||
fetchFromApi<Record<string, Favorite>>(`/api/favorites`),
|
||||
fetchFromApi<string[]>(`/api/searchhistory`),
|
||||
fetchFromApi<Record<string, SkipConfig>>(`/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('刷新缓存失败');
|
||||
|
||||
Reference in New Issue
Block a user