This commit is contained in:
2026-10-21 16:40:10 +08:00
parent 8356e98ce3
commit e2f99b4f4b
18 changed files with 391 additions and 52 deletions
@@ -286,7 +286,8 @@ function optional(value: string | null) { return value?.trim() || null }
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] } function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' } function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' }
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' } function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
function defaultArrivalDate(row: ContainerWorkbenchRow) { function defaultArrivalDate(source: object) {
const row = source as ContainerWorkbenchRow
const arrival = arrivalTimes.value[row.id] const arrival = arrivalTimes.value[row.id]
const projected = projectWarehouseArrival(row.actualFacts?.arrivedDestinationPortAt || row.actualPortArrivalAt || row.estimatedPortArrivalAt, const projected = projectWarehouseArrival(row.actualFacts?.arrivedDestinationPortAt || row.actualPortArrivalAt || row.estimatedPortArrivalAt,
arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined) arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
@@ -1,5 +1,17 @@
# 海外仓原型核对矩阵 # 海外仓原型核对矩阵
## 2026-10-21 智能排车区域入口替代仓库搜索
用户明确删除查询区海外仓选择,仅保留“全部/洛杉矶/纽约”。界面移除顶部仓库框,区域筛选直接提交服务端;候选 warehouseId 不传时跨仓数据库分页,首托确定草稿仓并继续同仓安排,已有排车/预约仍显式指定仓库。空单模式在排车设置中保留起运仓,避免无来源时猜测仓库。正式业务边界维护于《排车列表统一作业改造契约》的区域入口确认段。
自动检查:前端7文件134项中旧区域断言调整后,相关3文件57项复验通过,其余77项结果复用;排车样式合同通过,应用类型检查通过。后端3个精确测试类10项通过,覆盖区域HTTP参数、服务校验、Mapper统计/分页范围及OpenAPI;未运行全量回归。未部署、未访问测试环境数据;需Admin与Lastleg同步发布,视觉与真实HTTP待验收。
## 2026-10-21 智能排车全屏空间利用
依据用户截图 `codex-clipboard-df3ac2ac-0fa1-4e30-98e6-e508074c0d66.png` 与 `codex-clipboard-4833c2b8-d6a3-4e34-bb5c-0099a3fc8fcf.png`:区域按钮并入待排订单标题行,移除中栏订单号 65% 宽度上限;全屏新建选货模式沿弹框可用空间分配三栏高度,待排表格取消原 390px/40vh 上限,分页位于下方,三栏各自按内容滚动。退出全屏、空单与已有排车沿用各自布局。计划时间及备注迁移至列表尚处于用户要求的梳理阶段,本轮未删除其填写区;现有列表“维护车辆信息”已支持三字段,计划发车审核前补齐。
自动检查:受影响布局、选货及排车样式合同共 41 项通过,69 项无关样式用例未执行,未跑全量;先前样式合同整文件运行另发现 DispatchesView 的取货车辆弹框缺少密度类,两项失败与本轮改动无关,未扩散修改。差异检查通过;全屏实际高度及滚动待人工截图确认。
## 2026-10-21 智能排车查询与三栏统一对齐 ## 2026-10-21 智能排车查询与三栏统一对齐
用户确认保留蓝白配色,按最初第二张原型恢复区块与控件位置;最新截图 `codex-clipboard-40f3fcdc-8093-4fd1-8b0e-bd1abb147b3e.png` 确认已有排车查询散落、三栏错位,上轮不构成视觉验收通过。 用户确认保留蓝白配色,按最初第二张原型恢复区块与控件位置;最新截图 `codex-clipboard-40f3fcdc-8093-4fd1-8b0e-bd1abb147b3e.png` 确认已有排车查询散落、三栏错位,上轮不构成视觉验收通过。
@@ -0,0 +1,25 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type FuelSlot = 'FUEL_START' | 'FUEL_END'
export interface VehiclePhoto { slot: FuelSlot; fileId: string; fileName: string; contentType: string; savedAt: string | null }
export interface VehicleDocuments { dispatchNo: string; recordVersion: string; editable: boolean; photos: VehiclePhoto[] }
export interface BindVehiclePhoto { recordVersion: string; idempotencyKey: string; fileId: string }
const path = (no: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(no)}/vehicle-documents`
export async function getVehicleDocuments(no: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<VehicleDocuments>, ApiEnvelope<VehicleDocuments>>(path(no)))
}
export async function uploadVehiclePhoto(no: string, slot: FuelSlot, file: File, key: string) {
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
return unwrapApiResponse(await http.post<ApiEnvelope<VehiclePhoto>, ApiEnvelope<VehiclePhoto>>(`${path(no)}/${slot}/uploads`, form))
}
export async function recoverVehiclePhoto(no: string, key: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<VehiclePhoto>, ApiEnvelope<VehiclePhoto>>(`${path(no)}/uploads/${encodeURIComponent(key)}`))
}
export async function bindVehiclePhoto(no: string, slot: FuelSlot, body: BindVehiclePhoto) {
return unwrapApiResponse(await http.put<ApiEnvelope<VehicleDocuments>, ApiEnvelope<VehicleDocuments>>(`${path(no)}/${slot}`, body))
}
export async function readVehiclePhoto(no: string, photo: VehiclePhoto) {
const blob = await http.get<Blob, Blob>(`${path(no)}/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob' })
if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || !blob.size) throw new Error('图片读取失败,请重新核对')
return blob
}
@@ -12,7 +12,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions' import { overseasWarehousePermissions as permission } from '../permissions'
import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, uploadDeliveryDocument, downloadDeliveryDocument, type DeliveryDocuments, type DeliveryDocumentCategory, type DeliveryDocumentFile } from '../api/deliveryDocuments' import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, uploadDeliveryDocument, downloadDeliveryDocument, type DeliveryDocuments, type DeliveryDocumentCategory, type DeliveryDocumentFile } from '../api/deliveryDocuments'
const props = defineProps<{ deliveryNo: string; customerId: string }>() const props = defineProps<{ deliveryNo: string; customerId: string; embedded?: boolean; categories?: DeliveryDocumentCategory[] }>()
const open = defineModel<boolean>({ default: false }) const open = defineModel<boolean>({ default: false })
const emit = defineEmits<{ saved: [] }>() const emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore() const access = useAccessPreviewStore()
@@ -29,6 +29,9 @@ const conflict = ref(false)
const canEdit = computed(() => access.can(permission.deliveryDocuments) && Boolean(snapshot.value?.allowedActions.includes('MANAGE_DOCUMENTS'))) const canEdit = computed(() => access.can(permission.deliveryDocuments) && Boolean(snapshot.value?.allowedActions.includes('MANAGE_DOCUMENTS')))
const busy = computed(() => saving.value || uploading.value) const busy = computed(() => saving.value || uploading.value)
const uploadedCount = computed(() => draft.value.filter(item => item.files.length > 0).length) const uploadedCount = computed(() => draft.value.filter(item => item.files.length > 0).length)
const visibleGroups = computed(() => draft.value.filter(item => !props.categories || props.categories.includes(item.category)))
const dirty = computed(() => Boolean(snapshot.value) && (bolNo.value !== (snapshot.value?.bolNo || '') || draft.value.some(group => JSON.stringify(group.files.map(file => file.fileId)) !== JSON.stringify(snapshot.value?.categories.find(item => item.category === group.category)?.files.map(file => file.fileId) || []))))
defineExpose({ busy, dirty, canEdit, loading, conflict, save })
let sequence = 0 let sequence = 0
function isCurrent(current: number, deliveryNo: string, customerId: string) { return current === sequence && open.value && props.deliveryNo === deliveryNo && props.customerId === customerId } function isCurrent(current: number, deliveryNo: string, customerId: string) { return current === sequence && open.value && props.deliveryNo === deliveryNo && props.customerId === customerId }
let attempt: { body: string; key: string } | null = null let attempt: { body: string; key: string } | null = null
@@ -97,23 +100,27 @@ async function save() {
saving.value = true; error.value = null saving.value = true; error.value = null
const current = sequence; const { deliveryNo, customerId } = props const current = sequence; const { deliveryNo, customerId } = props
try { try {
await saveDeliveryDocuments(deliveryNo, customerId, { ...body, idempotencyKey: attempt.key }) const result = await saveDeliveryDocuments(deliveryNo, customerId, { ...body, idempotencyKey: attempt.key })
if (!isCurrent(current, deliveryNo, customerId)) return if (!isCurrent(current, deliveryNo, customerId)) return
const hasReceipt = body.documents.some(item => item.category === 'RECEIPT' && item.fileIds.length > 0) const hasReceipt = body.documents.some(item => item.category === 'RECEIPT' && item.fileIds.length > 0)
open.value = false; emit('saved'); ElMessage.success(hasReceipt ? '签收文件已保存,派送单已自动完成' : '派送资料已保存') if (props.embedded) {
snapshot.value = result; bolNo.value = result.bolNo || ''; attempt = null
draft.value = deliveryDocumentCategories.map(item => ({ ...item, files: (result.categories.find(group => group.category === item.category)?.files || []).map(file => ({ ...file })) }))
} else open.value = false
emit('saved'); ElMessage.success(hasReceipt ? '签收文件已保存,派送单已自动完成' : '派送资料已保存')
} catch (cause) { if (isCurrent(current, deliveryNo, customerId)) { error.value = getApiErrorDetails(cause, '派送资料保存失败'); conflict.value = error.value.code === 'A0409' } } } catch (cause) { if (isCurrent(current, deliveryNo, customerId)) { error.value = getApiErrorDetails(cause, '派送资料保存失败'); conflict.value = error.value.code === 'A0409' } }
finally { if (current === sequence) saving.value = false } finally { if (current === sequence) saving.value = false }
} }
</script> </script>
<template> <template>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="open" :title="'派送单所需资料 · ' + (deliveryNo || '—')" width="min(1040px, calc(100vw - 40px))" fullscreenable confirm-text="保存资料" cancel-text="关闭" :loading="loading" :show-confirm="canEdit" :confirm-loading="busy || loading" :confirm-disabled="conflict || loading" @confirm="save"> <component :is="embedded ? 'div' : AppDialog" class="admin-density-dialog admin-workbench is-dialog" v-model="open" :title="embedded ? undefined : '派送单所需资料 · ' + (deliveryNo || '—')" width="min(1040px, calc(100vw - 40px))" fullscreenable confirm-text="保存资料" cancel-text="关闭" :loading="loading" :show-confirm="canEdit" :confirm-loading="busy || loading" :confirm-disabled="conflict || loading" @confirm="save">
<div class="density-dialog-sections"> <div class="density-dialog-sections">
<LoadErrorState v-if="error" compact title="派送资料操作失败" :message="error.message" :loading="busy || loading" :show-retry="!snapshot || conflict" @retry="reload"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState> <LoadErrorState v-if="error" compact title="派送资料操作失败" :message="error.message" :loading="busy || loading" :show-retry="!snapshot || conflict" @retry="reload"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<section class="density-dialog-section" data-tone="summary" aria-label="派送资料摘要"><h3 class="density-dialog-section__title">资料核对</h3><table class="density-table"><tbody><tr><th scope="row">派送单号</th><td>{{ deliveryNo || '—' }}</td><th scope="row">资料类别</th><td>{{ snapshot ? uploadedCount + '/3 已上传' : loading ? '正在读取…' : '资料未返回' }}</td></tr></tbody></table><p v-if="snapshot" class="density-dialog-note">{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</p></section> <section class="density-dialog-section" data-tone="summary" aria-label="派送资料摘要"><h3 class="density-dialog-section__title">资料核对</h3><table class="density-table"><tbody><tr><th scope="row">派送单号</th><td>{{ deliveryNo || '—' }}</td><th scope="row">资料类别</th><td>{{ snapshot ? uploadedCount + '/3 已上传' : loading ? '正在读取…' : '资料未返回' }}</td></tr></tbody></table><p v-if="snapshot" class="density-dialog-note">{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</p></section>
<template v-if="snapshot"> <template v-if="snapshot">
<section class="density-dialog-section" data-tone="work" aria-label="BOL资料登记"><h3 class="density-dialog-section__title">BOL资料</h3><el-form class="density-form" label-position="left" label-width="80px" :disabled="busy || conflict || loading"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="BOL号"><el-input v-if="canEdit" v-model="bolNo" :disabled="busy || conflict || loading" /><span v-else>{{ bolNo || '—' }}</span></el-form-item></td></tr></tbody></table></el-form></section> <section v-if="!categories || categories.includes('BOL')" class="density-dialog-section" data-tone="work" aria-label="BOL资料登记"><h3 class="density-dialog-section__title">BOL资料</h3><el-form class="density-form" label-position="left" label-width="80px" :disabled="busy || conflict || loading"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="BOL号"><el-input v-if="canEdit" v-model="bolNo" :disabled="busy || conflict || loading" /><span v-else>{{ bolNo || '—' }}</span></el-form-item></td></tr></tbody></table></el-form></section>
<section v-for="group in draft" :key="group.category" class="document-group density-dialog-section" data-tone="source" :data-category="group.category"> <section v-for="group in visibleGroups" :key="group.category" class="document-group density-dialog-section" data-tone="source" :data-category="group.category">
<header><h3 class="density-dialog-section__title">{{ group.label }}</h3><el-tag :type="group.files.length ? 'success' : 'info'">{{ group.files.length ? '已上传' : '待上传' }}</el-tag><span>{{ group.files.length }}/20</span><el-upload v-if="canEdit" :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict || group.files.length >= 20" :on-change="(file: UploadFile) => upload(group.category, file)"><el-button class="density-action" data-tone="primary" :disabled="busy || conflict || group.files.length >= 20">上传</el-button></el-upload></header> <header><h3 class="density-dialog-section__title">{{ group.label }}</h3><el-tag :type="group.files.length ? 'success' : 'info'">{{ group.files.length ? '已上传' : '待上传' }}</el-tag><span>{{ group.files.length }}/20</span><el-upload v-if="canEdit" :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict || group.files.length >= 20" :on-change="(file: UploadFile) => upload(group.category, file)"><el-button class="density-action" data-tone="primary" :disabled="busy || conflict || group.files.length >= 20">上传</el-button></el-upload></header>
<p v-if="!group.files.length" class="density-dialog-note">尚未上传文件</p> <p v-if="!group.files.length" class="density-dialog-note">尚未上传文件</p>
<table v-else class="density-table document-files"><thead><tr><th class="document-file-actions">操作</th><th>文件名称</th><th class="document-file-time">上传时间</th><th class="document-file-state">保存状态</th></tr></thead><tbody> <table v-else class="density-table document-files"><thead><tr><th class="document-file-actions">操作</th><th>文件名称</th><th class="document-file-time">上传时间</th><th class="document-file-state">保存状态</th></tr></thead><tbody>
@@ -125,7 +132,7 @@ async function save() {
</section> </section>
</template> </template>
</div> </div>
</AppDialog> </component>
</template> </template>
<style scoped> <style scoped>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, ref, watch } from 'vue'
import { ElMessageBox } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { getDispatch } from '../api/dispatches'
import type { DispatchDetail } from '../dispatchContracts'
import { dispatchReviewAttachmentFields, dispatchReviewEvidence } from '../dispatchPresentation'
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
import DispatchVehicleDocuments from './DispatchVehicleDocuments.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ dispatchNo: string }>()
const emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore()
const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const tab = ref('RECEIPT'), selected = ref(''), fuelVisited = ref(false)
const editor = ref<InstanceType<typeof DeliveryDocumentsDialog>>(), vehicleEditor = ref<InstanceType<typeof DispatchVehicleDocuments>>()
const deliveries = computed(() => detail.value?.documents.deliveries || [])
const delivery = computed(() => deliveries.value.find(item => item.deliveryNo === selected.value))
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
const busy = computed(() => Boolean(editor.value?.busy || vehicleEditor.value?.busy))
let sequence = 0
async function load() {
const current = ++sequence; loading.value = true; error.value = null
try {
const value = await getDispatch(props.dispatchNo)
if (current !== sequence || !open.value) return
if (value.dispatchNo !== props.dispatchNo) throw new Error('资料与当前排车单不一致')
if (value.status !== 'ACTIVE' || !['DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('仅派送中和配送完成可管理资料')
detail.value = value
if (!value.documents.deliveries.some(item => item.deliveryNo === selected.value)) selected.value = value.documents.deliveries[0]?.deliveryNo || ''
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '排车资料读取失败') }
finally { if (current === sequence) loading.value = false }
}
async function canLeave() {
if (busy.value) return false
if (!editor.value?.dirty) return true
try { await ElMessageBox.confirm('有尚未保存的派送资料,是否放弃修改?', '未保存资料', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '放弃修改', cancelButtonText: '继续编辑' }); return true } catch { return false }
}
async function selectDelivery(value: string) { if (value !== selected.value && await canLeave()) selected.value = value }
async function beforeClose(done: () => void) { if (await canLeave()) done() }
function selectTab(value: string) { if (!busy.value) { tab.value = value; if (value === 'FUEL') fuelVisited.value = true } }
function saved() { emit('saved') }
watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
sequence++; detail.value = null; selected.value = ''; tab.value = 'RECEIPT'; fuelVisited.value = false; error.value = null
if (visible && props.dispatchNo) void load()
}, { immediate: true })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="'管理资料 · ' + (dispatchNo || '—')" width="min(1100px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="busy" :before-close="beforeClose" :show-confirm="tab !== 'FUEL' && Boolean(editor?.canEdit)" :confirm-disabled="loading || editor?.loading || editor?.conflict" confirm-text="保存资料" cancel-text="关闭" @confirm="editor?.save()">
<LoadErrorState v-if="error" compact title="排车资料读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-if="detail">
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in [{ key: 'RECEIPT', label: '签收文件' }, { key: 'FUEL', label: '油耗图片' }, { key: 'LOADING', label: '装车资料' }]" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
<div v-show="tab !== 'FUEL'">
<el-form class="density-form" label-position="left" label-width="80px"><el-form-item label="派送单"><el-select :model-value="selected" :disabled="busy || !access.can(permission.deliveryList)" aria-label="选择派送单" @update:model-value="selectDelivery"><el-option v-for="item in deliveries" :key="item.deliveryNo" :value="item.deliveryNo" :label="item.deliveryNo" /></el-select></el-form-item></el-form>
<DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="tab === 'RECEIPT' ? ['RECEIPT'] : ['LOADING_PHOTO', 'BOL']" @saved="saved" />
<p v-else>{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}</p>
</div>
<DispatchVehicleDocuments v-if="fuelVisited" v-show="tab === 'FUEL'" ref="vehicleEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="saved" />
<section v-if="tab === 'LOADING'" class="density-dialog-section"><h3 class="density-dialog-section__title">车辆及司机附件</h3><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></section>
</template>
</AppDialog>
</template>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { overseasWarehousePermissions as permission } from '../permissions'
import { bindVehiclePhoto, getVehicleDocuments, readVehiclePhoto, recoverVehiclePhoto, uploadVehiclePhoto, type BindVehiclePhoto, type FuelSlot, type VehicleDocuments, type VehiclePhoto } from '../api/dispatchVehicleDocuments'
const props = defineProps<{ dispatchNo: string }>()
const auth = useAuthStore(), access = useAccessPreviewStore()
const emit = defineEmits<{ saved: [] }>()
const snapshot = ref<VehicleDocuments | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
interface Pending { slot: FuelSlot; key: string; photo?: VehiclePhoto; binding?: BindVehiclePhoto }
const pending = ref<Pending | null>(null), preview = ref(''), corrupted = ref(false)
const actor = auth.user?.id || '', number = props.dispatchNo
const storageKey = `dispatch-vehicle-documents:${actor}:${number}`
const editable = computed(() => !corrupted.value && access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
const roles: Array<{ slot: FuelSlot; label: string }> = [{ slot: 'FUEL_START', label: '起始油耗表盘' }, { slot: 'FUEL_END', label: '结束油耗表盘' }]
let active = true
function ensureScope() { if (!active || auth.user?.id !== actor || props.dispatchNo !== number) throw new Error('资料操作身份已变化,请重新打开') }
function persist() { if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
async function load() {
busy.value = true; error.value = null
try { const result = await getVehicleDocuments(number); ensureScope(); if (result.dispatchNo !== number) throw new Error('车辆资料与排车单不一致'); snapshot.value = result }
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '车辆资料读取失败') }
finally { if (active) busy.value = false }
}
async function bind() {
const item = pending.value
if (!item?.photo || !snapshot.value) return
if (item.photo.slot !== item.slot) throw new Error('上传结果与图片分类不一致')
if (!item.binding) { item.binding = { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: item.photo.fileId }; persist() }
const result = await bindVehiclePhoto(number, item.slot, item.binding)
ensureScope(); snapshot.value = result; pending.value = null; persist(); emit('saved'); ElMessage.success('油耗图片已保存')
}
async function recover() {
if (busy.value || !editable.value || !pending.value) return
busy.value = true; error.value = null
try { ensureScope(); if (!pending.value.photo) { pending.value.photo = await recoverVehiclePhoto(number, pending.value.key); ensureScope(); persist() }; await bind() }
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '原上传或保存结果尚未确认') }
finally { if (active) busy.value = false }
}
async function upload(slot: FuelSlot, event: Event) {
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
if (!file || busy.value || pending.value || !editable.value) return
if (!['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
busy.value = true; error.value = null
try {
ensureScope(); pending.value = { slot, key: crypto.randomUUID() }; persist()
pending.value.photo = await uploadVehiclePhoto(number, slot, file, pending.value.key); ensureScope(); persist(); await bind()
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '图片上传或保存失败,请核对原请求') }
finally { if (active) busy.value = false }
}
async function view(photo: VehiclePhoto) {
if (busy.value) return
busy.value = true; error.value = null
try { const blob = await readVehiclePhoto(number, photo); ensureScope(); if (preview.value) URL.revokeObjectURL(preview.value); preview.value = URL.createObjectURL(blob) }
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '图片读取失败') }
finally { if (active) busy.value = false }
}
onMounted(() => {
try {
const raw = sessionStorage.getItem(storageKey)
if (raw) { const value = JSON.parse(raw) as Pending; if (!roles.some(role => role.slot === value.slot) || typeof value.key !== 'string' || !value.key) throw new Error('原图片请求记录无法读取'); pending.value = value }
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '原图片请求记录无法读取') }
if (!corrupted.value) void load()
})
onBeforeUnmount(() => { active = false; if (preview.value) URL.revokeObjectURL(preview.value) })
defineExpose({ busy })
</script>
<template>
<div v-loading="busy" class="density-dialog-sections">
<LoadErrorState v-if="error" compact title="车辆资料操作失败" :message="error.message" :show-retry="!pending && !corrupted" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="pending" role="status">有图片尚未确认保存。<el-button :disabled="busy || !editable" @click="recover">核对原请求并继续保存</el-button></div>
<section v-for="role in roles" :key="role.slot" class="density-dialog-section">
<h3 class="density-dialog-section__title">{{ role.label }}</h3>
<div v-for="photo in snapshot?.photos.filter(item => item.slot === role.slot) || []" :key="photo.fileId"><el-button link :disabled="busy" @click="view(photo)">{{ photo.fileName }}</el-button></div>
<span v-if="snapshot && !snapshot.photos.some(item => item.slot === role.slot)">未上传</span>
<label v-if="editable" class="photo-upload">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="role.label" :disabled="busy || Boolean(pending)" @change="upload(role.slot, $event)" /></label>
</section>
<img v-if="preview" :src="preview" alt="油耗图片预览" class="photo-preview" />
</div>
</template>
<style scoped>
.photo-upload { display: block; margin-top: var(--admin-space-3); }
.photo-upload input { margin-left: var(--admin-space-3); }
.photo-preview { display: block; max-width: 100%; max-height: 400px; object-fit: contain; }
</style>
@@ -11,6 +11,7 @@ const props = defineProps<{
capacity: number capacity: number
disabled: boolean disabled: boolean
warehouseSelected: boolean warehouseSelected: boolean
addVehicleDisabled?: boolean
page: number page: number
size: number size: number
total: number total: number
@@ -161,7 +162,7 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
<template> <template>
<div class="dispatch-cargo-board" aria-label="智能排车三栏选货"> <div class="dispatch-cargo-board" aria-label="智能排车三栏选货">
<section class="cargo-board-panel cargo-board-orders" aria-label="待排订单"> <section class="cargo-board-panel cargo-board-orders" aria-label="待排订单">
<header class="cargo-board-heading"><h3>待排订单</h3><span class="cargo-board-muted">本页 {{ orders.length }} 单</span></header> <header class="cargo-board-heading"><h3>待排订单</h3><span class="cargo-board-muted">本页 {{ orders.length }} 单</span><div v-if="slots['order-controls']" class="cargo-board-order-tools"><slot name="order-controls" /></div></header>
<div class="cargo-board-order-content"> <div class="cargo-board-order-content">
<div class="cargo-board-table-wrap"> <div class="cargo-board-table-wrap">
<table class="density-table cargo-board-table"> <table class="density-table cargo-board-table">
@@ -204,7 +205,7 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
</section> </section>
<section class="cargo-board-panel cargo-board-vehicles" aria-label="车辆装载"> <section class="cargo-board-panel cargo-board-vehicles" aria-label="车辆装载">
<header class="cargo-board-heading"><slot name="vehicle-heading"><template v-if="!slots.vehicles"><h3>装车托盘</h3><InlineHelp title="车辆装载说明"><p>将中间托盘拖入任意车辆,或选中车辆后点击托盘号。已装货物需先移除才能加入其他车辆。计划托位仅用于新车辆预排,不代表真实载重或容积;体积、重量以装车核对为准。</p></InlineHelp><div class="cargo-board-vehicle-tools"><slot name="vehicle-controls" /><el-button type="primary" :disabled="disabled || !warehouseSelected" @click="emit('addVehicle')">添加车辆</el-button></div></template></slot></header> <header class="cargo-board-heading"><slot name="vehicle-heading"><template v-if="!slots.vehicles"><h3>装车托盘</h3><InlineHelp title="车辆装载说明"><p>将中间托盘拖入任意车辆,或选中车辆后点击托盘号。已装货物需先移除才能加入其他车辆。计划托位仅用于新车辆预排,不代表真实载重或容积;体积、重量以装车核对为准。</p></InlineHelp><div class="cargo-board-vehicle-tools"><slot name="vehicle-controls" /><el-button type="primary" :disabled="disabled || !warehouseSelected || addVehicleDisabled" @click="emit('addVehicle')">添加车辆</el-button></div></template></slot></header>
<div class="cargo-board-vehicle-content"> <div class="cargo-board-vehicle-content">
<slot name="vehicles" :drag-over="dragOver" :drop="drop" :drop-target="dropTarget"> <slot name="vehicles" :drag-over="dragOver" :drop="drop" :drop-target="dropTarget">
<div class="cargo-board-vehicle-list"> <div class="cargo-board-vehicle-list">
@@ -250,7 +251,10 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
.cargo-board-table tr.is-selected td { background: var(--admin-action-primary-hover); } .cargo-board-table tr.is-selected td { background: var(--admin-action-primary-hover); }
.cargo-board-business-no { font-weight: 600; } .cargo-board-business-no { font-weight: 600; }
.cargo-board-pagination { display: flex; align-items: center; padding-top: var(--admin-space-5); overflow-x: auto; } .cargo-board-pagination { display: flex; align-items: center; padding-top: var(--admin-space-5); overflow-x: auto; }
.cargo-board-order-caption { margin-left: auto; max-width: 65%; overflow-wrap: anywhere; color: var(--cc-muted); font-size: var(--admin-font-caption); } .cargo-board-pallets > .cargo-board-heading { flex-wrap: nowrap; }
.cargo-board-pallets > .cargo-board-heading h3 { flex-shrink: 0; }
.cargo-board-order-tools { margin-left: auto; }
.cargo-board-order-caption { margin-left: auto; min-width: 0; text-align: right; overflow-wrap: anywhere; color: var(--cc-muted); font-size: var(--admin-font-caption); }
.cargo-board-order-caption small { display: block; } .cargo-board-order-caption small { display: block; }
.cargo-board-empty { padding: var(--admin-space-7); color: var(--cc-muted); text-align: center; margin: 0; } .cargo-board-empty { padding: var(--admin-space-7); color: var(--cc-muted); text-align: center; margin: 0; }
.cargo-board-pallet-list { max-height: min(500px, 48vh); overflow-y: auto; border: 1px solid var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); } .cargo-board-pallet-list { max-height: min(500px, 48vh); overflow-y: auto; border: 1px solid var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); }
@@ -47,31 +47,54 @@ beforeEach(() => {
async function render() { const wrapper = mount(SmartDispatchDialog, { attachTo: document.body, props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } } async function render() { const wrapper = mount(SmartDispatchDialog, { attachTo: document.body, props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
function view(dialog: VueWrapper) { return dialog.getComponent({ name: 'ElDialogContent' }) } function view(dialog: VueWrapper) { return dialog.getComponent({ name: 'ElDialogContent' }) }
function field(dialog: VueWrapper, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! } function field(dialog: VueWrapper, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
async function selectWarehouse(dialog: VueWrapper) { const select = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择起运仓')!; select.vm.$emit('update:modelValue', '321'); select.vm.$emit('change', '321'); await flushPromises() } async function selectWarehouse(dialog: VueWrapper) {
const select = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择起运仓')
if (select) { select.vm.$emit('update:modelValue', '321'); select.vm.$emit('change', '321') }
await flushPromises()
}
async function emptyMode(dialog: VueWrapper) { field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'empty'); await flushPromises() } async function emptyMode(dialog: VueWrapper) { field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'empty'); await flushPromises() }
async function restore(dialog: VueWrapper) { dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '继续待完成排车')!.vm.$emit('change', 'server-token'); await flushPromises() } async function restore(dialog: VueWrapper) { dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '继续待完成排车')!.vm.$emit('change', 'server-token'); await flushPromises() }
async function selectFirstOrder(dialog: VueWrapper) { await view(dialog).get('[aria-label="待排订单"] tbody tr button').trigger('click'); await flushPromises() } async function selectFirstOrder(dialog: VueWrapper) { await view(dialog).get('[aria-label="待排订单"] tbody tr button').trigger('click'); await flushPromises() }
describe('new dispatch native layout', () => { describe('new dispatch native layout', () => {
it('selects the first warehouse and loads its cargo immediately without creating a draft', async () => { it('opens all regional cargo without a warehouse picker or a silently selected warehouse', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse, { ...warehouse, id: '654', warehouseName: '纽约仓' }]) vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse, { ...warehouse, id: '654', warehouseCode: 'NYC', warehouseName: '纽约仓' }])
const { dialog } = await render() const { dialog } = await render()
const picker = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择起运仓')! expect(view(dialog).get('[aria-label="候选货物查询"]').find('[aria-label="起运仓"]').exists()).toBe(false)
expect(picker.props('modelValue')).toBe('321') expect(api.listDispatchCandidates).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: undefined, warehouseTab: 'ALL', page: 1 }))
expect(api.listDispatchCandidates).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: '321', page: 1 })) expect(view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').map(button => button.text())).toEqual(['全部', '洛杉矶', '纽约'])
expect(api.createDispatchDraft).not.toHaveBeenCalled() expect(api.createDispatchDraft).not.toHaveBeenCalled()
}) })
it('keeps the warehouse empty and skips cargo loading when no warehouse is available', async () => { it('reads regional candidates independently of the optional empty-dispatch warehouse directory', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([]) vi.mocked(listAllPlatformWarehouses).mockResolvedValue([])
const { dialog } = await render() const { dialog } = await render()
expect(api.listDispatchCandidates).not.toHaveBeenCalled() expect(api.listDispatchCandidates).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: undefined }))
expect(view(dialog).text()).toContain('请先选择起运仓') expect(view(dialog).text()).toContain('待排订单')
})
it('takes the first selected pallet warehouse instead of the first directory entry and locks the draft to that warehouse', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse, { ...warehouse, id: '654', warehouseCode: 'NYC', warehouseName: '纽约仓' }])
const row = candidate({ warehouseId: '654', warehouseCode: 'NYC', warehouseName: '纽约仓' })
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [row], total: 1, page: 1, size: 20, pages: 1, complete: true, snapshotAt: '' })
const empty = { ...draft(), warehouseId: '654', sources: [], containers: [] }
const car = { draftContainerId: 'ny-car', sequenceNo: 1, capacity: 30, sourceKeys: [] as string[] }
vi.mocked(api.createDispatchDraft).mockResolvedValue(empty)
vi.mocked(api.createDispatchDraftContainer).mockResolvedValue({ ...empty, recordVersion: 1, containers: [car] })
vi.mocked(api.lockDispatchDraftPallet).mockResolvedValue({ ...empty, recordVersion: 2, sources: [row], containers: [{ ...car, sourceKeys: [row.sourceKey] }] })
const { dialog } = await render(); await selectFirstOrder(dialog)
await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
expect(api.createDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: '654' }))
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ warehouseId: '654', draftToken: 'server-token' }))
const tabs = view(dialog).get('[aria-label="候选仓库展示"]').findAll('button')
expect(tabs[1]!.attributes('disabled')).toBeDefined()
expect(tabs[2]!.attributes('disabled')).toBeUndefined()
}) })
it('retains the restored draft warehouse instead of selecting the first warehouse on reopen', async () => { it('retains the restored draft warehouse instead of selecting the first warehouse on reopen', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse, { ...warehouse, id: '654', warehouseName: '纽约仓' }]) vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse, { ...warehouse, id: '654', warehouseName: '纽约仓' }])
vi.mocked(api.listDispatchDrafts).mockResolvedValue([{ ...draft(), warehouseId: '654', sources: [], containers: [] }]) vi.mocked(api.listDispatchDrafts).mockResolvedValue([{ ...draft(), warehouseId: '654', sources: [], containers: [] }])
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 0, page: 1, size: 20, pages: 0, candidates: [], complete: true, snapshotAt: '' }) vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 0, page: 1, size: 20, pages: 0, candidates: [], complete: true, snapshotAt: '' })
const { wrapper, dialog } = await render() const { wrapper, dialog } = await render()
await view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '洛杉矶')!.trigger('click'); await flushPromises()
await restore(dialog) await restore(dialog)
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ warehouseId: '654', warehouseTab: 'ALL' }))
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises() await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ warehouseId: '654' })) expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ warehouseId: '654' }))
}) })
@@ -81,7 +104,7 @@ describe('new dispatch native layout', () => {
const content = view(dialog) const content = view(dialog)
const searchPanel = content.get('[aria-label="候选货物查询"]') const searchPanel = content.get('[aria-label="候选货物查询"]')
expect(searchPanel.find('.el-form-item__label').exists()).toBe(false) expect(searchPanel.find('.el-form-item__label').exists()).toBe(false)
expect(searchPanel.find('[aria-label="起运仓"]').exists()).toBe(true) expect(searchPanel.find('[aria-label="起运仓"]').exists()).toBe(false)
expect(searchPanel.find('input[placeholder="订单号/入仓码"]').exists()).toBe(true) expect(searchPanel.find('input[placeholder="订单号/入仓码"]').exists()).toBe(true)
const settingsToggle = searchPanel.findAll('button').find(button => button.text() === '排车设置')! const settingsToggle = searchPanel.findAll('button').find(button => button.text() === '排车设置')!
expect(content.get('[aria-label="排车设置"]').isVisible()).toBe(false) expect(content.get('[aria-label="排车设置"]').isVisible()).toBe(false)
@@ -284,7 +307,7 @@ describe('new dispatch native layout', () => {
await view(dialog).get('input[placeholder="目的仓码"]').setValue('ONT8') await view(dialog).get('input[placeholder="目的仓码"]').setValue('ONT8')
dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '全部来源')!.vm.$emit('update:modelValue', sourceStage); await flushPromises() dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '全部来源')!.vm.$emit('update:modelValue', sourceStage); await flushPromises()
await view(dialog).findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises() await view(dialog).findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith({ page: 1, size: 20, warehouseTab: 'ALL', warehouseId: '321', orderNo: 'IN-QUERY', destinationWarehouseCode: 'ONT8', sourceStage, draftToken: undefined }) expect(api.listDispatchCandidates).toHaveBeenLastCalledWith({ page: 1, size: 20, warehouseTab: 'ALL', warehouseId: undefined, orderNo: 'IN-QUERY', destinationWarehouseCode: 'ONT8', sourceStage, draftToken: undefined })
const calls = vi.mocked(api.listDispatchCandidates).mock.calls.length const calls = vi.mocked(api.listDispatchCandidates).mock.calls.length
await view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.trigger('click'); await flushPromises() await view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.trigger('click'); await flushPromises()
expect(view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.attributes('aria-selected')).toBe('true') expect(view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.attributes('aria-selected')).toBe('true')
@@ -89,9 +89,10 @@ async function selectOrder(dialog: VueWrapper, palletNo: string) {
await row.get('button').trigger('click'); await flushPromises() await row.get('button').trigger('click'); await flushPromises()
} }
async function warehouseSelected(dialog: VueWrapper) { async function warehouseSelected(dialog: VueWrapper) {
const picker = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择起运仓')! expect(view(dialog).get('[aria-label="候选仓库展示"]').text()).toContain('全部')
picker.vm.$emit('update:modelValue', '321'); picker.vm.$emit('change', '321'); await flushPromises() await flushPromises()
} }
async function join(dialog: VueWrapper, palletNo: string) { await selectOrder(dialog, palletNo); await cargoRow(dialog, palletNo).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises() } async function join(dialog: VueWrapper, palletNo: string) { await selectOrder(dialog, palletNo); await cargoRow(dialog, palletNo).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises() }
async function chooseCar(dialog: VueWrapper, index: number) { await car(dialog, index).get('input[type="radio"]').setValue(true); await flushPromises() } async function chooseCar(dialog: VueWrapper, index: number) { await car(dialog, index).get('input[type="radio"]').setValue(true); await flushPromises() }
describe('three-column cargo planning through visible controls', () => { describe('three-column cargo planning through visible controls', () => {
@@ -167,7 +167,7 @@ it('keeps server draft cargo across pages, resets filters and sends regional tab
state.query.orderNo = 'query'; await state.search() state.query.orderNo = 'query'; await state.search()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, orderNo: 'query' })) expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, orderNo: 'query' }))
state.changeTab('NYC'); await flushPromises() state.changeTab('NYC'); await flushPromises()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, warehouseTab: 'NYC' })) expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, warehouseId: '321', warehouseTab: 'ALL' }))
state.changeSize(10); await flushPromises() state.changeSize(10); await flushPromises()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10 })) expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10 }))
state.changePage(2); await flushPromises(); await state.submit() state.changePage(2); await flushPromises(); await state.submit()
@@ -21,7 +21,7 @@ import { useDispatchCommandRecovery, validateCurrentDispatch, isDefiniteDispatch
import type { InboundOrderListItem } from '../types' import type { InboundOrderListItem } from '../types'
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue' import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
import SmartDispatchExistingCargo from './SmartDispatchExistingCargo.vue' import SmartDispatchExistingCargo from './SmartDispatchExistingCargo.vue'
import type { SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab' import { matchesSmartDispatchWarehouseTab, type SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
const props = defineProps<{ modelValue: boolean; orders: InboundOrderListItem[] }>() const props = defineProps<{ modelValue: boolean; orders: InboundOrderListItem[] }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [] }>() const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [] }>()
@@ -39,7 +39,7 @@ const settingsExpanded = ref(false)
const mode = ref<'cargo' | 'empty'>('cargo'), warehouseId = ref(''), warehouses = ref<PlatformWarehouseResource[]>([]) 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 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 working = ref(false), saving = ref(false), preparing = ref(false), error = ref<ApiErrorDetails | null>(null)
const candidatePage = useDispatchCandidates(error) const candidatePage = useDispatchCandidates(error, api.listDispatchCandidates, true)
const { candidates, page, size, total } = candidatePage const { candidates, page, size, total } = candidatePage
const loading = computed(() => working.value || candidatePage.loading.value) const loading = computed(() => working.value || candidatePage.loading.value)
const query = reactive({ orderNo: '', destinationWarehouseCode: '', sourceStage: '' }) const query = reactive({ orderNo: '', destinationWarehouseCode: '', sourceStage: '' })
@@ -71,11 +71,13 @@ async function ensureFirstVehicle(sequence: number): Promise<string | null> {
return currentContainer.value?.draftContainerId || null return currentContainer.value?.draftContainerId || null
} }
async function addCargo(source: Candidate, targetContainerId = activeContainerId.value) { async function addCargo(source: Candidate, targetContainerId = activeContainerId.value) {
if (frozen.value || loading.value || !warehouseId.value || assignedGroup(source)) return if (frozen.value || loading.value || assignedGroup(source)) return
const sequence = generation, target = targetContainerId const sequence = generation, target = targetContainerId
preparing.value = true preparing.value = true
try { try {
requireSelectionGroup(source); candidatePage.assertCurrent([source]) requireSelectionGroup(source); candidatePage.assertCurrent([source])
if (draft.value && source.warehouseId !== draft.value.warehouseId) throw new DispatchInputError('同一次排车安排只能选择同一起运仓的货物')
if (!draft.value) warehouseId.value = source.warehouseId
const containerId = target || await ensureFirstVehicle(sequence) const containerId = target || await ensureFirstVehicle(sequence)
if (!containerId || sequence !== generation || !props.modelValue) return if (!containerId || sequence !== generation || !props.modelValue) return
if (!draft.value?.containers.some(row => row.draftContainerId === containerId)) throw new DispatchInputError('所选车辆已变化,请重新选择') if (!draft.value?.containers.some(row => row.draftContainerId === containerId)) throw new DispatchInputError('所选车辆已变化,请重新选择')
@@ -85,7 +87,8 @@ async function addCargo(source: Candidate, targetContainerId = activeContainerId
finally { preparing.value = false } finally { preparing.value = false }
} }
async function addVehicle() { async function addVehicle() {
if (frozen.value || loading.value || !warehouseId.value) return if (frozen.value || loading.value || !vehicleWarehouse.value) return
if (!draft.value) warehouseId.value = vehicleWarehouse.value
const sequence = generation const sequence = generation
preparing.value = true preparing.value = true
try { try {
@@ -94,7 +97,17 @@ async function addVehicle() {
} finally { preparing.value = false } } finally { preparing.value = false }
} }
function changeWarehouse() { candidatePage.reset(); warehouseTab.value = 'ALL'; void search() } function changeWarehouse() { candidatePage.reset(); warehouseTab.value = 'ALL'; void search() }
function changeTab(tab: SmartDispatchWarehouseTab) { warehouseTab.value = tab; void search() } function changeTab(tab: SmartDispatchWarehouseTab) { if (tabDisabled(tab)) return; warehouseTab.value = tab; void search() }
function tabDisabled(tab: SmartDispatchWarehouseTab) {
if (!draft.value || tab === 'ALL') return false
const warehouse = warehouses.value.find(row => String(row.id) === draft.value!.warehouseId)
return !warehouse || !matchesSmartDispatchWarehouseTab(warehouse, tab)
}
const vehicleWarehouse = computed(() => {
if (draft.value) return draft.value.warehouseId
const matches = warehouses.value.filter(row => matchesSmartDispatchWarehouseTab(row, warehouseTab.value))
return matches.length === 1 ? String(matches[0]!.id) : ''
})
function changePage(value: number) { page.value = value; void loadCandidates() } function changePage(value: number) { page.value = value; void loadCandidates() }
function changeSize(value: number) { size.value = value; void search() } function changeSize(value: number) { size.value = value; void search() }
@@ -125,7 +138,7 @@ async function init() {
if (selectedWarehouses.size === 1 && directory.some(row => String(row.id) === [...selectedWarehouses][0])) warehouseId.value = [...selectedWarehouses][0]! 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) else if (directory[0]) warehouseId.value = String(directory[0].id)
} }
if (warehouseId.value) await loadCandidates(sequence) await loadCandidates(sequence)
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车候选或草稿恢复失败,请保留原选择重试') } } catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车候选或草稿恢复失败,请保留原选择重试') }
finally { if (sequence === generation) working.value = false } finally { if (sequence === generation) working.value = false }
} }
@@ -138,13 +151,14 @@ function switchWorkspace(value: 'create' | 'existing') {
} }
async function loadCandidates(sequence = generation) { async function loadCandidates(sequence = generation) {
if (sequence !== generation) return if (sequence !== generation) return
const filterKey = JSON.stringify([warehouseId.value, query, warehouseTab.value, planIds.value, draft.value?.draftToken]) const candidateWarehouse = draft.value?.warehouseId
const filterKey = JSON.stringify([candidateWarehouse, query, warehouseTab.value, planIds.value, draft.value?.draftToken])
if (filterKey !== candidateFilterKey) { page.value = 1; candidateFilterKey = filterKey } 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 }) await candidatePage.load({ warehouseId: candidateWarehouse, 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() } 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() } 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 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; warehouseTab.value = 'ALL'; activeContainerId.value = value.containers[0]?.draftContainerId || ''; void search() }
function creation(cargo: Candidate[], containerId?: string): CreateDispatch { function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
candidatePage.assertCurrent(cargo) candidatePage.assertCurrent(cargo)
requireBatchVersions(cargo.flatMap(row => row.appointmentBatches)) requireBatchVersions(cargo.flatMap(row => row.appointmentBatches))
@@ -221,6 +235,7 @@ async function retryDraft() {
addedContainerId = added[0]!.draftContainerId addedContainerId = added[0]!.draftContainerId
} }
draft.value = command.action === 'RELEASE' ? null : result as DispatchDraft draft.value = command.action === 'RELEASE' ? null : result as DispatchDraft
if (draft.value) warehouseId.value = draft.value.warehouseId
activeContainerId.value = addedContainerId || draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value)?.draftContainerId || draft.value?.containers[0]?.draftContainerId || '' 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. // No new source mutation before the original response has been validated.
pending.value = null; persist() pending.value = null; persist()
@@ -246,7 +261,7 @@ async function submit() {
watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true }) watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
</script> </script>
<template> <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 default-fullscreen :loading="working" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="needsRecovery ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit"> <AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog smart-dispatch-dialog" :class="{ 'is-cargo-workspace': workspace === 'create' && mode === 'cargo' }" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable default-fullscreen :loading="working" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="needsRecovery ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<div class="density-status-tabs" role="tablist" aria-label="智能排车工作方式"> <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="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> <button v-if="canEditCargo" type="button" role="tab" :aria-selected="workspace === 'existing'" :class="{ 'is-active': workspace === 'existing' }" :disabled="workspaceFrozen" @click="switchWorkspace('existing')">调整已有排车货物</button>
@@ -258,11 +273,11 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
<el-alert v-if="needsRecovery" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon /> <el-alert v-if="needsRecovery" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询"> <section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
<div class="dispatch-candidate-query"> <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" @click="search">查询</el-button><el-button :disabled="frozen || loading" @click="resetSearch">重置</el-button></template><el-button :aria-expanded="settingsExpanded" aria-controls="smart-dispatch-settings" @click="settingsExpanded = !settingsExpanded">排车设置</el-button></div>
<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>
<section v-show="settingsExpanded" id="smart-dispatch-settings" class="dispatch-context-toolbar" aria-label="排车设置"> <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 inline label-position="left" label-width="70px" class="density-form dispatch-setup-fields" :disabled="frozen || loading">
<el-form-item v-if="mode === 'empty'" label="起运仓"><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></el-form-item>
<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-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> </el-form>
<div v-if="mode === 'cargo'" class="dispatch-draft-tools density-actions" aria-label="车辆选货安排"> <div v-if="mode === 'cargo'" class="dispatch-draft-tools density-actions" aria-label="车辆选货安排">
@@ -273,9 +288,11 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
</section> </section>
<el-alert v-if="mode === 'empty'" title="空单暂不能核平、装车或发车。" type="info" :closable="false" show-icon /> <el-alert v-if="mode === 'empty'" title="空单暂不能核平、装车或发车。" type="info" :closable="false" show-icon />
<template v-if="mode === 'cargo'"> <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 :aria-busy="candidatePage.loading.value || saving || preparing" :candidates="candidates" :draft="draft" :active-container-id="activeContainerId" :capacity="capacity" :disabled="frozen || loading" :warehouse-selected="true" :add-vehicle-disabled="!vehicleWarehouse" :page="page" :size="size" :total="total"
<SmartDispatchCargoBoard :aria-busy="candidatePage.loading.value || saving || preparing" :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"> @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 #order-controls>
<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 || tabDisabled(tab.value)" @click="changeTab(tab.value)">{{ tab.label }}</el-button></div>
</template>
<template #vehicle-controls> <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> <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> </template>
@@ -295,6 +312,20 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
</AppDialog> </AppDialog>
</template> </template>
<style scoped> <style scoped>
/* In fullscreen, allocate the remaining body space to cargo instead of capping it at 390px. */
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace > .el-dialog__body) { display: flex; flex-direction: column; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace > .el-dialog__body > .app-dialog__body) { display: flex; flex-direction: column; flex: 1; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .dispatch-workspace) { display: flex; flex-direction: column; flex: 1; min-height: 0; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .dispatch-workspace > :not(.dispatch-cargo-board)) { flex-shrink: 0; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .dispatch-cargo-board) { flex: 1; min-height: 280px; grid-template-rows: auto minmax(0, 1fr); }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-panel) { min-height: 0; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-order-content) { align-self: stretch; display: flex; flex-direction: column; min-height: 0; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-table-wrap) { flex: 1; min-height: 0; max-height: none; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-pagination) { flex-shrink: 0; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-pallet-list),
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-vehicle-content) { min-height: 0; max-height: 100%; overflow-y: auto; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-vehicle-list) { max-height: none; overflow: visible; }
.dispatch-create-table { table-layout: fixed; } .dispatch-create-table { table-layout: fixed; }
.dispatch-create-table td:not([colspan]) { width: 50%; } .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-context-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-6); }
@@ -61,6 +61,31 @@ describe('whole-dispatch four-tab detail', () => {
expect(wrapper.findAll('[role="tab"]')).toHaveLength(0) expect(wrapper.findAll('[role="tab"]')).toHaveLength(0)
expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false); wrapper.unmount() expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false); wrapper.unmount()
}) })
it('keeps return-to-review in the footer across tabs and opens the existing workflow', async () => {
can.mockReturnValue(true)
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ stage: 'LOADING', actions: { RETURN_TO_REVIEW: { allowed: true, blockCode: null, blockReason: null } } }))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
await wrapper.findAll('[role="tab"]')[1]!.trigger('click'); await flushPromises()
const footer = wrapper.get('footer[aria-label="排车操作"]')
expect(footer.isVisible()).toBe(true)
expect(wrapper.get('.el-tabs').element.compareDocumentPosition(footer.element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
await footer.get('button').trigger('click')
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'RETURN_TO_REVIEW' })
wrapper.unmount()
})
it('keeps dispatch labels and placeholders visible when no schedule or appointments are recorded', async () => {
const value = detail()
value.dispatchInfo = { ...value.dispatchInfo, route: null, fbaAppointments: [] }
vi.mocked(getDispatch).mockResolvedValueOnce(value)
const wrapper = mount(DispatchDetailView, options); await flushPromises()
for (const name of ['dispatch', 'documents', 'vehicle', 'dispatch']) {
await wrapper.get(`#tab-${name}`).trigger('click'); await flushPromises()
expect(wrapper.get(`#pane-${name}`).isVisible()).toBe(true)
}
expect(wrapper.get('#pane-dispatch').findAll('dd').map(field => field.text())).toEqual(['—', '—', '—', '—'])
expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false)
wrapper.unmount()
})
it('returns to the original appointment date and view from the dispatch detail', async () => { it('returns to the original appointment date and view from the dispatch detail', async () => {
query.appointmentReturn = '/appointment-calendar?date=2026-10-21&mode=week' query.appointmentReturn = '/appointment-calendar?date=2026-10-21&mode=week'
const wrapper = mount(DispatchDetailView, options); await flushPromises() const wrapper = mount(DispatchDetailView, options); await flushPromises()
@@ -87,6 +112,8 @@ describe('whole-dispatch four-tab detail', () => {
await tabs[1]!.trigger('click'); await flushPromises() await tabs[1]!.trigger('click'); await flushPromises()
expect(tabs[1]!.attributes('aria-selected')).toBe('true') expect(tabs[1]!.attributes('aria-selected')).toBe('true')
const dispatchInfo = wrapper.get(`#${tabs[1]!.attributes('aria-controls')}`) const dispatchInfo = wrapper.get(`#${tabs[1]!.attributes('aria-controls')}`)
expect(dispatchInfo.isVisible()).toBe(true)
expect(dispatchInfo.get('dl').isVisible()).toBe(true)
expect(dispatchInfo.text()).toContain('LAX → ONT8'); expect(dispatchInfo.text()).toContain('FBA预约'); expect(dispatchInfo.text()).toContain('ONT8 · FBA-AP-1 · CONFIRMED') expect(dispatchInfo.text()).toContain('LAX → ONT8'); expect(dispatchInfo.text()).toContain('FBA预约'); expect(dispatchInfo.text()).toContain('ONT8 · FBA-AP-1 · CONFIRMED')
expect(dispatchInfo.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约']) expect(dispatchInfo.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约'])
expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('装车前'); wrapper.unmount() expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('装车前'); wrapper.unmount()
@@ -85,10 +85,9 @@ onBeforeUnmount(() => { generation++ })
<LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState> <LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading"> <div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
<TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" :dispatch-no="dispatchNo" :pickup-approved="detail.reviewState === 'APPROVED'" @saved="load" /> <TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" :dispatch-no="dispatchNo" :pickup-approved="detail.reviewState === 'APPROVED'" @saved="load" />
<section v-if="detail" class="detail-section" aria-label="排车操作"><div class="density-actions"><el-button v-for="value in visibleActions" :key="value" class="density-action" @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button><el-button v-if="(!detail.taskType || detail.taskType === 'DELIVERY') && canDispatchAction(detail?.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button></div></section>
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理(车辆信息),请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon /> <el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理(车辆信息),请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
<el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs"> <el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs density-contained-tabs--flush">
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></el-tab-pane> <el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields vehicle-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></el-tab-pane>
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><section v-if="detail && detail.dispatchInfo.exceptions.length" class="detail-section" aria-label="派送异常记录"> <el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><section v-if="detail && detail.dispatchInfo.exceptions.length" class="detail-section" aria-label="派送异常记录">
<h3>派送异常记录</h3> <h3>派送异常记录</h3>
<article v-for="(record, index) in detail.dispatchInfo.exceptions" :key="index"> <article v-for="(record, index) in detail.dispatchInfo.exceptions" :key="index">
@@ -97,11 +96,15 @@ onBeforeUnmount(() => { generation++ })
</article> </article>
</section></el-tab-pane> </section></el-tab-pane>
<el-tab-pane name="documents"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>单据信息</span></template> <el-tab-pane name="documents"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>单据信息</span></template>
<dl class="detail-fields density-fields"><div v-for="field in documentFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div><div class="linked-documents"><dt>关联派送单</dt><dd><template v-for="delivery in detail?.documents.deliveries || []" :key="delivery.deliveryNo"><el-button v-if="access.can(permission.deliveryList)" link type="primary" @click="router.push({ name: 'overseas-delivery-order-detail', params: { deliveryNo: delivery.deliveryNo } })">{{ delivery.deliveryNo }}</el-button><span v-else>{{ text(delivery.deliveryNo) }}</span></template><span v-if="!detail?.documents.deliveries.length">—</span></dd></div></dl> <dl class="detail-fields density-fields"><div v-for="field in documentFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div><div class="linked-documents"><dt>关联派送单</dt><dd><template v-for="delivery in detail?.documents.deliveries || []" :key="delivery.deliveryNo"><el-button v-if="access.can(permission.deliveryList)" link type="primary" @click="router.push({ name: 'overseas-delivery-order-detail', params: { deliveryNo: delivery.deliveryNo }, query: { customerId: delivery.customerId } })">{{ delivery.deliveryNo }}</el-button><span v-else>{{ text(delivery.deliveryNo) }}</span></template><span v-if="!detail?.documents.deliveries.length">—</span></dd></div></dl>
<div class="density-actions"><el-button v-for="outbound in detail?.documents.outbounds || []" v-show="access.can(permission.outboundList)" :key="outbound.outboundNo" link @click="router.push({ name: 'overseas-outbounds', query: { documentNo: outbound.outboundNo } })">查看出库 {{ outbound.outboundNo }}</el-button></div> <div class="density-actions"><el-button v-for="outbound in detail?.documents.outbounds || []" v-show="access.can(permission.outboundList)" :key="outbound.outboundNo" link @click="router.push({ name: 'overseas-outbounds', query: { documentNo: outbound.outboundNo } })">查看出库 {{ outbound.outboundNo }}</el-button></div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane name="orders"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>关联订单明细</span></template><div class="workbench-table"><DataTable :data="relatedOrders" :columns="orderColumns" column-settings-mode="hide" row-key="sourceKey" empty-text="暂无关联订单明细" /></div></el-tab-pane> <el-tab-pane name="orders"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>关联订单明细</span></template><div class="workbench-table"><DataTable :data="relatedOrders" :columns="orderColumns" column-settings-mode="hide" row-key="sourceKey" empty-text="暂无关联订单明细" /></div></el-tab-pane>
</el-tabs> </el-tabs>
<footer v-if="detail && visibleActions.length" class="dispatch-actions density-actions" aria-label="排车操作">
<el-button v-for="value in visibleActions" :key="value" :type="value === 'CANCEL' ? 'danger' : undefined" plain @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button>
<el-button v-if="(!detail.taskType || detail.taskType === 'DELIVERY') && canDispatchAction(detail.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button>
</footer>
</div> </div>
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="dispatchNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" /> <DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="dispatchNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
</OverseasDetailShell> </OverseasDetailShell>
@@ -110,7 +113,10 @@ onBeforeUnmount(() => { generation++ })
<style scoped> <style scoped>
.detail-section { min-width: 0; padding: var(--admin-space-5); } .detail-section { min-width: 0; padding: var(--admin-space-5); }
.dispatch-detail { min-width: 0; min-height: 0; } .dispatch-detail { min-width: 0; min-height: 0; }
.dispatch-actions { justify-content: flex-end; flex-wrap: wrap; padding: var(--admin-space-5); }
.dispatch-detail .vehicle-fields { grid-template-columns: minmax(0, 1.3fr) minmax(0, 1.6fr) minmax(0, 1.2fr) minmax(0, 1.5fr) minmax(0, 0.8fr); }
.dispatch-detail .vehicle-fields > div { grid-template-columns: max-content minmax(0, 1fr); }
.linked-documents { grid-column: 1 / -1; } .linked-documents { grid-column: 1 / -1; }
.linked-documents dd { display: flex; flex-wrap: wrap; gap: var(--admin-space-4); overflow-wrap: anywhere; } .linked-documents dd { display: flex; flex-wrap: wrap; gap: var(--admin-space-4); overflow-wrap: anywhere; }
@media (max-width: 960px) { .detail-fields { grid-template-columns: 1fr; } .detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); } } @media (max-width: 960px) { .dispatch-detail .detail-fields { grid-template-columns: 1fr; } .dispatch-detail .detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); } }
</style> </style>
@@ -27,6 +27,7 @@ import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
import { transportLabels, type TransportType } from '../api/transportTasks' import { transportLabels, type TransportType } from '../api/transportTasks'
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue' import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue' import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
import DispatchDocumentsDialog from '../components/DispatchDocumentsDialog.vue'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery' import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore() const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
@@ -37,6 +38,7 @@ const rejectionError = ref<ApiErrorDetails | null>(null)
const transportOpen = ref(false), transportType = ref<TransportType>('PICKUP') const transportOpen = ref(false), transportType = ref<TransportType>('PICKUP')
const transportEditOpen = ref(false), transportEditNo = ref('') const transportEditOpen = ref(false), transportEditNo = ref('')
function createTransport(type: TransportType) { transportType.value = type; transportOpen.value = true } function createTransport(type: TransportType) { transportType.value = type; transportOpen.value = true }
const documentsOpen = ref(false), documentsNo = ref('')
const page = ref(1) const page = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const total = ref(0) const total = ref(0)
@@ -82,6 +84,7 @@ function switchStage(value: DispatchStage) { stage.value = value; section.value
function switchSection(value: BeforeLoadingSection) { section.value = value; page.value = 1; void load() } function switchSection(value: BeforeLoadingSection) { section.value = value; page.value = 1; void load() }
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) } function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'MARK_EXCEPTION', 'CANCEL'] const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'MARK_EXCEPTION', 'CANCEL']
function canManageDocuments(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.status === 'ACTIVE' && ['DELIVERING', 'DELIVERED'].includes(row.stage) }
function rowActions(row: DispatchListRow): RowActionItem[] { function rowActions(row: DispatchListRow): RowActionItem[] {
if (showSections.value && section.value !== 'VEHICLE_INFO') { if (showSections.value && section.value !== 'VEHICLE_INFO') {
const review = section.value const review = section.value
@@ -90,7 +93,7 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
if (row.taskType === 'LOCAL_TRUCK') return [{ key: 'detail', label: '运输操作', priority: 500 }] if (row.taskType === 'LOCAL_TRUCK') return [{ key: 'detail', label: '运输操作', priority: 500 }]
const available = rowActionOrder.filter(value => canDispatchAction(row.actions, value, access.can)) const available = rowActionOrder.filter(value => canDispatchAction(row.actions, value, access.can))
const primaryAction = available.find(value => value === 'EDIT') ?? available[0] const primaryAction = available.find(value => value === 'EDIT') ?? available[0]
return [...available.map((value, index) => ({ return [...(canManageDocuments(row) ? [{ key: 'documents', label: '管理资料', priority: 700 }] : []), ...available.map((value, index) => ({
key: value, key: value,
label: dispatchActionLabels[value], label: dispatchActionLabels[value],
ariaLabel: dispatchActionLabels[value], ariaLabel: dispatchActionLabels[value],
@@ -101,6 +104,7 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
function handleAction(value: string, row: DispatchListRow) { function handleAction(value: string, row: DispatchListRow) {
if (value === 'detail') return openDetail(row) if (value === 'detail') return openDetail(row)
if (showSections.value && section.value !== 'VEHICLE_INFO' && value !== section.value) return if (showSections.value && section.value !== 'VEHICLE_INFO' && value !== section.value) return
if (value === 'documents') { if (canManageDocuments(row)) { documentsNo.value = row.dispatchNo; documentsOpen.value = true }; return }
if (value === 'bol') return void openBol(row) if (value === 'bol') return void openBol(row)
rejectionError.value = null rejectionError.value = null
if (value === 'route-order') { if (!canDispatchAction(row.actions, 'EDIT', access.can)) return; operation.value = 'ROUTE'; action.value = 'EDIT'; actionNo.value = row.dispatchNo; actionOpen.value = true; return } if (value === 'route-order') { if (!canDispatchAction(row.actions, 'EDIT', access.can)) return; operation.value = 'ROUTE'; action.value = 'EDIT'; actionNo.value = row.dispatchNo; actionOpen.value = true; return }
@@ -157,5 +161,6 @@ onBeforeUnmount(() => { sequence++ })
<AppDialog v-model="transportEditOpen" title="维护取货车辆信息" width="1000px"><TransportExecutionPanel v-if="transportEditOpen" :dispatch-no="transportEditNo" :pickup-approved="rows.find(row => row.dispatchNo === transportEditNo)?.reviewState === 'APPROVED'" @saved="load" /></AppDialog> <AppDialog v-model="transportEditOpen" title="维护取货车辆信息" width="1000px"><TransportExecutionPanel v-if="transportEditOpen" :dispatch-no="transportEditNo" :pickup-approved="rows.find(row => row.dispatchNo === transportEditNo)?.reviewState === 'APPROVED'" @saved="load" /></AppDialog>
<SmartDispatchDialog v-model="createOpen" :orders="[]" @created="load" /> <SmartDispatchDialog v-model="createOpen" :orders="[]" @created="load" />
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" /> <DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
<DispatchDocumentsDialog v-model="documentsOpen" :dispatch-no="documentsNo" @saved="load" />
<TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" /> <TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
</template> </template>
@@ -93,3 +93,19 @@ it.each(['success', 'failure'])('keeps rows stable during a refresh but rejects
expect(() => state.assertCurrent(previous)).toThrow('尚未读取完成') expect(() => state.assertCurrent(previous)).toThrow('尚未读取完成')
} }
}) })
it('allows mixed-warehouse pages only for the explicitly regional workspace and pins restored drafts to their warehouse', async () => {
const error = ref<ApiErrorDetails | null>(null)
const state = useDispatchCandidates(error, listDispatchCandidates, true)
const second = candidate({ sourceKey: 'ny-source', warehouseId: '654' })
vi.mocked(listDispatchCandidates).mockResolvedValueOnce(response({ candidates: [candidate(), second] }))
await state.load({ warehouseTab: 'ALL' })
expect(state.candidates.value).toHaveLength(2)
expect(() => state.assertCurrent([second])).not.toThrow()
vi.mocked(listDispatchCandidates).mockResolvedValueOnce(response({ candidates: [candidate()] }))
await state.load({ warehouseId: '321', warehouseTab: 'ALL', draftToken: 'server-token' })
expect(() => state.assertCurrent([second])).toThrow('组成已变化')
const scoped = setup()
await scoped.load({ warehouseTab: 'ALL' })
expect(listDispatchCandidates).toHaveBeenCalledTimes(2)
})
@@ -4,7 +4,7 @@ import { listDispatchCandidates } from './api/dispatches'
import { DispatchInputError, assertSelectionGroupVersions, type Candidate, type CandidateQuery } from './dispatchContracts' import { DispatchInputError, assertSelectionGroupVersions, type Candidate, type CandidateQuery } from './dispatchContracts'
/** One visible server page; selected cargo remains owned by the caller or server draft. */ /** One visible server page; selected cargo remains owned by the caller or server draft. */
export function useDispatchCandidates(error: Ref<ApiErrorDetails | null>, fetchCandidates = listDispatchCandidates) { export function useDispatchCandidates(error: Ref<ApiErrorDetails | null>, fetchCandidates = listDispatchCandidates, allowAllWarehouses = false) {
const candidates = ref<Candidate[]>([]), page = ref(1), size = ref(20), total = ref(0), loading = ref(false) const candidates = ref<Candidate[]>([]), page = ref(1), size = ref(20), total = ref(0), loading = ref(false)
let generation = 0, warehouse = '', current = false let generation = 0, warehouse = '', current = false
let pending: { key: string; promise: Promise<void> } | undefined let pending: { key: string; promise: Promise<void> } | undefined
@@ -21,7 +21,7 @@ export function useDispatchCandidates(error: Ref<ApiErrorDetails | null>, fetchC
assertSelectionGroupVersions(rows, candidates.value) assertSelectionGroupVersions(rows, candidates.value)
for (const row of rows) { for (const row of rows) {
const group = row.selectionGroup const group = row.selectionGroup
if (row.warehouseId !== warehouse || (groupVersions.has(group.groupKey) && groupVersions.get(group.groupKey) !== group.groupVersion) if ((warehouse && row.warehouseId !== warehouse) || (groupVersions.has(group.groupKey) && groupVersions.get(group.groupKey) !== group.groupVersion)
|| group.sources.some(source => { || group.sources.some(source => {
const latest = sourceGroups.get(source.sourceKey) const latest = sourceGroups.get(source.sourceKey)
return latest && (latest.groupKey !== group.groupKey || latest.sourceVersion !== source.selection.sourceVersion) return latest && (latest.groupKey !== group.groupKey || latest.sourceVersion !== source.selection.sourceVersion)
@@ -29,8 +29,8 @@ export function useDispatchCandidates(error: Ref<ApiErrorDetails | null>, fetchC
} }
} }
function load(query: CandidateQuery): Promise<void> { function load(query: CandidateQuery): Promise<void> {
if (!query.warehouseId) { reset(); return Promise.resolve() } if (!query.warehouseId && !allowAllWarehouses) { reset(); return Promise.resolve() }
if (warehouse !== query.warehouseId) { reset(); warehouse = query.warehouseId } if (warehouse !== (query.warehouseId || '')) { reset(); warehouse = query.warehouseId || '' }
const request = { ...query, page: query.page ?? page.value, size: query.size ?? size.value, warehouseTab: query.warehouseTab ?? 'ALL' } const request = { ...query, page: query.page ?? page.value, size: query.size ?? size.value, warehouseTab: query.warehouseTab ?? 'ALL' }
const key = JSON.stringify(request) const key = JSON.stringify(request)
if (pending?.key === key) return pending.promise if (pending?.key === key) return pending.promise
@@ -43,7 +43,7 @@ export function useDispatchCandidates(error: Ref<ApiErrorDetails | null>, fetchC
const result = await fetchCandidates(request) const result = await fetchCandidates(request)
if (sequence !== generation) return if (sequence !== generation) return
if (result.page !== request.page || result.size !== request.size) throw new DispatchInputError('候选响应页码不一致,请重新读取') if (result.page !== request.page || result.size !== request.size) throw new DispatchInputError('候选响应页码不一致,请重新读取')
if (result.candidates.some(row => row.warehouseId !== request.warehouseId || !row.sourceVersion || row.selection.sourceVersion !== row.sourceVersion)) throw new DispatchInputError('候选起运仓或来源版本不完整,请重新读取') if (result.candidates.some(row => !row.warehouseId || (request.warehouseId && row.warehouseId !== request.warehouseId) || !row.sourceVersion || row.selection.sourceVersion !== row.sourceVersion)) throw new DispatchInputError('候选起运仓或来源版本不完整,请重新读取')
for (const row of result.candidates) { for (const row of result.candidates) {
groupVersions.set(row.selectionGroup.groupKey, row.selectionGroup.groupVersion) groupVersions.set(row.selectionGroup.groupKey, row.selectionGroup.groupVersion)
for (const source of row.selectionGroup.sources) sourceGroups.set(source.sourceKey, { groupKey: row.selectionGroup.groupKey, sourceVersion: source.selection.sourceVersion }) for (const source of row.selectionGroup.sources) sourceGroups.set(source.sourceKey, { groupKey: row.selectionGroup.groupKey, sourceVersion: source.selection.sourceVersion })
+1
View File
@@ -243,6 +243,7 @@
.admin-density .density-contained-tabs > .el-tabs__content { padding: var(--admin-space-5); } .admin-density .density-contained-tabs > .el-tabs__content { padding: var(--admin-space-5); }
.admin-density .density-contained-tabs.density-contained-tabs--flush > .el-tabs__content { padding: 0; }
/* Explicit page descriptions share the teleported detail table scale. */ /* Explicit page descriptions share the teleported detail table scale. */
:is(.admin-density, .admin-density-dialog) .density-descriptions .el-descriptions__label.el-descriptions__cell { width: var(--admin-label-width); font-size: var(--admin-font-caption); background: var(--admin-label-bg); } :is(.admin-density, .admin-density-dialog) .density-descriptions .el-descriptions__label.el-descriptions__cell { width: var(--admin-label-width); font-size: var(--admin-font-caption); background: var(--admin-label-bg); }
+21 -2
View File
@@ -835,7 +835,7 @@ describe('approved Admin density ownership', () => {
it('keeps contained overseas tab content and external cargo tab bodies separate', () => { it('keeps contained overseas tab content and external cargo tab bodies separate', () => {
expect(shared).toContain('.density-element-tabs:not(.density-contained-tabs) > .el-tabs__content { display: none; }') expect(shared).toContain('.density-element-tabs:not(.density-contained-tabs) > .el-tabs__content { display: none; }')
for (const file of ['LastMileOrderDetailView', 'DeliveryOrderDetailView']) { for (const file of ['LastMileOrderDetailView', 'DeliveryOrderDetailView', 'DispatchDetailView']) {
const source = read('modules/overseas-warehouse/pages/' + file + '.vue') const source = read('modules/overseas-warehouse/pages/' + file + '.vue')
const ast = parse(source).descriptor.template!.ast! const ast = parse(source).descriptor.template!.ast!
let panes = 0 let panes = 0
@@ -854,6 +854,24 @@ describe('approved Admin density ownership', () => {
} }
}) })
it('fits the five dispatch vehicle fields in one desktop row and keeps actions below the tabs', () => {
const source = read('modules/overseas-warehouse/pages/DispatchDetailView.vue')
const css = parse(source).descriptor.styles.map(style => style.content).join('\n')
const declarations = (source: string, selector: string) => {
const values: Record<string, string> = {}
cssRoot(source).walkRules(rule => {
if (rule.selector === selector && rule.parent?.type !== 'atrule') rule.walkDecls(decl => { values[decl.prop] = decl.value })
})
return values
}
expect(declarations(css, '.dispatch-detail .vehicle-fields')['grid-template-columns']).toBe('minmax(0, 1.3fr) minmax(0, 1.6fr) minmax(0, 1.2fr) minmax(0, 1.5fr) minmax(0, 0.8fr)')
expect(declarations(css, '.dispatch-detail .vehicle-fields > div')['grid-template-columns']).toBe('max-content minmax(0, 1fr)')
expect(declarations(css, '.dispatch-actions')['justify-content']).toBe('flex-end')
expect(declarations(shared, '.admin-density .density-contained-tabs > .el-tabs__content').padding).toBe('var(--admin-space-5)')
expect(declarations(shared, '.admin-density .density-contained-tabs.density-contained-tabs--flush > .el-tabs__content').padding).toBe('0')
expect(elementsOf(source, 'el-tabs').every(node => classesOf(node).includes('density-contained-tabs--flush'))).toBe(true)
})
it('opts shared overseas detail and relationship wrappers in only at approved callers', () => { it('opts shared overseas detail and relationship wrappers in only at approved callers', () => {
for (const file of ['modules/overseas-warehouse/components/OverseasDetailShell.vue', 'modules/overseas-warehouse/components/OverseasReadOnlyDetail.vue', 'shared/components/InboundCodeRelationshipDialog.vue']) { for (const file of ['modules/overseas-warehouse/components/OverseasDetailShell.vue', 'modules/overseas-warehouse/components/OverseasReadOnlyDetail.vue', 'shared/components/InboundCodeRelationshipDialog.vue']) {
const source = read(file) const source = read(file)
@@ -1301,7 +1319,8 @@ describe('approved Admin density ownership', () => {
expect(source).not.toContain('class="density-dialog-section dispatch-draft-toolbar"') expect(source).not.toContain('class="density-dialog-section dispatch-draft-toolbar"')
expect(source).toContain('<template #vehicle-controls>') expect(source).toContain('<template #vehicle-controls>')
expect(source).toContain('class="density-form dispatch-setup-fields"') expect(source).toContain('class="density-form dispatch-setup-fields"')
expect(source).toContain('class="dispatch-warehouse-picker" aria-label="起运仓"') expect(source).toContain('<el-form-item v-if="mode === \'empty\'" label="起运仓">')
expect(source).toContain('<template #order-controls>')
expect(source).not.toContain('class="dispatch-warehouse-field" label="起运仓"') expect(source).not.toContain('class="dispatch-warehouse-field" label="起运仓"')
expect(source).not.toContain('data-tone="work"') expect(source).not.toContain('data-tone="work"')
expect(source).not.toContain('data-tone="summary"') expect(source).not.toContain('data-tone="summary"')