From 151d76f77cf3c8bea3ffc932f38220ee967ac13b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 31 Jul 2026 13:43:16 +0800 Subject: [PATCH] fix: align client integration contracts --- .env.example | 2 +- README.md | 8 ++-- src/api/merchantIam.test.mjs | 17 ++++++++ src/api/merchantIam.ts | 17 ++++++++ src/api/partners.ts | 4 +- src/data/firstLegPartySource.test.mjs | 4 ++ src/data/firstLegPartySource.ts | 17 +++----- src/stores/auth.ts | 32 ++++---------- src/stores/firstLegOrders.test.mjs | 1 + src/stores/firstLegOrders.ts | 3 ++ src/stores/tenantAdministration.ts | 44 ++++++++++++++------ src/views/RolesView.vue | 23 +++++++--- src/views/TenantAdministrationViews.test.mjs | 3 ++ vite.config.ts | 5 +++ 14 files changed, 120 insertions(+), 60 deletions(-) diff --git a/.env.example b/.env.example index 17b31d0..b43cf89 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,2 @@ VITE_API_BASE_URL=/api -API_PROXY_TARGET=http://127.0.0.1:9000 +API_PROXY_TARGET=http://8.163.30.128:9000 diff --git a/README.md b/README.md index 69304cf..a58b4f1 100644 --- a/README.md +++ b/README.md @@ -25,16 +25,16 @@ pnpm test 依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。 -一期路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限和头程订单。登录、注册、退出、令牌刷新、个人资料、修改密码、企业资料、企业资质、权限资源、角色列表/新增/授权和成员完整维护已经接入一期后端接口;开发代理默认转发到 `http://127.0.0.1:9000`,可通过 `API_PROXY_TARGET` 覆盖。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。 +一期路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限和头程订单。登录、注册、退出、令牌刷新、个人资料、修改密码、企业资料、企业资质、权限资源、角色列表/新增/授权和成员完整维护已经接入一期后端接口;开发代理默认转发到测试环境 `http://8.163.30.128:9000`,可通过 `API_PROXY_TARGET` 覆盖。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。 认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。刷新或会话上下文加载遇到临时网络故障、超时等非认证错误时保留当前有效 Token,只有服务端明确返回 401/会话失效才清理登录状态。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限取 JWT 声明与后端启用权限资源的交集。前端权限仅用于导航和交互控制,服务端仍须按用户、租户和权限编码完成最终授权。 本地联调账号提示和默认用户名只在 Vite 开发模式下启用,URL 显式传入的登录用户名仍优先。生产登录表单默认留空,生产构建产物不得包含本地联调账号或开发提示文案。 -“我的信息”使用真实个人资料和修改密码接口,姓名、电话、邮箱按服务端约束维护;修改密码成功后清理旧会话并返回登录页。成员列表返回姓名、联系方式和 `roleIds`,新增、编辑、角色全量保存、启停和管理员重置密码均使用真实接口,不回退静态成功。角色页同时加载成员关系,直接打开或刷新后成员数仍按真实 `roleIds` 统计。角色列表返回 `permissionIds`,页面可回显并全量保存角色权限;角色编辑、启停、删除虽在回归报告中通过,但路径尚未进入当前 Gateway OpenAPI,前端继续保持禁用,避免出现仅修改本地状态的假成功。 +“我的信息”使用真实个人资料和修改密码接口,姓名、电话、邮箱按服务端约束维护;修改密码成功后清理旧会话并返回登录页。成员列表返回姓名、联系方式和 `roleIds`,新增、编辑、角色全量保存、启停和管理员重置密码均使用真实接口,不回退静态成功。角色页同时加载成员关系,直接打开或刷新后成员数仍按真实 `roleIds` 统计。角色列表返回 `permissionIds`,页面可回显并全量保存角色权限;角色编辑、启停、删除分别调用 `PUT /merchant/v1/iam/roles/{roleId}`、`PUT /merchant/v1/iam/roles/{roleId}/status` 和 `DELETE /merchant/v1/iam/roles/{roleId}`,内置角色及仍有关联成员的角色继续受保护。 企业资料和资质列表使用真实租户接口,覆盖 `PROFILE_REQUIRED`、`PENDING`、`ACTIVE`、`REJECTED`、`DISABLED` 五种企业状态。企业名称、法定名称和联系人信息支持真实保存;营业执照上传使用文件服务,仅接受 JPG、PNG 且不超过 5MB,文件预览通过当前商户 Token 临时下载并在关闭弹窗后释放。 -头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。收发货单位通过 `/merchant/v1/partners/candidates` 按类型读取启用候选并保存订单快照,不再使用演示单位兜底;快递信息仍由平台维护、客户只读。订单取消不属于本轮本地一期验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。 +头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。收发货单位通过 `/merchant/v1/partners/candidates` 一次读取当前租户启用候选;后端主数据类型 `CUSTOMER / SUPPLIER / BOTH` 不等同于订单角色,同一候选可用于发货方或收货方并分别保存快照。订单写请求以发货方 ID 填充后端强制校验的主往来单位 `customerId`。快递信息仍由平台维护、客户只读。订单取消不属于本轮本地一期验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。 -联调远端 Gateway 时可在启动命令前设置 `API_PROXY_TARGET=http://192.168.110.149:9000`;进程环境变量优先于 `.env` 文件。浏览器仍统一请求同源 `/api`,源码中不写死业务服务地址。2026-07-30 Gateway 恢复后已完成业务总览、企业资料、成员/角色权限、头程订单列表与详情、仓库详情和快递空态的真实浏览器验收;当前后端样本仍存在部分中文字段为 `????`、旧订单引用失效仓库 `2001`、新建订单往来单位候选为空及缺少快递有值样本的问题。客户端已对失效仓库和历史往来单位快照提供可恢复处理,写操作仍待准备干净样本后验收。 +联调远端 Gateway 时可在启动命令前设置 `API_PROXY_TARGET=http://8.163.30.128:9000`;进程环境变量优先于 `.env` 文件。浏览器仍统一请求同源 `/api`,源码中不写死业务服务地址。联调时可只读检查同工作区的 `baiyue-firstleg/` Controller、DTO、Service、Repository 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。2026-07-31 测试 Gateway 登录和真实角色列表读取正常;Warehouse 服务已注册,但仓库和往来单位业务查询仍返回 `S0500`,头程草稿创建和相关浏览器流程需待后端修复后继续验收。 diff --git a/src/api/merchantIam.test.mjs b/src/api/merchantIam.test.mjs index d2c01c2..071c428 100644 --- a/src/api/merchantIam.test.mjs +++ b/src/api/merchantIam.test.mjs @@ -4,9 +4,13 @@ import test from 'node:test' const source = readFileSync(new URL('./merchantIam.ts', import.meta.url), 'utf8') const storeSource = readFileSync(new URL('../stores/tenantAdministration.ts', import.meta.url), 'utf8') +const authSource = readFileSync(new URL('../stores/auth.ts', import.meta.url), 'utf8') test('declares the documented merchant role and member endpoints', () => { assert.match(source, /url: '\/merchant\/v1\/iam\/roles'/) + assert.match(source, /method: 'PUT', url: `\/merchant\/v1\/iam\/roles\/\$\{roleId\}`/) + assert.match(source, /method: 'PUT', url: `\/merchant\/v1\/iam\/roles\/\$\{roleId\}\/status`/) + assert.match(source, /method: 'DELETE', url: `\/merchant\/v1\/iam\/roles\/\$\{roleId\}`/) assert.match(source, /url: '\/merchant\/v1\/iam\/roles\/permissions'/) assert.match(source, /url: '\/merchant\/v1\/iam\/members'/) assert.match(source, /url: '\/merchant\/v1\/iam\/members\/roles'/) @@ -33,8 +37,21 @@ test('clears mock records before loading server role and member lists', () => { assert.match(storeSource, /rolesLoadedFromApi\.value = true\s+roles\.value = \[\]/) assert.match(storeSource, /membersLoadedFromApi\.value = true\s+members\.value = \[\]/) assert.match(storeSource, /await createMerchantRole/) + assert.match(storeSource, /await updateMerchantRole/) + assert.match(storeSource, /await changeMerchantRoleStatus/) + assert.match(storeSource, /await deleteMerchantRole/) assert.match(storeSource, /await createMerchantMember/) assert.match(storeSource, /await updateMerchantMember/) assert.match(storeSource, /await changeMerchantMemberStatus/) assert.match(storeSource, /await resetMerchantMemberPassword/) }) + +test('shares the merchant permission map and preserves resources not rendered by the role editor', () => { + assert.match(storeSource, /export const FRONTEND_TO_MERCHANT_PERMISSION_CODES/) + assert.match(storeSource, /'order:first-leg:cancel': \['merchant:order:cancel'\]/) + assert.match(authSource, /FRONTEND_TO_MERCHANT_PERMISSION_CODES/) + assert.match(authSource, /Object\.entries\(FRONTEND_TO_MERCHANT_PERMISSION_CODES\)/) + assert.match(storeSource, /const preservedPermissionIds = role\.permissionIds\.filter/) + assert.match(storeSource, /const nextPermissionIds = \[\.\.\.new Set\(\[\.\.\.preservedPermissionIds, \.\.\.selectedPermissionIds\]\)\]/) + assert.match(storeSource, /await replaceMerchantRolePermissions\(id, nextPermissionIds\)/) +}) diff --git a/src/api/merchantIam.ts b/src/api/merchantIam.ts index 3748630..4f32100 100644 --- a/src/api/merchantIam.ts +++ b/src/api/merchantIam.ts @@ -72,6 +72,11 @@ export interface CreateMerchantRoleRequest { description: string } +export interface UpdateMerchantRoleRequest { + roleName: string + description: string +} + export interface MerchantMember { membershipId: number | string userId: number | string @@ -159,6 +164,18 @@ export function createMerchantRole(payload: CreateMerchantRoleRequest) { return apiRequest({ method: 'POST', url: '/merchant/v1/iam/roles', data: payload }) } +export function updateMerchantRole(roleId: number | string, payload: UpdateMerchantRoleRequest) { + return apiRequest({ method: 'PUT', url: `/merchant/v1/iam/roles/${roleId}`, data: payload }) +} + +export function changeMerchantRoleStatus(roleId: number | string, status: 'ACTIVE' | 'DISABLED') { + return apiRequest({ method: 'PUT', url: `/merchant/v1/iam/roles/${roleId}/status`, data: { status } }) +} + +export function deleteMerchantRole(roleId: number | string) { + return apiRequest({ method: 'DELETE', url: `/merchant/v1/iam/roles/${roleId}` }) +} + export function replaceMerchantRolePermissions(roleId: number | string, permissionIds: Array) { return apiRequest({ method: 'PUT', url: '/merchant/v1/iam/roles/permissions', data: { roleId, permissionIds } }) } diff --git a/src/api/partners.ts b/src/api/partners.ts index 877a85d..ee57c4a 100644 --- a/src/api/partners.ts +++ b/src/api/partners.ts @@ -1,6 +1,6 @@ import { apiRequest } from '@/api/http' -export type MerchantPartnerType = 'SENDER' | 'RECEIVER' +export type MerchantPartnerType = 'CUSTOMER' | 'SUPPLIER' | 'BOTH' export interface MerchantPartner { id: number | string @@ -19,7 +19,7 @@ export interface MerchantPartner { status: 'ACTIVE' | 'INACTIVE' | 'DISABLED' | string } -export function getMerchantPartnerCandidates(type: MerchantPartnerType, keyword?: string) { +export function getMerchantPartnerCandidates(type?: MerchantPartnerType, keyword?: string) { return apiRequest({ method: 'GET', url: '/merchant/v1/partners/candidates', diff --git a/src/data/firstLegPartySource.test.mjs b/src/data/firstLegPartySource.test.mjs index e22aca0..5152978 100644 --- a/src/data/firstLegPartySource.test.mjs +++ b/src/data/firstLegPartySource.test.mjs @@ -13,11 +13,15 @@ test('keeps counterparty availability behind a replaceable read-only source', () assert.match(source, /async load\(\)/) assert.match(source, /party\.status === 'ACTIVE' && party\.roles\.includes\(role\)/) assert.match(source, /getMerchantPartnerCandidates/) + assert.match(source, /roles: \['SHIPPER', 'CONSIGNEE'\]/) + assert.match(source, /await getMerchantPartnerCandidates\(\)/) + assert.doesNotMatch(source, /getMerchantPartnerCandidates\('SENDER'\)|getMerchantPartnerCandidates\('RECEIVER'\)/) assert.match(viewSource, /firstLegPartySource\.listAvailable\('SHIPPER'\)/) assert.match(viewSource, /firstLegPartySource\.listAvailable\('CONSIGNEE'\)/) assert.match(viewSource, /firstLegPartySource\.findAvailable\(id, role\)/) assert.match(viewSource, /firstLegPartySource\.load\(\)/) assert.doesNotMatch(viewSource, /DEMO_FIRST_LEG_PARTIES/) assert.match(apiSource, /url: '\/merchant\/v1\/partners\/candidates'/) + assert.match(apiSource, /'CUSTOMER' \| 'SUPPLIER' \| 'BOTH'/) assert.match(apiSource, /url: `\/merchant\/v1\/partners\/\$\{id\}`/) }) diff --git a/src/data/firstLegPartySource.ts b/src/data/firstLegPartySource.ts index d3787a3..6496e2f 100644 --- a/src/data/firstLegPartySource.ts +++ b/src/data/firstLegPartySource.ts @@ -1,4 +1,4 @@ -import { getMerchantPartnerCandidates, type MerchantPartner, type MerchantPartnerType } from '@/api/partners' +import { getMerchantPartnerCandidates, type MerchantPartner } from '@/api/partners' import type { FirstLegParty, FirstLegPartyRole } from '@/data/firstLegParties' export interface FirstLegPartySource { @@ -13,12 +13,14 @@ function isAvailableForRole(party: FirstLegParty, role: FirstLegPartyRole) { const parties: FirstLegParty[] = [] -function mapPartner(partner: MerchantPartner, role: FirstLegPartyRole): FirstLegParty { +function mapPartner(partner: MerchantPartner): FirstLegParty { return { id: String(partner.id), code: partner.partnerCode, name: partner.partnerName, - roles: [role], + // 后端往来单位类型为 CUSTOMER / SUPPLIER / BOTH,不表达订单中的发货/收货角色; + // 两类订单角色均可选择启用主数据,提交时分别保存独立快照。 + roles: ['SHIPPER', 'CONSIGNEE'], contactName: partner.contactName ?? '', phone: partner.contactPhone ?? '', email: partner.contactEmail ?? '', @@ -31,10 +33,6 @@ function mapPartner(partner: MerchantPartner, role: FirstLegPartyRole): FirstLeg } } -async function loadCandidates(type: MerchantPartnerType, role: FirstLegPartyRole) { - return (await getMerchantPartnerCandidates(type)).map((partner) => mapPartner(partner, role)) -} - export const firstLegPartySource: FirstLegPartySource = { listAvailable(role) { return parties.filter((party) => isAvailableForRole(party, role)) @@ -43,9 +41,6 @@ export const firstLegPartySource: FirstLegPartySource = { return parties.find((party) => party.id === id && isAvailableForRole(party, role)) }, async load() { - parties.splice(0, parties.length, ...await Promise.all([ - loadCandidates('SENDER', 'SHIPPER'), - loadCandidates('RECEIVER', 'CONSIGNEE'), - ]).then((groups) => groups.flat())) + parties.splice(0, parties.length, ...(await getMerchantPartnerCandidates()).map(mapPartner)) }, } diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 107ac65..3098da6 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -5,32 +5,18 @@ import { loginMerchant, logoutMerchant } from '@/api/auth' import { ApiError } from '@/api/http' import { getMerchantPermissionResources, getMerchantPersonalProfile, getTenantProfile, updateMerchantPersonalProfile, type MerchantPermissionResource } from '@/api/merchantIam' import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair } from '@/api/session' -import { TENANT_PERMISSION_GROUPS } from '@/stores/tenantAdministration' +import { FRONTEND_TO_MERCHANT_PERMISSION_CODES, TENANT_PERMISSION_GROUPS } from '@/stores/tenantAdministration' import { decodeMerchantToken, type MerchantJwtPayload } from '@/utils/jwt' const frontendPermissionCodes = new Set(TENANT_PERMISSION_GROUPS.flatMap((group) => group.items.map((item) => item.code))) -const backendPermissionMapping: Readonly> = { - 'merchant:profile:view': ['company:profile:view'], - 'merchant:profile:edit': ['company:profile:edit'], - 'merchant:qualification:upload': ['company:qualification:upload'], - 'merchant:member:list': ['company:member:list'], - 'merchant:member:create': ['company:member:write'], - 'merchant:member:edit': ['company:member:write'], - 'merchant:member:grant-role': ['company:member:write'], - 'merchant:member:status': ['company:member:write'], - 'merchant:member:reset-password': ['company:member:reset-password'], - 'merchant:role:list': ['company:role:list', 'company:permission:list'], - 'merchant:role:create': ['company:role:write'], - 'merchant:role:edit': ['company:role:write'], - 'merchant:role:status': ['company:role:write'], - 'merchant:role:delete': ['company:role:write'], - 'merchant:role:grant': ['company:permission:list', 'company:permission:assign'], - 'merchant:order:list': ['order:first-leg:list'], - 'merchant:order:view': ['order:first-leg:list'], - 'merchant:order:create': ['order:first-leg:create'], - 'merchant:order:edit': ['order:first-leg:edit'], - 'merchant:order:submit': ['order:first-leg:submit'], -} +const backendPermissionMapping = Object.entries(FRONTEND_TO_MERCHANT_PERMISSION_CODES) + .reduce>((mapping, [frontendCode, backendCodes]) => { + for (const backendCode of backendCodes) { + if (!mapping[backendCode]) mapping[backendCode] = [] + mapping[backendCode].push(frontendCode) + } + return mapping + }, {}) export interface ClientAccountProfile { username: string diff --git a/src/stores/firstLegOrders.test.mjs b/src/stores/firstLegOrders.test.mjs index 307d0cd..4a680ca 100644 --- a/src/stores/firstLegOrders.test.mjs +++ b/src/stores/firstLegOrders.test.mjs @@ -33,6 +33,7 @@ test('sends backend-required channel, customs, destination and cargo fields', () assert.match(storeSource, new RegExp(field)) } assert.match(storeSource, /containerMode: draft\.transportMode === 'FCL' \? 'FULL_CONTAINER'/) + assert.match(storeSource, /customerId: draft\.shipperPartyId/) }) test('does not expose the out-of-scope cancellation write', () => { diff --git a/src/stores/firstLegOrders.ts b/src/stores/firstLegOrders.ts index 64f9d24..11b5a3a 100644 --- a/src/stores/firstLegOrders.ts +++ b/src/stores/firstLegOrders.ts @@ -63,6 +63,9 @@ function partyPayload(snapshot: FirstLegPartySnapshot): FirstLegOrderPartyPayloa function savePayload(draft: FirstLegOrderDraft, warehouse: PlatformReceivingWarehouse, customerName: string): FirstLegOrderSaveRequest { if (!draft.shipperPartySnapshot || !draft.consigneePartySnapshot) throw new Error('请完整填写收发货信息') return { + // 后端把 customerId 作为订单主往来单位校验并以服务端名称覆盖 customerName; + // 当前商户下单以所选发货单位作为主往来单位,收发货详情仍分别保存订单快照。 + customerId: draft.shipperPartyId, customerName, transportMode: apiTransport(draft), tradeTerm: draft.tradeTerm || undefined, diff --git a/src/stores/tenantAdministration.ts b/src/stores/tenantAdministration.ts index b259377..753577b 100644 --- a/src/stores/tenantAdministration.ts +++ b/src/stores/tenantAdministration.ts @@ -2,8 +2,10 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' import { changeMerchantMemberStatus, + changeMerchantRoleStatus, createMerchantMember, createMerchantRole, + deleteMerchantRole, getMerchantMembers, getMerchantPermissionResources, getMerchantRoles, @@ -12,6 +14,7 @@ import { resetMerchantMemberPassword, replaceMerchantRolePermissions, updateMerchantMember, + updateMerchantRole, updateTenantProfile, type MerchantRole, type MerchantMember, @@ -67,6 +70,7 @@ export interface TenantRole { description: string status: TenantRecordStatus builtin: boolean + permissionIds: string[] permissionCodes: string[] } @@ -136,7 +140,7 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [ const allPermissionCodes = TENANT_PERMISSION_GROUPS.flatMap((group) => group.items.map((item) => item.code)) -const frontendToBackendPermissionCodes: Readonly> = { +export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly> = { 'order:first-leg:list': ['merchant:order:list', 'merchant:order:view'], 'order:first-leg:create': ['merchant:order:create'], 'order:first-leg:edit': ['merchant:order:edit'], @@ -154,10 +158,12 @@ const frontendToBackendPermissionCodes: Readonly grantedIds.includes(String(resource.id))) .map((resource) => resource.permissionCode)) - const permissionCodes = Object.entries(frontendToBackendPermissionCodes) + const permissionCodes = Object.entries(FRONTEND_TO_MERCHANT_PERMISSION_CODES) .filter(([, backendCodes]) => backendCodes.some((code) => grantedBackendCodes.has(code))) .map(([frontendCode]) => frontendCode) return { @@ -238,6 +244,7 @@ export const useTenantAdministrationStore = defineStore('tenant-administration', description: role.description ?? '暂无角色说明', status: role.status === 'ACTIVE' ? 'ACTIVE' : 'INACTIVE', builtin: role.roleType === 'SYSTEM', + permissionIds: grantedIds, permissionCodes: role.roleType === 'SYSTEM' && role.roleCode === 'MERCHANT_OWNER' ? [...allPermissionCodes] : permissionCodes, } } @@ -352,24 +359,28 @@ export const useTenantAdministrationStore = defineStore('tenant-administration', roles.value.push(mapMerchantRole(created)) } - function updateRole(id: string, payload: Pick) { + async function updateRole(id: string, payload: Pick) { const role = roles.value.find((item) => item.id === id) if (!role) throw new Error('角色不存在') - Object.assign(role, payload) + if (role.builtin) throw new Error('内置角色不能编辑') + const updated = await updateMerchantRole(id, { roleName: payload.name.trim(), description: payload.description.trim() }) + Object.assign(role, mapMerchantRole(updated)) } - function toggleRoleStatus(id: string) { + async function toggleRoleStatus(id: string) { const role = roles.value.find((item) => item.id === id) if (!role) throw new Error('角色不存在') if (role.builtin) throw new Error('内置角色不能停用') - role.status = role.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE' + const updated = await changeMerchantRoleStatus(id, role.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE') + Object.assign(role, mapMerchantRole(updated)) } - function deleteRole(id: string) { + async function deleteRole(id: string) { const role = roles.value.find((item) => item.id === id) if (!role) throw new Error('角色不存在') if (role.builtin) throw new Error('内置角色不能删除') if (members.value.some((member) => member.roleIds.includes(id))) throw new Error('该角色仍有成员使用,不能删除') + await deleteMerchantRole(id) roles.value = roles.value.filter((item) => item.id !== id) } @@ -377,11 +388,18 @@ export const useTenantAdministrationStore = defineStore('tenant-administration', const role = roles.value.find((item) => item.id === id) if (!role) throw new Error('角色不存在') if (role.builtin) throw new Error('内置管理员权限不能修改') - const backendCodes = new Set(permissionCodes.flatMap((code) => frontendToBackendPermissionCodes[code] ?? [])) - const permissionIds = permissionResources.value + const backendCodes = new Set(permissionCodes.flatMap((code) => FRONTEND_TO_MERCHANT_PERMISSION_CODES[code] ?? [])) + const selectedPermissionIds = permissionResources.value .filter((resource) => backendCodes.has(resource.permissionCode)) .map((resource) => resource.id) - await replaceMerchantRolePermissions(id, permissionIds) + const mappedResourceIds = new Set(permissionResources.value + .filter((resource) => mappedMerchantPermissionCodes.has(resource.permissionCode)) + .map((resource) => String(resource.id))) + // The endpoint replaces every ID, including MENU nodes this screen does not render. + const preservedPermissionIds = role.permissionIds.filter((permissionId) => !mappedResourceIds.has(permissionId)) + const nextPermissionIds = [...new Set([...preservedPermissionIds, ...selectedPermissionIds])] + await replaceMerchantRolePermissions(id, nextPermissionIds) + role.permissionIds = nextPermissionIds.map(String) role.permissionCodes = [...new Set(permissionCodes)] } diff --git a/src/views/RolesView.vue b/src/views/RolesView.vue index 31234cf..61f296c 100644 --- a/src/views/RolesView.vue +++ b/src/views/RolesView.vue @@ -13,6 +13,8 @@ const keyword = ref('') const editorOpen = ref(false) const editingId = ref('') const loading = ref(false) +const saving = ref(false) +const actionRoleId = ref('') const form = reactive({ name: '', code: '', description: '' }) const filteredRoles = computed(() => { @@ -54,13 +56,16 @@ async function saveRole() { ElMessage.warning('请完整填写角色名称、编码和说明') return } + saving.value = true try { - if (editingId.value) store.updateRole(editingId.value, { name: form.name, description: form.description }) + if (editingId.value) await store.updateRole(editingId.value, { name: form.name, description: form.description }) else await store.createRole({ ...form, code: form.code.trim().toUpperCase() }) editorOpen.value = false ElMessage.success(editingId.value ? '角色已更新' : '角色已新增') } catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存失败') + } finally { + saving.value = false } } @@ -68,22 +73,28 @@ async function toggleStatus(role: TenantRole) { const action = role.status === 'ACTIVE' ? '停用' : '启用' try { await ElMessageBox.confirm(`${action}后将影响拥有该角色的成员权限。`, `${action}角色 ${role.name}`, { type: 'warning', confirmButtonText: action, cancelButtonText: '取消' }) - store.toggleRoleStatus(role.id) + actionRoleId.value = role.id + await store.toggleRoleStatus(role.id) ElMessage.success(`角色已${action}`) } catch (error) { if (error === 'cancel' || error === 'close') return ElMessage.error(error instanceof Error ? error.message : '操作失败') + } finally { + actionRoleId.value = '' } } async function removeRole(role: TenantRole) { try { await ElMessageBox.confirm('删除后不能恢复,仅未被成员使用的自定义角色可以删除。', `删除角色 ${role.name}`, { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }) - store.deleteRole(role.id) + actionRoleId.value = role.id + await store.deleteRole(role.id) ElMessage.success('角色已删除') } catch (error) { if (error === 'cancel' || error === 'close') return ElMessage.error(error instanceof Error ? error.message : '删除失败') + } finally { + actionRoleId.value = '' } } @@ -95,7 +106,7 @@ function assignPermissions(role: TenantRole) { diff --git a/src/views/TenantAdministrationViews.test.mjs b/src/views/TenantAdministrationViews.test.mjs index 060845d..eac2959 100644 --- a/src/views/TenantAdministrationViews.test.mjs +++ b/src/views/TenantAdministrationViews.test.mjs @@ -53,6 +53,9 @@ test('protects built-in roles and exposes role permission assignment', () => { assert.match(rolesSource, /auth\.hasPermission\('company:member:list'\).*store\.loadMembers\(\)/) assert.match(rolesSource, /await Promise\.all\(requests\)/) assert.match(rolesSource, /await store\.createRole/) + assert.match(rolesSource, /await store\.updateRole/) + assert.match(rolesSource, /await store\.toggleRoleStatus/) + assert.match(rolesSource, /await store\.deleteRole/) }) test('shows company audit status and applies button permissions', () => { diff --git a/vite.config.ts b/vite.config.ts index b48e511..59bf850 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -31,6 +31,11 @@ export default defineConfig(({ mode }) => { '/api': { target: proxyTarget, changeOrigin: true, + configure(proxy) { + proxy.on('proxyReq', (proxyRequest) => { + proxyRequest.removeHeader('origin') + }) + }, }, }, },