feat: Web端401队列拦截器 + 消息/任务/个人中心重写 + TabBar红点 + Admin页面完善
This commit is contained in:
@ -1,4 +1,39 @@
|
||||
import axios from "axios";
|
||||
import axios, { type AxiosRequestConfig } from "axios";
|
||||
|
||||
// ============================================================
|
||||
// Token 存储 Key
|
||||
// ============================================================
|
||||
|
||||
const ACCESS_TOKEN_KEY = "track_admin_token";
|
||||
const REFRESH_TOKEN_KEY = "track_admin_refresh_token";
|
||||
const USER_KEY = "track_admin_user";
|
||||
|
||||
// ============================================================
|
||||
// Token 读写工具
|
||||
// ============================================================
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function getRefreshToken(): string | null {
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setTokens(accessToken: string, refreshToken: string) {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Axios 实例
|
||||
// ============================================================
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
@ -8,10 +43,13 @@ const api = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截器 — 注入 JWT token
|
||||
// ============================================================
|
||||
// 请求拦截器 — 注入 Access Token
|
||||
// ============================================================
|
||||
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem("track_admin_token");
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
@ -20,19 +58,104 @@ api.interceptors.request.use(
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
// 响应拦截器 — 统一错误处理
|
||||
// ============================================================
|
||||
// 响应拦截器 — 双 Token 无感刷新 + 并发请求队列
|
||||
// ============================================================
|
||||
|
||||
let isRefreshing = false;
|
||||
let retryQueue: Array<{
|
||||
resolve: (token: string) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}> = [];
|
||||
|
||||
/** 处理队列中的所有挂起请求 */
|
||||
function processQueue(error: unknown, token: string | null) {
|
||||
retryQueue.forEach((p) => {
|
||||
if (token) {
|
||||
p.resolve(token);
|
||||
} else {
|
||||
p.reject(error);
|
||||
}
|
||||
});
|
||||
retryQueue = [];
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Token 过期或无效 → 清除并跳转登录
|
||||
localStorage.removeItem("track_admin_token");
|
||||
localStorage.removeItem("track_admin_user");
|
||||
async (error) => {
|
||||
const originalRequest: AxiosRequestConfig & { _retry?: boolean } =
|
||||
error.config;
|
||||
const status = error.response?.status;
|
||||
|
||||
// 仅处理 401
|
||||
if (status !== 401) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 跳过登录和刷新接口自身(避免死循环)
|
||||
const url = originalRequest.url ?? "";
|
||||
if (url.includes("/auth/login") || url.includes("/auth/refresh")) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 避免对同一请求重复刷新
|
||||
if (originalRequest._retry) {
|
||||
clearAuth();
|
||||
if (window.location.pathname.startsWith("/admin")) {
|
||||
window.location.href = "/admin/login";
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
originalRequest._retry = true;
|
||||
|
||||
// ---- 并发请求队列机制 ----
|
||||
if (isRefreshing) {
|
||||
// 已有刷新进行中,加入队列等待
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
retryQueue.push({ resolve, reject });
|
||||
}).then((newToken) => {
|
||||
originalRequest.headers = originalRequest.headers || {};
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return api(originalRequest);
|
||||
});
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new Error("无 Refresh Token");
|
||||
}
|
||||
|
||||
// 调用刷新接口
|
||||
const { data } = await axios.post(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/auth/refresh`,
|
||||
{ refresh_token: refreshToken },
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
|
||||
const newAccessToken: string = data.access_token;
|
||||
setTokens(newAccessToken, refreshToken); // 更新 Access Token
|
||||
|
||||
// 重放队列中的所有请求
|
||||
processQueue(null, newAccessToken);
|
||||
|
||||
// 重试当前请求
|
||||
originalRequest.headers = originalRequest.headers || {};
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// Refresh Token 也过期 — 彻底登出
|
||||
processQueue(refreshError, null);
|
||||
clearAuth();
|
||||
if (window.location.pathname.startsWith("/admin")) {
|
||||
window.location.href = "/admin/login";
|
||||
}
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@ -1,14 +1,20 @@
|
||||
import axios from "axios";
|
||||
/** 认证 API — 登录、刷新 Token、获取用户信息 */
|
||||
import type { UserInfo } from "../contexts/AuthContext";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
user: UserInfo;
|
||||
}
|
||||
|
||||
/** 登录 — 注意:此请求不走 axios 实例(避免循环依赖),直接用 fetch */
|
||||
export interface RefreshResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
/** 登录 — 注意:此请求不走 axios 实例,直接用 fetch */
|
||||
export async function login(username: string, password: string): Promise<LoginResponse> {
|
||||
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: "POST",
|
||||
@ -22,6 +28,19 @@ export async function login(username: string, password: string): Promise<LoginRe
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 刷新 Access Token — 注意:不走 axios 实例,直接用 fetch */
|
||||
export async function refreshAccessToken(refreshToken: string): Promise<RefreshResponse> {
|
||||
const res = await fetch(`${API_BASE}/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Refresh Token 无效或已过期");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 验证 token 有效性 + 获取最新用户信息 */
|
||||
export async function getMe(token: string): Promise<UserInfo> {
|
||||
const res = await fetch(`${API_BASE}/auth/me`, {
|
||||
|
||||
41
frontend/src/services/notificationApi.ts
Normal file
41
frontend/src/services/notificationApi.ts
Normal file
@ -0,0 +1,41 @@
|
||||
/** 消息通知 API */
|
||||
import api from "./api";
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
type: "TRANSFER" | "REJECT";
|
||||
task_id: string | null;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface NotificationListResponse {
|
||||
notifications: NotificationItem[];
|
||||
total: number;
|
||||
unread_count: number;
|
||||
}
|
||||
|
||||
/** 获取当前用户的通知列表 */
|
||||
export async function getNotifications(
|
||||
userId: string,
|
||||
skip = 0,
|
||||
limit = 20
|
||||
): Promise<NotificationListResponse> {
|
||||
const { data } = await api.get<NotificationListResponse>("/notifications/", {
|
||||
params: { user_id: userId, skip, limit },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 标记单条通知为已读 */
|
||||
export async function markNotificationRead(
|
||||
notificationId: string
|
||||
): Promise<NotificationItem> {
|
||||
const { data } = await api.put<NotificationItem>(
|
||||
`/notifications/${notificationId}/read`
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import api from "./api";
|
||||
import type { ProductScanResponse } from "../types/api";
|
||||
|
||||
/** 扫码查询 — 根据 16 位序列号查产品 + 顶层任务 */
|
||||
/** 扫码查询 — 根据 16 位产品身份证查产品 + 顶层任务 */
|
||||
export async function scanProduct(serialNumber: string): Promise<ProductScanResponse> {
|
||||
const { data } = await api.get<ProductScanResponse>(
|
||||
`/products/scan/${encodeURIComponent(serialNumber)}`
|
||||
|
||||
Reference in New Issue
Block a user