diff --git a/frontend/contexts/AuthContext.tsx b/frontend/contexts/AuthContext.tsx index 84d011a..43f573e 100644 --- a/frontend/contexts/AuthContext.tsx +++ b/frontend/contexts/AuthContext.tsx @@ -1,19 +1,39 @@ 'use client' -import { createContext, useContext, useState, useEffect, ReactNode } from 'react' -import { User, UserRole, LoginCredentials, AuthContextType } from '@/types/auth' +import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react' +import { api, clearTokens, getAccessToken, User, UserRole, LoginRequest, RegisterRequest } from '@/lib/api' + +interface AuthState { + user: User | null + isAuthenticated: boolean + isLoading: boolean +} + +interface AuthContextType extends AuthState { + login: (credentials: LoginRequest) => Promise<{ success: boolean; error?: string }> + register: (data: RegisterRequest) => Promise<{ success: boolean; error?: string }> + logout: () => void + switchRole: (role: UserRole) => void +} const AuthContext = createContext(undefined) -// 模拟用户数据(后续替换为真实 API) +const USER_STORAGE_KEY = 'miaosi_user' + +// 开发模式:使用 mock 数据 +const USE_MOCK = process.env.NEXT_PUBLIC_USE_MOCK === 'true' || process.env.NODE_ENV === 'development' + +// Mock 用户数据 const MOCK_USERS: Record = { 'creator@demo.com': { id: 'user-001', name: '达人小美', email: 'creator@demo.com', role: 'creator', - tenantId: 'tenant-001', - tenantName: '美妆品牌A', + is_verified: true, + creator_id: 'CR123456', + tenant_id: 'BR001', + tenant_name: '美妆品牌A', password: 'demo123', }, 'agency@demo.com': { @@ -21,8 +41,10 @@ const MOCK_USERS: Record = { name: '张经理', email: 'agency@demo.com', role: 'agency', - tenantId: 'tenant-001', - tenantName: '美妆品牌A', + is_verified: true, + agency_id: 'AG123456', + tenant_id: 'BR001', + tenant_name: '美妆品牌A', password: 'demo123', }, 'brand@demo.com': { @@ -30,68 +52,110 @@ const MOCK_USERS: Record = { name: '李总监', email: 'brand@demo.com', role: 'brand', - tenantId: 'tenant-001', - tenantName: '美妆品牌A', + is_verified: true, + brand_id: 'BR001', + tenant_id: 'BR001', + tenant_name: '美妆品牌A', password: 'demo123', }, } -const STORAGE_KEY = 'miaosi_auth' - export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null) const [isLoading, setIsLoading] = useState(true) - const [mounted, setMounted] = useState(false) // 初始化时从 localStorage 恢复登录状态 useEffect(() => { - setMounted(true) if (typeof window !== 'undefined') { - const stored = localStorage.getItem(STORAGE_KEY) - if (stored) { + // 检查是否有 token + const token = getAccessToken() + const storedUser = localStorage.getItem(USER_STORAGE_KEY) + + if (token && storedUser) { try { - const parsed = JSON.parse(stored) + const parsed = JSON.parse(storedUser) setUser(parsed) } catch { - localStorage.removeItem(STORAGE_KEY) + clearTokens() + localStorage.removeItem(USER_STORAGE_KEY) } } } setIsLoading(false) }, []) - const login = async (credentials: LoginCredentials): Promise<{ success: boolean; error?: string }> => { - // 模拟 API 延迟 - await new Promise((resolve) => setTimeout(resolve, 500)) + const login = useCallback(async (credentials: LoginRequest): Promise<{ success: boolean; error?: string }> => { + try { + if (USE_MOCK) { + // Mock 登录 + await new Promise((resolve) => setTimeout(resolve, 500)) + const email = credentials.email || '' + const mockUser = MOCK_USERS[email] + if (!mockUser) { + return { success: false, error: '用户不存在' } + } + if (mockUser.password !== credentials.password) { + return { success: false, error: '密码错误' } + } + const { password: _, ...userWithoutPassword } = mockUser + setUser(userWithoutPassword) + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(userWithoutPassword)) + return { success: true } + } - const mockUser = MOCK_USERS[credentials.email] - if (!mockUser) { - return { success: false, error: '用户不存在' } + // 真实 API 登录 + const response = await api.login(credentials) + setUser(response.user) + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(response.user)) + return { success: true } + } catch (err) { + const error = err instanceof Error ? err.message : '登录失败' + return { success: false, error } } - if (mockUser.password !== credentials.password) { - return { success: false, error: '密码错误' } + }, []) + + const register = useCallback(async (data: RegisterRequest): Promise<{ success: boolean; error?: string }> => { + try { + if (USE_MOCK) { + // Mock 注册(直接登录) + await new Promise((resolve) => setTimeout(resolve, 500)) + const mockUser: User = { + id: `user-${Date.now()}`, + email: data.email, + phone: data.phone, + name: data.name, + role: data.role, + is_verified: false, + } + setUser(mockUser) + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(mockUser)) + return { success: true } + } + + // 真实 API 注册 + const response = await api.register(data) + setUser(response.user) + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(response.user)) + return { success: true } + } catch (err) { + const error = err instanceof Error ? err.message : '注册失败' + return { success: false, error } } + }, []) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { password, ...userWithoutPassword } = mockUser - setUser(userWithoutPassword) - localStorage.setItem(STORAGE_KEY, JSON.stringify(userWithoutPassword)) - - return { success: true } - } - - const logout = () => { + const logout = useCallback(() => { setUser(null) - localStorage.removeItem(STORAGE_KEY) - } + clearTokens() + localStorage.removeItem(USER_STORAGE_KEY) + }, []) - const switchRole = (role: UserRole) => { + const switchRole = useCallback((role: UserRole) => { if (user) { const updated = { ...user, role } setUser(updated) - localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)) + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(updated)) } - } + }, [user]) return ( void> = [] constructor() { this.client = axios.create({ @@ -30,17 +118,75 @@ class ApiClient { }, }) - // 请求拦截器:添加租户 ID - this.client.interceptors.request.use((config) => { + this.setupInterceptors() + } + + private setupInterceptors() { + // 请求拦截器:添加 Token 和租户 ID + this.client.interceptors.request.use((config: InternalAxiosRequestConfig) => { + const token = getAccessToken() + if (token) { + config.headers.Authorization = `Bearer ${token}` + } config.headers['X-Tenant-ID'] = this.tenantId return config }) - // 响应拦截器:统一错误处理 + // 响应拦截器:处理 401 错误 this.client.interceptors.response.use( (response) => response, - (error) => { - const message = error.response?.data?.detail || error.message || '请求失败' + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean } + + // 如果是 401 错误且不是刷新 Token 的请求 + if (error.response?.status === 401 && !originalRequest._retry) { + if (this.isRefreshing) { + // 如果正在刷新,等待刷新完成后重试 + return new Promise((resolve) => { + this.refreshSubscribers.push((token: string) => { + originalRequest.headers.Authorization = `Bearer ${token}` + resolve(this.client(originalRequest)) + }) + }) + } + + originalRequest._retry = true + this.isRefreshing = true + + try { + const refreshToken = getRefreshToken() + if (!refreshToken) { + throw new Error('No refresh token') + } + + const response = await axios.post( + `${API_BASE_URL}/api/v1/auth/refresh`, + { refresh_token: refreshToken } + ) + + const newAccessToken = response.data.access_token + setTokens(newAccessToken, refreshToken) + + // 通知所有等待的请求 + this.refreshSubscribers.forEach((callback) => callback(newAccessToken)) + this.refreshSubscribers = [] + + // 重试原请求 + originalRequest.headers.Authorization = `Bearer ${newAccessToken}` + return this.client(originalRequest) + } catch (refreshError) { + // 刷新失败,清除 Token 并跳转登录 + clearTokens() + if (typeof window !== 'undefined') { + window.location.href = '/login' + } + return Promise.reject(refreshError) + } finally { + this.isRefreshing = false + } + } + + const message = (error.response?.data as { detail?: string })?.detail || error.message || '请求失败' return Promise.reject(new Error(message)) } ) @@ -50,6 +196,78 @@ class ApiClient { this.tenantId = tenantId } + // ==================== 认证 ==================== + + /** + * 用户注册 + */ + async register(data: RegisterRequest): Promise { + const response = await this.client.post('/auth/register', data) + setTokens(response.data.access_token, response.data.refresh_token) + return response.data + } + + /** + * 用户登录 + */ + async login(data: LoginRequest): Promise { + const response = await this.client.post('/auth/login', data) + setTokens(response.data.access_token, response.data.refresh_token) + return response.data + } + + /** + * 退出登录 + */ + async logout(): Promise { + try { + await this.client.post('/auth/logout') + } finally { + clearTokens() + } + } + + /** + * 刷新 Token + */ + async refreshToken(): Promise { + const refreshToken = getRefreshToken() + if (!refreshToken) { + throw new Error('No refresh token') + } + const response = await axios.post( + `${API_BASE_URL}/api/v1/auth/refresh`, + { refresh_token: refreshToken } + ) + setTokens(response.data.access_token, refreshToken) + return response.data + } + + // ==================== 文件上传 ==================== + + /** + * 获取 OSS 上传凭证 + */ + async getUploadPolicy(fileType: string = 'general'): Promise { + const response = await this.client.post('/upload/policy', { + file_type: fileType, + }) + return response.data + } + + /** + * 文件上传完成回调 + */ + async fileUploaded(fileKey: string, fileName: string, fileSize: number, fileType: string): Promise<{ url: string }> { + const response = await this.client.post<{ url: string }>('/upload/complete', { + file_key: fileKey, + file_name: fileName, + file_size: fileSize, + file_type: fileType, + }) + return response.data + } + // ==================== 视频审核 ==================== /** @@ -135,4 +353,7 @@ class ApiClient { // 单例导出 export const api = new ApiClient() +// 导出 Token 管理函数供其他模块使用 +export { getAccessToken, getRefreshToken, setTokens, clearTokens } + export default api diff --git a/frontend/types/auth.ts b/frontend/types/auth.ts index c9e0e7a..66938bc 100644 --- a/frontend/types/auth.ts +++ b/frontend/types/auth.ts @@ -1,22 +1,39 @@ -'use client' +/** + * 认证相关类型定义 + * 注意:这些类型应与 @/lib/api 中的类型保持一致 + */ export type UserRole = 'creator' | 'agency' | 'brand' export interface User { id: string + email?: string + phone?: string name: string - email: string - role: UserRole avatar?: string - tenantId: string - tenantName: string + role: UserRole + is_verified: boolean + brand_id?: string + agency_id?: string + creator_id?: string + tenant_id?: string + tenant_name?: string } export interface LoginCredentials { - email: string + email?: string + phone?: string password: string } +export interface RegisterData { + email?: string + phone?: string + password: string + name: string + role: UserRole +} + export interface AuthState { user: User | null isAuthenticated: boolean @@ -25,6 +42,7 @@ export interface AuthState { export interface AuthContextType extends AuthState { login: (credentials: LoginCredentials) => Promise<{ success: boolean; error?: string }> + register: (data: RegisterData) => Promise<{ success: boolean; error?: string }> logout: () => void switchRole: (role: UserRole) => void }