111 lines
12 KiB
Vue
111 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { ArrowLeft } from '@lucide/vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { getOceanBooking, type OceanBookingDetail } from '@/modules/ocean-bookings'
|
||
import { getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, performTruckAppointmentAction, rescheduleTruckAppointment, updateTruckAppointment, type TruckAppointmentAction } from '../api/platformTrucking'
|
||
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
|
||
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
|
||
import { TRUCK_APPOINTMENT_SAVE_FIELDS, normalizeTruckAppointmentCurrency, truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
|
||
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: null, plannedLoadingAt: null })
|
||
const history = ref<TruckAppointmentStatusHistory[]>([])
|
||
const bookingReadiness = ref<OceanBookingDetail | null>(null)
|
||
const relatedError = ref('')
|
||
let activatedOnce = false
|
||
const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit'))
|
||
const relationSnapshot = computed(() => resource.value ? [resource.value.oceanBookingNo, resource.value.billOfLadingNo, resource.value.warehouseName].filter(Boolean).join(' / ') || '无订舱关联' : '')
|
||
async function loadRelated() {
|
||
if (!resource.value) return
|
||
relatedError.value = ''
|
||
const [attachments, histories, booking] = await Promise.allSettled([
|
||
listTruckAppointmentAttachments(id.value),
|
||
listTruckAppointmentHistory(id.value),
|
||
resource.value.oceanBookingId ? getOceanBooking(resource.value.oceanBookingId) : Promise.reject(new Error('拖车预约缺少订舱关联')),
|
||
])
|
||
if (attachments.status === 'fulfilled') resource.value.attachments = attachments.value
|
||
if (histories.status === 'fulfilled') history.value = histories.value
|
||
if (booking.status === 'fulfilled') bookingReadiness.value = booking.value
|
||
if (attachments.status === 'rejected' || histories.status === 'rejected' || booking.status === 'rejected') relatedError.value = '附件、状态历史或订舱资料状态暂时未能加载,主详情数据不受影响;派车时仍由服务端最终校验。'
|
||
}
|
||
async function load() { loading.value = true; error.value = null; resource.value = null; bookingReadiness.value = null; history.value = []; relatedError.value = ''; try { const result = await getTruckAppointment(id.value); resource.value = result; const draft = Object.fromEntries(TRUCK_APPOINTMENT_SAVE_FIELDS.map((key) => [key, key === 'expectedVersion' ? result.version ?? 0 : key === 'currency' ? normalizeTruckAppointmentCurrency(result.currency) : result[key as keyof TruckAppointmentResource]])); Object.assign(form, draft); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
|
||
async function submit() { if (saving.value) return; saving.value = true; error.value = null; try { await updateTruckAppointment(id.value, form); ElMessage.success('拖车预约已保存'); await load() } catch (err) { error.value = getApiErrorDetails(err, '保存失败,请稍后重试') } finally { saving.value = false } }
|
||
const actionByTarget: Partial<Record<TruckAppointmentStatus, TruckAppointmentAction>> = { PENDING_DISPATCH: 'SUBMIT', DISPATCHED: 'DISPATCH', PICKUP_IN_PROGRESS: 'PICKUP', ARRIVED_WAREHOUSE: 'ARRIVE', LOADING: 'LOAD', DEPARTED: 'DEPART', COMPLETED: 'COMPLETE', EXCEPTION: 'EXCEPTION', CANCELLED: 'CANCEL' }
|
||
const primaryTargetByStatus: Partial<Record<TruckAppointmentStatus, TruckAppointmentStatus>> = { DRAFT: 'PENDING_DISPATCH', PENDING_DISPATCH: 'DISPATCHED', RESCHEDULED: 'DISPATCHED', DISPATCHED: 'PICKUP_IN_PROGRESS', PICKUP_IN_PROGRESS: 'ARRIVED_WAREHOUSE', ARRIVED_WAREHOUSE: 'LOADING', LOADING: 'DEPARTED', DEPARTED: 'COMPLETED' }
|
||
const availableTargets = computed<TruckAppointmentStatus[]>(() => {
|
||
if (!resource.value || ['COMPLETED', 'CANCELLED'].includes(resource.value.status)) return []
|
||
const primary = primaryTargetByStatus[resource.value.status]
|
||
const targets: TruckAppointmentStatus[] = [...(primary ? [primary] : []), 'RESCHEDULED', 'EXCEPTION', 'CANCELLED']
|
||
return targets
|
||
.filter((status, index, statuses) => status !== resource.value?.status && statuses.indexOf(status) === index)
|
||
})
|
||
const dispatchBlockReason = computed(() => {
|
||
if (!resource.value || !['PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value.status)) return ''
|
||
if (!form.oceanBookingId) return '确认派车前必须关联已放舱订舱'
|
||
if (bookingReadiness.value && bookingReadiness.value.status !== 'RELEASED') return '确认派车前必须关联已放舱订舱'
|
||
if (!form.billOfLadingNo?.trim()) return '确认派车前所选订舱必须填写提单号'
|
||
if (bookingReadiness.value && !bookingReadiness.value.documents.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'
|
||
return ''
|
||
})
|
||
async function confirmStatusChange(status: TruckAppointmentStatus) {
|
||
await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
|
||
}
|
||
async function changeStatus(status: TruckAppointmentStatus) {
|
||
if (!resource.value || status === resource.value.status || statusSaving.value) return
|
||
if (status === 'DISPATCHED' && dispatchBlockReason.value) { error.value = { message: dispatchBlockReason.value }; return }
|
||
try { await confirmStatusChange(status); statusSaving.value = true; if (status === 'RESCHEDULED') { await rescheduleTruckAppointment(id.value, form.plannedLoadingAt || '', form.expectedVersion || 0, '后台改约') } else { const action = actionByTarget[status]; if (!action) throw new Error('当前状态没有可用动作'); await performTruckAppointmentAction(id.value, action, { expectedVersion: form.expectedVersion || 0 }) } ElMessage.success('状态已更新'); await load() } catch (err) { if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) error.value = getApiErrorDetails(err, '状态更新失败,请稍后重试') } finally { statusSaving.value = false }
|
||
}
|
||
onMounted(load)
|
||
onActivated(() => {
|
||
if (activatedOnce) {
|
||
if (error.value || resource.value?.id !== id.value) void load()
|
||
return
|
||
}
|
||
activatedOnce = true
|
||
})
|
||
</script>
|
||
<template>
|
||
<AdminPage as="div" class="truck-detail-page">
|
||
<DetailPageLayout aria-label="拖车预约详情">
|
||
<template #header>
|
||
<section class="truck-overview surface-panel" aria-label="拖车预约概览">
|
||
<div class="truck-overview__heading">
|
||
<el-button link type="primary" @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button>
|
||
<div class="truck-overview__identity"><h1>{{ resource?.appointmentNo || '拖车预约详情' }}</h1><p>{{ resource ? [resource.oceanBookingNo, resource.billOfLadingNo, resource.orderNo].filter(Boolean).join(' · ') || '订舱信息缺失' : '查看拖车预约详情' }}</p></div>
|
||
<span v-if="resource" class="truck-overview__status">当前状态:<strong>{{ truckAppointmentStatusLabel(resource.status) }}</strong></span>
|
||
<div v-if="resource && canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" size="small" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div>
|
||
</div>
|
||
<template v-if="resource">
|
||
<p class="relation-snapshot"><strong>历史关联快照:</strong>{{ relationSnapshot }}</p>
|
||
<el-alert v-if="dispatchBlockReason" :title="dispatchBlockReason" type="warning" :closable="false" show-icon />
|
||
<el-alert v-if="relatedError" :title="relatedError" type="warning" :closable="false" show-icon />
|
||
<ApiErrorMeta v-if="error" :details="error" />
|
||
</template>
|
||
</section>
|
||
</template>
|
||
<LoadErrorState v-if="error && !resource" title="详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||
<template v-else-if="resource">
|
||
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :resource="resource" :readonly="!canEdit" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section>
|
||
<section class="surface-panel history-section"><h3>状态历史</h3><el-empty v-if="!history.length" description="暂无状态历史" :image-size="48" /><ol v-else><li v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><strong>{{ truckAppointmentActionLabel(item.action) }}</strong><span>{{ truckAppointmentStatusLabel(item.fromStatus) }} → {{ truckAppointmentStatusLabel(item.toStatus) }}</span><small>{{ item.operatorName || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
|
||
<TruckAttachmentDialog v-model="attachmentOpen" :appointment-id="id" :attachments="resource.attachments || []" :can-manage="canManageAttachments" @changed="loadRelated" />
|
||
</template>
|
||
</DetailPageLayout>
|
||
</AdminPage>
|
||
</template>
|
||
<style scoped>
|
||
.truck-overview { overflow:hidden; }
|
||
.truck-overview__heading { display:grid; grid-template-columns:auto minmax(260px, 1fr) auto auto; align-items:center; gap:12px 18px; padding:12px 16px; }
|
||
.truck-overview__identity { min-width:0; }.truck-overview__identity h1 { margin:0; overflow:hidden; color:var(--cc-text); font-size:18px; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }.truck-overview__identity p { margin:2px 0 0; overflow:hidden; color:var(--cc-muted); font-size:12px; text-overflow:ellipsis; white-space:nowrap; }
|
||
.truck-overview__status { font-size:13px; white-space:nowrap; }.truck-overview__status strong { margin-left:4px; font-size:15px; }
|
||
.status-actions { display:flex; flex-wrap:wrap; gap:8px; }.status-actions :deep(.el-button) { margin-left:0; }
|
||
.relation-snapshot { margin:0; padding:9px 16px; border-top:1px solid var(--cc-border); color:var(--cc-muted); font-size:12px; line-height:1.5; }.relation-snapshot strong { margin-right:4px; color:var(--cc-text); }
|
||
.truck-detail-page { min-width:0; }.truck-detail-page :deep(.detail-page-layout__content) { display:grid; min-width:0; gap:16px; }.form-panel { padding:16px; }.history-section { padding:16px; }.history-section h3 { margin:0 0 12px; font-size:15px; }.history-section ol { display:grid; gap:10px; margin:0; padding:0; list-style:none; }.history-section li { display:grid; grid-template-columns:120px minmax(120px, 1fr) minmax(200px, 2fr); gap:12px; padding:9px 12px; border-radius:6px; background:var(--el-fill-color-lighter); }.history-section small { color:var(--cc-muted); overflow-wrap:anywhere; }
|
||
@media (max-width: 640px) { .history-section { padding:16px; }.history-section li { grid-template-columns:1fr; gap:4px; } }
|
||
</style>
|