优化
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue'
|
||||
import type { LoadingDetail } from '../api/platformLoadingLists'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('shared manifest presentation', () => {
|
||||
it.each([undefined, '返回货柜概览'])('keeps print in the common header and emits return for %s', async (backLabel) => {
|
||||
const print = vi.spyOn(window, 'print').mockImplementation(() => {})
|
||||
const wrapper = shallowMount(LoadingListDetailDrawer, {
|
||||
props: { mode: 'confirmed', dense: true, backLabel, canEdit: false, loading: false, error: null,
|
||||
detail: { summary: { planId: '1', warehouseId: '11', loadingListNo: 'LP-TEST', status: 'COMPLETED' }, items: [], histories: [] } as unknown as LoadingDetail },
|
||||
global: { directives: { loading: {} }, stubs: { ElButton: { template: '<button><slot /></button>' } } },
|
||||
})
|
||||
expect(wrapper.classes()).toContain('loading-density')
|
||||
expect(wrapper.get('h1').text()).toBe('装柜清单 · LP-TEST')
|
||||
const button = wrapper.get('.detail-page-header__actions button')
|
||||
expect(button.text()).toBe('打印装柜清单')
|
||||
await button.trigger('click'); await flushPromises()
|
||||
expect(print).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.get('.detail-page-header__back').text()).toBe(backLabel || '返回列表')
|
||||
await wrapper.get('.detail-page-header__back').trigger('click')
|
||||
expect(wrapper.emitted('back')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ArrowLeft, Check, ChevronRight, Pencil } from '@lucide/vue'
|
||||
import { ArrowLeft, Check, ChevronRight, Pencil, Printer } from '@lucide/vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
@@ -9,6 +9,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import PalletLabelPrintDialog from './PalletLabelPrintDialog.vue'
|
||||
import { loadingListStatusLabel } from '../status'
|
||||
import { printLoadingList } from '../printLoadingList'
|
||||
import type { ApiErrorDetails } from '@/shared/api/http'
|
||||
import type { LoadingDetail, LoadingHistory, LoadingItem, LoadingListMode, LoadingListSummary, PreloadSummary } from '../api/platformLoadingLists'
|
||||
|
||||
@@ -22,6 +23,7 @@ const props = defineProps<{
|
||||
pdaAssigneeSelected?: boolean
|
||||
dense?: boolean
|
||||
embedded?: boolean
|
||||
backLabel?: string
|
||||
canEditTransport?: boolean
|
||||
confirmSaving?: boolean
|
||||
}>()
|
||||
@@ -161,12 +163,13 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
<section v-loading="loading" class="loading-detail-content" :class="{ 'admin-density loading-density': mode === 'preload' || dense }" :aria-label="title" :aria-busy="loading">
|
||||
<section class="detail-overview" aria-label="清单概览">
|
||||
<header class="detail-page-header" :class="{ 'detail-page-header--embedded': embedded }">
|
||||
<el-button link type="primary" class="detail-page-header__back" :class="{ 'density-back': mode === 'preload' || dense }" @click="emit('back')"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<el-button link type="primary" class="detail-page-header__back" :class="{ 'density-back': mode === 'preload' || dense }" @click="emit('back')"><ArrowLeft :size="16" />{{ backLabel || '返回列表' }}</el-button>
|
||||
<div v-if="!embedded" class="detail-page-header__identity">
|
||||
<h1>{{ title }}</h1>
|
||||
<p v-if="mode !== 'preload'">查看已完成装柜明细和柜次费用</p>
|
||||
<p v-if="mode !== 'preload'">查看已完成装柜明细和操作历史</p>
|
||||
</div>
|
||||
<div v-if="detail && summary" class="detail-page-header__actions">
|
||||
<el-button v-if="mode === 'confirmed'" :disabled="loading" @click="printLoadingList"><Printer :size="16" />打印装柜清单</el-button>
|
||||
<PalletLabelPrintDialog :dense="mode === 'preload' || dense" v-if="summary.warehouseId" :plan-id="summary.planId" :warehouse-id="summary.warehouseId" :disabled="!summary.containerNo || summary.status === 'EDITING'" />
|
||||
<template v-if="canEditItemRemark">
|
||||
<el-button v-if="canEditTransport" @click="emit('edit')"><Pencil :size="16" />{{ summary.containerNo ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次' }}</el-button>
|
||||
|
||||
@@ -6,6 +6,7 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/co
|
||||
const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8')
|
||||
const preloadDetailSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/pages/PreloadPlanDetailView.vue'), 'utf8')
|
||||
const confirmedDetailSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/pages/ConfirmedLoadingListDetailView.vue'), 'utf8')
|
||||
const sharedLoaderSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/useLoadingListDetail.ts'), 'utf8')
|
||||
const routesSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/routes.ts'), 'utf8')
|
||||
|
||||
describe('LoadingListsView', () => {
|
||||
@@ -31,7 +32,7 @@ describe('LoadingListsView', () => {
|
||||
expect(source).not.toContain("ElMessage.warning('请先选择仓库')")
|
||||
expect(source).not.toContain("'请先选择仓库后查询'")
|
||||
expect(preloadDetailSource).toContain('MISSING_WAREHOUSE_CONTEXT')
|
||||
expect(confirmedDetailSource).toContain('MISSING_WAREHOUSE_CONTEXT')
|
||||
expect(sharedLoaderSource).toContain('MISSING_WAREHOUSE_CONTEXT')
|
||||
})
|
||||
|
||||
it('uses the shared query and table components while keeping details on route pages', () => {
|
||||
@@ -63,7 +64,7 @@ describe('LoadingListsView', () => {
|
||||
expect(source).not.toContain('confirmed-inline-detail')
|
||||
expect(preloadDetailSource).toContain('返回列表')
|
||||
expect(preloadDetailSource).toContain('router.back()')
|
||||
expect(confirmedDetailSource).toContain('返回列表')
|
||||
expect(detailSource).toContain("backLabel || '返回列表'")
|
||||
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).toContain("path: 'loading-lists/:planId'")
|
||||
@@ -112,10 +113,10 @@ describe('LoadingListsView', () => {
|
||||
expect(source).toContain('route.query.planId')
|
||||
expect(source).toContain('route.query.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(sharedLoaderSource).toContain("result.summary.status !== 'COMPLETED'")
|
||||
expect(sharedLoaderSource).toContain('MODE_STATUS_MISMATCH')
|
||||
expect(confirmedDetailSource).toContain('watch([planId, warehouseId], () => void loadDetail())')
|
||||
expect(confirmedDetailSource).toContain('if (sequence !== loadSequence) return')
|
||||
expect(sharedLoaderSource).toContain('if (request !== sequence) return null')
|
||||
expect(preloadDetailSource).toContain('watch([planId, warehouseId], () => {')
|
||||
})
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@ export { loadingListStatusLabel } from './status'
|
||||
export { default as ContainerFeesPanel } from './components/ContainerFeesPanel.vue'
|
||||
export { default as LoadingListDetailDrawer } from './components/LoadingListDetailDrawer.vue'
|
||||
export { default as PalletLabelPrintDialog } from './components/PalletLabelPrintDialog.vue'
|
||||
export { useLoadingListDetail } from './useLoadingListDetail'
|
||||
|
||||
@@ -5,7 +5,7 @@ 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('../api/platformLoadingLists', () => ({ getConfirmedLoadingList: mocks.get, getPreloadPlan: vi.fn() }))
|
||||
vi.mock('@/shared/api/http', () => ({ getApiErrorDetails: (cause: Error) => ({ message: cause.message }) }))
|
||||
|
||||
function render() {
|
||||
|
||||
@@ -1,57 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
|
||||
import {
|
||||
getConfirmedLoadingList,
|
||||
type ConfirmedSummary,
|
||||
type LoadingDetail,
|
||||
} from '../api/platformLoadingLists'
|
||||
import { useLoadingListDetail } from '../useLoadingListDetail'
|
||||
import { printLoadingList } from '../printLoadingList'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const detail = ref<LoadingDetail<ConfirmedSummary> | null>(null)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
let loadSequence = 0
|
||||
const { detail, loading, error, load } = useLoadingListDetail()
|
||||
const planId = computed(() => String(route.params.planId || ''))
|
||||
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
|
||||
|
||||
async function loadDetail() {
|
||||
const sequence = ++loadSequence
|
||||
detail.value = null
|
||||
if (!planId.value || !warehouseId.value) {
|
||||
loading.value = false
|
||||
error.value = { code: 'MISSING_WAREHOUSE_CONTEXT', message: '该清单缺少所属仓库信息,无法安全查询详情,请返回列表重新进入。' }
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await getConfirmedLoadingList(planId.value, warehouseId.value)
|
||||
if (sequence !== loadSequence) return
|
||||
if (result.summary.status !== 'COMPLETED') {
|
||||
error.value = { code: 'MODE_STATUS_MISMATCH', message: '该计划尚未形成正式装柜清单,请从预装清单入口查看。' }
|
||||
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
|
||||
error.value = getApiErrorDetails(cause, '装柜清单详情加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === loadSequence) loading.value = false
|
||||
}
|
||||
const result = await load('confirmed', planId.value, warehouseId.value)
|
||||
if (!result || detail.value !== result || route.query.print !== '1') return
|
||||
await printLoadingList()
|
||||
const { print: _print, ...query } = route.query
|
||||
await router.replace({ name: 'loading-list-detail', params: { planId: planId.value }, query })
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
@@ -59,10 +25,6 @@ function goBack() {
|
||||
else void router.push({ name: 'loading-lists' })
|
||||
}
|
||||
|
||||
function printDetail() {
|
||||
window.print()
|
||||
}
|
||||
|
||||
onMounted(() => void loadDetail())
|
||||
watch([planId, warehouseId], () => void loadDetail())
|
||||
</script>
|
||||
@@ -70,8 +32,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
|
||||
<LoadingListDetailDrawer dense
|
||||
mode="confirmed"
|
||||
:loading="loading"
|
||||
:detail="detail"
|
||||
@@ -86,6 +47,5 @@ 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>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
export async function printLoadingList() {
|
||||
await nextTick()
|
||||
window.print()
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { effectScope } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getConfirmedLoadingList, getPreloadPlan } from './api/platformLoadingLists'
|
||||
import { useLoadingListDetail } from './useLoadingListDetail'
|
||||
|
||||
vi.mock('./api/platformLoadingLists', () => ({ getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() }))
|
||||
const scopes: ReturnType<typeof effectScope>[] = []
|
||||
function createLoader() {
|
||||
const scope = effectScope()
|
||||
scopes.push(scope)
|
||||
return scope.run(useLoadingListDetail)!
|
||||
}
|
||||
const result = (status = 'COMPLETED') => ({ summary: { planId: '1', status }, items: [], histories: [] })
|
||||
|
||||
beforeEach(() => vi.resetAllMocks())
|
||||
afterEach(() => { scopes.splice(0).forEach(scope => scope.stop()) })
|
||||
|
||||
describe('shared loading list data', () => {
|
||||
it('uses the same confirmed data once per plan and warehouse', async () => {
|
||||
vi.mocked(getConfirmedLoadingList).mockResolvedValue(result() as never)
|
||||
const state = createLoader()
|
||||
await state.load('confirmed', '1', '11')
|
||||
await state.load('confirmed', '1', '11')
|
||||
expect(getConfirmedLoadingList).toHaveBeenCalledExactlyOnceWith('1', '11')
|
||||
expect(state.detail.value).toEqual(result())
|
||||
expect(state.loading.value).toBe(false)
|
||||
})
|
||||
it('switches APIs when a preload becomes confirmed and rejects an uncompleted manifest', async () => {
|
||||
vi.mocked(getPreloadPlan).mockResolvedValue(result('LOADING') as never)
|
||||
vi.mocked(getConfirmedLoadingList).mockResolvedValue(result('LOADING') as never)
|
||||
const state = createLoader()
|
||||
await state.load('preload', '1', '11')
|
||||
expect(getPreloadPlan).toHaveBeenCalledExactlyOnceWith('1', '11')
|
||||
await state.load('confirmed', '1', '11')
|
||||
expect(state.detail.value).toBeNull()
|
||||
expect(state.error.value?.code).toBe('MODE_STATUS_MISMATCH')
|
||||
})
|
||||
it('does not request detail without warehouse context', async () => {
|
||||
const state = createLoader()
|
||||
await state.load('confirmed', '1', '')
|
||||
expect(state.error.value?.code).toBe('MISSING_WAREHOUSE_CONTEXT')
|
||||
expect(getConfirmedLoadingList).not.toHaveBeenCalled()
|
||||
})
|
||||
it('supports retry after failure and reload after reset', async () => {
|
||||
vi.mocked(getConfirmedLoadingList).mockRejectedValueOnce(new Error('failure')).mockResolvedValue(result() as never)
|
||||
const state = createLoader()
|
||||
await state.load('confirmed', '1', '11')
|
||||
expect(state.error.value).not.toBeNull()
|
||||
await state.load('confirmed', '1', '11')
|
||||
expect(state.error.value).toBeNull()
|
||||
expect(state.detail.value).toEqual(result())
|
||||
state.reset()
|
||||
await state.load('confirmed', '1', '11')
|
||||
expect(getConfirmedLoadingList).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
it('does not reuse an old plan when its request finishes after switching', async () => {
|
||||
let resolveFirst!: (value: never) => void
|
||||
vi.mocked(getConfirmedLoadingList).mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve }))
|
||||
.mockResolvedValueOnce({ ...result(), summary: { planId: '2', status: 'COMPLETED' } } as never)
|
||||
const state = createLoader()
|
||||
const first = state.load('confirmed', '1', '11')
|
||||
await state.load('confirmed', '2', '22')
|
||||
resolveFirst(result() as never)
|
||||
await first
|
||||
expect(state.detail.value?.summary.planId).toBe('2')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { onScopeDispose, ref } from 'vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getConfirmedLoadingList, getPreloadPlan, type LoadingDetail, type LoadingListMode } from './api/platformLoadingLists'
|
||||
|
||||
export function useLoadingListDetail(api = { getConfirmedLoadingList, getPreloadPlan }) {
|
||||
const detail = ref<LoadingDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
let sequence = 0
|
||||
let currentKey = ''
|
||||
|
||||
function reset() {
|
||||
++sequence
|
||||
currentKey = ''
|
||||
detail.value = null
|
||||
loading.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
async function load(mode: LoadingListMode, planId: string, warehouseId: string) {
|
||||
const key = `${mode}:${planId}:${warehouseId}`
|
||||
if (key === currentKey && (detail.value || loading.value)) return detail.value
|
||||
const request = ++sequence
|
||||
currentKey = key
|
||||
detail.value = null
|
||||
error.value = null
|
||||
loading.value = true
|
||||
try {
|
||||
if (!planId || !warehouseId) {
|
||||
error.value = { code: 'MISSING_WAREHOUSE_CONTEXT', message: '该清单缺少计划或所属仓库信息,无法查询详情,请返回重新进入。' }
|
||||
return null
|
||||
}
|
||||
const result = mode === 'confirmed'
|
||||
? await api.getConfirmedLoadingList(planId, warehouseId)
|
||||
: await api.getPreloadPlan(planId, warehouseId)
|
||||
if (request !== sequence) return null
|
||||
if (mode === 'confirmed' && result.summary.status !== 'COMPLETED') {
|
||||
error.value = { code: 'MODE_STATUS_MISMATCH', message: '该计划尚未形成正式装柜清单,请从预装清单入口查看。' }
|
||||
return null
|
||||
}
|
||||
detail.value = result
|
||||
return detail.value
|
||||
} catch (cause) {
|
||||
if (request === sequence) error.value = getApiErrorDetails(cause, '装柜清单加载失败')
|
||||
return null
|
||||
} finally {
|
||||
if (request === sequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onScopeDispose(reset)
|
||||
return { detail, loading, error, load, reset }
|
||||
}
|
||||
Reference in New Issue
Block a user