front/src/views/warehouse/inboundNotice/index.vue
zzh fb1e566a21 优化仓储与店铺新增弹窗布局,修复 SKU 表单编译错误。
统一 OMS 表单样式与交互,补齐 i18n 文案。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 18:25:48 +08:00

598 lines
22 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.

<script setup lang="ts">
import { computed, onMounted, reactive, ref } 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 ModalPro from '@/components/ModalPro/index.vue'
import useModal from '@/components/ModalPro/useModal'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import {
deleteWarehouseInbound,
getWarehouseInboundDetail,
getWarehouseInboundList,
getWarehouseInboundSummary,
saveWarehouseInbound,
} from '@/api/warehouse'
import { getWarehouseList } from '@/api/system/warehouse'
import { getWarehouseSkuList } from '@/api/warehouse'
import { $baseMessage } from '@/utils'
import { hasPermission } from '@/utils/rbac-permission'
import {
buildWarehouseNameMap,
formatDateTime,
normalizeListRow,
pickField,
resolveWarehouseLabel,
} from '@/utils/warehouseListMapper'
defineOptions({ name: 'WarehouseInboundNotice' })
interface InboundLineForm {
skuId?: number
skuCode: string
plannedQty: number
}
interface InboundBoxForm {
key: string
boxSeqNo: string
markBoxNo: string
weightG?: number
lengthCm?: number
widthCm?: number
heightCm?: number
lines: InboundLineForm[]
}
const { t } = useI18n()
const { tableContainerRef, tableHeight } = useTableContainerHeight()
const receiptStatus = ref<string>()
const summary = ref({ forecasted: 0, received: 0, completed: 0 })
const keyword = ref('')
const warehouseId = ref<number>()
const warehouseOptions = ref<Array<{ label: string; value: number }>>([])
const warehouseNameMap = computed(() => buildWarehouseNameMap(warehouseOptions.value))
const skuOptions = ref<Array<{ label: string; value: number; code: string }>>([])
const listLoading = ref(false)
const tableRows = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(30)
const totalCount = ref(0)
const selectedBoxKeys = ref<string[]>([])
const activeBoxKey = ref('')
const statusTabs = [
{ value: undefined, labelKey: 'warehouse.inbound.status.all', countKey: null },
{ value: 'forecasted', labelKey: 'warehouse.inbound.status.forecasted', countKey: 'forecasted' as const },
{ value: 'received', labelKey: 'warehouse.inbound.status.received', countKey: 'received' as const },
{ value: 'completed', labelKey: 'warehouse.inbound.status.completed', countKey: 'completed' as const },
]
const detailVisible = ref(false)
const detailData = ref<any>(null)
const [registerFormModal, { openModal, closeModal }] = useModal() as any
const saving = ref(false)
const formModel = reactive({
id: 0,
warehouseId: undefined as number | undefined,
markReference: '',
inboundType: 'normal',
remark: '',
})
const boxRows = ref<InboundBoxForm[]>([])
const modalWarehouseName = computed(() => {
const wh = warehouseOptions.value.find((w) => w.value === formModel.warehouseId)
return wh?.label || ''
})
const boxCount = computed(() => boxRows.value.length)
const totalSkuCount = computed(() =>
boxRows.value.reduce((sum, box) => sum + box.lines.filter((l) => l.skuId).length, 0),
)
const totalPlannedQty = computed(() =>
boxRows.value.reduce(
(sum, box) => sum + box.lines.reduce((s, line) => s + (Number(line.plannedQty) || 0), 0),
0,
),
)
const hotColumns = computed(() => [
{ key: 'noticeNo', label: t('warehouse.inbound.table.noticeNo'), width: 180 },
{ key: 'markReference', label: t('warehouse.inbound.table.markReference'), width: 120 },
{ key: 'warehouseName', label: t('warehouse.inbound.table.warehouse'), width: 140 },
{ key: 'inboundType', label: t('warehouse.inbound.table.inboundType'), width: 100 },
{ key: 'receiptStatus', label: t('warehouse.inbound.table.receiptStatus'), width: 100 },
{ key: 'boxCount', label: t('warehouse.inbound.table.boxCount'), width: 80 },
{ key: 'plannedQty', label: t('warehouse.inbound.table.plannedQty'), width: 90 },
{ key: 'expectedArrivalTime', label: t('warehouse.inbound.table.expectedArrival'), width: 160 },
{ key: 'createTime', label: t('warehouse.inbound.table.createTime'), width: 160 },
])
function createBox(seq: number): InboundBoxForm {
return {
key: `box-${Date.now()}-${seq}`,
boxSeqNo: String(seq),
markBoxNo: `BOX-${String(seq).padStart(3, '0')}`,
lines: [{ skuId: undefined, skuCode: '', plannedQty: 1 }],
}
}
function resetBoxes() {
const first = createBox(1)
boxRows.value = [first]
selectedBoxKeys.value = []
activeBoxKey.value = first.key
}
function addBox() {
const next = boxRows.value.length + 1
const box = createBox(next)
boxRows.value = [...boxRows.value, box]
activeBoxKey.value = box.key
}
function addBoxes(count: number) {
const start = boxRows.value.length
const added = Array.from({ length: count }, (_, i) => createBox(start + i + 1))
boxRows.value = [...boxRows.value, ...added]
if (added.length) activeBoxKey.value = added[added.length - 1].key
}
function removeActiveBox() {
if (boxRows.value.length <= 1) return
const key = activeBoxKey.value
const remain = boxRows.value.filter((b) => b.key !== key)
boxRows.value = remain
selectedBoxKeys.value = selectedBoxKeys.value.filter((k) => k !== key)
activeBoxKey.value = remain[remain.length - 1]?.key || ''
}
function removeSelectedBoxes() {
if (!selectedBoxKeys.value.length) {
removeActiveBox()
return
}
const remain = boxRows.value.filter((b) => !selectedBoxKeys.value.includes(b.key))
boxRows.value = remain.length ? remain : [createBox(1)]
selectedBoxKeys.value = []
activeBoxKey.value = boxRows.value[boxRows.value.length - 1].key
}
function addLine(box: InboundBoxForm) {
box.lines.push({ skuId: undefined, skuCode: '', plannedQty: 1 })
}
function removeLine(box: InboundBoxForm, index: number) {
if (box.lines.length <= 1) return
box.lines.splice(index, 1)
}
function onSkuChange(line: InboundLineForm, skuId?: number) {
const sku = skuOptions.value.find((s) => s.value === skuId)
line.skuId = skuId
line.skuCode = sku?.code || ''
}
function mapRow(raw: Record<string, unknown>) {
const row = normalizeListRow(raw)
const status = String(pickField(row, 'receiptStatus', 'ReceiptStatus') ?? '')
const inboundType = String(pickField(row, 'inboundType', 'InboundType') ?? 'normal')
const whId = pickField<number>(row, 'warehouseId', 'WarehouseId')
return {
id: pickField<number>(row, 'id', 'Id'),
noticeNo: pickField(row, 'noticeNo', 'NoticeNo') ?? '-',
markReference: pickField(row, 'markReference', 'MarkReference') ?? '-',
warehouseName: resolveWarehouseLabel(whId, warehouseNameMap.value),
inboundType: t(`warehouse.inbound.type.${inboundType}`, inboundType),
receiptStatus: t(`warehouse.inbound.status.${status}`, status),
boxCount: pickField<number>(row, 'boxCount', 'BoxCount') ?? 0,
plannedQty: pickField<number>(row, 'plannedQty', 'PlannedQty') ?? 0,
expectedArrivalTime: formatDateTime(pickField(row, 'expectedArrivalTime', 'ExpectedArrivalTime')),
createTime: formatDateTime(pickField(row, 'createTime', 'CreateTime')),
_raw: row,
_status: status,
}
}
async function loadWarehouses() {
const { items } = await getWarehouseList({ page: 1, pageSize: 200 }).catch(() => ({ items: [] }))
warehouseOptions.value = (items || []).map((w: any) => ({
label: w.warehouseName || `#${w.id}`,
value: Number(w.id),
}))
}
async function loadSkus() {
const { items } = await getWarehouseSkuList({ page: 1, pageSize: 500 }).catch(() => ({ items: [] }))
skuOptions.value = (items || []).map((s: any) => ({
label: `${s.skuCode || s.SkuCode} - ${s.skuName || s.SkuName}`,
value: Number(s.id || s.Id),
code: s.skuCode || s.SkuCode,
}))
}
async function loadSummary() {
try {
const data = await getWarehouseInboundSummary({ warehouseId: warehouseId.value })
summary.value = {
forecasted: data?.forecasted ?? data?.Forecasted ?? 0,
received: data?.received ?? data?.Received ?? 0,
completed: data?.completed ?? data?.Completed ?? 0,
}
} catch {
summary.value = { forecasted: 0, received: 0, completed: 0 }
}
}
async function loadList() {
listLoading.value = true
try {
const { items, total } = await getWarehouseInboundList({
page: currentPage.value,
pageSize: pageSize.value,
receiptStatus: receiptStatus.value,
keyword: keyword.value || undefined,
warehouseId: warehouseId.value,
})
tableRows.value = (items || []).map((item: Record<string, unknown>) => mapRow(item))
totalCount.value = Number(total || 0)
} catch (e: any) {
$baseMessage(e?.message || t('warehouse.inbound.message.loadFailed'), 'error')
} 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()
}
async function showDetail(row: any) {
try {
detailData.value = await getWarehouseInboundDetail(row._raw?.id || row.id)
detailVisible.value = true
} catch (e: any) {
$baseMessage(e?.message || t('warehouse.inbound.message.detailFailed'), 'error')
}
}
function handleAdd() {
if (!hasPermission('Warehouse.InboundNotice.Save')) return
formModel.id = 0
formModel.warehouseId = warehouseId.value
formModel.markReference = ''
formModel.remark = ''
formModel.inboundType = 'normal'
resetBoxes()
openModal(true)
}
async function handleSave() {
if (!formModel.warehouseId) {
$baseMessage(t('warehouse.inbound.message.required'), 'warning')
return
}
const boxes = boxRows.value
.map((box) => ({
boxSeqNo: box.boxSeqNo,
markBoxNo: box.markBoxNo,
weightG: box.weightG,
lengthCm: box.lengthCm,
widthCm: box.widthCm,
heightCm: box.heightCm,
lines: box.lines
.filter((line) => line.skuId)
.map((line) => ({
skuId: line.skuId!,
skuCode: line.skuCode,
plannedQty: Number(line.plannedQty) || 1,
})),
}))
.filter((box) => box.lines.length > 0)
if (!boxes.length) {
$baseMessage(t('warehouse.inbound.message.boxRequired'), 'warning')
return
}
saving.value = true
try {
await saveWarehouseInbound({
id: formModel.id,
warehouseId: formModel.warehouseId,
markReference: formModel.markReference,
inboundType: formModel.inboundType,
remark: formModel.remark,
boxes,
})
$baseMessage(t('warehouse.inbound.message.saveSuccess'), 'success')
closeModal()
await loadSummary()
await loadList()
} catch (e: any) {
$baseMessage(e?.message || t('warehouse.inbound.message.saveFailed'), 'error')
} finally {
saving.value = false
}
}
function handleDelete(row: any) {
if (!hasPermission('Warehouse.InboundNotice.Delete')) return
Modal.confirm({
title: t('warehouse.inbound.actions.delete'),
content: row.noticeNo,
onOk: async () => {
await deleteWarehouseInbound(row._raw?.id || row.id)
$baseMessage(t('warehouse.inbound.message.deleteSuccess'), 'success')
await loadSummary()
await loadList()
},
})
}
function actionRenderer(row: any) {
const parts = [`<button type="button" class="hot-action-btn" data-action="detail">${t('warehouse.common.detail')}</button>`]
if (hasPermission('Warehouse.InboundNotice.Delete') && row._status === 'forecasted') {
parts.push(`<button type="button" class="hot-action-btn" data-action="delete">${t('warehouse.common.delete')}</button>`)
}
return parts.join('')
}
function handleActionClick(row: any, _i: number, action?: string) {
if (action === 'detail') showDetail(row)
if (action === 'delete') handleDelete(row)
}
onMounted(async () => {
await loadWarehouses()
await loadSkus()
await loadSummary()
await loadList()
})
</script>
<template>
<div class="warehouse-inbound-container warehouse-list-page">
<div class="warehouse-list-page-card">
<a-tabs
v-model:active-key="receiptStatus"
class="status-tabs"
@change="() => { currentPage = 1; loadList() }"
>
<a-tab-pane v-for="tab in statusTabs" :key="tab.value ?? 'all'">
<template #title>
{{ t(tab.labelKey) }}
<span v-if="tab.countKey" class="tab-count">({{ summary[tab.countKey] }})</span>
</template>
</a-tab-pane>
</a-tabs>
<div class="table-toolbar">
<div class="toolbar-left">
<a-space wrap :size="8">
<a-select
v-model="warehouseId"
allow-clear
:placeholder="t('warehouse.common.warehouse')"
:options="warehouseOptions"
style="width: 200px"
@change="() => { loadSummary(); handleSearch() }"
/>
<a-input
v-model="keyword"
allow-clear
:placeholder="t('warehouse.inbound.search.keyword')"
style="width: 200px"
@press-enter="handleSearch"
/>
<a-button type="primary" @click="handleSearch">{{ t('warehouse.common.search') }}</a-button>
<a-button v-if="hasPermission('Warehouse.InboundNotice.Save')" type="primary" @click="handleAdd">
{{ t('warehouse.common.add') }}
</a-button>
</a-space>
</div>
<div class="toolbar-right">
<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 ref="tableContainerRef" class="list-wrap">
<HandsontablePro
:columns="hotColumns"
:data="tableRows"
:height="tableHeight"
:loading="listLoading"
:stretch-columns="true"
show-action-column
:action-renderer="actionRenderer"
@action-click="handleActionClick"
/>
</div>
</div>
<a-drawer v-model:visible="detailVisible" :title="t('warehouse.inbound.drawer.detail')" :width="640">
<template v-if="detailData">
<p>{{ t('warehouse.inbound.table.noticeNo') }}: {{ detailData.noticeNo }}</p>
<p>{{ t('warehouse.inbound.table.markReference') }}: {{ detailData.markReference || '-' }}</p>
<div v-for="box in detailData.boxes || []" :key="box.id" class="box-block">
<div class="box-title">{{ t('warehouse.inbound.box') }}: {{ box.markBoxNo || box.boxSeqNo }}</div>
<a-table :data="box.lines || []" :pagination="false" size="small">
<template #columns>
<a-table-column :title="t('warehouse.inbound.line.skuCode')" data-index="skuCode" />
<a-table-column :title="t('warehouse.inbound.line.plannedQty')" data-index="plannedQty" />
<a-table-column :title="t('warehouse.inbound.line.receivedQty')" data-index="receivedQty" />
</template>
</a-table>
</div>
</template>
</a-drawer>
<ModalPro
:title="t('warehouse.inbound.modal.add')"
:width="800"
:mask-closable="false"
:body-style="{ maxHeight: '70vh', overflow: 'auto' }"
@register="registerFormModal"
@cancel="closeModal"
>
<div class="wh-form-modal">
<a-form
:model="formModel"
layout="horizontal"
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 16 }"
>
<a-form-item :label="t('warehouse.common.warehouse')" required>
<a-select v-model="formModel.warehouseId" :options="warehouseOptions" allow-search />
</a-form-item>
<a-form-item :label="t('warehouse.inbound.form.markReference')">
<a-input v-model="formModel.markReference" :placeholder="t('warehouse.inbound.form.markPlaceholder')" allow-clear />
</a-form-item>
<a-form-item :label="t('warehouse.inbound.table.inboundType')">
<a-select v-model="formModel.inboundType">
<a-option value="normal">{{ t('warehouse.inbound.type.normal') }}</a-option>
<a-option value="return">{{ t('warehouse.inbound.type.return') }}</a-option>
</a-select>
</a-form-item>
<a-form-item :label="t('warehouse.parcel.form.remark')">
<a-input v-model="formModel.remark" allow-clear />
</a-form-item>
</a-form>
<a-divider class="wh-form-divider" />
<div class="wh-form-section__head">
<div class="wh-form-section__title">{{ t('warehouse.inbound.form.boxSkuDetail') }}</div>
<div class="wh-form-meta">
<a-tag color="arcoblue">{{ t('warehouse.inbound.form.boxCount', { n: boxCount }) }}</a-tag>
<a-tag color="green">{{ t('warehouse.inbound.form.skuCount', { n: totalSkuCount }) }}</a-tag>
<a-tag color="purple">{{ t('warehouse.inbound.form.totalQty', { n: totalPlannedQty }) }}</a-tag>
</div>
</div>
<div class="wh-form-toolbar">
<a-button type="primary" size="small" @click="addBox">{{ t('warehouse.inbound.actions.addBox') }}</a-button>
<a-button size="small" @click="addBoxes(5)">{{ t('warehouse.inbound.actions.addBox5') }}</a-button>
<a-button size="small" status="danger" @click="removeSelectedBoxes">{{ t('warehouse.inbound.actions.removeBox') }}</a-button>
</div>
<a-tabs v-model:active-key="activeBoxKey" type="rounded" size="small">
<a-tab-pane v-for="box in boxRows" :key="box.key" :title="`${t('warehouse.inbound.box')} ${box.boxSeqNo}`">
<div class="wh-form-box-panel">
<a-row :gutter="12">
<a-col :span="8">
<a-form-item :label="t('warehouse.inbound.form.markBoxNo')" :label-col-props="{ span: 24 }">
<a-input v-model="box.markBoxNo" allow-clear />
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item :label="t('warehouse.inbound.form.boxWeight')" :label-col-props="{ span: 24 }">
<a-input-number v-model="box.weightG" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item :label="t('warehouse.inbound.form.boxLength')" :label-col-props="{ span: 24 }">
<a-input-number v-model="box.lengthCm" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item :label="t('warehouse.inbound.form.boxWidth')" :label-col-props="{ span: 24 }">
<a-input-number v-model="box.widthCm" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item :label="t('warehouse.inbound.form.boxHeight')" :label-col-props="{ span: 24 }">
<a-input-number v-model="box.heightCm" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<div class="wh-form-toolbar">
<a-button type="primary" size="small" @click="addLine(box)">{{ t('warehouse.inbound.actions.addSku') }}</a-button>
</div>
<a-table :data="box.lines" :pagination="false" size="small" :bordered="{ cell: true }">
<template #columns>
<a-table-column :title="t('warehouse.inbound.form.sku')">
<template #cell="{ record }">
<a-select
v-model="record.skuId"
:options="skuOptions"
allow-search
:placeholder="t('warehouse.inbound.form.selectSku')"
@change="(v: number) => onSkuChange(record, v)"
/>
</template>
</a-table-column>
<a-table-column :title="t('warehouse.inbound.line.plannedQty')" :width="130">
<template #cell="{ record }">
<a-input-number v-model="record.plannedQty" :min="1" style="width: 100%" />
</template>
</a-table-column>
<a-table-column :title="t('warehouse.common.delete')" :width="72" align="center">
<template #cell="{ rowIndex }">
<a-button type="text" status="danger" size="small" @click="removeLine(box, rowIndex)">
{{ t('warehouse.common.delete') }}
</a-button>
</template>
</a-table-column>
</template>
</a-table>
</div>
</a-tab-pane>
</a-tabs>
<a-alert
v-if="formModel.warehouseId && modalWarehouseName"
type="info"
:show-icon="true"
class="wh-form-divider"
>
{{ t('warehouse.common.warehouse') }}:{{ modalWarehouseName }}
</a-alert>
</div>
<template #footer>
<a-space :size="8">
<a-button @click="closeModal">{{ t('warehouse.common.cancel') }}</a-button>
<a-button type="primary" :loading="saving" @click="handleSave">{{ t('warehouse.common.save') }}</a-button>
</a-space>
</template>
</ModalPro>
</div>
</template>
<style scoped lang="less">
@import '@/styles/warehouse-list-page.less';
@import '@/styles/warehouse-form-modal.less';
.box-block {
margin-bottom: 16px;
}
.box-title {
font-weight: 500;
margin-bottom: 8px;
}
</style>