style(admin): unify dialog layouts and dense readonly presentation

This commit is contained in:
2026-10-20 10:11:31 +08:00
parent 7ff7c9914f
commit 3267679758
214 changed files with 10931 additions and 2654 deletions
@@ -93,14 +93,16 @@ describe('ContainerDetailView empty return', () => {
const wrapper = await mountDetail()
expect(getDirectDeliveries).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '直送卸货登记')!
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))!
await dialog.findAll('button').find(button => button.text() === '登记本票')!.trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises(); expect(recordDirectDelivery).not.toHaveBeenCalled()
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00')
for (const [label, value] of [['地点时区(IANA)', 'America/Los_Angeles'], ['签收人', '测试签收人'], ['真实签收凭据号', 'POD-001']]) {
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === label)!.findComponent(ElInput).vm.$emit('update:modelValue', value)
}
await flushPromises(); dialog.vm.$emit('confirm'); await flushPromises()
await flushPromises(); await dialog.find('button[aria-label="展开全屏"]').trigger('click')
expect(dialog.findComponent(ElDatePicker).props('modelValue')).toBe('2026-10-16T01:00:00')
dialog.vm.$emit('confirm'); await flushPromises()
const first = vi.mocked(recordDirectDelivery).mock.calls[0]!
expect(first.slice(0, 3)).toEqual(['9001', row.requirementKey, { expectedVersion: 0, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '测试签收人', evidenceNo: 'POD-001', remark: null }])
expect(dialog.text()).not.toContain('9007199254740999')
@@ -114,24 +116,46 @@ describe('ContainerDetailView empty return', () => {
vi.mocked(getDirectDeliveries).mockResolvedValue([{ requirementKey: 'INBOUND:9', inboundCode: 'IN-TEST', orderNo: null, destinationName: '测试客户仓', packageCount: null, version: 1, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '签收人', evidenceNo: 'POD-001', remark: null }])
const wrapper = await mountDetail()
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '直送卸货登记')!
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))!
await dialog.findAll('button').find(button => button.text() === '查看签收')!.trigger('click'); await flushPromises()
expect(dialog.props('showConfirm')).toBe(false); expect(dialog.text()).toContain('签收事实只读')
expect(dialog.findAll('input, textarea')).toHaveLength(0)
expect(dialog.text()).toContain('POD-001')
expect(dialog.text()).toContain('America/Los_Angeles')
dialog.vm.$emit('confirm'); await flushPromises(); expect(recordDirectDelivery).not.toHaveBeenCalled()
})
it('does not mislabel a cabinet number as a real container number or an unnamed fleet as unassigned', async () => {
routeState.query = { action: 'pickup' }
const withoutNumber = { ...(await getContainer('9001')), containerNo: '' }
const unnamedFleet = { ...(await getTerminalDrayage('9001')), containerNo: '', fleetSupplierId: 'private-supplier-id', fleetSupplierName: '' }
vi.mocked(getContainer).mockResolvedValue(withoutNumber)
vi.mocked(getTerminalDrayage).mockResolvedValue(unnamedFleet)
const wrapper = await mountDetail()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('提柜安排与执行'))!
const fields = dialog.findAllComponents(ElFormItem)
const number = fields.find(field => field.props('label') === '真实集装箱号')!
expect(number.text()).toBe('真实集装箱号—')
const fleet = fields.find(field => field.props('label') === '当前车队')!
expect(fleet.text()).toBe('当前车队—')
expect(fleet.text()).not.toContain('private-supplier-id')
expect(fleet.text()).not.toContain('尚未安排')
})
it('opens the pickup action and saves an actual overseas supplier before the explicit pickup appointment', async () => {
routeState.query = { action: 'pickup' }
const current = { ...base.terminalDrayage, actualPickupAt: null, allowedActions: ['SELECT_FLEET', 'SCHEDULE_PICKUP'] }
vi.mocked(getTerminalDrayage).mockResolvedValueOnce(current as never).mockResolvedValue({ ...current, version: 4 } as never)
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740999', name: '测试海外车队' }], total: 1 } as never)
const wrapper = await mountDetail()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('提柜安排与执行'))!
expect(dialog.props('modelValue')).toBe(true); expect(selectTerminalDrayageFleet).not.toHaveBeenCalled()
dialog.findComponent(ElSelect).vm.$emit('visible-change', true); await flushPromises()
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: '' })
dialog.findComponent(ElSelect).vm.$emit('update:modelValue', '9007199254740999'); await flushPromises()
await dialog.findAll('button').find(button => button.text() === '保存码头车队')!.trigger('click'); await flushPromises()
const fleetButton = dialog.findAll('button').find(button => button.text() === '保存码头车队')!
expect(fleetButton.element.closest('.el-dialog__footer')).not.toBeNull()
await fleetButton.trigger('click'); await flushPromises()
expect(selectTerminalDrayageFleet).toHaveBeenCalledWith('9001', { expectedVersion: 3, fleetSupplierId: '9007199254740999', fleetSupplierName: '测试海外车队', reason: undefined }, expect.any(String))
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00'); await flushPromises()
await dialog.findAll('button').find(button => button.text() === '保存提柜预约')!.trigger('click'); await flushPromises()
@@ -143,7 +167,7 @@ describe('ContainerDetailView empty return', () => {
vi.mocked(getTerminalDrayage).mockResolvedValue({ ...base.terminalDrayage, allowedActions: ['SCHEDULE_PICKUP'] } as never)
vi.mocked(scheduleTerminalDrayagePickup).mockRejectedValueOnce(new Error('connection lost'))
const wrapper = await mountDetail()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('提柜安排与执行'))!
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00'); await flushPromises()
await dialog.findAll('button').find(button => button.text() === '保存提柜预约')!.trigger('click'); await flushPromises()
const first = vi.mocked(scheduleTerminalDrayagePickup).mock.calls[0]!
@@ -155,7 +179,7 @@ describe('ContainerDetailView empty return', () => {
routeState.query = { action: 'pickup' }
vi.mocked(getTerminalDrayage).mockResolvedValue({ ...base.terminalDrayage, actualPickupAt: null } as never)
vi.mocked(recordTerminalPickup).mockRejectedValueOnce(new Error('lost response'))
const wrapper = await mountDetail(), dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
const wrapper = await mountDetail(), dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('提柜安排与执行'))!
await dialog.findAll('button').find(button => button.text() === '登记实际提柜')!.trigger('click'); await flushPromises(); expect(recordTerminalPickup).not.toHaveBeenCalled()
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === '实际提柜当地时间(登记实际时必填)')!.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-14T23:00:00')
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === '处理说明')!.findComponent(ElInput).vm.$emit('update:modelValue', '司机提供已提柜记录')
@@ -211,7 +235,7 @@ describe('ContainerDetailView empty return', () => {
expect(wrapper.text()).not.toContain('装柜完成')
await wrapper.findAll('button').find((item) => item.text() === '确认还柜')!.trigger('click')
await flushPromises()
const emptyDialog = wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title') === '确认还柜')!
const emptyDialog = wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith('确认还柜'))!
emptyDialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-09-09T12:00:00'); await flushPromises()
const confirm = [...document.body.querySelectorAll('button')].filter((item) => item.textContent?.trim() === '确认还柜').at(-1) as HTMLButtonElement
confirm.click()
@@ -75,7 +75,10 @@ describe('ContainerDetailView customs integration', () => {
expect(source).toContain('label="目的港及海外段费用(尾程)" name="last-leg-fees"')
expect(source).toContain('v-if="canViewFirstLegFees && firstLegFeesOpened"')
expect(source).toContain('v-show="activeTab === \'first-leg-fees\'"')
expect(source).toContain('title="国内段费用(头程)" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id"')
const firstLegFees = source.match(/<ContainerFeesPanel\b[^>]*\/>/)?.[0] || ''
for (const attribute of ['title="国内段费用(头程)"', ':plan-id="overview.loadingPlanId"', ':container-booking-id="detail.id"', ':cabinet-no="detail.cabinetNo"', ':container-no="detail.containerNo"', ':customers="feeCustomers"', ':business-references="feeBusinessReferences"']) {
expect(firstLegFees).toContain(attribute)
}
expect(source).not.toContain(':readonly="isOverseas"')
expect(source).toContain('v-if="canViewLastLegFees && lastLegFeesOpened"')
expect(source).toContain('title="目的港及海外段费用(尾程)"')
@@ -25,6 +25,7 @@ import {
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import { containerDialogTitle } from '../containerDialogTitle'
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
import { getSmartLoadingChannelGroup, isSeaRailChannel } from '../smartLoadingChannelGroups'
import { containerChannelLabel } from '../containerChannelLabel'
@@ -172,7 +173,7 @@ async function saveTerminal(kind: 'fleet' | 'appointment' | 'actual') {
async function leaveTerminal() {
if (terminalSaving.value || terminalPending.value) { ElMessage.warning('本次提柜安排结果尚未确定,请先重试确认结果'); return false }
if (!terminalOpen.value || terminalBaseline.value === JSON.stringify(terminalForm)) return true
try { await ElMessageBox.confirm('尚有未保存的提柜安排,确认放弃本次输入?', '未保存的安排', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
try { await ElMessageBox.confirm('尚有未保存的提柜安排,确认放弃本次输入?', containerDialogTitle('未保存的安排', detail.value), { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
}
async function closeTerminal(done: () => void) { if (await leaveTerminal()) { terminalBaseline.value = JSON.stringify(terminalForm); done() } }
onBeforeRouteLeave(leaveTerminal)
@@ -186,7 +187,7 @@ const directPending = ref<{ containerId: string; requirementKey: string; body: D
async function leaveDirect() {
if (directSaving.value || directPending.value) { ElMessage.warning('本次卸货登记结果尚未确定,请先重试确认结果'); return false }
if (!directOpen.value || JSON.stringify(directForm) === directBaseline.value) return true
try { await ElMessageBox.confirm('尚有未保存的卸货登记,确认放弃本次输入?', '未保存的登记', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
try { await ElMessageBox.confirm('尚有未保存的卸货登记,确认放弃本次输入?', containerDialogTitle('未保存的登记', detail.value), { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
}
onBeforeRouteLeave(leaveDirect)
onBeforeRouteUpdate(async (to, from) => {
@@ -287,7 +288,7 @@ const emptyReturnPending = ref<{ body: Parameters<typeof recordTerminalDrayageEm
async function leaveEmptyReturn() {
if (emptyReturnSaving.value || emptyReturnPending.value) { ElMessage.warning('还空登记结果尚未确定,请重试确认结果'); return false }
if (!emptyReturnOpen.value || (!emptyReturnAt.value && !emptyReturnReason.value)) return true
try { await ElMessageBox.confirm('尚有未保存的还空登记,确认放弃?', '未保存的登记', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
try { await ElMessageBox.confirm('尚有未保存的还空登记,确认放弃?', containerDialogTitle('未保存的登记', detail.value), { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
}
async function closeEmptyReturn(done: () => void) { if (await leaveEmptyReturn()) done() }
onBeforeRouteLeave(leaveEmptyReturn)
@@ -486,7 +487,7 @@ async function confirmDeparture() {
if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return
outboundConfirming.value = true
try {
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', '确认国内出仓', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认离仓' })
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', containerDialogTitle('确认国内出仓', detail.value), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认离仓' })
} catch { outboundConfirming.value = false; return }
outboundError.value = null
try {
@@ -691,7 +692,7 @@ onMounted(async () => {
<h2 id="container-voyage-heading">船名航次信息</h2>
<ContainerPortSchedulePanel :container="detail" @saved="load" />
<div class="detail-fields"><dl class="density-fields"><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>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel dense :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel dense :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
</section>
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-customs-heading">
@@ -736,49 +737,45 @@ onMounted(async () => {
<section v-if="canViewFirstLegFees && firstLegFeesOpened" v-show="activeTab === 'first-leg-fees'" v-loading="loadingDetailLoading" class="detail-section surface-panel" aria-label="头程计费">
<LoadErrorState v-if="loadingDetailError" compact title="头程计费归属明细加载失败" :message="loadingDetailError.message" :loading="loadingDetailLoading" @retry="overview && loadLoadingDetail(detail, overview)"><template #details><ApiErrorMeta :details="loadingDetailError" /></template></LoadErrorState>
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" title="国内段费用(头程)" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" />
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" title="国内段费用(头程)" :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" />
<el-empty v-else-if="!loadingDetailLoading" :description="firstLegFeesEmptyReason" :image-size="72" />
</section>
<section v-if="canViewLastLegFees && lastLegFeesOpened" v-show="activeTab === 'last-leg-fees'" class="detail-section surface-panel" aria-label="尾程计费">
<ContainerLastLegFeesPanel title="目的港及海外段费用(尾程)" :container-booking-id="detail.id" :container-no="detail.containerNo" />
<ContainerLastLegFeesPanel title="目的港及海外段费用(尾程)" :container-booking-id="detail.id" :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" />
</section>
</template>
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
<AppDialog class="admin-density-dialog" v-model="emptyReturnOpen" title="确认还柜" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
<AppDialog class="admin-density-dialog" v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form class="density-form" label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog class="admin-density-dialog" v-model="exceptionOpen" :title="exceptionTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入异常原因或取消备注" /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
<AppDialog class="admin-density-dialog" v-model="clearanceOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><el-form class="density-form" label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form><el-button v-if="clearanceDocument" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
<AppDialog class="admin-density-dialog" v-model="directOpen" title="直送卸货登记" width="1080px" :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
<p>按真实装柜来源逐票登记签收;全部来源完成后由服务端核验还空资格。海外仓接仓仍使用原仓库收货流程。</p>
<AppDialog class="admin-density-dialog" v-model="emptyReturnOpen" :title="containerDialogTitle('确认还柜', detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form density-form--flush-end" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
<AppDialog class="admin-density-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section" data-tone="work"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog class="admin-density-dialog" v-model="exceptionOpen" :title="containerDialogTitle(exceptionTitle, detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入异常原因或取消备注" /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
<AppDialog class="admin-density-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section" data-tone="work"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="!clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading || !clearanceFile" @click="uploadClearance">上传资料</el-button></div></template></AppDialog>
<AppDialog class="admin-density-dialog" v-model="directOpen" :title="containerDialogTitle('直送卸货登记', detail)" width="1080px" fullscreenable :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
<p class="density-dialog-note">按真实装柜来源逐票登记签收;全部来源完成后核验还柜资格。海外仓接仓仍使用原仓库收货流程。</p>
<LoadErrorState v-if="directError" title="直送卸货清单读取失败" :message="directError.message" :loading="directLoading" @retry="loadDirect"><template #details><ApiErrorMeta :details="directError" /></template></LoadErrorState>
<p v-if="directLoading">正在读取实际装柜来源…</p>
<table v-if="!directError && !directLoading" class="density-table"><thead><tr><th>操作</th><th>入仓码 / 订单号</th><th>目的地</th><th>箱数</th><th>实际卸货</th></tr></thead><tbody><tr v-for="row in directRows" :key="row.requirementKey"><td><el-button class="density-action" :disabled="directSaving || !!directPending" @click="selectDirect(row)">{{ row.actualAt ? '查看签收' : canWriteDirect ? '登记本票' : '查看本票' }}</el-button></td><td>{{ row.inboundCode || row.orderNo || '—' }}</td><td>{{ row.destinationName || '—' }}</td><td>{{ row.packageCount ?? '—' }}</td><td>{{ row.actualAt || '尚未登记' }} {{ row.timezone || '' }}</td></tr></tbody></table>
<section v-if="!directError && !directLoading" class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">实际装柜来源</h3><table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 订单号</th><th>目的地</th><th>箱数</th><th>实际卸货</th></tr></thead><tbody><tr v-for="row in directRows" :key="row.requirementKey"><td><el-button class="density-action" :disabled="directSaving || !!directPending" @click="selectDirect(row)">{{ row.actualAt ? '查看签收' : canWriteDirect ? '登记本票' : '查看本票' }}</el-button></td><td>{{ row.inboundCode || row.orderNo || '—' }}</td><td>{{ row.destinationName || '—' }}</td><td>{{ row.packageCount ?? '—' }}</td><td>{{ row.actualAt || '尚未登记' }} {{ row.timezone || '' }}</td></tr></tbody></table></section>
<p v-if="!directLoading && !directError && !directRows.length">当前没有可登记的直送来源。</p>
<template v-if="directSelected"><h3>{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="top" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><el-form-item label="实际卸货当地时间" required><el-date-picker v-model="directForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="地点时区(IANA)" required><el-input v-model="directForm.timezone" placeholder="例如 America/Los_Angeles" /></el-form-item><el-form-item label="签收人" required><el-input v-model="directForm.receivedBy" maxlength="128" placeholder="请输入实际签收人" /></el-form-item><el-form-item label="真实签收凭据号" required><el-input v-model="directForm.evidenceNo" maxlength="128" placeholder="请输入真实签收凭据号" /></el-form-item><el-form-item label="备注"><el-input v-model="directForm.remark" type="textarea" maxlength="1000" placeholder="请输入卸货备注(选填)" /></el-form-item></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></template>
<section v-if="directSelected" class="density-dialog-section" :data-tone="!canWriteDirect || directSelected.actualAt ? 'source' : 'work'"><h3 class="density-dialog-section__title">{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="left" label-width="160px" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><table class="density-table density-table--editable container-workflow-table" aria-label="本票直送签收"><tbody><tr><td><el-form-item label="实际卸货当地时间" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.actualAt || '尚未登记' }}</span><el-date-picker v-else v-model="directForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="地点时区(IANA)" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.timezone || '—' }}</span><el-input v-else v-model="directForm.timezone" placeholder="例如 America/Los_Angeles" /></el-form-item></td></tr><tr><td><el-form-item label="签收人" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.receivedBy || '—' }}</span><el-input v-else v-model="directForm.receivedBy" maxlength="128" placeholder="请输入实际签收人" /></el-form-item></td><td><el-form-item label="真实签收凭据号" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.evidenceNo || '—' }}</span><el-input v-else v-model="directForm.evidenceNo" maxlength="128" placeholder="请输入真实签收凭据号" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="备注"><span v-if="!canWriteDirect || !!directSelected?.actualAt" class="direct-remark">{{ directSelected?.remark || '—' }}</span><el-input v-else v-model="directForm.remark" type="textarea" maxlength="1000" placeholder="请输入卸货备注(选填)" /></el-form-item></td></tr></tbody></table></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></section>
<LoadErrorState v-if="directSaveError" title="卸货登记未完成" :message="directSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="directSaveError" /></template></LoadErrorState>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="terminalOpen" title="提柜安排与执行" width="780px" :show-confirm="false" :before-close="closeTerminal">
<AppDialog class="admin-density-dialog" v-model="terminalOpen" :title="containerDialogTitle('提柜安排与执行', detail)" width="780px" fullscreenable :show-confirm="false" :before-close="closeTerminal">
<LoadErrorState v-if="terminalError" title="提柜资料读取失败" :message="terminalError.message" :loading="terminalLoading" @retry="loadTerminal"><template #details><ApiErrorMeta :details="terminalError" /></template></LoadErrorState>
<p v-if="terminalLoading">正在核对码头拖车当前状态…</p>
<template v-if="terminal"><p>{{ terminal.containerNo || title }} · 当前车队:{{ terminal.fleetSupplierName || '尚未安排' }}</p><p>实际提柜:{{ terminal.actualPickupAt || '尚未登记' }}。时间沿用原业务当地时间。</p>
<el-form class="density-form" label-position="top" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError">
<el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item>
<LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
<el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<template v-if="canRecordPickup"><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" placeholder="请输入实际提柜地点代码(选填)" /></el-form-item><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" placeholder="请输入实际提柜地点名称(选填)" /></el-form-item></template>
<el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item>
</el-form>
<div class="density-actions"><el-button v-if="canSelectFleet" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'fleet')" :loading="terminalSaving" @click="saveTerminal('fleet')">{{ terminalPending?.kind === 'fleet' ? '重试本次车队保存' : '保存码头车队' }}</el-button><el-button v-if="canSchedulePickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'appointment')" :loading="terminalSaving" @click="saveTerminal('appointment')">{{ terminalPending?.kind === 'appointment' ? '重试本次预约保存' : '保存提柜预约' }}</el-button></div>
<el-button v-if="canRecordPickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'actual')" :loading="terminalSaving" @click="saveTerminal('actual')">{{ terminalPending?.kind === 'actual' ? '重试本次实际提柜登记' : '登记实际提柜' }}</el-button>
<template v-if="terminal"><p class="density-dialog-note">时间沿用原业务当地时间。</p>
<section class="density-dialog-section" data-tone="work"><el-form class="density-form" label-position="left" label-width="140px" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError"><table class="density-table density-table--editable container-workflow-table" aria-label="提柜安排资料"><tbody><tr><td colspan="2"><el-form-item label="真实集装箱号"><span>{{ terminal?.containerNo || detail?.containerNo || '—' }}</span></el-form-item></td></tr><tr><td><el-form-item label="当前车队"><span>{{ terminal?.fleetSupplierName || (terminal?.fleetSupplierId ? '—' : '尚未安排') }}</span></el-form-item></td><td><el-form-item label="实际提柜"><span>{{ terminal?.actualPickupAt || '尚未登记' }}</span></el-form-item></td></tr><tr><td><el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item></td><td><el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" placeholder="请输入实际提柜地点代码(选填)" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td colspan="2"><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" placeholder="请输入实际提柜地点名称(选填)" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item></td></tr></tbody></table></el-form></section><LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
<LoadErrorState v-if="terminalSaveError" title="提柜操作未完成" :message="terminalSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="terminalSaveError" /></template></LoadErrorState>
</template>
</AppDialog>
<template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button @click="close">取消</el-button><template v-if="terminal"><el-button v-if="canSelectFleet" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'fleet')" :loading="terminalSaving" @click="saveTerminal('fleet')">{{ terminalPending?.kind === 'fleet' ? '重试本次车队保存' : '保存码头车队' }}</el-button><el-button v-if="canSchedulePickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'appointment')" :loading="terminalSaving" @click="saveTerminal('appointment')">{{ terminalPending?.kind === 'appointment' ? '重试本次预约保存' : '保存提柜预约' }}</el-button><el-button v-if="canRecordPickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'actual')" :loading="terminalSaving" @click="saveTerminal('actual')">{{ terminalPending?.kind === 'actual' ? '重试本次实际提柜登记' : '登记实际提柜' }}</el-button></template></div></template></AppDialog>
</AdminPage>
</template>
<style scoped>
.container-workflow-table { table-layout: fixed; }
.direct-remark { white-space: pre-wrap; }
.loading-list-heading { margin-bottom: var(--admin-space-5); }
.loading-list-heading h2 { margin: 0; font-size: var(--admin-font-title); }
.loading-list-heading p { margin: var(--admin-space-2) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
@@ -133,7 +133,7 @@ describe('ContainersView', () => {
expect(buttons).toHaveLength(1)
await buttons[0].trigger('click')
await flushPromises()
const returnDialog = wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '确认还柜')!
const returnDialog = wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('确认还柜'))!
returnDialog.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-09-11T13:00:00')
await returnDialog.find('textarea').setValue('还柜凭证已核对')
const callsBefore = vi.mocked(listContainers).mock.calls.length
@@ -176,7 +176,7 @@ describe('ContainersView', () => {
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ scope: 'ACTIVE' }), 'OVERSEAS')
expect(wrapper.text()).toContain('MSCU-1')
expect(wrapper.findAll('.row-actions button').some((button) => button.text() === '确认还柜')).toBe(false)
expect(wrapper.findAllComponents(AppDialog).some((dialog) => dialog.props('title') === '确认还柜' && dialog.props('modelValue'))).toBe(false)
expect(wrapper.findAllComponents(AppDialog).some((dialog) => dialog.props('title').startsWith('确认还柜') && dialog.props('modelValue'))).toBe(false)
expect(recordTerminalDrayageEmptyReturn).not.toHaveBeenCalled()
expect(getTerminalDrayage).not.toHaveBeenCalled()
if (missing === 'historical unloaded snapshot') {
@@ -200,7 +200,7 @@ describe('ContainersView', () => {
const open = async () => {
await wrapper.findAll('.row-actions button').find((button) => button.text() === '确认还柜')!.trigger('click')
await flushPromises()
return wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '确认还柜')!
return wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('确认还柜'))!
}
const first = await open()
first.vm.$emit('update:modelValue', false)
@@ -270,7 +270,19 @@ describe('ContainersView', () => {
expect(listPlatformPartners).toHaveBeenCalledTimes(1)
expect(listSuppliers).toHaveBeenCalledTimes(1)
expect(listOceanBookings).toHaveBeenCalledTimes(1)
wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '编辑')!.vm.$emit('update:modelValue', false)
const editor = wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('编辑货柜资料'))!
expect(editor.props('title')).toBe('编辑货柜资料 · CB-1')
const editorFields = editor.findAllComponents({ name: 'ElFormItem' })
expect(editorFields).toHaveLength(17)
for (const label of ['真实集装箱号', '柜次号', '货柜容量(m³)', '重量上限(kg)']) expect(editorFields.find(field => field.props('label') === label)!.find('input').exists()).toBe(false)
const vessel = editorFields.find(field => field.props('label') === '船名')!.findComponent({ name: 'ElInput' })
vessel.vm.$emit('update:modelValue', '编辑中的船名')
await flushPromises()
await editor.find('button[aria-label="展开全屏"]').trigger('click')
expect(editor.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(vessel.props('modelValue')).toBe('编辑中的船名')
expect(updateContainer).not.toHaveBeenCalled()
editor.vm.$emit('update:modelValue', false)
await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click')
await flushPromises()
expect(listPlatformPartners).toHaveBeenCalledTimes(1)
@@ -417,9 +429,9 @@ describe('ContainersView', () => {
expect(source).toContain("'domestic-container-detail' : 'overseas-container-detail'")
expect(source).not.toContain('<AppDialog v-model="detailOpen"')
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog"')
expect(source).toContain('width="min(820px, calc(100vw - 28px))"')
expect(source).toContain('width="min(1080px, calc(100vw - 28px))"')
expect(source).toContain('admin-density-dialog')
expect(source).toContain('grid-template-columns: repeat(2, minmax(0, 1fr))')
expect(source).toContain('class="density-table density-table--editable container-editor-table"')
expect(source).not.toContain('AppDrawer')
expect(source).not.toContain('<AppDrawer v-model="editorOpen"')
})
@@ -588,7 +600,7 @@ describe('ContainersView', () => {
expect(link).toBeDefined()
link.click()
await flushPromises()
expect(wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '关联已放舱订舱')?.props('modelValue')).toBe(true)
expect(wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('关联已放舱订舱'))?.props('modelValue')).toBe(true)
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 })
} finally {
wrapper.unmount()
@@ -617,8 +629,8 @@ describe('ContainersView', () => {
expect(simulationSource).toContain("channelGroupCode: '', warehouseId: '', containerType: DEFAULT_CONTAINER_TYPE, capacityCbm: 68, maxWeightKg: 19500")
expect(source).toContain('CONTAINER_SPECIFICATIONS')
expect(simulationSource).toContain('selectSimulationContainerType')
expect(simulationSource).toContain('服务端会生成稳定的柜次号')
expect(source).toContain(':model-value="form.cabinetNo" disabled placeholder="系统生成"')
expect(simulationSource).toContain('创建后自动生成柜次号')
expect(source).toContain("{{ form.cabinetNo || '—' }}")
expect(source).not.toContain('v-model="form.cabinetNo"')
expect(source).not.toContain('cabinetNo: form.cabinetNo.trim()')
expect(source).not.toContain('openCreate')
@@ -725,7 +737,7 @@ describe('ContainersView', () => {
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
await flushPromises()
wrapper.findAllComponents(AppDialog)
.find((dialog) => dialog.props('title') === '关联已放舱订舱')!.vm.$emit('update:modelValue', false)
.find((dialog) => dialog.props('title').startsWith('关联已放舱订舱'))!.vm.$emit('update:modelValue', false)
await flushPromises()
await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click')
@@ -21,6 +21,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getContainer, listContainers, updateContainer } from '../api/platformContainerLoading'
import { containerDialogTitle } from '../containerDialogTitle'
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
import { getSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
import { containerChannelLabel } from '../containerChannelLabel'
@@ -147,7 +148,7 @@ async function retryEmail(mail: TerminalDrayageEmail) {
if (!target || !canFleetEdit.value || retryingEmail.value || mail.status !== 'FAILED') return
retryingEmail.value = mail.id
try {
await ElMessageBox.confirm('发送失败时邮件仍可能已送达。请先核对车队未收到,再确认重试;同柜后续邮件会等待本邮件发送成功。', '重试邮件', { customClass: 'admin-density-confirm', confirmButtonText: '确认重试', cancelButtonText: '取消', type: 'warning' })
await ElMessageBox.confirm('发送失败时邮件仍可能已送达。请先核对车队未收到,再确认重试;同柜后续邮件会等待本邮件发送成功。', containerDialogTitle('重试邮件', emailTarget.value), { customClass: 'admin-density-confirm', confirmButtonText: '确认重试', cancelButtonText: '取消', type: 'warning' })
await retryTerminalDrayageEmail(target.id, mail.id)
ElMessage.success('邮件已重新加入发送队列')
await openEmailHistory(target)
@@ -197,7 +198,7 @@ const canEmptyReturnConfirm = computed(() => access.can('platform:firstleg:termi
const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list'))
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
const editorTitle = computed(() => form.id ? '编辑' : '新增')
const editorTitle = computed(() => form.id ? containerDialogTitle('编辑货柜资料', form) : '新增')
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER')))
const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS'))
interface PortOption { value: string; code: string | null; name: string | null }
@@ -775,59 +776,53 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
<OceanBookingLinkDialog v-model="linkOpen" :container="linkTarget" @linked="loadRows" />
<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError)" confirm-text="确定" cancel-text="关闭" @confirm="save">
<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog" :title="editorTitle" width="min(1080px, calc(100vw - 28px))" fullscreenable :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError)" confirm-text="确定" cancel-text="关闭" @confirm="save">
<LoadErrorState v-if="editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
<LoadErrorState v-if="warehouseError" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<el-alert v-if="businessOptionsError" type="warning" :closable="false" show-icon :title="businessOptionsError"><el-button link type="warning" :loading="businessOptionsLoading" @click="loadBusinessOptions">重新加载业务选项</el-button></el-alert>
<el-form v-if="!warehouseError" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid density-form">
<el-form-item label="真实集装箱号"><el-input :model-value="form.containerNo || '待补录'" disabled /><small>货柜基础资料不提供柜号写入;请在拖车提柜或预装详情中补录,后续纠错也在预装详情完成。</small></el-form-item>
<el-form-item label="柜次号"><el-input :model-value="form.cabinetNo" disabled placeholder="系统生成" /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" /></el-form-item>
<el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
<el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
<el-form-item label="箱型(货柜类型)" prop="containerType"><el-select :model-value="form.containerType" placeholder="请选择箱型" @update:model-value="applyContainerSpecification(form, String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
<el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="起运港"><el-select :model-value="departurePortValue" filterable clearable placeholder="请选择起运港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('departure', String($event || ''))"><el-option v-for="port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
<el-form-item label="目的港"><el-select :model-value="destinationPortValue" filterable clearable placeholder="请选择目的港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('destination', String($event || ''))"><el-option v-for="port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
<el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item>
<el-form-item label="货柜容量(m³)"><el-input :model-value="form.capacityCbm == null ? '—' : `${form.capacityCbm} m³`" disabled /></el-form-item>
<el-form-item label="重量上限(kg)"><el-input :model-value="form.maxWeightKg == null ? '—' : `${form.maxWeightKg} kg`" disabled /></el-form-item>
<el-form-item label="拖车公司" prop="terminalTruckingCompany"><el-select :model-value="selectedTruckingSupplierId" filterable clearable placeholder="请选择拖车供应商" :loading="businessOptionsLoading" loading-text="拖车供应商加载中" no-data-text="暂无可用拖车供应商" @update:model-value="selectTruckingSupplier(String($event || ''))"><el-option v-if="form.terminalTruckingCompany && !selectedTruckingSupplierId" :label="`${form.terminalTruckingCompany}(需重新选择)`" value="" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item>
<el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" /></el-form-item>
<el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" /></el-form-item>
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" /></el-form-item>
</el-form>
<p class="density-dialog-note">货柜基础资料不提供柜号写入;请在拖车提柜或预装详情中补录,后续纠错也在预装详情完成。</p>
<section v-if="!warehouseError" class="density-dialog-section" data-tone="work">
<el-form ref="formRef" :model="form" :rules="rules" label-position="left" label-width="120px" class="density-form">
<table class="density-table density-table--editable container-editor-table" aria-label="货柜基础资料"><tbody>
<tr><td><el-form-item label="真实集装箱号"><span>{{ form.containerNo || '待补录' }}</span></el-form-item></td><td><el-form-item label="柜次号"><span>{{ form.cabinetNo || '—' }}</span></el-form-item></td><td><el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" /></el-form-item></td><td><el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item></td><td><el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item></td></tr>
<tr><td><el-form-item label="箱型(货柜类型)" prop="containerType"><el-select :model-value="form.containerType" placeholder="请选择箱型" @update:model-value="applyContainerSpecification(form, String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item></td><td><el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item></td><td><el-form-item label="起运港"><el-select :model-value="departurePortValue" filterable clearable placeholder="请选择起运港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('departure', String($event || ''))"><el-option v-for="port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="目的港"><el-select :model-value="destinationPortValue" filterable clearable placeholder="请选择目的港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('destination', String($event || ''))"><el-option v-for="port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item></td><td><el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item></td><td><el-form-item label="货柜容量(m³)"><span>{{ form.capacityCbm == null ? '—' : `${form.capacityCbm} m³` }}</span></el-form-item></td></tr>
<tr><td><el-form-item label="重量上限(kg)"><span>{{ form.maxWeightKg == null ? '—' : `${form.maxWeightKg} kg` }}</span></el-form-item></td><td><el-form-item label="拖车公司" prop="terminalTruckingCompany"><el-select :model-value="selectedTruckingSupplierId" filterable clearable placeholder="请选择拖车供应商" :loading="businessOptionsLoading" loading-text="拖车供应商加载中" no-data-text="暂无可用拖车供应商" @update:model-value="selectTruckingSupplier(String($event || ''))"><el-option v-if="form.terminalTruckingCompany && !selectedTruckingSupplierId" :label="`${form.terminalTruckingCompany}(需重新选择)`" value="" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item></td><td><el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" /></el-form-item></td></tr>
<tr><td><el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" /></el-form-item></td><td colspan="2"><el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" /></el-form-item></td></tr>
</tbody></table>
</el-form>
</section>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="drayageActionOpen" :title="drayageActionText(drayageAction)" width="min(520px, calc(100vw - 28px))" :confirm-text="drayageActionText(drayageAction)" cancel-text="取消" :confirm-loading="drayageActionSaving" :confirm-disabled="!drayageActionAt" @confirm="saveDrayageAction">
<AppDialog class="admin-density-dialog" v-model="drayageActionOpen" :title="containerDialogTitle(drayageActionText(drayageAction), drayageTarget)" width="min(520px, calc(100vw - 28px))" :confirm-text="drayageActionText(drayageAction)" cancel-text="取消" :confirm-loading="drayageActionSaving" :confirm-disabled="!drayageActionAt" @confirm="saveDrayageAction">
<el-form class="density-form" label-position="top"><el-form-item label="实际还柜时间" required><el-date-picker v-model="drayageActionAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择时间" /></el-form-item><el-form-item label="原因/备注"><el-input v-model="drayageReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填" /></el-form-item></el-form>
<LoadErrorState v-if="drayageActionError" compact :title="`${drayageActionText(drayageAction)}失败`" :message="drayageActionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="drayageActionError" /></template></LoadErrorState>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="emailHistoryOpen" title="车队邮件记录" width="min(1100px, calc(100vw - 28px))" :show-footer="false">
<p>集装箱号:{{ emailTarget?.containerNo || '—' }} · 显示最近 100 封邮件</p>
<AppDialog class="admin-density-dialog" v-model="emailHistoryOpen" :title="containerDialogTitle('车队邮件记录', emailTarget)" fullscreenable width="min(1100px, calc(100vw - 28px))" :show-footer="false">
<p class="density-dialog-note">集装箱号:{{ emailTarget?.containerNo || '—' }} · 显示最近 100 封邮件</p>
<el-alert v-if="!emailsLoading && !emailsError && !emailDeliveryEnabled" title="发信尚未启用,邮件保留在待发送队列中" type="warning" :closable="false" />
<LoadErrorState v-if="emailsError" compact title="邮件记录加载失败" :message="emailsError.message" @retry="emailTarget && openEmailHistory(emailTarget)"><template #details><ApiErrorMeta :details="emailsError" /></template></LoadErrorState>
<DataTable v-else :data="emails" :columns="emailColumns" :loading="emailsLoading" column-settings-mode="hide" empty-text="暂无邮件记录">
<section v-else class="density-dialog-section" data-tone="source"><DataTable :data="emails" :columns="emailColumns" :loading="emailsLoading" column-settings-mode="hide" empty-text="暂无邮件记录">
<template #toolbar-actions><el-button :loading="emailsLoading" @click="emailTarget && openEmailHistory(emailTarget)">刷新记录</el-button></template>
<template #emailActions="{ row }"><el-button class="density-action" v-if="canFleetEdit && row.status === 'FAILED'" size="small" :loading="retryingEmail === row.id" :disabled="Boolean(retryingEmail)" @click="retryEmail(row as TerminalDrayageEmail)">重试</el-button><span v-else>—</span></template>
<template #emailContent="{ row }"><details><summary>{{ row.subject }}</summary><p style="white-space: pre-wrap">{{ row.body }}</p></details></template>
</DataTable>
</DataTable></section>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="drayageHistoryOpen" title="码头拖车记录" width="min(980px, calc(100vw - 28px))" :show-footer="false">
<AppDialog class="admin-density-dialog" v-model="drayageHistoryOpen" :title="containerDialogTitle('码头拖车记录', drayageTarget)" fullscreenable width="min(980px, calc(100vw - 28px))" :show-footer="false">
<LoadErrorState v-if="drayageHistoryError" compact title="码头拖车记录加载失败" :message="drayageHistoryError.message" :loading="drayageHistoryLoading" @retry="drayageTarget && openDrayageHistory(drayageTarget)"><template #details><ApiErrorMeta :details="drayageHistoryError" /></template></LoadErrorState>
<template v-else><el-descriptions v-if="drayageTarget && drayageFor(drayageTarget.id)" :column="4" border class="drayage-summary"><el-descriptions-item label="当前状态">{{ drayageStatusText(drayageFor(drayageTarget.id)?.status) }}</el-descriptions-item><el-descriptions-item label="码头车队">{{ drayageFor(drayageTarget.id)?.fleetSupplierName || '—' }}</el-descriptions-item><el-descriptions-item label="预计还柜">{{ formatTime(drayageFor(drayageTarget.id)?.estimatedEmptyReturnAt) }}</el-descriptions-item><el-descriptions-item label="实际还柜">{{ formatTime(drayageFor(drayageTarget.id)?.actualEmptyReturnAt) }}</el-descriptions-item></el-descriptions><DataTable :data="drayageHistory" :columns="drayageHistoryColumns" :loading="drayageHistoryLoading" stripe column-settings-mode="hide" empty-text="暂无码头拖车操作记录" /></template>
<div v-else class="density-dialog-sections"><section v-if="drayageTarget && drayageFor(drayageTarget.id)" class="density-dialog-section" data-tone="summary"><el-descriptions :column="4" border class="density-descriptions"><el-descriptions-item label="当前状态">{{ drayageStatusText(drayageFor(drayageTarget.id)?.status) }}</el-descriptions-item><el-descriptions-item label="码头车队">{{ drayageFor(drayageTarget.id)?.fleetSupplierName || '—' }}</el-descriptions-item><el-descriptions-item label="预计还柜">{{ formatTime(drayageFor(drayageTarget.id)?.estimatedEmptyReturnAt) }}</el-descriptions-item><el-descriptions-item label="实际还柜">{{ formatTime(drayageFor(drayageTarget.id)?.actualEmptyReturnAt) }}</el-descriptions-item></el-descriptions></section><section class="density-dialog-section" data-tone="source"><DataTable :data="drayageHistory" :columns="drayageHistoryColumns" :loading="drayageHistoryLoading" stripe column-settings-mode="hide" empty-text="暂无码头拖车操作记录" /></section></div>
</AppDialog>
</section>
</AdminPage>
</template>
<style scoped>
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.container-editor-table { table-layout: fixed; }.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
.container-query-panel { width: 100%; max-width: 100%; min-width: 0; box-sizing: border-box; }
.container-query-toolbar :deep(.query-toolbar__tail) { width: 100%; flex: 0 0 100%; }
.container-page .table-panel { width: 100%; max-width: 100%; min-width: 0; }
@media(max-width:720px){.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
@media(max-width:720px){.form-grid{grid-template-columns:1fr}}
@media(max-width:720px){.container-editor-table :is(tbody,tr,td){display:block}}
</style>
@@ -7,6 +7,7 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { ContainerBookingResource } from '../types'
import {
assignPendingReviewSmartLoadingOrder,
@@ -118,6 +119,49 @@ function mockContainerSearch(records: ContainerBookingResource[]) {
}
describe('GlobalSmartLoadingView', () => {
it.each(['PACKAGE', 'SEALED_PALLET'] as const)('分配窗口全屏保留来源及输入,按%s原接口提交', async (unitType) => {
if (unitType === 'SEALED_PALLET') {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
...candidate, unitType, palletId: '801', palletNo: 'DP-TEST-1', indivisible: true,
groupMembers: [
{ accountId: '1', inboundId: '180', orderId: '180', orderNo: 'O180', inboundCode: 'I180', customerName: '客户甲', availablePackageCount: 3, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
{ accountId: '1', inboundId: '181', orderId: '181', orderNo: 'O181', inboundCode: 'I181', customerName: '客户甲', availablePackageCount: 9, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
],
}]))
}
vi.mocked(assignSmartLoadingItem).mockResolvedValue(plan)
vi.mocked(assignSmartLoadingPallet).mockResolvedValue(plan)
const wrapper = mount(GlobalSmartLoadingView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.get('.candidate-pane .el-table__body-wrapper').findAll('button').find(button => button.text() === '加入')!.trigger('click')
await flushPromises()
const popup = wrapper.getComponent(AppDialog)
expect(popup.get('table[aria-label="全局装柜分配来源"]').text()).toContain('CB-1')
if (unitType === 'PACKAGE') {
const input = popup.getComponent(ElInputNumber)
expect(input.props('modelValue')).toBe(candidate.availablePackageCount)
expect(input.props('max')).toBe(candidate.availablePackageCount)
input.vm.$emit('update:modelValue', 4)
} else {
expect(popup.findComponent(ElInputNumber).exists()).toBe(false)
expect(popup.get('details').text()).toContain('I181:9 箱')
expect(popup.props('title')).toBe('整托加入当前柜次 · DP-TEST-1')
}
await popup.get('[aria-label="展开全屏"]').trigger('click')
expect(popup.find('.el-dialog.is-fullscreen').exists()).toBe(true)
if (unitType === 'PACKAGE') expect(popup.getComponent(ElInputNumber).props('modelValue')).toBe(4)
popup.vm.$emit('confirm'); await flushPromises()
if (unitType === 'PACKAGE') {
expect(assignSmartLoadingItem).toHaveBeenCalledWith(plan.id, { inboundId: candidate.inboundId, packageCount: 4, expectedVersion: plan.version })
expect(assignSmartLoadingPallet).not.toHaveBeenCalled()
} else {
expect(assignSmartLoadingPallet).toHaveBeenCalledWith(plan.id, { palletId: '801', expectedVersion: plan.version })
expect(assignSmartLoadingItem).not.toHaveBeenCalled()
}
expect(popup.props('modelValue')).toBe(false)
wrapper.unmount()
})
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
@@ -139,6 +183,7 @@ describe('GlobalSmartLoadingView', () => {
it('loads real containers, candidates and the selected plan into the three-column workbench', async () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.get('.container-option.selected').attributes('aria-pressed')).toBe('true')
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 })
@@ -498,8 +543,13 @@ describe('GlobalSmartLoadingView', () => {
await buttons[0].trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除')
expect(document.body.textContent).toContain('目标柜次:CB-1 · 集装箱号:MSCU-1')
expect(document.body.textContent).toContain('发货仓:义乌仓 · 柜次渠道:美森正班')
const popup = wrapper.getComponent(AppDialog)
expect(popup.props('title')).toBe('整组加入当前柜次 · CLG-2093539779795795969')
const contextRows = popup.findAll('table[aria-label="全局装柜分配来源"] tr')
expect(contextRows[0]!.findAll('td').map(cell => cell.text())).toEqual(['CB-1', 'MSCU-1'])
expect(contextRows[1]!.findAll('td').map(cell => cell.text())).toEqual(['义乌仓', '美森正班'])
await popup.get('[aria-label="展开全屏"]').trigger('click')
expect(popup.get('table[aria-label="全局装柜同柜组成员"]').text()).toContain('NYEXX2608290051-B')
expect(document.body.textContent).not.toContain('本次分配箱数')
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
await flushPromises()
@@ -533,10 +583,12 @@ describe('GlobalSmartLoadingView', () => {
expect(add.classes()).not.toContain('is-disabled')
await add.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('平均单箱毛重 待测量')
expect(document.body.textContent).toContain('平均单箱体积 待测量')
expect(document.body.textContent).not.toContain('平均单箱毛重 0 kg')
expect(document.body.textContent).toContain('订单渠道:合德海卡')
const popup = wrapper.getComponent(AppDialog)
expect(popup.props('title')).toBe('加入当前柜次 · EXXHK2609020361')
const measurements = popup.findAll('table[aria-label="全局装柜分配填写"] tr').find(row => row.text().includes('平均单箱毛重'))!
expect(measurements.findAll('td').map(cell => cell.text())).toEqual(['待测量', '待测量'])
const channel = popup.findAll('table[aria-label="全局装柜分配来源"] tr').find(row => row.text().includes('订单渠道'))!
expect(channel.findAll('td')[0]!.text()).toBe('合德海卡')
expect(document.body.textContent).toContain('这是人工装柜分配,确认后仍会加入')
expect(document.body.textContent).toContain('智能推荐不会跨渠道')
;([...document.body.querySelectorAll('button')]
@@ -876,6 +928,7 @@ describe('GlobalSmartLoadingView', () => {
await removeAllButton!.trigger('click')
await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalledWith(expect.any(String), '移除未拣明细 · CB-1', expect.objectContaining({ customClass: 'admin-density-confirm' }))
expect(removeAllUnpickedSmartLoadingItems).toHaveBeenCalledTimes(1)
expect(removeAllUnpickedSmartLoadingItems).toHaveBeenCalledWith(plan.id, 2)
expect(removeSmartLoadingItem).not.toHaveBeenCalled()
@@ -1066,7 +1119,7 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain('.channel-tags :deep(.el-checkbox-button__inner)')
expect(source).toContain('flex-wrap: nowrap; gap: 6px;')
expect(source).toContain('height: 34px; padding: 0 10px;')
expect(source).toContain('font-size: 13px; line-height: 32px;')
expect(source).toContain('font-size: var(--admin-font-body); line-height: 32px;')
expect(source).not.toContain('class="summary-band"')
expect(source).toContain('class="capacity-grid"')
expect(source).toContain('智能推荐')
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { Pencil, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
import SmartLoadingCandidateInbounds from '../components/SmartLoadingCandidateInbounds.vue'
@@ -65,6 +66,12 @@ const mutationError = ref<ApiErrorDetails | null>(null)
const noDraft = ref(false)
const assignOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
const assignmentTitle = computed(() => {
const candidate = selectedCandidate.value
const action = candidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : candidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'
const code = candidate?.palletNo?.trim() || candidate?.groupNo?.trim() || candidate?.inboundCode?.trim() || candidate?.orderNo?.trim()
return code ? `${action} · ${code}` : action
})
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
@@ -590,6 +597,10 @@ async function adjust(source: object) {
ElMessage.success('箱数已调整')
}, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } })
}
function workbenchDialogTitle(action: string) {
const code = selectedContainer.value?.cabinetNo?.trim() || (hasRealContainerNo(selectedContainer.value) ? selectedContainer.value?.containerNo?.trim() : '')
return code ? `${action} · ${code}` : action
}
async function remove(source: object) {
const item = itemRow(source)
if (!plan.value || !item.canRemove) return
@@ -600,8 +611,8 @@ async function remove(source: object) {
: sealedPallet
? `确认从当前柜次整托移除托盘“${item.palletNo || '—'}”(${item.packageCount} 箱)吗?`
: `确认从当前柜次移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`,
row.coLoadGroup ? '移除同柜不可拆组' : sealedPallet ? '整托移除' : '移除装柜明细', {
type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : sealedPallet ? '整托移除' : '移除', cancelButtonText: '取消',
workbenchDialogTitle(row.coLoadGroup ? '移除同柜不可拆组' : sealedPallet ? '整托移除' : '移除装柜明细'), {
customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : sealedPallet ? '整托移除' : '移除', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
@@ -632,8 +643,8 @@ async function removeAll() {
const removableItems = displayedPlanItems.value.filter((item) => item.canRemove)
if (!removableItems.length) return
const itemCount = removableItems.length
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的 ${itemCount} 个未拣入仓码吗?已拣入仓码会保留。`, '移除未拣明细', {
type: 'warning', confirmButtonText: '确认移除', cancelButtonText: '取消',
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的 ${itemCount} 个未拣入仓码吗?已拣入仓码会保留。`, workbenchDialogTitle('移除未拣明细'), {
customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认移除', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
@@ -676,7 +687,7 @@ onMounted(() => { void load(); void loadCustomers() })
</script>
<template>
<component :is="props.embedded ? 'div' : AdminPage" :class="{ 'embedded-smart-loading': props.embedded }">
<component class="admin-density" :is="props.embedded ? 'div' : AdminPage" :class="{ 'embedded-smart-loading': props.embedded }">
<section class="global-loading-page">
<div v-if="channelOptions.length" class="channel-summary-row">
<el-checkbox-group :model-value="selectedChannelCodes" class="channel-tags" aria-label="渠道筛选,至少选择一项" @change="selectChannels($event as string[])">
@@ -743,9 +754,10 @@ onMounted(() => { void load(); void loadCustomers() })
<template #pagination-left="{ total, page, pageCount }"><span v-if="pageCount > 0">共 {{ total }} 条 · {{ page }}/{{ pageCount }} 页</span><span v-else>共 0 条</span></template>
<template #actions="{ row }">
<el-button
class="density-action" data-tone="primary"
v-if="canAssign && assignable"
size="small"
type="text"
type="primary"
plain
:disabled="mutating || candidateBlocked(row as SmartLoadingCandidateRow)"
@click="openAssign(row)"
@@ -774,6 +786,7 @@ onMounted(() => { void load(); void loadCustomers() })
type="button"
class="container-option"
:class="{ selected: item.bookingId === selectedContainerId }"
:aria-pressed="item.bookingId === selectedContainerId"
@click="selectContainer(item.bookingId)"
>
<span class="container-option__top">
@@ -825,7 +838,7 @@ onMounted(() => { void load(); void loadCustomers() })
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
</div>
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" text :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" data-tone="danger" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
<template #planInbounds="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><details v-if="row.coLoadGroup" class="candidate-group"><summary>整组加入 · 不可拆</summary><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`固定 ${row.coLoadGroup.fullPackageCount} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></template>
@@ -837,28 +850,38 @@ onMounted(() => { void load(); void loadCustomers() })
</section>
</section>
<AppDialog v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
<div v-if="selectedCandidate" class="assign-dialog">
<p><strong>{{ selectedCandidate.groupNo || selectedCandidate.customerName || '客户待补充' }}</strong></p>
<p>目标柜次:{{ displayCabinetNo(selectedContainer) }} · 集装箱号:{{ displayActualContainerNo(selectedContainer) }}</p>
<p>发货仓:{{ selectedContainer?.warehouseName || '—' }} · 柜次渠道:{{ containerChannelGroup(selectedContainer)?.name || '—' }}</p>
<p>订单渠道:{{ selectedCandidate.channelName || '—' }}</p>
<AppDialog class="admin-density-dialog" v-model="assignOpen" :title="assignmentTitle" width="min(920px, calc(100vw - 28px))" fullscreenable :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
<div v-if="selectedCandidate" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source">
<table class="density-table assignment-context-table" aria-label="全局装柜分配来源"><tbody>
<tr><th>目标柜次</th><td>{{ displayCabinetNo(selectedContainer) }}</td><th>集装箱号</th><td>{{ displayActualContainerNo(selectedContainer) }}</td></tr>
<tr><th>发货仓</th><td>{{ selectedContainer?.warehouseName || '—' }}</td><th>柜次渠道</th><td>{{ containerChannelGroup(selectedContainer)?.name || '—' }}</td></tr>
<tr><th>订单渠道</th><td>{{ selectedCandidate.channelName || '—' }}</td><th>{{ selectedCandidate.coLoadGroupId ? '同柜组' : '客户' }}</th><td>{{ selectedCandidate.coLoadGroupId ? selectedCandidate.groupNo : selectedCandidate.customerName || '客户待补充' }}</td></tr>
</tbody></table>
</section>
<el-alert v-if="crossChannelAssignment" title="订单渠道与柜次渠道不同;这是人工装柜分配,确认后仍会加入。智能推荐不会跨渠道。" type="warning" :closable="false" />
<section class="density-dialog-section" data-tone="work">
<el-form class="density-form">
<table class="density-table density-table--editable assignment-context-table" aria-label="全局装柜分配填写"><tbody>
<tr v-if="selectedCandidate.coLoadGroupId"><th>整组范围</th><td colspan="3">组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</td></tr>
<tr v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><th>托盘号</th><td>{{ selectedCandidate.palletNo || '—' }}</td><th>整托箱数</th><td>整托 {{ selectedCandidate.availablePackageCount }} 箱</td></tr>
<template v-else>
<tr><th>入仓码</th><td>{{ selectedCandidate.inboundCode || '—' }}</td><th>可用箱数</th><td>{{ selectedCandidate.availablePackageCount }} 箱</td></tr>
<tr><th>平均单箱毛重</th><td>{{ formatMeasurement(selectedCandidate.unitGrossWeightKg, 'kg', 1) }}</td><th>平均单箱体积</th><td>{{ formatMeasurement(selectedCandidate.averageUnitVolumeCbm, 'm³') }}</td></tr>
<tr><td colspan="4"><el-form-item label="本次分配箱数" label-position="left" label-width="128px" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></td></tr>
</template>
</tbody></table>
</el-form>
</section>
<template v-if="selectedCandidate.coLoadGroupId">
<p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p>
<p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p>
<section class="density-dialog-section" data-tone="source"><table class="density-table" aria-label="全局装柜同柜组成员"><thead><tr><th>组内订单及入仓片段</th></tr></thead><tbody><tr v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary"><td>{{ summary }}</td></tr></tbody></table></section>
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
</template>
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
<p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 整托 {{ selectedCandidate.availablePackageCount }} 箱</p>
<SmartLoadingCandidateInbounds :candidate="selectedCandidate" />
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">托盘入仓组成</h3><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /></section>
<el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" />
</template>
<template v-else>
<p>入仓码 {{ selectedCandidate.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatMeasurement(selectedCandidate.unitGrossWeightKg, 'kg', 1) }} · 平均单箱体积 {{ formatMeasurement(selectedCandidate.averageUnitVolumeCbm, 'm³') }}</p>
<small>本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</small>
<el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form>
</template>
<p v-else class="density-dialog-note">本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</p>
</div>
</AppDialog>
</template>
@@ -867,24 +890,26 @@ onMounted(() => { void load(); void loadCustomers() })
</template>
<style scoped>
.assignment-context-table { table-layout: fixed; }
.assignment-context-table th { width: 120px; }
.global-loading-page { display: flex; min-width: 0; min-height: 0; flex-direction: column; gap: 12px; }
.embedded-smart-loading { display: flex; width: 100%; min-width: 0; min-height: 0; flex: 1; overflow: hidden; }
.embedded-smart-loading .global-loading-page { width: 100%; min-height: 0; flex: 1; overflow: hidden; }
.channel-summary-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; min-width: 0; align-items: center; border-bottom: 2px solid var(--cc-border); background: #fff; }
.channel-tags { display: flex; min-width: 0; align-items: center; flex-wrap: nowrap; gap: 6px; padding: 6px 0; overflow-x: auto; }
.channel-tags__label { flex: 0 0 auto; margin-right: 2px; color: var(--cc-text); font-size: 13px; font-weight: 600; }
.channel-tags__label { flex: 0 0 auto; margin-right: 2px; color: var(--cc-text); font-size: var(--admin-font-body); font-weight: 600; }
.channel-tags :deep(.el-checkbox-button) { flex: 0 0 auto; }
.channel-tags :deep(.el-checkbox-button__inner) { height: 34px; padding: 0 10px; border: 1px solid var(--cc-border) !important; border-radius: 6px !important; box-shadow: none !important; font-size: 13px; line-height: 32px; }
.channel-tags :deep(.el-checkbox-button__inner) { height: 34px; padding: 0 10px; border: 1px solid var(--cc-border) !important; border-radius: 6px !important; box-shadow: none !important; font-size: var(--admin-font-body); line-height: 32px; }
.compact-summary { display: flex; min-height: 46px; align-items: center; justify-content: flex-end; gap: 12px; margin: 0; padding: 5px 8px; white-space: nowrap; }
.compact-summary > div { display: flex; align-items: baseline; gap: 4px; }
.compact-summary dt { color: var(--cc-muted); font-size: 12px; }
.compact-summary dd { margin: 0; color: var(--cc-text); font-size: 12px; }
.compact-summary strong { color: var(--el-color-primary); font-size: 14px; }
.compact-summary dt { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.compact-summary dd { margin: 0; color: var(--cc-text); font-size: var(--admin-font-caption); }
.compact-summary strong { color: var(--el-color-primary); font-size: var(--admin-font-section); }
:deep(.smart-loading-query.query-toolbar) { padding: 0; border: 0; background: transparent; }
:deep(.smart-loading-query .query-toolbar__filters) { flex-wrap: nowrap; gap: 8px; overflow-x: auto; }
:deep(.smart-loading-query .query-toolbar__tail) { flex-wrap: nowrap; gap: 8px; }
:deep(.smart-loading-query .query-toolbar__actions) { flex-wrap: nowrap; gap: 6px; }
.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: 12px; }
.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.dispatch-grid { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 2fr) minmax(0, 3fr); height: clamp(520px, 68vh, 720px); min-height: 0; overflow-x: auto; border: 1px solid var(--cc-border); background: #fff; }
.embedded-smart-loading .dispatch-grid { height: auto; min-height: 0; flex: 1; }
.dispatch-pane { display: flex; min-width: 0; min-height: 0; flex-direction: column; }
@@ -902,9 +927,9 @@ onMounted(() => { void load(); void loadCustomers() })
.container-list { display: grid; min-height: 0; flex: 1; grid-auto-rows: max-content; align-content: start; gap: 8px; padding: 10px; overflow-y: auto; }
.container-option { display: grid; width: 100%; min-height: 112px; height: auto; align-content: start; gap: 6px; padding: 14px; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; color: var(--cc-text); line-height: 1.4; text-align: left; cursor: pointer; }
.container-option:hover,.container-option:focus-visible { border-color: var(--el-color-primary); outline: none; }
.container-option.selected { border-color: var(--el-color-primary); box-shadow: inset 3px 0 0 var(--el-color-primary); background: var(--el-color-primary-light-9); }
.container-option.selected { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.container-option__top { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--cc-text) !important; }
.container-option__identity { color: var(--cc-text); font-size: 13px; font-weight: 600; }
.container-option__identity { color: var(--cc-text); font-size: var(--admin-font-body); font-weight: 600; }
.container-option__hint { color: var(--el-color-warning); font-weight: 600; }
.container-option span,.container-option strong { min-width: 0; overflow-wrap: anywhere; }
.plan-header { flex-wrap: wrap; }
@@ -914,17 +939,15 @@ onMounted(() => { void load(); void loadCustomers() })
.capacity-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 12px; border-bottom: 1px solid var(--cc-border); }
.preload-readiness-alert { border-radius: 0; border-right: 0; border-left: 0; }
.readonly-status-card { display: grid; align-content: start; justify-items: start; gap: 10px; padding: 28px 24px; }
.readonly-status-card strong { font-size: 16px; }
.readonly-status-card strong { font-size: var(--admin-font-title); }
.readonly-status-card p { max-width: 460px; margin: 0; color: var(--cc-muted); line-height: 1.6; }
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: var(--admin-font-caption); }
.package-count-editor { display: grid; min-width: 0; gap: 4px; }
.package-count-summary { display: grid; gap: 3px; line-height: 1.35; }
.package-count-used { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.package-count-summary strong { font-size: 13px; font-weight: 600; }
.package-count-summary small { color: var(--cc-muted); font-size: 12px; }
.package-count-summary strong { font-size: var(--admin-font-body); font-weight: 600; }
.package-count-summary small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
.assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; }
.candidate-group { display: grid; width: 100%; min-width: 0; gap: 4px; }
.candidate-group :deep(.el-tag),.candidate-group :deep(.el-tag__content),.candidate-group :deep(.line-clamp-text) { max-width: 100%; min-width: 0; }
.candidate-group :deep(.line-clamp-text) { width: 100%; }
@@ -1,5 +1,6 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { AxiosError } from 'axios'
import AppDialog from '@/shared/components/AppDialog.vue'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import { flushPromises, mount } from '@vue/test-utils'
@@ -75,7 +76,9 @@ describe('SmartLoadingWorkbenchView', () => {
expect(wrapper.text()).not.toContain('LP-99')
expect(wrapper.text()).toContain('IN-1')
expect(wrapper.text()).not.toContain('ORD-1')
expect(wrapper.text()).toContain('150% 容量上限')
expect(wrapper.text()).not.toContain('150% 容量上限')
await wrapper.get('button[aria-label="查看装柜说明"]').trigger('mouseenter')
await vi.waitFor(() => expect(document.body.textContent).toContain('150% 容量上限'))
})
it('shows the full inbound composition for an assigned sealed pallet', async () => {
@@ -189,6 +192,11 @@ describe('SmartLoadingWorkbenchView', () => {
expect(wrapper.findAll('.el-table__body-wrapper button').filter((button) => button.text() === '加入')).toHaveLength(1)
await wrapper.findAll('.el-table__body-wrapper button').find((button) => button.text() === '加入')!.trigger('click')
await flushPromises()
const dialog = wrapper.getComponent(AppDialog)
expect(dialog.props('title')).toBe('整组加入当前柜次 · CLG-9007199254740999')
expect(dialog.findComponent({ name: 'ElInputNumber' }).exists()).toBe(false)
await dialog.get('[aria-label="展开全屏"]').trigger('click')
expect(dialog.get('table[aria-label="同柜组成员"]').text()).toContain('ORD-1:IN-201、IN-203')
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
await flushPromises()
expect(assignSmartLoadingGroup).toHaveBeenCalledWith(planResponse.id, '201', planResponse.version)
@@ -222,9 +230,14 @@ describe('SmartLoadingWorkbenchView', () => {
await wrapper.find('.el-table__body-wrapper button').trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('本次计划装柜箱数')
const dialog = wrapper.getComponent(AppDialog)
expect(dialog.props('title')).toBe('按入仓码分配箱数 · IN-1')
const count = dialog.getComponent({ name: 'ElInputNumber' })
expect(count.props('max')).toBe(12)
count.vm.$emit('update:modelValue', 5)
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认分配') as HTMLButtonElement).click()
await flushPromises()
expect(assignSmartLoadingItem).toHaveBeenCalledWith(planResponse.id, { inboundId: '2', packageCount: 1, expectedVersion: planResponse.version })
expect(assignSmartLoadingItem).toHaveBeenCalledWith(planResponse.id, { inboundId: '2', packageCount: 5, expectedVersion: planResponse.version })
expect(assignSmartLoadingGroup).not.toHaveBeenCalled()
wrapper.unmount()
})
@@ -245,6 +258,10 @@ describe('SmartLoadingWorkbenchView', () => {
await flushPromises()
expect(document.body.textContent).toContain('不可拆分单元整托加入')
expect(document.body.textContent).not.toContain('本次计划装柜箱数')
const dialog = wrapper.getComponent(AppDialog)
expect(dialog.props('title')).toBe('整托加入当前柜次 · DP-260908-0001')
await dialog.get('[aria-label="展开全屏"]').trigger('click')
expect(dialog.findComponent({ name: 'ElInputNumber' }).exists()).toBe(false)
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认整托分配') as HTMLButtonElement).click()
await flushPromises()
@@ -1,5 +1,7 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import SmartLoadingCandidateInbounds from '../components/SmartLoadingCandidateInbounds.vue'
import { containerDialogTitle } from '../containerDialogTitle'
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -38,6 +40,12 @@ const noDraft = ref(false)
const editingQuantityId = ref('')
const assignOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
const assignmentTitle = computed(() => {
const candidate = selectedCandidate.value
const action = candidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : candidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'
const code = candidate?.palletNo?.trim() || candidate?.groupNo?.trim() || candidate?.inboundCode?.trim()
return code ? `${action} · ${code}` : action
})
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
@@ -307,7 +315,7 @@ async function remove(source: object) {
const message = row.coLoadGroup
? `确认整组移除 ${row.coLoadGroup.groupNo}(${groupMemberLabel(row.coLoadGroup)}、${row.packageCount} 箱)吗?组内所有入仓片段将联动移除。`
: `确认从计划移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`
await ElMessageBox.confirm(message, row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
await ElMessageBox.confirm(message, containerDialogTitle(row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', container.value), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
if (!confirmed || !plan.value) return
mutating.value = true
mutationError.value = null
@@ -386,7 +394,7 @@ onMounted(load)
<el-alert v-if="plan.status === 'LOADING' && plan.canEdit" type="warning" :closable="false" show-icon title="已下发 PDA,仍可维护未完成的装柜明细" description="新增、增加或移除未拣明细会刷新 PDA 明细与版本;PDA 端需要刷新后继续操作。" />
<el-alert v-else-if="!plan.canEdit" :type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'" :closable="false" show-icon :title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'" :description="plan.blockedReason || '请刷新计划获取最新作业状态。'" />
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案 <InlineHelp title="装柜说明"><p>智能建议必须经人工核对。</p><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></InlineHelp></h2></div></template>
<template #actions="{ row }"><div class="row-actions density-actions"><el-button data-tone="danger" class="density-action" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)">删除</el-button></div></template>
<template #order="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
@@ -396,7 +404,7 @@ onMounted(load)
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
</DataTable>
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可装柜货物">
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></div></template>
<template #toolbar-start><div><h2>候选货物</h2></div></template>
<template #actions="{ row }"><el-button data-tone="primary" class="density-action" v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)">加入</el-button></template>
<template #candidateInbounds="{ row }"><SmartLoadingCandidateInbounds :candidate="row as SmartLoadingCandidate" /></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
@@ -408,7 +416,35 @@ onMounted(load)
</template>
</template>
</template>
<AppDialog class="admin-density-dialog" v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><template v-if="selectedCandidate.coLoadGroupId"><p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p><p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p><el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /></template><template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 整托 {{ selectedCandidate.availablePackageCount }} 箱</p><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /><el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" /></template><template v-else><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form class="density-form" label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></template></div></AppDialog>
<AppDialog class="admin-density-dialog" v-model="assignOpen" :title="assignmentTitle" width="min(780px, calc(100vw - 28px))" :fullscreenable="Boolean(selectedCandidate?.coLoadGroupId || selectedCandidate?.unitType === 'SEALED_PALLET')" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign">
<div v-if="selectedCandidate" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="work">
<el-form class="density-form">
<table class="density-table density-table--editable assignment-table" aria-label="加入当前柜次"><tbody>
<tr><th>来源编号</th><td>{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</td><th>当前柜次</th><td>{{ container?.cabinetNo || displayContainerNo(container) }}</td></tr>
<tr v-if="selectedCandidate.coLoadGroupId"><th>整组范围</th><td colspan="3">组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</td></tr>
<tr v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><th>整托箱数</th><td colspan="3">整托 {{ selectedCandidate.availablePackageCount }} 箱</td></tr>
<template v-else>
<tr><th>货物摘要</th><td colspan="3"><LineClampText :content="selectedCandidate.goodsSummary || '货物摘要暂缺'" /></td></tr>
<tr><th>可用箱数</th><td>{{ selectedCandidate.availablePackageCount }} 箱</td><td colspan="2"><el-form-item label="本次计划装柜箱数" label-position="left" label-width="140px" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></td></tr>
</template>
</tbody></table>
</el-form>
</section>
<template v-if="selectedCandidate.coLoadGroupId">
<section class="density-dialog-section" data-tone="source"><table class="density-table" aria-label="同柜组成员"><thead><tr><th>组内订单及入仓片段</th></tr></thead><tbody><tr v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary"><td>{{ summary }}</td></tr></tbody></table></section>
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
</template>
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">托盘入仓组成</h3><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /></section>
<el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" />
</template>
<template v-else>
<el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" />
<p class="density-dialog-note">只记录数量,具体上柜箱号未核验。</p>
</template>
</div>
</AppDialog>
</section>
</AdminPage>
</template>
@@ -444,8 +480,8 @@ onMounted(load)
.package-count-summary strong { font-size: var(--admin-font-body); font-weight: 600; }
.package-count-summary small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
.assign-dialog p { margin: 0 0 var(--admin-space-4); }
.assign-dialog :deep(.el-input-number) { width: 100%; }
.assignment-table { table-layout: fixed; }
.assignment-table th { width: 90px; }
.candidate-group { display: grid; width: 100%; min-width: 0; gap: 3px; }
.candidate-group :deep(.el-tag),.candidate-group :deep(.el-tag__content),.candidate-group :deep(.line-clamp-text) { max-width: 100%; min-width: 0; }
.candidate-group :deep(.line-clamp-text) { width: 100%; color: var(--cc-muted); font-size: var(--admin-font-caption); }