优化
This commit is contained in:
@@ -4,19 +4,30 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingDetailView.vue'), 'utf8')
|
||||
|
||||
describe('OceanBookingDetailView form contract', () => {
|
||||
it('keeps prototype parties, sailing and cargo fields', () => {
|
||||
for (const field of ['SHIPPER', 'CONSIGNEE', 'NOTIFY_PARTY', 'departurePortName', 'destinationPortName', 'vesselName', 'voyageNo', 'containerRequirements', 'goodsDescription']) {
|
||||
describe('OceanBookingDetailView contract', () => {
|
||||
it('uses the backend draft fields and all required parties', () => {
|
||||
for (const field of ['SHIPPER', 'CONSIGNEE', 'NOTIFY_PARTY', 'partyName', 'departurePortName', 'destinationPortName', 'vesselName', 'voyageNo', 'containerRequirements']) {
|
||||
expect(source).toContain(field)
|
||||
}
|
||||
expect(source).not.toContain('goodsDescription')
|
||||
expect(source).not.toContain('form.data.soNo')
|
||||
})
|
||||
|
||||
it('validates required booking fields and protects edit-only fields', () => {
|
||||
expect(source).toContain("customerId: [{ required: true")
|
||||
expect(source).toContain("departurePortName: [{ required: true")
|
||||
expect(source).toContain("bookingAgentName: [{ required: true")
|
||||
it('reads the real version and protects create/edit permissions', () => {
|
||||
expect(source).toContain('form.expectedVersion = value.version')
|
||||
expect(source).toContain('expectedVersion: 0')
|
||||
expect(source).toContain("platform:firstleg:ocean-booking:create")
|
||||
expect(source).toContain("platform:firstleg:ocean-booking:edit")
|
||||
expect(source).toContain(':disabled="!isNew"')
|
||||
expect(source).toContain(':disabled="!editable"')
|
||||
expect(source).toContain("containerType: '40HC', containerKind: 'FCL', quantity: 1")
|
||||
})
|
||||
|
||||
it('maps each status action to its server permission and uses a change-specific request', () => {
|
||||
for (const permission of ['submit', 'confirm', 'release', 'cancel', 'change']) {
|
||||
expect(source).toContain(`platform:firstleg:ocean-booking:${permission}`)
|
||||
}
|
||||
expect(source).toContain('requestOceanBookingChange')
|
||||
expect(source).toContain("changeForm.vesselName = detail.value?.vesselName")
|
||||
expect(source).toContain('expectedVersion: detail.value.version')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,37 +1,271 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
import { createOceanBooking, getOceanBooking, performOceanBookingAction, updateOceanBooking, type OceanBookingAction } from '../api/platformOceanBookings'
|
||||
import type { OceanBookingDetail, OceanBookingDraftData, OceanBookingSaveRequest, OceanBookingStatus } from '../types'
|
||||
import {
|
||||
createOceanBooking,
|
||||
getOceanBooking,
|
||||
performOceanBookingAction,
|
||||
requestOceanBookingChange,
|
||||
updateOceanBooking,
|
||||
type OceanBookingAction,
|
||||
} from '../api/platformOceanBookings'
|
||||
import type {
|
||||
OceanBookingDetail,
|
||||
OceanBookingDraftData,
|
||||
OceanBookingParty,
|
||||
OceanBookingSaveRequest,
|
||||
OceanBookingStatus,
|
||||
} from '../types'
|
||||
|
||||
const statusLabels: Record<OceanBookingStatus, string> = {
|
||||
DRAFT: '草稿', SUBMITTED: '已提交', CONFIRMED: '已确认', RELEASED: '已放舱',
|
||||
FAILED: '订舱失败', CHANGE_PENDING: '变更中', CANCELLED: '已取消',
|
||||
}
|
||||
const partyTypes = [
|
||||
{ value: 'SHIPPER', label: '发货人' },
|
||||
{ value: 'CONSIGNEE', label: '收货人' },
|
||||
{ value: 'NOTIFY_PARTY', label: '通知人' },
|
||||
] as const
|
||||
const actionLabels: Record<OceanBookingAction, string> = {
|
||||
submit: '提交', confirm: '确认订舱', release: '确认放舱', fail: '标记失败', cancel: '取消订舱',
|
||||
'change-request': '申请改船', 'change-confirm': '确认改船', 'change-fail': '改船失败',
|
||||
}
|
||||
const actionPermissions: Record<OceanBookingAction, string> = {
|
||||
submit: 'platform:firstleg:ocean-booking:submit', confirm: 'platform:firstleg:ocean-booking:confirm',
|
||||
release: 'platform:firstleg:ocean-booking:release', fail: 'platform:firstleg:ocean-booking:confirm',
|
||||
cancel: 'platform:firstleg:ocean-booking:cancel', 'change-request': 'platform:firstleg:ocean-booking:change',
|
||||
'change-confirm': 'platform:firstleg:ocean-booking:change', 'change-fail': 'platform:firstleg:ocean-booking:change',
|
||||
}
|
||||
const actionsByStatus: Partial<Record<OceanBookingStatus, OceanBookingAction[]>> = {
|
||||
DRAFT: ['submit', 'cancel'],
|
||||
SUBMITTED: ['confirm', 'fail', 'cancel'],
|
||||
CONFIRMED: ['release', 'change-request', 'cancel'],
|
||||
CHANGE_PENDING: ['change-confirm', 'change-fail', 'cancel'],
|
||||
FAILED: ['cancel'],
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const id = computed(() => String(route.params.bookingId || ''))
|
||||
const isNew = computed(() => id.value === 'new')
|
||||
const detail = ref<OceanBookingDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const actionSaving = ref(false)
|
||||
const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
|
||||
const actionError = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
function blankParties(): OceanBookingParty[] {
|
||||
return partyTypes.map(({ value }) => ({ partyType: value, partyName: '', contactName: '', contactPhone: '', address: '' }))
|
||||
}
|
||||
function blankData(): OceanBookingDraftData {
|
||||
return {
|
||||
customerId: '', customerName: '', parties: blankParties(),
|
||||
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 1 }],
|
||||
}
|
||||
}
|
||||
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blankData() })
|
||||
const rules: FormRules = {
|
||||
customerId: [{ required: true, message: '请输入客户 ID', trigger: 'blur' }],
|
||||
customerName: [{ required: true, message: '请输入客户名称', trigger: 'blur' }],
|
||||
}
|
||||
const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT')
|
||||
const canSave = computed(() => isNew.value
|
||||
? access.can('platform:firstleg:ocean-booking:create')
|
||||
: editable.value && access.can('platform:firstleg:ocean-booking:edit'))
|
||||
const availableActions = computed(() => (actionsByStatus[detail.value?.status || 'DRAFT'] || [])
|
||||
.filter((action) => access.can(actionPermissions[action])))
|
||||
|
||||
const changeDialogOpen = ref(false)
|
||||
const changeForm = reactive({ vesselName: '', voyageNo: '', estimatedSailingAt: null as string | null, estimatedArrivalAt: null as string | null, reason: '' })
|
||||
|
||||
function statusLabel(status: OceanBookingStatus | string | null | undefined) {
|
||||
return status ? (statusLabels[status as OceanBookingStatus] || status) : '未设置'
|
||||
}
|
||||
function applyDetail(value: OceanBookingDetail) {
|
||||
detail.value = value
|
||||
form.expectedVersion = value.version
|
||||
form.data = {
|
||||
customerId: value.customerId || '', customerName: value.customerName || '', billOfLadingNo: value.billOfLadingNo,
|
||||
shippingCompanyId: value.shippingCompanyId, shippingCompanyName: value.shippingCompanyName,
|
||||
bookingAgentId: value.bookingAgentId, bookingAgentName: value.bookingAgentName,
|
||||
vesselName: value.vesselName, voyageNo: value.voyageNo,
|
||||
departurePortCode: value.departurePortCode, departurePortName: value.departurePortName,
|
||||
destinationPortCode: value.destinationPortCode, destinationPortName: value.destinationPortName,
|
||||
placeOfDelivery: value.placeOfDelivery, serviceTerm: value.serviceTerm, freightTerm: value.freightTerm,
|
||||
estimatedSailingAt: value.estimatedSailingAt, estimatedArrivalAt: value.estimatedArrivalAt,
|
||||
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, quantity }) => ({ containerType, containerKind, quantity })) : [{ containerType: '40HC', containerKind: 'FCL', quantity: 1 }],
|
||||
}
|
||||
}
|
||||
async function load() {
|
||||
if (!id.value || isNew.value) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try { applyDetail(await getOceanBooking(id.value)) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!canSave.value) return
|
||||
if (!await formRef.value?.validate().catch(() => false)) return
|
||||
if (form.data.parties.some((party) => !party.partyName.trim())) {
|
||||
actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
actionError.value = null
|
||||
try {
|
||||
if (isNew.value) {
|
||||
const created = await createOceanBooking({ expectedVersion: 0, data: form.data })
|
||||
ElMessage.success('订舱已创建')
|
||||
await router.replace(`/ocean-bookings/${created.id}`)
|
||||
applyDetail(created)
|
||||
} else {
|
||||
applyDetail(await updateOceanBooking(id.value, { expectedVersion: form.expectedVersion, data: form.data }))
|
||||
ElMessage.success('订舱草稿已保存')
|
||||
}
|
||||
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function requiredReason(action: OceanBookingAction) {
|
||||
if (!['fail', 'cancel', 'change-fail'].includes(action)) return null
|
||||
const result = await ElMessageBox.prompt(`请填写“${actionLabels[action]}”原因,服务端将写入状态历史。`, actionLabels[action], {
|
||||
inputValidator: (value) => Boolean(value?.trim()) || '原因不能为空',
|
||||
confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning',
|
||||
})
|
||||
return result.value.trim()
|
||||
}
|
||||
function openChangeDialog() {
|
||||
changeForm.vesselName = detail.value?.vesselName || ''
|
||||
changeForm.voyageNo = detail.value?.voyageNo || ''
|
||||
changeForm.estimatedSailingAt = detail.value?.estimatedSailingAt || null
|
||||
changeForm.estimatedArrivalAt = detail.value?.estimatedArrivalAt || null
|
||||
changeForm.reason = ''
|
||||
changeDialogOpen.value = true
|
||||
}
|
||||
async function submitChange() {
|
||||
if (!detail.value || !changeForm.vesselName.trim() || !changeForm.voyageNo.trim() || !changeForm.reason.trim()) return
|
||||
actionSaving.value = true
|
||||
actionError.value = null
|
||||
try {
|
||||
applyDetail(await requestOceanBookingChange(id.value, { expectedVersion: detail.value.version, ...changeForm }))
|
||||
changeDialogOpen.value = false
|
||||
ElMessage.success('改船申请已提交')
|
||||
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
|
||||
finally { actionSaving.value = false }
|
||||
}
|
||||
async function runAction(action: OceanBookingAction) {
|
||||
if (!detail.value || action === 'change-request') { if (action === 'change-request') openChangeDialog(); return }
|
||||
actionError.value = null
|
||||
try {
|
||||
const reason = await requiredReason(action)
|
||||
await ElMessageBox.confirm(`确认执行“${actionLabels[action]}”吗?`, actionLabels[action], { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
|
||||
actionSaving.value = true
|
||||
applyDetail(await performOceanBookingAction(id.value, action, { expectedVersion: detail.value.version, reason }))
|
||||
ElMessage.success(`${actionLabels[action]}成功`)
|
||||
} catch (cause) {
|
||||
if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) actionError.value = getApiErrorDetails(cause)
|
||||
} finally { actionSaving.value = false }
|
||||
}
|
||||
function addContainer() { form.data.containerRequirements.push({ containerType: '40HC', containerKind: 'FCL', quantity: 1 }) }
|
||||
function removeContainer(index: number) { if (form.data.containerRequirements.length > 1) form.data.containerRequirements.splice(index, 1) }
|
||||
|
||||
const statusLabels: Record<OceanBookingStatus, string> = { DRAFT: '草稿', SUBMITTED: '已提交', CONFIRMED: '已确认', RELEASED: '已放舱', FAILED: '订舱失败', CHANGE_PENDING: '变更中', CANCELLED: '已取消' }
|
||||
function statusLabel(status: OceanBookingStatus | string | null | undefined) { return status ? (statusLabels[status as OceanBookingStatus] || status) : '未设置' }
|
||||
const partyTypes = ['SHIPPER', 'CONSIGNEE', 'NOTIFY_PARTY'] as const
|
||||
const fieldRules = { customerId: [{ required: true, message: '请选择客户', trigger: 'blur' }], departurePortName: [{ required: true, message: '请输入起运港', trigger: 'blur' }], bookingAgentName: [{ required: true, message: '请输入订舱代理', trigger: 'blur' }] }
|
||||
// 编辑态保护:客户 ID 仅新增可修改,已确认后的表单由 editable 控制(模板使用 :disabled="!isNew" / :disabled="!editable")。
|
||||
const route = useRoute(); const router = useRouter(); const id = computed(() => String(route.params.bookingId || '')); const isNew = computed(() => id.value === 'new'); const editable = computed(() => isNew.value || ['DRAFT', 'SUBMITTED', 'CHANGE_PENDING'].includes(detail.value?.status || '')); const detail = ref<OceanBookingDetail | null>(null); const loading = ref(false); const saving = ref(false); const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null); const actionError = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
|
||||
const blankData = (): OceanBookingDraftData => ({ customerId: '', customerName: '', parties: [], containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 1 }] })
|
||||
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: null, data: blankData() })
|
||||
const actionStatuses: OceanBookingStatus[] = ['SUBMITTED', 'CONFIRMED', 'RELEASED', 'FAILED', 'CHANGE_PENDING', 'CANCELLED']
|
||||
const actionByStatus: Partial<Record<OceanBookingStatus, OceanBookingAction>> = { SUBMITTED: 'submit', CONFIRMED: 'confirm', RELEASED: 'release', FAILED: 'fail', CHANGE_PENDING: 'change-request', CANCELLED: 'cancel' }
|
||||
type OceanBookingResourceWithVersion = { expectedVersion?: number | string | null }
|
||||
async function load(){ if(!id.value || id.value==='new') return; loading.value=true; error.value=null; try { detail.value=await getOceanBooking(id.value); const source=detail.value.oceanBooking || detail.value; form.expectedVersion = Number((source as OceanBookingResourceWithVersion).expectedVersion ?? 0); form.data = { ...blankData(), ...(source as unknown as Partial<OceanBookingDraftData>), customerId: String(source.customerId || ''), customerName: source.customerName || '', parties: detail.value.parties || [], containerRequirements: detail.value.containerRequirements || [] } } catch(e){error.value=getApiErrorDetails(e)} finally{loading.value=false} }
|
||||
async function save(){ saving.value=true; actionError.value=null; try { if(id.value==='new') { const created=await createOceanBooking(form); await router.replace(`/ocean-bookings/${created}`) } else await updateOceanBooking(id.value, form); await load() } catch(e){actionError.value=getApiErrorDetails(e)} finally{saving.value=false} }
|
||||
async function action(status: OceanBookingStatus){ const actionName=actionByStatus[status]; if(!actionName) return; actionError.value=null; try { await performOceanBookingAction(id.value, actionName, { expectedVersion: form.expectedVersion, reason: null }); await load() } catch(e){actionError.value=getApiErrorDetails(e)} }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page booking-detail">
|
||||
<div class="detail-heading"><el-button @click="router.push('/ocean-bookings')">返回列表</el-button><div><h1>{{ detail?.bookingNo || '新增订舱' }}</h1><p v-if="detail">{{ detail.customerName || '—' }} · {{ detail.pol || '—' }} → {{ detail.pod || '—' }}</p></div><div v-if="detail" class="heading-status"><StatusBadge :status="detail.status" /><span class="heading-status-label">{{ statusLabel(detail.status) }}</span></div></div>
|
||||
<div class="detail-heading">
|
||||
<el-button @click="router.push('/ocean-bookings')">返回列表</el-button>
|
||||
<div><h1>{{ detail?.bookingNo || '新增订舱' }}</h1><p v-if="detail">{{ detail.customerName || '—' }} · {{ detail.pol || '—' }} → {{ detail.pod || '—' }}</p></div>
|
||||
<div v-if="detail" class="heading-status"><StatusBadge :status="detail.status" /><span>{{ statusLabel(detail.status) }}</span></div>
|
||||
</div>
|
||||
|
||||
<LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" @retry="load" />
|
||||
<template v-else>
|
||||
<el-alert v-if="actionError" type="error" :closable="false" show-icon>{{ actionError.message }}</el-alert>
|
||||
<section class="surface-panel"><div class="section-title"><h2>订舱信息</h2><div class="actions"><template v-for="nextStatus in actionStatuses" :key="nextStatus"><el-button v-if="detail && detail.status !== nextStatus" size="small" @click="action(nextStatus)">{{ statusLabel(nextStatus) }}</el-button></template><el-button type="primary" :loading="saving" @click="save">保存</el-button></div></div><el-form :model="form.data" label-position="top" class="form-grid"><el-form-item label="客户名称"><el-input v-model="form.data.customerName" /></el-form-item><el-form-item label="客户 ID"><el-input v-model="form.data.customerId" /></el-form-item><el-form-item label="关联订单号"><el-input v-model="form.data.orderNo" /></el-form-item><el-form-item label="船公司名称"><el-input v-model="form.data.shippingCompanyName" /></el-form-item><el-form-item label="船公司 ID"><el-input v-model="form.data.shippingCompanyId" /></el-form-item><el-form-item label="订舱代理"><el-input v-model="form.data.bookingAgentName" /></el-form-item><el-form-item label="起运港"><el-input v-model="form.data.departurePortName" /></el-form-item><el-form-item label="目的港"><el-input v-model="form.data.destinationPortName" /></el-form-item><el-form-item label="船名/航次"><el-input v-model="form.data.vesselName" /><el-input v-model="form.data.voyageNo" class="mt" /></el-form-item><el-form-item label="预计开船"><el-input v-model="form.data.estimatedSailingAt" /></el-form-item><el-form-item label="预计到港"><el-input v-model="form.data.estimatedArrivalAt" /></el-form-item><el-form-item label="S/O号"><el-input v-model="form.data.soNo" /></el-form-item><el-form-item class="wide" label="货物描述"><el-input v-model="form.data.goodsDescription" type="textarea" /></el-form-item><el-form-item class="wide" label="备注"><el-input v-model="form.data.remark" type="textarea" /></el-form-item></el-form></section>
|
||||
<section v-if="detail" class="surface-panel history-panel"><div class="history-heading"><div><h2>状态历史</h2><p>当前进度:<strong>{{ statusLabel(detail.status) }}</strong></p></div><el-tag type="primary" effect="light">当前状态</el-tag></div><el-timeline class="booking-timeline"><el-timeline-item v-for="(event, index) in detail.histories" :key="event.id" :timestamp="event.occurredAt" :type="event.toStatus === detail.status ? 'primary' : undefined" :hollow="event.toStatus !== detail.status" :class="{ 'is-current': event.toStatus === detail.status, 'is-latest': index === detail.histories.length - 1 }"><div class="history-event"><strong>{{ statusLabel(event.fromStatus) }} <span>→</span> {{ statusLabel(event.toStatus) }}</strong><span v-if="event.toStatus === detail.status" class="current-pill">当前状态</span><small>{{ event.operatorName || '系统' }}</small></div></el-timeline-item></el-timeline></section>
|
||||
<section class="surface-panel">
|
||||
<div class="section-title">
|
||||
<div><h2>订舱信息</h2><p v-if="detail && !editable">当前状态仅可查看,草稿状态才允许编辑。</p></div>
|
||||
<div class="actions">
|
||||
<el-button v-for="item in availableActions" :key="item" :loading="actionSaving" @click="runAction(item)">{{ actionLabels[item] }}</el-button>
|
||||
<el-button v-if="canSave" type="primary" :loading="saving" @click="save">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!editable" class="form-grid">
|
||||
<el-form-item label="客户 ID" prop="customerId"><el-input v-model="form.data.customerId" :disabled="!isNew" /></el-form-item>
|
||||
<el-form-item label="客户名称" prop="customerName"><el-input v-model="form.data.customerName" /></el-form-item>
|
||||
<el-form-item label="提单号"><el-input v-model="form.data.billOfLadingNo" /></el-form-item>
|
||||
<el-form-item label="船公司 ID"><el-input v-model="form.data.shippingCompanyId" /></el-form-item>
|
||||
<el-form-item label="船公司名称"><el-input v-model="form.data.shippingCompanyName" /></el-form-item>
|
||||
<el-form-item label="订舱代理 ID"><el-input v-model="form.data.bookingAgentId" /></el-form-item>
|
||||
<el-form-item label="订舱代理名称"><el-input v-model="form.data.bookingAgentName" /></el-form-item>
|
||||
<el-form-item label="船名"><el-input v-model="form.data.vesselName" /></el-form-item>
|
||||
<el-form-item label="航次"><el-input v-model="form.data.voyageNo" /></el-form-item>
|
||||
<el-form-item label="起运港代码"><el-input v-model="form.data.departurePortCode" /></el-form-item>
|
||||
<el-form-item label="起运港"><el-input v-model="form.data.departurePortName" /></el-form-item>
|
||||
<el-form-item label="目的港代码"><el-input v-model="form.data.destinationPortCode" /></el-form-item>
|
||||
<el-form-item label="目的港"><el-input v-model="form.data.destinationPortName" /></el-form-item>
|
||||
<el-form-item label="交货地点"><el-input v-model="form.data.placeOfDelivery" /></el-form-item>
|
||||
<el-form-item label="服务条款"><el-input v-model="form.data.serviceTerm" /></el-form-item>
|
||||
<el-form-item label="运费条款"><el-input v-model="form.data.freightTerm" /></el-form-item>
|
||||
<el-form-item label="预计开船"><el-date-picker v-model="form.data.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
||||
<el-form-item label="预计到港"><el-date-picker v-model="form.data.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
||||
<el-form-item class="wide" label="备注"><el-input v-model="form.data.remark" type="textarea" /></el-form-item>
|
||||
</el-form>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel">
|
||||
<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="单位名称" />
|
||||
<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="地址" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel">
|
||||
<div class="section-title"><h2>箱型需求</h2><el-button v-if="editable" @click="addContainer">新增箱型</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" :disabled="form.data.containerRequirements.length === 1" @click="removeContainer(index)">删除</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>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="changeDialogOpen" title="申请改船" width="min(560px, 94vw)" :close-on-click-modal="false">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="新船名" required><el-input v-model="changeForm.vesselName" /></el-form-item>
|
||||
<el-form-item label="新航次" required><el-input v-model="changeForm.voyageNo" /></el-form-item>
|
||||
<el-form-item label="预计开船"><el-date-picker v-model="changeForm.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
||||
<el-form-item label="预计到港"><el-date-picker v-model="changeForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
||||
<el-form-item label="变更原因" required><el-input v-model="changeForm.reason" type="textarea" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="changeDialogOpen = false">取消</el-button><el-button type="primary" :loading="actionSaving" :disabled="!changeForm.vesselName.trim() || !changeForm.voyageNo.trim() || !changeForm.reason.trim()" @click="submitChange">提交申请</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>.booking-detail{display:grid;gap:16px}.detail-heading{display:flex;align-items:center;gap:16px}.detail-heading h1{margin:0;font-size:20px}.detail-heading p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{display:flex;align-items:center;gap:8px;margin-left:auto}.heading-status-label{font-size:13px;font-weight:700;color:var(--cc-primary)}.surface-panel{padding:20px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:18px}.section-title h2,.surface-panel>h2,.history-heading h2{margin:0;font-size:16px}.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}.mt{margin-top:8px}.history-panel{padding-bottom:12px}.history-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:18px}.history-heading p{margin:6px 0 0;color:var(--cc-muted);font-size:12px}.history-heading strong{color:var(--cc-primary)}.booking-timeline{padding:4px 8px}.history-event{display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-height:28px;padding:8px 12px;border-radius:8px;color:var(--cc-muted)}.history-event strong{color:var(--cc-text);font-size:14px}.history-event strong span{padding:0 4px;color:var(--cc-muted)}.history-event small{color:var(--cc-muted);font-size:11px}.current-pill{padding:3px 8px;border-radius:999px;background:var(--el-color-primary-light-9);color:var(--el-color-primary);font-size:11px;font-weight:600}.booking-timeline :deep(.el-timeline-item__node--primary){box-shadow:0 0 0 5px var(--el-color-primary-light-9)}.booking-timeline :deep(.el-timeline-item.is-current .history-event){background:var(--el-color-primary-light-9);box-shadow:inset 3px 0 0 var(--el-color-primary)}@media(max-width:900px){.form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:600px){.detail-heading{align-items:flex-start;flex-wrap:wrap}.heading-status{margin-left:0}.form-grid{grid-template-columns:1fr}.form-grid .wide{grid-column:auto}.section-title,.history-heading{align-items:flex-start;flex-direction:column}.actions{width:100%}.actions :deep(.el-button){min-height:40px}.history-event{align-items:flex-start;flex-direction:column;gap:4px}}</style>
|
||||
|
||||
<style scoped>
|
||||
.booking-detail{display:grid;gap:16px}.detail-heading{display:flex;align-items:center;gap:16px}.detail-heading h1{margin:0;font-size:20px}.detail-heading p,.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;margin-left:auto}.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{align-items:flex-start;flex-wrap:wrap}.heading-status{margin-left:0}.form-grid,.party-grid,.container-row{grid-template-columns:1fr}.form-grid .wide{grid-column:auto}.section-title{flex-direction:column}.actions{width:100%}}
|
||||
</style>
|
||||
|
||||
@@ -18,7 +18,7 @@ const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const query = reactive<QueryModel>({ keyword: '', status: '', pol: '', pod: '', soNo: '' })
|
||||
const query = reactive<QueryModel>({ keyword: '', status: '' })
|
||||
const statuses: Array<{ label: string; value: string }> = [
|
||||
{ label: '草稿', value: 'DRAFT' }, { label: '已提交', value: 'SUBMITTED' }, { label: '已确认', value: 'CONFIRMED' },
|
||||
{ label: '已放舱', value: 'RELEASED' }, { label: '失败', value: 'FAILED' }, { label: '变更中', value: 'CHANGE_PENDING' }, { label: '已取消', value: 'CANCELLED' },
|
||||
@@ -26,16 +26,16 @@ const statuses: Array<{ label: string; value: string }> = [
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '订舱号/订单号/客户', width: 220 },
|
||||
{ key: 'status', type: 'select', label: '状态', options: statuses },
|
||||
{ key: 'pol', type: 'input', label: '起运港', width: 150 }, { key: 'pod', type: 'input', label: '目的港', width: 150 }, { key: 'soNo', type: 'input', label: 'S/O号', width: 150 },
|
||||
]
|
||||
const columns = [
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 170, slotName: 'actions', hideable: false, movable: false },
|
||||
{ prop: 'bookingNo', label: '订舱编号', minWidth: 150 }, { prop: 'orderNo', label: '关联订单', minWidth: 140 }, { prop: 'customerName', label: '委托单位', minWidth: 160 },
|
||||
{ prop: 'mblShipper', label: 'MB/L货人', minWidth: 150 }, { prop: 'mblConsignee', label: 'MB/L收人', minWidth: 150 }, { prop: 'pol', label: '起运港(POL)', minWidth: 125 }, { prop: 'pod', label: '目的港(POD)', minWidth: 125 },
|
||||
{ prop: 'containerSummary', label: '箱型/箱量', minWidth: 120 }, { prop: 'carrierName', label: '船公司', minWidth: 130 }, { prop: 'vesselName', label: '船名/航次', minWidth: 150, slotName: 'vessel' }, { prop: 'soNo', label: 'S/O号', minWidth: 130 },
|
||||
{ prop: 'cutoffAt', label: '预计截关', minWidth: 150 }, { prop: 'etd', label: 'ETD', minWidth: 140 }, { prop: 'atd', label: 'ATD', minWidth: 140 }, { prop: 'eta', label: 'ETA', minWidth: 140 }, { prop: 'ata', label: 'ATA', minWidth: 140 }, { prop: 'status', label: '状态', minWidth: 110, slotName: 'status' },
|
||||
{ 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' },
|
||||
]
|
||||
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, pol: String(query.pol || '') || undefined, pod: String(query.pod || '') || undefined, soNo: String(query.soNo || '') || undefined } satisfies OceanBookingQuery); rows.value = result.records; total.value = result.total } catch (e) { error.value = getApiErrorDetails(e) } finally { loading.value = false } }
|
||||
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() { page.value = 1; void load() }
|
||||
watch([page, pageSize], () => void load()); onMounted(load)
|
||||
|
||||
Reference in New Issue
Block a user