136 lines
12 KiB
Vue
136 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { useRouter } from 'vue-router'
|
||
import TripLoadingPanel from './TripLoadingPanel.vue'
|
||
import { getTrip, getTripLoading, updateTripPlan } from '../api/trips'
|
||
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 { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { listCapacityOffers, saveCapacityOffer, commandCapacityOffer } from '../api/capacityOffers'
|
||
import { offerStatusLabels, type CapacityOffer, type OfferWrite, type OfferCommand } from '../capacityOfferTypes'
|
||
import type { FleetTrip } from '../tripTypes'
|
||
import { useFleetDraftGuard } from '../useFleetDraftGuard'
|
||
const open = defineModel<boolean>({ default: false })
|
||
const props = defineProps<{ trip: FleetTrip }>()
|
||
const emit = defineEmits<{ saved: [version: number]; tripUpdated: [trip: FleetTrip] }>()
|
||
const access = useAccessPreviewStore(), offer = ref<CapacityOffer | null>(null), loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||
const loaded = ref(false), publishAfterSave = ref(false)
|
||
const router = useRouter()
|
||
const currentTrip = ref<FleetTrip>(props.trip)
|
||
const planner = ref<InstanceType<typeof TripLoadingPanel> | null>(null)
|
||
const planState = ref('')
|
||
let planInitialized = false
|
||
const canPlan = computed(() => access.can('platform:fleet:trip:edit'))
|
||
const form = reactive({ cutoffAt: '', contactName: '', contactPhone: '', remark: '' })
|
||
const pending = ref<{ action: 'save'; offerNo: string | null; body: OfferWrite } | { action: 'publish' | 'pause' | 'resume' | 'close'; offerNo: string; body: OfferCommand } | null>(null)
|
||
let generation = 0
|
||
const writable = computed(() => access.can('platform:fleet:capacity-offer:manage'))
|
||
const guard = useFleetDraftGuard('capacity-offer-settings', open, reactive({ form, planState }), busy, computed(() => !!pending.value))
|
||
async function load() {
|
||
if (!writable.value || !open.value || pending.value) return
|
||
const current = ++generation; loading.value = true; error.value = null; loaded.value = false
|
||
try {
|
||
const [value, detail] = await Promise.all([listCapacityOffers({ scope: 'ALL', tripNo: props.trip.tripNo, page: 1, size: 1 }), getTrip(props.trip.tripNo)])
|
||
if (current !== generation) return
|
||
currentTrip.value = detail.trip
|
||
offer.value = value.records[0] || null
|
||
planInitialized = false; planState.value = ''
|
||
Object.assign(form, { cutoffAt: offer.value?.cutoffAt || detail.trip.plannedDepartureAt, contactName: offer.value?.contactName || '', contactPhone: offer.value?.contactPhone || '', remark: offer.value?.remark || '' })
|
||
loaded.value = true; await nextTick(); guard.capture()
|
||
}
|
||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '发布设置读取失败') }
|
||
finally { if (current === generation) loading.value = false }
|
||
}
|
||
watch(open, value => { generation++; if (value) { if (pending.value) return; loaded.value = false; void load() } }, { immediate: true })
|
||
async function execute() {
|
||
if (!pending.value || busy.value || !writable.value) return
|
||
const current = generation; busy.value = true; error.value = null
|
||
try {
|
||
while (pending.value) {
|
||
const p: NonNullable<typeof pending.value> = pending.value
|
||
const result: Awaited<ReturnType<typeof saveCapacityOffer>> = p.action === 'save' ? await saveCapacityOffer(p.offerNo, p.body) : await commandCapacityOffer(p.offerNo, p.action, p.body)
|
||
if (current !== generation) return
|
||
offer.value = result.offer
|
||
currentTrip.value = { ...currentTrip.value, recordVersion: result.offer.tripRecordVersion }
|
||
emit('saved', result.offer.tripRecordVersion)
|
||
pending.value = null
|
||
if (p.action === 'save' && publishAfterSave.value) {
|
||
pending.value = { action: result.offer.status === 'DRAFT' ? 'publish' : 'resume', offerNo: result.offer.offerNo,
|
||
body: { tripRecordVersion: result.offer.tripRecordVersion, offerRecordVersion: result.offer.recordVersion, idempotencyKey: crypto.randomUUID() } }
|
||
}
|
||
}
|
||
guard.capture()
|
||
if (publishAfterSave.value) { publishAfterSave.value = false; ElMessage.success('拼车余位已发布,可在需求池登记意向') }
|
||
else 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 }
|
||
}
|
||
async function matchExistingStops() {
|
||
if (busy.value || !canPlan.value || pending.value) return
|
||
busy.value = true; error.value = null
|
||
try {
|
||
const cargo = await getTripLoading(currentTrip.value.tripNo)
|
||
const stops = currentTrip.value.stops
|
||
const next = stops.length === 2 && !stops[1]?.destinationKey && stops[1]?.name.startsWith('待确定卸货点') ? [stops[0]!] : [...stops]
|
||
for (const item of cargo.cargo) {
|
||
if (!item.destinationKey) throw new Error('排车货物目的地未确认,请先完善排车单')
|
||
if (!next.some(stop => stop.destinationKey === item.destinationKey)) next.push({ name: item.destinationLabel || '卸货站', destinationKey: item.destinationKey })
|
||
}
|
||
const result = await updateTripPlan(currentTrip.value.tripNo, { recordVersion: cargo.recordVersion, plannedDepartureAt: currentTrip.value.plannedDepartureAt, expectedDriverEndAt: currentTrip.value.expectedDriverEndAt, stops: next, remark: currentTrip.value.remark, reason: '发布前按货物匹配卸货站' })
|
||
currentTrip.value = result.trip; emit('tripUpdated', result.trip); emit('saved', result.trip.recordVersion)
|
||
if (offer.value) offer.value = { ...offer.value, tripRecordVersion: result.trip.recordVersion }
|
||
} catch (cause) { error.value = getApiErrorDetails(cause, '卸货站匹配未完成') }
|
||
finally { busy.value = false }
|
||
}
|
||
function planChanged(value: string) {
|
||
planState.value = value
|
||
if (!planInitialized) { planInitialized = true; void nextTick(() => guard.capture()) }
|
||
}
|
||
async function save() {
|
||
if (busy.value) return
|
||
if (pending.value) { await execute(); return }
|
||
if (loading.value || !loaded.value || !writable.value || offer.value && !offer.value.actions.canEdit) return
|
||
if (!form.cutoffAt) { ElMessage.warning('请填写交货截止时间'); return }
|
||
if (new Date(form.cutoffAt).getTime() <= Date.now() || new Date(form.cutoffAt).getTime() > new Date(currentTrip.value.plannedDepartureAt).getTime()) { ElMessage.warning('交货截止须晚于现在且不晚于计划发车'); return }
|
||
busy.value = true
|
||
let plan
|
||
try { plan = await planner.value?.save() } finally { busy.value = false }
|
||
if (!plan) return
|
||
currentTrip.value = { ...currentTrip.value, recordVersion: plan.recordVersion }
|
||
emit('saved', plan.recordVersion)
|
||
publishAfterSave.value = true
|
||
pending.value = { action: 'save', offerNo: offer.value?.offerNo || null, body: { tripNo: currentTrip.value.tripNo, tripRecordVersion: currentTrip.value.recordVersion, offerRecordVersion: offer.value?.recordVersion ?? null, cutoffAt: form.cutoffAt, contactName: form.contactName.trim() || null, contactPhone: form.contactPhone.trim() || null, remark: form.remark.trim() || null, idempotencyKey: crypto.randomUUID() } }
|
||
await execute()
|
||
}
|
||
async function command(action: 'publish' | 'pause' | 'resume' | 'close') {
|
||
if (!offer.value || loading.value || !loaded.value || busy.value || pending.value || !writable.value) return
|
||
if (guard.dirty.value) { ElMessage.warning('有未保存修改,请先确认发布或关闭后重新打开再操作'); return }
|
||
publishAfterSave.value = false
|
||
const labels = { publish: '发布运力', pause: '暂停接单', resume: '恢复接单', close: '关闭发布' }
|
||
try { await ElMessageBox.confirm(`${labels[action]} ${offer.value.offerNo}?已确认预订及客户订单不会被取消。`, labels[action], { customClass: 'admin-density-confirm' }) } catch { return }
|
||
pending.value = { action, offerNo: offer.value.offerNo, body: { tripRecordVersion: offer.value.tripRecordVersion, offerRecordVersion: offer.value.recordVersion, idempotencyKey: crypto.randomUUID() } }; await execute()
|
||
}
|
||
onBeforeUnmount(() => { generation++ })
|
||
</script>
|
||
<template><AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" title="发布拼车余位" width="1080px" :show-confirm="writable && (!offer || offer.actions.canEdit || !!pending)" :confirm-text="pending ? '重试原请求' : '确认发布'" :confirm-loading="busy" :confirm-disabled="loading || !loaded" @confirm="save">
|
||
<LoadErrorState v-if="error" title="发布操作未完成" :message="error.message" @retry="pending ? execute() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||
<p>{{ trip.tripNo }} · {{ offer ? `${offer.offerNo} · ${offerStatusLabels[offer.status]}` : '尚未建立发布' }}</p><p v-if="offer?.blockReason && offer.status !== 'DRAFT'">{{ offer.blockReason }}</p><p v-if="pending">上次结果尚未确定,原请求已保留,请重试核对。</p>
|
||
<template v-if="loaded && open && (!offer || offer.actions.canEdit)">
|
||
<h3>本车货物与余位</h3>
|
||
<p>卸货站已按货物匹配。核对下方数据,确认发布时一并保存容量;多个目的地的先后顺序请在车次计划中确认。</p>
|
||
<el-button v-if="canPlan && planner?.destinationIssue" :disabled="busy || !!pending" @click="matchExistingStops">按货物补齐卸货站</el-button>
|
||
<TripLoadingPanel ref="planner" :trip="currentTrip" :editable="canPlan && !busy && !pending" planning embedded @changed="planChanged" />
|
||
</template>
|
||
<p v-if="!canPlan">已有有效容量可直接发布;需要修改容量时请联系有车次编辑权限的调度。</p>
|
||
<el-button v-if="offer?.status === 'OPEN'" :disabled="busy || !!pending" type="primary" link @click="open = false; router.push({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: offer.offerNo } })">查看需求池发布</el-button>
|
||
<el-form v-loading="loading" label-position="top" class="density-form offer-settings-form" :disabled="busy || loading || !!pending || !writable || !!offer && !offer.actions.canEdit">
|
||
<el-form-item label="交货截止时间" required><el-date-picker v-model="form.cutoffAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="选择交货截止时间" /><span>默认与计划发车时间一致,可提前截止。</span></el-form-item><details class="offer-settings-wide"><summary>联系人与交货说明(选填)</summary><div class="offer-settings-form"><el-form-item label="公开给销售的调度联系人"><el-input v-model="form.contactName" maxlength="128" placeholder="填写公开给销售的联系人(选填)" /></el-form-item><el-form-item label="公开联系方式"><el-input v-model="form.contactPhone" maxlength="128" placeholder="填写公开电话或其他联系方式(选填)" /></el-form-item><el-form-item label="公开说明" class="offer-settings-wide"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" placeholder="补充交货要求或运力说明(选填)" /></el-form-item></div></details>
|
||
</el-form>
|
||
<div v-if="offer && writable" class="density-actions"><el-button v-if="offer.actions.canPause" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" @click="command('pause')">暂停接单</el-button><el-button v-if="offer.actions.canClose" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="danger" plain @click="command('close')">关闭发布</el-button></div>
|
||
</AppDialog></template>
|
||
<style scoped>.offer-settings-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.offer-settings-wide{grid-column:1/-1}.offer-settings-form :deep(.el-date-editor){width:100%}</style>
|