ow
This commit is contained in:
1 parent
6a8258ac4b
commit
a7b25d49c4
29 files changed
+1960
-649
No files matched your search
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 全局错误边界:兜住"没人处理的运行时错误与 Promise 拒绝"
|
||||
*
|
||||
* 为什么必须有:uni-app 没有 vue-router 的 errorHandler,页面里一个未 catch 的
|
||||
* promise 只会在控制台留一行日志,用户看到的是"点了没反应"或白屏。
|
||||
* 本模块负责把这类异常变成:控制台一定有记录 + 用户按需得到一次轻提示。
|
||||
*
|
||||
* 三条纪律(详见 src/md/error-handling.md):
|
||||
* 1) 请求层已经提示过的错误不再打扰用户——用 markErrorNotified 打标记,这里认标记;
|
||||
* 2) 同一文案节流去重,避免循环里报错把屏幕刷满弹窗;
|
||||
* 3) 只记录与提示,绝不吞掉错误:该抛的仍然抛,防止"边界把故障藏起来"。
|
||||
*
|
||||
* 平台差异(2026-10-13 用 H5 真跑一遍测出来的,不是抄文档):
|
||||
* uni-h5 的 ON_ERROR 只在 Vue 的 app.config.errorHandler 里派发,组件同步错误才走得到
|
||||
* App.onError;setTimeout / 事件回调里的异常够不着它,而 onUnhandledRejection 在 uni-h5
|
||||
* 全文没有任何派发点(搜 unhandledrejection 命中 0)。所以 H5 必须自己挂 window 监听兜底,
|
||||
* 否则"全局错误边界"只是看着挂了、实际从不触发。口径见 src/md/error-handling.md。
|
||||
*/
|
||||
import { onError, onUnhandledRejection } from "@dcloudio/uni-app";
|
||||
|
||||
/** 已在别处提示过的标记键:请求层弹过 toast/modal 或已跳错误页时置位 */
|
||||
const NOTIFIED_MARK = "__youjialaErrorNotified";
|
||||
|
||||
/** 同类文案的重复提示间隔(毫秒) */
|
||||
const THROTTLE_MS = 3000;
|
||||
|
||||
/** 记录表容量上限,超过即清理过期项,防止长时间运行后无限增长 */
|
||||
const TRACK_MAX = 64;
|
||||
|
||||
/** 上一次提示的时间戳,按文案分桶 */
|
||||
const lastShownAt = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* 标记"这个错误已经提示过用户",全局边界收到它就只记日志。
|
||||
* 供请求层调用:它已经弹过 modal / 已经跳了 401、403 页,再提示一次就是打扰。
|
||||
* 原样返回入参,方便在 `throw markErrorNotified(err)` / `reject(markErrorNotified(err))` 处内联。
|
||||
*/
|
||||
export function markErrorNotified<T>(error: T): T {
|
||||
if (error && (typeof error === "object" || typeof error === "function")) {
|
||||
(error as Record<string, unknown>)[NOTIFIED_MARK] = true;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
/** 是否已被别处提示过 */
|
||||
function isNotified(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") {
|
||||
return false;
|
||||
}
|
||||
return (error as Record<string, unknown>)[NOTIFIED_MARK] === true;
|
||||
}
|
||||
|
||||
/** 从错误对象里依次尝试取值可读文案的字段名 */
|
||||
const MESSAGE_KEYS = [
|
||||
"message",
|
||||
"errMsg",
|
||||
"errorMessage",
|
||||
"msg",
|
||||
"Message",
|
||||
// 兜底:万一有调用方把整个事件对象丢进来(window / uni 的拒绝事件都带 reason)
|
||||
"reason",
|
||||
] as const;
|
||||
|
||||
/** 从任意形态的错误里抠出一行可读文案(Error / 字符串 / uni 回调 / 后端包装) */
|
||||
function describe(error: unknown, depth = 0): string {
|
||||
if (!error) {
|
||||
return "";
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
// 非对象、或已经在往里挖第二层:直接字符串化(Error 会得到 "Error: 文案")
|
||||
if (typeof error !== "object" || depth > 1) {
|
||||
return String(error);
|
||||
}
|
||||
const raw = error as Record<string, unknown>;
|
||||
for (const key of MESSAGE_KEYS) {
|
||||
const value = raw[key];
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
// 字段自己是个错误对象(window / uni 的 reason 常是 Error):只再挖一层,防止成环
|
||||
if (depth === 0 && value && typeof value === "object") {
|
||||
const nested = describe(value, 1);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
/** 同一文案在 THROTTLE_MS 内只允许提示一次 */
|
||||
function shouldNotify(text: string): boolean {
|
||||
const now = Date.now();
|
||||
const previous = lastShownAt.get(text) ?? 0;
|
||||
if (now - previous < THROTTLE_MS) {
|
||||
return false;
|
||||
}
|
||||
if (lastShownAt.size >= TRACK_MAX) {
|
||||
lastShownAt.forEach((stamped, key) => {
|
||||
if (now - stamped > THROTTLE_MS * 10) {
|
||||
lastShownAt.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
lastShownAt.set(text, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 错误来源:运行时异常 / 未处理的 Promise 拒绝 */
|
||||
export type GlobalErrorSource = "error" | "rejection";
|
||||
|
||||
/** 开发期弹窗最长占用时间:过了这个点就允许下一条弹窗顶上 */
|
||||
const MODAL_MAX_HOLD_MS = 5000;
|
||||
|
||||
/** 下一条开发期弹窗可以出现的最早时刻(Date.now() 基准) */
|
||||
let modalFreeAt = 0;
|
||||
|
||||
/**
|
||||
* 开发期弹窗:uni 的 showModal 在 H5 是单例,后一次调用会直接改写前一次的标题与正文,
|
||||
* 连着来两条错误时用户只会看到最后一条。这里按时间槽排队,让每条都被看得见;
|
||||
* 到点没确认也放行(最多晚 MODAL_MAX_HOLD_MS 秒),不让弹窗把后续错误堵死。
|
||||
*/
|
||||
function showDevModal(title: string, content: string): void {
|
||||
const now = Date.now();
|
||||
const wait = Math.max(0, modalFreeAt - now);
|
||||
modalFreeAt = now + wait + MODAL_MAX_HOLD_MS;
|
||||
setTimeout(() => {
|
||||
uni.showModal({
|
||||
title,
|
||||
content,
|
||||
showCancel: false,
|
||||
});
|
||||
}, wait);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一入口:记日志 -> 判断要不要提示。
|
||||
* source 只用于日志前缀与开发期弹窗标题,生产提示里不带它:用户不需要内部术语。
|
||||
*/
|
||||
export function reportGlobalError(
|
||||
error: unknown,
|
||||
source: GlobalErrorSource,
|
||||
): void {
|
||||
const text = describe(error);
|
||||
// 全局边界的职责就是留痕:console.error 在 no-console 配置里本就放行,无需 disable 注释
|
||||
console.error(`[global:${source}]`, error);
|
||||
if (isNotified(error)) {
|
||||
return;
|
||||
}
|
||||
if (!text || !shouldNotify(text)) {
|
||||
return;
|
||||
}
|
||||
// 开发期把详情摊开:这时"看到了但没提示"和"确实没报错"必须能一眼区分
|
||||
if (import.meta.env.DEV) {
|
||||
showDevModal(`未捕获错误(${source})`, text.slice(0, 300));
|
||||
return;
|
||||
}
|
||||
// 生产期只给一次轻提示:详情已进 console,用户不需要看堆栈
|
||||
uni.showToast({
|
||||
title: "操作未成功,请稍后重试",
|
||||
icon: "none",
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
|
||||
/** window 兜底只挂一次:H5 的 HMR / 多次挂载不该叠出多份监听 */
|
||||
let windowFallbackInstalled = false;
|
||||
|
||||
/** 只有浏览器环境才有 window:小程序与 App 的逻辑层拿不到,返回 undefined 即跳过 */
|
||||
function browserWindow(): Window | undefined {
|
||||
if (typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
return typeof window.addEventListener === "function" ? window : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂载全局错误边界,在 App.vue 的 setup 顶层调用一次(钩子必须注册在 App 实例上,
|
||||
* 放进 onLaunch 回调里就离开了 setup 上下文)。
|
||||
*
|
||||
* 为什么要额外挂 window:uni-h5 的 ON_ERROR 只由 Vue 的 errorHandler 触发,
|
||||
* onUnhandledRejection 在 uni-h5 里根本没有派发点(实测:搜 unhandledrejection 命中 0),
|
||||
* 所以 setTimeout / 事件回调里的异常与所有 Promise 拒绝在 H5 上都到不了 uni 钩子,
|
||||
* 必须由 window 兜底;小程序 / App 端由各自运行时派发 uni 钩子,这段自动跳过。
|
||||
*/
|
||||
export function setupGlobalErrorBoundary(): void {
|
||||
onError((error) => {
|
||||
reportGlobalError(error, "error");
|
||||
});
|
||||
onUnhandledRejection((event) => {
|
||||
reportGlobalError(event?.reason, "rejection");
|
||||
});
|
||||
|
||||
const target = browserWindow();
|
||||
if (!target || windowFallbackInstalled) {
|
||||
return;
|
||||
}
|
||||
windowFallbackInstalled = true;
|
||||
target.addEventListener("error", (event) => {
|
||||
// 资源加载失败(img/script 404)时 error 为 null,只有 message:照常留痕
|
||||
reportGlobalError(event.error ?? event.message, "error");
|
||||
});
|
||||
target.addEventListener("unhandledrejection", (event) => {
|
||||
reportGlobalError(event.reason, "rejection");
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from "uview-pro";
|
||||
import type { JwtInfo } from "@/data/auto/jwtInfo";
|
||||
import { http } from "uview-pro";
|
||||
import { markErrorNotified } from "@/common/global-error";
|
||||
import { toBackendLocale } from "@/common/locale-const";
|
||||
import { WebSitePolicy } from "@/policy/custom/webSitePolicy";
|
||||
import {
|
||||
@@ -28,10 +29,11 @@ import {
|
||||
|
||||
// ==================== 全局配置 ====================
|
||||
|
||||
// 后端地址唯一来源:policy/custom/webSitePolicy(代码生成器维护,改地址只动那里)
|
||||
// 后端地址唯一来源:policy/custom/webSitePolicy(值可被项目根 .env 覆盖,缺失时回落该文件常量)
|
||||
// 与旧系统 WebVue 同逻辑:开发构建用 WebApiRootDev,发布构建用 WebApiRoot
|
||||
// App 端开发构建(真机/模拟器)连不上宿主机的 localhost,改用 WebApiRootDevApp
|
||||
// 小程序端需在平台配置 https 合法域名;本地 H5 联调如遇 CORS 需后端放行开发端口
|
||||
// 换环境只改 .env(详见 src/md/environment.md),本文件不允许再写死地址
|
||||
let devBaseUrl = WebSitePolicy.WebApiRootDev;
|
||||
// #ifdef APP-PLUS
|
||||
devBaseUrl = WebSitePolicy.WebApiRootDevApp;
|
||||
@@ -187,6 +189,18 @@ function notifyError(detail: string, fallback = "请求失败") {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 抛出请求层错误
|
||||
* @param message 供调用方与日志使用的错误文案
|
||||
* @param handled 为 true 表示这一处已经提示过用户(toast / modal / 跳错误页),
|
||||
* 打上标记后全局边界只记日志、不再重复打扰;false 时原样抛出,
|
||||
* 把兜底提示留给调用方(自己 catch 后处理)或全局边界
|
||||
*/
|
||||
function throwRequestError(message: string, handled: boolean): never {
|
||||
const err = new Error(message);
|
||||
throw handled ? markErrorNotified(err) : err;
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务异常识别:HTTP 成功但 body 是 .NET 异常对象时命中
|
||||
* @returns 命中返回异常消息(调用方需抛出),未命中返回空串
|
||||
@@ -261,11 +275,11 @@ const httpInterceptor: RequestInterceptor = {
|
||||
// 网络层错误
|
||||
if (errMsg && errMsg.includes("Failed to connect")) {
|
||||
meta.toast && notifyError("", "网络错误");
|
||||
throw new Error("网络错误");
|
||||
throwRequestError("网络错误", !!meta.toast);
|
||||
}
|
||||
if (errMsg && errMsg.includes("request:fail")) {
|
||||
meta.toast && notifyError("", "请求错误:未知");
|
||||
throw new Error("请求错误:未知");
|
||||
throwRequestError("请求错误:未知", !!meta.toast);
|
||||
}
|
||||
|
||||
// 401 且未重试过:共用刷新 + 重试
|
||||
@@ -273,7 +287,8 @@ const httpInterceptor: RequestInterceptor = {
|
||||
if (!getToken()) {
|
||||
// 本来就没有登录态,直接去登录页
|
||||
goLogin(true);
|
||||
throw new Error("登录已失效,请重新登录");
|
||||
// 页面已经跳到登录页,算已告知用户,边界不再弹提示
|
||||
throwRequestError("登录已失效,请重新登录", true);
|
||||
}
|
||||
// _retry 标记:重试请求再 401 时落入下方非 2xx 分支,防止无限递归
|
||||
config._retry = true;
|
||||
@@ -284,7 +299,8 @@ const httpInterceptor: RequestInterceptor = {
|
||||
}
|
||||
catch (refreshError: any) {
|
||||
goLogin(!!(refreshError && refreshError.status));
|
||||
throw refreshError;
|
||||
// 已经跳回登录页,保留原错误对象(status 供调用方分支)只补标记
|
||||
throw markErrorNotified(refreshError);
|
||||
}
|
||||
// ② 重试放在 try 之外:它自身的失败(500/403 等)只算这次业务请求失败,
|
||||
// 不能当成「刷新失败」,否则会误清已成功续期用户的登录态
|
||||
@@ -298,25 +314,30 @@ const httpInterceptor: RequestInterceptor = {
|
||||
// 请求错误(非 2xx)
|
||||
if (!(statusCode >= 200 && statusCode < 300)) {
|
||||
// 403:权限不足,跳 403 页列出原因(401 走上面的续期 / 回登录页链路,不进错误页)
|
||||
let handled = false;
|
||||
if (statusCode === 403) {
|
||||
goForbidden();
|
||||
handled = true; // 已跳 403 页并列出原因,边界不再重复提示
|
||||
}
|
||||
// 异常体优先:.NET 后端常以 500 + JSON 异常返回业务错误
|
||||
const bizMsg = detectBusinessException(rawData);
|
||||
if (bizMsg)
|
||||
throw new Error(bizMsg);
|
||||
throw markErrorNotified(new Error(bizMsg)); // detectBusinessException 内部已提示
|
||||
const errorMessage = `请求错误[${statusCode}]`;
|
||||
meta.toast && notifyError(extractErrorMessage(rawData), errorMessage);
|
||||
const err: any = new Error(`${errorMessage}:${errMsg ?? ""}`);
|
||||
err.status = statusCode; // 结构化状态码,供调用方按 401/403/500 分支处理
|
||||
err.data = rawData;
|
||||
if (handled || meta.toast) {
|
||||
markErrorNotified(err);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 2xx 但 body 是业务异常
|
||||
const bizMsg = detectBusinessException(rawData);
|
||||
if (bizMsg)
|
||||
throw new Error(bizMsg);
|
||||
throw markErrorNotified(new Error(bizMsg));
|
||||
|
||||
// 成功:剥壳返回 JSON body(service/* 经 utils/request 直接拿到后端实体对象)
|
||||
return rawData;
|
||||
@@ -351,8 +372,9 @@ export function uploadFile(options: UploadOptions): Promise<any> {
|
||||
header: token ? { Authorization: token, ...header } : header,
|
||||
success: (res) => {
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
notifyError("", `上传失败[${res.statusCode}]`);
|
||||
reject(new Error(`上传失败[${res.statusCode}]`));
|
||||
const message = `上传失败[${res.statusCode}]`;
|
||||
notifyError("", message);
|
||||
reject(markErrorNotified(new Error(message)));
|
||||
return;
|
||||
}
|
||||
let data: any = res.data;
|
||||
@@ -364,12 +386,12 @@ export function uploadFile(options: UploadOptions): Promise<any> {
|
||||
}
|
||||
const bizMsg = detectBusinessException(data);
|
||||
if (bizMsg)
|
||||
reject(new Error(bizMsg));
|
||||
reject(markErrorNotified(new Error(bizMsg)));
|
||||
else resolve(data);
|
||||
},
|
||||
fail: (err: any) => {
|
||||
notifyError(err.errMsg ?? "", "上传失败");
|
||||
reject(err);
|
||||
reject(markErrorNotified(err));
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -460,11 +482,12 @@ export async function downloadFile(options: DownloadOptions): Promise<void> {
|
||||
/* 解析失败沿用默认文案 */
|
||||
}
|
||||
notifyError(msg, "下载失败");
|
||||
throw new Error(msg);
|
||||
throw markErrorNotified(new Error(msg));
|
||||
}
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
notifyError("", `下载失败[${res.statusCode}]`);
|
||||
throw new Error(`下载失败[${res.statusCode}]`);
|
||||
const message = `下载失败[${res.statusCode}]`;
|
||||
notifyError("", message);
|
||||
throw markErrorNotified(new Error(message));
|
||||
}
|
||||
|
||||
const fileName = `${filename}${timestamp()}.${extension}`;
|
||||
|
||||
Reference in new issue
Block a user