feat(web): 登录、注册功能

This commit is contained in:
R524809
2026-09-03 16:06:13 +08:00
parent 35fcf8776e
commit ffa86f8d55
15 changed files with 5026 additions and 5757 deletions
+48 -8
View File
@@ -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;
+8 -4
View File
@@ -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'),
+111
View File
@@ -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;
}