diff --git a/track-uniapp/src/App.vue b/track-uniapp/src/App.vue
index 087aad5..2c44098 100644
--- a/track-uniapp/src/App.vue
+++ b/track-uniapp/src/App.vue
@@ -1,21 +1,53 @@
-
diff --git a/track-uniapp/src/pages/notify/index.vue b/track-uniapp/src/pages/notify/index.vue
index 1a07b8d..55f691b 100644
--- a/track-uniapp/src/pages/notify/index.vue
+++ b/track-uniapp/src/pages/notify/index.vue
@@ -4,22 +4,150 @@
消息通知
任务流转和系统通知
-
+
+
+ 加载中...
+
+
+
🔔
暂无新消息
+
+
+
+
+
+
+
+ {{ typeIcon(item.type) }}
+
+
+
+
+
+ {{ typeTitle(item.type) }}
+
+ {{ formatTime(item.created_at) }}
+
+ {{ item.content }}
+
+ ›
+
+
diff --git a/track-uniapp/src/pages/profile/index.vue b/track-uniapp/src/pages/profile/index.vue
index 68faa82..9e9d23b 100644
--- a/track-uniapp/src/pages/profile/index.vue
+++ b/track-uniapp/src/pages/profile/index.vue
@@ -1,69 +1,48 @@
-
- 张
+ {{ initial }}
- 张三
- 操作员
+ {{ user?.display_name || '未登录' }}
+ {{ user?.role === 'admin' ? '管理员' : '操作员' }}
- ›
-
diff --git a/track-uniapp/src/pages/scan/components/TreeCanvas.vue b/track-uniapp/src/pages/scan/components/TreeCanvas.vue
index 79c5e91..cd7da7b 100644
--- a/track-uniapp/src/pages/scan/components/TreeCanvas.vue
+++ b/track-uniapp/src/pages/scan/components/TreeCanvas.vue
@@ -1,41 +1,59 @@
-
+
+
+
+
+ ← 返回
+
+ 🌳 流转蓝图
+ 双指缩放 · 单指拖拽
+
+
+
+ 📇 卡片
+ -
+ {{ Math.round(mvScale * 100) }}%
+ +
+ ⊡ 全览
+
+
+
📋
该产品暂无流转记录
- 🖐 2D 流转蓝图:支持全视角自由拖拽探索
-
-
负责人: {{ node.assignee_id || '—' }}
⚠ 返工工序
+ 📦 已入库
@@ -52,7 +70,24 @@ export default {
props: {
product: { type: Object, required: true }
},
- emits: ["viewRecords"],
+ emits: ["viewRecords", "back", "swipe"],
+ data() {
+ return {
+ mvX: 0,
+ mvY: 0,
+ mvScale: 1,
+ viewportW: 375,
+ viewportH: 600,
+ };
+ },
+ mounted() {
+ try {
+ const info = uni.getSystemInfoSync();
+ this.viewportW = info.windowWidth || 375;
+ this.viewportH = info.windowHeight || 600;
+ } catch (e) { /* ignore */ }
+ this.$nextTick(() => { this.fitAll(); });
+ },
computed: {
treeNodes() {
if (!this.product || !this.product.task_tree) return [];
@@ -62,12 +97,23 @@ export default {
const rowMaxX = {};
let currentMaxYIdx = -1;
+ // 🚀 深拷贝:防止 Vue props readonly Proxy 异常导致白屏!
+ const cloneTree = (tasks) => {
+ if (!tasks) return [];
+ return tasks.map(t => ({
+ ...t,
+ child_tasks: cloneTree(t.child_tasks),
+ records: t.records ? [...t.records] : []
+ }));
+ };
+ const workTree = cloneTree(this.product.task_tree);
+
const flatMap = {};
const flatten = (tasks) => {
if (!tasks) return;
tasks.forEach(t => { flatMap[t.id] = t; flatten(t.child_tasks); });
};
- flatten(this.product.task_tree);
+ flatten(workTree);
const layoutNode = (t) => {
if (t._visited) return;
@@ -85,7 +131,8 @@ export default {
const type = t.task_type || (parent && parent.status === 'CANCELED' ? 'RECOVERY' : 'SPAWN');
if (type === 'SPAWN') {
- t._yIdx = parent._yIdx;
+ // 🚀 增加 parent 存在性防御
+ t._yIdx = parent ? parent._yIdx : 0;
t._xIdx = (rowMaxX[t._yIdx] !== undefined ? rowMaxX[t._yIdx] : 0) + 1;
} else if (type === 'TRANSFER' || type === 'MAIN') {
currentMaxYIdx++;
@@ -94,7 +141,8 @@ export default {
} else if (type === 'RECOVERY') {
currentMaxYIdx++;
t._yIdx = currentMaxYIdx;
- t._xIdx = parent._xIdx;
+ // 🚀 增加 parent 存在性防御
+ t._xIdx = parent ? parent._xIdx : 0;
}
}
@@ -104,23 +152,49 @@ export default {
nodes.push(t);
if (t.child_tasks && t.child_tasks.length) {
- t.child_tasks
- .sort((a, b) => (a.task_type === 'TRANSFER' ? -1 : 1))
+ // 🚀 先按类型排序,同类型再按创建时间(旧在上、新在下)
+ [...t.child_tasks]
+ .sort((a, b) => {
+ const aTrans = a.task_type === 'TRANSFER' ? -1 : 1;
+ const bTrans = b.task_type === 'TRANSFER' ? -1 : 1;
+ if (aTrans !== bTrans) return aTrans - bTrans;
+ return new Date(a.created_at) - new Date(b.created_at);
+ })
.forEach(child => layoutNode(child));
}
};
- this.product.task_tree.forEach(root => layoutNode(root));
+ workTree.forEach(root => layoutNode(root));
return nodes;
},
branchLabelMap() {
const map = {};
if (!this.product || !this.product.task_tree) return map;
+
+ // 拍平树结构,方便随时查父节点状态
+ const flatMap = {};
+ const flatten = (tasks) => {
+ if (!tasks) return;
+ tasks.forEach(t => { flatMap[t.id] = t; flatten(t.child_tasks); });
+ };
+ flatten(this.product.task_tree);
+
const traverse = (tasks, prefix) => {
if (!tasks) return;
let spawnIndex = 0;
tasks.forEach((t) => {
- if (t.task_type !== 'SPAWN') {
+ const parent = t.parent_task_id ? flatMap[t.parent_task_id] : null;
+
+ // 🚀 终极防御判定:
+ // 1. 没有父节点(打底任务) -> 主分支
+ // 2. 明确的基因 (TRANSFER/RECOVERY) -> 主分支
+ // 3. 兜底:如果基因丢失,但它的父节点已经完工(COMPLETED),那它必然是接力的转交任务 -> 主分支
+ const isMain = !t.parent_task_id
+ || t.task_type === 'TRANSFER'
+ || t.task_type === 'RECOVERY'
+ || (!t.task_type && parent && (parent.status === 'COMPLETED' || parent.status === 'CANCELED'));
+
+ if (isMain) {
map[t.id] = '主分支';
traverse(t.child_tasks, prefix);
} else {
@@ -137,10 +211,29 @@ export default {
});
return map;
},
- canvasWidth() { if (!this.treeNodes.length) return 400; return Math.max(800, Math.max(...this.treeNodes.map(n => n.x)) + 300); },
- canvasHeight() { if (!this.treeNodes.length) return 400; return Math.max(800, Math.max(...this.treeNodes.map(n => n.y)) + 300); }
+ canvasWidth() { if (!this.treeNodes.length) return 400; return Math.max(1200, Math.max(...this.treeNodes.map(n => n.x)) + 400); },
+ canvasHeight() { if (!this.treeNodes.length) return 400; return Math.max(1600, Math.max(...this.treeNodes.map(n => n.y)) + 400); },
+ fitScale() {
+ if (!this.treeNodes.length) return 1;
+ const vw = this.viewportW || 375;
+ const vh = this.viewportH || 600;
+ const cw = this.canvasWidth;
+ const ch = this.canvasHeight;
+ const sx = (vw - 32) / cw;
+ const sy = (vh - 100) / ch;
+ return Math.max(0.1, Math.min(sx, sy, 1));
+ }
},
methods: {
+ onScale(e) { if (e && e.detail && typeof e.detail.scale === 'number') this.mvScale = e.detail.scale; },
+ zoomIn() { this.mvScale = Math.min(5, +(this.mvScale + 0.2).toFixed(1)); },
+ zoomOut() { this.mvScale = Math.max(0.1, +(this.mvScale - 0.2).toFixed(1)); },
+ fitAll() {
+ const s = this.fitScale;
+ this.mvScale = s;
+ this.mvX = 0;
+ this.mvY = 0;
+ },
getTaskRemark(t) {
if (!t) return "";
if (t.remark) return t.remark;
@@ -157,40 +250,61 @@ export default {
return { left: line.x1 + 'px', top: line.y1 + 'px', width: len + 'px', transform: `rotate(${angle}deg)`, transformOrigin: '0 0' };
},
statusLabel(s) { const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" }; return map[s] || s; },
- statusColor(s) { switch (s) { case "PENDING": return "s-yellow"; case "WIP": return "s-blue"; case "COMPLETED": return "s-green"; case "REJECTED": return "s-red"; case "CANCELED": return "s-canceled"; default: return "s-gray"; } }
+ statusColor(s) { switch (s) { case "PENDING": return "s-yellow"; case "WIP": return "s-blue"; case "COMPLETED": return "s-green"; case "REJECTED": return "s-red"; case "CANCELED": return "s-canceled"; case "ARCHIVED": return "s-archived"; default: return "s-gray"; } }
}
};
diff --git a/track-uniapp/src/pages/scan/components/WorkspaceArea.vue b/track-uniapp/src/pages/scan/components/WorkspaceArea.vue
index 43cad15..549a175 100644
--- a/track-uniapp/src/pages/scan/components/WorkspaceArea.vue
+++ b/track-uniapp/src/pages/scan/components/WorkspaceArea.vue
@@ -49,7 +49,7 @@
{{ branchLabelsMap[lockedTask.id] || '' }}
{{ statusLabel(lockedTask.status) }}
⚠返工
- 🛑 结束协助
{{ lockedTask.task_name }}
@@ -110,11 +110,29 @@ export default {
product: { type: Object, default: null },
currentUserId: { type: String, default: "" },
currentUsername: { type: String, default: "" },
+ initialLockTaskId: { type: String, default: "" },
},
emits: ["action", "viewRecords"],
data() {
return { lockedTaskId: null };
},
+ watch: {
+ initialLockTaskId: {
+ immediate: true,
+ handler(id) {
+ if (id && this.focusTasks.some(t => t.id === id)) {
+ this.lockedTaskId = id;
+ }
+ },
+ },
+ product: {
+ handler() {
+ if (this.initialLockTaskId && this.focusTasks.some(t => t.id === this.initialLockTaskId)) {
+ this.lockedTaskId = this.initialLockTaskId;
+ }
+ },
+ },
+ },
computed: {
taskMap() {
const m = {};
@@ -133,8 +151,8 @@ export default {
};
if (this.product) walk(this.product.task_tree);
result.sort((a, b) => {
- const aIsMain = !a.parent_task_id || a.task_type !== 'SPAWN';
- const bIsMain = !b.parent_task_id || b.task_type !== 'SPAWN';
+ const aIsMain = !a.parent_task_id || a.task_type === 'TRANSFER' || a.task_type === 'RECOVERY';
+ const bIsMain = !b.parent_task_id || b.task_type === 'TRANSFER' || b.task_type === 'RECOVERY';
if (aIsMain && !bIsMain) return -1;
if (!aIsMain && bIsMain) return 1;
return new Date(a.created_at) - new Date(b.created_at);
@@ -154,11 +172,31 @@ export default {
branchLabelsMap() {
const map = {};
if (!this.product || !this.product.task_tree) return map;
+
+ // 拍平树结构,方便随时查父节点状态
+ const flatMap = {};
+ const flatten = (tasks) => {
+ if (!tasks) return;
+ tasks.forEach(t => { flatMap[t.id] = t; flatten(t.child_tasks); });
+ };
+ flatten(this.product.task_tree);
+
const traverse = (tasks, prefix) => {
if (!tasks) return;
let spawnIndex = 0;
tasks.forEach((t) => {
- if (t.task_type !== 'SPAWN') {
+ const parent = t.parent_task_id ? flatMap[t.parent_task_id] : null;
+
+ // 🚀 终极防御判定:
+ // 1. 没有父节点(打底任务) -> 主分支
+ // 2. 明确的基因 (TRANSFER/RECOVERY) -> 主分支
+ // 3. 兜底:如果基因丢失,但它的父节点已经完工(COMPLETED),那它必然是接力的转交任务 -> 主分支
+ const isMain = !t.parent_task_id
+ || t.task_type === 'TRANSFER'
+ || t.task_type === 'RECOVERY'
+ || (!t.task_type && parent && (parent.status === 'COMPLETED' || parent.status === 'CANCELED'));
+
+ if (isMain) {
map[t.id] = '主分支';
traverse(t.child_tasks, prefix);
} else {
diff --git a/track-uniapp/src/pages/scan/detail.vue b/track-uniapp/src/pages/scan/detail.vue
index e29962e..32256a9 100644
--- a/track-uniapp/src/pages/scan/detail.vue
+++ b/track-uniapp/src/pages/scan/detail.vue
@@ -4,45 +4,55 @@
{{ error }}
-
- 宏观状态
- {{ product.overall_status || '未激活 — 点击发起首道工序' }}
- ▾
-
+
+
+
+ 宏观状态
+ {{ product.overall_status || '未激活 — 点击发起首道工序' }}
+ ▾
+
-
-
+
-
+
+
+
+
+
@@ -155,13 +165,14 @@
import request, { get, post, patch, put } from "../../utils/request";
import WorkspaceArea from "./components/WorkspaceArea.vue";
import TreeCanvas from "./components/TreeCanvas.vue";
+import TaskSwipeCards from "./components/TaskSwipeCards.vue";
const OVERALL_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
const TASK_NAME_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" };
export default {
- components: { WorkspaceArea, TreeCanvas },
+ components: { WorkspaceArea, TreeCanvas, TaskSwipeCards },
data() {
return {
OVERALL_OPTIONS, loading: true, error: "", product: null,
@@ -169,7 +180,7 @@ export default {
users: [], TASK_NAME_OPTIONS,
createFirstVisible: false, isWarehouseTransfer: false, firstForm: { task_name: "", taskNameIdx: 0, assignee_id: "", assigneeLabel: "", assigneeIdx: 0, note: "" }, firstSaving: false,
recordPopup: { visible: false, task: null }, recordForm: { recordId: null, remark: "", images: [], pendingCount: 0 }, recordSaving: false, isUploading: false,
- currentUser: null, currentUserId: "", currentUsername: "", currentMode: "tree",
+ currentUser: null, currentUserId: "", currentUsername: "", currentMode: "workspace", autoLockTaskId: "",
processOptions: [], userOptions: [],
actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", receiveTaskName: "",
transferForm: { selectedUserId: "", isWarehouse: false, note: "" },
@@ -182,6 +193,7 @@ export default {
transferUserName() { const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId); return u ? u.name : ""; },
spawnUserName() { const u = this.userOptions.find(u => u.id === this.spawnForm.assignee_id); return u ? u.name : ""; },
userGridOptions() { return (this.userOptions || []).map(u => ({ id: u.id, name: u.name })); },
+ modeToggleLabel() { if (this.currentMode === 'workspace') return '📇 流转卡片'; if (this.currentMode === 'swipe') return '🌳 流转树'; return '🛠️ 工作区'; },
hasMyActiveTask() {
const find = (tasks) => { if (!tasks) return false; for (const t of tasks) { if ((t.status === 'WIP' || t.status === 'PENDING') && (t.assignee_id == this.currentUserId || t.assignee_id == this.currentUsername)) return true; if (find(t.child_tasks)) return true; } return false; };
return this.product ? find(this.product.task_tree) : false;
@@ -193,8 +205,24 @@ export default {
statusLabel(s) { return STATUS_MAP[s] || s; },
statusColor(s) { switch (s) { case "PENDING": return "s-yellow"; case "WIP": return "s-blue"; case "COMPLETED": return "s-green"; case "REJECTED": return "s-red"; default: return "s-gray"; } },
- async doQuery(sn) { this.loading = true; this.error = ""; try { this.product = await get(`/products/scan/${sn}`); this.$nextTick(() => { this.currentMode = this.hasMyActiveTask ? 'workspace' : 'tree'; if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } }); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
- toggleMode() { this.currentMode = this.currentMode === 'workspace' ? 'tree' : 'workspace'; },
+ async doQuery(sn) { this.loading = true; this.error = ""; try { this.product = await get(`/products/scan/${sn}`); this.$nextTick(() => { this.currentMode = 'workspace'; this.autoLockTaskId = this.findMyImmersiveTask() || ''; if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } }); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
+ findMyImmersiveTask() {
+ // 🚀 扫描用户的 WIP/PENDING 任务,自动沉浸锁定
+ if (!this.product || !this.product.task_tree) return null;
+ let wipTask = null, pendingTask = null;
+ const walk = (tasks) => {
+ if (!tasks) return;
+ for (const t of tasks) {
+ const isMine = t.assignee_id == this.currentUserId || t.assignee_id == this.currentUsername;
+ if (isMine && t.status === 'WIP') wipTask = t;
+ if (isMine && t.status === 'PENDING' && !pendingTask) pendingTask = t;
+ walk(t.child_tasks);
+ }
+ };
+ walk(this.product.task_tree);
+ return (wipTask || pendingTask) ? (wipTask || pendingTask).id : null;
+ },
+ toggleMode() { if (this.currentMode === 'workspace') this.currentMode = 'swipe'; else if (this.currentMode === 'swipe') this.currentMode = 'tree'; else this.currentMode = 'workspace'; },
async handleSetOverallStatus(status) { try { this.product = await patch(`/products/scan/${this.product.serial_number}/status`, { status }); uni.showToast({ title: `状态已更新: ${status}`, icon: "success" }); this.showStatusPicker = false; } catch {} },
openEditProduct() { this.editForm = { order_no: this.product.order_no || "", external_serial: this.product.external_serial || "" }; this.editProductVisible = true; },
diff --git a/track-uniapp/src/utils/request.js b/track-uniapp/src/utils/request.js
index 941886d..28f67e8 100644
--- a/track-uniapp/src/utils/request.js
+++ b/track-uniapp/src/utils/request.js
@@ -1,14 +1,122 @@
/**
- * uni.request 封装 — 统一的 HTTP 客户端
- * 自动携带 Token、401 跳转登录
+ * uni.request 封装 — 智能环境切换 + 双 Token 无感刷新 + 并发请求队列
+ *
+ * 🚀 自动检测:启动时 ping 本地服务器,可达则用本地,否则走生产。
+ * 无需手动复制 request.local.js / request.prod.js。
*/
-const BASE_URL = "http://192.168.9.80:8011/api/v1";
+const LOCAL_URL = "http://localhost:8011/api/v1";
+const LAN_URL = "http://192.168.9.80:8011/api/v1";
+const PROD_URL = "http://172.16.0.198:8011/api/v1";
+
+// ============================================================
+// 环境自动检测(仅首次启动时执行一次)
+// ============================================================
+
+let BASE_URL = uni.getStorageSync("env_base_url") || "";
+let envChecked = !!BASE_URL;
+
+function getBaseUrl() {
+ if (!BASE_URL) BASE_URL = PROD_URL; // 兜底
+ return BASE_URL;
+}
+
+async function detectEnv() {
+ if (envChecked) return;
+ envChecked = true;
+
+ // 并行 ping 两个服务器,谁先响应就用谁
+ const probe = (url) =>
+ new Promise((resolve) => {
+ const start = Date.now();
+ uni.request({
+ url: url + "/auth/login",
+ method: "POST",
+ data: { username: "_ping_", password: "_ping_" },
+ timeout: 3000,
+ complete() {
+ resolve({ url, ms: Date.now() - start });
+ },
+ });
+ });
+
+ const [localResult, lanResult, prodResult] = await Promise.all([
+ probe(LOCAL_URL),
+ probe(LAN_URL),
+ probe(PROD_URL),
+ ]);
+
+ // 优先级: localhost > 局域网IP > 生产
+ if (localResult.ms < 3000) {
+ BASE_URL = LOCAL_URL;
+ } else if (lanResult.ms < 3000) {
+ BASE_URL = LAN_URL;
+ } else {
+ BASE_URL = PROD_URL;
+ }
+ uni.setStorageSync("env_base_url", BASE_URL);
+ console.log("[Env] 自动选择:", BASE_URL,
+ `(localhost ${localResult.ms}ms, 局域网 ${lanResult.ms}ms, 生产 ${prodResult.ms}ms)`);
+}
+
+// App 启动时立即触发检测
+detectEnv();
+
+// ============================================================
+// 双 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); },
+ });
+ });
+}
export default function request(options) {
return new Promise((resolve, reject) => {
- const url = options.url.startsWith("http") ? options.url : BASE_URL + options.url;
- const token = uni.getStorageSync("token") || "";
+ const url = options.url.startsWith("http") ? options.url : getBaseUrl() + options.url;
+ const accessToken = getAccessToken();
uni.request({
url,
@@ -16,59 +124,65 @@ export default function request(options) {
data: options.data || {},
header: {
"Content-Type": "application/json",
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
...(options.header || {}),
},
timeout: 15000,
success(res) {
const code = res.statusCode;
- if (code >= 200 && code < 300) {
- resolve(res.data);
- } else if (code === 401) {
- uni.removeStorageSync("token");
- uni.removeStorageSync("user");
- uni.showToast({ title: "登录已过期,请重新登录", icon: "none" });
- setTimeout(() => uni.reLaunch({ url: "/pages/login/login" }), 1000);
- reject(res);
- } else if (code === 403) {
- uni.showToast({ title: res.data?.detail || "无权操作", icon: "none", duration: 3000 });
- reject(res);
- } else if (code === 400) {
- uni.showToast({ title: res.data?.detail || "请求参数有误", icon: "none", duration: 2500 });
- reject(res);
- } else if (code === 409) {
- uni.showToast({ title: res.data?.detail || "操作冲突", icon: "none", duration: 3000 });
- reject(res);
- } else {
- const detail = res.data?.detail || "";
- uni.showToast({ title: detail ? `${detail}` : `请求失败 (${code})`, icon: "none", duration: 3000 });
- reject(res);
+ 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;
}
+
+ if (code === 403) uni.showToast({ title: res.data?.detail || "无权操作", icon: "none", duration: 3000 });
+ else if (code === 400) uni.showToast({ title: res.data?.detail || "请求参数有误", icon: "none", duration: 2500 });
+ else if (code === 409) uni.showToast({ title: res.data?.detail || "操作冲突", icon: "none", duration: 3000 });
+ else { const d = res.data?.detail || ""; uni.showToast({ title: d || `请求失败 (${code})`, icon: "none", duration: 3000 }); }
+ reject(res);
},
- fail() {
- uni.showToast({ title: "网络连接失败", icon: "none" });
- reject(new Error("network"));
- },
+ fail() { uni.showToast({ title: "网络连接失败", icon: "none" }); reject(new Error("network")); },
});
});
}
export function get(url, params = {}) {
- const query = Object.entries(params)
- .filter(([, v]) => v != null && v !== "")
- .map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
- .join("&");
+ 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 });
-}
+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 }); }
diff --git a/track-uniapp/src/utils/request.local.js b/track-uniapp/src/utils/request.local.js
new file mode 100644
index 0000000..57b92f1
--- /dev/null
+++ b/track-uniapp/src/utils/request.local.js
@@ -0,0 +1,119 @@
+/**
+ * uni.request 封装 — 本地开发环境 (192.168.9.80)
+ * 双 Token 无感刷新 + 并发请求队列
+ */
+
+const BASE_URL = "http://192.168.9.80:8011/api/v1";
+
+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: BASE_URL + "/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); },
+ });
+ });
+}
+
+export default function request(options) {
+ return new Promise((resolve, reject) => {
+ const url = options.url.startsWith("http") ? options.url : BASE_URL + 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;
+ }
+ if (code === 403) uni.showToast({ title: res.data?.detail || "无权操作", icon: "none", duration: 3000 });
+ else if (code === 400) uni.showToast({ title: res.data?.detail || "请求参数有误", icon: "none", duration: 2500 });
+ else if (code === 409) uni.showToast({ title: res.data?.detail || "操作冲突", icon: "none", duration: 3000 });
+ else { const d = res.data?.detail || ""; uni.showToast({ title: d || `请求失败 (${code})`, icon: "none", duration: 3000 }); }
+ reject(res);
+ },
+ fail() { uni.showToast({ title: "网络连接失败", icon: "none" }); reject(new Error("network")); },
+ });
+ });
+}
+
+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 }); }
diff --git a/track-uniapp/src/utils/request.prod.js b/track-uniapp/src/utils/request.prod.js
new file mode 100644
index 0000000..1109824
--- /dev/null
+++ b/track-uniapp/src/utils/request.prod.js
@@ -0,0 +1,120 @@
+/**
+ * uni.request 封装 — 生产环境(172.16.0.198)
+ * 打包 APK 前:将 request.prod.js 重命名为 request.js 替换原文件
+ * 双 Token 无感刷新 + 并发请求队列
+ */
+
+const BASE_URL = "http://172.16.0.198:8011/api/v1";
+
+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: BASE_URL + "/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); },
+ });
+ });
+}
+
+export default function request(options) {
+ return new Promise((resolve, reject) => {
+ const url = options.url.startsWith("http") ? options.url : BASE_URL + 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;
+ }
+ if (code === 403) uni.showToast({ title: res.data?.detail || "无权操作", icon: "none", duration: 3000 });
+ else if (code === 400) uni.showToast({ title: res.data?.detail || "请求参数有误", icon: "none", duration: 2500 });
+ else if (code === 409) uni.showToast({ title: res.data?.detail || "操作冲突", icon: "none", duration: 3000 });
+ else { const d = res.data?.detail || ""; uni.showToast({ title: d || `请求失败 (${code})`, icon: "none", duration: 3000 }); }
+ reject(res);
+ },
+ fail() { uni.showToast({ title: "网络连接失败", icon: "none" }); reject(new Error("network")); },
+ });
+ });
+}
+
+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 }); }
diff --git a/track-uniapp/utils/request.js b/track-uniapp/utils/request.js
index dda458a..9e62e2f 100644
--- a/track-uniapp/utils/request.js
+++ b/track-uniapp/utils/request.js
@@ -1,9 +1,9 @@
/**
- * uni.request 封装 — 统一的 HTTP 客户端
- * 自动携带 Token、401 跳转登录
+ * uni.request 封装 — 生产环境(服务器 172.16.0.198)
+ * 打包 APK 前:将 request.prod.js 重命名为 request.js 替换原文件
*/
-const BASE_URL = "http://192.168.9.80:8011/api/v1";
+const BASE_URL = "http://172.16.0.198:8011/api/v1";
export default function request(options) {
return new Promise((resolve, reject) => {
@@ -30,11 +30,18 @@ export default function request(options) {
uni.showToast({ title: "登录已过期,请重新登录", icon: "none" });
setTimeout(() => uni.reLaunch({ url: "/pages/login/login" }), 1000);
reject(res);
+ } else if (code === 403) {
+ uni.showToast({ title: res.data?.detail || "无权操作", icon: "none", duration: 3000 });
+ reject(res);
} else if (code === 400) {
uni.showToast({ title: res.data?.detail || "请求参数有误", icon: "none", duration: 2500 });
reject(res);
+ } else if (code === 409) {
+ uni.showToast({ title: res.data?.detail || "操作冲突", icon: "none", duration: 3000 });
+ reject(res);
} else {
- uni.showToast({ title: `请求失败 (${code})`, icon: "none" });
+ const detail = res.data?.detail || "";
+ uni.showToast({ title: detail ? `${detail}` : `请求失败 (${code})`, icon: "none", duration: 3000 });
reject(res);
}
},
@@ -57,3 +64,11 @@ export function get(url, params = {}) {
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 });
+}