优化
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
|
||||
import { linkContainerOceanBooking } from '../api/platformContainerLoading'
|
||||
import type { ContainerBookingResource } from '../types'
|
||||
import OceanBookingLinkDialog from './OceanBookingLinkDialog.vue'
|
||||
|
||||
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.fn() }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ linkContainerOceanBooking: vi.fn() }))
|
||||
|
||||
const container = {
|
||||
id: '9001', cabinetNo: 'CAB-1', bookingNo: 'CB-1', containerNo: null, planId: '7001', planStatus: 'EDITING',
|
||||
sourceType: 'SIMULATION', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班',
|
||||
oceanBookingId: null, billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HC',
|
||||
shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null,
|
||||
destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null,
|
||||
capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null,
|
||||
loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null,
|
||||
actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 7,
|
||||
} satisfies ContainerBookingResource
|
||||
|
||||
const candidate = {
|
||||
oceanBookingId: '101', bookingNo: 'OB-101', requirementId: '202', containerType: '40HC', capacityCbm: 76,
|
||||
maxWeightKg: 28000, requiredQuantity: 2, allocatedQuantity: 1, remainingQuantity: 1,
|
||||
customerName: '测试客户', warehouseName: '义乌仓', channelName: '美森正班',
|
||||
}
|
||||
|
||||
describe('OceanBookingLinkDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(listOceanBookingLinkCandidates).mockReset().mockResolvedValue({ records: [candidate], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(linkContainerOceanBooking).mockReset().mockResolvedValue({ ...container, oceanBookingId: '101', oceanBookingRequirementId: '202', version: 8 } as never)
|
||||
})
|
||||
|
||||
it('loads matching released-booking requirements when opened', async () => {
|
||||
const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(listOceanBookingLinkCandidates).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelCode: 'ZBYHK', containerType: '40HC', page: 1, size: 200 })
|
||||
expect((wrapper.vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toContain('OB-101 · 40HC × 1')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[' mscu1234567 ', 'MSCU1234567'],
|
||||
['', null],
|
||||
])('normalizes and submits the optional real container number %#', async (input, expected) => {
|
||||
const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as { candidateKey: string; containerNo: string; save: () => Promise<void> }
|
||||
vm.candidateKey = '101:202'
|
||||
vm.containerNo = input
|
||||
await vm.save()
|
||||
expect(linkContainerOceanBooking).toHaveBeenCalledExactlyOnceWith('9001', {
|
||||
oceanBookingId: '101', requirementId: '202', containerNo: expected, expectedVersion: 7,
|
||||
})
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false])
|
||||
expect(wrapper.emitted('linked')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { linkContainerOceanBooking } from '../api/platformContainerLoading'
|
||||
import type { ContainerBookingResource } from '../types'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; container: ContainerBookingResource | null }>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
linked: [container: ContainerBookingResource]
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const candidateKey = ref('')
|
||||
const containerNo = ref('')
|
||||
const candidates = ref<Array<{ key: string; oceanBookingId: string; requirementId: string; label: string }>>([])
|
||||
let requestSequence = 0
|
||||
|
||||
function optional(value: string | null | undefined) { return value?.trim() || null }
|
||||
function normalizeContainerNo(value: string | null | undefined) { return String(value || '').trim().toUpperCase() }
|
||||
function close() { emit('update:modelValue', false) }
|
||||
|
||||
async function loadCandidates() {
|
||||
const target = props.container
|
||||
candidateKey.value = ''
|
||||
containerNo.value = ''
|
||||
candidates.value = []
|
||||
error.value = null
|
||||
if (!target) return
|
||||
const warehouseId = optional(target.warehouseId)
|
||||
const channelCode = optional(target.channelCode)
|
||||
const containerType = optional(target.containerType)
|
||||
if (!warehouseId || !channelCode || !containerType) {
|
||||
error.value = { message: '当前模拟柜缺少仓库、渠道或柜型,无法查询可关联订舱' }
|
||||
return
|
||||
}
|
||||
const sequence = ++requestSequence
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await listOceanBookingLinkCandidates({ warehouseId, channelCode, containerType, page: 1, size: 200 })
|
||||
if (sequence !== requestSequence || !props.modelValue || props.container?.id !== target.id) return
|
||||
candidates.value = result.records.map((candidate) => ({
|
||||
key: `${candidate.oceanBookingId}:${candidate.requirementId}`,
|
||||
oceanBookingId: candidate.oceanBookingId,
|
||||
requirementId: candidate.requirementId,
|
||||
label: `${candidate.bookingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName} · ${candidate.warehouseName} · ${candidate.channelName}`,
|
||||
}))
|
||||
} catch (cause) {
|
||||
if (sequence === requestSequence) error.value = getApiErrorDetails(cause, '已放舱订舱候选加载失败')
|
||||
} finally {
|
||||
if (sequence === requestSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const target = props.container
|
||||
const candidate = candidates.value.find((item) => item.key === candidateKey.value)
|
||||
const normalized = normalizeContainerNo(containerNo.value)
|
||||
containerNo.value = normalized
|
||||
if (!target || !candidate) {
|
||||
error.value = { message: '请选择规格一致且仍有剩余数量的柜型需求' }
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const linked = await linkContainerOceanBooking(target.id, {
|
||||
oceanBookingId: candidate.oceanBookingId,
|
||||
requirementId: candidate.requirementId,
|
||||
containerNo: normalized || null,
|
||||
expectedVersion: target.version,
|
||||
})
|
||||
close()
|
||||
ElMessage.success(normalized ? '订舱与真实集装箱号已关联,柜次号、装柜计划和明细保持不变' : '订舱已关联,真实集装箱号待司机提柜时录入')
|
||||
emit('linked', linked)
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, '关联真实订舱柜失败')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) void loadCandidates()
|
||||
else {
|
||||
requestSequence += 1
|
||||
loading.value = false
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
defineExpose({ candidates, candidateKey, containerNo, save })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog :model-value="modelValue" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="saving" :confirm-disabled="!candidateKey" @update:model-value="emit('update:modelValue', $event)" @confirm="save">
|
||||
<el-alert title="关联后保留当前柜次号、装柜计划和明细,并补充订舱快照;真实集装箱号可待司机提柜时录入。" type="info" :closable="false" />
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="柜次号"><el-input :model-value="container?.cabinetNo || ''" disabled /></el-form-item>
|
||||
<el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="candidateKey" filterable :loading="loading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in candidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item>
|
||||
<el-form-item label="真实集装箱号(可选)"><el-input v-model="containerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="containerNo = normalizeContainerNo(containerNo)" /></el-form-item>
|
||||
</el-form>
|
||||
<p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p>
|
||||
<LoadErrorState v-if="error" compact title="关联失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-hint { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
|
||||
</style>
|
||||
+29
-6
@@ -3,7 +3,8 @@ import { createPinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ContainerDetailView from './ContainerDetailView.vue'
|
||||
import { getContainer, getContainerOverview } from '../api/platformContainerLoading'
|
||||
import { getContainer, getContainerOverview, linkContainerOceanBooking } from '../api/platformContainerLoading'
|
||||
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
|
||||
import { recordTerminalDrayageEmptyReturn } from '../api/terminalDrayages'
|
||||
import { getConfirmedLoadingList, getPreloadPlan } from '@/modules/loading-lists'
|
||||
import DomesticContainerDetailView from './DomesticContainerDetailView.vue'
|
||||
@@ -13,10 +14,11 @@ const routeState = vi.hoisted(() => ({ params: { containerId: '9001' }, query: {
|
||||
const routerPush = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ push: routerPush }) }))
|
||||
vi.mock('@/modules/loading-lists', () => ({ ContainerFeesPanel: { template: '<div />' }, LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading'], template: '<div data-testid="loading-detail">{{ detail?.planNo }}</div>' }, getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() }))
|
||||
vi.mock('@/modules/loading-lists', () => ({ ContainerFeesPanel: { template: '<div />' }, LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading'], template: '<div data-testid="loading-detail">{{ detail?.planNo }}</div>' }, PalletLabelPrintDialog: { template: '<div />' }, getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() }))
|
||||
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.fn() }))
|
||||
vi.mock('@/modules/outbound-dispatch', () => ({ confirmContainerOutboundDispatch: vi.fn(), getContainerOutboundDispatch: vi.fn() }))
|
||||
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div />' } }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ getContainer: vi.fn(), getContainerOverview: vi.fn(), cancelContainerException: vi.fn(), completeContainerClearance: vi.fn(), downloadContainerClearance: vi.fn(), downloadContainerDocument: vi.fn(), getContainerClearance: vi.fn(), markContainerException: vi.fn(), uploadContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), updateContainerPickupCode: vi.fn() }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ getContainer: vi.fn(), getContainerOverview: vi.fn(), linkContainerOceanBooking: vi.fn(), cancelContainerException: vi.fn(), completeContainerClearance: vi.fn(), downloadContainerClearance: vi.fn(), downloadContainerDocument: vi.fn(), getContainerClearance: vi.fn(), markContainerException: vi.fn(), uploadContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), updateContainerPickupCode: vi.fn() }))
|
||||
vi.mock('../api/terminalDrayages', () => ({ recordTerminalDrayageEmptyReturn: vi.fn() }))
|
||||
|
||||
const base = { id: '9001', cabinetNo: 'CB-1', containerNo: 'MSCU-1', planNo: 'LP-1', planStatus: 'COMPLETED', warehouseName: '义乌仓', destinationWarehouseCode: 'LAX01', destinationWarehouseName: '洛杉矶接柜仓', workbenchStage: 'UNLOADED', statusSummary: { terminalDrayageStatus: 'UNLOADED' }, allowedActions: ['RECORD_EMPTY_RETURN'], terminalDrayage: { containerBookingId: '9001', status: 'UNLOADED', version: 3, fleetSupplierId: '501', fleetSupplierName: '港运车队', pickupAppointmentAt: '2026-09-07T08:00:00', actualPickupAt: '2026-09-07T09:00:00', estimatedEmptyReturnAt: '2026-09-10T10:00:00', actualEmptyReturnAt: null, unloadingCompletedAt: '2026-09-07T10:00:00', emptyReturnWarning: { status: 'PENDING', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['RECORD_EMPTY_RETURN'], updatedAt: '2026-09-09T10:00:00' }, actualFacts: { unloadingCompletedAt: '2026-09-07T10:00:00' } }
|
||||
@@ -86,7 +88,7 @@ describe('ContainerDetailView empty return', () => {
|
||||
})
|
||||
|
||||
describe('domestic container loading list and booking link', () => {
|
||||
const domestic = { ...base, containerNo: null, warehouseId: '8', sourceType: 'SIMULATION', oceanBookingId: null, planId: '7001', planStatus: 'EDITING', version: 1, allowedActions: ['EDIT_CONTAINER'], terminalDrayage: null }
|
||||
const domestic = { ...base, containerNo: null, warehouseId: '8', channelCode: 'ZBYHK', sourceType: 'SIMULATION', oceanBookingId: null, planId: '7001', planStatus: 'EDITING', version: 1, allowedActions: ['EDIT_CONTAINER'], terminalDrayage: null }
|
||||
const loadingDetail = { planNo: 'LP-1', items: [] }
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -97,6 +99,8 @@ describe('domestic container loading list and booking link', () => {
|
||||
vi.mocked(getContainerOverview).mockReset().mockResolvedValue({ ...overview, loadingPlanId: '7001' } as never)
|
||||
vi.mocked(getPreloadPlan).mockReset().mockResolvedValue(loadingDetail as never)
|
||||
vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue(loadingDetail as never)
|
||||
vi.mocked(listOceanBookingLinkCandidates).mockReset().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
|
||||
vi.mocked(linkContainerOceanBooking).mockReset()
|
||||
})
|
||||
|
||||
async function mountDomestic() {
|
||||
@@ -168,7 +172,24 @@ describe('domestic container loading list and booking link', () => {
|
||||
const link = wrapper.findAll('button').find((button) => button.text() === '关联已放舱订舱')!
|
||||
expect(link).toBeDefined()
|
||||
await link.trigger('click')
|
||||
expect(routerPush).toHaveBeenCalledExactlyOnceWith({ name: 'domestic-container-workbench', query: { linkContainerId: '9001' } })
|
||||
await flushPromises()
|
||||
expect(routerPush).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'OceanBookingLinkDialog' }).props('modelValue')).toBe(true)
|
||||
})
|
||||
|
||||
it('refreshes the routed detail after the shared link dialog succeeds', async () => {
|
||||
const linked = { ...domestic, oceanBookingId: '101', billOfLadingNo: 'BL-101', version: 2 }
|
||||
vi.mocked(getContainer).mockResolvedValueOnce(domestic as never).mockResolvedValueOnce(linked as never)
|
||||
const wrapper = await mountDomestic()
|
||||
await wrapper.findAll('button').find((button) => button.text() === '关联已放舱订舱')!.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.findComponent({ name: 'OceanBookingLinkDialog' })
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
dialog.vm.$emit('linked', linked)
|
||||
await flushPromises()
|
||||
expect(getContainer).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('BL-101')
|
||||
expect(wrapper.findAll('button').some((button) => button.text() === '关联已放舱订舱')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the same cabinet link before and after an inbound assignment snapshot refresh', async () => {
|
||||
@@ -185,7 +206,9 @@ describe('domestic container loading list and booking link', () => {
|
||||
expect(link.isVisible()).toBe(true)
|
||||
routerPush.mockClear()
|
||||
await link.trigger('click')
|
||||
expect(routerPush).toHaveBeenCalledExactlyOnceWith({ name: 'domestic-container-workbench', query: { linkContainerId: '9001' } })
|
||||
await flushPromises()
|
||||
expect(routerPush).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'OceanBookingLinkDialog' }).props('modelValue')).toBe(true)
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -129,6 +129,8 @@ describe('ContainerDetailView customs integration', () => {
|
||||
expect(source).toContain("!detail.value.oceanBookingId")
|
||||
expect(source).toContain("['WAIT_LOADING', 'EDITING'].includes(detail.value.planStatus)")
|
||||
expect(source).toContain('关联已放舱订舱')
|
||||
expect(source).toContain("query: { linkContainerId: detail.value.id }")
|
||||
expect(source).toContain('oceanBookingLinkOpen.value = true')
|
||||
expect(source).toContain('<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />')
|
||||
expect(source).not.toContain('linkContainerId')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
|
||||
import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue'
|
||||
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
|
||||
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
|
||||
import {
|
||||
cancelContainerException,
|
||||
completeContainerClearance,
|
||||
@@ -140,6 +141,7 @@ const emptyReturnAt = ref('')
|
||||
const emptyReturnReason = ref('')
|
||||
const emptyReturnSaving = ref(false)
|
||||
const emptyReturnError = ref<ApiErrorDetails | null>(null)
|
||||
const oceanBookingLinkOpen = ref(false)
|
||||
|
||||
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
|
||||
const documentTitle = computed(() => 'DO 资料')
|
||||
@@ -195,7 +197,7 @@ const customsDepartureState = computed<'DEPARTED' | 'WAITING_OTHER_GATES'>(() =>
|
||||
outboundDetail.value?.aggregate.dispatchStatus === 'WAITING_FOR_LOADING' ? 'DEPARTED' : 'WAITING_OTHER_GATES')
|
||||
function openOceanBookingLink() {
|
||||
if (!detail.value || !canLinkOceanBooking.value) return
|
||||
void router.push({ name: 'domestic-container-workbench', query: { linkContainerId: detail.value.id } })
|
||||
oceanBookingLinkOpen.value = true
|
||||
}
|
||||
|
||||
function toOverviewDocument(document: BusinessDocument, documentType: string, fallback?: ContainerOverviewDocument | null): ContainerOverviewDocument {
|
||||
@@ -558,6 +560,7 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
|
||||
<AppDialog v-model="emptyReturnOpen" title="确认还柜" width="min(520px, calc(100vw - 28px))" confirm-text="确认还柜" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form label-position="top"><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 v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-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 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 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>
|
||||
|
||||
@@ -10,7 +10,8 @@ import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { listPlatformPartners } from '@/modules/master-data'
|
||||
import { listSuppliers } from '@/modules/suppliers'
|
||||
import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } from '@/modules/ocean-bookings'
|
||||
import { getContainer, getContainerOverview, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading'
|
||||
import { getContainer, getContainerOverview, listContainers, updateContainer } from '../api/platformContainerLoading'
|
||||
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
|
||||
import { getTerminalDrayage, getTerminalDrayageHistory, recordTerminalDrayageEmptyReturn, selectTerminalDrayageFleet, sendTerminalDrayageEmail, getTerminalDrayageEmails } from '../api/terminalDrayages'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { containerLoadingRoutes } from '../routes'
|
||||
@@ -533,12 +534,10 @@ describe('ContainersView', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('opens the existing released-booking workflow when entered from container detail', () => {
|
||||
it('owns the released-booking entry through the shared dialog without a detail redirect query', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
expect(source).toContain('route.query.linkContainerId')
|
||||
expect(source).toContain("const target = await getContainer(linkContainerId, 'DOMESTIC')")
|
||||
expect(source).toContain('await openLink(target)')
|
||||
expect(source).toContain("void router.replace({ name: 'domestic-container-workbench' })")
|
||||
expect(source).toContain('<OceanBookingLinkDialog v-model="linkOpen" :container="linkTarget" @linked="loadRows" />')
|
||||
expect(source).not.toContain('linkContainerId')
|
||||
})
|
||||
|
||||
it.each(['EDITING', 'LOADING', 'COMPLETED'] as const)('keeps released-booking linking reachable alongside the %s process action', async (planStatus) => {
|
||||
@@ -567,63 +566,6 @@ describe('ContainersView', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes an optional booking-link container number', async () => {
|
||||
vi.mocked(linkContainerOceanBooking).mockResolvedValue({} as never)
|
||||
const router = createTestRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as {
|
||||
linkTarget: { id: string; version: number }
|
||||
linkCandidates: Array<{ key: string; oceanBookingId: string; requirementId: string; disabled: boolean }>
|
||||
linkCandidateKey: string
|
||||
linkContainerNo: string
|
||||
saveLink: () => Promise<void>
|
||||
}
|
||||
vm.linkTarget = { id: '9001', version: 7 }
|
||||
vm.linkCandidates = [{ key: '101:202', oceanBookingId: '101', requirementId: '202', disabled: false }]
|
||||
vm.linkCandidateKey = '101:202'
|
||||
vm.linkContainerNo = ' mscu1234567 '
|
||||
|
||||
await vm.saveLink()
|
||||
|
||||
expect(linkContainerOceanBooking).toHaveBeenCalledWith('9001', {
|
||||
oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU1234567', expectedVersion: 7,
|
||||
})
|
||||
expect(vm.linkContainerNo).toBe('MSCU1234567')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('links a released booking while leaving the real container number pending until pickup', async () => {
|
||||
vi.mocked(linkContainerOceanBooking).mockResolvedValue({} as never)
|
||||
const router = createTestRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as {
|
||||
linkTarget: { id: string; version: number }
|
||||
linkCandidates: Array<{ key: string; oceanBookingId: string; requirementId: string; disabled: boolean }>
|
||||
linkCandidateKey: string
|
||||
linkContainerNo: string
|
||||
saveLink: () => Promise<void>
|
||||
}
|
||||
vm.linkTarget = { id: '9001', version: 7 }
|
||||
vm.linkCandidates = [{ key: '101:202', oceanBookingId: '101', requirementId: '202', disabled: false }]
|
||||
vm.linkCandidateKey = '101:202'
|
||||
vm.linkContainerNo = ''
|
||||
|
||||
await vm.saveLink()
|
||||
|
||||
expect(linkContainerOceanBooking).toHaveBeenCalledWith('9001', {
|
||||
oceanBookingId: '101', requirementId: '202', containerNo: null, expectedVersion: 7,
|
||||
})
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
expect(source).toContain('真实集装箱号可待司机提柜时录入')
|
||||
expect(source).toContain(':confirm-disabled="!linkCandidateKey"')
|
||||
expect(source).toContain("row.containerNo || '待补录'")
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps document uploads on the routed detail page without duplicate list implementations', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
|
||||
@@ -675,8 +617,8 @@ describe('ContainersView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelCode: 'EXX_RAIL_NY', containerType: '45HC', page: 1, size: 200 })
|
||||
expect((wrapper.vm as unknown as { linkCandidates: unknown[] }).linkCandidates).toEqual([])
|
||||
expect(readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8'))
|
||||
expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: unknown[] }).candidates).toEqual([])
|
||||
expect(readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/OceanBookingLinkDialog.vue'), 'utf8'))
|
||||
.toContain('暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求')
|
||||
expect(getOceanBooking).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
@@ -701,6 +643,7 @@ describe('ContainersView', () => {
|
||||
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!
|
||||
|
||||
linkAction.click()
|
||||
await flushPromises()
|
||||
linkAction.click()
|
||||
|
||||
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(1)
|
||||
@@ -709,7 +652,7 @@ describe('ContainersView', () => {
|
||||
resolveCandidates({ records: [{ oceanBookingId: '315', bookingNo: 'OB-CAPACITY-SNAPSHOT', requirementId: '45315', containerType: '45HC', capacityCbm: 85, maxWeightKg: 28500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '东莞仓', channelName: '纽约铁路' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('OB-CAPACITY-SNAPSHOT · 45HC × 1')
|
||||
expect((wrapper.vm as unknown as { linkCandidates: Array<{ disabled: boolean }> }).linkCandidates[0]?.disabled).toBe(false)
|
||||
expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ key: string }> }).candidates[0]?.key).toBe('315:45315')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -743,7 +686,7 @@ describe('ContainersView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(2)
|
||||
const candidateLabels = (wrapper.vm as unknown as { linkCandidates: Array<{ label: string }> }).linkCandidates.map((candidate) => candidate.label)
|
||||
const candidateLabels = (wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ label: string }> }).candidates.map((candidate) => candidate.label)
|
||||
expect(candidateLabels.some((label) => label.includes('OB-LATEST'))).toBe(true)
|
||||
expect(candidateLabels.some((label) => label.includes('OB-STALE'))).toBe(false)
|
||||
wrapper.unmount()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import ContainerSimulationCreate from '../components/ContainerSimulationCreate.vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, Mail, MoreHorizontal, Pencil, Sparkles } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -9,7 +9,7 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
|
||||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||
import { listOceanBookingLinkCandidates, listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
|
||||
import { listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -19,7 +19,8 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { CONTAINER_SPECIFICATIONS, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getContainer, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading'
|
||||
import { getContainer, listContainers, updateContainer } from '../api/platformContainerLoading'
|
||||
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
|
||||
import {
|
||||
getTerminalDrayageEmails,
|
||||
sendTerminalDrayageEmail,
|
||||
@@ -43,7 +44,6 @@ type ContainerForm = ContainerSaveRequest & Pick<ContainerBookingResource, 'cabi
|
||||
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<ContainerWorkbenchRow[]>([])
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
@@ -73,13 +73,7 @@ const editorError = ref<ApiErrorDetails | null>(null)
|
||||
const saving = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const linkOpen = ref(false)
|
||||
const linkLoading = ref(false)
|
||||
const linkSaving = ref(false)
|
||||
const linkError = ref<ApiErrorDetails | null>(null)
|
||||
const linkTarget = ref<ContainerBookingResource | null>(null)
|
||||
const linkCandidateKey = ref('')
|
||||
const linkContainerNo = ref('')
|
||||
const linkCandidates = ref<Array<{ key: string; oceanBookingId: string; requirementId: string; label: string; disabled: boolean }>>([])
|
||||
const selectedTruckingSupplierId = ref('')
|
||||
const unresolvedTruckingSupplierName = ref<string | null>(null)
|
||||
const drayages = ref<Record<string, TerminalDrayageView>>({})
|
||||
@@ -157,7 +151,6 @@ async function retryEmail(mail: TerminalDrayageEmail) {
|
||||
}
|
||||
let listSequence = 0
|
||||
let detailSequence = 0
|
||||
let linkSequence = 0
|
||||
let warehousesLoaded = false
|
||||
let warehouseOptionsPromise: Promise<void> | null = null
|
||||
let partnersLoaded = false
|
||||
@@ -279,7 +272,6 @@ const drayageHistoryColumns: DataTableColumn[] = [
|
||||
]
|
||||
|
||||
function optional(value: string | null) { return value?.trim() || null }
|
||||
function normalizedContainerNo(value: string | null | undefined) { return String(value || '').trim().toUpperCase() }
|
||||
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
|
||||
function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' }
|
||||
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
|
||||
@@ -514,61 +506,10 @@ async function loadSimulationWarehouses() {
|
||||
if (warehouseError.value) throw warehouseError.value
|
||||
return warehouses.value
|
||||
}
|
||||
async function openLink(source: object) {
|
||||
if (linkLoading.value) return
|
||||
function openLink(source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
const warehouseId = optional(row.warehouseId)
|
||||
const channelCode = optional(row.channelCode)
|
||||
if (!warehouseId || !channelCode || !optional(row.containerType)) {
|
||||
linkTarget.value = row
|
||||
linkCandidates.value = []
|
||||
linkError.value = { message: '当前模拟柜缺少仓库、渠道或柜型,无法查询可关联订舱' }
|
||||
linkOpen.value = true
|
||||
return
|
||||
}
|
||||
const sequence = ++linkSequence
|
||||
linkTarget.value = row
|
||||
linkCandidateKey.value = ''
|
||||
linkContainerNo.value = ''
|
||||
linkCandidates.value = []
|
||||
linkError.value = null
|
||||
linkOpen.value = true
|
||||
linkLoading.value = true
|
||||
try {
|
||||
const result = await listOceanBookingLinkCandidates({
|
||||
warehouseId, channelCode, containerType: row.containerType,
|
||||
page: 1, size: 200,
|
||||
})
|
||||
if (sequence !== linkSequence || linkTarget.value?.id !== row.id) return
|
||||
linkCandidates.value = result.records.map((candidate) => ({
|
||||
key: `${candidate.oceanBookingId}:${candidate.requirementId}`,
|
||||
oceanBookingId: candidate.oceanBookingId,
|
||||
requirementId: candidate.requirementId,
|
||||
disabled: false,
|
||||
label: `${candidate.bookingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName} · ${candidate.warehouseName} · ${candidate.channelName}`,
|
||||
}))
|
||||
} catch (error) {
|
||||
if (sequence === linkSequence) linkError.value = getApiErrorDetails(error, '已放舱订舱候选加载失败')
|
||||
} finally {
|
||||
if (sequence === linkSequence) linkLoading.value = false
|
||||
}
|
||||
}
|
||||
async function saveLink() {
|
||||
const target = linkTarget.value
|
||||
const candidate = linkCandidates.value.find((item) => item.key === linkCandidateKey.value)
|
||||
const normalized = normalizedContainerNo(linkContainerNo.value)
|
||||
const containerNo = normalized || null
|
||||
linkContainerNo.value = normalized
|
||||
if (!target || !candidate || candidate.disabled) { linkError.value = { message: '请选择规格一致且仍有剩余数量的柜型需求' }; return }
|
||||
linkSaving.value = true
|
||||
linkError.value = null
|
||||
try {
|
||||
await linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, containerNo, expectedVersion: target.version })
|
||||
linkOpen.value = false
|
||||
ElMessage.success(containerNo ? '订舱与真实集装箱号已关联,柜次号、装柜计划和明细保持不变' : '订舱已关联,真实集装箱号待司机提柜时录入')
|
||||
await loadRows()
|
||||
} catch (error) { linkError.value = getApiErrorDetails(error, '关联真实订舱柜失败') }
|
||||
finally { linkSaving.value = false }
|
||||
}
|
||||
function replaceDrayage(value: TerminalDrayageView) {
|
||||
drayages.value = { ...drayages.value, [value.containerBookingId]: value }
|
||||
@@ -731,28 +672,7 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
if (nextPage !== previousPage || nextSize !== previousSize) void loadRows()
|
||||
})
|
||||
watch(scope, () => { if (page.value === 1) void loadRows(); else page.value = 1 })
|
||||
watch(linkOpen, (open) => {
|
||||
if (open) return
|
||||
linkSequence += 1
|
||||
linkLoading.value = false
|
||||
})
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadCustomers(), loadRows()])
|
||||
const linkContainerId = typeof route.query.linkContainerId === 'string' ? route.query.linkContainerId : ''
|
||||
if (!isDomestic.value || !linkContainerId || !canEdit.value) return
|
||||
try {
|
||||
const target = await getContainer(linkContainerId, 'DOMESTIC')
|
||||
if (target.sourceType !== 'SIMULATION' || target.oceanBookingId || !hasAction(target, 'EDIT_CONTAINER')) {
|
||||
ElMessage.warning('当前货柜已关联订舱或所处阶段不允许关联')
|
||||
return
|
||||
}
|
||||
await openLink(target)
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorDetails(error, '关联货柜加载失败').message)
|
||||
} finally {
|
||||
void router.replace({ name: 'domestic-container-workbench' })
|
||||
}
|
||||
})
|
||||
onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -797,12 +717,7 @@ onMounted(async () => {
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="linkOpen" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey" @confirm="saveLink">
|
||||
<el-alert title="关联后保留当前柜次号、装柜计划和明细,并补充订舱快照;真实集装箱号可待司机提柜时录入。" type="info" :closable="false" />
|
||||
<el-form label-position="top"><el-form-item label="柜次号"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" :disabled="candidate.disabled" /></el-select></el-form-item><el-form-item label="真实集装箱号(可选)"><el-input v-model="linkContainerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="linkContainerNo = normalizedContainerNo(linkContainerNo)" /></el-form-item></el-form>
|
||||
<p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p>
|
||||
<LoadErrorState v-if="linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
<OceanBookingLinkDialog v-model="linkOpen" :container="linkTarget" @linked="loadRows" />
|
||||
|
||||
<AppDialog v-model="editorOpen" class="container-editor-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :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>
|
||||
@@ -853,7 +768,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.container-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }.container-scope-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid #e5e7eb; background: #f9fafb; scrollbar-width: thin; }.container-scope-tab { position: relative; flex: 0 0 auto; min-height: 44px; padding: 0 18px; border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: 14px; cursor: pointer; }.container-scope-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }.container-scope-tab:hover { color: var(--cc-primary); background: #f6f9fd; }.container-scope-tab:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }.container-scope-tab.is-active { color: var(--cc-primary); font-weight: 600; }.container-scope-tab.is-active::after { background: var(--cc-primary); }.container-query-toolbar { border: 0; }.row-actions { display: flex; align-items: center; flex-wrap: nowrap; gap: 4px; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.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%; }.details :deep(.el-descriptions__label) { width: 120px; }.dialog-hint { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }.drayage-load-alert { margin-bottom: 12px; }.drayage-summary { margin-bottom: 14px; }
|
||||
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.container-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }.container-scope-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid #e5e7eb; background: #f9fafb; scrollbar-width: thin; }.container-scope-tab { position: relative; flex: 0 0 auto; min-height: 44px; padding: 0 18px; border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: 14px; cursor: pointer; }.container-scope-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }.container-scope-tab:hover { color: var(--cc-primary); background: #f6f9fd; }.container-scope-tab:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }.container-scope-tab.is-active { color: var(--cc-primary); font-weight: 600; }.container-scope-tab.is-active::after { background: var(--cc-primary); }.container-query-toolbar { border: 0; }.row-actions { display: flex; align-items: center; flex-wrap: nowrap; gap: 4px; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.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%; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: 12px; }.drayage-summary { margin-bottom: 14px; }
|
||||
:global(.container-editor-dialog.el-dialog) { display: flex; width: min(820px, calc(100vw - 28px)) !important; max-height: 90vh; margin-block: 5vh !important; overflow: hidden; flex-direction: column; border-radius: 12px; }
|
||||
:global(.container-editor-dialog .el-dialog__header) { flex: 0 0 auto; margin: 0; padding: 20px 28px; border-bottom: 1px solid #e5eaf1; }
|
||||
:global(.container-editor-dialog .el-dialog__title) { color: #1e293b; font-size: 18px; font-weight: 700; }
|
||||
|
||||
Reference in New Issue
Block a user