Files
track/track-uniapp/src/pages/scan/detail.vue
duxingchen 42c883fe7d feat: 移动端直接完结入口、权限与派发解绑
【直接完结入口(双重门槛)】
- 转交弹窗新增「🏁 直接完结」选项,与「入库」互斥
- 角色门槛:仅超管 / 主管可见可操作(与后端 ADMIN_ROLES 对齐,两个角色都判 ——
  本文件既有的 canEditOverallStatus 只判了 SUPER_ADMIN 漏了 SUPERVISOR)
- 场景门槛:新增 showFinishDirect,要求产品确实是「已出库」。普通生产设备若被
  直接完结,既无下游任务、又不在仓库池,会变成卡在工人名下的孤儿数据
- transferMode 的 direct 分支同步校验双门槛,防残留选中态把 finish_directly 发出去

【修复售后"鸡生蛋"死锁】
- 已出库设备的下拉工序改走售后词表:taskOptionsFor 增加 overall_status 入参,
  已出库与 AFTER_SALES 同等待遇。此前只认 lifecycle_phase,而上一轮已禁止后端
  因"正常已出库做质检"翻转该阶段 → 工人选不到工序 → 进不了售后 → 更选不到

【派发入口与仓库位置解绑】
- 原条件硬性要求 current_location_id === 'virtual_warehouse',直接完结后的设备
  位置停在最后经手人名下,派发入口彻底消失,产品再也无法流转
- 新增豁免:产品空闲(无活跃主线任务)+ 超管/主管即可派发;新增 hasActiveMainTask
  只算主干,协助分支(SPAWN)不阻塞派发
- 文案与图标按场景动态化(在仓库 / 任务已完结)

【顺带修复】
- isWarehouseTransfer 死标志位:openWarehouseTransfer 先置 true,紧接着调用的
  openCreateFirstTask 又立刻重置为 false,导致弹窗标题永远显示「发起首道工序」,
  仓库转出文案从未生效。改为在 openCreateFirstTask 内按产品实际位置推导
2026-09-17 17:06:00 +08:00

859 lines
66 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

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