fix(admin): render container documents as table
This commit is contained in:
@@ -2,8 +2,8 @@ import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ContainerDetailView from './ContainerDetailView.vue'
|
||||
import { getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
|
||||
import { completeLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists'
|
||||
import { downloadContainerDocument, getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
|
||||
import { completeLoadingDeclaration, downloadLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists'
|
||||
import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
|
||||
import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
@@ -12,7 +12,7 @@ const { accessCan, routerPush, carrierMounts } = vi.hoisted(() => ({ accessCan:
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { containerId: '2092438843630391297' }, query: {} }), useRouter: () => ({ push: routerPush }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
|
||||
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div data-test="carrier-panel" />', mounted: () => { carrierMounts.value += 1 } } }))
|
||||
vi.mock('@/modules/loading-lists', () => ({ uploadLoadingDeclaration: vi.fn(), completeLoadingDeclaration: vi.fn() }))
|
||||
vi.mock('@/modules/loading-lists', () => ({ uploadLoadingDeclaration: vi.fn(), completeLoadingDeclaration: vi.fn(), downloadLoadingDeclaration: vi.fn() }))
|
||||
vi.mock('@/modules/outbound-dispatch/api/platformOutboundDispatch', () => ({ getContainerOutboundDispatch: vi.fn(), confirmContainerOutboundDispatch: vi.fn() }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({
|
||||
getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(),
|
||||
@@ -40,21 +40,25 @@ const outboundDetail = {
|
||||
function mountView(customs = true, outbound = true) {
|
||||
accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:edit'
|
||||
|| (customs && permission === 'platform:firstleg:customs:manage')
|
||||
|| permission === 'platform:firstleg:loading-list:list'
|
||||
|| (outbound && ['platform:firstleg:outbound-dispatch:view', 'platform:firstleg:outbound-dispatch:confirm'].includes(permission)))
|
||||
return mount(ContainerDetailView, { global: { plugins: [ElementPlus] } })
|
||||
}
|
||||
function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.findAll('.document-item').find((item) => item.text().includes(label))! }
|
||||
function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.findAll('.document-table tbody tr').find((item) => item.text().includes(label))! }
|
||||
function documentButton(wrapper: VueWrapper, label: string, buttonText: string) { return documentArticle(wrapper, label).findAll('button').find((button) => button.text().includes(buttonText))! }
|
||||
|
||||
describe('ContainerDetailView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:document') })
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
|
||||
carrierMounts.value = 0
|
||||
vi.mocked(getContainer).mockResolvedValue(detail)
|
||||
vi.mocked(getContainerOverview).mockResolvedValue(overview as never)
|
||||
vi.mocked(getContainerClearance).mockResolvedValue(null)
|
||||
vi.mocked(uploadLoadingDeclaration).mockResolvedValue({} as never)
|
||||
vi.mocked(uploadLoadingDeclaration).mockResolvedValue({ id: '2092438843630391501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630391502', fileName: 'customs-new.pdf', fileHash: 'hash', uploadedBy: '1', uploadedByName: '操作员', uploadedAt: '2026-08-26T12:00:00', completedBy: null, completedByName: null, completedAt: null, version: 6 } as never)
|
||||
vi.mocked(completeLoadingDeclaration).mockResolvedValue({} as never)
|
||||
vi.mocked(downloadLoadingDeclaration).mockResolvedValue(new Blob(['customs']))
|
||||
vi.mocked(uploadContainerDocument).mockResolvedValue({} as never)
|
||||
vi.mocked(uploadContainerClearance).mockResolvedValue({} as never)
|
||||
vi.mocked(getContainerOutboundDispatch).mockResolvedValue(outboundDetail)
|
||||
@@ -63,7 +67,7 @@ describe('ContainerDetailView', () => {
|
||||
|
||||
it('opens domestic export customs upload and sends stable plan, warehouse, version and reason', async () => {
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
await documentArticle(wrapper, '国内出口报关附件').find('button').trigger('click'); await flushPromises()
|
||||
await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('国内出口报关附件')
|
||||
const file = new File(['customs'], 'customs.pdf', { type: 'application/pdf' })
|
||||
;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file })
|
||||
@@ -76,14 +80,14 @@ describe('ContainerDetailView', () => {
|
||||
it('shows a readable error when the container has no loading plan', async () => {
|
||||
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, loadingPlanId: null } as never)
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
await documentArticle(wrapper, '国内出口报关附件').find('button').trigger('click'); await flushPromises()
|
||||
await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('当前货柜没有装柜清单,无法上传国内出口报关附件')
|
||||
expect(uploadLoadingDeclaration).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
|
||||
it('hides customs and clearance upload without customs manage permission while keeping DO and PU', async () => {
|
||||
const wrapper = mountView(false); await flushPromises()
|
||||
expect(documentArticle(wrapper, '国内出口报关附件').find('button').exists()).toBe(false)
|
||||
expect(documentArticle(wrapper, '国内出口报关附件').findAll('button').map((button) => button.text())).toEqual(['下载'])
|
||||
expect(documentArticle(wrapper, '目的港清关资料').find('button').exists()).toBe(false)
|
||||
expect(documentArticle(wrapper, 'DO 资料').find('button').exists()).toBe(true)
|
||||
expect(documentArticle(wrapper, 'PU 资料').find('button').exists()).toBe(true)
|
||||
@@ -98,6 +102,43 @@ describe('ContainerDetailView', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('downloads customs through the dedicated loading-list API and keeps DO PU isolated', async () => {
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
await documentButton(wrapper, '国内出口报关附件', '下载').trigger('click'); await flushPromises()
|
||||
expect(downloadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', '2092438843630391502')
|
||||
expect(downloadContainerDocument).not.toHaveBeenCalled()
|
||||
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
|
||||
expect(click).toHaveBeenCalledOnce()
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps customs download after completion while hiding update and completion actions', async () => {
|
||||
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) } as never)
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
const row = documentArticle(wrapper, '国内出口报关附件')
|
||||
expect(row.findAll('button').map((button) => button.text())).toEqual(['下载'])
|
||||
expect(row.text()).toContain('已完成')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the optimistic customs upload when overview refresh fails and shows a readable warning', async () => {
|
||||
vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockRejectedValueOnce(new Error('overview unavailable'))
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises()
|
||||
const file = new File(['customs-new'], 'customs-new.pdf', { type: 'application/pdf' })
|
||||
;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file })
|
||||
await (wrapper.vm as unknown as { saveDocument: () => Promise<void> }).saveDocument(); await flushPromises()
|
||||
const row = documentArticle(wrapper, '国内出口报关附件')
|
||||
expect(row.text()).toContain('customs-new.pdf')
|
||||
expect(row.text()).toContain('操作员')
|
||||
expect(row.text()).toContain('2026-08-26 12:00')
|
||||
expect(wrapper.text()).toContain('附件已上传,但最新资料刷新失败;当前已保留本次上传结果')
|
||||
expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 5, '上传国内出口报关附件')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps customs attachment upload and completion as two explicit steps with exact payload', async () => {
|
||||
const completedOverview = { ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) }
|
||||
vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockResolvedValue(completedOverview as never)
|
||||
|
||||
@@ -11,7 +11,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
|
||||
import { completeLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists'
|
||||
import { completeLoadingDeclaration, downloadLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists'
|
||||
import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
|
||||
import type { ContainerOutboundDispatchDetail, ContainerOutboundGateItem } from '@/modules/outbound-dispatch/types'
|
||||
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
|
||||
@@ -34,6 +34,7 @@ const access = useAccessPreviewStore()
|
||||
const containerId = computed(() => String(route.params.containerId || ''))
|
||||
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
|
||||
const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage'))
|
||||
const canDownloadCustoms = computed(() => access.can('platform:firstleg:loading-list:list'))
|
||||
const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view'))
|
||||
const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm'))
|
||||
const loading = ref(false)
|
||||
@@ -49,7 +50,8 @@ const documentSaving = ref(false)
|
||||
const documentDownloading = ref(false)
|
||||
const documentError = ref<ApiErrorDetails | null>(null)
|
||||
const customsCompleting = ref(false)
|
||||
const customsCompletionError = ref<ApiErrorDetails | null>(null)
|
||||
const customsActionError = ref<ApiErrorDetails | null>(null)
|
||||
const documentRefreshWarning = ref<ApiErrorDetails | null>(null)
|
||||
const clearanceOpen = ref(false)
|
||||
const clearanceDocument = ref<BusinessDocument | null>(null)
|
||||
const clearanceFile = ref<File | null>(null)
|
||||
@@ -164,16 +166,33 @@ function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw ||
|
||||
function removeDocumentFile() { documentFile.value = null }
|
||||
async function saveDocument() {
|
||||
if (!documentFile.value || !overview.value || !detail.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return }
|
||||
documentSaving.value = true; documentError.value = null
|
||||
documentSaving.value = true; documentError.value = null; documentRefreshWarning.value = null
|
||||
try {
|
||||
const expectedVersion = selectedDocument.value?.version || 0
|
||||
if (documentType.value === 'CUSTOMS_DECLARATION') {
|
||||
if (!overview.value.loadingPlanId) throw new Error('当前货柜没有装柜清单,无法上传国内出口报关附件')
|
||||
await uploadLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, documentFile.value, expectedVersion, '上传国内出口报关附件')
|
||||
const uploaded = await uploadLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, documentFile.value, expectedVersion, '上传国内出口报关附件')
|
||||
if (!uploaded) throw new Error('报关附件上传成功但未返回附件信息')
|
||||
const optimistic: ContainerOverviewDocument = {
|
||||
id: uploaded.id || selectedDocument.value?.id || uploaded.fileId,
|
||||
documentType: 'CUSTOMS_DECLARATION', fileId: uploaded.fileId, fileName: uploaded.fileName,
|
||||
fileHash: uploaded.fileHash, uploadedBy: uploaded.uploadedBy, uploadedByName: uploaded.uploadedByName,
|
||||
uploadedAt: uploaded.uploadedAt, version: uploaded.version, completed: Boolean(uploaded.completedAt),
|
||||
}
|
||||
overview.value = { ...overview.value, documents: [...overview.value.documents.filter((item) => item.documentType !== 'CUSTOMS_DECLARATION'), optimistic] }
|
||||
try {
|
||||
const refreshed = await getContainerOverview(containerId.value)
|
||||
if (refreshed.documents.some((item) => item.documentType === 'CUSTOMS_DECLARATION')) overview.value = refreshed
|
||||
else {
|
||||
overview.value = { ...refreshed, documents: [...refreshed.documents, optimistic] }
|
||||
documentRefreshWarning.value = { code: 'CUSTOMS_DOCUMENT_STALE', message: '附件已上传,但资料汇总尚未同步;当前已保留本次上传结果' }
|
||||
}
|
||||
}
|
||||
catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '附件已上传,但最新资料刷新失败;当前已保留本次上传结果') }
|
||||
} else {
|
||||
await uploadContainerDocument(containerId.value, documentType.value, documentFile.value, expectedVersion, `上传${documentTitle.value}`)
|
||||
overview.value = await getContainerOverview(containerId.value)
|
||||
}
|
||||
overview.value = await getContainerOverview(containerId.value)
|
||||
documentOpen.value = false; documentFile.value = null; ElMessage.success(`${documentTitle.value}已上传`)
|
||||
} catch (cause) { documentError.value = getApiErrorDetails(cause, `${documentTitle.value}上传失败`) }
|
||||
finally { documentSaving.value = false }
|
||||
@@ -185,31 +204,44 @@ async function refreshCustomsFacts() {
|
||||
async function completeCustomsDeclaration(document: ContainerOverviewDocument) {
|
||||
if (!canManageCustoms.value || customsCompleting.value || document.completed) return
|
||||
if (!overview.value?.loadingPlanId || !detail.value) {
|
||||
customsCompletionError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法确认报关完成' }
|
||||
customsActionError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法确认报关完成' }
|
||||
return
|
||||
}
|
||||
customsCompleting.value = true; customsCompletionError.value = null
|
||||
customsCompleting.value = true; customsActionError.value = null; documentRefreshWarning.value = null
|
||||
try {
|
||||
await ElMessageBox.confirm('请确认装柜清单的国内出口报关协同已经完成。附件上传与确认报关完成是两个步骤;上传附件本身不代表正式报关放行。', '确认报关完成', { type: 'warning', confirmButtonText: '确认完成' })
|
||||
} catch { customsCompleting.value = false; return }
|
||||
try {
|
||||
await completeLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, document.version, '货柜详情确认国内出口报关完成')
|
||||
await refreshCustomsFacts()
|
||||
try { await refreshCustomsFacts() }
|
||||
catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '报关完成已确认,但最新资料刷新失败,请稍后重新加载') }
|
||||
ElMessage.success('已确认装柜清单报关协同完成;正式报关放行仍由报关业务事实核验')
|
||||
} catch (cause) {
|
||||
const details = getApiErrorDetails(cause, '确认报关完成失败')
|
||||
if (details.code === 'A0409') {
|
||||
try { await refreshCustomsFacts() } catch { /* 保留原并发错误证据 */ }
|
||||
}
|
||||
customsCompletionError.value = details
|
||||
customsActionError.value = details
|
||||
} finally { customsCompleting.value = false }
|
||||
}
|
||||
async function saveBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName || '货柜资料'; anchor.click(); URL.revokeObjectURL(url) }
|
||||
async function downloadDocument(item: ContainerOverviewDocument) {
|
||||
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(item.documentType)) return
|
||||
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION'].includes(item.documentType)) return
|
||||
if (item.documentType === 'CUSTOMS_DECLARATION' && (!overview.value?.loadingPlanId || !detail.value)) {
|
||||
customsActionError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法下载国内出口报关附件' }
|
||||
return
|
||||
}
|
||||
documentDownloading.value = true
|
||||
try { await saveBlob(await downloadContainerDocument(containerId.value, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId), item.fileName); ElMessage.success('资料下载已开始') }
|
||||
catch (cause) { ElMessage.error(getApiErrorDetails(cause, '资料下载失败').message) }
|
||||
try {
|
||||
const blob = item.documentType === 'CUSTOMS_DECLARATION'
|
||||
? await downloadLoadingDeclaration(overview.value!.loadingPlanId!, detail.value!.warehouseId, item.fileId)
|
||||
: await downloadContainerDocument(containerId.value, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId)
|
||||
await saveBlob(blob, item.fileName); ElMessage.success('资料下载已开始')
|
||||
} catch (cause) {
|
||||
const details = getApiErrorDetails(cause, '资料下载失败')
|
||||
if (item.documentType === 'CUSTOMS_DECLARATION') customsActionError.value = details
|
||||
ElMessage.error(details.message)
|
||||
}
|
||||
finally { documentDownloading.value = false }
|
||||
}
|
||||
function openException() { exceptionReason.value = ''; exceptionError.value = null; exceptionOpen.value = true }
|
||||
@@ -261,8 +293,9 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
|
||||
<template v-else-if="detail">
|
||||
<section class="detail-fields surface-panel"><h2>运输与单证快照</h2><dl><div><dt>真实订舱</dt><dd>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime(detail.unloadingCompletedAt) }}</dd></div><div><dt>卸柜照片上传</dt><dd>{{ formatTime(detail.unloadingPhotoUploadedAt) }}</dd></div><div><dt>预计报空</dt><dd>{{ formatTime(detail.estimatedEmptyReportedAt) }}</dd></div><div><dt>实际报空</dt><dd>{{ formatTime(detail.actualEmptyReportedAt) }}</dd></div></dl></section>
|
||||
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
|
||||
<ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" :document-busy="documentDownloading" :customs-completing="customsCompleting" @upload-document="openDocument" @download-document="downloadDocument" @complete-customs="completeCustomsDeclaration" @manage-exception="openException" />
|
||||
<LoadErrorState v-if="customsCompletionError" compact title="确认报关完成失败" :message="customsCompletionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="customsCompletionError" /></template></LoadErrorState>
|
||||
<ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-download-customs="canDownloadCustoms" :can-manage-exception="canEdit" :document-busy="documentDownloading" :customs-completing="customsCompleting" @upload-document="openDocument" @download-document="downloadDocument" @complete-customs="completeCustomsDeclaration" @manage-exception="openException" />
|
||||
<el-alert v-if="documentRefreshWarning" type="warning" :closable="false" :title="documentRefreshWarning.message"><template #default><ApiErrorMeta :details="documentRefreshWarning" /></template></el-alert>
|
||||
<LoadErrorState v-if="customsActionError" compact title="国内出口报关附件操作失败" :message="customsActionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="customsActionError" /></template></LoadErrorState>
|
||||
<section v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel surface-panel" aria-label="国内出仓门禁">
|
||||
<header class="outbound-panel__header"><div><h2>国内出仓门禁</h2><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
|
||||
<LoadErrorState v-if="outboundError" compact title="国内出仓操作失败" :message="outboundError.message" :loading="outboundLoading" @retry="loadOutboundDispatch"><template #details><ApiErrorMeta :details="outboundError" /></template></LoadErrorState>
|
||||
|
||||
Reference in New Issue
Block a user