diff --git a/src/app/router/index.test.ts b/src/app/router/index.test.ts index ed21b420..41862693 100644 --- a/src/app/router/index.test.ts +++ b/src/app/router/index.test.ts @@ -51,6 +51,18 @@ describe('admin router access metadata', () => { expect(route?.meta.permission).toBe('platform:express:list') }) + it('registers the global smart-loading route before the single-container deep link', () => { + const route = router.getRoutes().find((item) => item.name === 'global-smart-loading-workbench') + const moduleChildren = router.options.routes.find((item) => item.path === '/')?.children || [] + const globalIndex = moduleChildren.findIndex((item) => item.name === 'global-smart-loading-workbench') + const detailIndex = moduleChildren.findIndex((item) => item.name === 'smart-loading-workbench') + + expect(route?.path).toBe('/container-loading/smart-loading') + expect(route?.meta.permission).toBe('platform:firstleg:smart-loading:list') + expect(globalIndex).toBeGreaterThanOrEqual(0) + expect(detailIndex).toBeGreaterThan(globalIndex) + }) + it('protects counterparty records with a stable master-data permission', () => { const route = router.getRoutes().find((item) => item.name === 'counterparties') expect(route?.path).toBe('/master-data/counterparties') diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index 8abe0d7c..8fa1a96e 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -42,6 +42,7 @@ describe('adminNavigation', () => { ]) expect(adminNavigation.find((item) => item.key === 'container-management')?.children?.map((item) => item.title)).toEqual([ '货柜列表', + '智能装柜', '预装清单', '装柜清单', ]) @@ -85,6 +86,7 @@ describe('adminNavigation', () => { expect(paths).toContain('/downloads') expect(paths).toContain('/master-data/fee-items') expect(paths).toContain('/container-loading') + expect(paths).toContain('/container-loading/smart-loading') expect(paths).toContain('/loading-plans') expect(paths).toContain('/loading-lists') expect(paths).not.toContain('/prototype/container-management/container-list') @@ -150,6 +152,7 @@ describe('adminNavigation', () => { it('shows each formal container workflow entry only with its matching permission', () => { const filtered = filterAdminNavigation(adminNavigation, new Set([ 'platform:firstleg:container:list', + 'platform:firstleg:smart-loading:list', 'platform:firstleg:preload:list', 'platform:firstleg:loading-list:list', ])) @@ -157,6 +160,7 @@ describe('adminNavigation', () => { expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([ '/dashboard', '/container-loading', + '/container-loading/smart-loading', '/loading-plans', '/loading-lists', ]) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index fbe81abf..29b828fd 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -97,6 +97,7 @@ export const adminNavigation: AdminNavigationNode[] = [ icon: WarehouseIcon, children: [ { key: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading', permission: 'platform:firstleg:container:list' }, + { key: 'container-smart-loading', title: '智能装柜', icon: ClipboardList, path: '/container-loading/smart-loading', permission: 'platform:firstleg:smart-loading:list' }, { key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' }, { key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/loading-lists', permission: 'platform:firstleg:loading-list:list' }, ], diff --git a/src/modules/auth/pages/LoginView.test.ts b/src/modules/auth/pages/LoginView.test.ts index 371201bd..67ad5c19 100644 --- a/src/modules/auth/pages/LoginView.test.ts +++ b/src/modules/auth/pages/LoginView.test.ts @@ -42,6 +42,18 @@ describe('LoginView authentication lifecycle', () => { expect(loginSource).not.toContain('demo-tip') }) + it('uses the admin design tokens and keeps the brand visible at 320px', () => { + expect(loginSource).toContain('

柏悦物流管理后台

') + expect(loginSource).toContain('class="login-mobile-brand"') + expect(loginSource).toContain('aria-label="平台员工登录"') + expect(loginSource).toContain('autocomplete="username" maxlength="100" autofocus') + expect(loginSource).toContain('aria-live="polite"') + expect(loginSource).toContain('background: var(--cc-surface-muted)') + expect(loginSource).toContain('border-radius: var(--cc-radius-lg)') + expect(loginSource).toContain('@media (max-width: 360px)') + expect(loginSource).not.toContain('让跨境物流管理') + }) + it('initializes preview access after login and before redirecting', async () => { const pinia = createPinia() setActivePinia(pinia) diff --git a/src/modules/auth/pages/LoginView.vue b/src/modules/auth/pages/LoginView.vue index 96610942..7f62784e 100644 --- a/src/modules/auth/pages/LoginView.vue +++ b/src/modules/auth/pages/LoginView.vue @@ -54,8 +54,8 @@ async function submit() {
-

让跨境物流管理
清晰、可靠、可追踪

-

统一管理企业入驻、内部权限和头程订单,让每一次操作都有边界、有记录。

+

柏悦物流管理后台

+

平台内部运营与管理系统

@@ -65,12 +65,13 @@ async function submit() {
+ + diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index c7762c51..a5a37e5a 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -28,4 +28,33 @@ describe('ContainersView', () => { expect(wrapper.text()).toContain('宁波 → 长滩') expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true) }) + + it('uses stable view labels when cabinetNo is missing', async () => { + const base = { + id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'EDITING' as const, version: 0, + billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, + departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', + destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, + terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, + estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, + } + vi.mocked(listContainers).mockResolvedValueOnce({ + records: [ + { ...base, id: '9002', cabinetNo: undefined, containerNo: 'MSCU-2', bookingNo: 'BK-2' }, + { ...base, id: '9003', cabinetNo: '', containerNo: null, bookingNo: 'BK-3' }, + { ...base, id: '9004', cabinetNo: null, containerNo: null, bookingNo: '' }, + ], + page: 1, size: 20, total: 3, pages: 1, + } as never) + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: ContainersView }] }) + await router.push('/'); await router.isReady() + const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } }) + await flushPromises() + + const labels = wrapper.findAll('.el-table__body button') + .map((button) => button.attributes('aria-label')) + .filter((label): label is string => Boolean(label)) + expect(labels).toEqual(expect.arrayContaining(['查看 MSCU-2', '查看 BK-3', '查看货柜详情'])) + expect(labels.every((label) => !/undefined|null/.test(label))).toBe(true) + }) }) diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index a68ac90c..65459ee7 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -91,6 +91,11 @@ const columns: DataTableColumn[] = [ function optional(value: string | null) { return value?.trim() || null } function statusText(status: ContainerPlanStatus) { return ({ EDITING: '编辑中', LOADING: '装柜中', COMPLETED: '已完成' })[status] } function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' } +function containerViewLabel(row: ContainerBookingResource) { + const reference = [row.cabinetNo, row.containerNo, row.bookingNo] + .find((value) => typeof value === 'string' && value.trim()) + return reference ? `查看 ${reference}` : '查看货柜详情' +} function replaceForm(source: ContainerBookingResource | null) { Object.assign(form, emptyForm(), source || {}) } @@ -189,7 +194,7 @@ onMounted(() => { void Promise.all([loadWarehouses(), loadRows()]) })
- + diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts new file mode 100644 index 00000000..53a7c9ee --- /dev/null +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts @@ -0,0 +1,98 @@ +import ElementPlus from 'element-plus' +import { AxiosError } from 'axios' +import { createPinia, setActivePinia } from 'pinia' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue' +import { + getSmartLoadingPlanByBooking, + listContainers, + listSmartLoadingCandidates, + recommendSmartLoading, +} from '../api/platformContainerLoading' + +vi.mock('@/core/access/accessPreview', () => ({ + useAccessPreviewStore: () => ({ can: () => true }), +})) +vi.mock('../api/platformContainerLoading', () => ({ + listContainers: vi.fn(), + getSmartLoadingPlanByBooking: vi.fn(), + listSmartLoadingCandidates: vi.fn(), + createSmartLoadingPlan: vi.fn(), + assignSmartLoadingItem: vi.fn(), + recommendSmartLoading: vi.fn(), + adjustSmartLoadingItem: vi.fn(), + removeSmartLoadingItem: vi.fn(), + pushSmartLoadingPreload: vi.fn(), +})) + +const container = { + id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', + warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1, + billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, + departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, + destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 68, maxWeightKg: 28000, + terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, + estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, +} +const plan = { + id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING' as const, containerNo: 'MSCU-1', voyageNo: null, version: 2, + items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1 }], +} + +describe('GlobalSmartLoadingView', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 100, total: 1, pages: 1 }) + vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(plan) + vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', inboundDetailId: '6', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsName: '鞋类', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 }]) + }) + + it('loads real containers, candidates and the selected plan into the three-column workbench', async () => { + const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) + await flushPromises() + + expect(listContainers).toHaveBeenCalledWith({ page: 1, size: 100 }) + expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id) + expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.warehouseId) + expect(wrapper.text()).toContain('候选订单与货物') + expect(wrapper.text()).toContain('ORD-2') + expect(wrapper.text()).toContain('CB-1 装柜方案') + expect(wrapper.text()).toContain('LP-99') + expect(wrapper.text()).toContain('ORD-1') + }) + + it('keeps unsupported prototype filters out of the real workbench', async () => { + const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) + await flushPromises() + + expect(wrapper.text()).not.toContain('税率') + expect(wrapper.text()).not.toContain('是否退税') + expect(wrapper.text()).not.toContain('美森正班') + }) + + it('refreshes the latest plan after A0409 without losing the selected container', async () => { + const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) + await flushPromises() + vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...plan, version: 3 }) + vi.mocked(recommendSmartLoading).mockRejectedValue(new AxiosError( + 'conflict', + 'ERR_BAD_REQUEST', + undefined, + undefined, + { data: { code: 'A0409', message: '数据已被修改', requestId: 'request-1', traceId: 'trace-1' }, status: 200, statusText: 'OK', headers: {}, config: {} as never }, + )) + + const button = wrapper.findAll('button').find((item) => item.text().includes('智能推荐')) + expect(button).toBeDefined() + await button!.trigger('click') + await flushPromises() + + expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 2) + expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2) + expect(wrapper.text()).toContain('版本 3') + expect(wrapper.text()).toContain('已刷新最新版本') + expect(wrapper.text()).toContain('request-1') + }) +}) diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue new file mode 100644 index 00000000..2311286d --- /dev/null +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue @@ -0,0 +1,476 @@ + + + + + diff --git a/src/modules/container-loading/routes.ts b/src/modules/container-loading/routes.ts index 5c06b759..43107c92 100644 --- a/src/modules/container-loading/routes.ts +++ b/src/modules/container-loading/routes.ts @@ -7,6 +7,12 @@ export const containerLoadingRoutes = [ component: () => import('./pages/ContainersView.vue'), meta: { title: '货柜与排柜', description: '维护货柜资料并进入智能排柜工作台', permission: 'platform:firstleg:container:list' }, }, + { + path: 'container-loading/smart-loading', + name: 'global-smart-loading-workbench', + component: () => import('./pages/GlobalSmartLoadingView.vue'), + meta: { title: '智能装柜', description: '统一调度候选货物、货柜与装柜方案', permission: 'platform:firstleg:smart-loading:list' }, + }, { path: 'container-loading/:containerId/smart-loading', name: 'smart-loading-workbench', diff --git a/src/modules/customers/pages/CustomersView.test.ts b/src/modules/customers/pages/CustomersView.test.ts index b6960ef8..7f425c99 100644 --- a/src/modules/customers/pages/CustomersView.test.ts +++ b/src/modules/customers/pages/CustomersView.test.ts @@ -101,7 +101,7 @@ function tableButtonByText(text: string) { } async function clickMoreAction(text: string) { - document.querySelector('.el-table__body button[aria-label="更多客户操作"]')?.click() + document.querySelector('.el-table__body button[aria-label^="更多客户操作:"]')?.click() await flushPromises() const item = [...document.querySelectorAll('.el-dropdown-menu__item')] .find((candidate) => candidate.textContent?.trim() === text && !candidate.classList.contains('is-disabled')) @@ -455,7 +455,8 @@ describe('CustomersView', () => { mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() - await clickMoreAction('重置密码') + tableButtonByText('重置密码')?.click() + await flushPromises() const password = document.querySelector('input[autocomplete="new-password"]') const confirmation = document.querySelector('input[placeholder="请再次输入新密码"]') @@ -479,11 +480,10 @@ describe('CustomersView', () => { useAccessPreviewStore().initializeAfterLogin() vi.mocked(archivePlatformTenant).mockResolvedValue({ ...fullTenant, status: 'DISABLED' }) - const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() - const dropdowns = wrapper.findAllComponents({ name: 'ElDropdown' }) - expect(dropdowns.length).toBeGreaterThan(0) - dropdowns.at(-1)!.vm.$emit('command', 'delete') + expect(document.querySelector('.el-table__body button[aria-label^="更多客户操作:"]')).toBeNull() + tableButtonByText('删除')?.click() await flushPromises() const confirmation = document.querySelector('input[placeholder="TEN8"]') @@ -519,9 +519,53 @@ describe('CustomersView', () => { expect(tableButtonByText('详情')).toBeDefined() expect(tableButtonByText('编辑')).toBeDefined() - expect(document.querySelector('.el-table__body button[aria-label="更多客户操作"]')).toBeTruthy() - expect(tableButtonByText('重置密码')).toBeUndefined() - await clickMoreAction('重置密码') - expect(document.querySelector('input[autocomplete="new-password"]')).toBeTruthy() + expect(document.querySelector('.el-table__body button[aria-label="更多客户操作:柏悦物流"]')).toBeTruthy() + expect(tableButtonByText('重置密码')).toBeDefined() + expect(tableButtonByText('跳转登录')).toBeUndefined() + await clickMoreAction('跳转登录') + expect(document.body.textContent).toContain('代登录原因') + }) + + it('uses stable unique customer identities for multiple more-action buttons', async () => { + const auth = useAuthStore() + auth.permissions = [...auth.permissions, 'platform:customer:reset-password', 'platform:customer:impersonate', 'platform:customer:status', 'platform:customer:delete'] + useAccessPreviewStore().initializeAfterLogin() + vi.mocked(listPlatformTenants).mockResolvedValue({ + records: [ + { ...fullTenant, id: '8', tenantName: '客户甲', tenantCode: 'TEN-A', status: 'ACTIVE' }, + { ...fullTenant, id: '9', tenantName: '', tenantCode: 'TEN-B', status: 'ACTIVE' }, + { ...fullTenant, id: '10', tenantName: '', tenantCode: '', status: 'ACTIVE' }, + ], + page: 1, size: 10, total: 3, pages: 1, + }) + + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + + const buttons = [...document.querySelectorAll('.el-table__body button[aria-label^="更多客户操作:"]')] + expect(buttons.map((button) => button.getAttribute('aria-label'))).toEqual([ + '更多客户操作:客户甲', + '更多客户操作:TEN-B', + '更多客户操作:10', + ]) + expect(buttons.map((button) => button.getAttribute('title'))).toEqual(buttons.map((button) => button.getAttribute('aria-label'))) + }) + + it('falls back to the current customer when all row identifiers are missing', async () => { + const auth = useAuthStore() + auth.permissions = [...auth.permissions, 'platform:customer:reset-password', 'platform:customer:impersonate', 'platform:customer:status', 'platform:customer:delete'] + useAccessPreviewStore().initializeAfterLogin() + vi.mocked(listPlatformTenants).mockResolvedValue({ + records: [{ ...fullTenant, id: null, tenantName: '', tenantCode: '', status: 'ACTIVE' } as unknown as PlatformTenantResource], + page: 1, size: 10, total: 1, pages: 1, + }) + + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + + const button = document.querySelector('.el-table__body button[aria-label^="更多客户操作:"]') + expect(button?.getAttribute('aria-label')).toBe('更多客户操作:当前客户') + expect(button?.getAttribute('title')).toBe('更多客户操作:当前客户') + expect(button?.getAttribute('aria-label')).not.toMatch(/undefined|null/) }) }) diff --git a/src/modules/customers/pages/CustomersView.vue b/src/modules/customers/pages/CustomersView.vue index 08f74588..d0c45fc8 100644 --- a/src/modules/customers/pages/CustomersView.vue +++ b/src/modules/customers/pages/CustomersView.vue @@ -1,5 +1,5 @@ @@ -246,8 +277,22 @@ onMounted(loadPermissions)
- - + + + @@ -277,16 +322,14 @@ onMounted(loadPermissions)