From fa922ca96f0c245a4e9914e76e482c037afe367a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 3 Aug 2026 20:50:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- src/app/router/index.test.ts | 6 +- src/app/router/index.ts | 10 +- src/app/router/meta.d.ts | 1 + src/core/navigation/adminNavigation.test.ts | 11 +- src/core/navigation/adminNavigation.ts | 13 +- .../customers/pages/CustomersView.test.ts | 92 ++++ src/modules/customers/pages/CustomersView.vue | 218 +++++++- .../pages/EnterpriseReviewsView.test.ts | 194 ------- .../customers/pages/EnterpriseReviewsView.vue | 500 ------------------ src/modules/customers/routes.ts | 6 +- src/modules/iam/api/platformIam.test.ts | 15 + src/modules/iam/api/platformIam.ts | 23 +- 13 files changed, 357 insertions(+), 736 deletions(-) delete mode 100644 src/modules/customers/pages/EnterpriseReviewsView.test.ts delete mode 100644 src/modules/customers/pages/EnterpriseReviewsView.vue diff --git a/README.md b/README.md index 37f481a2..6cc8fac5 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ pnpm test 依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。 -工作台已从一期菜单和默认入口移除;登录后按当前角色进入第一个有权限的业务页面。历史工作台仍保留为不展示菜单的原型路由。一期 Admin 正式路由聚焦登录、客户及注册审核、平台收货仓、快递公司、往来单位、头程订单、内部用户、角色管理和菜单权限。 +工作台已从一期菜单和默认入口移除;登录后按当前角色进入第一个有权限的业务页面。历史工作台仍保留为不展示菜单的原型路由。一期 Admin 正式路由聚焦登录、客户及注册审核、平台收货仓、快递公司、往来单位、头程订单、内部用户、角色管理和菜单权限;企业注册审核已合并到客户列表,不再保留独立菜单。 后台菜单仅展示已接入的一期模块;其余平台原型菜单保留为导航配置中的注释,待对应业务契约接入后再恢复展示。企业审核、仓库、头程订单、平台角色、菜单及按钮权限资源均接入真实接口;用户管理通过 IAM 统一维护平台账号和企业租户账号。运行时不再提供本地演示业务数据或静态角色预览。 @@ -48,7 +48,7 @@ VITE_API_BASE_URL=/api API_PROXY_TARGET=http://8.163.30.128:9000 ``` -认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后从 JWT 读取平台身份和授权快照,再与 IAM 返回的启用权限资源取交集。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。一期真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。 +认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后从 JWT 读取平台身份和授权快照,再与 IAM 返回的启用权限资源取交集。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。审核通过新企业并由后端创建管理员时,页面必须展示不可通过遮罩、Esc 或关闭按钮跳过的一次性凭据弹窗,运营人员确认保存后才清除账号密码。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。一期真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。 仓库管理已接入真实平台 Warehouse API,支持按租户筛选、详情、新增、编辑和启停,并在更新时保留后端返回的地址、能力和截单时间等完整字段。停用仓库保留历史数据,但不再提供给客户新建头程订单选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。本阶段不包含 FBA/海外目的仓、库存、库位或出入库作业。 diff --git a/src/app/router/index.test.ts b/src/app/router/index.test.ts index 232c185d..baa2c89d 100644 --- a/src/app/router/index.test.ts +++ b/src/app/router/index.test.ts @@ -37,11 +37,13 @@ describe('admin router access metadata', () => { expect(route?.meta.permission).toBe('platform:download:list') }) - it('protects enterprise reviews with the backend review menu permission', () => { + it('redirects the removed enterprise review route to the protected customer list', () => { const route = router.getRoutes().find((item) => item.name === 'enterprise-reviews') expect(route?.path).toBe('/customers/reviews') - expect(route?.meta.permission).toBe('platform:customer:review') + expect(route?.redirect).toBe('/customers') + expect(route?.meta.permission).toBe('platform:customer:list') + expect(route?.meta.permissionAnyOf).toContain('platform:customer:review') }) it('keeps forbidden inside the authenticated layout without a business permission', () => { diff --git a/src/app/router/index.ts b/src/app/router/index.ts index f1833dcb..29483107 100644 --- a/src/app/router/index.ts +++ b/src/app/router/index.ts @@ -60,12 +60,18 @@ router.beforeEach((to) => { const access = useAccessPreviewStore() if (auth.isAuthenticated) access.initializeAfterLogin() + const alternativePermissions = to.meta.permissionAnyOf ?? [] + const requiresRoutePermission = Boolean(to.meta.permission || alternativePermissions.length) + const hasRoutePermission = !requiresRoutePermission || Boolean( + (to.meta.permission && access.can(to.meta.permission)) + || alternativePermissions.some((permission) => access.can(permission)), + ) const decision = decideRouteAccess({ requiresAuth: to.meta.requiresAuth, isAuthenticated: auth.isAuthenticated, fullPath: to.fullPath, - permission: to.meta.permission, - hasPermission: to.meta.permission ? access.can(to.meta.permission) : true, + permission: to.meta.permission ?? alternativePermissions[0], + hasPermission: hasRoutePermission, }) if (decision.type === 'login') { diff --git a/src/app/router/meta.d.ts b/src/app/router/meta.d.ts index 044d9a6d..e96e7dc2 100644 --- a/src/app/router/meta.d.ts +++ b/src/app/router/meta.d.ts @@ -7,5 +7,6 @@ declare module 'vue-router' { showPageHeading?: boolean requiresAuth?: boolean permission?: string + permissionAnyOf?: string[] } } diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index 33c1ddb5..0453858b 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -16,10 +16,7 @@ describe('adminNavigation', () => { '通用', '系统管理', ]) - expect(adminNavigation[0].children?.map((item) => item.title)).toEqual([ - '客户列表', - '企业审核', - ]) + expect(adminNavigation[0].children?.map((item) => item.title)).toEqual(['客户列表']) expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toContain('快递公司') expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toContain('下载中心') expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([ @@ -56,6 +53,12 @@ describe('adminNavigation', () => { expect(JSON.stringify(adminNavigation, (key, value) => key === 'icon' ? '[icon]' : value)).toBe(sourceSnapshot) }) + it('keeps the merged customer entry for existing review-only roles', () => { + const filtered = filterAdminNavigation(adminNavigation, new Set(['platform:customer:review'])) + + expect(getNavigationLeaves(filtered).map((item) => item.path)).toContain('/customers') + }) + it('supports manually hiding a module without removing its route definition', () => { const filtered = filterAdminNavigation([{ key: 'hidden', title: '隐藏模块', icon: adminNavigation[0].icon, path: '/hidden', hidden: true }], new Set()) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 90f1fb77..486bb6f5 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -5,7 +5,6 @@ import { ClipboardList, Database, Download, - FileCheck2, KeyRound, Settings, ShieldCheck, @@ -21,6 +20,7 @@ export interface AdminNavigationNode { iconColor?: string path?: string permission?: string + permissionAnyOf?: string[] /** Set to true to remove a prototype module from the sidebar without deleting its route. */ hidden?: boolean children?: AdminNavigationNode[] @@ -41,8 +41,7 @@ export const adminNavigation: AdminNavigationNode[] = [ icon: Building2, iconColor: '#52c41a', children: [ - { key: 'customers-list', title: '客户列表', icon: Building2, path: '/customers', permission: 'platform:customer:list' }, - { key: 'enterprise-reviews', title: '企业审核', icon: FileCheck2, path: '/customers/reviews', permission: 'platform:customer:review' }, + { key: 'customers-list', title: '客户列表', icon: Building2, path: '/customers', permission: 'platform:customer:list', permissionAnyOf: ['platform:customer:review'] }, // { key: 'warehouse-tier-fee', title: '海外仓阶梯费用', icon: Building2, path: '/prototype/customer-management/warehouse-tier-fee' }, ], }, @@ -258,7 +257,13 @@ export function filterAdminNavigation( const children = filterAdminNavigation(node.children, permissionCodes) return children.length ? [{ ...node, children }] : [] } - if (!node.path || (node.permission && !permissionCodes.has(node.permission))) return [] + const alternativePermissions = node.permissionAnyOf ?? [] + const requiresPermission = Boolean(node.permission || alternativePermissions.length) + const hasPermission = !requiresPermission || Boolean( + (node.permission && permissionCodes.has(node.permission)) + || alternativePermissions.some((permission) => permissionCodes.has(permission)), + ) + if (!node.path || !hasPermission) return [] return [{ ...node }] }) } diff --git a/src/modules/customers/pages/CustomersView.test.ts b/src/modules/customers/pages/CustomersView.test.ts index 982b34a5..21c35f4e 100644 --- a/src/modules/customers/pages/CustomersView.test.ts +++ b/src/modules/customers/pages/CustomersView.test.ts @@ -3,12 +3,16 @@ import { createPinia, setActivePinia } from 'pinia' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + approveEnterpriseReview, downloadTenantQualification, getPlatformTenant, + listEnterpriseReviews, listPlatformTenants, listTenantQualifications, + rejectEnterpriseReview, updatePlatformTenant, uploadPlatformTenantQualification, + type EnterpriseReview, type PlatformTenantResource, } from '@/modules/iam' import { createPlatformExportTask } from '@/modules/downloads' @@ -17,14 +21,17 @@ import { useAuthStore } from '@/core/auth/auth' import CustomersView from './CustomersView.vue' vi.mock('@/modules/iam', () => ({ + approveEnterpriseReview: vi.fn(), changePlatformTenantStatus: vi.fn(), createPlatformTenant: vi.fn(), downloadTenantQualification: vi.fn(), getPlatformTenant: vi.fn(), + listEnterpriseReviews: vi.fn(), listPlatformTenantOrderCounts: vi.fn(), listPlatformTenants: vi.fn(), listTenantQualifications: vi.fn(), qualificationFileName: vi.fn((documentUrl: string) => documentUrl.split('/').at(-1) || '资质文件'), + rejectEnterpriseReview: vi.fn(), submitPlatformTenantReview: vi.fn(), updatePlatformTenant: vi.fn(), uploadPlatformTenantQualification: vi.fn(), @@ -48,6 +55,14 @@ const fullTenant: PlatformTenantResource = { createdAt: '2026-08-01T10:00:00', approvedAt: null, } +const pendingReview: EnterpriseReview = { + id: '190000000000000001', + tenantId: fullTenant.id, + tenantName: fullTenant.tenantName, + tenantCode: fullTenant.tenantCode, + status: 'PENDING', +} + function tableButtonByText(text: string) { return [...document.querySelectorAll('.el-table__body button')] .find((button) => button.textContent?.trim() === text) @@ -76,6 +91,7 @@ describe('CustomersView', () => { }) vi.mocked(getPlatformTenant).mockResolvedValue(fullTenant) vi.mocked(listTenantQualifications).mockResolvedValue([]) + vi.mocked(listEnterpriseReviews).mockResolvedValue([]) vi.mocked(updatePlatformTenant).mockResolvedValue(fullTenant) vi.mocked(uploadPlatformTenantQualification).mockResolvedValue({ qualificationId: 'qualification-1', reviewId: null, tenantStatus: 'PROFILE_REQUIRED', documentUrl: '/files/license.png', @@ -181,4 +197,80 @@ describe('CustomersView', () => { }) expect(listPlatformTenants).toHaveBeenCalledTimes(1) }) + + it('approves a pending enterprise directly from the customer list', async () => { + const auth = useAuthStore() + auth.permissions = [...auth.permissions, 'platform:customer:review', 'platform:customer:approve'] + useAccessPreviewStore().initializeAfterLogin() + vi.mocked(listPlatformTenants).mockResolvedValue({ + records: [{ ...fullTenant, status: 'PENDING' }], page: 1, size: 10, total: 1, pages: 1, + }) + vi.mocked(listEnterpriseReviews).mockResolvedValue([pendingReview]) + vi.mocked(approveEnterpriseReview).mockResolvedValue({ + tenantId: fullTenant.id, + administratorCreated: true, + administratorUsername: 'merchant_8_owner', + temporaryPassword: 'Temporary#2026', + }) + + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + tableButtonByText('通过')?.click() + await flushPromises() + ;[...document.querySelectorAll('button')] + .find((button) => button.textContent?.trim() === '确认通过')?.click() + await flushPromises() + + expect(approveEnterpriseReview).toHaveBeenCalledWith(pendingReview.id, '企业资料与必填资质完整、清晰,审核通过') + expect(rejectEnterpriseReview).not.toHaveBeenCalled() + expect(document.body.textContent).toContain('请立即保存以下一次性凭证') + expect(document.body.textContent).toContain('merchant_8_owner') + expect(document.body.textContent).toContain('Temporary#2026') + }) + + it('keeps review-only roles on the merged customer page without calling the customer list endpoint', async () => { + const auth = useAuthStore() + auth.permissions = ['platform:customer:review', 'platform:customer:approve'] + useAccessPreviewStore().initializeAfterLogin() + vi.mocked(listEnterpriseReviews).mockResolvedValue([pendingReview]) + + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + + expect(listPlatformTenants).not.toHaveBeenCalled() + expect(document.body.textContent).toContain(fullTenant.tenantName) + expect(tableButtonByText('通过')).toBeDefined() + }) + + it('does not let an older review response restore stale actions', async () => { + const auth = useAuthStore() + auth.permissions = [...auth.permissions, 'platform:customer:review', 'platform:customer:approve'] + useAccessPreviewStore().initializeAfterLogin() + let resolveFirst!: (value: EnterpriseReview[]) => void + let resolveSecond!: (value: EnterpriseReview[]) => void + vi.mocked(listEnterpriseReviews) + .mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve })) + .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve })) + + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await Promise.resolve() + ;[...document.querySelectorAll('.query-toolbar__actions button')] + .find((button) => button.textContent?.trim() === '查询')?.click() + await Promise.resolve() + + resolveSecond([pendingReview]) + await flushPromises() + expect(tableButtonByText('通过')).toBeDefined() + + resolveFirst([]) + await flushPromises() + expect(tableButtonByText('通过')).toBeDefined() + }) + + it('keeps the first operation column unfixed so it cannot cover a 320px table viewport', async () => { + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + + expect(document.querySelector('.el-table__fixed')).toBeNull() + }) }) diff --git a/src/modules/customers/pages/CustomersView.vue b/src/modules/customers/pages/CustomersView.vue index a653e41f..ac5f0bd4 100644 --- a/src/modules/customers/pages/CustomersView.vue +++ b/src/modules/customers/pages/CustomersView.vue @@ -1,28 +1,34 @@ - -