Files
admin-web/src/modules/overseas-master-data/components/TripPreparationDialog.vue
T

87 lines
8.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listTrips, prepareTrip, type TripPreparation } from '../api/trips'
import { listVehicles } from '../api/overseasMasterData'
import { listDrivers } from '../api/drivers'
import type { FleetTrip, TripDetail } from '../tripTypes'
import type { VehicleDetail } from '../types'
import type { DriverDetail } from '../driverTypes'
import { useFleetDraftGuard } from '../useFleetDraftGuard'
import CapacityOfferSettingsDialog from './CapacityOfferSettingsDialog.vue'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ warehouseId: string; warehouseName: string; dispatchNos: string[] }>()
const emit = defineEmits<{ saved: [value: TripDetail] }>()
const access = useAccessPreviewStore(), router = useRouter()
const form = reactive({ mode: 'existing', tripNo: '', vehicleId: '', driverId: '', departure: '', end: '' })
const trips = ref<FleetTrip[]>([]), vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([])
const busy = ref(false), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const submittedSummary = ref<{ warehouseName: string; dispatchNos: string[] } | null>(null)
const pending = ref<TripPreparation | null>(null), result = ref<TripDetail | null>(null), publishOpen = ref(false)
const canCreate = computed(() => access.can('platform:fleet:trip:create') && access.can('platform:fleet:trip:assign'))
const guard = useFleetDraftGuard('trip-preparation', open, form, busy, computed(() => !!pending.value))
let generation = 0, searchSequence = 0
watch(open, async visible => {
generation++; searchSequence++
if (!visible || pending.value) return
Object.assign(form, { mode: 'existing', tripNo: '', vehicleId: '', driverId: '', departure: '', end: '' })
result.value = null; trips.value = []; vehicles.value = []; drivers.value = []; error.value = null
await nextTick(); guard.capture()
})
async function search(kind: 'trip' | 'vehicle' | 'driver', keyword = '') {
const current = ++searchSequence; loading.value = true; error.value = null
try {
if (kind === 'trip') { const value = await listTrips({ page: 1, size: 100, warehouseId: props.warehouseId, status: 'PLANNED', keyword: keyword || undefined }); if (current === searchSequence) trips.value = value.records }
if (kind === 'vehicle') { const value = await listVehicles({ page: 1, size: 100, region: 'OVERSEAS', status: 'IN_SERVICE', keyword: keyword || undefined }); if (current === searchSequence) vehicles.value = value.records }
if (kind === 'driver') { const value = await listDrivers({ page: 1, size: 100, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: form.end || undefined, keyword: keyword || undefined }); if (current === searchSequence) drivers.value = value.records }
} catch (cause) { if (current === searchSequence) error.value = getApiErrorDetails(cause, '候选读取失败') }
finally { if (current === searchSequence) loading.value = false }
}
async function save() {
if (busy.value || !access.can('platform:fleet:trip:edit')) return
if (!pending.value) {
const creating = form.mode === 'new', selected = trips.value.find(t => t.tripNo === form.tripNo)
if (!props.warehouseId || !props.dispatchNos.length) { ElMessage.warning('请选择同仓待运排车单'); return }
if (creating ? !canCreate.value || !form.vehicleId || !form.driverId || !form.departure || !form.end : !selected) { ElMessage.warning('请完整选择车次或新车次资源和时间'); return }
submittedSummary.value = { warehouseName: props.warehouseName, dispatchNos: [...props.dispatchNos] }
const key = crypto.randomUUID()
pending.value = { tripNo: creating ? null : selected!.tripNo, recordVersion: creating ? null : selected!.recordVersion,
vehicleId: creating ? form.vehicleId : null, driverId: creating ? form.driverId : null, dispatchNos: [...props.dispatchNos], idempotencyKey: key,
plan: creating ? { warehouseId: props.warehouseId, plannedDepartureAt: form.departure, expectedDriverEndAt: form.end, stops: [{ name: '起运仓' }, { name: '待确定卸货点' }], idempotencyKey: key } : null }
}
const current = generation; busy.value = true; error.value = null
try {
const value = await prepareTrip(pending.value)
if (current === generation) { result.value = value; pending.value = null; guard.capture(); emit('saved', value); ElMessage.success('已安排车次,请核对容量后发布余位') }
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '安排车次未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
finally { if (current === generation) busy.value = false }
}
onBeforeUnmount(() => { generation++; searchSequence++ })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog" title="安排车次" width="1080px" :before-close="guard.beforeClose" :show-confirm="!result" :confirm-text="pending ? '重试原请求' : '确认安排'" :confirm-loading="busy" @confirm="save">
<LoadErrorState v-if="error" title="安排未完成" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p>{{ pending ? submittedSummary?.warehouseName : warehouseName }} · 已选 {{ (pending ? submittedSummary?.dispatchNos : dispatchNos)?.length }} 张排车单:{{ (pending ? submittedSummary?.dispatchNos : dispatchNos)?.join('、') }}</p>
<p v-if="pending">提交结果尚未确定,请保留原请求重试,不另建车次。</p>
<template v-if="result"><p>已安排到 {{ result.trip.tripNo }} · {{ result.trip.stops.map(s => s.name).join(' → ') }}</p><p>多目的地请先在车次中确认行驶顺序,再核对容量发布余位。安排成功不代表已审核或发车。</p><el-button v-if="access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: result.trip.tripNo } })">查看车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:list')" type="primary" @click="publishOpen = true">核对容量并发布余位</el-button></template>
<el-form v-else label-position="top" class="density-form preparation-form" :disabled="busy || !!pending">
<el-form-item label="安排方式"><el-radio-group v-model="form.mode"><el-radio value="existing">加入已有车次</el-radio><el-radio v-if="canCreate" value="new">新建车次</el-radio></el-radio-group></el-form-item>
<el-form-item v-if="form.mode === 'existing'" label="本仓计划中车次" required><el-select v-model="form.tripNo" filterable remote :remote-method="(k: string) => search('trip', k)" :loading="loading" placeholder="按车次、车牌或司机搜索" @focus="search('trip')"><el-option v-for="t in trips" :key="t.tripNo" :value="t.tripNo" :label="`${t.tripNo} · ${t.vehiclePlate || '待分配车辆'} · ${t.stops.map(s => s.name).join(' → ')}`" /></el-select></el-form-item>
<template v-else>
<el-form-item label="计划发车时间" required><el-date-picker v-model="form.departure" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="司机预计任务结束" required><el-date-picker v-model="form.end" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /></el-form-item>
<el-form-item label="车辆" required><el-select v-model="form.vehicleId" placeholder="按车牌搜索" filterable remote :remote-method="(k: string) => search('vehicle', k)" :loading="loading" @focus="search('vehicle')"><el-option v-for="v in vehicles" :key="v.id" :value="v.id" :label="`${v.plateNumber} · ${v.maxPalletSpaces ?? '待核对'}托位`" /></el-select></el-form-item>
<el-form-item label="司机" required><el-select v-model="form.driverId" placeholder="按姓名搜索" filterable remote :remote-method="(k: string) => search('driver', k)" :loading="loading" @focus="search('driver')"><el-option v-for="d in drivers" :key="d.id" :value="d.id" :label="d.details.name" /></el-select></el-form-item>
</template>
</el-form>
</AppDialog>
<CapacityOfferSettingsDialog v-if="result" v-model="publishOpen" :trip="result.trip" @trip-updated="result.trip = $event" />
</template>
<style scoped>.preparation-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.preparation-form :deep(.el-select),.preparation-form :deep(.el-date-editor){width:100%}</style>