借库逻辑实现

This commit is contained in:
dxc
2026-02-06 17:11:47 +08:00
parent 387c8973d6
commit 04ee938cd1
15 changed files with 1766 additions and 268 deletions

View File

@ -1 +1,507 @@
<template><div style="padding:20px;"><h2>借库申请</h2></div></template>
<template>
<div class="app-container mobile-optimized">
<el-card class="box-card" shadow="never">
<template #header>
<div class="card-header">
<div class="title-box">
<span>借库作业 (领用人签字)</span>
<el-tag v-if="cartItems.length > 0" type="warning" size="small" effect="dark">
已选 {{ cartItems.length }}
</el-tag>
</div>
</div>
</template>
<div class="scan-section">
<div v-if="showCamera" class="camera-wrapper">
<QrScanner @decode="onScanSuccess" />
<div class="scan-overlay">
<el-button type="info" size="small" bg text @click="showCamera = false" icon="Close">
关闭摄像头
</el-button>
</div>
</div>
<div v-else class="camera-placeholder" @click="showCamera = true">
<el-icon :size="40" color="#409EFF"><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"
>
<template #prefix>
<el-icon><Scissor /></el-icon>
</template>
<template #append>
<el-button @click="handleManualInput">添加</el-button>
</template>
</el-input>
</div>
</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="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column label="可用库存" width="90" align="center">
<template #default="{row}">
<el-tag type="info">{{ parseFloat(row.available_quantity) }}</el-tag>
</template>
</el-table-column>
<el-table-column 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"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center" fixed="right">
<template #default="{$index}">
<el-button type="danger" icon="Delete" circle size="small" @click="removeFromCart($index)" />
</template>
</el-table-column>
</el-table>
</div>
<el-empty v-else description="暂无物品,请扫码借出" :image-size="80" />
</div>
<div v-if="cartItems.length > 0" class="form-section">
<el-divider content-position="left">借用登记信息</el-divider>
<el-form :model="form" ref="formRef" :rules="rules" label-position="top">
<el-row :gutter="15">
<el-col :span="24">
<el-form-item label="领用人/借用人" prop="borrower_name">
<el-input v-model="form.borrower_name" placeholder="请输入姓名" size="large" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="预计归还日期" prop="expected_return_time">
<el-date-picker
v-model="form.expected_return_time"
type="date"
placeholder="请选择日期"
style="width: 100%"
size="large"
value-format="YYYY-MM-DD"
:disabled-date="disabledDate"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注说明" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="用途说明..." />
</el-form-item>
<el-form-item label="领用人签名确认" required>
<div class="signature-box" @click="openSignatureDialog">
<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>
</el-form-item>
<div class="bottom-actions">
<el-button @click="clearAll" icon="Refresh">清空</el-button>
<el-button type="primary" size="large" :loading="loading" @click="submitForm" icon="Select">
确认借出
</el-button>
</div>
</el-form>
</div>
</el-card>
<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 } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
import QrScanner from '@/components/QrScanner/index.vue'
import { getStockByBarcode } from '@/api/outbound'
import request from '@/utils/request'
import { uploadFile } from '@/api/common/upload'
// --- 状态定义 ---
const barcodeInput = ref('')
const cartItems = ref<any[]>([])
const loading = ref(false)
const showCamera = ref(false)
const barcodeRef = ref()
const formRef = ref()
// 签名相关
const showSignatureDialog = ref(false)
const signaturePreviewUrl = ref('')
const signatureFile = ref<File | null>(null)
const nativeCanvasRef = ref<HTMLCanvasElement | null>(null)
const canvasContainerRef = ref<HTMLElement | null>(null)
const ctx = ref<CanvasRenderingContext2D | null>(null)
const isDrawing = ref(false)
const lastX = ref(0)
const lastY = ref(0)
const form = reactive({
borrower_name: '',
expected_return_time: '',
remark: ''
})
// ★ 修改点:增强校验规则
const rules = {
borrower_name: [
{ required: true, message: '请输入借用人姓名', trigger: 'blur' }
],
expected_return_time: [
{ required: true, message: '请选择预计归还日期', trigger: 'change' }
]
}
// ★ 新增:禁止选择今天之前的日期
const disabledDate = (time: Date) => {
return time.getTime() < Date.now() - 8.64e7 // 禁止选择昨天及之前
}
// --- 核心扫码逻辑 ---
const onScanSuccess = (code: string) => {
if (!code) return
const trimCode = code.trim()
const validPattern = /^[A-Za-z0-9\-\.]+$/
if (!validPattern.test(trimCode)) {
ElMessage.warning(`识别到异常符号,已忽略:${trimCode}`)
return
}
if (trimCode.length < 3) {
ElMessage.warning('扫描结果过短,请对准重试')
return
}
if (loading.value) return
barcodeInput.value = trimCode
handleManualInput()
}
const handleManualInput = async () => {
const code = barcodeInput.value.trim()
if (!code) return
try {
loading.value = true
// 查重
const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code)
if (existIndex > -1) {
const item = cartItems.value[existIndex]
const maxQty = parseFloat(item.available_quantity)
if (item.out_quantity < maxQty) {
item.out_quantity++
ElMessage.success(`数量+1 (当前: ${item.out_quantity})`)
if (navigator.vibrate) navigator.vibrate(50)
} else {
ElMessage.warning(`库存不足 (余: ${maxQty})`)
}
barcodeInput.value = ''
return
}
// 查库
const res = await getStockByBarcode(code)
if (res.data) {
const item = res.data
const availQty = parseFloat(item.available_quantity || 0)
if (availQty <= 0) {
ElMessage.warning(`库存不足 (余: ${availQty})`)
} else {
cartItems.value.push({
...item,
out_quantity: 1,
price: 0
})
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('查询出错')
}
} 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.borrower_name = ''
form.remark = ''
form.expected_return_time = ''
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
})
}
// --- 提交逻辑 ---
const submitForm = async () => {
if (!formRef.value) return
if (cartItems.value.length === 0) return ElMessage.warning('请先添加物品')
// ★ 核心修改:等待校验通过后再提交,否则报错会被拦截在前端
await formRef.value.validate(async (valid: boolean) => {
if (!valid) {
ElMessage.error('请填写完整的必填项(姓名、归还日期)')
return
}
if (!signatureFile.value) {
ElMessage.error('请领用人进行电子签名')
return
}
try {
loading.value = true
// 上传签名
const uploadRes = await uploadFile(signatureFile.value)
const signatureUrl = uploadRes.data.url
await request({
url: '/v1/transactions/borrow',
method: 'post',
data: {
items: cartItems.value,
...form, // 此时 form.expected_return_time 已经是 YYYY-MM-DD 格式
signature_path: signatureUrl
}
})
ElMessage.success('借用成功')
cartItems.value = []
form.borrower_name = ''
form.expected_return_time = ''
form.remark = ''
signatureFile.value = null
signaturePreviewUrl.value = ''
showCamera.value = false
} catch (error: any) {
console.error(error)
ElMessage.error(error.response?.data?.msg || '提交失败')
} finally {
loading.value = false
}
})
}
// --- 签名逻辑 ---
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()
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)
})
</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; }
/* 扫码区 */
.scan-section { margin-bottom: 20px; }
.camera-wrapper {
height: 25vh; background: #000; border-radius: 12px; overflow: hidden; position: relative; margin-bottom: 10px;
}
.scan-overlay {
position: absolute; bottom: 10px; right: 10px; z-index: 10;
}
.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;
}
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
/* 表单与购物车 */
.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>

