87 lines
8.9 KiB
Vue
87 lines
8.9 KiB
Vue
<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>
|