Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfea92ec7f | |||
| b61430856a | |||
| 7e6f4bcadc | |||
| c2ebf5758e | |||
| 446bb0e9f0 | |||
| ea3d1065e8 | |||
| eb3bffab4e | |||
| 45dfdc62f0 |
@@ -23,10 +23,10 @@
|
|||||||
- 🔍 **多源聚合搜索**:内置数十个免费资源站点,一次搜索立刻返回全源结果。
|
- 🔍 **多源聚合搜索**:内置数十个免费资源站点,一次搜索立刻返回全源结果。
|
||||||
- 📄 **丰富详情页**:支持剧集列表、演员、年份、简介等完整信息展示。
|
- 📄 **丰富详情页**:支持剧集列表、演员、年份、简介等完整信息展示。
|
||||||
- ▶️ **流畅在线播放**:集成 HLS.js & ArtPlayer。
|
- ▶️ **流畅在线播放**:集成 HLS.js & ArtPlayer。
|
||||||
- ❤️ **收藏 + 继续观看**:Docker 部署支持 Redis 存储,多端同步进度。
|
- ❤️ **收藏 + 继续观看**:支持 Redis/D1 存储,多端同步进度。
|
||||||
- 📱 **PWA**:离线缓存、安装到桌面/主屏,移动端原生体验。
|
- 📱 **PWA**:离线缓存、安装到桌面/主屏,移动端原生体验。
|
||||||
- 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸。
|
- 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸。
|
||||||
- 🚀 **极简部署**:一条 Docker 命令即可将完整服务跑起来,或免费部署到 Vercel。
|
- 🚀 **极简部署**:一条 Docker 命令即可将完整服务跑起来,或免费部署到 Vercel 和 Cloudflare。
|
||||||
- 👿 **智能去广告**:自动跳过视频中的切片广告(实验性)
|
- 👿 **智能去广告**:自动跳过视频中的切片广告(实验性)
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -78,6 +78,8 @@
|
|||||||
|
|
||||||
### Cloudflare 部署
|
### Cloudflare 部署
|
||||||
|
|
||||||
|
#### 普通部署(localstorage)
|
||||||
|
|
||||||
1. **Fork** 本仓库到你的 GitHub 账户。
|
1. **Fork** 本仓库到你的 GitHub 账户。
|
||||||
2. 登陆 [Cloudflare](https://cloudflare.com),点击 **计算(Workers)-> Workers 和 Pages**,点击创建
|
2. 登陆 [Cloudflare](https://cloudflare.com),点击 **计算(Workers)-> Workers 和 Pages**,点击创建
|
||||||
3. 选择 Pages,导入现有的 Git 存储库,选择 Fork 后的仓库
|
3. 选择 Pages,导入现有的 Git 存储库,选择 Fork 后的仓库
|
||||||
@@ -87,6 +89,14 @@
|
|||||||
7. 如需自定义 `config.json`,请直接修改 Fork 后仓库中该文件。
|
7. 如需自定义 `config.json`,请直接修改 Fork 后仓库中该文件。
|
||||||
8. 每次 Push 到 `main` 分支将自动触发重新构建。
|
8. 每次 Push 到 `main` 分支将自动触发重新构建。
|
||||||
|
|
||||||
|
#### D1 支持
|
||||||
|
|
||||||
|
1. 点击 **存储和数据库 -> D1 SQL 数据库**,创建一个新的数据库,名称随意
|
||||||
|
2. 进入刚创建的数据库,点击左上角的 Explore Data,将[D1 初始化](D1初始化.md) 中的内容粘贴到 Query 窗口后点击 Run All,等待运行完成
|
||||||
|
3. 返回你的 pages 项目,进入 **设置 -> 绑定**,添加绑定 D1 数据库,选择你刚创建的数据库,变量名称填 **DB**
|
||||||
|
4. 设置环境变量 NEXT_PUBLIC_STORAGE_TYPE,值为 d1;设置 USERNAME 和 PASSWORD 作为站长账号
|
||||||
|
5. 重试部署
|
||||||
|
|
||||||
### Docker 部署
|
### Docker 部署
|
||||||
|
|
||||||
> 适用于自建服务器 / NAS / 群晖等场景。
|
> 适用于自建服务器 / NAS / 群晖等场景。
|
||||||
@@ -213,7 +223,7 @@ MoonTV 支持标准的苹果 CMS V10 API 格式。
|
|||||||
|
|
||||||
## 管理员配置
|
## 管理员配置
|
||||||
|
|
||||||
**该特性目前仅支持通过 Docker Redis 的部署方式使用**
|
**该特性目前仅支持通过 Docker+Redis 或 Cloudflare+D1 的部署方式使用**
|
||||||
|
|
||||||
支持在运行时动态变更服务配置
|
支持在运行时动态变更服务配置
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await getDetailFromApi(apiSite, id);
|
const result = await getDetailFromApi(apiSite, id);
|
||||||
const cacheTime = getCacheTime();
|
const cacheTime = await getCacheTime();
|
||||||
|
|
||||||
return NextResponse.json(result, {
|
return NextResponse.json(result, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export async function GET(request: Request) {
|
|||||||
list: list,
|
list: list,
|
||||||
};
|
};
|
||||||
|
|
||||||
const cacheTime = getCacheTime();
|
const cacheTime = await getCacheTime();
|
||||||
return NextResponse.json(response, {
|
return NextResponse.json(response, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': `public, max-age=${cacheTime}`,
|
'Cache-Control': `public, max-age=${cacheTime}`,
|
||||||
@@ -180,7 +180,7 @@ function handleTop250(pageStart: number) {
|
|||||||
list: movies,
|
list: movies,
|
||||||
};
|
};
|
||||||
|
|
||||||
const cacheTime = getCacheTime();
|
const cacheTime = await getCacheTime();
|
||||||
return NextResponse.json(apiResponse, {
|
return NextResponse.json(apiResponse, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': `public, max-age=${cacheTime}`,
|
'Cache-Control': `public, max-age=${cacheTime}`,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function GET(request: Request) {
|
|||||||
const resourceId = searchParams.get('resourceId');
|
const resourceId = searchParams.get('resourceId');
|
||||||
|
|
||||||
if (!query || !resourceId) {
|
if (!query || !resourceId) {
|
||||||
const cacheTime = getCacheTime();
|
const cacheTime = await getCacheTime();
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ result: null, error: '缺少必要参数: q 或 resourceId' },
|
{ result: null, error: '缺少必要参数: q 或 resourceId' },
|
||||||
{
|
{
|
||||||
@@ -40,7 +40,7 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
const results = await searchFromApi(targetSite, query);
|
const results = await searchFromApi(targetSite, query);
|
||||||
const result = results.filter((r) => r.title === query);
|
const result = results.filter((r) => r.title === query);
|
||||||
const cacheTime = getCacheTime();
|
const cacheTime = await getCacheTime();
|
||||||
|
|
||||||
if (result.length === 0) {
|
if (result.length === 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export const runtime = 'edge';
|
|||||||
// OrionTV 兼容接口
|
// OrionTV 兼容接口
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const apiSites = getAvailableApiSites();
|
const apiSites = await getAvailableApiSites();
|
||||||
const cacheTime = getCacheTime();
|
const cacheTime = await getCacheTime();
|
||||||
|
|
||||||
return NextResponse.json(apiSites, {
|
return NextResponse.json(apiSites, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export async function GET(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const results = await Promise.all(searchPromises);
|
const results = await Promise.all(searchPromises);
|
||||||
const flattenedResults = results.flat();
|
const flattenedResults = results.flat();
|
||||||
const cacheTime = getCacheTime();
|
const cacheTime = await getCacheTime();
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ results: flattenedResults },
|
{ results: flattenedResults },
|
||||||
|
|||||||
+2
-2
@@ -13,7 +13,7 @@ const inter = Inter({ subsets: ['latin'] });
|
|||||||
|
|
||||||
// 动态生成 metadata,支持配置更新后的标题变化
|
// 动态生成 metadata,支持配置更新后的标题变化
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
let siteName = process.env.NEXT_PUBLIC_SITE_NAME;
|
let siteName = process.env.SITE_NAME || 'MoonTV';
|
||||||
if (process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1') {
|
if (process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1') {
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
siteName = config.SiteConfig.SiteName;
|
siteName = config.SiteConfig.SiteName;
|
||||||
@@ -35,7 +35,7 @@ export default async function RootLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV';
|
let siteName = process.env.SITE_NAME || 'MoonTV';
|
||||||
let announcement =
|
let announcement =
|
||||||
process.env.ANNOUNCEMENT ||
|
process.env.ANNOUNCEMENT ||
|
||||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
|
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
|
||||||
|
|||||||
+28
-10
@@ -1,3 +1,5 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
@@ -9,6 +11,7 @@ import {
|
|||||||
clearAllFavorites,
|
clearAllFavorites,
|
||||||
getAllFavorites,
|
getAllFavorites,
|
||||||
getAllPlayRecords,
|
getAllPlayRecords,
|
||||||
|
subscribeToDataUpdates,
|
||||||
} from '@/lib/db.client';
|
} from '@/lib/db.client';
|
||||||
import { DoubanItem, DoubanResult } from '@/lib/types';
|
import { DoubanItem, DoubanResult } from '@/lib/types';
|
||||||
|
|
||||||
@@ -82,15 +85,9 @@ function HomeClient() {
|
|||||||
fetchDoubanData();
|
fetchDoubanData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 当切换到收藏夹时加载收藏数据
|
// 处理收藏数据更新的函数
|
||||||
useEffect(() => {
|
const updateFavoriteItems = async (allFavorites: Record<string, any>) => {
|
||||||
if (activeTab !== 'favorites') return;
|
const allPlayRecords = await getAllPlayRecords();
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const [allFavorites, allPlayRecords] = await Promise.all([
|
|
||||||
getAllFavorites(),
|
|
||||||
getAllPlayRecords(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 根据保存时间排序(从近到远)
|
// 根据保存时间排序(从近到远)
|
||||||
const sorted = Object.entries(allFavorites)
|
const sorted = Object.entries(allFavorites)
|
||||||
@@ -117,7 +114,28 @@ function HomeClient() {
|
|||||||
} as FavoriteItem;
|
} as FavoriteItem;
|
||||||
});
|
});
|
||||||
setFavoriteItems(sorted);
|
setFavoriteItems(sorted);
|
||||||
})();
|
};
|
||||||
|
|
||||||
|
// 当切换到收藏夹时加载收藏数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'favorites') return;
|
||||||
|
|
||||||
|
const loadFavorites = async () => {
|
||||||
|
const allFavorites = await getAllFavorites();
|
||||||
|
await updateFavoriteItems(allFavorites);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadFavorites();
|
||||||
|
|
||||||
|
// 监听收藏更新事件
|
||||||
|
const unsubscribe = subscribeToDataUpdates(
|
||||||
|
'favoritesUpdated',
|
||||||
|
(newFavorites: Record<string, any>) => {
|
||||||
|
updateFavoriteItems(newFavorites);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
const handleCloseAnnouncement = (announcement: string) => {
|
const handleCloseAnnouncement = (announcement: string) => {
|
||||||
|
|||||||
+21
-1
@@ -14,6 +14,7 @@ import {
|
|||||||
getAllPlayRecords,
|
getAllPlayRecords,
|
||||||
isFavorited,
|
isFavorited,
|
||||||
savePlayRecord,
|
savePlayRecord,
|
||||||
|
subscribeToDataUpdates,
|
||||||
toggleFavorite,
|
toggleFavorite,
|
||||||
} from '@/lib/db.client';
|
} from '@/lib/db.client';
|
||||||
import { SearchResult } from '@/lib/types';
|
import { SearchResult } from '@/lib/types';
|
||||||
@@ -916,6 +917,22 @@ function PlayPageClient() {
|
|||||||
})();
|
})();
|
||||||
}, [currentSource, currentId]);
|
}, [currentSource, currentId]);
|
||||||
|
|
||||||
|
// 监听收藏数据更新事件
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentSource || !currentId) return;
|
||||||
|
|
||||||
|
const unsubscribe = subscribeToDataUpdates(
|
||||||
|
'favoritesUpdated',
|
||||||
|
(favorites: Record<string, any>) => {
|
||||||
|
const key = generateStorageKey(currentSource, currentId);
|
||||||
|
const isFav = !!favorites[key];
|
||||||
|
setFavorited(isFav);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [currentSource, currentId]);
|
||||||
|
|
||||||
// 切换收藏
|
// 切换收藏
|
||||||
const handleToggleFavorite = async () => {
|
const handleToggleFavorite = async () => {
|
||||||
if (
|
if (
|
||||||
@@ -1205,7 +1222,10 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
artPlayerRef.current.on('video:timeupdate', () => {
|
artPlayerRef.current.on('video:timeupdate', () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastSaveTimeRef.current > 5000) {
|
if (
|
||||||
|
now - lastSaveTimeRef.current >
|
||||||
|
(process.env.NEXT_PUBLIC_STORAGE_TYPE === 'd1' ? 10000 : 5000)
|
||||||
|
) {
|
||||||
saveCurrentPlayProgress();
|
saveCurrentPlayProgress();
|
||||||
lastSaveTimeRef.current = now;
|
lastSaveTimeRef.current = now;
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-17
@@ -10,6 +10,7 @@ import {
|
|||||||
clearSearchHistory,
|
clearSearchHistory,
|
||||||
deleteSearchHistory,
|
deleteSearchHistory,
|
||||||
getSearchHistory,
|
getSearchHistory,
|
||||||
|
subscribeToDataUpdates,
|
||||||
} from '@/lib/db.client';
|
} from '@/lib/db.client';
|
||||||
import { SearchResult } from '@/lib/types';
|
import { SearchResult } from '@/lib/types';
|
||||||
|
|
||||||
@@ -85,7 +86,19 @@ function SearchPageClient() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 无搜索参数时聚焦搜索框
|
// 无搜索参数时聚焦搜索框
|
||||||
!searchParams.get('q') && document.getElementById('searchInput')?.focus();
|
!searchParams.get('q') && document.getElementById('searchInput')?.focus();
|
||||||
|
|
||||||
|
// 初始加载搜索历史
|
||||||
getSearchHistory().then(setSearchHistory);
|
getSearchHistory().then(setSearchHistory);
|
||||||
|
|
||||||
|
// 监听搜索历史更新事件
|
||||||
|
const unsubscribe = subscribeToDataUpdates(
|
||||||
|
'searchHistoryUpdated',
|
||||||
|
(newHistory: string[]) => {
|
||||||
|
setSearchHistory(newHistory);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -95,11 +108,8 @@ function SearchPageClient() {
|
|||||||
setSearchQuery(query);
|
setSearchQuery(query);
|
||||||
fetchSearchResults(query);
|
fetchSearchResults(query);
|
||||||
|
|
||||||
// 保存到搜索历史
|
// 保存到搜索历史 (事件监听会自动更新界面)
|
||||||
addSearchHistory(query).then(async () => {
|
addSearchHistory(query);
|
||||||
const history = await getSearchHistory();
|
|
||||||
setSearchHistory(history);
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
setShowResults(false);
|
setShowResults(false);
|
||||||
}
|
}
|
||||||
@@ -161,11 +171,8 @@ function SearchPageClient() {
|
|||||||
// 直接发请求
|
// 直接发请求
|
||||||
fetchSearchResults(trimmed);
|
fetchSearchResults(trimmed);
|
||||||
|
|
||||||
// 保存到搜索历史
|
// 保存到搜索历史 (事件监听会自动更新界面)
|
||||||
addSearchHistory(trimmed).then(async () => {
|
addSearchHistory(trimmed);
|
||||||
const history = await getSearchHistory();
|
|
||||||
setSearchHistory(history);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -276,9 +283,8 @@ function SearchPageClient() {
|
|||||||
搜索历史
|
搜索历史
|
||||||
{searchHistory.length > 0 && (
|
{searchHistory.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={() => {
|
||||||
await clearSearchHistory();
|
clearSearchHistory(); // 事件监听会自动更新界面
|
||||||
setSearchHistory([]);
|
|
||||||
}}
|
}}
|
||||||
className='ml-3 text-sm text-gray-500 hover:text-red-500 transition-colors dark:text-gray-400 dark:hover:text-red-500'
|
className='ml-3 text-sm text-gray-500 hover:text-red-500 transition-colors dark:text-gray-400 dark:hover:text-red-500'
|
||||||
>
|
>
|
||||||
@@ -303,12 +309,10 @@ function SearchPageClient() {
|
|||||||
{/* 删除按钮 */}
|
{/* 删除按钮 */}
|
||||||
<button
|
<button
|
||||||
aria-label='删除搜索历史'
|
aria-label='删除搜索历史'
|
||||||
onClick={async (e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
await deleteSearchHistory(item);
|
deleteSearchHistory(item); // 事件监听会自动更新界面
|
||||||
const history = await getSearchHistory();
|
|
||||||
setSearchHistory(history);
|
|
||||||
}}
|
}}
|
||||||
className='absolute -top-1 -right-1 w-4 h-4 opacity-0 group-hover:opacity-100 bg-gray-400 hover:bg-red-500 text-white rounded-full flex items-center justify-center text-[10px] transition-colors'
|
className='absolute -top-1 -right-1 w-4 h-4 opacity-0 group-hover:opacity-100 bg-gray-400 hover:bg-red-500 text-white rounded-full flex items-center justify-center text-[10px] transition-colors'
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import type { PlayRecord } from '@/lib/db.client';
|
import type { PlayRecord } from '@/lib/db.client';
|
||||||
import { clearAllPlayRecords, getAllPlayRecords } from '@/lib/db.client';
|
import {
|
||||||
|
clearAllPlayRecords,
|
||||||
|
getAllPlayRecords,
|
||||||
|
subscribeToDataUpdates,
|
||||||
|
} from '@/lib/db.client';
|
||||||
|
|
||||||
import ScrollableRow from '@/components/ScrollableRow';
|
import ScrollableRow from '@/components/ScrollableRow';
|
||||||
import VideoCard from '@/components/VideoCard';
|
import VideoCard from '@/components/VideoCard';
|
||||||
@@ -19,21 +23,13 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
|||||||
>([]);
|
>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
// 处理播放记录数据更新的函数
|
||||||
const fetchPlayRecords = async () => {
|
const updatePlayRecords = (allRecords: Record<string, PlayRecord>) => {
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
// 从 localStorage 获取所有播放记录
|
|
||||||
const allRecords = await getAllPlayRecords();
|
|
||||||
|
|
||||||
// 将记录转换为数组并根据 save_time 由近到远排序
|
// 将记录转换为数组并根据 save_time 由近到远排序
|
||||||
const recordsArray = Object.entries(allRecords).map(
|
const recordsArray = Object.entries(allRecords).map(([key, record]) => ({
|
||||||
([key, record]) => ({
|
|
||||||
...record,
|
...record,
|
||||||
key,
|
key,
|
||||||
})
|
}));
|
||||||
);
|
|
||||||
|
|
||||||
// 按 save_time 降序排序(最新的在前面)
|
// 按 save_time 降序排序(最新的在前面)
|
||||||
const sortedRecords = recordsArray.sort(
|
const sortedRecords = recordsArray.sort(
|
||||||
@@ -41,6 +37,16 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
setPlayRecords(sortedRecords);
|
setPlayRecords(sortedRecords);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPlayRecords = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// 从缓存或API获取所有播放记录
|
||||||
|
const allRecords = await getAllPlayRecords();
|
||||||
|
updatePlayRecords(allRecords);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取播放记录失败:', error);
|
console.error('获取播放记录失败:', error);
|
||||||
setPlayRecords([]);
|
setPlayRecords([]);
|
||||||
@@ -50,6 +56,16 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchPlayRecords();
|
fetchPlayRecords();
|
||||||
|
|
||||||
|
// 监听播放记录更新事件
|
||||||
|
const unsubscribe = subscribeToDataUpdates(
|
||||||
|
'playRecordsUpdated',
|
||||||
|
(newRecords: Record<string, PlayRecord>) => {
|
||||||
|
updatePlayRecords(newRecords);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 如果没有播放记录,则不渲染组件
|
// 如果没有播放记录,则不渲染组件
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
import { CheckCircle, Heart, Link, PlayCircleIcon } from 'lucide-react';
|
import { CheckCircle, Heart, Link, PlayCircleIcon } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { deletePlayRecord, isFavorited, toggleFavorite } from '@/lib/db.client';
|
import {
|
||||||
|
deletePlayRecord,
|
||||||
|
generateStorageKey,
|
||||||
|
isFavorited,
|
||||||
|
subscribeToDataUpdates,
|
||||||
|
toggleFavorite,
|
||||||
|
} from '@/lib/db.client';
|
||||||
import { SearchResult } from '@/lib/types';
|
import { SearchResult } from '@/lib/types';
|
||||||
|
|
||||||
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
|
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
|
||||||
@@ -103,6 +111,7 @@ export default function VideoCard({
|
|||||||
// 获取收藏状态
|
// 获取收藏状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (from === 'douban' || !actualSource || !actualId) return;
|
if (from === 'douban' || !actualSource || !actualId) return;
|
||||||
|
|
||||||
const fetchFavoriteStatus = async () => {
|
const fetchFavoriteStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const fav = await isFavorited(actualSource, actualId);
|
const fav = await isFavorited(actualSource, actualId);
|
||||||
@@ -111,7 +120,21 @@ export default function VideoCard({
|
|||||||
throw new Error('检查收藏状态失败');
|
throw new Error('检查收藏状态失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchFavoriteStatus();
|
fetchFavoriteStatus();
|
||||||
|
|
||||||
|
// 监听收藏状态更新事件
|
||||||
|
const storageKey = generateStorageKey(actualSource, actualId);
|
||||||
|
const unsubscribe = subscribeToDataUpdates(
|
||||||
|
'favoritesUpdated',
|
||||||
|
(newFavorites: Record<string, any>) => {
|
||||||
|
// 检查当前项目是否在新的收藏列表中
|
||||||
|
const isNowFavorited = !!newFavorites[storageKey];
|
||||||
|
setFavorited(isNowFavorited);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
}, [from, actualSource, actualId]);
|
}, [from, actualSource, actualId]);
|
||||||
|
|
||||||
const handleToggleFavorite = useCallback(
|
const handleToggleFavorite = useCallback(
|
||||||
|
|||||||
+850
-57
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable no-console, @typescript-eslint/no-explicit-any */
|
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function */
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,10 +9,13 @@
|
|||||||
* 功能:
|
* 功能:
|
||||||
* 1. 获取全部播放记录(getAllPlayRecords)。
|
* 1. 获取全部播放记录(getAllPlayRecords)。
|
||||||
* 2. 保存播放记录(savePlayRecord)。
|
* 2. 保存播放记录(savePlayRecord)。
|
||||||
|
* 3. D1 存储模式下的混合缓存策略,提升用户体验。
|
||||||
*
|
*
|
||||||
* 如后续需要在客户端读取收藏等其它数据,可按同样方式在此文件中补充实现。
|
* 如后续需要在客户端读取收藏等其它数据,可按同样方式在此文件中补充实现。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { getAuthInfoFromBrowserCookie } from './auth';
|
||||||
|
|
||||||
// ---- 类型 ----
|
// ---- 类型 ----
|
||||||
export interface PlayRecord {
|
export interface PlayRecord {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -27,8 +30,39 @@ export interface PlayRecord {
|
|||||||
search_title?: string; // 搜索时使用的标题
|
search_title?: string; // 搜索时使用的标题
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 收藏类型 ----
|
||||||
|
export interface Favorite {
|
||||||
|
title: string;
|
||||||
|
source_name: string;
|
||||||
|
year: string;
|
||||||
|
cover: string;
|
||||||
|
total_episodes: number;
|
||||||
|
save_time: number;
|
||||||
|
search_title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 缓存数据结构 ----
|
||||||
|
interface CacheData<T> {
|
||||||
|
data: T;
|
||||||
|
timestamp: number;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserCacheStore {
|
||||||
|
playRecords?: CacheData<Record<string, PlayRecord>>;
|
||||||
|
favorites?: CacheData<Record<string, Favorite>>;
|
||||||
|
searchHistory?: CacheData<string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 常量 ----
|
// ---- 常量 ----
|
||||||
const PLAY_RECORDS_KEY = 'moontv_play_records';
|
const PLAY_RECORDS_KEY = 'moontv_play_records';
|
||||||
|
const FAVORITES_KEY = 'moontv_favorites';
|
||||||
|
const SEARCH_HISTORY_KEY = 'moontv_search_history';
|
||||||
|
|
||||||
|
// 缓存相关常量
|
||||||
|
const CACHE_PREFIX = 'moontv_cache_';
|
||||||
|
const CACHE_VERSION = '1.0.0';
|
||||||
|
const CACHE_EXPIRE_TIME = 60 * 60 * 1000; // 一小时缓存过期
|
||||||
|
|
||||||
// ---- 环境变量 ----
|
// ---- 环境变量 ----
|
||||||
const STORAGE_TYPE = (() => {
|
const STORAGE_TYPE = (() => {
|
||||||
@@ -37,16 +71,288 @@ const STORAGE_TYPE = (() => {
|
|||||||
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE) ||
|
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE) ||
|
||||||
(process.env.STORAGE_TYPE as 'localstorage' | 'redis' | 'd1' | undefined) ||
|
(process.env.STORAGE_TYPE as 'localstorage' | 'redis' | 'd1' | undefined) ||
|
||||||
'localstorage';
|
'localstorage';
|
||||||
// 兼容 redis => database
|
|
||||||
return raw;
|
return raw;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ---------------- 搜索历史相关常量 ----------------
|
// ---------------- 搜索历史相关常量 ----------------
|
||||||
const SEARCH_HISTORY_KEY = 'moontv_search_history';
|
|
||||||
|
|
||||||
// 搜索历史最大保存条数
|
// 搜索历史最大保存条数
|
||||||
const SEARCH_HISTORY_LIMIT = 20;
|
const SEARCH_HISTORY_LIMIT = 20;
|
||||||
|
|
||||||
|
// ---- 缓存管理器 ----
|
||||||
|
class HybridCacheManager {
|
||||||
|
private static instance: HybridCacheManager;
|
||||||
|
|
||||||
|
static getInstance(): HybridCacheManager {
|
||||||
|
if (!HybridCacheManager.instance) {
|
||||||
|
HybridCacheManager.instance = new HybridCacheManager();
|
||||||
|
}
|
||||||
|
return HybridCacheManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户名
|
||||||
|
*/
|
||||||
|
private getCurrentUsername(): string | null {
|
||||||
|
const authInfo = getAuthInfoFromBrowserCookie();
|
||||||
|
return authInfo?.username || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成用户专属的缓存key
|
||||||
|
*/
|
||||||
|
private getUserCacheKey(username: string): string {
|
||||||
|
return `${CACHE_PREFIX}${username}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户缓存数据
|
||||||
|
*/
|
||||||
|
private getUserCache(username: string): UserCacheStore {
|
||||||
|
if (typeof window === 'undefined') return {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cacheKey = this.getUserCacheKey(username);
|
||||||
|
const cached = localStorage.getItem(cacheKey);
|
||||||
|
return cached ? JSON.parse(cached) : {};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('获取用户缓存失败:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存用户缓存数据
|
||||||
|
*/
|
||||||
|
private saveUserCache(username: string, cache: UserCacheStore): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cacheKey = this.getUserCacheKey(username);
|
||||||
|
localStorage.setItem(cacheKey, JSON.stringify(cache));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('保存用户缓存失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查缓存是否有效
|
||||||
|
*/
|
||||||
|
private isCacheValid<T>(cache: CacheData<T>): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
return (
|
||||||
|
cache.version === CACHE_VERSION &&
|
||||||
|
now - cache.timestamp < CACHE_EXPIRE_TIME
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建缓存数据
|
||||||
|
*/
|
||||||
|
private createCacheData<T>(data: T): CacheData<T> {
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
version: CACHE_VERSION,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存的播放记录
|
||||||
|
*/
|
||||||
|
getCachedPlayRecords(): Record<string, PlayRecord> | null {
|
||||||
|
const username = this.getCurrentUsername();
|
||||||
|
if (!username) return null;
|
||||||
|
|
||||||
|
const userCache = this.getUserCache(username);
|
||||||
|
const cached = userCache.playRecords;
|
||||||
|
|
||||||
|
if (cached && this.isCacheValid(cached)) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存播放记录
|
||||||
|
*/
|
||||||
|
cachePlayRecords(data: Record<string, PlayRecord>): void {
|
||||||
|
const username = this.getCurrentUsername();
|
||||||
|
if (!username) return;
|
||||||
|
|
||||||
|
const userCache = this.getUserCache(username);
|
||||||
|
userCache.playRecords = this.createCacheData(data);
|
||||||
|
this.saveUserCache(username, userCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存的收藏
|
||||||
|
*/
|
||||||
|
getCachedFavorites(): Record<string, Favorite> | null {
|
||||||
|
const username = this.getCurrentUsername();
|
||||||
|
if (!username) return null;
|
||||||
|
|
||||||
|
const userCache = this.getUserCache(username);
|
||||||
|
const cached = userCache.favorites;
|
||||||
|
|
||||||
|
if (cached && this.isCacheValid(cached)) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存收藏
|
||||||
|
*/
|
||||||
|
cacheFavorites(data: Record<string, Favorite>): void {
|
||||||
|
const username = this.getCurrentUsername();
|
||||||
|
if (!username) return;
|
||||||
|
|
||||||
|
const userCache = this.getUserCache(username);
|
||||||
|
userCache.favorites = this.createCacheData(data);
|
||||||
|
this.saveUserCache(username, userCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存的搜索历史
|
||||||
|
*/
|
||||||
|
getCachedSearchHistory(): string[] | null {
|
||||||
|
const username = this.getCurrentUsername();
|
||||||
|
if (!username) return null;
|
||||||
|
|
||||||
|
const userCache = this.getUserCache(username);
|
||||||
|
const cached = userCache.searchHistory;
|
||||||
|
|
||||||
|
if (cached && this.isCacheValid(cached)) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存搜索历史
|
||||||
|
*/
|
||||||
|
cacheSearchHistory(data: string[]): void {
|
||||||
|
const username = this.getCurrentUsername();
|
||||||
|
if (!username) return;
|
||||||
|
|
||||||
|
const userCache = this.getUserCache(username);
|
||||||
|
userCache.searchHistory = this.createCacheData(data);
|
||||||
|
this.saveUserCache(username, userCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除指定用户的所有缓存
|
||||||
|
*/
|
||||||
|
clearUserCache(username?: string): void {
|
||||||
|
const targetUsername = username || this.getCurrentUsername();
|
||||||
|
if (!targetUsername) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cacheKey = this.getUserCacheKey(targetUsername);
|
||||||
|
localStorage.removeItem(cacheKey);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('清除用户缓存失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有过期缓存
|
||||||
|
*/
|
||||||
|
clearExpiredCaches(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keysToRemove: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key?.startsWith(CACHE_PREFIX)) {
|
||||||
|
try {
|
||||||
|
const cache = JSON.parse(localStorage.getItem(key) || '{}');
|
||||||
|
// 检查是否有任何缓存数据过期
|
||||||
|
let hasValidData = false;
|
||||||
|
for (const [, cacheData] of Object.entries(cache)) {
|
||||||
|
if (cacheData && this.isCacheValid(cacheData as CacheData<any>)) {
|
||||||
|
hasValidData = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasValidData) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 解析失败的缓存也删除
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('清除过期缓存失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取缓存管理器实例
|
||||||
|
const cacheManager = HybridCacheManager.getInstance();
|
||||||
|
|
||||||
|
// ---- 错误处理辅助函数 ----
|
||||||
|
/**
|
||||||
|
* 数据库操作失败时的通用错误处理
|
||||||
|
* 立即从数据库刷新对应类型的缓存以保持数据一致性
|
||||||
|
*/
|
||||||
|
async function handleDatabaseOperationFailure(
|
||||||
|
dataType: 'playRecords' | 'favorites' | 'searchHistory',
|
||||||
|
error: any
|
||||||
|
): Promise<void> {
|
||||||
|
console.error(`数据库操作失败 (${dataType}):`, error);
|
||||||
|
|
||||||
|
try {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发更新事件通知组件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(eventName, {
|
||||||
|
detail: freshData,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (refreshErr) {
|
||||||
|
console.error(`刷新${dataType}缓存失败:`, refreshErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面加载时清理过期缓存
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
setTimeout(() => cacheManager.clearExpiredCaches(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 工具函数 ----
|
// ---- 工具函数 ----
|
||||||
async function fetchFromApi<T>(path: string): Promise<T> {
|
async function fetchFromApi<T>(path: string): Promise<T> {
|
||||||
const res = await fetch(path);
|
const res = await fetch(path);
|
||||||
@@ -63,21 +369,62 @@ export function generateStorageKey(source: string, id: string): string {
|
|||||||
|
|
||||||
// ---- API ----
|
// ---- API ----
|
||||||
/**
|
/**
|
||||||
* 读取 localStorage 中的全部播放记录。
|
* 读取全部播放记录。
|
||||||
|
* D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||||
* 在服务端渲染阶段 (window === undefined) 时返回空对象,避免报错。
|
* 在服务端渲染阶段 (window === undefined) 时返回空对象,避免报错。
|
||||||
*/
|
*/
|
||||||
export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
|
export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
|
||||||
// 若配置标明使用数据库,则从后端 API 拉取
|
// 服务器端渲染阶段直接返回空,交由客户端 useEffect 再行请求
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// D1 存储模式:使用混合缓存策略
|
||||||
|
if (STORAGE_TYPE === 'd1') {
|
||||||
|
// 优先从缓存获取数据
|
||||||
|
const cachedData = cacheManager.getCachedPlayRecords();
|
||||||
|
|
||||||
|
if (cachedData) {
|
||||||
|
// 返回缓存数据,同时后台异步更新
|
||||||
|
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`)
|
||||||
|
.then((freshData) => {
|
||||||
|
// 只有数据真正不同时才更新缓存
|
||||||
|
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
|
||||||
|
cacheManager.cachePlayRecords(freshData);
|
||||||
|
// 触发数据更新事件,供组件监听
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('playRecordsUpdated', {
|
||||||
|
detail: freshData,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('后台同步播放记录失败:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return cachedData;
|
||||||
|
} else {
|
||||||
|
// 缓存为空,直接从 API 获取并缓存
|
||||||
|
try {
|
||||||
|
const freshData = await fetchFromApi<Record<string, PlayRecord>>(
|
||||||
|
`/api/playrecords`
|
||||||
|
);
|
||||||
|
cacheManager.cachePlayRecords(freshData);
|
||||||
|
return freshData;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取播放记录失败:', err);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他数据库存储模式:直接从 API 获取
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
return fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`);
|
return fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认 / localstorage 流程
|
// localstorage 模式
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
// 服务器端渲染阶段直接返回空,交由客户端 useEffect 再行请求
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(PLAY_RECORDS_KEY);
|
const raw = localStorage.getItem(PLAY_RECORDS_KEY);
|
||||||
if (!raw) return {};
|
if (!raw) return {};
|
||||||
@@ -89,7 +436,8 @@ export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存播放记录到 localStorage 或通过 API 保存到数据库
|
* 保存播放记录。
|
||||||
|
* D1 存储模式下使用乐观更新:先更新缓存(立即生效),再异步同步到数据库。
|
||||||
*/
|
*/
|
||||||
export async function savePlayRecord(
|
export async function savePlayRecord(
|
||||||
source: string,
|
source: string,
|
||||||
@@ -98,7 +446,41 @@ export async function savePlayRecord(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const key = generateStorageKey(source, id);
|
const key = generateStorageKey(source, id);
|
||||||
|
|
||||||
// 若配置标明使用数据库,则通过 API 保存
|
// D1 存储模式:乐观更新策略
|
||||||
|
if (STORAGE_TYPE === 'd1') {
|
||||||
|
// 立即更新缓存
|
||||||
|
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
|
||||||
|
cachedRecords[key] = record;
|
||||||
|
cacheManager.cachePlayRecords(cachedRecords);
|
||||||
|
|
||||||
|
// 触发立即更新事件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('playRecordsUpdated', {
|
||||||
|
detail: cachedRecords,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 异步同步到数据库
|
||||||
|
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) {
|
||||||
|
await handleDatabaseOperationFailure('playRecords', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他数据库存储模式:直接通过 API 保存
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/playrecords', {
|
const res = await fetch('/api/playrecords', {
|
||||||
@@ -116,7 +498,7 @@ export async function savePlayRecord(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认 / localstorage 流程
|
// localstorage 模式
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
console.warn('无法在服务端保存播放记录到 localStorage');
|
console.warn('无法在服务端保存播放记录到 localStorage');
|
||||||
return;
|
return;
|
||||||
@@ -133,7 +515,8 @@ export async function savePlayRecord(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除播放记录
|
* 删除播放记录。
|
||||||
|
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
*/
|
*/
|
||||||
export async function deletePlayRecord(
|
export async function deletePlayRecord(
|
||||||
source: string,
|
source: string,
|
||||||
@@ -141,7 +524,37 @@ export async function deletePlayRecord(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const key = generateStorageKey(source, id);
|
const key = generateStorageKey(source, id);
|
||||||
|
|
||||||
// 若配置标明使用数据库,则通过 API 删除
|
// D1 存储模式:乐观更新策略
|
||||||
|
if (STORAGE_TYPE === 'd1') {
|
||||||
|
// 立即更新缓存
|
||||||
|
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
|
||||||
|
delete cachedRecords[key];
|
||||||
|
cacheManager.cachePlayRecords(cachedRecords);
|
||||||
|
|
||||||
|
// 触发立即更新事件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('playRecordsUpdated', {
|
||||||
|
detail: cachedRecords,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 异步同步到数据库
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/playrecords?key=${encodeURIComponent(key)}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`删除播放记录失败: ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
await handleDatabaseOperationFailure('playRecords', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他数据库存储模式:直接通过 API 删除
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
@@ -158,7 +571,7 @@ export async function deletePlayRecord(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认 / localstorage 流程
|
// localstorage 模式
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
console.warn('无法在服务端删除播放记录到 localStorage');
|
console.warn('无法在服务端删除播放记录到 localStorage');
|
||||||
return;
|
return;
|
||||||
@@ -178,10 +591,54 @@ export async function deletePlayRecord(
|
|||||||
/* ---------------- 搜索历史相关 API ---------------- */
|
/* ---------------- 搜索历史相关 API ---------------- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取搜索历史
|
* 获取搜索历史。
|
||||||
|
* D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||||
*/
|
*/
|
||||||
export async function getSearchHistory(): Promise<string[]> {
|
export async function getSearchHistory(): Promise<string[]> {
|
||||||
// 如果配置为使用数据库,则从后端 API 获取
|
// 服务器端渲染阶段直接返回空
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// D1 存储模式:使用混合缓存策略
|
||||||
|
if (STORAGE_TYPE === 'd1') {
|
||||||
|
// 优先从缓存获取数据
|
||||||
|
const cachedData = cacheManager.getCachedSearchHistory();
|
||||||
|
|
||||||
|
if (cachedData) {
|
||||||
|
// 返回缓存数据,同时后台异步更新
|
||||||
|
fetchFromApi<string[]>(`/api/searchhistory`)
|
||||||
|
.then((freshData) => {
|
||||||
|
// 只有数据真正不同时才更新缓存
|
||||||
|
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
|
||||||
|
cacheManager.cacheSearchHistory(freshData);
|
||||||
|
// 触发数据更新事件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('searchHistoryUpdated', {
|
||||||
|
detail: freshData,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('后台同步搜索历史失败:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return cachedData;
|
||||||
|
} else {
|
||||||
|
// 缓存为空,直接从 API 获取并缓存
|
||||||
|
try {
|
||||||
|
const freshData = await fetchFromApi<string[]>(`/api/searchhistory`);
|
||||||
|
cacheManager.cacheSearchHistory(freshData);
|
||||||
|
return freshData;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取搜索历史失败:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他数据库存储模式
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
try {
|
try {
|
||||||
return fetchFromApi<string[]>(`/api/searchhistory`);
|
return fetchFromApi<string[]>(`/api/searchhistory`);
|
||||||
@@ -191,11 +648,7 @@ export async function getSearchHistory(): Promise<string[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认从 localStorage 读取
|
// localStorage 模式
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(SEARCH_HISTORY_KEY);
|
const raw = localStorage.getItem(SEARCH_HISTORY_KEY);
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
@@ -209,13 +662,48 @@ export async function getSearchHistory(): Promise<string[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将关键字添加到搜索历史
|
* 将关键字添加到搜索历史。
|
||||||
|
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
*/
|
*/
|
||||||
export async function addSearchHistory(keyword: string): Promise<void> {
|
export async function addSearchHistory(keyword: string): Promise<void> {
|
||||||
const trimmed = keyword.trim();
|
const trimmed = keyword.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
|
|
||||||
// 数据库模式
|
// D1 存储模式:乐观更新策略
|
||||||
|
if (STORAGE_TYPE === 'd1') {
|
||||||
|
// 立即更新缓存
|
||||||
|
const cachedHistory = cacheManager.getCachedSearchHistory() || [];
|
||||||
|
const newHistory = [trimmed, ...cachedHistory.filter((k) => k !== trimmed)];
|
||||||
|
// 限制长度
|
||||||
|
if (newHistory.length > SEARCH_HISTORY_LIMIT) {
|
||||||
|
newHistory.length = SEARCH_HISTORY_LIMIT;
|
||||||
|
}
|
||||||
|
cacheManager.cacheSearchHistory(newHistory);
|
||||||
|
|
||||||
|
// 触发立即更新事件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('searchHistoryUpdated', {
|
||||||
|
detail: newHistory,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 异步同步到数据库
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/searchhistory', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ keyword: trimmed }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`保存搜索历史失败: ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
await handleDatabaseOperationFailure('searchHistory', err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他数据库存储模式
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/searchhistory', {
|
await fetch('/api/searchhistory', {
|
||||||
@@ -248,10 +736,35 @@ export async function addSearchHistory(keyword: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清空搜索历史
|
* 清空搜索历史。
|
||||||
|
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
*/
|
*/
|
||||||
export async function clearSearchHistory(): Promise<void> {
|
export async function clearSearchHistory(): Promise<void> {
|
||||||
// 数据库模式
|
// D1 存储模式:乐观更新策略
|
||||||
|
if (STORAGE_TYPE === 'd1') {
|
||||||
|
// 立即更新缓存
|
||||||
|
cacheManager.cacheSearchHistory([]);
|
||||||
|
|
||||||
|
// 触发立即更新事件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('searchHistoryUpdated', {
|
||||||
|
detail: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 异步同步到数据库
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/searchhistory`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`清空搜索历史失败: ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
await handleDatabaseOperationFailure('searchHistory', err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他数据库存储模式
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/searchhistory`, {
|
await fetch(`/api/searchhistory`, {
|
||||||
@@ -269,13 +782,43 @@ export async function clearSearchHistory(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除单条搜索历史
|
* 删除单条搜索历史。
|
||||||
|
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
*/
|
*/
|
||||||
export async function deleteSearchHistory(keyword: string): Promise<void> {
|
export async function deleteSearchHistory(keyword: string): Promise<void> {
|
||||||
const trimmed = keyword.trim();
|
const trimmed = keyword.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
|
|
||||||
// 数据库模式
|
// D1 存储模式:乐观更新策略
|
||||||
|
if (STORAGE_TYPE === 'd1') {
|
||||||
|
// 立即更新缓存
|
||||||
|
const cachedHistory = cacheManager.getCachedSearchHistory() || [];
|
||||||
|
const newHistory = cachedHistory.filter((k) => k !== trimmed);
|
||||||
|
cacheManager.cacheSearchHistory(newHistory);
|
||||||
|
|
||||||
|
// 触发立即更新事件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('searchHistoryUpdated', {
|
||||||
|
detail: newHistory,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 异步同步到数据库
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/searchhistory?keyword=${encodeURIComponent(trimmed)}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`删除搜索历史失败: ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
await handleDatabaseOperationFailure('searchHistory', err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他数据库存储模式
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/searchhistory?keyword=${encodeURIComponent(trimmed)}`, {
|
await fetch(`/api/searchhistory?keyword=${encodeURIComponent(trimmed)}`, {
|
||||||
@@ -301,34 +844,62 @@ export async function deleteSearchHistory(keyword: string): Promise<void> {
|
|||||||
|
|
||||||
// ---------------- 收藏相关 API ----------------
|
// ---------------- 收藏相关 API ----------------
|
||||||
|
|
||||||
// 收藏数据结构
|
|
||||||
export interface Favorite {
|
|
||||||
title: string;
|
|
||||||
source_name: string;
|
|
||||||
year: string;
|
|
||||||
cover: string;
|
|
||||||
total_episodes: number;
|
|
||||||
save_time: number;
|
|
||||||
search_title?: string; // 搜索时使用的标题
|
|
||||||
}
|
|
||||||
|
|
||||||
// 收藏在 localStorage 中使用的 key
|
|
||||||
const FAVORITES_KEY = 'moontv_favorites';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取全部收藏
|
* 获取全部收藏。
|
||||||
|
* D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||||
*/
|
*/
|
||||||
export async function getAllFavorites(): Promise<Record<string, Favorite>> {
|
export async function getAllFavorites(): Promise<Record<string, Favorite>> {
|
||||||
// 数据库模式
|
// 服务器端渲染阶段直接返回空
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// D1 存储模式:使用混合缓存策略
|
||||||
|
if (STORAGE_TYPE === 'd1') {
|
||||||
|
// 优先从缓存获取数据
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('后台同步收藏失败:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return cachedData;
|
||||||
|
} else {
|
||||||
|
// 缓存为空,直接从 API 获取并缓存
|
||||||
|
try {
|
||||||
|
const freshData = await fetchFromApi<Record<string, Favorite>>(
|
||||||
|
`/api/favorites`
|
||||||
|
);
|
||||||
|
cacheManager.cacheFavorites(freshData);
|
||||||
|
return freshData;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取收藏失败:', err);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他数据库存储模式
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
return fetchFromApi<Record<string, Favorite>>(`/api/favorites`);
|
return fetchFromApi<Record<string, Favorite>>(`/api/favorites`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// localStorage 模式
|
// localStorage 模式
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(FAVORITES_KEY);
|
const raw = localStorage.getItem(FAVORITES_KEY);
|
||||||
if (!raw) return {};
|
if (!raw) return {};
|
||||||
@@ -340,7 +911,8 @@ export async function getAllFavorites(): Promise<Record<string, Favorite>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存收藏
|
* 保存收藏。
|
||||||
|
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
*/
|
*/
|
||||||
export async function saveFavorite(
|
export async function saveFavorite(
|
||||||
source: string,
|
source: string,
|
||||||
@@ -349,7 +921,38 @@ export async function saveFavorite(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const key = generateStorageKey(source, id);
|
const key = generateStorageKey(source, id);
|
||||||
|
|
||||||
// 数据库模式
|
// D1 存储模式:乐观更新策略
|
||||||
|
if (STORAGE_TYPE === 'd1') {
|
||||||
|
// 立即更新缓存
|
||||||
|
const cachedFavorites = cacheManager.getCachedFavorites() || {};
|
||||||
|
cachedFavorites[key] = favorite;
|
||||||
|
cacheManager.cacheFavorites(cachedFavorites);
|
||||||
|
|
||||||
|
// 触发立即更新事件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('favoritesUpdated', {
|
||||||
|
detail: cachedFavorites,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 异步同步到数据库
|
||||||
|
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) {
|
||||||
|
await handleDatabaseOperationFailure('favorites', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他数据库存储模式
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/favorites', {
|
const res = await fetch('/api/favorites', {
|
||||||
@@ -384,7 +987,8 @@ export async function saveFavorite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除收藏
|
* 删除收藏。
|
||||||
|
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
*/
|
*/
|
||||||
export async function deleteFavorite(
|
export async function deleteFavorite(
|
||||||
source: string,
|
source: string,
|
||||||
@@ -392,7 +996,34 @@ export async function deleteFavorite(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const key = generateStorageKey(source, id);
|
const key = generateStorageKey(source, id);
|
||||||
|
|
||||||
// 数据库模式
|
// D1 存储模式:乐观更新策略
|
||||||
|
if (STORAGE_TYPE === 'd1') {
|
||||||
|
// 立即更新缓存
|
||||||
|
const cachedFavorites = cacheManager.getCachedFavorites() || {};
|
||||||
|
delete cachedFavorites[key];
|
||||||
|
cacheManager.cacheFavorites(cachedFavorites);
|
||||||
|
|
||||||
|
// 触发立即更新事件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('favoritesUpdated', {
|
||||||
|
detail: cachedFavorites,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 异步同步到数据库
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`删除收藏失败: ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
await handleDatabaseOperationFailure('favorites', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他数据库存储模式
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`, {
|
const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`, {
|
||||||
@@ -423,7 +1054,8 @@ export async function deleteFavorite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断是否已收藏
|
* 判断是否已收藏。
|
||||||
|
* D1 存储模式下优先使用缓存数据。
|
||||||
*/
|
*/
|
||||||
export async function isFavorited(
|
export async function isFavorited(
|
||||||
source: string,
|
source: string,
|
||||||
@@ -431,7 +1063,26 @@ export async function isFavorited(
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const key = generateStorageKey(source, id);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他数据库存储模式
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`);
|
const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`);
|
||||||
@@ -516,3 +1167,145 @@ export async function clearAllFavorites(): Promise<void> {
|
|||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
localStorage.removeItem(FAVORITES_KEY);
|
localStorage.removeItem(FAVORITES_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- 混合缓存辅助函数 ----------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除当前用户的所有缓存数据
|
||||||
|
* 用于用户登出时清理缓存
|
||||||
|
*/
|
||||||
|
export function clearUserCache(): void {
|
||||||
|
if (STORAGE_TYPE === 'd1') {
|
||||||
|
cacheManager.clearUserCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动刷新所有缓存数据
|
||||||
|
* 强制从服务器重新获取数据并更新缓存
|
||||||
|
*/
|
||||||
|
export async function refreshAllCache(): Promise<void> {
|
||||||
|
if (STORAGE_TYPE !== 'd1') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 并行刷新所有数据
|
||||||
|
const [playRecords, favorites, searchHistory] = await Promise.allSettled([
|
||||||
|
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`),
|
||||||
|
fetchFromApi<Record<string, Favorite>>(`/api/favorites`),
|
||||||
|
fetchFromApi<string[]>(`/api/searchhistory`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 (searchHistory.status === 'fulfilled') {
|
||||||
|
cacheManager.cacheSearchHistory(searchHistory.value);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('searchHistoryUpdated', {
|
||||||
|
detail: searchHistory.value,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('刷新缓存失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存状态信息
|
||||||
|
* 用于调试和监控缓存健康状态
|
||||||
|
*/
|
||||||
|
export function getCacheStatus(): {
|
||||||
|
hasPlayRecords: boolean;
|
||||||
|
hasFavorites: boolean;
|
||||||
|
hasSearchHistory: boolean;
|
||||||
|
username: string | null;
|
||||||
|
} {
|
||||||
|
if (STORAGE_TYPE !== 'd1') {
|
||||||
|
return {
|
||||||
|
hasPlayRecords: false,
|
||||||
|
hasFavorites: false,
|
||||||
|
hasSearchHistory: false,
|
||||||
|
username: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const authInfo = getAuthInfoFromBrowserCookie();
|
||||||
|
return {
|
||||||
|
hasPlayRecords: !!cacheManager.getCachedPlayRecords(),
|
||||||
|
hasFavorites: !!cacheManager.getCachedFavorites(),
|
||||||
|
hasSearchHistory: !!cacheManager.getCachedSearchHistory(),
|
||||||
|
username: authInfo?.username || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- React Hook 辅助类型 ----------------
|
||||||
|
|
||||||
|
export type CacheUpdateEvent =
|
||||||
|
| 'playRecordsUpdated'
|
||||||
|
| 'favoritesUpdated'
|
||||||
|
| 'searchHistoryUpdated';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用于 React 组件监听数据更新的事件监听器
|
||||||
|
* 使用方法:
|
||||||
|
*
|
||||||
|
* useEffect(() => {
|
||||||
|
* const unsubscribe = subscribeToDataUpdates('playRecordsUpdated', (data) => {
|
||||||
|
* setPlayRecords(data);
|
||||||
|
* });
|
||||||
|
* return unsubscribe;
|
||||||
|
* }, []);
|
||||||
|
*/
|
||||||
|
export function subscribeToDataUpdates<T>(
|
||||||
|
eventType: CacheUpdateEvent,
|
||||||
|
callback: (data: T) => void
|
||||||
|
): () => void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = (event: CustomEvent) => {
|
||||||
|
callback(event.detail);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(eventType, handleUpdate as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(eventType, handleUpdate as EventListener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预加载所有用户数据到缓存
|
||||||
|
* 适合在应用启动时调用,提升后续访问速度
|
||||||
|
*/
|
||||||
|
export async function preloadUserData(): Promise<void> {
|
||||||
|
if (STORAGE_TYPE !== 'd1') return;
|
||||||
|
|
||||||
|
// 检查是否已有有效缓存,避免重复请求
|
||||||
|
const status = getCacheStatus();
|
||||||
|
if (status.hasPlayRecords && status.hasFavorites && status.hasSearchHistory) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台静默预加载,不阻塞界面
|
||||||
|
refreshAllCache().catch((err) => {
|
||||||
|
console.warn('预加载用户数据失败:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+16
-12
@@ -23,13 +23,13 @@ export async function middleware(request: NextRequest) {
|
|||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
|
||||||
if (!authInfo) {
|
if (!authInfo) {
|
||||||
return redirectToLogin(request, pathname);
|
return handleAuthFailure(request, pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
// localstorage模式:在middleware中完成验证
|
// localstorage模式:在middleware中完成验证
|
||||||
if (storageType === 'localstorage') {
|
if (storageType === 'localstorage') {
|
||||||
if (!authInfo.password || authInfo.password !== process.env.PASSWORD) {
|
if (!authInfo.password || authInfo.password !== process.env.PASSWORD) {
|
||||||
return redirectToLogin(request, pathname);
|
return handleAuthFailure(request, pathname);
|
||||||
}
|
}
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ export async function middleware(request: NextRequest) {
|
|||||||
// 其他模式:只验证签名
|
// 其他模式:只验证签名
|
||||||
// 检查是否有用户名(非localStorage模式下密码不存储在cookie中)
|
// 检查是否有用户名(非localStorage模式下密码不存储在cookie中)
|
||||||
if (!authInfo.username || !authInfo.signature) {
|
if (!authInfo.username || !authInfo.signature) {
|
||||||
return redirectToLogin(request, pathname);
|
return handleAuthFailure(request, pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证签名(如果存在)
|
// 验证签名(如果存在)
|
||||||
@@ -55,7 +55,7 @@ export async function middleware(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 签名验证失败或不存在签名
|
// 签名验证失败或不存在签名
|
||||||
return redirectToLogin(request, pathname);
|
return handleAuthFailure(request, pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证签名
|
// 验证签名
|
||||||
@@ -96,8 +96,17 @@ async function verifySignature(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重定向到登录页面
|
// 处理认证失败的情况
|
||||||
function redirectToLogin(request: NextRequest, pathname: string): NextResponse {
|
function handleAuthFailure(
|
||||||
|
request: NextRequest,
|
||||||
|
pathname: string
|
||||||
|
): NextResponse {
|
||||||
|
// 如果是 API 路由,返回 401 状态码
|
||||||
|
if (pathname.startsWith('/api')) {
|
||||||
|
return new NextResponse('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则重定向到登录页面
|
||||||
const loginUrl = new URL('/login', request.url);
|
const loginUrl = new URL('/login', request.url);
|
||||||
// 保留完整的URL,包括查询参数
|
// 保留完整的URL,包括查询参数
|
||||||
const fullUrl = `${pathname}${request.nextUrl.search}`;
|
const fullUrl = `${pathname}${request.nextUrl.search}`;
|
||||||
@@ -108,11 +117,6 @@ function redirectToLogin(request: NextRequest, pathname: string): NextResponse {
|
|||||||
// 判断是否需要跳过认证的路径
|
// 判断是否需要跳过认证的路径
|
||||||
function shouldSkipAuth(pathname: string): boolean {
|
function shouldSkipAuth(pathname: string): boolean {
|
||||||
const skipPaths = [
|
const skipPaths = [
|
||||||
'/login',
|
|
||||||
'/api/login',
|
|
||||||
'/api/register',
|
|
||||||
'/api/logout',
|
|
||||||
'/api/server-config',
|
|
||||||
'/_next',
|
'/_next',
|
||||||
'/favicon.ico',
|
'/favicon.ico',
|
||||||
'/robots.txt',
|
'/robots.txt',
|
||||||
@@ -128,6 +132,6 @@ function shouldSkipAuth(pathname: string): boolean {
|
|||||||
// 配置middleware匹配规则
|
// 配置middleware匹配规则
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
'/((?!_next/static|_next/image|favicon.ico|api/detail|api/search|api/image-proxy|api/douban|api/cron|api/server-config).*)',
|
'/((?!_next/static|_next/image|favicon.ico|login|api/login|api/register|api/logout|api/cron|api/server-config).*)',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user