Files
track/track-uniapp/src/pages/scan/detail.vue
duxingchen 0b982d192c feat(mobile): 移动端补齐出库单据页(领料 / 报废 / 删除)
PC 端早就能挂出库物料,但移动端一直没有入口 —— 只能看,一线的人(生产领料、
测试补料)反而够不着。

- 产品详情页的产品信息卡底部加「出库单据 N 张单 / M 条料」入口,常显不隐藏:
  以前没内容时整块消失,用户根本不知道有这功能。
- 新增「出库单据」页与「选择 MOM 出库物料」页,与 PC 端同一套数据源、
  同一套接口、同一形态(按出库单号分组 + 点开展开明细)。
- 挂载不需要先选任务:任务只是溯源(记 added_by),展示/报废/删除一律按设备走。
- 代挂确认:勾了不是自己领的单时先拦一道。★ 这是**提示**不是权限 ——
  料的归属是设备不是人,代挂是合理操作(测试替生产补挂、库管代录)。
  ⚠️ 判据是 MOM 的 consumer_name 与登录人姓名比对,比错也只是多让用户勾一下。
- navigateTo / navigateBack 失败在 uni 里是**静默**的(只留一行 warning),
  用户看到的就是「点了没反应」。全部补 fail 回调弹窗。
2026-09-23 15:18:06 +08:00

