Files
track/track-uniapp/src/utils/request.js
duxingchen 242a6d5463 refactor: 环境地址收口到 utils/config.js,请求层新增 silent 静默模式
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 有本质区别 ——
  后端可能已执行成功只是响应没回来,上层需要据此打断用户的连点重试
2026-09-15 15:51:30 +08:00

213 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 }); }