From 4f5cec1b94dd2efb17da1b21b96557d2f12a67ec Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 17:49:04 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/customers/pages/CustomersView.vue | 6 +- .../pages/OverseasStorageFeesView.vue | 4 +- .../measurement/measurementDensity.css | 3 +- .../overseas-warehouse/PROTOTYPE_MATRIX.md | 2 + .../DispatchDocumentsDialog.test.ts | 1 + .../components/DispatchDocumentsDialog.vue | 3 +- .../DispatchLoadingDocuments.test.ts | 11 ++++ .../components/DispatchLoadingDocuments.vue | 8 ++- .../DispatchWorkflowDialog.layout.test.ts | 4 +- .../components/DispatchWorkflowDialog.test.ts | 64 ++++++++++++++++++- .../components/DispatchWorkflowDialog.vue | 45 +++++++++---- .../components/SmartDispatchDialog.vue | 2 +- .../components/TransportExecutionPanel.vue | 2 +- .../dispatchCommandRecovery.ts | 4 +- .../dispatchPresentation.ts | 2 +- .../pages/DispatchDetailView.test.ts | 14 ++-- .../pages/DispatchDetailView.vue | 3 +- .../pages/DispatchesView.test.ts | 48 +++++++++----- .../pages/DispatchesView.vue | 10 +-- src/styles/adminDensity.test.ts | 12 +++- 20 files changed, 192 insertions(+), 56 deletions(-) diff --git a/src/modules/customers/pages/CustomersView.vue b/src/modules/customers/pages/CustomersView.vue index b3eb6a7f..58b79d13 100644 --- a/src/modules/customers/pages/CustomersView.vue +++ b/src/modules/customers/pages/CustomersView.vue @@ -1086,7 +1086,7 @@ onMounted(() => { void loadCustomers() }) -

基础资料

+

基础资料

@@ -1095,7 +1095,7 @@ onMounted(() => { void loadCustomers() })
-

联系与商务

仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。

+

联系与商务

仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。

@@ -1103,7 +1103,7 @@ onMounted(() => { void loadCustomers() })
-

企业认证资料

营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。

支持 PDF、JPG、PNG,单个文件不超过 5MB;* 为提交审核必填项。

{{ statusMeta(editorStatus).label }}
+

企业认证资料

营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。

支持 PDF、JPG、PNG,单个文件不超过 5MB;* 为提交审核必填项。