1023 lines
76 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 class="mat-entry" @tap="goMaterialPage">
<text class="mat-entry-icon">🚚</text>
<text class="mat-entry-label">出库单据</text>
<!-- 张数与条数都给:只显示「N 条」看不出挂了几张单,反过来也一样。
没料时不显示计数,只留入口 -->
<text class="mat-entry-count" v-if="mountedMaterials.length">
{{ mountedOrderCount }} 张单 / {{ mountedMaterials.length }} 条料
</text>
<text class="mat-entry-count" v-else>未挂载</text>
<text class="mat-entry-arrow">›</text>
</view>
</view>
<!-- 注:原先这里还有一张独立的「出库单据」卡,与上面产品信息卡里的
入口按钮**重复**(两处都叫「出库单据」、说的是同一件事)。
详情页只留入口按钮,单据清单与物料明细都在那一页里 ——
详情页已经很长,没必要再铺一遍。 -->
<!-- ♻️ 报废记录:本设备报过的废。状态与金额由后端实时回查 MOM。
这里只**展示结果**;报案本身(选料、填数量)在「领用物料」页里做 ——
详情页已经很长,把操作挪出去,这里留一眼能看懂的进度。 -->
<view class="card" v-if="scrapRecords.length">
<view class="card-header">
<text class="card-title">♻️ 报废记录</text>
<text class="ob-count">共 {{ scrapRecords.length }} 条</text>
</view>
<view v-for="s in scrapRecords" :key="s.id" class="ob-row">
<view class="ob-line1">
<text class="ob-no">{{ s.material_name || '(未命名物料)' }}</text>
<text :class="['sc-badge', scrapBadgeClass(s)]">{{ s.mom_status_label || '状态未知' }}</text>
<text class="ob-time">×{{ s.quantity }}</text>
</view>
<view class="ob-line2">
<text class="ob-meta">报废单 {{ s.scrap_request_no }}</text>
<text v-if="s.submitted_by" class="ob-meta">提交人 {{ formatName(s.submitted_by) }}</text>
<!-- ★ 只有执行过才有金额。未执行显示「—」,不显示 0 ——
0 会让人以为「这东西不值钱」,其实是「还没扫码执行」 -->
<text class="ob-meta" v-if="s.mom_executed">损失 {{ formatLoss(s.total_loss) }}</text>
</view>
<text v-if="s.reason" class="ob-remark">{{ s.reason }}</text>
</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";
// 本页只**读**报废记录;报案(选料/填数量/提交)在 pages/material/index
import { listProductScraps } from "../../api/scrap";
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: '',
// ♻️ 报废记录(只读展示)。状态与金额由后端**实时回查 MOM** ——
// 报废没有回调,本地存的那份会过期,而「批没批、执行没执行」正是要看的东西。
// 报案入口在「领用物料」页(pages/material/index),不在本页。
scrapRecords: [],
};
},
computed: {
userLabels() { return this.users.map(u => `${u.full_name} (${u.username})`); },
// 🚚 这台设备挂的出库明细条数 —— 只用来在入口按钮上显示数量。
// 具体清单/报废/领料都在「出库单据」页里(pages/material/index)。
// ⚠️ 读的是 `outbound_records`(统一后的设备级出库明细)。
// 以前读 `task_tree[].outbound_materials` —— 那个字段连同它那张表
// 一起被合并掉了,后端已经不再返回,照着读**恒为 0 条**
// (界面上就表现为「明明有料却显示 0 条,点进去又看得见」)。
mountedMaterials() {
return (this.product && this.product.outbound_records) || [];
},
/** 挂了**几张单**(按出库单号去重)—— 与条数一起显示 */
mountedOrderCount() {
return new Set(this.mountedMaterials.map(m => m.outbound_no).filter(Boolean)).size;
},
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) return;
this.fetchMessages();
// ⚠️ 必须**重新拉产品**,不只是刷新报废记录:
// 用户刚在「出库单据」页挂完料返回,入口上的「N 张单 / M 条料」靠的是
// product.outbound_records。只刷其它卡片的话计数一直是旧的,
// 用户会以为「我刚才那一下没挂上」。
this.refreshProductSilently();
},
// 🚀 页面卸载:清理确认倒计时定时器,避免泄漏
onUnload() { this.clearConfirm(); },
// ⚠️ 本页【刻意不开启】下拉刷新(pages.json 中已移除 enablePullDownRefresh)。
// 原因:本页有三种沉浸式模式 —— 锁定的工作区卡片、全屏流转卡片(swiper)、
// 全屏流转树(scroll-view),它们各自持有滚动/滑动手势,且都是 position:fixed
// 或原生 scroll-view,无法改由页面滚动接管。一旦开启页面下拉刷新,这些区域
// 滑到顶后再下拉就会误触发刷新,工人没法正常往上翻内容。
// 状态纠偏不依赖下拉刷新:handleNetworkFailure 会自动静默拉取真实状态。
methods: {
formatUserName, formatUserAvatar,
// ==================== ♻️ 生产报废 ====================
/** 拉本设备的报废记录(状态与金额由后端实时回查 MOM) */
async fetchScrapRecords() {
if (!this.product?.id) return;
try {
this.scrapRecords = (await listProductScraps(this.product.id)) || [];
} catch (e) {
// 静默失败:报废记录是「附加信息」,拉不到不该挡住产品详情的主流程
console.warn('[scrap] 拉取报废记录失败:', e?.data?.detail || e);
this.scrapRecords = [];
}
},
/** 进「领用物料」页:看这台设备的料、报废、补领 */
goMaterialPage() {
// ⚠️ 不要写 `if (!id) return` —— 静默返回在界面上就是「点了没反应」,
// 现场根本没法判断是没加载完、还是页面没注册。有情况都要说出来
if (!this.product || !this.product.id) {
uni.showToast({ title: '产品还没加载完,稍后再试', icon: 'none' });
return;
}
uni.navigateTo({
url: `/pages/material/index?productId=${this.product.id}`
+ `&serial=${encodeURIComponent(this.product.serial_number || '')}`,
// ★ navigateTo 失败时 uni 是**静默**的(只在控制台留一行 warning),
// 用户只会觉得「点了没反应」。这里必须弹出来。
// 最常见的原因:新页面没进 pages.json —— HBuilderX 会缓存它,
// 必须**重启 HBuilderX** 才会重新读取,光重新运行不够。
fail: (err) => {
console.error('[material] 跳转失败:', err);
uni.showModal({
title: '打不开「领用物料」',
content: '页面未注册或未编译:' + (err && err.errMsg ? err.errMsg : err)
+ '\n\n请完全关闭并重启 HBuilderX 后重新运行',
showCancel: false,
});
},
});
},
/** 报废状态 → 徽标配色。已完成绿色、被驳回/撤回灰色、其余蓝色 */
scrapBadgeClass(s) {
if (s.mom_executed) return 'sc-badge-done';
if (s.mom_status === 2 || s.mom_status === 4) return 'sc-badge-off';
return 'sc-badge-wait';
},
/** 金额展示。未执行时后端给 null → 显示「—」,不显示 0 */
formatLoss(v) {
if (v === null || v === undefined) return '—';
return '¥' + Number(v).toFixed(2);
},
formatName(name) { if (!name) return ""; return name.length === 2 ? name[0] + " " + name[1] : name; },
// 出库时间 → 可读格式。MOM 的 outbound_time 缺失时回退到本行写入时间,
// 避免这一列空白(后端返回的是带 +00:00 偏移的 ISO 串,Date 能正确解析)
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(); this.fetchScrapRecords(); } 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}`);
// 顺带刷新报废记录:MOM 里主管审批 / 库管扫码执行后状态与金额会变,
// 而报废没有回调,只能靠这类「顺手拉一次」让用户看到最新进度
this.fetchScrapRecords();
} 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; }
/* 🚚 出库单据(MOM 出库回调存档) */
.ob-count { font-size: 12px; color: #9ca3af; flex-shrink: 0; }
.ob-row { border: 1px solid #f3f4f6; border-radius: 8px; padding: 8px; margin-bottom: 6px; }
.ob-row:last-child { margin-bottom: 0; }
/* 已撤回:整行降调 + 单号删除线,但**不隐藏** ——「出过又撤了」也是历史 */
.ob-revoked { background: #f9fafb; border-color: #e5e7eb; }
.ob-line1 { display: flex; align-items: center; gap: 6px; }
.ob-no { font-family: monospace; font-size: 13px; font-weight: 700; color: #1f2937; word-break: break-all; }
.ob-no-revoked { color: #9ca3af; text-decoration: line-through; }
.ob-badge { font-size: 10px; font-weight: 700; color: #6b7280; background: #e5e7eb; border-radius: 10px; padding: 1px 6px; flex-shrink: 0; }
.ob-time { font-size: 11px; color: #9ca3af; margin-left: auto; flex-shrink: 0; }
.ob-line2 { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 4px; }
.ob-meta { font-size: 11px; color: #6b7280; }
.ob-remark { font-size: 11px; color: #9ca3af; margin-top: 3px; display: block; }
/* 📦 领用物料入口(产品信息卡底部)。
常显:以前这里没内容时整块消失,用户根本不知道有这功能 */
.mat-entry { display: flex; align-items: center; gap: 6px; margin-top: 12px; padding-top: 10px; border-top: 1px solid #f3f4f6; }
.mat-entry-icon { font-size: 15px; }
.mat-entry-label { font-size: 14px; font-weight: 600; color: #2563eb; }
.mat-entry-count { font-size: 12px; color: #9ca3af; }
.mat-entry-arrow { font-size: 16px; color: #9ca3af; margin-left: auto; }
/* ♻️ 报废记录状态徽标 */
.sc-badge { font-size: 10px; font-weight: 700; border-radius: 10px; padding: 1px 6px; flex-shrink: 0; }
.sc-badge-wait { color: #b45309; background: #fef3c7; } /* 待审批:琥珀 */
.sc-badge-done { color: #047857; background: #d1fae5; } /* 已执行:绿 */
.sc-badge-off { color: #6b7280; background: #e5e7eb; } /* 驳回/撤回:灰 */
/* 代报确认条的样式已随报废弹层移到 pages/material/index.vue */
.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>