Merge pull request #22 from OuOumm/main

feat(AuthProvider): 添加认证状态管理和加载界面
This commit is contained in:
senshinya
2025-07-01 17:26:44 +08:00
committed by GitHub
4 changed files with 123 additions and 30 deletions

View File

@@ -29,6 +29,9 @@ export default function RootLayout({
children: React.ReactNode;
}) {
const siteName = process.env.SITE_NAME || 'MoonTV';
const announcement =
process.env.ANNOUNCEMENT ||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
return (
<html lang='zh-CN' suppressHydrationWarning>
@@ -41,9 +44,9 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
<AuthProvider>
<SiteProvider siteName={siteName}>{children}</SiteProvider>
</AuthProvider>
<SiteProvider siteName={siteName} announcement={announcement}>
<AuthProvider>{children}</AuthProvider>
</SiteProvider>
</ThemeProvider>
</body>
</html>

View File

@@ -13,6 +13,7 @@ import ContinueWatching from '@/components/ContinueWatching';
import DemoCard from '@/components/DemoCard';
import PageLayout from '@/components/PageLayout';
import ScrollableRow from '@/components/ScrollableRow';
import { useSite } from '@/components/SiteProvider';
import VideoCard from '@/components/VideoCard';
function HomeClient() {
@@ -20,6 +21,16 @@ function HomeClient() {
const [hotMovies, setHotMovies] = useState<DoubanItem[]>([]);
const [hotTvShows, setHotTvShows] = useState<DoubanItem[]>([]);
const [loading, setLoading] = useState(true);
const { announcement } = useSite();
const [showAnnouncement, setShowAnnouncement] = useState(() => {
// 检查本地存储中是否已记录弹窗显示状态
const hasSeenAnnouncement = localStorage.getItem('hasSeenAnnouncement');
if (hasSeenAnnouncement !== announcement) {
return true;
}
return !hasSeenAnnouncement && announcement; // 未记录且有公告时显示弹窗
});
// 收藏夹数据
type FavoriteItem = {
@@ -88,6 +99,11 @@ function HomeClient() {
})();
}, [activeTab]);
const handleCloseAnnouncement = (announcement: string) => {
setShowAnnouncement(false);
localStorage.setItem('hasSeenAnnouncement', announcement); // 记录已查看弹窗
};
return (
<PageLayout>
<div className='px-2 sm:px-10 py-4 sm:py-8 overflow-visible'>
@@ -233,6 +249,40 @@ function HomeClient() {
)}
</div>
</div>
{announcement && showAnnouncement && (
<div
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm dark:bg-black/70 p-4 transition-opacity duration-300 ${
showAnnouncement ? '' : 'opacity-0 pointer-events-none'
}`}
>
<div className='w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-gray-900 transform transition-all duration-300 hover:shadow-2xl'>
<div className='flex justify-between items-start mb-4'>
<h3 className='text-2xl font-bold tracking-tight text-gray-800 dark:text-white border-b border-green-500 pb-1'>
</h3>
<button
onClick={() => handleCloseAnnouncement(announcement)}
className='text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-white transition-colors'
aria-label='关闭'
></button>
</div>
<div className='mb-6'>
<div className='relative overflow-hidden rounded-lg mb-4 bg-green-50 dark:bg-green-900/20'>
<div className='absolute inset-y-0 left-0 w-1.5 bg-green-500 dark:bg-green-400'></div>
<p className='ml-4 text-gray-600 dark:text-gray-300 leading-relaxed'>
{announcement}
</p>
</div>
</div>
<button
onClick={() => handleCloseAnnouncement(announcement)}
className='w-full rounded-lg bg-gradient-to-r from-green-600 to-green-700 px-4 py-3 text-white font-medium shadow-md hover:shadow-lg hover:from-green-700 hover:to-green-800 dark:from-green-600 dark:to-green-700 dark:hover:from-green-700 dark:hover:to-green-800 transition-all duration-300 transform hover:-translate-y-0.5'
>
</button>
</div>
</div>
)}
</PageLayout>
);
}

View File

@@ -1,7 +1,11 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { ThemeToggle } from '@/components/ThemeToggle';
import { useSite } from './SiteProvider';
interface Props {
children: React.ReactNode;
@@ -10,37 +14,66 @@ interface Props {
export default function AuthProvider({ children }: Props) {
const router = useRouter();
const pathname = usePathname();
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const { siteName } = useSite();
const authenticate = useCallback(async () => {
// 登录页
if (pathname.startsWith('/login') || pathname.startsWith('/api')) {
setIsAuthenticated(true);
return;
}
useEffect(() => {
// 登录页或 API 路径不做校验,避免死循环
if (pathname.startsWith('/login')) return;
// 从localStorage获取密码
const password = localStorage.getItem('password');
const fullPath =
typeof window !== 'undefined'
? window.location.pathname + window.location.search
: pathname;
// 有密码时验
(async () => {
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
// 尝试认
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
if (!res.ok) {
// 校验未通过,清理并跳转登录
localStorage.removeItem('password');
router.replace(`/login?redirect=${encodeURIComponent(fullPath)}`);
}
} catch (error) {
// 网络错误等也认为未登录
router.replace(`/login?redirect=${encodeURIComponent(fullPath)}`);
}
})();
if (!res.ok) throw new Error('认证失败');
setIsAuthenticated(true);
} catch (error) {
// 认证失败,清理并跳转登录
setIsAuthenticated(false);
localStorage.removeItem('password');
router.replace(`/login?redirect=${encodeURIComponent(fullPath)}`);
}
}, [pathname, router]);
return <>{children}</>;
useEffect(() => {
authenticate();
}, [pathname, authenticate]);
// 认证状态未知时显示加载状态
if (!isAuthenticated) {
return (
<div className='relative min-h-screen flex items-center justify-center px-4 overflow-hidden'>
<div className='absolute top-4 right-4'>
<ThemeToggle />
</div>
<div className='relative z-10 w-full max-w-md rounded-3xl bg-gradient-to-b from-white/90 via-white/70 to-white/40 dark:from-zinc-900/90 dark:via-zinc-900/70 dark:to-zinc-900/40 backdrop-blur-xl shadow-2xl p-10 dark:border dark:border-zinc-800'>
<h1 className='text-green-600 tracking-tight text-center text-3xl font-extrabold mb-8 bg-clip-text drop-shadow-sm'>
{siteName}
</h1>
<div className='flex justify-center my-10'>
<div className='animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-green-500' />
</div>
<p className='text-gray-700 dark:text-gray-300 font-medium text-lg text-center'>
...
</p>
</div>
</div>
);
} else {
return <>{children}</>;
}
}

View File

@@ -2,8 +2,11 @@
import { createContext, ReactNode, useContext } from 'react';
const SiteContext = createContext<{ siteName: string }>({
siteName: 'MoonTV', // Default value
const SiteContext = createContext<{ siteName: string; announcement?: string }>({
// 默认值
siteName: 'MoonTV',
announcement:
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
});
export const useSite = () => useContext(SiteContext);
@@ -11,11 +14,15 @@ export const useSite = () => useContext(SiteContext);
export function SiteProvider({
children,
siteName,
announcement,
}: {
children: ReactNode;
siteName: string;
announcement?: string;
}) {
return (
<SiteContext.Provider value={{ siteName }}>{children}</SiteContext.Provider>
<SiteContext.Provider value={{ siteName, announcement }}>
{children}
</SiteContext.Provider>
);
}