This commit is contained in:
2026-08-14 19:02:34 +08:00
parent 44ee2be246
commit 4dec54feef
14 changed files with 215 additions and 54 deletions
+5 -10
View File
@@ -50,16 +50,11 @@ describe('admin router access metadata', () => {
expect(route?.meta.permission).toBe('platform:express:list')
})
it('registers the global smart-loading route before the single-container deep link', () => {
const route = router.getRoutes().find((item) => item.name === 'global-smart-loading-workbench')
const moduleChildren = router.options.routes.find((item) => item.path === '/')?.children || []
const globalIndex = moduleChildren.findIndex((item) => item.name === 'global-smart-loading-workbench')
const detailIndex = moduleChildren.findIndex((item) => item.name === 'smart-loading-workbench')
expect(route?.path).toBe('/container-loading/smart-loading')
expect(route?.meta.permission).toBe('platform:firstleg:smart-loading:list')
expect(globalIndex).toBeGreaterThanOrEqual(0)
expect(detailIndex).toBeGreaterThan(globalIndex)
it('keeps smart loading as an order dialog while retaining the single-container deep link', () => {
expect(router.getRoutes().some((item) => item.name === 'global-smart-loading-workbench')).toBe(false)
const detail = router.getRoutes().find((item) => item.name === 'smart-loading-workbench')
expect(detail?.path).toBe('/container-loading/:containerId/smart-loading')
expect(detail?.meta.permission).toBe('platform:firstleg:smart-loading:list')
})
it('registers the retained phase-three service center routes with stable permissions', () => {
+2 -5
View File
@@ -45,7 +45,6 @@ describe('adminNavigation', () => {
'订舱列表',
'拖车列表',
'货柜列表',
'智能装柜',
'预装清单',
'装柜清单',
])
@@ -91,7 +90,7 @@ describe('adminNavigation', () => {
expect(paths).not.toEqual(expect.arrayContaining(['/notifications/messages', '/notifications/templates', '/notifications/mine']))
expect(paths).toContain('/master-data/fee-items')
expect(paths).toContain('/container-loading')
expect(paths).toContain('/container-loading/smart-loading')
expect(paths).not.toContain('/container-loading/smart-loading')
expect(paths).toContain('/loading-plans')
expect(paths).toContain('/loading-lists')
expect(paths).not.toContain('/port-schedules')
@@ -192,10 +191,9 @@ describe('adminNavigation', () => {
expect(getNavigationLeaves(missing).map((item) => item.path)).toEqual(['/dashboard'])
})
it('shows each formal container workflow entry only with its matching permission', () => {
it('shows container list and downstream lists without a standalone smart-loading menu', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set([
'platform:firstleg:container:list',
'platform:firstleg:smart-loading:list',
'platform:firstleg:preload:list',
'platform:firstleg:loading-list:list',
]))
@@ -203,7 +201,6 @@ describe('adminNavigation', () => {
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
'/dashboard',
'/container-loading',
'/container-loading/smart-loading',
'/loading-plans',
'/loading-lists',
])
-1
View File
@@ -102,7 +102,6 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' },
{ key: 'truck-list', title: '拖车列表', icon: Truck, path: '/trucking', permission: 'platform:firstleg:truck-appointment:list' },
{ key: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading', permission: 'platform:firstleg:container:list' },
{ key: 'container-smart-loading', title: '智能装柜', icon: ClipboardList, path: '/container-loading/smart-loading', permission: 'platform:firstleg:smart-loading:list' },
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' },
{ key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/loading-lists', permission: 'platform:firstleg:loading-list:list' },
],
@@ -0,0 +1,30 @@
<script setup lang="ts">
import AppDialog from '@/shared/components/AppDialog.vue'
import GlobalSmartLoadingView from '../pages/GlobalSmartLoadingView.vue'
const open = defineModel<boolean>({ default: false })
</script>
<template>
<AppDialog
v-model="open"
class="smart-loading-dialog"
title="智能装柜"
width="min(1600px, 98vw)"
cancel-text="关闭"
:show-confirm="false"
destroy-on-close
>
<GlobalSmartLoadingView embedded />
</AppDialog>
</template>
<style scoped>
:global(.smart-loading-dialog.app-dialog.el-dialog) { width: min(1600px, 98vw); max-height: 94vh; margin-block: 3vh !important; }
:global(.smart-loading-dialog .el-dialog__body) { padding: 0 16px 16px; }
:global(.smart-loading-dialog .app-dialog__body) { min-width: 0; }
@media (max-width: 720px) {
:global(.smart-loading-dialog.app-dialog.el-dialog) { width: calc(100vw - 12px); max-height: calc(100svh - 12px); margin-block: 6px !important; }
:global(.smart-loading-dialog .el-dialog__body) { padding: 0 8px 8px; }
}
</style>
@@ -35,8 +35,8 @@ describe('ContainersView', () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([])
vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
})
@@ -96,7 +96,7 @@ describe('ContainersView', () => {
it('routes each plan status to its owning workflow without reopening pushed drafts', async () => {
const base = {
id: '9001', planId: '9101', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', version: 0,
id: '9001', planId: '9101', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', version: 0,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000,
@@ -41,7 +41,7 @@ vi.mock('../api/platformContainerLoading', () => ({
const container = {
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1,
warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 68, maxWeightKg: 28000,
@@ -59,7 +59,7 @@ describe('GlobalSmartLoadingView', () => {
vi.clearAllMocks()
vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 100, total: 1, pages: 1 })
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(plan)
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', inboundDetailId: '6', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsName: '鞋类', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 }])
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', inboundDetailId: '6', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsName: '鞋类', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 }])
})
it('loads real containers, candidates and the selected plan into the three-column workbench', async () => {
@@ -69,13 +69,43 @@ describe('GlobalSmartLoadingView', () => {
expect(listContainers).toHaveBeenCalledWith({ page: 1, size: 100 })
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, container.warehouseId)
expect(wrapper.text()).toContain('候选订单与货物')
expect(wrapper.text()).toContain('候选入库单与货物')
expect(wrapper.text()).toContain('美森正班海卡包税')
expect(wrapper.text()).toContain('ORD-2')
expect(wrapper.text()).toContain('CB-1 装柜方案')
expect(wrapper.text()).toContain('LP-99')
expect(wrapper.text()).toContain('ORD-1')
})
it('groups containers by channel and reloads candidates when the channel changes', async () => {
const other = {
...container,
id: '9007199254740998',
planId: '9007199254740999',
cabinetNo: 'CB-2',
channelCode: 'HDHK',
channelName: '合德海卡包税',
}
vi.mocked(listContainers).mockResolvedValue({ records: [container, other], page: 1, size: 100, total: 2, pages: 1 })
vi.mocked(getSmartLoadingPlanByBooking).mockImplementation(async (bookingId) => ({ ...plan, bookingId }))
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.findAll('.container-option')).toHaveLength(1)
expect(wrapper.text()).toContain('CB-1')
expect(wrapper.text()).not.toContain('CB-2')
const tab = wrapper.findAll('[role="tab"]').find((item) => item.text().includes('合德海卡包税'))
expect(tab).toBeDefined()
await tab!.trigger('click')
await flushPromises()
expect(wrapper.findAll('.container-option')).toHaveLength(1)
expect(wrapper.text()).toContain('CB-2')
expect(wrapper.text()).not.toContain('CB-1')
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith(other.id, other.warehouseId)
})
it('filters candidates by an active name-only customer selector and accountId', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
@@ -113,7 +143,7 @@ describe('GlobalSmartLoadingView', () => {
expect(wrapper.text()).not.toContain('税率')
expect(wrapper.text()).not.toContain('是否退税')
expect(wrapper.text()).not.toContain('美森正班')
expect(wrapper.text()).not.toContain('税率')
})
it('prefers an editable container on first load so pushed plans do not show a false editing error', async () => {
@@ -31,12 +31,15 @@ import type {
SmartLoadingPlan,
} from '../types'
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
const access = useAccessPreviewStore()
const router = useRouter()
const containers = ref<ContainerBookingResource[]>([])
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const selectedContainerId = ref('')
const activeChannelCode = ref('')
const plan = ref<SmartLoadingPlan | null>(null)
const candidates = ref<SmartLoadingCandidate[]>([])
const loading = ref(false)
@@ -59,6 +62,21 @@ const canAssign = computed(() => access.can('platform:firstleg:smart-loading:ass
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
const canPush = computed(() => access.can('platform:firstleg:smart-loading:push'))
const selectedContainer = computed(() => containers.value.find((item) => item.id === selectedContainerId.value) || null)
const channelTabs = computed(() => {
const channels = new Map<string, { code: string; name: string; containerCount: number }>()
for (const item of containers.value) {
if (item.planStatus === 'COMPLETED' || !item.channelCode) continue
const current = channels.get(item.channelCode)
if (current) current.containerCount += 1
else channels.set(item.channelCode, {
code: item.channelCode,
name: item.channelName || item.channelCode,
containerCount: 1,
})
}
return [...channels.values()]
})
const activeChannel = computed(() => channelTabs.value.find((item) => item.code === activeChannelCode.value) || null)
const editable = computed(() => plan.value?.status === 'EDITING' || plan.value?.status === 'LOADING')
const selectedPlanStatus = computed(() => selectedContainer.value?.planStatus || null)
const assignedDetailIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundDetailId)))
@@ -88,6 +106,7 @@ const filteredContainers = computed(() => {
const status = String(appliedQuery.value.status || '')
return containers.value.filter((item) => {
if (item.planStatus === 'COMPLETED') return false
if (!activeChannelCode.value || item.channelCode !== activeChannelCode.value) return false
if (status && item.planStatus !== status) return false
if (!keyword) return true
return [item.cabinetNo, item.containerNo, item.bookingNo, item.destinationWarehouseCode, item.destinationWarehouseName]
@@ -215,6 +234,9 @@ async function loadAll(preserved: Record<string, number> = {}) {
? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listContainers({ page: index + 2, size: 100 })))
: []
containers.value = [firstPage, ...remainingPages].flatMap((page) => page.records)
if (!channelTabs.value.some((item) => item.code === activeChannelCode.value)) {
activeChannelCode.value = channelTabs.value[0]?.code || ''
}
if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) selectedContainerId.value = preferredContainerId()
await loadSelectedData(preserved)
} catch (error) {
@@ -241,6 +263,13 @@ async function selectContainer(containerId: string) {
mutationError.value = null
await loadSelectedData()
}
async function selectChannel(channelCode: string) {
if (activeChannelCode.value === channelCode) return
activeChannelCode.value = channelCode
selectedContainerId.value = preferredContainerId()
mutationError.value = null
await loadSelectedData()
}
async function applyFilters(model: QueryModel) {
appliedQuery.value = { ...model }
if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) {
@@ -347,8 +376,23 @@ onMounted(() => { void load(); void loadCustomers() })
</script>
<template>
<AdminPage>
<component :is="props.embedded ? 'div' : AdminPage" :class="{ 'embedded-smart-loading': props.embedded }">
<section class="global-loading-page">
<nav v-if="channelTabs.length" class="channel-tabs" aria-label="物流渠道" role="tablist">
<button
v-for="channel in channelTabs"
:key="channel.code"
type="button"
role="tab"
:aria-selected="channel.code === activeChannelCode"
:class="{ active: channel.code === activeChannelCode }"
@click="selectChannel(channel.code)"
>
<span>{{ channel.name }}</span>
<small>{{ channel.containerCount }} 个柜次</small>
</button>
</nav>
<el-alert v-else-if="containers.length" type="warning" :closable="false" show-icon title="当前柜次缺少渠道快照,暂时不能参与智能装柜" />
<QueryToolbar
v-model="query"
ariaLabel="智能装柜筛选"
@@ -376,7 +420,7 @@ onMounted(() => { void load(); void loadCustomers() })
<section class="dispatch-grid" aria-label="智能装柜调度台">
<section class="dispatch-pane candidate-pane">
<header class="pane-header"><div><strong>候选订单与货物</strong><small>{{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
<header class="pane-header"><div><strong>候选入库单与货物</strong><small>{{ activeChannel?.name || '未选择渠道' }} · {{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
<DataTable
:data="filteredCandidates"
:columns="candidateColumns"
@@ -407,7 +451,7 @@ onMounted(() => { void load(); void loadCustomers() })
</section>
<aside class="dispatch-pane container-pane" aria-label="货柜列表">
<header class="pane-header"><div><strong>货柜</strong><small>待装柜、预装柜中与拣货中</small></div></header>
<header class="pane-header"><div><strong>当前渠道柜次</strong><small>待装柜、预装柜中与拣货中</small></div></header>
<div v-loading="loading" class="container-list">
<el-empty v-if="!filteredContainers.length" description="暂无符合条件的货柜" :image-size="72" />
<button
@@ -420,6 +464,7 @@ onMounted(() => { void load(); void loadCustomers() })
>
<span class="container-option__top"><strong>{{ item.cabinetNo }}</strong><el-tag size="small" :type="statusType(item.planStatus)">{{ statusLabel(item.planStatus) }}</el-tag></span>
<span>{{ item.containerNo || '集装箱号待录入' }} · {{ item.containerType }}</span>
<span>{{ item.channelName || item.channelCode || '渠道未设置' }}</span>
<span>{{ item.warehouseName || '仓库未设置' }}</span>
<span>目的仓 {{ item.destinationWarehouseName || item.destinationWarehouseCode || '不限' }}</span>
<span v-if="item.id === selectedContainerId && plan">体积 {{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</span>
@@ -478,16 +523,23 @@ onMounted(() => { void load(); void loadCustomers() })
</AppDialog>
</template>
</section>
</AdminPage>
</component>
</template>
<style scoped>
.global-loading-page { display: grid; gap: 16px; min-width: 0; }
.embedded-smart-loading { min-width: 0; }
.channel-tabs { display: flex; min-height: 48px; overflow-x: auto; border-bottom: 2px solid var(--cc-border); background: #fff; }
.channel-tabs button { display: flex; min-width: 150px; min-height: 48px; align-items: center; justify-content: center; gap: 7px; padding: 8px 18px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--cc-text); cursor: pointer; }
.channel-tabs button.active { margin-bottom: -2px; border-bottom-color: var(--el-color-primary); color: var(--el-color-primary); font-weight: 600; }
.channel-tabs button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: -3px; }
.channel-tabs small { color: var(--cc-muted); font-weight: 400; white-space: nowrap; }
.summary-band { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 1px; border: 1px solid var(--cc-border); background: var(--cc-border); }
.summary-band > div { display: grid; gap: 4px; padding: 12px 14px; background: #fff; }
.summary-band span,.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: 12px; }
.summary-band strong { font-size: 16px; }
.dispatch-grid { display: grid; grid-template-columns: minmax(390px, 1.1fr) 270px minmax(460px, 1.35fr); min-height: 620px; overflow-x: auto; border: 1px solid var(--cc-border); background: #fff; }
.embedded-smart-loading .dispatch-grid { min-height: 520px; height: min(62vh, 700px); }
.dispatch-pane { display: flex; min-width: 0; flex-direction: column; }
.dispatch-pane + .dispatch-pane { border-left: 1px solid var(--cc-border); }
.pane-header { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--cc-border); background: #fff; }
@@ -522,5 +574,6 @@ onMounted(() => { void load(); void loadCustomers() })
.pane-header,.plan-actions,.meter-title { align-items: stretch; flex-direction: column; }
.plan-actions :deep(.el-button) { width: 100%; min-height: 40px; margin-left: 0; }
.container-list { grid-template-columns: 1fr; }
.channel-tabs button { min-width: 132px; padding-inline: 12px; }
}
</style>
@@ -18,7 +18,7 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
const container = {
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1,
warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 68, maxWeightKg: 28000,
@@ -32,7 +32,7 @@ describe('SmartLoadingWorkbenchView', () => {
vi.clearAllMocks()
vi.mocked(getContainer).mockResolvedValue(container)
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING', containerNo: 'MSCU-1', voyageNo: null, version: 2, items: [] })
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsName: '服装', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 }])
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsName: '服装', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 }])
})
it('restores the real draft and explains candidate capacity', async () => {
-6
View File
@@ -7,12 +7,6 @@ export const containerLoadingRoutes = [
component: () => import('./pages/ContainersView.vue'),
meta: { title: '货柜与排柜', description: '维护货柜资料并进入智能排柜工作台', permission: 'platform:firstleg:container:list' },
},
{
path: 'container-loading/smart-loading',
name: 'global-smart-loading-workbench',
component: () => import('./pages/GlobalSmartLoadingView.vue'),
meta: { title: '智能装柜', description: '统一调度候选货物、货柜与装柜方案', permission: 'platform:firstleg:smart-loading:list' },
},
{
path: 'container-loading/:containerId/smart-loading',
name: 'smart-loading-workbench',
+4
View File
@@ -8,6 +8,8 @@ export interface ContainerBookingResource {
cabinetNo: string
warehouseId: string
warehouseName: string | null
channelCode: string | null
channelName: string | null
billOfLadingNo: string | null
vesselName: string | null
voyageNo: string | null
@@ -188,6 +190,8 @@ export interface SmartLoadingCandidate {
orderNo: string | null
inboundCode: string | null
warehouseId: string
channelCode: string | null
channelName: string | null
destinationWarehouseId: string | null
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
@@ -39,11 +39,24 @@ describe('OceanBookingDetailView interaction contract', () => {
it('provides guidance for every free-text booking field', () => {
for (const placeholder of [
'请输入提单号(可选)', '请输入船名', '请输入航次', '例如:CNNGB', '例如:宁波港',
'例如:USLAX', '例如:洛杉矶港', '请输入最终交货地点', '例如:CY-CY',
'例如:PREPAID', '请输入订舱备注(可选)',
'例如:USLAX', '例如:洛杉矶港', '请输入最终交货地点', '请输入订舱备注(可选)',
]) expect(source).toContain(`placeholder="${placeholder}"`)
})
it('uses compact choices and keeps capacity snapshots behind one specification control', () => {
expect(source).toContain("const serviceTermOptions = ['CY-CY', 'CY-DOOR', 'DOOR-CY', 'DOOR-DOOR']")
expect(source).toContain("const freightTermOptions = ['PREPAID', 'COLLECT']")
expect(source).toContain("const containerTypeOptions = ['20GP', '40GP', '40HC', '40NR', '45HC']")
expect(source).toContain('<el-select v-model="form.data.serviceTerm"')
expect(source).toContain('<el-select v-model="form.data.freightTerm"')
expect(source).toContain('<el-select v-model="container.containerType"')
expect(source).toContain('<el-select v-model="container.containerKind"')
expect(source).toContain(':aria-label="`设置箱型 ${index + 1} 的容量与限重`"')
expect(source).toContain('function containerSpecificationLabel(')
expect(source).not.toContain('<el-input v-model="form.data.serviceTerm"')
expect(source).not.toContain('<el-input v-model="container.containerType"')
})
it('captures channel, warehouse, capacity snapshots and uses the release DTO', () => {
expect(source).toContain("listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'DOMESTIC', status: 'ACTIVE' })")
expect(source).toContain("warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC'")
@@ -78,7 +91,7 @@ describe('OceanBookingDetailView interaction contract', () => {
expect(source).toContain('function removeContainer(index: number) { if (form.data.containerRequirements.length > 1)')
expect(source).toContain('<table class="container-table">')
expect(source).toContain('.container-table-wrap{overflow-x:auto')
expect(source).toContain('.container-table td :deep(.el-input),.container-table td :deep(.el-input-number){width:100%;min-width:0}')
expect(source).toContain('.container-table td :deep(.el-select),.container-table td :deep(.el-input-number){width:100%;min-width:0}')
expect(source).toContain(':controls="false" placeholder="请输入数量"')
expect(source).not.toContain('class="container-row"')
})
@@ -95,11 +108,11 @@ describe('OceanBookingDetailView interaction contract', () => {
expect(source).toContain('aria-label="航线船期"')
expect(source).toContain('aria-label="条款与柜量"')
expect(source).toContain('grid-template-columns:minmax(0,1.4fr) repeat(2,minmax(0,1fr))')
expect(source).toContain('.booking-group__body--fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:12px}')
expect(source).toContain('.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:12px}')
expect(source).toContain('class="booking-field--wide" label="交货地点"')
expect(source).toContain('@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}')
expect(source).toContain('@media(max-width:520px){.booking-group__body--fields{grid-template-columns:1fr}')
expect(source).toContain('min-width:580px')
expect(source).toContain('@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}')
expect(source).toContain('min-width:520px')
})
it('keeps the three real booking parties in their own three-column section', () => {
@@ -108,4 +121,12 @@ describe('OceanBookingDetailView interaction contract', () => {
expect(source).toContain("{ value: 'NOTIFY_PARTY', label: '通知人' }")
expect(source).toContain('.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr))')
})
it('shows client-side validation failures instead of rendering an empty error alert', () => {
expect(source).toContain(':title="actionError.message"')
expect(source).toContain("actionError.value = { message: '请完善客户、装柜仓库和物流渠道等必填信息' }")
expect(source).toContain("actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }")
expect(source).toContain('规格(必填)')
expect(source).toContain('placeholder="单位名称(必填)"')
})
})
@@ -2,7 +2,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Plus, Save, Trash2 } from '@lucide/vue'
import { ArrowLeft, Plus, Save, SlidersHorizontal, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -41,6 +41,13 @@ const partyTypes = [
{ value: 'CONSIGNEE', label: '收货人' },
{ value: 'NOTIFY_PARTY', label: '通知人' },
] as const
const serviceTermOptions = ['CY-CY', 'CY-DOOR', 'DOOR-CY', 'DOOR-DOOR']
const freightTermOptions = ['PREPAID', 'COLLECT']
const containerTypeOptions = ['20GP', '40GP', '40HC', '40NR', '45HC']
const containerKindOptions = [
{ value: 'FCL', label: '整箱 FCL' },
{ value: 'LCL', label: '拼箱 LCL' },
]
const actionLabels: Record<OceanBookingAction, string> = {
submit: '提交', confirm: '确认订舱', release: '确认放舱', fail: '标记失败', cancel: '取消订舱',
'change-request': '申请改船', 'change-confirm': '确认改船', 'change-fail': '改船失败',
@@ -209,7 +216,11 @@ async function load() {
}
async function save() {
if (!canSave.value) return
if (!await formRef.value?.validate().catch(() => false)) return
actionError.value = null
if (!await formRef.value?.validate().catch(() => false)) {
actionError.value = { message: '请完善客户、装柜仓库和物流渠道等必填信息' }
return
}
if (form.data.parties.some((party) => !party.partyName.trim())) {
actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }
return
@@ -312,6 +323,10 @@ function validateContainerRequirements() {
}
return true
}
function containerSpecificationLabel(container: OceanBookingDraftData['containerRequirements'][number]) {
if (!(Number(container.capacityCbm) > 0) || !(Number(container.maxWeightKg) > 0)) return '设置规格'
return `${container.capacityCbm} m³ / ${container.maxWeightKg} kg`
}
function addContainer() { form.data.containerRequirements.push(createBlankContainerRequirement()) }
function removeContainer(index: number) { if (form.data.containerRequirements.length > 1) form.data.containerRequirements.splice(index, 1) }
@@ -332,7 +347,7 @@ onMounted(() => { void load(); void loadOptions() })
<LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else>
<el-alert v-if="actionError" type="error" :closable="false" show-icon><ApiErrorMeta :details="actionError" /></el-alert>
<el-alert v-if="actionError" type="error" :closable="false" show-icon :title="actionError.message"><ApiErrorMeta :details="actionError" /></el-alert>
<section class="surface-panel">
<div class="section-title">
<div><h2>订舱信息</h2><p v-if="detail && !editable">当前状态仅可查看,草稿状态才允许编辑。</p></div>
@@ -370,11 +385,13 @@ onMounted(() => { void load(); void loadOptions() })
</section>
<section class="booking-group" aria-label="条款与柜量">
<h3>条款与柜量</h3><div class="booking-group__body">
<el-form-item label="服务条款"><el-input v-model="form.data.serviceTerm" placeholder="例如:CY-CY" /></el-form-item>
<el-form-item label="运费条款"><el-input v-model="form.data.freightTerm" placeholder="例如:PREPAID" /></el-form-item>
<div class="booking-terms">
<el-form-item label="服务条款"><el-select v-model="form.data.serviceTerm" clearable placeholder="请选择服务条款"><el-option v-for="value in serviceTermOptions" :key="value" :label="value" :value="value" /></el-select></el-form-item>
<el-form-item label="运费条款"><el-select v-model="form.data.freightTerm" clearable placeholder="请选择运费条款"><el-option v-for="value in freightTermOptions" :key="value" :label="value" :value="value" /></el-select></el-form-item>
</div>
<div class="container-table-wrap" aria-label="箱型箱量">
<table class="container-table">
<thead><tr><th scope="col">操作</th><th scope="col">箱型</th><th scope="col">箱种</th><th scope="col">容量(m³)</th><th scope="col">限重(kg)</th><th scope="col">数量</th></tr></thead>
<thead><tr><th scope="col">操作</th><th scope="col">箱型</th><th scope="col">箱种</th><th scope="col">规格(必填)</th><th scope="col">数量</th></tr></thead>
<tbody>
<tr v-for="(container, index) in form.data.containerRequirements" :key="index">
<td class="container-table__action">
@@ -383,10 +400,18 @@ onMounted(() => { void load(); void loadOptions() })
</el-tooltip>
<span v-else aria-hidden="true">—</span>
</td>
<td><el-input v-model="container.containerType" :disabled="!editable" placeholder="如 40HC" /></td>
<td><el-input v-model="container.containerKind" :disabled="!editable" placeholder="如 FCL" /></td>
<td><el-input-number v-model="container.capacityCbm" :disabled="!editable" :min="0.01" :precision="2" :controls="false" placeholder="请输入容量" /></td>
<td><el-input-number v-model="container.maxWeightKg" :disabled="!editable" :min="0.01" :precision="2" :controls="false" placeholder="请输入限重" /></td>
<td><el-select v-model="container.containerType" :disabled="!editable" placeholder="请选择箱型"><el-option v-for="value in containerTypeOptions" :key="value" :label="value" :value="value" /></el-select></td>
<td><el-select v-model="container.containerKind" :disabled="!editable" placeholder="请选择箱种"><el-option v-for="item in containerKindOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></td>
<td class="container-table__specification">
<el-popover v-if="editable" placement="bottom" :width="300" trigger="click">
<template #reference><el-button size="small" plain :type="Number(container.capacityCbm) > 0 && Number(container.maxWeightKg) > 0 ? 'default' : 'warning'" :aria-label="`设置箱型 ${index + 1} 的容量与限重`"><SlidersHorizontal :size="14" />{{ containerSpecificationLabel(container) }}</el-button></template>
<div class="container-specification-fields">
<label><span>容量(m³)</span><el-input-number v-model="container.capacityCbm" :min="0.01" :precision="2" :controls="false" placeholder="请输入容量" /></label>
<label><span>限重(kg)</span><el-input-number v-model="container.maxWeightKg" :min="0.01" :precision="2" :controls="false" placeholder="请输入限重" /></label>
</div>
</el-popover>
<span v-else>{{ containerSpecificationLabel(container) }}</span>
</td>
<td><el-input-number v-model="container.quantity" :disabled="!editable" :min="1" :max="999" :controls="false" placeholder="请输入数量" /></td>
</tr>
</tbody>
@@ -403,8 +428,8 @@ onMounted(() => { void load(); void loadOptions() })
<h2>订舱关系方</h2>
<div class="party-grid">
<div v-for="(party, index) in form.data.parties" :key="party.partyType" class="subsection">
<h3>{{ partyTypes[index]?.label || party.partyType }}</h3>
<el-input v-model="party.partyName" :disabled="!editable" placeholder="单位名称" />
<h3><span class="required-mark" aria-hidden="true">*</span>{{ partyTypes[index]?.label || party.partyType }}</h3>
<el-input v-model="party.partyName" :disabled="!editable" :aria-label="`${partyTypes[index]?.label || party.partyType}单位名称(必填)`" placeholder="单位名称(必填)" />
<el-input v-model="party.contactName" :disabled="!editable" placeholder="联系人" />
<el-input v-model="party.contactPhone" :disabled="!editable" placeholder="联系电话" />
<el-input v-model="party.address" :disabled="!editable" placeholder="地址" />
@@ -445,5 +470,5 @@ onMounted(() => { void load(); void loadOptions() })
<style scoped>
.channel-code{display:block;margin-left:8px;color:var(--cc-muted);font-size:11px}.field-error,.field-hint{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:5px;color:var(--cc-muted);font-size:12px;line-height:20px}.field-error{color:var(--el-color-danger)}
.booking-detail{min-width:0}.detail-heading{display:flex;min-width:0;align-items:flex-start;gap:16px}.booking-page-toolbar{min-width:0;flex:1}.section-title p,.history-panel p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{display:flex;align-items:center;gap:8px}.surface-panel{padding:20px}.surface-panel h2{margin:0;font-size:16px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:18px}.actions{display:flex;flex-wrap:wrap;gap:8px}.booking-groups{display:grid;grid-template-columns:minmax(0,1.4fr) repeat(2,minmax(0,1fr));gap:16px}.booking-group{min-width:0;overflow:hidden;border:1px solid #e8edf3;border-radius:8px;background:#fff}.booking-group>h3{margin:0;padding:14px 16px;border-bottom:1px solid #eef2f6;color:#1f2937;font-size:14px}.booking-group__body{padding:16px}.booking-group__body--fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:12px}.booking-field--wide{grid-column:1/-1}.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.subsection{display:grid;gap:10px;padding:14px;border:1px solid var(--cc-border);border-radius:6px}.subsection h3{margin:0;font-size:14px}.container-table-wrap{overflow-x:auto;margin-bottom:10px}.container-table{width:100%;min-width:580px;table-layout:fixed;border-collapse:collapse;font-size:12px}.container-table th,.container-table td{padding:7px 6px;border-bottom:1px solid #eef2f6;text-align:left;vertical-align:middle}.container-table th{background:#fafafa;color:var(--cc-muted);font-weight:650;white-space:nowrap}.container-table th:first-child,.container-table td:first-child{width:56px}.container-table th:nth-child(2),.container-table td:nth-child(2){width:112px}.container-table th:nth-child(3),.container-table td:nth-child(3){width:88px}.container-table th:nth-child(4),.container-table td:nth-child(4),.container-table th:nth-child(5),.container-table td:nth-child(5){width:118px}.container-table th:nth-child(6),.container-table td:nth-child(6){width:78px}.container-table td :deep(.el-input),.container-table td :deep(.el-input-number){width:100%;min-width:0}.container-table__action{text-align:center!important}.container-table__action :deep(.el-button){width:28px;height:28px}.add-container{margin-bottom:18px}.history-panel strong{font-size:14px}@media(max-width:1599px){.booking-groups{grid-template-columns:repeat(2,minmax(0,1fr))}.booking-group:last-child{grid-column:1/-1}}@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}.booking-group:last-child{grid-column:auto}.detail-heading{flex-direction:column}.booking-page-toolbar{width:100%}.section-title{flex-direction:column}.actions{width:100%}}@media(max-width:520px){.booking-group__body--fields{grid-template-columns:1fr}.booking-field--wide{grid-column:auto}}@media(max-width:420px){.surface-panel{padding:12px}.booking-group__body{padding:14px}.actions :deep(.el-button){min-height:44px}}
.booking-detail{min-width:0}.detail-heading{display:flex;min-width:0;align-items:flex-start;gap:16px}.booking-page-toolbar{min-width:0;flex:1}.section-title p,.history-panel p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{display:flex;align-items:center;gap:8px}.surface-panel{padding:20px}.surface-panel h2{margin:0;font-size:16px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:18px}.actions{display:flex;flex-wrap:wrap;gap:8px}.booking-groups{display:grid;grid-template-columns:minmax(0,1.4fr) repeat(2,minmax(0,1fr));gap:16px}.booking-group{min-width:0;overflow:hidden;border:1px solid #e8edf3;border-radius:8px;background:#fff}.booking-group>h3{margin:0;padding:14px 16px;border-bottom:1px solid #eef2f6;color:#1f2937;font-size:14px}.booking-group__body{padding:16px}.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:12px}.booking-field--wide{grid-column:1/-1}.booking-terms :deep(.el-select){width:100%}.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.subsection{display:grid;gap:10px;padding:14px;border:1px solid var(--cc-border);border-radius:6px}.subsection h3{margin:0;font-size:14px}.required-mark{margin-right:4px;color:var(--el-color-danger)}.container-table-wrap{overflow-x:auto;margin-bottom:10px}.container-table{width:100%;min-width:520px;table-layout:fixed;border-collapse:collapse;font-size:12px}.container-table th,.container-table td{padding:7px 6px;border-bottom:1px solid #eef2f6;text-align:left;vertical-align:middle}.container-table th{background:#fafafa;color:var(--cc-muted);font-weight:650;white-space:nowrap}.container-table th:first-child,.container-table td:first-child{width:56px}.container-table th:nth-child(2),.container-table td:nth-child(2){width:110px}.container-table th:nth-child(3),.container-table td:nth-child(3){width:110px}.container-table th:nth-child(4),.container-table td:nth-child(4){width:180px}.container-table th:nth-child(5),.container-table td:nth-child(5){width:78px}.container-table td :deep(.el-select),.container-table td :deep(.el-input-number){width:100%;min-width:0}.container-table__action{text-align:center!important}.container-table__action :deep(.el-button){width:28px;height:28px}.container-table__specification :deep(.el-button){max-width:100%;overflow:hidden}.container-table__specification :deep(.el-button span){overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.container-specification-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.container-specification-fields label{display:grid;gap:6px;color:var(--cc-muted);font-size:12px}.container-specification-fields :deep(.el-input-number){width:100%}.add-container{margin-bottom:18px}.history-panel strong{font-size:14px}@media(max-width:1599px){.booking-groups{grid-template-columns:repeat(2,minmax(0,1fr))}.booking-group:last-child{grid-column:1/-1}}@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}.booking-group:last-child{grid-column:auto}.detail-heading{flex-direction:column}.booking-page-toolbar{width:100%}.section-title{flex-direction:column}.actions{width:100%}}@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}.booking-field--wide{grid-column:auto}}@media(max-width:420px){.surface-panel{padding:12px}.booking-group__body{padding:14px}.actions :deep(.el-button){min-height:44px}}
</style>
@@ -133,6 +133,13 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('v-if="moreUnifiedOrderActions(row as UnifiedOrderResource).length"')
})
it('opens smart loading from the order table toolbar with its own permission', () => {
expect(source).toContain("can('platform:firstleg:smart-loading:list')")
expect(source).toContain('#toolbar-actions')
expect(source).toContain('@click="smartLoadingOpen = true"')
expect(source).toContain('<SmartLoadingDialog v-model="smartLoadingOpen" />')
})
it('keeps editable query values separate from the applied list request', () => {
expect(source).toContain('const appliedFilters = ref<FilterModel>(emptyFilters())')
expect(source).toContain('const activeFilters = appliedFilters.value')
@@ -40,6 +40,7 @@ import {
firstLegTransportModeLabel,
} from '@/shared/utils/firstLegBusinessLabels'
import { UnifiedOrderWorkflowPanel } from '@/modules/unified-orders/workflows'
import SmartLoadingDialog from '@/modules/container-loading/components/SmartLoadingDialog.vue'
import {
unifiedOrderHistoryActionLabel,
unifiedOrderLoadingStatusLabel,
@@ -115,6 +116,7 @@ const measurementLoadedKey = ref('')
const measurementImageUrls = ref<string[]>([])
const measurementCollapsed = reactive({ customer: false, outer: false, actual: false })
const measurementDialogOpen = ref(false)
const smartLoadingOpen = ref(false)
const measurementOrder = ref<UnifiedOrderResource | null>(null)
let detailLoadingKey = ''
let detailRequestSerial = 0
@@ -986,6 +988,9 @@ onMounted(() => {
<template #default>
<div v-if="!loadError" id="unified-orders-results" class="surface-panel table-panel table-card" role="tabpanel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" border stripe empty-text="暂无符合条件的订单" aria-label="统一订单列表">
<template v-if="can('platform:firstleg:smart-loading:list')" #toolbar-actions>
<el-button type="primary" @click="smartLoadingOpen = true"><PackageOpen :size="16" />智能装柜</el-button>
</template>
<template #actions="{ row }">
<div class="row-actions">
<el-button size="small" plain type="primary" @click="openDetail(row as UnifiedOrderResource)"><Eye :size="15" />详情</el-button>
@@ -1030,6 +1035,7 @@ onMounted(() => {
</div>
</template>
</ListPageLayout>
<SmartLoadingDialog v-model="smartLoadingOpen" />
</template>
<AppDrawer v-model="relationOpen" title="父子订单关联" size="min(860px, 94vw)" :show-footer="false" destroy-on-close>