优化
This commit is contained in:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user