config.js(新增)作为后端域名/接口地址的唯一来源。此前 request.js 与 App.vue 各硬编码一份生产域名,改域名要改两处,漏一处就会出现「接口通了但 OTA 检查 更新接不上」这类极难排查的偏差。 request.js 改动: - PROD_URL / LOCAL_URL 改为从 ./config 引入,不再就地硬编码 - 新增 options.silent:调用方自行兜底提示时跳过通用错误 toast。 用于 feedback 的 404 回退 mock —— 否则用户会先看到「请求失败 (404)」 再看到「感谢反馈」,两句自相矛盾。HTTP 错误与 fail 两个分支都受其控制 - fail 回调保留原始 errMsg 并打上 isNetworkError / isTimeout 标记: fail 表示压根没拿到后端响应(超时/断网),与 4xx/5xx 有本质区别 —— 后端可能已执行成功只是响应没回来,上层需要据此打断用户的连点重试
213 lines
8.7 KiB
JavaScript
213 lines
8.7 KiB
JavaScript
/**
|
||
* uni.request 封装 — 双 Token 无感刷新 + 并发请求队列
|
||
*
|
||
* 网络环境选择(三级优先级):
|
||
* 1. env_base_url 缓存(App 内运行时动态切换,最高优先级)
|
||
* 2. process.env.NODE_ENV 自动判断(development → 本地 IP / production → 公网域名)
|
||
* 3. 生产默认 PROD_URL
|
||
*/
|
||
|
||
// 域名/环境地址统一来自 utils/config.js,勿在此处硬编码
|
||
import { PROD_URL, LOCAL_URL } from "./config";
|
||
|
||
// ============================================================
|
||
// 环境 URL(自动感知:缓存配置 > NODE_ENV > 生产默认)
|
||
// ============================================================
|
||
|
||
export function getBaseUrl() {
|
||
// 1. 最高优先级:动态切换的缓存配置(如通过 App 内隐藏菜单切换)
|
||
const envUrl = uni.getStorageSync("env_base_url");
|
||
if (envUrl) {
|
||
return envUrl;
|
||
}
|
||
|
||
// 2. 自动环境判断
|
||
if (process.env.NODE_ENV === 'development') {
|
||
// 本地运行/真机调试时,自动使用本地开发机 IP
|
||
return LOCAL_URL;
|
||
}
|
||
|
||
// 3. 生产打包发行时,自动使用公网域名
|
||
return PROD_URL;
|
||
}
|
||
|
||
// ============================================================
|
||
// 双 Token 存储
|
||
// ============================================================
|
||
|
||
function getAccessToken() { return uni.getStorageSync("access_token") || ""; }
|
||
function getRefreshToken() { return uni.getStorageSync("refresh_token") || ""; }
|
||
function setTokens(accessToken, refreshToken) {
|
||
uni.setStorageSync("access_token", accessToken);
|
||
uni.setStorageSync("refresh_token", refreshToken);
|
||
}
|
||
function clearTokens() {
|
||
uni.removeStorageSync("access_token");
|
||
uni.removeStorageSync("refresh_token");
|
||
uni.removeStorageSync("token");
|
||
uni.removeStorageSync("user");
|
||
}
|
||
|
||
// ============================================================
|
||
// 并发请求队列
|
||
// ============================================================
|
||
|
||
let isRefreshing = false;
|
||
let retryQueue = [];
|
||
|
||
function processQueue(error, newToken) {
|
||
retryQueue.forEach((p) => {
|
||
if (newToken) p.resolve(newToken);
|
||
else p.reject(error);
|
||
});
|
||
retryQueue = [];
|
||
}
|
||
|
||
async function refreshAccessToken() {
|
||
const refreshToken = getRefreshToken();
|
||
if (!refreshToken) throw new Error("无 Refresh Token");
|
||
return new Promise((resolve, reject) => {
|
||
uni.request({
|
||
url: getBaseUrl() + "/auth/refresh",
|
||
method: "POST",
|
||
data: { refresh_token: refreshToken },
|
||
header: { "Content-Type": "application/json" },
|
||
timeout: 10000,
|
||
success(res) {
|
||
if (res.statusCode >= 200 && res.statusCode < 300) resolve(res.data);
|
||
else reject(res);
|
||
},
|
||
fail(err) { reject(err); },
|
||
});
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// 错误文案提取 — 把后端 detail 统一转成能读的中文,杜绝 [object Object]
|
||
// ============================================================
|
||
//
|
||
// FastAPI 的 detail 有三种形态,直接丢给 showToast 会变成 [object Object]:
|
||
// 1. 字符串: {"detail": "任务状态为 COMPLETED,无法驳回"}
|
||
// 2. 校验错误数组(422): {"detail": [{"loc": ["body","images"], "msg": "Value error, 驳回必须...", "type": "value_error"}]}
|
||
// 3. 对象: {"detail": {"msg": "..."}}
|
||
export function extractErrorDetail(data, fallback = "") {
|
||
const detail = data && data.detail;
|
||
if (!detail) return (data && data.msg) || fallback;
|
||
if (typeof detail === "string") return detail || fallback;
|
||
|
||
if (Array.isArray(detail)) {
|
||
const msgs = detail.map((item) => {
|
||
if (typeof item === "string") return item;
|
||
if (!item || typeof item !== "object") return "";
|
||
// Pydantic v2 自定义校验器抛的错带 "Value error, " 前缀,读起来噪音大,去掉
|
||
const msg = String(item.msg || item.message || "").replace(/^Value error,\s*/, "");
|
||
const field = Array.isArray(item.loc)
|
||
? item.loc.filter((p) => !["body", "query", "path"].includes(p)).join(".")
|
||
: "";
|
||
return field && msg ? `${field}: ${msg}` : msg;
|
||
}).filter(Boolean);
|
||
return msgs.length ? msgs.join(";") : fallback;
|
||
}
|
||
|
||
if (typeof detail === "object") {
|
||
return detail.msg || detail.message || JSON.stringify(detail);
|
||
}
|
||
return String(detail) || fallback;
|
||
}
|
||
|
||
export default function request(options) {
|
||
return new Promise((resolve, reject) => {
|
||
const url = options.url.startsWith("http") ? options.url : getBaseUrl() + options.url;
|
||
const accessToken = getAccessToken();
|
||
|
||
uni.request({
|
||
url,
|
||
method: options.method || "GET",
|
||
data: options.data || {},
|
||
header: {
|
||
"Content-Type": "application/json",
|
||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||
...(options.header || {}),
|
||
},
|
||
timeout: 15000,
|
||
success(res) {
|
||
const code = res.statusCode;
|
||
if (code >= 200 && code < 300) { resolve(res.data); return; }
|
||
|
||
if (code === 401) {
|
||
if (url.includes("/auth/refresh") || url.includes("/auth/login")) { reject(res); return; }
|
||
|
||
if (isRefreshing) {
|
||
retryQueue.push({
|
||
resolve: (newToken) => {
|
||
options.header = options.header || {};
|
||
options.header.Authorization = `Bearer ${newToken}`;
|
||
request(options).then(resolve).catch(reject);
|
||
},
|
||
reject: (err) => reject(err),
|
||
});
|
||
return;
|
||
}
|
||
|
||
isRefreshing = true;
|
||
refreshAccessToken()
|
||
.then((data) => {
|
||
setTokens(data.access_token, getRefreshToken());
|
||
processQueue(null, data.access_token);
|
||
options.header = options.header || {};
|
||
options.header.Authorization = `Bearer ${data.access_token}`;
|
||
request(options).then(resolve).catch(reject);
|
||
})
|
||
.catch(() => {
|
||
console.error("[Request] Refresh Token 也过期,清除登录态");
|
||
processQueue(new Error("refresh_failed"), null);
|
||
clearTokens();
|
||
uni.showToast({ title: "登录已过期,请重新登录", icon: "none" });
|
||
setTimeout(() => uni.reLaunch({ url: "/pages/login/login" }), 1000);
|
||
reject(res);
|
||
})
|
||
.finally(() => { isRefreshing = false; });
|
||
return;
|
||
}
|
||
|
||
// silent:调用方自行兜底提示时(如 feedback 的 404 回退 mock)跳过通用 toast,
|
||
// 否则用户会先看到一句「请求失败 (404)」,再看到「感谢反馈」,自相矛盾。
|
||
if (!options.silent) {
|
||
if (code === 403) uni.showToast({ title: extractErrorDetail(res.data, "无权操作"), icon: "none", duration: 3000 });
|
||
else if (code === 400) uni.showToast({ title: extractErrorDetail(res.data, "请求参数有误"), icon: "none", duration: 2500 });
|
||
else if (code === 409) uni.showToast({ title: extractErrorDetail(res.data, "操作冲突"), icon: "none", duration: 3000 });
|
||
else if (code === 422) {
|
||
// 表单/参数校验失败(Pydantic)—— detail 是校验项数组,必须解析出 msg 再提示
|
||
uni.showToast({ title: extractErrorDetail(res.data, "提交内容不符合要求"), icon: "none", duration: 3000 });
|
||
}
|
||
else uni.showToast({ title: extractErrorDetail(res.data, `请求失败 (${code})`), icon: "none", duration: 3000 });
|
||
}
|
||
reject(res);
|
||
},
|
||
fail(err) {
|
||
// ⚠️ fail = 压根没拿到后端响应(超时/断网),与 4xx/5xx 有本质区别:
|
||
// 后端可能已经执行成功,只是响应没回来。故保留原始 errMsg 并打上标记,
|
||
// 让上层能识别出「网络级失败」从而打断用户的连点重试。
|
||
const errMsg = (err && (err.errMsg || err.message)) || "";
|
||
const isTimeout = /timeout/i.test(errMsg);
|
||
if (!options.silent) {
|
||
uni.showToast({ title: isTimeout ? "网络超时,请稍后重试" : "网络连接失败", icon: "none" });
|
||
}
|
||
const e = new Error(errMsg || "network");
|
||
e.errMsg = errMsg;
|
||
e.isNetworkError = true;
|
||
e.isTimeout = isTimeout;
|
||
reject(e);
|
||
},
|
||
});
|
||
});
|
||
}
|
||
|
||
export function get(url, params = {}) {
|
||
const query = Object.entries(params).filter(([, v]) => v != null && v !== "").map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
|
||
return request({ url: query ? `${url}?${query}` : url, method: "GET" });
|
||
}
|
||
export function post(url, data = {}) { return request({ url, method: "POST", data }); }
|
||
export function patch(url, data = {}) { return request({ url, method: "PATCH", data }); }
|
||
export function put(url, data = {}) { return request({ url, method: "PUT", data }); }
|