From 1626ccab2cc52d60420555aa8620e683bdd18383 Mon Sep 17 00:00:00 2001 From: shinya Date: Tue, 15 Jul 2025 13:13:17 +0800 Subject: [PATCH] feat: unify local cache --- src/app/play/page.tsx | 20 ++- src/components/VideoCard.tsx | 29 ++-- src/lib/db.client.ts | 322 ++++++++++++++--------------------- src/lib/db.ts | 21 --- 4 files changed, 156 insertions(+), 236 deletions(-) diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 747fbe7..aa3a6ba 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -9,13 +9,14 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useRef, useState } from 'react'; import { + deleteFavorite, deletePlayRecord, generateStorageKey, getAllPlayRecords, isFavorited, + saveFavorite, savePlayRecord, subscribeToDataUpdates, - toggleFavorite, } from '@/lib/db.client'; import { SearchResult } from '@/lib/types'; import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; @@ -944,10 +945,13 @@ function PlayPageClient() { return; try { - const newState = await toggleFavorite( - currentSourceRef.current, - currentIdRef.current, - { + if (favorited) { + // 如果已收藏,删除收藏 + await deleteFavorite(currentSourceRef.current, currentIdRef.current); + setFavorited(false); + } else { + // 如果未收藏,添加收藏 + await saveFavorite(currentSourceRef.current, currentIdRef.current, { title: videoTitleRef.current, source_name: detailRef.current?.source_name || '', year: detailRef.current?.year, @@ -955,9 +959,9 @@ function PlayPageClient() { total_episodes: detailRef.current?.episodes.length || 1, save_time: Date.now(), search_title: searchTitle, - } - ); - setFavorited(newState); + }); + setFavorited(true); + } } catch (err) { console.error('切换收藏失败:', err); } diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index a7d9991..92c129a 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -6,11 +6,12 @@ import { useRouter } from 'next/navigation'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { + deleteFavorite, deletePlayRecord, generateStorageKey, isFavorited, + saveFavorite, subscribeToDataUpdates, - toggleFavorite, } from '@/lib/db.client'; import { SearchResult } from '@/lib/types'; import { processImageUrl } from '@/lib/utils'; @@ -144,15 +145,22 @@ export default function VideoCard({ e.stopPropagation(); if (from === 'douban' || !actualSource || !actualId) return; try { - const newState = await toggleFavorite(actualSource, actualId, { - title: actualTitle, - source_name: source_name || '', - year: actualYear || '', - cover: actualPoster, - total_episodes: actualEpisodes ?? 1, - save_time: Date.now(), - }); - setFavorited(newState); + if (favorited) { + // 如果已收藏,删除收藏 + await deleteFavorite(actualSource, actualId); + setFavorited(false); + } else { + // 如果未收藏,添加收藏 + await saveFavorite(actualSource, actualId, { + title: actualTitle, + source_name: source_name || '', + year: actualYear || '', + cover: actualPoster, + total_episodes: actualEpisodes ?? 1, + save_time: Date.now(), + }); + setFavorited(true); + } } catch (err) { throw new Error('切换收藏状态失败'); } @@ -166,6 +174,7 @@ export default function VideoCard({ actualYear, actualPoster, actualEpisodes, + favorited, ] ); diff --git a/src/lib/db.client.ts b/src/lib/db.client.ts index 981a74d..6d62631 100644 --- a/src/lib/db.client.ts +++ b/src/lib/db.client.ts @@ -380,7 +380,7 @@ export async function getAllPlayRecords(): Promise> { } // D1 存储模式:使用混合缓存策略 - if (STORAGE_TYPE === 'd1') { + if (STORAGE_TYPE !== 'localstorage') { // 优先从缓存获取数据 const cachedData = cacheManager.getCachedPlayRecords(); @@ -419,11 +419,6 @@ export async function getAllPlayRecords(): Promise> { } } - // 其他数据库存储模式:直接从 API 获取 - if (STORAGE_TYPE !== 'localstorage') { - return fetchFromApi>(`/api/playrecords`); - } - // localstorage 模式 try { const raw = localStorage.getItem(PLAY_RECORDS_KEY); @@ -447,7 +442,7 @@ export async function savePlayRecord( const key = generateStorageKey(source, id); // D1 存储模式:乐观更新策略 - if (STORAGE_TYPE === 'd1') { + if (STORAGE_TYPE !== 'localstorage') { // 立即更新缓存 const cachedRecords = cacheManager.getCachedPlayRecords() || {}; cachedRecords[key] = record; @@ -480,24 +475,6 @@ export async function savePlayRecord( return; } - // 其他数据库存储模式:直接通过 API 保存 - if (STORAGE_TYPE !== 'localstorage') { - try { - const res = await fetch('/api/playrecords', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ key, record }), - }); - if (!res.ok) throw new Error(`保存播放记录失败: ${res.status}`); - } catch (err) { - console.error('保存播放记录到数据库失败:', err); - throw err; - } - return; - } - // localstorage 模式 if (typeof window === 'undefined') { console.warn('无法在服务端保存播放记录到 localStorage'); @@ -508,6 +485,11 @@ export async function savePlayRecord( const allRecords = await getAllPlayRecords(); allRecords[key] = record; localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords)); + window.dispatchEvent( + new CustomEvent('playRecordsUpdated', { + detail: allRecords, + }) + ); } catch (err) { console.error('保存播放记录失败:', err); throw err; @@ -525,7 +507,7 @@ export async function deletePlayRecord( const key = generateStorageKey(source, id); // D1 存储模式:乐观更新策略 - if (STORAGE_TYPE === 'd1') { + if (STORAGE_TYPE !== 'localstorage') { // 立即更新缓存 const cachedRecords = cacheManager.getCachedPlayRecords() || {}; delete cachedRecords[key]; @@ -554,23 +536,6 @@ export async function deletePlayRecord( return; } - // 其他数据库存储模式:直接通过 API 删除 - if (STORAGE_TYPE !== 'localstorage') { - try { - const res = await fetch( - `/api/playrecords?key=${encodeURIComponent(key)}`, - { - method: 'DELETE', - } - ); - if (!res.ok) throw new Error(`删除播放记录失败: ${res.status}`); - } catch (err) { - console.error('删除播放记录到数据库失败:', err); - throw err; - } - return; - } - // localstorage 模式 if (typeof window === 'undefined') { console.warn('无法在服务端删除播放记录到 localStorage'); @@ -581,7 +546,11 @@ export async function deletePlayRecord( const allRecords = await getAllPlayRecords(); delete allRecords[key]; localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords)); - console.log('播放记录已删除:', key); + window.dispatchEvent( + new CustomEvent('playRecordsUpdated', { + detail: allRecords, + }) + ); } catch (err) { console.error('删除播放记录失败:', err); throw err; @@ -601,7 +570,7 @@ export async function getSearchHistory(): Promise { } // D1 存储模式:使用混合缓存策略 - if (STORAGE_TYPE === 'd1') { + if (STORAGE_TYPE !== 'localstorage') { // 优先从缓存获取数据 const cachedData = cacheManager.getCachedSearchHistory(); @@ -638,16 +607,6 @@ export async function getSearchHistory(): Promise { } } - // 其他数据库存储模式 - if (STORAGE_TYPE !== 'localstorage') { - try { - return fetchFromApi(`/api/searchhistory`); - } catch (err) { - console.error('获取搜索历史失败:', err); - return []; - } - } - // localStorage 模式 try { const raw = localStorage.getItem(SEARCH_HISTORY_KEY); @@ -670,7 +629,7 @@ export async function addSearchHistory(keyword: string): Promise { if (!trimmed) return; // D1 存储模式:乐观更新策略 - if (STORAGE_TYPE === 'd1') { + if (STORAGE_TYPE !== 'localstorage') { // 立即更新缓存 const cachedHistory = cacheManager.getCachedSearchHistory() || []; const newHistory = [trimmed, ...cachedHistory.filter((k) => k !== trimmed)]; @@ -703,22 +662,6 @@ export async function addSearchHistory(keyword: string): Promise { return; } - // 其他数据库存储模式 - if (STORAGE_TYPE !== 'localstorage') { - try { - await fetch('/api/searchhistory', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ keyword: trimmed }), - }); - } catch (err) { - console.error('保存搜索历史失败:', err); - } - return; - } - // localStorage 模式 if (typeof window === 'undefined') return; @@ -730,6 +673,11 @@ export async function addSearchHistory(keyword: string): Promise { newHistory.length = SEARCH_HISTORY_LIMIT; } localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(newHistory)); + window.dispatchEvent( + new CustomEvent('searchHistoryUpdated', { + detail: newHistory, + }) + ); } catch (err) { console.error('保存搜索历史失败:', err); } @@ -741,7 +689,7 @@ export async function addSearchHistory(keyword: string): Promise { */ export async function clearSearchHistory(): Promise { // D1 存储模式:乐观更新策略 - if (STORAGE_TYPE === 'd1') { + if (STORAGE_TYPE !== 'localstorage') { // 立即更新缓存 cacheManager.cacheSearchHistory([]); @@ -764,21 +712,14 @@ export async function clearSearchHistory(): Promise { return; } - // 其他数据库存储模式 - if (STORAGE_TYPE !== 'localstorage') { - try { - await fetch(`/api/searchhistory`, { - method: 'DELETE', - }); - } catch (err) { - console.error('清空搜索历史失败:', err); - } - return; - } - // localStorage 模式 if (typeof window === 'undefined') return; localStorage.removeItem(SEARCH_HISTORY_KEY); + window.dispatchEvent( + new CustomEvent('searchHistoryUpdated', { + detail: [], + }) + ); } /** @@ -790,7 +731,7 @@ export async function deleteSearchHistory(keyword: string): Promise { if (!trimmed) return; // D1 存储模式:乐观更新策略 - if (STORAGE_TYPE === 'd1') { + if (STORAGE_TYPE !== 'localstorage') { // 立即更新缓存 const cachedHistory = cacheManager.getCachedSearchHistory() || []; const newHistory = cachedHistory.filter((k) => k !== trimmed); @@ -818,18 +759,6 @@ export async function deleteSearchHistory(keyword: string): Promise { return; } - // 其他数据库存储模式 - if (STORAGE_TYPE !== 'localstorage') { - try { - await fetch(`/api/searchhistory?keyword=${encodeURIComponent(trimmed)}`, { - method: 'DELETE', - }); - } catch (err) { - console.error('删除搜索历史失败:', err); - } - return; - } - // localStorage 模式 if (typeof window === 'undefined') return; @@ -837,6 +766,11 @@ export async function deleteSearchHistory(keyword: string): Promise { const history = await getSearchHistory(); const newHistory = history.filter((k) => k !== trimmed); localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(newHistory)); + window.dispatchEvent( + new CustomEvent('searchHistoryUpdated', { + detail: newHistory, + }) + ); } catch (err) { console.error('删除搜索历史失败:', err); } @@ -855,7 +789,7 @@ export async function getAllFavorites(): Promise> { } // D1 存储模式:使用混合缓存策略 - if (STORAGE_TYPE === 'd1') { + if (STORAGE_TYPE !== 'localstorage') { // 优先从缓存获取数据 const cachedData = cacheManager.getCachedFavorites(); @@ -894,11 +828,6 @@ export async function getAllFavorites(): Promise> { } } - // 其他数据库存储模式 - if (STORAGE_TYPE !== 'localstorage') { - return fetchFromApi>(`/api/favorites`); - } - // localStorage 模式 try { const raw = localStorage.getItem(FAVORITES_KEY); @@ -922,7 +851,7 @@ export async function saveFavorite( const key = generateStorageKey(source, id); // D1 存储模式:乐观更新策略 - if (STORAGE_TYPE === 'd1') { + if (STORAGE_TYPE !== 'localstorage') { // 立即更新缓存 const cachedFavorites = cacheManager.getCachedFavorites() || {}; cachedFavorites[key] = favorite; @@ -952,24 +881,6 @@ export async function saveFavorite( return; } - // 其他数据库存储模式 - if (STORAGE_TYPE !== 'localstorage') { - try { - const res = await fetch('/api/favorites', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ key, favorite }), - }); - if (!res.ok) throw new Error(`保存收藏失败: ${res.status}`); - } catch (err) { - console.error('保存收藏到数据库失败:', err); - throw err; - } - return; - } - // localStorage 模式 if (typeof window === 'undefined') { console.warn('无法在服务端保存收藏到 localStorage'); @@ -980,6 +891,11 @@ export async function saveFavorite( const allFavorites = await getAllFavorites(); allFavorites[key] = favorite; localStorage.setItem(FAVORITES_KEY, JSON.stringify(allFavorites)); + window.dispatchEvent( + new CustomEvent('favoritesUpdated', { + detail: allFavorites, + }) + ); } catch (err) { console.error('保存收藏失败:', err); throw err; @@ -997,7 +913,7 @@ export async function deleteFavorite( const key = generateStorageKey(source, id); // D1 存储模式:乐观更新策略 - if (STORAGE_TYPE === 'd1') { + if (STORAGE_TYPE !== 'localstorage') { // 立即更新缓存 const cachedFavorites = cacheManager.getCachedFavorites() || {}; delete cachedFavorites[key]; @@ -1023,20 +939,6 @@ export async function deleteFavorite( return; } - // 其他数据库存储模式 - if (STORAGE_TYPE !== 'localstorage') { - try { - const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`, { - method: 'DELETE', - }); - if (!res.ok) throw new Error(`删除收藏失败: ${res.status}`); - } catch (err) { - console.error('删除收藏到数据库失败:', err); - throw err; - } - return; - } - // localStorage 模式 if (typeof window === 'undefined') { console.warn('无法在服务端删除收藏到 localStorage'); @@ -1047,6 +949,11 @@ export async function deleteFavorite( const allFavorites = await getAllFavorites(); delete allFavorites[key]; localStorage.setItem(FAVORITES_KEY, JSON.stringify(allFavorites)); + window.dispatchEvent( + new CustomEvent('favoritesUpdated', { + detail: allFavorites, + }) + ); } catch (err) { console.error('删除收藏失败:', err); throw err; @@ -1055,7 +962,7 @@ export async function deleteFavorite( /** * 判断是否已收藏。 - * D1 存储模式下优先使用缓存数据。 + * D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。 */ export async function isFavorited( source: string, @@ -1063,35 +970,42 @@ export async function isFavorited( ): Promise { const key = generateStorageKey(source, id); - // D1 存储模式:优先使用缓存 - if (STORAGE_TYPE === 'd1') { - const cachedFavorites = cacheManager.getCachedFavorites(); - if (cachedFavorites) { - return !!cachedFavorites[key]; - } - - // 缓存为空时从 API 获取 - try { - const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`); - if (!res.ok) return false; - const data = await res.json(); - return !!data; - } catch (err) { - console.error('检查收藏状态失败:', err); - return false; - } - } - - // 其他数据库存储模式 + // D1 存储模式:使用混合缓存策略 if (STORAGE_TYPE !== 'localstorage') { - try { - const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`); - if (!res.ok) return false; - const data = await res.json(); - return !!data; - } catch (err) { - console.error('检查收藏状态失败:', err); - return false; + 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); + }); + + return !!cachedFavorites[key]; + } else { + // 缓存为空,直接从 API 获取并缓存 + try { + const freshData = await fetchFromApi>( + `/api/favorites` + ); + cacheManager.cacheFavorites(freshData); + return !!freshData[key]; + } catch (err) { + console.error('检查收藏状态失败:', err); + return false; + } } } @@ -1100,43 +1014,33 @@ export async function isFavorited( return !!allFavorites[key]; } -/** - * 切换收藏状态 - * 返回切换后的状态(true = 已收藏) - */ -export async function toggleFavorite( - source: string, - id: string, - favoriteData?: Favorite -): Promise { - const already = await isFavorited(source, id); - - if (already) { - await deleteFavorite(source, id); - return false; - } - - if (!favoriteData) { - throw new Error('收藏数据缺失'); - } - - await saveFavorite(source, id, favoriteData); - return true; -} - /** * 清空全部播放记录 + * D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。 */ export async function clearAllPlayRecords(): Promise { - // 数据库模式 + // D1 存储模式:乐观更新策略 if (STORAGE_TYPE !== 'localstorage') { + // 立即更新缓存 + cacheManager.cachePlayRecords({}); + + // 触发立即更新事件 + window.dispatchEvent( + new CustomEvent('playRecordsUpdated', { + detail: {}, + }) + ); + + // 异步同步到数据库 try { - await fetch(`/api/playrecords`, { + const res = await fetch(`/api/playrecords`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, }); + if (!res.ok) throw new Error(`清空播放记录失败: ${res.status}`); } catch (err) { - console.error('清空播放记录失败:', err); + await handleDatabaseOperationFailure('playRecords', err); + throw err; } return; } @@ -1144,21 +1048,40 @@ export async function clearAllPlayRecords(): Promise { // localStorage 模式 if (typeof window === 'undefined') return; localStorage.removeItem(PLAY_RECORDS_KEY); + window.dispatchEvent( + new CustomEvent('playRecordsUpdated', { + detail: {}, + }) + ); } /** * 清空全部收藏 + * D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。 */ export async function clearAllFavorites(): Promise { - // 数据库模式 + // D1 存储模式:乐观更新策略 if (STORAGE_TYPE !== 'localstorage') { + // 立即更新缓存 + cacheManager.cacheFavorites({}); + + // 触发立即更新事件 + window.dispatchEvent( + new CustomEvent('favoritesUpdated', { + detail: {}, + }) + ); + + // 异步同步到数据库 try { - await fetch(`/api/favorites`, { + const res = await fetch(`/api/favorites`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, }); + if (!res.ok) throw new Error(`清空收藏失败: ${res.status}`); } catch (err) { - console.error('清空收藏失败:', err); + await handleDatabaseOperationFailure('favorites', err); + throw err; } return; } @@ -1166,6 +1089,11 @@ export async function clearAllFavorites(): Promise { // localStorage 模式 if (typeof window === 'undefined') return; localStorage.removeItem(FAVORITES_KEY); + window.dispatchEvent( + new CustomEvent('favoritesUpdated', { + detail: {}, + }) + ); } // ---------------- 混合缓存辅助函数 ---------------- diff --git a/src/lib/db.ts b/src/lib/db.ts index d41a783..042f398 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -129,27 +129,6 @@ export class DbManager { return favorite !== null; } - async toggleFavorite( - userName: string, - source: string, - id: string, - favoriteData?: Favorite - ): Promise { - const isFav = await this.isFavorited(userName, source, id); - - if (isFav) { - await this.deleteFavorite(userName, source, id); - return false; - } - - if (favoriteData) { - await this.saveFavorite(userName, source, id, favoriteData); - return true; - } - - throw new Error('Favorite data is required when adding to favorites'); - } - // ---------- 用户相关 ---------- async registerUser(userName: string, password: string): Promise { await this.storage.registerUser(userName, password);