588 lines
20 KiB
Vue
588 lines
20 KiB
Vue
<template>
|
||
<div class="space-y-6">
|
||
<!-- page-header -->
|
||
<section class="page-header">
|
||
<div>
|
||
<p class="page-kicker">Quick Checkout</p>
|
||
<h2 class="page-title">扫码借出</h2>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 扫码区域 -->
|
||
<div class="scanner-hero">
|
||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||
<div>
|
||
<p class="page-kicker">Mobile First</p>
|
||
<h3 class="text-lg font-semibold text-text-primary">扫码出库</h3>
|
||
</div>
|
||
<div class="form-inline-actions">
|
||
<button type="button" class="btn-primary w-full sm:w-auto text-sm" @click="showCameraScanner = true">
|
||
手机扫码
|
||
</button>
|
||
<button type="button" class="btn-secondary w-full sm:w-auto text-sm" @click="showManualEntry = !showManualEntry">
|
||
{{ showManualEntry ? '收起' : '手动输入' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="scanner-hero__stats">
|
||
<div class="scanner-hero__stat">
|
||
<p class="scanner-hero__stat-label">推荐方式</p>
|
||
<p class="scanner-hero__stat-value">手机摄像头</p>
|
||
</div>
|
||
<div class="scanner-hero__stat">
|
||
<p class="scanner-hero__stat-label">识别结果</p>
|
||
<p class="scanner-hero__stat-value">{{ equipmentInfo ? '已识别资产' : '等待扫码' }}</p>
|
||
</div>
|
||
<div class="scanner-hero__stat">
|
||
<p class="scanner-hero__stat-label">当前可借</p>
|
||
<p class="scanner-hero__stat-value">{{ equipmentInfo ? equipmentInfo.remaining_quantity : '-' }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="showManualEntry" class="form-panel">
|
||
<div class="form-panel__header">
|
||
<h4 class="form-panel__title">手动输入</h4>
|
||
</div>
|
||
<div class="field-stack">
|
||
<label class="field-label">资产编码</label>
|
||
<div class="flex flex-col gap-2 sm:flex-row">
|
||
<input
|
||
ref="scanInputRef"
|
||
v-model="scanCode"
|
||
type="text"
|
||
class="input"
|
||
placeholder="输入编码后回车"
|
||
@keyup.enter="handleScan"
|
||
@focus="handleFocus"
|
||
/>
|
||
<button type="button" class="btn-secondary w-full sm:w-auto sm:min-w-[100px] text-sm" @click="handleScan">
|
||
读取
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 资产信息卡片 -->
|
||
<section v-if="equipmentInfo" class="card mt-6">
|
||
<div class="list-header">
|
||
<div class="flex items-center gap-3">
|
||
<h3 class="section-title">资产信息</h3>
|
||
<span class="badge" :class="getStatusBadgeClass(equipmentInfo.status)">
|
||
{{ equipmentInfo.status }}
|
||
</span>
|
||
</div>
|
||
<button type="button" class="btn-secondary text-sm" @click="showCameraScanner = true">
|
||
重新扫码
|
||
</button>
|
||
</div>
|
||
<div class="field-grid">
|
||
<div class="field-stack">
|
||
<p class="field-label">资产编码</p>
|
||
<p class="text-sm text-text-primary font-mono">{{ equipmentInfo.equipment_code }}</p>
|
||
</div>
|
||
<div class="field-stack">
|
||
<p class="field-label">资产名称</p>
|
||
<p class="text-sm text-text-primary">{{ equipmentInfo.equipment_name }}</p>
|
||
</div>
|
||
<div class="field-stack">
|
||
<p class="field-label">资产型号</p>
|
||
<p class="text-sm text-text-secondary">{{ equipmentInfo.equipment_model || '-' }}</p>
|
||
</div>
|
||
<div class="field-stack">
|
||
<p class="field-label">地址</p>
|
||
<p class="text-sm text-text-secondary">{{ equipmentInfo.storageLocation?.name || '-' }}</p>
|
||
</div>
|
||
<div class="field-stack">
|
||
<p class="field-label">单价</p>
|
||
<p class="text-sm text-text-secondary">{{ formatCurrency(equipmentInfo.unit_price) }}</p>
|
||
</div>
|
||
<div class="field-stack">
|
||
<p class="field-label">可用数量</p>
|
||
<p class="text-sm font-medium" :class="equipmentInfo.remaining_quantity > 0 ? 'text-status-success' : 'text-status-error'">
|
||
{{ equipmentInfo.remaining_quantity }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 已挂载配件列表 -->
|
||
<div v-if="mountedChildren.length > 0" class="mt-3 pt-3 border-t border-border">
|
||
<h4 class="text-xs font-medium text-text-primary mb-2">已挂载配件({{ mountedChildren.length }} 件)</h4>
|
||
<div class="space-y-1">
|
||
<div
|
||
v-for="child in mountedChildren"
|
||
:key="child.id"
|
||
class="flex items-center justify-between text-sm"
|
||
>
|
||
<span class="text-text-primary">{{ child.equipment_name }}</span>
|
||
<span class="text-text-tertiary font-mono">{{ child.equipment_code }}</span>
|
||
</div>
|
||
</div>
|
||
<p class="text-xs text-text-tertiary mt-2">
|
||
配件价值:¥{{ mountedChildrenValue.toFixed(0) }}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between pt-3 border-t border-border mt-3">
|
||
<p class="text-xs text-text-secondary">
|
||
核对无误后再出库
|
||
</p>
|
||
<div class="flex items-center gap-2">
|
||
<button
|
||
v-if="!equipmentConfirmed"
|
||
class="btn-primary text-sm"
|
||
@click="equipmentConfirmed = true"
|
||
>
|
||
确认
|
||
</button>
|
||
<span
|
||
v-else
|
||
class="badge badge-success"
|
||
>
|
||
已确认
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<CameraScannerModal
|
||
v-model="showCameraScanner"
|
||
title="扫码借出"
|
||
description="对准资产二维码"
|
||
manual-placeholder="资产编码"
|
||
@detected="handleCameraDetected"
|
||
/>
|
||
|
||
<!-- 借用表单 -->
|
||
<section v-if="equipmentInfo" class="card">
|
||
<h3 class="section-title mb-4">使用者信息</h3>
|
||
<div class="space-y-4">
|
||
<div class="field-stack">
|
||
<label class="field-label">使用者 *</label>
|
||
<div class="space-y-3">
|
||
<input
|
||
v-model.trim="personKeyword"
|
||
type="text"
|
||
class="input"
|
||
placeholder="输入花名 / 姓名 / 证件号进行搜索"
|
||
/>
|
||
<select
|
||
v-model.number="selectedPersonId"
|
||
class="select hidden md:block"
|
||
>
|
||
<option value="" disabled>请选择使用者</option>
|
||
<option
|
||
v-for="person in filteredPersons"
|
||
:key="person.id"
|
||
:value="person.id"
|
||
>
|
||
{{ person.nickname }} · {{ person.real_name }}{{ person.department ? `(${person.department})` : '' }}
|
||
</option>
|
||
</select>
|
||
<div class="touch-option-list md:hidden">
|
||
<button
|
||
v-for="person in topFilteredPersons"
|
||
:key="person.id"
|
||
type="button"
|
||
class="touch-option"
|
||
:class="{ 'is-active': selectedPersonId === person.id }"
|
||
@click="selectedPersonId = person.id"
|
||
>
|
||
<div class="flex items-start justify-between gap-3">
|
||
<div>
|
||
<p class="text-sm font-medium text-text-primary">{{ person.nickname }} · {{ person.real_name }}</p>
|
||
<p class="mt-1 text-xs text-text-secondary">
|
||
{{ person.department || '未填写部门' }}
|
||
<span v-if="person.phone"> · {{ person.phone }}</span>
|
||
</p>
|
||
</div>
|
||
<span v-if="selectedPersonId === person.id" class="badge badge-success">已选中</span>
|
||
</div>
|
||
</button>
|
||
<p v-if="topFilteredPersons.length === 0" class="text-center text-sm text-text-tertiary py-4">
|
||
当前没有匹配到使用者,请换个关键词再试。
|
||
</p>
|
||
</div>
|
||
<p class="text-xs text-text-tertiary">
|
||
没有对应人员?前往
|
||
<router-link
|
||
:to="{ path: '/settings', query: { tab: 'persons' } }"
|
||
class="font-medium text-accent hover:underline"
|
||
>
|
||
人员维护
|
||
</router-link>
|
||
新增。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div v-if="selectedPerson">
|
||
<div class="panel">
|
||
<div class="field-grid text-sm">
|
||
<div>
|
||
<span class="text-text-tertiary">花名:</span>
|
||
<span class="text-text-primary">{{ selectedPerson.nickname }}</span>
|
||
</div>
|
||
<div>
|
||
<span class="text-text-tertiary">姓名:</span>
|
||
<span class="text-text-primary">{{ selectedPerson.real_name }}</span>
|
||
</div>
|
||
<div v-if="selectedPerson.department">
|
||
<span class="text-text-tertiary">部门:</span>
|
||
<span class="text-text-primary">{{ selectedPerson.department }}</span>
|
||
</div>
|
||
<div v-if="selectedPerson.phone">
|
||
<span class="text-text-tertiary">电话:</span>
|
||
<span class="text-text-primary">{{ selectedPerson.phone }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="field-stack">
|
||
<div class="flex items-center justify-between">
|
||
<label class="field-label">预计归还时间</label>
|
||
<label class="flex items-center space-x-2 text-sm text-text-secondary cursor-pointer">
|
||
<input
|
||
v-model="borrowForm.is_long_term"
|
||
type="checkbox"
|
||
class="h-4 w-4 rounded border-border bg-bg-secondary text-accent-primary focus:ring-accent-primary/20"
|
||
/>
|
||
<span>在职长期</span>
|
||
</label>
|
||
</div>
|
||
<input
|
||
v-model="borrowForm.expected_return_time"
|
||
type="datetime-local"
|
||
class="input"
|
||
:disabled="borrowForm.is_long_term"
|
||
/>
|
||
<p class="text-xs text-text-tertiary">
|
||
勾选后不设具体归还日期
|
||
</p>
|
||
</div>
|
||
<div class="field-stack">
|
||
<label class="field-label">备注</label>
|
||
<textarea
|
||
v-model="borrowForm.notes"
|
||
class="input"
|
||
rows="3"
|
||
placeholder="请输入备注信息"
|
||
></textarea>
|
||
</div>
|
||
<div class="mobile-sticky-actions md:static">
|
||
<button
|
||
@click="handleSubmit"
|
||
:disabled="!canSubmit"
|
||
class="btn-primary w-full"
|
||
>
|
||
{{ loading ? '提交中...' : '确认出库' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 成功消息 -->
|
||
<section v-if="successInfo" class="card border border-status-success/40 bg-status-success/5">
|
||
<div class="flex items-start gap-3">
|
||
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-status-success/20 flex items-center justify-center">
|
||
<svg class="w-5 h-5 text-status-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<h3 class="text-base font-medium text-status-success mb-1">出库成功</h3>
|
||
<p class="text-sm text-text-secondary">资产 {{ successInfo.equipment_name }} ({{ successInfo.equipment_code }})已成功出库。</p>
|
||
<p class="text-sm text-text-tertiary mt-2">
|
||
出库人:{{ successInfo.person_display }};时间:{{ successInfo.borrow_time }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
import { scanEquipment } from '@/api/equipment'
|
||
import { scanBorrow } from '@/api/borrow'
|
||
import { getPersonList } from '@/api/person'
|
||
import { getChildren } from '@/api/mount'
|
||
import type { Equipment, Person } from '@/types'
|
||
import type { ChildEquipment } from '@/api/mount'
|
||
import { formatCurrency, playBeepSound, playErrorSound } from '@/utils/format'
|
||
import CameraScannerModal from '@/components/CameraScannerModal.vue'
|
||
|
||
const scanCode = ref('')
|
||
const scanInputRef = ref<HTMLInputElement>()
|
||
const route = useRoute()
|
||
const showCameraScanner = ref(false)
|
||
const showManualEntry = ref(window.matchMedia('(pointer: fine)').matches)
|
||
const equipmentInfo = ref<Equipment | null>(null)
|
||
const equipmentConfirmed = ref(false)
|
||
const mountedChildren = ref<ChildEquipment[]>([])
|
||
const mountedChildrenValue = ref(0)
|
||
const successInfo = ref<{
|
||
equipment_name: string
|
||
equipment_code: string
|
||
person_display: string
|
||
borrow_time: string
|
||
} | null>(null)
|
||
const loading = ref(false)
|
||
|
||
const borrowForm = ref({
|
||
expected_return_time: '',
|
||
notes: '',
|
||
is_long_term: false
|
||
})
|
||
|
||
const personKeyword = ref('')
|
||
const selectedPersonId = ref<number | null>(null)
|
||
const persons = ref<Person[]>([])
|
||
let searchTimer: number | null = null
|
||
let latestPersonRequestId = 0
|
||
|
||
const canSubmit = computed(() => {
|
||
const hasEquipment = Boolean(equipmentInfo.value)
|
||
const personId = selectedPersonId.value
|
||
const hasPerson = personId !== null && !Number.isNaN(personId)
|
||
return hasEquipment && hasPerson && equipmentConfirmed.value && !loading.value
|
||
})
|
||
|
||
const filteredPersons = computed(() => {
|
||
if (!personKeyword.value) {
|
||
return persons.value
|
||
}
|
||
const keyword = personKeyword.value.trim().toLowerCase()
|
||
return persons.value.filter((person) => {
|
||
return (
|
||
person.nickname.toLowerCase().includes(keyword) ||
|
||
person.real_name.toLowerCase().includes(keyword) ||
|
||
(person.id_number && person.id_number.toLowerCase().includes(keyword)) ||
|
||
(person.department && person.department.toLowerCase().includes(keyword)) ||
|
||
Boolean(person.department_names?.some((name) => name.toLowerCase().includes(keyword)))
|
||
)
|
||
})
|
||
})
|
||
|
||
const topFilteredPersons = computed(() => filteredPersons.value.slice(0, 8))
|
||
|
||
const selectedPerson = computed(() => {
|
||
if (!selectedPersonId.value) return null
|
||
return persons.value.find((person) => person.id === selectedPersonId.value) || null
|
||
})
|
||
|
||
const getStatusBadgeClass = (status: string) => {
|
||
switch (status) {
|
||
case '可用':
|
||
return 'badge-success'
|
||
case '已借出':
|
||
return 'badge-warning'
|
||
case '维护中':
|
||
return 'badge-error'
|
||
case '已报废':
|
||
return 'badge-neutral'
|
||
default:
|
||
return 'badge-neutral'
|
||
}
|
||
}
|
||
|
||
const handleFocus = () => {
|
||
scanCode.value = ''
|
||
}
|
||
|
||
const handleCameraDetected = async (value: string) => {
|
||
scanCode.value = value.trim()
|
||
await handleScan()
|
||
}
|
||
|
||
const handleScan = async () => {
|
||
if (!scanCode.value) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const response = await scanEquipment(scanCode.value)
|
||
if (response.data) {
|
||
equipmentInfo.value = response.data
|
||
equipmentConfirmed.value = false
|
||
successInfo.value = null
|
||
mountedChildren.value = []
|
||
mountedChildrenValue.value = 0
|
||
selectedPersonId.value = null
|
||
personKeyword.value = ''
|
||
borrowForm.value = {
|
||
expected_return_time: '',
|
||
notes: '',
|
||
is_long_term: false
|
||
}
|
||
playBeepSound()
|
||
|
||
// 获取已挂载的子资产
|
||
try {
|
||
const childrenResponse = await getChildren(response.data.id)
|
||
if (childrenResponse.data) {
|
||
mountedChildren.value = childrenResponse.data.children
|
||
mountedChildrenValue.value = childrenResponse.data.total_children_value
|
||
}
|
||
} catch (e) {
|
||
// 忽略获取子资产失败的错误
|
||
}
|
||
|
||
// 清空扫码框,准备下次扫码
|
||
scanCode.value = ''
|
||
nextTick(() => {
|
||
scanInputRef.value?.focus()
|
||
})
|
||
}
|
||
} catch (error: any) {
|
||
playErrorSound()
|
||
alert(error.message || '扫码失败,请检查资产编码是否正确')
|
||
scanCode.value = ''
|
||
}
|
||
}
|
||
|
||
const handleSubmit = async () => {
|
||
if (!equipmentInfo.value || selectedPersonId.value === null || Number.isNaN(selectedPersonId.value)) {
|
||
return
|
||
}
|
||
|
||
loading.value = true
|
||
try {
|
||
const response = await scanBorrow({
|
||
equipment_code: equipmentInfo.value.equipment_code,
|
||
person_id: Number(selectedPersonId.value),
|
||
expected_return_time: borrowForm.value.is_long_term
|
||
? undefined
|
||
: borrowForm.value.expected_return_time || undefined,
|
||
notes: borrowForm.value.notes || undefined,
|
||
is_long_term: borrowForm.value.is_long_term
|
||
})
|
||
if (response.data) {
|
||
alert('出库成功!')
|
||
const personDisplay = selectedPerson.value
|
||
? `${selectedPerson.value.nickname}(${selectedPerson.value.real_name})`
|
||
: `ID ${selectedPersonId.value}`
|
||
successInfo.value = {
|
||
equipment_name: equipmentInfo.value.equipment_name,
|
||
equipment_code: equipmentInfo.value.equipment_code,
|
||
person_display: personDisplay,
|
||
borrow_time: response.data.borrow_time
|
||
? new Date(response.data.borrow_time).toLocaleString()
|
||
: new Date().toLocaleString()
|
||
}
|
||
if (typeof response.data.remaining_quantity === 'number') {
|
||
equipmentInfo.value.remaining_quantity = response.data.remaining_quantity
|
||
}
|
||
if (response.data.equipment_status) {
|
||
equipmentInfo.value.status = response.data.equipment_status
|
||
} else if (equipmentInfo.value.remaining_quantity === 0) {
|
||
equipmentInfo.value.status = '已借出'
|
||
}
|
||
equipmentInfo.value = null
|
||
mountedChildren.value = []
|
||
mountedChildrenValue.value = 0
|
||
selectedPersonId.value = null
|
||
personKeyword.value = ''
|
||
equipmentConfirmed.value = false
|
||
borrowForm.value = {
|
||
expected_return_time: '',
|
||
notes: '',
|
||
is_long_term: false
|
||
}
|
||
nextTick(() => {
|
||
scanInputRef.value?.focus()
|
||
})
|
||
}
|
||
} catch (error: any) {
|
||
alert(error.message || '出库失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const loadEquipmentFromRoute = async () => {
|
||
const routeEquipmentCode =
|
||
typeof route.query.equipment_code === 'string' ? route.query.equipment_code.trim() : ''
|
||
|
||
if (!routeEquipmentCode) {
|
||
return
|
||
}
|
||
|
||
scanCode.value = routeEquipmentCode
|
||
await handleScan()
|
||
}
|
||
|
||
const fetchPersons = async (keyword = '') => {
|
||
const requestId = ++latestPersonRequestId
|
||
try {
|
||
const response = await getPersonList({
|
||
page: 1,
|
||
pageSize: 100,
|
||
keyword: keyword || undefined,
|
||
status: 'active'
|
||
})
|
||
if (response.data && requestId === latestPersonRequestId) {
|
||
persons.value = response.data.list
|
||
if (selectedPersonId.value) {
|
||
const exists = persons.value.some((person) => person.id === selectedPersonId.value)
|
||
if (!exists) {
|
||
selectedPersonId.value = null
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('获取使用者列表失败', error)
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => personKeyword.value,
|
||
(value) => {
|
||
if (searchTimer) {
|
||
window.clearTimeout(searchTimer)
|
||
}
|
||
searchTimer = window.setTimeout(() => {
|
||
fetchPersons(value)
|
||
}, 300)
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => borrowForm.value.is_long_term,
|
||
(value) => {
|
||
if (value) {
|
||
borrowForm.value.expected_return_time = ''
|
||
}
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => equipmentInfo.value?.equipment_code,
|
||
() => {
|
||
equipmentConfirmed.value = false
|
||
}
|
||
)
|
||
|
||
onMounted(() => {
|
||
fetchPersons()
|
||
nextTick(() => {
|
||
scanInputRef.value?.focus()
|
||
})
|
||
loadEquipmentFromRoute()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (searchTimer) {
|
||
window.clearTimeout(searchTimer)
|
||
}
|
||
})
|
||
|
||
watch(
|
||
() => route.query.equipment_code,
|
||
() => {
|
||
loadEquipmentFromRoute()
|
||
}
|
||
)
|
||
</script>
|