front/src/views/warehouse/parcelArrival/index.vue

498 lines
13 KiB
Vue

<template>
<div class="waybill-container">
<div class="waybill-layout">
<div class="form-section">
<div class="form-section-toolbar">
<a-checkbox v-model="keepScanFocus" size="small">
{{ $t('operation.common.keepScanFocus') }}
</a-checkbox>
<a-checkbox v-model="voiceEnabled" size="small">
{{ $t('operation.common.voiceBroadcast') }}
</a-checkbox>
</div>
<a-form :model="formData" layout="vertical" class="waybill-form">
<a-form-item
v-if="isAdmin"
:label="t('warehouse.common.customer')"
class="form-item-large"
>
<a-select
v-model="adminCustomerId"
allow-clear
allow-search
:placeholder="t('warehouse.common.selectCustomer')"
:options="customerOptions"
size="large"
class="large-input"
/>
</a-form-item>
<a-form-item :label="t('warehouse.parcelArrival.form.trackingNo')" class="form-item-large">
<a-input
ref="scanInputRef"
v-model="formData.trackingNo"
:placeholder="t('warehouse.parcelArrival.form.placeholder.scanOrInput')"
size="large"
class="large-input"
@press-enter="() => void handleSubmit()"
/>
</a-form-item>
<a-form-item :label="t('warehouse.common.warehouse')" class="form-item-large">
<a-input
:model-value="currentWarehouseLabel"
readonly
class="large-input readonly-field"
:placeholder="t('warehouse.common.noCurrentWarehouse')"
/>
</a-form-item>
<a-form-item>
<div class="form-actions">
<a-button class="reset-btn" @click="handleReset">
{{ t('warehouse.parcelArrival.actions.reset') }}
</a-button>
<a-button
v-if="hasPermission('Operation.ParcelArrival.Save')"
type="primary"
class="submit-btn"
:loading="submitting"
@click="handleSubmit"
>
{{ t('warehouse.parcelArrival.actions.arrive') }}
</a-button>
</div>
</a-form-item>
</a-form>
</div>
<div class="recent-orders-section">
<div class="list-toolbar">
<div class="toolbar-left">
<a-space wrap :size="8">
<a-input
v-model="keyword"
allow-clear
:placeholder="t('warehouse.parcelArrival.search.trackingNo')"
style="width: 200px"
@press-enter="handleSearch"
/>
<a-button type="primary" @click="handleSearch">
{{ t('warehouse.common.search') }}
</a-button>
</a-space>
<p
v-if="feedbackMessage"
class="scan-feedback-text"
:class="`is-${feedbackType}`"
>
{{ feedbackMessage }}
</p>
</div>
<div class="toolbar-right">
<div class="pagination-controls">
<PaginationPro
:total-count="totalCount"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
/>
</div>
</div>
</div>
<div ref="tableContainerRef" class="list-wrap">
<HandsontablePro
:columns="hotColumns"
:data="tableRows"
:total="totalCount"
:current-page="currentPage"
:page-size="pageSize"
:height="tableHeight"
:loading="listLoading"
:read-only="true"
:stretch-columns="true"
show-action-column
:action-column-width="72"
:action-renderer="actionRenderer"
@action-click="handleActionClick"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Modal } from '@arco-design/web-vue'
import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import { useOperationScanFocus } from '@/composables/useOperationScanFocus'
import { useOperationScanFeedback } from '@/composables/useOperationScanFeedback'
import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer'
import {
createWarehouseParcelArrival,
deleteWarehouseParcelArrival,
getWarehouseParcelArrivalList,
} from '@/api/warehouse'
import { useCurrentWarehouseStore } from '@/store'
import { hasPermission } from '@/utils/rbac-permission'
import { formatDateTime, normalizeListRow, pickField } from '@/utils/warehouseListMapper'
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
defineOptions({ name: 'WarehouseParcelArrival' })
const { t } = useI18n()
const { tableContainerRef, tableHeight } = useTableContainerHeight()
const scanInputRef = ref<{ focus?: () => void } | null>(null)
const { keepScanFocus, maybeRefocusScan } = useOperationScanFocus('parcelArrival', scanInputRef)
const {
voiceEnabled,
feedbackType,
feedbackMessage,
clearFeedback,
showError,
showSuccess,
showWarning,
} = useOperationScanFeedback('parcelArrival')
const {
isAdmin,
customerId: adminCustomerId,
customerOptions,
withCustomerId,
ensureMutationCustomerId,
} = useWarehouseAdminCustomer()
const currentWarehouseStore = useCurrentWarehouseStore()
const submitting = ref(false)
const listLoading = ref(false)
const keyword = ref('')
const tableRows = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(30)
const totalCount = ref(0)
const formData = reactive({
trackingNo: '',
})
const currentWarehouseLabel = computed(() => {
const id = currentWarehouseStore.warehouseId
if (!id) return ''
return currentWarehouseStore.warehouseName || String(id)
})
const hotColumns = computed(() => [
{ key: 'trackingNo', label: t('warehouse.parcelArrival.table.trackingNo'), width: 200 },
{ key: 'warehouseName', label: t('warehouse.parcelArrival.table.warehouse'), width: 160 },
{ key: 'createTime', label: t('warehouse.parcelArrival.table.createTime'), width: 180 },
{ key: 'createBy', label: t('warehouse.parcelArrival.table.createBy'), width: 120 },
])
function mapRow(raw: Record<string, unknown>) {
const row = normalizeListRow(raw)
return {
id: pickField<number>(row, 'id', 'Id'),
trackingNo: pickField(row, 'trackingNo', 'TrackingNo') ?? '-',
warehouseName: pickField(row, 'warehouseName', 'WarehouseName') ?? '-',
createTime: formatDateTime(pickField(row, 'createTime', 'CreateTime')),
createBy: pickField(row, 'createBy', 'CreateBy') ?? '-',
_raw: row,
}
}
async function loadList() {
listLoading.value = true
try {
const { items, total } = await getWarehouseParcelArrivalList(
withCustomerId({
page: currentPage.value,
pageSize: pageSize.value,
keyword: keyword.value || undefined,
warehouseId: currentWarehouseStore.warehouseId,
}),
)
tableRows.value = (items || []).map((item: Record<string, unknown>) => mapRow(item))
totalCount.value = Number(total || 0)
} catch (e: any) {
showError(e?.message || t('warehouse.parcelArrival.message.loadFailed'))
tableRows.value = []
totalCount.value = 0
} finally {
listLoading.value = false
}
}
function handlePageChange(page: number) {
currentPage.value = page
loadList()
}
function handlePageSizeChange(size: number) {
pageSize.value = size
currentPage.value = 1
loadList()
}
function handleSearch() {
currentPage.value = 1
loadList()
}
function handleReset(clearStatus = true) {
formData.trackingNo = ''
if (clearStatus) clearFeedback()
maybeRefocusScan()
}
async function handleSubmit() {
if (!hasPermission('Operation.ParcelArrival.Save')) return
const trackingNo = formData.trackingNo?.trim()
if (!trackingNo) {
showWarning(t('warehouse.parcelArrival.message.trackingRequired'))
maybeRefocusScan()
return
}
const warehouseId = currentWarehouseStore.warehouseId
if (!warehouseId) {
showWarning(t('warehouse.common.noCurrentWarehouse'))
maybeRefocusScan()
return
}
if (isAdmin.value && ensureMutationCustomerId() == null) {
showWarning(t('warehouse.common.selectCustomerRequired'))
maybeRefocusScan()
return
}
submitting.value = true
try {
await createWarehouseParcelArrival(
withCustomerId({
warehouseId,
trackingNo,
}),
)
showSuccess(t('warehouse.parcelArrival.message.arriveSuccess'))
handleReset(false)
currentPage.value = 1
await loadList()
maybeRefocusScan()
} catch (e: any) {
showError(e?.message || t('warehouse.parcelArrival.message.arriveFailed'))
maybeRefocusScan()
} finally {
submitting.value = false
}
}
function handleDelete(row: any) {
if (!hasPermission('Operation.ParcelArrival.Delete')) return
Modal.confirm({
title: t('warehouse.parcelArrival.actions.delete'),
content: row.trackingNo,
onOk: async () => {
await deleteWarehouseParcelArrival(row.id)
showSuccess(t('warehouse.parcelArrival.message.deleteSuccess'))
await loadList()
},
})
}
function actionRenderer(row: any) {
if (!hasPermission('Operation.ParcelArrival.Delete') || !row?.id) return ''
return wrapWarehouseActions([
renderWarehouseActionBtn('delete', t('warehouse.common.delete')),
])
}
function handleActionClick(row: any, _i: number, action?: string) {
if (action === 'delete') handleDelete(row)
}
watch(
() => currentWarehouseStore.warehouseId,
() => {
currentPage.value = 1
loadList()
},
)
onMounted(() => {
loadList()
})
</script>
<style lang="less" scoped>
.waybill-container {
padding: 16px;
background-color: #f0f3f4;
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.waybill-layout {
display: flex;
flex-direction: row;
align-items: stretch;
gap: 16px;
flex: 1;
min-height: 0;
min-width: 0;
}
.form-section {
background: #fff;
border-radius: 4px;
padding: 24px;
margin-bottom: 0;
flex: 3 1 0;
min-width: 0;
overflow-x: hidden;
overflow-y: auto;
align-self: stretch;
}
.form-section-toolbar {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 16px;
margin: -4px 0 12px;
flex-shrink: 0;
}
.waybill-form {
.form-item-large {
:deep(.arco-form-item-label) {
font-size: 14px;
font-weight: 500;
color: #262626;
}
}
.large-input {
width: 100%;
max-width: 100%;
height: 48px;
font-size: 16px;
}
.readonly-field {
:deep(.arco-input) {
background-color: #f7f8fa;
color: #4e5969;
cursor: default;
}
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 8px;
.reset-btn,
.submit-btn {
min-width: 100px;
height: 32px;
border-radius: 4px;
}
}
}
.recent-orders-section {
background: #fff;
border-radius: 4px;
padding: 0;
flex: 7 1 0;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
.list-toolbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16px 16px 0;
margin-bottom: 16px;
flex-shrink: 0;
flex-wrap: nowrap;
gap: 12px;
.toolbar-left {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.scan-feedback-text {
margin: 0;
font-size: 16px;
line-height: 1.5;
word-break: break-word;
&.is-error {
color: #f53f3f;
font-weight: 700;
}
&.is-success {
color: #00b42a;
font-weight: 600;
}
&.is-warning {
color: #d46b08;
font-weight: 600;
}
}
.toolbar-right .pagination-controls {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
flex-wrap: nowrap;
}
}
.list-wrap {
width: 100%;
flex: 1;
min-height: 0;
padding: 0 16px 16px;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
overscroll-behavior: contain;
touch-action: pan-x pan-y;
}
}
@media (max-width: 992px) {
.waybill-layout {
flex-direction: column;
overflow-y: auto;
}
.form-section {
flex: 0 0 auto;
max-width: none;
width: 100%;
min-width: 0;
}
.recent-orders-section {
flex: 1;
min-height: 360px;
}
}
</style>