This commit is contained in:
2026-10-21 16:40:10 +08:00
parent 8356e98ce3
commit e2f99b4f4b
18 changed files with 391 additions and 52 deletions
@@ -61,6 +61,31 @@ describe('whole-dispatch four-tab detail', () => {
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 () => {
can.mockReturnValue(true)
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ stage: 'LOADING', actions: { RETURN_TO_REVIEW: { allowed: true, blockCode: null, blockReason: null } } }))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
await wrapper.findAll('[role="tab"]')[1]!.trigger('click'); await flushPromises()
const footer = wrapper.get('footer[aria-label="排车操作"]')
expect(footer.isVisible()).toBe(true)
expect(wrapper.get('.el-tabs').element.compareDocumentPosition(footer.element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
await footer.get('button').trigger('click')
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'RETURN_TO_REVIEW' })
wrapper.unmount()
})
it('keeps dispatch labels and placeholders visible when no schedule or appointments are recorded', async () => {
const value = detail()
value.dispatchInfo = { ...value.dispatchInfo, route: null, fbaAppointments: [] }
vi.mocked(getDispatch).mockResolvedValueOnce(value)
const wrapper = mount(DispatchDetailView, options); await flushPromises()
for (const name of ['dispatch', 'documents', 'vehicle', 'dispatch']) {
await wrapper.get(`#tab-${name}`).trigger('click'); await flushPromises()
expect(wrapper.get(`#pane-${name}`).isVisible()).toBe(true)
}
expect(wrapper.get('#pane-dispatch').findAll('dd').map(field => field.text())).toEqual(['—', '—', '—', '—'])
expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false)
wrapper.unmount()
})
it('returns to the original appointment date and view from the dispatch detail', async () => {
query.appointmentReturn = '/appointment-calendar?date=2026-10-21&mode=week'
const wrapper = mount(DispatchDetailView, options); await flushPromises()
@@ -87,6 +112,8 @@ describe('whole-dispatch four-tab detail', () => {
await tabs[1]!.trigger('click'); await flushPromises()
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
const dispatchInfo = wrapper.get(`#${tabs[1]!.attributes('aria-controls')}`)
expect(dispatchInfo.isVisible()).toBe(true)
expect(dispatchInfo.get('dl').isVisible()).toBe(true)
expect(dispatchInfo.text()).toContain('LAX → ONT8'); expect(dispatchInfo.text()).toContain('FBA预约'); expect(dispatchInfo.text()).toContain('ONT8 · FBA-AP-1 · CONFIRMED')
expect(dispatchInfo.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约'])
expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('装车前'); wrapper.unmount()
@@ -85,10 +85,9 @@ onBeforeUnmount(() => { generation++ })
<LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
<TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" :dispatch-no="dispatchNo" :pickup-approved="detail.reviewState === 'APPROVED'" @saved="load" />
<section v-if="detail" class="detail-section" aria-label="排车操作"><div class="density-actions"><el-button v-for="value in visibleActions" :key="value" class="density-action" @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button><el-button v-if="(!detail.taskType || detail.taskType === 'DELIVERY') && canDispatchAction(detail?.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button></div></section>
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理(车辆信息),请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
<el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs">
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></el-tab-pane>
<el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs density-contained-tabs--flush">
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields vehicle-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></el-tab-pane>
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><section v-if="detail && detail.dispatchInfo.exceptions.length" class="detail-section" aria-label="派送异常记录">
<h3>派送异常记录</h3>
<article v-for="(record, index) in detail.dispatchInfo.exceptions" :key="index">
@@ -97,11 +96,15 @@ onBeforeUnmount(() => { generation++ })
</article>
</section></el-tab-pane>
<el-tab-pane name="documents"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>单据信息</span></template>
<dl class="detail-fields density-fields"><div v-for="field in documentFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div><div class="linked-documents"><dt>关联派送单</dt><dd><template v-for="delivery in detail?.documents.deliveries || []" :key="delivery.deliveryNo"><el-button v-if="access.can(permission.deliveryList)" link type="primary" @click="router.push({ name: 'overseas-delivery-order-detail', params: { deliveryNo: delivery.deliveryNo } })">{{ delivery.deliveryNo }}</el-button><span v-else>{{ text(delivery.deliveryNo) }}</span></template><span v-if="!detail?.documents.deliveries.length">—</span></dd></div></dl>
<dl class="detail-fields density-fields"><div v-for="field in documentFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div><div class="linked-documents"><dt>关联派送单</dt><dd><template v-for="delivery in detail?.documents.deliveries || []" :key="delivery.deliveryNo"><el-button v-if="access.can(permission.deliveryList)" link type="primary" @click="router.push({ name: 'overseas-delivery-order-detail', params: { deliveryNo: delivery.deliveryNo }, query: { customerId: delivery.customerId } })">{{ delivery.deliveryNo }}</el-button><span v-else>{{ text(delivery.deliveryNo) }}</span></template><span v-if="!detail?.documents.deliveries.length">—</span></dd></div></dl>
<div class="density-actions"><el-button v-for="outbound in detail?.documents.outbounds || []" v-show="access.can(permission.outboundList)" :key="outbound.outboundNo" link @click="router.push({ name: 'overseas-outbounds', query: { documentNo: outbound.outboundNo } })">查看出库 {{ outbound.outboundNo }}</el-button></div>
</el-tab-pane>
<el-tab-pane name="orders"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>关联订单明细</span></template><div class="workbench-table"><DataTable :data="relatedOrders" :columns="orderColumns" column-settings-mode="hide" row-key="sourceKey" empty-text="暂无关联订单明细" /></div></el-tab-pane>
</el-tabs>
<footer v-if="detail && visibleActions.length" class="dispatch-actions density-actions" aria-label="排车操作">
<el-button v-for="value in visibleActions" :key="value" :type="value === 'CANCEL' ? 'danger' : undefined" plain @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button>
<el-button v-if="(!detail.taskType || detail.taskType === 'DELIVERY') && canDispatchAction(detail.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button>
</footer>
</div>
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="dispatchNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
</OverseasDetailShell>
@@ -110,7 +113,10 @@ onBeforeUnmount(() => { generation++ })
<style scoped>
.detail-section { min-width: 0; padding: var(--admin-space-5); }
.dispatch-detail { min-width: 0; min-height: 0; }
.dispatch-actions { justify-content: flex-end; flex-wrap: wrap; padding: var(--admin-space-5); }
.dispatch-detail .vehicle-fields { grid-template-columns: minmax(0, 1.3fr) minmax(0, 1.6fr) minmax(0, 1.2fr) minmax(0, 1.5fr) minmax(0, 0.8fr); }
.dispatch-detail .vehicle-fields > div { grid-template-columns: max-content minmax(0, 1fr); }
.linked-documents { grid-column: 1 / -1; }
.linked-documents dd { display: flex; flex-wrap: wrap; gap: var(--admin-space-4); overflow-wrap: anywhere; }
@media (max-width: 960px) { .detail-fields { grid-template-columns: 1fr; } .detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); } }
@media (max-width: 960px) { .dispatch-detail .detail-fields { grid-template-columns: 1fr; } .dispatch-detail .detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); } }
</style>
@@ -27,6 +27,7 @@ import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
import { transportLabels, type TransportType } from '../api/transportTasks'
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
import DispatchDocumentsDialog from '../components/DispatchDocumentsDialog.vue'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
@@ -37,6 +38,7 @@ const rejectionError = ref<ApiErrorDetails | null>(null)
const transportOpen = ref(false), transportType = ref<TransportType>('PICKUP')
const transportEditOpen = ref(false), transportEditNo = ref('')
function createTransport(type: TransportType) { transportType.value = type; transportOpen.value = true }
const documentsOpen = ref(false), documentsNo = ref('')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
@@ -82,6 +84,7 @@ function switchStage(value: DispatchStage) { stage.value = value; section.value
function switchSection(value: BeforeLoadingSection) { section.value = value; page.value = 1; void load() }
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'MARK_EXCEPTION', 'CANCEL']
function canManageDocuments(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.status === 'ACTIVE' && ['DELIVERING', 'DELIVERED'].includes(row.stage) }
function rowActions(row: DispatchListRow): RowActionItem[] {
if (showSections.value && section.value !== 'VEHICLE_INFO') {
const review = section.value
@@ -90,7 +93,7 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
if (row.taskType === 'LOCAL_TRUCK') return [{ key: 'detail', label: '运输操作', priority: 500 }]
const available = rowActionOrder.filter(value => canDispatchAction(row.actions, value, access.can))
const primaryAction = available.find(value => value === 'EDIT') ?? available[0]
return [...available.map((value, index) => ({
return [...(canManageDocuments(row) ? [{ key: 'documents', label: '管理资料', priority: 700 }] : []), ...available.map((value, index) => ({
key: value,
label: dispatchActionLabels[value],
ariaLabel: dispatchActionLabels[value],
@@ -101,6 +104,7 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
function handleAction(value: string, row: DispatchListRow) {
if (value === 'detail') return openDetail(row)
if (showSections.value && section.value !== 'VEHICLE_INFO' && value !== section.value) return
if (value === 'documents') { if (canManageDocuments(row)) { documentsNo.value = row.dispatchNo; documentsOpen.value = true }; return }
if (value === 'bol') return void openBol(row)
rejectionError.value = null
if (value === 'route-order') { if (!canDispatchAction(row.actions, 'EDIT', access.can)) return; operation.value = 'ROUTE'; action.value = 'EDIT'; actionNo.value = row.dispatchNo; actionOpen.value = true; return }
@@ -157,5 +161,6 @@ onBeforeUnmount(() => { sequence++ })
<AppDialog v-model="transportEditOpen" title="维护取货车辆信息" width="1000px"><TransportExecutionPanel v-if="transportEditOpen" :dispatch-no="transportEditNo" :pickup-approved="rows.find(row => row.dispatchNo === transportEditNo)?.reviewState === 'APPROVED'" @saved="load" /></AppDialog>
<SmartDispatchDialog v-model="createOpen" :orders="[]" @created="load" />
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
<DispatchDocumentsDialog v-model="documentsOpen" :dispatch-no="documentsNo" @saved="load" />
<TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
</template>