Files
admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue
T

358 lines
26 KiB
Vue
Raw Normal View History

2026-08-08 13:32:27 +08:00
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
2026-08-10 09:12:06 +08:00
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
2026-08-08 13:32:27 +08:00
import { useRoute, useRouter } from 'vue-router'
2026-08-10 16:24:54 +08:00
import { ArrowLeft, Plus, Save, Trash2 } from '@lucide/vue'
2026-08-10 09:12:06 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails } from '@/shared/api/http'
2026-08-08 13:32:27 +08:00
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
2026-08-08 13:32:27 +08:00
import StatusBadge from '@/shared/components/StatusBadge.vue'
2026-08-10 16:24:54 +08:00
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'
2026-08-11 18:06:35 +08:00
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
2026-08-12 09:25:55 +08:00
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
2026-08-10 09:12:06 +08:00
import {
createOceanBooking,
getOceanBooking,
performOceanBookingAction,
releaseOceanBooking,
2026-08-10 09:12:06 +08:00
requestOceanBookingChange,
updateOceanBooking,
type OceanBookingAction,
} from '../api/platformOceanBookings'
import type {
OceanBookingDetail,
OceanBookingDraftData,
OceanBookingParty,
OceanBookingSaveRequest,
OceanBookingStatus,
} from '../types'
2026-08-11 18:43:56 +08:00
import { oceanBookingStatusLabel } from '../status'
import { containerRequirementError, createBlankContainerRequirement } from '../containerRequirements'
2026-08-10 09:12:06 +08:00
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>()
2026-08-11 18:06:35 +08:00
const customers = ref<PlatformTenantResource[]>([])
const partners = ref<PlatformPartnerResource[]>([])
const warehouses = ref<PlatformWarehouseResource[]>([])
2026-08-11 18:06:35 +08:00
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))))
2026-08-10 09:12:06 +08:00
function blankParties(): OceanBookingParty[] {
return partyTypes.map(({ value }) => ({ partyType: value, partyName: '', contactName: '', contactPhone: '', address: '' }))
}
function blankData(): OceanBookingDraftData {
return {
customerId: '', customerName: '', channelCode: '', channelName: '', warehouseId: '', warehouseName: '', parties: blankParties(),
containerRequirements: [createBlankContainerRequirement()],
2026-08-10 09:12:06 +08:00
}
}
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blankData() })
const rules: FormRules = {
2026-08-11 18:06:35 +08:00
customerId: [{ required: true, message: '请选择客户', trigger: 'change' }],
2026-08-10 09:12:06 +08:00
}
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) {
2026-08-11 18:43:56 +08:00
return oceanBookingStatusLabel(status)
2026-08-10 09:12:06 +08:00
}
2026-08-11 18:06:35 +08:00
async function loadOptions() {
optionsLoading.value = true
optionsError.value = ''
try {
const [customerResult, partnerResult, warehouseResult] = await Promise.all([
2026-08-14 12:50:42 +08:00
listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }),
2026-08-11 18:06:35 +08:00
listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' }),
listPlatformWarehouses({ page: 1, size: 200, status: 'ACTIVE' }),
2026-08-11 18:06:35 +08:00
])
2026-08-14 12:50:42 +08:00
customers.value = customerResult.records.filter((customer) => customer.status === 'ACTIVE')
2026-08-11 18:06:35 +08:00
partners.value = partnerResult.records
warehouses.value = warehouseResult.records
2026-08-11 18:06:35 +08:00
} catch (cause) {
optionsError.value = getApiErrorDetails(cause, '客户及合作单位选项加载失败').message
} finally {
optionsLoading.value = false
}
}
function selectCustomer(customerId: string) {
form.data.customerId = customerId
2026-08-14 12:50:42 +08:00
const customer = customers.value.find((item) => String(item.id) === customerId)
form.data.customerName = customer ? customer.legalName?.trim() || customer.tenantName : ''
2026-08-11 18:06:35 +08:00
}
function selectWarehouse(value: unknown) {
const warehouseId = String(value || '')
form.data.warehouseId = warehouseId
form.data.warehouseName = warehouses.value.find((warehouse) => String(warehouse.id) === warehouseId)?.warehouseName || ''
}
2026-08-11 18:06:35 +08:00
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
}
}
2026-08-10 09:12:06 +08:00
function applyDetail(value: OceanBookingDetail) {
detail.value = value
form.expectedVersion = value.version
form.data = {
customerId: value.customerId || '', customerName: value.customerName || '', channelCode: value.channelCode || '', channelName: value.channelName || '', warehouseId: value.warehouseId || '', warehouseName: value.warehouseName || '', billOfLadingNo: value.billOfLadingNo,
2026-08-10 09:12:06 +08:00
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, capacityCbm, maxWeightKg, quantity }) => ({ containerType, containerKind, capacityCbm: capacityCbm ?? null, maxWeightKg: maxWeightKg ?? null, quantity: quantity ?? null })) : [createBlankContainerRequirement()],
2026-08-10 09:12:06 +08:00
}
}
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
}
2026-08-12 18:50:03 +08:00
if (!validateContainerRequirements()) return
2026-08-10 09:12:06 +08:00
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
2026-08-12 18:50:03 +08:00
if (!validateContainerRequirements()) return
2026-08-10 09:12:06 +08:00
try {
const reason = await requiredReason(action)
await ElMessageBox.confirm(`确认执行“${actionLabels[action]}”吗?`, actionLabels[action], { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
actionSaving.value = true
if (action === 'release') {
const releaseRequest = { expectedVersion: detail.value.version, billOfLadingNo: form.data.billOfLadingNo || '', vesselName: form.data.vesselName || '', voyageNo: form.data.voyageNo || '', estimatedSailingAt: form.data.estimatedSailingAt || '', estimatedArrivalAt: form.data.estimatedArrivalAt || '', reason }
applyDetail(await releaseOceanBooking(id.value, releaseRequest))
} else applyDetail(await performOceanBookingAction(id.value, action, { expectedVersion: detail.value.version, reason }))
2026-08-10 09:12:06 +08:00
ElMessage.success(`${actionLabels[action]}成功`)
} catch (cause) {
if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) actionError.value = getApiErrorDetails(cause)
} finally { actionSaving.value = false }
}
2026-08-12 18:50:03 +08:00
function validateContainerRequirements() {
const message = containerRequirementError(form.data.containerRequirements)
if (message) {
actionError.value = { message }
2026-08-12 18:50:03 +08:00
return false
}
return true
}
function addContainer() { form.data.containerRequirements.push(createBlankContainerRequirement()) }
2026-08-10 09:12:06 +08:00
function removeContainer(index: number) { if (form.data.containerRequirements.length > 1) form.data.containerRequirements.splice(index, 1) }
2026-08-11 18:06:35 +08:00
onMounted(() => { void load(); void loadOptions() })
2026-08-08 13:32:27 +08:00
</script>
2026-08-10 09:12:06 +08:00
<template>
2026-08-10 16:24:54 +08:00
<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 || '—'}` : '填写并保存订舱资料'">
2026-08-11 18:43:56 +08:00
<template v-if="detail" #actions><div class="heading-status"><StatusBadge :status="statusLabel(detail.status)" /></div></template>
2026-08-10 16:24:54 +08:00
</PageToolbar>
</div>
</template>
2026-08-10 09:12:06 +08:00
<LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
2026-08-10 16:24:54 +08:00
<template v-else>
<el-alert v-if="actionError" type="error" :closable="false" show-icon><ApiErrorMeta :details="actionError" /></el-alert>
2026-08-10 09:12:06 +08:00
<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>
2026-08-10 16:24:54 +08:00
<el-button v-if="canSave" type="primary" :loading="saving" @click="save"><Save :size="16" />保存</el-button>
2026-08-10 09:12:06 +08:00
</div>
</div>
2026-08-11 18:06:35 +08:00
<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>
2026-08-12 16:40:09 +08:00
<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>
2026-08-14 12:50:42 +08:00
<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-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '当前客户'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.legalName?.trim() || customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
2026-08-12 16:40:09 +08:00
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '待创建'" disabled /></el-form-item>
<el-form-item label="渠道编码" prop="channelCode"><el-input v-model="form.data.channelCode" placeholder="请输入渠道编码" /></el-form-item>
<el-form-item label="渠道名称" prop="channelName"><el-input v-model="form.data.channelName" placeholder="请输入渠道名称" /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" style="width:100%" @update:model-value="selectWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
2026-08-12 16:40:09 +08:00
<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.capacityCbm" :disabled="!editable" :min="0.01" :precision="2" placeholder="体积 m³" />
<el-input-number v-model="container.maxWeightKg" :disabled="!editable" :min="0.01" :precision="2" placeholder="限重 kg" />
2026-08-12 16:40:09 +08:00
<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>
2026-08-10 09:12:06 +08:00
</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 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>
2026-08-10 16:24:54 +08:00
</template>
</DetailPageLayout>
2026-08-10 09:12:06 +08:00
2026-08-10 16:24:54 +08:00
<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">
2026-08-10 09:12:06 +08:00
<el-form label-position="top">
2026-08-11 18:06:35 +08:00
<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>
2026-08-10 09:12:06 +08:00
</el-form>
2026-08-10 16:24:54 +08:00
</AppDialog>
</AdminPage>
</template>
2026-08-10 09:12:06 +08:00
<style scoped>
2026-08-12 16:40:09 +08:00
.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}}
2026-08-10 09:12:06 +08:00
</style>