This commit is contained in:
2026-08-12 16:40:09 +08:00
parent d8bbfe05f3
commit a44fd5f235
12 changed files with 355 additions and 72 deletions
@@ -40,4 +40,26 @@ describe('OceanBookingDetailView interaction contract', () => {
'例如:PREPAID', '请输入订舱备注(可选)',
]) expect(source).toContain(`placeholder="${placeholder}"`)
})
it('keeps view, create and edit on the formal detail route with a return action', () => {
expect(source).toContain("router.push('/ocean-bookings')")
expect(source).toContain("router.replace(`/ocean-bookings/${created.id}`)")
expect(source).not.toContain('AppDrawer')
expect(source).not.toContain('打印')
})
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('grid-template-columns:minmax(0,1.4fr) repeat(2,minmax(0,1fr))')
expect(source).toContain('@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}')
})
it('keeps the three real booking parties in their own three-column section', () => {
expect(source).toContain("{ value: 'SHIPPER', label: '发货人' }")
expect(source).toContain("{ value: 'CONSIGNEE', label: '收货人' }")
expect(source).toContain("{ value: 'NOTIFY_PARTY', label: '通知人' }")
expect(source).toContain('.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr))')
})
})
@@ -249,23 +249,44 @@ onMounted(() => { void load(); void loadOptions() })
</div>
</div>
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!editable" class="form-grid">
<el-form-item label="客户" prop="customerId"><el-select :model-value="form.data.customerId" filterable placeholder="请选择客户" :loading="optionsLoading" :disabled="!isNew" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.data.billOfLadingNo" placeholder="请输入提单号(可选)" /></el-form-item>
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('shippingCompany', String($event || ''))"><el-option v-if="form.data.shippingCompanyId && !shippingCompanies.some((partner) => String(partner.id) === String(form.data.shippingCompanyId))" :label="form.data.shippingCompanyName || '当前船公司'" :value="String(form.data.shippingCompanyId)" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable placeholder="请选择订舱代理" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('bookingAgent', String($event || ''))"><el-option v-if="form.data.bookingAgentId && !bookingAgents.some((partner) => String(partner.id) === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '当前订舱代理'" :value="String(form.data.bookingAgentId)" /><el-option v-for="partner in bookingAgents" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="船名"><el-input v-model="form.data.vesselName" placeholder="请输入船名" /></el-form-item>
<el-form-item label="航次"><el-input v-model="form.data.voyageNo" placeholder="请输入航次" /></el-form-item>
<el-form-item label="起运港代码"><el-input v-model="form.data.departurePortCode" placeholder="例如:CNNGB" /></el-form-item>
<el-form-item label="起运港"><el-input v-model="form.data.departurePortName" placeholder="例如:宁波港" /></el-form-item>
<el-form-item label="目的港代码"><el-input v-model="form.data.destinationPortCode" placeholder="例如:USLAX" /></el-form-item>
<el-form-item label="目的港"><el-input v-model="form.data.destinationPortName" placeholder="例如:洛杉矶港" /></el-form-item>
<el-form-item label="交货地点"><el-input v-model="form.data.placeOfDelivery" placeholder="请输入最终交货地点" /></el-form-item>
<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>
<el-form-item label="预计开船"><el-date-picker v-model="form.data.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item>
<el-form-item label="预计到港"><el-date-picker v-model="form.data.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
<el-form-item class="wide" label="备注"><el-input v-model="form.data.remark" type="textarea" placeholder="请输入订舱备注(可选)" /></el-form-item>
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!editable" class="booking-groups">
<section class="booking-group" aria-label="基本信息">
<h3>基本信息</h3><div class="booking-group__body">
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></el-form-item>
<el-form-item label="客户" prop="customerId"><el-select :model-value="form.data.customerId" filterable placeholder="请选择客户" :loading="optionsLoading" :disabled="!isNew" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '待创建'" disabled /></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.data.billOfLadingNo" placeholder="请输入提单号(可选)" /></el-form-item>
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('shippingCompany', String($event || ''))"><el-option v-if="form.data.shippingCompanyId && !shippingCompanies.some((partner) => String(partner.id) === String(form.data.shippingCompanyId))" :label="form.data.shippingCompanyName || '当前船公司'" :value="String(form.data.shippingCompanyId)" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable placeholder="请选择订舱代理" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('bookingAgent', String($event || ''))"><el-option v-if="form.data.bookingAgentId && !bookingAgents.some((partner) => String(partner.id) === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '当前订舱代理'" :value="String(form.data.bookingAgentId)" /><el-option v-for="partner in bookingAgents" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
</div>
</section>
<section class="booking-group" aria-label="航线船期">
<h3>航线船期</h3><div class="booking-group__body">
<el-form-item label="船名"><el-input v-model="form.data.vesselName" placeholder="请输入船名" /></el-form-item>
<el-form-item label="航次"><el-input v-model="form.data.voyageNo" placeholder="请输入航次" /></el-form-item>
<el-form-item label="起运港代码"><el-input v-model="form.data.departurePortCode" placeholder="例如:CNNGB" /></el-form-item>
<el-form-item label="起运港"><el-input v-model="form.data.departurePortName" placeholder="例如:宁波港" /></el-form-item>
<el-form-item label="目的港代码"><el-input v-model="form.data.destinationPortCode" placeholder="例如:USLAX" /></el-form-item>
<el-form-item label="目的港"><el-input v-model="form.data.destinationPortName" placeholder="例如:洛杉矶港" /></el-form-item>
<el-form-item label="交货地点"><el-input v-model="form.data.placeOfDelivery" placeholder="请输入最终交货地点" /></el-form-item>
<el-form-item label="预计开船"><el-date-picker v-model="form.data.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item>
<el-form-item label="预计到港"><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">
<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 v-for="(container, index) in form.data.containerRequirements" :key="index" class="container-row">
<el-input v-model="container.containerType" :disabled="!editable" placeholder="箱型,如 40HC" />
<el-input v-model="container.containerKind" :disabled="!editable" placeholder="类型,如 FCL" />
<el-input-number v-model="container.quantity" :disabled="!editable" :min="1" :max="999" />
<el-button v-if="editable" type="danger" plain :disabled="form.data.containerRequirements.length === 1" @click="removeContainer(index)"><Trash2 :size="15" />删除</el-button>
</div>
<el-button v-if="editable" class="add-container" @click="addContainer"><Plus :size="16" />新增箱型</el-button>
<el-form-item label="备注"><el-input v-model="form.data.remark" type="textarea" :rows="3" placeholder="请输入订舱备注(可选)" /></el-form-item>
</div>
</section>
</el-form>
</section>
@@ -282,16 +303,6 @@ onMounted(() => { void load(); void loadOptions() })
</div>
</section>
<section class="surface-panel">
<div class="section-title"><h2>箱型需求</h2><el-button v-if="editable" @click="addContainer"><Plus :size="16" />新增箱型</el-button></div>
<div v-for="(container, index) in form.data.containerRequirements" :key="index" class="container-row">
<el-input v-model="container.containerType" :disabled="!editable" placeholder="箱型,如 40HC" />
<el-input v-model="container.containerKind" :disabled="!editable" placeholder="类型,如 FCL" />
<el-input-number v-model="container.quantity" :disabled="!editable" :min="1" :max="999" />
<el-button v-if="editable" type="danger" plain :disabled="form.data.containerRequirements.length === 1" @click="removeContainer(index)"><Trash2 :size="15" />删除</el-button>
</div>
</section>
<section v-if="detail" class="surface-panel history-panel">
<div class="section-title"><div><h2>状态历史</h2><p>当前进度:{{ statusLabel(detail.status) }}</p></div></div>
<el-timeline><el-timeline-item v-for="event in detail.histories" :key="event.id" :timestamp="event.occurredAt"><strong>{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}</strong><p>{{ event.operatorName || '系统' }}<span v-if="event.reason"> · {{ event.reason }}</span></p></el-timeline-item></el-timeline>
@@ -312,5 +323,5 @@ onMounted(() => { void load(); void loadOptions() })
</template>
<style scoped>
.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}.form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 16px}.form-grid .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-row{display:grid;grid-template-columns:1fr 1fr 160px auto;gap:10px;margin-top:10px}.history-panel strong{font-size:14px}@media(max-width:900px){.form-grid,.party-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.container-row{grid-template-columns:1fr 1fr}}@media(max-width:600px){.detail-heading{flex-direction:column}.booking-page-toolbar{width:100%}.form-grid,.party-grid,.container-row{grid-template-columns:1fr}.form-grid .wide{grid-column:auto}.section-title{flex-direction:column}.actions{width:100%}}
.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}.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-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) 110px auto;gap:8px;margin-bottom:10px}.add-container{width:100%;margin-bottom:18px}.history-panel strong{font-size:14px}@media(max-width:1199px){.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}.container-row{grid-template-columns:1fr 1fr}.detail-heading{flex-direction:column}.booking-page-toolbar{width:100%}.section-title{flex-direction:column}.actions{width:100%}}@media(max-width:420px){.surface-panel{padding:12px}.booking-group__body{padding:14px}.container-row{grid-template-columns:1fr}.actions :deep(.el-button){min-height:44px}}
</style>
@@ -2,3 +2,23 @@ import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }); it('keeps list actions in the table toolbar without a duplicate page title', () => { expect(source).toContain('<template #toolbar-actions>'); expect(source).not.toContain('<template #toolbar-start>'); expect(source).not.toContain('PageToolbar'); expect(source).not.toContain('<template #actions><el-button') }); it('shows the two frequent row actions directly without a more menu', () => { expect(source).toContain('<Eye :size="15" />查看'); expect(source).toContain('<Pencil :size="15" />编辑'); expect(source).not.toContain('MoreHorizontal') }); it('renders backend booking statuses through the domain translation contract', () => { expect(source).toContain('options: oceanBookingStatusOptions'); expect(source).toContain(':status="oceanBookingStatusLabel(row.status)"'); expect(source).not.toContain(':status="row.status"') }) })
describe('OceanBookingsView prototype field matrix', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8')
it('keeps the frozen business field sequence after the first action column', () => {
const fields = ['bookingNo', 'customerName', 'carrierName', 'bookingAgent', 'vesselName', 'pol', 'pod', 'etd', 'eta', 'status', 'updatedAt']
let previous = source.indexOf("key: 'actions'")
for (const field of fields) {
const current = source.indexOf(`prop: '${field}'`, previous + 1)
expect(current, field).toBeGreaterThan(previous)
previous = current
}
})
it('formats schedule and update timestamps without adding print actions', () => {
expect(source).toContain('<template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template>')
expect(source).toContain('<template #etd="{ row }">{{ formatDateTime(row.etd) }}</template>')
expect(source).not.toContain('打印')
})
})
@@ -30,12 +30,19 @@ const fields: QueryField[] = [
]
const columns = [
{ key: 'actions', label: '操作', fixed: 'left', width: 170, slotName: 'actions', hideable: false, movable: false },
{ prop: 'bookingNo', label: '订舱编号', minWidth: 150 }, { prop: 'customerName', label: '客户', minWidth: 160 },
{ prop: 'billOfLadingNo', label: '提单号', minWidth: 140 }, { prop: 'pol', label: '起运港', minWidth: 125 }, { prop: 'pod', label: '目的港', minWidth: 125 },
{ prop: 'carrierName', label: '船公司', minWidth: 130 }, { prop: 'bookingAgent', label: '订舱代理', minWidth: 140 },
{ prop: 'vesselName', label: '船名/航次', minWidth: 150, slotName: 'vessel' }, { prop: 'etd', label: '预计开船', minWidth: 140 },
{ prop: 'eta', label: '预计到港', minWidth: 140 }, { prop: 'status', label: '状态', minWidth: 110, slotName: 'status' },
{ prop: 'bookingNo', label: '订舱号', minWidth: 150 },
{ prop: 'customerName', label: '客户', minWidth: 160 },
{ prop: 'carrierName', label: '船公司', minWidth: 130 },
{ prop: 'bookingAgent', label: '订舱代理', minWidth: 140 },
{ prop: 'vesselName', label: '船名 / 航次', minWidth: 175, slotName: 'vessel' },
{ prop: 'pol', label: '起运港', minWidth: 125 },
{ prop: 'pod', label: '目的港', minWidth: 125 },
{ prop: 'etd', label: '预计开船', minWidth: 155, slotName: 'etd' },
{ prop: 'eta', label: '预计到港', minWidth: 155, slotName: 'eta' },
{ prop: 'status', label: '状态', minWidth: 110, slotName: 'status' },
{ prop: 'updatedAt', label: '更新时间', minWidth: 155, slotName: 'updatedAt' },
]
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
async function load() { loading.value = true; error.value = null; try { const result = await listOceanBookings({ page: page.value, size: pageSize.value, keyword: String(query.keyword || '') || undefined, status: (query.status || undefined) as OceanBookingStatus | undefined } satisfies OceanBookingQuery); rows.value = result.records; total.value = result.total } catch (e) { error.value = getApiErrorDetails(e) } finally { loading.value = false } }
function open(id?: string) { void router.push(id ? `/ocean-bookings/${id}` : '/ocean-bookings/new') }
function search() { if (page.value === 1) void load(); else page.value = 1 }
@@ -51,7 +58,10 @@ watch([page, pageSize], () => void load()); onMounted(load)
<template #toolbar-actions><el-button v-if="access.can('platform:firstleg:ocean-booking:create')" type="primary" @click="open()"><Plus :size="16" />新增订舱</el-button></template>
<template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:ocean-booking:view')" link type="primary" @click="open(row.id)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:ocean-booking:edit')" link @click="open(row.id)"><Pencil :size="15" />编辑</el-button></template>
<template #vessel="{ row }">{{ row.vesselName || '—' }}<span v-if="row.voyageNo"> / {{ row.voyageNo }}</span></template>
<template #etd="{ row }">{{ formatDateTime(row.etd) }}</template>
<template #eta="{ row }">{{ formatDateTime(row.eta) }}</template>
<template #status="{ row }"><StatusBadge :status="oceanBookingStatusLabel(row.status)" /></template>
<template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
</DataTable>
</section>
</ListPageLayout>