2026-03-23 11:54:41 +08:00

588 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="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>