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'
|
2026-08-11 11:26:31 +08:00
|
|
|
|
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'
|
2026-08-12 18:43:54 +08:00
|
|
|
|
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
2026-08-10 09:12:06 +08:00
|
|
|
|
import {
|
|
|
|
|
|
createOceanBooking,
|
|
|
|
|
|
getOceanBooking,
|
|
|
|
|
|
performOceanBookingAction,
|
2026-08-12 18:43:54 +08:00
|
|
|
|
releaseOceanBooking,
|
2026-08-10 09:12:06 +08:00
|
|
|
|
requestOceanBookingChange,
|
|
|
|
|
|
updateOceanBooking,
|
2026-08-14 18:50:11 +08:00
|
|
|
|
listWarehouseLogisticsChannels,
|
|
|
|
|
|
type OceanLogisticsChannel,
|
2026-08-10 09:12:06 +08:00
|
|
|
|
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'
|
2026-08-12 18:53:18 +08:00
|
|
|
|
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[]>([])
|
2026-08-12 18:43:54 +08:00
|
|
|
|
const warehouses = ref<PlatformWarehouseResource[]>([])
|
2026-08-11 18:06:35 +08:00
|
|
|
|
const optionsLoading = ref(false)
|
|
|
|
|
|
const optionsError = ref('')
|
2026-08-14 18:50:11 +08:00
|
|
|
|
const channels = ref<OceanLogisticsChannel[]>([])
|
|
|
|
|
|
const channelsLoading = ref(false)
|
|
|
|
|
|
const channelsError = ref('')
|
|
|
|
|
|
const selectedChannelName = ref('')
|
|
|
|
|
|
let channelRequestSerial = 0
|
2026-08-11 18:06:35 +08:00
|
|
|
|
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-14 18:50:11 +08:00
|
|
|
|
const channelUnavailable = computed(() => Boolean(form.data.warehouseId) && !channelsLoading.value && !channelsError.value && channels.value.length === 0)
|
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 {
|
2026-08-14 18:50:11 +08:00
|
|
|
|
customerId: '', customerName: '', channelCode: '', warehouseId: '', warehouseName: '', parties: blankParties(),
|
2026-08-12 18:53:18 +08:00
|
|
|
|
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-14 18:50:11 +08:00
|
|
|
|
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
|
|
|
|
|
|
channelCode: [{ 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: '' })
|
2026-08-14 14:27:03 +08:00
|
|
|
|
const releaseDialogOpen = ref(false)
|
|
|
|
|
|
const releaseForm = reactive({ billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null as string | null, estimatedArrivalAt: null as string | null, reason: '' })
|
2026-08-10 09:12:06 +08:00
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-08-12 18:43:54 +08:00
|
|
|
|
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' }),
|
2026-08-14 18:50:11 +08:00
|
|
|
|
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'DOMESTIC', 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
|
2026-08-14 18:50:11 +08:00
|
|
|
|
warehouses.value = warehouseResult.records.filter((warehouse) => warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC')
|
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)
|
2026-08-14 14:27:03 +08:00
|
|
|
|
form.data.customerName = customer?.tenantName || ''
|
2026-08-11 18:06:35 +08:00
|
|
|
|
}
|
2026-08-12 19:27:55 +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-14 18:50:11 +08:00
|
|
|
|
form.data.channelCode = ''
|
|
|
|
|
|
selectedChannelName.value = ''
|
|
|
|
|
|
channels.value = []
|
|
|
|
|
|
channelsError.value = ''
|
|
|
|
|
|
if (warehouseId) void loadChannels(warehouseId)
|
|
|
|
|
|
}
|
|
|
|
|
|
async function loadChannels(warehouseId: string) {
|
|
|
|
|
|
const serial = ++channelRequestSerial
|
|
|
|
|
|
channelsLoading.value = true
|
|
|
|
|
|
channelsError.value = ''
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await listWarehouseLogisticsChannels(warehouseId)
|
|
|
|
|
|
if (serial === channelRequestSerial && warehouseId === form.data.warehouseId) channels.value = result
|
|
|
|
|
|
} catch (cause) {
|
|
|
|
|
|
if (serial === channelRequestSerial) channelsError.value = getApiErrorDetails(cause, '物流渠道加载失败').message
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (serial === channelRequestSerial) channelsLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
function selectChannel(channelCode: string) {
|
|
|
|
|
|
const channel = channels.value.find((item) => item.channelCode === channelCode)
|
|
|
|
|
|
form.data.channelCode = channelCode
|
|
|
|
|
|
selectedChannelName.value = channel?.channelName || ''
|
2026-08-12 19:27:55 +08:00
|
|
|
|
}
|
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 = {
|
2026-08-14 18:50:11 +08:00
|
|
|
|
customerId: value.customerId || '', customerName: value.customerName || '', channelCode: value.channelCode || '', 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(),
|
2026-08-12 18:53:18 +08:00
|
|
|
|
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
|
|
|
|
}
|
2026-08-14 18:50:11 +08:00
|
|
|
|
selectedChannelName.value = value.channelName || ''
|
|
|
|
|
|
channels.value = []
|
|
|
|
|
|
if (form.data.warehouseId) void loadChannels(form.data.warehouseId)
|
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 {
|
2026-08-14 18:50:11 +08:00
|
|
|
|
const payload = { ...form.data }
|
2026-08-10 09:12:06 +08:00
|
|
|
|
if (isNew.value) {
|
2026-08-14 18:50:11 +08:00
|
|
|
|
const created = await createOceanBooking({ expectedVersion: 0, data: payload })
|
2026-08-10 09:12:06 +08:00
|
|
|
|
ElMessage.success('订舱已创建')
|
|
|
|
|
|
await router.replace(`/ocean-bookings/${created.id}`)
|
|
|
|
|
|
applyDetail(created)
|
|
|
|
|
|
} else {
|
2026-08-14 18:50:11 +08:00
|
|
|
|
applyDetail(await updateOceanBooking(id.value, { expectedVersion: form.expectedVersion, data: payload }))
|
2026-08-10 09:12:06 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-08-14 14:27:03 +08:00
|
|
|
|
function openReleaseDialog() {
|
|
|
|
|
|
releaseForm.billOfLadingNo = detail.value?.billOfLadingNo || ''
|
|
|
|
|
|
releaseForm.vesselName = detail.value?.vesselName || ''
|
|
|
|
|
|
releaseForm.voyageNo = detail.value?.voyageNo || ''
|
|
|
|
|
|
releaseForm.estimatedSailingAt = detail.value?.estimatedSailingAt || null
|
|
|
|
|
|
releaseForm.estimatedArrivalAt = detail.value?.estimatedArrivalAt || null
|
|
|
|
|
|
releaseForm.reason = ''
|
|
|
|
|
|
releaseDialogOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
async function submitRelease() {
|
|
|
|
|
|
if (!detail.value) return
|
|
|
|
|
|
const billOfLadingNo = releaseForm.billOfLadingNo.trim()
|
|
|
|
|
|
const vesselName = releaseForm.vesselName.trim()
|
|
|
|
|
|
const voyageNo = releaseForm.voyageNo.trim()
|
|
|
|
|
|
if (!billOfLadingNo || !vesselName || !voyageNo || !releaseForm.estimatedSailingAt || !releaseForm.estimatedArrivalAt) {
|
|
|
|
|
|
actionError.value = getApiErrorDetails(new Error('请完整填写提单号、船名、航次、预计开船和预计到港时间'))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (releaseForm.estimatedArrivalAt < releaseForm.estimatedSailingAt) {
|
|
|
|
|
|
actionError.value = getApiErrorDetails(new Error('预计到港时间不能早于预计开船时间'))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
actionSaving.value = true
|
|
|
|
|
|
actionError.value = null
|
|
|
|
|
|
try {
|
|
|
|
|
|
applyDetail(await releaseOceanBooking(id.value, { expectedVersion: detail.value.version, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt: releaseForm.estimatedSailingAt, estimatedArrivalAt: releaseForm.estimatedArrivalAt, reason: releaseForm.reason.trim() || null }))
|
|
|
|
|
|
releaseDialogOpen.value = false
|
|
|
|
|
|
ElMessage.success('确认放舱成功')
|
|
|
|
|
|
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
|
|
|
|
|
|
finally { actionSaving.value = false }
|
|
|
|
|
|
}
|
2026-08-10 09:12:06 +08:00
|
|
|
|
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 }
|
2026-08-14 14:27:03 +08:00
|
|
|
|
if (action === 'release') { openReleaseDialog(); return }
|
2026-08-10 09:12:06 +08:00
|
|
|
|
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
|
2026-08-14 14:27:03 +08:00
|
|
|
|
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() {
|
2026-08-12 18:53:18 +08:00
|
|
|
|
const message = containerRequirementError(form.data.containerRequirements)
|
|
|
|
|
|
if (message) {
|
|
|
|
|
|
actionError.value = { message }
|
2026-08-12 18:50:03 +08:00
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
2026-08-12 18:53:18 +08:00
|
|
|
|
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-08 15:15:04 +08:00
|
|
|
|
|
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
|
|
|
|
|
2026-08-08 15:15:04 +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
|
|
|
|
|
2026-08-11 11:26:31 +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>
|
2026-08-11 11:26:31 +08:00
|
|
|
|
<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="基本信息">
|
2026-08-14 18:50:11 +08:00
|
|
|
|
<h3>基本信息</h3><div class="booking-group__body booking-group__body--fields">
|
2026-08-12 16:40:09 +08:00
|
|
|
|
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></el-form-item>
|
2026-08-14 14:27:03 +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.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>
|
2026-08-14 18:50:11 +08:00
|
|
|
|
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" :loading="optionsLoading" 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>
|
|
|
|
|
|
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channels.some((channel) => channel.channelCode === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode"><span>{{ channel.channelName }}</span><small class="channel-code">{{ channel.channelCode }}</small></el-option></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></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>
|
2026-08-14 14:27:03 +08:00
|
|
|
|
<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>
|
2026-08-12 16:40:09 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
<section class="booking-group" aria-label="航线船期">
|
2026-08-14 18:50:11 +08:00
|
|
|
|
<h3>航线船期</h3><div class="booking-group__body booking-group__body--fields">
|
2026-08-12 16:40:09 +08:00
|
|
|
|
<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>
|
2026-08-14 18:50:11 +08:00
|
|
|
|
<el-form-item class="booking-field--wide" label="交货地点"><el-input v-model="form.data.placeOfDelivery" placeholder="请输入最终交货地点" /></el-form-item>
|
2026-08-12 16:40:09 +08:00
|
|
|
|
<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>
|
2026-08-14 18:50:11 +08:00
|
|
|
|
<div class="container-table-wrap" aria-label="箱型箱量">
|
|
|
|
|
|
<table class="container-table">
|
|
|
|
|
|
<thead><tr><th scope="col">操作</th><th scope="col">箱型</th><th scope="col">箱种</th><th scope="col">容量(m³)</th><th scope="col">限重(kg)</th><th scope="col">数量</th></tr></thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="(container, index) in form.data.containerRequirements" :key="index">
|
|
|
|
|
|
<td class="container-table__action">
|
|
|
|
|
|
<el-tooltip v-if="editable" :content="form.data.containerRequirements.length === 1 ? '至少保留一条箱型' : '删除该箱型'" placement="top">
|
|
|
|
|
|
<span><el-button circle plain size="small" type="danger" :aria-label="`删除箱型 ${index + 1}`" :disabled="form.data.containerRequirements.length === 1" @click="removeContainer(index)"><Trash2 :size="14" /></el-button></span>
|
|
|
|
|
|
</el-tooltip>
|
|
|
|
|
|
<span v-else aria-hidden="true">—</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td><el-input v-model="container.containerType" :disabled="!editable" placeholder="如 40HC" /></td>
|
|
|
|
|
|
<td><el-input v-model="container.containerKind" :disabled="!editable" placeholder="如 FCL" /></td>
|
|
|
|
|
|
<td><el-input-number v-model="container.capacityCbm" :disabled="!editable" :min="0.01" :precision="2" :controls="false" placeholder="请输入容量" /></td>
|
|
|
|
|
|
<td><el-input-number v-model="container.maxWeightKg" :disabled="!editable" :min="0.01" :precision="2" :controls="false" placeholder="请输入限重" /></td>
|
|
|
|
|
|
<td><el-input-number v-model="container.quantity" :disabled="!editable" :min="1" :max="999" :controls="false" placeholder="请输入数量" /></td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
2026-08-12 16:40:09 +08:00
|
|
|
|
</div>
|
2026-08-14 18:50:11 +08:00
|
|
|
|
<el-button v-if="editable" class="add-container" size="small" @click="addContainer"><Plus :size="15" />新增箱型</el-button>
|
2026-08-12 16:40:09 +08:00
|
|
|
|
<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-14 14:27:03 +08:00
|
|
|
|
<LoadErrorState v-if="actionError" compact title="提交失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
|
|
|
|
|
|
</AppDialog>
|
|
|
|
|
|
<AppDialog v-model="releaseDialogOpen" title="确认放舱" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="!releaseForm.billOfLadingNo.trim() || !releaseForm.vesselName.trim() || !releaseForm.voyageNo.trim() || !releaseForm.estimatedSailingAt || !releaseForm.estimatedArrivalAt" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
|
|
|
|
|
|
<el-form label-position="top">
|
|
|
|
|
|
<el-form-item label="提单号" required><el-input v-model="releaseForm.billOfLadingNo" maxlength="128" placeholder="请输入提单号" /></el-form-item>
|
|
|
|
|
|
<el-form-item label="船名" required><el-input v-model="releaseForm.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
|
|
|
|
|
|
<el-form-item label="航次" required><el-input v-model="releaseForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
|
|
|
|
|
|
<el-form-item label="预计开船" required><el-date-picker v-model="releaseForm.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item>
|
|
|
|
|
|
<el-form-item label="预计到港" required><el-date-picker v-model="releaseForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
|
|
|
|
|
|
<el-form-item label="备注"><el-input v-model="releaseForm.reason" type="textarea" maxlength="1000" placeholder="请输入放舱备注(可选)" /></el-form-item>
|
|
|
|
|
|
</el-form>
|
|
|
|
|
|
<LoadErrorState v-if="actionError" compact title="放舱失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
|
2026-08-10 16:24:54 +08:00
|
|
|
|
</AppDialog>
|
|
|
|
|
|
</AdminPage>
|
2026-08-08 15:15:04 +08:00
|
|
|
|
</template>
|
2026-08-10 09:12:06 +08:00
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-08-14 18:50:11 +08:00
|
|
|
|
.channel-code{display:block;margin-left:8px;color:var(--cc-muted);font-size:11px}.field-error,.field-hint{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:5px;color:var(--cc-muted);font-size:12px;line-height:20px}.field-error{color:var(--el-color-danger)}
|
|
|
|
|
|
.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}.booking-group__body--fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:12px}.booking-field--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-table-wrap{overflow-x:auto;margin-bottom:10px}.container-table{width:100%;min-width:580px;table-layout:fixed;border-collapse:collapse;font-size:12px}.container-table th,.container-table td{padding:7px 6px;border-bottom:1px solid #eef2f6;text-align:left;vertical-align:middle}.container-table th{background:#fafafa;color:var(--cc-muted);font-weight:650;white-space:nowrap}.container-table th:first-child,.container-table td:first-child{width:56px}.container-table th:nth-child(2),.container-table td:nth-child(2){width:112px}.container-table th:nth-child(3),.container-table td:nth-child(3){width:88px}.container-table th:nth-child(4),.container-table td:nth-child(4),.container-table th:nth-child(5),.container-table td:nth-child(5){width:118px}.container-table th:nth-child(6),.container-table td:nth-child(6){width:78px}.container-table td :deep(.el-input),.container-table td :deep(.el-input-number){width:100%;min-width:0}.container-table__action{text-align:center!important}.container-table__action :deep(.el-button){width:28px;height:28px}.add-container{margin-bottom:18px}.history-panel strong{font-size:14px}@media(max-width:1599px){.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}.detail-heading{flex-direction:column}.booking-page-toolbar{width:100%}.section-title{flex-direction:column}.actions{width:100%}}@media(max-width:520px){.booking-group__body--fields{grid-template-columns:1fr}.booking-field--wide{grid-column:auto}}@media(max-width:420px){.surface-panel{padding:12px}.booking-group__body{padding:14px}.actions :deep(.el-button){min-height:44px}}
|
2026-08-10 09:12:06 +08:00
|
|
|
|
</style>
|