2026-08-08 13:32:27 +08:00
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|
|
|
|
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
2026-08-08 21:14:45 +08:00
|
|
|
import { getApiErrorDetails } from '@/shared/api/http'
|
2026-08-08 13:46:21 +08:00
|
|
|
import { createOceanBooking, getOceanBooking, performOceanBookingAction, updateOceanBooking, type OceanBookingAction } from '../api/platformOceanBookings'
|
2026-08-08 21:14:45 +08:00
|
|
|
import type { OceanBookingDetail, OceanBookingDraftData, OceanBookingSaveRequest, OceanBookingStatus } from '../types'
|
2026-08-08 15:15:04 +08:00
|
|
|
|
2026-08-08 21:14:45 +08:00
|
|
|
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 }] })
|
2026-08-08 13:46:21 +08:00
|
|
|
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: null, data: blankData() })
|
2026-08-08 13:32:27 +08:00
|
|
|
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' }
|
2026-08-08 21:14:45 +08:00
|
|
|
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)} }
|
2026-08-08 13:32:27 +08:00
|
|
|
onMounted(load)
|
|
|
|
|
</script>
|
2026-08-08 15:15:04 +08:00
|
|
|
<template>
|
2026-08-08 21:14:45 +08:00
|
|
|
<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>
|
|
|
|
|
<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>
|
2026-08-08 15:15:04 +08:00
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
2026-08-08 21:14:45 +08:00
|
|
|
<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>
|