Files
admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue
T
2026-08-12 09:25:55 +08:00

317 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 { ArrowLeft, Plus, Save, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import {
createOceanBooking,
getOceanBooking,
performOceanBookingAction,
requestOceanBookingChange,
updateOceanBooking,
type OceanBookingAction,
} from '../api/platformOceanBookings'
import type {
OceanBookingDetail,
OceanBookingDraftData,
OceanBookingParty,
OceanBookingSaveRequest,
OceanBookingStatus,
} from '../types'
import { oceanBookingStatusLabel } from '../status'
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>()
const customers = ref<PlatformTenantResource[]>([])
const partners = ref<PlatformPartnerResource[]>([])
const optionsLoading = ref(false)
const optionsError = ref('')
const selectableCustomers = computed(() => customers.value.filter((customer) => (
customer.status === 'ACTIVE' || String(customer.id) === String(form.data.customerId)
)))
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.types.includes('CARRIER')))
const bookingAgents = computed(() => partners.value.filter((partner) => partner.types.some((type) => ['SUPPLIER', 'CUSTOMS_BROKER'].includes(type))))
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: '请选择客户', trigger: 'change' }],
}
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 oceanBookingStatusLabel(status)
}
async function loadOptions() {
optionsLoading.value = true
optionsError.value = ''
try {
const [customerResult, partnerResult] = await Promise.all([
listPlatformTenants({ page: 1, size: 200 }),
listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' }),
])
customers.value = customerResult.records
partners.value = partnerResult.records
} catch (cause) {
optionsError.value = getApiErrorDetails(cause, '客户及合作单位选项加载失败').message
} finally {
optionsLoading.value = false
}
}
function selectCustomer(customerId: string) {
form.data.customerId = customerId
form.data.customerName = customers.value.find((customer) => String(customer.id) === customerId)?.tenantName || ''
}
function selectPartner(kind: 'shippingCompany' | 'bookingAgent', partnerId: string) {
const partner = partners.value.find((item) => String(item.id) === partnerId)
if (kind === 'shippingCompany') {
form.data.shippingCompanyId = partnerId || null
form.data.shippingCompanyName = partner?.partnerName || null
} else {
form.data.bookingAgentId = partnerId || null
form.data.bookingAgentName = partner?.partnerName || null
}
}
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) }
onMounted(() => { void load(); void loadOptions() })
</script>
<template>
<AdminPage as="div" class="booking-detail">
<DetailPageLayout aria-label="订舱详情">
<template #header>
<div class="detail-heading">
<el-button @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱资料'" :description="detail ? `${detail.customerName || '—'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : '填写并保存订舱资料'">
<template v-if="detail" #actions><div class="heading-status"><StatusBadge :status="statusLabel(detail.status)" /></div></template>
</PageToolbar>
</div>
</template>
<LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else>
<el-alert v-if="actionError" type="error" :closable="false" show-icon><ApiErrorMeta :details="actionError" /></el-alert>
<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"><Save :size="16" />保存</el-button>
</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>
</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"><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>
</section>
</template>
</DetailPageLayout>
<AppDialog v-model="changeDialogOpen" title="申请改船" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="!changeForm.vesselName.trim() || !changeForm.voyageNo.trim() || !changeForm.reason.trim()" confirm-text="提交申请" @confirm="submitChange">
<el-form label-position="top">
<el-form-item label="新船名" required><el-input v-model="changeForm.vesselName" placeholder="请输入新船名" /></el-form-item>
<el-form-item label="新航次" required><el-input v-model="changeForm.voyageNo" placeholder="请输入新航次" /></el-form-item>
<el-form-item label="预计开船"><el-date-picker v-model="changeForm.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="changeForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
<el-form-item label="变更原因" required><el-input v-model="changeForm.reason" type="textarea" placeholder="请说明改船原因" /></el-form-item>
</el-form>
</AppDialog>
</AdminPage>
</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%}}
</style>