fix(projects): 执行和任务的按钮控制
This commit is contained in:
2
src/typings/api/project.d.ts
vendored
2
src/typings/api/project.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: '删除',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
179
tests/task-permission-rules.test.ts
Normal file
179
tests/task-permission-rules.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user