mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-21 17:24:41 +08:00
feat: add register page and logout button
This commit is contained in:
65
README.md
65
README.md
@@ -38,6 +38,7 @@
|
||||
|
||||
- [技术栈](#技术栈)
|
||||
- [部署](#部署)
|
||||
- [Compose 最佳实践](#Compose最佳实践)
|
||||
- [环境变量](#环境变量)
|
||||
- [配置说明](#配置说明)
|
||||
- [Roadmap](#roadmap)
|
||||
@@ -137,16 +138,64 @@ Pull Bot 会反复触发无效的 PR 和垃圾邮件,严重干扰项目维护
|
||||
|
||||
如需手动同步主仓库更新,也可以使用 GitHub 官方的 [Sync fork](https://docs.github.com/cn/github/collaborating-with-issues-and-pull-requests/syncing-a-fork) 功能。
|
||||
|
||||
## Compose 最佳实践
|
||||
|
||||
若你使用 docker compose 部署,以下是一些 compose 示例
|
||||
|
||||
### local storage 版本
|
||||
|
||||
```yaml
|
||||
version: '3.9'
|
||||
services:
|
||||
moontv:
|
||||
image: ghcr.io/senshinya/moontv:latest
|
||||
container_name: moontv
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
- PASSWORD=your_password
|
||||
# 如需自定义配置,可挂载文件
|
||||
# volumes:
|
||||
# - ./config.json:/app/config.json:ro
|
||||
```
|
||||
|
||||
### Redis 版本(推荐,多账户数据隔离,跨设备同步)
|
||||
|
||||
```yaml
|
||||
version: '3.9'
|
||||
services:
|
||||
moontv:
|
||||
image: ghcr.io/senshinya/moontv:latest
|
||||
container_name: moontv
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
- NEXT_PUBLIC_STORAGE_TYPE=redis
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- NEXT_PUBLIC_ENABLE_REGISTER=true # 首次部署请设置该变量,注册初始账户后可关闭
|
||||
# 如需自定义配置,可挂载文件
|
||||
# volumes:
|
||||
# - ./config.json:/app/config.json:ro
|
||||
redis:
|
||||
image: redis
|
||||
container_name: moontv-redis
|
||||
estart: unless-stopped
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 说明 | 可选值 | 默认值 |
|
||||
| ----------------------------------- | ---------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| PASSWORD | 实例访问密码,留空则不启用密码保护 | 任意字符串 | (空) |
|
||||
| SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
|
||||
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
||||
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | localstorage(本地浏览器存储)、database(后端数据库,暂不支持) | localstorage |
|
||||
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
|
||||
| NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT | 搜索结果默认是否按标题和年份聚合 | true / false | true |
|
||||
| 变量 | 说明 | 可选值 | 默认值 |
|
||||
| ----------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| PASSWORD | 实例访问密码,留空则不启用密码保护 | 任意字符串 | (空) |
|
||||
| SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
|
||||
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
||||
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | localstorage(本地浏览器存储)、redis(仅 docker 支持) | localstorage |
|
||||
| REDIS_URL | redis 连接 url,若 NEXT_PUBLIC_STORAGE_TYPE 为 redis 则必填 | 连接 url | 空 |
|
||||
| NEXT_PUBLIC_ENABLE_REGISTER | 是否开放注册,建议首次运行时设置 true,注册初始账号后可关闭 | true / false | false |
|
||||
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
|
||||
| NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT | 搜索结果默认是否按标题和年份聚合 | true / false | true |
|
||||
|
||||
## 配置说明
|
||||
|
||||
|
||||
49
src/app/api/register/route.ts
Normal file
49
src/app/api/register/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/* eslint-disable no-console */
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
// 读取存储类型环境变量,默认 localstorage
|
||||
const STORAGE_TYPE =
|
||||
(process.env.NEXT_PUBLIC_STORAGE_TYPE as string | undefined) ||
|
||||
'localstorage';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// localstorage 模式下不支持注册
|
||||
if (STORAGE_TYPE === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{ error: '当前模式不支持注册' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { username, password } = await req.json();
|
||||
|
||||
if (!username || typeof username !== 'string') {
|
||||
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
|
||||
}
|
||||
if (!password || typeof password !== 'string') {
|
||||
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查用户是否已存在
|
||||
const exist = await db.checkUserExist(username);
|
||||
if (exist) {
|
||||
return NextResponse.json({ error: '用户已存在' }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.registerUser(username, password);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('数据库注册失败', err);
|
||||
return NextResponse.json({ error: '数据库错误' }, { status: 500 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('注册接口异常', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export default function RootLayout({
|
||||
process.env.STORAGE_TYPE ||
|
||||
process.env.NEXT_PUBLIC_STORAGE_TYPE ||
|
||||
'localstorage',
|
||||
ENABLE_REGISTER: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -23,6 +23,11 @@ function LoginPageClient() {
|
||||
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE &&
|
||||
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE !== 'localstorage';
|
||||
|
||||
// 是否允许注册
|
||||
const enableRegister =
|
||||
typeof window !== 'undefined' &&
|
||||
Boolean((window as any).RUNTIME_CONFIG?.ENABLE_REGISTER);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
@@ -62,6 +67,35 @@ function LoginPageClient() {
|
||||
}
|
||||
};
|
||||
|
||||
// 新增:处理注册逻辑
|
||||
const handleRegister = async () => {
|
||||
setError(null);
|
||||
if (!password || !username) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('password', password);
|
||||
localStorage.setItem('username', username);
|
||||
}
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
router.replace(redirect);
|
||||
} 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='absolute top-4 right-4'>
|
||||
@@ -108,13 +142,38 @@ function LoginPageClient() {
|
||||
<p className='text-sm text-red-600 dark:text-red-400'>{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type='submit'
|
||||
disabled={!password || loading || (shouldAskUsername && !username)}
|
||||
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>
|
||||
{/* 登录 / 注册按钮 */}
|
||||
{shouldAskUsername && enableRegister ? (
|
||||
<div className='flex gap-4'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleRegister}
|
||||
disabled={!password || !username || loading}
|
||||
className='flex-1 inline-flex justify-center rounded-lg bg-blue-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
{loading ? '注册中...' : '注册'}
|
||||
</button>
|
||||
<button
|
||||
type='submit'
|
||||
disabled={
|
||||
!password || loading || (shouldAskUsername && !username)
|
||||
}
|
||||
className='flex-1 inline-flex 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>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type='submit'
|
||||
disabled={
|
||||
!password || loading || (shouldAskUsername && !username)
|
||||
}
|
||||
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>
|
||||
|
||||
36
src/components/LogoutButton.tsx
Normal file
36
src/components/LogoutButton.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
'use client';
|
||||
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* 退出登录按钮
|
||||
*
|
||||
* 功能:
|
||||
* 1. 清除 localStorage 中保存的 username 和 password
|
||||
* 2. 跳转到 /login 页面
|
||||
*/
|
||||
export function LogoutButton() {
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('username');
|
||||
localStorage.removeItem('password');
|
||||
}
|
||||
// 使用 replace,避免用户返回上一页时仍然处于已登录状态
|
||||
router.replace('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className='w-10 h-10 p-2 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors'
|
||||
aria-label='Logout'
|
||||
>
|
||||
<LogOut className='w-full h-full' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { LogoutButton } from './LogoutButton';
|
||||
import { useSite } from './SiteProvider';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
|
||||
@@ -17,7 +18,8 @@ const MobileHeader = () => {
|
||||
{siteName}
|
||||
</Link>
|
||||
</div>
|
||||
<div className='absolute top-1/2 right-4 -translate-y-1/2'>
|
||||
<div className='absolute top-1/2 right-4 -translate-y-1/2 flex items-center gap-2'>
|
||||
<LogoutButton />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LogoutButton } from './LogoutButton';
|
||||
import MobileBottomNav from './MobileBottomNav';
|
||||
import MobileHeader from './MobileHeader';
|
||||
import { useSidebar } from './Sidebar';
|
||||
@@ -22,7 +23,8 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
|
||||
isCollapsed ? 'col-start-2' : 'col-start-2'
|
||||
}`}
|
||||
>
|
||||
<div className='absolute top-2 right-4 z-20 hidden md:block'>
|
||||
<div className='absolute top-2 right-4 z-20 hidden md:flex items-center gap-2'>
|
||||
<LogoutButton />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{children}
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface IStorage {
|
||||
// 用户相关
|
||||
registerUser(userName: string, password: string): Promise<void>;
|
||||
verifyUser(userName: string, password: string): Promise<boolean>;
|
||||
// 检查用户是否存在(无需密码)
|
||||
checkUserExist(userName: string): Promise<boolean>;
|
||||
|
||||
// 搜索历史相关
|
||||
getSearchHistory(): Promise<string[]>;
|
||||
@@ -171,6 +173,13 @@ class RedisStorage implements IStorage {
|
||||
return stored === password;
|
||||
}
|
||||
|
||||
// 检查用户是否存在
|
||||
async checkUserExist(userName: string): Promise<boolean> {
|
||||
// 使用 EXISTS 判断 key 是否存在
|
||||
const exists = await this.client.exists(this.userPwdKey(userName));
|
||||
return exists === 1;
|
||||
}
|
||||
|
||||
// ---------- 搜索历史 ----------
|
||||
private shKey = 'moontv:search_history';
|
||||
|
||||
@@ -342,6 +351,11 @@ export class DbManager {
|
||||
return this.storage.verifyUser(userName, password);
|
||||
}
|
||||
|
||||
// 检查用户是否已存在
|
||||
async checkUserExist(userName: string): Promise<boolean> {
|
||||
return this.storage.checkUserExist(userName);
|
||||
}
|
||||
|
||||
// ---------- 搜索历史 ----------
|
||||
async getSearchHistory(): Promise<string[]> {
|
||||
return this.storage.getSearchHistory();
|
||||
|
||||
Reference in New Issue
Block a user