From 6d56bc0075c890e1fd85c73be600687a6419251f Mon Sep 17 00:00:00 2001 From: xy Date: Thu, 3 Sep 2026 14:15:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E6=9D=83=E9=99=90=E6=89=B9=E9=87=8F=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clinical-web/README.md | 6 +- clinical-web/docs/document-permissions-api.md | 76 ++++++--- .../api/management/document-permissions.ts | 144 +++++++++++++++- clinical-web/src/api/management/documents.ts | 44 +++-- clinical-web/src/api/management/types.ts | 80 ++++++++- .../components/TemplatePermissionPanel.vue | 6 +- .../management/document-permissions/index.vue | 156 ++++++++++++++---- 7 files changed, 437 insertions(+), 75 deletions(-) diff --git a/clinical-web/README.md b/clinical-web/README.md index 3ce312c..f57fe84 100644 --- a/clinical-web/README.md +++ b/clinical-web/README.md @@ -161,11 +161,15 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench` - `POST /api/v1/users/{id}/password/reset`、`POST /api/v1/users/import/preview`、`POST /api/v1/users/import/commit`、`GET /api/v1/users/import/jobs/{jobId}`:管理员重置密码和用户导入流程; - `GET /api/v1/roles/summary`、`GET/PUT /api/v1/roles/{roleId}/permissions`:角色人数统计和角色 API 权限分配; - `GET/POST/PUT/DELETE /api/v1/permissions`:API 权限分页查询、详情、新增、修改和停用; -- `GET/POST/DELETE /api/v1/templates/{id}/permissions`:按模板查询和维护文档权限; +- `GET /api/v1/templates/permission-config`:按权限配置范围分页查询模板; +- `GET /api/v1/templates/permissions/matrix`、`POST /api/v1/templates/permissions/batch`:跨模板权限矩阵查询和批量新增/撤销; +- `GET/POST/DELETE /api/v1/templates/{id}/permissions`:单模板权限兼容接口,其中前端使用 GET 补充用户继承预览; - `GET /api/v1/audit-logs`:全局审计日志查询 API。 用户与权限页面的接口核验结果、当前缺口和后端接口建议见 [`docs/users-permissions-api.md`](./docs/users-permissions-api.md)。 +文档权限页面的新接口契约、继承规则、批量保存行为和联调验收标准见 [`docs/document-permissions-api.md`](./docs/document-permissions-api.md)。 + 报表页真实模式已接入签署报表的 options、overview、tasks 和异步 export-jobs 接口,支持权限范围内的动态筛选、服务端分页、趋势/科室聚合和完整结果导出;Mock 模式仍可用于演示。接口矩阵、时间口径和联调验收见 [reports-api.md](./docs/reports-api.md),后端联调要求见 [reports-backend-requirements.md](./docs/reports-backend-requirements.md)。首页真实模式已接入 workbench overview。 首页真实模式已接入 GET /api/v1/workbench/overview;接口口径、字段映射和验收标准见 [docs/workbench-home-api.md](./docs/workbench-home-api.md)。 diff --git a/clinical-web/docs/document-permissions-api.md b/clinical-web/docs/document-permissions-api.md index 55fc40f..0e436e0 100644 --- a/clinical-web/docs/document-permissions-api.md +++ b/clinical-web/docs/document-permissions-api.md @@ -1,6 +1,6 @@ # 文档权限页面接口接入说明 -依据本机后端 `http://127.0.0.1:18080/v3/api-docs`,文档权限页面当前按“文档模板”维度接入权限。页面保留原型的三部分,并补充单用户直接授权: +核验时间:2026-09-03。依据后端提交 `b2f9a48` 部署后的 `http://127.0.0.1:18080/v3/api-docs`,文档权限页面已按“文档模板”维度接入新权限配置目录、跨模板矩阵和批量保存接口。页面保留原型的三部分,并补充单用户直接授权: 1. **文档库权限矩阵**:说明可见、可用、可维护和继承规则。 2. **按角色授权**:选择一个文档模板,调整角色的 VIEW、USE、MAINTAIN。 @@ -9,17 +9,19 @@ ## 已接入接口 -| 用途 | 方法 | 路径 | -| ---------------------- | ------ | --------------------------------------------------------- | -| 查询可配置模板 | GET | /api/v1/templates | -| 查询角色及人数 | GET | /api/v1/roles/summary | -| 查询科室 | GET | /api/v1/departments | -| 查询用户 | GET | /api/v1/users | -| 查询模板权限及继承结果 | GET | /api/v1/templates/{templateId}/permissions | -| 绑定模板权限 | POST | /api/v1/templates/{templateId}/permissions | -| 撤销模板权限 | DELETE | /api/v1/templates/{templateId}/permissions/{permissionId} | +| 用途 | 方法 | 路径 | +| ------------------ | ------ | --------------------------------------------------------- | +| 查询权限配置模板 | GET | /api/v1/templates/permission-config | +| 查询角色及人数 | GET | /api/v1/roles/summary | +| 查询科室 | GET | /api/v1/departments | +| 查询用户 | GET | /api/v1/users | +| 查询跨模板权限矩阵 | GET | /api/v1/templates/permissions/matrix | +| 批量新增或撤销权限 | POST | /api/v1/templates/permissions/batch | +| 查询用户继承结果 | GET | /api/v1/templates/{templateId}/permissions | +| 旧版单模板新增权限 | POST | /api/v1/templates/{templateId}/permissions | +| 旧版单模板撤销权限 | DELETE | /api/v1/templates/{templateId}/permissions/{permissionId} | -权限绑定请求使用 OpenAPI 中的 PermissionBindingRequest: +单条权限绑定请求使用 OpenAPI 中的 PermissionBindingRequest: ```json { @@ -30,32 +32,60 @@ } ``` +批量保存请求使用 `TemplatePermissionBatchRequest`: + +```json +{ + "operations": [ + { + "templateId": "", + "action": "ADD", + "subjectType": "ROLE", + "subjectId": "", + "permissionLevel": "USE", + "effect": "ALLOW" + }, + { + "templateId": "", + "action": "REVOKE", + "permissionId": "" + } + ] +} +``` + +成功响应的 `data.results` 与 `operations` 下标对应,ADD 的 `result` 为 `CREATED` 或 `EXISTING`,REVOKE 的 `result` 为 `REVOKED`。 + 权限等级映射: - VIEW:可浏览模板 - USE:可发起签署 - MAINTAIN:可维护模板 -模板、科室和用户接口都是分页接口,页面按后端返回的 `total` 自动补齐后续页,不会因为接口单页上限 200 而漏掉授权对象。角色优先使用 `/roles/summary` 获取人数;该接口失败时回退 `/roles`,人数按已加载用户的角色关系估算。 +权限配置目录、权限矩阵、科室和用户接口都是分页接口,页面按后端返回的 `total` 自动补齐后续页,不会因为接口单页上限 200 而漏掉配置模板或授权对象。权限配置目录遵守操作者数据范围;管理员可以查询范围内全部模板,但不能绕过院区、科室和数据范围。角色优先使用 `/roles/summary` 获取人数;该接口失败时回退 `/roles`,人数按已加载用户的角色关系估算。 -查询模板权限时不传 `userId` 表示查看当前登录用户的继承结果;页面也支持选择用户,将其 UUID 作为 `userId` 重新查询,用于预览该用户的科室签署员默认 `USE` 继承。接口返回的直接绑定和继承结果统一展示,但只有直接绑定带有可删除的 `id`。 +权限矩阵按当前选择的模板调用 `templateId` 查询,并自动读取所有矩阵分页。矩阵支持 `templateId/templateIds`(最多 100 个)、`roleId`、`subjectDepartmentId`、`userId`、`subjectType`、`campusId` 和 `templateDepartmentId` 筛选;数组参数按重复 query 参数发送。页面使用旧的单模板 GET 接口补充指定用户的有效继承预览,因为矩阵接口中的 `userId` 是授权主体筛选条件,不是继承预览参数。接口返回的直接绑定和继承结果统一展示;继承项的 `permissionId`、`subjectId`、`createdAt` 和 `createdBy` 可为空,页面将其标记为系统默认继承规则且不可删除。 ## 页面保存行为 -当前契约没有更新权限绑定的 PUT 接口,因此开关按以下规则保存: +页面开关和明细表单统一使用 `/templates/permissions/batch` 保存,后端保证整批预校验、事务回滚和幂等: -- 开启:删除同主体、同等级的直接 DENY;没有有效允许权限时 POST ALLOW。 -- 关闭:撤销直接 ALLOW;如果仍有继承的 ALLOW,POST 直接 DENY 覆盖继承。 +- 开启:批量 REVOKE 同主体、同等级的直接 DENY;没有有效允许权限时 ADD ALLOW。 +- 关闭:批量 REVOKE 直接 ALLOW;如果仍有继承的 ALLOW,ADD 直接 DENY 覆盖继承。 +- 明细新增发送单项 ADD,明细删除发送单项 REVOKE;REVOKE 只携带直接权限绑定 ID。 +- ADD 返回 `CREATED` 或 `EXISTING` 均视为成功;继承权限不参与撤销。 - 继承 DENY 没有持久化绑定 ID,且契约约定拒绝优先,页面将其显示为不可直接覆盖。 -页面不会把继承结果当成可删除的直接绑定,也不会伪造全局权限矩阵数据。权限查询失败时会显示错误态并禁用新增、删除和矩阵调整,避免把空响应误当成“无权限”。真实环境接口没有数据时会显示空态。 +页面不会把继承结果当成可删除的直接绑定,也不会伪造权限数据。权限矩阵查询失败时会显示错误态并禁用新增、删除和矩阵调整,避免把空响应误当成“无权限”。真实环境接口没有数据时会显示空态。 -## 当前 OpenAPI 未提供的能力 +## 当前核验结论 -如果产品要求“跨全部文档模板”的全局角色/科室权限矩阵,后端还需要提供以下能力之一: +本轮新增接口已覆盖当前文档权限页面的后端缺口,暂无阻断前端接入的缺失接口。跨模板矩阵查询最多支持 100 个模板,批量保存最多支持 200 项;页面当前采用“模板选择 + 当前模板矩阵”的交互,没有臆造跨模板对比表。 -- 全局角色权限查询与批量保存接口; -- 全局科室权限查询与批量保存接口; -- 或提供带模板维度的批量权限查询/保存接口,避免前端逐个模板请求。 +联调验收至少包括: -另外,当前 GET /api/v1/templates 的语义是查询当前用户有权限查看的模板。若权限管理员需要配置自己当前不可见的模板,请后端提供管理员可见的模板列表语义或专用接口。用户直接授权已使用 `subjectType=USER`,不需要新增专用用户权限接口。 +- 权限管理员能看到数据范围内、但普通模板 ACL 不可见的配置模板;越过院区、科室或数据范围时返回 403。 +- Network 中模板目录使用 `/api/v1/templates/permission-config`,矩阵使用 `/api/v1/templates/permissions/matrix` 并正确发送 `page/size/templateId`。 +- 页面能跨矩阵分页读取直接权限和继承权限;继承空字段显示为默认规则且不提供删除。 +- 开关操作使用一次批量请求;同时撤销旧绑定并新增覆盖规则时整批成功或整批回滚。 +- 重复新增返回 `EXISTING`,撤销继承权限被拒绝,审计日志记录批量操作。 diff --git a/clinical-web/src/api/management/document-permissions.ts b/clinical-web/src/api/management/document-permissions.ts index d79b1e0..cc55b73 100644 --- a/clinical-web/src/api/management/document-permissions.ts +++ b/clinical-web/src/api/management/document-permissions.ts @@ -1,13 +1,22 @@ import { unwrapApiResponse, unwrapNullableApiResponse } from '@/utils/api-response' import { request } from '@/utils/request' -import type { ApiResponse } from '@/types/common' +import type { ApiResponse, PageResult } from '@/types/common' import type { BackendCollection, + BackendPage, CreateTemplatePermissionRequest, PermissionListResponse, PermissionQuery, PermissionRecord, + TemplatePermissionBatchRequest, + TemplatePermissionBatchResponse, + TemplatePermissionBatchResponseDto, + TemplatePermissionBatchResult, + TemplatePermissionBatchResultDto, + TemplatePermissionMatrixItemDto, + TemplatePermissionMatrixQuery, + TemplatePermissionMatrixResponse, TemplatePermissionRecord, TemplatePermissionResponseDto, } from './types' @@ -85,17 +94,96 @@ function formatDateTime(value: string | null | undefined) { } function mapTemplatePermission(dto: TemplatePermissionResponseDto): TemplatePermissionRecord { + const subjectId = dto.subjectId ?? null + return { - id: dto.id, + id: dto.id ?? undefined, templateId: dto.templateId, subjectType: dto.subjectType, - subjectId: dto.subjectId, - subjectName: dto.subjectId, + subjectId, + subjectName: subjectId ?? '系统默认继承规则', permissionLevel: dto.permissionLevel, effect: dto.effect, inherited: dto.inherited, createdAt: formatDateTime(dto.createdAt), - createdBy: dto.createdBy, + createdBy: dto.createdBy ?? undefined, + } +} + +function mapMatrixPermission(dto: TemplatePermissionMatrixItemDto): TemplatePermissionRecord { + const subjectId = dto.subjectId ?? null + + return { + id: dto.permissionId ?? undefined, + templateId: dto.templateId, + subjectType: dto.subjectType, + subjectId, + subjectName: subjectId ?? '系统默认继承规则', + permissionLevel: dto.permissionLevel, + effect: dto.effect, + inherited: dto.inherited, + createdAt: formatDateTime(dto.createdAt), + createdBy: dto.createdBy ?? undefined, + } +} + +function normalizePage( + data: BackendPage | T[], + fallbackPage: number, + fallbackPageSize: number, +): PageResult { + const records = Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? []) + + return { + records, + total: Array.isArray(data) ? records.length : (data.total ?? records.length), + page: Array.isArray(data) ? fallbackPage : (data.page ?? fallbackPage), + pageSize: Array.isArray(data) ? fallbackPageSize : (data.size ?? fallbackPageSize), + } +} + +function toMatrixQuery(query: TemplatePermissionMatrixQuery) { + if (query.templateIds && query.templateIds.length > 100) { + throw new Error('跨模板权限矩阵最多同时查询 100 个模板') + } + + const params: Record = { + page: Math.max(query.page, 1), + size: Math.min(Math.max(query.pageSize, 1), 200), + } + + const optionalParams: Array<[string, string | undefined]> = [ + ['keyword', query.keyword?.trim() || undefined], + ['templateId', query.templateId], + ['roleId', query.roleId], + ['subjectDepartmentId', query.subjectDepartmentId], + ['userId', query.userId], + ['subjectType', query.subjectType], + ['campusId', query.campusId], + ['templateDepartmentId', query.templateDepartmentId], + ] + + for (const [key, value] of optionalParams) { + if (value) { + params[key] = value + } + } + + if (query.templateIds?.length) { + params.templateIds = query.templateIds + } + + return params +} + +function mapBatchResult(dto: TemplatePermissionBatchResultDto): TemplatePermissionBatchResult { + return { + index: dto.index, + templateId: dto.templateId, + action: dto.action, + result: dto.result, + permissionId: dto.permissionId ?? undefined, + permission: dto.permission ? mapMatrixPermission(dto.permission) : undefined, } } @@ -116,6 +204,52 @@ export async function getTemplatePermissions( return records.map(mapTemplatePermission) } +export async function getTemplatePermissionMatrix( + query: TemplatePermissionMatrixQuery, +): Promise { + const page = Math.max(query.page, 1) + const pageSize = Math.min(Math.max(query.pageSize, 1), 200) + + if (useMockData) { + return { records: [], total: 0, page, pageSize } + } + + const response = await request.get< + ApiResponse> + >('/v1/templates/permissions/matrix', { + params: toMatrixQuery(query), + paramsSerializer: { indexes: null }, + }) + const data = normalizePage(unwrapApiResponse(response), page, pageSize) + + return { + ...data, + records: data.records.map(mapMatrixPermission), + } +} + +export async function batchSaveTemplatePermissions( + payload: TemplatePermissionBatchRequest, +): Promise { + if (useMockData) { + throw new Error('Mock 模式不执行权限批量写操作') + } + + if (payload.operations.length > 200) { + throw new Error('模板权限批量保存最多支持 200 项操作') + } + + const response = await request.post>( + '/v1/templates/permissions/batch', + payload, + ) + const data = unwrapApiResponse(response) + + return { + results: (data.results ?? []).map(mapBatchResult), + } +} + export async function createTemplatePermission( templateId: string, payload: CreateTemplatePermissionRequest, diff --git a/clinical-web/src/api/management/documents.ts b/clinical-web/src/api/management/documents.ts index 255b4b2..784f7cf 100644 --- a/clinical-web/src/api/management/documents.ts +++ b/clinical-web/src/api/management/documents.ts @@ -138,23 +138,35 @@ function toBackendQuery(query: DocumentQuery) { params.status = statusMap[query.status] } + if (query.campusId) { + params.campusId = query.campusId + } + + if (query.departmentId) { + params.departmentId = query.departmentId + } + return params } +function getRemoteDocuments(path: string, query: DocumentQuery): Promise { + return request + .get>>(path, { + params: toBackendQuery(query), + }) + .then((response) => { + const page = normalizePage(unwrapApiResponse(response), query.page, query.pageSize) + + return { + ...page, + records: page.records.map(mapDocument), + } + }) +} + export function getDocuments(query: DocumentQuery): Promise { if (!useMockData) { - return request - .get>>('/v1/templates', { - params: toBackendQuery(query), - }) - .then((response) => { - const page = normalizePage(unwrapApiResponse(response), query.page, query.pageSize) - - return { - ...page, - records: page.records.map(mapDocument), - } - }) + return getRemoteDocuments('/v1/templates', query) } const keyword = query.keyword?.trim().toLowerCase() @@ -176,6 +188,14 @@ export function getDocuments(query: DocumentQuery): Promise { + if (useMockData) { + return getDocuments(query) + } + + return getRemoteDocuments('/v1/templates/permission-config', query) +} + export async function getDocumentDetail(id: string): Promise { if (useMockData) { const record = mockRecords.find((item) => item.id === id) diff --git a/clinical-web/src/api/management/types.ts b/clinical-web/src/api/management/types.ts index 5b365fc..f785ed1 100644 --- a/clinical-web/src/api/management/types.ts +++ b/clinical-web/src/api/management/types.ts @@ -5,6 +5,8 @@ export type DocumentStatus = 'draft' | 'published' | 'archived' | 'review' export interface DocumentQuery extends PageQuery { keyword?: string status?: DocumentStatus | 'all' + campusId?: string + departmentId?: string } export interface DocumentRecord { @@ -625,22 +627,22 @@ export type ApiPermissionLevel = 'VIEW' | 'USE' | 'MAINTAIN' export type ApiPermissionEffect = 'ALLOW' | 'DENY' export interface TemplatePermissionResponseDto { - id?: string + id?: string | null templateId: string subjectType: ApiPermissionSubjectType - subjectId: string + subjectId: string | null permissionLevel: ApiPermissionLevel effect: ApiPermissionEffect inherited: boolean - createdAt: string - createdBy?: string + createdAt?: string | null + createdBy?: string | null } export interface TemplatePermissionRecord { id?: string templateId: string subjectType: ApiPermissionSubjectType - subjectId: string + subjectId: string | null subjectName: string permissionLevel: ApiPermissionLevel effect: ApiPermissionEffect @@ -656,6 +658,74 @@ export interface CreateTemplatePermissionRequest { effect: ApiPermissionEffect } +export interface TemplatePermissionMatrixQuery extends PageQuery { + keyword?: string + templateId?: string + templateIds?: string[] + roleId?: string + subjectDepartmentId?: string + userId?: string + subjectType?: ApiPermissionSubjectType + campusId?: string + templateDepartmentId?: string +} + +export interface TemplatePermissionMatrixItemDto { + templateId: string + permissionId?: string | null + subjectType: ApiPermissionSubjectType + subjectId?: string | null + permissionLevel: ApiPermissionLevel + effect: ApiPermissionEffect + inherited: boolean + createdAt?: string | null + createdBy?: string | null +} + +export type TemplatePermissionBatchAction = 'ADD' | 'REVOKE' + +export interface TemplatePermissionBatchItemRequest { + templateId: string + action: TemplatePermissionBatchAction + permissionId?: string + subjectType?: ApiPermissionSubjectType + subjectId?: string + permissionLevel?: ApiPermissionLevel + effect?: ApiPermissionEffect +} + +export interface TemplatePermissionBatchRequest { + operations: TemplatePermissionBatchItemRequest[] +} + +export interface TemplatePermissionBatchResultDto { + index: number + templateId: string + action: TemplatePermissionBatchAction + result: string + permissionId?: string | null + permission?: TemplatePermissionMatrixItemDto | null +} + +export interface TemplatePermissionBatchResponseDto { + results?: TemplatePermissionBatchResultDto[] | null +} + +export interface TemplatePermissionBatchResult { + index: number + templateId: string + action: TemplatePermissionBatchAction + result: string + permissionId?: string + permission?: TemplatePermissionRecord +} + +export interface TemplatePermissionBatchResponse { + results: TemplatePermissionBatchResult[] +} + +export type TemplatePermissionMatrixResponse = PageResult + export interface AuditLogQuery extends PageQuery { keyword?: string } diff --git a/clinical-web/src/views/management/document-permissions/components/TemplatePermissionPanel.vue b/clinical-web/src/views/management/document-permissions/components/TemplatePermissionPanel.vue index bd4cefe..a215528 100644 --- a/clinical-web/src/views/management/document-permissions/components/TemplatePermissionPanel.vue +++ b/clinical-web/src/views/management/document-permissions/components/TemplatePermissionPanel.vue @@ -84,7 +84,11 @@ function deletePermission(permissionId: string | undefined) { } } -function formatSubject(subjectType: PermissionSubjectOption['type'], subjectId: string) { +function formatSubject(subjectType: PermissionSubjectOption['type'], subjectId: string | null) { + if (!subjectId) { + return '系统默认继承规则' + } + const subject = props.subjects.find((item) => item.type === subjectType && item.id === subjectId) return subject?.label ?? subjectId } diff --git a/clinical-web/src/views/management/document-permissions/index.vue b/clinical-web/src/views/management/document-permissions/index.vue index 25d61ab..ec331c4 100644 --- a/clinical-web/src/views/management/document-permissions/index.vue +++ b/clinical-web/src/views/management/document-permissions/index.vue @@ -3,11 +3,11 @@ import { computed, onMounted, ref } from 'vue' import { ElMessage } from 'element-plus' import { getAllDepartments } from '@/api/management/organization' -import { getDocuments } from '@/api/management/documents' +import { getTemplatePermissionConfig } from '@/api/management/documents' import { - createTemplatePermission, - deleteTemplatePermission, + batchSaveTemplatePermissions, getTemplatePermissions, + getTemplatePermissionMatrix, isPermissionMockEnabled, } from '@/api/management/document-permissions' import { getUsers } from '@/api/management/users' @@ -16,6 +16,7 @@ import type { DepartmentRecord, DocumentRecord, RoleRecord, + TemplatePermissionBatchItemRequest, TemplatePermissionRecord, UserRecord, } from '@/api/management/types' @@ -146,7 +147,7 @@ function syncPermissionRows() { } async function getAllTemplatesForPermission() { - const firstPage = await getDocuments({ page: 1, pageSize: 200, status: 'all' }) + const firstPage = await getTemplatePermissionConfig({ page: 1, pageSize: 200, status: 'all' }) const pageCount = Math.ceil(firstPage.total / firstPage.pageSize) if (pageCount <= 1) { @@ -155,13 +156,69 @@ async function getAllTemplatesForPermission() { const remainingPages = await Promise.all( Array.from({ length: pageCount - 1 }, (_, index) => - getDocuments({ page: index + 2, pageSize: firstPage.pageSize, status: 'all' }), + getTemplatePermissionConfig({ + page: index + 2, + pageSize: firstPage.pageSize, + status: 'all', + }), ), ) return [firstPage, ...remainingPages].flatMap((page) => page.records) } +async function getAllTemplatePermissionMatrix(templateId: string) { + const firstPage = await getTemplatePermissionMatrix({ + page: 1, + pageSize: 200, + templateId, + }) + const pageCount = Math.ceil(firstPage.total / firstPage.pageSize) + + if (pageCount <= 1) { + return firstPage.records + } + + const remainingPages = await Promise.all( + Array.from({ length: pageCount - 1 }, (_, index) => + getTemplatePermissionMatrix({ + page: index + 2, + pageSize: firstPage.pageSize, + templateId, + }), + ), + ) + + return [firstPage, ...remainingPages].flatMap((page) => page.records) +} + +function mergeTemplatePermissions( + matrixRecords: TemplatePermissionRecord[], + effectiveRecords: TemplatePermissionRecord[], +) { + const seen = new Set() + + return [...matrixRecords, ...effectiveRecords.filter((record) => record.inherited)].filter( + (record) => { + const key = [ + record.templateId, + record.subjectType, + record.subjectId ?? '', + record.permissionLevel, + record.effect, + record.inherited, + ].join('|') + + if (seen.has(key)) { + return false + } + + seen.add(key) + return true + }, + ) +} + async function getRolesForPermission(): Promise<{ records: RoleRecord[] usedFallback: boolean @@ -281,17 +338,28 @@ async function loadTemplatePermissions() { permissionError.value = false try { - const records = await getTemplatePermissions( - templateId, - permissionPreviewUserId.value || undefined, - ) + const [matrixResult, effectiveResult] = await Promise.allSettled([ + getAllTemplatePermissionMatrix(templateId), + getTemplatePermissions(templateId, permissionPreviewUserId.value || undefined), + ]) + + if (matrixResult.status === 'rejected') { + throw matrixResult.reason + } if (requestId !== permissionRequestId || templateId !== selectedTemplateId.value) { return } - templatePermissions.value = records + templatePermissions.value = mergeTemplatePermissions( + matrixResult.value, + effectiveResult.status === 'fulfilled' ? effectiveResult.value : [], + ) syncPermissionRows() + + if (effectiveResult.status === 'rejected') { + ElMessage.warning('用户继承权限预览加载失败,当前仅展示矩阵权限数据') + } } catch { if (requestId !== permissionRequestId) { return @@ -341,6 +409,7 @@ async function applyTemplatePermission( permission: PermissionKey, enabled: boolean, ) { + const operations: TemplatePermissionBatchItemRequest[] = [] const matchingRecords = templatePermissions.value.filter( (record) => record.subjectType === subjectType && @@ -362,34 +431,49 @@ async function applyTemplatePermission( if (!binding.id) { throw new Error('该直接拒绝权限缺少绑定 ID,无法撤销') } - await deleteTemplatePermission(templateId, binding.id) + operations.push({ + templateId, + action: 'REVOKE', + permissionId: binding.id, + }) } if (!directAllows.length && !inheritedAllows) { - await createTemplatePermission(templateId, { + operations.push({ + templateId, + action: 'ADD', subjectType, subjectId, permissionLevel: permission, effect: 'ALLOW', }) } - return - } - - for (const binding of directAllows) { - if (!binding.id) { - throw new Error('该直接允许权限缺少绑定 ID,无法撤销') + } else { + for (const binding of directAllows) { + if (!binding.id) { + throw new Error('该直接允许权限缺少绑定 ID,无法撤销') + } + operations.push({ + templateId, + action: 'REVOKE', + permissionId: binding.id, + }) + } + + if (!directDenies.length && inheritedAllows) { + operations.push({ + templateId, + action: 'ADD', + subjectType, + subjectId, + permissionLevel: permission, + effect: 'DENY', + }) } - await deleteTemplatePermission(templateId, binding.id) } - if (!directDenies.length && inheritedAllows) { - await createTemplatePermission(templateId, { - subjectType, - subjectId, - permissionLevel: permission, - effect: 'DENY', - }) + if (operations.length) { + await batchSaveTemplatePermissions({ operations }) } } @@ -437,7 +521,15 @@ async function addTemplatePermission(form: TemplatePermissionForm) { permissionSaving.value = true try { - await createTemplatePermission(selectedTemplateId.value, form) + await batchSaveTemplatePermissions({ + operations: [ + { + templateId: selectedTemplateId.value, + action: 'ADD', + ...form, + }, + ], + }) await loadTemplatePermissions() ElMessage.success('模板权限已添加') } catch (error) { @@ -455,7 +547,15 @@ async function removeTemplatePermission(permissionId: string) { permissionSaving.value = true try { - await deleteTemplatePermission(selectedTemplateId.value, permissionId) + await batchSaveTemplatePermissions({ + operations: [ + { + templateId: selectedTemplateId.value, + action: 'REVOKE', + permissionId, + }, + ], + }) await loadTemplatePermissions() ElMessage.success('模板权限已删除') } catch (error) {