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 头,可能失败,但防抖保存已覆盖
绝大部分内容)。
This commit is contained in:
yueli
2026-09-10 17:21:24 +08:00
parent ec66c33b06
commit a4a9afb6db
3 changed files with 501 additions and 5 deletions

View File

@ -239,3 +239,55 @@ export function getMyRequests(params: {
params
})
}
// ==========================================
// 扫码草稿(出库/借库作业中途暂停用)
// ==========================================
export interface ScanDraftItem {
stock_id: number
source_table: string
sku?: string
quantity: number
}
/** 读取草稿:返回该用户在这张单上已扫的内容 */
export function getScanDraft(bizType: 'outbound' | 'borrow', requestId: number) {
return request({
url: '/v1/scan-draft',
method: 'get',
params: { biz_type: bizType, request_id: requestId }
})
}
/** 保存草稿:全量覆盖(提交的 items 即当前完整清单) */
export function saveScanDraft(
bizType: 'outbound' | 'borrow',
requestId: number,
items: ScanDraftItem[],
requestNo?: string
) {
return request({
url: '/v1/scan-draft',
method: 'post',
data: { biz_type: bizType, request_id: requestId, request_no: requestNo || '', items }
})
}
/** 清除草稿:提交成功后调用 */
export function clearScanDraft(bizType: 'outbound' | 'borrow', requestId: number) {
return request({
url: '/v1/scan-draft',
method: 'delete',
params: { biz_type: bizType, request_id: requestId }
})
}
/** 草稿概览:供单据下拉显示「已扫 N 项」进度徽标 */
export function getScanDraftOverview(bizType: 'outbound' | 'borrow') {
return request({
url: '/v1/scan-draft/overview',
method: 'get',
params: { biz_type: bizType }
})
}

View File

