优化
This commit is contained in:
@@ -2,12 +2,12 @@
|
||||
|
||||
## 排车发布内部散板需求(2026-10-21)
|
||||
|
||||
依据本轮用户确认默认勾选发布,详见拼车主契约当前页面范围。
|
||||
依据用户确认及 2026-10-22 简化要求,详见拼车主契约当前页面范围。
|
||||
|
||||
| 页面/操作 | 当前承载与最小调整 | 验收 |
|
||||
|---|---|---|
|
||||
| 智能排车/新建空单或选货转换 | 保持原弹框,在每车计划信息内增加默认勾选的“发布公共散板需求”;每车独立提交,不新增发布页面 | 本轮8文件115项定向测试及受影响文件类型检查通过;待人工1920×1080、100%截图确认 |
|
||||
| 维护车辆信息/保存及提交审核 | 保持原弹框,回显并保存本车发布选项;与原资源命令同事务,后续提交审核使用新版本 | 同上 |
|
||||
| 智能排车/新建空单或选货转换 | 保持原弹框,搜索区增加默认不勾选的“发布公共需求”,统一用于本次生成的所有排车单;移除下方计划时间与备注区,沿用关联排车维护 | 3文件59项定向测试通过;组件及两份测试类型检查通过,规划测试原有VueWrapper辅助函数类型不匹配仍阻断其类型检查;待人工1920×1080、100%截图确认 |
|
||||
| 维护车辆信息/保存及提交审核 | 保持原弹框,回显并保存本车发布选项;与原资源命令同事务,后续提交审核使用新版本 | 沿用此前8文件115项定向测试及受影响文件类型检查结果;本轮未修改,待人工1920×1080、100%截图确认 |
|
||||
|
||||
|
||||
## 2026-10-21 智能排车区域入口替代仓库搜索
|
||||
@@ -60,7 +60,7 @@
|
||||
| 页面/操作 | 截图承载与当前实现 | 验证 |
|
||||
|---|---|---|
|
||||
| 排车列表/装车后页签 | 在装车中与派送中之间显示;计数与分页按服务端AFTER_LOADING查询,行内管理资料进入原居中弹框 | 定向自动检查;待1920×1080、100%人工截图确认 |
|
||||
| 管理资料/装车资料 | 原居中弹框内两列共五个装车照片框;装车后默认打开,Admin与PDA共享整车照片;第五张保存后自动进入派送中 | 上传恢复、权限和服务端事务定向检查;待人工截图及PDA真机确认 |
|
||||
| 管理资料/装车资料 | 原居中弹框内五行装车照片表格(2026-10-22 简化确认),保留图片预览;装车后默认打开,Admin与PDA共享整车照片;第五张保存后自动进入派送中 | 上传恢复、权限和服务端事务定向检查;待人工截图及PDA真机确认 |
|
||||
| 装车完成/原请求恢复 | 装车结果为AFTER_LOADING;已确认结果重读失败不重复POST,当前派送状态另读 | 定向恢复检查 |
|
||||
|
||||
## 2026-10-21 智能排车恢复10月18日布局
|
||||
@@ -445,6 +445,6 @@
|
||||
|
||||
| 页面 | 操作 | 原型/确认依据 | 承载与实现 | 验收 |
|
||||
|---|---|---|---|---|
|
||||
| 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 自动回归单独报告;1920×1080、100%待人工截图确认 |
|
||||
| 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;2026-10-22 用户截图确认移除资料核对、油耗和装车照片改表格;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;嵌入编辑器隐藏资料核对;三个页签仅显示各自资料,装车资料只保留整车装车照片,不再附带BOL及车辆/司机附件,保存资料按钮仅在签收页签显示,切换保留签收草稿;油耗两行、装车照片五行表格,操作列在首列,保留预览、上传/替换和原保存行为;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 4文件27项定向测试及受影响组件与测试源码类型检查通过;页签隔离追加修改后5项定向测试及组件/测试类型检查通过;1920×1080、100%待人工截图确认 |
|
||||
|
||||
| 仓库派送排车列表 → 维护车辆 | 提交审核入口合并 | 2026-10-21 用户确认:隐藏调整卸货顺序,提交审核移入车辆维护弹框,不需要备注 | 沿用原居中弹框;底部保存/提交审核,提交先保存本次资料及三类照片再按新版本送审;去掉维护和提交备注输入,保留审核驳回原因;详情保持只读 | 定向自动检查单独报告;1920×1080、100%待人工截图确认 |
|
||||
|
||||
@@ -118,7 +118,7 @@ async function 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">
|
||||
<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 + '/' + visibleGroups.length + ' 已上传' : loading ? '正在读取…' : '资料未返回' }}</td></tr></tbody></table><p v-if="snapshot" class="density-dialog-note">{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</p></section>
|
||||
<section v-if="!embedded" 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 + '/' + visibleGroups.length + ' 已上传' : loading ? '正在读取…' : '资料未返回' }}</td></tr></tbody></table><p v-if="snapshot" class="density-dialog-note">{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</p></section>
|
||||
<template v-if="snapshot">
|
||||
<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 visibleGroups" :key="group.category" class="document-group density-dialog-section" data-tone="source" :data-category="group.category">
|
||||
|
||||
@@ -15,7 +15,7 @@ const dialogStub = defineComponent({ props: ['modelValue', 'showConfirm'], emits
|
||||
function documents(no = 'DL-1', customerId = '101'): DeliveryDocuments {
|
||||
return { deliveryNo: no, customerId, version: 1, bolNo: 'BOL-ORIGINAL', uploadedCategoryCount: 1, totalCategoryCount: 3, allowedActions: ['MANAGE_DOCUMENTS'], replayed: false, categories: deliveryDocumentCategories.map(item => ({ category: item.category, status: item.category === 'RECEIPT' ? 'UPLOADED' : 'MISSING', fileCount: item.category === 'RECEIPT' ? 1 : 0, files: item.category === 'RECEIPT' ? [{ fileId: '99', fileName: 'existing-pod.pdf', contentType: 'application/pdf', sizeBytes: 10, uploadedAt: '2026-10-21T00:00:00' }] : [] })) }
|
||||
}
|
||||
function setup() { return mount(DispatchDocumentsDialog, { props: { modelValue: true, dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialogStub, DispatchVehicleDocuments: true, DispatchLoadingDocuments: true, DispatchEvidenceFiles: true } } }) }
|
||||
function setup() { return mount(DispatchDocumentsDialog, { attachTo: document.body, props: { modelValue: true, dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialogStub, DispatchVehicleDocuments: true, DispatchLoadingDocuments: true, DispatchEvidenceFiles: true } } }) }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); can.mockReturnValue(true)
|
||||
const value = detail(); value.stage = 'DELIVERING'
|
||||
@@ -32,6 +32,8 @@ it.each(['LOADING', 'AFTER_LOADING'] as const)('opens loading documents in %s be
|
||||
can.mockImplementation((value?: string) => value !== 'platform:overseas-warehouse:delivery:list')
|
||||
const wrapper = setup(); await flushPromises()
|
||||
expect(wrapper.find('dispatch-loading-documents-stub').exists()).toBe(true)
|
||||
expect(wrapper.find('dispatch-evidence-files-stub').exists()).toBe(false)
|
||||
expect(wrapper.find('.save').exists()).toBe(false)
|
||||
expect(wrapper.findAll('[role="tab"]').find(tab => tab.text() === '装车资料')!.attributes('aria-selected')).toBe('true')
|
||||
expect(getDeliveryDocuments).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
@@ -39,6 +41,8 @@ it('reads existing delivery files in one dialog and saves receipt without requir
|
||||
const wrapper = setup(); await flushPromises()
|
||||
expect(getDeliveryDocuments).toHaveBeenCalledWith('DL-1', '101')
|
||||
expect(wrapper.text()).toContain('existing-pod.pdf')
|
||||
expect(wrapper.text()).not.toContain('资料核对')
|
||||
expect(wrapper.find('[aria-label="派送资料摘要"]').exists()).toBe(false)
|
||||
expect(wrapper.findAllComponents(DeliveryDocumentsDialog)).toHaveLength(1)
|
||||
await wrapper.get('.save').trigger('click'); await flushPromises()
|
||||
expect(saveDeliveryDocuments).toHaveBeenCalledWith('DL-1', '101', expect.objectContaining({ bolNo: 'BOL-ORIGINAL', documents: [{ category: 'LOADING_PHOTO', fileIds: [] }, { category: 'BOL', fileIds: [] }, { category: 'RECEIPT', fileIds: ['99'] }] }))
|
||||
@@ -48,13 +52,31 @@ it('reads existing delivery files in one dialog and saves receipt without requir
|
||||
})
|
||||
it('switches customer scope exactly and keeps unsaved delivery edits when switching tabs', async () => {
|
||||
const wrapper = setup(); await flushPromises()
|
||||
const receipt = wrapper.getComponent(DeliveryDocumentsDialog)
|
||||
await receipt.findAll('button').find(button => button.text() === '删除')!.trigger('click')
|
||||
expect(receipt.text()).not.toContain('existing-pod.pdf')
|
||||
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '装车资料')!.trigger('click')
|
||||
expect(wrapper.getComponent(DeliveryDocumentsDialog).props('categories')).toEqual(['BOL'])
|
||||
expect(wrapper.getComponent(DeliveryDocumentsDialog).props('categories')).toEqual(['RECEIPT'])
|
||||
expect(receipt.isVisible()).toBe(false)
|
||||
expect(wrapper.find('dispatch-loading-documents-stub').isVisible()).toBe(true)
|
||||
expect(wrapper.find('dispatch-evidence-files-stub').exists()).toBe(false)
|
||||
expect(wrapper.find('.save').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('关联派送单资料')
|
||||
expect(wrapper.text()).not.toContain('车辆及司机附件')
|
||||
expect(wrapper.find('[data-category="BOL"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-category="LOADING_PHOTO"]').exists()).toBe(false)
|
||||
expect(wrapper.getComponent(DeliveryDocumentsDialog).text()).toContain('0/1 已上传')
|
||||
await wrapper.getComponent(DeliveryDocumentsDialog).find('input').setValue('BOL-CHANGED')
|
||||
expect(wrapper.getComponent(DeliveryDocumentsDialog).find('[aria-label="派送资料摘要"]').exists()).toBe(false)
|
||||
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '油耗图片')!.trigger('click')
|
||||
expect(wrapper.find('dispatch-loading-documents-stub').exists()).toBe(false)
|
||||
expect(wrapper.find('dispatch-vehicle-documents-stub').isVisible()).toBe(true)
|
||||
expect(receipt.isVisible()).toBe(false)
|
||||
expect(wrapper.find('.save').exists()).toBe(false)
|
||||
expect(getDeliveryDocuments).toHaveBeenCalledTimes(1)
|
||||
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '签收文件')!.trigger('click')
|
||||
expect(receipt.isVisible()).toBe(true)
|
||||
expect(receipt.text()).not.toContain('existing-pod.pdf')
|
||||
expect(wrapper.find('dispatch-vehicle-documents-stub').isVisible()).toBe(false)
|
||||
expect(wrapper.find('.save').exists()).toBe(true)
|
||||
const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValue('cancel')
|
||||
wrapper.getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DL-2'); await flushPromises()
|
||||
expect(getDeliveryDocuments).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -11,11 +11,9 @@ 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 DispatchLoadingDocuments from './DispatchLoadingDocuments.vue'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ dispatchNo: string }>()
|
||||
@@ -28,7 +26,6 @@ const loadingEditor = ref<InstanceType<typeof DispatchLoadingDocuments>>()
|
||||
const deliveries = computed(() => detail.value?.documents.deliveries || [])
|
||||
const beforeDeparture = computed(() => ['LOADING', 'AFTER_LOADING'].includes(detail.value?.stage || ''))
|
||||
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 || loadingEditor.value?.busy))
|
||||
let sequence = 0
|
||||
onBeforeUnmount(() => { sequence++ })
|
||||
@@ -61,20 +58,18 @@ watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density 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()">
|
||||
<AppDialog v-model="open" class="admin-density 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 === 'RECEIPT' && 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>
|
||||
<DispatchLoadingDocuments v-if="tab === 'LOADING'" ref="loadingEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="loadingSaved" />
|
||||
<div v-show="tab !== 'FUEL'" v-if="!beforeDeparture">
|
||||
<h3 v-if="tab === 'LOADING'" class="density-dialog-section__title">关联派送单资料</h3>
|
||||
<div v-show="tab === 'RECEIPT'" v-if="!beforeDeparture">
|
||||
<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'] : ['BOL']" @saved="saved" />
|
||||
<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="['RECEIPT']" @saved="saved" />
|
||||
<p v-else>{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}</p>
|
||||
</div>
|
||||
<p v-if="beforeDeparture && tab !== 'LOADING'">完成装车并补齐装车资料后,进入派送中管理{{ tab === 'FUEL' ? '油耗图片' : '签收文件' }}。</p>
|
||||
<DispatchVehicleDocuments v-if="fuelVisited && !beforeDeparture" 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>
|
||||
|
||||
@@ -27,11 +27,15 @@ beforeEach(() => {
|
||||
it('offers five whole-dispatch slots and only reports saved after binding succeeds', async () => {
|
||||
const wrapper = setup(); await flushPromises()
|
||||
expect(wrapper.findAll('input[type="file"]')).toHaveLength(5)
|
||||
const table = wrapper.get('table[aria-label="装车照片"]')
|
||||
expect(table.findAll('thead th').map(cell => cell.text())).toEqual(['操作', '照片类别', '文件名称', '图片预览', '保存时间'])
|
||||
expect(table.findAll('tbody tr').map(row => row.findAll('td')[1]!.text())).toEqual(['装车照片 1', '装车照片 2', '装车照片 3', '装车照片 4', '装车照片 5'])
|
||||
await upload(wrapper)
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.any(File), expect.any(String))
|
||||
expect(api.bindLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '0', fileId: '101' }))
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('图片读取失败')
|
||||
expect(table.get('tbody tr').text()).toContain('loading.png')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps the original binding and file after an unknown result and never uploads again', async () => {
|
||||
|
||||
@@ -94,22 +94,31 @@ defineExpose({ busy })
|
||||
<p v-if="snapshot?.stage === 'AFTER_LOADING'">请上传 5 张装车照片,全部保存成功后自动进入派送中。</p>
|
||||
<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">{{ pending.conflict ? '刷新并重试保存' : '核对原请求并继续保存' }}</el-button></div>
|
||||
<div class="loading-photo-grid">
|
||||
<section v-for="(slot, index) in loadingPhotoSlots" :key="slot" class="density-dialog-section loading-photo-card">
|
||||
<h3 class="density-dialog-section__title">装车照片 {{ index + 1 }}</h3>
|
||||
<template v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">
|
||||
<table class="density-table loading-documents-table" aria-label="装车照片"><thead><tr><th class="photo-actions">操作</th><th class="photo-category">照片类别</th><th>文件名称</th><th class="photo-preview">图片预览</th><th class="photo-time">保存时间</th></tr></thead><tbody>
|
||||
<tr v-for="(slot, index) in loadingPhotoSlots" :key="slot">
|
||||
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy || Boolean(pending) }">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="`装车照片 ${index + 1}`" :disabled="busy || Boolean(pending)" @change="upload(slot, $event)" /></label><span v-else>—</span></td>
|
||||
<td>装车照片 {{ index + 1 }}</td>
|
||||
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">{{ photo.fileName }}</div><span v-if="!snapshot?.photos.some(item => item.slot === slot)">未上传</span></td>
|
||||
<td><template v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">
|
||||
<el-image v-if="images[photo.fileId]" :src="images[photo.fileId]" :preview-src-list="[images[photo.fileId]!]" fit="contain" class="loading-photo-image" preview-teleported />
|
||||
<el-button v-else link :disabled="busy" @click="load">图片读取失败,点击重试</el-button>
|
||||
</template>
|
||||
<div v-if="!snapshot?.photos.some(item => item.slot === slot)" class="loading-photo-empty">未上传</div>
|
||||
<label v-if="editable">上传/替换图片<input type="file" accept="image/jpeg,image/png" :aria-label="`装车照片 ${index + 1}`" :disabled="busy || Boolean(pending)" @change="upload(slot, $event)" /></label>
|
||||
</section>
|
||||
</div>
|
||||
</template><span v-if="!snapshot?.photos.some(item => item.slot === slot)">—</span></td>
|
||||
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">{{ photo.savedAt?.replace('T', ' ') || '—' }}</div><span v-if="!snapshot?.photos.some(item => item.slot === slot)">—</span></td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.loading-photo-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4); }
|
||||
.loading-photo-image, .loading-photo-empty { width: 100%; height: 150px; margin-bottom: var(--admin-space-3); }
|
||||
.loading-photo-empty { display: grid; place-items: center; background: var(--cc-bg-soft, #f3f7ff); color: var(--cc-text-secondary, #64748b); }
|
||||
.loading-photo-card input { display: block; margin-top: var(--admin-space-2); max-width: 100%; }
|
||||
.loading-documents-table { table-layout: fixed; }
|
||||
.loading-documents-table td { overflow-wrap: anywhere; }
|
||||
.photo-actions { width: 110px; }
|
||||
.photo-category { width: 120px; }
|
||||
.photo-preview { width: 140px; }
|
||||
.photo-time { width: 190px; }
|
||||
.loading-photo-image { display: block; width: 100px; height: 64px; }
|
||||
.photo-upload { position: relative; display: inline-flex; color: var(--el-color-primary); cursor: pointer; }
|
||||
.photo-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
|
||||
.photo-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
|
||||
.photo-upload.is-disabled { color: var(--el-text-color-disabled); cursor: not-allowed; }
|
||||
.photo-upload.is-disabled input { cursor: not-allowed; }
|
||||
</style>
|
||||
|
||||
@@ -22,6 +22,9 @@ beforeEach(() => {
|
||||
it('binds the uploaded file to the car and restores the original binding after reopening without uploading twice', async () => {
|
||||
vi.mocked(bindVehiclePhoto).mockRejectedValueOnce(new Error('network timeout'))
|
||||
const first = setup(); await flushPromises(); await upload(first)
|
||||
const table = first.get('table[aria-label="油耗图片"]')
|
||||
expect(table.findAll('thead th').map(cell => cell.text())).toEqual(['操作', '图片类别', '文件名称', '保存时间'])
|
||||
expect(table.findAll('tbody tr').map(row => row.findAll('td')[1]!.text())).toEqual(['起始油耗表盘', '结束油耗表盘'])
|
||||
const original = vi.mocked(bindVehiclePhoto).mock.calls[0]![2]
|
||||
expect(original).toMatchObject({ recordVersion: '3', fileId: photo.fileId })
|
||||
first.unmount()
|
||||
@@ -30,6 +33,7 @@ it('binds the uploaded file to the car and restores the original binding after r
|
||||
expect(bindVehiclePhoto).toHaveBeenLastCalledWith('PC/1', 'FUEL_START', original)
|
||||
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(1)
|
||||
expect(reopened.text()).toContain('fuel.png')
|
||||
expect(reopened.get('table tbody tr').text()).toContain('2026-10-21 00:00:00Z')
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
reopened.unmount()
|
||||
})
|
||||
|
||||
@@ -93,17 +93,28 @@ defineExpose({ busy })
|
||||
<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 v-if="pending.versionConflict" :disabled="busy || !editable" @click="refreshBinding">刷新版本并保存已上传图片</el-button><el-button v-else :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>
|
||||
<table class="density-table fuel-documents-table" aria-label="油耗图片"><thead><tr><th class="photo-actions">操作</th><th class="photo-category">图片类别</th><th>文件名称</th><th class="photo-time">保存时间</th></tr></thead><tbody>
|
||||
<tr v-for="role in roles" :key="role.slot">
|
||||
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy || Boolean(pending) }">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="role.label" :disabled="busy || Boolean(pending)" @change="upload(role.slot, $event)" /></label><span v-else>—</span></td>
|
||||
<td>{{ role.label }}</td>
|
||||
<td><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?.photos.some(item => item.slot === role.slot)">未上传</span></td>
|
||||
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === role.slot) || []" :key="photo.fileId">{{ photo.savedAt?.replace('T', ' ') || '—' }}</div><span v-if="!snapshot?.photos.some(item => item.slot === role.slot)">—</span></td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<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); }
|
||||
.fuel-documents-table { table-layout: fixed; }
|
||||
.fuel-documents-table td { overflow-wrap: anywhere; }
|
||||
.fuel-documents-table :deep(.el-button > span) { white-space: normal; text-align: left; overflow-wrap: anywhere; }
|
||||
.photo-actions { width: 110px; }
|
||||
.photo-category { width: 160px; }
|
||||
.photo-time { width: 190px; }
|
||||
.photo-upload { position: relative; display: inline-flex; color: var(--el-color-primary); cursor: pointer; }
|
||||
.photo-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
|
||||
.photo-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
|
||||
.photo-upload.is-disabled { color: var(--el-text-color-disabled); cursor: not-allowed; }
|
||||
.photo-upload.is-disabled input { cursor: not-allowed; }
|
||||
.photo-preview { display: block; max-width: 100%; max-height: 400px; object-fit: contain; }
|
||||
</style>
|
||||
|
||||
@@ -200,7 +200,7 @@ describe('new dispatch native layout', () => {
|
||||
expect(calls).toHaveLength(2);expect(calls[1]![0].idempotencyKey).not.toBe(calls[0]![0].idempotencyKey)
|
||||
expect(api.createDispatchDraft).toHaveBeenCalledOnce();expect(api.lockDispatchDraftPallet).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('requires an active cargo group before accepting per-group settings while keeping setup controls available', async () => {
|
||||
it('keeps publication available before cargo selection without extra planning fields', async () => {
|
||||
const { dialog } = await render()
|
||||
expect(view(dialog).find('[aria-label="排车设置"] table').exists()).toBe(false)
|
||||
expect(view(dialog).find('[aria-label="排车设置"] .density-dialog-note').exists()).toBe(false)
|
||||
@@ -213,14 +213,16 @@ describe('new dispatch native layout', () => {
|
||||
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
|
||||
await restore(dialog)
|
||||
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
||||
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeUndefined()
|
||||
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
it('creates an unassigned empty dispatch while preserving optional dates and remark through fullscreen', async () => {
|
||||
it('creates an empty dispatch with optional publication preserved through fullscreen', async () => {
|
||||
const { dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
|
||||
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
||||
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T10:00:00Z')
|
||||
field(dialog, '预计到达时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T18:00:00Z')
|
||||
await field(dialog, '备注').get('input').setValue('本趟备注')
|
||||
expect(view(dialog).find('[aria-label="排车计划时间与备注"]').exists()).toBe(false)
|
||||
expect(view(dialog).findComponent({ name: 'ElDatePicker' }).exists()).toBe(false)
|
||||
const publication = view(dialog).get('.dispatch-candidate-query input[type="checkbox"]')
|
||||
expect(publication.element).toHaveProperty('checked', false)
|
||||
await publication.setValue(true)
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||||
await view(dialog).get('button[aria-label="退出全屏"]').trigger('click'); await flushPromises()
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(false)
|
||||
@@ -229,7 +231,7 @@ describe('new dispatch native layout', () => {
|
||||
expect(view(dialog).text()).toContain('空单暂不能核平、装车或发车')
|
||||
expect(view(dialog).find('[aria-label="智能排车三栏选货"]').exists()).toBe(false)
|
||||
await view(dialog).findAll('button').find(button => button.text() === '保存空排车单')!.trigger('click'); await flushPromises()
|
||||
expect(api.createDispatch).toHaveBeenCalledExactlyOnceWith({ idempotencyKey: expect.any(String), publishLooseboardDemand: true, warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: '2026-11-02T10:00:00Z', estimatedArrivalAt: '2026-11-02T18:00:00Z', remark: '本趟备注', vehicle: null, driver: null })
|
||||
expect(api.createDispatch).toHaveBeenCalledExactlyOnceWith({ idempotencyKey: expect.any(String), publishLooseboardDemand: true, warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, vehicle: null, driver: null })
|
||||
})
|
||||
it('creates with only the warehouse and never requests vehicle or driver directories', async () => {
|
||||
const { dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
|
||||
@@ -239,15 +241,14 @@ describe('new dispatch native layout', () => {
|
||||
expect(listVehicles).not.toHaveBeenCalled()
|
||||
expect(listDrivers).not.toHaveBeenCalled()
|
||||
})
|
||||
it('keeps each group configuration independent and keeps form values editable after conversion read failure', async () => {
|
||||
it('applies publication to all cars and keeps it editable after conversion read failure', async () => {
|
||||
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('details not ready')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
|
||||
const { wrapper, dialog } = await render(); await restore(dialog)
|
||||
await field(dialog, '备注').get('input').setValue('第一组')
|
||||
await view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').setValue(true)
|
||||
const radios = view(dialog).get('[aria-label="车辆装载"]').findAll('input[type="radio"]')
|
||||
await radios[1]!.setValue(true); await flushPromises()
|
||||
await field(dialog, '备注').get('input').setValue('第二组')
|
||||
await radios[0]!.setValue(true); await flushPromises()
|
||||
expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第一组')
|
||||
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').element).toHaveProperty('checked', true)
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||||
await view(dialog).get('button[aria-label="退出全屏"]').trigger('click'); await flushPromises()
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(false)
|
||||
@@ -256,7 +257,7 @@ describe('new dispatch native layout', () => {
|
||||
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
|
||||
const request = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0]
|
||||
expect(request.recordVersion).toBe(0)
|
||||
expect(request.groups.map(group => [group.dispatch.vehicle, group.dispatch.driver, group.dispatch.remark])).toEqual([[null, null, '第一组'], [null, null, '第二组']])
|
||||
expect(request.groups.map(group => [group.dispatch.publishLooseboardDemand, group.dispatch.plannedDepartureAt, group.dispatch.estimatedArrivalAt, group.dispatch.remark])).toEqual([[true, null, null, null], [true, null, null, null]])
|
||||
expect(wrapper.emitted('created')).toBeUndefined()
|
||||
expect(view(dialog).text()).toContain('本次排车操作失败,请重试')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
@@ -273,12 +274,12 @@ describe('new dispatch native layout', () => {
|
||||
expect(dialog.props('confirmText')).toBe('生成排车单')
|
||||
const original = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||||
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined()
|
||||
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').attributes('disabled')).toBeDefined()
|
||||
reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises()
|
||||
expect(view(dialog).text()).toContain('new-dispatch-request')
|
||||
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
|
||||
expect(dialog.props('confirmText')).toBe('生成排车单')
|
||||
expect(field(dialog,'备注').get('input').attributes('disabled')).toBeUndefined()
|
||||
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').attributes('disabled')).toBeUndefined()
|
||||
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
|
||||
@@ -79,7 +79,6 @@ async function render() {
|
||||
return wrapper.getComponent(AppDialog)
|
||||
}
|
||||
function view(dialog: VueWrapper) { return dialog.getComponent({ name: 'ElDialogContent' }) }
|
||||
function field(dialog: VueWrapper, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)! }
|
||||
function button(dialog: VueWrapper, label: string) { return view(dialog).findAll('button').find(row => row.text() === label)! }
|
||||
function car(dialog: VueWrapper, index: number) { return view(dialog).get(`article[aria-label="第 ${index} 辆车"]`) }
|
||||
function cargoRow(dialog: VueWrapper, palletNo: string) { return view(dialog).get(`[aria-label="托盘 ${palletNo}"]`) }
|
||||
@@ -128,24 +127,24 @@ describe('three-column cargo planning through visible controls', () => {
|
||||
expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
|
||||
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||
})
|
||||
it('retains the first car cargo, isolates per-car fields and adds later cargo to the car selected again', async () => {
|
||||
it('retains car cargo and the publication choice when switching cars', async () => {
|
||||
const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1')
|
||||
await field(dialog, '备注').get('input').setValue('第一车独立备注')
|
||||
await view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').setValue(true)
|
||||
await button(dialog, '添加车辆').trigger('click'); await flushPromises()
|
||||
expect(car(dialog, 1).text()).toContain('PALLET-1')
|
||||
expect(car(dialog, 2).get('input[type="radio"]').element).toHaveProperty('checked', true)
|
||||
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
||||
await field(dialog, '备注').get('input').setValue('第二车独立备注'); await join(dialog, 'PALLET-2')
|
||||
await join(dialog, 'PALLET-2')
|
||||
expect(vi.mocked(api.lockDispatchDraftPallet).mock.calls[1]![0].draftContainerId).toBe('container-2')
|
||||
await chooseCar(dialog, 1)
|
||||
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
||||
expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第一车独立备注')
|
||||
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').element).toHaveProperty('checked', true)
|
||||
await join(dialog, 'PALLET-3')
|
||||
expect(vi.mocked(api.lockDispatchDraftPallet).mock.calls[2]![0].draftContainerId).toBe('container-1')
|
||||
expect(car(dialog, 1).text()).toContain('PALLET-3'); expect(car(dialog, 2).text()).toContain('PALLET-2')
|
||||
await chooseCar(dialog, 2)
|
||||
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
||||
expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第二车独立备注')
|
||||
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').element).toHaveProperty('checked', true)
|
||||
})
|
||||
it('selects the newly added ID even when its response lists the new container first and old container last', async () => {
|
||||
const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1')
|
||||
|
||||
@@ -133,13 +133,12 @@ it('keeps planning remarks independent with unassigned resources and permits res
|
||||
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
|
||||
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('detail unavailable')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
|
||||
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
|
||||
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void>; activeContainerId: string; form: { remark: string } }
|
||||
state.restoreDraft('server-token'); await flushPromises(); state.form.remark = '车甲'
|
||||
state.activeContainerId = 'container-B'; state.form.remark = '车乙'
|
||||
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void> }
|
||||
state.restoreDraft('server-token'); await flushPromises()
|
||||
await state.submit(); expect(wrapper.emitted('created')).toBeUndefined()
|
||||
await state.submit(); expect(api.convertDispatchDraft).toHaveBeenCalledTimes(2)
|
||||
const groups = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].groups
|
||||
expect(groups.map(group => [group.dispatch.vehicle, group.dispatch.driver, group.dispatch.remark])).toEqual([[null, null, '车甲'], [null, null, '车乙']])
|
||||
expect(groups.map(group => [group.dispatch.vehicle, group.dispatch.driver, group.dispatch.remark])).toEqual([[null, null, null], [null, null, null]])
|
||||
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('clears an old departure request without blocking new empty creation',async()=>{
|
||||
@@ -178,14 +177,14 @@ it('keeps server draft cargo across pages, resets filters and sends regional tab
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it('defaults publication on for every new dispatch and carries opt-out in the atomic draft conversion', async () => {
|
||||
it.each([false, true])('defaults publication off and carries publication %s in the atomic draft conversion', async publish => {
|
||||
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
|
||||
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
|
||||
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void>; form: { publishLooseboardDemand: boolean } }
|
||||
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void>; publishLooseboardDemand: boolean }
|
||||
state.restoreDraft('server-token'); await flushPromises()
|
||||
expect(state.form.publishLooseboardDemand).toBe(true)
|
||||
state.form.publishLooseboardDemand = false
|
||||
expect(state.publishLooseboardDemand).toBe(false)
|
||||
state.publishLooseboardDemand = publish
|
||||
await state.submit()
|
||||
expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ groups: [expect.objectContaining({ dispatch: expect.objectContaining({ publishLooseboardDemand: false }) })] }))
|
||||
expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ groups: [expect.objectContaining({ dispatch: expect.objectContaining({ publishLooseboardDemand: publish }) })] }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -41,13 +41,7 @@ const { candidates, page, size, total } = candidatePage
|
||||
const loading = computed(() => working.value || candidatePage.loading.value)
|
||||
const query = reactive({ orderNo: '', destinationWarehouseCode: '', sourceStage: '' })
|
||||
const warehouseTab = ref<SmartDispatchWarehouseTab>('ALL')
|
||||
const settings = reactive<Record<string, { form: { plannedDepartureAt: string; estimatedArrivalAt: string; remark: string; publishLooseboardDemand: boolean } }>>({})
|
||||
const currentSettings = computed(() => {
|
||||
const key = mode.value === 'empty' ? 'empty' : activeContainerId.value || 'unselected'
|
||||
settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', publishLooseboardDemand: true } }
|
||||
return settings[key]!
|
||||
})
|
||||
const form = computed(() => currentSettings.value.form)
|
||||
const publishLooseboardDemand = ref(false)
|
||||
const frozen = computed(() => !canCreate.value || preparing.value || saving.value || recovery.busy.value)
|
||||
const canCreate = computed(() => access.can(dispatchActionPermissions.CREATE))
|
||||
const canEditCargo = computed(() => access.can(dispatchActionPermissions.ADD_CARGO))
|
||||
@@ -147,12 +141,11 @@ async function loadCandidates(sequence = generation) {
|
||||
function resetSearch() { if (frozen.value) return; query.orderNo = ''; query.destinationWarehouseCode = ''; query.sourceStage = ''; warehouseTab.value = 'ALL'; void search() }
|
||||
async function search() { if (frozen.value) return; page.value = 1; await loadCandidates() }
|
||||
function restoreDraft(token: string) { if (frozen.value) return; const value = drafts.value.find(row => row.draftToken === token); if (!value) return; draft.value = value; warehouseId.value = value.warehouseId; warehouseTab.value = 'ALL'; activeContainerId.value = value.containers[0]?.draftContainerId || ''; void search() }
|
||||
function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
|
||||
function creation(cargo: Candidate[]): CreateDispatch {
|
||||
candidatePage.assertCurrent(cargo)
|
||||
requireBatchVersions(cargo.flatMap(row => row.appointmentBatches))
|
||||
if (cargo.some(row => row.warehouseId !== warehouseId.value || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('货物仓库、来源版本或预约客户不一致,请重新读取')
|
||||
const config = containerId ? settings[containerId] : currentSettings.value
|
||||
return { publishLooseboardDemand: config?.form.publishLooseboardDemand ?? true, idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
|
||||
return { publishLooseboardDemand: publishLooseboardDemand.value, idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
|
||||
}
|
||||
async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string): Promise<boolean> {
|
||||
if (!canCreate.value || loading.value || saving.value || pending.value) return false
|
||||
@@ -178,7 +171,7 @@ async function draftAction(action: DraftCommand['action'], source?: Candidate, c
|
||||
const groups = current.containers.filter(row => row.sourceKeys.length).map(row => {
|
||||
const cargo = row.sourceKeys.map(keyValue => current.sources.find(value => value.sourceKey === keyValue))
|
||||
if (cargo.some(rowValue => !rowValue)) throw new DispatchInputError('草稿来源尚未恢复完整,请重新读取')
|
||||
return { draftContainerId: row.draftContainerId, dispatch: creation(cargo as Candidate[], row.draftContainerId) }
|
||||
return { draftContainerId: row.draftContainerId, dispatch: creation(cargo as Candidate[]) }
|
||||
})
|
||||
if (!groups.length) throw new DispatchInputError('请至少选择一组真实货物;空单请使用先建空单')
|
||||
body = { ...common, groups } satisfies ConvertDraft
|
||||
@@ -250,7 +243,7 @@ async function submit() {
|
||||
if (result) { emit('created'); emit('update:modelValue', false); ElMessage.success(`空排车单 ${result.dispatchNo} 已创建`) }
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '建单失败,请重试') }
|
||||
}
|
||||
watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
|
||||
watch(() => props.modelValue, value => { if (value) { publishLooseboardDemand.value = false; void init() } else { generation++; candidatePage.reset() } }, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<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="mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
|
||||
@@ -264,7 +257,7 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
|
||||
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
|
||||
<div class="dispatch-candidate-query">
|
||||
<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" @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><el-checkbox v-model="publishLooseboardDemand" :disabled="frozen || loading">发布公共需求</el-checkbox></div>
|
||||
</section>
|
||||
<section v-show="settingsExpanded" id="smart-dispatch-settings" class="dispatch-context-toolbar" aria-label="排车设置">
|
||||
<el-form inline label-position="left" label-width="70px" class="density-form dispatch-setup-fields" :disabled="frozen || loading">
|
||||
@@ -289,16 +282,6 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
|
||||
</template>
|
||||
</SmartDispatchCargoBoard>
|
||||
</template>
|
||||
<el-form v-if="mode === 'empty' || currentContainer" label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="frozen || loading">
|
||||
<section class="density-dialog-section" aria-label="排车计划时间与备注">
|
||||
<h3 class="density-dialog-section__title">{{ mode === 'empty' ? '计划时间与备注' : '第 ' + currentContainer!.sequenceNo + ' 辆车 · 计划时间与备注' }}<small>选填</small><InlineHelp title="计划时间与备注说明"><p>时间和备注分别保存在当前选中的排车安排中。车辆和司机在生成排车单后,通过“维护车辆”补充。</p></InlineHelp></h3>
|
||||
<table class="density-table density-table--editable dispatch-create-table"><tbody>
|
||||
<tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="可先保存,审核前补齐" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间(选填)" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="散板需求"><el-checkbox v-model="form.publishLooseboardDemand" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)">发布公共散板需求</el-checkbox></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
@@ -318,8 +301,6 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
|
||||
: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 td:not([colspan]) { width: 50%; }
|
||||
.dispatch-context-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-6); }
|
||||
.dispatch-setup-fields { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-4) var(--admin-space-6); }
|
||||
.dispatch-setup-fields :deep(.el-form-item) { margin: 0; }
|
||||
|
||||
@@ -266,7 +266,7 @@ onMounted(() => { void load() })
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="planEditOpen" :title="`编辑入库单 · ${planEditTarget?.orderNo || '—'}`" fullscreenable confirm-text="保存" :loading="planEditLoading" :confirm-disabled="planEditLoading || !planEditForm.warehouseId" :confirm-loading="planEditSaving" @confirm="submitPlanEdit"><div class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="入库计划来源"><h3 class="density-dialog-section__title">计划信息</h3><table class="density-table"><tbody><tr><th scope="row">入库单号</th><td>{{ planEditTarget?.orderNo || '—' }}</td></tr></tbody></table></section>
|
||||
<LoadErrorState v-if="mutationError" compact title="入库单保存失败" :message="mutationError.message" :loading="planEditSaving" retry-text="重试保存" @retry="submitPlanEdit"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="目的仓登记"><h3 class="density-dialog-section__title">目的仓登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="planEditLoading || planEditSaving"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable placeholder="请选择目的仓" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item></td></tr></tbody></table></el-form></section>
|
||||
<section class="density-dialog-section" aria-label="目的仓登记"><h3 class="density-dialog-section__title">目的仓登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="planEditLoading || planEditSaving"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable placeholder="请选择目的仓" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item></td></tr></tbody></table></el-form></section>
|
||||
</div></AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -1289,7 +1289,7 @@ onMounted(() => { void load() })
|
||||
<tr><th scope="row">计划入库仓</th><td>{{ inboundPlanWarehouseLabel || '—' }}</td></tr><tr><th scope="row">入仓码</th><td>{{ inboundPlanForm.inboundCode || '—' }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="入库计划登记"><h3 class="density-dialog-section__title">计划登记</h3>
|
||||
<section class="density-dialog-section" aria-label="入库计划登记"><h3 class="density-dialog-section__title">计划登记</h3>
|
||||
<table class="density-table density-table--editable last-mile-command-table"><tbody>
|
||||
<tr><td><el-form-item label="托数" required><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="density-dialog-note" v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</span></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td><td><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><InlineHelp title="合托提示说明">仅作为现场可选提示,不强制工人合托。</InlineHelp></el-form-item></td></tr>
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('PalletsView', () => {
|
||||
})
|
||||
|
||||
it('loads the all-warehouse pallet list and summary when the page opens', async () => {
|
||||
vi.mocked(getPalletSummary).mockResolvedValueOnce({ totalCount: 250, idleCount: 100, occupiedCount: 98, scrappedCount: 52 })
|
||||
vi.mocked(getPalletSummary).mockResolvedValueOnce({ totalCount: 1250, idleCount: 1100, occupiedCount: 98, scrappedCount: 52 })
|
||||
|
||||
const wrapper = mount(PalletsView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
@@ -42,10 +42,10 @@ describe('PalletsView', () => {
|
||||
expect(getPalletSummary).toHaveBeenCalledWith('', { page: 1, size: 10, keyword: undefined, palletType: undefined, status: undefined })
|
||||
expect(wrapper.get('[aria-label="托盘管理列表"]').text()).toContain('PLT-1')
|
||||
expect(wrapper.get('[aria-label="托盘管理列表"]').text()).not.toContain('请先选择仓库后查询')
|
||||
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('托盘总数250')
|
||||
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('闲置托盘100')
|
||||
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('占用托盘98')
|
||||
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('已报废52')
|
||||
const cards = wrapper.get('[aria-label="海外托盘数量概览"]').findAll('article')
|
||||
expect(cards.map(card => card.get('h2').text())).toEqual(['托盘总数', '闲置托盘', '占用托盘', '已报废'])
|
||||
expect(cards.map(card => card.get('strong').text())).toEqual(['1,250', '1,100', '98', '52'])
|
||||
expect(cards.map(card => card.get('.workbench-metric-value > span').text())).toEqual(['个', '个', '个', '个'])
|
||||
})
|
||||
|
||||
it('prints one pallet from the all-warehouse list and allows retry after failure', async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { Layers, Package, PackageCheck, PackageX, Plus } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -167,7 +167,24 @@ onMounted(initialize)
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density admin-workbench"><OperationErrorToast :details="mutationError" />
|
||||
<section class="summary-grid workbench-panel is-flush" aria-label="海外托盘数量概览"><article><span>托盘总数</span><strong>{{ summary.totalCount }}</strong></article><article><span>闲置托盘</span><strong class="idle">{{ summary.idleCount }}</strong></article><article><span>占用托盘</span><strong class="occupied">{{ summary.occupiedCount }}</strong></article><article><span>已报废</span><strong>{{ summary.scrappedCount }}</strong></article></section>
|
||||
<section class="summary-grid" aria-label="海外托盘数量概览">
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Layers aria-hidden="true" /></span><h2 class="workbench-metric-label">托盘总数</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ summary.totalCount.toLocaleString('zh-CN') }}</strong><span>个</span></div>
|
||||
</article>
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><PackageCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">闲置托盘</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ summary.idleCount.toLocaleString('zh-CN') }}</strong><span>个</span></div>
|
||||
</article>
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Package aria-hidden="true" /></span><h2 class="workbench-metric-label">占用托盘</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ summary.occupiedCount.toLocaleString('zh-CN') }}</strong><span>个</span></div>
|
||||
</article>
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="rose">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><PackageX aria-hidden="true" /></span><h2 class="workbench-metric-label">已报废</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ summary.scrappedCount.toLocaleString('zh-CN') }}</strong><span>个</span></div>
|
||||
</article>
|
||||
</section>
|
||||
<ListPageLayout aria-label="托盘管理列表">
|
||||
|
||||
|
||||
@@ -203,13 +220,9 @@ onMounted(initialize)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.summary-grid span { color:var(--workbench-muted); font-size:var(--admin-font-caption); }.summary-grid .idle { color:var(--el-color-success); }.summary-grid .occupied { color:var(--el-color-warning); }
|
||||
.pallet-editor-table { table-layout: fixed; }
|
||||
.pallet-editor-table td:not([colspan]) { width: 50%; }
|
||||
|
||||
.summary-grid { display: flex; gap: 0; flex-wrap: wrap; }
|
||||
.summary-grid article { display: flex; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border: 0; border-radius: 0; background:var(--workbench-surface); flex: 1 1 200px; align-items: baseline; border-right: 1px solid var(--workbench-border); }
|
||||
.summary-grid article:last-child { border-right: 0; }
|
||||
.summary-grid strong { font-size: var(--admin-font-body); }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--workbench-grid-gap); }
|
||||
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user