fix(admin): restore loading list detail page navigation
This commit is contained in:
@@ -2,8 +2,8 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import LoadingListsView from './LoadingListsView.vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ get: vi.fn(), list: vi.fn(), can: vi.fn(), push: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push: mocks.push, replace: vi.fn() }) }))
|
||||
const mocks = vi.hoisted(() => ({ get: vi.fn(), list: vi.fn(), can: vi.fn(), push: vi.fn(), replace: vi.fn(), query: {} as Record<string, string> }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ query: mocks.query }), useRouter: () => ({ push: mocks.push, replace: mocks.replace }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: mocks.can }) }))
|
||||
vi.mock('@/modules/container-loading', () => ({ ContainerSimulationCreate: { template: '<div />' } }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: async () => ({ records: [] }) }))
|
||||
@@ -20,27 +20,32 @@ function render() {
|
||||
ElButton: { template: '<button><slot /></button>' },
|
||||
} } })
|
||||
}
|
||||
describe('confirmed manifest inline detail', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); mocks.can.mockReturnValue(true); mocks.list.mockResolvedValue({ records: rows, total: 2 }) })
|
||||
it('ignores an older detail response when another manifest is selected', async () => {
|
||||
let resolveFirst!: (value: unknown) => void
|
||||
mocks.get.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve }))
|
||||
.mockResolvedValueOnce({ summary: { status: 'COMPLETED', planId: '2', loadingListNo: 'LIST-2' } })
|
||||
describe('confirmed manifest navigation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.query = {}
|
||||
mocks.can.mockReturnValue(true)
|
||||
mocks.list.mockResolvedValue({ records: rows, total: 2 })
|
||||
})
|
||||
it('opens each manifest on its dedicated detail route with warehouse context', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button')[0]!.trigger('click')
|
||||
await wrapper.findAll('button')[2]!.trigger('click'); await flushPromises()
|
||||
resolveFirst({ summary: { status: 'COMPLETED', planId: '1', loadingListNo: 'LIST-1' } }); await flushPromises()
|
||||
expect(wrapper.get('[data-testid="detail"]').text()).toBe('LIST-2')
|
||||
expect(mocks.get).toHaveBeenNthCalledWith(2, '2', '22')
|
||||
expect(mocks.push).not.toHaveBeenCalled()
|
||||
expect(mocks.push).toHaveBeenLastCalledWith({ name: 'loading-list-detail', params: { planId: '1' }, query: { warehouseId: '11' } })
|
||||
await wrapper.findAll('button')[2]!.trigger('click')
|
||||
expect(mocks.push).toHaveBeenLastCalledWith({ name: 'loading-list-detail', params: { planId: '2' }, query: { warehouseId: '22' } })
|
||||
expect(mocks.get).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('[data-testid="detail"]').exists()).toBe(false)
|
||||
})
|
||||
it('does not print when loading fails', async () => {
|
||||
const print = vi.spyOn(window, 'print').mockImplementation(() => {})
|
||||
mocks.get.mockRejectedValueOnce(new Error('清单不可用'))
|
||||
it('prints through the dedicated detail page', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button')[1]!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.get('[data-testid="detail"]').text()).toContain('清单不可用')
|
||||
expect(print).not.toHaveBeenCalled(); print.mockRestore()
|
||||
await wrapper.findAll('button')[1]!.trigger('click')
|
||||
expect(mocks.push).toHaveBeenCalledWith({ name: 'loading-list-detail', params: { planId: '1' }, query: { warehouseId: '11', print: '1' } })
|
||||
expect(mocks.get).not.toHaveBeenCalled()
|
||||
})
|
||||
it('replaces a list deep link with the dedicated detail route', async () => {
|
||||
mocks.query = { planId: '2', warehouseId: '22' }
|
||||
render(); await flushPromises()
|
||||
expect(mocks.replace).toHaveBeenCalledWith({ name: 'loading-list-detail', params: { planId: '2' }, query: { warehouseId: '22' } })
|
||||
})
|
||||
it('does not fetch manifests without list permission', async () => {
|
||||
mocks.can.mockReturnValue(false)
|
||||
|
||||
@@ -59,15 +59,15 @@ describe('LoadingListsView', () => {
|
||||
|
||||
it('keeps preload routes while completed lists move into domestic container detail', () => {
|
||||
expect(source).toContain("name: 'preload-plan-detail'")
|
||||
expect(source).toContain('loadConfirmedDetail')
|
||||
expect(source).toContain('confirmed-inline-detail')
|
||||
expect(source).toContain("name: 'loading-list-detail'")
|
||||
expect(source).not.toContain('confirmed-inline-detail')
|
||||
expect(preloadDetailSource).toContain('返回列表')
|
||||
expect(preloadDetailSource).toContain('router.back()')
|
||||
expect(confirmedDetailSource).toContain('返回列表')
|
||||
expect(confirmedDetailSource).toContain('router.back()')
|
||||
expect(confirmedDetailSource).toContain('@media (min-width: 1920px) { .confirmed-loading-detail-page { width: 100%; max-width: none; gap: 12px; } }')
|
||||
expect(routesSource).not.toContain("path: 'loading-lists/:planId'")
|
||||
expect(routesSource).not.toContain("name: 'loading-list-detail'")
|
||||
expect(routesSource).toContain("path: 'loading-lists/:planId'")
|
||||
expect(routesSource).toContain("name: 'loading-list-detail'")
|
||||
})
|
||||
|
||||
it('keeps the real transport edit contract in a 620px centered modal on the detail page', () => {
|
||||
@@ -111,7 +111,7 @@ describe('LoadingListsView', () => {
|
||||
expect(source).toContain('useRoute()')
|
||||
expect(source).toContain('route.query.planId')
|
||||
expect(source).toContain('route.query.warehouseId')
|
||||
expect(source).toContain('await loadConfirmedDetail(planId, warehouseId)')
|
||||
expect(source).toContain("await router.replace({ name: 'loading-list-detail'")
|
||||
expect(confirmedDetailSource).toContain("result.summary.status !== 'COMPLETED'")
|
||||
expect(confirmedDetailSource).toContain('MODE_STATUS_MISMATCH')
|
||||
expect(confirmedDetailSource).toContain('watch([planId, warehouseId], () => void loadDetail())')
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ContainerSimulationCreate } from '@/modules/container-loading'
|
||||
|
||||
@@ -19,8 +18,6 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { loadingListStatusLabel } from '../status'
|
||||
import {
|
||||
listConfirmedLoadingLists,
|
||||
getConfirmedLoadingList,
|
||||
type LoadingDetail,
|
||||
listPreloadPlans,
|
||||
type ConfirmedSummary,
|
||||
type LoadingListMode,
|
||||
@@ -43,44 +40,8 @@ const customers = ref<PlatformTenantResource[]>([])
|
||||
const customersLoading = ref(false)
|
||||
const query = reactive<QueryModel>({ customerId: '', keyword: '', createdRange: [] })
|
||||
const handledDeepLink = ref('')
|
||||
const selectedPlan = ref<{ planId: string; warehouseId: string } | null>(null)
|
||||
const confirmedDetail = ref<LoadingDetail<ConfirmedSummary> | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
let detailSequence = 0
|
||||
|
||||
function closeConfirmedDetail() {
|
||||
++detailSequence
|
||||
selectedPlan.value = null
|
||||
confirmedDetail.value = null
|
||||
detailError.value = null
|
||||
detailLoading.value = false
|
||||
}
|
||||
|
||||
async function loadConfirmedDetail(planId: string, warehouseId: string) {
|
||||
const sequence = ++detailSequence
|
||||
selectedPlan.value = { planId, warehouseId }
|
||||
confirmedDetail.value = null
|
||||
detailError.value = null
|
||||
detailLoading.value = true
|
||||
try {
|
||||
if (!warehouseId) throw new Error('该清单缺少所属仓库信息,无法查询详情')
|
||||
const result = await getConfirmedLoadingList(planId, warehouseId)
|
||||
if (sequence !== detailSequence) return
|
||||
if (result.summary.status !== 'COMPLETED') throw new Error('该计划尚未形成正式装柜清单')
|
||||
confirmedDetail.value = result
|
||||
} catch (cause) {
|
||||
if (sequence === detailSequence) detailError.value = getApiErrorDetails(cause, '装柜清单明细加载失败')
|
||||
} finally {
|
||||
if (sequence === detailSequence) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function printConfirmed(row: LoadingListSummary) {
|
||||
await loadConfirmedDetail(String(row.planId), String(row.warehouseId || ''))
|
||||
if (!confirmedDetail.value || confirmedDetail.value.summary.planId !== String(row.planId)) return
|
||||
await nextTick()
|
||||
window.print()
|
||||
function printConfirmed(row: LoadingListSummary) {
|
||||
void router.push({ name: 'loading-list-detail', params: { planId: String(row.planId) }, query: { warehouseId: String(row.warehouseId || ''), print: '1' } })
|
||||
}
|
||||
|
||||
const isPreload = computed(() => props.mode === 'preload')
|
||||
@@ -180,7 +141,7 @@ async function openDeepLinkedDetail() {
|
||||
await router.replace({ name: 'preload-plan-detail', params: { planId }, query: { warehouseId } })
|
||||
return
|
||||
}
|
||||
await loadConfirmedDetail(planId, warehouseId)
|
||||
await router.replace({ name: 'loading-list-detail', params: { planId }, query: { warehouseId } })
|
||||
}
|
||||
|
||||
function onSimulationCreated() {
|
||||
@@ -200,7 +161,7 @@ function openDetail(rowValue: object) {
|
||||
void router.push({ name: 'preload-plan-detail', params: { planId: String(row.planId) }, query: { warehouseId: String(row.warehouseId || '') } })
|
||||
return
|
||||
}
|
||||
void loadConfirmedDetail(String(row.planId), String(row.warehouseId || ''))
|
||||
void router.push({ name: 'loading-list-detail', params: { planId: String(row.planId) }, query: { warehouseId: String(row.warehouseId || '') } })
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
@@ -265,7 +226,7 @@ onMounted(() => {
|
||||
</template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions" :class="{ 'density-actions': isPreload }">
|
||||
<el-button v-if="canView" :class="{ 'density-action': isPreload }" size="small" :type="isPreload ? undefined : 'primary'" :plain="!isPreload" @click="openDetail(row)">查看</el-button>
|
||||
<el-button v-if="canView && !isPreload" class="density-action" size="small" :disabled="detailLoading" @click="printConfirmed(row as LoadingListSummary)">打印</el-button>
|
||||
<el-button v-if="canView && !isPreload" class="density-action" size="small" @click="printConfirmed(row as LoadingListSummary)">打印</el-button>
|
||||
</div></template>
|
||||
<template #customers="{ row }"><LineClampText :content="(row as PreloadSummary).customers.map((item) => item.customerName || '客户信息缺失').join('、')" /></template>
|
||||
<template #vesselVoyage="{ row }">{{ (row as PreloadSummary).vesselName || '—' }} / {{ (row as PreloadSummary).voyageNo || '—' }}</template>
|
||||
@@ -276,10 +237,6 @@ onMounted(() => {
|
||||
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
<section v-if="!isPreload && canView && selectedPlan" class="confirmed-inline-detail" aria-label="装柜清单明细">
|
||||
<h2>装柜清单明细 {{ confirmedDetail?.summary.loadingListNo || '' }}</h2>
|
||||
<LoadingListDetailDrawer dense embedded mode="confirmed" :loading="detailLoading" :detail="confirmedDetail" :error="detailError" :can-edit="false" @back="closeConfirmedDetail" @retry="loadConfirmedDetail(selectedPlan.planId, selectedPlan.warehouseId)" />
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</AdminPage>
|
||||
@@ -287,7 +244,6 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.loading-lists-page { display: grid; min-width: 0; gap: var(--admin-space-6, 16px); }
|
||||
@media print { .loading-lists-page :deep(.list-page-layout) { display: none; } }
|
||||
.table-panel { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }
|
||||
.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-2, 6px); }
|
||||
.row-actions :deep(.el-button) { margin-left: 0; }
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
import ConfirmedLoadingListDetailView from './ConfirmedLoadingListDetailView.vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ get: vi.fn(), push: vi.fn(), replace: vi.fn(), back: vi.fn(), route: { params: { planId: '1' }, query: { warehouseId: '11' } as Record<string, string> } }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => mocks }))
|
||||
vi.mock('../api/platformLoadingLists', () => ({ getConfirmedLoadingList: mocks.get }))
|
||||
vi.mock('@/shared/api/http', () => ({ getApiErrorDetails: (cause: Error) => ({ message: cause.message }) }))
|
||||
|
||||
function render() {
|
||||
return shallowMount(ConfirmedLoadingListDetailView, { global: { stubs: {
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading'], template: '<section />' },
|
||||
} } })
|
||||
}
|
||||
const completed = (planId = '1') => ({ summary: { status: 'COMPLETED', planId, loadingListNo: `LIST-${planId}` }, items: [], histories: [] })
|
||||
|
||||
describe('confirmed loading list detail page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.route = reactive({ params: { planId: '1' }, query: { warehouseId: '11' } })
|
||||
mocks.get.mockResolvedValue(completed())
|
||||
vi.spyOn(window, 'print').mockImplementation(() => {})
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
it('loads read-only detail on the dedicated page and returns to the list', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(mocks.get).toHaveBeenCalledWith('1', '11')
|
||||
const detail = wrapper.findComponent({ name: 'LoadingListDetailDrawer' })
|
||||
expect(detail.props('detail')).toEqual(completed())
|
||||
expect(window.print).not.toHaveBeenCalled()
|
||||
vi.spyOn(window.history, 'length', 'get').mockReturnValue(1)
|
||||
detail.vm.$emit('back')
|
||||
expect(mocks.push).toHaveBeenCalledWith({ name: 'loading-lists' })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('prints only after detail has loaded and removes the print request', async () => {
|
||||
mocks.route.query.print = '1'
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(window.print).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('loading')).toBe(false)
|
||||
expect(mocks.replace).toHaveBeenCalledWith({ name: 'loading-list-detail', params: { planId: '1' }, query: { warehouseId: '11' } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps failure visible and does not print when loading fails', async () => {
|
||||
mocks.route.query.print = '1'
|
||||
mocks.get.mockRejectedValueOnce(new Error('清单不可用'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('error').message).toBe('清单不可用')
|
||||
expect(window.print).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('ignores a stale response after switching manifests', async () => {
|
||||
let resolveFirst!: (value: unknown) => void
|
||||
mocks.get.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve }))
|
||||
.mockResolvedValueOnce(completed('2'))
|
||||
const wrapper = render()
|
||||
mocks.route.params.planId = '2'
|
||||
await flushPromises()
|
||||
resolveFirst(completed()); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('detail')).toEqual(completed('2'))
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
@@ -37,6 +37,14 @@ async function loadDetail() {
|
||||
return
|
||||
}
|
||||
detail.value = result
|
||||
loading.value = false
|
||||
if (route.query.print === '1') {
|
||||
await nextTick()
|
||||
if (sequence !== loadSequence) return
|
||||
window.print()
|
||||
const { print: _print, ...query } = route.query
|
||||
await router.replace({ name: 'loading-list-detail', params: { planId: planId.value }, query })
|
||||
}
|
||||
} catch (cause) {
|
||||
if (sequence !== loadSequence) return
|
||||
detail.value = null
|
||||
@@ -51,6 +59,10 @@ function goBack() {
|
||||
else void router.push({ name: 'loading-lists' })
|
||||
}
|
||||
|
||||
function printDetail() {
|
||||
window.print()
|
||||
}
|
||||
|
||||
onMounted(() => void loadDetail())
|
||||
watch([planId, warehouseId], () => void loadDetail())
|
||||
</script>
|
||||
@@ -58,6 +70,7 @@ watch([planId, warehouseId], () => void loadDetail())
|
||||
<template>
|
||||
<AdminPage>
|
||||
<div class="confirmed-loading-detail-page">
|
||||
<el-button v-if="detail" class="print-action" :disabled="loading" @click="printDetail">打印</el-button>
|
||||
<LoadingListDetailDrawer
|
||||
mode="confirmed"
|
||||
:loading="loading"
|
||||
@@ -73,5 +86,6 @@ watch([planId, warehouseId], () => void loadDetail())
|
||||
|
||||
<style scoped>
|
||||
.confirmed-loading-detail-page { display: grid; min-width: 0; max-width: 1280px; gap: 16px; }
|
||||
@media print { .print-action { display: none; } }
|
||||
@media (min-width: 1920px) { .confirmed-loading-detail-page { width: 100%; max-width: none; gap: 12px; } }
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { loadingListRoutes } from './routes'
|
||||
describe('loading list routes', () => {
|
||||
it('registers the confirmed list with its own read permission', () => {
|
||||
expect(loadingListRoutes.map((route) => [route.path, route.meta.permission])).toEqual([
|
||||
['loading-lists/:planId', 'platform:firstleg:loading-list:list'],
|
||||
['loading-lists', 'platform:firstleg:loading-list:list'],
|
||||
['loading-plans', 'platform:firstleg:preload:list'],
|
||||
['loading-plans/:planId', 'platform:firstleg:preload:list'],
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const loadingListRoutes = [
|
||||
{
|
||||
path: 'loading-lists/:planId',
|
||||
name: 'loading-list-detail',
|
||||
component: () => import('./pages/ConfirmedLoadingListDetailView.vue'),
|
||||
meta: { title: '装柜清单详情', showPageHeading: false, permission: 'platform:firstleg:loading-list:list' },
|
||||
},
|
||||
{
|
||||
path: 'loading-lists',
|
||||
name: 'loading-lists',
|
||||
|
||||
Reference in New Issue
Block a user