From 62b97a2fd76e31fe6f8a2e6c949096f0d993cf8e Mon Sep 17 00:00:00 2001 From: hongawen <83944980@qq.com> Date: Wed, 8 Jul 2026 11:12:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(projects):=20=E6=89=A7=E8=A1=8C=E5=92=8C?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=9A=84=E6=8C=89=E9=92=AE=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/typings/api/project.d.ts | 2 +- .../composables/task-permission-rules.ts | 64 +++++++ .../execution/composables/use-task-actions.ts | 23 +-- .../composables/use-task-permissions.ts | 62 +++--- .../execution/modules/task-workspace.vue | 2 +- tests/task-permission-rules.test.ts | 179 ++++++++++++++++++ 6 files changed, 282 insertions(+), 50 deletions(-) create mode 100644 src/views/project/project/execution/composables/task-permission-rules.ts create mode 100644 tests/task-permission-rules.test.ts diff --git a/src/typings/api/project.d.ts b/src/typings/api/project.d.ts index 08f7aad..6e087a7 100644 --- a/src/typings/api/project.d.ts +++ b/src/typings/api/project.d.ts @@ -239,7 +239,7 @@ declare namespace Api { type: string; ownerId: string; ownerNickname?: string | null; - /** 所属执行的负责人 userId(按钮可见度公式用);跨执行查询永远为 null,按钮判定退化为只看权限码 */ + /** 所属执行的负责人 userId(按钮可见度公式用);跨执行查询为空时,前端无法识别执行负责人身份 */ executionOwnerId: string | null; /** 父任务负责人 userId(一级任务为 null) */ parentTaskOwnerId: string | null; diff --git a/src/views/project/project/execution/composables/task-permission-rules.ts b/src/views/project/project/execution/composables/task-permission-rules.ts new file mode 100644 index 0000000..97c4388 --- /dev/null +++ b/src/views/project/project/execution/composables/task-permission-rules.ts @@ -0,0 +1,64 @@ +interface TaskMutationIdentity { + currentUserId: string; + projectManagerUserId?: string | null; +} + +/** + * 判定对象是否处于可编辑/可操作状态。 + * + * 按按钮可见度矩阵(spec §4.2 / §4.3 注释 `allowEdit === true 即 pending / active 状态`): + * 可编辑状态严格 = `pending` OR `active`,**不含 paused / completed / cancelled**。 + * + * 不用 `record.allowEdit === true`:列表 VO 后端不一定下发该字段, + * 经 `normalizeProjectExecution` 的 `Boolean(undefined) === false` 会让列表所有行误判为不可编辑。 + * 直接读 `statusCode` 字段(列表 / 详情 VO 都必下发,状态机核心字段)。 + */ +export function isMutable(record: { statusCode: string }): boolean { + return record.statusCode === 'pending' || record.statusCode === 'active'; +} + +function isTaskMutationActor(task: Api.Project.ProjectTask, identity: TaskMutationIdentity): boolean { + const { currentUserId, projectManagerUserId } = identity; + + if (!currentUserId) return false; + + return ( + currentUserId === task.ownerId || + currentUserId === task.executionOwnerId || + (Boolean(projectManagerUserId) && currentUserId === projectManagerUserId) + ); +} + +function isExecutionMutationActor(execution: Api.Project.ProjectExecution, identity: TaskMutationIdentity): boolean { + const { currentUserId, projectManagerUserId } = identity; + + if (!currentUserId) return false; + + return ( + currentUserId === execution.ownerId || (Boolean(projectManagerUserId) && currentUserId === projectManagerUserId) + ); +} + +export function canEditExecutionByIdentity( + execution: Api.Project.ProjectExecution, + identity: TaskMutationIdentity +): boolean { + return isMutable(execution) && isExecutionMutationActor(execution, identity); +} + +export function canDeleteExecutionByIdentity( + execution: Api.Project.ProjectExecution, + identity: TaskMutationIdentity +): boolean { + if (execution.statusCode === 'completed') return false; + return isExecutionMutationActor(execution, identity); +} + +export function canEditTaskByIdentity(task: Api.Project.ProjectTask, identity: TaskMutationIdentity): boolean { + return isMutable(task) && isTaskMutationActor(task, identity); +} + +export function canDeleteTaskByIdentity(task: Api.Project.ProjectTask, identity: TaskMutationIdentity): boolean { + if (task.statusCode === 'completed') return false; + return isTaskMutationActor(task, identity); +} diff --git a/src/views/project/project/execution/composables/use-task-actions.ts b/src/views/project/project/execution/composables/use-task-actions.ts index cda680a..3b348ff 100644 --- a/src/views/project/project/execution/composables/use-task-actions.ts +++ b/src/views/project/project/execution/composables/use-task-actions.ts @@ -1,5 +1,5 @@ -import { computed, markRaw } from 'vue'; -import { useAuthStore } from '@/store/modules/auth'; +import { markRaw } from 'vue'; +import { useTaskPermissions } from './use-task-permissions'; import IconMdiClipboardEditOutline from '~icons/mdi/clipboard-edit-outline'; import IconMdiDeleteOutline from '~icons/mdi/delete-outline'; import IconMdiPencilOutline from '~icons/mdi/pencil-outline'; @@ -25,19 +25,14 @@ export interface TaskActionEmits { /** * 任务行操作按钮的集中装配。 * - * 这里只收口任务行按钮的最终显示口径,不改动底层子任务 / 父任务负责人 / 执行负责人那套权限体系: - * - 当前用户是任务负责人(task.ownerId): 显示 填报 / 编辑 / 删除 - * - 当前用户是协办人(task.assignees[].userId): 只显示 填报 - * - 都不是: 也显示 填报 + * 这里只收口任务行按钮的最终显示口径;编辑 / 删除身份规则统一落在 useTaskPermissions。 + * 权限码只做后端兜底,不作为前端按钮展示的单独放行条件。 */ export function useTaskActions(emits: TaskActionEmits) { - const authStore = useAuthStore(); - const currentUserId = computed(() => authStore.userInfo.userId || ''); + const { canEditTask, canDeleteTask } = useTaskPermissions(); function createActions(row: Api.Project.ProjectTask): TaskAction[] { const actions: TaskAction[] = []; - const isOwner = Boolean(currentUserId.value) && row.ownerId === currentUserId.value; - const isCompleted = row.statusCode === 'completed'; actions.push({ key: 'report', @@ -47,11 +42,7 @@ export function useTaskActions(emits: TaskActionEmits) { onClick: () => emits.report(row) }); - if (!isOwner) { - return actions; - } - - if (!isCompleted) { + if (canEditTask(row)) { actions.push({ key: 'edit', tooltip: '编辑', @@ -59,7 +50,9 @@ export function useTaskActions(emits: TaskActionEmits) { type: 'primary', onClick: () => emits.edit(row) }); + } + if (canDeleteTask(row)) { actions.push({ key: 'delete', tooltip: '删除', diff --git a/src/views/project/project/execution/composables/use-task-permissions.ts b/src/views/project/project/execution/composables/use-task-permissions.ts index d66a9ed..c383b58 100644 --- a/src/views/project/project/execution/composables/use-task-permissions.ts +++ b/src/views/project/project/execution/composables/use-task-permissions.ts @@ -1,16 +1,22 @@ import { computed } from 'vue'; import { useAuthStore } from '@/store/modules/auth'; import { useObjectContextStore } from '@/store/modules/object-context'; +import { + canDeleteExecutionByIdentity, + canDeleteTaskByIdentity, + canEditExecutionByIdentity, + canEditTaskByIdentity, + isMutable +} from './task-permission-rules'; /** * 任务 / 执行按钮可见度集中判定 * * 关键领域规则: - * - 任务负责人本人不能编辑 / 删除自己负责的任务(增删改归上级 / 项目负责人裁决) + * - 任务编辑 / 删除入口先按身份收口:任务负责人、任务所属执行负责人、项目负责人 + * - 权限码只作为后端最终校验的兜底,不单独决定前端按钮可见度 * - 本人能做的:状态推进(含 cancel "退出"任务)、加协办人、在自己任务下新增子任务 - * - 执行负责人对子任务无编辑 / 删除权(子任务归父任务 owner 管) * - 执行负责人能维护一级任务协办人(一级任务 `executionOwnerId` 通道;子任务不开此通道) - * - 父任务负责人能改 / 删子任务,但不能给子任务加协办人 / 建孙任务 / 推进状态 * * 权限码来源:`project:*` / `project:execution:*` / `project:task:*` 是**对象域权限码**, * 挂在项目对象上下文里(项目负责人 / 项目协作者等角色),从 objectContextStore.buttonCodes 取, @@ -22,37 +28,30 @@ export function useTaskPermissions() { const currentUserId = computed(() => authStore.userInfo.userId || ''); const buttonCodeSet = computed(() => new Set(objectContextStore.buttonCodes)); + const currentProjectManagerUserId = computed(() => { + const summary = objectContextStore.objectSummary as Api.Project.ProjectContext | null; + return summary?.currentProject?.managerUserId || null; + }); function hasPermission(code: string): boolean { return buttonCodeSet.value.has(code); } - /** - * 判定对象是否处于可编辑/可操作状态。 - * - * 按按钮可见度矩阵(spec §4.2 / §4.3 注释 `allowEdit === true 即 pending / active 状态`): - * 可编辑状态严格 = `pending` OR `active`,**不含 paused / completed / cancelled**。 - * - * 不用 `record.allowEdit === true`:列表 VO 后端不一定下发该字段, - * 经 `normalizeProjectExecution` 的 `Boolean(undefined) === false` 会让列表所有行误判为不可编辑。 - * 直接读 `statusCode` 字段(列表 / 详情 VO 都必下发,状态机核心字段)。 - */ - function isMutable(record: { statusCode: string }): boolean { - return record.statusCode === 'pending' || record.statusCode === 'active'; - } - // —— 执行侧 —— function canEditExecution(execution: Api.Project.ProjectExecution): boolean { - return ( - isMutable(execution) && (hasPermission('project:execution:update') || currentUserId.value === execution.ownerId) - ); + return canEditExecutionByIdentity(execution, { + currentUserId: currentUserId.value, + projectManagerUserId: currentProjectManagerUserId.value + }); } function canDeleteExecution(execution: Api.Project.ProjectExecution): boolean { // completed 终态后端硬卡(不允许主动删,级联删除时由后端兜底),前端按钮也拦掉 - if (execution.statusCode === 'completed') return false; - return hasPermission('project:execution:delete'); + return canDeleteExecutionByIdentity(execution, { + currentUserId: currentUserId.value, + projectManagerUserId: currentProjectManagerUserId.value + }); } function canChangeExecutionOwner(execution: Api.Project.ProjectExecution): boolean { @@ -86,20 +85,17 @@ export function useTaskPermissions() { } function canEditTask(task: Api.Project.ProjectTask): boolean { - if (!isMutable(task)) return false; - if (hasPermission('project:task:update')) return true; - return isTopLevelTask(task) - ? currentUserId.value === task.executionOwnerId - : currentUserId.value === task.parentTaskOwnerId; + return canEditTaskByIdentity(task, { + currentUserId: currentUserId.value, + projectManagerUserId: currentProjectManagerUserId.value + }); } function canDeleteTask(task: Api.Project.ProjectTask): boolean { - // completed 终态后端硬卡(不允许主动删,级联删除时由后端兜底),前端按钮也拦掉 - if (task.statusCode === 'completed') return false; - if (hasPermission('project:task:delete')) return true; - return isTopLevelTask(task) - ? currentUserId.value === task.executionOwnerId - : currentUserId.value === task.parentTaskOwnerId; + return canDeleteTaskByIdentity(task, { + currentUserId: currentUserId.value, + projectManagerUserId: currentProjectManagerUserId.value + }); } function canCreateTopLevelTask(execution: Api.Project.ProjectExecution): boolean { diff --git a/src/views/project/project/execution/modules/task-workspace.vue b/src/views/project/project/execution/modules/task-workspace.vue index eb13d61..34f8a69 100644 --- a/src/views/project/project/execution/modules/task-workspace.vue +++ b/src/views/project/project/execution/modules/task-workspace.vue @@ -293,7 +293,7 @@ const { data, loading, getData, getDataByPage, mobilePagination } = useUIPaginat } as unknown as TaskPageResponse); } // 统一走跨执行接口:身份维度(my/all)与范围维度(全部/状态/具体执行)自由组合 - // 单执行场景下 executionOwnerId 等字段会是 null,按钮权限通过全局权限码 RBAC 控制 + // 单执行场景下任务详情会带 executionOwnerId;跨执行接口缺失时前端只能识别任务/项目负责人身份 return fetchGetProjectTaskPageCross(props.projectId, buildCrossSearchParams()); }, transform: response => transformTaskPage(response, pageNo.value, pageSize.value), diff --git a/tests/task-permission-rules.test.ts b/tests/task-permission-rules.test.ts new file mode 100644 index 0000000..9ef716b --- /dev/null +++ b/tests/task-permission-rules.test.ts @@ -0,0 +1,179 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + canDeleteExecutionByIdentity, + canDeleteTaskByIdentity, + canEditExecutionByIdentity, + canEditTaskByIdentity +} from '../src/views/project/project/execution/composables/task-permission-rules'; + +function createExecution(overrides: Partial = {}): Api.Project.ProjectExecution { + return { + id: 'execution-1', + projectId: 'project-1', + projectRequirementId: null, + projectRequirementName: null, + projectRequirementStatusCode: null, + executionName: 'execution', + executionType: null, + ownerId: 'execution-owner', + ownerNickname: null, + statusCode: 'active', + statusName: null, + terminal: false, + allowEdit: true, + availableActions: [], + plannedStartDate: null, + plannedEndDate: null, + actualStartDate: null, + actualEndDate: null, + progressRate: 0, + priority: '2', + priorityName: null, + executionDesc: null, + lastStatusReason: null, + createTime: '2026-07-07 10:00:00', + updateTime: '2026-07-07 10:00:00', + ...overrides + }; +} + +function createTask(overrides: Partial = {}): Api.Project.ProjectTask { + return { + id: 'task-1', + projectId: 'project-1', + executionId: 'execution-1', + executionName: '执行', + executionStatusCode: 'active', + parentTaskId: null, + projectRequirementId: null, + projectRequirementName: null, + projectRequirementStatusCode: null, + taskTitle: '任务', + type: '', + ownerId: 'task-owner', + ownerNickname: null, + executionOwnerId: 'execution-owner', + parentTaskOwnerId: null, + statusCode: 'active', + statusName: null, + terminal: false, + allowEdit: true, + availableActions: [], + progressRate: 0, + plannedStartDate: null, + plannedEndDate: null, + actualStartDate: null, + actualEndDate: null, + priority: '2', + priorityName: null, + taskDesc: null, + lastStatusReason: null, + assignees: null, + attachments: [], + totalSpentHours: null, + createTime: '2026-07-07 10:00:00', + updateTime: '2026-07-07 10:00:00', + ...overrides + }; +} + +describe('task mutation permission rules', () => { + it('allows task owner, execution owner, and project manager to edit mutable tasks', () => { + const task = createTask(); + + assert.equal( + canEditTaskByIdentity(task, { currentUserId: 'task-owner', projectManagerUserId: 'project-manager' }), + true + ); + assert.equal( + canEditTaskByIdentity(task, { currentUserId: 'execution-owner', projectManagerUserId: 'project-manager' }), + true + ); + assert.equal( + canEditTaskByIdentity(task, { currentUserId: 'project-manager', projectManagerUserId: 'project-manager' }), + true + ); + }); + + it('does not allow unrelated users to edit just because they have a task permission code', () => { + const task = createTask(); + + assert.equal( + canEditTaskByIdentity(task, { + currentUserId: 'developer', + projectManagerUserId: 'project-manager' + }), + false + ); + }); + + it('uses the same identity rule for delete and only blocks completed tasks by status', () => { + assert.equal( + canDeleteTaskByIdentity(createTask({ statusCode: 'active' }), { + currentUserId: 'execution-owner', + projectManagerUserId: 'project-manager' + }), + true + ); + assert.equal( + canDeleteTaskByIdentity(createTask({ statusCode: 'completed' }), { + currentUserId: 'execution-owner', + projectManagerUserId: 'project-manager' + }), + false + ); + }); +}); + +describe('execution mutation permission rules', () => { + it('allows only execution owner and project manager to edit mutable executions', () => { + const execution = createExecution(); + + assert.equal( + canEditExecutionByIdentity(execution, { + currentUserId: 'execution-owner', + projectManagerUserId: 'project-manager' + }), + true + ); + assert.equal( + canEditExecutionByIdentity(execution, { + currentUserId: 'project-manager', + projectManagerUserId: 'project-manager' + }), + true + ); + assert.equal( + canEditExecutionByIdentity(execution, { + currentUserId: 'developer', + projectManagerUserId: 'project-manager' + }), + false + ); + }); + + it('uses the same identity rule for delete and only blocks completed executions by status', () => { + assert.equal( + canDeleteExecutionByIdentity(createExecution({ statusCode: 'active' }), { + currentUserId: 'execution-owner', + projectManagerUserId: 'project-manager' + }), + true + ); + assert.equal( + canDeleteExecutionByIdentity(createExecution({ statusCode: 'active' }), { + currentUserId: 'developer', + projectManagerUserId: 'project-manager' + }), + false + ); + assert.equal( + canDeleteExecutionByIdentity(createExecution({ statusCode: 'completed' }), { + currentUserId: 'execution-owner', + projectManagerUserId: 'project-manager' + }), + false + ); + }); +});