8 Commits

Author SHA1 Message Date
shinya cfea92ec7f fix: auth 2025-07-14 22:33:45 +08:00
shinya b61430856a feat: d1 local cache 2025-07-14 22:09:53 +08:00
shinya 7e6f4bcadc feat: update readme 2025-07-14 21:02:59 +08:00
shinya c2ebf5758e fix: add await 2025-07-14 20:53:40 +08:00
senshinya 446bb0e9f0 Update layout.tsx 2025-07-14 19:59:19 +08:00
shinya ea3d1065e8 feat: lower d1 saveCurrentPlayProgress frequency 2025-07-14 13:23:46 +08:00
shinya eb3bffab4e fix: sitename 2025-07-14 13:21:37 +08:00
senshinya 45dfdc62f0 Merge pull request #147 from senshinya/d1
support D1 storage
2025-07-14 13:15:39 +08:00
15 changed files with 1098 additions and 210 deletions
+28 -28
View File
@@ -3,9 +3,9 @@ CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY, username TEXT PRIMARY KEY,
password TEXT NOT NULL, password TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
); );
CREATE TABLE IF NOT EXISTS play_records ( CREATE TABLE IF NOT EXISTS play_records (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL, username TEXT NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,
@@ -20,9 +20,9 @@ CREATE TABLE IF NOT EXISTS users (
save_time INTEGER NOT NULL, save_time INTEGER NOT NULL,
search_title TEXT, search_title TEXT,
UNIQUE(username, key) UNIQUE(username, key)
); );
CREATE TABLE IF NOT EXISTS favorites ( CREATE TABLE IF NOT EXISTS favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL, username TEXT NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,
@@ -33,43 +33,43 @@ CREATE TABLE IF NOT EXISTS users (
total_episodes INTEGER NOT NULL, total_episodes INTEGER NOT NULL,
save_time INTEGER NOT NULL, save_time INTEGER NOT NULL,
UNIQUE(username, key) UNIQUE(username, key)
); );
CREATE TABLE IF NOT EXISTS search_history ( CREATE TABLE IF NOT EXISTS search_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL, username TEXT NOT NULL,
keyword TEXT NOT NULL, keyword TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
UNIQUE(username, keyword) UNIQUE(username, keyword)
); );
CREATE TABLE IF NOT EXISTS admin_config ( CREATE TABLE IF NOT EXISTS admin_config (
id INTEGER PRIMARY KEY DEFAULT 1, id INTEGER PRIMARY KEY DEFAULT 1,
config TEXT NOT NULL, config TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
); );
-- 基本索引 -- 基本索引
CREATE INDEX IF NOT EXISTS idx_play_records_username ON play_records(username); CREATE INDEX IF NOT EXISTS idx_play_records_username ON play_records(username);
CREATE INDEX IF NOT EXISTS idx_favorites_username ON favorites(username); CREATE INDEX IF NOT EXISTS idx_favorites_username ON favorites(username);
CREATE INDEX IF NOT EXISTS idx_search_history_username ON search_history(username); CREATE INDEX IF NOT EXISTS idx_search_history_username ON search_history(username);
-- 复合索引优化查询性能 -- 复合索引优化查询性能
-- 播放记录:用户名+键值的复合索引,用于快速查找特定记录 -- 播放记录:用户名+键值的复合索引,用于快速查找特定记录
CREATE INDEX IF NOT EXISTS idx_play_records_username_key ON play_records(username, key); CREATE INDEX IF NOT EXISTS idx_play_records_username_key ON play_records(username, key);
-- 播放记录:用户名+保存时间的复合索引,用于按时间排序的查询 -- 播放记录:用户名+保存时间的复合索引,用于按时间排序的查询
CREATE INDEX IF NOT EXISTS idx_play_records_username_save_time ON play_records(username, save_time DESC); CREATE INDEX IF NOT EXISTS idx_play_records_username_save_time ON play_records(username, save_time DESC);
-- 收藏:用户名+键值的复合索引,用于快速查找特定收藏 -- 收藏:用户名+键值的复合索引,用于快速查找特定收藏
CREATE INDEX IF NOT EXISTS idx_favorites_username_key ON favorites(username, key); CREATE INDEX IF NOT EXISTS idx_favorites_username_key ON favorites(username, key);
-- 收藏:用户名+保存时间的复合索引,用于按时间排序的查询 -- 收藏:用户名+保存时间的复合索引,用于按时间排序的查询
CREATE INDEX IF NOT EXISTS idx_favorites_username_save_time ON favorites(username, save_time DESC); CREATE INDEX IF NOT EXISTS idx_favorites_username_save_time ON favorites(username, save_time DESC);
-- 搜索历史:用户名+关键词的复合索引,用于快速查找/删除特定搜索记录 -- 搜索历史:用户名+关键词的复合索引,用于快速查找/删除特定搜索记录
CREATE INDEX IF NOT EXISTS idx_search_history_username_keyword ON search_history(username, keyword); CREATE INDEX IF NOT EXISTS idx_search_history_username_keyword ON search_history(username, keyword);
-- 搜索历史:用户名+创建时间的复合索引,用于按时间排序的查询 -- 搜索历史:用户名+创建时间的复合索引,用于按时间排序的查询
CREATE INDEX IF NOT EXISTS idx_search_history_username_created_at ON search_history(username, created_at DESC); CREATE INDEX IF NOT EXISTS idx_search_history_username_created_at ON search_history(username, created_at DESC);
-- 搜索历史清理查询的优化索引 -- 搜索历史清理查询的优化索引
CREATE INDEX IF NOT EXISTS idx_search_history_username_id_created_at ON search_history(username, id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_search_history_username_id_created_at ON search_history(username, id, created_at DESC);
``` ```
+13 -3
View File
@@ -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 的部署方式使用**
支持在运行时动态变更服务配置 支持在运行时动态变更服务配置
+1 -1
View File
@@ -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: {
+2 -2
View File
@@ -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}`,
+2 -2
View File
@@ -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(
+2 -2
View File
@@ -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: {
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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'
> >
+29 -13
View File
@@ -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;
}, []); }, []);
// 如果没有播放记录,则不渲染组件 // 如果没有播放记录,则不渲染组件
+24 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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).*)',
], ],
}; };