feat: 移动端双Token队列拦截器 + 通知列表 + 登录页 + TabBar红点 + v1.0.1

This commit is contained in:
2026-08-07 11:44:20 +08:00
parent 721cfe1504
commit 0df7215134
13 changed files with 1013 additions and 206 deletions

View File

@ -1,21 +1,53 @@
<script setup>
import { onLaunch, onShow, onHide } from "@dcloudio/uni-app";
<script>
import { getNotifications } from "./api/notification";
onLaunch(() => {
console.log("生产流转 App 启动");
});
export default {
onLaunch() {
console.log("生产流转 T1.0.1 启动");
// 无 Token 跳转登录页
const token = uni.getStorageSync("access_token");
if (!token) {
uni.reLaunch({ url: "/pages/login/login" });
}
},
onShow() {
console.log("App 显示");
this.updateTabBarBadge();
},
onHide() {
console.log("App 隐藏");
},
methods: {
/** 更新底部 TabBar「消息」红点角标 */
async updateTabBarBadge() {
try {
let user = uni.getStorageSync("user");
if (typeof user === "string" && user) {
try { user = JSON.parse(user); } catch (e) { user = null; }
}
const userId = user?.username || user?.id || "";
if (!userId) return;
onShow(() => {
console.log("App 显示");
});
const res = await getNotifications(userId, 0, 1);
const unreadCount = res.unread_count || 0;
onHide(() => {
console.log("App 隐藏");
});
if (unreadCount > 0) {
uni.setTabBarBadge({
index: 2,
text: unreadCount > 99 ? "99+" : String(unreadCount),
});
} else {
uni.removeTabBarBadge({ index: 2 });
}
} catch {
// 静默失败
}
},
},
};
</script>
<style>
/* 全局样式 */
page {
background-color: #f3f4f6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;

View File

@ -0,0 +1,24 @@
/**
* 消息通知 API — 获取列表、标记已读
*/
import { get, put } from "../utils/request";
/**
* 获取当前用户的通知列表
* @param {string} userId - 当前用户ID
* @param {number} skip - 分页偏移
* @param {number} limit - 每页条数
* @returns {Promise<{notifications: Array, total: number, unread_count: number}>}
*/
export function getNotifications(userId, skip = 0, limit = 20) {
return get("/notifications/", { user_id: userId, skip, limit });
}
/**
* 标记单条通知为已读
* @param {string} notificationId - 通知ID
* @returns {Promise<Object>}
*/
export function markNotificationRead(notificationId) {
return put(`/notifications/${notificationId}/read`);
}

View File

@ -1,34 +1,62 @@
{
"name": "生产流转",
"appid": "__UNI__B572616",
"description": "工厂生产流转管理系统",
"versionName": "1.0.0",
"versionCode": "1",
"transformPx": false,
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
"name" : "Track",
"appid" : "__UNI__B572616",
"description" : "Track - 生产流转管理",
"versionName" : "T1.0.1",
"versionCode" : "100",
"transformPx" : false,
"vueVersion" : "3",
"app-plus" : {
"usingComponents" : true,
"nvueCompiler" : "uni-app",
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
"modules" : {
"Barcode" : {},
"Camera" : {}
},
"distribute" : {
"android" : {
"permissions" : [
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.INTERNET\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>"
]
},
"orientation" : [ "portrait-primary" ],
"icons" : {
"android" : {
"hdpi" : "D:/changyongruanjian/Download/现代企业应用图标设计.png",
"xhdpi" : "D:/changyongruanjian/Download/现代企业应用图标设计.png",
"xxhdpi" : "D:/changyongruanjian/Download/现代企业应用图标设计.png",
"xxxhdpi" : "D:/changyongruanjian/Download/现代企业应用图标设计.png"
}
},
"sdkConfigs" : {
"push" : {},
"speech" : {}
},
"ios" : {
"dSYMs" : false
}
}
},
"modules": {},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>"
]
}
}
},
"h5": {
"routerMode": "hash",
"title": "生产流转"
}
"h5" : {
"router" : {
"mode" : "hash",
"base" : ""
},
"title" : "生产流转"
},
"fallbackLocale" : "zh-Hans"
}

View File

