From d1b4a4d3f34ce7593113d6c5bb26c12a6b4867da Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 16:06:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layouts/AppLayout.test.ts | 19 ++++++ src/modules/audit/api/platformAudit.test.ts | 7 +- src/modules/audit/api/platformAudit.ts | 1 + .../components/CustomerAuditLogsPanel.test.ts | 3 +- .../components/CustomerAuditLogsPanel.vue | 2 + .../customers/pages/CustomerDetailView.vue | 3 +- .../customers/pages/CustomersView.test.ts | 20 +++++- src/modules/customers/pages/CustomersView.vue | 13 ++-- src/modules/iam/api/platformIam.test.ts | 2 +- src/modules/iam/api/platformIam.ts | 2 +- .../iam/components/PlatformTenantSelector.vue | 1 + .../counterparties/pages/PartnersView.test.ts | 2 +- .../counterparties/pages/PartnersView.vue | 2 +- .../pages/ExpressCompaniesView.test.ts | 2 +- .../pages/ExpressCompaniesView.vue | 2 +- .../api/notificationCenter.test.ts | 11 ++++ .../notifications/api/notificationCenter.ts | 5 +- .../notifications/notifications.test.ts | 9 +++ .../pages/MessageManagementView.vue | 15 +++-- src/modules/notifications/types.ts | 2 + .../components/SmartLoadingWorkbench.test.ts | 6 ++ .../components/SmartLoadingWorkbench.vue | 4 +- src/shared/components/AppDialog.test.ts | 10 +++ src/shared/components/AppDialog.vue | 5 +- src/shared/components/AppDrawer.test.ts | 66 +++++++++++++++++++ src/shared/components/AppDrawer.vue | 2 + 26 files changed, 187 insertions(+), 29 deletions(-) create mode 100644 src/shared/components/AppDrawer.test.ts diff --git a/src/app/layouts/AppLayout.test.ts b/src/app/layouts/AppLayout.test.ts index 8accc030..c3d18b1c 100644 --- a/src/app/layouts/AppLayout.test.ts +++ b/src/app/layouts/AppLayout.test.ts @@ -92,6 +92,25 @@ describe('AppLayout', () => { expect(menuButton.attributes('aria-expanded')).toBe('true') }) + it('closes the mobile menu when returning to the desktop breakpoint and removes the listener', async () => { + mockViewport(true) + const { wrapper } = await mountLayout() + const menuButton = wrapper.get('.shell-menu-button') + const mediaQuery = vi.mocked(window.matchMedia).mock.results[0]?.value as MediaQueryList + const changeHandler = vi.mocked(mediaQuery.addEventListener).mock.calls[0]?.[1] as ((event: MediaQueryListEvent) => void) + + await menuButton.trigger('click') + expect(menuButton.attributes('aria-expanded')).toBe('true') + + changeHandler({ matches: false } as MediaQueryListEvent) + await wrapper.vm.$nextTick() + expect(menuButton.attributes('aria-label')).toBe('收起导航') + expect(menuButton.attributes('aria-expanded')).toBe('true') + + wrapper.unmount() + expect(mediaQuery.removeEventListener).toHaveBeenCalledWith('change', changeHandler) + }) + it('shows the authenticated identity without a role preview switcher', async () => { const { wrapper } = await mountLayout() expect(wrapper.text()).toContain('登录身份:平台管理员(admin)') diff --git a/src/modules/audit/api/platformAudit.test.ts b/src/modules/audit/api/platformAudit.test.ts index 0effa047..acee3e0e 100644 --- a/src/modules/audit/api/platformAudit.test.ts +++ b/src/modules/audit/api/platformAudit.test.ts @@ -11,19 +11,20 @@ describe('platform audit api', () => { beforeEach(() => vi.clearAllMocks()) it('queries the selected customer and preserves long ids as strings', async () => { - vi.mocked(http.get).mockResolvedValue({ data: { records: [{ id: '2082813911786024961', accountId: '8', operatorTenantId: '0', userId: '9', resourceId: '8', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', description: '编辑客户', username: 'admin', appCode: 'ADMIN', clientType: 'WEB', requestId: null, traceId: null, serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }], page: 1, size: 20, total: 1, pages: 1 } }) + vi.mocked(http.get).mockResolvedValue({ data: { records: [{ id: '2082813911786024961', accountId: '8', operatorTenantId: '0', userId: '9', resourceId: '8', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', description: '编辑客户', username: 'admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: null, traceId: null, serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }], page: 1, size: 20, total: 1, pages: 1 } }) const result = await listPlatformAuditLogs({ accountId: '8', page: 1, size: 20 }) expect(http.get).toHaveBeenCalledWith('/platform/v1/audit/logs', { params: { accountId: '8', page: 1, size: 20 } }) expect(result.records[0].id).toBe('2082813911786024961') + expect(result.records[0].clientIp).toBe('127.0.0.1') expect(result.total).toBe(1) expect(result.pages).toBe(1) }) it('preserves the server total when the current page has fewer records', async () => { vi.mocked(http.get).mockResolvedValue({ data: { - records: [{ id: '1', accountId: '8', operatorTenantId: '0', userId: '9', resourceId: '8', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', description: '编辑客户', username: 'admin', appCode: 'ADMIN', clientType: 'WEB', requestId: null, traceId: null, serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }], + records: [{ id: '1', accountId: '8', operatorTenantId: '0', userId: '9', resourceId: '8', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', description: '编辑客户', username: 'admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: null, traceId: null, serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }], page: 1, size: 20, total: 13, pages: 1, } }) @@ -50,7 +51,7 @@ describe('platform audit api', () => { }) it('loads one audit detail', async () => { - vi.mocked(http.get).mockResolvedValue({ data: { id: '1', accountId: '8', operatorTenantId: '0', userId: '9', resourceId: '8', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', description: '编辑客户', username: 'admin', appCode: 'ADMIN', clientType: 'WEB', requestId: null, traceId: null, serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00', httpMethod: 'PUT', requestUri: '/tenants/8', clientIp: '127.0.0.1', durationMs: 12, beforeData: '{}', afterData: '{"name":"new"}' } }) + vi.mocked(http.get).mockResolvedValue({ data: { id: '1', accountId: '8', operatorTenantId: '0', userId: '9', resourceId: '8', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', description: '编辑客户', username: 'admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: null, traceId: null, serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00', httpMethod: 'PUT', requestUri: '/tenants/8', durationMs: 12, beforeData: '{}', afterData: '{"name":"new"}' } }) const result = await getPlatformAuditLog('1') expect(http.get).toHaveBeenCalledWith('/platform/v1/audit/logs/1') diff --git a/src/modules/audit/api/platformAudit.ts b/src/modules/audit/api/platformAudit.ts index 3dab17c9..6f61c222 100644 --- a/src/modules/audit/api/platformAudit.ts +++ b/src/modules/audit/api/platformAudit.ts @@ -13,6 +13,7 @@ export interface PlatformAuditLogListItem { username: string | null appCode: string | null clientType: string | null + clientIp: string | null requestId: string | null traceId: string | null serviceName: string | null diff --git a/src/modules/audit/components/CustomerAuditLogsPanel.test.ts b/src/modules/audit/components/CustomerAuditLogsPanel.test.ts index 9a6b5bf5..f74f1dd8 100644 --- a/src/modules/audit/components/CustomerAuditLogsPanel.test.ts +++ b/src/modules/audit/components/CustomerAuditLogsPanel.test.ts @@ -10,7 +10,7 @@ import CustomerAuditLogsPanel from './CustomerAuditLogsPanel.vue' vi.mock('../api/platformAudit', () => ({ getPlatformAuditLog: vi.fn(), listPlatformAuditLogs: vi.fn() })) -const row = { id: '1', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', resourceId: '8', description: '编辑客户', accountId: '8', operatorTenantId: '0', userId: '9', username: 'platform_admin', appCode: 'ADMIN', clientType: 'WEB', requestId: 'req', traceId: 'trace', serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' } +const row = { id: '1', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', resourceId: '8', description: '编辑客户', accountId: '8', operatorTenantId: '0', userId: '9', username: 'platform_admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: 'req', traceId: 'trace', serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' } function mountPanel(permissions: string[]) { const pinia = createPinia(); setActivePinia(pinia) @@ -26,6 +26,7 @@ describe('CustomerAuditLogsPanel', () => { const wrapper = mountPanel(['platform:audit:list']); await flushPromises() expect(listPlatformAuditLogs).toHaveBeenCalledWith({ accountId: '8', page: 1, size: 20 }) expect(wrapper.text()).toContain('编辑客户') + expect(wrapper.text()).toContain('127.0.0.1') }) it('shows the server total instead of the current page row count', async () => { diff --git a/src/modules/audit/components/CustomerAuditLogsPanel.vue b/src/modules/audit/components/CustomerAuditLogsPanel.vue index b8711f40..d522ddcf 100644 --- a/src/modules/audit/components/CustomerAuditLogsPanel.vue +++ b/src/modules/audit/components/CustomerAuditLogsPanel.vue @@ -36,6 +36,7 @@ const queryFields: QueryField[] = [ const columns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' }, { key: 'description', label: '操作内容', minWidth: 190, slotName: 'description' }, + { key: 'clientIp', label: 'IP 地址', width: 140, slotName: 'clientIp' }, { key: 'eventType', prop: 'eventType', label: '事件类型', minWidth: 220 }, { key: 'username', label: '操作人', width: 140, slotName: 'username' }, { key: 'serviceName', label: '来源服务', width: 150, slotName: 'serviceName' }, @@ -132,6 +133,7 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => + diff --git a/src/modules/customers/pages/CustomerDetailView.vue b/src/modules/customers/pages/CustomerDetailView.vue index 9cb17c11..6fd42248 100644 --- a/src/modules/customers/pages/CustomerDetailView.vue +++ b/src/modules/customers/pages/CustomerDetailView.vue @@ -113,6 +113,7 @@ function statusMeta(status: PlatformTenantStatus) { if (status === 'PROFILE_REQUIRED') return { label: '待完善', type: 'info' as const } if (status === 'PENDING') return { label: '待审核', type: 'warning' as const } if (status === 'REJECTED') return { label: '已驳回', type: 'danger' as const } + if (status === 'ARCHIVED') return { label: '已归档', type: 'info' as const } return { label: '已停用', type: 'info' as const } } function qualificationLabel(type: string) { @@ -373,7 +374,7 @@ watch(orderPage, (page, previousPage) => { diff --git a/src/modules/customers/pages/CustomersView.test.ts b/src/modules/customers/pages/CustomersView.test.ts index 19c43e17..cb741d38 100644 --- a/src/modules/customers/pages/CustomersView.test.ts +++ b/src/modules/customers/pages/CustomersView.test.ts @@ -529,7 +529,7 @@ describe('CustomersView', () => { const auth = useAuthStore() auth.permissions = [...auth.permissions, 'platform:customer:delete'] useAccessPreviewStore().initializeAfterLogin() - vi.mocked(archivePlatformTenant).mockResolvedValue({ ...fullTenant, status: 'DISABLED' }) + vi.mocked(archivePlatformTenant).mockResolvedValue({ ...fullTenant, status: 'ARCHIVED' }) mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() @@ -578,6 +578,24 @@ describe('CustomersView', () => { expect(document.body.textContent).toContain('代登录原因') }) + it('treats archived customers as read-only and terminal', async () => { + const auth = useAuthStore() + auth.permissions = [...auth.permissions, 'platform:customer:edit', 'platform:customer:status', 'platform:customer:delete'] + useAccessPreviewStore().initializeAfterLogin() + vi.mocked(listPlatformTenants).mockResolvedValue({ + records: [{ ...fullTenant, status: 'ARCHIVED' }], page: 1, size: 10, total: 1, pages: 1, + }) + + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + + expect(document.body.textContent).toContain('已归档') + expect(tableButtonByText('编辑')).toBeUndefined() + expect(tableButtonByText('启用')).toBeUndefined() + expect(tableButtonByText('停用')).toBeUndefined() + expect(tableButtonByText('删除')).toBeUndefined() + }) + it('renders all permitted actions directly for multiple customers', async () => { const auth = useAuthStore() auth.permissions = [...auth.permissions, 'platform:customer:reset-password', 'platform:customer:impersonate', 'platform:customer:status', 'platform:customer:delete'] diff --git a/src/modules/customers/pages/CustomersView.vue b/src/modules/customers/pages/CustomersView.vue index ebd0da7a..97a2f719 100644 --- a/src/modules/customers/pages/CustomersView.vue +++ b/src/modules/customers/pages/CustomersView.vue @@ -131,7 +131,7 @@ const queryFields: QueryField[] = [ { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [ { label: '待完善', value: 'PROFILE_REQUIRED' }, { label: '待审核', value: 'PENDING' }, { label: '启用', value: 'ACTIVE' }, { label: '已驳回', value: 'REJECTED' }, - { label: '已停用', value: 'DISABLED' }, + { label: '已停用', value: 'DISABLED' }, { label: '已归档', value: 'ARCHIVED' }, ] }, ] @@ -271,6 +271,7 @@ function statusMeta(status: PlatformTenantStatus) { if (status === 'PROFILE_REQUIRED') return { label: '待完善', type: 'info' as const } if (status === 'PENDING') return { label: '待审核', type: 'warning' as const } if (status === 'REJECTED') return { label: '已驳回', type: 'danger' as const } + if (status === 'ARCHIVED') return { label: '已归档', type: 'info' as const } return { label: '已停用', type: 'info' as const } } @@ -644,8 +645,8 @@ function canImpersonate(row: PlatformTenantResource) { function canChangeStatus(row: PlatformTenantResource) { return access.can('platform:customer:status') && ['ACTIVE', 'DISABLED'].includes(row.status) } -function canArchive() { - return access.can('platform:customer:delete') +function canArchive(row: PlatformTenantResource) { + return access.can('platform:customer:delete') && ['PROFILE_REQUIRED', 'REJECTED'].includes(row.status) } type CustomerRowAction = 'reset-password' | 'impersonate' | 'status' | 'delete' const customerRowActionIcons: Record = { @@ -664,7 +665,7 @@ function customerSecondaryActions(row: PlatformTenantResource): CustomerRowActio ...(canResetPassword(row) ? ['reset-password' as const] : []), ...(canImpersonate(row) ? ['impersonate' as const] : []), ...(canChangeStatus(row) ? ['status' as const] : []), - ...(canArchive() ? ['delete' as const] : []), + ...(canArchive(row) ? ['delete' as const] : []), ] } function customerActionLabel(row: PlatformTenantResource, action: CustomerRowAction) { @@ -819,7 +820,7 @@ async function submitArchive() { archiveError.value = null try { await archivePlatformTenant(archiveTenant.value.id) - ElMessage.success('客户已删除,历史数据已保留且账号已停用') + ElMessage.success('客户已归档,历史数据已保留且账号已停用') archiveDialogOpen.value = false archiveTenant.value = null archiveConfirmCode.value = '' @@ -1016,7 +1017,7 @@ onMounted(() => { void loadCustomers() }) diff --git a/src/shared/components/AppDrawer.test.ts b/src/shared/components/AppDrawer.test.ts new file mode 100644 index 00000000..0578d15f --- /dev/null +++ b/src/shared/components/AppDrawer.test.ts @@ -0,0 +1,66 @@ +import { defineComponent } from 'vue' +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import AppDrawer from './AppDrawer.vue' + +const ElDrawerStub = defineComponent({ + props: { + modelValue: Boolean, + showClose: Boolean, + closeOnClickModal: Boolean, + closeOnPressEscape: Boolean, + }, + template: '
', +}) + +const ElButtonStub = defineComponent({ + props: { disabled: Boolean, loading: Boolean }, + emits: ['click'], + template: '', +}) + +function mountDrawer(props: Record = {}) { + return mount(AppDrawer, { + props: { modelValue: true, title: '编辑资料', ...props }, + slots: { default: '

抽屉内容

' }, + global: { + stubs: { ElDrawer: ElDrawerStub, ElButton: ElButtonStub }, + directives: { loading: {} }, + }, + }) +} + +describe('AppDrawer', () => { + it('provides default cancel and confirm actions', async () => { + const wrapper = mountDrawer() + const buttons = wrapper.findAll('button') + + expect(buttons.map((button) => button.text())).toEqual(['取消', '确定']) + await buttons[1].trigger('click') + expect(wrapper.emitted('confirm')).toHaveLength(1) + + await buttons[0].trigger('click') + expect(wrapper.emitted('cancel')).toHaveLength(1) + expect(wrapper.emitted('update:modelValue')).toEqual([[false]]) + }) + + it('prevents accidental closing while confirmation is submitting', () => { + const wrapper = mountDrawer({ confirmLoading: true }) + const drawer = wrapper.getComponent(ElDrawerStub) + const cancelButton = wrapper.findAll('button')[0] + + expect(drawer.props('showClose')).toBe(false) + expect(drawer.props('closeOnClickModal')).toBe(false) + expect(drawer.props('closeOnPressEscape')).toBe(false) + expect(cancelButton.attributes('disabled')).toBeDefined() + }) + + it('keeps the drawer chrome fixed while long content scrolls in the body', () => { + const source = readFileSync(resolve(process.cwd(), 'src/shared/components/AppDrawer.vue'), 'utf8') + + expect(source).toContain(':global(.app-drawer .el-drawer__body) { min-height: 0; overflow-y: auto;') + expect(source).toContain('max-width: 94vw') + }) +}) diff --git a/src/shared/components/AppDrawer.vue b/src/shared/components/AppDrawer.vue index bf9cfbf6..aae5ad47 100644 --- a/src/shared/components/AppDrawer.vue +++ b/src/shared/components/AppDrawer.vue @@ -88,6 +88,8 @@ function closeDrawer() {