This commit is contained in:
2026-08-17 13:24:55 +08:00
parent 6a0c0b9229
commit 4a9438315c
20 changed files with 299 additions and 143 deletions
@@ -86,7 +86,7 @@ describe('OceanBookingDetailView interaction contract', () => {
expect(source).toContain('登记后直接确认')
})
it('uses source-backed free text terms and keeps capacity snapshots behind one specification control', () => {
it('uses source-backed free text terms and fixed container specifications', () => {
expect(source).not.toContain('serviceTermOptions')
expect(source).not.toContain('freightTermOptions')
expect(source).toContain('const containerTypeOptions = CONTAINER_SPECIFICATIONS')
@@ -96,8 +96,9 @@ describe('OceanBookingDetailView interaction contract', () => {
expect(source).toContain('<el-input v-model="form.data.freightTerm" clearable maxlength="32" placeholder="请输入 Freight Term"')
expect(source).toContain('<el-select :model-value="container.containerType"')
expect(source).toContain('<el-select v-model="container.containerKind"')
expect(source).toContain('规格(必填)')
expect(source).toContain('function containerSpecificationLabel(')
expect(source).toContain('<th scope="col">容量</th><th scope="col">限重</th>')
expect(source).toContain('{{ container.capacityCbm }} m³')
expect(source).toContain('{{ container.maxWeightKg }} kg')
expect(source).not.toContain("['PREPAID', 'COLLECT']")
expect(source).not.toContain('<el-input v-model="container.containerType"')
})
@@ -114,30 +115,35 @@ describe('OceanBookingDetailView interaction contract', () => {
expect(source).not.toContain('v-model="form.data.channelName"')
expect(source).toContain('form.data.channelCode = \'\'')
expect(source).toContain('function selectWarehouse(value: unknown)')
expect(source).toContain('form.data.warehouseName = warehouses.value.find')
expect(source).not.toContain('form.data.warehouseName')
expect(source).toContain('@update:model-value="selectWarehouse"')
expect(source).not.toContain('@update:model-value="(value) =>')
expect(source).toContain('selectContainerRequirementType(container')
expect(source).toContain('selectBookingContainerType(container')
expect(source).not.toContain('v-model="container.capacityCbm"')
expect(source).not.toContain('v-model="container.maxWeightKg"')
expect(source).toContain('releaseOceanBooking(id.value, { expectedVersion: detail.value.version')
expect(source).toContain('title="确认放舱"')
expect(source).toContain('createBlankContainerRequirement()')
expect(source).toContain('containerRequirementError(form.data.containerRequirements)')
expect(source).toContain('function validateContainerRequirements()')
expect(source).toContain('containerSpecificationLabel(container)')
expect(source).toContain('createBlankBookingContainer()')
expect(source).toContain('bookingContainerError(form.data.containers)')
expect(source).toContain('function validateBookingContainers()')
expect(source).toContain('form.data.containers.forEach')
})
it('keeps every container requirement removable without collapsing its fields', () => {
it('registers one real container per row and keeps each row removable', () => {
expect(source).toContain('@click="removeContainer(index)"')
expect(source).toContain(':aria-label="`删除箱型 ${index + 1}`"')
expect(source).toContain("form.data.containerRequirements.length === 1 ? '至少保留一条箱型' : '删除该箱型'")
expect(source).toContain(':disabled="form.data.containerRequirements.length === 1"')
expect(source).toContain('function removeContainer(index: number) { if (form.data.containerRequirements.length > 1)')
expect(source).toContain(':aria-label="`删除真实柜 ${index + 1}`"')
expect(source).toContain("form.data.containers.length === 1 ? '至少保留一个真实柜' : '删除该真实柜'")
expect(source).toContain(':disabled="form.data.containers.length === 1"')
expect(source).toContain('function removeContainer(index: number) { if (form.data.containers.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-select),.container-table td :deep(.el-input-number){width:100%;min-width:0}')
expect(source).toContain(':controls="false" placeholder="请输入数量"')
expect(source).toContain('v-model="container.containerNo"')
expect(source).toContain('maxlength="64" placeholder="请输入真实集装箱号"')
expect(source).toContain(':disabled="form.data.containers.length >= 20"')
expect(source).toContain('{{ form.data.containers.length }} / 20 个')
expect(source).toContain("detail.containers[index]?.cabinetNo || '—'")
expect(source).not.toContain('container.quantity')
expect(source).not.toContain('form.data.containerRequirements')
expect(source).not.toContain('class="container-row"')
})
@@ -151,13 +157,13 @@ describe('OceanBookingDetailView interaction contract', () => {
it('groups real detail fields into the frozen three-column prototype structure', () => {
expect(source).toContain('aria-label="基本信息"')
expect(source).toContain('aria-label="航线船期"')
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,.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,.booking-terms{grid-template-columns:1fr}')
expect(source).toContain('min-width:520px')
expect(source).toContain('min-width:780px')
})
it('keeps the three real booking parties in their own three-column section', () => {
@@ -171,7 +177,7 @@ describe('OceanBookingDetailView interaction contract', () => {
expect(source).toContain(':title="actionError.message"')
expect(source).toContain("actionError.value = { message: '请完善装柜仓库、物流渠道、真实运输资料和运输条款等必填信息' }")
expect(source).toContain("actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }")
expect(source).toContain('规格(必填)')
expect(source).toContain('aria-label="真实柜登记"')
expect(source).toContain('placeholder="单位名称(必填)"')
})
@@ -35,7 +35,7 @@ import type {
OceanBookingStatus,
} from '../types'
import { oceanBookingStatusLabel } from '../status'
import { containerRequirementError, createBlankContainerRequirement, selectContainerRequirementType } from '../containerRequirements'
import { bookingContainerError, createBlankBookingContainer, selectBookingContainerType } from '../containerRequirements'
const partyTypes = [
{ value: 'SHIPPER', label: '发货人' },
@@ -98,9 +98,9 @@ function blankParties(): OceanBookingParty[] {
}
function blankData(): OceanBookingDraftData {
return {
customerId: null, customerName: null, channelCode: '', warehouseId: '', warehouseName: '', serviceTerm: null, freightTerm: null, parties: blankParties(),
customerId: null, customerName: null, channelCode: '', warehouseId: '', serviceTerm: null, freightTerm: null, parties: blankParties(),
billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null,
containerRequirements: [createBlankContainerRequirement()],
containers: [createBlankBookingContainer()],
}
}
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blankData() })
@@ -162,11 +162,11 @@ function normalizeDraftData() {
form.data.billOfLadingNo = form.data.billOfLadingNo.trim()
form.data.vesselName = form.data.vesselName.trim()
form.data.voyageNo = form.data.voyageNo.trim()
form.data.containers.forEach((container) => { container.containerNo = container.containerNo.trim() })
}
function selectWarehouse(value: unknown) {
const warehouseId = String(value || '')
form.data.warehouseId = warehouseId
form.data.warehouseName = warehouses.value.find((warehouse) => String(warehouse.id) === warehouseId)?.warehouseName || ''
form.data.channelCode = ''
selectedChannelName.value = ''
channels.value = []
@@ -208,7 +208,7 @@ function applyDetail(value: OceanBookingDetail) {
detail.value = value
form.expectedVersion = value.version
form.data = {
customerId: value.customerId ?? null, customerName: value.customerName ?? null, channelCode: value.channelCode || '', warehouseId: value.warehouseId || '', warehouseName: value.warehouseName || '',
customerId: value.customerId ?? null, customerName: value.customerName ?? null, channelCode: value.channelCode || '', warehouseId: value.warehouseId || '',
billOfLadingNo: value.billOfLadingNo || '', vesselName: value.vesselName || '', voyageNo: value.voyageNo || '',
estimatedSailingAt: value.estimatedSailingAt || null, estimatedArrivalAt: value.estimatedArrivalAt || null,
shippingCompanyId: value.shippingCompanyId, shippingCompanyName: value.shippingCompanyName,
@@ -218,7 +218,7 @@ function applyDetail(value: OceanBookingDetail) {
placeOfDelivery: value.placeOfDelivery, serviceTerm: value.serviceTerm, freightTerm: value.freightTerm,
remark: value.remark,
parties: value.parties.length ? value.parties.map(({ partyType, partyName, contactName, contactPhone, address }) => ({ partyType, partyName, contactName, contactPhone, address })) : blankParties(),
containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ containerType, containerKind, capacityCbm, maxWeightKg, quantity }) => ({ containerType, containerKind, capacityCbm: capacityCbm ?? null, maxWeightKg: maxWeightKg ?? null, quantity: quantity ?? null })) : [createBlankContainerRequirement()],
containers: value.containers.length ? value.containers.map(({ containerNo, containerType, containerKind, capacityCbm, maxWeightKg }) => ({ containerNo, containerType, containerKind, capacityCbm, maxWeightKg })) : [createBlankBookingContainer()],
}
selectedChannelName.value = value.channelName || ''
channels.value = []
@@ -248,7 +248,7 @@ async function save() {
actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }
return
}
if (!validateContainerRequirements()) return
if (!validateBookingContainers()) return
saving.value = true
actionError.value = null
try {
@@ -319,7 +319,6 @@ async function runAction(action: OceanBookingAction) {
if (!detail.value || action === 'change-request') { if (action === 'change-request') openChangeDialog(); return }
if (action === 'release') { openReleaseDialog(); return }
actionError.value = null
if (!validateContainerRequirements()) return
try {
const reason = await requiredReason(action)
await ElMessageBox.confirm(`确认执行“${actionLabels[action]}”吗?`, actionLabels[action], { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
@@ -330,19 +329,18 @@ async function runAction(action: OceanBookingAction) {
if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) actionError.value = getApiErrorDetails(cause)
} finally { actionSaving.value = false }
}
function validateContainerRequirements() {
const message = containerRequirementError(form.data.containerRequirements)
function validateBookingContainers() {
const message = bookingContainerError(form.data.containers)
if (message) {
actionError.value = { message }
return false
}
return true
}
function containerSpecificationLabel(container: OceanBookingDraftData['containerRequirements'][number]) {
return `${container.capacityCbm} m³ / ${container.maxWeightKg} kg`
function addContainer() {
if (form.data.containers.length < 20) form.data.containers.push(createBlankBookingContainer())
}
function addContainer() { form.data.containerRequirements.push(createBlankContainerRequirement()) }
function removeContainer(index: number) { if (form.data.containerRequirements.length > 1) form.data.containerRequirements.splice(index, 1) }
function removeContainer(index: number) { if (form.data.containers.length > 1) form.data.containers.splice(index, 1) }
onMounted(() => { void load(); void loadOptions() })
</script>
@@ -397,32 +395,34 @@ onMounted(() => { void load(); void loadOptions() })
<el-form-item label="预计到港" prop="estimatedArrivalAt"><el-date-picker v-model="form.data.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
</div>
</section>
<section class="booking-group" aria-label="条款与柜量">
<h3>条款与柜量</h3><div class="booking-group__body">
<section class="booking-group" aria-label="条款与真实柜">
<h3>条款与真实柜</h3><div class="booking-group__body">
<div class="booking-terms">
<el-form-item label="主要运输条款 Service Term" prop="serviceTerm"><el-input v-model="form.data.serviceTerm" clearable maxlength="32" placeholder="请输入 Service Term" /></el-form-item>
<el-form-item label="运费条款 Freight Term" prop="freightTerm"><el-input v-model="form.data.freightTerm" clearable maxlength="32" placeholder="请输入 Freight Term" /></el-form-item>
</div>
<div class="container-table-wrap" aria-label="箱型箱量">
<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">规格(必填)</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><th scope="col">限重</th><th v-if="detail" scope="col">柜次号</th></tr></thead>
<tbody>
<tr v-for="(container, index) in form.data.containerRequirements" :key="index">
<tr v-for="(container, index) in form.data.containers" :key="detail?.containers[index]?.containerBookingId || index">
<td class="container-table__action">
<el-tooltip v-if="editable" :content="form.data.containerRequirements.length === 1 ? '至少保留一条箱型' : '删除该箱型'" placement="top">
<span><el-button circle plain size="small" type="danger" :aria-label="`删除箱型 ${index + 1}`" :disabled="form.data.containerRequirements.length === 1" @click="removeContainer(index)"><Trash2 :size="14" /></el-button></span>
<el-tooltip v-if="editable" :content="form.data.containers.length === 1 ? '至少保留一个真实柜' : '删除该真实柜'" placement="top">
<span><el-button circle plain size="small" type="danger" :aria-label="`删除真实柜 ${index + 1}`" :disabled="form.data.containers.length === 1" @click="removeContainer(index)"><Trash2 :size="14" /></el-button></span>
</el-tooltip>
<span v-else aria-hidden="true">—</span>
</td>
<td><el-select :model-value="container.containerType" :disabled="!editable" placeholder="请选择箱型" @update:model-value="selectContainerRequirementType(container, String($event || ''))"><el-option v-for="item in containerTypeOptions" :key="item.type" :label="item.type" :value="item.type" /></el-select></td>
<td><el-input v-model="container.containerNo" :disabled="!editable" maxlength="64" placeholder="请输入真实集装箱号" /></td>
<td><el-select :model-value="container.containerType" :disabled="!editable" placeholder="请选择柜型" @update:model-value="selectBookingContainerType(container, String($event || ''))"><el-option v-for="item in containerTypeOptions" :key="item.type" :label="item.type" :value="item.type" /></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"><span>{{ containerSpecificationLabel(container) }}</span></td>
<td><el-input-number v-model="container.quantity" :disabled="!editable" :min="1" :max="999" :controls="false" placeholder="请输入数量" /></td>
<td class="container-table__specification"><span>{{ container.capacityCbm }} m³</span></td>
<td class="container-table__specification"><span>{{ container.maxWeightKg }} kg</span></td>
<td v-if="detail"><span>{{ detail.containers[index]?.cabinetNo || '—' }}</span></td>
</tr>
</tbody>
</table>
</div>
<el-button v-if="editable" class="add-container" size="small" @click="addContainer"><Plus :size="15" />新增箱型</el-button>
<div v-if="editable" class="container-actions"><el-button class="add-container" size="small" :disabled="form.data.containers.length >= 20" @click="addContainer"><Plus :size="15" />新增真实柜</el-button><span>{{ form.data.containers.length }} / 20 个</span></div>
<el-form-item label="备注"><el-input v-model="form.data.remark" type="textarea" :rows="3" placeholder="请输入订舱备注(可选)" /></el-form-item>
</div>
</section>
@@ -478,5 +478,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,.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}}
.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:780px;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:180px}.container-table th:nth-child(3),.container-table td:nth-child(3){width:90px}.container-table th:nth-child(4),.container-table td:nth-child(4){width:110px}.container-table th:nth-child(5),.container-table td:nth-child(5),.container-table th:nth-child(6),.container-table td:nth-child(6){width:100px}.container-table td :deep(.el-select),.container-table td :deep(.el-input){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{color:var(--cc-muted);white-space:nowrap}.container-actions{display:flex;align-items:center;gap:10px;margin-bottom:18px;color:var(--cc-muted);font-size:12px}.add-container{margin:0}.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>