@ -0,0 +1,68 @@
<template>
<view class="page">
<view class="header">
<text class="logo">🏭</text>
<text class="title">Track</text>
<text class="version">T1.0.1</text>
</view>
<view class="form">
<input v-model="username" class="input" placeholder="用户名" />
<input v-model="password" class="input" type="password" placeholder="密码" />
<button class="login-btn" @tap="handleLogin" :disabled="loading">
{{ loading ? '登录中...' : '登 录' }}
</button>
<text v-if="error" class="error">{{ error }}</text>
</view>
</view>
</template>
<script setup>
import { ref } from "vue";
import { post } from "../../utils/request";
const username = ref("");
const password = ref("");
const loading = ref(false);
const error = ref("");
async function handleLogin() {
if (!username.value || !password.value) {
error.value = "请输入用户名和密码";
return;
}
loading.value = true;
error.value = "";
try {
const res = await post("/auth/login", {
username: username.value,
password: password.value,
});
// 双 Token 存储
uni.setStorageSync("access_token", res.access_token);
uni.setStorageSync("refresh_token", res.refresh_token);
uni.setStorageSync("user", JSON.stringify(res.user));
uni.showToast({ title: "登录成功", icon: "success" });
setTimeout(() => {
uni.switchTab({ url: "/pages/scan/index" });
}, 500);
} catch {
// request.js 已弹 toast
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.page { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px; background: #f3f4f6; }
.header { display: flex; flex-direction: column; align-items: center; margin-bottom: 40px; }
.logo { font-size: 64px; }
.title { font-size: 20px; font-weight: 700; color: #1f2937; margin-top: 12px; }
.version { font-size: 12px; color: #9ca3af; margin-top: 4px; }
.form { width: 100%; max-width: 320px; }
.input { width: 100%; height: 48px; padding: 0 16px; border: 1px solid #e5e7eb; border-radius: 10px; font-size: 15px; background: #fff; margin-bottom: 12px; box-sizing: border-box; }
.login-btn { width: 100%; height: 48px; background: #2563EB; color: #fff; border: none; border-radius: 10px; font-size: 16px; font-weight: 700; line-height: 48px; }
.login-btn[disabled] { opacity: 0.6; }
.error { display: block; text-align: center; color: #dc2626; font-size: 13px; margin-top: 12px; }
</style>

View File

@ -4,22 +4,150 @@
<text class="title">消息通知</text>
<text class="subtitle">任务流转和系统通知</text>
</view>
<view class="empty">
<!-- 加载中 -->
<view v-if="loading" class="center">加载中...</view>
<!-- 空状态 -->
<view v-else-if="notifications.length === 0" class="empty">
<text class="empty-icon">🔔</text>
<text class="empty-text">暂无新消息</text>
</view>
<!-- 通知列表 -->
<view v-else class="list">
<view
v-for="item in notifications"
:key="item.id"
:class="['card', item.is_read ? '' : 'card-unread']"
@tap="handleCardTap(item)"
>
<view class="card-left">
<view v-if="!item.is_read" class="unread-dot" />
<text :class="['type-icon', item.is_read ? 'type-icon-read' : '']">
{{ typeIcon(item.type) }}
</text>
</view>
<view class="card-body">
<view class="card-top">
<text :class="['card-title', item.is_read ? '' : 'card-title-bold']">
{{ typeTitle(item.type) }}
</text>
<text class="card-time">{{ formatTime(item.created_at) }}</text>
</view>
<text class="card-content">{{ item.content }}</text>
</view>
<text class="card-arrow"></text>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import { getNotifications, markNotificationRead } from "../../api/notification";
const TYPE_CONFIG = {
TRANSFER: { icon: "🟢", title: "新任务派发" },
REJECT: { icon: "🔴", title: "品质驳回提醒" },
};
const notifications = ref([]);
const loading = ref(true);
let currentUser = null;
onShow(() => {
loadUser();
setTimeout(() => {
if (!currentUser) loadUser();
fetchNotifications();
}, 200);
});
function loadUser() {
try {
let user = uni.getStorageSync("user");
if (typeof user === "string" && user) {
try { user = JSON.parse(user); } catch (e) { user = null; }
}
if (user && typeof user === "object") currentUser = user;
} catch {}
}
async function fetchNotifications() {
const userId = currentUser?.username || currentUser?.id || "";
if (!userId) { loading.value = false; return; }
loading.value = true;
try {
const res = await getNotifications(userId);
notifications.value = res.notifications || [];
} catch {
notifications.value = [];
} finally {
loading.value = false;
}
}
function typeIcon(type) { return (TYPE_CONFIG[type] || { icon: "📌" }).icon; }
function typeTitle(type) { return (TYPE_CONFIG[type] || { title: "系统通知" }).title; }
function formatTime(t) {
if (!t) return "";
const d = new Date(t);
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
async function handleCardTap(item) {
if (!item.is_read) {
try { await markNotificationRead(item.id); item.is_read = true; } catch {}
}
if (item.task_id) {
uni.navigateTo({ url: `/pages/scan/detail?taskId=${item.task_id}` });
}
}
</script>
<style scoped>
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; }
.header { margin-bottom: 24px; }
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; background: #f3f4f6; }
.header { margin-bottom: 20px; }
.title { font-size: 20px; font-weight: 700; color: #1f2937; display: block; }
.subtitle { font-size: 13px; color: #9ca3af; margin-top: 4px; display: block; }
.center { text-align: center; padding: 80px 0; color: #9ca3af; font-size: 14px; }
.empty { display: flex; flex-direction: column; align-items: center; padding-top: 80px; }
.empty-icon { font-size: 64px; margin-bottom: 12px; }
.empty-text { font-size: 14px; color: #9ca3af; }
.list { display: flex; flex-direction: column; gap: 10px; }
.card {
display: flex; align-items: flex-start; gap: 10px;
background: #fff; border-radius: 12px; padding: 14px 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
position: relative; transition: all 0.2s;
}
.card:active { transform: scale(0.98); }
.card-unread {
box-shadow: 0 1px 6px rgba(37,99,235,0.1);
border-left: 3px solid #2563eb;
}
.card-left { display: flex; flex-direction: column; align-items: center; gap: 4px; width: 28px; flex-shrink: 0; }
.unread-dot {
width: 8px; height: 8px; border-radius: 50%;
background: #ef4444; box-shadow: 0 0 0 3px rgba(239,68,68,0.15);
}
.type-icon { font-size: 20px; line-height: 1; }
.type-icon-read { opacity: 0.5; }
.card-body { flex: 1; min-width: 0; }
.card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
.card-title { font-size: 15px; font-weight: 600; color: #374151; }
.card-title-bold { color: #1f2937; font-weight: 700; }
.card-time { font-size: 11px; color: #9ca3af; flex-shrink: 0; }
.card-content { font-size: 13px; color: #6b7280; line-height: 1.5; display: block; word-break: break-all; }
.card-arrow { font-size: 20px; color: #d1d5db; margin-top: 6px; flex-shrink: 0; }
</style>

View File

@ -1,69 +1,48 @@
<template>
<view class="page">
<!-- 用户卡片 -->
<view class="user-card">
<view class="avatar"></view>
<view class="avatar">{{ initial }}</view>
<view class="user-info">
<text class="user-name">张三</text>
<text class="user-role">操作员</text>
<text class="user-name">{{ user?.display_name || '未登录' }}</text>
<text class="user-role">{{ user?.role === 'admin' ? '管理员' : '操作员' }}</text>
</view>
<text class="arrow"></text>
</view>
<!-- 菜单 -->
<view class="menu-card">
<view v-for="item in menuItems" :key="item" class="menu-item">
<view v-for="item in ['工作统计', '设置', '帮助与反馈', '关于']" :key="item" class="menu-item">
<text class="menu-text">{{ item }}</text>
<text class="arrow"></text>
</view>
</view>
<view class="version">生产流转 v1.0.0</view>
<button class="logout-btn" @tap="handleLogout">退出登录</button>
<view class="version">T1.0.1</view>
</view>
</template>
<script setup>
const menuItems = ["工作统计", "设置", "帮助与反馈", "关于"];
import { ref, computed } from "vue";
const user = ref(null);
try { const r = uni.getStorageSync("user"); if (r) user.value = JSON.parse(r); } catch {}
const initial = computed(() => (user.value?.display_name || "?")[0]);
function handleLogout() {
uni.removeStorageSync("token");
uni.removeStorageSync("user");
uni.reLaunch({ url: "/pages/login/login" });
}
</script>
<style scoped>
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; }
.user-card {
display: flex;
align-items: center;
gap: 12px;
background: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.avatar {
width: 48px; height: 48px;
border-radius: 50%;
background: #dbeafe;
color: #2563EB;
font-size: 20px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.user-card { display: flex; align-items: center; gap: 12px; background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.avatar { width: 48px; height: 48px; border-radius: 50%; background: #dbeafe; color: #2563EB; font-size: 20px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.user-name { font-size: 16px; font-weight: 700; color: #1f2937; display: block; }
.user-role { font-size: 12px; color: #9ca3af; }
.arrow { color: #d1d5db; font-size: 20px; margin-left: auto; }
.menu-card {
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
overflow: hidden;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid #f3f4f6;
}
.menu-card { background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); overflow: hidden; }
.menu-item { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #f3f4f6; }
.menu-item:last-child { border-bottom: none; }
.menu-text { font-size: 14px; color: #374151; }
.version { text-align: center; font-size: 12px; color: #d1d5db; margin-top: 32px; }
.logout-btn { width: 100%; height: 44px; background: #fff; color: #dc2626; border: 1px solid #fecaca; border-radius: 10px; font-size: 14px; margin-top: 24px; line-height: 44px; }
.version { text-align: center; font-size: 11px; color: #d1d5db; margin-top: 16px; }
</style>

View File

@ -1,41 +1,59 @@
<template>
<view class="tree-canvas-container">
<view class="tree-canvas-fullscreen">
<!-- 顶部控制栏 -->
<view class="tc-toolbar">
<view class="tc-toolbar-left">
<view class="tc-back-btn" @tap="$emit('back')"> 返回</view>
<view style="display:flex;flex-direction:column;gap:2px;">
<text class="tc-title">🌳 流转蓝图</text>
<text class="tc-hint">双指缩放 · 单指拖拽</text>
</view>
</view>
<view class="tc-toolbar-right">
<view class="tc-zoom-btn tc-mode-btn" @tap="$emit('swipe')">📇 卡片</view>
<view class="tc-zoom-btn" @tap="zoomOut"></view>
<text class="tc-zoom-label">{{ Math.round(mvScale * 100) }}%</text>
<view class="tc-zoom-btn" @tap="zoomIn"></view>
<view class="tc-zoom-btn tc-fit-btn" @tap="fitAll"> 全览</view>
</view>
</view>
<view v-if="!product.task_tree || !product.task_tree.length" class="zero-task">
<text class="zero-icon">📋</text>
<text class="zero-text">该产品暂无流转记录</text>
</view>
<template v-else>
<view class="canvas-hint">🖐 2D 流转蓝图支持全视角自由拖拽探索</view>
<movable-area class="tree-movable-area">
<movable-view
class="tree-movable-view"
direction="all"
:x="0" :y="0"
:scale="true" :scale-min="0.3" :scale-max="2"
:x="mvX" :y="mvY"
:scale="true" :scale-min="0.1" :scale-max="5"
:scale-value="mvScale"
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
@scale="onScale"
>
<view class="canvas-inner">
<!-- 节点卡片层 -->
<view v-for="node in treeNodes" :key="node.id"
class="canvas-node" :class="statusColor(node.status)"
class="canvas-node" :class="[statusColor(node.status), node.status === 'ARCHIVED' ? 'node-archived' : '']"
:style="{ left: node.x + 'px', top: node.y + 'px' }"
@tap="node.records && node.records.length && $emit('viewRecords', node)">
<view class="cn-header">
<text :class="node.parent_task_id ? 'badge-sub' : 'badge-main'">{{ node.parent_task_id ? branchLabelMap[node.id] || '分支' : '主分支' }}</text>
<text :class="['cn-badge', statusColor(node.status)]">{{ statusLabel(node.status) }}</text>
<text :class="['cn-badge', statusColor(node.status), node.status === 'ARCHIVED' ? 'cn-badge-archived' : '']">{{ statusLabel(node.status) }}</text>
</view>
<text class="cn-assignee">负责人: {{ node.assignee_id || '—' }}</text>
<text v-if="node.is_rework" class="tag-rework"> 返工工序</text>
<text v-if="node.status === 'ARCHIVED'" class="tag-archived">📦 已入库</text>
<text v-if="getTaskRemark(node)" class="cn-remark">📌 {{ getTaskRemark(node) }}</text>
<view class="cn-footer">
<text v-if="node.status==='COMPLETED' && (!node.child_tasks || !node.child_tasks.length)" class="cn-end">🏁 终止分支</text>
<text v-else-if="node.status==='ARCHIVED'" class="cn-end cn-end-wh">📦 入库</text>
<text v-else-if="node.status==='ARCHIVED'" class="cn-end cn-end-wh">📦 入库归档</text>
<text v-if="node.records && node.records.length" class="cn-records">📋 {{ node.records.length }}条记录 </text>
</view>
</view>
@ -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"; } }
}
};
</script>
<style scoped>
.tree-canvas-container { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: #ebedf0; border-radius: 12px; margin-top: 10px; box-shadow: inset 0 0 10px rgba(0,0,0,0.02); }
.zero-task { display: flex; flex-direction: column; align-items: center; padding: 40px 0; }
.tree-canvas-fullscreen { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 100; display: flex; flex-direction: column; background: #e8ecf0; }
/* 工具栏 */
.tc-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; padding-top: calc(10px + env(safe-area-inset-top)); background: rgba(255,255,255,0.95); backdrop-filter: blur(10px); border-bottom: 1px solid #e5e7eb; flex-shrink: 0; z-index: 10; }
.tc-toolbar-left { display: flex; align-items: center; gap: 12px; }
.tc-back-btn { font-size: 14px; font-weight: 700; color: #2563eb; padding: 6px 12px; background: #eff6ff; border-radius: 8px; }
.tc-title { font-size: 16px; font-weight: 800; color: #1f2937; }
.tc-hint { font-size: 10px; color: #9ca3af; }
.tc-toolbar-right { display: flex; align-items: center; gap: 8px; }
.tc-zoom-btn { width: 36px; height: 36px; border-radius: 10px; background: #f3f4f6; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 700; color: #374151; }
.tc-zoom-btn:active { background: #e5e7eb; }
.tc-zoom-label { font-size: 12px; font-weight: 600; color: #6b7280; min-width: 42px; text-align: center; }
.tc-mode-btn { width: auto; padding: 0 10px; font-size: 12px; background: #ede9fe; color: #7c3aed; }
.tc-fit-btn { width: auto; padding: 0 12px; font-size: 13px; background: #dbeafe; color: #2563eb; }
.zero-task { display: flex; flex-direction: column; align-items: center; padding: 60px 0; }
.zero-icon { font-size: 40px; margin-bottom: 10px; }
.zero-text { font-size: 14px; color: #9ca3af; }
.canvas-hint { font-size: 11px; color: #9ca3af; text-align: center; padding: 8px 0; background: #fff; border-bottom: 1px solid #e5e7eb; flex-shrink: 0; }
.tree-movable-area { flex: 1; width: 100%; background-color: #f8fafc; background-image: linear-gradient(#e5e7eb 1px, transparent 1px), linear-gradient(90deg, #e5e7eb 1px, transparent 1px); background-size: 20px 20px; }
.tree-movable-area { flex: 1; width: 100%; background-color: #f0f2f5; background-image: linear-gradient(#dde1e6 1px, transparent 1px), linear-gradient(90deg, #dde1e6 1px, transparent 1px); background-size: 24px 24px; }
.canvas-inner { position: absolute; left: 0; top: 0; }
.canvas-node { position: absolute; width: 320px; min-height: 460px; background: #ffffff; border-radius: 20px; padding: 24px; box-shadow: 0 10px 30px rgba(0,0,0,0.10), 0 4px 8px rgba(0,0,0,0.06); border-left: 12px solid #3b82f6; z-index: 10; display: flex; flex-direction: column; gap: 10px; }
/* 节点卡片 */
.canvas-node { position: absolute; width: 320px; min-height: 460px; background: #ffffff; border-radius: 20px; padding: 24px; box-shadow: 0 10px 30px rgba(0,0,0,0.10), 0 4px 8px rgba(0,0,0,0.06); border-left: 12px solid #3b82f6; z-index: 10; display: flex; flex-direction: column; gap: 10px; transition: opacity 0.2s; }
.canvas-node.s-yellow { border-left-color: #f59e0b; }
.canvas-node.s-blue { border-left-color: #3b82f6; }
.canvas-node.s-green { border-left-color: #22c55e; }
.canvas-node.s-red { border-left-color: #ef4444; }
.canvas-node.s-canceled { border-left-color: #9ca3af; opacity: 0.5; filter: grayscale(0.6); }
/* 🚀 ARCHIVED 入库卡片专属样式 */
.canvas-node.s-archived { border-left-color: #8b5cf6; opacity: 0.72; }
.canvas-node.s-archived::after { content: "📦"; position: absolute; right: 20px; bottom: 20px; font-size: 60px; opacity: 0.08; pointer-events: none; }
.cn-header { display: flex; align-items: center; justify-content: space-between; }
.badge-main { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #2563eb; color: #fff; font-weight: 700; }
.badge-sub { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #ede9fe; color: #7c3aed; font-weight: 700; }
.cn-name { font-size: 24px; font-weight: 800; color: #1f2937; }
.cn-badge { font-size: 14px; padding: 4px 12px; border-radius: 8px; font-weight: 700; }
.cn-badge.s-yellow { background: #fef3c7; color: #b45309; }
.cn-badge.s-blue { background: #dbeafe; color: #1d4ed8; }
.cn-badge.s-green { background: #dcfce7; color: #15803d; }
.cn-badge.s-red { background: #fce4ec; color: #be123c; }
.cn-badge-archived { background: #ede9fe; color: #7c3aed; }
.cn-assignee { font-size: 16px; color: #6b7280; font-weight: 500; margin-top: 10px; }
.tag-rework { font-size: 13px; color: #fff; background: #ef4444; padding: 4px 10px; border-radius: 6px; display: inline-block; width: max-content; }
.tag-archived { font-size: 14px; color: #7c3aed; background: #ede9fe; padding: 6px 12px; border-radius: 8px; display: inline-block; width: max-content; font-weight: 700; border: 2px dashed #a78bfa; }
.cn-remark { font-size: 16px; color: #a16207; background: #fefce8; padding: 16px; border-radius: 8px; border: 1px solid #fef08a; line-height: 1.5; word-break: break-all; min-height: 100px; white-space: normal; margin-top: 16px; }
.cn-footer { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 14px; border-top: 1px dashed #e5e7eb; }
.cn-end { font-size: 13px; font-weight: 700; color: #16a34a; }
.cn-end-wh { color: #7c3aed; }
.cn-records { font-size: 14px; color: #2563eb; font-weight: 700; background: #eff6ff; padding: 4px 12px; border-radius: 12px; }
.canvas-line { position: absolute; height: 3px; background: #94a3b8; z-index: 1; transform-origin: 0 0; }
</style>

View File

@ -49,7 +49,7 @@
<text class="tag-branch">{{ branchLabelsMap[lockedTask.id] || '' }}</text>
<text class="fc-status">{{ statusLabel(lockedTask.status) }}</text>
<text v-if="lockedTask.is_rework" class="tag tag-rework-sm">返工</text>
<text v-if="lockedTask.parent_task_id && lockedTask.status === 'WIP'" class="sub-branch-end"
<text v-if="lockedTask.parent_task_id && lockedTask.task_type === 'SPAWN' && lockedTask.status === 'WIP'" class="sub-branch-end"
@tap="$emit('action', { task: lockedTask, type: 'end' })">🛑 结束协助</text>
</view>
<text class="fc-name">{{ lockedTask.task_name }}</text>
@ -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 {

View File

@ -4,45 +4,55 @@
<view v-if="error" class="error-box">{{ error }}</view>
<template v-if="product && !loading">
<view class="overall-bar" @tap="handleOverallBarClick">
<text class="overall-label">宏观状态</text>
<text :class="['overall-val', product.overall_status ? '' : 'overall-empty']">{{ product.overall_status || '未激活 — 点击发起首道工序' }}</text>
<text v-if="product.task_tree && product.task_tree.length" class="overall-arrow"></text>
</view>
<!-- 工作区模式显示产品信息栏 -->
<template v-if="currentMode === 'workspace'">
<view class="overall-bar" @tap="handleOverallBarClick">
<text class="overall-label">宏观状态</text>
<text :class="['overall-val', product.overall_status ? '' : 'overall-empty']">{{ product.overall_status || '未激活 — 点击发起首道工序' }}</text>
<text v-if="product.task_tree && product.task_tree.length" class="overall-arrow"></text>
</view>
<view class="card">
<view class="card-header">
<text class="card-title">📦 产品信息</text>
<view class="card-header-right">
<text :class="['badge', statusColor(product.status)]">{{ statusLabel(product.status) }}</text>
<text class="mode-toggle" @tap="toggleMode">{{ currentMode === 'workspace' ? '🌳 流转树' : '🛠 工作区' }}</text>
<text class="edit-btn" @tap="openEditProduct"></text>
<view class="card">
<view class="card-header">
<text class="card-title">📦 产品信息</text>
<view class="card-header-right">
<text class="mode-toggle" @tap="toggleMode">{{ modeToggleLabel }}</text>
<text class="edit-btn" @tap="openEditProduct"></text>
</view>
</view>
<view class="info-grid">
<view class="info-item"><text class="label">身份证</text><text class="value sn">{{ product.serial_number }}</text></view>
<view class="info-item" v-if="product.external_serial"><text class="label">产品序列号</text><text class="value sn">{{ product.external_serial }}</text></view>
<view class="info-item"><text class="label">物料名称</text><text class="value">{{ product.material_name || product.material_id || '—' }}</text></view>
<view class="info-item"><text class="label">规格型号</text><text class="value">{{ product.spec_model || '—' }}</text></view>
<view class="info-item"><text class="label">订单编号</text><text class="value">{{ product.order_no || '—' }}</text></view>
<view class="info-item" v-if="product.current_location_id">
<text class="label">当前位置</text>
<text :class="['value', product.current_location_id === 'virtual_warehouse' ? 'warehouse' : '']">{{ product.current_location_id === 'virtual_warehouse' ? '🏭 仓库' : product.current_location_id }}</text>
</view>
</view>
</view>
<view class="info-grid">
<view class="info-item"><text class="label">身份证</text><text class="value sn">{{ product.serial_number }}</text></view>
<view class="info-item"><text class="label">物料名称</text><text class="value">{{ product.material_name || product.material_id || '—' }}</text></view>
<view class="info-item"><text class="label">规格型号</text><text class="value">{{ product.spec_model || '—' }}</text></view>
<view class="info-item"><text class="label">订单编号</text><text class="value">{{ product.order_no || '—' }}</text></view>
<view class="info-item" v-if="product.current_location_id">
<text class="label">当前位置</text>
<text :class="['value', product.current_location_id === 'virtual_warehouse' ? 'warehouse' : '']">{{ product.current_location_id === 'virtual_warehouse' ? '🏭 仓库' : product.current_location_id }}</text>
</view>
<view v-if="product && product.current_location_id === 'virtual_warehouse' && !hasMyActiveTask"
class="warehouse-transfer-banner" @tap="openWarehouseTransfer">
<text class="wt-icon">📤</text>
<text class="wt-text">该产品在仓库中 点击此处转出并派发给指定人员</text>
</view>
</view>
<!-- 📤 仓库转出横幅当前用户有活跃任务时隐藏 -->
<view v-if="product && product.current_location_id === 'virtual_warehouse' && !hasMyActiveTask"
class="warehouse-transfer-banner" @tap="openWarehouseTransfer">
<text class="wt-icon">📤</text>
<text class="wt-text">该产品在仓库中 点击此处转出并派发给指定人员</text>
</view>
</template>
<!-- 工作区视图 -->
<WorkspaceArea v-if="currentMode === 'workspace'" :product="product"
:currentUserId="currentUserId" :currentUsername="currentUsername"
:initialLockTaskId="autoLockTaskId"
@action="handleTaskAction" @viewRecords="handleViewRecords" />
<TreeCanvas v-if="currentMode === 'tree'" :product="product" @viewRecords="handleViewRecords" />
<!-- 📇 流转卡片探探式单张滑动 -->
<TaskSwipeCards v-if="currentMode === 'swipe'" :product="product"
@back="currentMode = 'workspace'" @overview="currentMode = 'tree'"
@viewRecords="handleViewRecords" />
<!-- 🌳 流转树全屏独立视图 -->
<TreeCanvas v-if="currentMode === 'tree'" :product="product" @viewRecords="handleViewRecords" @back="currentMode = 'workspace'" @swipe="currentMode = 'swipe'" />
</template>
<!-- 状态定调 -->
@ -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; },

View File

@ -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 }); }

View File

@ -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 }); }

View File

@ -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 }); }

View File

@ -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 });
}