发布:将已验 TEST 功能更新至既有生产灰度(2026-10-23) #21
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { computed, onActivated, onDeactivated, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -18,6 +18,7 @@ import WorkDetailPanel from './WorkDetailPanel.vue'
|
||||
import SourceAppointmentPanel from './SourceAppointmentPanel.vue'
|
||||
import type { PlanningSourceLookup } from './planningApi'
|
||||
import { appointmentExternalTarget } from './workNavigation'
|
||||
import { useWorkSyncRead } from './useWorkSyncRead'
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const stringQuery = (key: string) => typeof route.query[key] === 'string' ? route.query[key] as string : ''
|
||||
const savedAnchor = stringQuery('workAnchor')
|
||||
@@ -49,6 +50,14 @@ const heading = computed(() => {
|
||||
})
|
||||
const scopeDescription = computed(() => scope.value?.kind === 'day' ? '按所选日期、流程与类型查询,包含已完成事项' : scope.value?.kind === 'arrivals' ? '独立到港来源,不计工作量;来源未提供流程筛选,展示该日全部参考' : '跨月份、流程与类型,包含未预约和未定日期事项')
|
||||
const panelOpen = computed(() => !!scope.value || !!detailKey.value || !!sourceLink.value)
|
||||
// Cached routes must not leave their body-teleported dialog over another workspace.
|
||||
const pageActive = ref(true)
|
||||
onActivated(() => { const returning = !pageActive.value; pageActive.value = true; if (returning) void refreshWork() })
|
||||
onDeactivated(() => {
|
||||
pageActive.value = false
|
||||
++calendarRequest; ++summaryRequest; ++itemsRequest
|
||||
calendarController?.abort(); summaryController?.abort(); itemsController?.abort()
|
||||
})
|
||||
const panelBody = ref<HTMLElement | null>(null)
|
||||
const selectedDate = computed(() => scope.value?.kind === 'day' || scope.value?.kind === 'arrivals' ? scope.value.date : undefined)
|
||||
const selectedType = computed(() => scope.value?.kind === 'day' && scope.value.types.length === 1 ? scope.value.types[0] : undefined)
|
||||
@@ -62,19 +71,24 @@ onBeforeRouteLeave(canLeave)
|
||||
onBeforeRouteUpdate((to, from) => to.query.sourceKey !== from.query.sourceKey || to.query.orderId !== from.query.orderId || to.query.customerId !== from.query.customerId || to.query.destinationKey !== from.query.destinationKey || to.query.workItem !== from.query.workItem ? canLeave() : true)
|
||||
let calendarRequest = 0, summaryRequest = 0, itemsRequest = 0, referenceRequest = 0
|
||||
let calendarController: AbortController | null = null, summaryController: AbortController | null = null, itemsController: AbortController | null = null
|
||||
const calendarRead = useWorkSyncRead(), summaryRead = useWorkSyncRead(), itemsRead = useWorkSyncRead()
|
||||
const syncing = computed(() => calendarRead.syncing.value || summaryRead.syncing.value || itemsRead.syncing.value)
|
||||
async function loadCalendar() {
|
||||
const request = ++calendarRequest; calendarController?.abort(); calendarController = new AbortController(); calendarError.value = null; calendar.value = null
|
||||
if (!types.value.length) { calendarLoading.value = false; return }
|
||||
calendarLoading.value = true
|
||||
try {
|
||||
const result = await getWorkCalendar({ from: range.value.from, to: addDays(range.value.to, -1), flow: flow.value || undefined, types: [...types.value] }, calendarController.signal)
|
||||
const query = { from: range.value.from, to: addDays(range.value.to, -1), flow: flow.value || undefined, types: [...types.value] }
|
||||
const signal = calendarController.signal
|
||||
const result = await calendarRead.read(() => getWorkCalendar(query, signal), signal)
|
||||
if (request === calendarRequest) calendar.value = result
|
||||
} catch (cause) { if (request === calendarRequest) calendarError.value = getApiErrorDetails(cause, '工作日历读取失败') }
|
||||
finally { if (request === calendarRequest) calendarLoading.value = false }
|
||||
}
|
||||
async function loadSummary() {
|
||||
const request = ++summaryRequest; summaryController?.abort(); summaryController = new AbortController(); summaryLoading.value = true; summaryError.value = null
|
||||
try { const result = await getWorkSummary(summaryController.signal); if (request === summaryRequest) summary.value = result }
|
||||
const signal = summaryController.signal
|
||||
try { const result = await summaryRead.read(() => getWorkSummary(signal), signal); if (request === summaryRequest) summary.value = result }
|
||||
catch (cause) { if (request === summaryRequest) { summaryError.value = getApiErrorDetails(cause, '全局待办读取失败'); summary.value = null } }
|
||||
finally { if (request === summaryRequest) summaryLoading.value = false }
|
||||
}
|
||||
@@ -91,7 +105,8 @@ async function loadItems() {
|
||||
const request = ++itemsRequest; itemsController?.abort(); itemsController = new AbortController(); itemsError.value = null; items.value = null
|
||||
const query = itemQuery(); if (!query) { itemsLoading.value = false; return }
|
||||
itemsLoading.value = true
|
||||
try { const result = await getWorkItems(query, itemsController.signal); if (request === itemsRequest) items.value = result }
|
||||
const signal = itemsController.signal
|
||||
try { const result = await itemsRead.read(() => getWorkItems(query, signal), signal); if (request === itemsRequest) items.value = result }
|
||||
catch (cause) { if (request === itemsRequest) itemsError.value = getApiErrorDetails(cause, '工作明细读取失败') }
|
||||
finally { if (request === itemsRequest) itemsLoading.value = false }
|
||||
}
|
||||
@@ -188,6 +203,7 @@ onBeforeUnmount(() => { ++calendarRequest; ++summaryRequest; ++itemsRequest; ++r
|
||||
<form class="work-search" role="search" @submit.prevent="searchItems"><label class="sr-only" for="appointment-work-search">查找工作事项</label><input id="appointment-work-search" v-model="search" type="search" maxlength="80" placeholder="柜号、送仓批次、车次、目的仓"><button type="submit">查找事项</button><span>跨日期查找未完成事项 · 含未定日期</span></form>
|
||||
</div>
|
||||
<p v-if="linkError" role="alert">{{ linkError }}</p>
|
||||
<p v-if="syncing" role="status">预约工作清单同步中,正在自动读取最新日历与待办;已保存内容无需重复提交。</p>
|
||||
<LoadErrorState v-if="calendarError" title="工作日历读取失败" :message="calendarError.message" :loading="calendarLoading" @retry="loadCalendar"><template #details><ApiErrorMeta :details="calendarError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="referenceError" title="到港参考不可用" :message="referenceError.message" :loading="referenceLoading" @retry="loadArrivals"><template #details><ApiErrorMeta :details="referenceError" /></template></LoadErrorState>
|
||||
<div class="work-layout">
|
||||
@@ -199,7 +215,7 @@ onBeforeUnmount(() => { ++calendarRequest; ++summaryRequest; ++itemsRequest; ++r
|
||||
<LoadErrorState v-if="summaryError" title="全局待办读取失败" :message="summaryError.message" :loading="summaryLoading" @retry="loadSummary"><template #details><ApiErrorMeta :details="summaryError" /></template></LoadErrorState>
|
||||
</template>
|
||||
</WorkCalendarPanel>
|
||||
<AppDialog :model-value="panelOpen" :title="panelTitle" class="admin-density-dialog admin-density appointment-work work-dialog" width="min(1120px, calc(100vw - 48px))" fullscreenable append-to-body :close-on-click-modal="true" :close-on-press-escape="true" :before-close="closePanel" :show-footer="listVisible && !!items && !itemsError && !itemsLoading" destroy-on-close>
|
||||
<AppDialog v-if="pageActive" :model-value="panelOpen" :title="panelTitle" class="admin-density-dialog admin-density appointment-work work-dialog" width="min(1120px, calc(100vw - 48px))" fullscreenable append-to-body :close-on-click-modal="true" :close-on-press-escape="true" :before-close="closePanel" :show-footer="listVisible && !!items && !itemsError && !itemsLoading" destroy-on-close>
|
||||
<template #header>
|
||||
<header class="panel-header">
|
||||
<div class="panel-nav"><button v-if="detailKey || sourceLink" type="button" @click="back">‹ {{ origins.length ? '返回原事项' : '返回工作明细' }}</button><h3>{{ panelTitle }}</h3></div>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { defineComponent, h, inject, KeepAlive, onBeforeUnmount, ref, type Ref } from 'vue'
|
||||
import { ElDialog, ElPopover } from 'element-plus'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||||
import { getWorkCalendar, getWorkItems, getWorkSummary } from './workApi'
|
||||
import { listArrivalCalendar } from './api'
|
||||
import { workCalendar, workItem, workSummary, followAction } from './work.test-support'
|
||||
const routing = vi.hoisted(() => ({ query: {} as Record<string, string>, push: vi.fn(), replace: vi.fn(), canReadSource: true, allowLeave: true }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission !== 'platform:overseas-warehouse:last-mile-order:list' || routing.canReadSource }) }))
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkCalendar: vi.fn(), getWorkItems: vi.fn(), getWorkSummary: vi.fn() }))
|
||||
vi.mock('./api', async original => ({ ...await original<typeof import('./api')>(), listArrivalCalendar: vi.fn() }))
|
||||
function render(realDialog = false, active?: Ref<boolean>) {
|
||||
const host = active ? defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => active.value ? h(AppointmentCalendarView) : h('div', { 'data-other-page': '' }) }) }) : AppointmentCalendarView
|
||||
return mount(host, { attachTo: document.body, global: { components: { ElDialog, ElPopover }, directives: { loading: {} }, stubs: {
|
||||
teleport: !active,
|
||||
ElTooltip: active ? false : { template: '<slot />' },
|
||||
ElDialog: realDialog ? ElDialog : { name: 'ElDialog', props: ['modelValue', 'title', 'fullscreen', 'beforeClose', 'closeOnClickModal', 'closeOnPressEscape'], emits: ['update:modelValue'], template: '<div v-if="modelValue" class="el-dialog" :class="{ \'is-fullscreen\': fullscreen }"><button aria-label="关闭工作面板" @click="beforeClose(() => $emit(\'update:modelValue\', false))">关闭</button><slot name="header" /><slot /><slot name="footer" /></div>' },
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields', 'loading'], emits: ['update:modelValue', 'search'], template: '<div data-list-search />' },
|
||||
DataTable: { name: 'DataTable', props: ['data', 'columns', 'total', 'page', 'pageSize'], emits: ['update:page'], template: `<div><table><thead><tr><th v-for="column in columns" :key="column.key">{{ column.label }}</th></tr></thead><tbody><tr v-for="row in data" :key="row.workItemKey"><td v-for="column in columns" :key="column.key"><slot v-if="column.slotName" :name="column.slotName" :row="row" /><template v-else>{{ row[column.key] }}</template></td></tr></tbody></table><footer class="data-table__pagination"><slot name="pagination-left" /><button aria-label="下一页" @click="$emit('update:page', page + 1)">下一页</button></footer></div>` },
|
||||
ElButton: { props: ['type'], template: '<button type="button" :data-variant="type"><slot /></button>' },
|
||||
WorkCalendarPanel: { name: 'WorkCalendarPanel', props: ['anchor', 'mode', 'flow', 'types', 'calendar', 'failed', 'reference', 'selectedDate', 'selectedType'], template: '<div data-calendar><slot name="inbox" /></div>' },
|
||||
WorkDetailPanel: { name: 'WorkDetailPanel', props: ['itemKey', 'initialAction', 'canLeave'], setup() {
|
||||
const register = inject<(guard: LeaveGuard) => () => void>('appointment-work-leave-guard')!
|
||||
const unregister = register(async () => routing.allowLeave)
|
||||
onBeforeUnmount(unregister)
|
||||
}, template: '<div data-detail>{{ itemKey }}</div>' },
|
||||
SourceAppointmentPanel: { name: 'SourceAppointmentPanel', props: ['source'], template: '<div data-cargo />' },
|
||||
LoadErrorState: { props: ['title', 'message'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button @click="$emit(\'retry\')">重试</button></section>' },
|
||||
ApiErrorMeta: true,
|
||||
} } })
|
||||
}
|
||||
describe('appointment repair regression', () => {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); routing.query = { workAnchor: '2026-10-15' }; routing.canReadSource = true; routing.allowLeave = true
|
||||
routing.replace.mockResolvedValue(undefined); routing.push.mockResolvedValue(undefined)
|
||||
vi.mocked(getWorkCalendar).mockResolvedValue(workCalendar)
|
||||
vi.mocked(getWorkSummary).mockResolvedValue(workSummary)
|
||||
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem()], page: 1, size: 8, total: 1, pages: 1 })
|
||||
vi.mocked(listArrivalCalendar).mockResolvedValue([])
|
||||
})
|
||||
it('waits for synchronization before restoring calendar, summary and current work without showing a failed save', async () => {
|
||||
vi.useFakeTimers()
|
||||
routing.query = { workScope: 'global', workView: 'ALL' }
|
||||
const pending = new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409)
|
||||
vi.mocked(getWorkCalendar).mockRejectedValueOnce(pending)
|
||||
vi.mocked(getWorkSummary).mockRejectedValueOnce(pending)
|
||||
vi.mocked(getWorkItems).mockRejectedValueOnce(pending)
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('同步中')
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('加载失败')
|
||||
await vi.advanceTimersByTimeAsync(2000); await flushPromises()
|
||||
expect(getWorkCalendar).toHaveBeenCalledTimes(2)
|
||||
expect(getWorkSummary).toHaveBeenCalledTimes(2)
|
||||
expect(getWorkItems).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).not.toContain('同步中')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('cancels synchronization polling when leaving the cached calendar', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(getWorkSummary).mockRejectedValue(new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409))
|
||||
const active = ref(true), wrapper = render(false, active); await flushPromises()
|
||||
expect(getWorkSummary).toHaveBeenCalledTimes(1)
|
||||
active.value = false; await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
expect(getWorkSummary).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('removes the teleported dialog while a cached appointment page is inactive and restores its context', async () => {
|
||||
routing.query = { workScope: 'global', workView: 'UNDATED', workPage: '3', workItem: 'work:1' }
|
||||
const active = ref(true), wrapper = render(true, active); await flushPromises()
|
||||
expect(document.body.querySelector('.work-dialog [data-detail]')?.textContent).toBe('work:1')
|
||||
active.value = false; await flushPromises()
|
||||
expect(document.body.querySelector('.work-dialog')).toBeNull()
|
||||
active.value = true; await flushPromises()
|
||||
expect(document.body.querySelector('.work-dialog [data-detail]')?.textContent).toBe('work:1')
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 3, size: 8 }, expect.any(AbortSignal))
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -168,4 +168,7 @@ V13 日历布局:按已确认定高要求,月日格固定 146px,周日格
|
||||
| 本柜接仓计划与记录 | `executeForm()`、原入库单详情 | 单计划进入对应客户原单,多计划列出本柜实际关联清单;缺计划时先预览整柜分组 | `WorkReceiptPlans.vue`;复用 container-summaries、原整柜预览/生成 API 和原入库单路由,快照与原资源重试键保持一致;实际收货仍由原仓库作业流程登记 |
|
||||
| 失败、无权限、未保存 | 真实接口与应用验收规则 | 明确失败和重试;错误不改为0项,保留输入和原上下文 | 各组件直接回归;未知命令不执行,未确认保存结果禁止离开 |
|
||||
|
||||
| 保存后工作清单同步等待 | 用户截图 `codex-clipboard-40520e09-5cbf-489f-9941-325bdf22bdea.png`,顶部保存成功、正文续办 A0409 | 保留当前事项弹框和保存结果,不改变承载方式 | `useWorkSyncRead.ts` 仅对当前完整工作清单同步守卫重读 GET;间隔2秒,累计等待最多30秒,超限显示原错误;日历、汇总、明细与批次续办一致,不重复写入 |
|
||||
| 查看原排车单、继续安排本批车辆 | 用户截图 `codex-clipboard-4791b73f-7a9f-4be5-910a-38911e135ef4.png`、`codex-clipboard-e5b41c4b-a00c-4b99-ac06-cd23ec910aa9.png` | 原排车单进入正式路由,事项弹框随缓存页停用而移除;继续配车沿目标事项读取资格 | 保留已有 `pageActive/onDeactivated` 修复;跨事项 `nextAction` 仅导航,目标事项重新核对 `allowedActions`,不继承送仓资格或执行旧动作。修复后待人工截图确认 |
|
||||
|
||||
视觉目标:1920×1080、100%缩放,待人工截图确认。单元测试与类型检查单独报告,不代表真实接口或页面验收通过。原业务跳转缺少的业务号/客户范围在协作交接中列明,不由前端补造;工作 Slot 与原预约编辑均保留完整跨日时间。原业务页面返回入口只接受预约工作页,并保留原事项、前置事项、筛选及页码。
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import WorkBatchContinuation from './WorkBatchContinuation.vue'
|
||||
import { resolveBatchWork } from './workApi'
|
||||
import { workDetail, workItem } from './work.test-support'
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), resolveBatchWork: vi.fn() }))
|
||||
function render() { return mount(WorkBatchContinuation, { props: { batchKey: 'FBA_APPOINTMENT:40', labels: [{ customerId: 'customer-a', label: 'IN-A' }] }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></div>' }, ApiErrorMeta: true } } }) }
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
afterEach(() => vi.useRealTimers())
|
||||
it('shows synchronization after a successful save and continues by reading without resubmitting', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(resolveBatchWork).mockRejectedValueOnce(new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409)).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:40', customerId: null, detail: workDetail({ item: workItem({ workItemKey: 'actual-key' }) }), candidates: [] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('同步中')
|
||||
expect(wrapper.text()).toContain('无需重复提交')
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(2000); await flushPromises()
|
||||
expect(wrapper.emitted('open')).toEqual([['actual-key']])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('requires a customer selection for multiple real work candidates and uses the selected resolver result', async () => {
|
||||
vi.mocked(resolveBatchWork).mockResolvedValueOnce({ batchKey: 'FBA_APPOINTMENT:40', customerId: null, detail: null, candidates: [{ customerId: 'customer-a', workItemKey: 'real-work-a', batchNo: 'AP-40' }, { customerId: 'customer-b', workItemKey: 'real-work-b', batchNo: 'AP-40' }] }).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:40', customerId: 'customer-a', detail: workDetail({ item: workItem({ workItemKey: 'current-work-a' }) }), candidates: [] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
|
||||
@@ -4,13 +4,15 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { resolveBatchWork, type BatchWorkResolution } from './workApi'
|
||||
import { useWorkSyncRead } from './useWorkSyncRead'
|
||||
const props = defineProps<{ batchKey: string; customerId?: string; labels?: Array<{ customerId: string; label: string }> }>()
|
||||
const emit = defineEmits<{ open: [key: string] }>()
|
||||
const resolution = ref<BatchWorkResolution | null>(null), error = ref<ApiErrorDetails | null>(null), loading = ref(false)
|
||||
const controller = new AbortController()
|
||||
const syncRead = useWorkSyncRead(), syncing = syncRead.syncing
|
||||
async function load(customerId = props.customerId) {
|
||||
loading.value = true; error.value = null
|
||||
try { const value = await resolveBatchWork(props.batchKey, customerId, controller.signal); if (controller.signal.aborted) return; resolution.value = value; if (value.detail) emit('open', value.detail.item.workItemKey) }
|
||||
try { const value = await syncRead.read(() => resolveBatchWork(props.batchKey, customerId, controller.signal), controller.signal); if (controller.signal.aborted) return; resolution.value = value; if (value.detail) emit('open', value.detail.item.workItemKey) }
|
||||
catch (cause) { if (!controller.signal.aborted) error.value = getApiErrorDetails(cause, '当前批次下一步读取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
@@ -19,7 +21,7 @@ onBeforeUnmount(() => controller.abort())
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="当前批次续办">
|
||||
<p>正在定位当前批次的工作事项;已保存的内容无需重复提交。</p>
|
||||
<p role="status">{{ syncing ? '预约工作清单同步中,正在自动读取当前批次下一步;已保存内容无需重复提交。' : '正在定位当前批次的工作事项;已保存的内容无需重复提交。' }}</p>
|
||||
<LoadErrorState v-if="error" title="当前批次下一步读取失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-if="resolution && !resolution.detail && !error"><p>本批有多个客户,请选择本次继续办理的货物。</p><el-button v-for="(candidate, index) in resolution.candidates" :key="candidate.workItemKey" :disabled="loading" @click="load(candidate.customerId)">{{ candidate.batchNo || '—' }} · {{ labels?.find(row => row.customerId === candidate.customerId)?.label || `客户范围 ${index + 1}` }}</el-button><p v-if="!resolution.candidates.length">当前没有可定位的送仓事项,请核对服务端同步结果。</p></template>
|
||||
</section>
|
||||
|
||||
@@ -21,6 +21,21 @@ function render(detail: WorkDetail, initialAction?: WorkAction) {
|
||||
} } })
|
||||
}
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('navigates the server-selected transport next step outside the delivery write permissions', async () => {
|
||||
const transport: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '继续安排本批车辆', targetWorkItemKey: 'transport-current', target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['FBA_APPOINTMENT:1'] } }
|
||||
const wrapper = render(workDetail({ item: workItem({ nextAction: transport, allowedActions: [followAction] }) }))
|
||||
await flushPromises()
|
||||
await wrapper.get('.primary-next-action').trigger('click'); await flushPromises()
|
||||
expect(wrapper.emitted('open')).toEqual([['transport-current', transport]])
|
||||
expect(wrapper.text()).not.toContain('操作资格已变化')
|
||||
expect(wrapper.find('[data-dispatch]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
const target = render(workDetail({ item: workItem({ workItemKey: 'transport-current', type: 'TRANSPORT', allowedActions: [], nextAction: null }) }), transport)
|
||||
await flushPromises()
|
||||
expect(target.find('[data-dispatch]').exists()).toBe(false)
|
||||
expect(target.emitted('open')).toBeUndefined()
|
||||
target.unmount()
|
||||
})
|
||||
it('stays on the saved item and offers only the newly fetched primary next step', async () => {
|
||||
const wrapper = render(workDetail(), followAction); await flushPromises()
|
||||
const transport: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '继续安排本批车辆', targetWorkItemKey: 'transport-current', target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['FBA_APPOINTMENT:1'] } }
|
||||
|
||||
@@ -17,6 +17,7 @@ import WorkReceivingPanel from './WorkReceivingPanel.vue'
|
||||
import WorkBatchContinuation from './WorkBatchContinuation.vue'
|
||||
import type { FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
import { useWorkSyncRead } from './useWorkSyncRead'
|
||||
const props = defineProps<{ itemKey: string; initialAction?: WorkAction | null; canLeave: LeaveGuard }>()
|
||||
const emit = defineEmits<{ open: [key: string, action?: WorkAction]; external: [path: string]; saved: [result?: WorkCommandResult, command?: WorkCommand['command']]; updated: []; container: [id: string, label: string] }>()
|
||||
const detail = ref<WorkDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), action = ref<WorkAction | null>(null)
|
||||
@@ -28,9 +29,11 @@ const batchKey = ref<string | null>(null), openedAppointmentNo = ref<string | nu
|
||||
const receivingKey = ref<string | null>(null)
|
||||
const continuation = ref<{ batchKey: string; customerId?: string; labels?: Array<{ customerId: string; label: string }> } | null>(null)
|
||||
let controller: AbortController | null = null, request = 0
|
||||
const syncRead = useWorkSyncRead(), syncing = syncRead.syncing
|
||||
async function load() {
|
||||
const generation = ++request; controller?.abort(); controller = new AbortController(); loading.value = true; error.value = null
|
||||
try { const result = await getWorkDetail(props.itemKey, controller.signal); if (generation === request) detail.value = result }
|
||||
const signal = controller.signal
|
||||
try { const result = await syncRead.read(() => getWorkDetail(props.itemKey, signal), signal); if (generation === request) detail.value = result }
|
||||
catch (cause) { if (generation === request) error.value = getApiErrorDetails(cause, '工作事项读取失败') }
|
||||
finally { if (generation === request) loading.value = false }
|
||||
}
|
||||
@@ -60,7 +63,9 @@ const secondaryActions = computed(() => detail.value?.item.allowedActions.filter
|
||||
async function choose(next: WorkAction) {
|
||||
if (!detail.value || loading.value || error.value || !await props.canLeave()) return
|
||||
actionNotice.value = ''
|
||||
if (!actionAllowed(detail.value.item, next)) { actionNotice.value = '操作资格已变化,请刷新事项后查看当前下一步。'; return }
|
||||
// 跨事项的主要下一步只用于导航,目标面板会重新读取并校验自己的写入资格。
|
||||
const linkedNext = next.targetWorkItemKey && next.targetWorkItemKey !== props.itemKey && detail.value.item.nextAction && sameAction(next, detail.value.item.nextAction)
|
||||
if (!linkedNext && !actionAllowed(detail.value.item, next)) { actionNotice.value = '操作资格已变化,请刷新事项后查看当前下一步。'; return }
|
||||
saveCompleted.value = false
|
||||
if (next.targetWorkItemKey && next.targetWorkItemKey !== props.itemKey) { emit('open', next.targetWorkItemKey, next); return }
|
||||
const path = actionRoute(next, detail.value.references, detail.value.item)
|
||||
@@ -102,7 +107,7 @@ onBeforeUnmount(() => { ++request; controller?.abort() })
|
||||
<template>
|
||||
<div :aria-busy="loading">
|
||||
<LoadErrorState v-if="error" title="工作事项读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在读取当前事项与操作资格…</p>
|
||||
<p v-if="loading" role="status">{{ syncing ? '预约工作清单同步中,正在自动读取最新事项;已保存内容无需重复提交。' : '正在读取当前事项与操作资格…' }}</p>
|
||||
<template v-if="detail">
|
||||
<h3>{{ detail.item.title }}</h3><p>{{ typeLabels[detail.item.type] }} · {{ flowLabels[detail.item.flow] }} · {{ workStatus(detail.item) }}</p>
|
||||
<p>{{ workDate(detail.item) }} · {{ detail.item.timezone || '地点时区待核实' }}</p><p>{{ detail.item.destinationName || detail.item.originName || '地点待核实' }} · {{ detail.item.owner?.name || '待分派' }}</p>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { useWorkSyncRead } from './useWorkSyncRead'
|
||||
const pending = () => new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409, undefined, 'request-sync', 'trace-sync')
|
||||
afterEach(() => vi.useRealTimers())
|
||||
it('waits only for the full-work synchronization guard and returns the fresh result', async () => {
|
||||
vi.useFakeTimers()
|
||||
const reader = useWorkSyncRead(), load = vi.fn().mockRejectedValueOnce(pending()).mockResolvedValue('fresh')
|
||||
const result = reader.read(load, new AbortController().signal)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(reader.syncing.value).toBe(true)
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(await result).toBe('fresh')
|
||||
expect(reader.syncing.value).toBe(false)
|
||||
})
|
||||
it.each([new ApiResponseError('事项版本已变化', 'A0409', 409), new ApiResponseError('无权查看', 'A0403', 403), new Error('network')])('does not retry other failures: %s', async error => {
|
||||
const reader = useWorkSyncRead(), load = vi.fn().mockRejectedValue(error)
|
||||
await expect(reader.read(load, new AbortController().signal)).rejects.toBe(error)
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
expect(reader.syncing.value).toBe(false)
|
||||
})
|
||||
it('stops waiting at the limit and preserves diagnostic IDs on the original error', async () => {
|
||||
vi.useFakeTimers()
|
||||
const error = pending(), reader = useWorkSyncRead(), load = vi.fn().mockRejectedValue(error)
|
||||
const result = expect(reader.read(load, new AbortController().signal)).rejects.toBe(error)
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
await result
|
||||
expect(load).toHaveBeenCalledTimes(16)
|
||||
expect(reader.syncing.value).toBe(false)
|
||||
expect(error.traceId).toBe('trace-sync')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
it('cancels a waiting read without sending another request', async () => {
|
||||
vi.useFakeTimers()
|
||||
const controller = new AbortController(), reader = useWorkSyncRead(), load = vi.fn().mockRejectedValue(pending())
|
||||
const result = expect(reader.read(load, controller.signal)).rejects.toHaveProperty('name', 'AbortError')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
controller.abort()
|
||||
await result
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
expect(reader.syncing.value).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ref } from 'vue'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
|
||||
// 只等待工作清单同步守卫;版本冲突、权限失败和写请求均不重试。
|
||||
export function useWorkSyncRead() {
|
||||
const syncing = ref(false)
|
||||
let generation = 0
|
||||
async function read<T>(load: () => Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
const current = ++generation
|
||||
syncing.value = false
|
||||
try {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
signal.throwIfAborted()
|
||||
try { return await load() }
|
||||
catch (cause) {
|
||||
const error = getApiErrorDetails(cause)
|
||||
if (error.code !== 'A0409' || error.message !== '预约批次或运输关联已变化,服务端正在协调完整工作清单' || attempt >= 15) throw cause
|
||||
if (current === generation) syncing.value = true
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const abort = () => { clearTimeout(timer); reject(signal.reason) }
|
||||
const timer = setTimeout(() => { signal.removeEventListener('abort', abort); resolve() }, 2000)
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
if (signal.aborted) { signal.removeEventListener('abort', abort); abort() }
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally { if (current === generation) syncing.value = false }
|
||||
}
|
||||
return { syncing, read }
|
||||
}
|
||||
Reference in New Issue
Block a user