Files
KCGL/inventory-web/src/views/outbound/create.vue
yueli fe54d01391 fix(outbound): 序列号物料出库被误判超出计划数量
现象:申请单里同一个物料有 2 个,扫第 2 个时报
「超出计划数量(计划: 1,已扫: 1,本次: 1)」。

根因:计划侧与已扫侧用了**不同的聚合方式**。
  已扫侧 .filter().reduce()  → 把【所有】匹配行加总
  计划侧 .find()             → 只取【第一条】匹配行
序列号物料在申请单里是「一物一行、每行 quantity=1」(因为每个序列号对应
一条独立库存行),于是 planQty 只拿到 1,而 alreadyScanned 是全部之和,
1 + 1 > 1 直接判超发。普通数量制物料计划单只有一行,两边一致,所以一直
没暴露 —— 只有序列号物料会踩中。

同一缺陷还有第二处:unscannedList 逐行比较 planQty 与「累计已扫」,
导致两行各自都被同一笔扫码"满足",扫 1 个就把整个物料判为扫完、未扫清单漏报。

修复:
- validateAgainstPlan:计划侧改为 .filter().reduce() 汇总所有匹配行
- unscannedList:先按「名称+规格」合并计划行,再与同口径汇总的已扫数比较

用申请单 410(Opt1025 两行各 1 个)的真实数据模拟验证:
  修复前 扫第2个 → 超出计划数量(计划:1,已扫:1,本次:1)  ← 与现场报错逐字一致
  修复后 扫第2个 → 通过
  修复后 扫第3个 → 仍被拦(计划:2,已扫:2,本次:1),超发保护未失效
2026-09-15 13:21:36 +08:00

