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