feat(web): 登录、注册功能
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import axios from 'axios';
|
||||
import type { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
|
||||
import type {
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
InternalAxiosRequestConfig,
|
||||
} from 'axios';
|
||||
import { envConfig } from '@/config/env';
|
||||
import { authToken } from '@/services/authToken';
|
||||
|
||||
@@ -34,10 +39,11 @@ let refreshTask: Promise<string | null> | null = null;
|
||||
/** 并发 401 只触发一次刷新请求;刷新依赖 HttpOnly refresh cookie */
|
||||
function refreshAccessToken(): Promise<string | null> {
|
||||
refreshTask ??= apiClient
|
||||
.post<{ access_token: string }>('/auth/refresh')
|
||||
.post('/auth/refresh')
|
||||
.then((res) => {
|
||||
authToken.set(res.data.access_token);
|
||||
return res.data.access_token;
|
||||
const token = unwrapEnvelope<{ access_token: string }>(res).access_token;
|
||||
authToken.set(token);
|
||||
return token;
|
||||
})
|
||||
.catch(() => {
|
||||
authToken.clear();
|
||||
@@ -72,18 +78,31 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 统一解包强制响应信封:成功为 { data: T },204 No Content 无响应体
|
||||
* (docs/api/conventions.md §3.1)。服务层泛型 T 指业务负载本身;
|
||||
* 未来列表接口({ data, meta })在此扩展专门的解包方法以保留 meta。
|
||||
*/
|
||||
function unwrapEnvelope<T>(response: AxiosResponse): T {
|
||||
const body = response.data as { data?: unknown } | undefined;
|
||||
if (body === null || body === undefined) {
|
||||
return undefined as T;
|
||||
}
|
||||
return body.data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||
apiClient.get<T>(url, config).then((res) => res.data),
|
||||
apiClient.get<T>(url, config).then((res) => unwrapEnvelope<T>(res)),
|
||||
|
||||
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||
apiClient.post<T>(url, data, config).then((res) => res.data),
|
||||
apiClient.post<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
||||
|
||||
patch: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||
apiClient.patch<T>(url, data, config).then((res) => res.data),
|
||||
apiClient.patch<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
||||
|
||||
delete: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||
apiClient.delete<T>(url, config).then((res) => res.data),
|
||||
apiClient.delete<T>(url, config).then((res) => unwrapEnvelope<T>(res)),
|
||||
};
|
||||
|
||||
/** 统一错误格式为 { error: { code, message, request_id } }(docs/api/conventions.md),提取可展示信息 */
|
||||
@@ -99,4 +118,25 @@ export function apiErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export interface ApiErrorDetail {
|
||||
field: string;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 提取错误信封中的字段级明细 details: [{ field, code, message }](docs/api/conventions.md §4) */
|
||||
export function apiErrorDetails(error: unknown): ApiErrorDetail[] {
|
||||
if (!axios.isAxiosError(error)) return [];
|
||||
const details = (error.response?.data as { error?: { details?: unknown } } | undefined)?.error
|
||||
?.details;
|
||||
if (!Array.isArray(details)) return [];
|
||||
return details.filter(
|
||||
(item): item is ApiErrorDetail =>
|
||||
!!item &&
|
||||
typeof item === 'object' &&
|
||||
typeof (item as ApiErrorDetail).field === 'string' &&
|
||||
typeof (item as ApiErrorDetail).message === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export default apiClient;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { AuthSessionResponse, LoginRequest, RegisterRequest } from '@atlas/contracts';
|
||||
import { api } from '@/services/api';
|
||||
import type { AuthSession, LoginPayload, MeResult, RegisterPayload } from '@/types/auth';
|
||||
import type { MeResult, RefreshResult } from '@/types/auth';
|
||||
|
||||
/** 账户与会话接口,契约见 docs/api/auth.md */
|
||||
/** 账户与会话接口,契约见 docs/api/auth.md;类型来自 @atlas/contracts(me/refresh 暂为本地临时类型) */
|
||||
export const authApi = {
|
||||
register: (payload: RegisterPayload) => api.post<AuthSession>('/auth/register', payload),
|
||||
register: (payload: RegisterRequest) =>
|
||||
api.post<AuthSessionResponse>('/auth/register', payload),
|
||||
|
||||
login: (payload: LoginPayload) => api.post<AuthSession>('/auth/login', payload),
|
||||
login: (payload: LoginRequest) => api.post<AuthSessionResponse>('/auth/login', payload),
|
||||
|
||||
refresh: () => api.post<RefreshResult>('/auth/refresh'),
|
||||
|
||||
logout: () => api.post<void>('/auth/logout'),
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import axios from 'axios';
|
||||
import type { AuthSessionResponse } from '@atlas/contracts';
|
||||
import { authApi } from '@/services/auth';
|
||||
import { authToken } from '@/services/authToken';
|
||||
import type { MeResult } from '@/types/auth';
|
||||
|
||||
export type SessionStatus = 'restoring' | 'authenticated' | 'anonymous';
|
||||
|
||||
export interface SessionState {
|
||||
user: MeResult['user'];
|
||||
workspace: MeResult['workspace'];
|
||||
}
|
||||
|
||||
interface SessionContextValue {
|
||||
status: SessionStatus;
|
||||
session: SessionState | null;
|
||||
/** 登录 / 注册成功后写入会话 */
|
||||
applyAuthSession: (payload: AuthSessionResponse) => void;
|
||||
/** 撤销服务端会话并清除本地状态;网络失败也保证本地登出 */
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SessionContext = createContext<SessionContextValue | null>(null);
|
||||
|
||||
// 模块级去重:StrictMode 双挂载或并发恢复时只发一次 refresh(refresh 会轮换会话,重复调用会互相失效)
|
||||
let restoreTask: Promise<void> | null = null;
|
||||
|
||||
/** 会话上下文:应用启动时经 HttpOnly refresh cookie 恢复会话,登录/注册/登出统一在此维护状态 */
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<SessionStatus>('restoring');
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
restoreTask ??= (async () => {
|
||||
try {
|
||||
const refreshed = await authApi.refresh();
|
||||
authToken.set(refreshed.access_token);
|
||||
const me = await authApi.me();
|
||||
if (!mountedRef.current) return;
|
||||
setSession({ user: me.user, workspace: me.workspace });
|
||||
setStatus('authenticated');
|
||||
} catch {
|
||||
if (!mountedRef.current) return;
|
||||
authToken.clear();
|
||||
setStatus('anonymous');
|
||||
} finally {
|
||||
restoreTask = null;
|
||||
}
|
||||
})();
|
||||
void restoreTask;
|
||||
}, []);
|
||||
|
||||
const applyAuthSession = useCallback((payload: AuthSessionResponse) => {
|
||||
authToken.set(payload.access_token);
|
||||
setSession({ user: payload.user, workspace: payload.workspace });
|
||||
setStatus('authenticated');
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await authApi.logout();
|
||||
} catch (error) {
|
||||
// access token 已过期时先刷新一次再重试,确保服务端会话被撤销;
|
||||
// 仍失败则仅做本地登出,等待服务端会话自然过期
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
const refreshed = await authApi.refresh().catch(() => null);
|
||||
if (refreshed) {
|
||||
authToken.set(refreshed.access_token);
|
||||
await authApi.logout().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
authToken.clear();
|
||||
setSession(null);
|
||||
setStatus('anonymous');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ status, session, applyAuthSession, logout }),
|
||||
[status, session, applyAuthSession, logout]
|
||||
);
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSession(): SessionContextValue {
|
||||
const context = useContext(SessionContext);
|
||||
if (!context) {
|
||||
throw new Error('useSession 必须在 SessionProvider 内使用');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user