fix
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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"""
|
||||
|
||||
|
||||
16
src/App.tsx
16
src/App.tsx
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
48
src/components/ProtectedRoute.tsx
Normal file
48
src/components/ProtectedRoute.tsx
Normal 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;
|
||||
@@ -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
181
src/components/UserMenu.tsx
Normal 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;
|
||||
@@ -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
420
src/pages/LoginPage.tsx
Normal 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
329
src/services/nakamaAuth.ts
Normal 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
197
src/stores/useAuthStore.ts
Normal 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;
|
||||
Reference in New Issue
Block a user