This commit is contained in:
2026-08-08 14:13:32 +08:00
parent 6f0859e4f3
commit a3a09d1dec
9 changed files with 59 additions and 43 deletions
@@ -8,10 +8,9 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
import { createTruckAppointment } from '../api/platformTrucking'
import type { SaveTruckAppointmentRequest, TruckAttachmentReference } from '../types'
import type { SaveTruckAppointmentRequest } from '../types'
const router = useRouter(); const open = ref(true); const saving = ref(false); const attachmentOpen = ref(false); const error = ref<ApiErrorDetails | null>(null)
const form = reactive<SaveTruckAppointmentRequest>({ attachmentRefs: [], expectedVersion: 0, plannedLoadingAt: null, payableAmount: '0.00', receivableAmount: '0.00', currency: 'RMB' })
async function submit() { saving.value = true; error.value = null; try { await createTruckAppointment({ ...form, attachmentRefs: form.attachmentRefs || [] }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) } catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') } finally { saving.value = false } }
function saveAttachments(items: TruckAttachmentReference[]) { form.attachmentRefs = items }
const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, plannedLoadingAt: null, payableAmount: '0.00', receivableAmount: '0.00', currency: 'RMB' })
async function submit() { saving.value = true; error.value = null; try { await createTruckAppointment({ ...form }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) } catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') } finally { saving.value = false } }
</script>
<template><AppDrawer v-model="open" title="新增拖车预约" :confirm-loading="saving" :show-footer="false" @cancel="router.push({ name: 'trucking' })"><ApiErrorMeta v-if="error" :details="error" /><TruckAppointmentForm v-model="form" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /><TruckAttachmentDialog v-model="attachmentOpen" :attachments="form.attachmentRefs || []" @save="saveAttachments" /></AppDrawer></template>
<template><AppDrawer v-model="open" title="新增拖车预约" :confirm-loading="saving" :show-footer="false" @cancel="router.push({ name: 'trucking' })"><ApiErrorMeta v-if="error" :details="error" /><TruckAppointmentForm v-model="form" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /><TruckAttachmentDialog v-model="attachmentOpen" :attachments="[]" /></AppDrawer></template>
@@ -10,22 +10,28 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getTruckAppointment, performTruckAppointmentAction, rescheduleTruckAppointment, updateTruckAppointment, type TruckAppointmentAction } from '../api/platformTrucking'
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
import { TRUCK_APPOINTMENT_STATUSES, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAttachmentReference } from '../types'
import { truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus } from '../types'
const route = useRoute(); const router = useRouter(); const open = ref(true); 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 })
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') && !['COMPLETED','CANCELLED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate'))
async function load() { loading.value = true; error.value = null; try { const result = await getTruckAppointment(id.value); resource.value = result; Object.assign(form, { ...result, expectedVersion: result.version ?? 0, attachmentRefs: result.attachments || [] }) } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
async function load() { loading.value = true; error.value = null; try { const result = await getTruckAppointment(id.value); resource.value = result; Object.assign(form, { ...result, expectedVersion: result.version ?? 0 }) } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
async function submit() { 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]
return [...(primary ? [primary] : []), 'RESCHEDULED', 'EXCEPTION', 'CANCELLED']
})
async function changeStatus(status: TruckAppointmentStatus) { if (!resource.value || status === resource.value.status) return; try { await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { type: 'warning' }); 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, 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 } }
function saveAttachments(items: TruckAttachmentReference[]) { form.attachmentRefs = items }
onMounted(load)
</script>
<template>
<AppDrawer v-model="open" :title="resource?.appointmentNo ? `拖车预约 ${resource.appointmentNo}` : '拖车预约详情'" :loading="loading" :show-footer="false" @cancel="router.push({ name: 'trucking' })">
<LoadErrorState v-if="error && !resource" title="详情加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="resource"><div class="status-bar"><span>当前状态:<strong>{{ truckAppointmentStatusLabels[resource.status] }}</strong></span><el-select :model-value="resource.status" :loading="statusSaving" :disabled="statusSaving || !canChangeStatus" style="width:170px" @change="changeStatus"><el-option v-for="status in TRUCK_APPOINTMENT_STATUSES" :key="status" :value="status" :label="truckAppointmentStatusLabels[status]" /></el-select></div><ApiErrorMeta v-if="error" :details="error" /><TruckAppointmentForm v-model="form" :resource="resource" :readonly="!canEdit" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /><TruckAttachmentDialog v-model="attachmentOpen" :attachments="form.attachmentRefs || []" :readonly="!canEdit" @save="saveAttachments" /></template>
<template v-else-if="resource"><div class="status-bar"><span>当前状态:<strong>{{ truckAppointmentStatusLabels[resource.status] }}</strong></span><div v-if="canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div></div><ApiErrorMeta v-if="error" :details="error" /><TruckAppointmentForm v-model="form" :resource="resource" :readonly="!canEdit" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /><TruckAttachmentDialog v-model="attachmentOpen" :attachments="resource.attachments || []" /></template>
</AppDrawer>
</template>
<style scoped>
.status-bar { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-bottom:16px; padding:12px; background:var(--el-fill-color-light); border-radius:6px; }
.status-actions { display:flex; flex-wrap:wrap; gap:8px; }.status-actions :deep(.el-button) { margin-left:0; }
</style>
@@ -27,12 +27,13 @@ const columns: DataTableColumn[] = [
{ key: 'company', label: '承运商', minWidth: 150, slotName: 'company' },
{ key: 'driver', label: '司机/车头', minWidth: 150, slotName: 'driver' },
{ key: 'store', label: '门店', minWidth: 130, slotName: 'store' },
{ key: 'plannedLoadingAt', prop: 'plannedLoadingAt', label: '预计装箱时间', minWidth: 170 },
{ key: 'plannedLoadingAt', label: '预计装箱时间', minWidth: 170, formatter: (row: TruckAppointmentResource) => formatDateTime(row.plannedLoadingAt) },
{ key: 'payableAmount', label: '应付拖车费', minWidth: 130, formatter: (row: TruckAppointmentResource) => row.payableAmount ? `¥${row.payableAmount}` : '—' },
{ key: 'receivableAmount', label: '应收拖车费', minWidth: 130, formatter: (row: TruckAppointmentResource) => row.receivableAmount ? `¥${row.receivableAmount}` : '—' },
{ key: 'status', label: '状态', width: 130, slotName: 'status' },
{ key: 'attachments', label: '附件', width: 90, formatter: (row: TruckAppointmentResource) => String(row.attachments?.length || 0) },
]
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function statusLabel(value: unknown) {
return typeof value === 'string' && value in truckAppointmentStatusLabels
? truckAppointmentStatusLabels[value as TruckAppointmentStatus]
@@ -44,25 +45,29 @@ function reset() { filters.value = { keyword: '', status: '', containerBookingId
onMounted(load)
</script>
<template>
<section class="page-shell">
<div class="page-heading"><div><h1>拖车预约</h1><p>维护拖车预约、关联资源与派车状态</p></div><div class="heading-actions"><el-button v-if="access.can('platform:firstleg:truck-appointment:create')" type="primary" @click="router.push({ name: 'trucking-new' })"><Plus :size="16" />新增预约</el-button></div></div>
<LoadErrorState v-if="loadError" title="拖车预约加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<QueryToolbar v-model="filters" ariaLabel="拖车预约查询" :fields="fields" @search="search" @reset="reset" />
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="trucking-appointments">
<template #relation="{ row }"><div>{{ row.customerName || '—' }}</div><small>出仓单 {{ row.outboundOrderId || '—' }} · 柜体 {{ row.containerBookingId || '—' }}</small></template>
<section class="business-page trucking-page">
<div class="page-heading"><div><h1>拖车预约</h1><p>维护拖车预约、关联资源与派车状态</p></div></div>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="拖车预约查询" :fields="fields" preference-key="trucking-query" @search="search" @reset="reset" />
<LoadErrorState v-if="loadError" class="surface-panel" title="拖车预约加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<section class="surface-panel table-panel" :aria-busy="loading">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="trucking-appointments" aria-label="拖车预约列表" empty-text="暂无符合筛选条件的拖车预约">
<template #toolbar-start><div class="table-heading"><h2>拖车预约列表</h2><span>共 {{ total }} 条</span></div></template>
<template #toolbar-actions><el-button v-if="access.can('platform:firstleg:truck-appointment:create')" type="primary" :disabled="loading" @click="router.push({ name: 'trucking-new' })"><Plus :size="16" />新增拖车</el-button></template>
<template #relation="{ row }"><div>{{ row.customerName || '—' }}</div><small>{{ row.entrustingPartyName || '委托单位待关联' }} · 出仓单 {{ row.outboundOrderId || '—' }} · 柜体 {{ row.containerBookingId || '—' }}</small><small>柜型 {{ row.relatedContainerType || '由关联资源返回' }}</small></template>
<template #company="{ row }">{{ row.carrierName || row.carrierId || '—' }}</template>
<template #driver="{ row }">{{ row.driverName || '—' }}<small>{{ row.vehicleNo || '—' }}</small></template>
<template #store="{ row }">{{ row.storeName || '—' }}</template>
<template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
<template #actions="{ row }"><el-button text :aria-label="`查看 ${row.appointmentNo || row.id}`" @click="router.push({ name: 'trucking-detail', params: { id: String(row.id) } })"><Eye :size="16" />查看</el-button><el-button v-if="access.can('platform:firstleg:truck-appointment:edit')" text :aria-label="`编辑 ${row.appointmentNo || row.id}`" @click="router.push({ name: 'trucking-detail', params: { id: String(row.id) }, query: { edit: '1' } })"><Pencil :size="16" />编辑</el-button></template>
</DataTable>
</section>
<div v-if="!loading && loaded && rows.length === 0 && !loadError" class="empty-hint"><RefreshCw :size="16" />暂无拖车预约</div>
</section>
</template>
<style scoped>
.page-shell { min-width: 0; padding: 24px; }
.trucking-page { min-width: 0; padding: 24px; }
.page-heading { display:flex; justify-content:space-between; gap:16px; align-items:flex-start; margin-bottom:18px; flex-wrap:wrap; }
h1 { margin:0; font-size:24px; }.page-heading p { margin:6px 0 0; color:var(--el-text-color-secondary); }.heading-actions { display:flex; gap:8px; flex-wrap:wrap; }.heading-actions :deep(.el-button) { margin:0; }.heading-actions :deep(svg) { vertical-align:middle; margin-right:5px; }.empty-hint { display:flex; justify-content:center; gap:8px; color:var(--el-text-color-secondary); padding:24px; }
h1 { margin:0; font-size:24px; }.page-heading p { margin:6px 0 0; color:var(--el-text-color-secondary); }.surface-panel { margin-bottom:16px; }.table-panel { overflow:hidden; }.table-heading { display:flex; align-items:baseline; gap:10px; }.table-heading h2 { margin:0; font-size:16px; }.table-heading span { color:var(--el-text-color-secondary); font-size:13px; }.empty-hint { display:flex; justify-content:center; gap:8px; color:var(--el-text-color-secondary); padding:24px; }
small { display:block; color:var(--el-text-color-secondary); margin-top:3px; }
@media (max-width:600px) { .page-shell { padding:16px 12px; } h1 { font-size:20px; } }
@media (max-width:600px) { .trucking-page { padding:16px 12px; } h1 { font-size:20px; } }
</style>