fix(projects): 执行和任务的按钮控制

This commit is contained in:
2026-07-08 11:12:22 +08:00
parent a0dcd39c23
commit 62b97a2fd7
6 changed files with 282 additions and 50 deletions

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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: '删除',

View File

@@ -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 {

View File

@@ -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),

View File

@@ -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> = {}): 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> = {}): 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
);
});
});