feat: Web端401队列拦截器 + 消息/任务/个人中心重写 + TabBar红点 + Admin页面完善

This commit is contained in:
2026-08-07 11:44:12 +08:00
parent b71c5a2d07
commit 721cfe1504
25 changed files with 1730 additions and 435 deletions

View File

@ -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);
},
);

View File

@ -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`, {

View 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;
}

View File

@ -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)}`