From 29dbae573f2fbbdb03f1ce544a6c1faa0b5599d9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 1 Aug 2026 12:17:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E8=81=94=E8=B0=83=E4=B8=8E=E9=80=9A=E7=94=A8=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + README.md | 2 +- src/api/http.test.ts | 7 + src/api/http.ts | 2 +- src/api/platformExpressCompanies.test.ts | 74 ++++ src/api/platformExpressCompanies.ts | 100 +++++ src/api/platformFirstLegOrders.test.ts | 8 +- src/api/platformIam.test.ts | 89 +++- src/api/platformIam.ts | 95 ++++- src/api/platformPartners.test.ts | 59 +++ src/api/platformPartners.ts | 108 +++++ src/api/platformWarehouses.test.ts | 10 +- src/components.d.ts | 6 + src/components/ApiErrorMeta.vue | 33 +- .../PlatformFirstLegOrdersPanel.vue | 206 +++++----- src/components/common/AppDialog.test.ts | 65 +++ src/components/common/AppDialog.vue | 95 +++++ src/components/common/AppDrawer.vue | 95 +++++ src/components/common/CopyButton.vue | 62 +++ src/components/common/DataTable.test.ts | 32 ++ src/components/common/DataTable.vue | 180 +++++++++ src/components/common/LoadErrorState.vue | 48 +++ src/components/common/QueryToolbar.vue | 133 ++++++ src/env.d.ts | 2 +- src/layouts/AppLayout.test.ts | 16 +- src/layouts/AppLayout.vue | 9 +- src/router/index.ts | 13 +- src/router/meta.d.ts | 1 + src/stores/auth.test.ts | 8 +- src/styles/foundation.css | 18 + src/styles/index.css | 76 +--- src/styles/shell.css | 55 +++ src/types/admin.ts | 1 + src/utils/copyText.test.ts | 31 ++ src/utils/copyText.ts | 35 ++ src/utils/platformPermission.ts | 1 + src/views/CustomersView.vue | 165 ++++++++ src/views/EnterpriseReviewsView.vue | 137 ++++--- src/views/ExpressCompaniesView.test.ts | 98 +++++ src/views/ExpressCompaniesView.vue | 346 ++++++++++++++++ src/views/InternalUsersView.vue | 250 +++++++++--- src/views/LoginView.test.ts | 18 +- src/views/LoginView.vue | 24 +- src/views/PartnersView.test.ts | 149 +++++++ src/views/PartnersView.vue | 379 ++++++++++++++++++ src/views/PermissionsView.test.ts | 7 +- src/views/PermissionsView.vue | 233 ++++++++--- src/views/RolesView.vue | 89 ++-- src/views/WarehousesView.test.ts | 63 ++- src/views/WarehousesView.vue | 240 ++++++----- 50 files changed, 3394 insertions(+), 580 deletions(-) create mode 100644 src/api/platformExpressCompanies.test.ts create mode 100644 src/api/platformExpressCompanies.ts create mode 100644 src/api/platformPartners.test.ts create mode 100644 src/api/platformPartners.ts create mode 100644 src/components/common/AppDialog.test.ts create mode 100644 src/components/common/AppDialog.vue create mode 100644 src/components/common/AppDrawer.vue create mode 100644 src/components/common/CopyButton.vue create mode 100644 src/components/common/DataTable.test.ts create mode 100644 src/components/common/DataTable.vue create mode 100644 src/components/common/LoadErrorState.vue create mode 100644 src/components/common/QueryToolbar.vue create mode 100644 src/styles/foundation.css create mode 100644 src/styles/shell.css create mode 100644 src/utils/copyText.test.ts create mode 100644 src/utils/copyText.ts create mode 100644 src/views/CustomersView.vue create mode 100644 src/views/ExpressCompaniesView.test.ts create mode 100644 src/views/ExpressCompaniesView.vue create mode 100644 src/views/PartnersView.test.ts create mode 100644 src/views/PartnersView.vue diff --git a/AGENTS.md b/AGENTS.md index baddfbda..f237e8ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ - 当前登录和部分数据仍可能是模拟实现。接入 `services/api` 时删除对应模拟路径,不允许让生产逻辑静默回退到假数据。 - 删除、停用、改权限等高风险操作必须有明确确认、处理中、成功和失败反馈;失败时保留用户上下文,避免误以为已生效。 - 日志、提示和示例数据不得包含真实账号、令牌、客户隐私或生产数据。 +- 一期 Admin 头程订单仅服务唯一已知租户,前端固定使用 `tenantId=10001` 并且不展示租户选择或录入控件;接口仍保留租户参数以保持后续多租户扩展口。多租户选择、切换和权限范围尚未实现,后续迭代开始前不得自行放开或从本地存储推断租户。 ## UI 与交互 diff --git a/README.md b/README.md index 738a759a..c988b190 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ API_PROXY_TARGET=http://8.163.30.128:9000 仓库管理已接入真实平台 Warehouse API,支持按租户筛选、详情、新增、编辑和启停,并在更新时保留后端返回的地址、能力和截单时间等完整字段。停用仓库保留历史数据,但不再提供给客户新建头程订单选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。本阶段不包含 FBA/海外目的仓、库存、库位或出入库作业。 -头程订单列表、详情、状态历史、内部备注和快递信息已经接入真实平台 Order API;订单不设“已驳回”,后台修改待审核订单后仍保持待审核并写入审计。客户档案、快递公司和平台往来单位尚无正式平台端接口,相应页面明确展示不可用状态,不展示静态数据,也不模拟新增、编辑或导入成功。内部用户后端当前只支持查询和新增,编辑、启停、重置密码及角色分配保持不可用。 +头程订单列表、详情、状态历史、内部备注和快递信息已经接入真实平台 Order API;订单不设“已驳回”,后台修改待审核订单后仍保持待审核并写入审计。快递公司已经接入 Warehouse Service 的真实分页查询、新增、编辑和启停接口,按租户与平台权限访问;订单履约接口当前仍按公司名称和快递单号保存。平台往来单位已经接入 Warehouse Service 的真实分页、新增、编辑和启停接口,当前后端类型契约为客户、供应商及客户/供应商双重类型;批量导入接口尚未提供,不模拟导入成功。客户档案尚无正式平台端接口,相应页面明确展示不可用状态。内部用户后端当前只支持查询和新增,编辑、启停、重置密码及角色分配保持不可用。 真实接口错误会保留后端返回的业务错误码、`requestId` 和 `traceId`。登录、企业审核、平台角色及权限资源的主要失败态支持直接复制追踪标识,便于与后端日志关联。 diff --git a/src/api/http.test.ts b/src/api/http.test.ts index 6da21f60..eb02ee51 100644 --- a/src/api/http.test.ts +++ b/src/api/http.test.ts @@ -18,6 +18,13 @@ afterEach(() => { }) describe('API error diagnostics', () => { + it('treats only body code 200 as success', () => { + expect(unwrapApiResponse({ code: '200', message: 'success', data: { id: '1' } })) + .toEqual({ id: '1' }) + expect(() => unwrapApiResponse({ code: '0', message: 'legacy success code', data: null })) + .toThrow(ApiResponseError) + }) + it('keeps business request and trace identifiers when unwrapping fails', () => { expect(() => unwrapApiResponse({ code: 'A0403', diff --git a/src/api/http.ts b/src/api/http.ts index 33019cfb..07ee5e69 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -98,7 +98,7 @@ http.interceptors.response.use( ) export function unwrapApiResponse(response: ApiEnvelope): T { - if (response?.code !== '0') { + if (response?.code !== '200') { throw new ApiResponseError( response?.message || '接口请求失败', response?.code || 'UNKNOWN', diff --git a/src/api/platformExpressCompanies.test.ts b/src/api/platformExpressCompanies.test.ts new file mode 100644 index 00000000..eb66203f --- /dev/null +++ b/src/api/platformExpressCompanies.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/api/http' +import { + changePlatformExpressCompanyStatus, + createPlatformExpressCompany, + getPlatformExpressCompany, + listPlatformExpressCompanies, + updatePlatformExpressCompany, + type SavePlatformExpressCompanyRequest, +} from './platformExpressCompanies' + +vi.mock('@/api/http', () => ({ + http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() }, + unwrapApiResponse: (response: { data: T }) => response.data, +})) + +const payload: SavePlatformExpressCompanyRequest = { + companyCode: 'SF', + companyName: '顺丰速运', + servicePhone: '95338', + website: 'https://www.sf-express.com', + trackingUrlTemplate: 'https://example.com/track/{trackingNo}', + status: 'ACTIVE', + remark: null, +} + +describe('platform express company API', () => { + beforeEach(() => { + vi.mocked(http.get).mockReset() + vi.mocked(http.post).mockReset() + vi.mocked(http.put).mockReset() + vi.mocked(http.patch).mockReset() + }) + + it('uses the implemented list and detail contracts', async () => { + const page = { records: [], page: '1', size: '20', total: '0', pages: '0' } + vi.mocked(http.get) + .mockResolvedValueOnce({ code: '200', message: 'success', data: page }) + .mockResolvedValueOnce({ code: '200', message: 'success', data: { id: '19001' } }) + + await expect(listPlatformExpressCompanies(10001, 2, 20, ' 顺丰 ', 'ACTIVE')).resolves.toEqual({ + records: [], + page: 1, + size: 20, + total: 0, + pages: 0, + }) + await expect(getPlatformExpressCompany(10001, '19001')).resolves.toEqual({ id: '19001' }) + expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/express-companies', { + params: { tenantId: 10001, page: 2, size: 20, keyword: '顺丰', status: 'ACTIVE' }, + }) + expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/express-companies/19001', { + params: { tenantId: 10001 }, + }) + }) + + it('uses the implemented create, update, and status contracts', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: '19001' }) + vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null }) + vi.mocked(http.patch).mockResolvedValue({ code: '200', message: 'success', data: null }) + + await expect(createPlatformExpressCompany(10001, payload)).resolves.toBe('19001') + await expect(updatePlatformExpressCompany(10001, '19001', payload)).resolves.toBeNull() + await expect(changePlatformExpressCompanyStatus(10001, '19001', 'INACTIVE')).resolves.toBeNull() + + expect(http.post).toHaveBeenCalledWith('/platform/v1/express-companies', payload, { params: { tenantId: 10001 } }) + expect(http.put).toHaveBeenCalledWith('/platform/v1/express-companies/19001', payload, { params: { tenantId: 10001 } }) + expect(http.patch).toHaveBeenCalledWith( + '/platform/v1/express-companies/19001/status', + { status: 'INACTIVE' }, + { params: { tenantId: 10001 } }, + ) + }) +}) diff --git a/src/api/platformExpressCompanies.ts b/src/api/platformExpressCompanies.ts new file mode 100644 index 00000000..a5d50734 --- /dev/null +++ b/src/api/platformExpressCompanies.ts @@ -0,0 +1,100 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/api/http' + +export type PlatformExpressCompanyStatus = 'ACTIVE' | 'INACTIVE' + +export interface PlatformExpressCompanyResource { + id: number | string + tenantId: number | string + companyCode: string + companyName: string + servicePhone: string | null + website: string | null + trackingUrlTemplate: string | null + status: PlatformExpressCompanyStatus + remark: string | null + createdAt?: string | null + updatedAt?: string | null +} + +export interface PlatformExpressCompanyPage { + records: PlatformExpressCompanyResource[] + page: number + size: number + total: number + pages: number +} + +export interface SavePlatformExpressCompanyRequest { + companyCode: string + companyName: string + servicePhone: string | null + website: string | null + trackingUrlTemplate: string | null + status: PlatformExpressCompanyStatus + remark: string | null +} + +const basePath = '/platform/v1/express-companies' + +export async function listPlatformExpressCompanies( + tenantId: number, + page = 1, + size = 20, + keyword = '', + status: PlatformExpressCompanyStatus | '' = '', +) { + const response = await http.get, ApiEnvelope>(basePath, { + params: { + tenantId, + page, + size, + keyword: keyword.trim() || undefined, + status: status || undefined, + }, + }) + const result = unwrapApiResponse(response) + return { + ...result, + page: Number(result.page), + size: Number(result.size), + total: Number(result.total), + pages: Number(result.pages), + } +} + +export async function getPlatformExpressCompany(tenantId: number, id: number | string) { + const response = await http.get, ApiEnvelope>( + `${basePath}/${id}`, + { params: { tenantId } }, + ) + return unwrapApiResponse(response) +} + +export async function createPlatformExpressCompany(tenantId: number, payload: SavePlatformExpressCompanyRequest) { + const response = await http.post, ApiEnvelope>(basePath, payload, { + params: { tenantId }, + }) + return unwrapApiResponse(response) +} + +export async function updatePlatformExpressCompany( + tenantId: number, + id: number | string, + payload: SavePlatformExpressCompanyRequest, +) { + const response = await http.put, ApiEnvelope>(`${basePath}/${id}`, payload, { + params: { tenantId }, + }) + return unwrapApiResponse(response) +} + +export async function changePlatformExpressCompanyStatus( + tenantId: number, + id: number | string, + status: PlatformExpressCompanyStatus, +) { + const response = await http.patch, ApiEnvelope>(`${basePath}/${id}/status`, { status }, { + params: { tenantId }, + }) + return unwrapApiResponse(response) +} diff --git a/src/api/platformFirstLegOrders.test.ts b/src/api/platformFirstLegOrders.test.ts index 43b0d90f..4672b1d6 100644 --- a/src/api/platformFirstLegOrders.test.ts +++ b/src/api/platformFirstLegOrders.test.ts @@ -26,7 +26,7 @@ describe('platform first-leg order API', () => { it('queries a tenant-scoped order page with server pagination and filters', async () => { const page = { records: [], page: 2, size: 20, total: 0, pages: 0 } - vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: page }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: page }) await expect(listPlatformFirstLegOrders('10001', { keyword: 'FL2026', status: 'SUBMITTED', createdFrom: '2026-07-01T00:00:00', createdTo: '2026-07-31T23:59:59', page: 2, size: 20, @@ -39,8 +39,8 @@ describe('platform first-leg order API', () => { }) it('uses the platform lifecycle routes and target tenant query parameter', async () => { - vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: { order: {} } }) - vi.mocked(http.post).mockResolvedValue({ code: '0', message: 'success', data: null }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { order: {} } }) + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: null }) await getPlatformFirstLegOrder('10001', '90001') await approvePlatformFirstLegOrder('10001', '90001') @@ -60,7 +60,7 @@ describe('platform first-leg order API', () => { }) it('writes the backend express contract as names, not a guessed master-data ID', async () => { - vi.mocked(http.put).mockResolvedValue({ code: '0', message: 'success', data: null }) + vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null }) await updatePlatformFirstLegOrderExpress('10001', '90001', { expressCompany: '顺丰速运', expressTrackingNo: 'SF123456' }) expect(http.put).toHaveBeenCalledWith( diff --git a/src/api/platformIam.test.ts b/src/api/platformIam.test.ts index cf3e4207..07ec63ce 100644 --- a/src/api/platformIam.test.ts +++ b/src/api/platformIam.test.ts @@ -1,19 +1,29 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/api/http' import { + assignPlatformInternalUserRoles, + changePlatformInternalUserStatus, + changePlatformPermissionStatus, changePlatformRoleStatus, createPlatformPermission, createPlatformRole, + deletePlatformPermission, deletePlatformRole, downloadTenantQualification, + listPlatformTenants, listTenantQualifications, listCurrentPlatformPermissionCodes, listPlatformPermissions, listPlatformRoles, + listPlatformInternalUsers, qualificationFileName, replacePlatformRolePermissions, + resetPlatformInternalUserPassword, + updatePlatformInternalUser, + updatePlatformPermission, updatePlatformRole, type PlatformPermissionResource, + type PlatformInternalUser, type PlatformRoleResource, } from './platformIam' @@ -46,6 +56,21 @@ const permission: PlatformPermissionResource = { httpMethod: null, sortNo: 10, status: 'ACTIVE', + builtIn: false, +} + +const internalUser: PlatformInternalUser = { + membershipId: 3000001, + userId: 2000001, + authUserId: 2000001, + username: 'platform_admin', + displayName: '平台管理员', + phone: '13800138000', + email: 'admin@example.com', + memberType: 'INTERNAL', + memberNo: 'PLATFORM-0001', + status: 'ACTIVE', + roleIds: [4000001], } describe('platform IAM roles', () => { @@ -57,7 +82,7 @@ describe('platform IAM roles', () => { }) it('loads roles from the platform IAM prefix', async () => { - vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: [role] }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [role] }) await expect(listPlatformRoles()).resolves.toEqual([role]) expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/roles') @@ -69,15 +94,15 @@ describe('platform IAM roles', () => { roleName: '企业审核员', description: '企业资料与入驻审核', } - vi.mocked(http.post).mockResolvedValue({ code: '0', message: 'success', data: role }) + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: role }) await expect(createPlatformRole(payload)).resolves.toEqual(role) expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/roles', payload) }) it('updates status, permissions, and deletes roles with the implemented IAM routes', async () => { - vi.mocked(http.put).mockResolvedValue({ code: '0', message: 'success', data: role }) - vi.mocked(http.delete).mockResolvedValue({ code: '0', message: 'success', data: null }) + vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: role }) + vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null }) await expect(updatePlatformRole(role.id, { roleName: '审核主管', description: '负责企业审核' })).resolves.toEqual(role) await expect(changePlatformRoleStatus(role.id, 'DISABLED')).resolves.toEqual(role) @@ -91,8 +116,8 @@ describe('platform IAM roles', () => { }) it('loads and creates permission resources under the platform IAM prefix', async () => { - vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: [permission] }) - vi.mocked(http.post).mockResolvedValue({ code: '0', message: 'success', data: permission }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [permission] }) + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: permission }) const payload = { type: 'MENU' as const, code: permission.permissionCode, @@ -109,8 +134,46 @@ describe('platform IAM roles', () => { expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/permissions', payload) }) + it('updates, changes status, and deletes custom permission resources with IAM routes', async () => { + vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: permission }) + vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null }) + const payload = { name: '企业客户', parentId: permission.parentId, routePath: '/customers', httpMethod: null, sortNo: 10 } + + await expect(updatePlatformPermission(permission.id, payload)).resolves.toEqual(permission) + await expect(changePlatformPermissionStatus(permission.id, 'DISABLED')).resolves.toEqual(permission) + await expect(deletePlatformPermission(permission.id)).resolves.toBeNull() + + expect(http.put).toHaveBeenNthCalledWith(1, `/platform/v1/iam/permissions/${permission.id}`, payload) + expect(http.put).toHaveBeenNthCalledWith(2, `/platform/v1/iam/permissions/${permission.id}/status`, { status: 'DISABLED' }) + expect(http.delete).toHaveBeenCalledWith(`/platform/v1/iam/permissions/${permission.id}`) + }) + + it('maintains internal users through their public membership ID', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [internalUser] }) + vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: internalUser }) + const update = { + displayName: '平台管理员', + phone: '13800138000', + email: 'admin@example.com', + memberNo: 'PLATFORM-0001', + roleIds: [4000001], + } + + await expect(listPlatformInternalUsers()).resolves.toEqual([internalUser]) + await expect(updatePlatformInternalUser(internalUser.membershipId, update)).resolves.toEqual(internalUser) + await expect(changePlatformInternalUserStatus(internalUser.membershipId, 'DISABLED')).resolves.toEqual(internalUser) + await expect(resetPlatformInternalUserPassword(internalUser.membershipId, 'ChangeMe123')).resolves.toEqual(internalUser) + await expect(assignPlatformInternalUserRoles(internalUser.membershipId, [4000001])).resolves.toEqual(internalUser) + + expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/internal-users') + expect(http.put).toHaveBeenNthCalledWith(1, `/platform/v1/iam/internal-users/${internalUser.membershipId}`, update) + expect(http.put).toHaveBeenNthCalledWith(2, `/platform/v1/iam/internal-users/${internalUser.membershipId}/status`, { status: 'DISABLED' }) + expect(http.put).toHaveBeenNthCalledWith(3, `/platform/v1/iam/internal-users/${internalUser.membershipId}/password`, { newPassword: 'ChangeMe123' }) + expect(http.put).toHaveBeenNthCalledWith(4, `/platform/v1/iam/internal-users/${internalUser.membershipId}/roles`, { roleIds: [4000001] }) + }) + it('loads the current users permission snapshot without calling the role-management endpoint', async () => { - vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: ['platform:warehouse:list'] }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: ['platform:warehouse:list'] }) await expect(listCurrentPlatformPermissionCodes()).resolves.toEqual(['platform:warehouse:list']) expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/authorization/permissions') @@ -126,12 +189,22 @@ describe('platform IAM roles', () => { status: 'PENDING' as const, reviewComment: null, } - vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: [qualification] }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [qualification] }) await expect(listTenantQualifications(8)).resolves.toEqual([qualification]) expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/tenants/8/qualifications') }) + it('loads paginated tenants with the documented filters', async () => { + const page = { records: [], page: 2, size: 10, total: 11, pages: 2 } + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: page }) + + await expect(listPlatformTenants({ page: 2, size: 10, keyword: '柏悦', status: 'ACTIVE' })).resolves.toEqual(page) + expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/tenants', { + params: { page: 2, size: 10, keyword: '柏悦', status: 'ACTIVE' }, + }) + }) + it('downloads a qualification with the platform file endpoint and a safe filename', async () => { const blob = new Blob(['image'], { type: 'image/png' }) vi.mocked(http.get).mockResolvedValue(blob) diff --git a/src/api/platformIam.ts b/src/api/platformIam.ts index 7ccd5870..79f54fa4 100644 --- a/src/api/platformIam.ts +++ b/src/api/platformIam.ts @@ -11,7 +11,8 @@ export interface PlatformPermissionResource { routePath: string | null httpMethod: string | null sortNo: number - status: 'ACTIVE' | 'INACTIVE' + status: 'ACTIVE' | 'DISABLED' | 'INACTIVE' + builtIn?: boolean } export interface EnterpriseReview { @@ -33,6 +34,35 @@ export interface EnterpriseReview { [key: string]: unknown } +export type PlatformTenantStatus = 'PENDING' | 'ACTIVE' | 'REJECTED' | 'INACTIVE' | string + +export interface PlatformTenantResource { + id: number | string + tenantCode: string + tenantName: string + status: PlatformTenantStatus + legalName: string | null + unifiedSocialCreditCode: string | null + contactName: string | null + contactPhone: string | null + contactEmail: string | null +} + +export interface PlatformTenantPage { + records: PlatformTenantResource[] + page: number + size: number + total: number + pages: number +} + +export interface PlatformTenantQuery { + page: number + size: number + keyword?: string + status?: PlatformTenantStatus +} + export interface PlatformTenantQualification { id: number | string tenantId: number | string @@ -76,12 +106,22 @@ export interface CreatePlatformPermissionRequest { sortNo: number } +export interface UpdatePlatformPermissionRequest { + name: string + parentId: number | string | null + routePath: string | null + httpMethod: string | null + sortNo: number +} + export interface PlatformInternalUser { membershipId: number | string userId: number | string authUserId: number | string username: string displayName: string + phone: string | null + email: string | null memberType: string memberNo: string | null status: 'ACTIVE' | 'DISABLED' | string @@ -94,6 +134,16 @@ export interface CreatePlatformInternalUserRequest { displayName: string phone: string email: string + memberNo?: string + roleIds?: Array +} + +export interface UpdatePlatformInternalUserRequest { + displayName: string + phone: string + email: string + memberNo: string + roleIds: Array } async function getData(path: string): Promise { @@ -128,6 +178,21 @@ export function listEnterpriseReviews() { return getData('/platform/v1/iam/reviews') } +export async function listPlatformTenants(query: PlatformTenantQuery) { + const response = await http.get, ApiEnvelope>( + '/platform/v1/iam/tenants', + { + params: { + page: query.page, + size: query.size, + ...(query.keyword ? { keyword: query.keyword } : {}), + ...(query.status ? { status: query.status } : {}), + }, + }, + ) + return unwrapApiResponse(response) +} + export function listTenantQualifications(tenantId: number | string) { return getData(`/platform/v1/iam/tenants/${tenantId}/qualifications`) } @@ -183,6 +248,18 @@ export function createPlatformPermission(payload: CreatePlatformPermissionReques return postData('/platform/v1/iam/permissions', payload) } +export function updatePlatformPermission(permissionId: number | string, payload: UpdatePlatformPermissionRequest) { + return putData(`/platform/v1/iam/permissions/${permissionId}`, payload) +} + +export function changePlatformPermissionStatus(permissionId: number | string, status: 'ACTIVE' | 'DISABLED') { + return putData(`/platform/v1/iam/permissions/${permissionId}/status`, { status }) +} + +export function deletePlatformPermission(permissionId: number | string) { + return deleteData(`/platform/v1/iam/permissions/${permissionId}`) +} + export function listPlatformInternalUsers() { return getData('/platform/v1/iam/internal-users') } @@ -191,6 +268,22 @@ export function createPlatformInternalUser(payload: CreatePlatformInternalUserRe return postData('/platform/v1/iam/internal-users', payload) } +export function updatePlatformInternalUser(membershipId: number | string, payload: UpdatePlatformInternalUserRequest) { + return putData(`/platform/v1/iam/internal-users/${membershipId}`, payload) +} + +export function changePlatformInternalUserStatus(membershipId: number | string, status: 'ACTIVE' | 'DISABLED') { + return putData(`/platform/v1/iam/internal-users/${membershipId}/status`, { status }) +} + +export function resetPlatformInternalUserPassword(membershipId: number | string, newPassword: string) { + return putData(`/platform/v1/iam/internal-users/${membershipId}/password`, { newPassword }) +} + +export function assignPlatformInternalUserRoles(membershipId: number | string, roleIds: Array) { + return putData(`/platform/v1/iam/internal-users/${membershipId}/roles`, { roleIds }) +} + export function approveEnterpriseReview(reviewId: number, comment: string) { return postData(`/platform/v1/iam/reviews/${reviewId}/approve`, { comment }) } diff --git a/src/api/platformPartners.test.ts b/src/api/platformPartners.test.ts new file mode 100644 index 00000000..70b57950 --- /dev/null +++ b/src/api/platformPartners.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/api/http' +import { + changePlatformPartnerStatus, + createPlatformPartner, + listPlatformPartners, + updatePlatformPartner, + type SavePlatformPartnerRequest, +} from './platformPartners' + +vi.mock('@/api/http', () => ({ + http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() }, + unwrapApiResponse: (response: { data: T }) => response.data, +})) + +const payload: SavePlatformPartnerRequest = { + partnerCode: 'CUS-001', + partnerName: '测试往来单位', + partnerType: 'CUSTOMER', + contactName: '测试联系人', + contactPhone: '13800000000', + contactEmail: null, + countryCode: 'CN', + province: '广东省', + city: '深圳市', + district: null, + addressLine: '测试地址 1 号', + postalCode: null, + status: 'ACTIVE', +} + +describe('platform partner API', () => { + beforeEach(() => vi.clearAllMocks()) + + it('queries the platform partner page with tenant and filters', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: '1', size: '20', total: '1', pages: '1' } }) + + const result = await listPlatformPartners(10001, { page: 1, size: 20, keyword: '测试', type: 'CUSTOMER', status: 'ACTIVE' }) + + expect(http.get).toHaveBeenCalledWith('/platform/v1/partners', { + params: { tenantId: 10001, page: 1, size: 20, keyword: '测试', type: 'CUSTOMER', status: 'ACTIVE' }, + }) + expect(result).toMatchObject({ page: 1, size: 20, total: 1, pages: 1 }) + }) + + it('uses the backend create, update and status contracts', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: '19001' }) + vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null }) + vi.mocked(http.patch).mockResolvedValue({ code: '200', message: 'success', data: null }) + + await createPlatformPartner(10001, payload) + await updatePlatformPartner(10001, '19001', payload) + await changePlatformPartnerStatus(10001, '19001', 'INACTIVE') + + expect(http.post).toHaveBeenCalledWith('/platform/v1/partners', payload, { params: { tenantId: 10001 } }) + expect(http.put).toHaveBeenCalledWith('/platform/v1/partners/19001', payload, { params: { tenantId: 10001 } }) + expect(http.patch).toHaveBeenCalledWith('/platform/v1/partners/19001/status', { status: 'INACTIVE' }, { params: { tenantId: 10001 } }) + }) +}) diff --git a/src/api/platformPartners.ts b/src/api/platformPartners.ts new file mode 100644 index 00000000..732ebe31 --- /dev/null +++ b/src/api/platformPartners.ts @@ -0,0 +1,108 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/api/http' + +export type PlatformPartnerType = 'CUSTOMER' | 'SUPPLIER' | 'BOTH' +export type PlatformPartnerStatus = 'ACTIVE' | 'INACTIVE' + +export interface PlatformPartnerResource { + id: number | string + tenantId: number | string + partnerCode: string + partnerName: string + partnerType: PlatformPartnerType + contactName: string + contactPhone: string + contactEmail: string | null + countryCode: string + province: string | null + city: string + district: string | null + addressLine: string + postalCode: string | null + status: PlatformPartnerStatus + createdAt?: string | null + updatedAt?: string | null +} + +export interface PlatformPartnerPage { + records: PlatformPartnerResource[] + page: number + size: number + total: number + pages: number +} + +type PlatformPartnerPagePayload = Omit & { + page: number | string + size: number | string + total: number | string + pages: number | string +} + +export interface PlatformPartnerQuery { + page: number + size: number + keyword?: string + type?: PlatformPartnerType + status?: PlatformPartnerStatus +} + +export interface SavePlatformPartnerRequest { + partnerCode: string + partnerName: string + partnerType: PlatformPartnerType + contactName: string + contactPhone: string + contactEmail: string | null + countryCode: string + province: string | null + city: string + district: string | null + addressLine: string + postalCode: string | null + status: PlatformPartnerStatus +} + +const basePath = '/platform/v1/partners' + +export async function listPlatformPartners(tenantId: number, query: PlatformPartnerQuery) { + const response = await http.get, ApiEnvelope>(basePath, { + params: { tenantId, ...query }, + }) + const page = unwrapApiResponse(response) + return { + ...page, + page: Number(page.page), + size: Number(page.size), + total: Number(page.total), + pages: Number(page.pages), + } +} + +export async function createPlatformPartner(tenantId: number, payload: SavePlatformPartnerRequest) { + const response = await http.post, ApiEnvelope>(basePath, payload, { + params: { tenantId }, + }) + return unwrapApiResponse(response) +} + +export async function updatePlatformPartner( + tenantId: number, + partnerId: number | string, + payload: SavePlatformPartnerRequest, +) { + const response = await http.put, ApiEnvelope>(`${basePath}/${partnerId}`, payload, { + params: { tenantId }, + }) + return unwrapApiResponse(response) +} + +export async function changePlatformPartnerStatus( + tenantId: number, + partnerId: number | string, + status: PlatformPartnerStatus, +) { + const response = await http.patch, ApiEnvelope>(`${basePath}/${partnerId}/status`, { status }, { + params: { tenantId }, + }) + return unwrapApiResponse(response) +} diff --git a/src/api/platformWarehouses.test.ts b/src/api/platformWarehouses.test.ts index f6fe8221..7e61424a 100644 --- a/src/api/platformWarehouses.test.ts +++ b/src/api/platformWarehouses.test.ts @@ -59,8 +59,8 @@ describe('platform warehouse API', () => { const page = { records: [], page: 1, size: 200, total: 0, pages: 0 } const detail = { warehouse: {}, addresses: [], capabilities: [] } vi.mocked(http.get) - .mockResolvedValueOnce({ code: '0', message: 'success', data: page }) - .mockResolvedValueOnce({ code: '0', message: 'success', data: detail }) + .mockResolvedValueOnce({ code: '200', message: 'success', data: page }) + .mockResolvedValueOnce({ code: '200', message: 'success', data: detail }) await expect(listPlatformWarehouses(10001)).resolves.toEqual(page) await expect(getPlatformWarehouse(10001, '19001')).resolves.toEqual(detail) @@ -73,9 +73,9 @@ describe('platform warehouse API', () => { }) it('uses the implemented create, update, and status routes', async () => { - vi.mocked(http.post).mockResolvedValue({ code: '0', message: 'success', data: '19001' }) - vi.mocked(http.put).mockResolvedValue({ code: '0', message: 'success', data: null }) - vi.mocked(http.patch).mockResolvedValue({ code: '0', message: 'success', data: null }) + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: '19001' }) + vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null }) + vi.mocked(http.patch).mockResolvedValue({ code: '200', message: 'success', data: null }) await expect(createPlatformWarehouse(payload)).resolves.toBe('19001') await expect(updatePlatformWarehouse(10001, '19001', payload)).resolves.toBeNull() diff --git a/src/components.d.ts b/src/components.d.ts index 4aa94402..b52ca657 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -14,6 +14,10 @@ declare module 'vue' { AdminSidebarMenu: typeof import('./components/AdminSidebarMenu.vue')['default'] AdminSidebarMenuNode: typeof import('./components/AdminSidebarMenuNode.vue')['default'] ApiErrorMeta: typeof import('./components/ApiErrorMeta.vue')['default'] + AppDialog: typeof import('./components/common/AppDialog.vue')['default'] + AppDrawer: typeof import('./components/common/AppDrawer.vue')['default'] + CopyButton: typeof import('./components/common/CopyButton.vue')['default'] + DataTable: typeof import('./components/common/DataTable.vue')['default'] ElAlert: typeof import('element-plus/es')['ElAlert'] ElButton: typeof import('element-plus/es')['ElButton'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] @@ -43,8 +47,10 @@ declare module 'vue' { ElTimeline: typeof import('element-plus/es')['ElTimeline'] ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] ElTree: typeof import('element-plus/es')['ElTree'] + LoadErrorState: typeof import('./components/common/LoadErrorState.vue')['default'] MetricStrip: typeof import('./components/MetricStrip.vue')['default'] PlatformFirstLegOrdersPanel: typeof import('./components/PlatformFirstLegOrdersPanel.vue')['default'] + QueryToolbar: typeof import('./components/common/QueryToolbar.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] StatusBadge: typeof import('./components/StatusBadge.vue')['default'] diff --git a/src/components/ApiErrorMeta.vue b/src/components/ApiErrorMeta.vue index 296fc001..5305f40d 100644 --- a/src/components/ApiErrorMeta.vue +++ b/src/components/ApiErrorMeta.vue @@ -1,38 +1,29 @@ diff --git a/src/components/PlatformFirstLegOrdersPanel.vue b/src/components/PlatformFirstLegOrdersPanel.vue index 8e19d9c6..5e362bce 100644 --- a/src/components/PlatformFirstLegOrdersPanel.vue +++ b/src/components/PlatformFirstLegOrdersPanel.vue @@ -1,7 +1,8 @@ diff --git a/src/components/common/AppDialog.test.ts b/src/components/common/AppDialog.test.ts new file mode 100644 index 00000000..e3e15cae --- /dev/null +++ b/src/components/common/AppDialog.test.ts @@ -0,0 +1,65 @@ +import { defineComponent } from 'vue' +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import AppDialog from './AppDialog.vue' + +const ElDialogStub = defineComponent({ + props: { + modelValue: Boolean, + showClose: Boolean, + closeOnClickModal: Boolean, + closeOnPressEscape: Boolean, + }, + template: '
', +}) + +const ElButtonStub = defineComponent({ + props: { + disabled: Boolean, + loading: Boolean, + }, + emits: ['click'], + template: '', +}) + +function mountDialog(props: Record = {}) { + return mount(AppDialog, { + props: { modelValue: true, title: '编辑资料', ...props }, + slots: { default: '

