Merge remote-tracking branch 'origin/release/test' into release/test

This commit is contained in:
2026-10-27 17:45:56 +08:00
28 changed files with 478 additions and 282 deletions
@@ -152,13 +152,14 @@ it('allows editing after unknown failure and submits a new command', async () =>
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).not.toBe(payload.idempotencyKey); expect(vi.mocked(commandException).mock.calls[1]![3]).toEqual(expect.objectContaining({ handlingResult: 'changed' }))
wrapper.unmount()
})
it('keeps a conflict visible and lets the operator refresh voluntarily', async () => {
it('keeps a conflict visible and reloads when the operator reopens the detail', async () => {
vi.mocked(getExceptionDetail).mockResolvedValueOnce(snapshot()).mockResolvedValueOnce(snapshot('EX-1', 7, 2))
vi.mocked(commandException).mockRejectedValueOnce(new ApiResponseError('版本变化', 'A0409', 409))
const wrapper = open(); await flushPromises(); await fill(wrapper)
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('版本变化'); expect(commandException).toHaveBeenCalledTimes(1); expect(getExceptionDetail).toHaveBeenCalledTimes(1)
await wrapper.findAll('button').find(button => button.text() === '重新读取详情')!.trigger('click'); await flushPromises()
expect(wrapper.text()).not.toContain('重新读取详情')
await wrapper.setProps({ active: false }); await wrapper.setProps({ active: true }); await flushPromises()
await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[1]![3].expectedVersion).toBe(7)
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).not.toBe(vi.mocked(commandException).mock.calls[0]![3].idempotencyKey)
@@ -284,3 +285,21 @@ it('twenty-one historical files do not get resubmitted automatically with the ne
expect(vi.mocked(commandException).mock.calls[0]![3].evidenceFileIds).toEqual(['991'])
wrapper.unmount()
})
it('moves processing controls into the dialog footer target', async () => {
const footer = document.createElement('div')
footer.id = 'exception-test-footer'
document.body.append(footer)
const wrapper = open()
try {
await flushPromises()
await wrapper.setProps({ actionsTarget: '#exception-test-footer' })
await flushPromises()
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
expect(footer.querySelector('[data-test="complete"]')).not.toBeNull()
expect(wrapper.text()).not.toContain('重新读取详情')
} finally {
wrapper.unmount()
footer.remove()
}
})
@@ -10,7 +10,7 @@ import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import { returnSourceKey, returnSourceReference } from '../returnSource'
const props = defineProps<{ customerId: string; exceptionNo: string; active: boolean }>()
const props = defineProps<{ customerId: string; exceptionNo: string; active: boolean; actionsTarget?: string }>()
const emit = defineEmits<{ changed: [] }>()
const access = useAccessPreviewStore()
const canProcess = computed(() => access.can('platform:overseas-warehouse:exception-order:process'))
@@ -156,9 +156,8 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
<template>
<section v-loading="loading" aria-label="异常处理详情">
<p v-if="error" role="alert">{{ error }}</p><p v-if="message" role="status">{{ message }}</p>
<el-button :disabled="busy" @click="load">重新读取详情</el-button>
<template v-if="detail">
<el-descriptions :column="2" border>
<el-descriptions class="density-descriptions exception-detail-fields" :column="2" border>
<el-descriptions-item label="异常单号">{{ detail.order.exceptionNo }}</el-descriptions-item>
<el-descriptions-item label="处理状态">{{ ({ PENDING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[detail.order.status] || detail.order.status }}</el-descriptions-item>
<el-descriptions-item label="客户">{{ detail.order.customerName || '—' }}</el-descriptions-item>
@@ -180,7 +179,7 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
<section v-if="detail.returnReceipts.length" aria-label="退回实收与重派关联" class="exception-return-history workbench-evidence">
<h3>退回实收与重派关联</h3>
<div v-for="receipt in detail.returnReceipts" :key="receipt.receiptNo" class="exception-return-batch">
<el-descriptions :column="3" border>
<el-descriptions class="density-descriptions" :column="3" border>
<el-descriptions-item label="退回收货号">{{ receipt.receiptNo }}</el-descriptions-item>
<el-descriptions-item label="本批实收">{{ receipt.receivedPackages }} 箱</el-descriptions-item>
<el-descriptions-item label="实收时间">{{ formatOverseasDateTime(receipt.receivedAt) }}</el-descriptions-item>
@@ -200,8 +199,8 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
</section>
<p v-if="detail.order.evidenceFileIds?.length">已保存 {{ detail.order.evidenceFileIds.length }} 份历史证据;本次可按需选用,不自动全部提交。</p>
<template v-if="canProcess">
<el-form label-position="top" :disabled="locked">
<el-form-item label="处理说明 / 取消原因(选填)"><el-input v-model="notes" data-test="notes" type="textarea" :rows="2" maxlength="1000" /></el-form-item>
<el-form class="density-form density-form--outlined exception-processing-form" label-position="left" label-width="140px" :disabled="locked">
<el-form-item label="处理说明 / 取消原因(选填)" label-width="220px"><el-input v-model="notes" data-test="notes" type="textarea" :rows="2" maxlength="1000" placeholder="请输入处理说明或取消原因(选填)" /></el-form-item>
<template v-if="allowed('COMPLETE_EXCEPTION')">
<label>处理证据 <input data-test="evidence" type="file" :disabled="locked" @change="upload" /></label>
<div v-for="(fileId, index) in detail.order.evidenceFileIds || []" :key="fileId"><label><input :data-test="`history-${index}`" type="checkbox" :checked="selectedHistory.includes(fileId)" :disabled="locked" @change="selectHistory(fileId, $event)" />选用历史证据 {{ index + 1 }}</label></div>
@@ -219,15 +218,21 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
<p v-else-if="detail.order.exceptionType === 'RETURN'">关闭此异常只记录处理结果,不代表货物交接完成,也不会释放车辆或司机。</p>
</template>
</el-form>
<el-button v-if="allowed('START_PROCESSING')" data-test="start" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ '开始处理' }}</el-button>
<el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ '取消异常' }}</el-button>
<el-button v-if="allowed('COMPLETE_EXCEPTION')" data-test="complete" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>
<Teleport :to="actionsTarget || 'body'" :disabled="!actionsTarget" defer>
<div class="density-actions density-actions--footer">
<el-button v-if="allowed('START_PROCESSING')" data-test="start" type="primary" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ '开始处理' }}</el-button>
<el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" type="danger" plain :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ '取消异常' }}</el-button>
<el-button v-if="allowed('COMPLETE_EXCEPTION')" data-test="complete" type="primary" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>
</div>
</Teleport>
</template>
</template>
</section>
</template>
<style scoped>
.exception-processing-form { margin-top: var(--admin-space-5); }
.exception-detail-fields :deep(.el-descriptions__table) { table-layout: fixed; }
.exception-return-history { margin: 12px 0; }
.exception-return-history h3 { margin: 0 0 8px; font-size: 14px; }
.exception-return-batch + .exception-return-batch { margin-top: 12px; }
@@ -2,7 +2,7 @@
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { onMounted, reactive, ref } from 'vue'
import { FileUp } from '@lucide/vue'
import { FileUp, ClipboardList, Clock, CircleCheck } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -158,14 +158,27 @@ onMounted(load)
</script>
<template>
<AdminPage class="admin-density admin-workbench">
<AdminPage spacing="compact" class="customer-visits-page admin-density admin-workbench">
<section class="visit-summary-grid" aria-label="回访统计">
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><ClipboardList aria-hidden="true" /></span><h2 class="workbench-metric-label">可回访订单</h2></div>
<div class="workbench-metric-value"><strong>{{ summary.visitable.toLocaleString('zh-CN') }}</strong><span>单</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Clock aria-hidden="true" /></span><h2 class="workbench-metric-label">待回访</h2></div>
<div class="workbench-metric-value"><strong>{{ summary.pending.toLocaleString('zh-CN') }}</strong><span>单</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">已完成回访</h2></div>
<div class="workbench-metric-value"><strong>{{ summary.completed.toLocaleString('zh-CN') }}</strong><span>单</span></div>
</article>
</section>
<ListPageLayout aria-label="客户回访列表">
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-customer-visits" row-key="id" empty-text="暂无可回访派送单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>客户回访</h1></template>
<template #filters>
<dl class="visit-summary density-detail-overview" aria-label="回访统计"><div><dt>可回访订单</dt><dd>{{ summary.visitable }}</dd></div><div><dt>待回访</dt><dd>{{ summary.pending }}</dd></div><div><dt>已完成回访</dt><dd>{{ summary.completed }}</dd></div></dl>
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="客户回访筛选" preference-key="overseas-customer-visits-query" :search-on-reset="false" @search="search" @reset="reset" />
</template>
<template #notices><template v-if="error"><LoadErrorState title="客户回访列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
@@ -212,7 +225,8 @@ onMounted(load)
</template>
<style scoped>
.visit-summary { flex-wrap: wrap; }
.admin-workbench.customer-visits-page.admin-page.is-spacing-compact { gap: 0; }
.visit-summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--workbench-grid-gap); }
.visit-order-summary.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: var(--admin-space-5); }
.inbound-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); width: 100%; }
.inbound-option { display: grid; min-width: 0; gap: var(--admin-space-2); padding: var(--admin-space-3); border: 1px solid var(--workbench-border); border-radius: var(--admin-radius); cursor: pointer; color: var(--workbench-muted); overflow-wrap: anywhere; }
@@ -222,5 +236,5 @@ onMounted(load)
.attachment-list, .attachment-empty { width: 100%; margin-top: var(--admin-space-3); }
.attachment-list > div { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: var(--admin-space-3); padding: var(--admin-space-2) 0; border-bottom: 1px solid var(--workbench-border); overflow-wrap: anywhere; }
.attachment-list small, .attachment-empty { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
@media (max-width: 720px) { .visit-summary { flex-wrap: wrap; } .visit-order-summary.density-fields, .inbound-options { grid-template-columns: 1fr; } .file-tip { display: block; margin: 8px 0 0; } }
@media (max-width: 720px) { .visit-summary-grid { grid-template-columns: 1fr; } .visit-order-summary.density-fields, .inbound-options { grid-template-columns: 1fr; } .file-tip { display: block; margin: 8px 0 0; } }
</style>
@@ -110,7 +110,8 @@ onMounted(load)
</el-form>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="detailOpen" title="异常订单详情" width="min(760px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭">
<ExceptionProcessingPanel v-if="detailRow" :customer-id="detailRow.customerId" :exception-no="detailRow.exceptionNo" :active="detailOpen" @changed="load()" />
<ExceptionProcessingPanel v-if="detailRow" :customer-id="detailRow.customerId" :exception-no="detailRow.exceptionNo" :active="detailOpen" actions-target="#exception-detail-actions" @changed="load()" />
<template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button @click="close">关闭</el-button><div id="exception-detail-actions"></div></div></template>
</AppDialog>
</template>