diff --git a/CHANGELOG b/CHANGELOG index 35d1465..4ef8c36 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,12 @@ +## [210.1.0] - 2026-01-27 +### Added +- 增加直链播放 +- 直播兼容txt格式 + +### Changed +- 首页轮播图和继续观看可隐藏 +- 刷新token改为前端进行防止redis数据库方式报错 + ## [210.0.0] - 2026-01-25 ### Added - 新增网络直播功能 diff --git a/VERSION.txt b/VERSION.txt index bf607e4..152f09c 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1,2 +1,2 @@ -210.0.0 +210.1.0 diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts new file mode 100644 index 0000000..667f6d5 --- /dev/null +++ b/src/app/api/auth/refresh/route.ts @@ -0,0 +1,122 @@ +/* eslint-disable no-console */ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie, parseAuthInfo } from '@/lib/auth'; +import { refreshAccessToken } from '@/lib/middleware-auth'; +import { TOKEN_CONFIG } from '@/lib/refresh-token'; + +export const runtime = 'nodejs'; + +const STORAGE_TYPE = + (process.env.NEXT_PUBLIC_STORAGE_TYPE as + | 'localstorage' + | 'redis' + | 'upstash' + | 'kvrocks' + | undefined) || 'localstorage'; + +function buildRefreshResponse(authToken?: string | null) { + const body: Record = { ok: true }; + + if (authToken) { + body.token = authToken; + const authInfo = parseAuthInfo(authToken); + if (authInfo) { + const { password, ...rest } = authInfo; + body.auth = rest; + } + } + + return NextResponse.json(body); +} + +export async function POST(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + + if (!authInfo) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (STORAGE_TYPE === 'localstorage') { + if (!authInfo.password || authInfo.password !== process.env.PASSWORD) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const authCookie = request.cookies.get('auth'); + if (!authCookie?.value) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const response = buildRefreshResponse(authCookie.value); + const expires = new Date(); + expires.setDate(expires.getDate() + 60); + response.cookies.set('auth', authCookie.value, { + path: '/', + expires, + sameSite: 'lax', + httpOnly: false, + secure: false, + }); + return response; + } + + if ( + !authInfo.username || + !authInfo.role || + !authInfo.timestamp || + !authInfo.tokenId || + !authInfo.refreshToken || + !authInfo.refreshExpires + ) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const now = Date.now(); + const accessTokenAge = now - authInfo.timestamp; + const remainingAccessTime = TOKEN_CONFIG.ACCESS_TOKEN_AGE - accessTokenAge; + const refreshWindow = 15 * 60 * 1000; + + if (remainingAccessTime <= 0) { + return NextResponse.json( + { error: 'Access token expired' }, + { status: 401 } + ); + } + + if (remainingAccessTime > refreshWindow) { + return NextResponse.json( + { error: 'Refresh not allowed' }, + { status: 400 } + ); + } + + if (now >= authInfo.refreshExpires) { + return NextResponse.json( + { error: 'Refresh token expired' }, + { status: 401 } + ); + } + + const newAuthData = await refreshAccessToken( + authInfo.username, + authInfo.role, + authInfo.tokenId, + authInfo.refreshToken, + authInfo.refreshExpires + ); + + if (!newAuthData) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const response = buildRefreshResponse(newAuthData); + const expires = new Date(authInfo.refreshExpires); + response.cookies.set('auth', newAuthData, { + path: '/', + expires, + sameSite: 'lax', + httpOnly: false, + secure: false, + }); + return response; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index beb03d7..f3f35d5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,6 +13,7 @@ import { DownloadPanel } from '../components/DownloadPanel'; import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator'; import { SiteProvider } from '../components/SiteProvider'; import { ThemeProvider } from '../components/ThemeProvider'; +import { TokenRefreshManager } from '../components/TokenRefreshManager'; import TopProgressBar from '../components/TopProgressBar'; import ChatFloatingWindow from '../components/watch-room/ChatFloatingWindow'; import { WatchRoomProvider } from '../components/WatchRoomProvider'; @@ -222,6 +223,7 @@ export default async function RootLayout({ disableTransitionOnChange > + diff --git a/src/app/page.tsx b/src/app/page.tsx index f04e6a2..bcc4aa9 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,8 +2,9 @@ 'use client'; -import { Bot, ChevronRight, ListVideo } from 'lucide-react'; +import { Bot, ChevronRight, Link as LinkIcon, ListVideo } from 'lucide-react'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { Suspense, useEffect, useState } from 'react'; import { @@ -13,7 +14,7 @@ import { import { getDoubanCategories } from '@/lib/douban.client'; import { getTMDBImageUrl, TMDBItem } from '@/lib/tmdb.client'; import { DoubanItem } from '@/lib/types'; -import { processImageUrl } from '@/lib/utils'; +import { base58Encode, processImageUrl } from '@/lib/utils'; import AIChatPanel from '@/components/AIChatPanel'; import BannerCarousel from '@/components/BannerCarousel'; @@ -45,6 +46,7 @@ function HomeClient() { >([]); const [loading, setLoading] = useState(true); const { announcement } = useSite(); + const router = useRouter(); // 首页模块配置状态 const [homeModules, setHomeModules] = useState([ @@ -55,6 +57,8 @@ function HomeClient() { { id: 'hotVarietyShows', name: '热门综艺', enabled: true, order: 4 }, { id: 'upcomingContent', name: '即将上映', enabled: true, order: 5 }, ]); + const [homeBannerEnabled, setHomeBannerEnabled] = useState(true); + const [homeContinueWatchingEnabled, setHomeContinueWatchingEnabled] = useState(true); const [showAnnouncement, setShowAnnouncement] = useState(false); const [showHttpWarning, setShowHttpWarning] = useState(true); @@ -62,34 +66,56 @@ function HomeClient() { const [aiEnabled, setAiEnabled] = useState(false); const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState('你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?'); const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true); + const [showDirectPlayDialog, setShowDirectPlayDialog] = useState(false); + const [directPlayUrl, setDirectPlayUrl] = useState(''); + + const handleDirectPlay = () => { + setDirectPlayUrl(''); + setShowDirectPlayDialog(true); + }; + + const submitDirectPlay = () => { + const trimmed = directPlayUrl.trim(); + if (!trimmed) return; + const encoded = base58Encode(trimmed); + if (!encoded) return; + setShowDirectPlayDialog(false); + setDirectPlayUrl(''); + router.push(`/play?source=directplay&id=${encodeURIComponent(encoded)}`); + }; + + const loadHomeLayoutSettings = () => { + if (typeof window === 'undefined') return; + + const savedHomeModules = localStorage.getItem('homeModules'); + if (savedHomeModules) { + try { + setHomeModules(JSON.parse(savedHomeModules)); + } catch (error) { + console.error('解析首页模块配置失败:', error); + } + } + + const savedHomeBannerEnabled = localStorage.getItem('homeBannerEnabled'); + if (savedHomeBannerEnabled !== null) { + setHomeBannerEnabled(savedHomeBannerEnabled === 'true'); + } + + const savedHomeContinueWatchingEnabled = localStorage.getItem('homeContinueWatchingEnabled'); + if (savedHomeContinueWatchingEnabled !== null) { + setHomeContinueWatchingEnabled(savedHomeContinueWatchingEnabled === 'true'); + } + }; // 加载首页模块配置 useEffect(() => { - if (typeof window !== 'undefined') { - const savedHomeModules = localStorage.getItem('homeModules'); - if (savedHomeModules) { - try { - setHomeModules(JSON.parse(savedHomeModules)); - } catch (error) { - console.error('解析首页模块配置失败:', error); - } - } - } + loadHomeLayoutSettings(); }, []); // 监听首页模块配置更新事件 useEffect(() => { const handleHomeModulesUpdated = () => { - if (typeof window !== 'undefined') { - const savedHomeModules = localStorage.getItem('homeModules'); - if (savedHomeModules) { - try { - setHomeModules(JSON.parse(savedHomeModules)); - } catch (error) { - console.error('解析首页模块配置失败:', error); - } - } - } + loadHomeLayoutSettings(); }; window.addEventListener('homeModulesUpdated', handleHomeModulesUpdated); @@ -547,16 +573,26 @@ function HomeClient() { {/* TMDB 热门轮播图 */} -
- -
+ {homeBannerEnabled && ( +
+ +
+ )}
{/* 首页内容 */} <> {/* 源站寻片和AI问片入口 */} -
+
+ + {/* 源站寻片入口 */} {sourceSearchEnabled && ( @@ -582,7 +618,7 @@ function HomeClient() {
{/* 继续观看 */} - + {homeContinueWatchingEnabled && } {/* 根据配置动态渲染首页模块 */} {homeModules @@ -626,6 +662,62 @@ function HomeClient() {
)} + + {showDirectPlayDialog && ( +
setShowDirectPlayDialog(false)} + > +
event.stopPropagation()} + > +
+

+ 直链播放 +

+ +
+
+
+ 请输入可直接播放的视频链接。 +
+ setDirectPlayUrl(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + submitDirectPlay(); + } + }} + placeholder='https://example.com/video.m3u8' + className='w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500' + /> +
+ + +
+
+
+
+ )} ); } diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index f2e1055..d081a7d 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -48,7 +48,7 @@ import { import { getDoubanDetail } from '@/lib/douban.client'; import { getTMDBImageUrl } from '@/lib/tmdb.search'; import { DanmakuFilterConfig, EpisodeFilterConfig,SearchResult } from '@/lib/types'; -import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; +import { base58Decode, getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; import { useEnableComments } from '@/hooks/useEnableComments'; import { usePlaySync } from '@/hooks/usePlaySync'; @@ -488,6 +488,7 @@ function PlayPageClient() { const [currentSource, setCurrentSource] = useState(searchParams.get('source') || ''); const [currentId, setCurrentId] = useState(searchParams.get('id') || ''); const [fileName] = useState(searchParams.get('fileName') || ''); // 小雅源:用户点击的文件名 + const isDirectPlay = currentSource === 'directplay'; // 解析 source 参数以获取 embyKey(仅用于 API 调用) const parseSourceForApi = (source: string): { source: string; embyKey?: string } => { @@ -601,6 +602,10 @@ function PlayPageClient() { return; } + if (isDirectPlay) { + return; + } + // 检查是否禁用了自动装填弹幕 const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true'; if (disableAutoLoad) { @@ -938,11 +943,19 @@ function PlayPageClient() { }; loadDanmakuForCurrentEpisode(); - }, [currentEpisodeIndex, videoTitle, loading]); + }, [currentEpisodeIndex, videoTitle, loading, isDirectPlay]); // 获取豆瓣评分数据 useEffect(() => { const fetchDoubanRating = async () => { + if (isDirectPlay) { + setDoubanRating(null); + setDoubanCardSubtitle(''); + setDoubanAka([]); + setDoubanYear(''); + return; + } + if (!videoDoubanId || videoDoubanId === 0) { setDoubanRating(null); setDoubanCardSubtitle(''); @@ -994,11 +1007,16 @@ function PlayPageClient() { }; fetchDoubanRating(); - }, [videoDoubanId]); + }, [videoDoubanId, isDirectPlay]); // 获取TMDB背景图 useEffect(() => { const fetchTMDBBackdrop = async () => { + if (isDirectPlay) { + setTmdbBackdrop(null); + return; + } + // 检查是否禁用背景图 if (typeof window !== 'undefined') { const disabled = localStorage.getItem('tmdb_backdrop_disabled'); @@ -1146,7 +1164,7 @@ function PlayPageClient() { }; fetchTMDBBackdrop(); - }, [videoTitle, videoDoubanId]); + }, [videoTitle, videoDoubanId, isDirectPlay]); // 视频播放地址 @@ -1168,6 +1186,14 @@ function PlayPageClient() { // 总集数 const totalEpisodes = detail?.episodes?.length || 0; + const directEpisodeLabel = detail?.episodes_titles?.[currentEpisodeIndex] || '直链'; + const shouldShowEpisodeLabel = totalEpisodes > 1 || isDirectPlay; + const episodeLabel = isDirectPlay + ? directEpisodeLabel + : detail?.episodes_titles?.[currentEpisodeIndex] || `第 ${currentEpisodeIndex + 1} 集`; + const playerEpisodeLabel = isDirectPlay + ? directEpisodeLabel + : `第${currentEpisodeIndex + 1}集`; // 用于记录是否需要在播放器 ready 后跳转到指定进度 const resumeTimeRef = useRef(null); @@ -2808,6 +2834,73 @@ function PlayPageClient() { }; const initAll = async () => { + if (currentSource === 'directplay') { + if (!currentId) { + setError('缺少直链地址'); + setLoading(false); + return; + } + + setLoading(true); + setLoadingStage('fetching'); + setLoadingMessage('🎬 正在准备直链播放...'); + + let directUrl = ''; + try { + directUrl = base58Decode(currentId); + } catch (decodeError) { + console.error('直链地址解析失败:', decodeError); + setError('直链地址解析失败'); + setLoading(false); + return; + } + + const directDetail: SearchResult = { + id: currentId, + title: '直链播放', + poster: '', + episodes: [directUrl], + episodes_titles: ['直链'], + source: 'directplay', + source_name: '直链', + class: '', + year: '', + desc: '', + type_name: '', + douban_id: 0, + }; + + setNeedPrefer(false); + setCurrentSource('directplay'); + setCurrentId(currentId); + setVideoTitle('直链播放'); + setVideoYear(''); + setVideoCover(''); + setVideoDoubanId(0); + setCorrectedDesc(''); + setDetail(directDetail); + setSourceProxyMode(false); + setAvailableSources([directDetail]); + setCurrentEpisodeIndex(0); + setSourceSearchError(null); + setSourceSearchLoading(false); + setBackgroundSourcesLoading(false); + + const newUrl = new URL(window.location.href); + newUrl.searchParams.set('source', 'directplay'); + newUrl.searchParams.set('id', currentId); + newUrl.searchParams.delete('prefer'); + newUrl.searchParams.delete('fileName'); + window.history.replaceState({}, '', newUrl.toString()); + + setLoadingStage('ready'); + setLoadingMessage('✨ 准备就绪,即将开始播放...'); + setTimeout(() => { + setLoading(false); + }, 500); + return; + } + if (!currentSource && !currentId && !videoTitle && !searchTitle) { setError('缺少必要参数'); setLoading(false); @@ -3698,6 +3791,7 @@ function PlayPageClient() { // 预加载下一集弹幕(完全复制 loadDanmakuForCurrentEpisode 的逻辑) const preloadNextEpisodeDanmaku = async () => { try { + if (isDirectPlay) return; const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true'; if (disableAutoLoad) return; @@ -4043,6 +4137,7 @@ function PlayPageClient() { // 自动搜索并加载弹幕 const autoSearchDanmaku = async () => { + if (isDirectPlay) return; const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true'; if (disableAutoLoad) return; @@ -4616,9 +4711,7 @@ function PlayPageClient() { // 非WebKit浏览器且播放器已存在,使用switch方法切换 if (!isWebkit && artPlayerRef.current) { artPlayerRef.current.switch = videoUrl; - artPlayerRef.current.title = `${videoTitle} - 第${ - currentEpisodeIndex + 1 - }集`; + artPlayerRef.current.title = `${videoTitle} - ${playerEpisodeLabel}`; artPlayerRef.current.poster = videoCover; if (artPlayerRef.current?.video) { ensureVideoSource( @@ -6993,12 +7086,9 @@ function PlayPageClient() {

{videoTitle || '影片标题'} - {totalEpisodes > 1 && ( + {shouldShowEpisodeLabel && ( - {` > ${ - detail?.episodes_titles?.[currentEpisodeIndex] || - `第 ${currentEpisodeIndex + 1} 集` - }`} + {` > ${episodeLabel}`} )} @@ -7568,254 +7658,258 @@ function PlayPageClient() {

- {/* 详情展示 */} -
- {/* 文字区 */} -
-
- {/* 标题 */} -

- 0 ? 'relative group cursor-help' : ''}> - {videoTitle || '影片标题'} - {/* aka 悬浮提示 */} - {doubanAka.length > 0 && ( -
-
又名:
- {doubanAka.map((name, index) => ( -
- {name} + {!isDirectPlay && ( + <> + {/* 详情展示 */} +
+ {/* 文字区 */} +
+
+ {/* 标题 */} +

+ 0 ? 'relative group cursor-help' : ''}> + {videoTitle || '影片标题'} + {/* aka 悬浮提示 */} + {doubanAka.length > 0 && ( +
+
又名:
+ {doubanAka.map((name, index) => ( +
+ {name} +
+ ))} +
- ))} -
-

- )} - - - {/* 网盘搜索按钮 */} - - {/* AI问片按钮 */} - {aiEnabled && detail && ( - - )} - {/* 纠错按钮 - 仅小雅源显示 */} - {detail && detail.source === 'xiaoya' && ( - - )} - {/* 豆瓣评分显示 */} - {doubanRating && doubanRating.value > 0 && ( -
- {/* 星级显示 */} -
- {[1, 2, 3, 4, 5].map((star) => { - const starValue = doubanRating.value / 2; // 转换为5星制 - const isFullStar = star <= Math.floor(starValue); - const isHalfStar = !isFullStar && star <= Math.ceil(starValue) && starValue % 1 >= 0.25; - - return ( -
- {isFullStar ? ( - // 全星 - - - - ) : isHalfStar ? ( - // 半星 - <> - {/* 空星背景 */} - - - - {/* 半星遮罩 */} - - - - - ) : ( - // 空星 - - - - )} -
- ); - })} -
- {/* 评分数值 */} - - {doubanRating.value.toFixed(1)} + )} - {/* 评分人数 */} - - ({doubanRating.count.toLocaleString()}人评价) - -
- )} -

- - {/* 关键信息行 */} -
- {detail?.class && ( - - {detail.class} - - )} - {/* 优先使用 doubanYear,如果没有则使用 detail.year 或 videoYear */} - {(doubanYear || detail?.year || videoYear) && ( - {doubanYear || detail?.year || videoYear} - )} - {detail?.source_name && ( - - {detail.source_name} - - )} - {detail?.type_name && {detail.type_name}} -
- {/* 剧情简介 */} - {(doubanCardSubtitle || correctedDesc || detail?.desc) && ( -
- {/* card_subtitle 在前,desc 在后 */} - {doubanCardSubtitle && ( -
- {doubanCardSubtitle} -
- )} - {correctedDesc || detail?.desc} -
- )} -
-
- - {/* 封面展示 */} -
-
-
- {videoCover ? ( - <> - {videoTitle} - - {/* 豆瓣链接按钮 */} - {videoDoubanId !== 0 && ( - { + e.stopPropagation(); + handleToggleFavorite(); + }} + className='flex-shrink-0 hover:opacity-80 transition-opacity' + > + + + {/* 网盘搜索按钮 */} + + {/* AI问片按钮 */} + {aiEnabled && detail && ( + )} - - ) : ( - - 封面图片 - - )} + {/* 纠错按钮 - 仅小雅源显示 */} + {detail && detail.source === 'xiaoya' && ( + + )} + {/* 豆瓣评分显示 */} + {doubanRating && doubanRating.value > 0 && ( +
+ {/* 星级显示 */} +
+ {[1, 2, 3, 4, 5].map((star) => { + const starValue = doubanRating.value / 2; // 转换为5星制 + const isFullStar = star <= Math.floor(starValue); + const isHalfStar = !isFullStar && star <= Math.ceil(starValue) && starValue % 1 >= 0.25; + + return ( +
+ {isFullStar ? ( + // 全星 + + + + ) : isHalfStar ? ( + // 半星 + <> + {/* 空星背景 */} + + + + {/* 半星遮罩 */} + + + + + ) : ( + // 空星 + + + + )} +
+ ); + })} +
+ {/* 评分数值 */} + + {doubanRating.value.toFixed(1)} + + {/* 评分人数 */} + + ({doubanRating.count.toLocaleString()}人评价) + +
+ )} + + + {/* 关键信息行 */} +
+ {detail?.class && ( + + {detail.class} + + )} + {/* 优先使用 doubanYear,如果没有则使用 detail.year 或 videoYear */} + {(doubanYear || detail?.year || videoYear) && ( + {doubanYear || detail?.year || videoYear} + )} + {detail?.source_name && ( + + {detail.source_name} + + )} + {detail?.type_name && {detail.type_name}} +
+ {/* 剧情简介 */} + {(doubanCardSubtitle || correctedDesc || detail?.desc) && ( +
+ {/* card_subtitle 在前,desc 在后 */} + {doubanCardSubtitle && ( +
+ {doubanCardSubtitle} +
+ )} + {correctedDesc || detail?.desc} +
+ )} +
+
+ + {/* 封面展示 */} +
+
+
+ {videoCover ? ( + <> + {videoTitle} + + {/* 豆瓣链接按钮 */} + {videoDoubanId !== 0 && ( + +
+ + + + +
+
+ )} + + ) : ( + + 封面图片 + + )} +
+
-
- - {/* 推荐区域 */} - + {/* 推荐区域 */} + - {/* 豆瓣评论区域 */} - {videoDoubanId !== 0 && enableComments && ( -
-
- {/* 标题 */} -
-

- - - - 豆瓣评论 -

+ {/* 豆瓣评论区域 */} + {videoDoubanId !== 0 && enableComments && ( +
+
+ {/* 标题 */} +
+

+ + + + 豆瓣评论 +

+
+ + {/* 评论内容 */} +
+ +
+
- - {/* 评论内容 */} -
- -
-
-
+ )} + )}
diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index ee82b8b..73ef15a 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -1,6 +1,6 @@ /* eslint-disable @next/next/no-img-element */ -import { Settings } from 'lucide-react'; +import { Link as LinkIcon, Settings } from 'lucide-react'; import { useRouter } from 'next/navigation'; import React, { useCallback, @@ -811,8 +811,10 @@ const EpisodeSelector: React.FC = ({ }`.trim()} > {/* 封面 */} -
- {source.poster && ( +
+ {source.source === 'directplay' ? ( + + ) : source.poster ? ( {source.title} = ({ target.style.display = 'none'; }} /> - )} + ) : null}
{/* 信息区域 */} diff --git a/src/components/TokenRefreshManager.tsx b/src/components/TokenRefreshManager.tsx new file mode 100644 index 0000000..28fe0f7 --- /dev/null +++ b/src/components/TokenRefreshManager.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { useEffect } from 'react'; + +import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; + +/** + * Token 自动刷新管理器 + * + * 功能: + * 1. 拦截所有 fetch 请求 + * 2. 检测到 401 错误时自动刷新 Token 并重试 + * 3. 在请求前检查 Token 是否即将过期,主动刷新 + * + * 策略: + * - 响应拦截:401 错误 → 刷新 Token → 重试请求 + * - 请求拦截:剩余时间 < 10 分钟 → 主动刷新 + */ +export function TokenRefreshManager() { + useEffect(() => { + // localStorage 模式不需要刷新 + const storageType = (window as any).RUNTIME_CONFIG?.STORAGE_TYPE || 'localstorage'; + if (storageType === 'localstorage') { + return; + } + + // 刷新状态管理 + let isRefreshing = false; + let refreshPromise: Promise | null = null; + + // Token 刷新函数 + const refreshToken = async (): Promise => { + // 如果正在刷新,返回现有的 Promise + if (isRefreshing && refreshPromise) { + return refreshPromise; + } + + isRefreshing = true; + refreshPromise = (async () => { + try { + // 使用原始 fetch 避免递归 + const response = await window.fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include', + }); + + if (response.ok) { + console.log('[Token] Refreshed successfully'); + return true; + } else { + console.error('[Token] Refresh failed:', response.status); + + // 刷新失败,跳转登录 + if (response.status === 401 || response.status === 403) { + window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`; + } + return false; + } + } catch (error) { + console.error('[Token] Refresh error:', error); + return false; + } finally { + isRefreshing = false; + refreshPromise = null; + } + })(); + + return refreshPromise; + }; + + // 检查 Token 是否需要刷新 + const shouldRefreshToken = (): boolean => { + const authInfo = getAuthInfoFromBrowserCookie(); + if (!authInfo || !authInfo.timestamp || !authInfo.refreshExpires) { + return false; + } + + const now = Date.now(); + + // Refresh Token 已过期 + if (now >= authInfo.refreshExpires) { + console.log('[Token] Refresh token expired, redirecting to login'); + window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`; + return false; + } + + // 计算 Access Token 剩余时间 + const ACCESS_TOKEN_AGE = 4 * 60 * 60 * 1000; // 4 小时 + const age = now - authInfo.timestamp; + const remaining = ACCESS_TOKEN_AGE - age; + + // 剩余时间 < 10 分钟时需要刷新 + const REFRESH_THRESHOLD = 10 * 60 * 1000; // 10 分钟 + return remaining < REFRESH_THRESHOLD && remaining > 0; + }; + + // 保存原始 fetch + const originalFetch = window.fetch; + + // 拦截 fetch + window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + // 跳过不需要 Token 刷新的 API + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + + // 跳过:刷新 API、登录、登出、注册等认证相关接口 + if ( + url.includes('/api/auth/refresh') || + url.includes('/api/login') || + url.includes('/api/logout') || + url.includes('/api/register') || + url.includes('/api/auth/oidc') + ) { + return originalFetch(input, init); + } + + // 请求前检查:Token 即将过期时主动刷新 + if (shouldRefreshToken() && !isRefreshing) { + console.log('[Token] Expiring soon, refreshing proactively...'); + await refreshToken(); + } + + // 发送请求 + let response = await originalFetch(input, init); + + // 响应拦截:401 错误时刷新 Token 并重试(仅重试一次) + if (response.status === 401 && !isRefreshing) { + console.log('[Token] Received 401, attempting refresh and retry...'); + + const refreshed = await refreshToken(); + + if (refreshed) { + // 刷新成功,重试原请求(仅此一次) + response = await originalFetch(input, init); + + // 如果重试后仍然是 401,说明有其他问题,不再重试 + if (response.status === 401) { + console.error('[Token] Still 401 after refresh, redirecting to login'); + window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`; + } + } + } + + return response; + }; + + // 清理:恢复原始 fetch + return () => { + window.fetch = originalFetch; + }; + }, []); + + // 这是一个纯逻辑组件,不渲染任何内容 + return null; +} diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index e096f48..81a5ee1 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -174,6 +174,8 @@ export const UserMenu: React.FC = () => { ]; const [homeModules, setHomeModules] = useState(defaultHomeModules); + const [homeBannerEnabled, setHomeBannerEnabled] = useState(true); + const [homeContinueWatchingEnabled, setHomeContinueWatchingEnabled] = useState(true); // 豆瓣数据源选项 const doubanDataSourceOptions = [ @@ -443,6 +445,16 @@ export const UserMenu: React.FC = () => { setDanmakuHeatmapDisabled(savedDanmakuHeatmapDisabled === 'true'); } + const savedHomeBannerEnabled = localStorage.getItem('homeBannerEnabled'); + if (savedHomeBannerEnabled !== null) { + setHomeBannerEnabled(savedHomeBannerEnabled === 'true'); + } + + const savedHomeContinueWatchingEnabled = localStorage.getItem('homeContinueWatchingEnabled'); + if (savedHomeContinueWatchingEnabled !== null) { + setHomeContinueWatchingEnabled(savedHomeContinueWatchingEnabled === 'true'); + } + // 加载首页模块配置 const savedHomeModules = localStorage.getItem('homeModules'); if (savedHomeModules !== null) { @@ -917,6 +929,22 @@ export const UserMenu: React.FC = () => { } }; + const handleHomeBannerToggle = (value: boolean) => { + setHomeBannerEnabled(value); + if (typeof window !== 'undefined') { + localStorage.setItem('homeBannerEnabled', String(value)); + window.dispatchEvent(new CustomEvent('homeModulesUpdated')); + } + }; + + const handleHomeContinueWatchingToggle = (value: boolean) => { + setHomeContinueWatchingEnabled(value); + if (typeof window !== 'undefined') { + localStorage.setItem('homeContinueWatchingEnabled', String(value)); + window.dispatchEvent(new CustomEvent('homeModulesUpdated')); + } + }; + // 首页模块配置处理函数 const handleHomeModuleToggle = (id: string, enabled: boolean) => { const updatedModules = homeModules.map(module => @@ -1010,6 +1038,8 @@ export const UserMenu: React.FC = () => { setNextEpisodePreCache(true); setNextEpisodeDanmakuPreload(true); setDisableAutoLoadDanmaku(false); + setHomeBannerEnabled(true); + setHomeContinueWatchingEnabled(true); setHomeModules(defaultHomeModules); setSearchTraditionalToSimplified(false); @@ -1031,6 +1061,8 @@ export const UserMenu: React.FC = () => { localStorage.setItem('disableAutoLoadDanmaku', 'false'); localStorage.setItem('danmakuMaxCount', '0'); localStorage.setItem('danmaku_heatmap_disabled', 'false'); + localStorage.setItem('homeBannerEnabled', 'true'); + localStorage.setItem('homeContinueWatchingEnabled', 'true'); localStorage.setItem('homeModules', JSON.stringify(defaultHomeModules)); localStorage.setItem('searchTraditionalToSimplified', 'false'); window.dispatchEvent(new CustomEvent('homeModulesUpdated')); @@ -2189,6 +2221,55 @@ export const UserMenu: React.FC = () => {

+ {/* 首页顶部组件显示 */} +
+
+ +
+ + 首页轮播图 + +
+
+ +
+ +
+ + 继续观看 + +
+
+
+ {/* 模块列表 */}
{homeModules.map((module, index) => ( @@ -2247,8 +2328,12 @@ export const UserMenu: React.FC = () => {