优化
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/pages/InboundOrderDetailView.vue'), 'utf8')
|
||||
|
||||
describe('InboundOrderDetailView', () => {
|
||||
it('maps the compatible route parameter to customer context without a detail-page selector', () => {
|
||||
expect(source).toContain('route.query.tenantId')
|
||||
expect(source).toContain('const customerId = computed')
|
||||
expect(source).toContain("name: 'inbound-orders'")
|
||||
expect(source).toContain(':customer-id="customerId"')
|
||||
expect(source).toContain(':inbound-id="inboundId"')
|
||||
expect(source).toContain('缺少所属企业信息,请从入库单列表重新打开')
|
||||
expect(source).not.toContain('PlatformTenantSelector')
|
||||
expect(source).not.toContain('租户')
|
||||
expect(source).toContain('<h1>入库详情</h1>')
|
||||
expect(source).toContain('货物测量、审核、费用及托盘库位记录')
|
||||
expect(source).toContain('class="surface-panel detail-page"')
|
||||
expect(source).toContain('class="detail-page-actions"')
|
||||
expect(source).toContain('返回入库单列表')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import InboundWorkflowWorkspace from '../components/InboundWorkflowWorkspace.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function routeValue(value: unknown) {
|
||||
if (Array.isArray(value)) return String(value[0] || '')
|
||||
return value === null || value === undefined ? '' : String(value)
|
||||
}
|
||||
|
||||
const inboundId = computed(() => routeValue(route.params.inboundId))
|
||||
const customerId = computed(() => routeValue(route.query.tenantId))
|
||||
|
||||
function backToList() {
|
||||
void router.push({ name: 'inbound-orders', query: customerId.value ? { tenantId: customerId.value } : undefined })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page inbound-detail-page">
|
||||
<section class="surface-panel detail-page">
|
||||
<header class="detail-page-header">
|
||||
<div class="detail-page-heading">
|
||||
<h1>入库详情</h1>
|
||||
<p>查看基本信息、货物测量、审核、费用及托盘库位记录。</p>
|
||||
</div>
|
||||
<div class="detail-page-actions">
|
||||
<el-button @click="backToList"><ArrowLeft :size="16" />返回入库单列表</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="detail-page-content">
|
||||
<InboundWorkflowWorkspace
|
||||
v-if="inboundId && customerId"
|
||||
:customer-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
/>
|
||||
<el-empty
|
||||
v-else
|
||||
:description="inboundId ? '缺少所属企业信息,请从入库单列表重新打开' : '缺少有效的入库单标识'"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbound-detail-page { min-width: 0; }
|
||||
.detail-page { overflow: hidden; }
|
||||
.detail-page-header { display: flex; min-height: 88px; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 24px; border-bottom: 1px solid var(--cc-border); }
|
||||
.detail-page-heading { min-width: 0; }
|
||||
.detail-page-heading h1 { margin: 0; color: var(--cc-text); font-size: 20px; line-height: 1.35; }
|
||||
.detail-page-heading p { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.detail-page-actions { display: flex; min-width: 0; align-items: center; justify-content: flex-end; gap: 12px; }
|
||||
.detail-page-content { min-width: 0; }
|
||||
@media (max-width: 720px) {
|
||||
.detail-page-header { align-items: flex-start; flex-direction: column; padding: 16px; }
|
||||
.detail-page-actions { width: 100%; align-items: stretch; flex-direction: column; }
|
||||
.detail-page-actions :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,261 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import InboundOrdersView from './InboundOrdersView.vue'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import { listPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { getPlatformInbound, listPlatformInbounds } from '../core/api'
|
||||
import type { InboundOrderDetail, InboundOrderSummary } from '../core/types'
|
||||
|
||||
vi.mock('../core/api', () => ({
|
||||
getPlatformInbound: vi.fn(),
|
||||
listPlatformInbounds: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/modules/warehouses', () => ({
|
||||
listPlatformWarehouses: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/modules/iam', () => ({
|
||||
listPlatformTenants: vi.fn(),
|
||||
}))
|
||||
|
||||
const customers: PlatformTenantResource[] = [
|
||||
{ id: '10001', tenantCode: 'CUS-001', tenantName: '示例企业', status: 'ACTIVE', legalName: '示例企业有限公司', legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null, contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null, registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null, internalRemark: null, createdAt: null, approvedAt: null },
|
||||
{ id: '10002', tenantCode: 'CUS-002', tenantName: '第二客户', status: 'ACTIVE', legalName: null, legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null, contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null, registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null, internalRemark: null, createdAt: null, approvedAt: null },
|
||||
]
|
||||
|
||||
const summary: InboundOrderSummary = {
|
||||
id: '19001', orderId: '18001', orderNo: 'ORD-001', inboundCode: 'RK-001', processStatus: 'WAIT_MEASURE',
|
||||
orderStatusSnapshot: 'APPROVED', measureReviewStatus: 'PENDING', feeStatus: 'UNPRICED', feeReviewStatus: null,
|
||||
notificationStatus: null, version: 1, expectedArrivalAt: '2026-08-04T10:00:00', customerId: '10001', customerName: '示例客户',
|
||||
companyId: '10001', companyName: '示例企业', warehouseId: '20001', warehouseName: '义乌收货仓', deliveryWarehouseId: null,
|
||||
deliveryWarehouseName: null, businessType: 'FBA', channelCode: 'SEA', channelName: '海运', taxMode: 'DDP', containerMode: null,
|
||||
customsInspection: false, destinationType: 'FBA', destinationWarehouseId: '30001', destinationCode: 'ONT8', destinationName: 'ONT8',
|
||||
holdFlag: false, currentHoldReason: null, totalPackageCount: 6, actualGrossWeightKg: 120, actualNetWeightKg: 110,
|
||||
actualVolumeCbm: 1.2, volumeWeightKg: 200, chargeableWeightKg: 200, chargeableVolumeCbm: null, palletCount: 1, locationCodes: 'A-01',
|
||||
measureCompletedAt: null, inboundedAt: null, expressCompanyCode: null, expressCompanyName: null, expressTrackingNo: null,
|
||||
loadingStatus: null, containerNo: null, cabinetNo: null, vesselName: null, voyageNo: null, sailingAt: null, portArrivalAt: null,
|
||||
createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00',
|
||||
}
|
||||
|
||||
const detail: InboundOrderDetail = {
|
||||
summary,
|
||||
publicRemark: '请优先安排测量',
|
||||
palletLocations: [{ allocationNo: 1, warehouseId: '20001', palletId: '21001', palletCode: 'P-001', palletCapacityCbm: 2, allocatedVolumeCbm: 1.2, allocatedPackageCount: 6, locationId: '22001', locationCode: 'A-01', reservationNo: 'RES-1', allocationStatus: 'PREPARED' }],
|
||||
files: [], internalNotes: [], statusHistory: [{ fromStatus: null, toStatus: 'WAIT_MEASURE', action: 'CREATE', reason: null, operatorId: '2', operatorName: '管理员', occurredAt: '2026-08-01T10:00:00' }],
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
function mountPage() {
|
||||
return mount(InboundOrdersView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
}
|
||||
|
||||
async function selectCustomer(wrapper: ReturnType<typeof mountPage>, customerId = '10001') {
|
||||
await flushPromises()
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), customerId })
|
||||
await flushPromises()
|
||||
return toolbar
|
||||
}
|
||||
|
||||
describe('InboundOrdersView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(listPlatformInbounds).mockResolvedValue({ records: [summary], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformInbound).mockResolvedValue(detail)
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: customers, page: 1, size: 200, total: 2, pages: 1 })
|
||||
vi.mocked(listPlatformWarehouses).mockResolvedValue({
|
||||
records: [
|
||||
{ id: '20001', tenantId: '10001', warehouseCode: 'YIWU', warehouseName: '义乌收货仓', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null },
|
||||
{ id: 20002, tenantId: '10001', warehouseCode: 'B001', warehouseName: '交货仓 B001', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null },
|
||||
],
|
||||
page: 1, size: 200, total: 2, pages: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('loads the customer-scoped list and renders the first-column view action', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
const customerField = (toolbar.props('fields') as Array<{ key: string; placeholder?: string }>).find((field) => field.key === 'customerId')
|
||||
expect(customerField?.placeholder).toBe('全部客户')
|
||||
expect((toolbar.props('modelValue') as { customerId: string }).customerId).toBe('')
|
||||
expect(listPlatformInbounds).toHaveBeenCalledWith(undefined, expect.objectContaining({ page: 1, size: 20 }))
|
||||
expect(listPlatformWarehouses).not.toHaveBeenCalled()
|
||||
const requestsBeforeSearch = vi.mocked(listPlatformInbounds).mock.calls.length
|
||||
toolbar.vm.$emit('search')
|
||||
await flushPromises()
|
||||
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeSearch + 1)
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ page: 1, size: 20 }))
|
||||
|
||||
await selectCustomer(wrapper)
|
||||
expect(listPlatformInbounds).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith('10001', 1, 200)
|
||||
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200 })
|
||||
expect(document.body.textContent).toContain('RK-001')
|
||||
expect(document.body.textContent).toContain('查看')
|
||||
expect(document.body.textContent).toContain('所属企业 示例企业有限公司')
|
||||
expect(document.body.textContent).not.toContain('租户')
|
||||
expect(wrapper.find('.inbound-tabs .is-active').text()).toContain('全部')
|
||||
})
|
||||
|
||||
it('uses backend-supported warehouse IDs from tenant-scoped options without coercing Long IDs', async () => {
|
||||
const wrapper = mountPage()
|
||||
const toolbar = await selectCustomer(wrapper)
|
||||
toolbar.vm.$emit('update:modelValue', {
|
||||
...toolbar.props('modelValue'),
|
||||
warehouseId: '20001',
|
||||
deliveryWarehouseId: '20002',
|
||||
})
|
||||
toolbar.vm.$emit('search')
|
||||
await flushPromises()
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({
|
||||
warehouseId: '20001',
|
||||
deliveryWarehouseId: '20002',
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps common filters compact and clears hidden advanced filters when collapsed', async () => {
|
||||
const wrapper = mountPage()
|
||||
const toolbar = await selectCustomer(wrapper)
|
||||
const fieldKeys = () => (toolbar.props('fields') as Array<{ key: string }>).map((field) => field.key)
|
||||
const widths = Object.fromEntries((toolbar.props('fields') as Array<{ key: string; width?: number }>).map((field) => [field.key, field.width]))
|
||||
expect(widths.customerId).toBe(260)
|
||||
expect(widths.processStatus).toBe(160)
|
||||
expect(widths.inboundCode).toBe(220)
|
||||
expect(widths.expectedArrivalRange).toBe(320)
|
||||
expect(fieldKeys()).toContain('expectedArrivalRange')
|
||||
expect(fieldKeys()).not.toContain('feeReviewStatus')
|
||||
|
||||
const advancedButton = () => wrapper.findAll('button').find((button) => button.text().includes('更多筛选') || button.text().includes('收起筛选'))
|
||||
await advancedButton()?.trigger('click')
|
||||
expect(fieldKeys()).toContain('feeReviewStatus')
|
||||
|
||||
toolbar.vm.$emit('update:modelValue', {
|
||||
...toolbar.props('modelValue'),
|
||||
feeReviewStatus: 'PENDING',
|
||||
destinationCode: 'ONT8',
|
||||
})
|
||||
await flushPromises()
|
||||
await advancedButton()?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(fieldKeys()).not.toContain('feeReviewStatus')
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({
|
||||
feeReviewStatus: undefined,
|
||||
destinationCode: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('passes the selected backend tab and opens an aggregate read-only detail', async () => {
|
||||
const wrapper = mountPage()
|
||||
await selectCustomer(wrapper)
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('.inbound-tab')].find((button) => button.textContent?.includes('待审核'))?.click()
|
||||
await flushPromises()
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({ tab: 'WAIT_REVIEW', page: 1 }))
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')].filter((button) => button.textContent?.trim() === '查看').at(-1)?.click()
|
||||
await flushPromises()
|
||||
expect(getPlatformInbound).toHaveBeenCalledWith('10001', '19001')
|
||||
expect(document.body.textContent).toContain('托盘与库位')
|
||||
expect(document.body.textContent).toContain('请优先安排测量')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('uses the app header as the page title and exposes compact accessible status tabs', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.inbound-page-heading').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('所有处理均进入单票工作台完成')
|
||||
const tabList = wrapper.find('[role="tablist"]')
|
||||
const tabButtons = tabList.findAll('[role="tab"]')
|
||||
const queryToolbar = wrapper.findComponent(QueryToolbar)
|
||||
expect(tabList.attributes('aria-label')).toBe('入库单处理状态')
|
||||
expect(tabList.element.parentElement?.classList.contains('table-toolbar')).toBe(true)
|
||||
expect(queryToolbar.element.compareDocumentPosition(tabList.element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(tabButtons).toHaveLength(5)
|
||||
expect(wrapper.find('.table-toolbar').text()).toContain('共 1 条 · 所属企业 全部客户')
|
||||
expect(wrapper.find('.table-toolbar').text()).toContain('刷新')
|
||||
expect(wrapper.text()).not.toContain('全部入库单')
|
||||
expect(tabButtons[0].attributes('aria-selected')).toBe('true')
|
||||
expect(tabButtons[0].attributes('tabindex')).toBe('0')
|
||||
await tabButtons[0].trigger('keydown', { key: 'ArrowRight' })
|
||||
await flushPromises()
|
||||
expect(tabButtons[1].attributes('aria-selected')).toBe('true')
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ tab: 'WAIT_MEASURE', page: 1 }))
|
||||
})
|
||||
|
||||
it('shows an actionable list error and recovers on retry', async () => {
|
||||
vi.mocked(listPlatformInbounds).mockRejectedValueOnce(new Error('network down')).mockResolvedValueOnce({ records: [summary], page: 1, size: 20, total: 1, pages: 1 })
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('入库单暂时无法加载')
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '重新加载')?.click()
|
||||
await flushPromises()
|
||||
expect(listPlatformInbounds).toHaveBeenCalledTimes(2)
|
||||
expect(document.body.textContent).toContain('RK-001')
|
||||
})
|
||||
|
||||
it('clears the selected list and detail when the selected customer changes', async () => {
|
||||
const wrapper = mountPage()
|
||||
const toolbar = await selectCustomer(wrapper)
|
||||
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), customerId: '10002' })
|
||||
await flushPromises()
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10002', expect.objectContaining({ page: 1 }))
|
||||
expect(listPlatformWarehouses).toHaveBeenLastCalledWith('10002', 1, 200)
|
||||
})
|
||||
|
||||
it('resets to all customers without sending an empty backend tenant ID', async () => {
|
||||
const wrapper = mountPage()
|
||||
const toolbar = await selectCustomer(wrapper)
|
||||
const requestsBeforeReset = vi.mocked(listPlatformInbounds).mock.calls.length
|
||||
const resetButton = wrapper.findAll('button').find((button) => button.text().includes('重置'))
|
||||
await resetButton?.trigger('click')
|
||||
await flushPromises()
|
||||
expect((toolbar.props('modelValue') as { customerId: string }).customerId).toBe('')
|
||||
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeReset + 1)
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ page: 1 }))
|
||||
expect(document.body.textContent).not.toContain('请选择所属企业')
|
||||
expect(document.body.textContent).not.toContain('请选择客户')
|
||||
})
|
||||
|
||||
it('loads another full-data page without a customer filter', async () => {
|
||||
vi.mocked(listPlatformInbounds).mockResolvedValue({ records: [summary], page: 1, size: 20, total: 60, pages: 3 })
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
wrapper.findComponent(DataTable).vm.$emit('update:page', 2)
|
||||
await flushPromises()
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ page: 2, size: 20 }))
|
||||
})
|
||||
|
||||
it('keeps the operation column first and follows the prototype field order with backend-backed columns', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/pages/InboundOrdersView.vue'), 'utf8')
|
||||
const toolbarSource = readFileSync(resolve(process.cwd(), 'src/shared/components/QueryToolbar.vue'), 'utf8')
|
||||
const columnSource = source.slice(source.indexOf('const columns:'))
|
||||
const operation = columnSource.indexOf("key: 'actions'")
|
||||
const inboundCode = columnSource.indexOf("key: 'inboundCode'")
|
||||
const orderStatus = columnSource.indexOf("key: 'orderStatus'")
|
||||
const measureReview = columnSource.indexOf("key: 'measureReviewStatus'")
|
||||
const feeStatus = columnSource.indexOf("key: 'feeStatus'")
|
||||
const customer = columnSource.indexOf("key: 'customerName'")
|
||||
const warehouse = columnSource.indexOf("key: 'warehouseName'")
|
||||
expect(operation).toBeGreaterThan(-1)
|
||||
expect(operation).toBeLessThan(inboundCode)
|
||||
expect(inboundCode).toBeLessThan(orderStatus)
|
||||
expect(orderStatus).toBeLessThan(measureReview)
|
||||
expect(measureReview).toBeLessThan(feeStatus)
|
||||
expect(feeStatus).toBeLessThan(customer)
|
||||
expect(customer).toBeLessThan(warehouse)
|
||||
expect(columnSource).toContain("fixed: 'left'")
|
||||
expect(toolbarSource).toMatch(/@media \(max-width: 720px\)[\s\S]*width: 100%/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,474 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ChevronDown, ChevronUp, ClipboardCheck, Eye, RefreshCw, SlidersHorizontal } from '@lucide/vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { getPlatformInbound, listPlatformInbounds } from '../core/api'
|
||||
import InboundHistoryPanel from '../core/InboundHistoryPanel.vue'
|
||||
import InboundSummaryPanel from '../core/InboundSummaryPanel.vue'
|
||||
import type { InboundListTab, InboundOrderDetail, InboundOrderSummary } from '../core/types'
|
||||
|
||||
type FilterModel = QueryModel & {
|
||||
customerId: string
|
||||
inboundCode: string
|
||||
orderNo: string
|
||||
warehouseId: string
|
||||
deliveryWarehouseId: string
|
||||
processStatus: string
|
||||
orderStatus: string
|
||||
measureReviewStatus: string
|
||||
customerName: string
|
||||
destinationCode: string
|
||||
feeStatus: string
|
||||
feeReviewStatus: string
|
||||
holdFlag: string
|
||||
expectedArrivalRange: string[]
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const tab = ref<InboundListTab | ''>('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<InboundOrderDetail | null>(null)
|
||||
const detailTarget = ref<InboundOrderSummary | null>(null)
|
||||
const result = ref({ records: [] as InboundOrderSummary[], total: 0 })
|
||||
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', orderNo: '', warehouseId: '', deliveryWarehouseId: '', processStatus: '', orderStatus: '', measureReviewStatus: '', customerName: '', destinationCode: '', feeStatus: '', feeReviewStatus: '', holdFlag: '', expectedArrivalRange: [] })
|
||||
const selectedCustomerId = ref('')
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const customersLoading = ref(false)
|
||||
const customerLoadError = ref<ApiErrorDetails | null>(null)
|
||||
const warehouseOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
const warehousesLoading = ref(false)
|
||||
const warehousesUnavailable = ref(false)
|
||||
const showAdvancedFilters = ref(false)
|
||||
let loadRequestId = 0
|
||||
let detailRequestId = 0
|
||||
let customerRequestId = 0
|
||||
let warehouseRequestId = 0
|
||||
let disposed = false
|
||||
|
||||
const tabs: Array<{ value: InboundListTab | ''; label: string; description: string }> = [
|
||||
{ value: '', label: '全部', description: '全部入库单' },
|
||||
{ value: 'WAIT_MEASURE', label: '待测量', description: '等待测量或补正' },
|
||||
{ value: 'WAIT_REVIEW', label: '待审核', description: '等待测量审核' },
|
||||
{ value: 'WAIT_FEE_SEND', label: '待费用/入库', description: '等待费用编排完成' },
|
||||
{ value: 'INBOUNDED', label: '已入库', description: '费用已发布并完成入库' },
|
||||
]
|
||||
|
||||
const advancedFilterKeys = new Set(['orderStatus', 'feeReviewStatus', 'customerName', 'destinationCode', 'holdFlag'])
|
||||
|
||||
const allQueryFields = computed<QueryField[]>(() => [
|
||||
{
|
||||
key: 'customerId',
|
||||
type: 'select',
|
||||
width: 260,
|
||||
placeholder: customerLoadError.value ? '所属企业加载失败' : customersLoading.value ? '正在加载客户' : '全部客户',
|
||||
options: customers.value.map((customer) => ({
|
||||
value: String(customer.id),
|
||||
label: `${customer.legalName?.trim() || customer.tenantName}(${customer.tenantCode || '无客户编码'})`,
|
||||
})),
|
||||
props: { 'aria-label': '所属企业', loading: customersLoading.value, filterable: true, disabled: Boolean(customerLoadError.value) || customers.value.length === 0 },
|
||||
},
|
||||
{ key: 'warehouseId', type: 'select', width: 180, placeholder: warehousesUnavailable.value ? '定位仓库加载失败' : '定位仓库', options: warehouseOptions.value, props: { 'aria-label': '定位仓库', loading: warehousesLoading.value, filterable: true, disabled: !selectedCustomerId.value || warehousesUnavailable.value } },
|
||||
{ key: 'deliveryWarehouseId', type: 'select', width: 180, placeholder: warehousesUnavailable.value ? '交货仓加载失败' : '交货仓', options: warehouseOptions.value, props: { 'aria-label': '交货仓', loading: warehousesLoading.value, filterable: true, disabled: !selectedCustomerId.value || warehousesUnavailable.value } },
|
||||
{ key: 'inboundCode', type: 'input', width: 220, placeholder: '入仓码' },
|
||||
{ key: 'orderNo', type: 'input', width: 220, placeholder: '头程订单号' },
|
||||
{ key: 'processStatus', type: 'select', width: 160, placeholder: '流程状态', options: [
|
||||
{ label: '待测量', value: 'WAIT_MEASURE' }, { label: '测量中', value: 'MEASURING' },
|
||||
{ label: '待测量审核', value: 'MEASURE_PENDING' }, { label: '测量已驳回', value: 'MEASURE_REJECTED' },
|
||||
{ label: '待计费', value: 'WAIT_PRICING' }, { label: '费用处理中', value: 'FEE_CONFIRMING' },
|
||||
{ label: '入库处理中', value: 'INBOUNDING' }, { label: '已入库', value: 'INBOUNDED' }, { label: '已作废', value: 'VOIDED' },
|
||||
] },
|
||||
{ key: 'orderStatus', type: 'input', width: 200, placeholder: '订单状态码' },
|
||||
{ key: 'measureReviewStatus', type: 'select', width: 160, placeholder: '测量审核状态', options: [
|
||||
{ label: '待审核', value: 'PENDING' }, { label: '审核通过', value: 'APPROVED' }, { label: '审核驳回', value: 'REJECTED' },
|
||||
] },
|
||||
{ key: 'feeStatus', type: 'select', width: 160, placeholder: '费用状态', options: [
|
||||
{ label: '未计费', value: 'UNPRICED' }, { label: '已试算', value: 'CALCULATED' },
|
||||
{ label: '已确认', value: 'CONFIRMED' }, { label: '已发送', value: 'SENT' }, { label: '已作废', value: 'VOIDED' },
|
||||
] },
|
||||
{ key: 'feeReviewStatus', type: 'input', width: 200, placeholder: '费用审核状态码' },
|
||||
{ key: 'customerName', type: 'input', width: 260, placeholder: '客户名称' },
|
||||
{ key: 'destinationCode', type: 'input', width: 220, placeholder: '目的仓编码' },
|
||||
{ key: 'holdFlag', type: 'select', width: 160, placeholder: '挂起状态', options: [{ label: '已挂起', value: 'true' }, { label: '未挂起', value: 'false' }] },
|
||||
{ key: 'expectedArrivalRange', type: 'daterange', width: 320, placeholder: '预计到达日期' },
|
||||
])
|
||||
|
||||
const queryFields = computed(() => showAdvancedFilters.value
|
||||
? allQueryFields.value
|
||||
: allQueryFields.value.filter((field) => !advancedFilterKeys.has(field.key)))
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 152, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'inboundCode', label: '入仓码', minWidth: 166, slotName: 'inbound' },
|
||||
{ key: 'orderStatus', label: '订单 / 流程状态', minWidth: 156, slotName: 'orderStatus' },
|
||||
{ key: 'measureReviewStatus', label: '测量数据审核状态', minWidth: 142, slotName: 'measureReviewStatus' },
|
||||
{ key: 'expectedArrivalAt', label: '预计到达时间', minWidth: 164, slotName: 'expectedArrivalAt' },
|
||||
{ key: 'feeStatus', label: '费用状态', minWidth: 124, slotName: 'feeStatus' },
|
||||
{ key: 'customerName', label: '客户', minWidth: 142, slotName: 'customerName' },
|
||||
{ key: 'warehouseName', label: '仓库', minWidth: 140, slotName: 'warehouseName' },
|
||||
{ key: 'deliveryWarehouseName', label: '交货仓', minWidth: 140, slotName: 'deliveryWarehouseName' },
|
||||
{ key: 'loadingStatus', label: '配载状态', minWidth: 110, slotName: 'loadingStatus' },
|
||||
{ key: 'holdFlag', label: '是否扣货', width: 96, slotName: 'holdFlag' },
|
||||
{ key: 'currentHoldReason', label: '扣货理由', minWidth: 150, slotName: 'currentHoldReason' },
|
||||
{ key: 'containerNo', label: '集装箱号', minWidth: 132, slotName: 'containerNo' },
|
||||
{ key: 'cabinetNo', label: '柜号', minWidth: 118, slotName: 'cabinetNo' },
|
||||
{ key: 'companyName', label: '公司', minWidth: 160, slotName: 'companyName' },
|
||||
{ key: 'businessType', label: '业务类型', minWidth: 112, slotName: 'businessType' },
|
||||
{ key: 'channelName', label: '渠道', minWidth: 138, slotName: 'channelName' },
|
||||
{ key: 'taxMode', label: '计税模式', minWidth: 108, slotName: 'taxMode' },
|
||||
{ key: 'containerMode', label: '装载模式', minWidth: 108, slotName: 'containerMode' },
|
||||
{ key: 'customsInspection', label: '商检验', width: 92, slotName: 'customsInspection' },
|
||||
{ key: 'sailingAt', label: '开船时间', minWidth: 164, slotName: 'sailingAt' },
|
||||
{ key: 'portArrivalAt', label: '到港时间', minWidth: 164, slotName: 'portArrivalAt' },
|
||||
{ key: 'totalPackageCount', label: '总箱数', width: 90, slotName: 'totalPackageCount' },
|
||||
{ key: 'actualGrossWeightKg', label: '总毛重(kg)', minWidth: 118, slotName: 'actualGrossWeightKg' },
|
||||
{ key: 'chargeableWeightKg', label: '计费重量(kg)', minWidth: 126, slotName: 'chargeableWeightKg' },
|
||||
{ key: 'actualVolumeCbm', label: '总体积(CBM)', minWidth: 122, slotName: 'actualVolumeCbm' },
|
||||
{ key: 'chargeableVolumeCbm', label: '计费体积(CBM)', minWidth: 132, slotName: 'chargeableVolumeCbm' },
|
||||
{ key: 'palletCount', label: '总托盘数', width: 100, slotName: 'palletCount' },
|
||||
{ key: 'locationCodes', label: '库位位置', minWidth: 124, slotName: 'locationCodes' },
|
||||
{ key: 'volumeWeightKg', label: '总材积重(kg)', minWidth: 126, slotName: 'volumeWeightKg' },
|
||||
{ key: 'actualNetWeightKg', label: '总净重(kg)', minWidth: 118, slotName: 'actualNetWeightKg' },
|
||||
{ key: 'destinationType', label: '目的仓类型', minWidth: 112, slotName: 'destinationType' },
|
||||
{ key: 'destinationName', label: '目的仓', minWidth: 132, slotName: 'destinationName' },
|
||||
{ key: 'measureCompletedAt', label: '测量完成时间', minWidth: 164, slotName: 'measureCompletedAt' },
|
||||
{ key: 'inboundedAt', label: '入库完成时间', minWidth: 164, slotName: 'inboundedAt' },
|
||||
{ key: 'expressCompanyName', label: '快递公司', minWidth: 120, slotName: 'expressCompanyName' },
|
||||
{ key: 'expressTrackingNo', label: '快递单号', minWidth: 150, slotName: 'expressTrackingNo' },
|
||||
{ key: 'createdAt', label: '创建日期', minWidth: 164, slotName: 'createdAt' },
|
||||
]
|
||||
|
||||
const selectedCustomerName = computed(() => {
|
||||
const customer = customers.value.find((item) => String(item.id) === selectedCustomerId.value)
|
||||
return customer?.legalName?.trim() || customer?.tenantName?.trim() || '全部客户'
|
||||
})
|
||||
const listEmptyText = '暂无符合条件的入库单'
|
||||
|
||||
function queryValue(value: string) { return value.trim() || undefined }
|
||||
function handleTabKeydown(event: KeyboardEvent, currentIndex: number) {
|
||||
const lastIndex = tabs.length - 1
|
||||
let nextIndex = currentIndex
|
||||
if (event.key === 'ArrowRight') nextIndex = currentIndex === lastIndex ? 0 : currentIndex + 1
|
||||
else if (event.key === 'ArrowLeft') nextIndex = currentIndex === 0 ? lastIndex : currentIndex - 1
|
||||
else if (event.key === 'Home') nextIndex = 0
|
||||
else if (event.key === 'End') nextIndex = lastIndex
|
||||
else return
|
||||
event.preventDefault()
|
||||
tab.value = tabs[nextIndex].value
|
||||
const tabButtons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')
|
||||
tabButtons?.[nextIndex]?.focus()
|
||||
}
|
||||
function dateParam(value: string | undefined, end = false) {
|
||||
if (!value) return undefined
|
||||
return `${value}T${end ? '23:59:59' : '00:00:00'}`
|
||||
}
|
||||
|
||||
async function loadWarehouseOptions() {
|
||||
const requestId = ++warehouseRequestId
|
||||
warehouseOptions.value = []
|
||||
warehousesUnavailable.value = false
|
||||
if (!selectedCustomerId.value) {
|
||||
warehousesLoading.value = false
|
||||
return
|
||||
}
|
||||
warehousesLoading.value = true
|
||||
try {
|
||||
const response = await listPlatformWarehouses(selectedCustomerId.value, 1, 200)
|
||||
if (disposed || requestId !== warehouseRequestId) return
|
||||
warehouseOptions.value = response.records.map((warehouse) => ({
|
||||
label: `${warehouse.warehouseName} · ${warehouse.warehouseCode}`,
|
||||
value: String(warehouse.id),
|
||||
}))
|
||||
} catch {
|
||||
if (disposed || requestId !== warehouseRequestId) return
|
||||
warehousesUnavailable.value = true
|
||||
} finally {
|
||||
if (!disposed && requestId === warehouseRequestId) warehousesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
const requestId = ++customerRequestId
|
||||
customersLoading.value = true
|
||||
customerLoadError.value = null
|
||||
try {
|
||||
const records: PlatformTenantResource[] = []
|
||||
let currentPage = 1
|
||||
let pages = 1
|
||||
do {
|
||||
const response = await listPlatformTenants({ page: currentPage, size: 200 })
|
||||
records.push(...response.records)
|
||||
pages = Math.max(1, response.pages)
|
||||
currentPage += 1
|
||||
} while (currentPage <= pages)
|
||||
if (disposed || requestId !== customerRequestId) return
|
||||
customers.value = records
|
||||
} catch (error) {
|
||||
if (disposed || requestId !== customerRequestId) return
|
||||
customers.value = []
|
||||
customerLoadError.value = getApiErrorDetails(error, '客户列表加载失败,暂时无法按所属企业筛选;入库单仍按全部客户查询')
|
||||
} finally {
|
||||
if (!disposed && requestId === customerRequestId) customersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInbounds() {
|
||||
const requestId = ++loadRequestId
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
const range = filters.value.expectedArrivalRange
|
||||
try {
|
||||
const response = await listPlatformInbounds(selectedCustomerId.value || undefined, {
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
tab: tab.value || undefined,
|
||||
inboundCode: queryValue(filters.value.inboundCode),
|
||||
orderNo: queryValue(filters.value.orderNo),
|
||||
warehouseId: queryValue(filters.value.warehouseId),
|
||||
deliveryWarehouseId: queryValue(filters.value.deliveryWarehouseId),
|
||||
processStatus: queryValue(filters.value.processStatus),
|
||||
orderStatus: queryValue(filters.value.orderStatus),
|
||||
measureReviewStatus: queryValue(filters.value.measureReviewStatus),
|
||||
customerName: queryValue(filters.value.customerName),
|
||||
destinationCode: queryValue(filters.value.destinationCode),
|
||||
feeStatus: queryValue(filters.value.feeStatus),
|
||||
feeReviewStatus: queryValue(filters.value.feeReviewStatus),
|
||||
holdFlag: filters.value.holdFlag === '' ? undefined : filters.value.holdFlag === 'true',
|
||||
expectedArrivalFrom: dateParam(range?.[0]),
|
||||
expectedArrivalTo: dateParam(range?.[1], true),
|
||||
})
|
||||
if (disposed || requestId !== loadRequestId) return
|
||||
result.value = { records: response.records, total: response.total }
|
||||
} catch (error) {
|
||||
if (disposed || requestId !== loadRequestId) return
|
||||
loadError.value = getApiErrorDetails(error, '入库单加载失败')
|
||||
} finally {
|
||||
if (!disposed && requestId === loadRequestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
if (page.value === 1) void loadInbounds()
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
function resetDetail() {
|
||||
detailRequestId += 1
|
||||
detail.value = null
|
||||
detailTarget.value = null
|
||||
detailError.value = null
|
||||
detailLoading.value = false
|
||||
}
|
||||
|
||||
async function openDetail(row: InboundOrderSummary) {
|
||||
const requestId = ++detailRequestId
|
||||
detailOpen.value = true
|
||||
detailTarget.value = row
|
||||
detailLoading.value = true
|
||||
detailError.value = null
|
||||
detail.value = null
|
||||
try {
|
||||
const response = await getPlatformInbound(recordCustomerId(row), row.id)
|
||||
if (disposed || requestId !== detailRequestId) return
|
||||
detail.value = response
|
||||
} catch (error) {
|
||||
if (disposed || requestId !== detailRequestId) return
|
||||
detailError.value = getApiErrorDetails(error, '入库单详情加载失败')
|
||||
} finally {
|
||||
if (!disposed && requestId === detailRequestId) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail() { resetDetail() }
|
||||
function recordCustomerId(row: InboundOrderSummary) { return row.customerId || row.companyId || selectedCustomerId.value }
|
||||
function openWorkspace(row: InboundOrderSummary) {
|
||||
const customerId = recordCustomerId(row)
|
||||
void router.push({ name: 'inbound-order-detail', params: { inboundId: row.id }, query: customerId ? { tenantId: customerId } : undefined })
|
||||
}
|
||||
function formatNumber(value: number | null, digits = 3) { return value === null ? '—' : value.toFixed(digits) }
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
function text(value: string | number | null | undefined) { return value === null || value === undefined || value === '' ? '—' : String(value) }
|
||||
function statusLabel(status: string) {
|
||||
return ({ WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待审核', MEASURE_REJECTED: '测量驳回', WAIT_PRICING: '待计费', FEE_CONFIRMING: '待发送费用', INBOUNDING: '入库处理中', INBOUNDED: '已入库', VOIDED: '已作废' } as Record<string, string>)[status] || status || '未知状态'
|
||||
}
|
||||
function statusType(status: string) {
|
||||
if (status === 'INBOUNDED') return 'success'
|
||||
if (status === 'MEASURE_REJECTED' || status === 'VOIDED') return 'danger'
|
||||
if (status === 'MEASURING' || status === 'INBOUNDING') return 'info'
|
||||
return 'warning'
|
||||
}
|
||||
function codeLabel(status: string | null | undefined, labels: Record<string, string>) { return status ? labels[status] || status : '—' }
|
||||
const measureReviewLabels: Record<string, string> = { PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回' }
|
||||
const feeStatusLabels: Record<string, string> = { UNPRICED: '未计费', CALCULATED: '已试算', CONFIRMED: '已确认', SENT: '已发送', VOIDED: '已作废' }
|
||||
const loadingStatusLabels: Record<string, string> = { NOT_LOADED: '未配载', LOADING: '配载中', LOADED: '已配载' }
|
||||
const taxModeLabels: Record<string, string> = { TAX_INCLUDED: '包税', TAX_EXCLUDED: '不包税', DDP: '包税', DDU: '不包税' }
|
||||
const containerModeLabels: Record<string, string> = { FCL: '整柜', LCL: '拼箱' }
|
||||
|
||||
function resetFilters() {
|
||||
selectedCustomerId.value = ''
|
||||
showAdvancedFilters.value = false
|
||||
filters.value = { ...filters.value, customerId: '', warehouseId: '', deliveryWarehouseId: '' }
|
||||
warehouseOptions.value = []
|
||||
warehousesUnavailable.value = false
|
||||
resetDetail()
|
||||
detailOpen.value = false
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
function toggleAdvancedFilters() {
|
||||
if (!showAdvancedFilters.value) {
|
||||
showAdvancedFilters.value = true
|
||||
return
|
||||
}
|
||||
showAdvancedFilters.value = false
|
||||
filters.value = {
|
||||
...filters.value,
|
||||
orderStatus: '',
|
||||
feeReviewStatus: '',
|
||||
customerName: '',
|
||||
destinationCode: '',
|
||||
holdFlag: '',
|
||||
}
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
watch(() => filters.value.customerId, (customerId) => {
|
||||
if (customerId === selectedCustomerId.value) return
|
||||
selectedCustomerId.value = customerId
|
||||
page.value = 1
|
||||
tab.value = ''
|
||||
filters.value = { ...filters.value, warehouseId: '', deliveryWarehouseId: '' }
|
||||
resetDetail()
|
||||
detailOpen.value = false
|
||||
void loadWarehouseOptions()
|
||||
void loadInbounds()
|
||||
})
|
||||
watch(tab, () => { page.value = 1; void loadInbounds() })
|
||||
watch([page, pageSize], () => { void loadInbounds() })
|
||||
onMounted(() => { void loadCustomers(); void loadInbounds() })
|
||||
onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId += 1; customerRequestId += 1; warehouseRequestId += 1 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page inbound-orders-page">
|
||||
<QueryToolbar v-model="filters" class="surface-panel inbound-query-toolbar" ariaLabel="入库单筛选" :fields="queryFields" :search-on-reset="false" @search="applyFilters" @reset="resetFilters">
|
||||
<template #actions>
|
||||
<el-button :aria-expanded="showAdvancedFilters" @click="toggleAdvancedFilters">
|
||||
<SlidersHorizontal :size="16" />
|
||||
{{ showAdvancedFilters ? '收起筛选' : '更多筛选' }}
|
||||
<ChevronUp v-if="showAdvancedFilters" :size="15" />
|
||||
<ChevronDown v-else :size="15" />
|
||||
</el-button>
|
||||
</template>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-alert v-if="customerLoadError" type="warning" :closable="false" show-icon role="alert">
|
||||
<template #title><div class="api-error-alert-title"><span>{{ customerLoadError.message }}</span><ApiErrorMeta :details="customerLoadError" /></div></template>
|
||||
<el-button link type="warning" :loading="customersLoading" @click="loadCustomers">重新加载客户列表</el-button>
|
||||
</el-alert>
|
||||
|
||||
<LoadErrorState v-if="loadError" class="inbound-load-error" :message="loadError.message" title="入库单暂时无法加载" @retry="loadInbounds"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<section v-else class="surface-panel table-panel" :aria-busy="loading">
|
||||
<header class="table-toolbar">
|
||||
<nav class="inbound-tabs" role="tablist" aria-label="入库单处理状态">
|
||||
<button v-for="(item, index) in tabs" :key="item.value || 'all'" type="button" role="tab" class="inbound-tab" :class="{ 'is-active': tab === item.value }" :aria-selected="tab === item.value" :tabindex="tab === item.value ? 0 : -1" :title="item.description" @click="tab = item.value" @keydown="handleTabKeydown($event, index)">{{ item.label }}</button>
|
||||
</nav>
|
||||
<div class="table-toolbar-actions">
|
||||
<span class="result-summary">共 {{ result.total }} 条 · 所属企业 {{ selectedCustomerName }}</span>
|
||||
<el-button :loading="loading" @click="loadInbounds"><RefreshCw :size="16" />刷新</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" :loading="loading" :total="result.total" :page-sizes="[20, 50, 100]" row-key="id" :empty-text="listEmptyText" aria-label="入库单列表">
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openWorkspace(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button link @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div></template>
|
||||
<template #inbound="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><span>订单 {{ text((row as InboundOrderSummary).orderNo) }}</span></div></template>
|
||||
<template #orderStatus="{ row }"><div class="status-stack"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag><span>订单:{{ text((row as InboundOrderSummary).orderStatusSnapshot) }}</span></div></template>
|
||||
<template #measureReviewStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).measureReviewStatus, measureReviewLabels) }}</template>
|
||||
<template #expectedArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}</template>
|
||||
<template #feeStatus="{ row }"><div class="status-stack"><span>{{ codeLabel((row as InboundOrderSummary).feeStatus, feeStatusLabels) }}</span><small v-if="(row as InboundOrderSummary).feeReviewStatus">审核:{{ text((row as InboundOrderSummary).feeReviewStatus) }}</small></div></template>
|
||||
<template #customerName="{ row }">{{ text((row as InboundOrderSummary).customerName) }}</template>
|
||||
<template #warehouseName="{ row }">{{ text((row as InboundOrderSummary).warehouseName) }}</template>
|
||||
<template #deliveryWarehouseName="{ row }">{{ text((row as InboundOrderSummary).deliveryWarehouseName) }}</template>
|
||||
<template #loadingStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).loadingStatus, loadingStatusLabels) }}</template>
|
||||
<template #holdFlag="{ row }"><el-tag :type="(row as InboundOrderSummary).holdFlag ? 'danger' : 'info'" effect="plain">{{ (row as InboundOrderSummary).holdFlag ? '是' : '否' }}</el-tag></template>
|
||||
<template #currentHoldReason="{ row }">{{ text((row as InboundOrderSummary).currentHoldReason) }}</template>
|
||||
<template #containerNo="{ row }">{{ text((row as InboundOrderSummary).containerNo) }}</template>
|
||||
<template #cabinetNo="{ row }">{{ text((row as InboundOrderSummary).cabinetNo) }}</template>
|
||||
<template #companyName="{ row }">{{ text((row as InboundOrderSummary).companyName) }}</template>
|
||||
<template #businessType="{ row }">{{ text((row as InboundOrderSummary).businessType) }}</template>
|
||||
<template #channelName="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).channelName) }}</strong><span>{{ text((row as InboundOrderSummary).channelCode) }}</span></div></template>
|
||||
<template #taxMode="{ row }">{{ codeLabel((row as InboundOrderSummary).taxMode, taxModeLabels) }}</template>
|
||||
<template #containerMode="{ row }">{{ codeLabel((row as InboundOrderSummary).containerMode, containerModeLabels) }}</template>
|
||||
<template #customsInspection="{ row }">{{ (row as InboundOrderSummary).customsInspection === null ? '—' : (row as InboundOrderSummary).customsInspection ? '是' : '否' }}</template>
|
||||
<template #sailingAt="{ row }">{{ formatDate((row as InboundOrderSummary).sailingAt) }}</template>
|
||||
<template #portArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).portArrivalAt) }}</template>
|
||||
<template #totalPackageCount="{ row }">{{ text((row as InboundOrderSummary).totalPackageCount) }}</template>
|
||||
<template #actualGrossWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).actualGrossWeightKg) }}</template>
|
||||
<template #chargeableWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).chargeableWeightKg) }}</template>
|
||||
<template #actualVolumeCbm="{ row }">{{ formatNumber((row as InboundOrderSummary).actualVolumeCbm) }}</template>
|
||||
<template #chargeableVolumeCbm="{ row }">{{ formatNumber((row as InboundOrderSummary).chargeableVolumeCbm) }}</template>
|
||||
<template #palletCount="{ row }">{{ text((row as InboundOrderSummary).palletCount) }}</template>
|
||||
<template #locationCodes="{ row }">{{ text((row as InboundOrderSummary).locationCodes) }}</template>
|
||||
<template #volumeWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).volumeWeightKg) }}</template>
|
||||
<template #actualNetWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).actualNetWeightKg) }}</template>
|
||||
<template #destinationType="{ row }">{{ text((row as InboundOrderSummary).destinationType) }}</template>
|
||||
<template #destinationName="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).destinationName) }}</strong><span>{{ text((row as InboundOrderSummary).destinationCode) }}</span></div></template>
|
||||
<template #measureCompletedAt="{ row }">{{ formatDate((row as InboundOrderSummary).measureCompletedAt) }}</template>
|
||||
<template #inboundedAt="{ row }">{{ formatDate((row as InboundOrderSummary).inboundedAt) }}</template>
|
||||
<template #expressCompanyName="{ row }">{{ text((row as InboundOrderSummary).expressCompanyName) }}</template>
|
||||
<template #expressTrackingNo="{ row }">{{ text((row as InboundOrderSummary).expressTrackingNo) }}</template>
|
||||
<template #createdAt="{ row }">{{ formatDate((row as InboundOrderSummary).createdAt) }}</template>
|
||||
<template #empty><el-empty :description="listEmptyText" /></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<AppDrawer v-model="detailOpen" :title="detail ? `入库单详情 · ${detail.summary.inboundCode || detail.summary.id}` : '入库单详情'" size="min(720px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close @closed="closeDetail">
|
||||
<section v-if="detailError" class="detail-error" role="alert"><strong>入库单详情暂时无法加载</strong><p>{{ detailError.message }}</p><ApiErrorMeta :details="detailError" /><el-button type="primary" :disabled="!detailTarget" @click="detailTarget && openDetail(detailTarget)">重新加载</el-button></section>
|
||||
<div v-else-if="detail" class="detail-content"><InboundSummaryPanel :summary="detail.summary" /><InboundHistoryPanel :pallet-locations="detail.palletLocations" :files="detail.files" :internal-notes="detail.internalNotes" :status-history="detail.statusHistory" :public-remark="detail.publicRemark" /></div>
|
||||
</AppDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbound-orders-page { min-width: 0; }
|
||||
.inbound-tabs { display: flex; max-width: 100%; min-width: 0; overflow-x: auto; padding: 4px; border: 1px solid var(--cc-border); border-radius: 8px; background: #f7f9fc; scrollbar-width: none; }
|
||||
.inbound-tabs::-webkit-scrollbar { display: none; }
|
||||
.inbound-tab { min-height: 44px; flex: 0 0 auto; padding: 8px 18px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--cc-muted); cursor: pointer; font-size: 13px; line-height: 1.4; white-space: nowrap; transition: background-color .16s ease, border-color .16s ease, color .16s ease, box-shadow .16s ease; }
|
||||
.inbound-tab:hover { background: #fff; color: var(--cc-primary); }
|
||||
.inbound-tab:focus-visible { outline: 3px solid rgba(102, 126, 234, .2); outline-offset: 1px; }
|
||||
.inbound-tab.is-active { border-color: #d8e0f0; background: #fff; color: var(--cc-primary); box-shadow: 0 1px 3px rgba(16, 24, 40, .08); font-weight: 650; }
|
||||
.inbound-load-error { margin-top: 12px; }
|
||||
.inbound-query-toolbar { border-radius: 8px; }
|
||||
.row-actions { display: flex; align-items: center; gap: 2px; white-space: nowrap; }
|
||||
.row-actions :deep(.el-button) { margin-left: 0; }
|
||||
.table-toolbar { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; min-height: 68px; padding: 10px 14px; border-bottom: 1px solid var(--cc-border); }
|
||||
.table-toolbar-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 14px; }
|
||||
.result-summary { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
|
||||
.table-toolbar :deep(.el-button) { min-height: 40px; margin-left: 0; }
|
||||
.primary-cell, .status-stack { display: grid; min-width: 0; justify-items: start; gap: 4px; }
|
||||
.primary-cell strong, .primary-cell span, .status-stack span, .status-stack small { overflow-wrap: anywhere; }
|
||||
.primary-cell span, .status-stack span, .status-stack small { color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-content { display: grid; gap: 24px; min-width: 0; }
|
||||
.detail-error { display: flex; min-height: 240px; align-items: flex-start; justify-content: center; flex-direction: column; }
|
||||
.detail-error strong { color: var(--cc-danger); font-size: 15px; }
|
||||
.detail-error p { margin: 7px 0; color: var(--cc-text); font-size: 13px; }
|
||||
.detail-error :deep(.el-button) { margin-top: 14px; }
|
||||
@media (max-width: 900px) { .table-toolbar { align-items: stretch; flex-direction: column; } .inbound-tabs { width: 100%; } .table-toolbar-actions { justify-content: space-between; } }
|
||||
@media (max-width: 720px) { .inbound-tab { padding-inline: 14px; } .table-toolbar { padding: 10px; } .table-toolbar-actions { gap: 10px; } .result-summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .table-toolbar :deep(.el-button) { min-height: 44px; } }
|
||||
</style>
|
||||
Reference in New Issue
Block a user