This commit is contained in:
root
2025-07-12 17:31:46 +08:00
parent 1afe9026d6
commit 0000732e3b
11 changed files with 1318 additions and 83 deletions

View File

@@ -16,6 +16,7 @@
},
"dependencies": {
"@ag-ui/core": "^0.0.33",
"@heroiclabs/nakama-js": "^2.7.0",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",

View File

@@ -1,5 +1,18 @@
from dataclasses import dataclass
from typing import Dict, List, Any
from typing import Dict, List, Any, Optional
@dataclass
class MaterialInfo:
"""Material information structure"""
id: str
material_id: str
name: str
type: str # video, audio, image, text, sticker, etc.
original_path: str
relative_path: str
duration: Optional[int] = None
size: Optional[int] = None
@dataclass
class TemplateInfo:
@@ -16,7 +29,7 @@ class TemplateInfo:
material_count: int
track_count: int
tags: List[str]
# local/cloud
# local/cloud 如果类型是 local 那么只加载自己的 如果是 cloud 就是公用的
type: str
# 创建用户
user_id: str

View File

@@ -14,41 +14,9 @@ from datetime import datetime
from ..utils.logger import setup_logger
from ..config import settings
from python_core.database.types import TemplateInfo, MaterialInfo
logger = setup_logger(__name__)
@dataclass
class TemplateInfo:
"""Template information structure"""
id: str
name: str
description: str
thumbnail_path: str
draft_content_path: str
resources_path: str
created_at: str
updated_at: str
canvas_config: Dict[str, Any]
duration: int
material_count: int
track_count: int
tags: List[str]
@dataclass
class MaterialInfo:
"""Material information structure"""
id: str
material_id: str
name: str
type: str # video, audio, image, text, sticker, etc.
original_path: str
relative_path: str
duration: Optional[int] = None
size: Optional[int] = None
class TemplateManager:
"""Template management service"""

View File

@@ -1,6 +1,8 @@
import React, { useEffect } from 'react'
import { Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import ProtectedRoute from './components/ProtectedRoute'
import LoginPage from './pages/LoginPage'
import HomePage from './pages/HomePage'
import EditorPage from './pages/EditorPage'
import AIVideoPage from './pages/AIVideoPage'
@@ -17,6 +19,7 @@ import KVTestPage from './pages/KVTestPage'
import TextVideoGeneratorPage from './pages/TextVideoGeneratorPage'
import PythonCoreTestPage from './pages/PythonCoreTestPage'
import PythonEnvManagerPage from './pages/PythonEnvManagerPage'
import { initializeAuth } from './stores/useAuthStore'
import * as path from '@tauri-apps/api/path';
import { convertFileSrc } from '@tauri-apps/api/core'
async function testPath() {
@@ -32,8 +35,18 @@ function App() {
useEffect(() => {
testPath()
// 初始化认证状态
initializeAuth()
}, [])
return (
<Routes>
{/* 登录页面 - 公开访问 */}
<Route path="/login" element={<LoginPage />} />
{/* 受保护的路由 */}
<Route path="/*" element={
<ProtectedRoute>
<Routes>
{/* 项目详情页 - 无侧边栏布局 */}
<Route path="/projects/:projectId" element={<ProjectDetailPage />} />
@@ -61,6 +74,9 @@ function App() {
</Layout>
} />
</Routes>
</ProtectedRoute>
} />
</Routes>
)
}

View File

@@ -0,0 +1,48 @@
/**
* 受保护的路由组件
* 检查用户认证状态,未登录时重定向到登录页面
*/
import React, { useEffect } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore, initializeAuth } from '../stores/useAuthStore';
import { Loader2 } from 'lucide-react';
interface ProtectedRouteProps {
children: React.ReactNode;
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { isAuthenticated, loading, getCurrentUser } = useAuthStore();
const location = useLocation();
useEffect(() => {
// 初始化认证状态
initializeAuth();
}, []);
// 如果正在加载,显示加载界面
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-secondary-900 via-secondary-800 to-primary-900 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 bg-gradient-to-r from-primary-500 to-blue-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Loader2 className="w-8 h-8 text-white animate-spin" />
</div>
<h3 className="text-xl font-semibold text-white mb-2"></h3>
<p className="text-secondary-300">...</p>
</div>
</div>
);
}
// 如果未认证,重定向到登录页面
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
// 如果已认证,渲染子组件
return <>{children}</>;
};
export default ProtectedRoute;

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'
import { Minimize2, Maximize2, Square, X } from 'lucide-react'
import { getCurrentWindow } from '@tauri-apps/api/window'
import UserMenu from './UserMenu'
const TitleBar: React.FC = () => {
const [isMaximized, setIsMaximized] = useState(false)
@@ -128,6 +129,13 @@ const TitleBar: React.FC = () => {
<span className="text-sm font-medium">MixVideo V2</span>
</div>
<div className="flex items-center space-x-3">
{/* 用户菜单 */}
<div onClick={(e) => e.stopPropagation()}>
<UserMenu />
</div>
{/* 窗口控制按钮 */}
<div className="flex items-center space-x-1">
<button
onClick={(e) => {
@@ -158,6 +166,7 @@ const TitleBar: React.FC = () => {
</button>
</div>
</div>
</div>
)
}

181
src/components/UserMenu.tsx Normal file
View File

@@ -0,0 +1,181 @@
/**
* 用户菜单组件
* 显示用户头像、信息和操作菜单
*/
import React, { useState, useRef, useEffect } from 'react';
import {
User,
Settings,
LogOut,
ChevronDown,
Shield,
Edit3
} from 'lucide-react';
import { useAuthStore } from '../stores/useAuthStore';
import { useNavigate } from 'react-router-dom';
const UserMenu: React.FC = () => {
const { user, logout } = useAuthStore();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// 点击外部关闭菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const handleLogout = async () => {
try {
await logout();
navigate('/login');
} catch (error) {
console.error('Logout failed:', error);
}
};
const getAvatarUrl = () => {
if (user?.avatarUrl) {
return user.avatarUrl;
}
// 生成基于用户名的头像
const name = user?.displayName || user?.username || 'User';
return `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=2563eb&color=ffffff&size=40`;
};
const getUserInitials = () => {
const name = user?.displayName || user?.username || 'User';
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
};
if (!user) {
return null;
}
return (
<div className="relative" ref={menuRef}>
{/* 用户头像按钮 */}
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center space-x-3 p-2 rounded-lg hover:bg-secondary-100 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<div className="relative">
<img
src={getAvatarUrl()}
alt={user.displayName || user.username}
className="w-8 h-8 rounded-full object-cover"
onError={(e) => {
// 如果头像加载失败,显示文字头像
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const parent = target.parentElement;
if (parent) {
const fallback = document.createElement('div');
fallback.className = 'w-8 h-8 rounded-full bg-primary-500 flex items-center justify-center text-white text-sm font-semibold';
fallback.textContent = getUserInitials();
parent.appendChild(fallback);
}
}}
/>
<div className="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-green-500 border-2 border-white rounded-full"></div>
</div>
<div className="hidden md:block text-left">
<p className="text-sm font-medium text-secondary-900">{user.displayName || user.username}</p>
<p className="text-xs text-secondary-500">{user.email}</p>
</div>
<ChevronDown className={`w-4 h-4 text-secondary-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{/* 下拉菜单 */}
{isOpen && (
<div className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-secondary-200 py-2 z-50">
{/* 用户信息 */}
<div className="px-4 py-3 border-b border-secondary-200">
<div className="flex items-center space-x-3">
<img
src={getAvatarUrl()}
alt={user.displayName || user.username}
className="w-10 h-10 rounded-full object-cover"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-secondary-900 truncate">
{user.displayName || user.username}
</p>
<p className="text-xs text-secondary-500 truncate">{user.email}</p>
<div className="flex items-center mt-1">
<Shield className="w-3 h-3 text-green-500 mr-1" />
<span className="text-xs text-green-600"></span>
</div>
</div>
</div>
</div>
{/* 菜单项 */}
<div className="py-2">
<button
onClick={() => {
setIsOpen(false);
navigate('/profile');
}}
className="w-full flex items-center px-4 py-2 text-sm text-secondary-700 hover:bg-secondary-50 transition-colors"
>
<User className="w-4 h-4 mr-3" />
</button>
<button
onClick={() => {
setIsOpen(false);
navigate('/profile/edit');
}}
className="w-full flex items-center px-4 py-2 text-sm text-secondary-700 hover:bg-secondary-50 transition-colors"
>
<Edit3 className="w-4 h-4 mr-3" />
</button>
<button
onClick={() => {
setIsOpen(false);
navigate('/settings');
}}
className="w-full flex items-center px-4 py-2 text-sm text-secondary-700 hover:bg-secondary-50 transition-colors"
>
<Settings className="w-4 h-4 mr-3" />
</button>
</div>
{/* 分割线 */}
<div className="border-t border-secondary-200 my-2"></div>
{/* 登出 */}
<button
onClick={() => {
setIsOpen(false);
handleLogout();
}}
className="w-full flex items-center px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
<LogOut className="w-4 h-4 mr-3" />
退
</button>
</div>
)}
</div>
);
};
export default UserMenu;

View File

@@ -49,6 +49,59 @@
@apply relative h-16 bg-secondary-200 border-b border-secondary-300;
}
/* 登录页面特殊样式 */
.login-bg-pattern {
background-image:
radial-gradient(circle at 25% 25%, rgba(59, 130, 246, 0.1) 0%, transparent 50%),
radial-gradient(circle at 75% 75%, rgba(147, 51, 234, 0.1) 0%, transparent 50%);
}
.login-glass {
backdrop-filter: blur(20px);
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.login-input {
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
transition: all 0.3s ease;
}
.login-input:focus {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(59, 130, 246, 0.5);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.login-button {
background: linear-gradient(135deg, #2563eb 0%, #3b82f6 50%, #1d4ed8 100%);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.login-button:hover {
transform: translateY(-1px);
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.3);
}
.login-button:before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.login-button:hover:before {
left: 100%;
}
.timeline-clip {
@apply absolute top-0 h-full bg-primary-500 rounded border border-primary-600 cursor-pointer hover:bg-primary-600 transition-colors;
}

420
src/pages/LoginPage.tsx Normal file
View File

@@ -0,0 +1,420 @@
/**
* 登录页面
* 美观大气的科技风登录界面集成Nakama用户系统
*/
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
User,
Lock,
Mail,
Eye,
EyeOff,
Zap,
Shield,
Cpu,
Play,
Sparkles,
ArrowRight
} from 'lucide-react';
import { useAuthStore } from '../stores/useAuthStore';
interface LoginFormData {
email: string;
password: string;
rememberMe: boolean;
}
interface RegisterFormData {
email: string;
username: string;
password: string;
confirmPassword: string;
displayName: string;
}
const LoginPage: React.FC = () => {
const navigate = useNavigate();
const { login, register, loading, error, clearError, isAuthenticated } = useAuthStore();
const [isRegisterMode, setIsRegisterMode] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [loginForm, setLoginForm] = useState<LoginFormData>({
email: '',
password: '',
rememberMe: false
});
const [registerForm, setRegisterForm] = useState<RegisterFormData>({
email: '',
username: '',
password: '',
confirmPassword: '',
displayName: ''
});
// 如果已登录,重定向到首页
useEffect(() => {
if (isAuthenticated) {
navigate('/');
}
}, [isAuthenticated, navigate]);
// 清除错误信息
useEffect(() => {
clearError();
}, [isRegisterMode, clearError]);
const handleLoginSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await login({
email: loginForm.email,
password: loginForm.password
});
navigate('/');
} catch (error) {
// 错误已在store中处理
}
};
const handleRegisterSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (registerForm.password !== registerForm.confirmPassword) {
return;
}
try {
await register({
email: registerForm.email,
username: registerForm.username,
password: registerForm.password,
displayName: registerForm.displayName || registerForm.username
});
navigate('/');
} catch (error) {
// 错误已在store中处理
}
};
const toggleMode = () => {
setIsRegisterMode(!isRegisterMode);
clearError();
};
return (
<div className="min-h-screen bg-gradient-to-br from-secondary-900 via-secondary-800 to-primary-900 flex items-center justify-center p-4 relative overflow-hidden">
{/* 背景动画元素 */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-primary-500/20 rounded-full blur-3xl animate-pulse"></div>
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-blue-500/20 rounded-full blur-3xl animate-pulse delay-1000"></div>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl animate-pulse delay-500"></div>
</div>
{/* 网格背景 */}
<div className="absolute inset-0 opacity-30"></div>
<div className="relative z-10 w-full max-w-6xl mx-auto grid lg:grid-cols-2 gap-8 items-center">
{/* 左侧 - 品牌展示 */}
<div className="hidden lg:block space-y-8">
<div className="space-y-6">
<div className="flex items-center space-x-3">
<div className="w-12 h-12 bg-gradient-to-r from-primary-500 to-blue-500 rounded-xl flex items-center justify-center">
<Play className="w-6 h-6 text-white" />
</div>
<h1 className="text-3xl font-bold text-white">MixVideo</h1>
</div>
<h2 className="text-4xl font-bold text-white leading-tight">
<br />
<span className="bg-gradient-to-r from-primary-400 to-blue-400 bg-clip-text text-transparent">
</span>
</h2>
<p className="text-xl text-secondary-300 leading-relaxed">
AI技术的专业视频混剪工具
</p>
</div>
{/* 特性展示 */}
<div className="space-y-4">
<div className="flex items-center space-x-4 p-4 bg-white/5 backdrop-blur-sm rounded-lg border border-white/10">
<div className="w-10 h-10 bg-primary-500/20 rounded-lg flex items-center justify-center">
<Zap className="w-5 h-5 text-primary-400" />
</div>
<div>
<h3 className="text-white font-semibold"></h3>
<p className="text-secondary-400 text-sm">AI驱动的视频场景自动识别与分割</p>
</div>
</div>
<div className="flex items-center space-x-4 p-4 bg-white/5 backdrop-blur-sm rounded-lg border border-white/10">
<div className="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center">
<Cpu className="w-5 h-5 text-blue-400" />
</div>
<div>
<h3 className="text-white font-semibold"></h3>
<p className="text-secondary-400 text-sm"></p>
</div>
</div>
<div className="flex items-center space-x-4 p-4 bg-white/5 backdrop-blur-sm rounded-lg border border-white/10">
<div className="w-10 h-10 bg-purple-500/20 rounded-lg flex items-center justify-center">
<Sparkles className="w-5 h-5 text-purple-400" />
</div>
<div>
<h3 className="text-white font-semibold"></h3>
<p className="text-secondary-400 text-sm"></p>
</div>
</div>
</div>
</div>
{/* 右侧 - 登录表单 */}
<div className="w-full max-w-md mx-auto">
<div className="bg-white/10 backdrop-blur-xl rounded-2xl border border-white/20 shadow-2xl p-8">
{/* 表单头部 */}
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-r from-primary-500 to-blue-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Shield className="w-8 h-8 text-white" />
</div>
<h3 className="text-2xl font-bold text-white mb-2">
{isRegisterMode ? '创建账户' : '欢迎回来'}
</h3>
<p className="text-secondary-300">
{isRegisterMode ? '加入MixVideo开始您的创作之旅' : '登录您的账户继续使用'}
</p>
</div>
{/* 错误提示 */}
{error && (
<div className="mb-6 p-4 bg-red-500/20 border border-red-500/30 rounded-lg">
<p className="text-red-300 text-sm">{error}</p>
</div>
)}
{/* 登录表单 */}
{!isRegisterMode ? (
<form onSubmit={handleLoginSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-secondary-300 mb-2">
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-secondary-400" />
<input
type="email"
required
value={loginForm.email}
onChange={(e) => setLoginForm({ ...loginForm, email: e.target.value })}
className="w-full pl-10 pr-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-secondary-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
placeholder="请输入邮箱地址"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-secondary-300 mb-2">
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-secondary-400" />
<input
type={showPassword ? 'text' : 'password'}
required
value={loginForm.password}
onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })}
className="w-full pl-10 pr-12 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-secondary-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
placeholder="请输入密码"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-secondary-400 hover:text-white transition-colors"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center">
<input
type="checkbox"
checked={loginForm.rememberMe}
onChange={(e) => setLoginForm({ ...loginForm, rememberMe: e.target.checked })}
className="w-4 h-4 text-primary-500 bg-white/10 border-white/20 rounded focus:ring-primary-500 focus:ring-2"
/>
<span className="ml-2 text-sm text-secondary-300"></span>
</label>
<button
type="button"
className="text-sm text-primary-400 hover:text-primary-300 transition-colors"
>
</button>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-gradient-to-r from-primary-500 to-blue-500 text-white py-3 px-4 rounded-lg font-semibold hover:from-primary-600 hover:to-blue-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:ring-offset-transparent transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2"
>
{loading ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
) : (
<>
<span></span>
<ArrowRight className="w-4 h-4" />
</>
)}
</button>
</form>
) : (
/* 注册表单 */
<form onSubmit={handleRegisterSubmit} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-secondary-300 mb-2">
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-secondary-400" />
<input
type="text"
required
value={registerForm.username}
onChange={(e) => setRegisterForm({ ...registerForm, username: e.target.value })}
className="w-full pl-10 pr-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-secondary-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
placeholder="用户名"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-secondary-300 mb-2">
</label>
<input
type="text"
value={registerForm.displayName}
onChange={(e) => setRegisterForm({ ...registerForm, displayName: e.target.value })}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-secondary-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
placeholder="显示名称"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-secondary-300 mb-2">
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-secondary-400" />
<input
type="email"
required
value={registerForm.email}
onChange={(e) => setRegisterForm({ ...registerForm, email: e.target.value })}
className="w-full pl-10 pr-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-secondary-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
placeholder="请输入邮箱地址"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-secondary-300 mb-2">
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-secondary-400" />
<input
type={showPassword ? 'text' : 'password'}
required
value={registerForm.password}
onChange={(e) => setRegisterForm({ ...registerForm, password: e.target.value })}
className="w-full pl-10 pr-12 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-secondary-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
placeholder="请输入密码"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-secondary-400 hover:text-white transition-colors"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-secondary-300 mb-2">
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-secondary-400" />
<input
type={showConfirmPassword ? 'text' : 'password'}
required
value={registerForm.confirmPassword}
onChange={(e) => setRegisterForm({ ...registerForm, confirmPassword: e.target.value })}
className="w-full pl-10 pr-12 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-secondary-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
placeholder="请再次输入密码"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-secondary-400 hover:text-white transition-colors"
>
{showConfirmPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
{registerForm.password && registerForm.confirmPassword && registerForm.password !== registerForm.confirmPassword && (
<p className="mt-1 text-sm text-red-400"></p>
)}
</div>
<button
type="submit"
disabled={loading || registerForm.password !== registerForm.confirmPassword}
className="w-full bg-gradient-to-r from-primary-500 to-blue-500 text-white py-3 px-4 rounded-lg font-semibold hover:from-primary-600 hover:to-blue-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:ring-offset-transparent transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2"
>
{loading ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
) : (
<>
<span></span>
<ArrowRight className="w-4 h-4" />
</>
)}
</button>
</form>
)}
{/* 切换模式 */}
<div className="mt-8 text-center">
<p className="text-secondary-300">
{isRegisterMode ? '已有账户?' : '还没有账户?'}
<button
onClick={toggleMode}
className="ml-1 text-primary-400 hover:text-primary-300 font-semibold transition-colors"
>
{isRegisterMode ? '立即登录' : '立即注册'}
</button>
</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default LoginPage;

329
src/services/nakamaAuth.ts Normal file
View File

@@ -0,0 +1,329 @@
/**
* Nakama认证服务
* 处理用户登录、注册、会话管理等功能
*/
import { Client, Session } from '@heroiclabs/nakama-js';
export interface LoginCredentials {
email?: string;
username?: string;
password: string;
}
export interface RegisterCredentials {
email: string;
username: string;
password: string;
displayName?: string;
}
export interface UserProfile {
id: string;
username: string;
displayName: string;
email?: string;
avatarUrl?: string;
createTime: string;
updateTime: string;
}
export interface AuthState {
isAuthenticated: boolean;
user: UserProfile | null;
session: Session | null;
loading: boolean;
error: string | null;
}
class NakamaAuthService {
private client: Client;
private session: Session | null = null;
private readonly SERVER_KEY = 'defaultkey'; // 替换为实际的服务器密钥
private readonly HOST = 'https://nakama.bowong.cc'; // 替换为实际的Nakama服务器地址
private readonly PORT = '443'; // 替换为实际的端口
private readonly USE_SSL = true; // 根据实际情况设置
constructor() {
this.client = new Client(this.SERVER_KEY, this.HOST, this.PORT, this.USE_SSL);
this.loadStoredSession();
}
/**
* 从本地存储加载会话
*/
private loadStoredSession(): void {
try {
const storedSession = localStorage.getItem('nakama_session');
if (storedSession) {
const sessionData = JSON.parse(storedSession);
this.session = Session.restore(
sessionData.token,
sessionData.refresh_token,
sessionData.username,
sessionData.user_id,
sessionData.created,
sessionData.expires_at,
sessionData.vars
);
// 检查会话是否过期
if (this.session.isexpired(Date.now() / 1000)) {
this.refreshSession();
}
}
} catch (error) {
console.error('Failed to load stored session:', error);
this.clearStoredSession();
}
}
/**
* 保存会话到本地存储
*/
private saveSession(session: Session): void {
try {
const sessionData = {
token: session.token,
refresh_token: session.refresh_token,
username: session.username,
user_id: session.user_id,
created: session.created,
expires_at: session.expires_at,
vars: session.vars
};
localStorage.setItem('nakama_session', JSON.stringify(sessionData));
this.session = session;
} catch (error) {
console.error('Failed to save session:', error);
}
}
/**
* 清除本地存储的会话
*/
private clearStoredSession(): void {
localStorage.removeItem('nakama_session');
this.session = null;
}
/**
* 刷新会话
*/
private async refreshSession(): Promise<void> {
if (!this.session?.refresh_token) {
throw new Error('No refresh token available');
}
try {
const newSession = await this.client.sessionRefresh(this.session);
this.saveSession(newSession);
} catch (error) {
console.error('Failed to refresh session:', error);
this.clearStoredSession();
throw error;
}
}
/**
* 用户登录
*/
async login(credentials: LoginCredentials): Promise<UserProfile> {
try {
let session: Session;
if (credentials.email) {
// 邮箱登录
session = await this.client.authenticateEmail(credentials.email, credentials.password);
} else if (credentials.username) {
// 用户名登录
session = await this.client.authenticateUsername(credentials.username, credentials.password);
} else {
throw new Error('Email or username is required');
}
this.saveSession(session);
// 获取用户信息
const account = await this.client.getAccount(session);
return {
id: account.user?.id || '',
username: account.user?.username || '',
displayName: account.user?.display_name || account.user?.username || '',
email: account.email,
avatarUrl: account.user?.avatar_url,
createTime: account.user?.create_time || '',
updateTime: account.user?.update_time || ''
};
} catch (error) {
console.error('Login failed:', error);
throw new Error(this.getErrorMessage(error));
}
}
/**
* 用户注册
*/
async register(credentials: RegisterCredentials): Promise<UserProfile> {
try {
const session = await this.client.authenticateEmail(
credentials.email,
credentials.password,
true, // create account if not exists
credentials.username,
credentials.displayName
);
this.saveSession(session);
// 获取用户信息
const account = await this.client.getAccount(session);
return {
id: account.user?.id || '',
username: account.user?.username || '',
displayName: account.user?.display_name || credentials.displayName || credentials.username,
email: account.email,
avatarUrl: account.user?.avatar_url,
createTime: account.user?.create_time || '',
updateTime: account.user?.update_time || ''
};
} catch (error) {
console.error('Registration failed:', error);
throw new Error(this.getErrorMessage(error));
}
}
/**
* 用户登出
*/
async logout(): Promise<void> {
try {
if (this.session) {
await this.client.sessionLogout(this.session);
}
} catch (error) {
console.error('Logout error:', error);
} finally {
this.clearStoredSession();
}
}
/**
* 获取当前用户信息
*/
async getCurrentUser(): Promise<UserProfile | null> {
if (!this.session) {
return null;
}
try {
// 检查会话是否过期
if (this.session.isexpired(Date.now() / 1000)) {
await this.refreshSession();
}
const account = await this.client.getAccount(this.session);
return {
id: account.user?.id || '',
username: account.user?.username || '',
displayName: account.user?.display_name || account.user?.username || '',
email: account.email,
avatarUrl: account.user?.avatar_url,
createTime: account.user?.create_time || '',
updateTime: account.user?.update_time || ''
};
} catch (error) {
console.error('Failed to get current user:', error);
this.clearStoredSession();
return null;
}
}
/**
* 检查是否已登录
*/
isAuthenticated(): boolean {
return this.session !== null && !this.session.isexpired(Date.now() / 1000);
}
/**
* 获取当前会话
*/
getSession(): Session | null {
return this.session;
}
/**
* 更新用户资料
*/
async updateProfile(updates: Partial<Pick<UserProfile, 'displayName' | 'avatarUrl'>>): Promise<void> {
if (!this.session) {
throw new Error('Not authenticated');
}
try {
await this.client.updateAccount(this.session, {
display_name: updates.displayName,
avatar_url: updates.avatarUrl
});
} catch (error) {
console.error('Failed to update profile:', error);
throw new Error(this.getErrorMessage(error));
}
}
/**
* 修改密码
*/
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
if (!this.session) {
throw new Error('Not authenticated');
}
try {
// 首先验证当前密码
const account = await this.client.getAccount(this.session);
if (account.email) {
await this.client.authenticateEmail(account.email, currentPassword);
}
// 更新密码这里需要根据Nakama的实际API调整
// Nakama可能需要通过其他方式更新密码
console.warn('Password change not implemented - requires server-side function');
throw new Error('Password change requires server-side implementation');
} catch (error) {
console.error('Failed to change password:', error);
throw new Error(this.getErrorMessage(error));
}
}
/**
* 获取错误消息
*/
private getErrorMessage(error: any): string {
if (error?.message) {
return error.message;
}
// 根据Nakama错误码返回友好的错误消息
switch (error?.code) {
case 3: // INVALID_ARGUMENT
return '输入参数无效';
case 5: // NOT_FOUND
return '用户不存在';
case 6: // ALREADY_EXISTS
return '用户已存在';
case 16: // UNAUTHENTICATED
return '用户名或密码错误';
default:
return '操作失败,请稍后重试';
}
}
}
// 导出单例实例
export const nakamaAuth = new NakamaAuthService();
export default nakamaAuth;

197
src/stores/useAuthStore.ts Normal file
View File

@@ -0,0 +1,197 @@
/**
* 认证状态管理Store
* 使用Zustand管理用户认证状态
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { nakamaAuth, UserProfile, LoginCredentials, RegisterCredentials } from '../services/nakamaAuth';
interface AuthState {
// 状态
isAuthenticated: boolean;
user: UserProfile | null;
loading: boolean;
error: string | null;
// 操作
login: (credentials: LoginCredentials) => Promise<void>;
register: (credentials: RegisterCredentials) => Promise<void>;
logout: () => Promise<void>;
getCurrentUser: () => Promise<void>;
updateProfile: (updates: Partial<Pick<UserProfile, 'displayName' | 'avatarUrl'>>) => Promise<void>;
clearError: () => void;
setLoading: (loading: boolean) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
// 初始状态
isAuthenticated: false,
user: null,
loading: false,
error: null,
// 登录
login: async (credentials: LoginCredentials) => {
set({ loading: true, error: null });
try {
const user = await nakamaAuth.login(credentials);
set({
isAuthenticated: true,
user,
loading: false,
error: null
});
} catch (error) {
set({
loading: false,
error: error instanceof Error ? error.message : '登录失败',
isAuthenticated: false,
user: null
});
throw error;
}
},
// 注册
register: async (credentials: RegisterCredentials) => {
set({ loading: true, error: null });
try {
const user = await nakamaAuth.register(credentials);
set({
isAuthenticated: true,
user,
loading: false,
error: null
});
} catch (error) {
set({
loading: false,
error: error instanceof Error ? error.message : '注册失败',
isAuthenticated: false,
user: null
});
throw error;
}
},
// 登出
logout: async () => {
set({ loading: true });
try {
await nakamaAuth.logout();
set({
isAuthenticated: false,
user: null,
loading: false,
error: null
});
} catch (error) {
console.error('Logout error:', error);
// 即使登出失败,也要清除本地状态
set({
isAuthenticated: false,
user: null,
loading: false,
error: null
});
}
},
// 获取当前用户信息
getCurrentUser: async () => {
set({ loading: true, error: null });
try {
const user = await nakamaAuth.getCurrentUser();
if (user) {
set({
isAuthenticated: true,
user,
loading: false
});
} else {
set({
isAuthenticated: false,
user: null,
loading: false
});
}
} catch (error) {
set({
loading: false,
error: error instanceof Error ? error.message : '获取用户信息失败',
isAuthenticated: false,
user: null
});
}
},
// 更新用户资料
updateProfile: async (updates: Partial<Pick<UserProfile, 'displayName' | 'avatarUrl'>>) => {
set({ loading: true, error: null });
try {
await nakamaAuth.updateProfile(updates);
// 更新本地用户信息
const currentUser = get().user;
if (currentUser) {
set({
user: { ...currentUser, ...updates },
loading: false
});
}
} catch (error) {
set({
loading: false,
error: error instanceof Error ? error.message : '更新资料失败'
});
throw error;
}
},
// 清除错误
clearError: () => {
set({ error: null });
},
// 设置加载状态
setLoading: (loading: boolean) => {
set({ loading });
}
}),
{
name: 'auth-storage',
partialize: (state) => ({
isAuthenticated: state.isAuthenticated,
user: state.user
})
}
)
);
// 初始化时检查认证状态
export const initializeAuth = async () => {
const { getCurrentUser } = useAuthStore.getState();
// 检查是否有有效的会话
if (nakamaAuth.isAuthenticated()) {
await getCurrentUser();
} else {
// 清除过期的状态
useAuthStore.setState({
isAuthenticated: false,
user: null,
loading: false,
error: null
});
}
};
export default useAuthStore;