fix(admin): restore manifest menu and overseas container access

This commit is contained in:
2026-10-08 11:16:58 +08:00
parent 080042d941
commit d7911ad09d
17 changed files with 197 additions and 28 deletions
@@ -0,0 +1,50 @@
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() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: mocks.can }) }))
vi.mock('@/modules/container-loading', () => ({ ContainerSimulationCreate: { template: '<div />' } }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: async () => ({ records: [] }) }))
vi.mock('../api/platformLoadingLists', () => ({ listConfirmedLoadingLists: mocks.list, listPreloadPlans: mocks.list, getConfirmedLoadingList: mocks.get }))
vi.mock('@/shared/api/http', () => ({ getApiErrorDetails: (cause: Error) => ({ message: cause.message }) }))
vi.mock('./LoadingListDetailDrawer.vue', () => ({ default: { props: ['detail', 'error'], template: '<div data-testid="detail">{{ detail?.summary.loadingListNo }}{{ error?.message }}</div>' } }))
const rows = [{ planId: '1', warehouseId: '11' }, { planId: '2', warehouseId: '22' }]
function render() {
return mount(LoadingListsView, { props: { mode: 'confirmed' }, global: { stubs: {
AdminPage: { template: '<div><slot /></div>' },
ListPageLayout: { template: '<div><slot /></div>' },
DataTable: { props: ['data'], template: '<div><div v-for="row in data" :key="row.planId"><slot name="actions" :row="row" /></div></div>' },
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' } })
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()
})
it('does not print when loading fails', async () => {
const print = vi.spyOn(window, 'print').mockImplementation(() => {})
mocks.get.mockRejectedValueOnce(new Error('清单不可用'))
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()
})
it('does not fetch manifests without list permission', async () => {
mocks.can.mockReturnValue(false)
render(); await flushPromises()
expect(mocks.list).not.toHaveBeenCalled()
})
})
@@ -59,8 +59,8 @@ describe('LoadingListsView', () => {
it('keeps preload routes while completed lists move into domestic container detail', () => {
expect(source).toContain("name: 'preload-plan-detail'")
expect(source).toContain("name: 'loading-list-detail'")
expect(source).not.toContain('confirmed-inline-detail')
expect(source).toContain('loadConfirmedDetail')
expect(source).toContain('confirmed-inline-detail')
expect(preloadDetailSource).toContain('返回列表')
expect(preloadDetailSource).toContain('router.back()')
expect(confirmedDetailSource).toContain('返回列表')
@@ -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("router.replace({ name: 'loading-list-detail'")
expect(source).toContain('await loadConfirmedDetail(planId, warehouseId)')
expect(confirmedDetailSource).toContain("result.summary.status !== 'COMPLETED'")
expect(confirmedDetailSource).toContain('MODE_STATUS_MISMATCH')
expect(confirmedDetailSource).toContain('watch([planId, warehouseId], () => void loadDetail())')
@@ -139,7 +139,7 @@ describe('LoadingListsView', () => {
expect(source).toContain(":class=\"{ 'density-action': isPreload }\"")
expect(source).toContain(':type="isPreload ? undefined : \'primary\'" :plain="!isPreload"')
expect(source).not.toContain('<el-button v-if="canView" link')
expect(source).toContain("key: 'actions', label: '操作', fixed: 'left', width: 110")
expect(source).toContain("key: 'actions', label: '操作', fixed: 'left', width: 150")
expect(source).toContain('flex-wrap: wrap')
expect(source).toContain('@media (max-width: 720px)')
expect(source).toContain('.row-actions { gap: 4px; }')
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue'
import { useRoute, useRouter } from 'vue-router'
import { ContainerSimulationCreate } from '@/modules/container-loading'
@@ -18,6 +19,8 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
import { loadingListStatusLabel } from '../status'
import {
listConfirmedLoadingLists,
getConfirmedLoadingList,
type LoadingDetail,
listPreloadPlans,
type ConfirmedSummary,
type LoadingListMode,
@@ -40,6 +43,45 @@ 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()
}
const isPreload = computed(() => props.mode === 'preload')
const title = computed(() => isPreload.value ? '预装清单' : '装柜清单')
@@ -69,7 +111,7 @@ const columns = computed<DataTableColumn[]>(() => isPreload.value ? [
{ key: 'totalGrossWeightKg', label: '总毛重(kg)', width: 90, align: 'right', slotName: 'grossWeight' },
{ key: 'createdAt', label: '创建时间', minWidth: 140, slotName: 'createdAt' },
] : [
{ key: 'actions', label: '操作', fixed: 'left', width: 110, slotName: 'actions', hideable: false, movable: false },
{ key: 'actions', label: '操作', fixed: 'left', width: 150, slotName: 'actions', hideable: false, movable: false },
{ key: 'loadingListNo', prop: 'loadingListNo', label: '装柜清单号', minWidth: 175 },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 },
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 150 },
@@ -138,7 +180,7 @@ async function openDeepLinkedDetail() {
await router.replace({ name: 'preload-plan-detail', params: { planId }, query: { warehouseId } })
return
}
await router.replace({ name: 'loading-list-detail', params: { planId }, query: { warehouseId } })
await loadConfirmedDetail(planId, warehouseId)
}
function onSimulationCreated() {
@@ -158,7 +200,7 @@ function openDetail(rowValue: object) {
void router.push({ name: 'preload-plan-detail', params: { planId: String(row.planId) }, query: { warehouseId: String(row.warehouseId || '') } })
return
}
void router.push({ name: 'loading-list-detail', params: { planId: String(row.planId) }, query: { warehouseId: String(row.warehouseId || '') } })
void loadConfirmedDetail(String(row.planId), String(row.warehouseId || ''))
}
async function loadCustomers() {
@@ -223,6 +265,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>
</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>
@@ -233,6 +276,10 @@ 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>
@@ -240,6 +287,7 @@ 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; }
+2 -1
View File
@@ -2,8 +2,9 @@ import { describe, expect, it } from 'vitest'
import { loadingListRoutes } from './routes'
describe('loading list routes', () => {
it('keeps completed loading lists inside domestic container detail', () => {
it('registers the confirmed list with its own read permission', () => {
expect(loadingListRoutes.map((route) => [route.path, route.meta.permission])).toEqual([
['loading-lists', 'platform:firstleg:loading-list:list'],
['loading-plans', 'platform:firstleg:preload:list'],
['loading-plans/:planId', 'platform:firstleg:preload:list'],
])
+6
View File
@@ -1,6 +1,12 @@
import type { RouteRecordRaw } from 'vue-router'
export const loadingListRoutes = [
{
path: 'loading-lists',
name: 'loading-lists',
component: () => import('./pages/ConfirmedLoadingListsView.vue'),
meta: { title: '装柜清单', showPageHeading: false, permission: 'platform:firstleg:loading-list:list' },
},
{
path: 'loading-plans',
name: 'loading-plans',