1494 lines
56 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 class="header-price" v-if="totalAmount > 0">
¥{{ totalAmount.toFixed(2) }}
</div>
</div>
</template>
<!-- ★ 出库模式:仅支持按单出库(已移除直接出库) -->
<div class="mode-switch-bar">
<el-tag type="primary" size="large" style="font-size: 14px;">按单出库</el-tag>
<span class="mode-hint">请先选择已审批通过的申请单,再扫码出库</span>
</div>
<!-- ★ 按单出库:审批单选择 -->
<div class="approval-request-select">
<el-select
v-model="selectedRequestId"
placeholder="请选择已审批通过的出库申请单"
filterable
clearable
style="width: 100%"
:loading="requestsLoading"
@change="handleRequestChange"
>
<el-option
v-for="req in approvalRequests"
:key="req.id"
:value="req.id"
: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" />
<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="selectedRequest" class="planned-items-section">
<div class="planned-header">
<span class="planned-title">计划出库清单</span>
<el-tag type="success" size="small">{{ selectedRequest.items?.length || 0 }} 种</el-tag>
</div>
<el-table :data="selectedRequest.items || []" 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="selectedRequest && 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="!selectedRequestId">
<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('outbound_create: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('outbound_create:operation')"
>
<template #prefix>
<el-icon><Scissor /></el-icon>
</template>
<template #append>
<el-button @click="handleManualInput" :disabled="!userStore.hasPermission('outbound_create: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 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 prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column label="库存" width="70" align="center">
<template #default="{row}">
<el-tag :type="row.available_quantity > 0 ? 'success' : 'danger'" size="small">
{{ parseFloat(row.available_quantity) }}
</el-tag>
</template>
</el-table-column>
<el-table-column 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('outbound_create:operation')"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center" fixed="right">
<template #default="{$index}">
<el-button v-if="userStore.hasPermission('outbound_create:operation')" 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" :md="8">
<el-form-item label="出库类型" prop="outbound_type">
<el-select v-model="form.outbound_type" placeholder="请选择类型" style="width: 100%" clearable>
<el-option label="销售出库" value="SALES" />
<el-option label="内部领用" value="USE" />
<el-option label="生产出库" value="PRODUCTION" />
<el-option label="盘亏出库" value="LOSS" />
<el-option label="维修出库" value="REPAIR" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24" :md="8">
<el-form-item label="领用人/客户" prop="consumer_name">
<el-input v-model="form.consumer_name" placeholder="请输入姓名" />
</el-form-item>
</el-col>
<el-col :span="24" :md="8">
<el-form-item label="经办人 (库管)" prop="operator_name">
<el-select
v-model="form.operator_name"
filterable
allow-create
default-first-option
placeholder="选择或输入"
style="width: 100%"
>
<el-option v-for="item in operatorOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注说明" prop="remark" required>
<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('outbound_create: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('outbound_create:operation')" @click="clearAll" icon="Refresh">清空</el-button>
<el-button v-if="userStore.hasPermission('outbound_create: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, 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,
getScanDraft, saveScanDraft, clearScanDraft, getScanDraftOverview,
} from '@/api/outbound'
import { uploadFile } from '@/api/common/upload'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// --- 状态定义 ---
const barcodeInput = ref('')
const cartItems = ref<any[]>([])
const loading = ref(false)
const showCamera = ref(false)
const barcodeRef = ref()
const formRef = ref()
// ★ 双轨制模式
// ★ 已移除直接出库模式,所有出库必须按单出库(通过 selectedRequest 校验)
const approvalRequests = ref<any[]>([])
const selectedRequest = ref<any>(null)
const requestsLoading = ref(false)
// 签名相关
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 operatorOptions = ref<string[]>([])
const form = reactive({
outbound_type: '',
consumer_name: '',
operator_name: '',
remark: ''
})
const rules = {
outbound_type: [{ required: true, message: '请选择出库类型', trigger: 'change' }],
consumer_name: [{ required: true, message: '请输入领用人姓名', trigger: 'blur' }],
operator_name: [{ required: true, message: '请指定操作员', trigger: 'change' }],
remark: [{ required: true, message: '请填写备注/出库原因', trigger: 'blur' }]
}
// 计算总金额
const totalAmount = computed(() => {
return cartItems.value.reduce((sum, item) => sum + (item.price * item.out_quantity), 0)
})
// ★ 双轨制 computed
const selectedRequestId = computed({
get: () => selectedRequest.value?.id ?? null,
set: (val) => {
if (!val) {
selectedRequest.value = null
} else {
selectedRequest.value = approvalRequests.value.find(r => r.id === val) ?? null
}
}
})
const plannedItems = computed(() => selectedRequest.value?.items ?? [])
// ============================================================================
// ★ 备选库位
//
// 背景:申请单已把货预占在某个库位,工人到现场发现该库位进不去/找不到,
// 需要改扫同物料的其它批次(后端执行端已支持「物理覆盖」,按 base_id 校验
// 身份、允许换批次)。但改造前系统不告诉他还有哪些库位有货,只能凭记忆翻。
//
// 这里按需查询:点击图标才发请求,避免计划清单渲染时打出一片请求。
// 后端按 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 {
// ★ 带上本单 id:本单锁定的批次实时可用量为 0,不回加的话会从列表里消失
const res: any = await getStockAlternatives(
row.base_id,
{ stock_id: row.stock_id, source_table: row.source_table },
selectedRequest.value?.id,
'outbound'
)
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(() => {
// ★ 先按「名称+规格」把计划行**合并**再比较。
// 序列号物料是一物一行(每行 quantity=1),若逐行比较,同一笔扫码会让
// 每一行都算作"已满足"(planQty 1 - scannedQty 1 = 0),于是扫了 1 个
// 就把整个物料判为扫完,未扫清单漏报。
const planGroups = new Map<string, { name: string; spec: string; planQty: number }>()
for (const plan of plannedItems.value) {
const name = (plan.name || '').trim()
const spec = (plan.spec_model || '').trim()
const key = `${name}|${spec}`
const g = planGroups.get(key) || { name, spec, planQty: 0 }
g.planQty += Number(plan.quantity) || 0
planGroups.set(key, g)
}
// 已扫数量同样按「名称+规格」汇总
const scannedByKey = new Map<string, number>()
for (const ci of cartItems.value) {
const key = `${(ci.name || '').trim()}|${(ci.spec_model || ci.standard || '').trim()}`
scannedByKey.set(key, (scannedByKey.get(key) || 0) + (Number(ci.out_quantity) || 0))
}
return Array.from(planGroups.entries()).map(([key, g]) => {
const scannedQty = scannedByKey.get(key) || 0
return {
name: g.name,
spec: g.spec,
planQty: g.planQty,
scannedQty,
remaining: Math.max(0, g.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 getApprovalRequestList({ status: 1, page: 1, pageSize: 100 })
approvalRequests.value = res.data?.items || []
await loadDraftOverview() // ★ 一并取草稿进度,用于下拉徽标
} catch (e) {
console.error('加载审批单列表失败', e)
} finally {
requestsLoading.value = false
}
}
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 = ''
} else {
selectedRequest.value = approvalRequests.value.find(r => r.id === val) ?? null
// ★ 按单出库:自动将申请单的申请原因填入备注,用户可修改
if (selectedRequest.value?.remark) {
form.remark = selectedRequest.value.remark
}
// ★ 自动带出申请时确定的出库类型(库管无需再选);旧单无类型则留待手动选择
if (selectedRequest.value?.outbound_type) {
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;缺失字段补默认值,
// 避免购物车出现 NaN(NaN 会在提交时被兜底成 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(requestId)
} catch (e) {
console.warn('恢复草稿失败', e)
}
}
/**
* 用实时库存刷新购物车行的「库存」列。
*
* 草稿里的 available_quantity 是扫描那一刻的快照,恢复时可能已过期
* (期间别人出库/借出/报废都会消耗可用量)。这里按 base_id 查一次实时值,
* 避免工人基于陈旧数字扫满、到提交时才发现不够。
*
* 复用 /alternatives 端点 —— 它按 base_id 返回各库存行的实时可用量。
* ★ 传入本单 id 后,返回的 available_quantity 是**有效可用量**
* (实时值 + 本单预占),故本单锁定行不会因实时值为 0 而被过滤掉,
* 下面的「已扫数量 > 最新可用量」判定也不会误报。
* 失败不阻断:拿不到实时值就沿用草稿快照,至少不影响继续作业。
*/
const refreshStockFromDraft = async (requestId?: number | null) => {
const plan = selectedRequest.value?.items || []
const baseIds = [...new Set(plan.map((p: any) => p.base_id).filter(Boolean))]
if (!baseIds.length) return
// ★ 显式透传单据 id(而非依赖 selectedRequest 已解析),保证预占回加一定生效
const rid = requestId ?? selectedRequest.value?.id
try {
const results = await Promise.all(
(baseIds as number[]).map(bid =>
getStockAlternatives(bid, undefined, rid, 'outbound').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()
// ★ 计划侧必须**汇总所有匹配行**,不能只取第一行。
// 序列号物料在申请单里是「一物一行、每行 quantity=1」,用 .find() 只拿到 1,
// 而下面的已扫侧是 .reduce() 求总和 —— 两边聚合口径不一致,
// 扫到第 2 个就会误报「超出计划数量(计划: 1,已扫: 1,本次: 1)」。
const matchedPlans = plannedItems.value.filter((plan: any) => {
const planName = (plan.name || '').trim()
const planSpec = (plan.spec_model || '').trim()
return planName === normalizedName && planSpec === normalizedSpec
})
if (!matchedPlans.length) {
return `该物料【${normalizedName} × ${normalizedSpec}】不在计划清单中,请检查`
}
const planQty = matchedPlans.reduce((sum: number, plan: any) => sum + (Number(plan.quantity) || 0), 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 // 通过
}
// --- 初始化 ---
onMounted(() => {
// 加载已审批通过的申请单列表
loadApprovalRequests()
if (userStore.username) {
form.operator_name = userStore.username
operatorOptions.value.push(userStore.username)
}
loadHistoryOperators()
})
const loadHistoryOperators = async () => {
try {
const res = await getOutboundList({ page: 1, limit: 50 })
if (res.data && res.data.items) {
const names = new Set<string>()
if (userStore.username) names.add(userStore.username)
res.data.items.forEach((group: any) => {
if (group.operator_name) names.add(group.operator_name)
})
operatorOptions.value = Array.from(names)
}
} catch (e) {
console.error('加载历史操作员失败', e)
}
}
// --- 核心扫码逻辑 (适配 QrScanner 组件) ---
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('outbound_create:operation')) {
ElMessage.warning('无操作权限')
return
}
const code = barcodeInput.value.trim()
if (!code) return
// ★ 按单出库模式:必须先选择申请单
if (!selectedRequest.value) {
ElMessage.warning('请先选择要出库的审批申请单')
return
}
try {
loading.value = true
// 1. 检查购物车重复(直接模式走旧的追加逻辑,按单模式也复用但后续会校验)
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
}
// 2. 调用 API 查询
// ★ 带上本单 id:申请阶段已把本单要的货从 available_quantity 扣掉(预占),
// 不回加的话,被本单占满的行会显示 0,工人根本扫不进去。
const res = await getStockByBarcode(code, selectedRequest.value?.id, 'outbound')
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: parseFloat(item.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
// 注意:全屏扫码模式下,我们不需要 refocus input,因为用户还在看摄像头
// 只有在非全屏模式下才 focus
if (!showCamera.value) {
nextTick(() => { barcodeRef.value?.focus() })
}
}
}
const removeFromCart = (index: number) => {
if (!userStore.hasPermission('outbound_create:operation')) {
ElMessage.warning('无操作权限')
return
}
cartItems.value.splice(index, 1)
}
const clearAll = () => {
if (!userStore.hasPermission('outbound_create:operation')) {
ElMessage.warning('无操作权限')
return
}
ElMessageBox.confirm('确定清空所有已选商品吗?', '提示', { type: 'warning' })
.then(() => {
cartItems.value = []
form.consumer_name = ''
form.remark = ''
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
// ★ 按单模式:仅清空购物车,保留申请单选择
// 用户显式清空 = 放弃本次作业 → 同步清除草稿与徽标
const rid = activeRequestId.value
if (rid) {
clearScanDraft('outbound', rid).catch(() => {})
delete draftProgress.value[rid]
}
})
}
// --- 提交逻辑 ---
const submitForm = async () => {
if (!userStore.hasPermission('outbound_create:operation')) {
ElMessage.warning('无操作权限')
return
}
if (!formRef.value) return
if (cartItems.value.length === 0) return ElMessage.warning('请先添加商品')
// ★ 按单出库:提交前必须已选择审批申请单
if (!selectedRequest.value) {
ElMessage.warning('请先选择要出库的审批申请单')
return
}
// 记下单据ID:清空 cartItems 后 selectedRequest 可能被重置,草稿清除需要它
const submittedRequestId = selectedRequest.value.id
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return
if (!signatureFile.value) {
ElMessage.error('请进行电子签名')
return
}
try {
loading.value = true
// 1. 上传签名
const uploadRes = await uploadFile(signatureFile.value)
const signatureUrl = uploadRes.data.url
// 2. 核心保护:坚决杜绝 undefined、null 和 0
const itemsPayload = cartItems.value.map(item => {
// 强制确保出库数量是一个大于 0 的有效数字
let safeQuantity = Number(item.out_quantity)
if (isNaN(safeQuantity) || safeQuantity <= 0) {
safeQuantity = 1 // 兜底:只要扫了码,最少出 1 个
}
return {
stock_id: item.id || 0,
source_table: item.source_table || '',
// 如果原数据 sku 是空,强制塞一个默认字符串,绝不传空值给后端引发 None 报错
sku: item.sku ? String(item.sku) : (item.barcode ? String(item.barcode) : 'NO_SKU'),
barcode: item.barcode ? String(item.barcode) : '',
quantity: safeQuantity,
price: item.price ? Number(item.price) : 0
}
})
if (itemsPayload.length === 0) {
ElMessage.warning('请至少扫描一件物料后再提交出库')
return
}
// 3. 组装发给后端的包
const submitPayload: any = {
outbound_type: form.outbound_type,
request_id: selectedRequest.value ? selectedRequest.value.id : null,
consumer_name: form.consumer_name,
operator_name: form.operator_name,
remark: form.remark,
signature_path: signatureUrl,
items: itemsPayload
}
// 打印在前端控制台,你可以按 F12 在 Console 里核对这把"铁证"
console.log('准备提交给后端的最终数据:', JSON.parse(JSON.stringify(submitPayload)))
// 4. 发送请求
await submitOutbound(submitPayload)
ElMessage.success('出库成功')
// ★ 提交成功 → 清除该单据的草稿,避免下次打开时恢复出已提交的内容
if (submittedRequestId) {
clearScanDraft('outbound', submittedRequestId).catch(() => {})
delete draftProgress.value[submittedRequestId] // 同步移除下拉徽标
}
// 5. 成功后重置页面
cartItems.value = []
form.consumer_name = ''
form.remark = ''
signatureFile.value = null
// 根据你的项目实际变量重置签名组件,如果没有这句可以删掉
if (typeof signaturePreviewUrl !== 'undefined') {
signaturePreviewUrl.value = ''
}
} catch (error) {
console.error('出库报错:', error)
ElMessage.error('提交失败,请检查数据')
} finally {
loading.value = false
}
})
}
// --- 签名逻辑 (Canvas) ---
const openSignatureDialog = () => {
if (!userStore.hasPermission('outbound_create: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 }
onUnmounted(() => {
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
if (draftTimer) clearTimeout(draftTimer)
// 其余清理(定时检测、事件监听)见文件末尾的统一 onUnmounted
})
// ★ 离开页面前:立即存草稿(防抖中未落盘的内容会丢)
//
// 只有清单非空时才提示 —— 空清单离开是正常操作,弹窗只会造成干扰。
// 草稿已在后端,所以提示文案是"可恢复"而非"会丢失",语气相应放松。
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) { /* 尽力而为,失败不影响 */ }
}
// ============================================================================
// ★ 单据失效检测
//
// 场景:库管正在扫码,管理员/申请人把这张单撤回了(或已执行完)。
// 若不检测,库管会一直扫到提交时才发现单据已作废 —— 白扫一场。
//
// 检测方式:重新拉一次「已通过(status=1)」列表,看当前单据是否还在其中。
// 不在 → 说明已被撤回/驳回/执行,立即告知并清空界面。
// 复用现有列表接口,无需新增端点。
//
// 触发时机:
// · 页面重新可见时(PDA 熄屏唤醒、切回浏览器)
// · 每 60 秒一次(仅在购物车非空、即正在作业时)
// ============================================================================
const checkRequestStillValid = async () => {
const rid = activeRequestId.value
if (!rid || cartItems.value.length === 0) return // 没在作业,不打扰
try {
const res: any = await getApprovalRequestList({ status: 1, page: 1, pageSize: 100 })
const stillValid = (res.data?.items || []).some((r: any) => r.id === rid)
if (stillValid) return
// 单据已不在「已通过」列表中 → 失效
const no = selectedRequest.value?.request_no || rid
cartItems.value = []
selectedRequest.value = null
activeRequestId.value = null
ElMessageBox.alert(
`申请单【${no}】已被撤回或已执行,不能再继续出库。\n\n` +
`本次已扫的内容已清空(草稿也已随之清除)。\n` +
`如需出库,请重新提交申请。`,
'⚠️ 申请单已失效',
{ type: 'warning', confirmButtonText: '知道了' }
).catch(() => {})
} catch (e) {
// 网络异常时不打断作业 —— 后端在提交时仍会做最终校验
console.warn('单据状态检测失败', e)
}
}
// 页面重新可见时检测(PDA 熄屏唤醒、切回标签页)
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') checkRequestStillValid()
}
let validCheckTimer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
window.addEventListener('beforeunload', handleBeforeUnload)
document.addEventListener('visibilitychange', onVisibilityChange)
validCheckTimer = setInterval(checkRequestStillValid, 60000)
})
onUnmounted(() => {
if (validCheckTimer) clearInterval(validCheckTimer)
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('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; }
.header-price { font-size: 18px; color: #F56C6C; font-weight: bold; }
/* ★ 双轨制模式切换 */
.mode-switch-bar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
padding: 12px 16px;
background: #f5f7fa;
border-radius: 8px;
border: 1px solid #e4e7ed;
}
.mode-hint { color: #909399; font-size: 13px; }
/* ★ 审批单选择 */
.approval-request-select { margin-bottom: 16px; }
.select-tip { margin: 6px 0 0 0; color: #909399; font-size: 12px; }
/* ★ 计划清单 */
.planned-items-section {
margin-bottom: 16px;
padding: 12px;
background: #f0f9eb;
border: 1px solid #e1f3d8;
border-radius: 8px;
}
.planned-header {
display: flex;
align-items: center;
justify-content: space-between;
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; }
/* 图标放大到 18px,并加圆形底衬,在触屏上更易命中 */
.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;
}
/* 强制子组件(QrScanner)填满容器 */
: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>