Files
KCGL/inventory-web/src/views/transaction/borrow.vue
yueli a4a9afb6db feat(scan): 出库/借库扫码页接入草稿,切换单据不清空
交互
----
无需「暂停」按钮 —— 在下拉框切换单据这个动作本身就是暂停:
  切走 → 自动存当前单据的进度
  切回 → 自动恢复,并提示「已恢复上次的扫码进度(N 项)」
下拉框对扫到一半的单据显示橙色「已扫 N」徽标,不必逐个点开试。
提交成功后自动清除该单据的草稿。

修复的三个 bug
--------------
1) 切换时把 A 的内容存到了 B 名下
   v-model="selectedRequestId" 的 computed setter 会**先于** @change 把
   selectedRequest 改成新单,故 handleRequestChange 里读到的是新单。
   新增 activeRequestId ref 记录「界面上真正显示的是哪张单」,
   保存时显式传入离开的那张单的 ID。

2) 切回时把目标单的旧草稿删了
   原先写了「购物车为空则清除草稿」,但切换瞬间购物车必然为空,
   于是切回 A 时触发了清除。现改为空清单只跳过保存、不清除;
   清理由「提交成功」或「用户点清空列表」显式触发。

3) 恢复后名称/规格为空、出库数显示 NaN
   draftPayload 只存了 4 个字段(stock_id/source_table/sku/quantity),
   而购物车表格绑定的是 name/spec_model/available_quantity/out_quantity
   —— 全都没存。现保存完整快照,并在恢复时归一化
   (out_quantity ?? quantity)以兼容已存在的旧草稿。

补充:恢复后刷新实时库存
------------------------
草稿里的 available_quantity 是扫描那一刻的快照,跨时间恢复可能已过期
(期间别人出库/借出会消耗可用量)。恢复后复用 /alternatives 端点拉一次
实时可用量:数量超了会明确提示「N 项物料的实际库存已少于你扫的数量」,
避免工人扫满后到提交时才被后端拒绝。失败不阻断,沿用草稿快照。

另:离开页面(路由跳转)时存草稿并弹确认;beforeunload 用 sendBeacon
尽力保存(该路径无法带 Authorization 头,可能失败,但防抖保存已覆盖
绝大部分内容)。
2026-09-10 17:21:24 +08:00

