Files
KCGL/inventory-web/src/views/transaction/borrow.vue
dxc a2b1a62132 feat: add RBAC and field masking for borrow/return/records pages
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 14:05:52 +08:00

601 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="app-container mobile-optimized">
<el-card class="box-card" shadow="never">
<template #header>
<div class="card-header">
<div class="title-box">
<span>借库作业 (领用人签字)</span>
<el-tag v-if="cartItems.length > 0" type="warning" size="small" effect="dark">
已选 {{ cartItems.length }}
</el-tag>
</div>
</div>
</template>
<div class="scan-section">
<div v-if="userStore.hasPermission('op_borrow:operation')" class="camera-placeholder" @click="showCamera = true">
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<span class="text">点击开启全屏扫码</span>
</div>
<div v-else class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
<span class="text">无扫码权限</span>
</div>
<div class="input-box">
<el-input
v-model="barcodeInput"
placeholder="扫描或输入条码回车"
@keyup.enter="handleManualInput"
clearable
ref="barcodeRef"
size="large"
:disabled="!userStore.hasPermission('op_borrow:operation')"
>
<template #prefix>
<el-icon><Scissor /></el-icon>
</template>
<template #append>
<el-button @click="handleManualInput" :disabled="!userStore.hasPermission('op_borrow:operation')">添加</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 v-if="hasColumnPermission('name')" prop="name" label="物品名称" min-width="120" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('sku')" prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('available_quantity')" 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 v-if="hasColumnPermission('out_quantity')" label="借用数" width="130" align="center">
<template #default="{row}">
<el-input-number
v-model="row.out_quantity"
:min="1"
:max="parseFloat(row.available_quantity)"
size="small"
style="width: 100px"
:disabled="!userStore.hasPermission('op_borrow:operation')"
/>
</template>
</el-table-column>
<el-table-column v-if="userStore.hasPermission('op_borrow:operation')" 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" v-if="userStore.hasPermission('op_borrow:operation')">
<div v-if="signaturePreviewUrl" class="signed-img">
<img :src="signaturePreviewUrl" alt="签名" />
<span class="re-sign-tip">点击重签</span>
</div>
<div v-else class="unsigned-placeholder">
<el-icon :size="24"><EditPen /></el-icon>
<span>点击此处进行全屏签名</span>
</div>
</div>
<div v-else class="signature-box" style="background-color: #f5f5f5; cursor: not-allowed;">
<div class="unsigned-placeholder">
<el-icon :size="24"><EditPen /></el-icon>
<span>无签名权限</span>
</div>
</div>
</el-form-item>
<div class="bottom-actions">
<el-button v-if="userStore.hasPermission('op_borrow:operation')" @click="clearAll" icon="Refresh">清空</el-button>
<el-button v-if="userStore.hasPermission('op_borrow:operation')" type="primary" size="large" :loading="loading" @click="submitForm" icon="Select">
确认借出
</el-button>
</div>
</el-form>
</div>
</el-card>
<div v-if="showCamera" class="fullscreen-scanner-overlay">
<div class="scanner-header">
<el-button circle icon="Close" @click="showCamera = false" class="close-btn" />
<span class="scanner-title">扫码模式</span>
<div class="scanner-placeholder"></div>
</div>
<div class="scanner-body">
<QrScanner @decode="onScanSuccess" />
</div>
<div class="scanner-footer">
<p>请将条码/二维码放入镜头范围</p>
<p v-if="cartItems.length > 0" class="current-count">已添加: {{ cartItems.length }} </p>
</div>
</div>
<el-dialog
v-model="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'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
borrower_name: 'op_borrow:borrower_name',
sku: 'op_borrow:sku',
available_quantity: 'op_borrow:available_quantity',
out_quantity: 'op_borrow:out_quantity',
// 其他字段可根据需要添加
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
}
// --- 状态定义 ---
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
// ★ 核心修改:只有当非全屏模式时,才自动聚焦输入框
if (!showCamera.value) {
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,
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-placeholder {
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
display: flex; flex-direction: column; justify-content: center; align-items: center;
color: #909399; margin-bottom: 10px; cursor: pointer;
transition: all 0.3s;
}
.camera-placeholder:active { background: #e6e8eb; }
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
/* ★ 全屏扫码层样式 */
.fullscreen-scanner-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #000;
z-index: 9999;
display: flex;
flex-direction: column;
}
.scanner-header {
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 15px;
background: rgba(0,0,0,0.6);
color: #fff;
position: absolute;
top: 0;
width: 100%;
z-index: 10;
}
.scanner-title { font-size: 16px; font-weight: bold; }
.close-btn { background: rgba(255,255,255,0.2); border: none; color: #fff; }
.scanner-body {
flex: 1;
width: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
/* 强制子组件QrScanner填满容器 */
:deep(.qr-scanner-container) {
width: 100% !important;
height: 100% !important;
border-radius: 0 !important;
}
.scanner-footer {
position: absolute;
bottom: 0;
width: 100%;
padding: 20px;
background: rgba(0,0,0,0.6);
color: #fff;
text-align: center;
z-index: 10;
}
.current-count { color: #67c23a; font-weight: bold; margin-top: 5px; font-size: 16px; }
/* 表单与购物车 */
.cart-section { margin-bottom: 20px; }
.form-section { background: #fff; }
.signature-box {
border: 1px dashed #dcdfe6; border-radius: 6px; height: 100px;
background: #fcfcfc; display: flex; justify-content: center; align-items: center; cursor: pointer;
}
.unsigned-placeholder { display: flex; flex-direction: column; align-items: center; color: #909399; font-size: 13px; }
.signed-img img { max-height: 90px; }
.re-sign-tip { display: block; text-align: center; font-size: 12px; color: #409EFF; margin-top: 2px; }
.bottom-actions { display: flex; justify-content: space-between; margin-top: 30px; }
.bottom-actions .el-button { width: 48%; }
/* 全屏签名弹窗 */
:deep(.fullscreen-signature-dialog .el-dialog__body) { padding: 0; height: 100%; display: flex; }
.signature-wrapper { display: flex; width: 100%; height: 100%; }
.signature-canvas-container { flex: 1; position: relative; background: #fff; overflow: hidden; }
.native-canvas { display: block; width: 100%; height: 100%; touch-action: none; }
.canvas-tip {
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
color: #ccc; font-size: 20px; pointer-events: none; opacity: 0.5; writing-mode: vertical-lr;
}
.signature-sidebar {
width: 120px; background: #333; color: #fff;
display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px 10px;
}
.sidebar-title { writing-mode: vertical-rl; font-size: 18px; letter-spacing: 5px; margin-bottom: 30px; font-weight: bold; }
.sidebar-actions { display: flex; flex-direction: column; gap: 20px; width: 100%; }
.sidebar-actions .el-button { width: 100%; margin: 0; height: 50px; }
@media screen and (max-width: 768px) {
.signature-wrapper { flex-direction: column; }
.signature-canvas-container { flex: 1; }
.canvas-tip { writing-mode: horizontal-tb; bottom: 50%; }
.signature-sidebar { width: 100%; height: auto; flex-direction: row; padding: 10px; justify-content: space-between; }
.sidebar-title { display: none; }
.sidebar-actions { flex-direction: row; width: 100%; gap: 10px; }
.sidebar-actions .el-button { flex: 1; height: 40px; }
}
</style>