mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-21 00:44:41 +08:00
feat: user menu and hot show
This commit is contained in:
72
src/app/api/change-password/route.ts
Normal file
72
src/app/api/change-password/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/* eslint-disable no-console*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getStorage } from '@/lib/db';
|
||||
import { IStorage } from '@/lib/types';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
|
||||
// 不支持 localstorage 模式
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持本地存储模式修改密码',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { newPassword } = body;
|
||||
|
||||
// 获取认证信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 验证新密码
|
||||
if (!newPassword || typeof newPassword !== 'string') {
|
||||
return NextResponse.json({ error: '新密码不得为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
const username = authInfo.username;
|
||||
|
||||
// 不允许站长修改密码(站长用户名等于 process.env.USERNAME)
|
||||
if (username === process.env.USERNAME) {
|
||||
return NextResponse.json(
|
||||
{ error: '站长不能通过此接口修改密码' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取存储实例
|
||||
const storage: IStorage | null = getStorage();
|
||||
if (!storage || typeof storage.changePassword !== 'function') {
|
||||
return NextResponse.json(
|
||||
{ error: '存储服务不支持修改密码' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
await storage.changePassword(username, newPassword);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('修改密码失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '修改密码失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ function HomeClient() {
|
||||
const [activeTab, setActiveTab] = useState<'home' | 'favorites'>('home');
|
||||
const [hotMovies, setHotMovies] = useState<DoubanItem[]>([]);
|
||||
const [hotTvShows, setHotTvShows] = useState<DoubanItem[]>([]);
|
||||
const [hotVarietyShows, setHotVarietyShows] = useState<DoubanItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { announcement } = useSite();
|
||||
|
||||
@@ -63,14 +64,15 @@ function HomeClient() {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 并行获取热门电影和热门剧集
|
||||
const [moviesData, tvShowsData] = await Promise.all([
|
||||
// 并行获取热门电影、热门剧集和热门综艺
|
||||
const [moviesData, tvShowsData, varietyShowsData] = await Promise.all([
|
||||
getDoubanCategories({
|
||||
kind: 'movie',
|
||||
category: '热门',
|
||||
type: '全部',
|
||||
}),
|
||||
getDoubanCategories({ kind: 'tv', category: 'tv', type: 'tv' }),
|
||||
getDoubanCategories({ kind: 'tv', category: 'show', type: 'show' }),
|
||||
]);
|
||||
|
||||
if (moviesData.code === 200) {
|
||||
@@ -80,6 +82,10 @@ function HomeClient() {
|
||||
if (tvShowsData.code === 200) {
|
||||
setHotTvShows(tvShowsData.list);
|
||||
}
|
||||
|
||||
if (varietyShowsData.code === 200) {
|
||||
setHotVarietyShows(varietyShowsData.list);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取豆瓣数据失败:', error);
|
||||
} finally {
|
||||
@@ -301,6 +307,53 @@ function HomeClient() {
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
|
||||
{/* 热门综艺 */}
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
热门综艺
|
||||
</h2>
|
||||
<Link
|
||||
href='/douban?type=show'
|
||||
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
>
|
||||
查看更多
|
||||
<ChevronRight className='w-4 h-4 ml-1' />
|
||||
</Link>
|
||||
</div>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
hotVarietyShows.map((show, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<VideoCard
|
||||
from='douban'
|
||||
title={show.title}
|
||||
poster={show.poster}
|
||||
douban_id={show.id}
|
||||
rate={show.rate}
|
||||
year={show.year}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
'use client';
|
||||
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export const LogoutButton: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (loading) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// 调用注销API来清除cookie
|
||||
await fetch('/api/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('注销请求失败:', error);
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -3,10 +3,9 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { BackButton } from './BackButton';
|
||||
import { LogoutButton } from './LogoutButton';
|
||||
import { SettingsButton } from './SettingsButton';
|
||||
import { useSite } from './SiteProvider';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import { UserMenu } from './UserMenu';
|
||||
|
||||
interface MobileHeaderProps {
|
||||
showBackButton?: boolean;
|
||||
@@ -20,13 +19,12 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
|
||||
{/* 左侧:返回按钮和设置按钮 */}
|
||||
<div className='flex items-center gap-2'>
|
||||
{showBackButton && <BackButton />}
|
||||
<SettingsButton />
|
||||
</div>
|
||||
|
||||
{/* 右侧按钮 */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<LogoutButton />
|
||||
<ThemeToggle />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { BackButton } from './BackButton';
|
||||
import { LogoutButton } from './LogoutButton';
|
||||
import MobileBottomNav from './MobileBottomNav';
|
||||
import MobileHeader from './MobileHeader';
|
||||
import { SettingsButton } from './SettingsButton';
|
||||
import Sidebar from './Sidebar';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import { UserMenu } from './UserMenu';
|
||||
|
||||
interface PageLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -35,9 +34,8 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
|
||||
|
||||
{/* 桌面端顶部按钮 */}
|
||||
<div className='absolute top-2 right-4 z-20 hidden md:flex items-center gap-2'>
|
||||
<SettingsButton />
|
||||
<LogoutButton />
|
||||
<ThemeToggle />
|
||||
<UserMenu />
|
||||
</div>
|
||||
|
||||
{/* 主内容 */}
|
||||
|
||||
@@ -1,26 +1,59 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||
|
||||
'use client';
|
||||
|
||||
import { Settings, X } from 'lucide-react';
|
||||
import { KeyRound, LogOut, Settings, Shield, User, X } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
export const SettingsButton: React.FC = () => {
|
||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
|
||||
interface AuthInfo {
|
||||
username?: string;
|
||||
role?: 'owner' | 'admin' | 'user';
|
||||
}
|
||||
|
||||
export const UserMenu: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
|
||||
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
|
||||
const [storageType, setStorageType] = useState<string>('localstorage');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// 设置相关状态
|
||||
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
|
||||
const [doubanProxyUrl, setDoubanProxyUrl] = useState('');
|
||||
const [imageProxyUrl, setImageProxyUrl] = useState('');
|
||||
const [enableOptimization, setEnableOptimization] = useState(true);
|
||||
const [enableImageProxy, setEnableImageProxy] = useState(false);
|
||||
const [enableDoubanProxy, setEnableDoubanProxy] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// 修改密码相关状态
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordLoading, setPasswordLoading] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
|
||||
// 确保组件已挂载
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 获取认证信息和存储类型
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const auth = getAuthInfoFromBrowserCookie();
|
||||
setAuthInfo(auth);
|
||||
|
||||
const type =
|
||||
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE || 'localstorage';
|
||||
setStorageType(type);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 从 localStorage 读取设置
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -37,7 +70,6 @@ export const SettingsButton: React.FC = () => {
|
||||
if (savedEnableDoubanProxy !== null) {
|
||||
setEnableDoubanProxy(JSON.parse(savedEnableDoubanProxy));
|
||||
} else if (defaultDoubanProxy) {
|
||||
// 如果有默认豆瓣代理配置,则默认开启
|
||||
setEnableDoubanProxy(true);
|
||||
}
|
||||
|
||||
@@ -54,7 +86,6 @@ export const SettingsButton: React.FC = () => {
|
||||
if (savedEnableImageProxy !== null) {
|
||||
setEnableImageProxy(JSON.parse(savedEnableImageProxy));
|
||||
} else if (defaultImageProxy) {
|
||||
// 如果有默认图片代理配置,则默认开启
|
||||
setEnableImageProxy(true);
|
||||
}
|
||||
|
||||
@@ -73,7 +104,99 @@ export const SettingsButton: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 保存设置到 localStorage
|
||||
const handleMenuClick = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const handleCloseMenu = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('注销请求失败:', error);
|
||||
}
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
const handleAdminPanel = () => {
|
||||
router.push('/admin');
|
||||
};
|
||||
|
||||
const handleChangePassword = () => {
|
||||
setIsOpen(false);
|
||||
setIsChangePasswordOpen(true);
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setPasswordError('');
|
||||
};
|
||||
|
||||
const handleCloseChangePassword = () => {
|
||||
setIsChangePasswordOpen(false);
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setPasswordError('');
|
||||
};
|
||||
|
||||
const handleSubmitChangePassword = async () => {
|
||||
setPasswordError('');
|
||||
|
||||
// 验证密码
|
||||
if (!newPassword) {
|
||||
setPasswordError('新密码不得为空');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordError('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
setPasswordLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/change-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
newPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setPasswordError(data.error || '修改密码失败');
|
||||
return;
|
||||
}
|
||||
|
||||
// 修改成功,关闭弹窗并登出
|
||||
setIsChangePasswordOpen(false);
|
||||
await handleLogout();
|
||||
} catch (error) {
|
||||
setPasswordError('网络错误,请稍后重试');
|
||||
} finally {
|
||||
setPasswordLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSettings = () => {
|
||||
setIsOpen(false);
|
||||
setIsSettingsOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseSettings = () => {
|
||||
setIsSettingsOpen(false);
|
||||
};
|
||||
|
||||
// 设置相关的处理函数
|
||||
const handleAggregateToggle = (value: boolean) => {
|
||||
setDefaultAggregateSearch(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -116,21 +239,11 @@ export const SettingsButton: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSettingsClick = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const handleClosePanel = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// 重置所有设置为默认值
|
||||
const handleResetSettings = () => {
|
||||
const defaultImageProxy = (window as any).RUNTIME_CONFIG?.IMAGE_PROXY || '';
|
||||
const defaultDoubanProxy =
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY || '';
|
||||
|
||||
// 重置所有状态
|
||||
setDefaultAggregateSearch(true);
|
||||
setEnableOptimization(true);
|
||||
setDoubanProxyUrl(defaultDoubanProxy);
|
||||
@@ -138,7 +251,6 @@ export const SettingsButton: React.FC = () => {
|
||||
setEnableImageProxy(!!defaultImageProxy);
|
||||
setImageProxyUrl(defaultImageProxy);
|
||||
|
||||
// 保存到 localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('defaultAggregateSearch', JSON.stringify(true));
|
||||
localStorage.setItem('enableOptimization', JSON.stringify(true));
|
||||
@@ -155,13 +267,124 @@ export const SettingsButton: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 检查是否显示管理面板按钮
|
||||
const showAdminPanel =
|
||||
authInfo?.role === 'owner' || authInfo?.role === 'admin';
|
||||
|
||||
// 检查是否显示修改密码按钮
|
||||
const showChangePassword =
|
||||
authInfo?.role !== 'owner' && storageType !== 'localstorage';
|
||||
|
||||
// 角色中文映射
|
||||
const getRoleText = (role?: string) => {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return '站长';
|
||||
case 'admin':
|
||||
return '管理员';
|
||||
case 'user':
|
||||
return '用户';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// 菜单面板内容
|
||||
const menuPanel = (
|
||||
<>
|
||||
{/* 背景遮罩 - 普通菜单无需模糊 */}
|
||||
<div
|
||||
className='fixed inset-0 bg-transparent z-[1000]'
|
||||
onClick={handleCloseMenu}
|
||||
/>
|
||||
|
||||
{/* 菜单面板 */}
|
||||
<div className='fixed top-14 right-4 w-56 bg-white dark:bg-gray-900 rounded-lg shadow-xl z-[1001] border border-gray-200/50 dark:border-gray-700/50 overflow-hidden select-none'>
|
||||
{/* 用户信息区域 */}
|
||||
{authInfo?.username && (
|
||||
<div className='px-3 py-2.5 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-gray-50 to-gray-100/50 dark:from-gray-800 dark:to-gray-800/50'>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||
当前用户
|
||||
</span>
|
||||
{authInfo.role && (
|
||||
<span
|
||||
className={`inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
authInfo.role === 'owner'
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
||||
: authInfo.role === 'admin'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||
}`}
|
||||
>
|
||||
{getRoleText(authInfo.role)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='font-semibold text-gray-900 dark:text-gray-100 text-sm truncate'>
|
||||
{authInfo.username}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 菜单项 */}
|
||||
<div className='py-1'>
|
||||
{/* 设置按钮 */}
|
||||
<button
|
||||
onClick={handleSettings}
|
||||
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
|
||||
>
|
||||
<Settings className='w-4 h-4 text-gray-500 dark:text-gray-400' />
|
||||
<span className='font-medium'>设置</span>
|
||||
</button>
|
||||
|
||||
{/* 管理面板按钮 */}
|
||||
{showAdminPanel && (
|
||||
<button
|
||||
onClick={handleAdminPanel}
|
||||
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
|
||||
>
|
||||
<Shield className='w-4 h-4 text-gray-500 dark:text-gray-400' />
|
||||
<span className='font-medium'>管理面板</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 修改密码按钮 */}
|
||||
{showChangePassword && (
|
||||
<button
|
||||
onClick={handleChangePassword}
|
||||
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
|
||||
>
|
||||
<KeyRound className='w-4 h-4 text-gray-500 dark:text-gray-400' />
|
||||
<span className='font-medium'>修改密码</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 分割线 */}
|
||||
<div className='my-1 border-t border-gray-200 dark:border-gray-700'></div>
|
||||
|
||||
{/* 登出按钮 */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-sm'
|
||||
>
|
||||
<LogOut className='w-4 h-4' />
|
||||
<span className='font-medium'>登出</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// 设置面板内容
|
||||
const settingsPanel = (
|
||||
<>
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
|
||||
onClick={handleClosePanel}
|
||||
onClick={handleCloseSettings}
|
||||
/>
|
||||
|
||||
{/* 设置面板 */}
|
||||
@@ -181,7 +404,7 @@ export const SettingsButton: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClosePanel}
|
||||
onClick={handleCloseSettings}
|
||||
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
|
||||
aria-label='Close'
|
||||
>
|
||||
@@ -352,18 +575,119 @@ export const SettingsButton: React.FC = () => {
|
||||
</>
|
||||
);
|
||||
|
||||
// 修改密码面板内容
|
||||
const changePasswordPanel = (
|
||||
<>
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
|
||||
onClick={handleCloseChangePassword}
|
||||
/>
|
||||
|
||||
{/* 修改密码面板 */}
|
||||
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] p-6'>
|
||||
{/* 标题栏 */}
|
||||
<div className='flex items-center justify-between mb-6'>
|
||||
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
修改密码
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleCloseChangePassword}
|
||||
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
|
||||
aria-label='Close'
|
||||
>
|
||||
<X className='w-full h-full' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 表单 */}
|
||||
<div className='space-y-4'>
|
||||
{/* 新密码输入 */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
新密码
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
|
||||
placeholder='请输入新密码'
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={passwordLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 确认密码输入 */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
确认密码
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
|
||||
placeholder='请再次输入新密码'
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={passwordLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
{passwordError && (
|
||||
<div className='text-red-500 text-sm bg-red-50 dark:bg-red-900/20 p-3 rounded-md border border-red-200 dark:border-red-800'>
|
||||
{passwordError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<button
|
||||
onClick={handleCloseChangePassword}
|
||||
className='flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors'
|
||||
disabled={passwordLoading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmitChangePassword}
|
||||
className='flex-1 px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
disabled={passwordLoading || !newPassword || !confirmPassword}
|
||||
>
|
||||
{passwordLoading ? '修改中...' : '确认修改'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 底部说明 */}
|
||||
<div className='mt-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'>
|
||||
修改密码后需要重新登录
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSettingsClick}
|
||||
onClick={handleMenuClick}
|
||||
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='Settings'
|
||||
aria-label='User Menu'
|
||||
>
|
||||
<Settings className='w-full h-full' />
|
||||
<User className='w-full h-full' />
|
||||
</button>
|
||||
|
||||
{/* 使用 Portal 将菜单面板渲染到 document.body */}
|
||||
{isOpen && mounted && createPortal(menuPanel, document.body)}
|
||||
|
||||
{/* 使用 Portal 将设置面板渲染到 document.body */}
|
||||
{isOpen && mounted && createPortal(settingsPanel, document.body)}
|
||||
{isSettingsOpen && mounted && createPortal(settingsPanel, document.body)}
|
||||
|
||||
{/* 使用 Portal 将修改密码面板渲染到 document.body */}
|
||||
{isChangePasswordOpen &&
|
||||
mounted &&
|
||||
createPortal(changePasswordPanel, document.body)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user