fix(drivers): isolate cached detail requests and reject absent route IDs

This commit is contained in:
2026-10-04 01:02:28 +08:00
parent 5c4a381d87
commit f4d58e669b
2 changed files with 102 additions and 7 deletions
@@ -1,6 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -27,6 +27,12 @@ const fileInput = ref<HTMLInputElement | null>(null), qualificationLoading = ref
const qualificationStale = ref(false)
const historyOpen = ref(false), historyRows = ref<DriverHistory[]>([]), historyPage = ref(1), historySize = ref(20), historyTotal = ref(0), historyLoading = ref(false), historyError = ref<ApiErrorDetails | null>(null)
let request = 0, historyRequest = 0, qualificationRequest = 0
let detailActive = true, initialActivation = true
function routeDriverId(): string | null {
const id = route.params.driverId
return route.name === 'overseas-master-data-driver-detail' && typeof id === 'string' && /^\d+$/.test(id) ? id : null
}
const instanceDriverId = routeDriverId()
const browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone
const basic = computed(() => detail.value ? [
['司机编号', detail.value.driverNo], ['司机姓名', detail.value.details.name], ['联系电话', detail.value.details.phone],
@@ -46,13 +52,20 @@ const historyColumns: DataTableColumn[] = [
{ key: 'reviewStatus', label: '当时审核状态', slotName: 'historyReview', minWidth: 120 },
]
const actionLabels: Record<DriverHistory['action'], string> = { CREATE: '创建档案', UPDATE: '编辑档案', ACTIVATE: '启用', DEACTIVATE: '停用', UPLOAD_LICENSE: '上传驾驶证', APPROVED: '审核通过', REJECTED: '审核打回', RESUBMIT: '重新提交' }
async function load() {
function resetDetailRequests() {
const current = ++request
historyRequest++; qualificationRequest++; detail.value = null; error.value = null; loading.value = true
historyRequest++; qualificationRequest++; detail.value = null; error.value = null; loading.value = false
historyOpen.value = false; historyRows.value = []; historyPage.value = 1; reviewReason.value = ''; expectedEnd.value = null
historyLoading.value = false; historyError.value = null
previewOpen.value = false; editorOpen.value = false; qualificationLoading.value = false
qualificationStale.value = false
try { const result = await getDriver(String(route.params.driverId)); if (current === request) detail.value = result }
return current
}
async function load() {
const current = resetDetailRequests()
if (!detailActive || !instanceDriverId || routeDriverId() !== instanceDriverId) return
loading.value = true
try { const result = await getDriver(instanceDriverId); if (current === request) detail.value = result }
catch (cause) { if (current === request) error.value = getApiErrorDetails(cause, '司机详情加载失败') }
finally { if (current === request) loading.value = false }
}
@@ -116,10 +129,19 @@ async function upload(event: Event) {
finally { busy.value = false }
}
function showLicense(fileId: string) { previewFile.value = fileId; previewOpen.value = true }
watch(() => route.params.driverId, load, { immediate: true })
watch(() => [route.name, route.params.driverId], () => {
if (routeDriverId() !== instanceDriverId) resetDetailRequests()
}, { flush: 'sync' })
onMounted(() => void load())
onActivated(() => {
detailActive = true
if (initialActivation) { initialActivation = false; return }
void load()
})
onDeactivated(() => { detailActive = false; resetDetailRequests() })
watch([historyPage, historySize], () => void loadHistory())
watch(historyOpen, value => { if (value) void loadHistory() })
onBeforeUnmount(() => { request++; historyRequest++; qualificationRequest++ })
onBeforeUnmount(() => { detailActive = false; resetDetailRequests() })
</script>
<template>
@@ -11,8 +11,13 @@ import DriverEditorDialog from '../components/DriverEditorDialog.vue'
import DriverLicensePreview from '../components/DriverLicensePreview.vue'
const security = vi.hoisted(() => ({ allowWrite: true }))
const routing = vi.hoisted(() => ({ route: null as null | { name: string; params: { driverId?: string | string[] } } }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => code.endsWith(':list') || security.allowWrite }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }), useRoute: () => ({ params: { driverId: '9223372036854775701' } }) }))
vi.mock('vue-router', async () => {
const { reactive } = await import('vue')
routing.route = reactive({ name: 'overseas-master-data-driver-detail', params: { driverId: '9223372036854775701' } })
return { useRouter: () => ({ push: vi.fn() }), useRoute: () => routing.route }
})
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined), prompt: vi.fn().mockResolvedValue({ value: '暂停' }) } }))
vi.mock('../api/drivers', () => ({ listDrivers: vi.fn(), getDriver: vi.fn(), listDriverHistory: vi.fn(), listDriverWarehouses: vi.fn(), createDriver: vi.fn(), updateDriver: vi.fn(), changeDriverStatus: vi.fn(), reviewDriver: vi.fn(), submitDriver: vi.fn(), getDriverQualification: vi.fn(), uploadDriverLicense: vi.fn(), downloadDriverLicense: vi.fn() }))
@@ -33,9 +38,22 @@ function mount(component: object, props: Record<string, unknown> = {}): VueWrapp
return wrapper
}
function findButton(wrapper: VueWrapper, text: string) { const found = wrapper.findAll('button').find(value => value.text().includes(text)); expect(found).toBeDefined(); return found! }
function cachedDetails() {
const showing = ref<string | null>(driver.id)
const host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => showing.value ? h(DriverDetailView, { key: showing.value }) : h('div', '司机列表') }) })
const wrapper = shallowMount(host, { global: { stubs: { ...stubs, KeepAlive: false, DriverDetailView: false }, directives: { loading: () => undefined } } })
wrappers.push(wrapper)
return { wrapper, show(id: string | null) {
routing.route!.name = id ? 'overseas-master-data-driver-detail' : 'overseas-master-data-drivers'
routing.route!.params = id ? { driverId: id } : {}
showing.value = id
} }
}
beforeEach(() => {
vi.clearAllMocks(); security.allowWrite = true
routing.route!.name = 'overseas-master-data-driver-detail'
routing.route!.params = { driverId: driver.id }
vi.mocked(api.listDrivers).mockResolvedValue({ records: [structuredClone(driver)], page: 1, size: 20, total: 24, pages: 2, summary: { total: 24, owned: 20, dispatched: 4, expiringSoon: 3, expired: 1 } })
vi.mocked(api.getDriver).mockResolvedValue(structuredClone(driver))
vi.mocked(api.listDriverWarehouses).mockResolvedValue([{ id: '92', code: 'WH-US', name: '洛杉矶仓', region: 'OVERSEAS' }])
@@ -45,6 +63,61 @@ beforeEach(() => {
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.unstubAllGlobals() })
describe('driver page workflows', () => {
it('does not fetch an absent driver on return to list and loads the valid driver on reentry', async () => {
const { wrapper, show } = cachedDetails(); await flushPromises()
expect(api.getDriver).toHaveBeenCalledExactlyOnceWith(driver.id)
show(null)
await flushPromises()
expect(api.getDriver).toHaveBeenCalledTimes(1)
const next = { ...structuredClone(driver), recordVersion: 3, details: { ...driver.details, name: '当前司机' } }
vi.mocked(api.getDriver).mockResolvedValueOnce(next)
show(next.id)
await flushPromises()
expect(api.getDriver).toHaveBeenCalledTimes(2)
expect(api.getDriver).toHaveBeenLastCalledWith(next.id)
expect(wrapper.text()).toContain('当前司机')
})
it('loads only the active driver instance when two parameter-keyed detail pages are cached', async () => {
const other = { ...structuredClone(driver), id: '9223372036854775702', driverNo: 'DR-OTHER', details: { ...driver.details, name: '另一司机' } }
vi.mocked(api.getDriver).mockImplementation(async id => structuredClone(id === driver.id ? driver : other))
const { wrapper, show } = cachedDetails(); await flushPromises()
const first = wrapper.findComponent(DriverDetailView)
expect(api.getDriver).toHaveBeenCalledExactlyOnceWith(driver.id)
show(other.id); await flushPromises()
expect(api.getDriver).toHaveBeenCalledTimes(2)
expect(api.getDriver).toHaveBeenLastCalledWith(other.id)
expect(first.text()).not.toContain('另一司机')
expect(wrapper.text()).toContain('另一司机')
show(driver.id); await flushPromises()
expect(api.getDriver).toHaveBeenCalledTimes(3)
expect(api.getDriver).toHaveBeenLastCalledWith(driver.id)
expect(wrapper.text()).toContain('测试司机')
show(null); await flushPromises()
expect(api.getDriver).toHaveBeenCalledTimes(3)
show(other.id); await flushPromises()
expect(api.getDriver).toHaveBeenCalledTimes(4)
expect(api.getDriver).toHaveBeenLastCalledWith(other.id)
})
it('invalidates a pending detail response when the route leaves even before params are cleared', async () => {
let finish!: (value: DriverDetail) => void
vi.mocked(api.getDriver).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
const wrapper = mount(DriverDetailView); await flushPromises()
routing.route!.name = 'overseas-master-data-drivers'
await flushPromises()
finish({ ...structuredClone(driver), details: { ...driver.details, name: '离页迟到司机' } })
await flushPromises()
expect(api.getDriver).toHaveBeenCalledTimes(1)
expect(wrapper.text()).not.toContain('离页迟到司机')
})
it.each([{ label: 'missing', id: undefined }, { label: 'empty', id: '' }, { label: 'array', id: [driver.id] }])('does not fabricate a detail ID from $label route params', async ({ id }) => {
routing.route!.params = { driverId: id }
mount(DriverDetailView); await flushPromises()
expect(api.getDriver).not.toHaveBeenCalled()
})
it('refreshes a cached list once on return and retains applied filters and pagination', async () => {
const showingList = ref(true)
const host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => showingList.value ? h(DriversView) : h('div', '司机详情') }) })