From 6f709267b7976b8d7db4656fcaded30f65104183 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 8 Aug 2026 10:14:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E7=AE=A1=E7=90=86=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + Dockerfile | 2 + docker-compose.test.yml | 2 + .../customers/pages/CustomersView.test.ts | 70 +++++- src/modules/customers/pages/CustomersView.vue | 215 +++++++++++++++++- src/modules/iam/api/platformIam.test.ts | 21 ++ src/modules/iam/api/platformIam.ts | 33 +++ 7 files changed, 334 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index b43cf89c..30f7bb32 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ VITE_API_BASE_URL=/api API_PROXY_TARGET=http://8.163.30.128:9000 +VITE_CLIENT_WEB_URL=http://localhost:5176 diff --git a/Dockerfile b/Dockerfile index 83c36a6a..a1ecea6e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,8 @@ RUN pnpm config set registry https://registry.npmmirror.com && \ pnpm install --frozen-lockfile COPY . . +ARG VITE_CLIENT_WEB_URL +ENV VITE_CLIENT_WEB_URL=${VITE_CLIENT_WEB_URL} # Docker 构建时同机还会运行多项服务,限制 Vitest 并发以避免测试超时。 # pnpm 会直接把脚本名后的参数传给 Vitest;额外的 `--` 会让 Vitest # 把 maxWorkers 当作位置参数,导致并发限制不生效。 diff --git a/docker-compose.test.yml b/docker-compose.test.yml index f673abde..c71d0652 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -3,6 +3,8 @@ services: build: context: . dockerfile: Dockerfile + args: + VITE_CLIENT_WEB_URL: "${VITE_CLIENT_WEB_URL:-http://8.163.30.128:9002}" image: admin-web-test:local container_name: admin-web-test restart: unless-stopped diff --git a/src/modules/customers/pages/CustomersView.test.ts b/src/modules/customers/pages/CustomersView.test.ts index ae91e3cf..0e4d3ce0 100644 --- a/src/modules/customers/pages/CustomersView.test.ts +++ b/src/modules/customers/pages/CustomersView.test.ts @@ -4,12 +4,15 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { approveEnterpriseReview, + archivePlatformTenant, downloadTenantQualification, + createPlatformTenantImpersonation, getPlatformTenant, listEnterpriseReviews, listPlatformTenants, listTenantQualifications, rejectEnterpriseReview, + resetPlatformTenantOwnerPassword, updatePlatformTenant, uploadPlatformTenantQualification, uploadPlatformTenantRequiredQualificationPackage, @@ -45,9 +48,11 @@ vi.mock('vue-router', async (importOriginal) => ({ vi.mock('@/modules/iam', () => ({ approveEnterpriseReview: vi.fn(), + archivePlatformTenant: vi.fn(), changePlatformTenantStatus: vi.fn(), checkPlatformTenantContactPhone: vi.fn().mockResolvedValue(true), createPlatformTenant: vi.fn(), + createPlatformTenantImpersonation: vi.fn(), downloadTenantQualification: vi.fn(), getPlatformTenant: vi.fn(), listEnterpriseReviews: vi.fn(), @@ -56,6 +61,7 @@ vi.mock('@/modules/iam', () => ({ listTenantQualifications: vi.fn(), qualificationFileName: vi.fn((documentUrl: string) => documentUrl.split('/').at(-1) || '资质文件'), rejectEnterpriseReview: vi.fn(), + resetPlatformTenantOwnerPassword: vi.fn(), submitPlatformTenantReview: vi.fn(), updatePlatformTenant: vi.fn(), uploadPlatformTenantQualification: vi.fn(), @@ -349,7 +355,7 @@ describe('CustomersView', () => { mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() - tableButtonByText('通过')?.click() + tableButtonByText('审核')?.click() await flushPromises() ;[...document.querySelectorAll('button')] .find((button) => button.textContent?.trim() === '确认通过')?.click() @@ -379,7 +385,7 @@ describe('CustomersView', () => { mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() - tableButtonByText('通过')?.click() + tableButtonByText('审核')?.click() await flushPromises() ;[...document.querySelectorAll('button')] .find((button) => button.textContent?.trim() === '确认通过')?.click() @@ -402,7 +408,7 @@ describe('CustomersView', () => { expect(listPlatformTenants).not.toHaveBeenCalled() expect(document.body.textContent).toContain(fullTenant.tenantName) - expect(tableButtonByText('通过')).toBeDefined() + expect(tableButtonByText('审核')).toBeDefined() }) it('does not let an older review response restore stale actions', async () => { @@ -423,11 +429,65 @@ describe('CustomersView', () => { resolveSecond([pendingReview]) await flushPromises() - expect(tableButtonByText('通过')).toBeDefined() + expect(tableButtonByText('审核')).toBeDefined() resolveFirst([]) await flushPromises() - expect(tableButtonByText('通过')).toBeDefined() + expect(tableButtonByText('审核')).toBeDefined() + }) + + it('resets only the customer owner password through the tenant contract', async () => { + const auth = useAuthStore() + auth.permissions = [...auth.permissions, 'platform:customer:reset-password'] + useAccessPreviewStore().initializeAfterLogin() + vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ ...fullTenant, status: 'ACTIVE' }], page: 1, size: 10, total: 1, pages: 1 }) + vi.mocked(resetPlatformTenantOwnerPassword).mockResolvedValue() + + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + tableButtonByText('重置密码')?.click() + await flushPromises() + + const password = document.querySelector('input[autocomplete="new-password"]') + const confirmation = document.querySelector('input[placeholder="请再次输入新密码"]') + password!.value = 'NewPassword#2026' + password!.dispatchEvent(new Event('input', { bubbles: true })) + confirmation!.value = 'NewPassword#2026' + confirmation!.dispatchEvent(new Event('input', { bubbles: true })) + await flushPromises() + ;[...document.querySelectorAll('button')] + .find((button) => button.textContent?.trim() === '确认重置')?.click() + await flushPromises() + + expect(resetPlatformTenantOwnerPassword).toHaveBeenCalledWith(fullTenant.id, { + newPassword: 'NewPassword#2026', forceChangePassword: true, + }) + }) + + it('archives a customer only after its customer ID is explicitly confirmed', async () => { + const auth = useAuthStore() + auth.permissions = [...auth.permissions, 'platform:customer:delete'] + useAccessPreviewStore().initializeAfterLogin() + vi.mocked(archivePlatformTenant).mockResolvedValue({ ...fullTenant, status: 'DISABLED' }) + + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + tableButtonByText('删除')?.click() + await flushPromises() + + const confirmation = document.querySelector('input[placeholder="TEN8"]') + expect(confirmation).toBeTruthy() + expect([...document.querySelectorAll('button')] + .find((button) => button.textContent?.trim() === '确认删除')?.disabled).toBe(true) + + confirmation!.value = 'TEN8' + confirmation!.dispatchEvent(new Event('input', { bubbles: true })) + await flushPromises() + ;[...document.querySelectorAll('button')] + .find((button) => button.textContent?.trim() === '确认删除')?.click() + await flushPromises() + + expect(archivePlatformTenant).toHaveBeenCalledWith(fullTenant.id) }) it('keeps the first operation column unfixed so it cannot cover a 320px table viewport', async () => { diff --git a/src/modules/customers/pages/CustomersView.vue b/src/modules/customers/pages/CustomersView.vue index fc026481..98e5584d 100644 --- a/src/modules/customers/pages/CustomersView.vue +++ b/src/modules/customers/pages/CustomersView.vue @@ -1,15 +1,17 @@ @@ -694,7 +870,7 @@ onMounted(() => { void loadCustomers() }) - + @@ -767,11 +943,39 @@ onMounted(() => { void loadCustomers() })
{{ selectedReview.tenantName || '当前客户' }}审核单 {{ selectedReview.id }}
+ 通过驳回
+ + +
正在重置企业管理员登录密码{{ passwordTenant.tenantName }}
客户ID:{{ passwordTenant.tenantCode }}
+ + +
随机生成
+ + 客户下次登录时必须修改密码 +