{{ statusMeta(editorStatus).label }}
{{ form.unifiedSocialCreditCode }}{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。' }}
diff --git a/src/modules/customers/pages/OverseasStorageFeesView.vue b/src/modules/customers/pages/OverseasStorageFeesView.vue index af760d5a..f7717373 100644 --- a/src/modules/customers/pages/OverseasStorageFeesView.vue +++ b/src/modules/customers/pages/OverseasStorageFeesView.vue @@ -93,9 +93,9 @@ onMounted(() => { void load() }) -
+
-
+

{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档

新增阶梯
diff --git a/src/modules/inbounds/measurement/measurementDensity.css b/src/modules/inbounds/measurement/measurementDensity.css index 1e7ea3ce..0eddcf71 100644 --- a/src/modules/inbounds/measurement/measurementDensity.css +++ b/src/modules/inbounds/measurement/measurementDensity.css @@ -4,8 +4,9 @@ .inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context { flex: 1 1 auto; } .inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context p > span { flex-shrink: 0; } .inbound-measurement-workspace-dialog.inbound-density .measurement-summary-status { flex-wrap: wrap; } -.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section { min-width: 0; margin: var(--admin-space-5, 12px) 0 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-work-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-work-bg); } +.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section { min-width: 0; margin: var(--admin-space-5, 12px) 0 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--cc-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-surface, #fff); } +.inbound-measurement-workspace-dialog.inbound-density .measurement-deviation-alert { background: var(--admin-surface, #fff); } .inbound-measurement-workspace-dialog.inbound-density .measurement-subheading { color: #18795d; font-size: var(--admin-font-section, 14px); } .inbound-measurement-workspace-dialog.inbound-density .measurement-declaration { min-width: 0; margin-top: var(--admin-space-3, 8px); padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-source-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-source-bg); } diff --git a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md index 42106cbf..ca34d513 100644 --- a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md @@ -429,3 +429,5 @@ | 页面 | 操作 | 原型/确认依据 | 承载与实现 | 验收 | |---|---|---|---|---| | 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 自动回归单独报告;1920×1080、100%待人工截图确认 | + +| 仓库派送排车列表/详情 → 维护车辆信息 | 提交审核入口合并 | 2026-10-21 用户确认:隐藏调整卸货顺序,提交审核移入车辆维护弹框,不需要备注 | 沿用原居中弹框;底部保存/提交审核,提交先保存本次资料再按新版本送审;去掉维护和提交备注输入,保留审核驳回原因 | 定向自动检查单独报告;1920×1080、100%待人工截图确认 | diff --git a/src/modules/overseas-warehouse/components/DispatchDocumentsDialog.test.ts b/src/modules/overseas-warehouse/components/DispatchDocumentsDialog.test.ts index ee7dd20b..3b795447 100644 --- a/src/modules/overseas-warehouse/components/DispatchDocumentsDialog.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchDocumentsDialog.test.ts @@ -49,6 +49,7 @@ 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() await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '装车资料')!.trigger('click') + expect(wrapper.getComponent(DeliveryDocumentsDialog).props('categories')).toEqual(['LOADING_PHOTO', 'BOL']) await wrapper.getComponent(DeliveryDocumentsDialog).find('input').setValue('BOL-CHANGED') await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '油耗图片')!.trigger('click') expect(getDeliveryDocuments).toHaveBeenCalledTimes(1) diff --git a/src/modules/overseas-warehouse/components/DispatchDocumentsDialog.vue b/src/modules/overseas-warehouse/components/DispatchDocumentsDialog.vue index d718f6f9..89e030a6 100644 --- a/src/modules/overseas-warehouse/components/DispatchDocumentsDialog.vue +++ b/src/modules/overseas-warehouse/components/DispatchDocumentsDialog.vue @@ -66,8 +66,9 @@ watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
+

关联派送单资料

- +

{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}

完成装车并补齐装车资料后,进入派送中管理{{ tab === 'FUEL' ? '油耗图片' : '签收文件' }}。

diff --git a/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.test.ts b/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.test.ts index 4e459d5e..a2aec516 100644 --- a/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.test.ts @@ -62,6 +62,17 @@ it('shows existing photos without exposing upload for a read-only account', asyn expect(api.uploadLoadingPhoto).not.toHaveBeenCalled(); wrapper.unmount() }) +it('allows choosing another image after the server definitively rejects the file', async () => { + vi.mocked(api.uploadLoadingPhoto).mockRejectedValueOnce(new ApiResponseError('图片内容无效', 'A0400')) + const wrapper = setup(); await flushPromises(); await upload(wrapper) + expect(wrapper.text()).toContain('图片内容无效') + expect(wrapper.find('[role="status"]').exists()).toBe(false) + expect(api.bindLoadingPhoto).not.toHaveBeenCalled() + await upload(wrapper) + expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(2) + expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount() +}) + it('allows saving photos while loading and keeps the loading stage', async () => { vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ stage: 'LOADING' })) vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ stage: 'LOADING', recordVersion: '1', photos: [photo] })) diff --git a/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.vue b/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.vue index e0090ee3..17dba294 100644 --- a/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.vue +++ b/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.vue @@ -69,7 +69,13 @@ async function upload(slot: LoadingPhotoSlot, event: Event) { try { ensureScope(); pending.value = { slot, key: crypto.randomUUID() }; persist() pending.value.photo = await uploadLoadingPhoto(number, slot, file, pending.value.key); ensureScope(); persist(); await bind() - } catch (cause) { if (active) error.value = getApiErrorDetails(cause, '装车照片上传或保存失败,请核对原请求') } + } catch (cause) { + if (active) { + error.value = getApiErrorDetails(cause, '装车照片上传或保存失败,请核对原请求') + // 文件校验明确拒绝时尚未创建上传意图,允许重新选择;未知结果仍保留原请求。 + if (pending.value && !pending.value.photo && /^(?:A0|HTTP_)?400$/.test(String(error.value.code))) { pending.value = null; persist() } + } + } finally { if (active) busy.value = false } } onMounted(() => { diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts index 71f66c16..9b834b9e 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts @@ -54,7 +54,7 @@ function field(wrapper: Pick, label: string) { describe('dispatch workflow native layout', () => { it('submits all edited resources, dates and zero version through the continuous fullscreen form', async () => { const { dialog } = await render('EDIT') - expect(dialog.props('title')).toBe('维护车辆信息 · PC/1') + expect(dialog.props('title')).toBe('维护车辆 · PC/1') expect(dialog.getComponent(DispatchResourceFields).props('continuous')).toBe(true) await field(dialog, '车牌号').get('input').setValue('CA-NEW') field(dialog, '实际托位容量').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 22) @@ -76,7 +76,7 @@ describe('dispatch workflow native layout', () => { expect(dialog.text()).toContain('workflow-request') expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined() await wrapper.setProps({ dispatchNo: 'PC/2', action: 'CANCEL' }); await flushPromises() - expect(dialog.props('title')).toBe('维护车辆信息 · PC/1') + expect(dialog.props('title')).toBe('维护车辆 · PC/1') await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises() expect((field(dialog, '车牌号').get('input').element as HTMLInputElement).value).toBe('CA-PRESERVED') expect((dialog.get('textarea[aria-label="本次操作备注"]').element as HTMLTextAreaElement).value).toBe('保留本次输入') diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts index 87bd2157..411baee8 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts @@ -12,7 +12,7 @@ vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '
' } })) vi.mock('../api/dispatches', () => ({ markDispatchException: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() })) -interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(): Promise; retryError(): Promise; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail } +interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(submitReviewAfterSave?: boolean): Promise; retryError(): Promise; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail } async function open(action: DispatchAction, allowed = true) { vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } })) const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises() @@ -215,3 +215,65 @@ it('does not submit an exception when the server disallows the action', async () state.exception.exceptionType = 'REFUSED'; await state.submit() expect(api.markDispatchException).not.toHaveBeenCalled(); wrapper.unmount() }) + +it('submits review from vehicle maintenance after saving the current inputs and reading the new version', async () => { + const { wrapper, state } = await open('EDIT') + const gate = { allowed: true, blockCode: null, blockReason: null } + expect(wrapper.find('[aria-label="本次操作备注"]').exists()).toBe(false) + expect(wrapper.findAll('button').some(button => button.text() === '保存')).toBe(true) + expect(wrapper.findAll('button').some(button => button.text() === '提交审核')).toBe(true) + vi.mocked(api.updateDispatch).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey)) + vi.mocked(api.submitDispatchReview).mockImplementation(async (_, body) => ({ ...command('SUBMIT_REVIEW', body.idempotencyKey), recordVersion: 2 })) + vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { EDIT: gate, SUBMIT_REVIEW: gate } })).mockResolvedValueOnce(detail({ recordVersion: 2 })) + await state.submit(true) + expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, vehicle: expect.any(Object), driver: expect.any(Object) })) + expect(api.submitDispatchReview).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 1, remark: null })) + expect(vi.mocked(api.updateDispatch).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(api.submitDispatchReview).mock.invocationCallOrder[0]!) + expect(wrapper.emitted('update:modelValue')).toContainEqual([false]); wrapper.unmount() +}) +it('save alone never submits review and a failed save cannot advance review', async () => { + const first = await open('EDIT') + vi.mocked(api.updateDispatch).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey)) + vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1 })) + await first.state.submit(); expect(api.submitDispatchReview).not.toHaveBeenCalled(); first.wrapper.unmount() + const second = await open('EDIT') + vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('车辆资料不完整', 'A0400')) + await second.state.submit(true); expect(api.submitDispatchReview).not.toHaveBeenCalled() + expect(second.wrapper.text()).toContain('车辆资料不完整'); second.wrapper.unmount() +}) +it('rechecks review eligibility after save and leaves the saved editor open if blocked', async () => { + const { wrapper, state } = await open('EDIT') + vi.mocked(api.updateDispatch).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey)) + vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, SUBMIT_REVIEW: { allowed: false, blockCode: 'MISSING', blockReason: '预约资料未齐全' } } })) + await state.submit(true) + expect(api.submitDispatchReview).not.toHaveBeenCalled(); expect(state.detail.recordVersion).toBe(1) + expect(wrapper.text()).toContain('预约资料未齐全'); expect(wrapper.emitted('update:modelValue')).toBeUndefined(); wrapper.unmount() +}) +it('retains the follow-up review intent through an unknown save and does not save twice on recovery', async () => { + const { wrapper, state } = await open('EDIT') + vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('save outcome unknown')) + await state.submit(true) + const pending = useDispatchCommandRecovery(() => identity.id).pending.value! + expect(pending.submitReviewAfterSave).toBe(true) + expect(JSON.parse(sessionStorage.getItem(`dispatch-command:${identity.id}`)!).submitReviewAfterSave).toBe(true) + vi.mocked(api.getDispatchCommand).mockResolvedValueOnce(command('EDIT', String(pending.body.idempotencyKey))) + vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: true, blockCode: null, blockReason: null } } })).mockResolvedValueOnce(detail({ recordVersion: 2 })) + vi.mocked(api.submitDispatchReview).mockImplementationOnce(async (_, body) => ({ ...command('SUBMIT_REVIEW', body.idempotencyKey), recordVersion: 2 })) + await state.submit() + expect(api.updateDispatch).toHaveBeenCalledTimes(1); expect(api.submitDispatchReview).toHaveBeenCalledTimes(1) + expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull(); wrapper.unmount() +}) +it('recovers an unknown review submission without saving the vehicle again', async () => { + const { wrapper, state } = await open('EDIT') + vi.mocked(api.updateDispatch).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey)) + vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: true, blockCode: null, blockReason: null } } })) + vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new Error('review outcome unknown')) + await state.submit(true) + const pending = useDispatchCommandRecovery(() => identity.id).pending.value! + expect(pending.action).toBe('SUBMIT_REVIEW'); expect(pending.body).toMatchObject({ recordVersion: 1, remark: null }) + vi.mocked(api.getDispatchCommand).mockResolvedValueOnce({ ...command('SUBMIT_REVIEW', String(pending.body.idempotencyKey)), recordVersion: 2 }) + vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 2 })) + await state.submit() + expect(api.updateDispatch).toHaveBeenCalledTimes(1); expect(api.submitDispatchReview).toHaveBeenCalledTimes(1) + expect(wrapper.emitted('update:modelValue')).toContainEqual([false]); wrapper.unmount() +}) diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue index 9ba4dd14..0fa8cca7 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue @@ -45,8 +45,10 @@ let editorRequestKey = '' const cargoAction = computed(() => ['ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value)) const cargoEntryMessage = '货物安排统一在智能排车中修改;如有未确认操作,请从智能排车恢复原请求。' const allowed = computed(() => !cargoAction.value && !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true)) -const width = computed(() => ['EDIT', 'LOADING_COMPLETE'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW', 'MARK_EXCEPTION'].includes(currentAction.value) ? '760px' : '560px') -const frozen = computed(() => busy.value || Boolean(pending.value)) +const vehicleEditor = computed(() => currentOperation.value !== 'ROUTE' && (currentAction.value === 'EDIT' || props.action === 'EDIT' && currentAction.value === 'SUBMIT_REVIEW')) +const submitting = ref(false) +const width = computed(() => vehicleEditor.value || currentAction.value === 'LOADING_COMPLETE' ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW', 'MARK_EXCEPTION'].includes(currentAction.value) ? '760px' : '560px') +const frozen = computed(() => submitting.value || busy.value || Boolean(pending.value)) const evidenceBusy = reactive({ EXCEPTION: false, VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false }) const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean)) @@ -115,6 +117,7 @@ function payload(): object { if (!measurements.value.length || measurements.value.some(row => row.weightKg == null || row.volumeCbm == null || row.palletSpaces == null || row.deliveryIndex == null || row.weightKg <= 0 || row.volumeCbm <= 0 || row.palletSpaces <= 0 || !Number.isInteger(row.palletSpaces) || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 0)) throw new DispatchInputError('请核对每托真实托位、重量、体积和卸货站;不能以参考值代替实测') return { recordVersion: requireVersion(preview.value.recordVersion), idempotencyKey: common.idempotencyKey, manifestHash: preview.value.manifestHash, measurements: measurements.value.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: row.palletSpaces!, weightKg: row.weightKg!, volumeCbm: row.volumeCbm!, deliveryIndex: row.deliveryIndex! })), remark: form.remark || null } satisfies LoadingComplete } + case 'SUBMIT_REVIEW': return { ...common, remark: null } default: return { ...common, remark: form.remark || null } } } @@ -133,20 +136,35 @@ async function send(command: PendingDispatchCommand): Promise { default: throw new DispatchInputError('此操作不属于当前后台作业入口') } } -async function submit() { - if (busy.value || loading.value || uploadingEvidence.value) return +async function submit(submitReviewAfterSave = false) { + if (submitting.value || busy.value || loading.value || uploadingEvidence.value) return + submitting.value = true error.value = null try { if (cargoAction.value) throw new DispatchInputError(cargoEntryMessage) if (pending.value?.action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车') if (!pending.value) { - recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value }) + recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value, ...(submitReviewAfterSave && vehicleEditor.value ? { submitReviewAfterSave: true } : {}) }) editorRequestKey = String(recovery.pending.value?.body.idempotencyKey || '') } const routeCommand = pending.value?.operation === 'ROUTE' const reviewReturned = ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(pending.value?.action || '') && pending.value?.body.decision === 'REJECTED' - const result = await recovery.execute(send, api.getDispatch, api.getDispatchCommand) + const continueReview = pending.value?.action === 'EDIT' && pending.value.submitReviewAfterSave === true + const readCurrent = async (no: string) => { + const value = await api.getDispatch(no) + if (value.dispatchNo === currentNo.value) detail.value = value + return value + } + let result = await recovery.execute(send, readCurrent, api.getDispatchCommand) if (!result) return + if (continueReview) { + emit('saved') + if (!canDispatchAction(detail.value?.actions, 'SUBMIT_REVIEW', access.can)) throw new DispatchInputError(detail.value?.actions.SUBMIT_REVIEW?.blockReason || '车辆信息已保存,当前不能提交审核,请核对资料') + recovery.begin(result.dispatchNo, 'SUBMIT_REVIEW', { recordVersion: requireVersion(detail.value?.recordVersion), idempotencyKey: crypto.randomUUID(), remark: null }) + editorRequestKey = String(pending.value?.body.idempotencyKey || '') + result = await recovery.execute(send, readCurrent, api.getDispatchCommand) + if (!result) return + } ElMessage.success(reviewReturned ? '已退回客服处理(车辆信息),修改后需重新提交调度审核' : result.action === 'LOADING_COMPLETE' ? result.stage === 'DELIVERING' ? '装车完成、资料齐全,已进入派送中' : '装车完成,请在装车后补齐装车资料' : routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`) emit('saved'); open.value = false } catch (cause) { @@ -157,7 +175,7 @@ async function submit() { emit('rejected', error.value) await refreshFacts() } - } + } finally { submitting.value = false } } async function refreshFacts() { const sequence = ++generation, no = currentNo.value @@ -185,7 +203,7 @@ function moveStop(index: number, offset: number) { const target = index + offset watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true }) - -

操作说明

操作开始天数结束天数价格
删除以上
-

本次备注

+

本次备注

+
diff --git a/src/modules/overseas-master-data/pages/CapacityOffersView.vue b/src/modules/overseas-master-data/pages/CapacityOffersView.vue index e8f42d19..c3c3439f 100644 --- a/src/modules/overseas-master-data/pages/CapacityOffersView.vue +++ b/src/modules/overseas-master-data/pages/CapacityOffersView.vue @@ -18,10 +18,12 @@ import { listLooseboardDemands, listCapacityOffers, listCapacityInquiries, getCa import { offerStatusLabels, inquiryStatusLabels, offerTime, loadText, type LooseboardDemand, type CapacityOffer, type CapacityInquiry, type InquiryResult, type OfferQuery, type InquiryQuery } from '../capacityOfferTypes' import { inquirySourceQuery, inquiryOrderTarget } from '../inquiryNavigation' import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands' +import { false } from '../capacityOfferFeatures' const router = useRouter(), access = useAccessPreviewStore() const manage = computed(() => access.can('platform:fleet:capacity-offer:manage')) -const tab = ref<'offers' | 'inquiries' | 'demands'>(manage.value ? 'inquiries' : 'offers') -const filters = ref(manage.value ? { scope: 'TODO' } : {}), applied = ref({ ...filters.value }) +// 当前只展示余位需求池;登记、原单匹配及履约模板直接隐藏,原实现保留。 +const tab = ref<'offers' | 'inquiries' | 'demands'>('offers') +const filters = ref({}), applied = ref({ ...filters.value }) const page = ref(1), size = ref(20), total = ref(0), loading = ref(false), error = ref(null) const offers = ref([]), inquiries = ref([]) const editorOpen = ref(false), editing = ref(null), selectedOffer = ref(null), readonly = ref(true) @@ -56,8 +58,8 @@ let sequence = 0, editorSequence = 0 const fields = computed(() => tab.value === 'offers' ? [ { key: 'origin', label: '起运仓', type: 'input', placeholder: '起运仓名称', width: 170 }, { key: 'destination', label: '目的站', type: 'input', placeholder: '目的站名称', width: 170 }, { key: 'departureFrom', label: '发车起日', type: 'date', placeholder: '发车起日', width: 170 }, { key: 'departureTo', label: '发车止日', type: 'date', placeholder: '发车止日', width: 170 }, - { key: 'canConfirm', label: '接单资格', type: 'select', placeholder: '全部接单资格', width: 150, options: [{ value: 'true', label: '可确认承运' }, { value: 'false', label: '暂不可确认' }] }, - ...(manage.value ? [{ key: 'scope', label: '发布范围', type: 'select', placeholder: '可登记意向', width: 180, options: [{ value: 'AVAILABLE', label: '可登记意向' }, { value: 'ALL', label: '全部发布(含历史)' }] } as QueryField, { key: 'status', label: '发布状态', type: 'select', placeholder: '全部发布状态', width: 140, options: Object.entries(offerStatusLabels).map(([value, label]) => ({ value, label })) } as QueryField] : []), + { key: 'canConfirm', label: '余位状态', type: 'select', placeholder: '全部状态', width: 150, options: [{ value: 'true', label: '余位已核对' }, { value: 'false', label: '待核对或不可承运' }] }, + ...(manage.value ? [{ key: 'scope', label: '发布范围', type: 'select', placeholder: '当前发布', width: 180, options: [{ value: 'AVAILABLE', label: '当前发布' }, { value: 'ALL', label: '全部发布(含历史)' }] } as QueryField, { key: 'status', label: '发布状态', type: 'select', placeholder: '全部发布状态', width: 140, options: Object.entries(offerStatusLabels).map(([value, label]) => ({ value, label })) } as QueryField] : []), ] : tab.value === 'demands' ? [{ key: 'keyword', label: '需求关键词', type: 'input', placeholder: '原单号 / 起运地 / 目的地', width: 280 }] : [ { key: 'keyword', label: '意向关键词', type: 'input', placeholder: '预订号 / 客户 / 车次', width: 260 }, { key: 'status', label: '意向状态', type: 'select', placeholder: '全部状态', width: 150, options: Object.entries(inquiryStatusLabels).map(([value, label]) => ({ value, label })) }, @@ -68,7 +70,7 @@ const columns = computed(() => [ { key: 'warehouseName', label: '起运仓', minWidth: 155 }, { key: 'destinations', label: '目的站与可接余位', minWidth: 310, slotName: 'destinations', singleLine: false }, { key: 'plannedDepartureAt', label: '发车 / 最晚送仓', minWidth: 195, slotName: 'times' }, ...(manage.value ? [{ key: 'segments', label: '分段计划占用 / 外接预留', minWidth: 320, slotName: 'loads', singleLine: false }] : []), - { key: 'status', label: '接单状态', minWidth: 170, slotName: 'status', singleLine: false }, { key: 'contactName', label: '调度联系信息', minWidth: 180, slotName: 'contact' }, + { key: 'status', label: '发布状态', minWidth: 170, slotName: 'status', singleLine: false }, { key: 'contactName', label: '调度联系信息', minWidth: 180, slotName: 'contact' }, ]) const canCreate = computed(() => manage.value || access.can('platform:fleet:capacity-offer:list') && access.can('platform:fleet:capacity-offer:inquiry-create')) async function create(row: CapacityOffer) { @@ -77,7 +79,7 @@ async function create(row: CapacityOffer) { try { const value = await getCapacityOffer(row.offerNo); if (current !== editorSequence) return; selectedOffer.value = value.offer; confirmMode.value = false; editing.value = null; readonly.value = false; editorOpen.value = true } catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '登记信息读取失败') } } -function switchTab(value: 'offers' | 'inquiries' | 'demands') { if (tab.value === value) return; filters.value = {}; applied.value = {}; page.value = 1; total.value = 0; tab.value = value } +function switchTab(value: 'offers' | 'inquiries' | 'demands') { if (value !== 'offers' || tab.value === value) return; filters.value = {}; applied.value = {}; page.value = 1; total.value = 0; tab.value = value } async function load() { const current = ++sequence; loading.value = true; error.value = null try { @@ -104,6 +106,7 @@ function accept(value: InquiryResult) { } const commands = useCapacityInquiryCommands('capacity-pool-command', accept) async function inquiryAction(key: string, row: CapacityInquiry) { + if (!false) return followupSequence++ if (openFulfillment(key, row)) return if (key === 'cancel') { await commands.run(row, key); return } @@ -129,30 +132,30 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followu diff --git a/src/modules/overseas-master-data/routes.ts b/src/modules/overseas-master-data/routes.ts index 34c4ecde..766301f6 100644 --- a/src/modules/overseas-master-data/routes.ts +++ b/src/modules/overseas-master-data/routes.ts @@ -6,13 +6,13 @@ export const overseasMasterDataRoutes = [ path: 'overseas-master-data/capacity-offers', name: 'overseas-master-data-capacity-offers', component: () => import('./pages/CapacityOffersView.vue'), - meta: { title: '散板需求', description: '查看车次可接运力并跟进拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false }, + meta: { title: '散板需求', description: '查看车次余位与调度联系方式', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false }, }, { path: 'overseas-master-data/capacity-offers/:offerNo', name: 'overseas-master-data-capacity-offer-detail', component: () => import('./pages/CapacityOfferDetailView.vue'), - meta: { title: '散板需求详情', description: '核对可接运力与本车拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false }, + meta: { title: '散板需求详情', description: '查看车次分段余位与调度联系方式', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false }, }, { path: 'overseas-master-data/fleet-quotes', From ba072611dcf85eb0af8198b6a8419e6bb4072fe9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 18:05:02 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../overseas-master-data/PROTOTYPE_MATRIX.md | 9 + .../components/CapacityDialogClosing.test.ts | 17 +- .../pages/CapacityOfferDetailView.vue | 29 ++- .../pages/CapacityOffersBehavior.test.ts | 189 ++++++++---------- .../pages/CapacityOffersView.vue | 38 ++-- 5 files changed, 145 insertions(+), 137 deletions(-) diff --git a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md index 9d310c78..8f28029d 100644 --- a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md @@ -1,5 +1,14 @@ # 海外通用资料原型核对矩阵 +## 散板需求只读展示(2026-10-21) + +依据本轮用户确认:仅保留内部余位需求池,人工联系下单;登记与后续拼车直接隐藏,不增加开关,不删除代码。当前范围唯一见[拼车主契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md#当前页面范围2026-10-21),覆盖下文此前开放登记的页面结论。 + +| 页面/操作 | 当前承载与调整 | 验收 | +|---|---|---| +| 散板需求列表/查询、分页、详情 | 保持独立列表与原余位来源,默认展示车次余位、线路、时间、联系信息;隐藏待匹配散板、我的登记、登记及待办入口,原代码保留 | 43项定向测试及页面、测试源码定向类型检查通过;待人工1920×1080、100%截图确认 | +| 散板需求详情/查看、重新核对 | 保持独立详情,仅展示运输摘要、联系方式和分段余位;隐藏登记、意向列表、来货及履约窗口,旧登记参数不再打开窗口 | 同上 | + ## 2026-10-20 列表顶部紧凑收尾 依据为本轮用户整柜业务截图及“所有列表顶部紧凑”的确认;列表公共工具栏和筛选内距由 `adminWorkbench.css` 统一维护。 diff --git a/src/modules/overseas-master-data/components/CapacityDialogClosing.test.ts b/src/modules/overseas-master-data/components/CapacityDialogClosing.test.ts index 0f7ee28b..d12f529c 100644 --- a/src/modules/overseas-master-data/components/CapacityDialogClosing.test.ts +++ b/src/modules/overseas-master-data/components/CapacityDialogClosing.test.ts @@ -2,11 +2,10 @@ import ElementPlus, { ElMessageBox } from 'element-plus' import { createPinia } from 'pinia' import { mount, flushPromises, type VueWrapper } from '@vue/test-utils' import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { defineComponent } from 'vue' +import { defineComponent, ref } from 'vue' import { createMemoryHistory, createRouter, RouterView } from 'vue-router' import CapacityInquiryDialog from './CapacityInquiryDialog.vue' import CapacityOfferSettingsDialog from './CapacityOfferSettingsDialog.vue' -import CapacityOfferDetailView from '../pages/CapacityOfferDetailView.vue' import type { CapacityOffer } from '../capacityOfferTypes' import type { FleetTrip } from '../tripTypes' import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork' @@ -64,11 +63,13 @@ it.each(['inquiry', 'settings'] as const)('keeps the real %s dialog and edited i expect(wrapper.emitted('update:modelValue')).toEqual([[false]]) }) -it('blocks browser back with the real router, cached detail and native dialog, without duplicating the global prompt', async () => { +it('preserves route-leave protection in the retained native inquiry dialog without duplicating the global prompt', async () => { const pinia = createPinia(), registry = useUnsavedWorkStore(pinia), confirms = vi.spyOn(ElMessageBox, 'confirm') + // 登记页面入口已隐藏;保留组件自身与真实路由的未保存保护回归。 + const InquiryHost = defineComponent({ components: { CapacityInquiryDialog }, setup: () => ({ offer, open: ref(false) }), template: '' }) const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/pool', name: 'overseas-master-data-capacity-offers', component: { template: '

运力列表

' }, meta: { requiresAuth: true } }, - { path: '/pool/:offerNo', name: 'overseas-master-data-capacity-offer-detail', component: CapacityOfferDetailView, meta: { requiresAuth: true } }, + { path: '/pool/:offerNo', name: 'overseas-master-data-capacity-offer-detail', component: InquiryHost, meta: { requiresAuth: true } }, { path: '/outside', name: 'outside', component: { template: '

外部页面

' }, meta: { workspaceTab: false } }, ] }) // Match the application's intentional policy: cross-workspace switching skips the global prompt. @@ -81,7 +82,7 @@ it('blocks browser back with the real router, cached detail and native dialog, w await router.push('/pool'); await router.isReady() const wrapper = mount(Host, { attachTo: document.body, global: { plugins: [pinia, router, ElementPlus] } }); wrappers.push(wrapper) await router.push('/pool/CO-CLOSE'); await flushPromises() - await wrapper.findAll('button').find(button => button.text() === '登记客户货物')!.trigger('click'); await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '打开保留的登记组件')!.trigger('click'); await flushPromises() await wrapper.get('details summary').trigger('click') const input = wrapper.get('input[placeholder="填写货物名称(确认承运前必填)"]') await input.setValue('UI-修复后保留输入'); await flushPromises() @@ -92,15 +93,15 @@ it('blocks browser back with the real router, cached detail and native dialog, w expect(confirms).toHaveBeenCalledOnce(); expect(wrapper.get('.app-dialog').isVisible()).toBe(true) await continueEditing() expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offer-detail') - expect(input.element.value).toBe('UI-修复后保留输入'); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE') + expect(input.element.value).toBe('UI-修复后保留输入'); expect(getCapacityOffer).not.toHaveBeenCalled() confirms.mockClear(); router.back(); await flushPromises() const message = [...document.querySelectorAll('.el-message-box')].at(-1)! const leave = [...message.querySelectorAll('button')].find(button => button.textContent?.trim() === '放弃并离开') as HTMLButtonElement expect(leave).toBeTruthy(); leave.click(); await flushPromises() expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offers') - expect(confirms).toHaveBeenCalledOnce(); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE') + expect(confirms).toHaveBeenCalledOnce(); expect(getCapacityOffer).not.toHaveBeenCalled() await new Promise(resolve => setTimeout(resolve, 50)); await router.push('/pool/CO-CLOSE'); await flushPromises() - await wrapper.findAll('button').find(button => button.text() === '登记客户货物')!.trigger('click'); await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '打开保留的登记组件')!.trigger('click'); await flushPromises() await wrapper.get('input[placeholder="填写货物名称(确认承运前必填)"]').setValue('另一次修改'); await flushPromises() confirms.mockClear(); const navigation = router.push('/outside'); await flushPromises() expect(confirms).toHaveBeenCalledOnce(); expect(document.body.textContent).toContain('全局离开确认') diff --git a/src/modules/overseas-master-data/pages/CapacityOfferDetailView.vue b/src/modules/overseas-master-data/pages/CapacityOfferDetailView.vue index 263fc536..a8738fbe 100644 --- a/src/modules/overseas-master-data/pages/CapacityOfferDetailView.vue +++ b/src/modules/overseas-master-data/pages/CapacityOfferDetailView.vue @@ -54,13 +54,13 @@ async function load() { const value = await getCapacityOffer(String(route.params.offerNo)) let checked: CapacityInquiry | null = null // 当前只读展示余位,旧登记深链不再打开登记窗口;原流程保留。 - if (false) { + /* if (typeof route.query?.inquiry === 'string' && /^[A-Z][A-Z0-9-]{1,63}$/.test(route.query.inquiry)) { const returned = await getCapacityInquiry(route.query.inquiry) if (returned.offerNo !== value.offer.offerNo) throw new Error('登记不属于当前发布') if (current === sequence) { checked = returned; readonly.value = true; confirmMode.value = false; editorOpen.value = true } } else if (editing.value && editorOpen.value) checked = value.inquiries.records.find(row => row.bookingNo === editing.value?.bookingNo) || await getCapacityInquiry(editing.value.bookingNo) - } + */ if (current === sequence) { resetting = true; detail.value = value; if (checked) editing.value = checked; page.value = value.inquiries.page; size.value = value.inquiries.size; queueMicrotask(() => { resetting = false }) } } catch (cause) { if (current === sequence) { detail.value = null; error.value = getApiErrorDetails(cause, '发布详情读取失败') } } @@ -93,18 +93,29 @@ watch([page, size], loadInquiries) onBeforeUnmount(() => { followupSequence++; sequence++; inquirySequence++ })