From 2027322a984236cddfcc0d1ee65b9688b93e55b8 Mon Sep 17 00:00:00 2001 From: shinya Date: Wed, 2 Jul 2025 23:34:07 +0800 Subject: [PATCH] feat: add register page and logout button --- README.md | 65 +++++++++++++++++++++++++---- src/app/api/register/route.ts | 49 ++++++++++++++++++++++ src/app/layout.tsx | 1 + src/app/login/page.tsx | 73 +++++++++++++++++++++++++++++---- src/components/LogoutButton.tsx | 36 ++++++++++++++++ src/components/MobileHeader.tsx | 4 +- src/components/PageLayout.tsx | 4 +- src/lib/db.ts | 14 +++++++ 8 files changed, 229 insertions(+), 17 deletions(-) create mode 100644 src/app/api/register/route.ts create mode 100644 src/components/LogoutButton.tsx diff --git a/README.md b/README.md index 991fa27..91e88b2 100644 --- a/README.md +++ b/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 | ## 配置说明 diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts new file mode 100644 index 0000000..7751f09 --- /dev/null +++ b/src/app/api/register/route.ts @@ -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 }); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f02461c..8ae938e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 9e5dc88..346e682 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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) => { 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 (
@@ -108,13 +142,38 @@ function LoginPageClient() {

{error}

)} - + {/* 登录 / 注册按钮 */} + {shouldAskUsername && enableRegister ? ( +
+ + +
+ ) : ( + + )}
diff --git a/src/components/LogoutButton.tsx b/src/components/LogoutButton.tsx new file mode 100644 index 0000000..853aebf --- /dev/null +++ b/src/components/LogoutButton.tsx @@ -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 ( + + ); +} diff --git a/src/components/MobileHeader.tsx b/src/components/MobileHeader.tsx index 93e0024..28ac1c5 100644 --- a/src/components/MobileHeader.tsx +++ b/src/components/MobileHeader.tsx @@ -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} -
+
+
diff --git a/src/components/PageLayout.tsx b/src/components/PageLayout.tsx index 91c9b65..0eddb0f 100644 --- a/src/components/PageLayout.tsx +++ b/src/components/PageLayout.tsx @@ -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' }`} > -
+
+
{children} diff --git a/src/lib/db.ts b/src/lib/db.ts index 4773fff..87136c3 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -54,6 +54,8 @@ export interface IStorage { // 用户相关 registerUser(userName: string, password: string): Promise; verifyUser(userName: string, password: string): Promise; + // 检查用户是否存在(无需密码) + checkUserExist(userName: string): Promise; // 搜索历史相关 getSearchHistory(): Promise; @@ -171,6 +173,13 @@ class RedisStorage implements IStorage { return stored === password; } + // 检查用户是否存在 + async checkUserExist(userName: string): Promise { + // 使用 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 { + return this.storage.checkUserExist(userName); + } + // ---------- 搜索历史 ---------- async getSearchHistory(): Promise { return this.storage.getSearchHistory();