@@ -111,6 +139,12 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
.dispatch-evidence--readonly .dispatch-evidence__actions { width: 76px; }
.dispatch-evidence > .el-button { margin-top: var(--admin-space-3); }
.evidence-input { display: none; }
+.evidence-photo-card { margin: 0; text-align: center; }
+.evidence-photo-list { display: grid; gap: var(--admin-space-3); }
+.evidence-photo-preview, .evidence-photo-empty { box-sizing: border-box; width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--el-border-color-lighter); border-radius: var(--el-border-radius-base); color: var(--el-text-color-secondary); }
+.evidence-photo-preview { padding: 0; cursor: pointer; }
+.evidence-photo-preview img { width: 100%; height: 100%; object-fit: contain; }
+.evidence-photo-card figcaption { margin-top: var(--admin-space-3); font-weight: 600; }
.evidence-preview { display: grid; place-items: center; height: min(60vh, 600px); min-width: 0; }
.evidence-image { display: block; width: 100%; height: 100%; min-height: 0; object-fit: contain; }
diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts
index 9b834b9e..ab3a42c7 100644
--- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts
+++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts
@@ -60,17 +60,27 @@ describe('dispatch workflow native layout', () => {
field(dialog, '实际托位容量').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 22)
await field(dialog, '司机姓名').get('input').setValue('新司机')
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-01T10:00:00Z')
- await dialog.get('textarea[aria-label="本次操作备注"]').setValue('维护备注')
+ expect(dialog.find('textarea[aria-label="本次操作备注"]').exists()).toBe(false)
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
const { vehicleStatus: _status, ...vehicle } = resources().vehicle
- expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '维护备注' })
+ expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '真实备注' })
+ })
+ it('saves all three resource photos before submitting review with the refreshed version', async () => {
+ const { dialog } = await render('EDIT')
+ const fields = dialog.findAllComponents(DispatchEvidenceFiles)
+ fields.forEach((field, index) => field.vm.$emit('update:modelValue', [String(401 + index)]))
+ await flushPromises()
+ await dialog.findAll('button').find(button => button.text() === '提交审核')!.trigger('click')
+ await flushPromises()
+ expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 0, vehicle: expect.objectContaining({ evidenceFileIds: ['401'] }), driver: expect.objectContaining({ evidence: expect.objectContaining({ photoFileIds: ['402'], fileIds: ['403'] }) }) }))
+ expect(api.submitDispatchReview).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 1, remark: null }))
+ expect(vi.mocked(api.updateDispatch).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(api.submitDispatchReview).mock.invocationCallOrder[0]!)
})
it('retains the locally submitted draft after close and reopen and retries exactly the same failed request', async () => {
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('结果未确认', 'A0503', 503, null, 'workflow-request')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
const { wrapper, dialog } = await render('EDIT')
await field(dialog, '车牌号').get('input').setValue('CA-PRESERVED')
- await dialog.get('textarea[aria-label="本次操作备注"]').setValue('保留本次输入')
dialog.vm.$emit('confirm'); await flushPromises()
const original = JSON.parse(JSON.stringify(vi.mocked(api.updateDispatch).mock.calls[0]![1])) as UpdateDispatch
expect(dialog.text()).toContain('workflow-request')
@@ -79,30 +89,62 @@ describe('dispatch workflow native layout', () => {
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('保留本次输入')
+ expect(original.remark).toBe('真实备注')
+ expect(dialog.find('textarea[aria-label="本次操作备注"]').exists()).toBe(false)
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toEqual(original)
})
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('keeps %s facts read-only and requires a rejection reason', async action => {
const { dialog } = await render(action)
expect(dialog.get('[aria-label="本车审核依据"]').findAll('input')).toHaveLength(0)
- expect(dialog.text()).toContain('审核通过不代替后续现场扫码')
- field(dialog, '审核结果').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECTED'); await flushPromises()
+ expect(dialog.get('[aria-label="本车审核依据"]').find('h3').exists()).toBe(false)
+ expect(dialog.text()).not.toContain('请核对本车资源与本趟证据')
+ expect(dialog.findAll('button').some(button => button.text() === '取消')).toBe(false)
+ expect(dialog.findComponent({ name: 'ElRadioGroup' }).exists()).toBe(false)
+ expect(dialog.findAll('button').some(button => button.text() === '确认提交')).toBe(false)
+ const reject = dialog.findAll('button').find(button => button.text() === '驳回')!
const review = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading
- dialog.vm.$emit('confirm'); await flushPromises(); expect(review).not.toHaveBeenCalled()
- await dialog.get('textarea').setValue(' 证据不完整 '); dialog.vm.$emit('confirm'); await flushPromises()
+ await reject.trigger('click'); await flushPromises(); expect(review).not.toHaveBeenCalled()
+ await vi.waitFor(() => expect(field(dialog, '驳回原因(必填)').text()).toContain('请填写驳回原因'))
+ await dialog.get('textarea').setValue(' '); await reject.trigger('click'); await flushPromises(); expect(review).not.toHaveBeenCalled()
+ await dialog.get('textarea').setValue(' 证据不完整 '); await reject.trigger('click'); await flushPromises()
expect(review).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), decision: 'REJECTED', reason: '证据不完整' })
- expect(dialog.findAll('input[type="file"]')).toHaveLength(3)
+ expect(dialog.findAll('input[type="file"]')).toHaveLength(0)
})
- it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('uploads three audit attachments with only %s permission and retries the same rejected review', async action => {
- can.mockImplementation(code => code === (action === 'DISPATCH_REVIEW' ? 'platform:fleet:trip:dispatch-review' : 'platform:fleet:trip:loading-review'))
+ it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('approves %s directly and keeps an unknown result on the original request', async action => {
const review = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading
- vi.mocked(review).mockRejectedValueOnce(new Error('unknown review')).mockImplementationOnce(async (_, body) => command(action, body.idempotencyKey))
- const { wrapper, dialog } = await render(action, undefined, true)
+ vi.mocked(review).mockRejectedValueOnce(new Error('结果未知'))
+ const { dialog } = await render(action)
+ await dialog.findAll('button').find(button => button.text() === '审核通过')!.trigger('click')
+ await flushPromises()
+ expect(review).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), decision: 'APPROVED', reason: null })
+ const original = structuredClone(vi.mocked(review).mock.calls[0]![1])
+ expect(dialog.props('showCancel')).toBe(false)
+ expect(dialog.findAll('button').some(button => button.text() === '取消')).toBe(false)
+ expect(dialog.findAll('button').some(button => button.text() === '驳回')).toBe(false)
+ await dialog.findAll('button').find(button => button.text() === '核对并重试原请求')!.trigger('click')
+ await flushPromises()
+ expect(vi.mocked(review).mock.calls[1]![1]).toEqual(original)
+ })
+ it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('disables both %s decisions when write permission is missing', async action => {
+ can.mockReturnValue(false)
+ const { dialog } = await render(action)
+ for (const label of ['驳回', '审核通过']) {
+ const button = dialog.findAll('button').find(item => item.text() === label)!
+ expect(button.attributes('disabled')).toBeDefined()
+ await button.trigger('click')
+ }
+ expect(api.reviewDispatch).not.toHaveBeenCalled()
+ expect(api.reviewDispatchLoading).not.toHaveBeenCalled()
+ })
+ it('uploads all three photos during resource maintenance and retries the exact original edit', async () => {
+ can.mockImplementation(code => code === 'platform:overseas-warehouse:dispatch:edit')
+ vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('unknown edit')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
+ const { wrapper, dialog } = await render('EDIT', undefined, true)
const fields = dialog.findAllComponents(DispatchEvidenceFiles)
expect(fields.map(item => item.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片'])
- expect(fields[0]!.props('referenceIds')).toEqual(['301'])
- expect(fields[0]!.findAll('button').some(button => button.text() === '替换')).toBe(false)
+ expect(fields[0]!.props('modelValue')).toEqual(['301'])
+ expect(dialog.get('.workflow-photo-grid').findAll('figure')).toHaveLength(3)
for (const [index, files] of fields.entries()) {
let resolve!: (value: DispatchEvidenceFile) => void
vi.mocked(api.uploadDispatchEvidence).mockReturnValueOnce(new Promise(done => { resolve = done }))
@@ -110,28 +152,42 @@ describe('dispatch workflow native layout', () => {
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
await input.trigger('change'); await flushPromises()
expect(dialog.props('confirmLoading')).toBe(true)
- dialog.vm.$emit('confirm'); await flushPromises(); expect(review).not.toHaveBeenCalled()
+ dialog.vm.$emit('confirm'); await flushPromises(); expect(api.updateDispatch).not.toHaveBeenCalled()
resolve(evidence(String(401 + index))); await flushPromises()
}
expect(vi.mocked(api.uploadDispatchEvidence).mock.calls.map(call => call[1])).toEqual(['VEHICLE', 'DRIVER_PHOTO', 'DRIVER'])
- field(dialog, '审核结果').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECTED'); await flushPromises()
- await dialog.get('textarea').setValue('需重新核对车牌')
dialog.vm.$emit('confirm'); await flushPromises()
- const original = structuredClone(vi.mocked(review).mock.calls[0]![1])
- expect(original).toMatchObject({ decision: 'REJECTED', reason: '需重新核对车牌', attachments: { vehicleFileIds: ['401'], driverPhotoFileIds: ['402'], licenseFileIds: ['403'] } })
+ const original = structuredClone(vi.mocked(api.updateDispatch).mock.calls[0]![1])
+ expect(original).toMatchObject({ vehicle: { evidenceFileIds: ['301', '401'] }, driver: { evidence: { photoFileIds: ['402'], fileIds: expect.arrayContaining(['403']) } } })
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
- expect(vi.mocked(review).mock.calls[1]![1]).toEqual(original)
- expect(api.updateDispatch).not.toHaveBeenCalled()
+ expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toEqual(original)
+ expect(api.reviewDispatch).not.toHaveBeenCalled()
+ expect(api.reviewDispatchLoading).not.toHaveBeenCalled()
+ })
+ it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('shows saved photos and old audit evidence read-only in %s', async action => {
+ const { wrapper, dialog } = await render(action)
+ const value = detail({ actions })
+ value.vehicleInfo.driver!.evidence!.photoFileIds = ['402']
+ value.reviews.dispatchReview.attachments = { vehicleFileIds: [], driverPhotoFileIds: ['403'], licenseFileIds: [] }
+ vi.mocked(api.getDispatch).mockResolvedValueOnce(value)
+ await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
+ const fields = dialog.findAllComponents(DispatchEvidenceFiles)
+ expect(fields.map(item => item.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片'])
+ expect(fields.every(item => item.props('readonly') && item.props('photoCard'))).toBe(true)
+ expect(fields[1]!.props('modelValue')).toEqual(['402', '403'])
+ expect(dialog.findAll('input[type="file"]')).toHaveLength(0)
+ expect(api.uploadDispatchEvidence).not.toHaveBeenCalled()
})
it.each(['CANCEL', 'RETURN_TO_REVIEW', 'SUBMIT_REVIEW'] as const)('preserves the %s confirmation branch and its exact reason or remark', async action => {
const { dialog } = await render(action)
const needsReason = action === 'CANCEL' || action === 'RETURN_TO_REVIEW'
const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : api.submitDispatchReview
if (needsReason) { dialog.vm.$emit('confirm'); await flushPromises(); expect(send).not.toHaveBeenCalled() }
- await dialog.get('textarea').setValue(needsReason ? ' 现场原因 ' : '本次交接')
+ if (needsReason) await dialog.get('textarea').setValue(' 现场原因 ')
+ else expect(dialog.find('textarea').exists()).toBe(false)
dialog.vm.$emit('confirm'); await flushPromises()
- expect(send).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), ...(needsReason ? { reason: '现场原因' } : { remark: '本次交接' }) })
+ expect(send).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), ...(needsReason ? { reason: '现场原因' } : { remark: null }) })
})
it('reorders only real unload stops and preserves the external stop and zero route version', async () => {
const { dialog } = await render('EDIT', 'ROUTE')
@@ -170,11 +226,14 @@ describe('dispatch workflow native layout', () => {
vi.mocked(api.uploadDispatchEvidence).mockReturnValue(new Promise(done => { resolve = done }))
const { dialog } = await render('EDIT', undefined, true)
const files = dialog.findAllComponents(DispatchEvidenceFiles)[0]!
- await files.findAll('button').find(button => button.text() === '预览')!.trigger('click'); await flushPromises()
+ await files.get('button.evidence-photo-preview').trigger('click'); await flushPromises()
const image = files.findComponent(AppDialog)
expect(image.props('modelValue')).toBe(true)
- const preview = new DOMWrapper(document.querySelector('[aria-label="本车附件预览信息"]')!.closest('.el-dialog')!)
+ const preview = new DOMWrapper(document.querySelector('.evidence-preview')!.closest('.el-dialog')!)
expect(preview.get('img').attributes('src')).toBe('blob:owned-dispatch')
+ Object.defineProperties(preview.get('img').element, { naturalWidth: { value: 1200 }, naturalHeight: { value: 800 } })
+ await preview.get('img').trigger('load'); await flushPromises()
+ expect(preview.get('.evidence-preview').attributes('aria-busy')).toBe('false')
await preview.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(preview.classes()).toContain('is-fullscreen')
await preview.findAll('button').find(button => button.text() === '关闭')!.trigger('click'); await flushPromises()
diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue
index 0fa8cca7..5f2f0b4b 100644
--- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue
+++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue
@@ -13,7 +13,7 @@ import * as api from '../api/dispatches'
import type { MarkDispatchException, DispatchExceptionType, DispatchReturnDirection, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
import type { DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
import { DispatchInputError, requireVersion } from '../dispatchContracts'
-import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, emptyReviewAttachments } from '../dispatchPresentation'
+import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence } from '../dispatchPresentation'
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
import DispatchResourceFields from './DispatchResourceFields.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
@@ -36,8 +36,9 @@ const measurements = ref
({ mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] })
const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } })
const vehicle = ref(blankVehicle()), driver = ref(blankDriver())
-const reviewAttachments = ref(emptyReviewAttachments())
+const driverPhotos = computed({ get: () => driver.value.evidence?.photoFileIds || [], set: (ids: string[]) => { if (driver.value.evidence) driver.value.evidence.photoFileIds = ids } })
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
+const isReview = computed(() => ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value))
const exception = reactive({ exceptionType: '' as DispatchExceptionType | '', returnDirection: '' as DispatchReturnDirection | '', description: '', imageFileIds: [] as string[] })
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
let generation = 0
@@ -64,7 +65,6 @@ async function load() {
if (sequence !== generation) return
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
detail.value = value
- reviewAttachments.value = emptyReviewAttachments()
Object.assign(exception, { exceptionType: '', returnDirection: '', description: '', imageFileIds: [] })
const oldVehicle = value.vehicleInfo.vehicle
vehicle.value = oldVehicle ? { mode: oldVehicle.mode, vehicleId: oldVehicle.vehicleId, plate: oldVehicle.plate, carrierName: oldVehicle.carrierName, capacity: oldVehicle.capacity ? { ...oldVehicle.capacity } : null, evidenceFileIds: [...oldVehicle.evidenceFileIds] } : blankVehicle()
@@ -108,7 +108,7 @@ function payload(): object {
case 'EDIT': return { ...common, vehicle: vehicle.value, driver: driver.value, plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
- return { ...common, decision: form.decision, reason: form.reason.trim() || null, ...(Object.values(reviewAttachments.value).some(ids => ids.length) ? { attachments: reviewAttachments.value } : {}) } satisfies ReviewCommand
+ return { ...common, decision: form.decision, reason: form.reason.trim() || null } satisfies ReviewCommand
case 'CANCEL': case 'RETURN_TO_REVIEW':
if (!form.reason.trim()) throw new DispatchInputError('请填写操作原因')
return { ...common, reason: form.reason.trim() } satisfies ReasonCommand
@@ -136,6 +136,11 @@ async function send(command: PendingDispatchCommand): Promise {
default: throw new DispatchInputError('此操作不属于当前后台作业入口')
}
}
+async function submitReview(decision: 'APPROVED' | 'REJECTED') {
+ if (frozen.value || loading.value || uploadingEvidence.value || !allowed.value) return
+ form.decision = decision
+ await submit()
+}
async function submit(submitReviewAfterSave = false) {
if (submitting.value || busy.value || loading.value || uploadingEvidence.value) return
submitting.value = true
@@ -203,7 +208,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 })
-
+
@@ -227,11 +232,9 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
- 审核依据
| 车牌 | {{ detail.vehicleInfo.vehicle?.plate || '—' }} | 司机 | {{ detail.vehicleInfo.driver?.name || '—' }} |
|---|
| 托盘数 | {{ detail.counts.palletCount }} 托 | 箱数 | {{ detail.counts.packageCount }} 箱 |
|---|
+ | 车牌 | {{ detail.vehicleInfo.vehicle?.plate || '—' }} | 司机 | {{ detail.vehicleInfo.driver?.name || '—' }} |
|---|
| 托盘数 | {{ detail.counts.palletCount }} 托 | 箱数 | {{ detail.counts.packageCount }} 箱 |
|---|
| 目的仓 | 预约单号 | 预约状态 |
|---|
| {{ appointment.destinationWarehouseCode }} | {{ appointment.appointmentNo || '—' }} | {{ appointment.status }} |
-
-
@@ -240,14 +243,15 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
@@ -256,10 +260,19 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
保存
{{ pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : '提交审核' }}
+
+
+ 驳回
+ 审核通过
+
+
diff --git a/src/modules/overseas-warehouse/components/TransportExecutionPanel.test.ts b/src/modules/overseas-warehouse/components/TransportExecutionPanel.test.ts
new file mode 100644
index 00000000..9d63ad00
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/TransportExecutionPanel.test.ts
@@ -0,0 +1,84 @@
+import { mount, flushPromises } from '@vue/test-utils'
+import ElementPlus from 'element-plus'
+import { beforeEach, expect, it, vi } from 'vitest'
+import TransportExecutionPanel from './TransportExecutionPanel.vue'
+import { commandTransport, getTransport, uploadTransport, type TransportDetail } from '../api/transportTasks'
+
+vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() }))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
+vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), pickupStatusLabels: { ASSIGNED: '待取货', PICKED: '已取货', ARRIVED: '已到仓' } }))
+vi.mock('../api/transportTasks', () => ({ getTransport: vi.fn(), assignTransport: vi.fn(), transportOccupancy: vi.fn(), cancelTransport: vi.fn(), releaseTransport: vi.fn(), commandTransport: vi.fn(), uploadTransport: vi.fn(), downloadTransportEvidence: vi.fn(), transportLabels: { PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' } }))
+const facts = (arriving = false): TransportDetail => ({ dispatchNo: 'PC1', taskType: 'PICKUP', execution: { trip: { status: arriving ? 'IN_TRANSIT' : 'PLANNED', recordVersion: 4, warehouseName: '洛杉矶仓', vehicleId: '1', vehiclePlate: 'CA-1', driverId: '2', driverName: '司机甲', driverPhone: '555' } }, pickups: [{ customerId: 'C1', transferNo: 'WT1', packageCount: 5, actualPackages: arriving ? 5 : null, status: arriving ? 'PICKED' : 'ASSIGNED', evidence: [] }], completionEvidence: [] }) as unknown as TransportDetail
+const options = { global: { plugins: [ElementPlus] } }
+beforeEach(() => {
+ vi.clearAllMocks(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
+ vi.mocked(getTransport).mockResolvedValue(facts())
+ vi.mocked(uploadTransport).mockResolvedValue({ fileId: '88', fileName: '现场.jpg', contentType: 'image/jpeg', sizeBytes: 10 })
+})
+it.each([false, true])('confirms the %s photo stage with its own evidence and current transport version', async arriving => {
+ vi.mocked(getTransport).mockResolvedValue(facts(arriving))
+ const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true } }); await flushPromises()
+ expect(wrapper.text()).toContain(arriving ? '派送中' : '装货中')
+ expect(wrapper.findAll('button').some(button => button.text() === '确认发车')).toBe(false)
+ await wrapper.findAll('button').find(button => button.text() === (arriving ? '上传到仓照片' : '上传装货照片'))!.trigger('click'); await flushPromises()
+ if (!arriving) {
+ const quantity = wrapper.get('input[role="spinbutton"]')
+ expect((quantity.element as HTMLInputElement).value).toBe('')
+ await quantity.setValue('3'); await quantity.trigger('change'); await flushPromises()
+ }
+ const input = wrapper.get('input[type="file"]')
+ Object.defineProperty(input.element, 'files', { value: [new File(['photo'], '现场.jpg', { type: 'image/jpeg' })], configurable: true })
+ await input.trigger('change'); await flushPromises()
+ await wrapper.findAll('button').find(button => button.text() === (arriving ? '确认到仓' : '确认取货'))!.trigger('click'); await flushPromises()
+ expect(commandTransport).toHaveBeenCalledWith('PC1', `pickup/C1/WT1/${arriving ? 'arrive' : 'picked'}`, expect.objectContaining({ recordVersion: 4, evidenceFileIds: ['88'], ...(arriving ? { reason: '上传到仓照片,确认取货派送完成' } : { actualPackages: 3 }) }))
+ if (arriving) expect(vi.mocked(commandTransport).mock.calls[0]![2]).not.toHaveProperty('actualPackages')
+ expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
+})
+it('keeps photo confirmation unavailable until both reviews are approved', async () => {
+ const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: false } }); await flushPromises()
+ expect(wrapper.findAll('button').some(button => button.text() === '上传装货照片')).toBe(false)
+ expect(commandTransport).not.toHaveBeenCalled(); wrapper.unmount()
+})
+it('requires an explicitly entered pickup quantity even after a photo has uploaded', async () => {
+ const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true } }); await flushPromises()
+ await wrapper.findAll('button').find(button => button.text() === '上传装货照片')!.trigger('click'); await flushPromises()
+ const input = wrapper.get('input[type="file"]')
+ Object.defineProperty(input.element, 'files', { value: [new File(['photo'], '现场.jpg', { type: 'image/jpeg' })], configurable: true })
+ await input.trigger('change'); await flushPromises()
+ const confirm = wrapper.findAll('button').find(button => button.text() === '确认取货')!
+ expect(confirm.attributes('disabled')).toBeDefined()
+ await confirm.trigger('click')
+ expect(commandTransport).not.toHaveBeenCalled()
+ wrapper.unmount()
+})
+it.each([false, true])('renders read-only transport facts without resource editing or execution controls (%s)', async arriving => {
+ const value = facts(arriving)
+ value.execution.trip.vehicleId = null
+ value.execution.trip.driverId = null
+ vi.mocked(getTransport).mockResolvedValue(value)
+ const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, readonly: true } }); await flushPromises()
+ expect(wrapper.text()).toContain('WT1')
+ expect(wrapper.text()).toContain('洛杉矶仓')
+ expect(wrapper.findAll('input, select, textarea')).toHaveLength(0)
+ expect(wrapper.findAll('button')).toHaveLength(0)
+ expect(wrapper.findAll('th').map(column => column.text())).not.toContain('操作')
+ expect(commandTransport).not.toHaveBeenCalled()
+ wrapper.unmount()
+})
+it('keeps pickup addresses and contacts on their own transfer rows', async () => {
+ const value = facts()
+ value.pickups[0] = { ...value.pickups[0]!, pickupAddress: '100 Test Pickup Avenue', contactName: '取货联系人甲', contactPhone: '0000000001' }
+ value.pickups.push({ ...value.pickups[0]!, transferNo: 'WT2', customerId: 'C2', pickupAddress: '200 Test Pickup Road', contactName: '取货联系人乙', contactPhone: '0000000002' })
+ vi.mocked(getTransport).mockResolvedValue(value)
+ const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', readonly: true } }); await flushPromises()
+ const rows = wrapper.get('[aria-label="取货明细"]').findAll('tbody tr')
+ expect(rows[0]!.text()).toContain('100 Test Pickup Avenue')
+ expect(rows[0]!.text()).toContain('取货联系人甲')
+ expect(rows[0]!.text()).toContain('0000000001')
+ expect(rows[0]!.text()).not.toContain('200 Test Pickup Road')
+ expect(rows[1]!.text()).toContain('200 Test Pickup Road')
+ expect(rows[1]!.text()).toContain('取货联系人乙')
+ expect(rows[1]!.text()).toContain('0000000002')
+ expect(wrapper.findAll('button')).toHaveLength(0)
+ wrapper.unmount()
+})
diff --git a/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue b/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue
index 0b53beb5..1d593f69 100644
--- a/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue
+++ b/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue
@@ -1,7 +1,10 @@
-
-
- 按原请求重试
+
+
+ 核对原请求
- {{ transportLabels[detail.taskType] }} · {{ { PLANNED: '待发车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }[trip.status] }}
- 本土卡车原单:{{ detail.orderNo }} · 客户:{{ detail.customerId }}
- 调度仓库:{{ trip.warehouseName }} · 车辆:{{ trip.vehiclePlate || '待分配' }} · 司机:{{ trip.driverName || '待分配' }} {{ trip.driverPhone }}
- {{ index + 1 }}. {{ stop.name }} {{ stop.address }}
-
-
- 加载车辆司机候选
-
-
- 保存车辆司机
-
-
+
+
+ 车辆信息
维护车辆信息
+
+ - {{ detail.taskType === 'PICKUP' ? '收货仓库' : '所属仓库' }}
- {{ trip.warehouseName || '—' }}
+ - 计划发车
- {{ time(trip.plannedDepartureAt) }}
+ - 预计结束
- {{ time(trip.expectedDriverEndAt) }}
+ - 车辆
- {{ trip.vehiclePlate || '待分配' }}
- 司机
- {{ trip.driverName || '待分配' }}{{ trip.driverPhone }}
+ - 订单号
- {{ detail.orderNo }}
+ - 路线
- {{ trip.stops.map(stop => stop.name).join(' → ') }}
+
+
+ value && !vehicles.length && candidates()">
+ value && !drivers.length && candidates()">
+ 保存车辆司机收起
+
+
+
+ 取货明细
{{ detail.pickups.length }} 单 · 预报 {{ detail.pickups.reduce((total, row) => total + row.packageCount, 0) }} 箱
+
+ 上传装货照片上传到仓照片—
+
+ {{ row.pickupAddress || '—' }}
+ {{ row.contactName || '—' }}
+ {{ row.contactPhone || '—' }}
+ {{ trip.status === 'CANCELLED' ? '已取消' : pickupStatusLabels[row.status] }}
+ {{ row.packageCount }} / {{ row.actualPackages ?? '—' }}
+ 照片{{ index + 1 }}—
+ 照片{{ index + 1 }}—
+
+
+
+ {{ file.fileName }}
- {{ selected?.transferNo }}
{{ file.fileName }}
确认实提
- 确认任务结束并释放占用
+
+ {{ selected?.transferNo }}
+
+ {{ selected?.packageCount ?? '—' }}
+
+ {{ selected?.actualPackages ?? '—' }}
+
+
+
+ {{ file.fileName }}
+
+ {{ arrivalMode ? '确认到仓' : '确认取货' }}
+
+ 确认结束
+
+
diff --git a/src/modules/overseas-warehouse/dispatchContracts.ts b/src/modules/overseas-warehouse/dispatchContracts.ts
index 7d470107..915b7a96 100644
--- a/src/modules/overseas-warehouse/dispatchContracts.ts
+++ b/src/modules/overseas-warehouse/dispatchContracts.ts
@@ -11,7 +11,7 @@ export interface Gate { allowed: boolean; blockCode: string | null; blockReason:
export type DispatchActions = Partial>
export interface Counts { itemCount: number; palletCount: number; packageCount: number; pickedPackageCount: number; shippedPackageCount: number }
export interface Capacity { palletSpaces: number | null; maxLoadKg: number | null; maxVolumeCbm: number | null }
-export interface DriverEvidence { licenseNo: string | null; licenseExpiresOn: string | null; fileIds: string[]; expectedTaskEndAt: string | null }
+export interface DriverEvidence { licenseNo: string | null; licenseExpiresOn: string | null; fileIds: string[]; expectedTaskEndAt: string | null; photoFileIds?: string[] }
export interface Vehicle { mode: 'MASTER' | 'EXTERNAL'; vehicleId: string | null; plate: string | null; carrierName: string | null; capacity: Capacity | null; evidenceFileIds: string[] }
export interface Driver { mode: 'MASTER' | 'EXTERNAL'; driverId: string | null; name: string | null; phone: string | null; evidence: DriverEvidence | null }
export interface VehicleSnapshot extends Vehicle { vehicleStatus: string | null }
diff --git a/src/modules/overseas-warehouse/dispatchPresentation.ts b/src/modules/overseas-warehouse/dispatchPresentation.ts
index a5601b9e..7830d4b1 100644
--- a/src/modules/overseas-warehouse/dispatchPresentation.ts
+++ b/src/modules/overseas-warehouse/dispatchPresentation.ts
@@ -9,6 +9,7 @@ export function dispatchReviewEvidence(detail: DispatchDetail | null): ReviewAtt
const result = emptyReviewAttachments()
if (!detail) return result
result.vehicleFileIds = [...(detail.vehicleInfo.vehicle?.evidenceFileIds || [])]
+ result.driverPhotoFileIds = [...(detail.vehicleInfo.driver?.evidence?.photoFileIds || [])]
result.licenseFileIds = [...(detail.vehicleInfo.driver?.evidence?.fileIds || [])]
for (const { key } of dispatchReviewAttachmentFields) {
result[key] = [...new Set([...result[key], ...(detail.reviews.dispatchReview.attachments?.[key] || []), ...(detail.reviews.loadingReview.attachments?.[key] || [])])]
diff --git a/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts b/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts
index 22b1e52e..7dd1b295 100644
--- a/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts
+++ b/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts
@@ -61,19 +61,15 @@ describe('whole-dispatch four-tab detail', () => {
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(wrapper.text()).toContain('已退回客服处理(车辆信息)')
expect(wrapper.text()).toContain('司机资料需要更正')
- vi.mocked(getDispatch).mockResolvedValue(detail({ recordVersion: 2 }))
- wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).vm.$emit('saved'); await flushPromises()
- expect(wrapper.text()).not.toContain('司机资料需要更正')
+ expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false)
wrapper.unmount()
})
- it('keeps cargo additions and removals out of the detail even when authorized', async () => {
+ it('keeps every write action out of the detail even when authorized', async () => {
can.mockReturnValue(true)
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null }, REMOVE_CARGO: { allowed: true, blockCode: null, blockReason: null } } }))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
- const actions = wrapper.get('[aria-label="排车操作"]').findAll('button').map(button => button.text())
- expect(actions).toContain('维护车辆')
- expect(wrapper.findAll('button').map(button => button.text())).not.toContain('调整卸货顺序')
- expect(wrapper.findAll('button').map(button => button.text())).not.toContain('提交调度审核')
+ const actions = wrapper.findAll('button').map(button => button.text())
+ expect(actions).not.toContain('维护车辆')
expect(actions).not.toContain('添加货物')
expect(actions).not.toContain('移除货物')
wrapper.unmount()
@@ -82,19 +78,18 @@ describe('whole-dispatch four-tab detail', () => {
vi.mocked(getDispatch).mockResolvedValueOnce({ ...detail(), taskType })
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(wrapper.get('transport-execution-panel-stub').attributes('dispatchno')).toBe('PC/1')
+ expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('readonly')).toBe(true)
expect(wrapper.findAll('[role="tab"]')).toHaveLength(0)
expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false); wrapper.unmount()
})
- it('keeps return-to-review in the footer across tabs and opens the existing workflow', async () => {
+ it('keeps detail tabs read-only when return-to-review is available from the list', async () => {
can.mockReturnValue(true)
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ stage: 'LOADING', actions: { RETURN_TO_REVIEW: { allowed: true, blockCode: null, blockReason: null } } }))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
await wrapper.findAll('[role="tab"]')[1]!.trigger('click'); await flushPromises()
- const footer = wrapper.get('footer[aria-label="排车操作"]')
- expect(footer.isVisible()).toBe(true)
- expect(wrapper.get('.el-tabs').element.compareDocumentPosition(footer.element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
- await footer.get('button').trigger('click')
- expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'RETURN_TO_REVIEW' })
+ expect(wrapper.find('footer[aria-label="排车操作"]').exists()).toBe(false)
+ expect(wrapper.findAll('button').map(button => button.text())).not.toContain('退回装车前重审')
+ expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false)
wrapper.unmount()
})
it('keeps dispatch labels and placeholders visible when no schedule or appointments are recorded', async () => {
@@ -175,7 +170,7 @@ describe('whole-dispatch four-tab detail', () => {
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(wrapper.text()).not.toContain('尚未安排派送'); expect(wrapper.findAll('button').some(button => button.text() === '确认发车')).toBe(false); wrapper.unmount()
})
- it('keeps the allowed Admin action without presenting unrelated stage or PDA permission refusals as page warnings', async () => {
+ it('shows stage facts without execution actions or unrelated permission refusals', async () => {
can.mockReturnValue(true)
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ stage: 'LOADING', actions: {
LOADING_COMPLETE: { allowed: true, blockCode: null, blockReason: null },
@@ -187,8 +182,8 @@ describe('whole-dispatch four-tab detail', () => {
expect(wrapper.text()).not.toContain('当前阶段或作业事实不满足操作条件')
expect(wrapper.text()).not.toContain('当前无此操作权限')
expect(wrapper.findAll('button').some(button => button.text() === '维护车辆')).toBe(false)
- await wrapper.findAll('button').find(button => button.text() === '确认装车完成')!.trigger('click')
- expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, dispatchNo: 'PC/1', action: 'LOADING_COMPLETE' })
+ expect(wrapper.findAll('button').some(button => button.text() === '确认装车完成')).toBe(false)
+ expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false)
wrapper.unmount()
})
it('retains a real attachment A0404 failure and its request diagnostics in the vehicle tab', async () => {
@@ -212,45 +207,16 @@ describe('whole-dispatch four-tab detail', () => {
Object.assign(query, { fleetOfferNo: 'FO-1', fleetBookingNo: 'FB-2' }); const wrapper = mount(DispatchDetailView, options); await flushPromises(); await wrapper.get('button.density-back').trigger('click')
expect(push).toHaveBeenCalledWith({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'FO-1' }, query: { inquiry: 'FB-2' } }); wrapper.unmount()
})
- it.each(['saved', 'rejected'] as const)('refreshes detail actions after the workflow %s event and retains a real rejection after closure', async event => {
+ it('does not mount review or mutation workflows for an authorized administrator', async () => {
can.mockReturnValue(true)
- vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { EDIT: { allowed: true, blockCode: null, blockReason: null } } }))
+ const allowed = { allowed: true, blockCode: null, blockReason: null }
+ vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { EDIT: allowed, CANCEL: allowed, SUBMIT_REVIEW: allowed, DISPATCH_REVIEW: allowed, LOADING_REVIEW: allowed } }))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
- await wrapper.findAll('button').find(button => button.text() === '维护车辆')!.trigger('click')
- vi.mocked(getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { EDIT: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '已提交' } } }))
- const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
- if (event === 'rejected') dialog.vm.$emit('rejected', { message: '本轮审核已经提交', code: 'A0409', requestId: 'detail-reject-request' })
- else dialog.vm.$emit('saved')
- dialog.vm.$emit('update:modelValue', false)
- await flushPromises()
- expect(getDispatch).toHaveBeenCalledTimes(2)
- expect(wrapper.findAll('button').some(button => button.text() === '维护车辆')).toBe(false)
+ expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false)
+ expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false)
+ expect(wrapper.findAll('input, textarea')).toHaveLength(0)
expect(wrapper.findAll('[role="tab"]')).toHaveLength(4)
- if (event === 'rejected') {
- expect(wrapper.text()).toContain('本轮审核已经提交')
- expect(wrapper.text()).toContain('A0409')
- expect(wrapper.text()).toContain('detail-reject-request')
- }
- expect(wrapper.text()).not.toContain('结果尚待核对')
- wrapper.unmount()
- })
- it('retains a workflow rejection when the parent detail refresh also fails', async () => {
- const wrapper = mount(DispatchDetailView, options); await flushPromises()
- vi.mocked(getDispatch).mockRejectedValueOnce(new Error('detail read failed'))
- wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).vm.$emit('rejected', { message: '已提交,请刷新', code: 'A0409' })
- await flushPromises()
- expect(wrapper.text()).toContain('已提交,请刷新')
- expect(wrapper.text()).toContain('A0409')
- expect(wrapper.text()).toContain('排车详情加载失败')
- wrapper.unmount()
- })
- it('clears an earlier input rejection after the corrected workflow succeeds', async () => {
- const wrapper = mount(DispatchDetailView, options); await flushPromises()
- const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
- dialog.vm.$emit('rejected', { message: '请补齐本趟资料', code: 'A0400' }); await flushPromises()
- expect(wrapper.text()).toContain('请补齐本趟资料')
- dialog.vm.$emit('saved'); await flushPromises()
- expect(wrapper.text()).not.toContain('请补齐本趟资料')
+ expect(getDispatch).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
})
diff --git a/src/modules/overseas-warehouse/pages/DispatchDetailView.vue b/src/modules/overseas-warehouse/pages/DispatchDetailView.vue
index bbceebc6..0d463068 100644
--- a/src/modules/overseas-warehouse/pages/DispatchDetailView.vue
+++ b/src/modules/overseas-warehouse/pages/DispatchDetailView.vue
@@ -11,11 +11,10 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getDispatch } from '../api/dispatches'
-import type { DispatchAction, DispatchDetail } from '../dispatchContracts'
+import type { DispatchDetail } from '../dispatchContracts'
import { stageLabel } from '../dispatchContracts'
-import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
+import { dispatchExceptionLabels, dispatchReturnDirectionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
-import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import { formatOverseasDateTime as time } from '../lastMilePresentation'
@@ -25,11 +24,6 @@ const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const dispatchNo = computed(() => String(route.params.dispatchNo || ''))
const detail = ref(null), activeTab = ref('vehicle'), loading = ref(false), loadError = ref(null)
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
-const rejectionError = ref(null)
-const operation = ref<'ROUTE' | undefined>()
-const action = ref('EDIT'), actionOpen = ref(false)
-const detailActions: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'CANCEL']
-const visibleActions = computed(() => detailActions.filter(value => (value !== 'SUBMIT_REVIEW' || detail.value?.taskType === 'PICKUP') && !(detail.value?.taskType === 'PICKUP' && value === 'EDIT') && canDispatchAction(detail.value?.actions, value, access.can)))
const vehicleStatus = computed(() => {
const status = detail.value?.vehicleInfo.vehicle?.vehicleStatus
return status && Object.hasOwn(vehicleStatusLabels, status) ? vehicleStatusLabels[status as keyof typeof vehicleStatusLabels] : '—'
@@ -72,19 +66,15 @@ async function load() {
const appointmentReturn = computed(() => appointmentReturnTarget(route.query))
const backLabel = computed(() => appointmentReturn.value ? '返回预约日历' : inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表')
function back() { void router.push(appointmentReturn.value || inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' }) }
-function openAction(value: DispatchAction, requestedOperation?: 'ROUTE') { if (value === 'ADD_CARGO' || value === 'REMOVE_CARGO') return; if (!canDispatchAction(detail.value?.actions, value, access.can)) return; rejectionError.value = null; operation.value = requestedOperation; action.value = value; actionOpen.value = true }
-async function handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() }
-async function handleSaved() { rejectionError.value = null; await load() }
watch(dispatchNo, load, { immediate: true })
onBeforeUnmount(() => { generation++ })
-
-
+
🚚车辆信息- {{ field[0] }}
- {{ field[1] }}
@@ -103,18 +93,12 @@ onBeforeUnmount(() => { generation++ })
📦关联订单明细
-
-