chore: fork from IRIS track 供 LICA 部门独立运行
- 复制来源: /home/yueli/track @ 192c8ee (feature/ai-audit-update) - 组织隔离目标: LICA - 端口规划: 前端 8030 / 后端 8031 / 数据库 8032 - 已排除 deploy.sh、deploy_full.sh、docker-compose.prod.yml(IRIS 生产发布脚本) - 已排除工作区未提交改动,取干净的 192c8ee 状态
This commit is contained in:
6
track-uniapp/.gitignore
vendored
Normal file
6
track-uniapp/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
unpackage/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
10
track-uniapp/.hbuilderx/launch.json
Normal file
10
track-uniapp/.hbuilderx/launch.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"version" : "1.0",
|
||||
"configurations" : [
|
||||
{
|
||||
"customPlaygroundType" : "device",
|
||||
"playground" : "standard",
|
||||
"type" : "uni-app:app-android"
|
||||
}
|
||||
]
|
||||
}
|
||||
12
track-uniapp/index.html
Normal file
12
track-uniapp/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>生产流转</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
10901
track-uniapp/package-lock.json
generated
Normal file
10901
track-uniapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
125
track-uniapp/src/App.vue
Normal file
125
track-uniapp/src/App.vue
Normal file
@ -0,0 +1,125 @@
|
||||
<script>
|
||||
import { getNotifications } from "./api/notification";
|
||||
import { checkAppUpdate } from "./utils/ota";
|
||||
|
||||
export default {
|
||||
onLaunch() {
|
||||
console.log("生产流转 T1.0.2 启动");
|
||||
|
||||
// 🚦 路由守卫:首页已是 scan,未登录才跳登录页(零闪烁)
|
||||
const token = uni.getStorageSync("access_token") || uni.getStorageSync("refresh_token");
|
||||
const user = uni.getStorageSync("user");
|
||||
|
||||
if (!token || !user) {
|
||||
uni.reLaunch({ url: "/pages/login/login" });
|
||||
}
|
||||
// 已登录 → 原地渲染 scan 页,不需任何跳转
|
||||
|
||||
// 🚀 OTA 热更新检测(仅 App 端生效;实现见 utils/ota.js,设置页手动检查共用同一份)
|
||||
// #ifdef APP-PLUS
|
||||
checkAppUpdate();
|
||||
// #endif
|
||||
},
|
||||
onShow() {
|
||||
console.log("App 显示");
|
||||
this.updateTabBarBadge();
|
||||
// 🚀 每次回到前台也检查更新,用户无需杀后台就能感知新版本
|
||||
// #ifdef APP-PLUS
|
||||
checkAppUpdate();
|
||||
// #endif
|
||||
},
|
||||
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;
|
||||
|
||||
const res = await getNotifications(userId, 0, 1);
|
||||
const unreadCount = res.unread_count || 0;
|
||||
|
||||
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;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
上传成功角标 —— 图片缩略图右上角「绿底白勾」
|
||||
============================================================
|
||||
用法:给图片的【直接父容器】加 .success-badge-wrapper,
|
||||
容器内放 <image> 和一个空的 <view class="success-badge" />,
|
||||
后者仅在确认该图已上传成功时才渲染。
|
||||
|
||||
⚠️ 容器只能包住图片本身,不要连删除按钮一起包 —— overflow:hidden 会把
|
||||
定位在单元格外的 ✕ 按钮一并裁掉(各页面的 .img-del 都在 -12rpx 处)。
|
||||
============================================================ */
|
||||
.success-badge-wrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 12rpx; /* 与缩略图圆角一致,负责裁剪贴边的角标 */
|
||||
}
|
||||
|
||||
/* 直角三角形本体:border-top 实色 + border-left 透明,直角落在右上角。
|
||||
注意别写成 border-top + border-right —— 那样得到的直角在左上,方向是反的。 */
|
||||
.success-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
z-index: 2;
|
||||
pointer-events: none; /* 不遮挡图片本身的点击(预览) */
|
||||
}
|
||||
.success-badge::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 40rpx solid #67c23a;
|
||||
border-left: 40rpx solid transparent;
|
||||
}
|
||||
|
||||
/* 白色对勾:用两条边框旋转 -45° 画出来,不依赖字体。
|
||||
直接用 "✓" 字符的话,安卓与 iOS 的字形和基线差异很大,对不齐。 */
|
||||
.success-badge::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 9rpx;
|
||||
right: 7rpx;
|
||||
width: 12rpx;
|
||||
height: 7rpx;
|
||||
border-left: 3rpx solid #ffffff;
|
||||
border-bottom: 3rpx solid #ffffff;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
</style>
|
||||
37
track-uniapp/src/api/feedback.js
Normal file
37
track-uniapp/src/api/feedback.js
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 帮助与反馈 API。
|
||||
*
|
||||
* ⚠️ 后端目前【没有】feedback 接口(已核对 openapi 路由表)。这里不直接写死
|
||||
* 一个假函数,而是「先真后假」:先按最终契约 POST /feedback/,只有拿到
|
||||
* 404(端点尚未实现)才回退到本地 mock 成功。这样等后端补上接口后,前端
|
||||
* 一行都不用改就能自动接上真实链路;而 400/422/断网等真实错误仍会照常
|
||||
* 抛出,不会被 mock 悄悄吞掉 —— 否则工人会以为反馈提交成功了,其实没有。
|
||||
*/
|
||||
import request from "../utils/request";
|
||||
|
||||
/** 反馈问题类型(与页面选择项一一对应) */
|
||||
export const FEEDBACK_TYPES = [
|
||||
{ value: "system", label: "系统问题" },
|
||||
{ value: "device", label: "设备故障" },
|
||||
{ value: "other", label: "其他" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 提交一条反馈。
|
||||
* @param {{type: string, content: string, images?: string[], user_id?: string}} payload
|
||||
* @returns {Promise<{id?: string, mocked?: boolean}>}
|
||||
*/
|
||||
export async function submitFeedback(payload) {
|
||||
try {
|
||||
// silent:404 会走到下面的 mock 分支,不能先弹一句「请求失败 (404)」再弹「感谢反馈」
|
||||
return await request({ url: "/feedback/", method: "POST", data: payload, silent: true });
|
||||
} catch (e) {
|
||||
// 只认「端点不存在」这一种情况回退 mock;其余错误(校验失败/网络断)
|
||||
// 必须原样抛给页面去提示,不能让用户误以为提交成功
|
||||
if (e && e.statusCode === 404) {
|
||||
console.warn("[feedback] 后端暂无 /feedback/ 接口,回退本地 mock");
|
||||
return { id: null, mocked: true };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
24
track-uniapp/src/api/notification.js
Normal file
24
track-uniapp/src/api/notification.js
Normal 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`);
|
||||
}
|
||||
27
track-uniapp/src/api/stats.js
Normal file
27
track-uniapp/src/api/stats.js
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 个人效能统计 API — 「个人中心 → 工作统计」数据源。
|
||||
*
|
||||
* 后端 /dashboard/* 系列是「上帝视角」(全厂数据、不按人过滤),且
|
||||
* people-history 只返回 WIP/PENDING/COMPLETED、rejected-tasks 压根没有
|
||||
* assignee_id 参数 —— 一线工人要看的「我的今日被驳回数」在这两个接口里
|
||||
* 都拿不到。故后端补了一个薄桥接端点 /dashboard/my-stats,沿用同一套统计
|
||||
* 口径,但强制按 assignee_id 过滤到本人。
|
||||
*/
|
||||
import { get } from "../utils/request";
|
||||
|
||||
/**
|
||||
* 获取当前登录用户的个人效能统计(支持自选时段)。
|
||||
*
|
||||
* 返回两组指标:
|
||||
* 生产战绩 — tasks_completed / tasks_rejected / products_touched
|
||||
* 操作统计 — receive_count / transfer_count / record_count / op_total
|
||||
* (与 PC「人员操作统计」严格同口径,已逐字段交叉验证)
|
||||
*
|
||||
* @param {string} assigneeId - 负责人ID(即登录用户的 username)
|
||||
* @param {string} [since] - 起始时间 ISO,**必须带时区偏移**(如 2026-09-01T00:00:00+08:00)
|
||||
* @param {string} [until] - 截止时间 ISO,同上
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export function getMyStats(assigneeId, since, until) {
|
||||
return get("/dashboard/my-stats", { assignee_id: assigneeId, since, until });
|
||||
}
|
||||
24
track-uniapp/src/main.js
Normal file
24
track-uniapp/src/main.js
Normal file
@ -0,0 +1,24 @@
|
||||
import App from './App'
|
||||
|
||||
// #ifndef VUE3
|
||||
import Vue from 'vue'
|
||||
import './uni.promisify.adaptor'
|
||||
Vue.config.productionTip = false
|
||||
App.mpType = 'app'
|
||||
const app = new Vue({
|
||||
...App
|
||||
})
|
||||
app.$mount()
|
||||
// #endif
|
||||
|
||||
// #ifdef VUE3
|
||||
import {
|
||||
createSSRApp
|
||||
} from 'vue'
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
return {
|
||||
app
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
61
track-uniapp/src/manifest.json
Normal file
61
track-uniapp/src/manifest.json
Normal file
@ -0,0 +1,61 @@
|
||||
{
|
||||
"name" : "Track",
|
||||
"appid" : "__UNI__B572616",
|
||||
"description" : "Track - 生产流转管理",
|
||||
"versionName" : "T1.0.8",
|
||||
"versionCode" : 108,
|
||||
"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" : {
|
||||
"speech" : {}
|
||||
},
|
||||
"ios" : {
|
||||
"dSYMs" : false
|
||||
}
|
||||
}
|
||||
},
|
||||
"h5" : {
|
||||
"router" : {
|
||||
"mode" : "hash",
|
||||
"base" : ""
|
||||
},
|
||||
"title" : "生产流转"
|
||||
},
|
||||
"fallbackLocale" : "zh-Hans"
|
||||
}
|
||||
116
track-uniapp/src/pages.json
Normal file
116
track-uniapp/src/pages.json
Normal file
@ -0,0 +1,116 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/scan/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "扫码干活",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/login/login",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/scan/records",
|
||||
"style": {
|
||||
"navigationBarTitleText": "任务历史记录",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/scan/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "产品详情",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/tasks/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的任务",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/notify/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "消息通知",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "个人中心",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/statistics",
|
||||
"style": {
|
||||
"navigationBarTitleText": "工作统计",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/settings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "设置",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/feedback",
|
||||
"style": {
|
||||
"navigationBarTitleText": "帮助与反馈",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "white",
|
||||
"navigationBarTitleText": "生产流转",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"backgroundColor": "#F3F4F6"
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#9CA3AF",
|
||||
"selectedColor": "#2563EB",
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"borderStyle": "black",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/scan/index",
|
||||
"text": "扫码干活"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/tasks/index",
|
||||
"text": "我的任务"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/notify/index",
|
||||
"text": "消息"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/profile/index",
|
||||
"text": "我的"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
79
track-uniapp/src/pages/login/login.vue
Normal file
79
track-uniapp/src/pages/login/login.vue
Normal file
@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header">
|
||||
<text class="logo">🏭</text>
|
||||
<text class="title">Track</text>
|
||||
<text class="version">{{ appVersion }}</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, onMounted } from "vue";
|
||||
import { post } from "../../utils/request";
|
||||
|
||||
// 🚀 动态版本号 — 热更新后自动变化(路由由 App.vue onLaunch 统一接管)
|
||||
const appVersion = ref("T1.0.2");
|
||||
onMounted(() => {
|
||||
try {
|
||||
const v = uni.getSystemInfoSync().appWgtVersion || uni.getSystemInfoSync().appVersion;
|
||||
if (v) appVersion.value = v.startsWith("T") ? v : "T" + v;
|
||||
} catch {}
|
||||
});
|
||||
|
||||
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 (e) {
|
||||
// request.js 对 /auth/login 的 401 直接 reject 不弹 toast,此处补齐
|
||||
const detail = e?.data?.detail || "";
|
||||
uni.showToast({ title: detail || "账号或密码错误", icon: "none", duration: 2000 });
|
||||
} 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>
|
||||
234
track-uniapp/src/pages/notify/index.vue
Normal file
234
track-uniapp/src/pages/notify/index.vue
Normal file
@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header">
|
||||
<text class="title">消息通知</text>
|
||||
<text class="subtitle">任务流转和系统通知</text>
|
||||
</view>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<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 v-if="loadingMore" class="load-more">加载中...</view>
|
||||
<view v-else-if="!hasMore" class="load-more">— 没有更多了 —</view>
|
||||
<view v-else class="load-more">上拉加载更多</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onShow, onPullDownRefresh, onReachBottom } from "@dcloudio/uni-app";
|
||||
import { getNotifications, markNotificationRead } from "../../api/notification";
|
||||
|
||||
const TYPE_CONFIG = {
|
||||
TRANSFER: { icon: "🟢", title: "新任务派发" },
|
||||
REJECT: { icon: "🔴", title: "品质驳回提醒" },
|
||||
COMMENT: { icon: "💬", title: "收到新留言" },
|
||||
};
|
||||
|
||||
const notifications = ref([]);
|
||||
const loading = ref(true);
|
||||
let currentUser = null;
|
||||
|
||||
// 📄 分页状态:此前用默认 limit=20 但翻不动页,第 21 条以后的消息永远看不到
|
||||
const pageSize = 20;
|
||||
let page = 1;
|
||||
const hasMore = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
|
||||
onShow(() => {
|
||||
loadUser();
|
||||
setTimeout(() => {
|
||||
if (!currentUser) loadUser();
|
||||
fetchNotifications("initial");
|
||||
}, 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 {}
|
||||
}
|
||||
|
||||
// 🚀 下拉刷新:界面提示了「请下拉刷新重试」,就必须把功能做实。
|
||||
// 分页后下拉必须重置 page 并清空列表 —— 否则新的第 1 页会和旧的第 2、3 页
|
||||
// 混在一起,出现重复项与排序错乱。
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
loadUser();
|
||||
await fetchNotifications("refresh");
|
||||
} finally {
|
||||
uni.stopPullDownRefresh(); // 无论成败都要收起动画,否则会一直挂着
|
||||
}
|
||||
});
|
||||
|
||||
// 🚀 触底加载下一页
|
||||
onReachBottom(() => {
|
||||
if (loading.value || loadingMore.value || !hasMore.value) return;
|
||||
loadingMore.value = true;
|
||||
fetchNotifications("more").finally(() => { loadingMore.value = false; });
|
||||
});
|
||||
|
||||
/**
|
||||
* 拉取通知
|
||||
* @param {'initial'|'refresh'|'more'} mode
|
||||
* initial — 首次进入/切页:显示整页 loading,失败清空
|
||||
* refresh — 下拉刷新:重置到第 1 页并清空重载
|
||||
* more — 触底加载:追加下一页,失败保留已加载内容只提示
|
||||
*/
|
||||
async function fetchNotifications(mode = "initial") {
|
||||
const userId = currentUser?.username || currentUser?.id || "";
|
||||
if (!userId) {
|
||||
if (mode === "initial") loading.value = false;
|
||||
else uni.showToast({ title: "未获取到登录信息,请重新登录", icon: "none", duration: 2500 });
|
||||
return;
|
||||
}
|
||||
// initial / refresh 都要从第 1 页重来:前者是切页回来,后者是下拉刷新。
|
||||
// 若 initial 沿用旧的 page,onShow 只会去拉"第 N 页"并追加,列表永远刷不新。
|
||||
if (mode === "initial" || mode === "refresh") {
|
||||
page = 1;
|
||||
hasMore.value = true;
|
||||
notifications.value = [];
|
||||
}
|
||||
if (mode === "initial") loading.value = true;
|
||||
const target = page;
|
||||
try {
|
||||
const res = await getNotifications(userId, (target - 1) * pageSize, pageSize);
|
||||
const batch = res.notifications || [];
|
||||
// 按 id 去重后追加:翻页期间若来了新消息,分页边界会错位导致重复项
|
||||
const seen = new Set(notifications.value.map((n) => n.id));
|
||||
notifications.value = notifications.value.concat(batch.filter((n) => !seen.has(n.id)));
|
||||
// 后端给了 total 就以它为准;否则退回「本页是否满员」判断
|
||||
hasMore.value = res.total != null
|
||||
? notifications.value.length < res.total
|
||||
: batch.length >= pageSize;
|
||||
if (hasMore.value) page = target + 1;
|
||||
} catch (e) {
|
||||
console.error("[notify] 拉取消息失败:", e);
|
||||
if (mode === "initial") notifications.value = [];
|
||||
else uni.showToast({
|
||||
title: mode === "more" ? "加载更多失败,请重试" : "刷新失败,请稍后重试",
|
||||
icon: "none",
|
||||
duration: 2500,
|
||||
});
|
||||
} finally {
|
||||
if (mode === "initial") 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 {
|
||||
uni.showToast({ title: "标记已读失败,请下拉刷新重试", icon: "none", duration: 2000 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 🚀 优先使用 product_serial_number,兜底从 content 中解析
|
||||
let sn = item.product_serial_number || "";
|
||||
if (!sn && item.content) {
|
||||
const match = item.content.match(/\[([A-Za-z0-9]{8,16})\]/);
|
||||
if (match) sn = match[1];
|
||||
}
|
||||
if (sn) {
|
||||
uni.navigateTo({ url: `/pages/scan/detail?serial=${sn}` });
|
||||
} else 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; 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; }
|
||||
|
||||
/* 触底加载状态 */
|
||||
.load-more { text-align: center; font-size: 12px; color: #9ca3af; padding: 16px 0 4px; }
|
||||
</style>
|
||||
233
track-uniapp/src/pages/profile/feedback.vue
Normal file
233
track-uniapp/src/pages/profile/feedback.vue
Normal file
@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 问题类型 -->
|
||||
<view class="section">
|
||||
<text class="section-title">问题类型</text>
|
||||
<view class="type-row">
|
||||
<view
|
||||
v-for="t in FEEDBACK_TYPES"
|
||||
:key="t.value"
|
||||
:class="['type-chip', form.type === t.value ? 'type-chip-active' : '']"
|
||||
@tap="form.type = t.value"
|
||||
>{{ t.label }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 问题描述 -->
|
||||
<view class="section">
|
||||
<text class="section-title">详细描述</text>
|
||||
<view class="textarea-wrap">
|
||||
<textarea
|
||||
v-model="form.content"
|
||||
class="textarea"
|
||||
placeholder="请描述遇到的问题:在哪个页面、做了什么操作、出现什么结果。描述越清楚,我们修得越快。"
|
||||
placeholder-class="ta-placeholder"
|
||||
:maxlength="MAX_LEN"
|
||||
:show-confirm-bar="false"
|
||||
auto-height
|
||||
/>
|
||||
<text class="counter">{{ form.content.length }}/{{ MAX_LEN }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 图片上传(复用 utils/upload.js 的有界并发上传;上限 4 张) -->
|
||||
<view class="section">
|
||||
<text class="section-title">问题截图<text class="section-sub">(选填,最多 {{ MAX_IMAGES }} 张)</text></text>
|
||||
<view class="img-grid">
|
||||
<view v-for="(img, i) in form.images" :key="img + '-' + i" class="img-cell">
|
||||
<view class="success-badge-wrapper img-frame">
|
||||
<image :src="imageUrl(img)" mode="aspectFill" class="img" @tap="previewImage(i)" />
|
||||
<view v-if="isUploaded(img)" class="success-badge" />
|
||||
</view>
|
||||
<text class="img-del" @tap.stop="removeImage(i)">✕</text>
|
||||
</view>
|
||||
<!-- ⏳ 占位符:每张图成功/失败都会恰好回收一个,避免"以为传好了其实没有" -->
|
||||
<view v-for="n in pendingCount" :key="'p' + n" class="img-cell img-loading">
|
||||
<text class="img-loading-text">⏳</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="form.images.length + pendingCount < MAX_IMAGES"
|
||||
class="img-cell img-add"
|
||||
@tap="chooseImages"
|
||||
>
|
||||
<text class="img-add-icon">📷</text>
|
||||
<text class="img-add-text">添加图片</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button
|
||||
class="submit-btn"
|
||||
:disabled="!canSubmit"
|
||||
@tap="handleSubmit"
|
||||
>{{ submitting ? '提交中...' : '提交反馈' }}</button>
|
||||
|
||||
<view class="hint">提交后我们会尽快处理。若设备故障影响生产,请同时通知当班主管。</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, reactive } from "vue";
|
||||
import { FEEDBACK_TYPES, submitFeedback } from "../../api/feedback";
|
||||
import { uploadImages, isUploadedUrl } from "../../utils/upload";
|
||||
import { getBaseUrl } from "../../utils/request";
|
||||
|
||||
const MAX_IMAGES = 4;
|
||||
const MAX_LEN = 500;
|
||||
|
||||
const form = reactive({ type: "system", content: "", images: [] });
|
||||
const pendingCount = ref(0);
|
||||
const uploading = ref(false);
|
||||
const submitting = ref(false);
|
||||
|
||||
const canSubmit = computed(
|
||||
() => form.content.trim().length > 0 && !uploading.value && !submitting.value
|
||||
);
|
||||
|
||||
function imageUrl(url) {
|
||||
if (!url) return "";
|
||||
if (url.startsWith("http")) return url;
|
||||
const domain = getBaseUrl().replace(/\/api.*$/, "");
|
||||
return domain + (url.startsWith("/") ? url : "/" + url);
|
||||
}
|
||||
|
||||
/** 绿勾角标只认「已上传成功」的项(判定逻辑统一收口在 utils/upload.js) */
|
||||
function isUploaded(img) {
|
||||
return isUploadedUrl(img);
|
||||
}
|
||||
|
||||
function previewImage(index) {
|
||||
uni.previewImage({ urls: form.images.map(imageUrl), current: index });
|
||||
}
|
||||
|
||||
function removeImage(index) {
|
||||
form.images.splice(index, 1);
|
||||
}
|
||||
|
||||
async function chooseImages() {
|
||||
const maxSlots = MAX_IMAGES - (form.images.length + pendingCount.value);
|
||||
if (maxSlots <= 0) return;
|
||||
|
||||
const chooseRes = await new Promise((resolve, reject) => {
|
||||
uni.chooseImage({
|
||||
count: maxSlots,
|
||||
sizeType: ["compressed"],
|
||||
sourceType: ["camera", "album"],
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
});
|
||||
}).catch(() => null);
|
||||
if (!chooseRes || !chooseRes.tempFilePaths || !chooseRes.tempFilePaths.length) return;
|
||||
|
||||
const compressedPaths = [];
|
||||
for (const p of chooseRes.tempFilePaths) {
|
||||
try {
|
||||
const compressed = await new Promise((resolve, reject) => {
|
||||
uni.compressImage({ src: p, quality: 60, success: resolve, fail: reject });
|
||||
});
|
||||
compressedPaths.push(compressed.tempFilePath);
|
||||
} catch {}
|
||||
}
|
||||
if (!compressedPaths.length) {
|
||||
uni.showToast({ title: "图片处理失败,请重试", icon: "none", duration: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
uploading.value = true;
|
||||
pendingCount.value += compressedPaths.length;
|
||||
// 🚀 复用 utils/upload.js 的 3 并发上传池(与追加记录 / 驳回弹窗同一份实现)
|
||||
const { failed, total } = await uploadImages(compressedPaths, (url) => {
|
||||
if (url) form.images.push(url);
|
||||
pendingCount.value--;
|
||||
});
|
||||
uploading.value = false;
|
||||
if (failed > 0) {
|
||||
uni.showToast({
|
||||
title: failed === total ? "图片上传失败,请重试" : "部分图片上传失败,请重试",
|
||||
icon: "none",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function readUserId() {
|
||||
try {
|
||||
let user = uni.getStorageSync("user");
|
||||
if (typeof user === "string" && user) user = JSON.parse(user);
|
||||
return (user && (user.username || user.id)) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit.value) return;
|
||||
// 上传还没结束就提交,会把没传完的图片漏掉 —— 拦住并说明原因
|
||||
if (pendingCount.value > 0) {
|
||||
uni.showToast({ title: "图片还在上传,请稍候", icon: "none" });
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
await submitFeedback({
|
||||
type: form.type,
|
||||
content: form.content.trim(),
|
||||
images: form.images,
|
||||
user_id: readUserId(),
|
||||
});
|
||||
uni.showToast({ title: "感谢反馈", icon: "success" });
|
||||
// 让 toast 露个面再返回;被直接打开(无上级页)时退回个人中心,避免卡死
|
||||
setTimeout(() => {
|
||||
if (getCurrentPages().length > 1) uni.navigateBack();
|
||||
else uni.reLaunch({ url: "/pages/profile/index" });
|
||||
}, 1200);
|
||||
} catch (e) {
|
||||
// 校验失败 / 断网等真实错误照常提示;request.js 已弹过通用 toast 就不再重复弹
|
||||
console.error("[feedback] 提交失败:", e);
|
||||
if (e && e.isNetworkError) {
|
||||
uni.showToast({ title: "网络连接失败,请稍后重试", icon: "none", duration: 3000 });
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; background: #f3f4f6; }
|
||||
|
||||
.section { background: #fff; border-radius: 14px; padding: 16px; margin-bottom: 14px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.section-title { font-size: 14px; font-weight: 700; color: #1f2937; display: block; margin-bottom: 12px; }
|
||||
.section-sub { font-size: 12px; font-weight: 400; color: #9ca3af; }
|
||||
|
||||
/* 问题类型 */
|
||||
.type-row { display: flex; gap: 10px; }
|
||||
.type-chip { flex: 1; height: 40px; line-height: 40px; text-align: center; border-radius: 10px; background: #f3f4f6; color: #6b7280; font-size: 13px; font-weight: 600; }
|
||||
.type-chip-active { background: #2563eb; color: #fff; }
|
||||
|
||||
/* 描述 */
|
||||
.textarea-wrap { position: relative; }
|
||||
.textarea { width: 100%; min-height: 110px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px 10px 26px; font-size: 14px; line-height: 1.5; box-sizing: border-box; background: #fafbfc; }
|
||||
.ta-placeholder { color: #b0b6bf; font-size: 13px; }
|
||||
.counter { position: absolute; right: 12px; bottom: 8px; font-size: 11px; color: #b0b6bf; }
|
||||
|
||||
/* 图片 */
|
||||
.img-grid { display: flex; flex-wrap: wrap; }
|
||||
.img-cell { position: relative; width: 160rpx; height: 160rpx; margin: 0 16rpx 16rpx 0; }
|
||||
/* 圆角与裁剪交给 .success-badge-wrapper,图片只负责填满 */
|
||||
.img-frame { width: 160rpx; height: 160rpx; }
|
||||
.img { width: 100%; height: 100%; display: block; border: 1px solid #e5e7eb; box-sizing: border-box; background: #e5e7eb; }
|
||||
.img-del { position: absolute; top: -12rpx; right: -12rpx; width: 40rpx; height: 40rpx; background: #ef4444; color: #fff; border-radius: 20rpx; font-size: 24rpx; text-align: center; line-height: 40rpx; z-index: 2; }
|
||||
.img-loading { display: flex; align-items: center; justify-content: center; background: #f3f4f6; border-radius: 12rpx; border: 1px dashed #d1d5db; }
|
||||
.img-loading-text { font-size: 36rpx; }
|
||||
.img-add { display: flex; flex-direction: column; align-items: center; justify-content: center; background: #f9fafb; border-radius: 12rpx; border: 1px dashed #d1d5db; }
|
||||
.img-add-icon { font-size: 36rpx; }
|
||||
.img-add-text { font-size: 20rpx; color: #9ca3af; margin-top: 4rpx; }
|
||||
|
||||
/* 提交 */
|
||||
.submit-btn { width: 100%; height: 46px; line-height: 46px; background: #2563eb; color: #fff; border-radius: 12px; font-size: 15px; font-weight: 600; margin-top: 6px; padding: 0; }
|
||||
.submit-btn::after { border: none; }
|
||||
.submit-btn[disabled] { opacity: 0.45; }
|
||||
.hint { margin-top: 14px; padding: 0 4px; font-size: 11px; color: #b0b6bf; line-height: 1.6; }
|
||||
</style>
|
||||
73
track-uniapp/src/pages/profile/index.vue
Normal file
73
track-uniapp/src/pages/profile/index.vue
Normal file
@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="user-card">
|
||||
<view class="avatar">{{ initial }}</view>
|
||||
<view class="user-info">
|
||||
<text class="user-name">{{ user?.display_name || '未登录' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-card">
|
||||
<view v-for="item in ['工作统计', '设置', '帮助与反馈', '关于']" :key="item"
|
||||
class="menu-item" @click="handleMenuClick(item)">
|
||||
<text class="menu-text">{{ item }}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="version">{{ appVersion }}</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } 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]);
|
||||
|
||||
// 🚀 动态版本号 — 热更新后自动变化
|
||||
const appVersion = ref("T1.0.2");
|
||||
onMounted(() => {
|
||||
try {
|
||||
const sysInfo = uni.getSystemInfoSync();
|
||||
appVersion.value = sysInfo.appWgtVersion || sysInfo.appVersion || "T1.0.2";
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// 菜单项 → 子页面路由。退出登录已迁到「设置」页,此处不再保留按钮
|
||||
const MENU_ROUTES = {
|
||||
"工作统计": "/pages/profile/statistics",
|
||||
"设置": "/pages/profile/settings",
|
||||
"帮助与反馈": "/pages/profile/feedback",
|
||||
};
|
||||
|
||||
function handleMenuClick(item) {
|
||||
const url = MENU_ROUTES[item];
|
||||
if (url) {
|
||||
uni.navigateTo({ url });
|
||||
return;
|
||||
}
|
||||
if (item === "关于") {
|
||||
uni.showModal({
|
||||
title: "关于 Track",
|
||||
content: "Track 生产流转管理系统\n当前版本:" + appVersion.value + "\n核心架构:FastAPI + Vue3 + uni-app",
|
||||
showCancel: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
</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-name { font-size: 16px; font-weight: 700; color: #1f2937; display: block; }
|
||||
.user-role { font-size: 12px; color: #9ca3af; }
|
||||
.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; }
|
||||
.menu-arrow { font-size: 18px; color: #d1d5db; }
|
||||
.version { text-align: center; font-size: 11px; color: #d1d5db; margin-top: 24px; }
|
||||
</style>
|
||||
231
track-uniapp/src/pages/profile/settings.vue
Normal file
231
track-uniapp/src/pages/profile/settings.vue
Normal file
@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="menu-card">
|
||||
<view class="menu-item" @tap="handleClearCache">
|
||||
<view class="menu-labels">
|
||||
<text class="menu-text">清理缓存</text>
|
||||
<text class="menu-sub">{{ cacheSummary }}</text>
|
||||
</view>
|
||||
<view class="menu-right">
|
||||
<text class="menu-value">{{ cacheSize }}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @tap="handleCheckUpdate">
|
||||
<view class="menu-labels">
|
||||
<text class="menu-text">检查更新</text>
|
||||
<text class="menu-sub">热更新资源包,无需重装 App</text>
|
||||
</view>
|
||||
<view class="menu-right">
|
||||
<text class="menu-value">{{ appVersion }}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 明确告诉工人「清的是什么、不动什么」—— 否则点了没变化会以为没生效 -->
|
||||
<view class="hint">
|
||||
清理仅删除留言已读记录等临时数据,不会影响登录状态、后端地址配置,也不会删除已上传的照片。
|
||||
</view>
|
||||
|
||||
<button class="logout-btn" @tap="handleLogout">退出登录</button>
|
||||
|
||||
<view class="version">Track 生产流转 {{ appVersion }}</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { checkAppUpdate } from "../../utils/ota";
|
||||
import { post } from "../../utils/request";
|
||||
|
||||
// ============================================================
|
||||
// 缓存清理策略 —— 黑名单式:只删「明确登记过的业务缓存」
|
||||
// ============================================================
|
||||
//
|
||||
// 不用白名单(遍历时跳过白名单、其余全删):那样每引入一个新的插件 / SDK,
|
||||
// 只要它在本地存了配置,就会被无声清掉,属于「默认伤害」。
|
||||
// 黑名单反过来 —— **没被匹配到的 key 一律保留**,新增插件天然免疫。
|
||||
//
|
||||
// ⚠️ 新增业务缓存时,请顺手到下面登记前缀,不要在这里写零散判断。
|
||||
|
||||
/** 按前缀匹配的可清理缓存 */
|
||||
const CACHE_KEY_PREFIXES = [
|
||||
// ── 当前真实在用 ──────────────────────────────────────────
|
||||
// 留言抽屉的「已读位点」,scan/detail.vue 里每打开一台设备的留言就写一个
|
||||
// msg_seen_<product_id>,随工人扫过的设备数无上限增长。这是目前全 App 唯一
|
||||
// 真正会无限膨胀的业务缓存。清掉只影响未读小红点会重新出现,无业务损失。
|
||||
"msg_seen_",
|
||||
|
||||
// ── 预留:以下前缀当前无数据,供新功能落缓存时对齐命名 ──────
|
||||
"draft_", // 各页面临时保存的草稿
|
||||
"cache_", // 通用业务缓存
|
||||
"search_", // 历史搜索
|
||||
"history_", // 操作历史
|
||||
"scan_", // 扫码记录
|
||||
"recent_", // 最近访问
|
||||
"tmp_", // 临时数据
|
||||
"upload_", // 未完成上传的图片本地记录
|
||||
];
|
||||
|
||||
/** 无前缀可循、需整键精确匹配的(当前为空,留作扩展位) */
|
||||
const CACHE_KEYS_EXACT = new Set();
|
||||
|
||||
/**
|
||||
* 无论如何都不允许删除的 key —— 最后一道保险。
|
||||
* 即使将来有人加了与它们撞车的前缀规则(比如为「user_viewed_tips」加了
|
||||
* `user_`),也不至于把登录凭证或环境配置连带清掉。
|
||||
*/
|
||||
const NEVER_DELETE = new Set(["access_token", "refresh_token", "token", "user", "env_base_url"]);
|
||||
|
||||
function isCacheKey(key) {
|
||||
if (NEVER_DELETE.has(key)) return false;
|
||||
if (CACHE_KEYS_EXACT.has(key)) return true;
|
||||
return CACHE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
|
||||
}
|
||||
|
||||
const appVersion = ref("T1.0.2");
|
||||
const cacheSize = ref("—");
|
||||
const cacheCount = ref(0); // 当前可清理的临时数据条数
|
||||
|
||||
/**
|
||||
* 「清理缓存」的副标题 —— 直接回答「到底清的是什么」。
|
||||
* 原先只有一个按钮,工人点完看不到任何变化,会怀疑没生效。
|
||||
*/
|
||||
const cacheSummary = computed(() => {
|
||||
if (cacheCount.value > 0) return `${cacheCount.value} 项临时数据(留言已读记录等)`;
|
||||
return "暂无临时数据可清理";
|
||||
});
|
||||
|
||||
function readAppVersion() {
|
||||
try {
|
||||
const sysInfo = uni.getSystemInfoSync();
|
||||
return sysInfo.appWgtVersion || sysInfo.appVersion || "T1.0.2";
|
||||
} catch {
|
||||
return "T1.0.2";
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新缓存信息:占用体积 + 可清理条数 —— 让「清理缓存」有个看得见的效果,而不是盲点一下 */
|
||||
function refreshCacheSize() {
|
||||
try {
|
||||
const info = uni.getStorageInfoSync() || {};
|
||||
const kb = typeof info.currentSize === "number" ? info.currentSize : null;
|
||||
cacheSize.value = kb == null ? "—" : kb < 1024 ? `${kb} KB` : `${(kb / 1024).toFixed(1)} MB`;
|
||||
cacheCount.value = (info.keys || []).filter(isCacheKey).length;
|
||||
} catch {
|
||||
cacheSize.value = "—";
|
||||
cacheCount.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理缓存 —— 逐键判定:只清 isCacheKey() 认得出来的业务缓存。
|
||||
*
|
||||
* 其余一律保留,包括登录态、环境配置,以及任何未登记的 key。
|
||||
* 逐项 try/catch,单项失败不影响其余项继续清理。
|
||||
*/
|
||||
function handleClearCache() {
|
||||
let keys = [];
|
||||
try {
|
||||
keys = (uni.getStorageInfoSync() || {}).keys || [];
|
||||
} catch (e) {
|
||||
console.error("[settings] 读取缓存列表失败:", e);
|
||||
uni.showToast({ title: "清理失败,请重试", icon: "none" });
|
||||
return;
|
||||
}
|
||||
|
||||
let removed = 0;
|
||||
let failed = 0;
|
||||
for (const key of keys) {
|
||||
if (!isCacheKey(key)) continue; // 未登记的一律保留(登录态、环境配置、未来插件配置)
|
||||
try {
|
||||
uni.removeStorageSync(key);
|
||||
removed++;
|
||||
} catch (e) {
|
||||
// 单项失败不影响其余项继续清理
|
||||
failed++;
|
||||
console.error("[settings] 清除缓存项失败:", key, e);
|
||||
}
|
||||
}
|
||||
console.log(`[settings] 缓存清理完成:已清除 ${removed} 项,失败 ${failed} 项,保留 ${keys.length - removed} 项`);
|
||||
|
||||
refreshCacheSize();
|
||||
if (failed > 0) {
|
||||
uni.showToast({ title: `清理完成,${failed} 项未能清除`, icon: "none" });
|
||||
} else if (removed === 0) {
|
||||
// 一项都没删就别报「清理成功」—— 那是在骗用户
|
||||
uni.showToast({ title: "暂无可清理的缓存", icon: "none" });
|
||||
} else {
|
||||
uni.showToast({ title: "清理成功", icon: "success" });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCheckUpdate() {
|
||||
// manual = true:跳过节流并给出「已是最新 / 检查失败」的明确反馈 ——
|
||||
// 用户主动点的按钮必须有个回音(自动检查才静默收场)
|
||||
await checkAppUpdate({ manual: true });
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
// 退出是不可逆的(要重新输账号密码),按车间使用场景加一道确认防误触
|
||||
uni.showModal({
|
||||
title: "退出登录",
|
||||
content: "退出后需要重新输入账号密码,确定退出吗?",
|
||||
confirmText: "退出",
|
||||
cancelText: "取消",
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return;
|
||||
|
||||
// 🔴 必须【先 await 上报、再清 token】——两者顺序反了或不等,退出就留不下痕:
|
||||
// 1) uni.reLaunch 会销毁页面上下文,直接掐断尚未发出的 uni.request;
|
||||
// 2) 而 request.js 是在发送时才从 storage 读 access_token,
|
||||
// 先清 storage 的话请求会不带 Authorization,后端只能记成「未认证」。
|
||||
// 这里刻意 try/catch 兜住:上报失败(断网/超时)也绝不能挡住用户退出。
|
||||
uni.showLoading({ title: "退出中...", mask: true });
|
||||
try {
|
||||
await post("/auth/logout");
|
||||
} catch (e) {
|
||||
// 静默:JWT 无状态,服务端本就不需要它成功
|
||||
console.warn("[logout] 上报失败(不影响退出)", e);
|
||||
} finally {
|
||||
uni.hideLoading();
|
||||
}
|
||||
|
||||
try {
|
||||
uni.removeStorageSync("token");
|
||||
uni.removeStorageSync("access_token");
|
||||
uni.removeStorageSync("refresh_token");
|
||||
uni.removeStorageSync("user");
|
||||
} catch {}
|
||||
uni.reLaunch({ url: "/pages/login/login" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
appVersion.value = readAppVersion();
|
||||
refreshCacheSize();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; background: #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-labels { display: flex; flex-direction: column; gap: 3px; }
|
||||
.menu-text { font-size: 14px; color: #374151; }
|
||||
.menu-sub { font-size: 11px; color: #9ca3af; }
|
||||
|
||||
.hint { margin: 14px 4px 0; font-size: 11px; color: #b0b6bf; line-height: 1.6; }
|
||||
.menu-right { display: flex; align-items: center; gap: 6px; }
|
||||
.menu-value { font-size: 12px; color: #9ca3af; }
|
||||
.menu-arrow { font-size: 18px; color: #d1d5db; }
|
||||
|
||||
.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; }
|
||||
.logout-btn::after { border: none; }
|
||||
.version { text-align: center; font-size: 11px; color: #d1d5db; margin-top: 16px; }
|
||||
</style>
|
||||
290
track-uniapp/src/pages/profile/statistics.vue
Normal file
290
track-uniapp/src/pages/profile/statistics.vue
Normal file
@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 时段选择 -->
|
||||
<view class="range-bar">
|
||||
<view v-for="r in RANGES" :key="r.key"
|
||||
:class="['range-chip', range === r.key ? 'range-chip-active' : '']"
|
||||
@tap="selectRange(r.key)">{{ r.label }}</view>
|
||||
</view>
|
||||
|
||||
<!-- 自定义时段:起止日期 -->
|
||||
<view v-if="range === 'custom'" class="custom-row">
|
||||
<picker mode="date" :value="customStart || todayStr" :end="todayStr" @change="onCustomStart">
|
||||
<view class="date-pill">{{ customStart || '开始日期' }}</view>
|
||||
</picker>
|
||||
<text class="date-sep">至</text>
|
||||
<picker mode="date" :value="customEnd || todayStr" :start="customStart" :end="todayStr" @change="onCustomEnd">
|
||||
<view class="date-pill">{{ customEnd || todayStr }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view v-if="!rangeInvalid" class="range-label">{{ rangeLabel }}</view>
|
||||
|
||||
<view v-if="loading" class="center">
|
||||
<text class="center-icon">⏳</text>
|
||||
<text class="center-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<!-- 失败也要给出口:工人不会去看控制台,只有「重试」按钮能救他 -->
|
||||
<view v-else-if="error" class="error-box">
|
||||
<text class="error-text">{{ error }}</text>
|
||||
<button class="retry-btn" @tap="load">重新加载</button>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<!-- 生产战绩 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">生产战绩</text>
|
||||
<text class="card-date">{{ rangeLabel }}</text>
|
||||
</view>
|
||||
<view class="stat-row">
|
||||
<view class="stat">
|
||||
<text class="stat-num num-done">{{ stats.tasks_completed }}</text>
|
||||
<text class="stat-label">完成</text>
|
||||
</view>
|
||||
<view class="stat-divider" />
|
||||
<view class="stat">
|
||||
<text class="stat-num" :class="stats.tasks_rejected > 0 ? 'num-reject' : 'num-zero'">
|
||||
{{ stats.tasks_rejected }}
|
||||
</text>
|
||||
<text class="stat-label">被驳回</text>
|
||||
</view>
|
||||
<view class="stat-divider" />
|
||||
<view class="stat">
|
||||
<text class="stat-num num-product">{{ stats.products_touched }}</text>
|
||||
<text class="stat-label">参与产品</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-foot">{{ recordComment }}</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作统计 —— 与 PC「人员操作统计」同一口径,方便和主管核对 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">操作统计</text>
|
||||
<text class="card-date">与管理系统同口径</text>
|
||||
</view>
|
||||
<view class="stat-row">
|
||||
<view class="stat">
|
||||
<text class="stat-num num-op">{{ stats.receive_count }}</text>
|
||||
<text class="stat-label">接收</text>
|
||||
</view>
|
||||
<view class="stat-divider" />
|
||||
<view class="stat">
|
||||
<text class="stat-num num-op">{{ stats.transfer_count }}</text>
|
||||
<text class="stat-label">转交</text>
|
||||
</view>
|
||||
<view class="stat-divider" />
|
||||
<view class="stat">
|
||||
<text class="stat-num num-op">{{ stats.record_count }}</text>
|
||||
<text class="stat-label">上传备注</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-foot">操作总计 {{ stats.op_total }} 次</view>
|
||||
</view>
|
||||
|
||||
<view class="hint">
|
||||
统计口径:完成 / 被驳回按本人名下任务计;接收、转交按实际操作人计,上传备注归到任务负责人名下。时间以北京时间为准。
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { onShow, onPullDownRefresh } from "@dcloudio/uni-app";
|
||||
import { getMyStats } from "../../api/stats";
|
||||
|
||||
const RANGES = [
|
||||
{ key: "today", label: "今日" },
|
||||
{ key: "week", label: "本周" },
|
||||
{ key: "month", label: "本月" },
|
||||
{ key: "custom", label: "自定义" },
|
||||
];
|
||||
|
||||
const range = ref("today");
|
||||
const customStart = ref("");
|
||||
const customEnd = ref("");
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const stats = ref({
|
||||
tasks_completed: 0, tasks_rejected: 0, products_touched: 0,
|
||||
receive_count: 0, transfer_count: 0, record_count: 0, op_total: 0,
|
||||
});
|
||||
|
||||
function pad(n) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
function ymd(d) {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
const todayStr = ymd(new Date());
|
||||
|
||||
/**
|
||||
* 把当前选择换算成时间段。
|
||||
*
|
||||
* 边界一律显式拼 "+08:00" —— 车间按北京时间作息,统计口径也定义在北京时间上。
|
||||
* 若图省事发不带偏移的裸字符串,后端会按服务器本地时间解释,边界整体偏 8 小时,
|
||||
* 出现「选了今日,昨晚下午的活也算进来」这种错位。
|
||||
*/
|
||||
function buildRange() {
|
||||
const now = new Date();
|
||||
let startYmd = todayStr;
|
||||
let endYmd = todayStr;
|
||||
|
||||
if (range.value === "week") {
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); // 周一为一周之始
|
||||
startYmd = ymd(d);
|
||||
} else if (range.value === "month") {
|
||||
startYmd = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-01`;
|
||||
} else if (range.value === "custom") {
|
||||
startYmd = customStart.value || todayStr;
|
||||
endYmd = customEnd.value || todayStr;
|
||||
}
|
||||
|
||||
return {
|
||||
since: `${startYmd}T00:00:00+08:00`,
|
||||
until: `${endYmd}T23:59:59+08:00`,
|
||||
label: startYmd === endYmd ? startYmd : `${startYmd} ~ ${endYmd}`,
|
||||
};
|
||||
}
|
||||
|
||||
const rangeInvalid = computed(
|
||||
() => range.value === "custom" && !!customStart.value && !!customEnd.value && customStart.value > customEnd.value
|
||||
);
|
||||
const rangeLabel = computed(() => buildRange().label);
|
||||
|
||||
const recordComment = computed(() => {
|
||||
const { tasks_completed, tasks_rejected, products_touched } = stats.value;
|
||||
if (tasks_completed === 0 && tasks_rejected === 0) return "这段时间还没有完工记录";
|
||||
if (tasks_rejected > 0) return `有 ${tasks_rejected} 单被驳回,记得跟进返工`;
|
||||
return `完成 ${tasks_completed} 单,涉及 ${products_touched} 台设备 👍`;
|
||||
});
|
||||
|
||||
function readUsername() {
|
||||
try {
|
||||
let user = uni.getStorageSync("user");
|
||||
if (typeof user === "string" && user) user = JSON.parse(user);
|
||||
return (user && user.username) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (rangeInvalid.value) {
|
||||
loading.value = false;
|
||||
error.value = "";
|
||||
return;
|
||||
}
|
||||
const username = readUsername();
|
||||
if (!username) {
|
||||
loading.value = false;
|
||||
error.value = "未获取到登录信息,请重新登录";
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const { since, until } = buildRange();
|
||||
const res = await getMyStats(username, since, until);
|
||||
stats.value = {
|
||||
tasks_completed: res?.tasks_completed || 0,
|
||||
tasks_rejected: res?.tasks_rejected || 0,
|
||||
products_touched: res?.products_touched || 0,
|
||||
receive_count: res?.receive_count || 0,
|
||||
transfer_count: res?.transfer_count || 0,
|
||||
record_count: res?.record_count || 0,
|
||||
op_total: res?.op_total || 0,
|
||||
};
|
||||
} catch (e) {
|
||||
// request.js 已经弹过一次 toast,这里只在页面上留一个可操作的出口
|
||||
error.value = e && e.isNetworkError ? "网络连接失败,请检查网络后重试" : "统计数据加载失败";
|
||||
console.error("[statistics] 加载个人统计失败:", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectRange(key) {
|
||||
if (range.value === key) return;
|
||||
range.value = key;
|
||||
// 切到自定义时先给个默认区间(本月至今),避免空值直接发请求
|
||||
if (key === "custom" && !customStart.value) {
|
||||
const now = new Date();
|
||||
customStart.value = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-01`;
|
||||
customEnd.value = todayStr;
|
||||
}
|
||||
load();
|
||||
}
|
||||
|
||||
function onCustomStart(e) {
|
||||
customStart.value = e.detail.value;
|
||||
// 开始日期越过结束日期时顺势把结束日跟过去,避免出现无效区间
|
||||
if (customEnd.value && customStart.value > customEnd.value) customEnd.value = customStart.value;
|
||||
load();
|
||||
}
|
||||
function onCustomEnd(e) {
|
||||
customEnd.value = e.detail.value;
|
||||
load();
|
||||
}
|
||||
|
||||
// 统计是「每次进来都要新鲜」的数据,用 onShow 保证从详情页返回后也刷新
|
||||
onShow(load);
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
await load();
|
||||
} finally {
|
||||
uni.stopPullDownRefresh(); // 无论成败都要收起动画
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; background: #f3f4f6; }
|
||||
|
||||
/* 时段选择 */
|
||||
.range-bar { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.range-chip { flex: 1; height: 34px; line-height: 34px; text-align: center; border-radius: 10px; background: #fff; color: #6b7280; font-size: 13px; font-weight: 600; }
|
||||
.range-chip-active { background: #2563eb; color: #fff; }
|
||||
|
||||
/* 自定义起止日期 */
|
||||
.custom-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.date-pill { flex: 1; height: 36px; line-height: 36px; text-align: center; border-radius: 10px; background: #fff; color: #374151; font-size: 13px; border: 1px solid #e5e7eb; }
|
||||
.date-sep { font-size: 12px; color: #9ca3af; }
|
||||
|
||||
.range-label { font-size: 11px; color: #9ca3af; margin: 0 2px 10px; }
|
||||
|
||||
.center { display: flex; flex-direction: column; align-items: center; padding-top: 60px; }
|
||||
.center-icon { font-size: 40px; margin-bottom: 8px; }
|
||||
.center-text { font-size: 14px; color: #9ca3af; }
|
||||
|
||||
.error-box { background: #fef2f2; border: 1px solid #fecaca; border-radius: 12px; padding: 20px 16px; display: flex; flex-direction: column; align-items: center; }
|
||||
.error-text { font-size: 14px; color: #dc2626; margin-bottom: 14px; text-align: center; }
|
||||
.retry-btn { width: 160px; height: 40px; line-height: 40px; background: #2563eb; color: #fff; border-radius: 10px; font-size: 14px; font-weight: 600; padding: 0; margin: 0; }
|
||||
.retry-btn::after { border: none; }
|
||||
|
||||
.card { background: #fff; border-radius: 14px; padding: 16px; margin-bottom: 14px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.card-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px; }
|
||||
.card-title { font-size: 16px; font-weight: 700; color: #1f2937; }
|
||||
.card-date { font-size: 11px; color: #9ca3af; }
|
||||
|
||||
.stat-row { display: flex; align-items: center; }
|
||||
.stat { flex: 1; display: flex; flex-direction: column; align-items: center; }
|
||||
.stat-divider { width: 1px; height: 40px; background: #f3f4f6; }
|
||||
.stat-num { font-size: 30px; font-weight: 800; line-height: 1.15; }
|
||||
.stat-label { font-size: 12px; color: #6b7280; margin-top: 4px; }
|
||||
.num-done { color: #16a34a; }
|
||||
.num-reject { color: #dc2626; }
|
||||
.num-zero { color: #d1d5db; }
|
||||
.num-product { color: #2563eb; }
|
||||
.num-op { color: #4f46e5; }
|
||||
|
||||
.card-foot { margin-top: 14px; padding-top: 12px; border-top: 1px solid #f3f4f6; font-size: 12px; color: #9ca3af; }
|
||||
|
||||
.hint { margin-top: 4px; padding: 0 4px; font-size: 11px; color: #b0b6bf; line-height: 1.6; }
|
||||
</style>
|
||||
106
track-uniapp/src/pages/scan/components/FlowBranch.vue
Normal file
106
track-uniapp/src/pages/scan/components/FlowBranch.vue
Normal file
@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<view class="fb-cell">
|
||||
<!-- 左翼:子分支在卡片左侧(继续向左展开) -->
|
||||
<view v-if="side === 'left' && kids.length" class="fb-kids-col">
|
||||
<FlowBranch
|
||||
v-for="k in kids"
|
||||
:key="k.id"
|
||||
:node="k"
|
||||
:side="side"
|
||||
:child-map="childMap"
|
||||
:current-user="currentUser"
|
||||
:current-user-id="currentUserId"
|
||||
:current-username="currentUsername"
|
||||
@view-records="$emit('viewRecords', $event)"
|
||||
/>
|
||||
</view>
|
||||
<!-- 左翼:卡片在右侧贴近主干道 -->
|
||||
<FlowCard
|
||||
v-if="side === 'left'"
|
||||
:node="node"
|
||||
@view-records="$emit('viewRecords', $event)"
|
||||
/>
|
||||
<!-- 横向连线箭头:从主干道指向分支(左翼←,右翼→),橙色虚线 -->
|
||||
<view class="fb-arrow" :class="'arrow-' + side">
|
||||
<view v-if="side === 'left'" class="fb-hline" />
|
||||
<text class="fb-arrow-text">{{ side === 'left' ? '←' : '→' }}</text>
|
||||
<view v-if="side === 'right'" class="fb-hline" />
|
||||
</view>
|
||||
<!-- 右翼:卡片在左侧贴近主干道 -->
|
||||
<FlowCard
|
||||
v-if="side === 'right'"
|
||||
:node="node"
|
||||
@view-records="$emit('viewRecords', $event)"
|
||||
/>
|
||||
<!-- 右翼:子分支在卡片右侧(继续向右展开) -->
|
||||
<view v-if="side === 'right' && kids.length" class="fb-kids-col">
|
||||
<FlowBranch
|
||||
v-for="k in kids"
|
||||
:key="k.id"
|
||||
:node="k"
|
||||
:side="side"
|
||||
:child-map="childMap"
|
||||
:current-user="currentUser"
|
||||
:current-user-id="currentUserId"
|
||||
:current-username="currentUsername"
|
||||
@view-records="$emit('viewRecords', $event)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FlowCard from "./FlowCard.vue";
|
||||
|
||||
export default {
|
||||
name: "FlowBranch",
|
||||
components: { FlowCard },
|
||||
props: {
|
||||
node: { type: Object, required: true },
|
||||
side: { type: String, default: "left" }, // 'left' | 'right'
|
||||
childMap: { type: Object, default: () => ({}) },
|
||||
currentUser: { type: Object, default: null },
|
||||
currentUserId: { type: [String, Number], default: "" },
|
||||
currentUsername: { type: String, default: "" },
|
||||
},
|
||||
emits: ["viewRecords"],
|
||||
computed: {
|
||||
// 该分支节点的直接子节点(进入分支后子子孙孙全部继续作为分支展开,不回主干道)
|
||||
kids() {
|
||||
return this.childMap[this.node.id] || [];
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fb-cell {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.fb-kids-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
.fb-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* 橙色虚线连接主干道与分支 */
|
||||
.fb-hline {
|
||||
width: 22px;
|
||||
border-top: 2px dashed #fb923c;
|
||||
}
|
||||
.fb-arrow-text {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #fb923c;
|
||||
}
|
||||
</style>
|
||||
175
track-uniapp/src/pages/scan/components/FlowCard.vue
Normal file
175
track-uniapp/src/pages/scan/components/FlowCard.vue
Normal file
@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<view class="fc-card" :class="[statusColorClass(node.status), { 'is-completed': isCompleted, 'is-active': isActive }]">
|
||||
<view class="fc-head">
|
||||
<!-- 主线/分支 标签(仅中央主线显示) -->
|
||||
<text v-if="showBadge" class="fc-badge" :class="isMainNode ? 'fc-badge-main' : 'fc-badge-sub'">{{ isMainNode ? '主线' : '分支' }}</text>
|
||||
<text :class="['fc-status', statusColorClass(node.status)]">{{ statusLabel(node.status) }}</text>
|
||||
<text v-if="node.is_rework" class="fc-rework">⚠返工</text>
|
||||
</view>
|
||||
<text class="fc-name">{{ node.task_name }}</text>
|
||||
<view class="fc-meta">
|
||||
<!-- 仓储系统任务:系统图标 + 固定名(assignee 为 null 不报错) -->
|
||||
<text v-if="isSystemTask" class="fc-assignee">📦 MOM 仓储系统</text>
|
||||
<!-- 在库任务显示"转入在库",其余显示负责人 -->
|
||||
<text v-else-if="isWarehouseTask" class="fc-warehouse-in">📥 转入在库: {{ formatUserName(node.assignee_id) }}</text>
|
||||
<text v-else class="fc-assignee">👤 {{ formatUserName(node.assignee_id) || '未分配' }}</text>
|
||||
</view>
|
||||
<!-- 时间独占一行 -->
|
||||
<text class="fc-time">⏰ {{ fmtDate(node.created_at) }}{{ node.completed_at ? '→' + fmtDate(node.completed_at) : '→至今' }}</text>
|
||||
<!-- 驳回/返工原因:原 REJECTED 取 reject_reason;返工节点取含“驳回/返工”的备注 -->
|
||||
<text v-if="reasonText" style="display:block;margin:8rpx 0 0;font-size:22rpx;line-height:1.4;color:#dc2626;font-weight:600;">❌ {{ reasonText }}</text>
|
||||
<!-- 卡片底部:全宽蓝色日志条(对齐 Web 端) -->
|
||||
<view v-if="node.records && node.records.length" class="ft-log-bar" @tap.stop="$emit('viewRecords', node)">
|
||||
查看操作日志 ({{ node.records.length }}条) ›
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { formatUserName as fmtUserName } from "../../../utils/format";
|
||||
|
||||
export default {
|
||||
name: "FlowCard",
|
||||
props: {
|
||||
node: { type: Object, required: true },
|
||||
showBadge: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ["viewRecords"],
|
||||
computed: {
|
||||
// 驳回/返工原因:原 REJECTED 有 reject_reason;返工节点原因在 remark(形如“返工任务(驳回自…原因…)”)
|
||||
reasonText() {
|
||||
const n = this.node || {};
|
||||
if (n.reject_reason) return `驳回: ${n.reject_reason}`;
|
||||
const rm = n.remark;
|
||||
return rm && (rm.indexOf("驳回") >= 0 || rm.indexOf("返工") >= 0) ? rm : "";
|
||||
},
|
||||
isMainNode() {
|
||||
return !this.node.parent_task_id
|
||||
|| this.node.task_type === "TRANSFER"
|
||||
|| this.node.task_type === "RECOVERY"
|
||||
|| this.node.task_type === "WAREHOUSE";
|
||||
},
|
||||
// ★ 仓储系统任务(扫码入库/扫码出库):防御性渲染,不请求用户数据
|
||||
isSystemTask() {
|
||||
const name = String(this.node.task_name || "");
|
||||
return name.includes("扫码") || this.node.task_type === "WAREHOUSE";
|
||||
},
|
||||
isWarehouseTask() {
|
||||
const name = String(this.node.task_name || "");
|
||||
return (name.includes("在库") || name.includes("入库")) || this.node.assignee_id === "virtual_warehouse";
|
||||
},
|
||||
// 进行中/待接收:WIP / PENDING / IN_PROGRESS(待收货)
|
||||
isActive() {
|
||||
return ["WIP", "PENDING", "IN_PROGRESS"].includes(this.node.status);
|
||||
},
|
||||
// 已完成 / 历史状态:非进行中
|
||||
isCompleted() {
|
||||
return !this.isActive;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatUserName(userId) {
|
||||
return fmtUserName(userId);
|
||||
},
|
||||
statusLabel(s) {
|
||||
const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", OUTBOUND: "已出库", IN_PROGRESS: "待收货" };
|
||||
return map[s] || s;
|
||||
},
|
||||
statusColorClass(s) {
|
||||
switch (s) {
|
||||
case "PENDING": return "s-yellow";
|
||||
case "WIP": return "s-blue";
|
||||
case "COMPLETED": return "s-green";
|
||||
case "REJECTED": return "s-red";
|
||||
case "ARCHIVED": return "s-archived";
|
||||
case "OUTBOUND": return "s-outbound";
|
||||
case "IN_PROGRESS": return "s-orange";
|
||||
default: return "s-gray";
|
||||
}
|
||||
},
|
||||
fmtDate(t) {
|
||||
if (!t) return "";
|
||||
const d = new Date(t);
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-left-width: 4px;
|
||||
padding: 10px 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
width: 168px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fc-card.s-yellow { border-left-color: #f59e0b; }
|
||||
.fc-card.s-blue { border-left-color: #3b82f6; }
|
||||
.fc-card.s-green { border-left-color: #22c55e; }
|
||||
.fc-card.s-red { border-left-color: #ef4444; }
|
||||
.fc-card.s-archived { border-left-color: #8b5cf6; }
|
||||
.fc-card.s-outbound { border-left-color: #4f46e5; }
|
||||
.fc-card.s-orange { border-left-color: #f97316; }
|
||||
.fc-card.s-gray { border-left-color: #9ca3af; }
|
||||
|
||||
/* 已完成/历史状态:降透明度 + 浅灰底 + 深灰文字(视觉降噪) */
|
||||
.fc-card.is-completed {
|
||||
opacity: 0.85;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
.fc-card.is-completed .fc-name,
|
||||
.fc-card.is-completed .fc-assignee,
|
||||
.fc-card.is-completed .fc-warehouse-in {
|
||||
color: #6b7280;
|
||||
}
|
||||
/* 进行中/待接收:纯白底 + 完整蓝色边框 + 外发光,浮起来 */
|
||||
.fc-card.is-active {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #93c5fd;
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.fc-head { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; flex-wrap: wrap; }
|
||||
.fc-badge { font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 4px; color: #fff; }
|
||||
.fc-badge-main { background: #2563eb; }
|
||||
.fc-badge-sub { background: #7c3aed; }
|
||||
.fc-status { font-size: 11px; font-weight: 700; padding: 1px 8px; border-radius: 10px; }
|
||||
.s-yellow .fc-status { background: #fef3c7; color: #b45309; }
|
||||
.s-blue .fc-status { background: #dbeafe; color: #1d4ed8; }
|
||||
.s-green .fc-status { background: #dcfce7; color: #15803d; }
|
||||
.s-red .fc-status { background: #fce4ec; color: #be123c; }
|
||||
.s-archived .fc-status { background: #ede9fe; color: #7c3aed; }
|
||||
.s-outbound .fc-status { background: #e0e7ff; color: #4338ca; }
|
||||
.s-orange .fc-status { background: #ffedd5; color: #ea580c; }
|
||||
.s-gray .fc-status { background: #f3f4f6; color: #6b7280; }
|
||||
.fc-rework { font-size: 10px; background: #ef4444; color: #fff; padding: 1px 5px; border-radius: 4px; font-weight: 700; }
|
||||
|
||||
.fc-name { font-size: 13px; font-weight: 700; color: #1f2937; }
|
||||
.fc-meta { margin-top: 4px; font-size: 11px; }
|
||||
.fc-assignee { color: #4b5563; }
|
||||
.fc-warehouse-in { color: #059669; font-weight: 700; }
|
||||
/* 时间独占一行 */
|
||||
.fc-time { display: block; font-size: 10px; color: #9ca3af; margin-top: 6px; }
|
||||
/* 操作日志:底部全宽蓝色条块(对齐 Web 端) */
|
||||
.ft-log-bar {
|
||||
width: calc(100% + 24px);
|
||||
margin-left: -12px;
|
||||
margin-right: -12px;
|
||||
margin-bottom: -10px;
|
||||
margin-top: 10px;
|
||||
background-color: #eff6ff;
|
||||
color: #2563eb;
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
border-top: 1px solid #dbeafe;
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
.ft-log-bar:active { background-color: #dbeafe; }
|
||||
</style>
|
||||
303
track-uniapp/src/pages/scan/components/TaskSwipeCards.vue
Normal file
303
track-uniapp/src/pages/scan/components/TaskSwipeCards.vue
Normal file
@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<view class="swipe-fullscreen">
|
||||
<!-- 顶部栏 -->
|
||||
<view class="ss-topbar">
|
||||
<view class="ss-back" @tap="$emit('back')">← 返回</view>
|
||||
<view class="ss-title-group">
|
||||
<text class="ss-title">{{ lanes[currentLane].label }}</text>
|
||||
<text class="ss-step-hint">
|
||||
步骤 {{ getCardIdx(lanes[currentLane]._key) + 1 }}/{{ lanes[currentLane].cards.length }}
|
||||
<text v-if="lanes.length > 1"> · ← 左右滑切换分支 →</text>
|
||||
</text>
|
||||
</view>
|
||||
<view class="ss-topbar-right">
|
||||
<view class="ss-overview-btn" @tap="$emit('overview')">⊡ 全览</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 🚀 水平滑动:切换分支(主分支 / 分支1 / 分支2 ...) -->
|
||||
<swiper class="ss-swiper-h" :current="currentLane" @change="onLaneSwipe"
|
||||
:style="{ height: swiperHeight + 'px' }" duration="250">
|
||||
<swiper-item v-for="(lane, li) in lanes" :key="lane._key">
|
||||
<!-- 🚀 垂直滑动:当前分支的时间线 -->
|
||||
<swiper class="ss-swiper-v" :current="getCardIdx(lane._key)" @change="onCardSwipe($event, li)"
|
||||
duration="200" vertical :style="{ height: swiperHeight + 'px' }">
|
||||
<swiper-item v-for="(card, ci) in lane.cards" :key="card._key">
|
||||
<view class="ss-card-wrapper">
|
||||
<view class="ss-card task-card"
|
||||
:class="[card._isMain ? 'task-card-main' : 'task-card-branch', taskCardClass(card)]">
|
||||
|
||||
<view v-if="card._isMain" class="tc-ribbon tc-ribbon-main">主分支</view>
|
||||
<view v-else class="tc-ribbon tc-ribbon-sub">{{ lane.label }}</view>
|
||||
|
||||
<view class="tc-head">
|
||||
<view class="tc-head-left">
|
||||
<text :class="card._isMain ? 'badge-main' : 'badge-sub'">{{ card._isMain ? '主分支' : lane.label }}</text>
|
||||
<text v-if="card.is_rework" class="tag-rework-sm">⚠ 返工</text>
|
||||
<text v-if="card.status === 'ARCHIVED'" class="tag-archived-sm">📦 入库</text>
|
||||
</view>
|
||||
<text :class="['tc-status', statusColor(card.status)]">{{ statusLabel(card.status) }}</text>
|
||||
</view>
|
||||
|
||||
<text class="tc-name">{{ card.task_name }}</text>
|
||||
|
||||
<!-- 被驳回原任务:显示驳回原因(返工节点原因由下方“备注”框承载) -->
|
||||
<text v-if="card.reject_reason" style="display:block;margin-top:6rpx;font-size:22rpx;line-height:1.4;color:#dc2626;font-weight:600;">❌ 驳回: {{ card.reject_reason }}</text>
|
||||
|
||||
<view class="tc-meta">
|
||||
<view class="tc-meta-row">
|
||||
<!-- 仓储任务:assignee 为 null,直接显示系统名,不取用户头像 -->
|
||||
<text v-if="isWarehouseTask(card)" class="tc-meta-label">📦 仓储</text>
|
||||
<text v-else class="tc-meta-label">👤 负责人</text>
|
||||
<text v-if="isWarehouseTask(card)" class="tc-meta-val">MOM 仓储系统</text>
|
||||
<text v-else class="tc-meta-val">{{ formatUserName(card.assignee_id) || '未分配' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="card.remark" class="tc-remark-box">
|
||||
<text class="tc-remark-label">📌 备注</text>
|
||||
<text class="tc-remark-text">{{ card.remark }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 提交记录:显示总数 + 可点击查看全部 -->
|
||||
<view v-if="card.records && card.records.length" class="tc-records-link"
|
||||
@tap.stop="$emit('viewRecords', card)">
|
||||
<text class="tc-records-icon">📋</text>
|
||||
<text class="tc-records-count">共 {{ card.records.length }} 条提交记录</text>
|
||||
<text class="tc-records-arrow">查看全部 ›</text>
|
||||
</view>
|
||||
|
||||
<view class="tc-stats">
|
||||
<text v-if="card.received_at" class="tc-stat">✅ 已接收</text>
|
||||
<text v-if="card.completed_at" class="tc-stat">🏁 已完工</text>
|
||||
</view>
|
||||
<view class="tc-time"><text>创建: {{ fmtTime(card.created_at) }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
|
||||
<!-- 底部:分支标签条 + 步骤进度 -->
|
||||
<view class="ss-footer">
|
||||
<view class="ss-lane-tabs">
|
||||
<view v-for="(lane, i) in lanes" :key="'lt'+i"
|
||||
:class="['ss-lane-tab', i === currentLane ? 'ss-lane-active' : '']"
|
||||
@tap="currentLane = i">
|
||||
<text :class="i === 0 ? 'ss-lane-tab-main' : 'ss-lane-tab-sub'">{{ lane.label }}</text>
|
||||
<text class="ss-lane-count">{{ lane.cards.length }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="ss-dots">
|
||||
<view v-for="(c, i) in lanes[currentLane].cards" :key="'d'+i"
|
||||
:class="['ss-dot', i === getCardIdx(lanes[currentLane]._key) ? 'ss-dot-active' : '', c._isMain ? 'ss-dot-main' : '']">
|
||||
<text class="ss-dot-label">{{ i + 1 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { formatUserName } from "../../../utils/format";
|
||||
export default {
|
||||
name: "TaskSwipeCards",
|
||||
props: {
|
||||
product: { type: Object, required: true },
|
||||
},
|
||||
emits: ["back", "overview", "viewRecords"],
|
||||
data() {
|
||||
return {
|
||||
currentLane: 0,
|
||||
cardIndices: {}, // { laneKey: currentCardIndex } — 独立管理避免computed重置
|
||||
swiperHeight: 600,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
// product 变化时重置所有滑动位置
|
||||
product: {
|
||||
immediate: true,
|
||||
handler() { this.cardIndices = {}; },
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
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 isMainFn = (t) => !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY' || t.task_type === 'WAREHOUSE';
|
||||
const traverse = (tasks, prefix) => {
|
||||
if (!tasks) return;
|
||||
let spawnIndex = 0;
|
||||
tasks.forEach((t) => {
|
||||
if (isMainFn(t)) { map[t.id] = '主分支'; traverse(t.child_tasks, prefix); }
|
||||
else { spawnIndex++; const num = prefix ? `${prefix}.${spawnIndex}` : `${spawnIndex}`; map[t.id] = `分支 ${num}`; traverse(t.child_tasks, num); }
|
||||
});
|
||||
};
|
||||
this.product.task_tree.forEach((t) => { map[t.id] = '主分支'; traverse(t.child_tasks, ''); });
|
||||
return map;
|
||||
},
|
||||
lanes() {
|
||||
const result = [];
|
||||
if (!this.product || !this.product.task_tree) return result;
|
||||
|
||||
const mainLane = { _key: 'main', label: '主分支', cards: [] };
|
||||
|
||||
const sortTasks = (tasks) => {
|
||||
if (!tasks) return [];
|
||||
return [...tasks].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
};
|
||||
|
||||
// 🚀 终极递归收集算法
|
||||
const followMain = (taskList, lane) => {
|
||||
if (!taskList || !taskList.length) return;
|
||||
const sorted = sortTasks(taskList);
|
||||
|
||||
for (const t of sorted) {
|
||||
// 1. 无条件入列:既然进到了这个 lane,就属于这个 lane 的卡片
|
||||
lane.cards.push({ ...t, _key: t.id + '_' + lane._key, _isMain: lane._key === 'main' });
|
||||
|
||||
if (t.child_tasks && t.child_tasks.length) {
|
||||
// 1. 同一分支线性延续(含仓储任务)
|
||||
const nextOnThisLane = t.child_tasks.filter(c =>
|
||||
c.task_type === 'TRANSFER' || c.task_type === 'RECOVERY' || c.task_type === 'WAREHOUSE'
|
||||
);
|
||||
followMain(nextOnThisLane, lane);
|
||||
|
||||
// 2. 凡是 SPAWN,必定开辟新分支 (不限层级)
|
||||
const spawns = t.child_tasks.filter(c => c.task_type === 'SPAWN');
|
||||
for (const sc of spawns) {
|
||||
const blabel = (this.branchLabelMap && this.branchLabelMap[sc.id]) || '协助分支';
|
||||
const branchLane = { _key: 'branch_' + sc.id, label: blabel, cards: [] };
|
||||
result.push(branchLane); // 先占坑:父分支排在前面
|
||||
followMain([sc], branchLane); // 再递归:孙子分支自然排在后面
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
result.push(mainLane);
|
||||
followMain(this.product.task_tree, mainLane);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
try {
|
||||
const info = uni.getSystemInfoSync();
|
||||
this.swiperHeight = (info.windowHeight || 600) - 130;
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
methods: {
|
||||
formatUserName,
|
||||
// ★ 仓储系统任务(扫码入库/扫码出库):assignee 为 null,防御性渲染
|
||||
isWarehouseTask(t) {
|
||||
const name = String((t && t.task_name) || "");
|
||||
return name.includes("扫码") || (t && t.task_type === "WAREHOUSE");
|
||||
},
|
||||
getCardIdx(laneKey) { return this.cardIndices[laneKey] || 0; },
|
||||
setCardIdx(laneKey, idx) { this.$set(this.cardIndices, laneKey, idx); },
|
||||
onLaneSwipe(e) { this.currentLane = e.detail.current; },
|
||||
onCardSwipe(e, laneIdx) {
|
||||
const lane = this.lanes[laneIdx];
|
||||
if (lane) {
|
||||
this.setCardIdx(lane._key, e.detail.current);
|
||||
}
|
||||
},
|
||||
taskCardClass(t) {
|
||||
if (t.status === 'ARCHIVED') return 'card-archived';
|
||||
if (t.status === 'CANCELED') return 'card-canceled';
|
||||
return '';
|
||||
},
|
||||
statusLabel(s) { const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" }; return map[s] || s; },
|
||||
statusColor(s) { switch (s) { case "PENDING": return "sc-yellow"; case "WIP": return "sc-blue"; case "COMPLETED": return "sc-green"; case "REJECTED": return "sc-red"; case "CANCELED": return "sc-canceled"; case "ARCHIVED": return "sc-purple"; default: return "sc-gray"; } },
|
||||
fmtTime(d) { if (!d) return ""; const dt = new Date(d); const pad = (n) => String(n).padStart(2, "0"); return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; },
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ss-fullscreen { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 100; display: flex; flex-direction: column; background: #e8ecf0; }
|
||||
|
||||
/* 顶部栏 */
|
||||
.ss-topbar { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; padding-top: calc(8px + 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; }
|
||||
.ss-back { font-size: 13px; font-weight: 700; color: #2563eb; padding: 5px 10px; background: #eff6ff; border-radius: 8px; }
|
||||
.ss-title-group { display: flex; flex-direction: column; align-items: center; gap: 0; flex: 1; }
|
||||
.ss-title { font-size: 15px; font-weight: 800; color: #1f2937; }
|
||||
.ss-step-hint { font-size: 10px; color: #9ca3af; }
|
||||
.ss-topbar-right { display: flex; gap: 6px; }
|
||||
.ss-overview-btn { font-size: 12px; font-weight: 700; color: #2563eb; padding: 5px 10px; background: #dbeafe; border-radius: 8px; }
|
||||
|
||||
/* 滑动区域 */
|
||||
.ss-swiper-h { width: 100%; flex: 1; min-height: 220px; }
|
||||
.ss-swiper-v { width: 100%; }
|
||||
.ss-card-wrapper { display: flex; align-items: flex-start; justify-content: center; padding: 10px 16px; height: 100%; min-height: 220px; box-sizing: border-box; }
|
||||
|
||||
/* 任务卡片 */
|
||||
.ss-card { position: relative; width: 100%; max-width: 420px; background: #fff; border-radius: 20px; padding: 18px 22px; box-shadow: 0 8px 24px rgba(0,0,0,0.1); display: flex; flex-direction: column; gap: 10px; max-height: 100%; overflow-y: auto; }
|
||||
.task-card { border-left: 10px solid #3b82f6; }
|
||||
.task-card-main { border-left-color: #2563eb; border-left-width: 12px; }
|
||||
.task-card-branch { border-left-color: #7c3aed; }
|
||||
.task-card.card-archived { border-left-color: #8b5cf6; opacity: 0.75; }
|
||||
.task-card.card-canceled { border-left-color: #9ca3af; opacity: 0.5; }
|
||||
|
||||
.tc-ribbon { position: absolute; top: 14px; right: 14px; font-size: 10px; padding: 3px 10px; border-radius: 6px; color: #fff; font-weight: 700; z-index: 2; }
|
||||
.tc-ribbon-main { background: #2563eb; }
|
||||
.tc-ribbon-sub { background: #7c3aed; }
|
||||
|
||||
.tc-head { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; }
|
||||
.tc-head-left { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.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; }
|
||||
.tag-rework-sm { font-size: 10px; padding: 2px 6px; border-radius: 6px; background: #ef4444; color: #fff; font-weight: 700; }
|
||||
.tag-archived-sm { font-size: 10px; padding: 2px 6px; border-radius: 6px; background: #ede9fe; color: #7c3aed; font-weight: 700; border: 1px dashed #a78bfa; }
|
||||
.tc-status { font-size: 12px; padding: 3px 10px; border-radius: 10px; font-weight: 700; }
|
||||
.tc-status.sc-yellow { background: #fef3c7; color: #b45309; }
|
||||
.tc-status.sc-blue { background: #dbeafe; color: #1d4ed8; }
|
||||
.tc-status.sc-green { background: #dcfce7; color: #15803d; }
|
||||
.tc-status.sc-red { background: #fce4ec; color: #be123c; }
|
||||
.tc-status.sc-purple { background: #ede9fe; color: #7c3aed; }
|
||||
.tc-status.sc-canceled { background: #f3f4f6; color: #9ca3af; text-decoration: line-through; }
|
||||
.tc-name { font-size: 20px; font-weight: 800; color: #1f2937; line-height: 1.3; }
|
||||
.tc-meta { display: flex; flex-direction: column; gap: 4px; }
|
||||
.tc-meta-row { display: flex; align-items: center; gap: 8px; }
|
||||
.tc-meta-label { font-size: 13px; color: #9ca3af; }
|
||||
.tc-meta-val { font-size: 14px; color: #374151; font-weight: 600; }
|
||||
.tc-remark-box { padding: 10px 12px; border-radius: 10px; display: flex; flex-direction: column; gap: 3px; background: #fefce8; border: 1px solid #fef08a; }
|
||||
.tc-remark-label { font-size: 12px; font-weight: 700; color: #a16207; }
|
||||
.tc-remark-text { font-size: 14px; color: #374151; line-height: 1.5; }
|
||||
|
||||
/* 提交记录链接 */
|
||||
.tc-records-link { display: flex; align-items: center; gap: 6px; padding: 10px 14px; background: linear-gradient(135deg, #eff6ff, #dbeafe); border-radius: 10px; border: 1px solid #bfdbfe; }
|
||||
.tc-records-icon { font-size: 16px; }
|
||||
.tc-records-count { flex: 1; font-size: 13px; font-weight: 700; color: #2563eb; }
|
||||
.tc-records-arrow { font-size: 12px; color: #2563eb; font-weight: 600; }
|
||||
|
||||
.tc-stats { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.tc-stat { font-size: 11px; padding: 3px 8px; border-radius: 8px; background: #f3f4f6; color: #6b7280; font-weight: 600; }
|
||||
.tc-time { font-size: 11px; color: #9ca3af; padding-top: 6px; border-top: 1px solid #f3f4f6; }
|
||||
|
||||
/* 底部 */
|
||||
.ss-footer { flex-shrink: 0; background: rgba(255,255,255,0.95); padding-bottom: calc(6px + env(safe-area-inset-bottom)); }
|
||||
.ss-lane-tabs { display: flex; gap: 4px; padding: 6px 12px; overflow-x: auto; }
|
||||
.ss-lane-tab { display: flex; align-items: center; gap: 4px; padding: 5px 12px; border-radius: 14px; background: #f3f4f6; font-size: 12px; font-weight: 600; white-space: nowrap; flex-shrink: 0; }
|
||||
.ss-lane-tab-main { color: #2563eb; }
|
||||
.ss-lane-tab-sub { color: #7c3aed; }
|
||||
.ss-lane-active { background: #2563eb; }
|
||||
.ss-lane-active .ss-lane-tab-main { color: #fff; }
|
||||
.ss-lane-active .ss-lane-tab-sub { color: #ddd6fe; }
|
||||
.ss-lane-count { font-size: 10px; color: #9ca3af; background: #fff; padding: 1px 6px; border-radius: 8px; }
|
||||
.ss-lane-active .ss-lane-count { color: #2563eb; }
|
||||
|
||||
.ss-dots { display: flex; justify-content: center; gap: 5px; padding: 4px 0; overflow-x: auto; }
|
||||
.ss-dot { width: 20px; height: 20px; border-radius: 10px; background: #e5e7eb; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all 0.2s; }
|
||||
.ss-dot-active { width: 26px; height: 26px; border-radius: 13px; background: #2563eb; }
|
||||
.ss-dot-main { background: #bfdbfe; }
|
||||
.ss-dot-label { font-size: 9px; font-weight: 700; color: #9ca3af; }
|
||||
.ss-dot-active .ss-dot-label { color: #fff; }
|
||||
</style>
|
||||
212
track-uniapp/src/pages/scan/components/TreeCanvas.vue
Normal file
212
track-uniapp/src/pages/scan/components/TreeCanvas.vue
Normal file
@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<view class="tree-fullscreen">
|
||||
<view class="tc-toolbar">
|
||||
<view class="tc-toolbar-left">
|
||||
<view class="tc-back-btn" @tap="$emit('back')">← 返回</view>
|
||||
<text class="tc-title">🌳 流转树</text>
|
||||
</view>
|
||||
<view class="tc-toolbar-right">
|
||||
<view class="tc-zoom-btn tc-mode-btn" @tap="$emit('swipe')">📇 卡片</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>
|
||||
|
||||
<!-- 双向滚动:横向看分支、纵向看全部主线行(否则底部进行中任务会被截断) -->
|
||||
<scroll-view v-else scroll-x scroll-y class="tree-scroll" :style="{ height: scrollHeight + 'px' }">
|
||||
<view class="tree-canvas" :style="{ width: canvasWidth + 'px' }">
|
||||
<!-- 每条主线一个 Row:[左翼] - [中央主线卡片] - [右翼] -->
|
||||
<view v-for="(mainTask, idx) in allMains" :key="mainTask.id" class="flow-row">
|
||||
<!-- 中央主干道垂直线:首行从卡片中间起、末行到卡片中间止,避免冒出/多截 -->
|
||||
<view
|
||||
class="flow-vline"
|
||||
:style="{
|
||||
top: idx === 0 ? '50%' : '0',
|
||||
bottom: idx === allMains.length - 1 ? '50%' : '0'
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- 左翼:奇数分支(向下/左展开) -->
|
||||
<view class="flow-side flow-left">
|
||||
<FlowBranch
|
||||
v-for="b in leftDirect(mainTask)"
|
||||
:key="b.id"
|
||||
:node="b"
|
||||
side="left"
|
||||
:child-map="childMap"
|
||||
:current-user="currentUser"
|
||||
:current-user-id="currentUserId"
|
||||
:current-username="currentUsername"
|
||||
@view-records="$emit('viewRecords', $event)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 中央主线卡片 -->
|
||||
<view class="flow-center">
|
||||
<FlowCard
|
||||
:node="mainTask"
|
||||
:show-badge="true"
|
||||
@view-records="$emit('viewRecords', $event)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 右翼:偶数分支(向右展开) -->
|
||||
<view class="flow-side flow-right">
|
||||
<FlowBranch
|
||||
v-for="b in rightDirect(mainTask)"
|
||||
:key="b.id"
|
||||
:node="b"
|
||||
side="right"
|
||||
:child-map="childMap"
|
||||
:current-user="currentUser"
|
||||
:current-user-id="currentUserId"
|
||||
:current-username="currentUsername"
|
||||
@view-records="$emit('viewRecords', $event)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FlowCard from "./FlowCard.vue";
|
||||
import FlowBranch from "./FlowBranch.vue";
|
||||
|
||||
export default {
|
||||
name: "TreeCanvas",
|
||||
components: { FlowCard, FlowBranch },
|
||||
props: {
|
||||
product: { type: Object, required: true },
|
||||
currentUser: { type: Object, default: null },
|
||||
currentUserId: { type: [String, Number], default: "" },
|
||||
currentUsername: { type: String, default: "" },
|
||||
},
|
||||
emits: ["viewRecords", "back", "swipe"],
|
||||
data() {
|
||||
return { scrollHeight: 500 };
|
||||
},
|
||||
computed: {
|
||||
// 复刻 Web 端:递归拍平 + childMap(parent_task_id → 子数组)
|
||||
childMap() {
|
||||
const flat = [];
|
||||
const walk = (tasks) => { (tasks || []).forEach((t) => { flat.push(t); walk(t.child_tasks); }); };
|
||||
walk(this.product.task_tree);
|
||||
const map = {};
|
||||
flat.forEach((t) => {
|
||||
const pid = t.parent_task_id || "";
|
||||
if (!map[pid]) map[pid] = [];
|
||||
map[pid].push(t);
|
||||
});
|
||||
Object.values(map).forEach((arr) => arr.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)));
|
||||
return map;
|
||||
},
|
||||
// 所有主线任务,按创建时间排序
|
||||
allMains() {
|
||||
const flat = [];
|
||||
const walk = (tasks) => { (tasks || []).forEach((t) => { flat.push(t); walk(t.child_tasks); }); };
|
||||
walk(this.product.task_tree);
|
||||
const mains = flat.filter((t) => this.isMain(t));
|
||||
mains.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
return mains;
|
||||
},
|
||||
// 横向画布宽度:中央 + 左右翼展开空间(scroll-x 用)
|
||||
canvasWidth() {
|
||||
return 900;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isMain(t) {
|
||||
return !t.parent_task_id || t.task_type === "TRANSFER" || t.task_type === "RECOVERY" || t.task_type === "WAREHOUSE";
|
||||
},
|
||||
// 当前主线的直接分支子节点(非主线)
|
||||
branchChildren(mainTask) {
|
||||
return (this.childMap[mainTask.id] || []).filter((c) => !this.isMain(c));
|
||||
},
|
||||
// 左翼(奇偶拆分)
|
||||
leftDirect(mainTask) {
|
||||
return this.branchChildren(mainTask).filter((_, i) => i % 2 === 0);
|
||||
},
|
||||
// 右翼
|
||||
rightDirect(mainTask) {
|
||||
return this.branchChildren(mainTask).filter((_, i) => i % 2 === 1);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
try {
|
||||
const info = uni.getSystemInfoSync();
|
||||
this.scrollHeight = Math.max((info.windowHeight || 600) - 70, 300);
|
||||
} catch (e) {}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tree-fullscreen {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.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: 999;
|
||||
}
|
||||
.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-toolbar-right { display: flex; align-items: center; gap: 8px; }
|
||||
.tc-zoom-btn { display: flex; align-items: center; justify-content: center; }
|
||||
.tc-mode-btn { width: auto; padding: 0 10px; font-size: 12px; background: #ede9fe; color: #7c3aed; border-radius: 8px; height: 30px; }
|
||||
.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; }
|
||||
|
||||
.tree-scroll { flex-shrink: 0; }
|
||||
.tree-canvas { display: flex; flex-direction: column; padding: 16px 12px; }
|
||||
|
||||
/* 每条主线一行 */
|
||||
.flow-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
padding: 20px 0;
|
||||
}
|
||||
/* 中央主干道垂直线(绝对定位,穿透整行) */
|
||||
.flow-vline {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #d1d5db;
|
||||
transform: translateX(-50%);
|
||||
z-index: 0;
|
||||
}
|
||||
/* 左右翼容器:各占一半,分支从中央向两侧展开 */
|
||||
.flow-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.flow-left { align-items: flex-end; padding-right: 10px; }
|
||||
.flow-right { align-items: flex-start; padding-left: 10px; }
|
||||
/* 中央卡片 */
|
||||
.flow-center { z-index: 2; flex-shrink: 0; }
|
||||
</style>
|
||||
318
track-uniapp/src/pages/scan/components/WorkspaceArea.vue
Normal file
318
track-uniapp/src/pages/scan/components/WorkspaceArea.vue
Normal file
@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<view class="workspace-area" :class="{ 'is-locked': !!lockedTaskId }">
|
||||
<!-- 0任务 -->
|
||||
<view v-if="!product.task_tree || !product.task_tree.length" class="zero-task">
|
||||
<text class="zero-icon">📋</text>
|
||||
<text class="zero-text">该产品暂无流转任务</text>
|
||||
</view>
|
||||
|
||||
<!-- 无本人任务 -->
|
||||
<view v-else-if="!focusTasks.length" class="zero-task">
|
||||
<text class="zero-icon">🔒</text>
|
||||
<text class="zero-text">当前没有需要你处理的任务</text>
|
||||
<text class="task-count">共 {{ countTasks(product.task_tree) }} 个任务</text>
|
||||
</view>
|
||||
|
||||
<!-- 任务列表(未锁定) -->
|
||||
<template v-else-if="!lockedTaskId">
|
||||
<view class="list-header">
|
||||
<text class="list-title">📋 待处理任务 ({{ focusTasks.length }})</text>
|
||||
<text class="list-hint">点击任务进入绝对锁定工作区</text>
|
||||
</view>
|
||||
<view class="task-list">
|
||||
<view v-for="t in focusTasks" :key="t.id" class="task-list-item"
|
||||
:class="statusColor(t.status)" @tap="lockTask(t.id)">
|
||||
<view class="tli-left">
|
||||
<text class="tag-branch">{{ branchLabelsMap[t.id] || '' }}</text>
|
||||
<text class="tli-name">{{ t.task_name }}</text>
|
||||
<text class="tli-assignee">→ {{ formatUserName(t.assignee_id) || '未分配' }}</text>
|
||||
<text v-if="getTaskRemark(t)" class="tli-remark">{{ getTaskRemark(t) }}</text>
|
||||
</view>
|
||||
<view class="tli-right">
|
||||
<text :class="['tli-badge', statusColor(t.status)]">{{ statusLabel(t.status) }}</text>
|
||||
<text v-if="t.is_rework" class="tag tag-rework-sm" style="margin-top:2px;">⚠返工</text>
|
||||
<text class="tli-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 锁定工作区(单任务详情) -->
|
||||
<template v-else>
|
||||
<view class="lock-bar">
|
||||
<text class="lock-back" @tap="lockedTaskId = null">← 返回列表</text>
|
||||
<text class="lock-title">🔒 沉浸工作区</text>
|
||||
</view>
|
||||
|
||||
<view v-if="isAdminProxy" class="admin-proxy-notice">
|
||||
<text class="proxy-text">⚠️ 正在以管理员身份代 [{{ formatUserName(lockedTask.assignee_id) || lockedTask.assignee_id }}] 操作</text>
|
||||
</view>
|
||||
|
||||
<view class="focus-card" :class="statusColor(lockedTask.status)">
|
||||
<view class="fc-header">
|
||||
<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.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>
|
||||
|
||||
<view v-if="getTaskRemark(lockedTask)" style="margin-top:20rpx;padding:20rpx;background:#FFFBE8;border-left:8rpx solid #FADB14;border-radius:12rpx;">
|
||||
<text style="font-size:26rpx;color:#8C6A00;font-weight:bold;">📌 初始/交接备注:</text>
|
||||
<view style="font-size:28rpx;color:#333;margin-top:10rpx;">{{ getTaskRemark(lockedTask) }}</view>
|
||||
</view>
|
||||
|
||||
<view v-if="lockedTask.parent_task_id && parentTaskOf(lockedTask)" class="fc-link fc-up">
|
||||
<text class="fc-link-label">{{ isNestedSpawn ? '⬆ 上游协助 (嵌套)' : '⬆ 上游工序' }}</text>
|
||||
<text class="fc-link-name">{{ parentTaskOf(lockedTask).task_name }} → {{ formatUserName(lockedTask.created_by) || formatUserName(parentTaskOf(lockedTask).assignee_id) || '—' }}</text>
|
||||
</view>
|
||||
<view v-if="lockedTask.child_tasks && lockedTask.child_tasks.length" class="fc-link fc-down">
|
||||
<text class="fc-link-label">⬇ 下游分支 ({{ lockedTask.child_tasks.length }})</text>
|
||||
<text v-for="c in lockedTask.child_tasks" :key="c.id" class="fc-link-name">
|
||||
· {{ c.task_name }} → {{ formatUserName(c.assignee_id) || '—' }}
|
||||
<text v-if="c.status==='COMPLETED'" class="branch-done">✓已完成</text>
|
||||
<text v-else-if="c.status==='ARCHIVED'" class="branch-done">📦已入库</text>
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-if="lockedTask.records && lockedTask.records.length" class="fc-records-bar" @tap="$emit('viewRecords', lockedTask)">
|
||||
📋 {{ lockedTask.records.length }} 条干活记录 ›
|
||||
</view>
|
||||
<view class="fc-time">{{ formatTaskTime(lockedTask) }}</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isAssignee" class="footer-actions">
|
||||
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-transfer"
|
||||
@tap="$emit('action', { task: lockedTask, type: 'transfer' })"><text class="btn-icon">🔄</text><text class="btn-txt">完工转交</text></button>
|
||||
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-record"
|
||||
@tap="$emit('action', { task: lockedTask, type: 'record' })"><text class="btn-icon">📝</text><text class="btn-txt">记录拍照</text></button>
|
||||
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-spawn"
|
||||
@tap="$emit('action', { task: lockedTask, type: 'spawn' })"><text class="btn-icon">➕</text><text class="btn-txt">派发协助</text></button>
|
||||
<button v-if="lockedTask.status === 'PENDING'" class="footer-btn footer-receive"
|
||||
@tap="$emit('action', { task: lockedTask, type: 'receive' })"><text class="btn-icon">✅</text><text class="btn-txt">接收任务</text></button>
|
||||
<button v-if="lockedTask.status === 'PENDING'" class="footer-btn footer-reject"
|
||||
@tap="$emit('action', { task: lockedTask, type: 'reject' })"><text class="btn-icon">❌</text><text class="btn-txt">驳回任务</text></button>
|
||||
</view>
|
||||
<view v-else-if="lockedTask.status === 'PENDING' && canRecall" class="footer-actions">
|
||||
<button class="footer-btn footer-recall"
|
||||
@tap="$emit('action', { task: lockedTask, type: 'recall' })"><text class="btn-icon">🔄</text><text class="btn-txt">撤回转交</text></button>
|
||||
</view>
|
||||
<view v-else class="footer-actions footer-readonly">
|
||||
<text class="readonly-hint">🔒 非当前任务指派人,仅可查看</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { formatUserName } from "../../../utils/format";
|
||||
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", OUTBOUND: "已出库", CANCELED: "已撤回" };
|
||||
|
||||
export default {
|
||||
name: "WorkspaceArea",
|
||||
props: {
|
||||
product: { type: Object, default: null },
|
||||
currentUserId: { type: String, default: "" },
|
||||
currentUsername: { type: String, default: "" },
|
||||
currentUserRole: { 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 = {};
|
||||
const walk = (tasks) => { if (!tasks) return; for (const t of tasks) { m[t.id] = t; walk(t.child_tasks); } };
|
||||
if (this.product) walk(this.product.task_tree);
|
||||
return m;
|
||||
},
|
||||
focusTasks() {
|
||||
const result = [];
|
||||
const isMainFn = (t) => !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY' || t.task_type === 'WAREHOUSE';
|
||||
const walk = (tasks) => {
|
||||
if (!tasks) return;
|
||||
for (const t of tasks) {
|
||||
if (t.status === 'WIP' || t.status === 'PENDING') result.push(t);
|
||||
walk(t.child_tasks);
|
||||
}
|
||||
};
|
||||
if (this.product) walk(this.product.task_tree);
|
||||
result.sort((a, b) => {
|
||||
const aIsMain = isMainFn(a);
|
||||
const bIsMain = isMainFn(b);
|
||||
if (aIsMain && !bIsMain) return -1;
|
||||
if (!aIsMain && bIsMain) return 1;
|
||||
return new Date(a.created_at) - new Date(b.created_at);
|
||||
});
|
||||
return result;
|
||||
},
|
||||
lockedTask() { return this.focusTasks.find(t => t.id === this.lockedTaskId) || null; },
|
||||
isAdmin() { return this.currentUserRole === 'SUPER_ADMIN' || this.currentUserRole === 'SUPERVISOR'; },
|
||||
isAssignee() {
|
||||
if (!this.lockedTask) return false;
|
||||
if (this.isAdmin) return true; // 管理员上帝视角
|
||||
return this.lockedTask.assignee_id == this.currentUserId
|
||||
|| this.lockedTask.assignee_id == this.currentUsername;
|
||||
},
|
||||
isAdminProxy() {
|
||||
// 管理员正在代操作非本人任务
|
||||
return this.isAdmin && this.lockedTask
|
||||
&& this.lockedTask.assignee_id != this.currentUserId
|
||||
&& this.lockedTask.assignee_id != this.currentUsername;
|
||||
},
|
||||
canRecall() {
|
||||
if (!this.lockedTask || this.lockedTask.status !== 'PENDING') return false;
|
||||
if (this.isAdmin) return true; // 管理员可撤回任何转交
|
||||
if (this.isAssignee) return false;
|
||||
if (!this.lockedTask.parent_task_id) return false;
|
||||
const parent = this.taskMap[this.lockedTask.parent_task_id];
|
||||
if (!parent) return false;
|
||||
return parent.assignee_id == this.currentUserId || parent.assignee_id == this.currentUsername;
|
||||
},
|
||||
// 🚀 嵌套协助判定:直接父任务不是主线 → 孙子/曾孙协助
|
||||
isNestedSpawn() {
|
||||
if (!this.lockedTask || !this.lockedTask.parent_task_id) return false;
|
||||
const parent = this.taskMap[this.lockedTask.parent_task_id];
|
||||
if (!parent) return false;
|
||||
return parent.parent_task_id && parent.task_type !== 'TRANSFER' && parent.task_type !== 'RECOVERY';
|
||||
},
|
||||
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 isMainFn = (t) => !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY' || t.task_type === 'WAREHOUSE';
|
||||
|
||||
const traverse = (tasks, prefix) => {
|
||||
if (!tasks) return;
|
||||
let spawnIndex = 0;
|
||||
tasks.forEach((t) => {
|
||||
if (isMainFn(t)) {
|
||||
map[t.id] = '主分支';
|
||||
traverse(t.child_tasks, prefix);
|
||||
} else {
|
||||
spawnIndex++;
|
||||
const num = prefix ? `${prefix}.${spawnIndex}` : `${spawnIndex}`;
|
||||
map[t.id] = `分支 ${num}`;
|
||||
traverse(t.child_tasks, num);
|
||||
}
|
||||
});
|
||||
};
|
||||
this.product.task_tree.forEach((t) => {
|
||||
map[t.id] = '主分支';
|
||||
traverse(t.child_tasks, '');
|
||||
});
|
||||
return map;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatUserName,
|
||||
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"; } },
|
||||
countTasks(tree) { return tree ? tree.reduce((s, t) => s + 1 + this.countTasks(t.child_tasks), 0) : 0; },
|
||||
getTaskRemark(t) { if (!t) return ""; if (t.remark) return t.remark; if (t.records && t.records.length > 0) { const recs = [...t.records].reverse(); const rec = recs.find(r => r.remark && r.remark.length > 0); if (rec) return rec.remark; } return ""; },
|
||||
lockTask(taskId) { this.lockedTaskId = taskId; },
|
||||
parentTaskOf(t) { return t && t.parent_task_id ? (this.taskMap[t.parent_task_id] || null) : null; },
|
||||
formatTaskTime(t) { if (!t) return ""; const parts = []; if (t.created_at) parts.push("创建: " + this.formatTime(t.created_at)); if (t.received_at) parts.push("接收: " + this.formatTime(t.received_at)); return parts.join(" | "); },
|
||||
formatTime(d) { if (!d) return ""; const dt = new Date(d); const pad = (n) => String(n).padStart(2, "0"); return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; },
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workspace-area { height: auto; display: flex; flex-direction: column; }
|
||||
/* 沉浸工作区(is-locked)不再锁死 height:100vh + overflow:hidden ——
|
||||
那会造出「卡片内部滚动 + 原生页面滚动」两个滚动容器并存的局面:卡片滚到顶
|
||||
之后再往下拉,手势会穿透到页面层,误触发下拉刷新,工人根本没法往上翻内容。
|
||||
高度交还给内容自然撑开,滚动统一由原生页面负责。 */
|
||||
.workspace-area.is-locked { height: auto; }
|
||||
.list-header { display: flex; align-items: baseline; justify-content: space-between; padding: 8px 4px; flex-shrink: 0; }
|
||||
.list-title { font-size: 14px; font-weight: 700; color: #1f2937; }
|
||||
.list-hint { font-size: 11px; color: #9ca3af; }
|
||||
.task-list { height: auto; }
|
||||
.task-list-item { display: flex; align-items: flex-start; justify-content: space-between; background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); border-left: 4px solid transparent; }
|
||||
.task-list-item.s-yellow { border-left-color: #f59e0b; }
|
||||
.task-list-item.s-blue { border-left-color: #3b82f6; }
|
||||
.tli-left { flex: 1; min-width: 0; }
|
||||
.tag-branch { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #ede9fe; color: #7c3aed; font-weight: 700; display: inline-block; width: max-content; margin-bottom: 4px; }
|
||||
.tli-name { font-size: 15px; font-weight: 700; color: #1f2937; display: block; }
|
||||
.tli-assignee { font-size: 11px; color: #9ca3af; display: block; margin-top: 2px; }
|
||||
.tli-remark { font-size: 12px; color: #a16207; font-weight: bold; margin-top: 4px; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tli-right { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex-shrink: 0; margin-left: 8px; }
|
||||
.tli-badge { font-size: 11px; padding: 2px 10px; border-radius: 12px; font-weight: 600; }
|
||||
.tli-badge.s-yellow { background: #fef3c7; color: #b45309; }
|
||||
.tli-badge.s-blue { background: #dbeafe; color: #1d4ed8; }
|
||||
.tli-arrow { font-size: 18px; color: #d1d5db; }
|
||||
.lock-bar { display: flex; align-items: center; gap: 12px; padding: 8px 0; flex-shrink: 0; }
|
||||
.lock-back { font-size: 13px; color: #2563eb; font-weight: 600; }
|
||||
.lock-title { font-size: 14px; font-weight: 700; color: #1f2937; }
|
||||
.branch-done { font-size: 11px; font-weight: 600; color: #16a34a; }
|
||||
.task-count { font-size: 12px; color: #9ca3af; display: block; margin-top: 4px; }
|
||||
/* 去掉 overflow-y: auto —— 锁定任务详情不再自己滚,随页面一起滚。
|
||||
padding-bottom 仍保留 160rpx,用来让内容避开底部固定操作栏。 */
|
||||
.focus-card { min-height: 0; margin: 0 4px; padding: 20px 16px calc(160rpx + env(safe-area-inset-bottom)); border-radius: 16px; background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,0.08); border-top: 5px solid #3b82f6; }
|
||||
.focus-card.s-yellow { border-top-color: #f59e0b; }
|
||||
.focus-card.s-blue { border-top-color: #3b82f6; }
|
||||
.focus-card.s-green { border-top-color: #22c55e; }
|
||||
.focus-card.s-red { border-top-color: #ef4444; }
|
||||
.fc-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.fc-status { font-size: 12px; padding: 2px 10px; border-radius: 12px; font-weight: 700; background: #f3f4f6; color: #6b7280; }
|
||||
.s-yellow .fc-status { background: #fef3c7; color: #b45309; }
|
||||
.s-blue .fc-status { background: #dbeafe; color: #1d4ed8; }
|
||||
.tag-rework-sm { font-size: 10px; padding: 2px 6px; border-radius: 8px; background: #ef4444; color: #fff; }
|
||||
.sub-branch-end { font-size: 11px; color: #ef4444; font-weight: 600; margin-left: auto; padding: 2px 6px; }
|
||||
.fc-name { font-size: 22px; font-weight: 800; color: #1f2937; display: block; margin-bottom: 10px; }
|
||||
.fc-link { padding: 6px 10px; border-radius: 8px; margin-bottom: 4px; font-size: 12px; margin-top: 10px; }
|
||||
.fc-up { background: #f0fdf4; color: #16a34a; }
|
||||
.fc-down { background: #eff6ff; color: #2563eb; }
|
||||
.fc-link-label { font-weight: 700; display: block; }
|
||||
.fc-link-name { display: block; margin-top: 2px; }
|
||||
.fc-records-bar { padding: 8px 12px; background: linear-gradient(135deg,#eff6ff,#dbeafe); border-radius: 8px; font-size: 13px; font-weight: 700; color: #2563eb; margin-top: 10px; margin-bottom: 8px; }
|
||||
.fc-time { font-size: 11px; color: #9ca3af; margin-top: auto; padding-top: 8px; border-top: 1px solid #f3f4f6; }
|
||||
.zero-task { display: flex; flex-direction: column; align-items: center; padding: 24px 0; }
|
||||
.zero-icon { font-size: 40px; margin-bottom: 8px; }
|
||||
.zero-text { font-size: 14px; color: #9ca3af; }
|
||||
.footer-actions { position: fixed; left: 0; right: 0; bottom: 0; z-index: 50; display: flex; gap: 12rpx; padding: 16rpx 20rpx; padding-bottom: calc(16rpx + env(safe-area-inset-bottom)); background: #ffffff; border-top: 1px solid #e5e7eb; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.05); }
|
||||
.footer-btn { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100rpx; border: none; border-radius: 16rpx; line-height: 1.2; padding: 0; }
|
||||
.footer-btn::after { border: none; }
|
||||
.btn-icon { font-size: 36rpx; margin-bottom: 6rpx; }
|
||||
.btn-txt { font-size: 24rpx; font-weight: 700; }
|
||||
.footer-readonly { justify-content: center; background: #fef2f2; }
|
||||
.admin-proxy-notice { padding: 10rpx 20rpx; background: #fef9e7; border-bottom: 2rpx solid #fde68a; flex-shrink: 0; }
|
||||
.proxy-text { font-size: 22rpx; color: #b45309; font-weight: 600; }
|
||||
.readonly-hint { font-size: 24rpx; color: #dc2626; font-weight: 600; }
|
||||
.footer-transfer { background: #dcfce7; color: #16a34a; }
|
||||
.footer-record { background: #eff6ff; color: #2563eb; }
|
||||
.footer-receive { background: #dbeafe; color: #1d4ed8; }
|
||||
.footer-reject { background: #fce4ec; color: #dc2626; }
|
||||
.footer-end { background: #fef3c7; color: #b45309; }
|
||||
.footer-spawn { background: #ede9fe; color: #7c3aed; }
|
||||
.footer-recall { background: #fef2f2; color: #dc2626; }
|
||||
</style>
|
||||
858
track-uniapp/src/pages/scan/detail.vue
Normal file
858
track-uniapp/src/pages/scan/detail.vue
Normal file
@ -0,0 +1,858 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<view v-if="loading" class="loading">加载中...</view>
|
||||
<view v-if="error" class="error-box">{{ error }}</view>
|
||||
|
||||
<template v-if="product && !loading">
|
||||
<!-- 工作区模式:显示产品信息栏 -->
|
||||
<template v-if="currentMode === 'workspace'">
|
||||
<view :key="'prod-card-' + dictVersion">
|
||||
<view class="overall-bar" @tap="handleOverallBarClick">
|
||||
<text class="overall-label">宏观状态</text>
|
||||
<text :class="['overall-val', overallStatusClass(product.overall_status)]">{{ product.overall_status || '未激活 — 点击发起首道工序' }}</text>
|
||||
<!-- 🔧 生命周期标识:生产阶段「发货测试」 vs 出库回流后「售后维修」 -->
|
||||
<text v-if="lifeBadge" :class="['life-badge', lifeBadge.cls]">{{ lifeBadge.label }}</text>
|
||||
<text v-if="product.task_tree && product.task_tree.length && canEditOverallStatus" class="overall-arrow">▾</text>
|
||||
</view>
|
||||
|
||||
<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>
|
||||
<text class="print-label-btn" @tap="printLabel">🖨️ 打印标签</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' : '']">{{ formatUserName(product.current_location_id) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="showDispatchBanner"
|
||||
class="warehouse-transfer-banner" @tap="openCreateFirstTask">
|
||||
<text class="wt-icon">{{ dispatchBannerIcon }}</text>
|
||||
<text class="wt-text">{{ dispatchBannerText }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 工作区视图 -->
|
||||
<WorkspaceArea v-if="currentMode === 'workspace'" :product="product"
|
||||
:currentUserId="currentUserId" :currentUsername="currentUsername"
|
||||
:currentUserRole="currentUserRole"
|
||||
:initialLockTaskId="autoLockTaskId"
|
||||
:key="'wa-' + dictVersion"
|
||||
@action="handleTaskAction" @viewRecords="handleViewRecords" />
|
||||
|
||||
<!-- 📇 流转卡片:探探式单张滑动 -->
|
||||
<TaskSwipeCards v-if="currentMode === 'swipe'" :product="product"
|
||||
:key="'sw-' + dictVersion"
|
||||
@back="currentMode = 'workspace'" @overview="currentMode = 'tree'"
|
||||
@viewRecords="handleViewRecords" />
|
||||
|
||||
<!-- 🌳 流转树:全屏独立视图 -->
|
||||
<TreeCanvas v-if="currentMode === 'tree'" :product="product" :key="'tc-' + dictVersion"
|
||||
:currentUser="currentUser" :currentUserId="currentUserId" :currentUsername="currentUsername"
|
||||
@viewRecords="handleViewRecords" @back="currentMode = 'workspace'" @swipe="currentMode = 'swipe'" />
|
||||
|
||||
</template>
|
||||
|
||||
<!-- 状态定调 -->
|
||||
<view v-if="showStatusPicker" class="overlay" @tap="() => {}">
|
||||
<view class="sheet">
|
||||
<text class="sheet-title">{{ product && product.overall_status ? '修改宏观状态' : '🔔 请设定产品宏观状态' }}</text>
|
||||
<text class="sheet-hint">首次扫码,请选择一个状态以开启流转</text>
|
||||
<view class="sheet-options">
|
||||
<view v-for="opt in availableOverallOptions" :key="opt" :class="['sheet-opt', product && product.overall_status === opt ? 'sheet-opt-active' : '']" @tap="handleSetOverallStatus(opt)"><text>{{ opt }}</text></view>
|
||||
</view>
|
||||
<button v-if="product && product.overall_status" class="sheet-close" @tap="showStatusPicker = false">关闭</button>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 编辑产品 -->
|
||||
<view v-if="editProductVisible" class="overlay" @tap="editProductVisible = false">
|
||||
<view class="popup" @tap.stop>
|
||||
<text class="popup-title">编辑产品</text>
|
||||
<view class="field-label">订单编号</view>
|
||||
<input v-model="editForm.order_no" class="popup-input" placeholder="请输入订单编号" />
|
||||
<view class="field-label" style="margin-top:10px;">产品序列号</view>
|
||||
<input v-model="editForm.external_serial" class="popup-input" placeholder="请输入产品序列号" />
|
||||
<view class="popup-btns"><button class="btn-cancel" @tap="editProductVisible = false">取消</button><button class="btn-primary" :disabled="editSaving" @tap="doEditProduct">{{ editSaving ? '保存中...' : '保存' }}</button></view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 发起首道工序 (只选人+填备注) -->
|
||||
<view v-if="createFirstVisible" class="overlay" @tap="createFirstVisible = false">
|
||||
<view class="popup" @tap.stop>
|
||||
<text class="popup-title">{{ isWarehouseTransfer ? '📤 仓库转出派发' : '🚀 发起首道工序' }}</text>
|
||||
<view class="field-label">接收人 <text class="required">*</text></view>
|
||||
<view class="user-grid">
|
||||
<view v-for="u in userGridOptions" :key="u.id"
|
||||
:class="['user-grid-item', firstForm.assignee_id === u.id ? 'user-grid-active' : '']"
|
||||
@tap="firstForm.assignee_id = u.id; firstForm.assigneeLabel = u.name">{{ formatName(u.name) }}</view>
|
||||
</view>
|
||||
<view class="field-label" style="margin-top:12px;">备注 <text class="required">*</text></view>
|
||||
<textarea v-model="firstForm.note" class="popup-textarea" placeholder="请填写备注说明(必填)" :maxlength="500" />
|
||||
<view class="popup-btns"><button class="btn-cancel" @tap="createFirstVisible = false">取消</button><button class="btn-primary" :disabled="firstSaving || !firstForm.assignee_id || !firstForm.note.trim()" @tap="doCreateFirstTask">{{ firstSaving ? '创建中...' : '确认创建' }}</button></view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 记录/拍照 -->
|
||||
<view v-if="recordPopup.visible" class="overlay" @tap="closeRecordPopup">
|
||||
<view class="popup" @tap.stop>
|
||||
<text class="popup-title">{{ recordForm.recordId ? '✏️ 编辑记录' : '📝 记录/拍照' }}</text>
|
||||
<text class="popup-task">{{ recordPopup.task && recordPopup.task.task_name }}</text>
|
||||
<textarea v-model="recordForm.remark" class="popup-textarea" placeholder="填写备注说明" :maxlength="2000" />
|
||||
<view class="img-grid">
|
||||
<view v-for="(img, i) in recordForm.images" :key="i" class="img-cell"><view class="success-badge-wrapper img-frame"><image :src="imageUrl(img)" mode="aspectFill" class="img-thumb" @tap="previewRecordImage(i)" /><view v-if="isUploaded(img)" class="success-badge" /></view><text v-if="canDeleteRecordImage(i)" class="img-del" @tap.stop="removeRecordImage(i)">✕</text></view>
|
||||
<view v-for="n in recordForm.pendingCount" :key="'p'+n" class="img-cell img-cell-loading"><text class="img-loading-text">⏳</text></view>
|
||||
</view>
|
||||
<button v-if="recordForm.images.length + recordForm.pendingCount < 9" class="btn-upload" @tap="handleChooseImage" :disabled="isUploading">{{ isUploading ? '上传中...' : `📷 拍照/选图 (${recordForm.images.length + recordForm.pendingCount}/9)` }}</button>
|
||||
<view class="popup-btns"><button class="btn-cancel" @tap="closeRecordPopup">取消</button><button class="btn-primary" :disabled="recordSaving || isUploading" @tap="doSaveRecord">{{ isUploading ? '上传中' : (recordSaving ? '保存中...' : (recordForm.recordId ? '更新记录' : '保存记录')) }}</button></view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 任务操作 -->
|
||||
<view v-if="actionPopup.visible" class="overlay" @tap="closeActionPopup">
|
||||
<view class="popup" @tap.stop>
|
||||
<template v-if="actionPopup.type === 'receive'">
|
||||
<text class="popup-title">确认接收任务</text>
|
||||
<view class="popup-task">{{ actionPopup.task && actionPopup.task.task_name }}</view>
|
||||
<text class="popup-hint">状态: {{ statusLabel(actionPopup.task && actionPopup.task.status) }} → 进行中</text>
|
||||
<view class="field-label">选择工序 <text class="required">*</text></view>
|
||||
<view class="user-grid">
|
||||
<view v-for="opt in availableTaskOptions" :key="opt"
|
||||
:class="['user-grid-item', receiveTaskName === opt ? 'user-grid-active' : '']"
|
||||
@tap="receiveTaskName = opt">{{ opt }}</view>
|
||||
</view>
|
||||
<textarea v-model="receiveRemark" class="popup-textarea" placeholder="接收备注(选填)" :maxlength="500" style="margin-top:12px;" />
|
||||
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-primary" :disabled="actionLoading || !receiveTaskName" @tap="doReceive">确认接收</button></view>
|
||||
</template>
|
||||
<template v-if="actionPopup.type === 'reject'">
|
||||
<text class="popup-title">品质驳回</text>
|
||||
<textarea v-model="rejectReason" class="popup-textarea" placeholder="请填写驳回原因(必填)" :maxlength="500" />
|
||||
<!-- 📷 异常图片为选填:编号错误、选错工序等场景可不拍照 -->
|
||||
<view class="field-label">异常图片 <text class="optional">(选填,最多 9 张)</text></view>
|
||||
<view class="img-grid">
|
||||
<view v-for="(img, i) in rejectForm.images" :key="i" class="img-cell"><view class="success-badge-wrapper img-frame"><image :src="imageUrl(img)" mode="aspectFill" class="img-thumb" @tap="previewRejectImage(i)" /><view v-if="isUploaded(img)" class="success-badge" /></view><text class="img-del" @tap.stop="removeRejectImage(i)">✕</text></view>
|
||||
<view v-for="n in rejectForm.pendingCount" :key="'rp'+n" class="img-cell img-cell-loading"><text class="img-loading-text">⏳</text></view>
|
||||
</view>
|
||||
<button v-if="rejectForm.images.length + rejectForm.pendingCount < 9" class="btn-upload" @tap="handleChooseRejectImage" :disabled="isUploading">{{ isUploading ? '上传中...' : `📷 拍照/选图 (${rejectForm.images.length + rejectForm.pendingCount}/9)` }}</button>
|
||||
<text class="popup-hint">⚠ 驳回后将自动创建返工任务</text>
|
||||
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-danger" :class="{ 'btn-counting': confirming === 'reject' && confirmCount > 0 }" :disabled="actionLoading || isUploading || !rejectReason.trim() || (confirming === 'reject' && confirmCount > 0)" @tap="confirmBtn('reject', doReject)">{{ isUploading ? '上传中...' : confirmLabel('reject', '确认驳回') }}</button></view>
|
||||
</template>
|
||||
<template v-if="actionPopup.type === 'transfer'">
|
||||
<text class="popup-title">完工转交</text>
|
||||
<view class="field-label">接收人 / 处理方式 <text class="required">*</text></view>
|
||||
<view class="user-grid">
|
||||
<view v-for="u in userGridOptions" :key="u.id"
|
||||
:class="['user-grid-item', transferForm.selectedUserId === u.id ? 'user-grid-active' : '']"
|
||||
@tap="selectTransferUser(u.id)">{{ formatName(u.name) }}</view>
|
||||
</view>
|
||||
<view class="field-label" style="margin-top:12px;">或</view>
|
||||
<view :class="['user-grid-item', transferForm.isWarehouse ? 'user-grid-active' : '']" style="width:100%;" @tap="toggleWarehouse">📦 入库 (virtual_warehouse)</view>
|
||||
<template v-if="showFinishDirect">
|
||||
<view :class="['user-grid-item', transferForm.isFinishDirect ? 'user-grid-active' : '']" style="width:100%;margin-top:8px;" @tap="toggleFinishDirect">🏁 直接完结 (不入库)</view>
|
||||
<text class="popup-hint">🏁 直接完结:结束本任务且不创建下游任务,不改动产品状态(已出库的设备完结后依然是「已出库」)。用于售后返厂直接发走、半成品被直接提走等无需入库的场景</text>
|
||||
</template>
|
||||
<view class="field-label" style="margin-top:12px;">交接备注 <text class="required">*</text></view>
|
||||
<textarea v-model="transferForm.note" class="popup-textarea" placeholder="请填写交接备注(必填)" :maxlength="500" />
|
||||
<view v-if="transferMode" class="preview-hint">{{ transferPreview }}</view>
|
||||
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-primary" :disabled="actionLoading || !transferMode || !transferForm.note.trim()" @tap="doTransfer">{{ actionLoading ? '提交中...' : transferSubmitLabel }}</button></view>
|
||||
</template>
|
||||
<template v-if="actionPopup.type === 'spawn'">
|
||||
<text class="popup-title">➕ 派发协助分支</text>
|
||||
<text class="popup-hint">为当前任务创建并行协助,当前任务保持进行中</text>
|
||||
<view class="field-label">接收人 <text class="required">*</text></view>
|
||||
<view class="user-grid">
|
||||
<view v-for="u in userGridOptions" :key="u.id"
|
||||
:class="['user-grid-item', spawnForm.assignee_id === u.id ? 'user-grid-active' : '']"
|
||||
@tap="spawnForm.assignee_id = u.id">{{ formatName(u.name) }}</view>
|
||||
</view>
|
||||
<view class="field-label" style="margin-top:12px;">派发备注 <text class="required">*</text></view>
|
||||
<textarea v-model="spawnForm.remark" class="popup-textarea" placeholder="请填写派发备注说明(必填)" :maxlength="500" />
|
||||
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-primary btn-spawn" :disabled="actionLoading || !spawnForm.assignee_id || !spawnForm.remark.trim()" @tap="doSpawn">{{ actionLoading ? '提交中...' : '确认派发' }}</button></view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 💬 留言悬浮按钮 -->
|
||||
<view class="msg-fab" @tap="openMsgDrawer">
|
||||
<text class="msg-fab-icon">💬</text>
|
||||
<text v-if="msgUnreadCount" class="msg-fab-badge">{{ msgUnreadCount }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 💬 留言板底部抽屉 -->
|
||||
<view v-if="showMsgDrawer" class="msg-drawer-overlay" @tap="closeMsgDrawer">
|
||||
<view class="message-board-drawer" @tap.stop>
|
||||
<view class="mb-drawer-handle"></view>
|
||||
<view class="mb-title">💬 协同留言板</view>
|
||||
<scroll-view scroll-y class="mb-scroll-area" :scroll-into-view="bottomMsgId" scroll-with-animation>
|
||||
<view v-for="msg in messages" :key="msg.id" class="mb-item" :id="'msg-' + msg.id">
|
||||
<view class="mb-avatar">{{ formatUserAvatar(msg.operator_id) }}</view>
|
||||
<view class="mb-content-wrapper">
|
||||
<view class="mb-header-info">
|
||||
<text class="mb-name">{{ formatUserName(msg.operator_id) }}</text>
|
||||
<text class="mb-time">{{ fmtMsgTime(msg.created_at) }}</text>
|
||||
</view>
|
||||
<view class="mb-bubble">{{ msg.content }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view id="msg-bottom" class="mb-bottom-anchor"></view>
|
||||
</scroll-view>
|
||||
<view class="mb-input-bar">
|
||||
<input v-model="newMsgText" class="mb-input" placeholder="输入交接注意事项..." confirm-type="send" @confirm="submitMessage" />
|
||||
<view :class="['mb-send-btn', !newMsgText.trim() ? 'btn-disabled' : '']" @tap="submitMessage">发送</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 🛡️ 双重确认弹窗(删除/结束分支/驳回 5 秒倒计时防误触) -->
|
||||
<view v-if="confirmDlg.visible" class="overlay" @tap="confirmDlgCancel">
|
||||
<view class="popup" @tap.stop style="max-width:360px;border-radius:16px;">
|
||||
<text class="popup-title">{{ confirmDlg.title }}</text>
|
||||
<text class="popup-hint" style="display:block;margin-bottom:4px;">{{ confirmDlg.content }}</text>
|
||||
<text v-if="confirmDlg.countdown" class="cd-tip">⚠️ 5 秒确认等待中:请核对信息,倒计时结束后确认按钮才可点击</text>
|
||||
<view class="popup-btns">
|
||||
<button class="btn-cancel" @tap="confirmDlgCancel">取消</button>
|
||||
<button class="btn-primary" :class="{ 'btn-counting': confirming === 'dlg' && confirmCount > 0 }" :disabled="confirming === 'dlg' && confirmCount > 0" @tap="confirmDlgConfirm">{{ confirmDlg.countdown ? confirmLabel('dlg', '确认') : '确认' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import request, { get, post, patch, put, getBaseUrl } from "../../utils/request";
|
||||
import { uploadImages, isUploadedUrl } from "../../utils/upload";
|
||||
import { setUserNameMap, formatUserName, formatUserAvatar } from "../../utils/format";
|
||||
import { taskOptionsFor, overallOptionsFor, lifecycleBadge } from "../../utils/lifecycle";
|
||||
import WorkspaceArea from "./components/WorkspaceArea.vue";
|
||||
import TreeCanvas from "./components/TreeCanvas.vue";
|
||||
import TaskSwipeCards from "./components/TaskSwipeCards.vue";
|
||||
|
||||
// 🔧 工序可选项不再写死 —— 由 computed availableOverallOptions / availableTaskOptions
|
||||
// 按生命周期阶段(生产制造 / 售后回流)动态收窄,词表见 utils/lifecycle.js
|
||||
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", OUTBOUND: "已出库", CANCELED: "已撤回" };
|
||||
|
||||
export default {
|
||||
components: { WorkspaceArea, TreeCanvas, TaskSwipeCards },
|
||||
data() {
|
||||
return {
|
||||
loading: true, error: "", product: null,
|
||||
showStatusPicker: false, editProductVisible: false, editForm: { order_no: "", external_serial: "" }, editSaving: false,
|
||||
users: [],
|
||||
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, savedCount: 0 }, recordSaving: false, isUploading: false,
|
||||
currentUser: null, currentUserId: "", currentUsername: "", currentUserRole: "", currentMode: "workspace", autoLockTaskId: "",
|
||||
processOptions: [], userOptions: [],
|
||||
actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", receiveTaskName: "",
|
||||
// 📷 驳回异常图片(选填):与追加记录共用同一套选图/上传流程
|
||||
rejectForm: { images: [], pendingCount: 0 },
|
||||
transferForm: { selectedUserId: "", isWarehouse: false, isFinishDirect: false, note: "" },
|
||||
spawnForm: { assignee_id: "", remark: "" },
|
||||
// 🛡️ 双重确认倒计时:避免误触
|
||||
confirming: "", // 当前倒计时中的操作 key('' = 无)
|
||||
confirmCount: 5, // 剩余秒数
|
||||
confirmTimer: null, // 定时器句柄
|
||||
confirmDlg: { visible: false, title: "", content: "", action: null }, // 确认框类操作弹窗
|
||||
// 💬 留言板
|
||||
messages: [],
|
||||
// 🚀 字典版本号:驱动子组件强制重建,解决 userNameMap 非响应式问题
|
||||
dictVersion: 0,
|
||||
showMsgDrawer: false,
|
||||
newMsgText: '',
|
||||
bottomMsgId: '',
|
||||
lastMsgSeenAt: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
userLabels() { return this.users.map(u => `${u.full_name} (${u.username})`); },
|
||||
canDeleteImage() { if (!this.recordPopup.task) return true; if (!this.currentUser) return true; const frozen = ["COMPLETED","REJECTED","ARCHIVED","CANCELED"]; if (frozen.includes(this.recordPopup.task.status)) return false; const assignee = this.recordPopup.task.assignee_id; return assignee == this.currentUserId || assignee == this.currentUsername || (this.currentUser && this.currentUser.id == assignee) || (this.currentUser && this.currentUser.username == assignee); },
|
||||
transferUserName() { const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId); return u ? u.name : ""; },
|
||||
// 🔒 直接完结入口仅超管/主管【可见】——普通人看不到,而不是点了才被后端 403。
|
||||
// ⚠️ 必须两个角色都判:本文件既有的 canEditOverallStatus 只判了 SUPER_ADMIN、
|
||||
// 漏了 SUPERVISOR,导致主管被前端误挡。此处与后端 ADMIN_ROLES 对齐。
|
||||
// 🔒 管理角色判定(超管 / 主管)—— 与后端 ADMIN_ROLES 对齐,作为各处权限判断的唯一入口
|
||||
isAdminUser() { const r = (this.currentUser && this.currentUser.role) || this.currentUserRole || ''; return r === 'SUPER_ADMIN' || r === 'SUPERVISOR'; },
|
||||
canFinishDirectly() { return this.isAdminUser; },
|
||||
// 📤 产品是否空闲:没有任何活跃的【主线】任务(WIP/PENDING)。
|
||||
// 只算主干(无父任务 或 TRANSFER/RECOVERY):协助分支(SPAWN)不阻塞派发,
|
||||
// 否则一个挂着的协助分支会把产品永久锁死。
|
||||
hasActiveMainTask() {
|
||||
const walk = (tasks) => {
|
||||
if (!tasks) return false;
|
||||
for (const t of tasks) {
|
||||
const isMain = !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
|
||||
if (isMain && (t.status === 'WIP' || t.status === 'PENDING')) return true;
|
||||
if (walk(t.child_tasks)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return this.product ? walk(this.product.task_tree) : false;
|
||||
},
|
||||
// 📤 派发新任务入口的显示条件 —— 2026-09-17 解绑「派发权限」与「仓库位置」。
|
||||
//
|
||||
// 原实现硬性要求 current_location_id === 'virtual_warehouse',于是
|
||||
// 【直接完结】后的设备(位置停在最后经手人名下、又不在仓库池)派发入口
|
||||
// 彻底消失,产品变成再也无法流转的孤儿数据。
|
||||
// 但物理上除非设备报废,永远存在派发新任务的需求,故增加豁免:
|
||||
// · 在仓库且我本人没有待办 → 转出派发(原有行为,保持不动)
|
||||
// · 产品空闲(无活跃主线任务)且我是超管/主管 → 直接派发新任务
|
||||
// (已出库设备尤其如此:位置在工人名下也不该挡住派发)
|
||||
showDispatchBanner() {
|
||||
if (!this.product) return false;
|
||||
if (this.product.current_location_id === 'virtual_warehouse' && !this.hasMyActiveTask) return true;
|
||||
return this.isAdminUser && !this.hasActiveMainTask;
|
||||
},
|
||||
dispatchBannerText() {
|
||||
return this.product && this.product.current_location_id === 'virtual_warehouse'
|
||||
? '该产品在仓库中 — 点击此处转出并派发给指定人员'
|
||||
: '当前任务已完结 — 点击此处直接派发新任务';
|
||||
},
|
||||
dispatchBannerIcon() {
|
||||
return this.product && this.product.current_location_id === 'virtual_warehouse' ? '📤' : '🚀';
|
||||
},
|
||||
// 🛡️ 直接完结的【场景】门槛:角色够 + 产品确实是「已出库」。
|
||||
// 为什么必须卡状态:普通生产中的设备若被直接完结,产品既无下游任务、
|
||||
// 又不在仓库池中,会变成卡在工人名下的**孤儿数据**;而且售后往往要多步
|
||||
// 流转(发货测试 → 维修 → 入库 → …),提前完结会把任务流彻底切断。
|
||||
// 生产中的设备只能走「入库」或「转交个人」,回归正常流转。
|
||||
showFinishDirect() {
|
||||
return this.canFinishDirectly
|
||||
&& !!this.product
|
||||
&& this.product.overall_status === '已出库';
|
||||
},
|
||||
// 🏁 转交弹窗的三选一模式:'' = 未选 | 'user' 转交个人 | 'warehouse' 入库 | 'direct' 直接完结
|
||||
// direct 分支额外校验 角色 + 场景 双门槛,防御残留选中态把 finish_directly 发出去
|
||||
transferMode() { const f = this.transferForm; if (f.isFinishDirect && this.showFinishDirect) return 'direct'; if (f.isWarehouse) return 'warehouse'; return f.selectedUserId ? 'user' : ''; },
|
||||
transferSubmitLabel() { return { direct: '🏁 确认直接完结', warehouse: '📦 确认入库', user: '确认转交' }[this.transferMode] || '确认转交'; },
|
||||
transferPreview() { return { direct: '任务将直接完结,不创建下游任务;不改动产品状态(已出库的设备完结后依然是「已出库」)', warehouse: '产品将入库并从个人待办中移除', user: '将创建新任务指派给 ' + (this.transferUserName || '—') }[this.transferMode] || ''; },
|
||||
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;
|
||||
},
|
||||
msgUnreadCount() { if (!this.lastMsgSeenAt) return this.messages.length; return this.messages.filter(m => m.created_at > this.lastMsgSeenAt).length; },
|
||||
// 🔒 宏观状态修改权限:对齐后端 update_overall_status 的 main_task 判断标准
|
||||
canEditOverallStatus() {
|
||||
if (!this.currentUser) return false;
|
||||
if (this.currentUser.role === 'SUPER_ADMIN') return true;
|
||||
if (!this.product || !this.product.task_tree) return false;
|
||||
let hasPermission = false;
|
||||
const checkTask = (tasks) => {
|
||||
if (!tasks || hasPermission) return;
|
||||
for (const t of tasks) {
|
||||
const isMain = !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
|
||||
if (isMain && (t.status === 'WIP' || t.status === 'PENDING')) {
|
||||
if (t.assignee_id == this.currentUserId || t.assignee_id == this.currentUsername) {
|
||||
hasPermission = true;
|
||||
}
|
||||
}
|
||||
checkTask(t.child_tasks);
|
||||
}
|
||||
};
|
||||
checkTask(this.product.task_tree);
|
||||
return hasPermission;
|
||||
},
|
||||
// 🔧 生命周期标签:只在售后回流环节(发货测试 / 售后维修)打红色标签
|
||||
lifeBadge() {
|
||||
return lifecycleBadge(
|
||||
this.product && this.product.overall_status,
|
||||
this.product && this.product.lifecycle_phase,
|
||||
);
|
||||
},
|
||||
// ── 🔧 选项隔离:按生命周期阶段收窄可选工序 ──
|
||||
// 生产制造设备只能排「备货/生产/测试/维修/在库」;
|
||||
// 售后回流设备只能选「发货测试/售后维修/在库」,排不回前期环节。
|
||||
isAfterSales() {
|
||||
return !!(this.product && this.product.lifecycle_phase === "AFTER_SALES");
|
||||
},
|
||||
// 是否已有"真实工序"历史 —— 仅判断 task_tree 非空是不够的:
|
||||
// 老设备首次「发起首道工序」建出来的任务名是占位符「待确认」,
|
||||
// 此时产品已有一条任务,但接收人还没机会声明工序。若按 task_tree 非空
|
||||
// 就判定"有历史",接收下拉将只给生产工序,老设备永远选不到「售后维修」,
|
||||
// 售后通路直接断掉。故这里必须排除占位符。
|
||||
hasHistory() {
|
||||
const walk = (tasks) => {
|
||||
if (!tasks) return false;
|
||||
for (const t of tasks) {
|
||||
const name = String(t.task_name || "").trim();
|
||||
if (name && name !== "待确认" && !name.includes("virtual_warehouse")) return true;
|
||||
if (walk(t.child_tasks)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return walk(this.product && this.product.task_tree);
|
||||
},
|
||||
availableOverallOptions() {
|
||||
return overallOptionsFor(this.product && this.product.lifecycle_phase, this.hasHistory);
|
||||
},
|
||||
availableTaskOptions() {
|
||||
// 🔧 第三个参数 overall_status:已出库设备同样要走售后词表(发货测试 / 售后维修),
|
||||
// 否则出库后补做质检的工人永远选不到对应工序(鸡生蛋死锁)。
|
||||
return taskOptionsFor(
|
||||
this.product && this.product.lifecycle_phase,
|
||||
this.hasHistory,
|
||||
this.product && this.product.overall_status,
|
||||
);
|
||||
},
|
||||
},
|
||||
onLoad(options) { this.loadUsers(); this.loadCurrentUser(); const sn = options.serial || ""; if (sn) { this.doQuery(sn); return; } /* 🚀 兜底: 从 taskId 反查 product */ const tid = options.taskId || ""; if (tid) this.doQueryByTask(tid); },
|
||||
// 🚀 onShow 生命周期:每次页面显示时刷新留言板(解决从聊天室退回不更新问题)
|
||||
onShow() { if (this.product?.id) { this.fetchMessages(); } },
|
||||
// 🚀 页面卸载:清理确认倒计时定时器,避免泄漏
|
||||
onUnload() { this.clearConfirm(); },
|
||||
// ⚠️ 本页【刻意不开启】下拉刷新(pages.json 中已移除 enablePullDownRefresh)。
|
||||
// 原因:本页有三种沉浸式模式 —— 锁定的工作区卡片、全屏流转卡片(swiper)、
|
||||
// 全屏流转树(scroll-view),它们各自持有滚动/滑动手势,且都是 position:fixed
|
||||
// 或原生 scroll-view,无法改由页面滚动接管。一旦开启页面下拉刷新,这些区域
|
||||
// 滑到顶后再下拉就会误触发刷新,工人没法正常往上翻内容。
|
||||
// 状态纠偏不依赖下拉刷新:handleNetworkFailure 会自动静默拉取真实状态。
|
||||
methods: {
|
||||
formatUserName, formatUserAvatar,
|
||||
formatName(name) { if (!name) return ""; return name.length === 2 ? name[0] + " " + name[1] : name; },
|
||||
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 = 'workspace'; this.autoLockTaskId = this.findMyImmersiveTask() || ''; if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } }); this.fetchMessages(); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
|
||||
// 🚀 从 taskId 反查 product_serial → 再 doQuery
|
||||
async doQueryByTask(tid) { try { const task = await get(`/tasks/${tid}`); const sn = task?.product_sn || ""; if (sn) { this.doQuery(sn); } else { this.error = "未找到关联产品"; this.loading = false; } } catch { this.error = "任务查询失败"; 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 {} },
|
||||
// 宏观状态语义色:已入库=灰,已出库=靛蓝,待仓库收货=橙,其余默认蓝
|
||||
overallStatusClass(status) {
|
||||
if (!status) return 'overall-empty';
|
||||
if (status === '已入库') return 'overall-archived';
|
||||
if (status === '已出库') return 'overall-outbound';
|
||||
if (status === '待仓库收货') return 'overall-warehouse';
|
||||
return '';
|
||||
},
|
||||
openEditProduct() { this.editForm = { order_no: this.product.order_no || "", external_serial: this.product.external_serial || "" }; this.editProductVisible = true; },
|
||||
async doEditProduct() { this.editSaving = true; try { this.product = await patch(`/products/${this.product.id}`, { order_no: this.editForm.order_no.trim(), external_serial: this.editForm.external_serial.trim() }); uni.showToast({ title: "已保存", icon: "success" }); this.editProductVisible = false; } catch {} finally { this.editSaving = false; } },
|
||||
|
||||
async loadUsers() { try { const res = await get("/users/", { limit: 200, dept: "IRIS" }); this.users = (res || []).filter(u => u.department === "IRIS"); this.userOptions = this.users.map(u => ({ id: u.username || u.id, name: u.full_name || u.username })); setUserNameMap(this.users); this.dictVersion += 1; } catch (e) { console.error('[loadUsers] 获取用户列表失败:', e); } },
|
||||
loadCurrentUser() { 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") { this.currentUser = user; this.currentUserId = String(user.id || ""); this.currentUsername = user.username || ""; this.currentUserRole = user.role || ""; } } catch {} },
|
||||
|
||||
onTaskNameChange(e) { const idx = e.detail.value; this.firstForm.taskNameIdx = idx; this.firstForm.task_name = this.availableTaskOptions[idx]; },
|
||||
onAssigneeChange(e) { const idx = e.detail.value; const u = this.users[idx]; if (u) { this.firstForm.assigneeIdx = idx; this.firstForm.assignee_id = u.username; this.firstForm.assigneeLabel = `${u.full_name} (${u.username})`; } },
|
||||
// 🚀 打开「派发新任务」弹窗。标题由 isWarehouseTransfer 决定,
|
||||
// 而该标志【必须在此处按产品实际位置重新推导】——
|
||||
// 原先 openWarehouseTransfer 先置 true、再调用本方法被立刻重置为 false,
|
||||
// 导致弹窗标题永远显示「发起首道工序」,仓库转出场景的文案从未生效。
|
||||
openCreateFirstTask() {
|
||||
this.isWarehouseTransfer = !!(this.product && this.product.current_location_id === 'virtual_warehouse');
|
||||
if (this.currentMode === 'tree') this.currentMode = 'workspace';
|
||||
this.firstForm = { assignee_id: "", assigneeLabel: "", note: "" };
|
||||
this.createFirstVisible = true;
|
||||
},
|
||||
handleOverallBarClick() { if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } else if (this.canEditOverallStatus) { this.showStatusPicker = true; } else { uni.showToast({ title: '仅超级管理员或当前主线负责人可修改状态', icon: 'none', duration: 2500 }); } },
|
||||
// 注:原 openWarehouseTransfer() 已移除 —— 它的 isWarehouseTransfer=true 会被
|
||||
// openCreateFirstTask 立刻覆盖(死代码)。现在 banner 直接调用 openCreateFirstTask,
|
||||
// 由后者按产品实际位置推导标题。
|
||||
async doCreateFirstTask() { this.firstSaving = true; try { await post("/tasks/", { product_id: this.product.id, task_name: "待确认", assignee_id: this.firstForm.assignee_id, notify_parent_on_complete: false, remark: this.firstForm.note.trim() || undefined }); if (this.product.current_location_id === 'virtual_warehouse') { try { await patch(`/products/${this.product.id}`, { current_location_id: this.firstForm.assignee_id }); } catch {} } uni.showToast({ title: "任务已派发,待接收", icon: "success" }); this.createFirstVisible = false; this.doQuery(this.product.serial_number); } catch {} finally { this.firstSaving = false; } },
|
||||
|
||||
// savedCount = 打开弹窗时已落库的图片张数。列表里 [0, savedCount) 是服务端已有的,
|
||||
// 之后的都是本次会话新选的(尚未提交),删除判定据此区分,见 canDeleteRecordImage。
|
||||
openRecordPopup(task) { this.recordPopup = { visible: true, task }; this.recordForm = { recordId: null, remark: "", images: [], pendingCount: 0, savedCount: 0 }; this.isUploading = false; },
|
||||
openEditRecord({ task, record }) { const saved = record.images || []; this.recordPopup = { visible: true, task }; this.recordForm = { recordId: record.id, remark: record.remark || "", images: saved.slice(), pendingCount: 0, savedCount: saved.length }; this.isUploading = false; },
|
||||
closeRecordPopup() { this.recordPopup = { visible: false, task: null }; },
|
||||
// 🖼️ 后端返回的是相对路径(如 /api/v1/upload/files/xxx.jpg),直接丢给 <image> 会破图,
|
||||
// 这里补全域名 → 完整可访问 URL。逻辑与 records.vue / TaskTreeNode.vue 保持一致。
|
||||
imageUrl(url) {
|
||||
if (!url) return "";
|
||||
if (url.startsWith("http")) return url;
|
||||
const domain = getBaseUrl().replace(/\/api.*$/, '');
|
||||
return domain + (url.startsWith("/") ? url : "/" + url);
|
||||
},
|
||||
// 📷 通用选图上传:压缩后逐个上传,URL 累积进 target.images,
|
||||
// 过程中用 target.pendingCount 显示 ⏳ 占位(追加记录 / 驳回共用)。
|
||||
// ⚠️ 失败必须显式提示 + 确保图片不进 images 数组,绝不静默吞掉:
|
||||
// 工人会以为图传好了,提交上去才发现缺图,而任务已流转出去。
|
||||
async pickAndUploadImages(target) {
|
||||
const maxSlots = 9 - (target.images.length + target.pendingCount);
|
||||
if (maxSlots <= 0) return;
|
||||
const chooseRes = await new Promise((resolve, reject) => { uni.chooseImage({ count: maxSlots, sizeType: ["compressed"], sourceType: ["camera", "album"], success: resolve, fail: reject }); }).catch(() => null);
|
||||
if (!chooseRes || !chooseRes.tempFilePaths || !chooseRes.tempFilePaths.length) return;
|
||||
const compressedPaths = [];
|
||||
for (const p of chooseRes.tempFilePaths) {
|
||||
try { const compressed = await new Promise((resolve, reject) => { uni.compressImage({ src: p, quality: 60, success: resolve, fail: reject }); }); compressedPaths.push(compressed.tempFilePath); } catch {}
|
||||
}
|
||||
if (!compressedPaths.length) {
|
||||
// 压缩全军覆没(内存不足/格式异常)也必须说一声,不能悄悄什么都没发生
|
||||
uni.showToast({ title: "图片处理失败,请重试", icon: "none", duration: 3000 });
|
||||
return;
|
||||
}
|
||||
this.isUploading = true;
|
||||
target.pendingCount += compressedPaths.length;
|
||||
// 🚀 有界并发上传(utils/upload.js):9 张图并行补位,不再串行干等。
|
||||
// onEachDone 对每张图恰好回调一次 —— 成功的推入 images 供预览,
|
||||
// 失败的保持不推入(不给"图片已传好"的错觉),两者都回收一个 ⏳ 占位。
|
||||
const { failed, total } = await uploadImages(compressedPaths, (url) => {
|
||||
if (url) target.images.push(url);
|
||||
target.pendingCount--;
|
||||
});
|
||||
this.isUploading = false;
|
||||
if (failed > 0) {
|
||||
uni.showToast({
|
||||
title: failed === total ? "图片上传失败,请重试" : "部分图片上传失败,请重试",
|
||||
icon: "none",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
},
|
||||
handleChooseImage() { return this.pickAndUploadImages(this.recordForm); },
|
||||
handleChooseRejectImage() { return this.pickAndUploadImages(this.rejectForm); },
|
||||
/**
|
||||
* 删除一张记录图。
|
||||
*
|
||||
* 已落库的图(编辑历史记录)删掉是不可逆的,保留 5 秒倒计时防误触;
|
||||
* 本次刚选、尚未提交的图只存在内存里,删错了重新拍一张即可,没必要卡 5 秒。
|
||||
*/
|
||||
removeRecordImage(i) {
|
||||
const isUnsaved = i >= (this.recordForm.savedCount || 0);
|
||||
this.openConfirmDlg({
|
||||
title: "删除图片",
|
||||
content: "确定删除这张图片吗?",
|
||||
countdown: !isUnsaved,
|
||||
action: () => { this.recordForm.images.splice(i, 1); },
|
||||
});
|
||||
},
|
||||
previewRecordImage(i) { uni.previewImage({ urls: this.recordForm.images.map((u) => this.imageUrl(u)), current: i }); },
|
||||
removeRejectImage(i) { this.rejectForm.images.splice(i, 1); },
|
||||
previewRejectImage(i) { uni.previewImage({ urls: this.rejectForm.images.map((u) => this.imageUrl(u)), current: i }); },
|
||||
/** 绿勾角标只认「已上传成功」的项(判定逻辑统一收口在 utils/upload.js) */
|
||||
isUploaded(url) { return isUploadedUrl(url); },
|
||||
/**
|
||||
* 某张记录图能否删除。
|
||||
*
|
||||
* 本次会话新选、尚未提交的图(下标 >= savedCount)**永远可删** —— 它只存在于
|
||||
* 内存里,删掉不产生任何服务端影响。canDeleteImage 那道「任务已定稿 / 不是我的活」
|
||||
* 的闸门本意是保护已落库的历史记录,不该连带把工人刚选错、想撤掉的候选图一起锁死。
|
||||
* (此前这里直接写 v-if="canDeleteImage",导致在已完成任务上打开「记录/拍照」,
|
||||
* 选完图后根本没有删除入口。)
|
||||
*/
|
||||
canDeleteRecordImage(i) { return this.canDeleteImage || i >= (this.recordForm.savedCount || 0); },
|
||||
async doSaveRecord() { if (this.isUploading) return; this.recordSaving = true; try { const payload = { remark: this.recordForm.remark.trim(), images: this.recordForm.images }; if (this.recordForm.recordId) await put(`/records/${this.recordForm.recordId}`, payload); else await patch(`/tasks/${this.recordPopup.task.id}/records`, payload); uni.showToast({ title: "已保存", icon: "success" }); this.closeRecordPopup(); this.doQuery(this.product.serial_number); } catch (e) { this.handleNetworkFailure(e, this.recordForm.recordId ? "更新记录" : "保存记录"); } finally { this.recordSaving = false; } },
|
||||
|
||||
async handleTaskAction({ task, type, record }) {
|
||||
if (type === "record") { this.openRecordPopup(task); return; }
|
||||
if (type === "deleteRecord") { this.doDeleteRecord(record); return; }
|
||||
if (type === "end") { this.confirmEndBranch(task); return; }
|
||||
if (type === "recall") { this.confirmRecall(task); return; }
|
||||
if (type === "transfer" || type === "spawn") { uni.showLoading({ title: "加载数据..." }); try { if (!this.userOptions.length) await this.loadUsers(); this.processOptions = ["🏭 入库 (virtual_warehouse)", ...this.availableTaskOptions]; } finally { uni.hideLoading(); } }
|
||||
this.actionPopup = { visible: true, type, task }; this.rejectReason = ""; this.receiveRemark = ""; this.receiveTaskName = "";
|
||||
this.rejectForm = { images: [], pendingCount: 0 }; this.isUploading = false;
|
||||
this.transferForm = { selectedUserId: "", isWarehouse: false, isFinishDirect: false, note: "" };
|
||||
this.spawnForm = { assignee_id: "", remark: "" };
|
||||
},
|
||||
handleViewRecords(task) { uni.navigateTo({ url: `/pages/scan/records?taskId=${task.id}` }); },
|
||||
async doDeleteRecord(record) { this.openConfirmDlg({ title: "删除记录", content: "确定删除这条记录吗?删除后不可恢复。", countdown: true, action: async () => { try { await request({ url: `/records/${record.id}`, method: "DELETE" }); uni.showToast({ title: "记录已删除", icon: "success" }); this.doQuery(this.product.serial_number); } catch (e) { uni.showToast({ title: (e && e.data && e.data.detail) || "删除失败", icon: "none" }); } } }); },
|
||||
confirmEndBranch(task) { this.openConfirmDlg({ title: "结束分支", content: `确定结束「${task.task_name}」吗?`, countdown: true, action: () => this.doEndBranch(task) }); },
|
||||
confirmRecall(task) { this.openConfirmDlg({ title: "撤回转交", content: `确定撤回「${task.task_name}」吗?撤回后任务将回到您的手中。`, countdown: true, action: () => this.doRecall(task) }); },
|
||||
async doRecall(task) { try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${task.id}/recall?operator_id=${encodeURIComponent(opId)}`); uni.showToast({ title: "已撤回", icon: "success" }); this.doQuery(this.product.serial_number); } catch (e) { this.handleNetworkFailure(e, "撤回转交"); } },
|
||||
async doEndBranch(task) { try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${task.id}/end?operator_id=${encodeURIComponent(opId)}`); uni.showToast({ title: "分支已结束", icon: "success" }); this.doQuery(this.product.serial_number); } catch (e) { this.handleNetworkFailure(e, "结束分支"); } },
|
||||
closeActionPopup() { this.actionPopup = { visible: false, type: "", task: null }; },
|
||||
|
||||
// ═══ 网络级失败的识别与统一处置(防"超时后连点"造成重复流转) ═══
|
||||
// 🌐 判定「网络级失败」:uni.request 的 fail 回调 = 压根没拿到后端响应
|
||||
// (超时 / 断网 / DNS 失败),与 4xx/5xx 有本质区别 —— 后者后端明确应答过,
|
||||
// 而前者后端很可能已经把动作执行成功了,只是响应没回来。
|
||||
isNetworkFailure(e) {
|
||||
if (!e) return false;
|
||||
if (e.isNetworkError === true) return true;
|
||||
if (e.statusCode) return false; // 有 HTTP 状态码 = 后端应答过,不是网络级
|
||||
const msg = String(e.errMsg || e.message || "");
|
||||
return /timeout|network|request:fail/i.test(msg);
|
||||
},
|
||||
// 🛡️ 网络级失败的统一处置:
|
||||
// 1) 关闭弹窗 —— 强行打断工人的连点,否则第二次点下去就是重复驳回/重复转交;
|
||||
// 2) 强提示(模态,必须手动确认)—— 告诉他"可能已生效",别再点;
|
||||
// 3) 静默拉取真实状态 —— 防止继续基于过期数据产生脏操作。
|
||||
// 返回 true 表示已按网络级失败处理,调用方无需再兜底。
|
||||
handleNetworkFailure(e, actionLabel) {
|
||||
if (!this.isNetworkFailure(e)) return false;
|
||||
this.closeActionPopup();
|
||||
this.closeRecordPopup();
|
||||
uni.showModal({
|
||||
title: "网络超时",
|
||||
content: `未收到服务器响应,「${actionLabel}」可能已生效。已为你刷新最新状态,请确认后再操作。`,
|
||||
showCancel: false,
|
||||
confirmText: "知道了",
|
||||
});
|
||||
this.refreshProductSilently();
|
||||
return true;
|
||||
},
|
||||
// 静默刷新产品详情:不置 loading,避免打断视线(网络故障后自动纠偏用)
|
||||
async refreshProductSilently() {
|
||||
const sn = this.product && this.product.serial_number;
|
||||
if (!sn) return;
|
||||
try { this.product = await get(`/products/scan/${sn}`); } catch (e) { console.error("[refresh] 静默刷新失败:", e); }
|
||||
},
|
||||
|
||||
// ═══ 双重确认倒计时(防误触) ═══
|
||||
// 首次点击进入 5 秒倒计时:期间确认按钮虚化禁用(点不了),只有「取消」可用;
|
||||
// 5 秒结束后确认按钮解锁,点击才真正执行。
|
||||
confirmBtn(key, doAction) {
|
||||
if (this.confirming === key) {
|
||||
if (this.confirmCount <= 0) {
|
||||
// 倒计时结束,点击执行
|
||||
this.clearConfirm();
|
||||
doAction();
|
||||
}
|
||||
// 倒计时中:按钮已禁用,忽略点击
|
||||
return;
|
||||
}
|
||||
// 首次点击,开始倒计时
|
||||
this.clearConfirm();
|
||||
this.confirming = key;
|
||||
this.confirmCount = 5;
|
||||
this.confirmTimer = setInterval(() => {
|
||||
this.confirmCount -= 1;
|
||||
if (this.confirmCount <= 0) clearInterval(this.confirmTimer);
|
||||
}, 1000);
|
||||
},
|
||||
clearConfirm() {
|
||||
if (this.confirmTimer) clearInterval(this.confirmTimer);
|
||||
this.confirmTimer = null;
|
||||
this.confirming = "";
|
||||
this.confirmCount = 5;
|
||||
},
|
||||
confirmLabel(key, baseText) {
|
||||
if (this.confirming === key && this.confirmCount > 0) return `${baseText} (${this.confirmCount}s)`;
|
||||
return baseText;
|
||||
},
|
||||
// 确认框类操作(结束分支/删除记录/删除图片/撤回转交)
|
||||
// countdown=true 时确认按钮需要 5 秒等待(期间禁用);false 时立即确认
|
||||
openConfirmDlg({ title, content, action, countdown = false }) {
|
||||
this.clearConfirm();
|
||||
this.confirmDlg = { visible: true, title, content, action, countdown };
|
||||
if (countdown) {
|
||||
this.confirming = "dlg";
|
||||
this.confirmCount = 5;
|
||||
this.confirmTimer = setInterval(() => {
|
||||
this.confirmCount -= 1;
|
||||
if (this.confirmCount <= 0) clearInterval(this.confirmTimer);
|
||||
}, 1000);
|
||||
}
|
||||
},
|
||||
confirmDlgConfirm() {
|
||||
if (this.confirming === "dlg" && this.confirmCount > 0) return; // 倒计时中:忽略
|
||||
if (this.confirming === "dlg") this.clearConfirm();
|
||||
const action = this.confirmDlg.action;
|
||||
this.confirmDlg.visible = false;
|
||||
this.confirmDlg.action = null;
|
||||
if (action) action();
|
||||
},
|
||||
confirmDlgCancel() {
|
||||
this.clearConfirm();
|
||||
this.confirmDlg.visible = false;
|
||||
this.confirmDlg.action = null;
|
||||
},
|
||||
async doReceive() { this.actionLoading = true; try { const remark = this.receiveRemark.trim() || undefined; const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/receive?operator_id=${encodeURIComponent(opId)}`, { remark, task_name: this.receiveTaskName }); uni.showToast({ title: "已接收", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch (e) { this.handleNetworkFailure(e, "接收任务"); } finally { this.actionLoading = false; } },
|
||||
// 驳回:reason 必填,images 选填(编号错误等场景允许空数组)
|
||||
async doReject() { if (this.isUploading) return; this.actionLoading = true; try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/reject?operator_id=${encodeURIComponent(opId)}`, { reason: this.rejectReason.trim(), images: this.rejectForm.images }); uni.showToast({ title: "已驳回", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch (e) { this.handleNetworkFailure(e, "驳回任务"); } finally { this.actionLoading = false; } },
|
||||
// 转交 — 互斥选择
|
||||
selectTransferUser(userId) { this.transferForm.selectedUserId = userId; this.transferForm.isWarehouse = false; this.transferForm.isFinishDirect = false; },
|
||||
toggleWarehouse() { this.transferForm.isWarehouse = !this.transferForm.isWarehouse; if (this.transferForm.isWarehouse) { this.transferForm.selectedUserId = ""; this.transferForm.isFinishDirect = false; } },
|
||||
// 🏁 直接完结:与前两者互斥。不发往仓库 → 后端落入"无下家"分支,overall_status 原样保留
|
||||
toggleFinishDirect() { this.transferForm.isFinishDirect = !this.transferForm.isFinishDirect; if (this.transferForm.isFinishDirect) { this.transferForm.selectedUserId = ""; this.transferForm.isWarehouse = false; } },
|
||||
async doTransfer() {
|
||||
this.actionLoading = true;
|
||||
const { isWarehouse, isFinishDirect } = this.transferForm;
|
||||
try {
|
||||
const note = this.transferForm.note.trim() || undefined;
|
||||
// 🏁 直接完结:next_tasks 留空 + finish_directly=true,
|
||||
// 后端据此跳过分支解析,只闭环任务、不改产品宏观状态(不会变成"待仓库收货")
|
||||
const payload = isFinishDirect
|
||||
? { next_tasks: [], finish_directly: true, note }
|
||||
: { next_tasks: [{ task_name: isWarehouse ? "🏭 入库 (virtual_warehouse)" : "待确认", assignees: isWarehouse ? ["virtual_warehouse"] : [this.transferForm.selectedUserId] }], note };
|
||||
await post(`/tasks/${this.actionPopup.task.id}/transfer`, payload);
|
||||
uni.showToast({ title: isFinishDirect ? "已直接完结" : (isWarehouse ? "已入库" : "转交成功"), icon: "success" });
|
||||
this.closeActionPopup();
|
||||
this.doQuery(this.product.serial_number);
|
||||
} catch (e) {
|
||||
this.handleNetworkFailure(e, isFinishDirect ? "直接完结" : (isWarehouse ? "入库" : "转交"));
|
||||
} finally { this.actionLoading = false; }
|
||||
},
|
||||
// 派发协助分支
|
||||
async doSpawn() { this.actionLoading = true; try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/spawn?operator_id=${encodeURIComponent(opId)}`, { task_name: "待确认", assignee_id: this.spawnForm.assignee_id, remark: this.spawnForm.remark.trim() || undefined }); uni.showToast({ title: "协助分支已派发", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch (e) { this.handleNetworkFailure(e, "派发协助分支"); } finally { this.actionLoading = false; } },
|
||||
// 💬 留言板
|
||||
async fetchMessages() { if (!this.product?.id) return; try { const res = await get(`/products/${this.product.id}/messages`); this.messages = res || []; const key = `msg_seen_${this.product.id}`; this.lastMsgSeenAt = uni.getStorageSync(key) || ''; this.scrollToBottom(); } catch (e) { console.error('获取留言失败', e); } },
|
||||
async submitMessage() { const content = this.newMsgText.trim(); if (!content) return; this.newMsgText = ''; const tempId = 'temp_' + Date.now(); const tempMsg = { id: tempId, operator_id: this.currentUsername || this.currentUserId || '?', content, created_at: new Date().toISOString() }; this.messages.push(tempMsg); this.scrollToBottom(); try { await post(`/products/${this.product.id}/messages`, { operator_id: this.currentUsername || this.currentUserId, content }); this.fetchMessages(); } catch (e) { uni.showToast({ title: '发送失败', icon: 'none' }); this.messages = this.messages.filter(m => m.id !== tempId); } },
|
||||
openMsgDrawer() { this.showMsgDrawer = true; this.$nextTick(() => { this.scrollToBottom(); }); },
|
||||
closeMsgDrawer() { const last = this.messages[this.messages.length - 1]; this.lastMsgSeenAt = last ? last.created_at : new Date().toISOString(); if (this.product?.id && last) { uni.setStorageSync(`msg_seen_${this.product.id}`, this.lastMsgSeenAt); } this.showMsgDrawer = false; },
|
||||
scrollToBottom() { this.$nextTick(() => { this.bottomMsgId = 'msg-bottom'; }); },
|
||||
fmtMsgTime(d) { if (!d) return ''; const dt = new Date(d); const pad = (n) => String(n).padStart(2, '0'); return `${pad(dt.getMonth()+1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; },
|
||||
// 🖨️ 打印标签:调用后端 API 发送打印指令
|
||||
async printLabel() {
|
||||
if (!this.product?.serial_number) return;
|
||||
uni.showActionSheet({
|
||||
itemList: ['网络打印机(后端API)', '蓝牙打印机(ESC/POS)'],
|
||||
success: async (res) => {
|
||||
if (res.tapIndex === 0) {
|
||||
// 方案A:网络打印机 → 调用后端 /print/execute API
|
||||
try {
|
||||
uni.showLoading({ title: '发送打印指令...' });
|
||||
await post(`/print/execute`, {
|
||||
serial_number: this.product.serial_number,
|
||||
material_name: this.product.material_name || '',
|
||||
spec_model: this.product.spec_model || '',
|
||||
order_no: this.product.order_no || '',
|
||||
copies: 1,
|
||||
});
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: '打印指令已发送', icon: 'success' });
|
||||
} catch (e) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: e?.data?.detail || '打印失败', icon: 'none' });
|
||||
}
|
||||
} else if (res.tapIndex === 1) {
|
||||
// 方案B:蓝牙打印机 → 前端直连 ESC/POS 指令
|
||||
// ⚠️ 需要引入蓝牙打印 SDK,当前为占位架构
|
||||
uni.showToast({ title: '蓝牙打印功能开发中', icon: 'none' });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 根容器只负责背景与内边距,不设固定高度、不做内部滚动 —— 内容自然撑开,
|
||||
整页滚动完全交给原生 Page 层,避免与页面下拉刷新手势打架。 */
|
||||
.page-container { min-height: 100vh; display: block; background-color: #f3f4f6; box-sizing: border-box; padding: 16px; padding-bottom: 24px; }
|
||||
.loading { text-align: center; padding: 48px 0; color: #6b7280; }
|
||||
.error-box { padding: 12px; border-radius: 10px; background: #fef2f2; color: #dc2626; font-size: 13px; border: 1px solid #fecaca; }
|
||||
.overall-bar { display: flex; align-items: center; gap: 8px; padding: 10px 14px; background: #fff; border-radius: 12px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.overall-label { font-size: 13px; color: #6b7280; }
|
||||
.overall-val { font-size: 15px; font-weight: 700; color: #2563eb; flex: 1; }
|
||||
.overall-empty { color: #ef4444; }
|
||||
.overall-archived { color: #6b7280; } /* 已入库:灰 */
|
||||
.overall-outbound { color: #4f46e5; } /* 已出库:靛蓝 */
|
||||
.overall-warehouse { color: #ea580c; } /* 待仓库收货:橙 */
|
||||
.overall-arrow { font-size: 12px; color: #9ca3af; }
|
||||
/* 🔧 售后回流标识:紫底白字(生产阶段不打标)
|
||||
不用红色——红色在本系统是「驳回/危险」语义,售后只是另一条流转支线。 */
|
||||
.life-badge { font-size: 11px; font-weight: 700; padding: 3px 10px; border-radius: 20px; flex-shrink: 0; }
|
||||
.life-badge-after { background: #9333ea; color: #ffffff; }
|
||||
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); flex-shrink: 0; min-height: 120px; }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; gap: 6px; }
|
||||
.card-header-right { display: flex; align-items: center; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.card-title { font-size: 15px; font-weight: 700; flex-shrink: 0; }
|
||||
.edit-btn { font-size: 18px; padding: 2px 6px; flex-shrink: 0; }
|
||||
.mode-toggle { font-size: 12px; font-weight: 700; padding: 4px 8px; border-radius: 8px; background: #eff6ff; color: #2563eb; white-space: nowrap; flex-shrink: 0; }
|
||||
.print-label-btn { font-size: 11px; font-weight: 700; padding: 4px 6px; border-radius: 8px; background: #fef3c7; color: #b45309; white-space: nowrap; flex-shrink: 0; }
|
||||
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.label { font-size: 12px; color: #9ca3af; }
|
||||
.value { font-size: 14px; color: #1f2937; font-weight: 600; word-break: break-all; }
|
||||
.sn { font-family: monospace; }
|
||||
.warehouse { color: #7c3aed; }
|
||||
.badge { font-size: 11px; padding: 2px 10px; border-radius: 20px; font-weight: 600; }
|
||||
.s-yellow .badge, .s-yellow { color: #b45309; }
|
||||
.s-blue .badge, .s-blue { color: #1d4ed8; }
|
||||
.s-green .badge, .s-green { color: #15803d; }
|
||||
.s-red .badge, .s-red { color: #be123c; }
|
||||
.s-gray .badge, .s-gray { color: #6b7280; }
|
||||
.overlay { position: fixed; inset: 0; z-index: 999; background: rgba(0,0,0,0.45); display: flex; align-items: flex-end; justify-content: center; }
|
||||
.sheet { width: 100%; max-width: 480px; background: #fff; border-radius: 20px 20px 0 0; padding: 20px 16px 32px; }
|
||||
.sheet-title { font-size: 17px; font-weight: 700; display: block; text-align: center; }
|
||||
.sheet-hint { font-size: 13px; color: #9ca3af; display: block; text-align: center; margin: 6px 0 16px; }
|
||||
.sheet-options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
||||
.sheet-opt { padding: 14px 8px; border-radius: 12px; text-align: center; font-size: 15px; font-weight: 600; background: #f3f4f6; color: #374151; border: 2px solid transparent; }
|
||||
.sheet-opt-active { background: #dbeafe; color: #2563eb; border-color: #2563eb; }
|
||||
.sheet-close { margin-top: 14px; height: 40px; background: #f3f4f6; border: none; border-radius: 10px; font-size: 14px; color: #6b7280; line-height: 40px; }
|
||||
.popup { width: 100%; max-width: 480px; background: #fff; border-radius: 16px 16px 0 0; padding: 20px 16px 32px; max-height: 80vh; overflow-y: auto; }
|
||||
.popup-title { font-size: 16px; font-weight: 700; display: block; text-align: center; margin-bottom: 12px; }
|
||||
.popup-task { font-size: 14px; font-weight: 600; color: #2563eb; text-align: center; margin-bottom: 4px; }
|
||||
.popup-hint { font-size: 12px; color: #9ca3af; display: block; text-align: center; }
|
||||
.popup-textarea { width: 100%; height: 80px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px; font-size: 14px; margin: 10px 0; box-sizing: border-box; }
|
||||
.popup-input { width: 100%; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 0 10px; font-size: 14px; margin: 8px 0; box-sizing: border-box; }
|
||||
.popup-btns { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.btn-cancel { flex: 1; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px; background: #fff; color: #6b7280; font-size: 14px; line-height: 42px; }
|
||||
.btn-primary { flex: 1; height: 42px; border: none; border-radius: 10px; background: #2563eb; color: #fff; font-size: 14px; font-weight: 600; line-height: 42px; }
|
||||
.btn-primary[disabled] { opacity: 0.5; }
|
||||
.btn-danger { flex: 1; height: 42px; border: none; border-radius: 10px; background: #dc2626; color: #fff; font-size: 14px; font-weight: 600; line-height: 42px; }
|
||||
.btn-danger[disabled] { opacity: 0.5; }
|
||||
.btn-counting { background: #f59e0b !important; }
|
||||
.cd-tip { display: block; text-align: center; font-size: 12px; color: #b45309; background: #fffbeb; border: 1px solid #fcd34d; border-radius: 8px; padding: 6px 10px; margin: 8px 0 0; line-height: 1.4; }
|
||||
.branch-item { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; margin-bottom: 10px; }
|
||||
.branch-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
|
||||
.branch-label { font-size: 13px; font-weight: 700; color: #374151; }
|
||||
.branch-del { font-size: 12px; color: #ef4444; font-weight: 600; padding: 2px 8px; }
|
||||
.btn-add-branch { width: 100%; height: 40px; border: 2px dashed #93c5fd; border-radius: 10px; background: #eff6ff; color: #2563eb; font-size: 14px; font-weight: 700; line-height: 40px; margin: 4px 0; }
|
||||
.btn-add-branch::after { border: none; }
|
||||
.field-label { font-size: 14px; font-weight: 600; color: #374151; margin-top: 10px; margin-bottom: 4px; }
|
||||
|
||||
/* 💬 留言悬浮按钮 */
|
||||
.msg-fab { position: fixed; right: 20px; bottom: 100px; z-index: 99; width: 50px; height: 50px; border-radius: 25px; background: #3b82f6; color: #fff; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 12px rgba(59,130,246,0.4); }
|
||||
.msg-fab-icon { font-size: 22px; }
|
||||
.msg-fab-badge { position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px; border-radius: 9px; background: #ef4444; color: #fff; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; padding: 0 5px; }
|
||||
|
||||
/* 💬 留言板底部抽屉 */
|
||||
.msg-drawer-overlay { position: fixed; inset: 0; z-index: 200; background: rgba(0,0,0,0.45); display: flex; align-items: flex-end; justify-content: center; }
|
||||
.message-board-drawer { height: 65vh; display: flex; flex-direction: column; background: #fff; border-radius: 16px 16px 0 0; width: 100%; max-width: 480px; }
|
||||
.mb-drawer-handle { width: 40px; height: 4px; border-radius: 2px; background: #d1d5db; margin: 8px auto; flex-shrink: 0; }
|
||||
.mb-title { font-size: 14px; font-weight: bold; padding: 12px 16px; border-bottom: 1px solid #f3f4f6; color: #374151; flex-shrink: 0; }
|
||||
.mb-scroll-area { flex: 1; padding: 12px; overflow-y: auto; }
|
||||
.mb-item { display: flex; margin-bottom: 16px; }
|
||||
.mb-avatar { width: 36px; height: 36px; border-radius: 18px; background: #3b82f6; color: #fff; font-weight: bold; display: flex; align-items: center; justify-content: center; margin-right: 12px; flex-shrink: 0; font-size: 14px; }
|
||||
.mb-content-wrapper { flex: 1; min-width: 0; }
|
||||
.mb-header-info { margin-bottom: 4px; display: flex; align-items: baseline; }
|
||||
.mb-name { font-size: 12px; color: #6b7280; margin-right: 8px; font-weight: 600; }
|
||||
.mb-time { font-size: 10px; color: #9ca3af; }
|
||||
.mb-bubble { background: #f3f4f6; padding: 8px 12px; border-radius: 0 12px 12px 12px; font-size: 14px; color: #1f2937; word-break: break-all; line-height: 1.5; }
|
||||
.mb-input-bar { display: flex; padding: 10px 16px; border-top: 1px solid #e5e7eb; align-items: center; background: #f9fafb; border-radius: 0 0 12px 12px; flex-shrink: 0; }
|
||||
.mb-input { flex: 1; background: #ffffff; border: 1px solid #d1d5db; padding: 6px 12px; border-radius: 16px; font-size: 14px; height: 36px; }
|
||||
.mb-send-btn { margin-left: 12px; background: #3b82f6; color: #fff; padding: 6px 16px; border-radius: 16px; font-size: 14px; font-weight: 600; transition: all 0.2s; }
|
||||
.btn-disabled { background: #9ca3af; opacity: 0.5; }
|
||||
.mb-bottom-anchor { height: 1px; }
|
||||
.required { color: #ef4444; }
|
||||
.optional { color: #9ca3af; font-weight: 400; font-size: 12px; }
|
||||
.picker-box { width: 100%; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 0 12px; font-size: 14px; color: #1f2937; line-height: 42px; box-sizing: border-box; background: #fff; }
|
||||
.img-grid { display: flex; flex-wrap: wrap; margin: 8px -5px; }
|
||||
.img-cell { position: relative; width: 160rpx; height: 160rpx; margin: 10rpx; }
|
||||
/* 圆角与裁剪交给 .success-badge-wrapper,图片只负责填满 */
|
||||
.img-frame { width: 160rpx; height: 160rpx; }
|
||||
.img-thumb { width: 100%; height: 100%; display: block; border: 1px solid #e5e7eb; box-sizing: border-box; }
|
||||
.img-cell-loading { display: flex; align-items: center; justify-content: center; background: #f3f4f6; border-radius: 12rpx; border: 1px dashed #d1d5db; }
|
||||
.img-loading-text { font-size: 36rpx; }
|
||||
.img-del { position: absolute; top: -12rpx; right: -12rpx; width: 40rpx; height: 40rpx; background: #ef4444; color: #fff; border-radius: 20rpx; font-size: 24rpx; text-align: center; line-height: 40rpx; z-index: 2; }
|
||||
.btn-upload { width: 100%; height: 42px; border: 1px dashed #d1d5db; border-radius: 10px; background: #f9fafb; color: #6b7280; font-size: 14px; line-height: 42px; margin: 8px 0; }
|
||||
.form-item { margin: 10px 0; }
|
||||
.form-label { font-size: 14px; font-weight: 600; color: #374151; display: block; margin-bottom: 4px; }
|
||||
.picker-value { display: flex; align-items: center; justify-content: space-between; width: 100%; height: 42px; padding: 0 12px; border: 1px solid #e5e7eb; border-radius: 10px; background: #f9fafb; font-size: 14px; box-sizing: border-box; }
|
||||
.user-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
||||
.user-grid-item { padding: 12px 8px; border-radius: 10px; background: #f3f4f6; text-align: center; font-size: 14px; font-weight: 600; color: #374151; border: 2px solid transparent; }
|
||||
.user-grid-active { background: #dbeafe; color: #2563eb; border-color: #2563eb; }
|
||||
.warehouse-hint { font-size: 13px; background: #ede9fe; color: #7c3aed; padding: 10px 14px; border-radius: 10px; margin: 8px 0; text-align: center; }
|
||||
.warehouse-transfer-banner { display: flex; align-items: center; gap: 12px; font-weight: 700; background: linear-gradient(135deg, #ede9fe, #dbeafe); color: #5b21b6; padding: 14px 16px; border-radius: 12px; margin-bottom: 12px; border: 2px dashed #a78bfa; }
|
||||
.wt-icon { font-size: 24px; }
|
||||
.wt-text { font-size: 14px; flex: 1; }
|
||||
.preview-hint { font-size: 12px; background: #f0fdf4; color: #16a34a; padding: 8px 10px; border-radius: 8px; margin: 6px 0; }
|
||||
</style>
|
||||
170
track-uniapp/src/pages/scan/index.vue
Normal file
170
track-uniapp/src/pages/scan/index.vue
Normal file
@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 🚀 全屏扫码大按钮 -->
|
||||
<view class="scan-btn camera-btn" @tap="handleScanCamera">
|
||||
<text class="scan-icon">📷</text>
|
||||
<text class="scan-text">拍照扫码</text>
|
||||
<text class="scan-hint">全屏扫描二维码 / 条码</text>
|
||||
</view>
|
||||
|
||||
<!-- 手动输入 -->
|
||||
<view class="manual-section">
|
||||
<text class="section-label">或手动输入</text>
|
||||
<view class="manual-input">
|
||||
<view class="input-wrap">
|
||||
<input v-model="serialNumber" class="input" type="text" maxlength="16"
|
||||
placeholder="输入16位身份证" @confirm="handleSearch" />
|
||||
<text v-if="serialNumber" class="input-clear" @tap="serialNumber=''">✕</text>
|
||||
</view>
|
||||
<button class="search-btn" @tap="handleSearch" :disabled="loading">
|
||||
{{ loading ? '查询中' : '查询' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最近扫描 -->
|
||||
<view v-if="lastScanned" class="last-scan">
|
||||
<text class="last-label">最近扫描</text>
|
||||
<text class="sn-text" @tap="handleSearch">{{ lastScanned }}</text>
|
||||
</view>
|
||||
<view v-if="loading" class="loading">
|
||||
<text class="loading-icon">⏳</text>
|
||||
<text>查询中...</text>
|
||||
</view>
|
||||
<view v-if="error" class="error-box">
|
||||
<text class="error-icon">⚠️</text>
|
||||
<text>{{ error }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view v-if="!loading && !error && !lastScanned" class="empty">
|
||||
<text class="empty-icon">📱</text>
|
||||
<text class="empty-text">扫码或手动输入身份证</text>
|
||||
<text class="empty-sub">查询产品流转进度</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { get } from "../../utils/request";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
serialNumber: "",
|
||||
lastScanned: "",
|
||||
loading: false,
|
||||
error: "",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async doQuery(sn) {
|
||||
if (!sn || sn.length < 8) { this.error = "身份证至少需要 8 位"; return; }
|
||||
this.serialNumber = sn;
|
||||
this.lastScanned = sn;
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
try {
|
||||
await get(`/products/scan/${sn}`);
|
||||
uni.navigateTo({ url: `/pages/scan/detail?serial=${sn}` });
|
||||
} catch (e) {
|
||||
this.error = e?.data?.detail || "未找到该产品";
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
handleSearch() { this.doQuery(this.serialNumber.trim()); },
|
||||
|
||||
// 📷 从扫码原始文本中提取设备身份证(SN)
|
||||
// 二维码内容有两种可能,必须都兼容:
|
||||
// ① 裸 SN → ABC1234567890DEF
|
||||
// ② 带域名的完整 URL → https://track.iris-rs.cn/sn/ABC1234567890DEF?from=label
|
||||
// 旧逻辑只是「去掉非字母数字后截前 16 位」,扫到 ② 时会把 "https"、域名
|
||||
// 一起当成 SN 的前半段,截出来是个完全不存在的号,永远提示"未找到该产品"。
|
||||
extractSerial(raw) {
|
||||
const text = String(raw || "").trim();
|
||||
if (!text) return "";
|
||||
|
||||
// 关键一步:先把 "协议://主机名" 整段剥掉,否则域名会被当成候选 SN
|
||||
// (如 trackbackirisrscn 这种 17 位串,恰好能通过长度校验)。
|
||||
let body = text;
|
||||
const origin = text.match(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]+/i);
|
||||
if (origin) body = text.slice(origin[0].length);
|
||||
|
||||
// 在剩余内容里找长度 >= 8 的连续字母数字串(身份证至少 8 位)。
|
||||
// 取最长的一段;等长时取靠后的 —— URL 里 SN 通常在路径末尾。
|
||||
const runs = body.match(/[a-zA-Z0-9]{8,}/g) || [];
|
||||
if (!runs.length) return "";
|
||||
let best = runs[0];
|
||||
for (const run of runs) {
|
||||
if (run.length >= best.length) best = run;
|
||||
}
|
||||
return best.slice(0, 16);
|
||||
},
|
||||
|
||||
// 📷 全屏相机扫码
|
||||
handleScanCamera() {
|
||||
uni.scanCode({
|
||||
onlyFromCamera: true,
|
||||
scanType: ["qrCode", "barCode"],
|
||||
success: (res) => {
|
||||
const sn = this.extractSerial(res.result);
|
||||
if (!sn) {
|
||||
uni.showToast({ title: "无法识别二维码内容,请确认扫的是设备标签", icon: "none", duration: 2500 });
|
||||
return;
|
||||
}
|
||||
this.doQuery(sn);
|
||||
},
|
||||
fail: (err) => {
|
||||
if (!err.errMsg || !err.errMsg.includes("cancel")) {
|
||||
uni.showToast({ title: "扫码失败,请重试", icon: "none" });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 24px 16px 16px; background: #f3f4f6; }
|
||||
|
||||
/* 扫码区域 */
|
||||
.scan-btn { display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
height: 180px; border-radius: 20px; color: #fff; box-shadow: 0 4px 20px rgba(0,0,0,0.12);
|
||||
transition: transform 0.15s; margin-bottom: 24px; }
|
||||
.scan-btn:active { transform: scale(0.96); }
|
||||
.camera-btn { background: linear-gradient(135deg, #2563EB, #4F46E5); }
|
||||
.scan-icon { font-size: 44px; margin-bottom: 6px; }
|
||||
.scan-text { font-size: 17px; font-weight: 700; }
|
||||
.scan-hint { font-size: 11px; opacity: 0.75; margin-top: 4px; }
|
||||
|
||||
/* 手动输入 */
|
||||
.manual-section { margin-top: 24px; }
|
||||
.section-label { font-size: 12px; color: #9ca3af; margin-bottom: 8px; display: block; }
|
||||
.manual-input { display: flex; gap: 8px; }
|
||||
.input-wrap { flex: 1; position: relative; }
|
||||
.input { width: 100%; height: 48px; padding: 0 36px 0 14px; border: 1px solid #e5e7eb; border-radius: 12px;
|
||||
font-size: 15px; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,0.04); box-sizing: border-box; }
|
||||
.input-clear { position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
|
||||
font-size: 16px; color: #9ca3af; padding: 4px; z-index: 2; }
|
||||
.search-btn { height: 48px; padding: 0 22px; background: #2563EB; color: #fff; border: none;
|
||||
border-radius: 12px; font-size: 15px; font-weight: 600; line-height: 48px; }
|
||||
.search-btn[disabled] { opacity: 0.5; }
|
||||
|
||||
/* 状态 */
|
||||
.last-scan { display: flex; align-items: center; gap: 8px; margin-top: 20px; padding: 12px 16px;
|
||||
background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
|
||||
.last-label { font-size: 12px; color: #9ca3af; }
|
||||
.sn-text { font-family: monospace; font-size: 14px; color: #2563EB; font-weight: 600; flex: 1; }
|
||||
.loading { display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
padding: 24px 0; color: #6b7280; font-size: 14px; }
|
||||
.loading-icon { font-size: 20px; }
|
||||
.error-box { display: flex; align-items: center; gap: 8px; margin-top: 16px; padding: 14px;
|
||||
border-radius: 12px; background: #fef2f2; color: #dc2626; font-size: 13px; border: 1px solid #fecaca; }
|
||||
.error-icon { font-size: 16px; }
|
||||
.empty { display: flex; flex-direction: column; align-items: center; padding-top: 60px; color: #9ca3af; }
|
||||
.empty-icon { font-size: 56px; margin-bottom: 12px; }
|
||||
.empty-text { font-size: 15px; font-weight: 500; }
|
||||
.empty-sub { font-size: 12px; margin-top: 4px; }
|
||||
</style>
|
||||
378
track-uniapp/src/pages/scan/records.vue
Normal file
378
track-uniapp/src/pages/scan/records.vue
Normal file
@ -0,0 +1,378 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view v-if="loading" class="loading">加载中...</view>
|
||||
<view v-if="error" class="error-box">{{ error }}</view>
|
||||
|
||||
<template v-if="!loading && task">
|
||||
<!-- 任务信息头 -->
|
||||
<view class="task-header">
|
||||
<text class="task-name">{{ task.task_name }}</text>
|
||||
<text class="task-meta">负责人: {{ task.assignee_id || '—' }} · {{ statusLabel(task.status) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 记录时间轴 -->
|
||||
<view v-if="records.length" class="timeline">
|
||||
<view v-for="(rec, i) in records" :key="rec.id" class="tl-item">
|
||||
<!-- 时间轴竖线 + 圆点 -->
|
||||
<view class="tl-line">
|
||||
<view class="tl-dot" :class="i === 0 ? 'tl-dot-latest' : ''" />
|
||||
<view v-if="i < records.length - 1" class="tl-connector" />
|
||||
</view>
|
||||
|
||||
<!-- 内容卡片 -->
|
||||
<view class="tl-card">
|
||||
<view class="tl-top">
|
||||
<text class="tl-time">{{ formatTime(rec.created_at) }}</text>
|
||||
<view v-if="canEdit" class="tl-actions">
|
||||
<text class="tl-act" @tap="openEditRecord(rec)">✏️</text>
|
||||
<text class="tl-act" @tap="confirmDelete(rec)">🗑️</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="rec.remark" class="tl-remark">{{ rec.remark }}</text>
|
||||
<view v-if="rec.images && rec.images.length" class="tl-images">
|
||||
<image
|
||||
v-for="(img, j) in rec.images"
|
||||
:key="j"
|
||||
:src="imageUrl(img)"
|
||||
mode="aspectFill"
|
||||
class="tl-thumb"
|
||||
@tap="previewImage(rec.images, j)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="empty-timeline">
|
||||
<text class="empty-icon">📭</text>
|
||||
<text class="empty-text">暂无历史记录</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 🛡️ 双重确认删除(5 秒倒计时防误触) -->
|
||||
<view v-if="confirmDlg.visible" class="dlg-overlay" @tap="confirmDlgCancel">
|
||||
<view class="dlg-box" @tap.stop>
|
||||
<text class="dlg-title">{{ confirmDlg.title }}</text>
|
||||
<text class="dlg-content">{{ confirmDlg.content }}</text>
|
||||
<text class="dlg-tip">⚠️ 5 秒确认等待中:请核对信息,倒计时结束后确认按钮才可点击</text>
|
||||
<view class="dlg-btns">
|
||||
<button class="dlg-btn dlg-cancel" @tap="confirmDlgCancel">取消</button>
|
||||
<button class="dlg-btn dlg-danger" :disabled="confirming === 'dlg' && confirmCount > 0" @tap="confirmDlgConfirm">{{ confirmLabel('删除') }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ✏️ 编辑记录弹窗 -->
|
||||
<view v-if="editVisible" class="edit-overlay" @tap="closeEditPopup">
|
||||
<view class="edit-popup" @tap.stop>
|
||||
<text class="edit-title">✏️ 编辑记录</text>
|
||||
<textarea v-model="editForm.remark" class="edit-textarea" placeholder="填写备注说明" :maxlength="2000" />
|
||||
<view class="edit-imgs">
|
||||
<view v-for="(img, i) in editForm.images" :key="i" class="edit-img-cell">
|
||||
<view class="success-badge-wrapper edit-img-frame">
|
||||
<image :src="imageUrl(img)" mode="aspectFill" class="edit-img" @tap="previewEditImage(i)" />
|
||||
<view v-if="isUploaded(img)" class="success-badge" />
|
||||
</view>
|
||||
<text class="edit-img-del" @tap.stop="removeEditImage(i)">✕</text>
|
||||
</view>
|
||||
<view v-for="n in editForm.pendingCount" :key="'p'+n" class="edit-img-cell edit-img-loading"><text class="edit-img-loading-text">⏳</text></view>
|
||||
</view>
|
||||
<button v-if="editForm.images.length + editForm.pendingCount < 9" class="edit-upload" @tap="handleChooseImage" :disabled="isUploading">{{ isUploading ? '上传中...' : '📷 拍照/选图' }}</button>
|
||||
<view class="edit-btns">
|
||||
<button class="edit-btn edit-cancel" @tap="closeEditPopup">取消</button>
|
||||
<button class="edit-btn edit-save" :disabled="editSaving || isUploading" @tap="doSaveEdit">{{ editSaving ? '保存中...' : '保存' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import request, { get, put, getBaseUrl } from "../../utils/request";
|
||||
import { uploadImages, isUploadedUrl } from "../../utils/upload";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
error: "",
|
||||
task: null,
|
||||
records: [],
|
||||
currentUser: null,
|
||||
// 🛡️ 双重确认(5 秒倒计时防误触)
|
||||
confirmDlg: { visible: false, title: "", content: "", action: null },
|
||||
confirming: "",
|
||||
confirmCount: 5,
|
||||
confirmTimer: null,
|
||||
// ✏️ 编辑记录弹窗
|
||||
editVisible: false,
|
||||
editForm: { recordId: null, remark: "", images: [], pendingCount: 0 },
|
||||
editSaving: false,
|
||||
isUploading: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
canEdit() {
|
||||
if (!this.currentUser || !this.task) return false;
|
||||
// 已完成/已驳回/已入库/已撤回 的任务禁止编辑
|
||||
const frozen = ["COMPLETED", "REJECTED", "ARCHIVED", "CANCELED"];
|
||||
if (frozen.includes(this.task.status)) return false;
|
||||
return (
|
||||
this.currentUser.id == this.task.assignee_id ||
|
||||
this.currentUser.username == this.task.assignee_id
|
||||
);
|
||||
},
|
||||
},
|
||||
onLoad(options) {
|
||||
this.loadCurrentUser();
|
||||
const taskId = options.taskId || "";
|
||||
if (taskId) {
|
||||
this.loadTask(taskId);
|
||||
} else {
|
||||
this.error = "缺少任务ID";
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
// 🚀 页面卸载:清理确认倒计时定时器
|
||||
onUnload() { this.clearConfirm(); },
|
||||
methods: {
|
||||
loadCurrentUser() {
|
||||
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") {
|
||||
this.currentUser = user;
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
|
||||
async loadTask(taskId) {
|
||||
this.loading = true;
|
||||
try {
|
||||
// 通过产品扫码接口反向获取任务(后端可能没有单独的任务查询接口)
|
||||
// 这里假设后端提供 GET /tasks/{taskId}
|
||||
this.task = await get(`/tasks/${taskId}`);
|
||||
this.records = this.task.records || [];
|
||||
} catch {
|
||||
this.error = "加载任务失败";
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
imageUrl(url) {
|
||||
if (!url) return "";
|
||||
if (url.startsWith("http")) return url;
|
||||
const domain = getBaseUrl().replace(/\/api.*$/, '');
|
||||
return domain + (url.startsWith("/") ? url : "/" + url);
|
||||
},
|
||||
|
||||
previewImage(urls, index) {
|
||||
const fullUrls = (urls || []).map((u) => this.imageUrl(u));
|
||||
uni.previewImage({ urls: fullUrls, current: index });
|
||||
},
|
||||
|
||||
/** 绿勾角标只认「已上传成功」的项(判定逻辑统一收口在 utils/upload.js) */
|
||||
isUploaded(url) {
|
||||
return isUploadedUrl(url);
|
||||
},
|
||||
|
||||
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())}`;
|
||||
},
|
||||
|
||||
statusLabel(s) {
|
||||
const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库" };
|
||||
return map[s] || s;
|
||||
},
|
||||
|
||||
// ✏️ 编辑记录:直接在当前页弹窗编辑备注与图片
|
||||
openEditRecord(rec) {
|
||||
this.editForm = { recordId: rec.id, remark: rec.remark || "", images: rec.images || [], pendingCount: 0 };
|
||||
this.editVisible = true;
|
||||
this.isUploading = false;
|
||||
},
|
||||
closeEditPopup() {
|
||||
this.editVisible = false;
|
||||
this.editForm = { recordId: null, remark: "", images: [], pendingCount: 0 };
|
||||
},
|
||||
previewEditImage(i) {
|
||||
uni.previewImage({ urls: this.editForm.images.map((u) => this.imageUrl(u)), current: i });
|
||||
},
|
||||
removeEditImage(i) {
|
||||
this.openConfirmDlg({ title: "删除图片", content: "确定删除这张图片吗?", action: () => { this.editForm.images.splice(i, 1); } });
|
||||
},
|
||||
async handleChooseImage() {
|
||||
const maxSlots = 9 - (this.editForm.images.length + this.editForm.pendingCount);
|
||||
if (maxSlots <= 0) return;
|
||||
const chooseRes = await new Promise((resolve, reject) => {
|
||||
uni.chooseImage({ count: maxSlots, sizeType: ["compressed"], sourceType: ["camera", "album"], success: resolve, fail: reject });
|
||||
}).catch(() => null);
|
||||
if (!chooseRes || !chooseRes.tempFilePaths || !chooseRes.tempFilePaths.length) return;
|
||||
const compressedPaths = [];
|
||||
for (const p of chooseRes.tempFilePaths) {
|
||||
try {
|
||||
const compressed = await new Promise((resolve, reject) => { uni.compressImage({ src: p, quality: 60, success: resolve, fail: reject }); });
|
||||
compressedPaths.push(compressed.tempFilePath);
|
||||
} catch {}
|
||||
}
|
||||
if (!compressedPaths.length) {
|
||||
// 压缩全军覆没(内存不足/格式异常)也必须说一声,不能悄悄什么都没发生
|
||||
uni.showToast({ title: "图片处理失败,请重试", icon: "none", duration: 3000 });
|
||||
return;
|
||||
}
|
||||
this.isUploading = true;
|
||||
this.editForm.pendingCount += compressedPaths.length;
|
||||
// 🚀 有界并发上传(utils/upload.js):9 张图并行补位,不再串行干等。
|
||||
// onEachDone 对每张图恰好回调一次 —— 成功的推入 images 供预览,
|
||||
// 失败的保持不推入(不给"图片已传好"的错觉),两者都回收一个 ⏳ 占位。
|
||||
const { failed, total } = await uploadImages(compressedPaths, (url) => {
|
||||
if (url) this.editForm.images.push(url);
|
||||
this.editForm.pendingCount--;
|
||||
});
|
||||
this.isUploading = false;
|
||||
if (failed > 0) {
|
||||
uni.showToast({
|
||||
title: failed === total ? "图片上传失败,请重试" : "部分图片上传失败,请重试",
|
||||
icon: "none",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
},
|
||||
async doSaveEdit() {
|
||||
if (this.isUploading) return;
|
||||
this.editSaving = true;
|
||||
try {
|
||||
const payload = { remark: this.editForm.remark.trim(), images: this.editForm.images };
|
||||
await put(`/records/${this.editForm.recordId}`, payload);
|
||||
uni.showToast({ title: "已保存", icon: "success" });
|
||||
const idx = this.records.findIndex((r) => r.id === this.editForm.recordId);
|
||||
if (idx >= 0) this.records[idx] = { ...this.records[idx], remark: payload.remark, images: payload.images };
|
||||
this.closeEditPopup();
|
||||
} catch (e) {
|
||||
uni.showToast({ title: (e && e.data && e.data.detail) || "保存失败", icon: "none" });
|
||||
} finally {
|
||||
this.editSaving = false;
|
||||
}
|
||||
},
|
||||
|
||||
confirmDelete(rec) {
|
||||
this.openConfirmDlg({ title: "删除记录", content: "确定删除这条记录吗?删除后不可恢复。", action: () => this.deleteRecord(rec) });
|
||||
},
|
||||
// ═══ 双重确认倒计时(防误触) ═══
|
||||
openConfirmDlg({ title, content, action }) {
|
||||
this.clearConfirm();
|
||||
this.confirmDlg = { visible: true, title, content, action };
|
||||
this.confirming = "dlg";
|
||||
this.confirmCount = 5;
|
||||
this.confirmTimer = setInterval(() => {
|
||||
this.confirmCount -= 1;
|
||||
if (this.confirmCount <= 0) clearInterval(this.confirmTimer);
|
||||
}, 1000);
|
||||
},
|
||||
clearConfirm() {
|
||||
if (this.confirmTimer) clearInterval(this.confirmTimer);
|
||||
this.confirmTimer = null;
|
||||
this.confirming = "";
|
||||
this.confirmCount = 5;
|
||||
},
|
||||
confirmLabel(baseText) {
|
||||
if (this.confirming === "dlg" && this.confirmCount > 0) return `${baseText} (${this.confirmCount}s)`;
|
||||
return baseText;
|
||||
},
|
||||
confirmDlgConfirm() {
|
||||
if (this.confirming === "dlg" && this.confirmCount > 0) return; // 倒计时中:忽略
|
||||
if (this.confirming === "dlg") this.clearConfirm();
|
||||
const action = this.confirmDlg.action;
|
||||
this.confirmDlg.visible = false;
|
||||
this.confirmDlg.action = null;
|
||||
if (action) action();
|
||||
},
|
||||
confirmDlgCancel() {
|
||||
this.clearConfirm();
|
||||
this.confirmDlg.visible = false;
|
||||
this.confirmDlg.action = null;
|
||||
},
|
||||
async deleteRecord(rec) {
|
||||
try {
|
||||
await request({ url: `/records/${rec.id}`, method: "DELETE" });
|
||||
uni.showToast({ title: "已删除", icon: "success" });
|
||||
this.records = this.records.filter((r) => r.id !== rec.id);
|
||||
} catch {
|
||||
uni.showToast({ title: "删除失败", icon: "none" });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; padding-bottom: 40px; background: #f3f4f6; }
|
||||
.loading { text-align: center; padding: 48px 0; color: #6b7280; }
|
||||
.error-box { padding: 12px; border-radius: 10px; background: #fef2f2; color: #dc2626; font-size: 13px; }
|
||||
|
||||
/* 任务头 */
|
||||
.task-header { background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.task-name { font-size: 18px; font-weight: 700; color: #1f2937; display: block; }
|
||||
.task-meta { font-size: 13px; color: #9ca3af; margin-top: 4px; display: block; }
|
||||
|
||||
/* 时间轴 */
|
||||
.timeline { padding-left: 8px; }
|
||||
.tl-item { display: flex; gap: 12px; }
|
||||
.tl-line { display: flex; flex-direction: column; align-items: center; width: 24px; flex-shrink: 0; }
|
||||
.tl-dot { width: 12px; height: 12px; border-radius: 50%; background: #d1d5db; margin-top: 6px; }
|
||||
.tl-dot-latest { background: #2563eb; box-shadow: 0 0 0 4px rgba(37,99,235,0.15); }
|
||||
.tl-connector { flex: 1; width: 2px; background: #e5e7eb; min-height: 12px; }
|
||||
.tl-card { flex: 1; background: #fff; border-radius: 10px; padding: 12px; margin-bottom: 12px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
|
||||
.tl-top { display: flex; align-items: center; justify-content: space-between; }
|
||||
.tl-time { font-size: 12px; color: #9ca3af; }
|
||||
.tl-actions { display: flex; gap: 12px; }
|
||||
.tl-act { font-size: 16px; padding: 2px; }
|
||||
.tl-remark { display: block; font-size: 14px; color: #374151; margin-top: 6px; line-height: 1.5; word-break: break-all; }
|
||||
.tl-images { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
|
||||
.tl-thumb { width: 80px; height: 80px; border-radius: 8px; background: #e5e7eb; }
|
||||
|
||||
.empty-timeline { display: flex; flex-direction: column; align-items: center; padding-top: 60px; }
|
||||
.empty-icon { font-size: 48px; margin-bottom: 8px; }
|
||||
.empty-text { font-size: 14px; color: #9ca3af; }
|
||||
|
||||
/* 双重确认弹窗 */
|
||||
.dlg-overlay { position: fixed; inset: 0; z-index: 999; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; }
|
||||
.dlg-box { width: 80%; max-width: 360px; background: #fff; border-radius: 16px; padding: 28px 20px 20px; box-sizing: border-box; }
|
||||
.dlg-title { display: block; text-align: center; font-size: 17px; font-weight: 700; color: #1f2937; }
|
||||
.dlg-content { display: block; text-align: center; font-size: 14px; color: #6b7280; margin-top: 10px; line-height: 1.5; }
|
||||
.dlg-tip { display: block; text-align: center; font-size: 12px; color: #b45309; background: #fffbeb; border: 1px solid #fcd34d; border-radius: 8px; padding: 6px 10px; margin: 12px 0 0; line-height: 1.4; }
|
||||
.dlg-btns { display: flex; gap: 12px; margin-top: 20px; }
|
||||
.dlg-btn { flex: 1; height: 42px; line-height: 42px; border-radius: 10px; font-size: 15px; font-weight: 600; text-align: center; box-sizing: border-box; padding: 0; margin: 0; }
|
||||
.dlg-btn::after { border: none; }
|
||||
.dlg-cancel { background: #f3f4f6; color: #6b7280; }
|
||||
.dlg-danger { background: #dc2626; color: #fff; }
|
||||
.dlg-danger[disabled] { opacity: 0.5; }
|
||||
|
||||
/* 编辑记录弹窗 */
|
||||
.edit-overlay { position: fixed; inset: 0; z-index: 999; background: rgba(0,0,0,0.45); display: flex; align-items: flex-end; justify-content: center; }
|
||||
.edit-popup { width: 100%; max-width: 480px; background: #fff; border-radius: 16px 16px 0 0; padding: 20px 16px 32px; box-sizing: border-box; max-height: 80vh; overflow-y: auto; }
|
||||
.edit-title { display: block; text-align: center; font-size: 16px; font-weight: 700; color: #1f2937; margin-bottom: 12px; }
|
||||
.edit-textarea { width: 100%; height: 80px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px; font-size: 14px; box-sizing: border-box; }
|
||||
.edit-imgs { display: flex; flex-wrap: wrap; margin-top: 10px; }
|
||||
.edit-img-cell { position: relative; width: 160rpx; height: 160rpx; margin: 10rpx; }
|
||||
/* 圆角与裁剪交给 .success-badge-wrapper,图片只负责填满 */
|
||||
.edit-img-frame { width: 160rpx; height: 160rpx; }
|
||||
.edit-img { width: 100%; height: 100%; display: block; border: 1px solid #e5e7eb; box-sizing: border-box; }
|
||||
.edit-img-loading { display: flex; align-items: center; justify-content: center; background: #f3f4f6; border-radius: 12rpx; border: 1px dashed #d1d5db; }
|
||||
.edit-img-loading-text { font-size: 36rpx; }
|
||||
.edit-img-del { position: absolute; top: -12rpx; right: -12rpx; width: 40rpx; height: 40rpx; background: #ef4444; color: #fff; border-radius: 20rpx; font-size: 24rpx; text-align: center; line-height: 40rpx; z-index: 2; }
|
||||
.edit-upload { width: 100%; height: 42px; border: 1px dashed #d1d5db; border-radius: 10px; background: #f9fafb; color: #6b7280; font-size: 14px; line-height: 42px; margin-top: 10px; }
|
||||
.edit-upload[disabled] { opacity: 0.5; }
|
||||
.edit-btns { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.edit-btn { flex: 1; height: 42px; line-height: 42px; border-radius: 10px; font-size: 14px; font-weight: 600; text-align: center; box-sizing: border-box; padding: 0; margin: 0; }
|
||||
.edit-btn::after { border: none; }
|
||||
.edit-cancel { background: #f3f4f6; color: #6b7280; }
|
||||
.edit-save { background: #2563eb; color: #fff; }
|
||||
.edit-save[disabled] { opacity: 0.5; }
|
||||
</style>
|
||||
216
track-uniapp/src/pages/tasks/index.vue
Normal file
216
track-uniapp/src/pages/tasks/index.vue
Normal file
@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header">
|
||||
<text class="title">我的任务</text>
|
||||
</view>
|
||||
|
||||
<!-- Tab 栏 — 固定顶部 -->
|
||||
<view class="tabs">
|
||||
<view v-for="t in TABS" :key="t.key"
|
||||
:class="['tab', tab === t.key ? 'tab-active' : '']"
|
||||
@tap="tab = t.key">{{ t.label }} ({{ countBy(t.key) }})</view>
|
||||
</view>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<view v-if="loading" class="center">加载中...</view>
|
||||
<template v-else>
|
||||
<view v-if="filtered.length === 0" class="empty">
|
||||
<text class="empty-icon">📋</text>
|
||||
<text class="empty-text">{{ tab === 'all' ? '暂无待办任务' : '无此状态任务' }}</text>
|
||||
</view>
|
||||
<view v-for="task in filtered" :key="task.id" class="card" @tap="goDetail(task)">
|
||||
<view class="card-row">
|
||||
<view style="display:flex;align-items:center;gap:6px;min-width:0;">
|
||||
<text :class="['tag-badge', task.task_type === 'SPAWN' ? 'tag-sub' : 'tag-main']">{{ task.task_type === 'SPAWN' ? '协助' : '主干' }}</text>
|
||||
<text class="card-name">{{ task.task_name }}</text>
|
||||
</view>
|
||||
<text :class="['card-status', statusColor(task.status)]">{{ statusLabel(task.status) }}</text>
|
||||
</view>
|
||||
<view class="card-meta">
|
||||
<text>身份证: {{ task.product_sn || '—' }}</text>
|
||||
<text>物料: {{ task.product_material || '—' }}</text>
|
||||
</view>
|
||||
<view class="card-time">创建: {{ formatTime(task.created_at) }}</view>
|
||||
</view>
|
||||
|
||||
<!-- ⬇️ 触底加载状态:让工人知道"到底了"还是"还在拉" -->
|
||||
<view v-if="loadingMore" class="load-more">加载中...</view>
|
||||
<view v-else-if="!hasMore && tasks.length > 0" class="load-more">— 没有更多了 —</view>
|
||||
<view v-else-if="hasMore && tasks.length > 0" class="load-more">上拉加载更多</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { get } from "../../utils/request";
|
||||
|
||||
const TABS = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "PENDING", label: "待接收" },
|
||||
{ key: "WIP", label: "进行中" },
|
||||
{ key: "COMPLETED", label: "已完成" },
|
||||
];
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
TABS,
|
||||
tab: "PENDING",
|
||||
tasks: [],
|
||||
loading: true,
|
||||
currentUser: null,
|
||||
// 📄 分页状态:此前硬编码 limit:100 一次性拉全量,任务多了会拖慢首屏
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
hasMore: true,
|
||||
loadingMore: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filtered() {
|
||||
if (this.tab === "all") return this.tasks;
|
||||
return this.tasks.filter(t => t.status === this.tab);
|
||||
},
|
||||
},
|
||||
onShow() {
|
||||
this.loadCurrentUser();
|
||||
this.fetchTasks({ reset: true });
|
||||
},
|
||||
// 🚀 下拉刷新:回到第 1 页并清空现有列表重载(分页后必须重置,否则新旧页会错位)
|
||||
async onPullDownRefresh() {
|
||||
try {
|
||||
this.loadCurrentUser();
|
||||
await this.fetchTasks({ reset: true });
|
||||
} finally {
|
||||
uni.stopPullDownRefresh(); // 无论成败都要收起动画,否则会一直挂着
|
||||
}
|
||||
},
|
||||
// 🚀 触底加载下一页
|
||||
onReachBottom() {
|
||||
if (this.loading || this.loadingMore || !this.hasMore) return;
|
||||
this.loadMore();
|
||||
},
|
||||
methods: {
|
||||
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";
|
||||
default: return "s-gray";
|
||||
}
|
||||
},
|
||||
countBy(key) {
|
||||
if (key === "all") return this.tasks.length;
|
||||
return this.tasks.filter(t => t.status === key).length;
|
||||
},
|
||||
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())}`;
|
||||
},
|
||||
loadCurrentUser() {
|
||||
try {
|
||||
const u = uni.getStorageSync("user");
|
||||
if (u) this.currentUser = typeof u === "string" ? JSON.parse(u) : u;
|
||||
} catch {}
|
||||
},
|
||||
// 排序:主干任务在前,协助分支在后;同组内按创建时间升序
|
||||
compareTask(a, b) {
|
||||
const aIsMain = !a.parent_task_id || a.task_type !== 'SPAWN';
|
||||
const bIsMain = !b.parent_task_id || b.task_type !== 'SPAWN';
|
||||
if (aIsMain && !bIsMain) return -1;
|
||||
if (!aIsMain && bIsMain) return 1;
|
||||
return new Date(a.created_at) - new Date(b.created_at);
|
||||
},
|
||||
|
||||
/** 拉取一页任务;reset=true 表示回到第 1 页并清空重载(下拉刷新 / 首次进入) */
|
||||
async fetchTasks({ reset = false } = {}) {
|
||||
if (reset) {
|
||||
this.page = 1;
|
||||
this.hasMore = true;
|
||||
this.tasks = [];
|
||||
}
|
||||
if (reset) this.loading = true;
|
||||
const target = this.page;
|
||||
try {
|
||||
const username = this.currentUser?.username || "";
|
||||
const res = await get("/tasks/", {
|
||||
assignee_id: username,
|
||||
skip: (target - 1) * this.pageSize,
|
||||
limit: this.pageSize,
|
||||
});
|
||||
const batch = res.tasks || [];
|
||||
// 按 id 去重后追加:翻页期间若有新任务插入,分页边界会错位导致重复项
|
||||
const seen = new Set(this.tasks.map((t) => t.id));
|
||||
this.tasks = this.tasks.concat(batch.filter((t) => !seen.has(t.id))).sort(this.compareTask);
|
||||
// 后端给了 total 就以它为准;否则退回「本页是否满员」判断
|
||||
this.hasMore = res.total != null
|
||||
? this.tasks.length < res.total
|
||||
: batch.length >= this.pageSize;
|
||||
if (this.hasMore) this.page = target + 1;
|
||||
} catch (e) {
|
||||
console.error("[tasks] 拉取任务失败:", e);
|
||||
if (reset) this.tasks = [];
|
||||
this.hasMore = false;
|
||||
} finally {
|
||||
if (reset) this.loading = false;
|
||||
}
|
||||
},
|
||||
async loadMore() {
|
||||
this.loadingMore = true;
|
||||
try { await this.fetchTasks(); } finally { this.loadingMore = false; }
|
||||
},
|
||||
goDetail(task) {
|
||||
const sn = task.product_sn || task.product_id;
|
||||
if (sn) uni.navigateTo({ url: `/pages/scan/detail?serial=${sn}` });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; padding-bottom: 100px; }
|
||||
.header { margin-bottom: 12px; }
|
||||
.title { font-size: 20px; font-weight: 700; color: #1f2937; }
|
||||
.center { text-align: center; padding: 48px 0; color: #9ca3af; }
|
||||
.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; }
|
||||
|
||||
/* tabs — sticky 不换行,溢出滚动 */
|
||||
.tabs {
|
||||
display: flex; gap: 8px; margin-bottom: 14px;
|
||||
white-space: nowrap; overflow-x: auto;
|
||||
position: sticky; top: 0; z-index: 999;
|
||||
background: #f3f4f6; padding: 12px 0 8px;
|
||||
}
|
||||
.tab {
|
||||
padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 600;
|
||||
background: #fff; color: #6b7280; flex-shrink: 0;
|
||||
}
|
||||
.tab-active { background: #2563eb; color: #fff; }
|
||||
|
||||
/* card */
|
||||
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 10px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.card-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
||||
.card-name { font-size: 15px; font-weight: 700; color: #1f2937; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tag-badge { display: inline-block; padding: 2rpx 12rpx; font-size: 20rpx; border-radius: 6rpx; font-weight: bold; flex-shrink: 0; }
|
||||
.tag-main { background-color: #dbeafe; color: #1e40af; }
|
||||
.tag-sub { background-color: #f3e8ff; color: #6b21a8; }
|
||||
.card-status { font-size: 11px; padding: 2px 10px; border-radius: 20px; font-weight: 600; }
|
||||
.s-yellow { background: #fef3c7; color: #b45309; }
|
||||
.s-blue { background: #dbeafe; color: #1d4ed8; }
|
||||
.s-green { background: #dcfce7; color: #15803d; }
|
||||
.s-gray { background: #f3f4f6; color: #6b7280; }
|
||||
.card-meta { font-size: 12px; color: #6b7280; display: flex; gap: 12px; }
|
||||
.card-time { font-size: 11px; color: #9ca3af; margin-top: 4px; }
|
||||
|
||||
/* 触底加载状态 */
|
||||
.load-more { text-align: center; font-size: 12px; color: #9ca3af; padding: 16px 0 4px; }
|
||||
</style>
|
||||
24
track-uniapp/src/utils/config.js
Normal file
24
track-uniapp/src/utils/config.js
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 全局环境配置 — 后端域名/接口地址的唯一来源。
|
||||
*
|
||||
* 此前 request.js 与 App.vue 各自硬编码了一份生产域名,改域名要改两处,
|
||||
* 漏一处就会出现「接口通了但 OTA 检查更新接不上」这类极难排查的偏差,
|
||||
* 故统一收口到本文件。新增环境地址一律加在这里,不要在业务代码里写死。
|
||||
*/
|
||||
|
||||
// 生产环境 API 地址
|
||||
//
|
||||
// ⚠️ HTTPS 说明(2026-09 实测):生产域名 track_back.iris-rs.cn 的 443 端口
|
||||
// 是通的、也确实配有证书,但证书【已过期】—— 现在切 https 会让 App 的
|
||||
// 全部请求直接失败。待运维完成证书续期后,只需把下面 PROD_URL 与
|
||||
// LOCAL_URL 的协议头改成 https:// 即可,业务代码无需任何改动。
|
||||
// 续期前请勿改动,保持 http:// 以保证可用性。
|
||||
export const PROD_URL = "http://track_back.iris-rs.cn/api/v1";
|
||||
|
||||
// 本地开发地址 — 真机调试时指向开发机局域网 IP
|
||||
export const LOCAL_URL = "http://192.168.9.80:8011/api/v1";
|
||||
|
||||
// 生产基础地址:供只认「生产环境」的场景直接引用
|
||||
export const BASE_URL = PROD_URL;
|
||||
|
||||
export default { PROD_URL, LOCAL_URL, BASE_URL };
|
||||
55
track-uniapp/src/utils/format.js
Normal file
55
track-uniapp/src/utils/format.js
Normal file
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 全局人名映射工具
|
||||
*
|
||||
* 使用方式:
|
||||
* 1. 在页面 loadUsers() 后调用 setUserNameMap(userList) 填充字典
|
||||
* 2. 模板中直接 `{{ formatUserName(task.assignee_id) }}`
|
||||
* 3. 头像中 `{{ formatUserAvatar(msg.operator_id) }}` 取中文名末字
|
||||
*/
|
||||
|
||||
// 全局用户名 → 中文姓名 映射表
|
||||
const userNameMap = {};
|
||||
|
||||
/**
|
||||
* 批量设置用户名映射
|
||||
* @param {Array} users - 用户列表,每项需含 username 和 full_name
|
||||
*/
|
||||
export function setUserNameMap(users) {
|
||||
if (!users || !users.length) return;
|
||||
for (const u of users) {
|
||||
const id = u.username || u.id || '';
|
||||
const name = u.full_name || u.name || u.real_name || '';
|
||||
if (id && name) {
|
||||
userNameMap[id] = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将用户 ID 翻译为中文姓名
|
||||
* @param {string} userId - 用户标识(username 或 id)
|
||||
* @returns {string} 中文姓名,查不到则降级返回原 ID
|
||||
*/
|
||||
export function formatUserName(userId) {
|
||||
if (!userId) return '—';
|
||||
// 特殊值原样返回
|
||||
if (userId === 'virtual_warehouse') return '🏭 仓库';
|
||||
const name = userNameMap[userId];
|
||||
return name || userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户头像文字(中文名取末字,拼音名取首字母)
|
||||
* @param {string} userId - 用户标识
|
||||
* @returns {string} 单字头像文字
|
||||
*/
|
||||
export function formatUserAvatar(userId) {
|
||||
if (!userId) return '?';
|
||||
const name = userNameMap[userId];
|
||||
if (name) {
|
||||
// 中文名取最后一个字
|
||||
return name.charAt(name.length - 1);
|
||||
}
|
||||
// 降级:取 ID 首字母大写
|
||||
return userId.charAt(0).toUpperCase();
|
||||
}
|
||||
91
track-uniapp/src/utils/lifecycle.js
Normal file
91
track-uniapp/src/utils/lifecycle.js
Normal file
@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 生命周期阶段(lifecycle_phase)与工序选项隔离 — 移动端口径
|
||||
*
|
||||
* ⚠️ 与后端 backend/app/core/lifecycle.py、PC 端 frontend/src/constants/task.ts
|
||||
* 是同一份词表,改动请三处同步。
|
||||
*
|
||||
* 售后回流阶段使用**独立工序名**(发货测试 / 售后维修),不复用生产阶段的
|
||||
* 「测试 / 维修」,选中售后专属工序即代表设备进入售后生命周期。
|
||||
*
|
||||
* 本模块只负责"该给操作员看哪些选项"(体验层);
|
||||
* 真正的拦截在后端 task_service._enforce_step_isolation(防伪造传参)。
|
||||
*/
|
||||
|
||||
export const LIFECYCLE_PHASE = {
|
||||
PRODUCTION: "PRODUCTION",
|
||||
AFTER_SALES: "AFTER_SALES",
|
||||
};
|
||||
|
||||
/** 售后专属工序名 — 选中即代表设备进入售后生命周期 */
|
||||
export const STEP_SHIP_TEST = "发货测试";
|
||||
export const STEP_AFTER_SALES_REPAIR = "售后维修";
|
||||
|
||||
/**
|
||||
* 绝对物理终态之一:设备已发货出库。
|
||||
*
|
||||
* 🔧 出库设备也要按售后环节给选项:设备出库后仍可能被叫回来补做
|
||||
* 「发货测试 / 售后维修」,但后端已不再因「正常已出库做质检」而把
|
||||
* lifecycle_phase 翻成 AFTER_SALES(避免不可回退的售后烙印)。
|
||||
* 若选项渲染仍只认 phase,工人就永远选不到那两个工序 —— 鸡生蛋死锁。
|
||||
*/
|
||||
export const OUTBOUND_OVERALL = "已出库";
|
||||
|
||||
const AFTER_SALES_ONLY_STEPS = [STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR];
|
||||
|
||||
// ── 生产制造阶段合法工序(原有词表,一字未改)──
|
||||
export const PRODUCTION_TASK_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
|
||||
export const PRODUCTION_OVERALL_OPTIONS = [
|
||||
"备货", "生产", "测试", "维修", "在库", "已入库", "已出库",
|
||||
];
|
||||
|
||||
// ── 售后回流阶段:只保留「发货测试 / 售后维修 / 入库出库」──
|
||||
export const AFTER_SALES_TASK_OPTIONS = [STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库"];
|
||||
export const AFTER_SALES_OVERALL_OPTIONS = [
|
||||
STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库", "已入库", "已出库",
|
||||
];
|
||||
|
||||
/**
|
||||
* 接收任务时可选工序。
|
||||
*
|
||||
* hasHistory = false(无任何历史任务的首次激活 / 老设备)时返回**并集**:
|
||||
* 这类设备既可能是新投产,也可能是直接返厂售后的老设备,让操作员自己声明。
|
||||
* 一旦选定售后专属工序,后端即把设备判为 AFTER_SALES,之后下拉自动收窄。
|
||||
*
|
||||
* @param phase lifecycle_phase
|
||||
* @param hasHistory 是否有历史任务
|
||||
* @param overallStatus 产品当前宏观状态 — 【已出库】同样走售后词表,见下方说明
|
||||
*/
|
||||
export function taskOptionsFor(phase, hasHistory = true, overallStatus = null) {
|
||||
// 🔧 出库设备与售后生命周期同等待遇(2026-09-17 修复「鸡生蛋」死锁):
|
||||
// 后端为消除不可回退的售后烙印,已不再把「正常已出库做质检」的设备
|
||||
// 翻成 AFTER_SALES。若这里仍只认 phase,工人就永远选不到
|
||||
// 「发货测试 / 售后维修」—— 而选不到就无法进入售后,phase 也就永远不变。
|
||||
if (
|
||||
phase === LIFECYCLE_PHASE.AFTER_SALES ||
|
||||
String(overallStatus || "").trim() === OUTBOUND_OVERALL
|
||||
) {
|
||||
return AFTER_SALES_TASK_OPTIONS;
|
||||
}
|
||||
if (!hasHistory) return [...PRODUCTION_TASK_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
|
||||
return PRODUCTION_TASK_OPTIONS;
|
||||
}
|
||||
|
||||
/** 宏观状态可选值 — 同上,含已入库/已出库等终态 */
|
||||
export function overallOptionsFor(phase, hasHistory = true) {
|
||||
if (phase === LIFECYCLE_PHASE.AFTER_SALES) return AFTER_SALES_OVERALL_OPTIONS;
|
||||
if (!hasHistory) return [...PRODUCTION_OVERALL_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
|
||||
return PRODUCTION_OVERALL_OPTIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生命周期标签 — 只在设备确实处于售后回流环节时返回:
|
||||
* 生命周期为 AFTER_SALES 且当前工序是「发货测试」或「售后维修」
|
||||
* → 红底白字,文案即工序名。
|
||||
* 生产制造阶段一律返回 null(不打标),避免与售后设备混淆。
|
||||
*/
|
||||
export function lifecycleBadge(overallStatus, phase) {
|
||||
if (phase !== LIFECYCLE_PHASE.AFTER_SALES) return null;
|
||||
const step = String(overallStatus || "").trim();
|
||||
if (step !== STEP_SHIP_TEST && step !== STEP_AFTER_SALES_REPAIR) return null;
|
||||
return { label: step, phase: LIFECYCLE_PHASE.AFTER_SALES, cls: "life-badge-after" };
|
||||
}
|
||||
400
track-uniapp/src/utils/ota.js
Normal file
400
track-uniapp/src/utils/ota.js
Normal file
@ -0,0 +1,400 @@
|
||||
/**
|
||||
* OTA 热更新 — 版本检测 + WGT 下载 + 安装。App 端专属能力的唯一来源。
|
||||
*
|
||||
* 原先这套逻辑整个长在 App.vue 的 methods 里(checkUpdate / parseVersionCode /
|
||||
* downloadAndInstall / downloadWgt / installWgt / otaFailed),个人中心的
|
||||
*「设置 → 检查更新」想手动触发一次就得把 App.vue 的方法捞出来,做不到。
|
||||
* 抽到这里后两处共用同一份实现,改版本比对规则或下载策略只需改这里。
|
||||
*
|
||||
* 调用方:
|
||||
* - App.vue onLaunch / onShow 静默自动检查(受 5 分钟节流)
|
||||
* - settings.vue 「检查更新」手动检查(manual = true,跳过节流并给出明确反馈)
|
||||
*/
|
||||
|
||||
import { getBaseUrl } from "./request";
|
||||
|
||||
/** 自动检查的节流窗口:5 分钟内不重复检查,避免 onShow 频繁触发弹窗骚扰 */
|
||||
const THROTTLE_MS = 5 * 60 * 1000;
|
||||
|
||||
/** 上次检查时间戳(模块级单例,等价于原 App.vue 的 this._lastUpdateCheck) */
|
||||
let lastCheckAt = 0;
|
||||
|
||||
/** 版本号→数字: "T1.0.10" → 1*100+10 = 110 */
|
||||
export function parseVersionCode(v) {
|
||||
if (!v) return 0;
|
||||
const nums = v.match(/\d+/g);
|
||||
if (!nums || nums.length < 2) return 0;
|
||||
return parseInt(nums[0], 10) * 100 + parseInt(nums[nums.length - 1], 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有新版本。
|
||||
*
|
||||
* @param {{manual?: boolean}} [opts]
|
||||
* manual = true 时(设置页手动触发):跳过节流、显示「检查中」、
|
||||
* 并在「已是最新 / 检查失败」时给出明确提示 —— 用户主动点的按钮
|
||||
* 必须有个回音,不能像自动检查那样静默收场。
|
||||
* @returns {Promise<"latest"|"available"|"skipped"|"unsupported"|"error">}
|
||||
*/
|
||||
export async function checkAppUpdate({ manual = false } = {}) {
|
||||
// #ifndef APP-PLUS
|
||||
// 非 App 环境(H5 / 小程序)没有 plus.runtime,热更新无从谈起
|
||||
if (manual) {
|
||||
uni.showToast({ title: "当前环境不支持热更新", icon: "none", duration: 2500 });
|
||||
}
|
||||
return "unsupported";
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
const now = Date.now();
|
||||
if (!manual && lastCheckAt && now - lastCheckAt < THROTTLE_MS) {
|
||||
console.log("[OTA] 距上次检查不足5分钟,跳过");
|
||||
return "skipped";
|
||||
}
|
||||
lastCheckAt = now;
|
||||
|
||||
// 🔧 appWgtVersion 跟随 WGT 更新,解析算法: major*100 + lastNum
|
||||
// "T1.0.1"→101, "T1.0.10"→110, "T1.0.99"→199
|
||||
//
|
||||
// ⚠️ 这两个字段必须分开看,别只看合并后的值:
|
||||
// appWgtVersion = 已安装的 wgt 资源版本(热更新成功后会变)
|
||||
// appVersion = APK 基座版本(重装多少次热更新都不会变)
|
||||
// 若 appWgtVersion 为空,说明热更新从未装成功过,此时取到的是 APK 的
|
||||
// 静态版本号,表现就是"更新提示反复弹、版本号永远不动"。
|
||||
const sysInfo = uni.getSystemInfoSync();
|
||||
const wgtVersion = sysInfo.appWgtVersion || "";
|
||||
const apkVersion = sysInfo.appVersion || "";
|
||||
const wgtVer = wgtVersion || apkVersion || "0";
|
||||
const currentVersionCode = parseVersionCode(wgtVer);
|
||||
|
||||
// 🔧 复用 getBaseUrl():与业务请求永远指向同一个后端(含 App 内动态切换的环境)
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
console.log(
|
||||
"[OTA] wgt版本:", wgtVersion || "(空,说明热更新从未装成功)",
|
||||
"| APK版本:", apkVersion,
|
||||
"→ 取用:", wgtVer, "=", currentVersionCode
|
||||
);
|
||||
|
||||
if (manual) uni.showLoading({ title: "检查更新中...", mask: true });
|
||||
|
||||
return new Promise((resolve) => {
|
||||
uni.request({
|
||||
url: `${baseUrl}/app/check-update`,
|
||||
method: "GET",
|
||||
timeout: 8000,
|
||||
success: (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
if (manual) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: `检查更新失败 (${res.statusCode})`, icon: "none" });
|
||||
}
|
||||
resolve("error");
|
||||
return;
|
||||
}
|
||||
const data = res.data;
|
||||
if (!data || !data.wgt_url) {
|
||||
console.log("[OTA] 服务端无可用更新包");
|
||||
if (manual) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: "当前已是最新版本", icon: "none" });
|
||||
}
|
||||
resolve("latest");
|
||||
return;
|
||||
}
|
||||
|
||||
const serverVersionCode = data.version_code || 0;
|
||||
console.log("[OTA] 服务端版本:", data.version, "| 数字版本:", serverVersionCode);
|
||||
|
||||
// 客户端自行对比:服务端 > 本地 = 需要更新
|
||||
if (serverVersionCode <= currentVersionCode) {
|
||||
console.log("[OTA] 已是最新版本,无需更新");
|
||||
if (manual) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: "当前已是最新版本", icon: "none" });
|
||||
}
|
||||
resolve("latest");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[OTA] 发现新版本:", data.version);
|
||||
if (manual) uni.hideLoading();
|
||||
downloadAndInstall(data.wgt_url, data.version, data.description);
|
||||
resolve("available");
|
||||
},
|
||||
fail: (err) => {
|
||||
console.log("[OTA] 版本检测网络失败,跳过");
|
||||
if (manual) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({
|
||||
title: err && err.errMsg && /timeout/i.test(err.errMsg) ? "检查更新超时" : "网络连接失败",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
resolve("error");
|
||||
},
|
||||
});
|
||||
});
|
||||
// #endif
|
||||
}
|
||||
|
||||
function downloadAndInstall(wgtUrl, newVersion, description) {
|
||||
if (!wgtUrl) {
|
||||
console.log("[OTA] 无 WGT 下载地址");
|
||||
return;
|
||||
}
|
||||
if (typeof plus === "undefined") {
|
||||
console.log("[OTA] 非 App 环境,跳过安装");
|
||||
return;
|
||||
}
|
||||
|
||||
const content = description
|
||||
? `发现新版本 ${newVersion}\n\n${description}\n\n是否立即更新?`
|
||||
: `发现新版本 ${newVersion},是否立即更新?`;
|
||||
|
||||
uni.showModal({
|
||||
title: "版本更新",
|
||||
content,
|
||||
confirmText: "立即更新",
|
||||
cancelText: "稍后再说",
|
||||
success: (modalRes) => {
|
||||
if (!modalRes.confirm) return;
|
||||
downloadWgt(wgtUrl, newVersion);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 WGT 更新包。
|
||||
*
|
||||
* 为什么不用 uni.downloadFile(旧实现的坑):
|
||||
* plus.runtime.install 是**靠文件扩展名**区分 wgt / apk 的,而
|
||||
* uni.downloadFile 返回的 tempFilePath 不保证带 `.wgt` 后缀,路径不对时
|
||||
* 安装会被直接拒绝 —— 且旧代码失败只打 console.error,工人只看到
|
||||
* 「点了立即更新没反应」。这里改用 plus.downloader 并显式指定带后缀的
|
||||
* 落盘路径;写到 _doc/(应用私有目录)也顺带避开 Android 10+ 分区存储限制。
|
||||
* (注意:本函数走的是 plus.downloader,不是 uni.downloadFile —— 后者没有
|
||||
* 可用的落盘路径控制,正是当初被换掉的原因。)
|
||||
*
|
||||
* ⚠️ 两道内容校验的由来("invalid LOC header" 事故):
|
||||
* plus.runtime.install 拿到文件就按 ZIP 解压,文件不是合法 wgt 时抛的是
|
||||
* "invalid LOC header (bad signature)" 这种底层报错,工人只看到一句看不懂的
|
||||
* 英文。而拿到非 wgt 内容的情形并不罕见:静态服务器把 404 配成返回首页、
|
||||
* 反向代理吐 JSON 错误体、SSO 把请求 302 到登录页 —— 这些**都可能带着
|
||||
* HTTP 200 回来**,只判断 status === 200 根本拦不住。
|
||||
* 故:下载前探测响应头,下载后校验 ZIP 魔数,两道都过了才交给 install。
|
||||
*/
|
||||
async function downloadWgt(wgtUrl, newVersion) {
|
||||
// ── 第一道:下载前探测(状态码 / Content-Type / 体积)──
|
||||
// 目的是在浪费流量之前就拦下明显的错误页,并给出人话错误
|
||||
const probeError = await probeWgtUrl(wgtUrl);
|
||||
if (probeError) {
|
||||
otaFailed(probeError);
|
||||
return;
|
||||
}
|
||||
|
||||
// 文件名必须带 .wgt 后缀 —— 这是 install 能识别为 wgt 资源包的前提。
|
||||
// 从下载地址取文件名(去掉 query),拿不到合法后缀时退回默认名。
|
||||
const rawName = String(wgtUrl).split("?")[0].split("/").pop() || "";
|
||||
const fileName = /\.wgt$/i.test(rawName) ? rawName : "__UNI__B572616.wgt";
|
||||
const savePath = "_doc/" + fileName;
|
||||
console.log("[OTA] 开始下载:", wgtUrl, "→", savePath);
|
||||
|
||||
let lastPct = -1;
|
||||
let task = null;
|
||||
|
||||
const onFinished = async (d, status) => {
|
||||
uni.hideLoading();
|
||||
console.log("[OTA] 下载结束, status =", status, "| 文件:", d && d.filename);
|
||||
|
||||
// 非 2xx 一律当失败:404=包没传上去,401/403=下载地址需要鉴权
|
||||
if (status !== 200) {
|
||||
const hint =
|
||||
status === 404 ? "更新包不存在,请联系管理员确认是否已上传"
|
||||
: status === 401 || status === 403 ? "更新包下载被拒绝(无权限),请联系管理员"
|
||||
: "请稍后重试";
|
||||
otaFailed(`更新包下载失败(HTTP ${status}):${hint}`);
|
||||
return;
|
||||
}
|
||||
// 没拿到落盘路径就没法安装 —— 明确报出来,别让它以"没反应"收场
|
||||
if (!d || !d.filename) {
|
||||
otaFailed("下载已完成但未生成本地文件,请重试");
|
||||
return;
|
||||
}
|
||||
// 截断检测:服务端中途断开时 status 也可能是 200,但文件是不完整的
|
||||
if (d.totalSize > 0 && d.downloadedSize > 0 && d.downloadedSize < d.totalSize) {
|
||||
otaFailed(`更新包下载不完整(${d.downloadedSize}/${d.totalSize} 字节),请重试`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 第二道:ZIP 魔数校验(拦下所有「HTTP 200 但内容不是 wgt」的情况)──
|
||||
const contentError = await verifyWgtFile(d.filename);
|
||||
if (contentError) {
|
||||
otaFailed(contentError);
|
||||
return;
|
||||
}
|
||||
|
||||
installWgt(d.filename, newVersion);
|
||||
};
|
||||
|
||||
try {
|
||||
task = plus.downloader.createDownload(wgtUrl, { filename: savePath }, onFinished);
|
||||
// 进度反馈:每跨 10% 刷新一次提示(过于频繁会卡顿)
|
||||
task.addEventListener("statechanged", (d) => {
|
||||
if (!d || d.state !== 3) return; // 3 = 下载中
|
||||
if (!d.totalSize || d.totalSize <= 0) return;
|
||||
const pct = Math.floor((d.downloadedSize / d.totalSize) * 100);
|
||||
if (pct >= lastPct + 10) {
|
||||
lastPct = pct - (pct % 10);
|
||||
uni.showLoading({ title: `下载中 ${pct}%`, mask: true });
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[OTA] 创建下载任务异常:", e);
|
||||
otaFailed("无法创建下载任务:" + ((e && e.message) || "未知原因"));
|
||||
return;
|
||||
}
|
||||
|
||||
uni.showLoading({ title: "下载中 0%", mask: true });
|
||||
task.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载前探测更新包地址 —— 在消耗流量之前拦下「错误页」。
|
||||
*
|
||||
* 用 HEAD 只取响应头,不下载正文。核对三件事:
|
||||
* 1. statusCode 必须是 200(404 / 401 / 403 直接判失败)
|
||||
* 2. Content-Type 不能是 JSON / HTML —— 那说明拿回来的是错误页而不是安装包
|
||||
* 3. Content-Length 不能小得离谱(合法 wgt 不可能只有几百字节)
|
||||
*
|
||||
* @returns {Promise<string>} 空串 = 通过;非空 = 给用户看的失败原因
|
||||
*/
|
||||
function probeWgtUrl(wgtUrl) {
|
||||
return new Promise((resolve) => {
|
||||
uni.request({
|
||||
url: wgtUrl,
|
||||
method: "HEAD",
|
||||
timeout: 8000,
|
||||
success: (res) => {
|
||||
const header = res.header || {};
|
||||
const contentType = String(header["Content-Type"] || header["content-type"] || "");
|
||||
const contentLength = Number(header["Content-Length"] || header["content-length"] || 0);
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
resolve(
|
||||
res.statusCode === 404 ? "更新包不存在,请联系管理员确认是否已上传"
|
||||
: res.statusCode === 401 || res.statusCode === 403 ? "更新包下载被拒绝(无权限),请联系管理员"
|
||||
: `更新包地址不可访问(HTTP ${res.statusCode})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (/json|html|text\/plain/i.test(contentType)) {
|
||||
resolve(`更新包地址返回的是 ${contentType},不是安装包 —— 请检查服务端配置`);
|
||||
return;
|
||||
}
|
||||
if (contentLength > 0 && contentLength < 1024) {
|
||||
resolve(`更新包体积异常(仅 ${contentLength} 字节),疑似错误页面`);
|
||||
return;
|
||||
}
|
||||
// Content-Type / Content-Length 都可能被服务端省略,此处只做「明显不对」的拦截
|
||||
console.log("[OTA] 预检通过 | Content-Type:", contentType || "(未返回)", "| 长度:", contentLength || "(未返回)");
|
||||
resolve("");
|
||||
},
|
||||
fail: (err) => {
|
||||
// HEAD 不被支持(部分静态服务器返回 405)或探测请求本身超时:
|
||||
// 不阻断下载 —— 还有第二道魔数校验兜底,没必要因此卡死更新
|
||||
console.warn("[OTA] 预检请求失败,跳过预检,改由下载后校验兜底:", err && err.errMsg);
|
||||
resolve("");
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验已下载的文件确实是 WGT —— 读取文件头 4 字节比对 ZIP 魔数。
|
||||
*
|
||||
* WGT 就是 ZIP 包,合法文件必定以 "50 4B 03 04" 开头(ASCII 即 "PK\x03\x04")。
|
||||
* 这正是 install 报 "invalid LOC header (bad signature)" 时会去校验的东西,
|
||||
* 我们提前自己验一遍,就能把底层报错换成一句人话。
|
||||
*
|
||||
* 实现上用 readAsDataURL 而非读二进制:base64 后 4 字节是 "UEsD",比对前缀
|
||||
* 即可,避开了 plus.io.FileReader 对二进制读取支持不一致的问题。
|
||||
*
|
||||
* @returns {Promise<string>} 空串 = 通过;非空 = 给用户看的失败原因
|
||||
*/
|
||||
function verifyWgtFile(filePath) {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
plus.io.resolveLocalFileSystemURL(
|
||||
filePath,
|
||||
(entry) => {
|
||||
entry.file(
|
||||
(file) => {
|
||||
// slice 不可用的老机型上放弃校验(不阻断更新),交给 install 自己报错
|
||||
if (typeof file.slice !== "function") {
|
||||
console.warn("[OTA] 当前环境不支持文件切片,跳过魔数校验");
|
||||
resolve("");
|
||||
return;
|
||||
}
|
||||
const reader = new plus.io.FileReader();
|
||||
reader.onloadend = (e) => {
|
||||
const result = String((e.target && e.target.result) || "");
|
||||
if (/;base64,UEsD/.test(result)) {
|
||||
console.log("[OTA] 更新包文件头校验通过 (ZIP signature OK)");
|
||||
resolve("");
|
||||
} else {
|
||||
resolve("下载到的文件不是有效的更新包(文件头异常),请联系管理员重新打包上传");
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
console.warn("[OTA] 读取更新包文件头失败,跳过校验");
|
||||
resolve("");
|
||||
};
|
||||
reader.readAsDataURL(file.slice(0, 4));
|
||||
},
|
||||
() => resolve("")
|
||||
);
|
||||
},
|
||||
() => resolve("")
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn("[OTA] 魔数校验异常,跳过:", e);
|
||||
resolve("");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 安装已下载的 WGT —— 成败都要在界面上说清楚 */
|
||||
function installWgt(filePath, newVersion) {
|
||||
console.log("[OTA] 开始安装:", filePath);
|
||||
uni.showLoading({ title: "安装中...", mask: true });
|
||||
plus.runtime.install(
|
||||
filePath,
|
||||
{ force: true },
|
||||
() => {
|
||||
uni.hideLoading();
|
||||
console.log("[OTA] 安装成功:", newVersion, "→ 即将重启");
|
||||
uni.showToast({ title: "更新完成,即将重启", icon: "none", duration: 2500 });
|
||||
setTimeout(() => {
|
||||
plus.runtime.restart();
|
||||
}, 2500);
|
||||
},
|
||||
(err) => {
|
||||
uni.hideLoading();
|
||||
console.error("[OTA] 安装失败:", JSON.stringify(err));
|
||||
otaFailed(
|
||||
`更新包安装失败:${(err && (err.message || err.code)) || "未知原因"}`
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** 更新失败必须在界面上说出来 —— 只打日志等于让工人对着「点了没反应」干瞪眼 */
|
||||
function otaFailed(msg) {
|
||||
uni.showModal({
|
||||
title: "更新失败",
|
||||
content: `${msg}\n\n请确认网络正常后重试;若反复失败请联系管理员。`,
|
||||
showCancel: false,
|
||||
confirmText: "知道了",
|
||||
});
|
||||
}
|
||||
212
track-uniapp/src/utils/request.js
Normal file
212
track-uniapp/src/utils/request.js
Normal file
@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 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 }); }
|
||||
101
track-uniapp/src/utils/upload.js
Normal file
101
track-uniapp/src/utils/upload.js
Normal file
@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 图片上传工具 — 超时与并发策略的唯一来源(追加记录 / 驳回弹窗共用)。
|
||||
*
|
||||
* 此前两个页面各写一份 uni.uploadFile 封装,行为容易漂移;且都是串行上传,
|
||||
* 9 张图要排队一张张传完,弱网下工人要干等半分钟以上。
|
||||
*/
|
||||
import { getBaseUrl, extractErrorDetail } from "./request";
|
||||
|
||||
/** 单张上传超时(毫秒)—— 弱网下防止请求永久挂起,卡住整个上传流程 */
|
||||
export const UPLOAD_TIMEOUT = 15000;
|
||||
|
||||
/**
|
||||
* 上传并发数。
|
||||
* 不用「9 张一起上」的无限并发:弱网带宽本就紧张,同时打满会互相挤占、
|
||||
* 整体反而更慢,还容易触发服务端限流。3 是等待时间与成功率的折中点。
|
||||
*/
|
||||
export const UPLOAD_CONCURRENCY = 3;
|
||||
|
||||
/**
|
||||
* 上传单个文件。
|
||||
* @param {string} filePath 本地临时文件路径
|
||||
* @returns {Promise<string>} 成功时 resolve 后端返回的可访问 URL
|
||||
* @throws 失败时 reject —— 不 resolve(null) 静默丢弃,调用方必须显式处理
|
||||
*/
|
||||
export function uploadImage(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: getBaseUrl() + "/upload/",
|
||||
filePath,
|
||||
name: "file",
|
||||
timeout: UPLOAD_TIMEOUT,
|
||||
success(res) {
|
||||
let data = null;
|
||||
try { data = JSON.parse(res.data); } catch { data = null; }
|
||||
const url = data && data.url;
|
||||
// HTTP 非 2xx 时(类型不支持 400 / 超过 50MB 413 等)后端返回的是
|
||||
// {detail: ...} 而非 {url},必须当失败处理,否则图片会被静默丢弃
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && url) resolve(url);
|
||||
else reject(new Error(extractErrorDetail(data, `图片上传失败 (${res.statusCode || "无响应"})`)));
|
||||
},
|
||||
fail(err) {
|
||||
const errMsg = (err && err.errMsg) || "";
|
||||
reject(new Error(/timeout/i.test(errMsg) ? "图片上传超时" : errMsg || "图片上传失败"));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一个图片项是否「已真正上传成功」。
|
||||
*
|
||||
* 依据来自本模块的契约:uploadImage() 成功时 resolve 的是后端返回的 URL
|
||||
* (相对路径 /api/v1/upload/files/xxx,或带域名的 http(s)://…);
|
||||
* 而未上传完 / 上传失败的项,拿到的是本地临时地址(blob:、file://、
|
||||
* _doc/、wxfile:// 等)。
|
||||
*
|
||||
* 各页面的「成功绿勾」角标只应依据本函数显示 —— 绝不能因为「它出现在
|
||||
* images 数组里」就当作成功,否则将来若有人把失败项也塞进数组(比如为了
|
||||
* 支持重试而保留占位),角标就会撒谎。
|
||||
*
|
||||
* @param {*} url 图片项
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isUploadedUrl(url) {
|
||||
if (typeof url !== "string" || !url) return false;
|
||||
return url.startsWith("/") || /^https?:\/\//i.test(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 并发上传一组图片(有界并发池,逐个补位,不会一次性打满)。
|
||||
*
|
||||
* @param {string[]} filePaths 本地临时文件路径列表
|
||||
* @param {(url: string|null, index: number) => void} [onEachDone]
|
||||
* 每张图片「有结果」时回调一次(成功传 url,失败传 null)——
|
||||
* 调用方据此回收 ⏳ 占位符,保证成功与失败都恰好回收一次。
|
||||
* @returns {Promise<{failed: number, total: number}>}
|
||||
*/
|
||||
export async function uploadImages(filePaths, onEachDone) {
|
||||
const queue = filePaths.map((filePath, index) => ({ filePath, index }));
|
||||
let cursor = 0;
|
||||
let failed = 0;
|
||||
|
||||
const worker = async () => {
|
||||
while (cursor < queue.length) {
|
||||
// 单线程 JS 里 cursor++ 在两次 await 之间是原子的,不会重复取到同一项
|
||||
const { filePath, index } = queue[cursor++];
|
||||
let url = null;
|
||||
try {
|
||||
url = await uploadImage(filePath);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.error("[upload] 图片上传失败:", e);
|
||||
}
|
||||
if (onEachDone) onEachDone(url, index);
|
||||
}
|
||||
};
|
||||
|
||||
const workerCount = Math.min(UPLOAD_CONCURRENCY, queue.length);
|
||||
await Promise.all(Array.from({ length: workerCount }, worker));
|
||||
return { failed, total: filePaths.length };
|
||||
}
|
||||
BIN
track-uniapp/static/logo.png
Normal file
BIN
track-uniapp/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 MiB |
117
track-uniapp/sync-watch.sh
Normal file
117
track-uniapp/sync-watch.sh
Normal file
@ -0,0 +1,117 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# track-uniapp 源码 → HBuilderX 项目 同步
|
||||
#
|
||||
# 方向:单向 WSL track-uniapp/src/ → G:\Track\track-app\track
|
||||
#
|
||||
# ⚠️ 为什么是「src/ → 项目根」且单向(2026-09-15 修正):
|
||||
# HBuilderX 是以 G:\Track\track-app\track 为项目根打开的(经典布局,
|
||||
# pages.json / App.vue / manifest.json 都在根目录),该目录就是
|
||||
# track-uniapp/src/ 的**扁平镜像** —— 证据:test_wgt_local.bat 里写着
|
||||
# "Please build WGT in HBuilderX first",且两侧文件时间戳逐项吻合。
|
||||
#
|
||||
# 旧版脚本按 track-uniapp/ ↔ track-app/track/ 双向对拷,方向完全错误:
|
||||
# · 正向会把 src/ 整个塞成 track/src/,并用 WSL 的 index.html 覆盖
|
||||
# HBuilderX 的入口文件;
|
||||
# · 反向会把 Windows 项目根的文件搬回 WSL 根目录,让已清理的
|
||||
# 「幽灵文件」复活。
|
||||
# 故改为单向,且显式排除所有「只在 Windows 侧存在」的项。
|
||||
#
|
||||
# 用法:
|
||||
# bash sync-watch.sh # 同步一次,然后持续监听(推荐)
|
||||
# bash sync-watch.sh once # 只同步一次
|
||||
# bash sync-watch.sh dry # 只预览差异,不落盘
|
||||
# 停止: Ctrl+C
|
||||
# ============================================================
|
||||
|
||||
SRC="/home/yueli/track/track-uniapp/src"
|
||||
DST="/mnt/g/Track/track-app/track"
|
||||
|
||||
# ── 只在 Windows 侧存在、绝对不能被覆盖或删除的项 ──
|
||||
EXCLUDES=(
|
||||
# manifest.json 的版本号(T1.0.44)与存储权限只在 HBuilderX 里维护。
|
||||
# WSL 那份停留在 T1.0.8,是过期副本 —— 同步过去会回退版本号并抹掉权限。
|
||||
--exclude 'manifest.json'
|
||||
# HBuilderX 经典布局特有,src/ 里没有对应物
|
||||
--exclude 'index.html'
|
||||
--exclude 'static/'
|
||||
--exclude 'uni.scss'
|
||||
--exclude 'uni.promisify.adaptor.js'
|
||||
--exclude '.editorconfig'
|
||||
--exclude '.gitignore'
|
||||
--exclude '.hbuilderx/'
|
||||
# HBuilderX 的 WGT 构建产物(约 216M)
|
||||
--exclude 'unpackage/'
|
||||
--exclude 'node_modules/'
|
||||
--exclude '.git/'
|
||||
# 手工备份(如 pages/scan/detail.vue.bak-20260914)
|
||||
--exclude '*.bak-*'
|
||||
--exclude '*.wgt'
|
||||
)
|
||||
|
||||
# --delete 让「在 WSL 删掉的文件」在 Windows 侧同步消失(如已废弃的
|
||||
# FlowTree.vue / TaskTreeNode.vue)。被 --exclude 命中的项不受 --delete
|
||||
# 影响,所以上面的白名单不会被误删。
|
||||
#
|
||||
# 不用 -a:目标 /mnt/g 是 drvfs,POSIX 权限/属主都是合成的(恒为 777),
|
||||
# -a 里的 -p/-o/-g 每次都会判定为「有差异」→ 无谓 chmod + 刷屏的 p 标记。
|
||||
# 保留 -r(递归)+ -t(时间戳,两侧时间戳本就是同步依据)。
|
||||
RSYNC_OPTS=(-rlt --delete "${EXCLUDES[@]}" --no-perms --no-owner --no-group)
|
||||
|
||||
do_sync() {
|
||||
case "$1" in
|
||||
dry)
|
||||
echo "🔍 预览模式(不落盘):"
|
||||
rsync "${RSYNC_OPTS[@]}" --dry-run --itemize-changes "$SRC/" "$DST/"
|
||||
;;
|
||||
*)
|
||||
rsync "${RSYNC_OPTS[@]}" --itemize-changes "$SRC/" "$DST/"
|
||||
local rc=$?
|
||||
if [ $rc -eq 0 ]; then
|
||||
echo " ↻ $(date +%H:%M:%S) 已同步"
|
||||
else
|
||||
# 不吞错误:旧脚本 2>/dev/null 把 rsync 的报错全丢了,出了问题只能干瞪眼
|
||||
echo " ❌ $(date +%H:%M:%S) rsync 退出码 $rc —— 同步可能不完整,请检查"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
dry)
|
||||
do_sync dry
|
||||
exit 0
|
||||
;;
|
||||
once)
|
||||
do_sync
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "🔁 单向同步 $SRC → $DST"
|
||||
do_sync
|
||||
echo "✅ 首次同步完成,开始监听..."
|
||||
|
||||
if command -v inotifywait >/dev/null 2>&1; then
|
||||
echo "👀 inotifywait 实时监听中(Ctrl+C 停止)"
|
||||
inotifywait -m -r -e modify,create,delete,move \
|
||||
--exclude 'node_modules|\.git|dist|unpackage|\.hbuilderx' \
|
||||
"$SRC" | while read -r _dir _action _file; do
|
||||
do_sync
|
||||
done
|
||||
else
|
||||
# 旧脚本在这里静默失败过:inotifywait 没装,2>/dev/null 又把报错吞了,
|
||||
# 表现为「脚本像是跑着,但改了代码 G 盘一直不更新」。改为明确提示 + 轮询兜底。
|
||||
echo "⚠️ inotifywait 未安装,退回轮询模式(每 3 秒比对一次)"
|
||||
echo " 想要实时监听: sudo apt install -y inotify-tools"
|
||||
echo " Ctrl+C 停止"
|
||||
last=""
|
||||
while true; do
|
||||
cur=$(find "$SRC" -type f -printf '%T@ %p\n' 2>/dev/null | sort | md5sum)
|
||||
if [ "$cur" != "$last" ]; then
|
||||
[ -n "$last" ] && do_sync
|
||||
last="$cur"
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
fi
|
||||
12
track-uniapp/vite.config.ts
Normal file
12
track-uniapp/vite.config.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vite";
|
||||
import uni from "@dcloudio/vite-plugin-uni";
|
||||
import basicSsl from "@vitejs/plugin-basic-ssl";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [uni(), basicSsl()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 8020,
|
||||
https: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user