feat: 短信登录、会话续签与侧栏体验优化
All checks were successful
CI / build (push) Successful in 2m17s

新增 auth/paths 与登录页;HTTP 与流式聊天在 401 时续签,失败则清理会话并跳转登录。
侧栏用户信息区布局修复;用户菜单项间距与验证码发送冷却、antd 发送按钮。

Made-with: Cursor
This commit is contained in:
2026-04-21 06:30:43 +08:00
parent 6579578a62
commit d4a91f11cb
9 changed files with 773 additions and 83 deletions

157
src/api/auth.ts Normal file
View File

@@ -0,0 +1,157 @@
/**
* 鉴权相关:登录、刷新令牌、本地会话读写。
*
* 会话默认存 `localStorage`,键名见下方常量(与 `http.ts` 里读取的 `accessToken` 一致)。
*/
import { postJson } from "./http";
import { ApiPath } from "./paths";
/** localStorage访问令牌 */
export const ACCESS_TOKEN_KEY = "accessToken";
/** localStorage刷新令牌 */
export const REFRESH_TOKEN_KEY = "refreshToken";
/** localStorage用户信息 JSON 字符串 */
export const USER_KEY = "user";
export type AuthUser = Record<string, unknown>;
/** 发送短信验证码时 `scene` 取值(与后端约定一致) */
export const AUTH_SMS_SCENE_LOGIN = "login";
/** `POST .../auth/sms/send` 响应(含开发环境可能返回的 `testCode` */
export type SmsSendResponse = {
testCode?: string;
data?: {
testCode?: string;
};
};
function pickSmsSendPayload(body: SmsSendResponse & { data?: SmsSendResponse }): SmsSendResponse {
if (body.data && typeof body.data === "object") {
return { ...body, testCode: body.data.testCode ?? body.testCode };
}
return body;
}
/** `POST .../auth/sms/login` 成功后的典型响应(若后端包一层 `data`,见 `pickAuthPayload` */
export type SmsLoginResponse = {
accessToken: string;
refreshToken: string;
user?: AuthUser;
};
/** `POST .../auth/refresh` 成功后的典型响应 */
export type RefreshTokenResponse = {
accessToken: string;
refreshToken: string;
};
function pickAuthPayload<T extends { accessToken?: string; refreshToken?: string }>(
body: T & { data?: T },
): T {
if (body.data && typeof body.data.accessToken === "string") {
return body.data;
}
return body;
}
/** 写入一对令牌,并清理历史 mock 键 `token` */
export function persistTokens(accessToken: string, refreshToken: string): void {
window.localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
window.localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
window.localStorage.removeItem("token");
}
/** 写入用户信息(可选) */
export function persistUser(user: AuthUser | undefined): void {
if (!user) return;
window.localStorage.setItem(USER_KEY, JSON.stringify(user));
}
/** 清除本地会话(含兼容旧键) */
export function clearSession(): void {
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
window.localStorage.removeItem(REFRESH_TOKEN_KEY);
window.localStorage.removeItem(USER_KEY);
window.localStorage.removeItem("token");
}
/**
* 续签失败、双 token 均失效等场景:清会话并回到登录页(整页跳转,避免残留状态)。
*/
export class SessionExpiredError extends Error {
override readonly name = "SessionExpiredError";
constructor(message = "登录已过期,请重新登录") {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}
export function invalidateSessionAndGoLogin(): void {
clearSession();
const base = import.meta.env.BASE_URL || "/";
const loginPath = base === "/" ? "/login" : `${String(base).replace(/\/$/, "")}/login`;
window.location.replace(loginPath);
}
/**
* 退出登录:尽量通知后端吊销 refresh失败则忽略并清除本地会话。
*/
export async function logout(): Promise<void> {
const refreshToken = window.localStorage.getItem(REFRESH_TOKEN_KEY);
if (refreshToken) {
try {
await postJson<unknown>(ApiPath.authLogout, { refreshToken });
} catch {
// 无网或接口未实现时仍允许用户本地退出
}
}
clearSession();
}
/**
* 发送短信验证码(如登录前获取验证码)。
* @param phone 国际格式,如 `+86153xxxxxxxx`
* @param scene 业务场景,默认 `AUTH_SMS_SCENE_LOGIN`
* @returns `testCode` 仅部分环境返回,用于联调自动填码
*/
export async function sendAuthSmsCode(
phone: string,
scene: string = AUTH_SMS_SCENE_LOGIN,
): Promise<{ testCode?: string }> {
const raw = await postJson<SmsSendResponse & { data?: SmsSendResponse }>(ApiPath.authSmsSend, {
phone,
scene,
});
const merged = pickSmsSendPayload(raw);
const testCode = merged.testCode;
if (typeof testCode === "string" && testCode.trim()) {
return { testCode: testCode.trim() };
}
return {};
}
/**
* 短信验证码登录。
* @param phone 国际格式,如 `+86153xxxxxxxx`
* @param code 短信验证码
*/
export async function smsLogin(phone: string, code: string): Promise<SmsLoginResponse> {
const raw = await postJson<SmsLoginResponse & { data?: SmsLoginResponse }>(ApiPath.authSmsLogin, {
phone,
code,
});
return pickAuthPayload(raw);
}
/**
* 使用 refreshToken 换取新的访问令牌与刷新令牌。
* 该请求在 `http` 层不会附带旧的 `Authorization`。
*/
export async function refreshTokens(refreshToken: string): Promise<RefreshTokenResponse> {
const raw = await postJson<RefreshTokenResponse & { data?: RefreshTokenResponse }>(
ApiPath.authRefresh,
{ refreshToken },
);
return pickAuthPayload(raw);
}