42 lines
7.7 KiB
Vue
42 lines
7.7 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
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 { 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 })
|
|
const history = ref<TruckAppointmentStatusHistory[]>([])
|
|
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')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit'))
|
|
async function loadRelated() { if (!resource.value) return; const [attachments, histories] = await Promise.all([listTruckAppointmentAttachments(id.value), listTruckAppointmentHistory(id.value)]); resource.value.attachments = attachments; history.value = histories }
|
|
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 }); await loadRelated() } 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 } }
|
|
onMounted(load)
|
|
</script>
|
|
<template>
|
|
<main class="business-page truck-detail-page"><div class="detail-actions"><el-button @click="router.push({ name: 'trucking' })">返回列表</el-button></div>
|
|
<LoadErrorState v-if="error && !resource" class="surface-panel" title="详情加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
|
<template v-else-if="resource"><section class="surface-panel status-panel"><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" /></section><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>{{ item.action }}</strong><span>{{ item.fromStatus || '—' }} → {{ item.toStatus || '—' }}</span><small>{{ item.operatorName || item.operatorId || '系统' }} · {{ 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>
|
|
</main>
|
|
</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; }
|
|
.truck-detail-page { min-width:0; }.detail-actions { display:flex; justify-content:flex-end; }.status-panel { padding:12px 16px; }.form-panel { padding:24px; }.history-section { padding:20px; }.history-section h3 { margin:0 0 12px; font-size:15px; }.history-section ol { display:grid; gap:12px; 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:10px 12px; border-radius:6px; background:var(--el-fill-color-lighter); }.history-section small { color:var(--cc-muted); overflow-wrap:anywhere; }
|
|
@media (max-width: 720px) { .form-panel { padding:16px; } }
|
|
@media (max-width: 640px) { .history-section { padding:16px; }.history-section li { grid-template-columns:1fr; gap:4px; } }
|
|
</style>
|