重置对象为企业 OWNER 管理员,原密码将立即失效。

+
+ +
+ + +
{{ impersonationTenant.tenantName }}将以企业管理员身份打开客户工作台
客户ID:{{ impersonationTenant.tenantCode }}
+ 本操作会生成一次性、短时有效的客户会话,并记录代登录原因;不会替换当前 Admin 会话。 + + + +
+ + + 删除是逻辑归档:历史业务数据会保留,客户账号会被停用。是否允许归档由服务端最终判断。 +
{{ archiveTenant.tenantName }}请输入客户编号 {{ archiveTenant.tenantCode }} 以确认。
+ + +
@@ -806,7 +1010,8 @@ onMounted(() => { void loadCustomers() }) .customer-editor :deep(.el-drawer__footer) { flex:0 0 auto; } .customer-editor-footer { display:flex; align-items:center; justify-content:space-between; gap:16px; } .customer-editor-footer > span { color:var(--cc-muted); font-size:12px; text-align:left; } .customer-editor-footer > div { display:flex; flex:0 0 auto; gap:8px; } .customer-editor-footer :deep(.el-button + .el-button) { margin-left:0; } .decision-summary { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:12px; margin-bottom:18px; padding:12px 14px; border-radius:8px; background:#f8fafc; } .decision-summary > * { min-width:0; overflow-wrap:anywhere; } .decision-summary strong { color:var(--cc-text); font-size:13px; } .decision-summary span { color:var(--cc-muted); font-size:11px; } +.decision-summary > div > span { display:block; margin-top:4px; }.decision-type { margin:0 0 16px; }.password-input-row { display:grid; grid-template-columns:minmax(0,1fr) auto; width:100%; gap:8px; }.dialog-hint { margin:12px 0 0; color:var(--cc-muted); font-size:12px; line-height:1.6; }.archive-confirm { display:grid; gap:8px; margin:16px 0; }.archive-confirm strong { color:var(--cc-text); }.archive-confirm span { color:var(--cc-muted); font-size:12px; } .approval-result-warning { display:flex; align-items:flex-start; gap:10px; padding:12px 14px; border:1px solid #fecdca; border-radius:8px; background:#fffafa; color:#b42318; } .approval-result-warning strong,.approval-result-warning span { display:block; } .approval-result-warning strong { font-size:14px; } .approval-result-warning span { margin-top:4px; font-size:12px; line-height:1.5; } .approval-credentials { margin:18px 0 0; border:1px solid var(--cc-border); border-radius:8px; } .approval-credentials > div { display:grid; grid-template-columns:90px minmax(0,1fr); gap:12px; padding:12px 14px; } .approval-credentials > div + div { border-top:1px solid var(--cc-border); } .approval-credentials dt { color:var(--cc-muted); font-size:12px; } .approval-credentials dd { display:flex; min-width:0; align-items:center; gap:10px; margin:0; } .approval-credentials code { min-width:0; flex:1; overflow-wrap:anywhere; color:var(--cc-text); font-size:14px; font-weight:700; } -@media (max-width:720px) { .customers-error,.customer-editor-footer,.review-sync-error { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; width:100%; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .decision-summary { align-items:flex-start; flex-direction:column; } } +@media (max-width:720px) { .customers-error,.customer-editor-footer,.review-sync-error { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; width:100%; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .decision-summary { align-items:flex-start; flex-direction:column; }.password-input-row { grid-template-columns:1fr; }.table-actions :deep(.el-button) { min-height:40px; } } @media (max-width:360px) { .customers-table-actions,.capital-input { grid-template-columns:1fr; } } diff --git a/src/modules/iam/api/platformIam.test.ts b/src/modules/iam/api/platformIam.test.ts index 923b780a..b91ab80c 100644 --- a/src/modules/iam/api/platformIam.test.ts +++ b/src/modules/iam/api/platformIam.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' import { approveEnterpriseReview, + archivePlatformTenant, assignPlatformInternalUserRoles, changePlatformManagedUserStatus, changePlatformInternalUserStatus, @@ -12,6 +13,7 @@ import { createPlatformManagedUser, createPlatformRole, createPlatformTenant, + createPlatformTenantImpersonation, deletePlatformPermission, deletePlatformRole, downloadTenantQualification, @@ -27,6 +29,7 @@ import { replacePlatformRolePermissions, resetPlatformInternalUserPassword, resetPlatformManagedUserPassword, + resetPlatformTenantOwnerPassword, submitPlatformTenantReview, updatePlatformInternalUser, updatePlatformManagedUser, @@ -290,6 +293,24 @@ describe('platform IAM roles', () => { expect(http.put).toHaveBeenNthCalledWith(2, '/platform/v1/iam/tenants/8/status', { status: 'DISABLED' }) }) + it('supports owner password reset, logical archive, and audited impersonation contracts', async () => { + const password = { newPassword: 'NewPassword#2026', forceChangePassword: true } + const archivedTenant = { id: '190000000000000099', tenantCode: 'TEN99', tenantName: '测试客户', status: 'DISABLED' } + const impersonation = { code: 'ticket-code', ticketId: 'ticket-id', expiresIn: 60 } + vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null }) + vi.mocked(http.post) + .mockResolvedValueOnce({ code: '200', message: 'success', data: archivedTenant }) + .mockResolvedValueOnce({ code: '200', message: 'success', data: impersonation }) + + await expect(resetPlatformTenantOwnerPassword('190000000000000099', password)).resolves.toBeNull() + await expect(archivePlatformTenant('190000000000000099')).resolves.toEqual(archivedTenant) + await expect(createPlatformTenantImpersonation('190000000000000099', { reason: '客户问题排查' })).resolves.toEqual(impersonation) + + expect(http.put).toHaveBeenCalledWith('/platform/v1/iam/tenants/190000000000000099/owner/password', password) + expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/iam/tenants/190000000000000099/archive', {}) + expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/iam/tenants/190000000000000099/impersonation', { reason: '客户问题排查' }) + }) + it('loads real order totals for the visible customer tenants', async () => { const counts = [{ tenantId: '8', orderCount: 12 }, { tenantId: '9', orderCount: 0 }] vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: counts }) diff --git a/src/modules/iam/api/platformIam.ts b/src/modules/iam/api/platformIam.ts index b0f08685..007202c8 100644 --- a/src/modules/iam/api/platformIam.ts +++ b/src/modules/iam/api/platformIam.ts @@ -141,6 +141,21 @@ export interface EnterpriseApprovalResponse { temporaryPassword: string | null } +export interface ResetPlatformTenantOwnerPasswordRequest { + newPassword: string + forceChangePassword: boolean +} + +export interface CreatePlatformTenantImpersonationRequest { + reason: string +} + +export interface PlatformTenantImpersonationResponse { + code: string + ticketId: string + expiresIn: number +} + export interface PlatformRoleResource { id: number | string tenantId: number | string @@ -350,6 +365,24 @@ export function submitPlatformTenantReview(tenantId: number | string) { return postData(`/platform/v1/iam/tenants/${tenantId}/submit-review`, {}) } +export function resetPlatformTenantOwnerPassword( + tenantId: number | string, + payload: ResetPlatformTenantOwnerPasswordRequest, +) { + return putData(`/platform/v1/iam/tenants/${tenantId}/owner/password`, payload) +} + +export function archivePlatformTenant(tenantId: number | string) { + return postData(`/platform/v1/iam/tenants/${tenantId}/archive`, {}) +} + +export function createPlatformTenantImpersonation( + tenantId: number | string, + payload: CreatePlatformTenantImpersonationRequest, +) { + return postData(`/platform/v1/iam/tenants/${tenantId}/impersonation`, payload) +} + export async function uploadPlatformTenantQualification( tenantId: number | string, qualificationType: PlatformQualificationType,