2026-02-04 17:22:20 +08:00
|
|
|
|
<template>
|
2026-02-06 11:28:48 +08:00
|
|
|
|
<div class="app-container mobile-optimized">
|
|
|
|
|
|
<el-card class="box-card" shadow="never">
|
2026-02-04 17:22:20 +08:00
|
|
|
|
<template #header>
|
|
|
|
|
|
<div class="card-header">
|
2026-02-06 11:28:48 +08:00
|
|
|
|
<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) }}
|
2026-02-05 16:54:11 +08:00
|
|
|
|
</div>
|
2026-02-04 17:22:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
2026-02-06 11:28:48 +08:00
|
|
|
|
<div class="scan-section">
|
|
|
|
|
|
|
2026-02-27 13:54:06 +08:00
|
|
|
|
<div v-if="userStore.hasPermission('outbound_create:operation')" class="camera-placeholder" @click="showCamera = true">
|
2026-02-06 11:28:48 +08:00
|
|
|
|
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
|
2026-02-09 14:48:09 +08:00
|
|
|
|
<span class="text">点击开启全屏扫码</span>
|
2026-02-05 15:51:19 +08:00
|
|
|
|
</div>
|
2026-02-27 13:54:06 +08:00
|
|
|
|
<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>
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
|
|
|
|
|
<div class="input-box">
|
|
|
|
|
|
<el-input
|
|
|
|
|
|
v-model="barcodeInput"
|
2026-02-06 11:28:48 +08:00
|
|
|
|
placeholder="扫描或输入条码回车"
|
|
|
|
|
|
@keyup.enter="handleManualInput"
|
2026-02-04 17:22:20 +08:00
|
|
|
|
clearable
|
|
|
|
|
|
ref="barcodeRef"
|
2026-02-06 11:28:48 +08:00
|
|
|
|
size="large"
|
2026-02-27 13:54:06 +08:00
|
|
|
|
:disabled="!userStore.hasPermission('outbound_create:operation')"
|
2026-02-04 17:22:20 +08:00
|
|
|
|
>
|
|
|
|
|
|
<template #prefix>
|
|
|
|
|
|
<el-icon><Scissor /></el-icon>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template #append>
|
2026-02-27 13:54:06 +08:00
|
|
|
|
<el-button @click="handleManualInput" :disabled="!userStore.hasPermission('outbound_create:operation')">添加</el-button>
|
2026-02-04 17:22:20 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</el-input>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-06 11:28:48 +08:00
|
|
|
|
<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"
|
2026-02-27 13:54:06 +08:00
|
|
|
|
:disabled="!userStore.hasPermission('outbound_create:operation')"
|
2026-02-06 11:28:48 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
|
|
|
|
|
|
<el-table-column label="操作" width="60" align="center" fixed="right">
|
|
|
|
|
|
<template #default="{$index}">
|
2026-02-27 13:54:06 +08:00
|
|
|
|
<el-button v-if="userStore.hasPermission('outbound_create:operation')" type="danger" icon="Delete" circle size="small" @click="removeFromCart($index)" />
|
2026-02-06 11:28:48 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
</el-table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<el-empty v-else description="暂无商品,请扫码添加" :image-size="80" />
|
2026-02-05 16:54:11 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-06 11:28:48 +08:00
|
|
|
|
<div v-if="cartItems.length > 0" class="form-section">
|
|
|
|
|
|
<el-divider content-position="left">出库单据信息</el-divider>
|
2026-02-05 16:54:11 +08:00
|
|
|
|
|
|
|
|
|
|
<el-form :model="form" ref="formRef" :rules="rules" label-position="top">
|
2026-02-06 11:28:48 +08:00
|
|
|
|
<el-row :gutter="15">
|
|
|
|
|
|
<el-col :span="24" :md="8">
|
2026-02-04 17:22:20 +08:00
|
|
|
|
<el-form-item label="出库类型" prop="outbound_type">
|
2026-02-06 11:28:48 +08:00
|
|
|
|
<el-select v-model="form.outbound_type" placeholder="请选择类型" style="width: 100%">
|
2026-02-04 17:22:20 +08:00
|
|
|
|
<el-option label="销售出库" value="SALES" />
|
|
|
|
|
|
<el-option label="内部领用" value="USE" />
|
2026-03-19 17:13:24 +08:00
|
|
|
|
<el-option label="生产出库" value="PRODUCTION" />
|
2026-03-19 15:26:40 +08:00
|
|
|
|
<el-option label="盘亏出库" value="LOSS" />
|
2026-02-04 17:22:20 +08:00
|
|
|
|
</el-select>
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
</el-col>
|
2026-02-06 11:28:48 +08:00
|
|
|
|
|
|
|
|
|
|
<el-col :span="24" :md="8">
|
|
|
|
|
|
<el-form-item label="领用人/客户" prop="consumer_name">
|
2026-02-05 10:20:52 +08:00
|
|
|
|
<el-input v-model="form.consumer_name" placeholder="请输入姓名" />
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
</el-col>
|
2026-02-06 11:28:48 +08:00
|
|
|
|
|
|
|
|
|
|
<el-col :span="24" :md="8">
|
|
|
|
|
|
<el-form-item label="经办人 (库管)" prop="operator_name">
|
2026-02-05 10:20:52 +08:00
|
|
|
|
<el-select
|
|
|
|
|
|
v-model="form.operator_name"
|
|
|
|
|
|
filterable
|
|
|
|
|
|
allow-create
|
|
|
|
|
|
default-first-option
|
2026-02-06 11:28:48 +08:00
|
|
|
|
placeholder="选择或输入"
|
2026-02-05 10:20:52 +08:00
|
|
|
|
style="width: 100%"
|
|
|
|
|
|
>
|
2026-02-06 11:28:48 +08:00
|
|
|
|
<el-option v-for="item in operatorOptions" :key="item" :label="item" :value="item" />
|
2026-02-05 10:20:52 +08:00
|
|
|
|
</el-select>
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
</el-col>
|
|
|
|
|
|
</el-row>
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
2026-02-06 11:28:48 +08:00
|
|
|
|
<el-form-item label="备注说明" prop="remark">
|
|
|
|
|
|
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="可选填" />
|
2026-02-04 17:22:20 +08:00
|
|
|
|
</el-form-item>
|
|
|
|
|
|
|
2026-02-06 11:28:48 +08:00
|
|
|
|
<el-form-item label="电子签名确认" required>
|
2026-02-27 13:54:06 +08:00
|
|
|
|
<div class="signature-box" @click="openSignatureDialog" v-if="userStore.hasPermission('outbound_create:operation')">
|
2026-02-06 11:28:48 +08:00
|
|
|
|
<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>
|
2026-02-05 15:51:19 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-27 13:54:06 +08:00
|
|
|
|
<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>
|
2026-02-04 17:22:20 +08:00
|
|
|
|
</el-form-item>
|
|
|
|
|
|
|
2026-02-06 11:28:48 +08:00
|
|
|
|
<div class="bottom-actions">
|
2026-02-27 13:54:06 +08:00
|
|
|
|
<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">
|
2026-02-06 11:28:48 +08:00
|
|
|
|
确认出库
|
|
|
|
|
|
</el-button>
|
2026-02-04 17:22:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</el-form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</el-card>
|
2026-02-05 15:51:19 +08:00
|
|
|
|
|
2026-02-09 14:48:09 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-02-05 15:51:19 +08:00
|
|
|
|
<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>
|
2026-02-06 11:28:48 +08:00
|
|
|
|
<div class="canvas-tip">请在此区域横屏书写</div>
|
2026-02-05 15:51:19 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="signature-sidebar">
|
2026-02-06 11:28:48 +08:00
|
|
|
|
<div class="sidebar-title">电子签名</div>
|
2026-02-05 15:51:19 +08:00
|
|
|
|
<div class="sidebar-actions">
|
2026-02-06 11:28:48 +08:00
|
|
|
|
<el-button type="warning" @click="clearCanvas">重写</el-button>
|
2026-02-05 15:51:19 +08:00
|
|
|
|
<el-button @click="handleSignCancel">取消</el-button>
|
2026-02-06 11:28:48 +08:00
|
|
|
|
<el-button type="success" class="confirm-btn" @click="handleSignConfirm">确认使用</el-button>
|
2026-02-05 15:51:19 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</el-dialog>
|
|
|
|
|
|
|
2026-02-04 17:22:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-02-05 15:51:19 +08:00
|
|
|
|
import { ref, reactive, nextTick, onUnmounted, onMounted, computed } from 'vue'
|
2026-02-05 16:54:11 +08:00
|
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
2026-02-06 11:28:48 +08:00
|
|
|
|
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
|
|
|
|
|
|
import QrScanner from '@/components/QrScanner/index.vue'
|
2026-02-05 16:54:11 +08:00
|
|
|
|
import { getStockByBarcode, submitOutbound, getOutboundList } from '@/api/outbound'
|
2026-02-04 17:22:20 +08:00
|
|
|
|
import { uploadFile } from '@/api/common/upload'
|
2026-02-05 10:20:52 +08:00
|
|
|
|
import { useUserStore } from '@/stores/user'
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
2026-02-27 13:54:06 +08:00
|
|
|
|
const userStore = useUserStore()
|
|
|
|
|
|
|
2026-02-04 17:22:20 +08:00
|
|
|
|
// --- 状态定义 ---
|
|
|
|
|
|
const barcodeInput = ref('')
|
2026-02-06 11:28:48 +08:00
|
|
|
|
const cartItems = ref<any[]>([])
|
2026-02-04 17:22:20 +08:00
|
|
|
|
const loading = ref(false)
|
2026-02-09 14:48:09 +08:00
|
|
|
|
const showCamera = ref(false)
|
2026-02-04 17:22:20 +08:00
|
|
|
|
const barcodeRef = ref()
|
2026-02-05 10:20:52 +08:00
|
|
|
|
const formRef = ref()
|
|
|
|
|
|
|
2026-02-06 11:28:48 +08:00
|
|
|
|
// 签名相关
|
2026-02-05 15:51:19 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-02-05 10:20:52 +08:00
|
|
|
|
const operatorOptions = ref<string[]>([])
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
|
|
|
|
|
const form = reactive({
|
|
|
|
|
|
outbound_type: 'SALES',
|
|
|
|
|
|
consumer_name: '',
|
2026-02-05 15:51:19 +08:00
|
|
|
|
operator_name: '',
|
2026-02-04 17:22:20 +08:00
|
|
|
|
remark: ''
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const rules = {
|
|
|
|
|
|
consumer_name: [{ required: true, message: '请输入领用人姓名', trigger: 'blur' }],
|
2026-02-05 10:20:52 +08:00
|
|
|
|
operator_name: [{ required: true, message: '请指定操作员', trigger: 'change' }]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
// 计算总金额
|
|
|
|
|
|
const totalAmount = computed(() => {
|
|
|
|
|
|
return cartItems.value.reduce((sum, item) => sum + (item.price * item.out_quantity), 0)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-06 11:28:48 +08:00
|
|
|
|
// --- 初始化 ---
|
2026-02-05 10:20:52 +08:00
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
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)
|
2026-02-05 16:54:11 +08:00
|
|
|
|
res.data.items.forEach((group: any) => {
|
2026-02-06 11:28:48 +08:00
|
|
|
|
if (group.operator_name) names.add(group.operator_name)
|
2026-02-05 10:20:52 +08:00
|
|
|
|
})
|
|
|
|
|
|
operatorOptions.value = Array.from(names)
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('加载历史操作员失败', e)
|
|
|
|
|
|
}
|
2026-02-04 17:22:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 11:28:48 +08:00
|
|
|
|
// --- 核心扫码逻辑 (适配 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
|
2026-02-09 14:48:09 +08:00
|
|
|
|
handleManualInput()
|
2026-02-06 11:28:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleManualInput = async () => {
|
2026-02-27 13:54:06 +08:00
|
|
|
|
if (!userStore.hasPermission('outbound_create:operation')) {
|
|
|
|
|
|
ElMessage.warning('无操作权限')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-02-05 16:54:11 +08:00
|
|
|
|
const code = barcodeInput.value.trim()
|
|
|
|
|
|
if (!code) return
|
2026-02-05 15:51:19 +08:00
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
try {
|
|
|
|
|
|
loading.value = true
|
2026-02-05 15:51:19 +08:00
|
|
|
|
|
2026-02-06 11:28:48 +08:00
|
|
|
|
// 1. 检查购物车重复
|
2026-02-05 16:54:11 +08:00
|
|
|
|
const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code)
|
|
|
|
|
|
if (existIndex > -1) {
|
|
|
|
|
|
const item = cartItems.value[existIndex]
|
2026-02-06 11:28:48 +08:00
|
|
|
|
const maxQty = parseFloat(item.available_quantity)
|
|
|
|
|
|
|
|
|
|
|
|
if (item.out_quantity < maxQty) {
|
2026-02-05 16:54:11 +08:00
|
|
|
|
item.out_quantity++
|
2026-02-06 11:28:48 +08:00
|
|
|
|
ElMessage.success(`数量+1 (当前: ${item.out_quantity})`)
|
|
|
|
|
|
if (navigator.vibrate) navigator.vibrate(50)
|
2026-02-05 16:54:11 +08:00
|
|
|
|
} else {
|
2026-02-06 11:28:48 +08:00
|
|
|
|
ElMessage.warning(`库存不足 (余: ${maxQty})`)
|
|
|
|
|
|
if (navigator.vibrate) navigator.vibrate([100, 50, 100])
|
2026-02-05 16:54:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
barcodeInput.value = ''
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-02-05 15:51:19 +08:00
|
|
|
|
|
2026-02-06 11:28:48 +08:00
|
|
|
|
// 2. 调用 API 查询
|
2026-02-05 16:54:11 +08:00
|
|
|
|
const res = await getStockByBarcode(code)
|
|
|
|
|
|
if (res.data) {
|
|
|
|
|
|
const item = res.data
|
2026-02-06 11:28:48 +08:00
|
|
|
|
const availQty = parseFloat(item.available_quantity || 0)
|
|
|
|
|
|
|
|
|
|
|
|
if (availQty <= 0) {
|
|
|
|
|
|
ElMessage.warning(`库存不足或已出库 (余: ${availQty})`)
|
|
|
|
|
|
if (navigator.vibrate) navigator.vibrate([100, 50, 100])
|
2026-02-05 16:54:11 +08:00
|
|
|
|
} else {
|
2026-02-06 11:28:48 +08:00
|
|
|
|
// 加入购物车
|
2026-02-05 16:54:11 +08:00
|
|
|
|
cartItems.value.push({
|
|
|
|
|
|
...item,
|
|
|
|
|
|
out_quantity: 1,
|
2026-02-06 11:28:48 +08:00
|
|
|
|
price: parseFloat(item.price || 0)
|
2026-02-05 16:54:11 +08:00
|
|
|
|
})
|
2026-02-06 11:28:48 +08:00
|
|
|
|
ElMessage.success(`添加成功: ${item.name}`)
|
|
|
|
|
|
if (navigator.vibrate) navigator.vibrate(100)
|
2026-02-05 16:54:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
barcodeInput.value = ''
|
2026-02-05 15:51:19 +08:00
|
|
|
|
}
|
2026-02-05 16:54:11 +08:00
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
if (error.response && error.response.status === 404) {
|
2026-02-06 11:28:48 +08:00
|
|
|
|
ElMessage.error(`未找到条码: ${code}`)
|
2026-02-05 16:54:11 +08:00
|
|
|
|
} else {
|
2026-02-06 11:28:48 +08:00
|
|
|
|
ElMessage.error('查询出错')
|
2026-02-05 16:54:11 +08:00
|
|
|
|
}
|
2026-02-06 11:28:48 +08:00
|
|
|
|
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
|
2026-02-05 16:54:11 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
2026-02-09 14:48:09 +08:00
|
|
|
|
// 注意:全屏扫码模式下,我们不需要 refocus input,因为用户还在看摄像头
|
|
|
|
|
|
// 只有在非全屏模式下才 focus
|
|
|
|
|
|
if (!showCamera.value) {
|
|
|
|
|
|
nextTick(() => { barcodeRef.value?.focus() })
|
|
|
|
|
|
}
|
2026-02-05 15:51:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
const removeFromCart = (index: number) => {
|
2026-02-27 13:54:06 +08:00
|
|
|
|
if (!userStore.hasPermission('outbound_create:operation')) {
|
|
|
|
|
|
ElMessage.warning('无操作权限')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-02-05 16:54:11 +08:00
|
|
|
|
cartItems.value.splice(index, 1)
|
2026-02-05 15:51:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
const clearAll = () => {
|
2026-02-27 13:54:06 +08:00
|
|
|
|
if (!userStore.hasPermission('outbound_create:operation')) {
|
|
|
|
|
|
ElMessage.warning('无操作权限')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-02-06 11:28:48 +08:00
|
|
|
|
ElMessageBox.confirm('确定清空所有已选商品吗?', '提示', { type: 'warning' })
|
|
|
|
|
|
.then(() => {
|
|
|
|
|
|
cartItems.value = []
|
|
|
|
|
|
form.consumer_name = ''
|
|
|
|
|
|
form.remark = ''
|
|
|
|
|
|
signatureFile.value = null
|
|
|
|
|
|
signaturePreviewUrl.value = ''
|
|
|
|
|
|
barcodeInput.value = ''
|
|
|
|
|
|
})
|
2026-02-05 15:51:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 11:28:48 +08:00
|
|
|
|
// --- 提交逻辑 ---
|
2026-02-05 16:54:11 +08:00
|
|
|
|
const submitForm = async () => {
|
2026-02-27 13:54:06 +08:00
|
|
|
|
if (!userStore.hasPermission('outbound_create:operation')) {
|
|
|
|
|
|
ElMessage.warning('无操作权限')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-02-05 16:54:11 +08:00
|
|
|
|
if (!formRef.value) return
|
2026-02-06 11:28:48 +08:00
|
|
|
|
if (cartItems.value.length === 0) return ElMessage.warning('请先添加商品')
|
2026-02-05 15:51:19 +08:00
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
await formRef.value.validate(async (valid: boolean) => {
|
|
|
|
|
|
if (!valid) return
|
2026-02-05 15:51:19 +08:00
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
if (!signatureFile.value) {
|
|
|
|
|
|
ElMessage.error('请进行电子签名')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-02-05 15:51:19 +08:00
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
try {
|
|
|
|
|
|
loading.value = true
|
2026-02-05 15:51:19 +08:00
|
|
|
|
|
2026-02-06 11:28:48 +08:00
|
|
|
|
// 上传签名
|
2026-02-05 16:54:11 +08:00
|
|
|
|
const uploadRes = await uploadFile(signatureFile.value)
|
|
|
|
|
|
const signatureUrl = uploadRes.data.url
|
2026-02-05 15:51:19 +08:00
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
const itemsPayload = cartItems.value.map(item => ({
|
|
|
|
|
|
stock_id: item.id,
|
|
|
|
|
|
source_table: item.source_table,
|
|
|
|
|
|
sku: item.sku,
|
|
|
|
|
|
barcode: item.barcode,
|
|
|
|
|
|
quantity: item.out_quantity,
|
|
|
|
|
|
price: item.price
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
await submitOutbound({
|
|
|
|
|
|
items: itemsPayload,
|
|
|
|
|
|
outbound_type: form.outbound_type,
|
|
|
|
|
|
consumer_name: form.consumer_name,
|
|
|
|
|
|
operator_name: form.operator_name,
|
|
|
|
|
|
remark: form.remark,
|
|
|
|
|
|
signature_path: signatureUrl
|
|
|
|
|
|
})
|
2026-02-05 15:51:19 +08:00
|
|
|
|
|
2026-02-06 11:28:48 +08:00
|
|
|
|
ElMessage.success('出库成功')
|
|
|
|
|
|
// 重置
|
2026-02-05 16:54:11 +08:00
|
|
|
|
cartItems.value = []
|
|
|
|
|
|
form.consumer_name = ''
|
|
|
|
|
|
form.remark = ''
|
|
|
|
|
|
signatureFile.value = null
|
|
|
|
|
|
signaturePreviewUrl.value = ''
|
|
|
|
|
|
loadHistoryOperators()
|
2026-02-05 10:20:52 +08:00
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
} catch (error) {
|
2026-02-05 10:20:52 +08:00
|
|
|
|
console.error(error)
|
2026-02-06 11:28:48 +08:00
|
|
|
|
ElMessage.error('提交失败')
|
2026-02-05 16:54:11 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
2026-02-04 17:22:20 +08:00
|
|
|
|
}
|
2026-02-05 16:54:11 +08:00
|
|
|
|
})
|
2026-02-04 17:22:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 11:28:48 +08:00
|
|
|
|
// --- 签名逻辑 (Canvas) ---
|
2026-02-27 13:54:06 +08:00
|
|
|
|
const openSignatureDialog = () => {
|
|
|
|
|
|
if (!userStore.hasPermission('outbound_create:operation')) {
|
|
|
|
|
|
ElMessage.warning('无签名权限')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
showSignatureDialog.value = true
|
|
|
|
|
|
}
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
const initCanvas = async () => {
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
const canvas = nativeCanvasRef.value
|
|
|
|
|
|
const container = canvasContainerRef.value
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
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)
|
2026-02-04 17:22:20 +08:00
|
|
|
|
}
|
2026-02-05 16:54:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
const getPos = (e: MouseEvent | TouchEvent) => {
|
|
|
|
|
|
if (!nativeCanvasRef.value) return { x: 0, y: 0 }
|
|
|
|
|
|
const rect = nativeCanvasRef.value.getBoundingClientRect()
|
2026-02-06 11:28:48 +08:00
|
|
|
|
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
|
2026-02-05 16:54:11 +08:00
|
|
|
|
return { x: clientX - rect.left, y: clientY - rect.top }
|
|
|
|
|
|
}
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
const startDrawing = (e: MouseEvent | TouchEvent) => {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
isDrawing.value = true
|
|
|
|
|
|
const { x, y } = getPos(e)
|
2026-02-06 11:28:48 +08:00
|
|
|
|
lastX.value = x; lastY.value = y
|
|
|
|
|
|
ctx.value?.beginPath()
|
|
|
|
|
|
ctx.value?.moveTo(x, y)
|
2026-02-05 16:54:11 +08:00
|
|
|
|
}
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
2026-02-06 11:28:48 +08:00
|
|
|
|
const stopDrawing = () => { isDrawing.value = false }
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
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 = () => {
|
2026-02-06 11:28:48 +08:00
|
|
|
|
nativeCanvasRef.value?.toBlob((blob) => {
|
2026-02-05 16:54:11 +08:00
|
|
|
|
if (blob) {
|
2026-02-06 11:28:48 +08:00
|
|
|
|
const file = new File([blob], `sign_${Date.now()}.png`, { type: 'image/png' })
|
2026-02-05 16:54:11 +08:00
|
|
|
|
signatureFile.value = file
|
|
|
|
|
|
signaturePreviewUrl.value = URL.createObjectURL(file)
|
|
|
|
|
|
showSignatureDialog.value = false
|
2026-02-04 17:22:20 +08:00
|
|
|
|
}
|
2026-02-05 16:54:11 +08:00
|
|
|
|
}, 'image/png')
|
2026-02-04 17:22:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 11:28:48 +08:00
|
|
|
|
const handleSignCancel = () => { showSignatureDialog.value = false }
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
2026-02-06 11:28:48 +08:00
|
|
|
|
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
|
2026-02-04 17:22:20 +08:00
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-02-06 11:28:48 +08:00
|
|
|
|
.app-container.mobile-optimized {
|
|
|
|
|
|
padding: 10px; max-width: 600px; margin: 0 auto;
|
2026-02-04 17:22:20 +08:00
|
|
|
|
}
|
2026-02-06 11:28:48 +08:00
|
|
|
|
|
|
|
|
|
|
/* 头部 */
|
|
|
|
|
|
.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; }
|
|
|
|
|
|
|
2026-02-09 14:48:09 +08:00
|
|
|
|
/* 扫码区(卡片内触发器) */
|
2026-02-06 11:28:48 +08:00
|
|
|
|
.scan-section { margin-bottom: 20px; }
|
|
|
|
|
|
.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;
|
2026-02-09 14:48:09 +08:00
|
|
|
|
transition: all 0.3s;
|
2026-02-05 15:51:19 +08:00
|
|
|
|
}
|
2026-02-09 14:48:09 +08:00
|
|
|
|
.camera-placeholder:active { background: #e6e8eb; }
|
2026-02-06 11:28:48 +08:00
|
|
|
|
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
|
|
|
|
|
|
|
2026-02-09 14:48:09 +08:00
|
|
|
|
/* ★ 全屏扫码层样式 */
|
|
|
|
|
|
.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; }
|
|
|
|
|
|
|
2026-02-06 11:28:48 +08:00
|
|
|
|
/* 表单与购物车 */
|
|
|
|
|
|
.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;
|
2026-02-05 15:51:19 +08:00
|
|
|
|
}
|
2026-02-06 11:28:48 +08:00
|
|
|
|
.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;
|
2026-02-05 15:51:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
.signature-sidebar {
|
2026-02-06 11:28:48 +08:00
|
|
|
|
width: 120px; background: #333; color: #fff;
|
|
|
|
|
|
display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px 10px;
|
2026-02-05 15:51:19 +08:00
|
|
|
|
}
|
2026-02-06 11:28:48 +08:00
|
|
|
|
.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; }
|
|
|
|
|
|
|
2026-02-05 15:51:19 +08:00
|
|
|
|
@media screen and (max-width: 768px) {
|
2026-02-06 11:28:48 +08:00
|
|
|
|
.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; }
|
2026-02-05 15:51:19 +08:00
|
|
|
|
}
|
2026-02-27 13:54:06 +08:00
|
|
|
|
</style>
|