feat: implement password

This commit is contained in:
shinya
2025-06-23 22:58:16 +08:00
parent 44cd7d0ed3
commit a9ad984564
9 changed files with 199 additions and 23 deletions

View File

@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
try {
const result = process.env.PASSWORD;
if (!result) {
return NextResponse.json({ ok: true });
}
const { password } = await req.json();
if (typeof password !== 'string') {
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
}
const matched = password === result;
if (!matched) {
return NextResponse.json(
{ ok: false, error: '密码错误' },
{ status: 401 }
);
}
return NextResponse.json({ ok: true });
} catch (error) {
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
}
}

View File

@@ -3,6 +3,8 @@ import { Inter } from 'next/font/google';
import './globals.css';
import AuthProvider from '../components/AuthProvider';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
@@ -18,7 +20,7 @@ export default function RootLayout({
return (
<html lang='zh-CN'>
<body className={`${inter.className} min-h-screen text-gray-900`}>
{children}
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);

81
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,81 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(null);
if (!password) return;
try {
setLoading(true);
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
if (res.ok) {
// 保存密码以供后续请求使用
if (typeof window !== 'undefined') {
localStorage.setItem('password', password);
}
const redirect = searchParams.get('redirect') || '/';
router.replace(redirect);
} else if (res.status === 401) {
setError('密码错误');
} else {
const data = await res.json().catch(() => ({}));
setError(data.error ?? '服务器错误');
}
} finally {
setLoading(false);
}
};
return (
<div className='relative min-h-screen flex items-center justify-center px-4 overflow-hidden'>
<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 backdrop-blur-xl shadow-2xl p-10'>
<h1 className='text-green-600 tracking-tight text-center text-3xl font-extrabold mb-8 bg-clip-text drop-shadow-sm'>
MoonTV
</h1>
<form onSubmit={handleSubmit} className='space-y-8'>
<div>
<label htmlFor='password' className='sr-only'>
</label>
<input
id='password'
type='password'
autoComplete='current-password'
className='block w-full rounded-lg border-0 py-3 px-4 text-gray-900 shadow-sm ring-1 ring-white/60 placeholder:text-gray-500 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 backdrop-blur'
placeholder='输入访问密码'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <p className='text-sm text-red-600'>{error}</p>}
<button
type='submit'
disabled={!password || loading}
className='inline-flex w-full justify-center rounded-lg bg-green-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-green-600 hover:to-blue-600 disabled:cursor-not-allowed disabled:opacity-50'
>
{loading ? '登录中...' : '登录'}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect } from 'react';
interface Props {
children: React.ReactNode;
}
export default function AuthProvider({ children }: Props) {
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
// 登录页或 API 路径不做校验,避免死循环
if (pathname.startsWith('/login')) return;
const password =
typeof window !== 'undefined' ? localStorage.getItem('password') : null;
const fullPath =
typeof window !== 'undefined'
? window.location.pathname + window.location.search
: pathname;
// 没有密码直接跳转登录
if (!password) {
router.replace(`/login?redirect=${encodeURIComponent(fullPath)}`);
return;
}
// 有密码时验证
(async () => {
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)}`);
}
})();
}, [pathname, router]);
return <>{children}</>;
}

View File

@@ -31,8 +31,10 @@ const PLAY_RECORDS_KEY = 'moontv_play_records';
// ---- 环境变量 ----
const STORAGE_TYPE =
(process.env.STORAGE_TYPE as 'localstorage' | 'database' | undefined) ||
'localstorage';
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
| 'localstorage'
| 'database'
| undefined) || 'localstorage';
// ---------------- 搜索历史相关常量 ----------------
const SEARCH_HISTORY_KEY = 'moontv_search_history';

View File

@@ -2,8 +2,10 @@
// storage type 常量: 'localstorage' | 'database',默认 'localstorage'
const STORAGE_TYPE =
(process.env.STORAGE_TYPE as 'localstorage' | 'database' | undefined) ||
'localstorage';
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
| 'localstorage'
| 'database'
| undefined) || 'localstorage';
// 播放记录数据结构
export interface PlayRecord {