This commit is contained in:
owenchen committed 2026-10-13 10:47:39 +08:00
1 parent 6a8258ac4b
commit a7b25d49c4
29 files changed
+1960 -649

No files matched your search

+208
View File
@@ -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");
});
}
+37 -14
View File
@@ -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}`;