弹框内容

' }, + global: { + stubs: { ElDialog: ElDialogStub, ElButton: ElButtonStub }, + directives: { loading: {} }, + }, + }) +} + +describe('AppDialog', () => { + it('provides default cancel and confirm actions', async () => { + const wrapper = mountDialog() + 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 = mountDialog({ confirmLoading: true }) + const dialog = wrapper.getComponent(ElDialogStub) + const cancelButton = wrapper.findAll('button')[0] + + expect(dialog.props('showClose')).toBe(false) + expect(dialog.props('closeOnClickModal')).toBe(false) + expect(dialog.props('closeOnPressEscape')).toBe(false) + expect(cancelButton.attributes('disabled')).toBeDefined() + }) + + it('can render a content-only dialog without footer actions', () => { + const wrapper = mountDialog({ showFooter: false }) + expect(wrapper.findAll('button')).toHaveLength(0) + }) +}) diff --git a/src/components/common/AppDialog.vue b/src/components/common/AppDialog.vue new file mode 100644 index 00000000..2eb557c5 --- /dev/null +++ b/src/components/common/AppDialog.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/src/components/common/AppDrawer.vue b/src/components/common/AppDrawer.vue new file mode 100644 index 00000000..bf9cfbf6 --- /dev/null +++ b/src/components/common/AppDrawer.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/src/components/common/CopyButton.vue b/src/components/common/CopyButton.vue new file mode 100644 index 00000000..58bcb48f --- /dev/null +++ b/src/components/common/CopyButton.vue @@ -0,0 +1,62 @@ + + + diff --git a/src/components/common/DataTable.test.ts b/src/components/common/DataTable.test.ts new file mode 100644 index 00000000..126ced65 --- /dev/null +++ b/src/components/common/DataTable.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { createPaginationItems, normalizePage, normalizePageSizes } from './DataTable.vue' + +describe('DataTable pagination helpers', () => { + it('normalizes page-size options', () => { + expect(normalizePageSizes([20, 10, 20, 0, -1, 0.8, 10.8])).toEqual([20, 10]) + expect(normalizePageSizes([])).toEqual([10]) + }) + + it('keeps the page inside the available range', () => { + expect(normalizePage(0, 8)).toBe(1) + expect(normalizePage(12, 8)).toBe(8) + expect(normalizePage(3.8, 8)).toBe(3) + expect(normalizePage(Number.NaN, 8)).toBe(1) + expect(normalizePage(4, 0)).toBe(1) + expect(normalizePage(4, Number.NaN)).toBe(1) + }) + + it('does not create page buttons for an empty result', () => { + expect(createPaginationItems(1, 0)).toEqual([]) + }) + + it('shows all pages for a short result set', () => { + expect(createPaginationItems(3, 5)).toEqual([1, 2, 3, 4, 5]) + }) + + it('keeps first and last pages visible for long result sets', () => { + expect(createPaginationItems(1, 30)).toEqual([1, 2, 3, 4, 5, 'ellipsis-end', 30]) + expect(createPaginationItems(15, 30)).toEqual([1, 'ellipsis-start', 14, 15, 16, 'ellipsis-end', 30]) + expect(createPaginationItems(30, 30)).toEqual([1, 'ellipsis-start', 26, 27, 28, 29, 30]) + }) +}) diff --git a/src/components/common/DataTable.vue b/src/components/common/DataTable.vue new file mode 100644 index 00000000..6a8bf94e --- /dev/null +++ b/src/components/common/DataTable.vue @@ -0,0 +1,180 @@ + + + + + + + diff --git a/src/components/common/LoadErrorState.vue b/src/components/common/LoadErrorState.vue new file mode 100644 index 00000000..3e2ef8a6 --- /dev/null +++ b/src/components/common/LoadErrorState.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/src/components/common/QueryToolbar.vue b/src/components/common/QueryToolbar.vue new file mode 100644 index 00000000..ef3c17d2 --- /dev/null +++ b/src/components/common/QueryToolbar.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/src/env.d.ts b/src/env.d.ts index f5c051ec..d6141d96 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -8,8 +8,8 @@ declare module 'vue-router' { interface RouteMeta { title?: string description?: string + showPageHeading?: boolean requiresAuth?: boolean permission?: string } } - diff --git a/src/layouts/AppLayout.test.ts b/src/layouts/AppLayout.test.ts index 6f77c264..24c944c7 100644 --- a/src/layouts/AppLayout.test.ts +++ b/src/layouts/AppLayout.test.ts @@ -11,7 +11,7 @@ vi.mock('element-plus', async (importOriginal) => { return { ...actual, ElMessage: { warning: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn() } } }) -async function mountLayout() { +async function mountLayout(path = '/') { const pinia = createPinia() setActivePinia(pinia) const auth = useAuthStore() @@ -20,9 +20,13 @@ async function mountLayout() { auth.permissions = ['platform:warehouse:list'] const router = createRouter({ history: createMemoryHistory(), - routes: [{ path: '/', component: { template: '

page

' } }, { path: '/login', component: { template: '

login

' } }], + routes: [ + { path: '/', component: { template: '

page

' } }, + { path: '/customers', component: { template: '

customers

' }, meta: { title: '客户管理', showPageHeading: false } }, + { path: '/login', component: { template: '

login

' } }, + ], }) - await router.push('/') + await router.push(path) await router.isReady() const wrapper = mount(AppLayout, { global: { plugins: [pinia, router], stubs: { AdminSidebarMenu: { template: '