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

136 lines
12 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, 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>