fix(buyer): 修复登录过期异常及全局请求错误处理

This commit is contained in:
pikachu1995@126.com
2026-11-09 16:06:33 +08:00
parent b2981b3ad7
commit ecbb80e24a
11 changed files with 384 additions and 100 deletions

View File

@@ -1,3 +1,4 @@
import { setupRequestErrorHandling } from "@/utils/requestError";
import { createApp } from "vue";
import { createPinia } from "pinia";
import "core-js/stable";
@@ -24,6 +25,7 @@ if (aMapSecurityJsCode) {
}
const app = createApp(App);
setupRequestErrorHandling(app);
setupElementPlus(app);
setupLegacyMessage(app);

View File

@@ -200,7 +200,7 @@ function handleSubmit () { // 提交密码
Message.success('修改密码成功');
router.push('login');
}
}).catch(() => { loading.value = false; });
}).catch(() => { loading1.value = false; });
};
});
}

View File

@@ -137,6 +137,7 @@
</template>
<script setup>
import { catchHandledRequestError } from "@/utils/requestError";
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "@/utils/message";
@@ -230,6 +231,8 @@ function handleSubmit() {
params.content = "";
getDetail();
}
}).catch(catchHandledRequestError).finally(() => {
submitLoading.value = false;
});
}

View File

@@ -17,7 +17,9 @@
</div>
</template>
<!-- 列表 -->
<empty v-if="orderList.length === 0"/>
<el-skeleton v-if="spinShow" :rows="5" animated />
<el-empty v-else-if="queryFailed" description="加载失败,请重试"><el-button type="primary" @click="getList">重新加载</el-button></el-empty>
<empty v-else-if="orderList.length === 0" />
<div class="order-content" v-else>
<div class="order-cols">
<span>订单详情</span>
@@ -77,7 +79,7 @@
</div>
</div>
</div>
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
</div>
<!-- 分页 -->
<div class="page-size">
@@ -134,6 +136,7 @@
</template>
<script setup>
import { catchHandledRequestError } from "@/utils/requestError";
import { nextTick, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { Message, Modal } from "@/utils/message";
@@ -157,6 +160,8 @@ const params = reactive({ // 请求参数
order: 'desc'
});
const total = ref(0); // 订单总数
const queryFailed = ref(false); // 区分加载失败与暂无记录,支持重新查询
let listRequestVersion = 0; // 仅允许最后一次查询更新列表与加载状态
const spinShow = ref(false); // 加载状态
const companyList = ref([]); // 物流公司列表
const logisticsShow = ref(false); // 物流信息modal
@@ -230,14 +235,27 @@ function change(index) {
getList()
}
function getList() { // 获取售后列表
const version = ++listRequestVersion;
queryFailed.value = false;
spinShow.value = true;
let paramsData = JSON.parse(JSON.stringify(params))
afterSaleList(paramsData).then(res => {
spinShow.value = false
if (version !== listRequestVersion) return;
if (res.success) {
orderList.value = res.result.records;
total.value = res.result.total;
}
}).catch((error) => {
if (version !== listRequestVersion) {
catchHandledRequestError(error);
return;
}
queryFailed.value = true;
orderList.value = [];
total.value = 0;
catchHandledRequestError(error);
}).finally(() => {
if (version === listRequestVersion) spinShow.value = false;
});
}
function changePageNum(val) { // 修改页码

View File

@@ -21,7 +21,9 @@
</template>
<!-- 订单列表 -->
<empty v-if="orderList.length === 0" />
<el-skeleton v-if="spinShow" :rows="5" animated />
<el-empty v-else-if="queryFailed" description="加载失败,请重试"><el-button type="primary" @click="getList">重新加载</el-button></el-empty>
<empty v-else-if="orderList.length === 0" />
<div class="order-content" v-else>
<div class="order-cols">
<span>订单详情</span>
@@ -91,7 +93,7 @@
</div>
</div>
</div>
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
</div>
<!-- 分页 -->
@@ -103,7 +105,9 @@
</UserCenterLayout>
<!-- 个人中心首页订单列表 -->
<empty v-if="homePage && orderList.length === 0" />
<el-skeleton v-if="homePage && spinShow" :rows="5" animated />
<el-empty v-else-if="homePage && queryFailed" description="加载失败,请重试"><el-button type="primary" @click="getList">重新加载</el-button></el-empty>
<empty v-else-if="homePage && orderList.length === 0" />
<div class="order-content" v-else-if="homePage">
<div class="order-cols">
<span>订单详情</span>
@@ -173,7 +177,7 @@
</div>
</div>
</div>
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
</div>
<el-dialog v-model="cancelAvail" title="请选择取消订单原因">
<el-alert v-if="cancelIsPickup" title="此订单使用提货卡兑换,取消不退还提货权益,无法再次兑换。" type="warning" :closable="false" style="margin-bottom:16px" />
@@ -193,6 +197,7 @@
</template>
<script setup>
import { catchHandledRequestError } from "@/utils/requestError";
import { onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message, Modal } from "@/utils/message";
@@ -227,6 +232,8 @@ const cancelParams = reactive({ // 取消售后参数
const changeWay = ref(['全部', '待付款', '待发货', '待收货/使用']);
const activeOrderTab = ref(0);
const total = ref(0); // 数据总数
const queryFailed = ref(false); // 区分加载失败与暂无记录,支持重新查询
let listRequestVersion = 0; // 仅允许最后一次查询更新列表与加载状态
const spinShow = ref(false); // 加载状态
const cancelIsPickup = ref(false);
const cancelAvail = ref(false); // 取消订单modal控制
@@ -322,17 +329,30 @@ function delOrder (sn) { // 删除订单
});
}
function getList () {
const version = ++listRequestVersion;
queryFailed.value = false;
spinShow.value = true;
let _params = JSON.parse(JSON.stringify(params))
if (_params.orderStatus === 'ALL') {
delete _params.orderStatus
}
getOrderList(_params).then(res => {
spinShow.value = false
if (version !== listRequestVersion) return;
if (res.success) {
orderList.value = res.result.records;
total.value = res.result.total;
}
}).catch((error) => {
if (version !== listRequestVersion) {
catchHandledRequestError(error);
return;
}
queryFailed.value = true;
orderList.value = [];
total.value = 0;
catchHandledRequestError(error);
}).finally(() => {
if (version === listRequestVersion) spinShow.value = false;
});
}
function mapAfterSaleOrder (item) {

View File

@@ -5,7 +5,9 @@
<el-button class="del-btn" @click="clearAll" type="primary">删除全部</el-button>
</template>
<!-- 订单列表 -->
<empty v-if="list.length === 0" />
<el-skeleton v-if="spinShow" :rows="5" animated />
<el-empty v-else-if="queryFailed" description="加载失败,请重试"><el-button type="primary" @click="getList">重新加载</el-button></el-empty>
<empty v-else-if="list.length === 0" />
<ul class="track-list" v-else>
<li
v-for="(item, index) in list"
@@ -39,6 +41,7 @@
</template>
<script setup>
import { catchHandledRequestError } from "@/utils/requestError";
import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { Message, Modal } from "@/utils/message";
@@ -50,6 +53,8 @@ defineOptions({ name: "MyTrack" });
const router = useRouter();
const list = ref([]); // 我的足迹,商品列表
const queryFailed = ref(false); // 区分加载失败与暂无记录,支持重新查询
let listRequestVersion = 0; // 仅允许最后一次查询更新列表与加载状态
const spinShow = ref(false); // 控制loading是否加载
const params = reactive({
pageNumber: 1,
@@ -118,9 +123,11 @@ function changePageSize(val) {
function getList() {
// 获取足迹列表
const version = ++listRequestVersion;
queryFailed.value = false;
spinShow.value = true;
tracksList(params).then((res) => {
spinShow.value = false;
if (version !== listRequestVersion) return;
if (res.success && res.result.records.length) {
list.value = res.result.records.filter(item =>{
return item != null
@@ -129,6 +136,17 @@ function getList() {
} else {
list.value = [];
}
}).catch((error) => {
if (version !== listRequestVersion) {
catchHandledRequestError(error);
return;
}
queryFailed.value = true;
list.value = [];
total.value = 0;
catchHandledRequestError(error);
}).finally(() => {
if (version === listRequestVersion) spinShow.value = false;
});
}

View File

@@ -163,6 +163,7 @@ function getGoodsDetail() {
}
})
.catch(() => {
isLoading.value = false;
router.push("/");
});
}

View File

@@ -1,4 +1,5 @@
import axios from "axios";
import { markHandledRequestError, isHandledRequestError } from "@/utils/requestError";
import { ElLoading } from "element-plus";
import { Message, Modal } from "@/utils/message";
import Storage from "./storage";
@@ -14,7 +15,7 @@ export const buyerUrl =
? BASE.API_DEV.buyer
: BASE.API_PROD.buyer;
let isRefreshToken = 0;
let loginPrompt = null;
const refreshToken = getTokenDebounce();
/** 会员中心(/home/*)页面使用局部 loading,不启用全屏遮罩 */
@@ -37,20 +38,22 @@ const service = axios.create({
});
service.interceptors.request.use(
(config) => {
async (config) => {
const { loading } = config;
const isPutPost = config.method === "put" || config.method === "post";
const isJson = config.headers["Content-Type"] === "application/json";
const isFile = config.headers["Content-Type"] === "multipart/form-data";
if (isPutPost && isJson) {
if (!config._dataSerialized && isPutPost && isJson) {
config.data = JSON.stringify(config.data);
}
if (isPutPost && !isFile && !isJson) {
if (!config._dataSerialized && isPutPost && !isFile && !isJson) {
config.data = qs.stringify(config.data, {
arrayFormat: "repeat",
});
}
config._dataSerialized = true;
const method = (config.method || "get").toLowerCase();
const isReadRequest = method === "get" || method === "head";
const shouldShowLoading =
@@ -58,7 +61,7 @@ service.interceptors.request.use(
(!isReadRequest && loading !== false && !isMemberCenterRoute());
if (typeof window !== "undefined" && shouldShowLoading) {
config.loading = ElLoading.service({ fullscreen: true });
config._loadingInstance = ElLoading.service({ fullscreen: true });
}
let uuid = Storage.getItem("uuid");
@@ -82,7 +85,7 @@ service.interceptors.request.use(
const expired = jwtData.exp < Math.round(new Date() / 1000);
if (expired) {
if (config.needToken) {
return refresh({ response: { config } });
return refresh(config);
}
// 公开接口:丢弃过期 token,匿名继续请求,避免浏览商品时被引导登录
Storage.removeItem("accessToken");
@@ -94,7 +97,7 @@ service.interceptors.request.use(
Storage.removeItem("accessToken");
Storage.removeItem("refreshToken");
if (config.needToken) {
return refresh({ response: { config } });
return refresh(config);
}
}
}
@@ -104,59 +107,64 @@ service.interceptors.request.use(
(error) => Promise.reject(error)
);
/** 保留请求配置、状态与错误码,供页面判断业务失败并定位原因。 */
function buildRejectError(error, errorData = {}) {
const message =
errorData.message ||
(error?.code === "ECONNABORTED" ? "连接超时,请稍候再试!" : "网络错误,请稍后再试!");
return Object.assign(new Error(message), { data: errorData });
return Object.assign(new Error(message), { data: errorData, config: error.config, response: error.response, code: error.code });
}
async function refresh(error) {
const getTokenRes = await refreshToken();
if (getTokenRes === "success") {
if (error?.response?.config) {
error.response.config.headers.accessToken = Storage.getItem("accessToken");
return service(error.response.config);
/**
* 刷新凭证并返回请求配置;请求拦截器不能返回业务响应。每个业务请求最多刷新一次。
* 刷新失败时保持 Promise 拒绝,阻止后续支付、订单等成功回调继续执行。
*/
async function refresh(config) {
if (!config._retryAfterRefresh) {
config._retryAfterRefresh = true;
const result = await refreshToken();
if (result === "success") {
config.headers.accessToken = Storage.getItem("accessToken");
return config;
}
router.go(0);
return new Promise(() => {});
}
Storage.removeItem("accessToken");
Storage.removeItem("refreshToken");
Storage.removeItem("userInfo");
Storage.setItem("cartNum", 0);
useAppStore().setCartNum(0);
const currentRoute = router.currentRoute.value;
Modal.confirm({
title: "请登录",
content: "请登录后执行此操作",
okText: "立即登录",
cancelText: "继续浏览",
onOk: () => {
router.push({
// 多个受保护接口同时失败时共用一个弹窗,关闭后允许再次提示。
if (!loginPrompt) {
const currentRoute = router.currentRoute.value;
loginPrompt = Modal.confirm({
title: "请登录",
content: "请登录后执行此操作",
okText: "立即登录",
cancelText: "继续浏览",
onOk: () => router.push({
path: "/login",
query: {
rePath: currentRoute.path,
query: JSON.stringify(currentRoute.query),
},
});
},
onCancel: () => {
router.push("/");
Modal.remove();
},
});
return Promise.reject(new Error("登录已过期"));
query: { rePath: currentRoute.path, query: JSON.stringify(currentRoute.query) },
}),
onCancel: () => router.push("/"),
}).finally(() => { loginPrompt = null; });
}
const error = Object.assign(new Error("登录已过期"), { code: "AUTH_EXPIRED", config });
throw markHandledRequestError(error);
}
service.interceptors.response.use(
async (response) => {
await closeLoading(response);
const config = response.config;
if (!config.skipAuthHandler && [401, 403, 20004].includes(response.data?.code)) {
return service(await refresh(config));
}
return response.data;
},
async (error) => {
if (typeof window === "undefined") return Promise.reject(error);
await closeLoading(error);
if (isHandledRequestError(error)) return Promise.reject(error);
const errorResponse = error.response || {};
const errorData = errorResponse.data || {};
@@ -166,32 +174,26 @@ service.interceptors.response.use(
errorResponse.status === 403 ||
error.response?.data?.code === 20004)
) {
isRefreshToken++;
if (isRefreshToken === 1) {
isRefreshToken = 0;
return refresh(error);
}
return Promise.reject(buildRejectError(error, errorData));
return service(await refresh(error.config || errorResponse.config));
}
if (errorResponse.status === 404) {
return Promise.reject(buildRejectError(error, { message: "请求的资源不存在" }));
if (!axios.isAxiosError(error) || axios.isCancel(error)) return Promise.reject(error);
const rejected = buildRejectError(error, errorResponse.status === 404
? { ...errorData, message: "请求的资源不存在" }
: errorData);
if (!error.config?.skipMessage && axios.isAxiosError(error) && !axios.isCancel(error)) {
Message.error(rejected.message);
markHandledRequestError(rejected);
}
if (error.message && !error.config?.skipMessage) {
const _message =
error.code === "ECONNABORTED"
? "连接超时,请稍候再试!"
: "网络错误,请稍后再试!";
Message.error(errorData.message || _message);
}
return Promise.reject(buildRejectError(error, errorData));
return Promise.reject(rejected);
}
);
const closeLoading = (target) => {
if (!target.config || !target.config.loading) return true;
if (typeof target.config?._loadingInstance?.close !== "function") return true;
return new Promise((resolve) => {
setTimeout(() => {
target.config.loading.close();
target.config._loadingInstance.close();
delete target.config._loadingInstance;
resolve();
}, 200);
});
@@ -208,42 +210,22 @@ export default function request(options) {
return service(options);
}
/** 并发刷新共用同一个 Promise;不轮询,不保留上次刷新结果,失败后允许下次重试。 */
function getTokenDebounce() {
let lock = false;
let success = false;
let pending = null;
return function () {
if (!lock) {
const oldRefreshToken = Storage.getItem("refreshToken");
// 无 refreshToken(未登录)时不发起刷新请求,直接判失败,避免请求 /refresh/undefined 造成拦截器死锁
if (!oldRefreshToken) {
return Promise.resolve("fail");
}
lock = true;
handleRefreshToken(oldRefreshToken)
.then((res) => {
if (res.success) {
const { accessToken, refreshToken: newRefreshToken } = res.result;
Storage.setItem("accessToken", accessToken);
Storage.setItem("refreshToken", newRefreshToken);
success = true;
lock = false;
} else {
success = false;
lock = false;
}
})
.catch(() => {
success = false;
lock = false;
});
}
return new Promise((resolve) => {
const timer = setInterval(() => {
if (!lock) {
clearInterval(timer);
resolve(success ? "success" : "fail");
}
}, 500);
});
if (pending) return pending;
const token = Storage.getItem("refreshToken");
if (!token) return Promise.resolve("fail");
pending = handleRefreshToken(token)
.then((res) => {
if (!res.success || !res.result?.accessToken || !res.result?.refreshToken) return "fail";
Storage.setItem("accessToken", res.result.accessToken);
Storage.setItem("refreshToken", res.result.refreshToken);
return "success";
})
.catch(() => "fail")
.finally(() => { pending = null; });
return pending;
};
}

View File

@@ -0,0 +1,40 @@
/**
* 已向用户提示的请求异常兜底;只识别本模块登记的错误,不屏蔽代码异常。
* @author Bulbasaur
* @since 2026-11-09
*/
const handledErrors = new WeakSet();
/** 登记已由请求层提示的错误,仍保持原请求的拒绝语义。 */
export function markHandledRequestError(error) {
handledErrors.add(error);
// 开发服务器的错误遮罩有独立监听器,通过同一 Symbol 识别已提示的异常。
Object.defineProperty(error, Symbol.for("lilishop.buyer.handledRequestError"), { value: true });
return error;
}
/** 判断错误是否已由请求层向用户展示。 */
export function isHandledRequestError(error) {
return !!error && typeof error === "object" && handledErrors.has(error);
}
/** 页面处理预期请求失败;程序错误继续向上抛出,避免被静默吞掉。 */
export function catchHandledRequestError(error) {
if (!isHandledRequestError(error)) throw error;
}
/**
* 覆盖未返回 Promise 的事件回调及 Vue 异步钩子;只有已提示的请求异常免于重复报错。
* 未登记异常沿用已有 Vue 错误处理,无处理器时重新抛出供开发环境定位。
*/
export function setupRequestErrorHandling(app, target = window) {
target.addEventListener("unhandledrejection", (event) => {
if (isHandledRequestError(event.reason)) event.preventDefault();
});
const previous = app.config.errorHandler;
app.config.errorHandler = (error, instance, info) => {
if (isHandledRequestError(error)) return;
if (previous) return previous(error, instance, info);
throw error;
};
}