317 lines
22 KiB
Vue
317 lines
22 KiB
Vue
<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>
|