Browse Source

feat: frontend login accepts phone/username, remove email from auth flow

master
dark 1 month ago
parent
commit
e31c274a64
  1. 61
      frontend/react-shadcn/pc/src/contexts/AuthContext.tsx
  2. 333
      frontend/react-shadcn/pc/src/pages/LoginPage.tsx
  3. 13
      frontend/react-shadcn/pc/src/types/index.ts

61
frontend/react-shadcn/pc/src/contexts/AuthContext.tsx

@ -7,7 +7,8 @@ interface AuthContextType {
token: string | null token: string | null
isAuthenticated: boolean isAuthenticated: boolean
isLoading: boolean isLoading: boolean
login: (email: string, password: string) => Promise<void> login: (account: string, password: string) => Promise<void>
loginWithToken: (token: string) => void
logout: () => void logout: () => void
} }
@ -37,29 +38,74 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setIsLoading(false) setIsLoading(false)
}, []) }, [])
const login = async (email: string, password: string) => { const login = async (account: string, password: string) => {
try { try {
const response = await apiClient.login({ email, password }) const response = await apiClient.login({ account, password })
if (response.success && response.token) { if (response.success && response.token) {
setToken(response.token) setToken(response.token)
// Mock user data - in real app, fetch from API // 解析 JWT 获取用户信息
try {
const payload = JSON.parse(atob(response.token.split('.')[1]))
const userData: User = {
id: payload.userId || 0,
username: payload.username || account,
email: '',
role: payload.role || 'user',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
setUser(userData)
localStorage.setItem('user', JSON.stringify(userData))
} catch {
const userData: User = { const userData: User = {
id: 1, id: 0,
username: email.split('@')[0], username: account,
email, email: '',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} }
setUser(userData) setUser(userData)
localStorage.setItem('user', JSON.stringify(userData)) localStorage.setItem('user', JSON.stringify(userData))
} }
}
} catch (error) { } catch (error) {
throw error throw error
} }
} }
const loginWithToken = (ssoToken: string) => {
localStorage.setItem('token', ssoToken)
setToken(ssoToken)
// 解析 JWT 获取用户信息
try {
const payload = JSON.parse(atob(ssoToken.split('.')[1]))
const userData: User = {
id: payload.userId || 0,
username: payload.username || '',
email: payload.email || '',
role: payload.role || 'user',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
setUser(userData)
localStorage.setItem('user', JSON.stringify(userData))
} catch {
// JWT 解析失败,使用占位数据
const userData: User = {
id: 0,
username: 'SSO User',
email: '',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
setUser(userData)
localStorage.setItem('user', JSON.stringify(userData))
}
}
const logout = () => { const logout = () => {
apiClient.logout() apiClient.logout()
setToken(null) setToken(null)
@ -73,6 +119,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
isAuthenticated: !!token, isAuthenticated: !!token,
isLoading, isLoading,
login, login,
loginWithToken,
logout, logout,
} }

333
frontend/react-shadcn/pc/src/pages/LoginPage.tsx

@ -1,26 +1,56 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { Mail, Lock, AlertCircle, Zap } from 'lucide-react' import { UserRound, Lock, AlertCircle, Eye, EyeOff, ArrowRight, Shield, Cpu, BarChart3 } from 'lucide-react'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { apiClient } from '@/services/api'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Card } from '@/components/ui/Card'
export function LoginPage() { export function LoginPage() {
const [email, setEmail] = useState('') const [account, setAccount] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [ssoLoading, setSsoLoading] = useState(false)
const [mounted, setMounted] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams()
const { login } = useAuth() const { login } = useAuth()
useEffect(() => {
setMounted(true)
// 检查 SSO 错误参数
const ssoError = searchParams.get('error')
if (ssoError === 'sso_failed') {
setError('SSO 登录失败,请重试')
}
}, [searchParams])
const handleSSOLogin = async () => {
setSsoLoading(true)
setError('')
try {
const res = await apiClient.getSSOLoginUrl()
if (res.login_url) {
window.location.href = res.login_url
} else {
setError('获取 SSO 登录链接失败')
setSsoLoading(false)
}
} catch {
setError('SSO 服务不可用')
setSsoLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError('') setError('')
setIsLoading(true) setIsLoading(true)
try { try {
await login(email, password) await login(account, password)
navigate('/dashboard') navigate('/dashboard')
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '登录失败') setError(err instanceof Error ? err.message : '登录失败')
@ -30,64 +60,183 @@ export function LoginPage() {
} }
return ( return (
<div className="min-h-screen flex items-center justify-center relative overflow-hidden grid-pattern"> <div className="min-h-screen flex bg-[oklch(0.10_0.01_240)]">
{/* Animated background elements */} {/* Left Panel - Brand Showcase (visible from md breakpoint) */}
<div className="absolute inset-0 overflow-hidden"> <div
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-sky-500/10 rounded-full blur-3xl animate-pulse" /> className={`hidden md:flex md:w-[45%] lg:w-[55%] relative overflow-hidden flex-col justify-between p-8 lg:p-12 transition-all duration-700 ease-out ${
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '1s' }} /> mounted ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-8'
</div> }`}
>
{/* Decorative grid lines */} {/* Gradient background */}
<div className="absolute inset-0 pointer-events-none"> <div className="absolute inset-0 bg-gradient-to-br from-indigo-600 via-blue-600 to-sky-500" />
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-sky-500/30 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-purple-500/30 to-transparent" /> {/* Subtle pattern overlay */}
<div className="absolute top-0 bottom-0 left-0 w-px bg-gradient-to-b from-transparent via-sky-500/30 to-transparent" /> <div
<div className="absolute top-0 bottom-0 right-0 w-px bg-gradient-to-b from-transparent via-purple-500/30 to-transparent" /> className="absolute inset-0 opacity-[0.07]"
</div> style={{
backgroundImage:
{/* Main content */} 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)',
<div className="relative z-10 w-full max-w-md px-4 animate-scale-in"> backgroundSize: '32px 32px',
{/* Logo and title */} }}
<div className="text-center mb-8"> />
<div className="inline-flex items-center justify-center w-20 h-20 mb-6 rounded-2xl bg-gradient-to-br from-sky-500 to-blue-600 glow-primary relative overflow-hidden">
<div className="absolute inset-0 stripe-pattern" /> {/* Floating orbs */}
<Zap className="h-10 w-10 text-white relative z-10" /> <div className="absolute top-20 right-20 w-48 lg:w-72 h-48 lg:h-72 bg-white/10 rounded-full blur-3xl animate-login-float" />
</div> <div
<h1 className="text-4xl font-bold text-white font-display tracking-wide mb-2"> className="absolute bottom-32 left-16 w-40 lg:w-56 h-40 lg:h-56 bg-sky-300/15 rounded-full blur-3xl animate-login-float"
BASE<span className="text-sky-400">.</span> style={{ animationDelay: '2s', animationDuration: '8s' }}
/>
<div
className="absolute top-1/2 right-1/3 w-32 lg:w-40 h-32 lg:h-40 bg-indigo-300/10 rounded-full blur-2xl animate-login-float"
style={{ animationDelay: '4s', animationDuration: '10s' }}
/>
{/* Content */}
<div className="relative z-10">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-xl bg-white/20 backdrop-blur-sm flex items-center justify-center">
<Cpu className="h-5 w-5 text-white" />
</div>
<span className="text-xl font-bold text-white tracking-wider font-display">
BASE
</span>
</div>
</div>
<div className="relative z-10 max-w-lg">
<h1
className={`text-3xl lg:text-5xl font-bold text-white leading-tight mb-4 lg:mb-6 font-display tracking-wide transition-all duration-700 delay-200 ease-out ${
mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-6'
}`}
>
<br />
<span className="text-sky-200"></span>
</h1> </h1>
<p className="text-gray-500 font-body"></p> <p
className={`text-base lg:text-lg text-blue-100/80 leading-relaxed mb-6 lg:mb-10 font-body transition-all duration-700 delay-300 ease-out ${
mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-6'
}`}
>
</p>
{/* Feature highlights */}
<div
className={`space-y-3 lg:space-y-4 transition-all duration-700 delay-500 ease-out ${
mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-6'
}`}
>
{[
{ icon: Shield, text: 'JWT 认证与权限管理' },
{ icon: BarChart3, text: '实时数据仪表盘' },
{ icon: Cpu, text: 'Go + React 全栈架构' },
].map(({ icon: Icon, text }) => (
<div key={text} className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-white/10 backdrop-blur-sm flex items-center justify-center flex-shrink-0">
<Icon className="h-4 w-4 text-sky-200" />
</div>
<span className="text-blue-100/90 text-sm font-body">{text}</span>
</div>
))}
</div>
</div> </div>
{/* Login form */} {/* Bottom */}
<Card className="backdrop-blur-xl bg-gray-900/90 border-gray-800"> <div className="relative z-10">
<form onSubmit={handleSubmit} className="space-y-6"> <p className="text-blue-200/50 text-sm font-body">
<div className="space-y-4"> &copy; 2026 Base System. All rights reserved.
</p>
</div>
</div>
{/* Right Panel - Login Form */}
<div className="flex-1 flex items-center justify-center px-4 py-8 sm:px-8 md:px-6 lg:px-8 relative overflow-hidden">
{/* Subtle background effects for right panel */}
<div className="absolute inset-0">
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-br from-indigo-500/[0.03] via-transparent to-sky-500/[0.03]" />
<div
className="absolute inset-0 opacity-[0.02]"
style={{
backgroundImage:
'linear-gradient(rgba(99,102,241,0.3) 1px, transparent 1px), linear-gradient(90deg, rgba(99,102,241,0.3) 1px, transparent 1px)',
backgroundSize: '48px 48px',
}}
/>
</div>
<div
className={`relative z-10 w-full max-w-[420px] transition-all duration-700 delay-100 ease-out ${
mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'
}`}
>
{/* Mobile logo - only visible below md */}
<div className="md:hidden text-center mb-6">
<div className="inline-flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-sky-500 flex items-center justify-center">
<Cpu className="h-5 w-5 text-white" />
</div>
<span className="text-xl font-bold text-white tracking-wider font-display">
BASE
</span>
</div>
</div>
{/* Form header */}
<div className="mb-6">
<h2 className="text-xl sm:text-2xl font-bold text-white font-display tracking-wide mb-1.5">
</h2>
<p className="text-gray-400 font-body text-sm">
</p>
</div>
{/* Login card */}
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.03] backdrop-blur-xl p-5 sm:p-6 lg:p-8 shadow-2xl shadow-black/20">
<form onSubmit={handleSubmit} className="space-y-4">
<Input <Input
type="email" type="text"
label="邮箱地址" label="手机号 / 用户名"
placeholder="user@example.com" placeholder="请输入手机号或用户名"
value={email} value={account}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setAccount(e.target.value)}
required required
leftIcon={<Mail className="h-4 w-4" />} leftIcon={<UserRound className="h-4 w-4" />}
disabled={isLoading} disabled={isLoading}
/> />
<Input <Input
type="password" type={showPassword ? 'text' : 'password'}
label="密码" label="密码"
placeholder="••••••••" placeholder="&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
leftIcon={<Lock className="h-4 w-4" />} leftIcon={<Lock className="h-4 w-4" />}
rightIcon={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="cursor-pointer text-gray-500 hover:text-gray-300 transition-colors duration-200"
tabIndex={-1}
aria-label={showPassword ? '隐藏密码' : '显示密码'}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
}
disabled={isLoading} disabled={isLoading}
/> />
</div>
{error && ( {error && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm font-body"> <div
className="flex items-center gap-2.5 p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm font-body animate-scale-in"
role="alert"
>
<AlertCircle className="h-4 w-4 flex-shrink-0" /> <AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{error}</span> <span>{error}</span>
</div> </div>
@ -97,39 +246,97 @@ export function LoginPage() {
type="submit" type="submit"
variant="primary" variant="primary"
size="lg" size="lg"
className="w-full" className="w-full !bg-gradient-to-r !from-indigo-500 !to-sky-500 hover:!from-indigo-400 hover:!to-sky-400 !border-indigo-500/30 !shadow-lg !shadow-indigo-500/20 cursor-pointer group"
isLoading={isLoading} isLoading={isLoading}
> >
<span></span>
{!isLoading && (
<ArrowRight className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5" />
)}
</Button> </Button>
</form>
<div className="text-center"> {/* Divider */}
<p className="text-sm text-gray-500 font-body"> <div className="relative my-5">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/[0.06]" />
</div>
<div className="relative flex justify-center text-xs">
<span className="px-3 bg-[oklch(0.12_0.01_240)] text-gray-500 font-body">
SSO
</span>
</div>
</div>
{/* SSO Buttons */}
<div className="flex flex-col sm:flex-row gap-2.5">
<button
type="button"
className="flex items-center justify-center gap-2.5 h-11 flex-1 rounded-xl border border-white/[0.08] bg-white/[0.03] hover:bg-white/[0.07] transition-colors duration-200 cursor-pointer group"
aria-label="使用 Google 登录"
>
<svg className="h-5 w-5 flex-shrink-0" viewBox="0 0 24 24">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</svg>
<span className="text-sm text-gray-300 font-body group-hover:text-white transition-colors duration-200">Google</span>
</button>
<button
type="button"
className="flex items-center justify-center gap-2.5 h-11 flex-1 rounded-xl border border-white/[0.08] bg-white/[0.03] hover:bg-white/[0.07] transition-colors duration-200 cursor-pointer group"
aria-label="使用 GitHub 登录"
>
<svg className="h-5 w-5 flex-shrink-0 text-gray-300 group-hover:text-white transition-colors duration-200" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2z" />
</svg>
<span className="text-sm text-gray-300 font-body group-hover:text-white transition-colors duration-200">GitHub</span>
</button>
<button
type="button"
onClick={handleSSOLogin}
disabled={ssoLoading}
className="flex items-center justify-center gap-2.5 h-11 flex-1 rounded-xl border border-white/[0.08] bg-white/[0.03] hover:bg-white/[0.07] transition-colors duration-200 cursor-pointer group disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="使用 XHY.GROUP 登录"
>
<svg className="h-5 w-5 flex-shrink-0" viewBox="0 0 32 32" fill="none">
<defs>
<linearGradient id="xhy-grad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#6366F1" />
<stop offset="100%" stopColor="#06B6D4" />
</linearGradient>
</defs>
<rect width="32" height="32" rx="7" fill="url(#xhy-grad)" />
<path d="M8 10L13.5 16L8 22H11.5L15.5 17.2L19.5 22H23L17.5 16L23 10H19.5L15.5 14.8L11.5 10H8Z" fill="white" fillOpacity="0.95" />
<rect x="6" y="25" width="20" height="1.5" rx="0.75" fill="white" fillOpacity="0.4" />
</svg>
<span className="text-sm text-gray-300 font-body group-hover:text-white transition-colors duration-200">XHY.GROUP</span>
</button>
</div>
{/* Register link */}
<p className="text-center text-sm text-gray-400 font-body mt-5">
{' '} {' '}
<button <button
type="button" type="button"
className="text-sky-400 hover:text-sky-300 transition-colors font-medium" className="text-indigo-400 hover:text-indigo-300 transition-colors duration-200 font-medium cursor-pointer"
> >
</button> </button>
</p> </p>
</div> </div>
</form>
</Card>
{/* Footer info */} {/* Mobile footer */}
<div className="mt-8 text-center"> <div className="md:hidden mt-6 text-center">
<p className="text-xs text-gray-600 font-body"> <p className="text-xs text-gray-600 font-body">
© 2026 Base System. All rights reserved. &copy; 2026 Base System. All rights reserved.
</p> </p>
</div> </div>
</div> </div>
</div>
{/* Corner decorations */}
<div className="absolute top-0 left-0 w-24 h-24 border-l-2 border-t-2 border-sky-500/30 rounded-tl-3xl" />
<div className="absolute top-0 right-0 w-24 h-24 border-r-2 border-t-2 border-purple-500/30 rounded-tr-3xl" />
<div className="absolute bottom-0 left-0 w-24 h-24 border-l-2 border-b-2 border-purple-500/30 rounded-bl-3xl" />
<div className="absolute bottom-0 right-0 w-24 h-24 border-r-2 border-b-2 border-sky-500/30 rounded-br-3xl" />
</div> </div>
) )
} }

13
frontend/react-shadcn/pc/src/types/index.ts

@ -20,6 +20,9 @@ export interface User {
email: string email: string
phone?: string phone?: string
avatar?: string avatar?: string
role?: string
source?: string
remark?: string
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
@ -28,15 +31,15 @@ export interface UserInfo extends User {}
// Request Types // Request Types
export interface LoginRequest { export interface LoginRequest {
email: string account: string
password: string password: string
} }
export interface RegisterRequest { export interface RegisterRequest {
username: string username: string
email: string
password: string password: string
phone?: string phone: string
email?: string
} }
export interface CreateUserRequest { export interface CreateUserRequest {
@ -44,6 +47,8 @@ export interface CreateUserRequest {
email: string email: string
password: string password: string
phone?: string phone?: string
role?: string
remark?: string
} }
export interface UpdateUserRequest { export interface UpdateUserRequest {
@ -51,6 +56,8 @@ export interface UpdateUserRequest {
email?: string email?: string
phone?: string phone?: string
avatar?: string avatar?: string
role?: string
remark?: string
} }
export interface UserListRequest { export interface UserListRequest {

Loading…
Cancel
Save