@ -39,6 +39,10 @@
:label="req.request_no"
>
<span>{{ req.request_no }}</span>
<!-- 草稿进度徽标一眼看出哪张单之前扫到一半不必逐个点开试 -->
<el-tag v-if="draftProgress[req.id]" type="warning" size="small" effect="dark" style="margin-left:6px">
已扫 {{ draftProgress[req.id] }}
</el-tag>
<el-divider direction="vertical" />
<span>{{ req.applicant_name || '未知申请人' }}</span>
<el-divider direction="vertical" />
@ -372,10 +376,14 @@
<script setup lang="ts">
import { ref, reactive, nextTick, onUnmounted, onMounted, 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, submitOutbound, getOutboundList, getApprovalRequestList, getStockAlternatives } from '@/api/outbound'
import {
getStockByBarcode, submitOutbound, getOutboundList, getApprovalRequestList, getStockAlternatives,
getScanDraft, saveScanDraft, clearScanDraft, getScanDraftOverview,
} from '@/api/outbound'
import { uploadFile } from '@/api/common/upload'
import { useUserStore } from '@/stores/user'
@ -516,6 +524,7 @@ const loadApprovalRequests = async () => {
try {
const res: any = await getApprovalRequestList({ status: 1, page: 1, pageSize: 100 })
approvalRequests.value = res.data?.items || []
await loadDraftOverview() // ★ 一并取草稿进度,用于下拉徽标
} catch (e) {
console.error('加载审批单列表失败', e)
} finally {
@ -523,7 +532,12 @@ const loadApprovalRequests = async () => {
}
}
const handleRequestChange = (val: number | null) => {
const handleRequestChange = async (val: number | null) => {
// ★ 关键:此刻 selectedRequest 已被 v-model 的 setter 改成了新单,
// 所以必须用 activeRequestId 拿到「刚离开的那张单」来保存。
const leavingId = activeRequestId.value
await saveDraftNow(leavingId)
if (!val) {
selectedRequest.value = null
form.remark = ''
@ -538,10 +552,187 @@ const handleRequestChange = (val: number | null) => {
form.outbound_type = selectedRequest.value.outbound_type
}
}
// 切换申请单时清空购物车,防止已扫物品与新单据混淆
cartItems.value = []
signatureFile.value = null
signaturePreviewUrl.value = ''
activeRequestId.value = val // ★ 切换到新单,后续保存以它为准
// ★ 载入目标单据的草稿(若之前扫到一半,这里自动恢复)
if (val) await restoreDraft(val)
}
// ============================================================================
// ★ 扫码草稿
//
// 场景:一张单几十项,扫到一半被更紧急的单打断,回来还要接着扫。
// 库存在申请审批通过时已**预占**,暂停期间不会被他人抢走,因此草稿只需
// 记住「扫到哪了」,不涉及任何库存操作 —— 即使草稿丢了也只是重扫,
// 不会造成库存错乱。
//
// 保存时机:扫码后防抖 800ms 自动存;切换单据/离开页面时立即存。
// 隔离:后端按 (user_id, 单据ID) 隔离,一人一单互不影响。
// ============================================================================
let draftTimer: ReturnType<typeof setTimeout> | null = null
// 各单据的草稿进度 { request_id: 已扫项数 },用于下拉徽标
const draftProgress = ref<Record<number, number>>({})
const loadDraftOverview = async () => {
try {
const res: any = await getScanDraftOverview('outbound')
const map: Record<number, number> = {}
for (const d of res?.data?.drafts || []) {
map[d.request_id] = d.item_count
}
draftProgress.value = map
} catch (e) {
console.warn('加载草稿概览失败', e)
}
}
// ★ 必须保存购物车行的**全部展示字段**,不能只存定位信息。
// 原先只存 (stock_id, source_table, sku, quantity),恢复后名称/规格/库存
// 都是空的、出库数显示 NaN —— 因为购物车表格绑定的是 name / spec_model /
// available_quantity / out_quantity而这些字段根本没被存下来。
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 —— 用于精确知道「哪张单的草稿需要存」
//
// ★ 为什么需要它v-model="selectedRequestId" 的 computed setter 会**先于**
// @change 把 selectedRequest 改成新单据。若在 handleRequestChange 里读
// selectedRequest.value拿到的已经是新单 —— 会把 A 的内容存到 B 名下。
// 用一个独立的 ref 记录「当前界面上显示的是哪张单」,保存时以此为准。
const activeRequestId = ref<number | null>(null)
// 立即保存(切换单据、离开页面、提交前调用)
//
// 显式接收 requestId切换场景下必须传「旧单ID」不能依赖 selectedRequest。
const saveDraftNow = async (requestId?: number | null) => {
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
const rid = requestId ?? activeRequestId.value
if (!rid) return
const items = draftPayload()
// ★ 空清单**不清除草稿**。
// 原先"空则清除"是为了处理用户主动清空列表的情况,但它会在切换单据时
// 误删目标单的旧草稿(切换瞬间购物车必然为空)—— 这正是"切回来就没了"
// 的直接原因。现在空清单只是不保存,清理由「提交成功」或用户显式清空触发。
if (items.length === 0) return
try {
await saveScanDraft('outbound', rid, items, selectedRequest.value?.request_no)
draftProgress.value[rid] = items.length
} catch (e) {
// 草稿保存失败不阻断作业:工人手上还在扫,报错反而干扰
console.warn('保存草稿失败', e)
}
}
// 防抖保存(扫码后调用)
const scheduleSaveDraft = () => {
if (draftTimer) clearTimeout(draftTimer)
draftTimer = setTimeout(() => saveDraftNow(), 800)
}
// 恢复草稿到购物车
//
// 草稿存的是完整购物车快照(含名称/规格/库位等展示字段),因此直接还原即可,
// 无需回查申请单的 items_json —— 历史单据的 items_json 不含 stock_id
// 回查反而会错配。
const restoreDraft = async (requestId: number) => {
try {
const res: any = await getScanDraft('outbound', 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)
}
}
/**
* 用实时库存刷新购物车行的「库存」列。
*
* 草稿里的 available_quantity 是扫描那一刻的快照,恢复时可能已过期
* (期间别人出库/借出/报废都会消耗可用量)。这里按 base_id 查一次实时
* 可用量,避免工人基于陈旧数字扫满、到提交时才发现不够。
*
* 复用 /alternatives 端点 —— 它本来就按 base_id 返回各库存行的实时可用量。
* 失败不阻断:拿不到实时值就沿用草稿快照,至少不影响继续作业。
*/
const refreshStockFromDraft = async () => {
const plan = selectedRequest.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) // 失败沿用草稿快照
}
}
// ★ 按单出库模式:校验扫码是否在计划内
@ -710,6 +901,7 @@ const handleManualInput = async () => {
out_quantity: 1,
price: parseFloat(item.price || 0)
})
scheduleSaveDraft() // ★ 自动存草稿(防抖),中途离开可恢复
ElMessage.success(`添加成功: ${item.name}`)
if (navigator.vibrate) navigator.vibrate(100)
barcodeInput.value = ''
@ -753,6 +945,12 @@ const clearAll = () => {
signaturePreviewUrl.value = ''
barcodeInput.value = ''
// ★ 按单模式:仅清空购物车,保留申请单选择
// 用户显式清空 = 放弃本次作业 → 同步清除草稿与徽标
const rid = activeRequestId.value
if (rid) {
clearScanDraft('outbound', rid).catch(() => {})
delete draftProgress.value[rid]
}
})
}
@ -769,6 +967,8 @@ const submitForm = async () => {
ElMessage.warning('请先选择要出库的审批申请单')
return
}
// 记下单据ID清空 cartItems 后 selectedRequest 可能被重置,草稿清除需要它
const submittedRequestId = selectedRequest.value.id
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return
@ -828,6 +1028,12 @@ const submitForm = async () => {
ElMessage.success('出库成功')
// ★ 提交成功 → 清除该单据的草稿,避免下次打开时恢复出已提交的内容
if (submittedRequestId) {
clearScanDraft('outbound', submittedRequestId).catch(() => {})
delete draftProgress.value[submittedRequestId] // 同步移除下拉徽标
}
// 5. 成功后重置页面
cartItems.value = []
form.consumer_name = ''
@ -926,7 +1132,57 @@ const handleSignCancel = () => { showSignatureDialog.value = false }
onUnmounted(() => {
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
if (draftTimer) clearTimeout(draftTimer)
})
// ★ 离开页面前:立即存草稿(防抖中未落盘的内容会丢)
//
// 只有清单非空时才提示 —— 空清单离开是正常操作,弹窗只会造成干扰。
// 草稿已在后端,所以提示文案是"可恢复"而非"会丢失",语气相应放松。
onBeforeRouteLeave(async (_to, _from, next) => {
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
const hasItems = cartItems.value.length > 0 && selectedRequest.value?.id
if (hasItems) {
await saveDraftNow() // 先确保落盘,再询问
try {
await ElMessageBox.confirm(
`当前单据【${selectedRequest.value.request_no}】已扫 ${cartItems.value.length} 项尚未提交。\n\n` +
`已自动保存为草稿,下次选择该单据时可继续扫码。\n确认离开吗`,
'离开确认',
{ confirmButtonText: '离开', cancelButtonText: '留下继续', type: 'warning' }
)
} catch (e) {
return next(false) // 用户选择留下
}
}
next()
})
// 刷新/关闭浏览器时同步存草稿
//
// 注意:不能用异步请求(浏览器不会等待),改用 sendBeacon 在卸载期间
// 投递一个"尽力而为"的保存。失败也无妨 —— 扫码过程中的防抖保存已经
// 落盘了绝大部分内容,这里只补最后 800ms 的差量。
const handleBeforeUnload = () => {
const req = selectedRequest.value
if (!req?.id || cartItems.value.length === 0) return
try {
const token = localStorage.getItem('token') || ''
const blob = new Blob([JSON.stringify({
biz_type: 'outbound', request_id: req.id, request_no: req.request_no,
items: draftPayload(),
})], { type: 'application/json' })
// sendBeacon 无法自定义 header故用查询参数带上 token仅用于此处的
// 非敏感草稿保存;后端仍以 JWT 校验身份,缺失时该次保存被忽略)
navigator.sendBeacon?.(
`/api/v1/scan-draft?token=${encodeURIComponent(token)}`, blob
)
} catch (e) { /* 尽力而为,失败不影响 */ }
}
onMounted(() => { window.addEventListener('beforeunload', handleBeforeUnload) })
onUnmounted(() => { window.removeEventListener('beforeunload', handleBeforeUnload) })
</script>
<style scoped>

View File

@ -355,10 +355,11 @@
<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 } from '@/api/outbound'
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'
@ -486,7 +487,12 @@ const loadApprovalRequests = async () => {
}
// ★ 切换审批单时:带出申请时填写的归还日期/长期借用,并清空购物车和签名,防止跨单据污染
const handleApprovalChange = (val: number | null) => {
const handleApprovalChange = async (val: number | null) => {
// ★ 关键selectedApproval 已被 computed 更新为新单,
// 故用 activeApprovalId 拿到「刚离开的那张单」来保存
const leavingId = activeApprovalId.value
await saveDraftNow(leavingId)
if (!val) {
selectedApprovalId.value = null
}
@ -510,6 +516,140 @@ const handleApprovalChange = (val: number | null) => {
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)
}
}
// ★ 扫码校验:比对扫描物料是否在审批计划清单内,且累计数量不超过审批上限
@ -679,6 +819,7 @@ const handleManualInput = async () => {
out_quantity: 1,
price: 0
})
scheduleSaveDraft() // ★ 自动存草稿(防抖),中途离开可恢复
ElMessage.success(`添加成功: ${item.name}`)
if (navigator.vibrate) navigator.vibrate(100)
barcodeInput.value = ''
@ -734,6 +875,8 @@ const submitForm = async () => {
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) {
@ -782,6 +925,12 @@ const submitForm = async () => {
})
ElMessage.success('借用成功')
// ★ 提交成功 → 清除该单据的草稿,避免下次打开恢复出已提交的内容
if (submittedRequestId) {
clearScanDraft('borrow', submittedRequestId).catch(() => {})
}
cartItems.value = []
form.borrower_name = ''
form.expected_return_time = ''
@ -883,7 +1032,46 @@ onMounted(() => {
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>