fix(admin): restore manifest menu and overseas container access
This commit is contained in:
@@ -8,6 +8,10 @@ import {
|
||||
} from './adminNavigation'
|
||||
|
||||
describe('adminNavigation', () => {
|
||||
it('offers the common container menu to overseas-only operators', () => {
|
||||
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:firstleg:terminal-drayage:list']))
|
||||
expect(getNavigationLeaves(visible).map(item => item.path)).toEqual(['/dashboard', '/container-loading/domestic'])
|
||||
})
|
||||
it('matches the latest prototype sidebar using only implemented menu entries', () => {
|
||||
// 新版.html sidebar (1404–1539), ordered by its data-menu CSS rules (45–56).
|
||||
const allPermissions = new Set(getNavigationLeaves(adminNavigation).flatMap(node => node.permission ? [node.permission] : []))
|
||||
@@ -16,7 +20,7 @@ describe('adminNavigation', () => {
|
||||
{ title: '首页数据看板', children: [] },
|
||||
{ title: '客户管理', children: ['客户列表', '海外仓阶梯费用'] },
|
||||
{ title: '国内头程', children: ['订单列表', '订单测量', '测量数据审核', '库位管理', '托盘管理', '整柜业务', '船期管理'] },
|
||||
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '预装清单'] },
|
||||
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
|
||||
{ title: '海外尾程', children: ['尾程订单', '入库单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理'] },
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车需求池', '出车记录'] },
|
||||
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] },
|
||||
@@ -26,7 +30,7 @@ describe('adminNavigation', () => {
|
||||
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
|
||||
])
|
||||
expect(visible).toHaveLength(11)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(47)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(48)
|
||||
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -47,7 +51,7 @@ describe('adminNavigation', () => {
|
||||
expect(paths).toContain('/container-loading/overseas')
|
||||
expect(paths).not.toContain('/container-loading/smart-loading')
|
||||
expect(paths).toContain('/loading-plans')
|
||||
expect(paths).not.toContain('/loading-lists')
|
||||
expect(paths).toContain('/loading-lists')
|
||||
expect(paths).not.toContain('/port-schedules')
|
||||
expect(paths).not.toContain('/customs')
|
||||
expect(paths).not.toContain('/outbound-dispatches')
|
||||
@@ -202,6 +206,7 @@ describe('adminNavigation', () => {
|
||||
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
|
||||
'/dashboard',
|
||||
'/container-loading/domestic',
|
||||
'/loading-lists',
|
||||
'/loading-plans',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -94,7 +94,8 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
children: [
|
||||
{ key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' },
|
||||
{ key: 'truck-list', title: '拖车列表', icon: Truck, path: '/trucking', permission: 'platform:firstleg:truck-appointment:list' },
|
||||
{ key: 'domestic-container-workbench', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading/domestic', permission: 'platform:firstleg:container:list' },
|
||||
{ key: 'domestic-container-workbench', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading/domestic', permission: 'platform:firstleg:container:list', permissionAnyOf: ['platform:firstleg:terminal-drayage:list'] },
|
||||
{ key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/loading-lists', permission: 'platform:firstleg:loading-list:list' },
|
||||
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' },
|
||||
{ key: 'overseas-container-workbench', title: '清关提柜', icon: ClipboardList, path: '/container-loading/overseas', permission: 'platform:firstleg:terminal-drayage:list', hidden: true },
|
||||
],
|
||||
|
||||
@@ -39,11 +39,11 @@ describe('ContainerDetailView customs integration', () => {
|
||||
expect(source).toContain(':sections="[\'pda\']"')
|
||||
})
|
||||
|
||||
it('keeps the confirmed loading list inside the domestic container detail only', () => {
|
||||
it('keeps the container detail entry alongside the standalone manifest', () => {
|
||||
expect(routeSource).toContain("name: 'domestic-container-detail'")
|
||||
expect(routeSource).toContain("name: 'overseas-container-detail'")
|
||||
expect(loadingRouteSource).not.toContain("name: 'loading-lists'")
|
||||
expect(navigationSource).not.toContain("title: '装柜清单'")
|
||||
expect(loadingRouteSource).toContain("name: 'loading-lists'")
|
||||
expect(navigationSource).toContain("title: '装柜清单'")
|
||||
expect(source).toContain('<el-tabs v-model="activeTab"')
|
||||
expect(source).toContain("v-if=\"isDomestic && activeTab === 'loading-list'\"")
|
||||
expect(source).toContain("loadingDetail.value = await getConfirmedLoadingList(aggregate.loadingPlanId, resource.warehouseId)")
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ContainerWorkbenchView from './ContainerWorkbenchView.vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ permissions: new Set<string>(), push: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => mocks.permissions.has(permission) }) }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: mocks.push }) }))
|
||||
vi.mock('./ContainersView.vue', () => ({ default: { props: ['workbench'], template: '<div data-testid="workbench">{{ workbench }}</div>' } }))
|
||||
|
||||
function render(stage: 'DOMESTIC' | 'OVERSEAS' = 'DOMESTIC') {
|
||||
return mount(ContainerWorkbenchView, { props: { stage }, global: { stubs: { ElButton: { template: '<button><slot /></button>' } } } })
|
||||
}
|
||||
|
||||
describe('container stage entry permissions', () => {
|
||||
beforeEach(() => { mocks.permissions.clear(); mocks.push.mockReset() })
|
||||
it('opens overseas operations for an overseas-only user entering the common menu', () => {
|
||||
mocks.permissions.add('platform:firstleg:terminal-drayage:list')
|
||||
const wrapper = render()
|
||||
expect(wrapper.get('[data-testid="workbench"]').text()).toBe('OVERSEAS')
|
||||
expect(wrapper.text()).not.toContain('国内装柜')
|
||||
})
|
||||
it('preserves a direct overseas visit and allows switching back to domestic', async () => {
|
||||
mocks.permissions.add('platform:firstleg:terminal-drayage:list')
|
||||
mocks.permissions.add('platform:firstleg:container:list')
|
||||
const wrapper = render('OVERSEAS')
|
||||
expect(wrapper.get('[data-testid="workbench"]').text()).toBe('OVERSEAS')
|
||||
await wrapper.findAll('button')[0]!.trigger('click')
|
||||
expect(mocks.push).toHaveBeenCalledWith({ name: 'domestic-container-workbench' })
|
||||
})
|
||||
it('does not mount data consumers without either list permission', () => {
|
||||
expect(render().find('[data-testid="workbench"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ContainersView from './ContainersView.vue'
|
||||
|
||||
const props = defineProps<{ stage: 'DOMESTIC' | 'OVERSEAS' }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const router = useRouter()
|
||||
const canDomestic = computed(() => access.can('platform:firstleg:container:list'))
|
||||
const canOverseas = computed(() => access.can('platform:firstleg:terminal-drayage:list'))
|
||||
const current = computed(() => props.stage === 'DOMESTIC' && !canDomestic.value && canOverseas.value ? 'OVERSEAS' : props.stage)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-density">
|
||||
<nav class="density-status-tabs" aria-label="货柜业务阶段">
|
||||
<el-button v-if="canDomestic" :class="{ 'is-active': current === 'DOMESTIC' }" :aria-current="current === 'DOMESTIC' ? 'page' : undefined" @click="router.push({ name: 'domestic-container-workbench' })">📦 国内装柜</el-button>
|
||||
<el-button v-if="canOverseas" :class="{ 'is-active': current === 'OVERSEAS' }" :aria-current="current === 'OVERSEAS' ? 'page' : undefined" @click="router.push({ name: 'overseas-container-workbench' })">🚢 海外清关提柜</el-button>
|
||||
</nav>
|
||||
<ContainersView v-if="canDomestic || canOverseas" :key="current" :workbench="current" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import ContainerSimulationCreate from '../components/ContainerSimulationCreate.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
@@ -46,6 +46,7 @@ type ContainerForm = ContainerSaveRequest & Pick<ContainerBookingResource, 'cabi
|
||||
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<ContainerWorkbenchRow[]>([])
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
@@ -67,7 +68,7 @@ const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const scope = ref<'ACTIVE' | 'HISTORY'>('ACTIVE')
|
||||
const filters = ref<Filters>({ customerId: '', keyword: '', containerType: '', status: '', sailingRange: [] })
|
||||
const filters = ref<Filters>({ customerId: '', keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '', containerType: '', status: '', sailingRange: [] })
|
||||
const applied = ref<Filters>({ ...filters.value })
|
||||
const detail = ref<ContainerBookingResource | null>(null)
|
||||
const editorOpen = ref(false)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ContainersView from './ContainersView.vue'
|
||||
import ContainerWorkbenchView from './ContainerWorkbenchView.vue'
|
||||
</script>
|
||||
|
||||
<template><ContainersView workbench="DOMESTIC" /></template>
|
||||
<template><ContainerWorkbenchView stage="DOMESTIC" /></template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ContainersView from './ContainersView.vue'
|
||||
import ContainerWorkbenchView from './ContainerWorkbenchView.vue'
|
||||
</script>
|
||||
|
||||
<template><ContainersView workbench="OVERSEAS" /></template>
|
||||
<template><ContainerWorkbenchView stage="OVERSEAS" /></template>
|
||||
|
||||
@@ -5,7 +5,7 @@ export const containerLoadingRoutes = [
|
||||
path: 'container-loading/domestic',
|
||||
name: 'domestic-container-workbench',
|
||||
component: () => import('./pages/DomesticContainersView.vue'),
|
||||
meta: { showPageHeading: false, title: '货柜列表', description: '管理国内送柜、装柜、出口报关与出仓', permission: 'platform:firstleg:container:list' },
|
||||
meta: { showPageHeading: false, title: '货柜列表', description: '管理国内装柜、海外清关提柜与到仓', permission: 'platform:firstleg:container:list', permissionAnyOf: ['platform:firstleg:terminal-drayage:list'] },
|
||||
},
|
||||
{
|
||||
path: 'container-loading/domestic/:containerId',
|
||||
|
||||
@@ -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,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'],
|
||||
])
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1222,7 +1222,7 @@ onMounted(() => { void load() })
|
||||
<el-alert title="保存后生成入库单;清关完成后入库计划不可更改。" type="info" :closable="false" show-icon />
|
||||
<el-alert v-if="inboundPlanPreviewError" class="container-convert-alert" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
|
||||
<el-form class="command-form density-form" label-position="top">
|
||||
<div class="command-form__grid"><el-form-item label="预计到仓时间"><el-input :model-value="inboundPlanExpectedArrival" disabled placeholder="当前柜预计到仓时间" /><small>取整柜预计到仓时间,同柜所有入库计划保持一致。</small></el-form-item><el-form-item label="计划入库仓"><el-input :model-value="inboundPlanWarehouseLabel" disabled placeholder="当前柜接柜仓" /><small>接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</small></el-form-item><el-form-item label="入仓码"><el-input :model-value="inboundPlanForm.inboundCode" disabled placeholder="订单冻结入仓码" /></el-form-item><el-form-item label="托数" required><el-input-number v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="inboundPlanIsDropship ? inboundPlanMinimumPalletCount : 999" :disabled="inboundPlanIsDropship" controls-position="right" placeholder="请输入托数" /><small v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</small><small v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</small></el-form-item><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><small>仅作为现场可选提示,不强制工人合托。</small></el-form-item><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></div>
|
||||
<div class="command-form__grid"><el-form-item label="预计到仓时间"><el-input :model-value="inboundPlanExpectedArrival" disabled placeholder="当前柜预计到仓时间" /><small>取整柜预计到仓时间,同柜所有入库计划保持一致;已生成计划后锁定。</small><el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></el-form-item><el-form-item label="计划入库仓"><el-input :model-value="inboundPlanWarehouseLabel" disabled placeholder="当前柜接柜仓" /><small>接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</small></el-form-item><el-form-item label="入仓码"><el-input :model-value="inboundPlanForm.inboundCode" disabled placeholder="订单冻结入仓码" /></el-form-item><el-form-item label="托数" required><el-input-number v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="inboundPlanIsDropship ? inboundPlanMinimumPalletCount : 999" :disabled="inboundPlanIsDropship" controls-position="right" placeholder="请输入托数" /><small v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</small><small v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</small></el-form-item><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><small>仅作为现场可选提示,不强制工人合托。</small></el-form-item><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></div>
|
||||
<el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
|
||||
@@ -339,12 +339,12 @@ describe('approved Admin density ownership', () => {
|
||||
for (const file of ['CartonDetailView', 'ProductDetailView']) expect(read('modules/overseas-warehouse/pages/' + file + '.vue')).not.toContain(' dense')
|
||||
})
|
||||
|
||||
it('covers four cargo menus, independent detail routes and both workbench modes', () => {
|
||||
it('covers five cargo menus, independent detail routes and both workbench modes', () => {
|
||||
const navigation = read('core/navigation/adminNavigation.ts')
|
||||
for (const path of ['/ocean-bookings', '/trucking', '/container-loading/domestic', '/container-loading/overseas']) expect(navigation).toContain(`path: '${path}'`)
|
||||
for (const path of ['/ocean-bookings', '/trucking', '/container-loading/domestic', '/loading-lists', '/loading-plans']) expect(navigation).toContain(`path: '${path}'`)
|
||||
for (const [prefix, workbench] of [['Domestic', 'DOMESTIC'], ['Overseas', 'OVERSEAS']]) {
|
||||
for (const [suffix, component] of [['ContainersView', 'ContainersView'], ['ContainerDetailView', 'ContainerDetailView']]) {
|
||||
expect(read(`modules/container-loading/pages/${prefix}${suffix}.vue`)).toContain(`<${component} workbench="${workbench}"`)
|
||||
expect(read(`modules/container-loading/pages/${prefix}${suffix}.vue`)).toContain(suffix === 'ContainersView' ? `<ContainerWorkbenchView stage="${workbench}"` : `<${component} workbench="${workbench}"`)
|
||||
const ast = parse(read(`modules/container-loading/pages/${component}.vue`)).descriptor.template!.ast!
|
||||
const root = ast.children.find(node => node.type === 1) as ElementNode
|
||||
expect(classesOf(root, false, true, workbench)).toContain('admin-density')
|
||||
|
||||
Reference in New Issue
Block a user