增加剧集更新检查
This commit is contained in:
159
src/app/api/favorites/check-updates/route.ts
Normal file
159
src/app/api/favorites/check-updates/route.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getStorage } from '@/lib/db';
|
||||
import { getAvailableApiSites } from '@/lib/config';
|
||||
import { getDetailFromApi } from '@/lib/downstream';
|
||||
import { Notification } from '@/lib/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const storage = getStorage();
|
||||
const username = authInfo.username;
|
||||
const now = Date.now();
|
||||
|
||||
console.log(`用户 ${username} 请求检查收藏更新`);
|
||||
console.log(`当前时间: ${new Date(now).toLocaleString('zh-CN')}`);
|
||||
console.log(`开始检查收藏更新...`);
|
||||
|
||||
// 获取所有收藏
|
||||
const favorites = await storage.getAllFavorites(username);
|
||||
const favoriteKeys = Object.keys(favorites);
|
||||
|
||||
if (favoriteKeys.length === 0) {
|
||||
return NextResponse.json({
|
||||
message: '没有收藏',
|
||||
updates: [],
|
||||
});
|
||||
}
|
||||
|
||||
// 获取可用的 API 站点
|
||||
const apiSites = await getAvailableApiSites(username);
|
||||
|
||||
// 检查每个收藏的更新
|
||||
const updates: Array<{
|
||||
source: string;
|
||||
id: string;
|
||||
title: string;
|
||||
old_episodes: number;
|
||||
new_episodes: number;
|
||||
}> = [];
|
||||
|
||||
// 限制并发请求数量,避免过载
|
||||
const BATCH_SIZE = 5;
|
||||
for (let i = 0; i < favoriteKeys.length; i += BATCH_SIZE) {
|
||||
const batch = favoriteKeys.slice(i, i + BATCH_SIZE);
|
||||
|
||||
await Promise.all(
|
||||
batch.map(async (key) => {
|
||||
try {
|
||||
const favorite = favorites[key];
|
||||
|
||||
// 跳过 live 类型的收藏
|
||||
if (favorite.origin === 'live') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 跳过已完结的收藏
|
||||
if (favorite.is_completed) {
|
||||
console.log(`跳过已完结的收藏: ${favorite.title}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析 source 和 id
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找对应的 API 站点
|
||||
const apiSite = apiSites.find((site) => site.key === source);
|
||||
if (!apiSite) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取最新详情
|
||||
const detail = await getDetailFromApi(apiSite, id);
|
||||
|
||||
// 比较集数
|
||||
const oldEpisodes = favorite.total_episodes;
|
||||
const newEpisodes = detail.episodes.length;
|
||||
|
||||
console.log(`检查收藏: ${favorite.title} (${source}+${id})`);
|
||||
console.log(` 旧集数: ${oldEpisodes}, 新集数: ${newEpisodes}`);
|
||||
console.log(` 是否完结: ${favorite.is_completed}, 备注: ${favorite.vod_remarks}`);
|
||||
|
||||
if (newEpisodes > oldEpisodes) {
|
||||
updates.push({
|
||||
source,
|
||||
id,
|
||||
title: favorite.title,
|
||||
old_episodes: oldEpisodes,
|
||||
new_episodes: newEpisodes,
|
||||
});
|
||||
|
||||
// 更新收藏的集数和完结状态
|
||||
await storage.setFavorite(username, key, {
|
||||
...favorite,
|
||||
total_episodes: newEpisodes,
|
||||
is_completed: detail.vod_remarks
|
||||
? ['全', '完结', '大结局', 'end', '完'].some((keyword) =>
|
||||
detail.vod_remarks!.toLowerCase().includes(keyword)
|
||||
)
|
||||
: false,
|
||||
vod_remarks: detail.vod_remarks,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`检查收藏更新失败 (${key}):`, error);
|
||||
// 继续处理其他收藏
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`检查完成,发现 ${updates.length} 个更新`);
|
||||
|
||||
// 如果有更新,创建通知
|
||||
if (updates.length > 0) {
|
||||
for (const update of updates) {
|
||||
const notification: Notification = {
|
||||
id: `fav_update_${update.source}_${update.id}_${now}`,
|
||||
type: 'favorite_update',
|
||||
title: '收藏更新',
|
||||
message: `《${update.title}》有新集数更新!从 ${update.old_episodes} 集更新到 ${update.new_episodes} 集`,
|
||||
timestamp: now,
|
||||
read: false,
|
||||
metadata: {
|
||||
source: update.source,
|
||||
id: update.id,
|
||||
title: update.title,
|
||||
old_episodes: update.old_episodes,
|
||||
new_episodes: update.new_episodes,
|
||||
},
|
||||
};
|
||||
|
||||
await storage.addNotification(username, notification);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: updates.length > 0 ? `发现 ${updates.length} 个更新` : '没有更新',
|
||||
updates,
|
||||
checked: favoriteKeys.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('检查收藏更新失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
69
src/app/api/notifications/route.ts
Normal file
69
src/app/api/notifications/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getStorage } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// GET: 获取所有通知
|
||||
export async function GET(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const storage = getStorage();
|
||||
const notifications = await storage.getNotifications(authInfo.username);
|
||||
const unreadCount = await storage.getUnreadNotificationCount(authInfo.username);
|
||||
|
||||
return NextResponse.json({
|
||||
notifications,
|
||||
unreadCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取通知失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST: 标记通知为已读或删除通知
|
||||
export async function POST(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action, notificationId } = body;
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
if (action === 'mark_read' && notificationId) {
|
||||
await storage.markNotificationAsRead(authInfo.username, notificationId);
|
||||
return NextResponse.json({ message: '已标记为已读' });
|
||||
}
|
||||
|
||||
if (action === 'delete' && notificationId) {
|
||||
await storage.deleteNotification(authInfo.username, notificationId);
|
||||
return NextResponse.json({ message: '已删除' });
|
||||
}
|
||||
|
||||
if (action === 'clear_all') {
|
||||
await storage.clearAllNotifications(authInfo.username);
|
||||
return NextResponse.json({ message: '已清空所有通知' });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: '无效的操作' }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error('操作通知失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,55 @@ function HomeClient() {
|
||||
}
|
||||
}, [announcement]);
|
||||
|
||||
// 首次进入时检查收藏更新(带前端冷却检查)
|
||||
useEffect(() => {
|
||||
const checkFavoriteUpdates = async () => {
|
||||
try {
|
||||
// 检查冷却时间(前端 localStorage)
|
||||
const COOLDOWN_TIME = 30 * 60 * 1000; // 30分钟
|
||||
const lastCheckTime = localStorage.getItem('lastFavoriteCheckTime');
|
||||
const now = Date.now();
|
||||
|
||||
if (lastCheckTime) {
|
||||
const timeSinceLastCheck = now - parseInt(lastCheckTime, 10);
|
||||
if (timeSinceLastCheck < COOLDOWN_TIME) {
|
||||
const remainingMinutes = Math.ceil((COOLDOWN_TIME - timeSinceLastCheck) / 1000 / 60);
|
||||
console.log(`收藏更新检查冷却中,还需等待 ${remainingMinutes} 分钟`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('开始检查收藏更新...');
|
||||
const response = await fetch('/api/favorites/check-updates', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// 更新本地检查时间
|
||||
localStorage.setItem('lastFavoriteCheckTime', now.toString());
|
||||
|
||||
const data = await response.json();
|
||||
if (data.updates && data.updates.length > 0) {
|
||||
console.log(`发现 ${data.updates.length} 个收藏更新`);
|
||||
// 触发通知更新事件
|
||||
window.dispatchEvent(new Event('notificationsUpdated'));
|
||||
} else {
|
||||
console.log('没有收藏更新');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查收藏更新失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 延迟3秒后检查,避免影响首页加载
|
||||
const timer = setTimeout(() => {
|
||||
checkFavoriteUpdates();
|
||||
}, 3000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// 收藏夹数据
|
||||
type FavoriteItem = {
|
||||
id: string;
|
||||
|
||||
@@ -602,6 +602,39 @@ function PlayPageClient() {
|
||||
// 工具函数(Utils)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// 判断剧集是否已完结
|
||||
const isSeriesCompleted = (detail: SearchResult | null): boolean => {
|
||||
if (!detail) return false;
|
||||
|
||||
// 方法1:通过 vod_remarks 判断
|
||||
if (detail.vod_remarks) {
|
||||
const remarks = detail.vod_remarks.toLowerCase();
|
||||
// 判定为完结的关键词
|
||||
const completedKeywords = ['全', '完结', '大结局', 'end', '完'];
|
||||
// 判定为连载的关键词
|
||||
const ongoingKeywords = ['更新至', '连载', '第', '更新到'];
|
||||
|
||||
// 如果包含连载关键词,则为连载中
|
||||
if (ongoingKeywords.some(keyword => remarks.includes(keyword))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果包含完结关键词,则为已完结
|
||||
if (completedKeywords.some(keyword => remarks.includes(keyword))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 方法2:通过 vod_total 和实际集数对比判断
|
||||
if (detail.vod_total && detail.vod_total > 0 && detail.episodes && detail.episodes.length > 0) {
|
||||
// 如果实际集数 >= 总集数,则为已完结
|
||||
return detail.episodes.length >= detail.vod_total;
|
||||
}
|
||||
|
||||
// 无法判断,默认返回 false(连载中)
|
||||
return false;
|
||||
};
|
||||
|
||||
// 播放源优选函数
|
||||
const preferBestSource = async (
|
||||
sources: SearchResult[]
|
||||
@@ -2758,11 +2791,13 @@ function PlayPageClient() {
|
||||
await saveFavorite(currentSourceRef.current, currentIdRef.current, {
|
||||
title: videoTitleRef.current,
|
||||
source_name: detailRef.current?.source_name || '',
|
||||
year: detailRef.current?.year,
|
||||
year: detailRef.current?.year || 'unknown',
|
||||
cover: detailRef.current?.poster || '',
|
||||
total_episodes: detailRef.current?.episodes.length || 1,
|
||||
save_time: Date.now(),
|
||||
search_title: searchTitle,
|
||||
is_completed: isSeriesCompleted(detailRef.current),
|
||||
vod_remarks: detailRef.current?.vod_remarks,
|
||||
});
|
||||
setFavorited(true);
|
||||
}
|
||||
@@ -4288,14 +4323,28 @@ function PlayPageClient() {
|
||||
<div className='flex flex-col gap-3 py-4 px-5 lg:px-[3rem] 2xl:px-20'>
|
||||
{/* 第一行:影片标题 */}
|
||||
<div className='py-1'>
|
||||
<h1 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||
{videoTitle || '影片标题'}
|
||||
{totalEpisodes > 1 && (
|
||||
<span className='text-gray-500 dark:text-gray-400'>
|
||||
{` > ${
|
||||
detail?.episodes_titles?.[currentEpisodeIndex] ||
|
||||
`第 ${currentEpisodeIndex + 1} 集`
|
||||
<h1 className='text-xl font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2 flex-wrap'>
|
||||
<span>
|
||||
{videoTitle || '影片标题'}
|
||||
{totalEpisodes > 1 && (
|
||||
<span className='text-gray-500 dark:text-gray-400'>
|
||||
{` > ${
|
||||
detail?.episodes_titles?.[currentEpisodeIndex] ||
|
||||
`第 ${currentEpisodeIndex + 1} 集`
|
||||
}`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{/* 完结状态标识 */}
|
||||
{detail && totalEpisodes > 1 && (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
isSeriesCompleted(detail)
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
}`}
|
||||
>
|
||||
{isSeriesCompleted(detail) ? '已完结' : '连载中'}
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
|
||||
243
src/components/NotificationPanel.tsx
Normal file
243
src/components/NotificationPanel.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
'use client';
|
||||
|
||||
import { Bell, Check, Trash2, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Notification } from '@/lib/types';
|
||||
|
||||
interface NotificationPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const NotificationPanel: React.FC<NotificationPanelProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 加载通知
|
||||
const loadNotifications = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/notifications');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setNotifications(data.notifications || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载通知失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 标记为已读
|
||||
const markAsRead = async (notificationId: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/notifications', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'mark_read',
|
||||
notificationId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) =>
|
||||
n.id === notificationId ? { ...n, read: true } : n
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('标记已读失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除通知
|
||||
const deleteNotification = async (notificationId: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/notifications', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'delete',
|
||||
notificationId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setNotifications((prev) => prev.filter((n) => n.id !== notificationId));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除通知失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 清空所有通知
|
||||
const clearAll = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/notifications', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'clear_all',
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setNotifications([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('清空通知失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理通知点击
|
||||
const handleNotificationClick = (notification: Notification) => {
|
||||
// 标记为已读
|
||||
if (!notification.read) {
|
||||
markAsRead(notification.id);
|
||||
}
|
||||
|
||||
// 根据通知类型跳转
|
||||
if (notification.type === 'favorite_update' && notification.metadata) {
|
||||
const { source, id } = notification.metadata;
|
||||
router.push(`/play?source=${source}&id=${id}`);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// 打开面板时加载通知
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadNotifications();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 通知面板 */}
|
||||
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-lg max-h-[80vh] bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] flex flex-col overflow-hidden'>
|
||||
{/* 标题栏 */}
|
||||
<div className='flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Bell className='w-5 h-5 text-gray-600 dark:text-gray-400' />
|
||||
<h3 className='text-lg font-bold text-gray-800 dark:text-gray-200'>
|
||||
通知中心
|
||||
</h3>
|
||||
{notifications.length > 0 && (
|
||||
<span className='px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full'>
|
||||
{notifications.filter((n) => !n.read).length} 条未读
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className='text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors'
|
||||
>
|
||||
清空全部
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
|
||||
aria-label='Close'
|
||||
>
|
||||
<X className='w-full h-full' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 通知列表 */}
|
||||
<div className='flex-1 overflow-y-auto p-4'>
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='w-8 h-8 border-4 border-green-500 border-t-transparent rounded-full animate-spin'></div>
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className='flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400'>
|
||||
<Bell className='w-12 h-12 mb-3 opacity-30' />
|
||||
<p className='text-sm'>暂无通知</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
{notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`group relative p-4 rounded-lg border transition-all cursor-pointer ${
|
||||
notification.read
|
||||
? 'bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700'
|
||||
: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
|
||||
} hover:shadow-md`}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
>
|
||||
{/* 未读标识 */}
|
||||
{!notification.read && (
|
||||
<div className='absolute top-4 right-4 w-2 h-2 bg-green-500 rounded-full'></div>
|
||||
)}
|
||||
|
||||
{/* 通知内容 */}
|
||||
<div className='pr-8'>
|
||||
<div className='flex items-start justify-between mb-1'>
|
||||
<h4 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
|
||||
{notification.title}
|
||||
</h4>
|
||||
</div>
|
||||
<p className='text-sm text-gray-600 dark:text-gray-400 mb-2'>
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-500'>
|
||||
{new Date(notification.timestamp).toLocaleString('zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='absolute top-2 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity'>
|
||||
{!notification.read && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
markAsRead(notification.id);
|
||||
}}
|
||||
className='p-1.5 rounded-full bg-white dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors'
|
||||
title='标记为已读'
|
||||
>
|
||||
<Check className='w-3.5 h-3.5 text-green-600 dark:text-green-400' />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteNotification(notification.id);
|
||||
}}
|
||||
className='p-1.5 rounded-full bg-white dark:bg-gray-700 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors'
|
||||
title='删除'
|
||||
>
|
||||
<Trash2 className='w-3.5 h-3.5 text-red-600 dark:text-red-400' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Bell,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
@@ -28,6 +29,7 @@ import { UpdateStatus } from '@/lib/version_check';
|
||||
import { useVersionCheck } from './VersionCheckProvider';
|
||||
import { VersionPanel } from './VersionPanel';
|
||||
import { OfflineDownloadPanel } from './OfflineDownloadPanel';
|
||||
import { NotificationPanel } from './NotificationPanel';
|
||||
|
||||
interface AuthInfo {
|
||||
username?: string;
|
||||
@@ -43,9 +45,11 @@ export const UserMenu: React.FC = () => {
|
||||
const [isSubscribeOpen, setIsSubscribeOpen] = useState(false);
|
||||
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
|
||||
const [isOfflineDownloadPanelOpen, setIsOfflineDownloadPanelOpen] = useState(false);
|
||||
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
|
||||
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
|
||||
const [storageType, setStorageType] = useState<string>('localstorage');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
// 订阅相关状态
|
||||
const [subscribeEnabled, setSubscribeEnabled] = useState(false);
|
||||
@@ -129,6 +133,36 @@ export const UserMenu: React.FC = () => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 加载未读通知数量
|
||||
const loadUnreadCount = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/notifications');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUnreadCount(data.unreadCount || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载未读通知数量失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 首次加载时检查未读通知数量
|
||||
useEffect(() => {
|
||||
loadUnreadCount();
|
||||
}, []);
|
||||
|
||||
// 监听通知更新事件
|
||||
useEffect(() => {
|
||||
const handleNotificationsUpdated = () => {
|
||||
loadUnreadCount();
|
||||
};
|
||||
|
||||
window.addEventListener('notificationsUpdated', handleNotificationsUpdated);
|
||||
return () => {
|
||||
window.removeEventListener('notificationsUpdated', handleNotificationsUpdated);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 从运行时配置读取订阅是否启用
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -600,6 +634,23 @@ export const UserMenu: React.FC = () => {
|
||||
|
||||
{/* 菜单项 */}
|
||||
<div className='py-1'>
|
||||
{/* 通知按钮 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
setIsNotificationPanelOpen(true);
|
||||
}}
|
||||
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm relative'
|
||||
>
|
||||
<Bell className='w-4 h-4 text-gray-500 dark:text-gray-400' />
|
||||
<span className='font-medium'>通知中心</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className='ml-auto px-2 py-0.5 text-xs font-medium bg-red-500 text-white rounded-full'>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 设置按钮 */}
|
||||
<button
|
||||
onClick={handleSettings}
|
||||
@@ -1358,9 +1409,14 @@ export const UserMenu: React.FC = () => {
|
||||
>
|
||||
<User className='w-full h-full' />
|
||||
</button>
|
||||
{/* 版本更新红点 */}
|
||||
{updateStatus === UpdateStatus.HAS_UPDATE && (
|
||||
<div className='absolute top-[2px] right-[2px] w-2 h-2 bg-yellow-500 rounded-full'></div>
|
||||
)}
|
||||
{/* 未读通知红点 */}
|
||||
{unreadCount > 0 && (
|
||||
<div className='absolute top-[2px] right-[2px] w-2 h-2 bg-red-500 rounded-full'></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 使用 Portal 将菜单面板渲染到 document.body */}
|
||||
@@ -1390,6 +1446,20 @@ export const UserMenu: React.FC = () => {
|
||||
isOpen={isOfflineDownloadPanelOpen}
|
||||
onClose={() => setIsOfflineDownloadPanelOpen(false)}
|
||||
/>
|
||||
|
||||
{/* 使用 Portal 将通知面板渲染到 document.body */}
|
||||
{isNotificationPanelOpen &&
|
||||
mounted &&
|
||||
createPortal(
|
||||
<NotificationPanel
|
||||
isOpen={isNotificationPanelOpen}
|
||||
onClose={() => {
|
||||
setIsNotificationPanelOpen(false);
|
||||
loadUnreadCount(); // 关闭时刷新未读数量
|
||||
}}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface Favorite {
|
||||
save_time: number;
|
||||
search_title?: string;
|
||||
origin?: 'vod' | 'live';
|
||||
is_completed?: boolean; // 是否已完结
|
||||
vod_remarks?: string; // 视频备注信息
|
||||
}
|
||||
|
||||
// ---- 缓存数据结构 ----
|
||||
|
||||
@@ -33,7 +33,7 @@ function createStorage(): IStorage {
|
||||
// 单例存储实例
|
||||
let storageInstance: IStorage | null = null;
|
||||
|
||||
function getStorage(): IStorage {
|
||||
export function getStorage(): IStorage {
|
||||
if (!storageInstance) {
|
||||
storageInstance = createStorage();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ApiSearchItem {
|
||||
vod_content?: string;
|
||||
vod_douban_id?: number;
|
||||
type_name?: string;
|
||||
vod_total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,6 +115,8 @@ async function searchWithCache(
|
||||
desc: cleanHtmlTags(item.vod_content || ''),
|
||||
type_name: item.type_name,
|
||||
douban_id: item.vod_douban_id,
|
||||
vod_remarks: item.vod_remarks,
|
||||
vod_total: item.vod_total,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -283,6 +286,8 @@ export async function getDetailFromApi(
|
||||
desc: cleanHtmlTags(videoDetail.vod_content),
|
||||
type_name: videoDetail.type_name,
|
||||
douban_id: videoDetail.vod_douban_id,
|
||||
vod_remarks: videoDetail.vod_remarks,
|
||||
vod_total: videoDetail.vod_total,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -363,5 +368,7 @@ async function handleSpecialSourceDetail(
|
||||
desc: descText,
|
||||
type_name: '',
|
||||
douban_id: 0,
|
||||
vod_remarks: undefined,
|
||||
vod_total: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -517,4 +517,85 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
async deleteGlobalValue(key: string): Promise<void> {
|
||||
await this.withRetry(() => this.client.del(this.globalValueKey(key)));
|
||||
}
|
||||
|
||||
// ---------- 通知相关 ----------
|
||||
private notificationsKey(userName: string) {
|
||||
return `u:${userName}:notifications`;
|
||||
}
|
||||
|
||||
private lastFavoriteCheckKey(userName: string) {
|
||||
return `u:${userName}:last_fav_check`;
|
||||
}
|
||||
|
||||
async getNotifications(userName: string): Promise<import('./types').Notification[]> {
|
||||
const val = await this.withRetry(() =>
|
||||
this.client.get(this.notificationsKey(userName))
|
||||
);
|
||||
return val ? (JSON.parse(val) as import('./types').Notification[]) : [];
|
||||
}
|
||||
|
||||
async addNotification(
|
||||
userName: string,
|
||||
notification: import('./types').Notification
|
||||
): Promise<void> {
|
||||
const notifications = await this.getNotifications(userName);
|
||||
notifications.unshift(notification); // 新通知放在最前面
|
||||
// 限制通知数量,最多保留100条
|
||||
if (notifications.length > 100) {
|
||||
notifications.splice(100);
|
||||
}
|
||||
await this.withRetry(() =>
|
||||
this.client.set(this.notificationsKey(userName), JSON.stringify(notifications))
|
||||
);
|
||||
}
|
||||
|
||||
async markNotificationAsRead(
|
||||
userName: string,
|
||||
notificationId: string
|
||||
): Promise<void> {
|
||||
const notifications = await this.getNotifications(userName);
|
||||
const notification = notifications.find((n) => n.id === notificationId);
|
||||
if (notification) {
|
||||
notification.read = true;
|
||||
await this.withRetry(() =>
|
||||
this.client.set(this.notificationsKey(userName), JSON.stringify(notifications))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteNotification(
|
||||
userName: string,
|
||||
notificationId: string
|
||||
): Promise<void> {
|
||||
const notifications = await this.getNotifications(userName);
|
||||
const filtered = notifications.filter((n) => n.id !== notificationId);
|
||||
await this.withRetry(() =>
|
||||
this.client.set(this.notificationsKey(userName), JSON.stringify(filtered))
|
||||
);
|
||||
}
|
||||
|
||||
async clearAllNotifications(userName: string): Promise<void> {
|
||||
await this.withRetry(() => this.client.del(this.notificationsKey(userName)));
|
||||
}
|
||||
|
||||
async getUnreadNotificationCount(userName: string): Promise<number> {
|
||||
const notifications = await this.getNotifications(userName);
|
||||
return notifications.filter((n) => !n.read).length;
|
||||
}
|
||||
|
||||
async getLastFavoriteCheckTime(userName: string): Promise<number> {
|
||||
const val = await this.withRetry(() =>
|
||||
this.client.get(this.lastFavoriteCheckKey(userName))
|
||||
);
|
||||
return val ? parseInt(val, 10) : 0;
|
||||
}
|
||||
|
||||
async setLastFavoriteCheckTime(
|
||||
userName: string,
|
||||
timestamp: number
|
||||
): Promise<void> {
|
||||
await this.withRetry(() =>
|
||||
this.client.set(this.lastFavoriteCheckKey(userName), timestamp.toString())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface Favorite {
|
||||
save_time: number; // 记录保存时间(时间戳)
|
||||
search_title: string; // 搜索时使用的标题
|
||||
origin?: 'vod' | 'live';
|
||||
is_completed?: boolean; // 是否已完结
|
||||
vod_remarks?: string; // 视频备注信息
|
||||
}
|
||||
|
||||
// 存储接口
|
||||
@@ -96,6 +98,18 @@ export interface IStorage {
|
||||
getGlobalValue(key: string): Promise<string | null>;
|
||||
setGlobalValue(key: string, value: string): Promise<void>;
|
||||
deleteGlobalValue(key: string): Promise<void>;
|
||||
|
||||
// 通知相关
|
||||
getNotifications(userName: string): Promise<Notification[]>;
|
||||
addNotification(userName: string, notification: Notification): Promise<void>;
|
||||
markNotificationAsRead(userName: string, notificationId: string): Promise<void>;
|
||||
deleteNotification(userName: string, notificationId: string): Promise<void>;
|
||||
clearAllNotifications(userName: string): Promise<void>;
|
||||
getUnreadNotificationCount(userName: string): Promise<number>;
|
||||
|
||||
// 收藏更新检查相关
|
||||
getLastFavoriteCheckTime(userName: string): Promise<number>;
|
||||
setLastFavoriteCheckTime(userName: string, timestamp: number): Promise<void>;
|
||||
}
|
||||
|
||||
// 搜索结果数据结构
|
||||
@@ -112,6 +126,8 @@ export interface SearchResult {
|
||||
desc?: string;
|
||||
type_name?: string;
|
||||
douban_id?: number;
|
||||
vod_remarks?: string; // 视频备注信息(如"全80集"、"更新至25集"等)
|
||||
vod_total?: number; // 总集数
|
||||
}
|
||||
|
||||
// 豆瓣数据结构
|
||||
@@ -161,3 +177,32 @@ export interface EpisodeFilterRule {
|
||||
export interface EpisodeFilterConfig {
|
||||
rules: EpisodeFilterRule[]; // 过滤规则列表
|
||||
}
|
||||
|
||||
// 通知类型枚举
|
||||
export type NotificationType =
|
||||
| 'favorite_update' // 收藏更新
|
||||
| 'system' // 系统通知
|
||||
| 'announcement'; // 公告
|
||||
|
||||
// 通知数据结构
|
||||
export interface Notification {
|
||||
id: string; // 通知ID
|
||||
type: NotificationType; // 通知类型
|
||||
title: string; // 通知标题
|
||||
message: string; // 通知内容
|
||||
timestamp: number; // 通知时间戳
|
||||
read: boolean; // 是否已读
|
||||
metadata?: Record<string, any>; // 额外的元数据(如收藏更新的source、id等)
|
||||
}
|
||||
|
||||
// 收藏更新检查结果
|
||||
export interface FavoriteUpdateCheck {
|
||||
last_check_time: number; // 上次检查时间戳
|
||||
updates: Array<{
|
||||
source: string;
|
||||
id: string;
|
||||
title: string;
|
||||
old_episodes: number;
|
||||
new_episodes: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -415,6 +415,87 @@ export class UpstashRedisStorage implements IStorage {
|
||||
async deleteGlobalValue(key: string): Promise<void> {
|
||||
await withRetry(() => this.client.del(this.globalValueKey(key)));
|
||||
}
|
||||
|
||||
// ---------- 通知相关 ----------
|
||||
private notificationsKey(userName: string) {
|
||||
return `u:${userName}:notifications`;
|
||||
}
|
||||
|
||||
private lastFavoriteCheckKey(userName: string) {
|
||||
return `u:${userName}:last_fav_check`;
|
||||
}
|
||||
|
||||
async getNotifications(userName: string): Promise<import('./types').Notification[]> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.notificationsKey(userName))
|
||||
);
|
||||
return val ? (val as import('./types').Notification[]) : [];
|
||||
}
|
||||
|
||||
async addNotification(
|
||||
userName: string,
|
||||
notification: import('./types').Notification
|
||||
): Promise<void> {
|
||||
const notifications = await this.getNotifications(userName);
|
||||
notifications.unshift(notification); // 新通知放在最前面
|
||||
// 限制通知数量,最多保留100条
|
||||
if (notifications.length > 100) {
|
||||
notifications.splice(100);
|
||||
}
|
||||
await withRetry(() =>
|
||||
this.client.set(this.notificationsKey(userName), notifications)
|
||||
);
|
||||
}
|
||||
|
||||
async markNotificationAsRead(
|
||||
userName: string,
|
||||
notificationId: string
|
||||
): Promise<void> {
|
||||
const notifications = await this.getNotifications(userName);
|
||||
const notification = notifications.find((n) => n.id === notificationId);
|
||||
if (notification) {
|
||||
notification.read = true;
|
||||
await withRetry(() =>
|
||||
this.client.set(this.notificationsKey(userName), notifications)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteNotification(
|
||||
userName: string,
|
||||
notificationId: string
|
||||
): Promise<void> {
|
||||
const notifications = await this.getNotifications(userName);
|
||||
const filtered = notifications.filter((n) => n.id !== notificationId);
|
||||
await withRetry(() =>
|
||||
this.client.set(this.notificationsKey(userName), filtered)
|
||||
);
|
||||
}
|
||||
|
||||
async clearAllNotifications(userName: string): Promise<void> {
|
||||
await withRetry(() => this.client.del(this.notificationsKey(userName)));
|
||||
}
|
||||
|
||||
async getUnreadNotificationCount(userName: string): Promise<number> {
|
||||
const notifications = await this.getNotifications(userName);
|
||||
return notifications.filter((n) => !n.read).length;
|
||||
}
|
||||
|
||||
async getLastFavoriteCheckTime(userName: string): Promise<number> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.lastFavoriteCheckKey(userName))
|
||||
);
|
||||
return val ? (val as number) : 0;
|
||||
}
|
||||
|
||||
async setLastFavoriteCheckTime(
|
||||
userName: string,
|
||||
timestamp: number
|
||||
): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(this.lastFavoriteCheckKey(userName), timestamp)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 单例 Upstash Redis 客户端
|
||||
|
||||
Reference in New Issue
Block a user