Files
admin-web/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue
T
2026-10-21 15:10:54 +08:00

303 lines
29 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 '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import * as api from '../api/dispatches'
import { useDispatchCandidates } from '../useDispatchCandidates'
import type { Candidate, ConvertedDraft, ConvertDraft, CreateDispatch, DispatchDraft, DraftSelection, VersionCommand } from '../dispatchContracts'
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups } from '../dispatchContracts'
import { dispatchActionPermissions } from '../dispatchPresentation'
import { useDispatchCommandRecovery, validateCurrentDispatch, isDefiniteDispatchRejection } from '../dispatchCommandRecovery'
import type { InboundOrderListItem } from '../types'
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
import SmartDispatchExistingCargo from './SmartDispatchExistingCargo.vue'
import type { SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
const props = defineProps<{ modelValue: boolean; orders: InboundOrderListItem[] }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [] }>()
const router = useRouter(), auth = useAuthStore(), access = useAccessPreviewStore()
const recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
const owner = auth.user?.id || '', storageKey = `dispatch-draft-command:${owner}`
type DraftCommand = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'UNLOCK' | 'DELETE' | 'RELEASE' | 'CONVERT'; body: Record<string, unknown>; containerId?: string; priorContainerIds?: string[]; attempted: boolean; rejected: boolean; result?: ConvertedDraft }
const pending = ref<DraftCommand | null>(null)
if (owner && sessionStorage.getItem(storageKey)) pending.value = JSON.parse(sessionStorage.getItem(storageKey)!) as DraftCommand
const workspace = ref<'create' | 'existing'>('create'), existingBusy = ref(false)
const settingsExpanded = ref(false)
const mode = ref<'cargo' | 'empty'>('cargo'), warehouseId = ref(''), warehouses = ref<PlatformWarehouseResource[]>([])
const draft = ref<DispatchDraft | null>(null), drafts = ref<DispatchDraft[]>([]), activeContainerId = ref(''), capacity = ref(30)
const working = ref(false), saving = ref(false), preparing = ref(false), error = ref<ApiErrorDetails | null>(null)
const candidatePage = useDispatchCandidates(error)
const { candidates, page, size, total } = candidatePage
const loading = computed(() => working.value || candidatePage.loading.value)
const query = reactive({ orderNo: '', destinationWarehouseCode: '', sourceStage: '' })
const warehouseTab = ref<SmartDispatchWarehouseTab>('ALL')
const settings = reactive<Record<string, { form: { plannedDepartureAt: string; estimatedArrivalAt: string; remark: string } }>>({})
const currentSettings = computed(() => {
const key = mode.value === 'empty' ? 'empty' : activeContainerId.value || 'unselected'
settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '' } }
return settings[key]!
})
const form = computed(() => currentSettings.value.form)
const frozen = computed(() => !canCreate.value || preparing.value || saving.value || Boolean(pending.value) || Boolean(recovery.pending.value))
const canCreate = computed(() => access.can(dispatchActionPermissions.CREATE))
const canEditCargo = computed(() => access.can(dispatchActionPermissions.ADD_CARGO))
const workspaceFrozen = computed(() => loading.value || preparing.value || saving.value || existingBusy.value || Boolean(pending.value) || Boolean(recovery.pending.value))
const planIds = computed(() => [...new Set(props.orders.map(row => row.planId).filter(Boolean))])
const currentContainer = computed(() => draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value))
const populatedGroupCount = computed(() => draft.value?.containers.filter(row => row.sourceKeys.length).length || 0)
const confirmDisabled = computed(() => preparing.value || loading.value || !canCreate.value || (!pending.value && !recovery.pending.value && (mode.value === 'empty' ? !warehouseId.value : !populatedGroupCount.value)))
function assignedGroup(row: Candidate) {
return draft.value?.containers.find(group => group.sourceKeys.some(key => row.selectionGroup.sources.some(source => source.sourceKey === key)))
}
async function ensureFirstVehicle(sequence: number): Promise<string | null> {
if (!draft.value && !await draftAction('CREATE')) return null
if (sequence !== generation || !props.modelValue || !draft.value) return null
if (!draft.value.containers.length && !await draftAction('CONTAINER')) return null
if (sequence !== generation || !props.modelValue) return null
return currentContainer.value?.draftContainerId || null
}
async function addCargo(source: Candidate, targetContainerId = activeContainerId.value) {
if (frozen.value || loading.value || !warehouseId.value || assignedGroup(source)) return
const sequence = generation, target = targetContainerId
preparing.value = true
try {
requireSelectionGroup(source); candidatePage.assertCurrent([source])
const containerId = target || await ensureFirstVehicle(sequence)
if (!containerId || sequence !== generation || !props.modelValue) return
if (!draft.value?.containers.some(row => row.draftContainerId === containerId)) throw new DispatchInputError('所选车辆已变化,请重新选择')
activeContainerId.value = containerId
await draftAction('LOCK', source, containerId)
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '加入货物失败,请核对后重试') }
finally { preparing.value = false }
}
async function addVehicle() {
if (frozen.value || loading.value || !warehouseId.value) return
const sequence = generation
preparing.value = true
try {
if (!await ensureFirstVehicle(sequence)) return
if (sequence === generation && props.modelValue) await draftAction('CONTAINER')
} finally { preparing.value = false }
}
function changeWarehouse() { candidatePage.reset(); warehouseTab.value = 'ALL'; void search() }
function changeTab(tab: SmartDispatchWarehouseTab) { warehouseTab.value = tab; void search() }
function changePage(value: number) { page.value = value; void loadCandidates() }
function changeSize(value: number) { size.value = value; void search() }
let generation = 0
let candidateFilterKey = ''
function persist() {
if (!owner || owner !== auth.user?.id) throw new DispatchInputError('登录身份已变化,请重新进入排车列表')
if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey)
}
async function init() {
const sequence = ++generation
working.value = true; error.value = null
try {
if (recovery.pending.value && ['ADD_CARGO', 'REMOVE_CARGO'].includes(recovery.pending.value.action)) workspace.value = 'existing'
else if (pending.value || recovery.pending.value?.action === 'CREATE') workspace.value = 'create'
else if (!canCreate.value && canEditCargo.value) workspace.value = 'existing'
if (workspace.value === 'existing' || !canCreate.value) return
if (recovery.pending.value?.action === 'CREATE') { mode.value = 'empty'; warehouseId.value = String(recovery.pending.value.body.warehouseId || '') }
const [directory, saved] = await Promise.all([listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }), api.listDispatchDrafts()])
if (sequence !== generation) return
warehouses.value = directory; drafts.value = saved
if (pending.value?.body.draftToken) draft.value = saved.find(row => row.draftToken === pending.value!.body.draftToken) || null
if (draft.value) warehouseId.value = draft.value.warehouseId
if (!warehouseId.value) {
const selectedWarehouses = new Set(props.orders.map(row => row.originWarehouseId).filter(Boolean))
if (selectedWarehouses.size === 1 && directory.some(row => String(row.id) === [...selectedWarehouses][0])) warehouseId.value = [...selectedWarehouses][0]!
else if (directory[0]) warehouseId.value = String(directory[0].id)
}
if (warehouseId.value) await loadCandidates(sequence)
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车候选或草稿恢复失败,请保留原选择重试') }
finally { if (sequence === generation) working.value = false }
}
function switchWorkspace(value: 'create' | 'existing') {
if (workspaceFrozen.value || value === workspace.value || (value === 'create' ? !canCreate.value : !canEditCargo.value)) return
workspace.value = value
existingBusy.value = false
candidatePage.reset()
void init()
}
async function loadCandidates(sequence = generation) {
if (sequence !== generation) return
const filterKey = JSON.stringify([warehouseId.value, query, warehouseTab.value, planIds.value, draft.value?.draftToken])
if (filterKey !== candidateFilterKey) { page.value = 1; candidateFilterKey = filterKey }
await candidatePage.load({ warehouseId: warehouseId.value, page: page.value, size: size.value, warehouseTab: warehouseTab.value, orderNo: query.orderNo || undefined, destinationWarehouseCode: query.destinationWarehouseCode || undefined, sourceStage: query.sourceStage || undefined, ...(query.sourceStage !== 'RELABEL' && planIds.value.length ? { planIds: planIds.value } : {}), draftToken: draft.value?.draftToken })
}
function resetSearch() { if (frozen.value) return; query.orderNo = ''; query.destinationWarehouseCode = ''; query.sourceStage = ''; warehouseTab.value = 'ALL'; void search() }
async function search() { if (frozen.value) return; page.value = 1; await loadCandidates() }
function restoreDraft(token: string) { if (frozen.value) return; const value = drafts.value.find(row => row.draftToken === token); if (!value) return; draft.value = value; warehouseId.value = value.warehouseId; activeContainerId.value = value.containers[0]?.draftContainerId || ''; void search() }
function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
candidatePage.assertCurrent(cargo)
requireBatchVersions(cargo.flatMap(row => row.appointmentBatches))
if (cargo.some(row => row.warehouseId !== warehouseId.value || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('货物仓库、来源版本或预约客户不一致,请重新读取')
const config = containerId ? settings[containerId] : currentSettings.value
return { idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
}
async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string): Promise<boolean> {
if (!canCreate.value || loading.value || saving.value) return false
error.value = null
try {
if (!pending.value) {
if (!warehouseId.value) throw new DispatchInputError('请选择真实起运仓')
const key = crypto.randomUUID(), current = draft.value
let body: object
if (action === 'CREATE') body = { warehouseId: warehouseId.value, idempotencyKey: key }
else {
if (!current) throw new DispatchInputError('请先新建或恢复草稿')
const common = { recordVersion: requireVersion(current.recordVersion), idempotencyKey: key, draftToken: current.draftToken }
if (action === 'CONTAINER') body = { ...common, capacity: capacity.value }
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId) throw new DispatchInputError('请选择货物和装车容器'); if (action === 'LOCK') { requireSelectionGroup(source); candidatePage.assertCurrent([source]) } body = { ...common, draftContainerId: containerId, source: source.selection } }
else if (action === 'DELETE') body = { ...common, reason: '移除本草稿装车容器' }
else if (action === 'RELEASE') body = common
else {
const groups = current.containers.filter(row => row.sourceKeys.length).map(row => {
const cargo = row.sourceKeys.map(keyValue => current.sources.find(value => value.sourceKey === keyValue))
if (cargo.some(rowValue => !rowValue)) throw new DispatchInputError('草稿来源尚未恢复完整,请重新读取')
return { draftContainerId: row.draftContainerId, dispatch: creation(cargo as Candidate[], row.draftContainerId) }
})
if (!groups.length) throw new DispatchInputError('请至少选择一组真实货物;空单请使用先建空单')
body = { ...common, groups } satisfies ConvertDraft
}
}
pending.value = { action, body: JSON.parse(JSON.stringify(body)) as Record<string, unknown>, containerId, ...(action === 'CONTAINER' ? { priorContainerIds: current!.containers.map(row => row.draftContainerId) } : {}), attempted: false, rejected: false }; persist()
}
await retryDraft()
return true
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '草稿操作结果未确认,原请求已保留'); return false }
}
async function retryDraft() {
if (!pending.value || saving.value) return
const command = pending.value, previousAttempt = command.attempted
saving.value = true; command.attempted = true; persist()
try {
const body = command.body
let result: DispatchDraft | ConvertedDraft
switch (command.action) {
case 'CREATE': result = await api.createDispatchDraft(body as unknown as { warehouseId: string; idempotencyKey: string }); break
case 'CONTAINER': result = await api.createDispatchDraftContainer(body as unknown as VersionCommand & { draftToken: string; capacity: number }); break
case 'LOCK': result = await api.lockDispatchDraftPallet(body as unknown as DraftSelection); break
case 'UNLOCK': result = await api.unlockDispatchDraftPallet(body as unknown as DraftSelection); break
case 'RELEASE': result = await api.releaseDispatchDraft(body as unknown as VersionCommand & { draftToken: string }); break
case 'DELETE': result = await api.deleteDispatchDraftContainer(String(body.draftToken), command.containerId!, body as unknown as VersionCommand & { reason: string }); break
case 'CONVERT': result = command.result || await api.convertDispatchDraft(body as unknown as ConvertDraft); break
}
if (body.draftToken && result.draftToken !== body.draftToken) throw new DispatchInputError('草稿结果与原请求不一致')
if (command.action === 'CONVERT') {
const converted = result as ConvertedDraft, expected = (body as unknown as ConvertDraft).groups
if (converted.vehicles.length !== expected.length || new Set(converted.vehicles.map(row => row.dispatch.dispatchNo)).size !== expected.length || expected.some(group => !converted.vehicles.some(row => row.draftContainerId === group.draftContainerId && row.dispatch.action === 'CREATE' && row.dispatch.idempotencyKey === group.dispatch.idempotencyKey && Boolean(row.dispatch.dispatchNo)))) throw new DispatchInputError('整车转换结果与原草稿容器不一致,请核对原请求')
command.result = converted; persist()
await Promise.all(converted.vehicles.map(async row => validateCurrentDispatch(await api.getDispatch(row.dispatch.dispatchNo), row.dispatch)))
pending.value = null; persist(); emit('created'); emit('update:modelValue', false); draft.value = null; ElMessage.success('整车排车单已创建'); await router.push({ name: 'overseas-dispatches' }); return
}
requireVersion((result as DispatchDraft).recordVersion)
let addedContainerId: string | undefined
if (command.action === 'CONTAINER') {
const updated = result as DispatchDraft, previous = command.priorContainerIds
const added = previous && updated.containers.filter(row => !previous.includes(row.draftContainerId))
if (!previous || !added || added.length !== 1 || added[0]!.capacity !== body.capacity || updated.containers.length !== previous.length + 1 || previous.some(id => !updated.containers.some(row => row.draftContainerId === id))) throw new DispatchInputError('新增车辆结果尚未核对,请保留原请求重试')
addedContainerId = added[0]!.draftContainerId
}
draft.value = command.action === 'RELEASE' ? null : result as DispatchDraft
activeContainerId.value = addedContainerId || draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value)?.draftContainerId || draft.value?.containers[0]?.draftContainerId || ''
// No new source mutation before the original response has been validated.
pending.value = null; persist()
if (command.action !== 'CREATE' && command.action !== 'CONTAINER') await loadCandidates()
} catch (cause) { if (!previousAttempt && !command.result && isDefiniteDispatchRejection(cause)) command.rejected = true; persist(); throw cause }
finally { saving.value = false }
}
async function submit() {
if (workspace.value === 'existing') return
if (!canCreate.value || preparing.value || saving.value || loading.value) return
error.value = null
try {
if (pending.value) { await retryDraft(); return }
if (recovery.pending.value && recovery.pending.value.action !== 'CREATE') throw new DispatchInputError('另一次排车操作尚待核对,请从列表的原请求恢复入口继续')
if (!recovery.pending.value && mode.value === 'cargo') { await draftAction('CONVERT'); return }
if (!recovery.pending.value) { if (!warehouseId.value) throw new DispatchInputError('请选择真实起运仓'); recovery.begin('', 'CREATE', creation([])) }
const result = await recovery.execute(command => api.createDispatch(command.body as unknown as CreateDispatch), api.getDispatch, api.getDispatchCommand)
if (result) { emit('created'); emit('update:modelValue', false); ElMessage.success(`空排车单 ${result.dispatchNo} 已创建`) }
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '建单结果尚未核对,请使用原请求重试') }
}
watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
</script>
<template>
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="loading" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<div class="density-status-tabs" role="tablist" aria-label="智能排车工作方式">
<button v-if="canCreate" type="button" role="tab" :aria-selected="workspace === 'create'" :class="{ 'is-active': workspace === 'create' }" :disabled="workspaceFrozen" @click="switchWorkspace('create')">新建排车</button>
<button v-if="canEditCargo" type="button" role="tab" :aria-selected="workspace === 'existing'" :class="{ 'is-active': workspace === 'existing' }" :disabled="workspaceFrozen" @click="switchWorkspace('existing')">调整已有排车货物</button>
</div>
<SmartDispatchExistingCargo v-if="workspace === 'existing' && modelValue" @saved="emit('created')" @busy="existingBusy = $event" />
<template v-else-if="workspace === 'create'">
<div class="density-dialog-sections dispatch-workspace">
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="pending || recovery.pending.value ? submit() : init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="pending || recovery.pending.value" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
<el-button v-if="pending?.rejected" :disabled="saving || loading" @click="pending = null; persist(); init()">按明确驳回结果重新核对</el-button>
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
<div class="dispatch-candidate-query">
<el-select class="dispatch-warehouse-picker" aria-label="起运仓" v-model="warehouseId" :disabled="frozen || loading || Boolean(draft)" placeholder="请选择起运仓" @change="changeWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select>
<template v-if="mode === 'cargo'"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="在途预排(已离港)" value="PLAN" /><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button type="primary" :disabled="frozen || loading || !warehouseId" @click="search">查询</el-button><el-button :disabled="frozen || loading || !warehouseId" @click="resetSearch">重置</el-button></template><el-button :aria-expanded="settingsExpanded" aria-controls="smart-dispatch-settings" @click="settingsExpanded = !settingsExpanded">排车设置</el-button></div>
</section>
<section v-show="settingsExpanded" id="smart-dispatch-settings" class="dispatch-context-toolbar" aria-label="排车设置">
<el-form inline label-position="left" label-width="70px" class="density-form dispatch-setup-fields" :disabled="frozen || loading">
<el-form-item label="建单方式"><el-radio-group v-model="mode" :disabled="frozen || loading"><el-radio value="cargo">先选货再建单</el-radio><el-radio value="empty">先建空单后加货</el-radio></el-radio-group></el-form-item>
</el-form>
<div v-if="mode === 'cargo'" class="dispatch-draft-tools density-actions" aria-label="车辆选货安排">
<el-select class="dispatch-draft-picker" :model-value="draft?.draftToken" :disabled="frozen || loading" placeholder="继续待完成排车" aria-label="继续待完成排车" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="(warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实') + ' · ' + saved.containers.length + '辆车 · ' + saved.sources.length + '条货物'" /></el-select>
<el-button v-if="draft" :disabled="frozen || loading" @click="draftAction('RELEASE')">放弃本次安排</el-button>
<InlineHelp title="选货与草稿说明"><p>点击左侧订单查看托盘,将托盘拖入右侧车辆,或选择车辆后点击加入。添加车辆会保留已选货物;每辆有货的车生成一张排车单,同仓可混装多个客户和目的地。未生成的安排暂存并占用货物,可恢复或放弃。车辆和司机在建单后维护。</p></InlineHelp>
</div>
</section>
<el-alert v-if="mode === 'empty'" title="空单暂不能核平、装车或发车。" type="info" :closable="false" show-icon />
<template v-if="mode === 'cargo'">
<div class="warehouse-tabs density-actions" role="tablist" aria-label="候选仓库展示"><el-button v-for="tab in ([{ value: 'ALL', label: '全美' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :class="{ active: warehouseTab === tab.value }" :type="warehouseTab === tab.value ? 'primary' : undefined" :disabled="frozen || loading" @click="changeTab(tab.value)">{{ tab.label }}</el-button></div>
<SmartDispatchCargoBoard :candidates="candidates" :draft="draft" :active-container-id="activeContainerId" :capacity="capacity" :disabled="frozen || loading" :warehouse-selected="Boolean(warehouseId)" :page="page" :size="size" :total="total"
@add="addCargo" @select-container="activeContainerId = $event" @add-vehicle="addVehicle" @remove-cargo="(source, id) => draftAction('UNLOCK', source, id)" @remove-container="id => draftAction('DELETE', undefined, id)" @change-page="changePage" @change-size="changeSize">
<template #vehicle-controls>
<label class="dispatch-capacity-field"><span>托位</span><el-select aria-label="下一辆车计划托位" class="dispatch-capacity-picker" v-model="capacity" :disabled="frozen || loading" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select></label>
</template>
</SmartDispatchCargoBoard>
</template>
<el-form v-if="mode === 'empty' || currentContainer" label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="frozen || loading">
<section class="density-dialog-section" aria-label="排车计划时间与备注">
<h3 class="density-dialog-section__title">{{ mode === 'empty' ? '计划时间与备注' : '第 ' + currentContainer!.sequenceNo + ' 辆车 · 计划时间与备注' }}<small>选填</small><InlineHelp title="计划时间与备注说明"><p>时间和备注分别保存在当前选中的排车安排中。车辆和司机在生成排车单后,通过“维护车辆信息”补充。</p></InlineHelp></h3>
<table class="density-table density-table--editable dispatch-create-table"><tbody>
<tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="可先保存,审核前补齐" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间(选填)" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" /></el-form-item></td></tr>
</tbody></table>
</section>
</el-form>
</div>
</template>
</AppDialog>
</template>
<style scoped>
.dispatch-create-table { table-layout: fixed; }
.dispatch-create-table td:not([colspan]) { width: 50%; }
.dispatch-context-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-6); }
.dispatch-setup-fields { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-4) var(--admin-space-6); }
.dispatch-setup-fields :deep(.el-form-item) { margin: 0; }
.dispatch-draft-tools { flex-wrap: wrap; align-items: center; }
.dispatch-draft-picker { width: 240px; }
.dispatch-capacity-field { display: inline-flex; align-items: center; gap: var(--admin-space-3); margin: 0; }
.dispatch-capacity-picker { width: 92px; }
.dispatch-candidate-query { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.dispatch-candidate-query > .el-input { width: 180px; }
.dispatch-candidate-query > .el-select { width: 170px; }
.dispatch-candidate-query > .dispatch-warehouse-picker { width: 200px; }
.dispatch-candidate-query > .el-button { margin-left: 0; }
.dispatch-workspace { margin-top: var(--admin-space-5); }
</style>