View File

@ -0,0 +1,198 @@
<template>
<div class="app-container">
<div class="filter-container">
<el-radio-group v-model="status" @change="fetchData" style="margin-right: 20px">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="borrowed">未归还</el-radio-button>
<el-radio-button label="returned">已归还</el-radio-button>
</el-radio-group>
<el-input v-model="keyword" placeholder="搜索借用人/SKU" style="width: 200px" @keyup.enter="fetchData" />
<el-button type="primary" @click="fetchData">查询</el-button>
</div>
<el-table
:data="list"
border
stripe
style="margin-top:20px"
v-loading="loading"
:row-class-name="tableRowClassName"
>
<el-table-column prop="borrow_no" label="单号" width="180" show-overflow-tooltip />
<el-table-column prop="borrower_name" label="借用人" width="100" />
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column prop="borrow_time" label="借出时间" width="160" sortable />
<el-table-column label="归还时间 / 预计" min-width="200">
<template #default="{row}">
<div v-if="row.status === 'returned'">
<el-tag type="success" size="small">实际</el-tag>
{{ row.return_time || '-' }}
</div>
<div v-else>
<el-tag type="info" size="small">预计</el-tag>
{{ formatExpectedTime(row.expected_return_time).text }}
<span :class="formatExpectedTime(row.expected_return_time).cssClass">
{{ formatExpectedTime(row.expected_return_time).diffText }}
</span>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{row}">
<el-tag :type="row.status==='returned'?'success':'warning'">
{{ row.status==='returned'?'已还':'借出中' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="归还库位" min-width="120">
<template #default="{row}">
<span v-if="row.return_location">{{ row.return_location }}</span>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
<el-table-column label="电子签名" width="140" align="center">
<template #default="{row}">
<div style="display:flex; justify-content: center; gap:10px">
<el-popover trigger="hover" placement="top" v-if="row.borrow_signature" width="220">
<template #reference><el-tag size="small"></el-tag></template>
<img :src="row.borrow_signature" style="width:200px; border:1px solid #eee" />
</el-popover>
<el-popover trigger="hover" placement="top" v-if="row.return_signature" width="220">
<template #reference><el-tag type="success" size="small"></el-tag></template>
<img :src="row.return_signature" style="width:200px; border:1px solid #eee" />
</el-popover>
</div>
</template>
</el-table-column>
</el-table>
<el-pagination
background
layout="prev, pager, next"
:total="total"
@current-change="handlePage"
style="margin-top:10px; text-align:right"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import request from '@/utils/request'
import dayjs from 'dayjs' // 建议使用 dayjs 处理日期,如果没有安装,可以用原生 Date
import 'dayjs/locale/zh-cn' // 导入中文包
dayjs.locale('zh-cn')
const list = ref<any[]>([])
const total = ref(0)
// ★ 修改点:默认状态改为 'borrowed' (未归还)
const status = ref('borrowed')
const keyword = ref('')
const page = ref(1)
const loading = ref(false)
const fetchData = async () => {
loading.value = true
try {
const res = await request({
url: '/v1/transactions/records',
method: 'get',
params: {
page: page.value,
status: status.value,
keyword: keyword.value
}
})
list.value = res.data.items
total.value = res.data.total
} finally { loading.value = false }
}
const handlePage = (val: number) => {
page.value = val
fetchData()
}
// ★ 新增:格式化预计归还时间及倒计时逻辑
const formatExpectedTime = (timeStr: string) => {
if (!timeStr) return { text: '-', diffText: '', cssClass: '' }
// 后端返回的可能是 YYYY-MM-DD HH:mm:ss我们只取日期部分比较
const expected = dayjs(timeStr).startOf('day')
const today = dayjs().startOf('day')
const diffDays = expected.diff(today, 'day')
let diffText = ''
let cssClass = ''
// 这里的 timeStr 只展示前10位 (日期),或者展示完整
// 需求说单号规则是日期,预计归还也主要看日期
const displayTime = timeStr.substring(0, 10)
if (diffDays < 0) {
// 逾期
diffText = ` (逾期 ${Math.abs(diffDays)} 天)`
cssClass = 'text-danger'
} else if (diffDays === 0) {
// 今天到期
diffText = ` (今天到期)`
cssClass = 'text-warning'
} else {
// 剩余
diffText = ` (剩 ${diffDays} 天)`
cssClass = 'text-normal' // 正常,或者灰色
}
return { text: displayTime, diffText, cssClass }
}
// ★ 新增:表格行样式逻辑
const tableRowClassName = ({ row }: { row: any }) => {
// 如果已归还,不标颜色
if (row.status === 'returned') return ''
if (!row.expected_return_time) return ''
const expected = dayjs(row.expected_return_time).startOf('day')
const today = dayjs().startOf('day')
const diffDays = expected.diff(today, 'day')
if (diffDays < 0) {
return 'danger-row' // 逾期标红
} else if (diffDays === 0) {
return 'warning-row' // 当天标黄
}
return ''
}
onMounted(fetchData)
</script>
<style>
/* 注意Element Plus Table 的 row-class-name 样式通常不能放在 scoped 中 */
.el-table .warning-row {
--el-table-tr-bg-color: #fdf6ec !important; /* 浅橙色/黄色 */
}
.el-table .danger-row {
--el-table-tr-bg-color: #fef0f0 !important; /* 浅红色 */
color: #F56C6C; /* 文字变红增强警示 */
}
/* 文字颜色辅助类 */
.text-danger {
color: #F56C6C;
font-weight: bold;
}
.text-warning {
color: #E6A23C;
font-weight: bold;
}
.text-normal {
color: #909399;
}
</style>

View File

@ -1 +1,447 @@
<template><div style="padding:20px;"><h2>维修登记</h2></div></template>
<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="returnList.length > 0" type="success" size="small" effect="dark">
待还 {{ returnList.length }}
</el-tag>
</div>
</div>
</template>
<div class="scan-section">
<div v-if="showCamera" class="camera-wrapper">
<QrScanner @decode="onScanSuccess" />
<div class="scan-overlay">
<el-button type="info" size="small" bg text @click="showCamera = false" icon="Close">
关闭摄像头
</el-button>
</div>
</div>
<div v-else class="camera-placeholder" @click="showCamera = true">
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<span class="text">点击开启扫码</span>
</div>
<div class="input-box">
<el-input
v-model="barcode"
placeholder="扫描已借出物品条码"
@keyup.enter="scanItem"
clearable
ref="barcodeRef"
size="large"
>
<template #prefix>
<el-icon><Scissor /></el-icon>
</template>
<template #append>
<el-button @click="scanItem">识别</el-button>
</template>
</el-input>
</div>
</div>
<div class="cart-section">
<div v-if="returnList.length > 0">
<el-table :data="returnList" border stripe style="width: 100%">
<el-table-column prop="borrower_name" label="借用人" width="90" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column label="归还库位(可改)" min-width="160">
<template #default="{row}">
<el-input
v-model="row.return_location"
:placeholder="`原: ${row.current_location || '无'}`"
clearable
size="small"
>
<template #append v-if="row.return_location !== row.current_location">
<span style="color: #E6A23C; font-size: 12px;">变更</span>
</template>
</el-input>
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center" fixed="right">
<template #default="{$index}">
<el-button type="danger" icon="Delete" circle size="small" @click="returnList.splice($index, 1)" />
</template>
</el-table-column>
</el-table>
</div>
<el-empty v-else description="请扫描已借出的条码" :image-size="80" />
</div>
<div v-if="returnList.length > 0" class="form-section">
<el-divider content-position="left">还库确认</el-divider>
<div style="margin-bottom: 10px; font-size: 14px; color: #606266; padding: 0 10px;">
请库管员在此签字确认入库
</div>
<el-form label-position="top">
<el-form-item required>
<div class="signature-box" @click="openSignatureDialog">
<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>
</el-form-item>
</el-form>
<div class="bottom-actions">
<el-button @click="clearAll" icon="Refresh">清空</el-button>
<el-button type="success" size="large" :loading="loading" @click="preSubmitCheck" icon="Select">
确认归还
</el-button>
</div>
</div>
</el-card>
<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, nextTick, onUnmounted } from 'vue'
import request from '@/utils/request'
import { uploadFile } from '@/api/common/upload'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
import QrScanner from '@/components/QrScanner/index.vue'
// --- 状态 ---
const barcode = ref('')
const returnList = ref<any[]>([])
const loading = ref(false)
const showCamera = ref(false)
const barcodeRef = ref()
// 签名状态
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 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
barcode.value = trimCode
scanItem()
}
const scanItem = async () => {
const code = barcode.value.trim()
if(!code) return
try {
loading.value = true
const res = await request({
url: '/v1/transactions/return/scan',
method: 'get',
params: { barcode: code }
})
if(returnList.value.some(i => i.id === res.data.id)) {
ElMessage.warning('已在清单中')
barcode.value = ''
return
}
const item = res.data
// 默认将归还库位填为当前库位
item.return_location = item.current_location || ''
returnList.value.push(item)
barcode.value = ''
ElMessage.success('识别成功')
} catch(e) {
ElMessage.error('未找到该物品的未还记录')
} finally {
loading.value = false
nextTick(() => { barcodeRef.value?.focus() })
}
}
const clearAll = () => {
ElMessageBox.confirm('确定清空所有待还物品吗?', '提示', { type: 'warning' })
.then(() => {
returnList.value = []
signatureFile.value = null
signaturePreviewUrl.value = ''
barcode.value = ''
})
}
// --- 提交前检查 (库位变更警告) ---
const preSubmitCheck = async () => {
if (returnList.value.length === 0) return
if (!signatureFile.value) {
ElMessage.error('库管必须签字确认')
return
}
// 1. 处理空输入:默认为原库位
returnList.value.forEach(item => {
if (!item.return_location || item.return_location.trim() === '') {
item.return_location = item.current_location
}
})
// 2. 检测变更
const changedItems = returnList.value.filter(
item => item.return_location !== item.current_location
)
// 3. 弹窗确认
if (changedItems.length > 0) {
try {
await ElMessageBox.confirm(
`检测到 ${changedItems.length} 个物品的库位发生变更。\n\n请务必打印新的标签并贴在物品上\n\n是否确认继续`,
'库位变更提醒',
{
confirmButtonText: '已知晓,确认归还',
cancelButtonText: '取消',
type: 'warning',
center: true
}
)
submitReturn()
} catch {
// 用户取消
return
}
} else {
submitReturn()
}
}
const submitReturn = async () => {
loading.value = true
try {
const upRes = await uploadFile(signatureFile.value!)
await request({
url: '/v1/transactions/return',
method: 'post',
data: {
items: returnList.value,
signature_path: upRes.data.url
}
})
ElMessage.success('还库成功')
returnList.value = []
signatureFile.value = null
signaturePreviewUrl.value = ''
showCamera.value = false // 关闭摄像头
} catch(e: any) {
ElMessage.error(e.response?.data?.msg || '提交失败')
} finally {
loading.value = false
}
}
// --- 签名逻辑 (复刻) ---
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()
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)
})
</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; }
/* 扫码区 */
.scan-section { margin-bottom: 20px; }
.camera-wrapper {
height: 25vh; background: #000; border-radius: 12px; overflow: hidden; position: relative; margin-bottom: 10px;
}
.scan-overlay {
position: absolute; bottom: 10px; right: 10px; z-index: 10;
}
.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;
}
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
/* 表单与购物车 */
.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>

View File

@ -1 +0,0 @@
<template><div style="padding:20px;"><h2>报废处理</h2></div></template>