1240 lines
46 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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>
<div class="app-container mobile-optimized">
<el-card class="box-card" shadow="never">
<template #header>
<div class="card-header">
<div class="title-box">
<span>借库作业 (领用人签字)</span>
<el-tag v-if="cartItems.length > 0" type="warning" size="small" effect="dark">
已选 {{ cartItems.length }}
</el-tag>
</div>
</div>
</template>
<!-- 审批单选择下拉框 -->
<div class="approval-request-select">
<el-select
v-model="selectedApprovalId"
placeholder="请选择已通过审批的借库申请单"
filterable
clearable
style="width: 100%"
:loading="requestsLoading"
@change="handleApprovalChange"
>
<el-option
v-for="req in approvalRequests"
:key="req.id"
:value="req.id"
:label="req.request_no"
>
<span>{{ req.request_no }}</span>
<el-divider direction="vertical" />
<span>{{ req.borrower_name || '未知借库人' }}</span>
<el-divider direction="vertical" />
<span style="color: #909399; font-size: 13px">{{ req.remark || '无备注' }}</span>
</el-option>
</el-select>
<p class="select-tip">仅显示已通过status=1的审批单</p>
</div>
<!-- 计划借用清单预览与扫码出库页的计划出库清单对齐 -->
<div v-if="selectedApproval" class="planned-items-section">
<div class="planned-header">
<span class="planned-title">计划借用清单</span>
<el-tag type="success" size="small">{{ plannedItems.length }} </el-tag>
</div>
<el-table :data="plannedItems" border size="small" style="width: 100%;">
<el-table-column type="index" label="序号" width="60" align="center" />
<!-- 计划数量前置与出库页一致原先排在表格最右列多时易被裁掉 -->
<el-table-column label="计划数量" width="100" align="center">
<template #default="{ row }">
<span style="color: #E6A23C; font-weight: bold; font-size: 15px;">
{{ row.quantity ?? '-' }}
</span>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格" min-width="100" show-overflow-tooltip />
<!-- 库位 + 备选库位现场进不去时可改扫其它批次 -->
<el-table-column label="库位" width="170">
<template #default="{ row }">
<el-popover
v-if="row.base_id"
placement="right"
:width="340"
trigger="click"
@show="loadAlternatives(row)"
>
<!-- 整个库位格子都是点击热区工人不必精准点图标 -->
<template #reference>
<span class="loc-cell" title="点击查看该物料的所有可选库位">
<span class="loc-text">{{ row.warehouse_location || '-' }}</span>
<el-icon class="alt-icon"><LocationInformation /></el-icon>
</span>
</template>
<div v-loading="altLoading" class="alt-panel">
<div class="alt-title">
该物料的可选库位
<span v-if="altTotal > 0" class="alt-total">合计可用 {{ altTotal }}</span>
</div>
<div v-if="!altLoading && altItems.length === 0" class="alt-empty">
暂无其它可用库位
</div>
<div v-for="a in altItems" :key="a.source_table + '_' + a.stock_id" class="alt-row">
<el-tag :type="a.is_locked ? 'success' : 'info'" size="small" effect="plain">
{{ a.is_locked ? '推荐' : '备选' }}
</el-tag>
<span class="alt-loc">{{ a.warehouse_location || '(无库位)' }}</span>
<span class="alt-qty">可用 {{ a.available_quantity }}</span>
</div>
<div v-if="altItems.length" class="alt-hint">
现场取不到推荐库位时可直接扫备选库位的条码借用
</div>
</div>
</el-popover>
<span v-else>{{ row.warehouse_location || '-' }}</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="scan-section">
<!-- 扫码进度条 -->
<div v-if="selectedApproval && scanTotalQty > 0" class="scan-progress">
<div class="progress-info">
<span>扫码进度</span>
<el-tag type="success" size="small">{{ scanScannedTypes }}/{{ scanTotalTypes }} </el-tag>
<el-tag type="primary" size="small">{{ scanScannedQty }}/{{ scanTotalQty }} </el-tag>
<el-button
v-if="unscannedCount > 0"
type="warning" plain size="small" style="margin-left: auto;"
@click="showUnscannedDialog = true"
>
未扫清单 ({{ unscannedCount }})
</el-button>
</div>
<el-progress
:percentage="scanTotalQty > 0 ? Math.min(100, Math.round(scanScannedQty / scanTotalQty * 100)) : 0"
:stroke-width="8"
:color="scanScannedQty >= scanTotalQty ? '#67C23A' : '#409EFF'"
/>
</div>
<template v-if="!selectedApprovalId">
<div class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
<span class="text">请先选择审批单</span>
</div>
<div class="input-box">
<el-input v-model="barcodeInput" placeholder="请先选择审批单" disabled size="large">
<template #prefix><el-icon><Scissor /></el-icon></template>
<template #append><el-button disabled>添加</el-button></template>
</el-input>
</div>
</template>
<template v-else>
<div v-if="userStore.hasPermission('op_borrow:operation')" class="camera-placeholder" @click="showCamera = true">
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<span class="text">点击开启全屏扫码</span>
</div>
<div v-else class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
<span class="text">无扫码权限</span>
</div>
<div class="input-box">
<el-input
v-model="barcodeInput"
placeholder="扫描或输入条码回车"
@keyup.enter="handleManualInput"
clearable
ref="barcodeRef"
size="large"
:disabled="!userStore.hasPermission('op_borrow:operation')"
>
<template #prefix>
<el-icon><Scissor /></el-icon>
</template>
<template #append>
<el-button @click="handleManualInput" :disabled="!userStore.hasPermission('op_borrow:operation')">添加</el-button>
</template>
</el-input>
</div>
</template>
</div>
<div class="cart-section">
<div v-if="cartItems.length > 0">
<el-table :data="cartItems" border stripe style="width: 100%">
<el-table-column v-if="hasColumnPermission('name')" prop="name" label="物品名称" min-width="120" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('sku')" prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('available_quantity')" label="可用库存" width="90" align="center">
<template #default="{row}">
<el-tag type="info">{{ parseFloat(row.available_quantity) }}</el-tag>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('out_quantity')" label="借用数" width="130" align="center">
<template #default="{row}">
<el-input-number
v-model="row.out_quantity"
:min="1"
:max="parseFloat(row.available_quantity)"
size="small"
style="width: 100px"
:disabled="!userStore.hasPermission('op_borrow:operation')"
/>
</template>
</el-table-column>
<el-table-column v-if="userStore.hasPermission('op_borrow:operation')" label="操作" width="60" align="center" fixed="right">
<template #default="{$index}">
<el-button type="danger" icon="Delete" circle size="small" @click="removeFromCart($index)" />
</template>
</el-table-column>
</el-table>
</div>
<el-empty v-else description="暂无物品,请扫码借出" :image-size="80" />
</div>
<div v-if="cartItems.length > 0" class="form-section">
<el-divider content-position="left">借用登记信息</el-divider>
<el-form :model="form" ref="formRef" :rules="rules" label-position="top">
<el-row :gutter="15">
<el-col :span="24">
<el-form-item label="领用人/借用人" prop="borrower_name">
<el-input v-model="form.borrower_name" placeholder="请输入姓名" size="large" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="预计归还日期" prop="expected_return_time">
<el-date-picker
v-model="form.expected_return_time"
type="date"
placeholder="请选择日期"
style="width: 100%"
size="large"
value-format="YYYY-MM-DD"
:disabled="isIndefinite"
:disabled-date="disabledDate"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-checkbox v-model="isIndefinite" @change="handleIndefiniteChange">
无限期/长期借用不设归还期限
</el-checkbox>
</el-col>
</el-row>
<el-form-item label="备注说明" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="用途说明..." />
</el-form-item>
<el-form-item label="领用人签名确认" required>
<div class="signature-box" @click="openSignatureDialog" v-if="userStore.hasPermission('op_borrow:operation')">
<div v-if="signaturePreviewUrl" class="signed-img">
<img :src="signaturePreviewUrl" alt="签名" />
<span class="re-sign-tip">点击重签</span>
</div>
<div v-else class="unsigned-placeholder">
<el-icon :size="24"><EditPen /></el-icon>
<span>点击此处进行全屏签名</span>
</div>
</div>
<div v-else class="signature-box" style="background-color: #f5f5f5; cursor: not-allowed;">
<div class="unsigned-placeholder">
<el-icon :size="24"><EditPen /></el-icon>
<span>无签名权限</span>
</div>
</div>
</el-form-item>
<div class="bottom-actions">
<el-button v-if="userStore.hasPermission('op_borrow:operation')" @click="clearAll" icon="Refresh">清空</el-button>
<el-button v-if="userStore.hasPermission('op_borrow:operation')" type="primary" size="large" :loading="loading" @click="submitForm" icon="Select">
确认借出
</el-button>
</div>
</el-form>
</div>
</el-card>
<div v-if="showCamera" class="fullscreen-scanner-overlay">
<div class="scanner-header">
<el-button circle icon="Close" @click="showCamera = false" class="close-btn" />
<span class="scanner-title">扫码模式</span>
<div class="scanner-placeholder"></div>
</div>
<div class="scanner-body">
<QrScanner @decode="onScanSuccess" />
</div>
<div class="scanner-footer">
<p>请将条码/二维码放入镜头范围</p>
<p v-if="cartItems.length > 0" class="current-count">已添加: {{ cartItems.length }} </p>
</div>
</div>
<!-- 未扫清单弹窗 -->
<el-dialog v-model="showUnscannedDialog" title="未扫清单" width="600px" destroy-on-close>
<el-alert
v-if="unscannedList.length > 0"
:title="`以下 ${unscannedList.length} 种物料还未扫满,请补扫:`"
type="warning" :closable="false" show-icon style="margin-bottom: 12px;"
/>
<el-table :data="unscannedList" border size="small" max-height="400">
<el-table-column type="index" label="#" width="40" align="center" />
<el-table-column prop="name" label="物料名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="spec" label="规格型号" min-width="120" show-overflow-tooltip />
<el-table-column label="计划" width="70" align="center">
<template #default="{ row }">{{ row.planQty }}</template>
</el-table-column>
<el-table-column label="已扫" width="70" align="center">
<template #default="{ row }">
<span style="color: #67C23A;">{{ row.scannedQty }}</span>
</template>
</el-table-column>
<el-table-column label="待扫" width="70" align="center">
<template #default="{ row }">
<span style="color: #F56C6C; font-weight: bold;">{{ row.remaining }}</span>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="showUnscannedDialog = false">关闭</el-button>
<el-button type="primary" @click="showUnscannedDialog = false; showCamera = true">去扫码</el-button>
</template>
</el-dialog>
<el-dialog
v-model="showSignatureDialog"
fullscreen
destroy-on-close
:show-close="false"
class="fullscreen-signature-dialog"
@opened="initCanvas"
>
<div class="signature-wrapper">
<div class="signature-canvas-container" ref="canvasContainerRef">
<canvas
ref="nativeCanvasRef"
class="native-canvas"
@mousedown="startDrawing"
@mousemove="draw"
@mouseup="stopDrawing"
@mouseleave="stopDrawing"
@touchstart="startDrawing"
@touchmove="draw"
@touchend="stopDrawing"
></canvas>
<div class="canvas-tip">请在此区域横屏书写</div>
</div>
<div class="signature-sidebar">
<div class="sidebar-title">电子签名</div>
<div class="sidebar-actions">
<el-button type="warning" @click="clearCanvas">重写</el-button>
<el-button @click="handleSignCancel">取消</el-button>
<el-button type="success" class="confirm-btn" @click="handleSignConfirm">确认使用</el-button>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, nextTick, onUnmounted, computed } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select, LocationInformation } from '@element-plus/icons-vue'
import QrScanner from '@/components/QrScanner/index.vue'
import { getStockByBarcode, getStockAlternatives, getScanDraft, saveScanDraft, clearScanDraft } from '@/api/outbound'
import { dispatchBorrow, getBorrowApprovalList } from '@/api/transaction'
import { uploadFile } from '@/api/common/upload'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 列与权限Code的映射关系
const permissionMap: Record<string, string | null> = {
// 基础显示列 — 始终可见
name: null,
spec_model: null,
sku: null,
borrower_name: null,
available_quantity: null,
out_quantity: null,
}
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true
if (!(prop in permissionMap)) return false
const code = permissionMap[prop]
if (code === null || code === undefined) return true
return userStore.hasPermission(code)
}
// --- 状态定义 ---
const barcodeInput = ref('')
const cartItems = ref<any[]>([])
const loading = ref(false)
const showCamera = ref(false)
const barcodeRef = ref()
const formRef = ref()
// ★ 审批单选择
const approvalRequests = ref<any[]>([])
const selectedApprovalId = ref<number | null>(null)
const requestsLoading = ref(false)
const selectedApproval = computed(() =>
selectedApprovalId.value
? approvalRequests.value.find(r => r.id === selectedApprovalId.value) ?? null
: null
)
const plannedItems = computed(() => selectedApproval.value?.items ?? [])
// ============================================================================
// ★ 备选库位(与扫码出库页同款)
//
// 申请单已把货预占在某库位,工人现场可能进不去,需要改扫同物料的其它批次
// (后端执行端按 base_id 校验身份、允许换批次)。此处复用出库模块的
// /alternatives 端点:按 available_quantity > 0 过滤,只给真正能拿的库位。
// 点击图标才发请求,避免展开单据时打出一片并发查询。
// ============================================================================
const altLoading = ref(false)
const altItems = ref<any[]>([])
const altTotal = ref(0)
const loadAlternatives = async (row: any) => {
if (!row?.base_id) return
altLoading.value = true
altItems.value = []
altTotal.value = 0
try {
const res: any = await getStockAlternatives(row.base_id, {
stock_id: row.stock_id,
source_table: row.source_table,
})
altItems.value = res?.data?.items || []
altTotal.value = res?.data?.total_available || 0
} catch (e) {
ElMessage.error('查询备选库位失败')
} finally {
altLoading.value = false
}
}
// ★ 扫码进度:总需扫数(计划数量总和)/ 已扫数(购物车数量总和)
const scanTotalQty = computed(() =>
plannedItems.value.reduce((sum, it) => sum + (Number(it.quantity) || 0), 0)
)
const scanScannedQty = computed(() =>
cartItems.value.reduce((sum, it) => sum + (Number(it.out_quantity) || 0), 0)
)
// 已扫物品种类数 / 计划物品种类数
const scanScannedTypes = computed(() => cartItems.value.length)
const scanTotalTypes = computed(() => plannedItems.value.length)
// ★ 未扫清单:计划中还没扫满的物料
const unscannedList = computed(() => {
return plannedItems.value.map(plan => {
const planQty = Number(plan.quantity) || 0
// 已扫数量(按名称+规格匹配)
const scannedQty = cartItems.value
.filter(ci => {
const ciName = (ci.name || '').trim()
const ciSpec = (ci.spec_model || ci.standard || '').trim()
return ciName === (plan.name || '').trim() &&
ciSpec === (plan.spec_model || '').trim()
})
.reduce((sum, ci) => sum + (Number(ci.out_quantity) || 0), 0)
return {
name: plan.name || '',
spec: plan.spec_model || '',
planQty,
scannedQty,
remaining: Math.max(0, planQty - scannedQty)
}
}).filter(item => item.remaining > 0) // 只保留未扫满的
})
const unscannedCount = computed(() => unscannedList.value.length)
const showUnscannedDialog = ref(false)
// ★ 加载已通过审批的借库申请单列表
const loadApprovalRequests = async () => {
requestsLoading.value = true
try {
const res: any = await getBorrowApprovalList({ status: 1, page: 1, limit: 100 })
approvalRequests.value = res.data?.items || []
} catch (e) {
console.error('加载借库审批单列表失败', e)
} finally {
requestsLoading.value = false
}
}
// ★ 切换审批单时:带出申请时填写的归还日期/长期借用,并清空购物车和签名,防止跨单据污染
const handleApprovalChange = async (val: number | null) => {
// ★ 关键selectedApproval 已被 computed 更新为新单,
// 故用 activeApprovalId 拿到「刚离开的那张单」来保存
const leavingId = activeApprovalId.value
await saveDraftNow(leavingId)
if (!val) {
selectedApprovalId.value = null
}
// 从申请单明细快照中带出"预计归还日期"(申请时通过 items_json 存储,无需改数据库)
const req = approvalRequests.value.find(r => r.id === val)
const firstItem = req?.items?.[0]
if (firstItem?.is_indefinite) {
isIndefinite.value = true
form.expected_return_time = ''
} else if (firstItem?.expected_return_time) {
isIndefinite.value = false
form.expected_return_time = firstItem.expected_return_time
} else {
// 旧申请单/未填时间:保持手动选择
isIndefinite.value = false
form.expected_return_time = ''
}
// ★ 自动关联:把该借库申请单的“申请原因(remark)”自动带入执行备注,库管无需重复填写(可手动改)
form.remark = (req && req.remark) || ''
cartItems.value = []
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
activeApprovalId.value = val // ★ 切换到新单
// ★ 载入目标单据的草稿(之前扫到一半则自动恢复)
if (val) await restoreDraft(val)
}
// ============================================================================
// ★ 扫码草稿(与扫码出库页同款)
//
// 场景:一张单几十项,扫到一半被更紧急的单打断,回来接着扫。
// 库存在申请审批通过时已预占,暂停期间不会被他人抢走,故草稿只记「扫到哪了」。
// 隔离:后端按 (user_id, 单据ID) 隔离,一人一单互不影响。
// ============================================================================
let draftTimer: ReturnType<typeof setTimeout> | null = null
// ★ 必须保存购物车行的**全部展示字段**,不能只存定位信息。
// 原先只存 4 个字段,恢复后名称/库存为空、借用数显示 NaN。
const draftPayload = () => cartItems.value.map((it: any) => ({
id: it.id,
source_table: it.source_table,
sku: it.sku || '',
name: it.name || '',
spec_model: it.spec_model || '',
warehouse_location: it.warehouse_location || '',
barcode: it.barcode || '',
available_quantity: Number(it.available_quantity) || 0,
price: Number(it.price) || 0,
out_quantity: Number(it.out_quantity) || 0,
}))
// 监听中的单据ID
//
// ★ 为什么需要它selectedApproval 是由 selectedApprovalId 派生的 computed
// 切换时它**立刻**变成新单。若在 handleApprovalChange 里读 selectedApproval
// 拿到的已经是新单 —— 会把 A 的内容存到 B 名下。用一个独立的 ref 记录
// 「当前界面上显示的是哪张单」,保存时以此为准。
const activeApprovalId = ref<number | null>(null)
const saveDraftNow = async (requestId?: number | null) => {
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
const rid = requestId ?? activeApprovalId.value
if (!rid) return
const items = draftPayload()
// ★ 空清单不清除草稿:切换单据瞬间购物车必然为空,若此时清除会误删
// 目标单的旧草稿(这正是"切回来就没了"的直接原因)。清理由提交成功触发。
if (items.length === 0) return
try {
await saveScanDraft('borrow', rid, items, selectedApproval.value?.request_no)
} catch (e) {
console.warn('保存草稿失败', e) // 不阻断作业
}
}
const scheduleSaveDraft = () => {
if (draftTimer) clearTimeout(draftTimer)
draftTimer = setTimeout(() => saveDraftNow(), 800)
}
const restoreDraft = async (requestId: number) => {
try {
const res: any = await getScanDraft('borrow', requestId)
const items = res?.data?.items || []
if (!items.length) return
// 归一化:老草稿用 quantity新草稿用 out_quantity缺失字段补默认值
// 避免购物车出现 NaNNaN 提交时会被兜底成 1造成静默的数量错误
cartItems.value = items.map((it: any) => ({
...it,
id: it.id ?? it.stock_id,
out_quantity: Number(it.out_quantity ?? it.quantity) || 0,
available_quantity: Number(it.available_quantity) || 0,
price: Number(it.price) || 0,
name: it.name || '',
spec_model: it.spec_model || '',
}))
ElMessage.success(`已恢复上次的扫码进度(${items.length} 项)`)
// ★ 刷新库存:草稿里的 available_quantity 是扫描那一刻的快照,
// 期间别人出库/借出会消耗可用量,需重新拉取实时值。
await refreshStockFromDraft()
} catch (e) {
console.warn('恢复草稿失败', e)
}
}
/**
* 用实时库存刷新购物车行的「库存」列(与扫码出库页同款)。
* 复用 /alternatives 端点;失败不阻断,沿用草稿快照。
*/
const refreshStockFromDraft = async () => {
const plan = selectedApproval.value?.items || []
const baseIds = [...new Set(plan.map((p: any) => p.base_id).filter(Boolean))]
if (!baseIds.length) return
try {
const results = await Promise.all(
(baseIds as number[]).map(bid => getStockAlternatives(bid).catch(() => null))
)
const latestByKey = new Map<string, number>()
for (const r of results) {
for (const a of ((r as any)?.data?.items || [])) {
latestByKey.set(`${a.source_table}_${a.stock_id}`, Number(a.available_quantity) || 0)
}
}
if (!latestByKey.size) return
let changed = 0
for (const row of cartItems.value) {
const key = `${row.source_table}_${row.id}`
if (!latestByKey.has(key)) continue
const latest = latestByKey.get(key)!
if (latest !== Number(row.available_quantity)) {
row.available_quantity = latest
changed++
}
}
const over = cartItems.value.filter(
(r: any) => Number(r.out_quantity) > Number(r.available_quantity)
)
if (over.length > 0) {
ElMessage.warning(
`${over.length} 项物料的实际库存已少于你扫的数量` +
`(如 ${over[0].name || over[0].sku}),请核对后再提交`
)
} else if (changed > 0) {
ElMessage.info(`已刷新 ${changed} 项物料的实时库存`)
}
} catch (e) {
console.warn('刷新库存失败', e)
}
}
// ★ 扫码校验:比对扫描物料是否在审批计划清单内,且累计数量不超过审批上限
const validateAgainstPlan = (scannedName: string, scannedSpec: string, scannedQty: number): string | null => {
const normalizedName = scannedName.trim()
const normalizedSpec = (scannedSpec || '').trim()
const matchedPlan = plannedItems.value.find(plan => {
const planName = (plan.name || '').trim()
const planSpec = (plan.spec_model || '').trim()
return planName === normalizedName && planSpec === normalizedSpec
})
if (!matchedPlan) {
return `该物料【${normalizedName} × ${normalizedSpec}】不在审批计划清单中,请检查`
}
const planQty = matchedPlan.quantity ?? 0
// 购物车中已扫的同名同规格物料累计数量
const alreadyScanned = cartItems.value
.filter(ci => {
const ciName = (ci.name || '').trim()
const ciSpec = (ci.spec_model || '').trim()
return ciName === normalizedName && ciSpec === normalizedSpec
})
.reduce((sum, ci) => sum + (ci.out_quantity || 0), 0)
if (alreadyScanned + scannedQty > planQty) {
return `${normalizedName} × ${normalizedSpec}】超出审批数量(审批: ${planQty},已扫: ${alreadyScanned},本次: ${scannedQty}`
}
return null
}
// 签名相关
const showSignatureDialog = ref(false)
const signaturePreviewUrl = ref('')
const signatureFile = ref<File | null>(null)
const nativeCanvasRef = ref<HTMLCanvasElement | null>(null)
const canvasContainerRef = ref<HTMLElement | null>(null)
const ctx = ref<CanvasRenderingContext2D | null>(null)
const isDrawing = ref(false)
const lastX = ref(0)
const lastY = ref(0)
const form = reactive({
borrower_name: '',
expected_return_time: '',
remark: ''
})
const rules = computed(() => ({
borrower_name: [{ required: true, message: '请输入借用人姓名', trigger: 'blur' }],
expected_return_time: [
{ required: !isIndefinite.value, message: '请选择预计归还日期', trigger: 'change' }
]
}))
const isIndefinite = ref(false)
const handleIndefiniteChange = (val: boolean) => {
if (val) form.expected_return_time = ''
}
const disabledDate = (time: Date) => {
return time.getTime() < Date.now() - 8.64e7
}
// --- 核心扫码逻辑 ---
const onScanSuccess = (code: string) => {
if (!code) return
const trimCode = code.trim()
const validPattern = /^[A-Za-z0-9\-\.]+$/
if (!validPattern.test(trimCode)) {
ElMessage.warning(`识别到异常符号,已忽略:${trimCode}`)
return
}
if (trimCode.length < 3) {
ElMessage.warning('扫描结果过短,请对准重试')
return
}
if (loading.value) return
barcodeInput.value = trimCode
handleManualInput()
}
const handleManualInput = async () => {
if (!userStore.hasPermission('op_borrow:operation')) {
ElMessage.warning('无操作权限')
return
}
const code = barcodeInput.value.trim()
if (!code) return
// ★ 必须先选择审批单
if (!selectedApproval.value) {
ElMessage.warning('请先选择要执行借库的审批申请单')
return
}
try {
loading.value = true
// 查重:条码或 SKU 匹配已扫记录
const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code)
if (existIndex > -1) {
const item = cartItems.value[existIndex]
// ★ 追加前仍需校验审批数量上限
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
const maxQty = parseFloat(item.available_quantity)
if (item.out_quantity < maxQty) {
// ★ 重复扫码:弹窗确认是否 +1防止手滑重复扫
try {
await ElMessageBox.confirm(
`${item.name} × ${item.spec_model}】已在清单中(当前已扫 ${item.out_quantity} 个)。\n\n确认再 +1 吗?`,
'重复扫码确认',
{ confirmButtonText: '确认 +1', cancelButtonText: '取消', type: 'warning' }
)
} catch (e) {
barcodeInput.value = ''
return // 用户取消,不加
}
item.out_quantity++
ElMessage.success(`数量+1 (当前: ${item.out_quantity})`)
if (navigator.vibrate) navigator.vibrate(50)
} else {
ElMessage.warning(`库存不足 (余: ${maxQty})`)
if (navigator.vibrate) navigator.vibrate([100, 50, 100])
}
barcodeInput.value = ''
return
}
// 查库
const res = await getStockByBarcode(code)
if (res.data) {
const item = res.data
const availQty = parseFloat(item.available_quantity || 0)
if (availQty <= 0) {
ElMessage.warning(`库存不足或已借出 (余: ${availQty})`)
if (navigator.vibrate) navigator.vibrate([100, 50, 100])
barcodeInput.value = ''
return
}
// ★ 扫码加入前强校验:不在清单内或超量直接阻断
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
cartItems.value.push({
...item,
out_quantity: 1,
price: 0
})
scheduleSaveDraft() // ★ 自动存草稿(防抖),中途离开可恢复
ElMessage.success(`添加成功: ${item.name}`)
if (navigator.vibrate) navigator.vibrate(100)
barcodeInput.value = ''
}
} catch (error: any) {
if (error.response && error.response.status === 404) {
ElMessage.error(`未找到条码: ${code}`)
} else {
ElMessage.error('查询出错')
}
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
} finally {
loading.value = false
if (!showCamera.value) {
nextTick(() => { barcodeRef.value?.focus() })
}
}
}
const removeFromCart = (index: number) => {
if (!userStore.hasPermission('op_borrow:operation')) {
ElMessage.warning('无操作权限')
return
}
cartItems.value.splice(index, 1)
}
const clearAll = () => {
if (!userStore.hasPermission('op_borrow:operation')) {
ElMessage.warning('无操作权限')
return
}
ElMessageBox.confirm('确定清空所有已选物品吗?', '提示', { type: 'warning' })
.then(() => {
cartItems.value = []
form.borrower_name = ''
form.remark = ''
form.expected_return_time = ''
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
isIndefinite.value = false
// 仅清空购物车,保留审批单选择
})
}
// --- 提交逻辑 ---
const submitForm = async () => {
if (!userStore.hasPermission('op_borrow:operation')) {
ElMessage.warning('无操作权限')
return
}
if (!formRef.value) return
if (cartItems.value.length === 0) return ElMessage.warning('请先添加物品')
if (!selectedApprovalId.value) return ElMessage.warning('请选择关联的审批申请单')
// 记下单据ID清空 cartItems 后可能被重置,草稿清除需要它
const submittedRequestId = selectedApprovalId.value
await formRef.value.validate(async (valid: boolean) => {
if (!valid) {
const requiredMsg = isIndefinite.value ? '请填写完整的必填项(姓名)' : '请填写完整的必填项(姓名、归还日期)'
ElMessage.error(requiredMsg)
return
}
if (!signatureFile.value) {
ElMessage.error('请领用人进行电子签名')
return
}
try {
loading.value = true
// 上传签名
const uploadRes = await uploadFile(signatureFile.value)
const signatureUrl = uploadRes.data.url
// ★ 规范 Payload只包含后端需要的最小字段
const itemsPayload = cartItems.value.map(item => {
let safeQty = Number(item.out_quantity)
if (isNaN(safeQty) || safeQty <= 0) safeQty = 1
return {
id: item.id || 0,
source_table: item.source_table || '',
sku: item.sku ? String(item.sku) : (item.barcode ? String(item.barcode) : 'NO_SKU'),
barcode: item.barcode ? String(item.barcode) : '',
out_quantity: safeQty
}
})
if (itemsPayload.length === 0) {
ElMessage.warning('请至少扫描一件物料后再提交')
return
}
await dispatchBorrow({
approval_id: selectedApprovalId.value,
items: itemsPayload,
borrower_name: form.borrower_name,
signature_path: signatureUrl,
remark: form.remark,
expected_return_time: isIndefinite.value ? null : form.expected_return_time
})
ElMessage.success('借用成功')
// ★ 提交成功 → 清除该单据的草稿,避免下次打开恢复出已提交的内容
if (submittedRequestId) {
clearScanDraft('borrow', submittedRequestId).catch(() => {})
}
cartItems.value = []
form.borrower_name = ''
form.expected_return_time = ''
form.remark = ''
signatureFile.value = null
signaturePreviewUrl.value = ''
showCamera.value = false
isIndefinite.value = false
} catch (error: any) {
console.error(error)
ElMessage.error(error.response?.data?.msg || '提交失败')
} finally {
loading.value = false
}
})
}
// --- 签名逻辑 ---
const openSignatureDialog = () => {
if (!userStore.hasPermission('op_borrow:operation')) {
ElMessage.warning('无签名权限')
return
}
showSignatureDialog.value = true
}
const initCanvas = async () => {
await nextTick()
const canvas = nativeCanvasRef.value
const container = canvasContainerRef.value
if (canvas && container) {
canvas.width = container.clientWidth
canvas.height = container.clientHeight
ctx.value = canvas.getContext('2d')
if (ctx.value) {
ctx.value.lineWidth = 4
ctx.value.lineCap = 'round'
ctx.value.lineJoin = 'round'
ctx.value.strokeStyle = '#000000'
ctx.value.fillStyle = '#ffffff'
ctx.value.fillRect(0, 0, canvas.width, canvas.height)
}
}
}
const getPos = (e: MouseEvent | TouchEvent) => {
if (!nativeCanvasRef.value) return { x: 0, y: 0 }
const rect = nativeCanvasRef.value.getBoundingClientRect()
const clientX = e.type.startsWith('touch') ? (e as TouchEvent).touches[0].clientX : (e as MouseEvent).clientX
const clientY = e.type.startsWith('touch') ? (e as TouchEvent).touches[0].clientY : (e as MouseEvent).clientY
return { x: clientX - rect.left, y: clientY - rect.top }
}
const startDrawing = (e: MouseEvent | TouchEvent) => {
e.preventDefault()
isDrawing.value = true
const { x, y } = getPos(e)
lastX.value = x; lastY.value = y
ctx.value?.beginPath()
ctx.value?.moveTo(x, y)
}
const draw = (e: MouseEvent | TouchEvent) => {
e.preventDefault()
if (!isDrawing.value || !ctx.value) return
const { x, y } = getPos(e)
ctx.value.lineTo(x, y)
ctx.value.stroke()
}
const stopDrawing = () => { isDrawing.value = false }
const clearCanvas = () => {
if (!ctx.value || !nativeCanvasRef.value) return
ctx.value.clearRect(0, 0, nativeCanvasRef.value.width, nativeCanvasRef.value.height)
ctx.value.fillStyle = '#ffffff'
ctx.value.fillRect(0, 0, nativeCanvasRef.value.width, nativeCanvasRef.value.height)
}
const handleSignConfirm = () => {
nativeCanvasRef.value?.toBlob((blob) => {
if (blob) {
const file = new File([blob], `sign_${Date.now()}.png`, { type: 'image/png' })
signatureFile.value = file
signaturePreviewUrl.value = URL.createObjectURL(file)
showSignatureDialog.value = false
}
}, 'image/png')
}
const handleSignCancel = () => { showSignatureDialog.value = false }
// --- 初始化 ---
import { onMounted } from 'vue'
onMounted(() => {
loadApprovalRequests()
})
onUnmounted(() => {
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
if (draftTimer) clearTimeout(draftTimer)
window.removeEventListener('beforeunload', handleBeforeUnload)
})
// ★ 离开页面前立即存草稿(防抖中未落盘的内容会丢)
onBeforeRouteLeave(async (_to, _from, next) => {
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
const hasItems = cartItems.value.length > 0 && selectedApproval.value?.id
if (hasItems) {
await saveDraftNow()
try {
await ElMessageBox.confirm(
`当前单据【${selectedApproval.value.request_no}】已扫 ${cartItems.value.length} 项尚未提交。\n\n` +
`已自动保存为草稿,下次选择该单据时可继续扫码。\n确认离开吗`,
'离开确认',
{ confirmButtonText: '离开', cancelButtonText: '留下继续', type: 'warning' }
)
} catch (e) {
return next(false)
}
}
next()
})
// 刷新/关闭浏览器时尽力保存sendBeacon 不阻塞卸载)
const handleBeforeUnload = () => {
const req = selectedApproval.value
if (!req?.id || cartItems.value.length === 0) return
try {
const token = localStorage.getItem('token') || ''
const blob = new Blob([JSON.stringify({
biz_type: 'borrow', request_id: req.id, request_no: req.request_no,
items: draftPayload(),
})], { type: 'application/json' })
navigator.sendBeacon?.(`/api/v1/scan-draft?token=${encodeURIComponent(token)}`, blob)
} catch (e) { /* 尽力而为 */ }
}
onMounted(() => { window.addEventListener('beforeunload', handleBeforeUnload) })
</script>
<style scoped>
.app-container.mobile-optimized {
padding: 10px; max-width: 600px; margin: 0 auto;
}
/* 头部 */
.card-header { display: flex; justify-content: space-between; align-items: center; }
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
/* 审批单选择 */
.approval-request-select { margin-bottom: 16px; }
.select-tip { color: #909399; font-size: 12px; margin: 4px 0 0 0; }
/* 计划清单:与扫码出库页的「计划出库清单」保持一致的视觉 */
.planned-items-section {
margin-bottom: 16px;
padding: 12px;
background: #f0f9eb;
border: 1px solid #e1f3d8;
border-radius: 8px;
}
.planned-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.planned-title { font-weight: bold; font-size: 14px; color: #67C23A; }
/* ★ 备选库位:库位格子整体可点击(与扫码出库页同款) */
.loc-cell {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 6px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.loc-cell:hover { background-color: #ecf5ff; }
.loc-cell .loc-text { color: #409EFF; }
.alt-icon {
font-size: 18px;
color: #409EFF;
vertical-align: middle;
flex-shrink: 0;
}
.loc-cell:hover .alt-icon { color: #66b1ff; }
.alt-panel { font-size: 13px; }
.alt-title {
font-weight: bold;
color: #303133;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.alt-total { font-weight: normal; color: #67C23A; font-size: 12px; }
.alt-empty { color: #909399; font-size: 12px; padding: 6px 0; }
.alt-row {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 0;
border-bottom: 1px dashed #ebeef5;
}
.alt-row:last-of-type { border-bottom: none; }
.alt-loc { flex: 1; color: #409EFF; font-weight: 500; }
.alt-qty { color: #606266; font-size: 12px; }
.alt-hint {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #ebeef5;
color: #E6A23C;
font-size: 12px;
line-height: 1.5;
}
/* 扫码区 */
.scan-section { margin-bottom: 20px; }
.scan-progress {
background: #f5f7fa;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 10px 14px;
margin-bottom: 12px;
}
.progress-info {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
}
.camera-placeholder {
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
display: flex; flex-direction: column; justify-content: center; align-items: center;
color: #909399; margin-bottom: 10px; cursor: pointer;
transition: all 0.3s;
}
.camera-placeholder:active { background: #e6e8eb; }
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
/* 全屏扫码层 */
.fullscreen-scanner-overlay {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: #000; z-index: 9999; display: flex; flex-direction: column;
}
.scanner-header {
height: 60px; display: flex; align-items: center; justify-content: space-between;
padding: 0 15px; background: rgba(0,0,0,0.6); color: #fff;
position: absolute; top: 0; width: 100%; z-index: 10;
}
.scanner-title { font-size: 16px; font-weight: bold; }
.close-btn { background: rgba(255,255,255,0.2); border: none; color: #fff; }
.scanner-body {
flex: 1; width: 100%; position: relative; display: flex;
align-items: center; justify-content: center;
}
:deep(.qr-scanner-container) { width: 100% !important; height: 100% !important; border-radius: 0 !important; }
.scanner-footer {
position: absolute; bottom: 0; width: 100%; padding: 20px;
background: rgba(0,0,0,0.6); color: #fff; text-align: center; z-index: 10;
}
.current-count { color: #67c23a; font-weight: bold; margin-top: 5px; font-size: 16px; }
/* 表单与购物车 */
.cart-section { margin-bottom: 20px; }
.form-section { background: #fff; }
.signature-box {
border: 1px dashed #dcdfe6; border-radius: 6px; height: 100px;
background: #fcfcfc; display: flex; justify-content: center; align-items: center; cursor: pointer;
}
.unsigned-placeholder { display: flex; flex-direction: column; align-items: center; color: #909399; font-size: 13px; }
.signed-img img { max-height: 90px; }
.re-sign-tip { display: block; text-align: center; font-size: 12px; color: #409EFF; margin-top: 2px; }
.bottom-actions { display: flex; justify-content: space-between; margin-top: 30px; }
.bottom-actions .el-button { width: 48%; }
/* 全屏签名弹窗 */
:deep(.fullscreen-signature-dialog .el-dialog__body) { padding: 0; height: 100%; display: flex; }
.signature-wrapper { display: flex; width: 100%; height: 100%; }
.signature-canvas-container { flex: 1; position: relative; background: #fff; overflow: hidden; }
.native-canvas { display: block; width: 100%; height: 100%; touch-action: none; }
.canvas-tip {
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
color: #ccc; font-size: 20px; pointer-events: none; opacity: 0.5; writing-mode: vertical-lr;
}
.signature-sidebar {
width: 120px; background: #333; color: #fff;
display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px 10px;
}
.sidebar-title { writing-mode: vertical-rl; font-size: 18px; letter-spacing: 5px; margin-bottom: 30px; font-weight: bold; }
.sidebar-actions { display: flex; flex-direction: column; gap: 20px; width: 100%; }
.sidebar-actions .el-button { width: 100%; margin: 0; height: 50px; }
@media screen and (max-width: 768px) {
.signature-wrapper { flex-direction: column; }
.signature-canvas-container { flex: 1; }
.canvas-tip { writing-mode: horizontal-tb; bottom: 50%; }
.signature-sidebar { width: 100%; height: auto; flex-direction: row; padding: 10px; justify-content: space-between; }
.sidebar-title { display: none; }
.sidebar-actions { flex-direction: row; width: 100%; gap: 10px; }
.sidebar-actions .el-button { flex: 1; height: 40px; }
}
</style>