feat: add register page and logout button

This commit is contained in:
shinya
2025-07-02 23:34:07 +08:00
parent 2d066d5d26
commit 2027322a98
8 changed files with 229 additions and 17 deletions

View File

@@ -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 |
## 配置说明

View 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 });
}
}

View File

@@ -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 (

View File

@@ -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>

View 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>
);
}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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();