出库进行修改,确保可以进行多个样例的出库以及出库的记录展示
This commit is contained in:
@ -3,14 +3,20 @@
|
||||
<el-card class="box-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>扫码出库作业台</span>
|
||||
<el-button type="primary" @click="toggleCamera">
|
||||
{{ showCamera ? '关闭摄像头' : '打开摄像头扫码' }}
|
||||
</el-button>
|
||||
<span>批量扫码出库作业台</span>
|
||||
<div class="header-right">
|
||||
<span v-if="cartItems.length > 0" class="summary-text">
|
||||
已选: <span class="num">{{ cartItems.length }}</span> 项 |
|
||||
总金额: <span class="price">¥{{ totalAmount.toFixed(2) }}</span>
|
||||
</span>
|
||||
<el-button type="primary" @click="toggleCamera" style="margin-left: 10px;">
|
||||
{{ showCamera ? '关闭摄像头' : '打开摄像头扫码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!currentItem" class="scan-area">
|
||||
<div class="scan-area">
|
||||
<div v-if="showCamera" id="reader" class="camera-box"></div>
|
||||
<div v-if="isHttpAndNotLocal" class="http-warning">
|
||||
注意:当前为 HTTP 环境,摄像头可能无法启动。请使用 HTTPS 或 localhost,或配置浏览器安全白名单。
|
||||
@ -19,58 +25,76 @@
|
||||
<div class="input-box">
|
||||
<el-input
|
||||
v-model="barcodeInput"
|
||||
placeholder="请扫描条码或手动输入后回车"
|
||||
placeholder="请连续扫描条码或手动输入后回车"
|
||||
@keyup.enter="handleScan"
|
||||
clearable
|
||||
ref="barcodeRef"
|
||||
class="scan-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Scissor /></el-icon>
|
||||
</template>
|
||||
<template #append>
|
||||
<el-button @click="handleScan">确定</el-button>
|
||||
<el-button @click="handleScan">添加商品</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="confirm-area">
|
||||
<el-descriptions title="货物信息" border :column="1">
|
||||
<el-descriptions-item label="名称">{{ currentItem.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="规格/型号">{{ currentItem.spec_model }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前库存">
|
||||
<el-tag :type="currentItem.available_quantity > 0 ? 'success' : 'danger'">
|
||||
{{ currentItem.stock_quantity }} (可用: {{ currentItem.available_quantity }})
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="库位">{{ currentItem.warehouse_location || '暂无' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="cart-area mt-20" v-if="cartItems.length > 0">
|
||||
<el-table :data="cartItems" border stripe style="width: 100%">
|
||||
<el-table-column prop="name" label="名称" show-overflow-tooltip />
|
||||
<el-table-column prop="spec_model" label="规格" show-overflow-tooltip />
|
||||
<el-table-column prop="sku" label="SKU" width="150" />
|
||||
<el-table-column label="单价" width="120">
|
||||
<template #default="{row}">
|
||||
¥{{ row.price }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="库存" width="100">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="row.available_quantity > 0 ? 'success' : 'danger'">{{ row.available_quantity }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="出库数量" width="160">
|
||||
<template #default="{row}">
|
||||
<el-input-number v-model="row.out_quantity" :min="1" :max="row.available_quantity" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="小计" width="120">
|
||||
<template #default="{row}">
|
||||
<span style="color: #F56C6C; font-weight: bold;">¥{{ (row.price * row.out_quantity).toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{$index}">
|
||||
<el-button type="danger" icon="Delete" circle size="small" @click="removeFromCart($index)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<el-empty v-else description="暂无扫描商品,请扫描上方条码进行添加" />
|
||||
|
||||
<el-form :model="form" ref="formRef" :rules="rules" label-position="top" class="mt-20">
|
||||
<div v-if="cartItems.length > 0" class="confirm-area mt-20">
|
||||
<el-divider content-position="left">单据信息</el-divider>
|
||||
|
||||
<el-form :model="form" ref="formRef" :rules="rules" label-position="top">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="出库类型" prop="outbound_type">
|
||||
<el-select v-model="form.outbound_type" placeholder="请选择">
|
||||
<el-select v-model="form.outbound_type" placeholder="请选择" style="width: 100%">
|
||||
<el-option label="销售出库" value="SALES" />
|
||||
<el-option label="内部领用" value="USE" />
|
||||
<el-option label="调拨" value="TRANSFER" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="出库数量" prop="quantity">
|
||||
<el-input-number v-model="form.quantity" :min="1" :max="currentItem.available_quantity" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="领用人/客户姓名" prop="consumer_name">
|
||||
<el-input v-model="form.consumer_name" placeholder="请输入姓名" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="操作员 (库管)" prop="operator_name">
|
||||
<el-select
|
||||
v-model="form.operator_name"
|
||||
@ -108,8 +132,8 @@
|
||||
</el-form-item>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button @click="resetScan">取消 / 重新扫码</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="submitForm">确认出库</el-button>
|
||||
<el-button @click="clearAll">清空重置</el-button>
|
||||
<el-button type="primary" size="large" :loading="loading" @click="submitForm">确认批量出库</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
@ -154,16 +178,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, nextTick, onUnmounted, onMounted, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode'
|
||||
import { Scissor, EditPen } from '@element-plus/icons-vue'
|
||||
import { getStockByBarcode, submitOutbound, getOutboundList, type ScanResult } from '@/api/outbound'
|
||||
import { Scissor, EditPen, Delete } from '@element-plus/icons-vue'
|
||||
import { getStockByBarcode, submitOutbound, getOutboundList } from '@/api/outbound'
|
||||
import { uploadFile } from '@/api/common/upload'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
// --- 状态定义 ---
|
||||
const barcodeInput = ref('')
|
||||
const currentItem = ref<ScanResult | null>(null)
|
||||
const cartItems = ref<any[]>([]) // 购物车列表
|
||||
const loading = ref(false)
|
||||
const showCamera = ref(false)
|
||||
const html5QrCode = ref<Html5Qrcode | null>(null)
|
||||
@ -188,7 +212,6 @@ const operatorOptions = ref<string[]>([])
|
||||
|
||||
const form = reactive({
|
||||
outbound_type: 'SALES',
|
||||
quantity: 1,
|
||||
consumer_name: '',
|
||||
operator_name: '',
|
||||
remark: ''
|
||||
@ -196,7 +219,6 @@ const form = reactive({
|
||||
|
||||
const rules = {
|
||||
consumer_name: [{ required: true, message: '请输入领用人姓名', trigger: 'blur' }],
|
||||
quantity: [{ required: true, message: '请输入数量', trigger: 'blur' }],
|
||||
operator_name: [{ required: true, message: '请指定操作员', trigger: 'change' }]
|
||||
}
|
||||
|
||||
@ -206,6 +228,11 @@ const isHttpAndNotLocal = computed(() => {
|
||||
return !isHttps && !isLocal
|
||||
})
|
||||
|
||||
// 计算总金额
|
||||
const totalAmount = computed(() => {
|
||||
return cartItems.value.reduce((sum, item) => sum + (item.price * item.out_quantity), 0)
|
||||
})
|
||||
|
||||
// --- 初始化逻辑 ---
|
||||
onMounted(() => {
|
||||
if (userStore.username) {
|
||||
@ -221,9 +248,10 @@ const loadHistoryOperators = async () => {
|
||||
if (res.data && res.data.items) {
|
||||
const names = new Set<string>()
|
||||
if (userStore.username) names.add(userStore.username)
|
||||
res.data.items.forEach((item: any) => {
|
||||
if (item.operator_name) {
|
||||
names.add(item.operator_name)
|
||||
// 注意:现在的列表结构变了,operator_name 在外层
|
||||
res.data.items.forEach((group: any) => {
|
||||
if (group.operator_name) {
|
||||
names.add(group.operator_name)
|
||||
}
|
||||
})
|
||||
operatorOptions.value = Array.from(names)
|
||||
@ -233,149 +261,139 @@ const loadHistoryOperators = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 签名逻辑 ---
|
||||
|
||||
const openSignatureDialog = () => {
|
||||
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()
|
||||
let clientX, clientY
|
||||
|
||||
if (e.type.startsWith('touch')) {
|
||||
clientX = (e as TouchEvent).touches[0].clientX
|
||||
clientY = (e as TouchEvent).touches[0].clientY
|
||||
} else {
|
||||
clientX = (e as MouseEvent).clientX
|
||||
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
|
||||
|
||||
if(ctx.value) {
|
||||
ctx.value.beginPath()
|
||||
ctx.value.moveTo(x, y)
|
||||
ctx.value.lineTo(x, y)
|
||||
ctx.value.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
const draw = (e: MouseEvent | TouchEvent) => {
|
||||
e.preventDefault()
|
||||
if (!isDrawing.value || !ctx.value) return
|
||||
|
||||
const { x, y } = getPos(e)
|
||||
|
||||
ctx.value.beginPath()
|
||||
ctx.value.moveTo(lastX.value, lastY.value)
|
||||
ctx.value.lineTo(x, y)
|
||||
ctx.value.stroke()
|
||||
|
||||
lastX.value = x
|
||||
lastY.value = y
|
||||
}
|
||||
|
||||
const stopDrawing = () => {
|
||||
isDrawing.value = false
|
||||
if (ctx.value) ctx.value.beginPath()
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
if (!nativeCanvasRef.value) return
|
||||
|
||||
nativeCanvasRef.value.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const file = new File([blob], `signature_${Date.now()}.png`, { type: 'image/png' })
|
||||
signatureFile.value = file
|
||||
signaturePreviewUrl.value = URL.createObjectURL(file)
|
||||
showSignatureDialog.value = false
|
||||
} else {
|
||||
ElMessage.error('生成签名失败')
|
||||
}
|
||||
}, 'image/png')
|
||||
}
|
||||
|
||||
const handleSignCancel = () => {
|
||||
showSignatureDialog.value = false
|
||||
}
|
||||
|
||||
// --- 扫码逻辑 ---
|
||||
// --- 扫码逻辑 (加入购物车) ---
|
||||
const handleScan = async () => {
|
||||
if (!barcodeInput.value) return
|
||||
const code = barcodeInput.value.trim()
|
||||
if (!code) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await getStockByBarcode(barcodeInput.value)
|
||||
|
||||
// 1. 检查是否已经在购物车中
|
||||
const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code)
|
||||
if (existIndex > -1) {
|
||||
// 存在则数量+1
|
||||
const item = cartItems.value[existIndex]
|
||||
if (item.out_quantity < item.available_quantity) {
|
||||
item.out_quantity++
|
||||
ElMessage.success(`商品 ${item.name} 数量+1`)
|
||||
} else {
|
||||
ElMessage.warning(`库存不足 (余: ${item.available_quantity})`)
|
||||
}
|
||||
barcodeInput.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 新商品调用 API
|
||||
const res = await getStockByBarcode(code)
|
||||
if (res.data) {
|
||||
const item = res.data
|
||||
if (item.available_quantity <= 0) {
|
||||
ElMessage.warning(`该商品库存不足或已出库!(当前库存: ${item.available_quantity})`)
|
||||
barcodeInput.value = ''
|
||||
return
|
||||
}
|
||||
currentItem.value = item
|
||||
if (html5QrCode.value && html5QrCode.value.isScanning) {
|
||||
await stopCamera()
|
||||
} else {
|
||||
// 加入购物车,默认出库 1
|
||||
cartItems.value.push({
|
||||
...item,
|
||||
out_quantity: 1,
|
||||
price: item.price || 0
|
||||
})
|
||||
ElMessage.success('添加成功')
|
||||
}
|
||||
barcodeInput.value = ''
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response && error.response.status === 404) {
|
||||
ElMessage.error(`未找到条码 ${barcodeInput.value} 的入库记录,请先入库!`)
|
||||
ElMessage.error(`未找到条码 ${barcodeInput.value} 的入库记录`)
|
||||
} else {
|
||||
console.error(error)
|
||||
ElMessage.error('查询出错,请重试')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
// 聚焦回输入框
|
||||
nextTick(() => { barcodeRef.value?.focus() })
|
||||
}
|
||||
}
|
||||
|
||||
const removeFromCart = (index: number) => {
|
||||
cartItems.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
ElMessageBox.confirm('确定清空所有已扫商品和填写信息吗?', '提示', {
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
cartItems.value = []
|
||||
form.consumer_name = ''
|
||||
form.remark = ''
|
||||
signatureFile.value = null
|
||||
signaturePreviewUrl.value = ''
|
||||
barcodeInput.value = ''
|
||||
})
|
||||
}
|
||||
|
||||
// --- 提交逻辑 (批量提交) ---
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
if (cartItems.value.length === 0) {
|
||||
ElMessage.warning('购物车为空,请先扫码')
|
||||
return
|
||||
}
|
||||
|
||||
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. 构造 items 数据
|
||||
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
|
||||
}))
|
||||
|
||||
// 3. 提交业务单据
|
||||
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
|
||||
})
|
||||
|
||||
ElMessage.success('批量出库成功')
|
||||
// 清空
|
||||
cartItems.value = []
|
||||
form.consumer_name = ''
|
||||
form.remark = ''
|
||||
signatureFile.value = null
|
||||
signaturePreviewUrl.value = ''
|
||||
|
||||
// 刷新操作员列表
|
||||
loadHistoryOperators()
|
||||
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- 摄像头控制 ---
|
||||
const toggleCamera = async () => {
|
||||
if (showCamera.value) {
|
||||
@ -435,64 +453,106 @@ const stopCamera = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 提交逻辑 ---
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
if (!currentItem.value) return
|
||||
|
||||
if (!signatureFile.value) {
|
||||
ElMessage.error('请进行电子签名')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
const uploadRes = await uploadFile(signatureFile.value)
|
||||
const signatureUrl = uploadRes.data.url
|
||||
|
||||
await submitOutbound({
|
||||
sku: currentItem.value.sku,
|
||||
source_table: currentItem.value.source_table,
|
||||
stock_id: currentItem.value.id,
|
||||
barcode: barcodeInput.value,
|
||||
outbound_type: form.outbound_type,
|
||||
quantity: form.quantity,
|
||||
consumer_name: form.consumer_name,
|
||||
operator_name: form.operator_name,
|
||||
remark: form.remark,
|
||||
signature_path: signatureUrl
|
||||
})
|
||||
|
||||
ElMessage.success('出库成功')
|
||||
resetScan()
|
||||
loadHistoryOperators()
|
||||
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
// --- 签名核心逻辑 ---
|
||||
const openSignatureDialog = () => {
|
||||
showSignatureDialog.value = true
|
||||
}
|
||||
|
||||
const resetScan = () => {
|
||||
currentItem.value = null
|
||||
barcodeInput.value = ''
|
||||
form.consumer_name = ''
|
||||
form.quantity = 1
|
||||
form.remark = ''
|
||||
if (userStore.username) {
|
||||
form.operator_name = userStore.username
|
||||
const initCanvas = async () => {
|
||||
// 必须等待 DOM 更新完成,确保 dialog 里的 ref 已经挂载
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
console.error('Canvas 初始化失败:无法获取 DOM 元素')
|
||||
}
|
||||
signatureFile.value = null
|
||||
signaturePreviewUrl.value = ''
|
||||
nextTick(() => {
|
||||
barcodeRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const getPos = (e: MouseEvent | TouchEvent) => {
|
||||
if (!nativeCanvasRef.value) return { x: 0, y: 0 }
|
||||
const rect = nativeCanvasRef.value.getBoundingClientRect()
|
||||
let clientX, clientY
|
||||
if (e.type.startsWith('touch')) {
|
||||
clientX = (e as TouchEvent).touches[0].clientX
|
||||
clientY = (e as TouchEvent).touches[0].clientY
|
||||
} else {
|
||||
clientX = (e as MouseEvent).clientX
|
||||
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
|
||||
if(ctx.value) {
|
||||
ctx.value.beginPath()
|
||||
ctx.value.moveTo(x, y)
|
||||
ctx.value.lineTo(x, y)
|
||||
ctx.value.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
const draw = (e: MouseEvent | TouchEvent) => {
|
||||
e.preventDefault()
|
||||
if (!isDrawing.value || !ctx.value) return
|
||||
const { x, y } = getPos(e)
|
||||
ctx.value.beginPath()
|
||||
ctx.value.moveTo(lastX.value, lastY.value)
|
||||
ctx.value.lineTo(x, y)
|
||||
ctx.value.stroke()
|
||||
lastX.value = x
|
||||
lastY.value = y
|
||||
}
|
||||
|
||||
const stopDrawing = () => {
|
||||
isDrawing.value = false
|
||||
if (ctx.value) ctx.value.beginPath()
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
if (!nativeCanvasRef.value) return
|
||||
nativeCanvasRef.value.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const file = new File([blob], `signature_${Date.now()}.png`, { type: 'image/png' })
|
||||
signatureFile.value = file
|
||||
signaturePreviewUrl.value = URL.createObjectURL(file)
|
||||
showSignatureDialog.value = false
|
||||
} else {
|
||||
ElMessage.error('生成签名失败')
|
||||
}
|
||||
}, 'image/png')
|
||||
}
|
||||
|
||||
const handleSignCancel = () => {
|
||||
showSignatureDialog.value = false
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
@ -504,7 +564,6 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 原有样式保持不变 */
|
||||
.scan-area {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
@ -526,7 +585,7 @@ onUnmounted(() => {
|
||||
font-size: 12px;
|
||||
}
|
||||
.confirm-area {
|
||||
max-width: 600px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.mt-20 {
|
||||
@ -537,7 +596,6 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.signature-display-area {
|
||||
border: 1px dashed #dcdfe6;
|
||||
border-radius: 4px;
|
||||
@ -549,9 +607,6 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.signed-image-box {
|
||||
text-align: center;
|
||||
}
|
||||
.signed-image-box img {
|
||||
max-height: 100px;
|
||||
max-width: 100%;
|
||||
@ -559,23 +614,32 @@ onUnmounted(() => {
|
||||
margin-bottom: 5px;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
/* 新增:头部右侧信息 */
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.summary-text {
|
||||
margin-right: 15px;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
.summary-text .num { color: #409EFF; font-weight: bold; margin: 0 4px; }
|
||||
.summary-text .price { color: #F56C6C; font-weight: bold; margin: 0 4px; font-size: 16px; }
|
||||
|
||||
/* --- 响应式全屏弹窗样式 --- */
|
||||
/* 响应式签名弹窗 */
|
||||
:deep(.fullscreen-signature-dialog .el-dialog__body) {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* 默认布局(电脑/平板):左右结构 */
|
||||
.signature-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%; /* 使用 100% 适应弹窗 body */
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.signature-canvas-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
@ -584,7 +648,6 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.native-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@ -592,8 +655,6 @@ onUnmounted(() => {
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* 右侧边栏(默认) */
|
||||
.signature-sidebar {
|
||||
width: 150px;
|
||||
background: #333;
|
||||
@ -607,7 +668,6 @@ onUnmounted(() => {
|
||||
z-index: 10;
|
||||
box-shadow: -2px 0 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
writing-mode: vertical-rl;
|
||||
letter-spacing: 4px;
|
||||
@ -615,69 +675,50 @@ onUnmounted(() => {
|
||||
font-weight: bold;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.sidebar-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-actions .el-button {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
margin-top: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* --- ★ 手机端适配 (屏幕宽度小于 768px) --- */
|
||||
@media screen and (max-width: 768px) {
|
||||
/* 1. 改为上下布局:画布在上,按钮在下 */
|
||||
.signature-wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 2. 画布区域自动填充剩余空间 */
|
||||
.signature-canvas-container {
|
||||
flex: 1;
|
||||
height: auto; /* 高度自适应 */
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* 3. 底部工具栏样式重写 */
|
||||
.signature-sidebar {
|
||||
width: 100%; /* 宽度占满 */
|
||||
height: auto; /* 高度由内容撑开 */
|
||||
flex-direction: row; /* 内容横向排列 */
|
||||
padding: 15px; /* 增加内边距 */
|
||||
width: 100%;
|
||||
height: auto;
|
||||
flex-direction: row;
|
||||
padding: 15px;
|
||||
gap: 10px;
|
||||
/* 放在底部 */
|
||||
order: 2;
|
||||
}
|
||||
|
||||
/* 4. 隐藏竖排文字标题,节省空间 */
|
||||
.sidebar-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 5. 按钮组改为横向排列 */
|
||||
.sidebar-actions {
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 6. 按钮大小调整,均匀分布 */
|
||||
.sidebar-actions .el-button {
|
||||
flex: 1; /* 三个按钮平分宽度 */
|
||||
height: 44px; /* 适合手指点击的高度 */
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 去掉确认按钮原本的顶部外边距 */
|
||||
.confirm-btn {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@ -30,7 +30,33 @@
|
||||
style="width: 100%; margin-top: 20px;"
|
||||
:header-cell-style="{ background: '#f5f7fa', color: '#606266' }"
|
||||
>
|
||||
<el-table-column prop="outbound_no" label="出库单号" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column type="expand">
|
||||
<template #default="props">
|
||||
<div style="padding: 10px 40px; background: #fafafa;">
|
||||
<h4 style="margin: 0 0 10px 0; color: #409EFF;">商品明细 (按单价降序)</h4>
|
||||
<el-table :data="props.row.items" border size="small">
|
||||
<el-table-column prop="sku" label="SKU" width="150" />
|
||||
|
||||
<el-table-column prop="name" label="名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="material_type" label="类型" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="category" label="类别" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="spec_model" label="规格型号" min-width="150" show-overflow-tooltip />
|
||||
|
||||
<el-table-column prop="quantity" label="数量" width="100" />
|
||||
<el-table-column prop="unit_price" label="单价" width="120">
|
||||
<template #default="{row}">¥{{ row.unit_price }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="subtotal" label="小计">
|
||||
<template #default="{row}">
|
||||
<span style="color: #F56C6C; font-weight: bold;">¥{{ row.subtotal.toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="outbound_no" label="出库单号" min-width="200" show-overflow-tooltip />
|
||||
|
||||
<el-table-column prop="outbound_time" label="出库时间" width="170" align="center">
|
||||
<template #default="{ row }">
|
||||
@ -44,9 +70,11 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="sku" label="SKU" min-width="140" show-overflow-tooltip />
|
||||
|
||||
<el-table-column prop="quantity" label="数量" width="90" align="center" />
|
||||
<el-table-column prop="total_amount" label="总金额" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span style="color: #F56C6C; font-weight: bold; font-size: 15px;">¥{{ row.total_amount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="consumer_name" label="领用/客户" min-width="120" show-overflow-tooltip />
|
||||
|
||||
@ -76,18 +104,31 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div style="margin-top: 20px; text-align: right;">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:total="total"
|
||||
:page-size="listQuery.limit"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, reactive } from 'vue'
|
||||
import { getOutboundList } from '@/api/outbound'
|
||||
import { Picture } from '@element-plus/icons-vue' // 引入图标用于图片加载失败显示
|
||||
import { Picture } from '@element-plus/icons-vue'
|
||||
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const listQuery = reactive({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: '',
|
||||
dateRange: []
|
||||
})
|
||||
@ -95,8 +136,15 @@ const listQuery = reactive({
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getOutboundList(listQuery)
|
||||
const params = {
|
||||
...listQuery,
|
||||
start_date: listQuery.dateRange && listQuery.dateRange[0] ? listQuery.dateRange[0] : null,
|
||||
end_date: listQuery.dateRange && listQuery.dateRange[1] ? listQuery.dateRange[1] : null
|
||||
}
|
||||
|
||||
const res = await getOutboundList(params)
|
||||
list.value = res.data.items || []
|
||||
total.value = res.data.total || 0
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
@ -104,6 +152,11 @@ const fetchData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageChange = (val: number) => {
|
||||
listQuery.page = val
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const formatType = (type: string) => {
|
||||
const map: any = {
|
||||
'SALES': '销售出库',
|
||||
@ -114,7 +167,6 @@ const formatType = (type: string) => {
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
// 辅助函数:根据类型返回 Tag 颜色
|
||||
const getTagType = (type: string) => {
|
||||
const map: any = {
|
||||
'SALES': 'success',
|
||||
|
||||
Reference in New Issue
Block a user