React - 账号密码登录

This commit is contained in:
柳清爽
2025-03-29 17:02:40 +08:00
parent 7e7c199996
commit 870cec6978
4 changed files with 325 additions and 150 deletions

View File

@@ -2,19 +2,51 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react" import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { validateToken } from "@/lib/api"
// 安全的localStorage访问方法
const safeLocalStorage = {
getItem: (key: string): string | null => {
if (typeof window !== 'undefined') {
return localStorage.getItem(key)
}
return null
},
setItem: (key: string, value: string): void => {
if (typeof window !== 'undefined') {
localStorage.setItem(key, value)
}
},
removeItem: (key: string): void => {
if (typeof window !== 'undefined') {
localStorage.removeItem(key)
}
}
}
interface User {
id: number;
username: string;
account?: string;
avatar?: string;
}
interface AuthContextType { interface AuthContextType {
isAuthenticated: boolean isAuthenticated: boolean
token: string | null token: string | null
login: (token: string) => void user: User | null
login: (token: string, userData: User) => void
logout: () => void logout: () => void
updateToken: (newToken: string) => void
} }
const AuthContext = createContext<AuthContextType>({ const AuthContext = createContext<AuthContextType>({
isAuthenticated: false, isAuthenticated: false,
token: null, token: null,
user: null,
login: () => {}, login: () => {},
logout: () => {}, logout: () => {},
updateToken: () => {}
}) })
export const useAuth = () => useContext(AuthContext) export const useAuth = () => useContext(AuthContext)
@@ -25,43 +57,82 @@ interface AuthProviderProps {
export function AuthProvider({ children }: AuthProviderProps) { export function AuthProvider({ children }: AuthProviderProps) {
const [token, setToken] = useState<string | null>(null) const [token, setToken] = useState<string | null>(null)
const [user, setUser] = useState<User | null>(null)
const [isAuthenticated, setIsAuthenticated] = useState(false) const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const router = useRouter() const router = useRouter()
// 检查token有效性并初始化认证状态
useEffect(() => { useEffect(() => {
// 客户端检查token const initAuth = async () => {
if (typeof window !== "undefined") { setIsLoading(true)
const storedToken = localStorage.getItem("token") const storedToken = safeLocalStorage.getItem("token")
if (storedToken) { if (storedToken) {
setToken(storedToken) try {
setIsAuthenticated(true) // 验证token是否有效
} else { const isValid = await validateToken()
setIsAuthenticated(false)
// 暂时禁用重定向逻辑,允许访问所有页面 if (isValid) {
// 将来需要恢复登录验证时,取消下面注释 // 从localStorage获取用户信息
/* const userDataStr = safeLocalStorage.getItem("user")
if (pathname !== "/login") { if (userDataStr) {
router.push("/login") const userData = JSON.parse(userDataStr) as User
setToken(storedToken)
setUser(userData)
setIsAuthenticated(true)
} else {
// token有效但没有用户信息清除token
handleLogout()
}
} else {
// token无效清除
handleLogout()
}
} catch (error) {
console.error("验证token时出错:", error)
handleLogout()
} }
*/
} }
setIsLoading(false)
} }
initAuth()
}, []) }, [])
const login = (newToken: string) => { const handleLogout = () => {
localStorage.setItem("token", newToken) safeLocalStorage.removeItem("token")
safeLocalStorage.removeItem("user")
setToken(null)
setUser(null)
setIsAuthenticated(false)
}
const login = (newToken: string, userData: User) => {
safeLocalStorage.setItem("token", newToken)
safeLocalStorage.setItem("user", JSON.stringify(userData))
setToken(newToken) setToken(newToken)
setUser(userData)
setIsAuthenticated(true) setIsAuthenticated(true)
} }
const logout = () => { const logout = () => {
localStorage.removeItem("token") handleLogout()
setToken(null)
setIsAuthenticated(false)
// 登出后不强制跳转到登录页 // 登出后不强制跳转到登录页
// router.push("/login") // router.push("/login")
} }
return <AuthContext.Provider value={{ isAuthenticated, token, login, logout }}>{children}</AuthContext.Provider> // 用于刷新 token 的方法
const updateToken = (newToken: string) => {
safeLocalStorage.setItem("token", newToken)
setToken(newToken)
}
return (
<AuthContext.Provider value={{ isAuthenticated, token, user, login, logout, updateToken }}>
{isLoading ? <div className="flex h-screen w-screen items-center justify-center">...</div> : children}
</AuthContext.Provider>
)
} }

View File

@@ -11,19 +11,10 @@ import { useRouter } from "next/navigation"
import { WeChatIcon } from "@/components/icons/wechat-icon" import { WeChatIcon } from "@/components/icons/wechat-icon"
import { AppleIcon } from "@/components/icons/apple-icon" import { AppleIcon } from "@/components/icons/apple-icon"
import { useToast } from "@/components/ui/use-toast" import { useToast } from "@/components/ui/use-toast"
import { useAuth } from "@/app/components/AuthProvider"
import { loginApi } from "@/lib/api"
// 使用环境变量获取API域名 // 定义登录表单类型
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.example.com"
// 定义登录响应类型
interface LoginResponse {
code: number
message: string
data?: {
token: string
}
}
interface LoginForm { interface LoginForm {
phone: string phone: string
password: string password: string
@@ -44,6 +35,7 @@ export default function LoginPage() {
const router = useRouter() const router = useRouter()
const { toast } = useToast() const { toast } = useToast()
const { login, isAuthenticated } = useAuth()
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target const { name, value } = e.target
@@ -101,38 +93,40 @@ export default function LoginPage() {
setIsLoading(true) setIsLoading(true)
try { try {
// 创建FormData对象
const formData = new FormData()
formData.append("phone", form.phone)
if (activeTab === "password") { if (activeTab === "password") {
formData.append("password", form.password) // 发送账号密码登录请求
const response = await loginApi.login(form.phone, form.password)
if (response.code === 200 && response.data) {
// 获取用户信息和token
const { token, token_expired, member } = response.data
// 保存token和用户信息
login(token, {
id: member.id,
username: member.username || member.account || '',
account: member.account,
avatar: member.avatar
})
// 显示成功提示
toast({
title: "登录成功",
description: "欢迎回来!",
})
// 跳转到首页
router.push("/")
} else {
throw new Error(response.msg || "登录失败")
}
} else { } else {
formData.append("verificationCode", form.verificationCode) // 验证码登录逻辑保持原样,未来可以实现
}
// 发送登录请求
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: "POST",
body: formData,
// 不需要设置Content-Type浏览器会自动设置为multipart/form-data并添加boundary
})
const result: LoginResponse = await response.json()
if (result.code === 10000 && result.data?.token) {
// 保存token到localStorage
localStorage.setItem("token", result.data.token)
// 成功后跳转
router.push("/profile")
toast({ toast({
title: "登录成功", variant: "destructive",
description: "欢迎回来!", title: "功能未实现",
description: "验证码登录功能尚未实现,请使用密码登录",
}) })
} else {
throw new Error(result.message || "登录失败")
} }
} catch (error) { } catch (error) {
toast({ toast({
@@ -155,46 +149,19 @@ export default function LoginPage() {
return return
} }
setIsLoading(true) toast({
try { variant: "destructive",
// 创建FormData对象 title: "功能未实现",
const formData = new FormData() description: "验证码发送功能尚未实现",
formData.append("phone", form.phone) })
// 发送验证码请求
const response = await fetch(`${API_BASE_URL}/auth/send-code`, {
method: "POST",
body: formData,
})
const result = await response.json()
if (result.code === 10000) {
toast({
title: "验证码已发送",
description: "请查看手机短信",
})
} else {
throw new Error(result.message || "发送失败")
}
} catch (error) {
toast({
variant: "destructive",
title: "发送失败",
description: error instanceof Error ? error.message : "请稍后重试",
})
} finally {
setIsLoading(false)
}
} }
useEffect(() => { useEffect(() => {
// 检查是否已登录 // 检查是否已登录,如果已登录则跳转到首页
const token = localStorage.getItem("token") if (isAuthenticated) {
if (token) { router.push("/")
router.push("/profile")
} }
}, [router]) }, [isAuthenticated, router])
return ( return (
<div className="min-h-screen bg-white text-gray-900 flex flex-col px-4 py-8"> <div className="min-h-screen bg-white text-gray-900 flex flex-col px-4 py-8">
@@ -261,25 +228,24 @@ export default function LoginPage() {
</TabsContent> </TabsContent>
<TabsContent value="verification" className="m-0"> <TabsContent value="verification" className="m-0">
<div className="flex gap-3"> <div className="relative">
<Input <Input
type="text" type="text"
name="verificationCode" name="verificationCode"
value={form.verificationCode} value={form.verificationCode}
onChange={handleInputChange} onChange={handleInputChange}
placeholder="验证码" placeholder="验证码"
className="border-gray-300 text-gray-900 h-12" className="pr-32 border-gray-300 text-gray-900 h-12"
disabled={isLoading} disabled={isLoading}
/> />
<Button <button
type="button" type="button"
variant="outline"
className="w-32 h-12 border-gray-300 text-gray-600 hover:text-gray-900"
onClick={handleSendVerificationCode} onClick={handleSendVerificationCode}
className="absolute right-3 top-1/2 -translate-y-1/2 px-4 h-8 bg-blue-50 text-blue-500 rounded text-sm font-medium"
disabled={isLoading} disabled={isLoading}
> >
</Button> </button>
</div> </div>
</TabsContent> </TabsContent>
@@ -288,65 +254,35 @@ export default function LoginPage() {
id="terms" id="terms"
checked={form.agreeToTerms} checked={form.agreeToTerms}
onCheckedChange={handleCheckboxChange} onCheckedChange={handleCheckboxChange}
className="border-gray-300 data-[state=checked]:bg-blue-500"
disabled={isLoading} disabled={isLoading}
/> />
<label htmlFor="terms" className="text-sm text-gray-600"> <label
htmlFor="terms"
<a href="#" className="text-blue-500 mx-1"> className="text-sm text-gray-500 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
</a>
<a href="#" className="text-blue-500 ml-1">
</a>
</label> </label>
</div> </div>
<Button <Button type="submit" className="w-full h-12 bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
type="submit"
className="w-full h-12 bg-blue-500 hover:bg-blue-600 text-white"
disabled={isLoading}
>
{isLoading ? "登录中..." : "登录"} {isLoading ? "登录中..." : "登录"}
</Button> </Button>
<div className="relative"> <div className="flex items-center space-x-2 justify-center">
<div className="absolute inset-0 flex items-center"> <hr className="w-full border-gray-200" />
<span className="w-full border-t border-gray-300"></span> <span className="px-2 text-gray-400 text-sm whitespace-nowrap"></span>
</div> <hr className="w-full border-gray-200" />
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-gray-500"></span>
</div>
</div> </div>
<div className="space-y-3"> <div className="flex justify-center space-x-6">
<Button <button type="button" className="p-2 text-gray-500 hover:text-gray-700">
type="button" <WeChatIcon className="h-8 w-8" />
variant="outline" </button>
className="w-full h-12 border-gray-300 text-gray-700 hover:bg-gray-50" <button type="button" className="p-2 text-gray-500 hover:text-gray-700">
disabled={isLoading} <AppleIcon className="h-8 w-8" />
> </button>
<WeChatIcon className="w-6 h-6 mr-2 text-[#07C160]" />
使
</Button>
<Button
type="button"
variant="outline"
className="w-full h-12 border-gray-300 text-gray-700 hover:bg-gray-50"
disabled={isLoading}
>
<AppleIcon className="w-6 h-6 mr-2" />
使 Apple
</Button>
</div> </div>
</form> </form>
<div className="mt-8 text-center">
<a href="#" className="text-sm text-gray-500">
</a>
</div>
</div> </div>
</Tabs> </Tabs>
</div> </div>

129
Cunkebao/lib/api.ts Normal file
View File

@@ -0,0 +1,129 @@
import { handleApiResponse, handleApiError } from "./http-interceptors"
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://yishi.com';
// 安全地获取token
const getToken = (): string | null => {
if (typeof window !== 'undefined') {
return localStorage.getItem('token');
}
return null;
};
// 安全地设置token
const setToken = (token: string): void => {
if (typeof window !== 'undefined') {
localStorage.setItem('token', token);
}
};
// 创建请求头
const createHeaders = (withAuth: boolean = true): HeadersInit => {
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (withAuth) {
const token = getToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
return headers;
};
// 基础请求函数
export const request = async <T>(
url: string,
method: string = 'GET',
data?: any,
withAuth: boolean = true
): Promise<T> => {
const options: RequestInit = {
method,
headers: createHeaders(withAuth),
};
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
options.body = JSON.stringify(data);
}
try {
const response = await fetch(`${API_BASE_URL}${url}`, options);
const result = await response.json();
// 使用响应拦截器处理响应
return handleApiResponse<T>(response, result);
} catch (error) {
// 使用错误拦截器处理错误
return handleApiError(error);
}
};
// 导出便捷的请求方法
export const api = {
get: <T>(url: string, withAuth: boolean = true) =>
request<T>(url, 'GET', undefined, withAuth),
post: <T>(url: string, data: any, withAuth: boolean = true) =>
request<T>(url, 'POST', data, withAuth),
put: <T>(url: string, data: any, withAuth: boolean = true) =>
request<T>(url, 'PUT', data, withAuth),
delete: <T>(url: string, withAuth: boolean = true) =>
request<T>(url, 'DELETE', undefined, withAuth),
};
// 登录API
export const loginApi = {
// 账号密码登录
login: (account: string, password: string) =>
api.post<ApiResponse>('/v1/auth/login', {
account,
password,
typeId: 1 // 默认使用用户类型1
}, false),
// 获取用户信息
getUserInfo: () =>
api.get<ApiResponse>('/v1/auth/info'),
// 刷新Token
refreshToken: () =>
api.post<ApiResponse>('/v1/auth/refresh', {}),
};
// 验证 Token 是否有效
export const validateToken = async (): Promise<boolean> => {
try {
const response = await loginApi.getUserInfo();
return response.code === 200;
} catch (error) {
return false;
}
};
// 刷新令牌
export const refreshAuthToken = async (): Promise<boolean> => {
try {
const response = await loginApi.refreshToken();
if (response.code === 200 && response.data?.token) {
// 更新本地存储的token
setToken(response.data.token);
return true;
}
return false;
} catch (error) {
console.error('刷新Token失败:', error);
return false;
}
};
// 定义响应类型接口
export interface ApiResponse {
code: number;
msg: string;
data?: any;
}

View File

@@ -0,0 +1,39 @@
import { useRouter } from "next/navigation";
// Token过期处理
export const handleTokenExpired = () => {
if (typeof window !== 'undefined') {
// 清除本地存储
localStorage.removeItem('token');
localStorage.removeItem('user');
// 跳转到登录页
window.location.href = '/login';
}
};
// 响应拦截器
export const handleApiResponse = <T>(response: Response, result: any): T => {
// 处理token过期情况
if (result && (result.code === 401 || result.msg?.includes('token'))) {
handleTokenExpired();
throw new Error(result.msg || '登录已过期,请重新登录');
}
return result;
};
// 处理API错误
export const handleApiError = (error: unknown): never => {
console.error('API请求错误:', error);
if (error instanceof Error) {
// 如果是未授权错误可能是token过期
if (error.message.includes('401') || error.message.includes('token') || error.message.includes('授权')) {
handleTokenExpired();
}
throw error;
}
throw new Error('未知错误,请稍后重试');
};