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:
2026-09-21 15:56:52 +08:00
commit 3286a11bc7
212 changed files with 44060 additions and 0 deletions

6
track-uniapp/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
unpackage/
.env
*.log
.DS_Store
Thumbs.db

View 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
View 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

File diff suppressed because it is too large Load Diff

125
track-uniapp/src/App.vue Normal file
View 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>

View 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;
}
}

View File

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

View File

@ -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
View 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

View 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
View 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": "我的"
}
]
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 };

View 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();
}

View 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" };
}

View 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: "知道了",
});
}

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

View 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 };
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

117
track-uniapp/sync-watch.sh Normal file
View 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

View 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,
},
});