From e9214137c1b6495a19af7646a145fcc8a9d36f77 Mon Sep 17 00:00:00 2001 From: hongawen <83944980@qq.com> Date: Sat, 23 May 2026 14:22:58 +0800 Subject: [PATCH] =?UTF-8?q?refactor(project):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=89=A7=E8=A1=8C=E6=A8=A1=E5=9D=97=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E7=BB=93=E6=9E=84=E5=92=8C=E6=95=B0=E6=8D=AE=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 execution-list-panel.vue 组件并将功能整合到执行区域 - 新增 execution-section.vue 组件替代原有的列表面板 - 将 task-workspace.vue 重命名为 task-workspace-comp.vue 并更新引用 - 引入 useTaskViewContext 组合式 API 进行任务视图上下文管理 - 添加跨执行任务状态统计接口调用和数据处理逻辑 - 重构执行状态筛选和任务创建权限判断逻辑 - 更新执行选择、搜索和重置功能的事件处理方式 - 调整页面布局结构,优化左右分栏的内容组织方式 - 完善执行详情获取和状态操作的业务流程 - 优化执行分配和状态变更的异步处理机制 --- src/service/api/project-shared.ts | 10 + src/service/api/project.ts | 74 ++ src/typings/api/project.d.ts | 82 +- .../execution/composables/use-task-actions.ts | 2 +- .../composables/use-task-board-columns.ts | 59 +- .../execution/composables/use-task-drag.ts | 64 ++ .../composables/use-task-view-context.ts | 34 + src/views/project/project/execution/index.vue | 374 ++++++--- src/views/project/project/execution/mock.ts | 53 -- ...n-list-panel.vue => execution-section.vue} | 513 +++++------- .../execution/modules/task-board-view.vue | 440 +++++++++-- .../project/execution/modules/task-search.vue | 105 ++- .../execution/modules/task-table-view.vue | 78 +- .../execution/modules/task-workspace.vue | 737 +++++++++++++----- src/views/project/project/execution/shared.ts | 13 + .../composables/use-workbench-modules.ts | 262 +++++-- src/views/workbench/index.vue | 58 +- .../modules/workbench-activity-panel.vue | 157 ---- .../workbench/modules/workbench-approval.vue | 94 +++ src/views/workbench/modules/workbench-kpi.vue | 212 ----- .../workbench/modules/workbench-mentions.vue | 121 +++ .../modules/workbench-module-library.vue | 26 +- .../modules/workbench-my-completion-rate.vue | 122 +++ .../modules/workbench-my-execution.vue | 146 ++++ .../workbench/modules/workbench-my-task.vue | 128 +-- .../workbench/modules/workbench-my-ticket.vue | 147 ++++ .../modules/workbench-my-week-worklog.vue | 100 +++ .../modules/workbench-notice-notification.vue | 114 +++ .../modules/workbench-personal-item.vue | 73 ++ .../modules/workbench-product-snapshot.vue | 241 ++++++ .../modules/workbench-progress-chart.vue | 78 -- .../modules/workbench-project-health.vue | 50 +- .../modules/workbench-project-snapshot.vue | 275 +++++++ .../modules/workbench-recent-visit.vue | 81 ++ .../modules/workbench-risk-alert.vue | 124 +++ .../workbench/modules/workbench-team-load.vue | 108 +++ .../workbench/modules/workbench-team-todo.vue | 187 ++++- .../modules/workbench-team-worklog.vue | 112 +++ .../modules/workbench-ticket-sla.vue | 86 ++ .../modules/workbench-worklog-reminder.vue | 111 +++ 40 files changed, 4432 insertions(+), 1419 deletions(-) create mode 100644 src/views/project/project/execution/composables/use-task-drag.ts create mode 100644 src/views/project/project/execution/composables/use-task-view-context.ts delete mode 100644 src/views/project/project/execution/mock.ts rename src/views/project/project/execution/modules/{execution-list-panel.vue => execution-section.vue} (51%) delete mode 100644 src/views/workbench/modules/workbench-activity-panel.vue create mode 100644 src/views/workbench/modules/workbench-approval.vue delete mode 100644 src/views/workbench/modules/workbench-kpi.vue create mode 100644 src/views/workbench/modules/workbench-mentions.vue create mode 100644 src/views/workbench/modules/workbench-my-completion-rate.vue create mode 100644 src/views/workbench/modules/workbench-my-execution.vue create mode 100644 src/views/workbench/modules/workbench-my-ticket.vue create mode 100644 src/views/workbench/modules/workbench-my-week-worklog.vue create mode 100644 src/views/workbench/modules/workbench-notice-notification.vue create mode 100644 src/views/workbench/modules/workbench-personal-item.vue create mode 100644 src/views/workbench/modules/workbench-product-snapshot.vue delete mode 100644 src/views/workbench/modules/workbench-progress-chart.vue create mode 100644 src/views/workbench/modules/workbench-project-snapshot.vue create mode 100644 src/views/workbench/modules/workbench-recent-visit.vue create mode 100644 src/views/workbench/modules/workbench-risk-alert.vue create mode 100644 src/views/workbench/modules/workbench-team-load.vue create mode 100644 src/views/workbench/modules/workbench-team-worklog.vue create mode 100644 src/views/workbench/modules/workbench-ticket-sla.vue create mode 100644 src/views/workbench/modules/workbench-worklog-reminder.vue diff --git a/src/service/api/project-shared.ts b/src/service/api/project-shared.ts index 1ad6a54..25cba36 100644 --- a/src/service/api/project-shared.ts +++ b/src/service/api/project-shared.ts @@ -112,6 +112,8 @@ export type ProjectTaskResponse = Omit< | 'executionId' | 'parentTaskId' | 'ownerId' + | 'executionOwnerId' + | 'parentTaskOwnerId' | 'availableActions' | 'plannedStartDate' | 'plannedEndDate' @@ -126,8 +128,12 @@ export type ProjectTaskResponse = Omit< id: StringIdResponse; projectId: StringIdResponse; executionId: StringIdResponse; + executionName?: string | null; + executionStatusCode?: Api.Project.ProjectExecutionStatusCode | null; parentTaskId?: StringIdResponse | null; ownerId: StringIdResponse; + executionOwnerId?: StringIdResponse | null; + parentTaskOwnerId?: StringIdResponse | null; availableActions?: LifecycleActionResponse[] | null; plannedStartDate?: ProjectLocalDateValue; plannedEndDate?: ProjectLocalDateValue; @@ -314,6 +320,8 @@ export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project id: normalizeStringId(response.id), projectId: normalizeStringId(response.projectId), executionId: normalizeStringId(response.executionId), + executionName: response.executionName ?? null, + executionStatusCode: response.executionStatusCode ?? null, parentTaskId: normalizeNullableStringId(response.parentTaskId), projectRequirementId: normalizeNullableStringId(response.projectRequirementId), projectRequirementName: response.projectRequirementName ?? null, @@ -321,6 +329,8 @@ export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project type: response.type ?? '', ownerId: normalizeStringId(response.ownerId), ownerNickname: response.ownerNickname ?? null, + executionOwnerId: normalizeNullableStringId(response.executionOwnerId), + parentTaskOwnerId: normalizeNullableStringId(response.parentTaskOwnerId), statusName: response.statusName ?? null, terminal: Boolean(response.terminal), allowEdit: Boolean(response.allowEdit), diff --git a/src/service/api/project.ts b/src/service/api/project.ts index fc521b7..39ada08 100644 --- a/src/service/api/project.ts +++ b/src/service/api/project.ts @@ -668,6 +668,80 @@ export function fetchChangeProjectTaskStatus( }); } +// ============= 项目级跨执行任务(不带 executionId 路径段) ============= +// 调试文档:所有接口挂在 /project/project/{projectId}/tasks/* 下;通过 involveUserId / ownerId / executionIds 等 +// 入参组合表达"我的任务 / 项目全部 / 指定执行"等视角。原有执行级 {eid}/tasks/page 等保留不动。 + +function getProjectTasksPrefix(projectId: string) { + return `${PROJECT_PREFIX}/${projectId}/tasks`; +} + +/** 项目级跨执行任务分页 */ +export async function fetchGetProjectTaskPageCross( + projectId: string, + params?: Api.Project.ProjectTaskCrossSearchParams +) { + const result = await request({ + ...safeJsonRequestConfig, + url: `${getProjectTasksPrefix(projectId)}/page`, + method: 'get', + params + }); + + return mapServiceResult(result as ServiceRequestResult, data => ({ + ...data, + list: data.list.map(normalizeProjectTask) + })); +} + +/** 项目级跨执行任务状态看板 */ +export function fetchGetProjectTaskStatusBoardCross( + projectId: string, + params?: Api.Project.ProjectTaskCrossStatusBoardParams +) { + return request({ + ...safeJsonRequestConfig, + url: `${getProjectTasksPrefix(projectId)}/status-board`, + method: 'get', + params + }); +} + +/** 项目级跨执行任务看板分页(每列共用同一组 pageNo / pageSize;列内固定 plannedEndDate ASC, id DESC) */ +export async function fetchGetProjectTaskBoardPageCross( + projectId: string, + params?: Api.Project.ProjectTaskCrossBoardPageParams +) { + const result = await request({ + ...safeJsonRequestConfig, + url: `${getProjectTasksPrefix(projectId)}/board-page`, + method: 'get', + params + }); + + return mapServiceResult(result as ServiceRequestResult, data => ({ + items: data.items.map(item => ({ + ...item, + list: item.list.map(normalizeProjectTask) + })) + })); +} + +/** + * 项目级"今日小条"汇总(4 个数字 + 服务器日期边界)。 + * + * scope=all 必须有 project:task:list-all 权限,否则 403(PROJECT_OBJECT_PERMISSION_DENIED)。 + * 前端切到"项目全部"视角前应已基于权限码隐藏入口;如真被 403,UI 应自动切回"我的"。 + */ +export function fetchGetProjectTaskSummary(projectId: string, params?: Api.Project.ProjectTaskSummaryParams) { + return request({ + ...safeJsonRequestConfig, + url: `${getProjectTasksPrefix(projectId)}/summary`, + method: 'get', + params + }); +} + type TaskWorklogPageResponse = Api.Project.PageResult; function getWorklogPrefix(projectId: string, executionId: string, taskId: string) { diff --git a/src/typings/api/project.d.ts b/src/typings/api/project.d.ts index f1a8101..d2a2ee0 100644 --- a/src/typings/api/project.d.ts +++ b/src/typings/api/project.d.ts @@ -220,19 +220,23 @@ declare namespace Api { id: string; projectId: string; executionId: string; + /** 所属执行名称;跨执行查询必有,单执行查询可缺省 */ + executionName?: string | null; + /** 所属执行状态编码;跨执行查询必有,单执行查询可缺省(用于灰显已完成执行的任务行) */ + executionStatusCode?: ProjectExecutionStatusCode | null; parentTaskId: string | null; /** 所属执行关联的项目需求 ID(透传,未关联 = null) */ projectRequirementId: string | null; - /** 所属执行关联的项目需求名称(透传,未关联 = null) */ + /** 所属执行关联的项目需求名称(透传,未关联 = null;跨执行查询永远为 null,前端不在跨执行视角展示) */ projectRequirementName: string | null; - /** 所属执行关联的项目需求状态编码(透传,未关联 = null) */ + /** 所属执行关联的项目需求状态编码(同上) */ projectRequirementStatusCode: string | null; taskTitle: string; type: string; ownerId: string; ownerNickname?: string | null; - /** 所属执行的负责人 userId(按钮可见度公式用) */ - executionOwnerId: string; + /** 所属执行的负责人 userId(按钮可见度公式用);跨执行查询永远为 null,按钮判定退化为只看权限码 */ + executionOwnerId: string | null; /** 父任务负责人 userId(一级任务为 null) */ parentTaskOwnerId: string | null; statusCode: ProjectTaskStatusCode; @@ -376,6 +380,76 @@ declare namespace Api { items: ProjectTaskBoardColumn[]; } + /** 截止时间快速选项(跨执行接口专属) */ + type ProjectTaskDueRange = 'overdue' | 'today' | 'thisWeek'; + + /** 跨执行任务排序字段 */ + type ProjectTaskCrossSortBy = 'plannedEndDate' | 'priority' | 'updateTime' | 'createTime'; + + type ProjectTaskCrossSortOrder = 'asc' | 'desc'; + + /** + * 项目级跨执行任务分页入参(`GET /project/project/{projectId}/tasks/page`)。 + * + * - `involveUserId` / `ownerId` 互斥:同传只 `ownerId` 生效(后端 SQL 双重过滤)。 + * - `executionIds` 不传 = 项目内全部执行。 + * - `executionStatusCodes` 在任务可见性之上叠加"任务所属执行状态 ∈ 白名单"过滤;多值 OR; + * 与 `executionIds` 同传时为 AND。详见 `docs/debt/跨执行任务接口-按执行状态过滤-契约调整.html`。 + * - 不传 `involveUserId / ownerId` 且无 `project:task:list-all` 权限时,后端静默降级为"自己有身份的范围",不抛 403。 + */ + type ProjectTaskCrossSearchParams = CommonType.RecordNullable< + Pick & { + keyword: string; + executionIds: string[]; + /** 任务所属执行的状态白名单(用于左侧执行池按状态 chip 切换时的任务范围过滤) */ + executionStatusCodes: ProjectExecutionStatusCode[]; + /** "我参与"语义:当前用户作为 owner 或活跃协办;与 ownerId 二选一 */ + involveUserId: string; + /** 仅作为 owner 匹配;与 involveUserId 二选一 */ + ownerId: string; + statusCodes: ProjectTaskStatusCode[]; + /** 优先级字典 value("0"~"3") */ + priority: string; + parentTaskId: string; + dueRange: ProjectTaskDueRange; + /** 更新时间范围 [start, end],格式 yyyy-MM-dd HH:mm:ss */ + updateTime: string[]; + sortBy: ProjectTaskCrossSortBy; + sortOrder: ProjectTaskCrossSortOrder; + } + >; + + /** 项目级跨执行任务状态看板入参(与 page 同口径但不含 pageNo/pageSize/statusCodes/sortBy/sortOrder) */ + type ProjectTaskCrossStatusBoardParams = Omit< + ProjectTaskCrossSearchParams, + 'pageNo' | 'pageSize' | 'statusCodes' | 'sortBy' | 'sortOrder' + >; + + /** 项目级跨执行任务看板分页入参 */ + type ProjectTaskCrossBoardPageParams = Omit; + + /** 项目级"今日小条"汇总入参 */ + interface ProjectTaskSummaryParams { + /** 默认 mine(不传也走 mine);all 必须有 project:task:list-all 权限,否则 403 */ + scope?: 'mine' | 'all'; + } + + /** + * 项目级"今日小条"汇总响应(`GET /project/project/{projectId}/tasks/summary`)。 + * + * 数字一致性:dueThisWeek 的范围与 page?dueRange=thisWeek 完全一致(本周一~本周日)。 + * today / weekStart / weekEnd 直接展示,不要前端再算"今天/本周一"(服务器时区为 Asia/Shanghai)。 + */ + interface ProjectTaskSummary { + overdue: number; + dueToday: number; + dueThisWeek: number; + doneThisWeek: number; + today: string; + weekStart: string; + weekEnd: string; + } + interface SaveProjectTaskParams { parentTaskId: string | null; taskTitle: string; 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 95e582c..e5965da 100644 --- a/src/views/project/project/execution/composables/use-task-actions.ts +++ b/src/views/project/project/execution/composables/use-task-actions.ts @@ -36,7 +36,7 @@ const STATUS_ACTION_ICON_MAP: Record = { cancel: markRaw(IconMdiCloseCircleOutline) }; -// 状态推进按钮 type 映射(对齐执行 execution-list-panel.vue 同源语义): +// 状态推进按钮 type 映射(对齐执行 execution-section.vue 同源语义): // cancel 破坏性=红,pause 中断=橙,complete 完结=绿,resume 主动作=蓝 const STATUS_ACTION_TYPE_MAP: Record = { cancel: 'danger', diff --git a/src/views/project/project/execution/composables/use-task-board-columns.ts b/src/views/project/project/execution/composables/use-task-board-columns.ts index 408573a..783b490 100644 --- a/src/views/project/project/execution/composables/use-task-board-columns.ts +++ b/src/views/project/project/execution/composables/use-task-board-columns.ts @@ -1,5 +1,5 @@ import { type Ref, ref, watch } from 'vue'; -import { fetchGetProjectTaskBoardPage } from '@/service/api/project'; +import type { ServiceRequestResult } from '@/service/api/shared'; export interface BoardColumnState { statusCode: string; @@ -15,16 +15,24 @@ export interface BoardColumnState { hasMore: boolean; } -const DEFAULT_PAGE_SIZE = 20; - export type BoardBaseParams = Pick< Api.Project.ProjectTaskSearchParams, 'keyword' | 'parentTaskId' | 'ownerId' | 'priority' | 'updateTime' >; +const DEFAULT_PAGE_SIZE = 20; + +/** 看板 fetcher 入参(每列的分页与 statusCode 由 composable 内部带入) */ +export interface BoardFetcherParams { + pageNo: number; + pageSize: number; + /** 首屏不传 → 后端返全部列;列加载更多传 [statusCode] → 仅返该列 */ + statusCode?: string[]; +} + export interface UseTaskBoardColumnsOptions { - projectId: Ref; - executionId: Ref; + /** 是否具备加载条件(projectId / executionId 等校验由调用方做完);false 时清空列表 */ + canLoad: Ref; /** * 刷新触发器:workspace 的 statusBoard ref。 * @@ -32,7 +40,23 @@ export interface UseTaskBoardColumnsOptions { * 触发本 composable 重新拉看板首屏。**composable 不读它的内容**,列结构以 board-page 响应为准。 */ refreshSignal: Ref; - baseParams: () => BoardBaseParams; + /** + * 调用方提供的 fetcher,屏蔽"单执行 / 跨执行"两套接口的差异: + * - 单执行视角 → 调 fetchGetProjectTaskBoardPage(projectId, executionId, { ...baseParams, ...params }) + * - 跨执行视角 → 调 fetchGetProjectTaskBoardPageCross(projectId, { ...baseParamsCross, ...params }) + */ + fetcher: (params: BoardFetcherParams) => Promise< + ServiceRequestResult<{ + items: Array<{ + statusCode: string; + statusName: string; + sort: number; + terminal?: boolean; + list: Api.Project.ProjectTask[]; + total: number; + }>; + }> + >; pageSize?: number; } @@ -40,26 +64,20 @@ export interface UseTaskBoardColumnsOptions { * 看板按状态分列、每列独立分页(无限下拉)。 * * 节奏: - * - 进入看板 / 任何刷新事件:1 次 board-page(不带 statusCode)→ 拿到所有列骨架 + 各列首页 + 各列总数。 - * - 用户滚某列到底:1 次 board-page(`statusCode=[X]`, `pageNo=N+1`)→ 取 `items[0].list` 追加到本列。 - * - * 不消费 searchParams.statusCode —— 每列总是查自己的 statusCode;顶部搜索栏的状态过滤在看板模式下天然失效。 + * - 进入看板 / 任何刷新事件:1 次 fetcher(不带 statusCode)→ 拿到所有列骨架 + 各列首页 + 各列总数。 + * - 用户滚某列到底:1 次 fetcher(`statusCode=[X]`, `pageNo=N+1`)→ 取 `items[0].list` 追加到本列。 */ export function useTaskBoardColumns(options: UseTaskBoardColumnsOptions) { const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; const columns = ref([]); async function refresh() { - if (!options.projectId.value || !options.executionId.value) { + if (!options.canLoad.value) { columns.value = []; return; } - const result = await fetchGetProjectTaskBoardPage(options.projectId.value, options.executionId.value, { - ...options.baseParams(), - pageNo: 1, - pageSize - }); + const result = await options.fetcher({ pageNo: 1, pageSize }); if (result.error || !result.data) { columns.value = []; @@ -81,18 +99,17 @@ export function useTaskBoardColumns(options: UseTaskBoardColumnsOptions) { } async function loadMore(statusCode: string) { - if (!options.projectId.value || !options.executionId.value) return; + if (!options.canLoad.value) return; const col = columns.value.find(item => item.statusCode === statusCode); if (!col || col.loading || !col.hasMore) return; col.loading = true; const nextPage = col.pageNo + 1; - const result = await fetchGetProjectTaskBoardPage(options.projectId.value, options.executionId.value, { - ...options.baseParams(), - statusCode: [statusCode], + const result = await options.fetcher({ pageNo: nextPage, - pageSize + pageSize, + statusCode: [statusCode] }); if (result.error || !result.data) { diff --git a/src/views/project/project/execution/composables/use-task-drag.ts b/src/views/project/project/execution/composables/use-task-drag.ts new file mode 100644 index 0000000..9057360 --- /dev/null +++ b/src/views/project/project/execution/composables/use-task-drag.ts @@ -0,0 +1,64 @@ +import { taskStatusFallbackNameMap } from '../shared'; + +type ProjectTask = Api.Project.ProjectTask; +type TaskActionCode = Api.Project.ProjectTaskActionCode; +type TaskStatusCode = Api.Project.ProjectTaskStatusCode; +type TaskAction = Api.Project.LifecycleAction; + +/** + * 任务 actionCode → 目标 statusCode 映射。 + * 看板拖拽用此映射把"拖到哪一列"反查出"应该走哪个动作",再去 task.availableActions 里找匹配项。 + * auto_start 是后端在填工时自动触发,前端不暴露(useTaskActions 也做了同样过滤)。 + */ +export const TASK_ACTION_TO_STATUS_CODE: Record = { + auto_start: 'active', + pause: 'paused', + resume: 'active', + complete: 'completed', + cancel: 'cancelled' +}; + +export type DragResolution = { ok: true; action: TaskAction } | { ok: false; reason: string | null }; + +/** + * 判定"把 task 拖到 targetStatusCode 列"是否合法,合法时返回应触发的 action。 + * + * - 同列(statusCode 相等):静默拒绝(看板不支持同列手动排序) + * - 不在 task.availableActions 内的目标列:拒绝,带文案 + * - complete 动作下进度 < 100:拒绝,带文案(复刻按钮的兜底) + * - auto_start:不走拖拽通道 + */ +export function resolveTaskDragAction(task: ProjectTask, targetStatusCode: string): DragResolution { + if (task.statusCode === targetStatusCode) { + return { ok: false, reason: null }; + } + + const candidate = task.availableActions.find( + action => action.actionCode !== 'auto_start' && TASK_ACTION_TO_STATUS_CODE[action.actionCode] === targetStatusCode + ); + + if (!candidate) { + const targetName = taskStatusFallbackNameMap[targetStatusCode as TaskStatusCode] || targetStatusCode; + return { ok: false, reason: `当前任务不能直接变更为「${targetName}」` }; + } + + if (candidate.actionCode === 'complete' && task.progressRate < 100) { + return { ok: false, reason: '完成任务前请先将进度调整为 100%' }; + } + + return { ok: true, action: candidate }; +} + +/** + * 卡片"是否允许拖起"。 + * + * - 执行已 completed/cancelled (跨执行视角下灰显卡片):禁拖 + * - availableActions 为空(后端按当前用户上下文已过滤):禁拖 + * → 自然覆盖"不是负责人 / 无权限 / 终态任务 / paused 等无出度"全部场景,口径与按钮完全一致 + * - 仅剩 auto_start 时也禁拖(无可暴露动作) + */ +export function isTaskDraggable(task: ProjectTask, options: { executionLocked?: boolean } = {}): boolean { + if (options.executionLocked) return false; + if (!task.availableActions.length) return false; + return task.availableActions.some(action => action.actionCode !== 'auto_start'); +} diff --git a/src/views/project/project/execution/composables/use-task-view-context.ts b/src/views/project/project/execution/composables/use-task-view-context.ts new file mode 100644 index 0000000..830b0d9 --- /dev/null +++ b/src/views/project/project/execution/composables/use-task-view-context.ts @@ -0,0 +1,34 @@ +import { reactive } from 'vue'; + +/** + * 任务 tab 的身份维度视角。 + * + * 范围维度(全部 / 某状态 / 某具体执行)由父组件的 selectedStatus + selectedExecution 单独维护, + * 与本身份维度自由组合。 + * + * - my: 我参与的(owner 或活跃协办) + * - all: 所有任务(需 project:task:list-all 权限) + */ +export type ViewContextType = 'my' | 'all'; + +export interface ViewContext { + type: ViewContextType; +} + +export function useTaskViewContext() { + const state = reactive({ type: 'my' }); + + function switchToMine() { + state.type = 'my'; + } + + function switchToAll() { + state.type = 'all'; + } + + return { + context: state, + switchToMine, + switchToAll + }; +} diff --git a/src/views/project/project/execution/index.vue b/src/views/project/project/execution/index.vue index e73922c..a8ded3f 100644 --- a/src/views/project/project/execution/index.vue +++ b/src/views/project/project/execution/index.vue @@ -11,20 +11,23 @@ import { fetchGetProjectExecutionPage, fetchGetProjectExecutionStatusBoard, fetchGetProjectMembers, + fetchGetProjectTaskStatusBoardCross, fetchInactiveProjectExecutionAssignee, fetchPrecheckDeleteProjectExecution, fetchUpdateProjectExecution } from '@/service/api'; import { useObjectContextStore } from '@/store/modules/object-context'; +import { useAuthStore } from '@/store/modules/auth'; import { useUIPaginatedTable } from '@/hooks/common/table'; import { useCurrentProject } from '../../shared/use-current-project'; import { useTaskPermissions } from './composables/use-task-permissions'; -import ExecutionListPanel from './modules/execution-list-panel.vue'; +import { useTaskViewContext } from './composables/use-task-view-context'; import ExecutionAssigneeDialog from './modules/execution-assignee-dialog.vue'; import ExecutionOperateDialog from './modules/execution-operate-dialog.vue'; +import ExecutionSection from './modules/execution-section.vue'; import ObjectDeleteDialog from './modules/object-delete-dialog.vue'; import StatusActionDialog from './modules/status-action-dialog.vue'; -import TaskWorkspace from './modules/task-workspace.vue'; +import TaskWorkspaceComp from './modules/task-workspace.vue'; defineOptions({ name: 'ProjectExecution' }); @@ -47,29 +50,25 @@ function getInitExecutionSearchParams(): Api.Project.ProjectExecutionSearchParam function transformExecutionPage(response: ExecutionPageResponse, pageNo: number, pageSize: number) { if (!response.error && response.data) { - return { - data: response.data.list, - pageNum: pageNo, - pageSize, - total: response.data.total - }; + return { data: response.data.list, pageNum: pageNo, pageSize, total: response.data.total }; } - - return { - data: [], - pageNum: pageNo, - pageSize, - total: 0 - }; + return { data: [], pageNum: pageNo, pageSize, total: 0 }; } const { currentObjectId } = useCurrentProject(); const objectContextStore = useObjectContextStore(); +const authStore = useAuthStore(); + +const { context: viewContext, switchToMine, switchToAll } = useTaskViewContext(); const searchParams = reactive(getInitExecutionSearchParams()); -const DEFAULT_EXECUTION_STATUS: ExecutionStatusFilter = 'active'; +// 默认"全部":右侧任务列表也对应"项目全部执行下的我参与/所有任务",不预先把范围收窄 +const DEFAULT_EXECUTION_STATUS: ExecutionStatusFilter = null; const selectedStatus = ref(DEFAULT_EXECUTION_STATUS); + +/** 范围维度:选中的具体执行;为 null 表示"未锚定具体执行",数据来源由 selectedStatus 决定 */ const selectedExecution = ref(null); + const projectMembers = ref([]); const projectMemberOptions = ref([]); const operateVisible = ref(false); @@ -84,20 +83,62 @@ const executionAssignees = ref([]); const assigneeLoading = ref(false); const executionStatusBoard = ref(null); +/** + * 项目下"全部执行简明列表"。一次性拉取(pageSize=-1),用于: + * 1. task-search 的「所属执行」下拉(executionOptionsForFilter) + * 2. 左侧 chip 选择某状态时,前端 filter 出"该状态下的执行 ids"传给右侧任务列表 + * 3. 任务行 pill 点击切换执行时的兜底查找(handleSelectExecutionById) + */ +const allProjectExecutions = ref([]); + +const allTasksCount = ref(0); +const myTasksCount = ref(0); + const projectId = computed(() => currentObjectId.value || ''); +const currentUserId = computed(() => authStore.userInfo.userId || ''); const statusActionTitle = computed(() => statusAction.value ? `执行状态变更:${statusAction.value.actionName}` : '执行状态变更' ); const buttonCodeSet = computed(() => new Set(objectContextStore.buttonCodes)); const canCreateExecution = computed(() => buttonCodeSet.value.has('project:execution:create')); +/** 「所有任务」视角按钮可见度:权限码 project:task:list-all */ +const showAllPerspective = computed(() => buttonCodeSet.value.has('project:task:list-all')); + +/** + * 当前左侧锚定的"执行范围"——同时供 task-workspace 的任务列表/状态看板入参,以及视角按钮上的计数。 + * + * 范围维度拆成两个独立字段下传,由后端组合: + * - scopedExecutionIdsForTasks:仅在锚定具体执行时下发 [id];其它场景 undefined + * - scopedExecutionStatusCodesForTasks:仅在左侧选了某状态 chip 时下发 [statusCode];其它场景 undefined + * + * 历史上"按状态过滤"用前端 filter 出 ids 中转的方式实现,会把"对执行无成员权限但对其下任务有 owner/协办权限" + * 的任务漏掉(详见 docs/debt/跨执行任务接口-按执行状态过滤-契约调整.html)。现改为把状态码直接下传给任务接口, + * 由后端在任务可见性之上叠加"任务所属执行的状态 ∈ 白名单"过滤。 + */ +const scopedExecutionIdsForTasks = computed(() => { + if (selectedExecution.value) return [selectedExecution.value.id]; + return undefined; +}); + +const scopedExecutionStatusCodesForTasks = computed(() => { + if (selectedExecution.value) return undefined; + if (!selectedStatus.value) return undefined; + return [selectedStatus.value as Api.Project.ProjectExecutionStatusCode]; +}); const deleteDialogVisible = ref(false); const deleteExecutionDependentSummary = ref(null); const { canCreateTopLevelTask } = useTaskPermissions(); -// 第 2 类:项目内 RBAC 权限码 OR 执行 owner 字段身份;含 isMutable 状态前置 -// 选中的执行 = null 时按钮隐藏(无对象上下文可判) -const canCreateTask = computed(() => - selectedExecution.value ? canCreateTopLevelTask(selectedExecution.value) : false + +const canCreateTask = computed(() => (selectedExecution.value ? canCreateTopLevelTask(selectedExecution.value) : true)); + +const workspaceTitle = computed(() => { + if (selectedExecution.value) return selectedExecution.value.executionName || '执行任务'; + return viewContext.type === 'my' ? '我参与的' : '所有任务'; +}); + +const workspaceSubtitle = computed(() => + viewContext.type === 'my' ? `${myTasksCount.value} 条` : `${allTasksCount.value} 条` ); function createRequestParams(): Api.Project.ProjectExecutionSearchParams { @@ -132,7 +173,6 @@ const { data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable< error: null } as unknown as ExecutionPageResponse); } - return fetchGetProjectExecutionPage(projectId.value, createRequestParams()); }, transform: response => transformExecutionPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10), @@ -144,14 +184,9 @@ const { data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable< immediate: false }); -function syncSelectedExecution() { - if (!data.value.length) { - selectedExecution.value = null; - return; - } - - selectedExecution.value = data.value.find(item => item.id === selectedExecution.value?.id) || data.value[0]; -} +const executionOptionsForFilter = computed(() => + allProjectExecutions.value.map(item => ({ id: item.id, name: item.executionName })) +); async function loadProjectMemberOptions() { if (!projectId.value) { @@ -178,7 +213,6 @@ async function loadProjectMemberOptions() { async function reloadExecutionData(page = searchParams.pageNo ?? 1) { await getDataByPage(page); - syncSelectedExecution(); } async function loadExecutionStatusBoard() { @@ -186,41 +220,121 @@ async function loadExecutionStatusBoard() { executionStatusBoard.value = null; return; } - const { error, data: board } = await fetchGetProjectExecutionStatusBoard(projectId.value, createStatusBoardParams()); - executionStatusBoard.value = error || !board ? null : board; } +/** 拉取项目下"全部执行简明列表"。pageSize=-1 是后端"不分页全量"约定,见 CLAUDE.md §9 */ +async function loadAllProjectExecutions() { + if (!projectId.value) { + allProjectExecutions.value = []; + return; + } + const { error, data: pageData } = await fetchGetProjectExecutionPage(projectId.value, { + pageNo: 1, + pageSize: -1 + }); + allProjectExecutions.value = error || !pageData ? [] : pageData.list; +} + +async function loadCrossExecutionCounts() { + if (!projectId.value || !currentUserId.value) { + myTasksCount.value = 0; + allTasksCount.value = 0; + return; + } + + // 视角按钮计数 = 当前左侧 chip / 执行 锚定范围内的"我参与/所有"总数 + const scopedIds = scopedExecutionIdsForTasks.value; + const scopedStatusCodes = scopedExecutionStatusCodesForTasks.value; + + // 短路:锚定到具体执行但 ids=[] 不会发生(scopedExecutionIdsForTasks 要么 undefined 要么 [id]); + // 状态码维度交给后端处理(空数组语义统一返空,不在前端短路) + const scopeParams: Pick = {}; + if (scopedIds !== undefined) scopeParams.executionIds = scopedIds; + if (scopedStatusCodes !== undefined) scopeParams.executionStatusCodes = scopedStatusCodes; + + const [allRes, myRes] = await Promise.all([ + fetchGetProjectTaskStatusBoardCross(projectId.value, scopeParams), + fetchGetProjectTaskStatusBoardCross(projectId.value, { ...scopeParams, involveUserId: currentUserId.value }) + ]); + + allTasksCount.value = allRes.error || !allRes.data ? 0 : allRes.data.total; + myTasksCount.value = myRes.error || !myRes.data ? 0 : myRes.data.total; +} + async function refreshPageData() { - await Promise.all([loadProjectMemberOptions(), reloadExecutionData(), loadExecutionStatusBoard()]); + await Promise.all([ + loadProjectMemberOptions(), + reloadExecutionData(), + loadExecutionStatusBoard(), + loadAllProjectExecutions(), + loadCrossExecutionCounts() + ]); } -async function handleSearch() { - await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]); -} - -async function handleReset() { - Object.assign(searchParams, getInitExecutionSearchParams()); - selectedStatus.value = DEFAULT_EXECUTION_STATUS; - await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]); -} - -async function handleStatusChange(status: ExecutionStatusFilter) { +async function handleExecutionStatusFilter(status: ExecutionStatusFilter) { selectedStatus.value = status; + // 状态 chip 是"按状态范围浏览":意味着不再锚定具体执行,清掉 selectedExecution + if (selectedExecution.value) selectedExecution.value = null; + // 左侧范围切换默认回「我参与的」视角,符合"在该范围内看自己的"主用法 + switchToMine(); await reloadExecutionData(1); } +async function handleExecutionSearch() { + await reloadExecutionData(1); +} + +async function handleExecutionResetSearch() { + Object.assign(searchParams, getInitExecutionSearchParams()); + selectedStatus.value = DEFAULT_EXECUTION_STATUS; + if (selectedExecution.value) selectedExecution.value = null; + await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]); +} + async function getExecutionDetail(row: Api.Project.ProjectExecution) { - if (!projectId.value) { - return row; - } - + if (!projectId.value) return row; const result = await fetchGetProjectExecution(projectId.value, row.id); - return result.error || !result.data ? row : result.data; } +function handleSelectExecution(row: Api.Project.ProjectExecution) { + // 锚定具体执行,默认回「我参与的」视角 + selectedExecution.value = row; + switchToMine(); +} + +async function handleSelectExecutionById(executionId: string) { + // 左栏当前分页 → 项目全部执行简明列表 → 接口详情,三层兜底 + const inListMatch = data.value.find(item => item.id === executionId); + if (inListMatch) { + selectedExecution.value = inListMatch; + switchToMine(); + return; + } + const allListMatch = allProjectExecutions.value.find(item => item.id === executionId); + if (allListMatch) { + selectedExecution.value = allListMatch; + switchToMine(); + return; + } + if (!projectId.value) return; + const { error, data: detail } = await fetchGetProjectExecution(projectId.value, executionId); + if (error || !detail) { + window.$message?.info('该执行不在当前项目执行池中'); + return; + } + selectedExecution.value = detail; + switchToMine(); +} + +function handleSelectPerspective(type: 'my' | 'all') { + // 只切身份维度,保留范围(具体执行 / 状态 chip) + if (type === 'my') switchToMine(); + else switchToAll(); +} + function openCreateExecution() { editingExecution.value = null; editingExecutionAssignees.value = []; @@ -230,12 +344,10 @@ function openCreateExecution() { async function openEditExecution(row: Api.Project.ProjectExecution) { const detail = await getExecutionDetail(row); - if (!detail.allowEdit) { window.$message?.warning('当前执行状态不允许编辑'); return; } - editingExecution.value = detail; const assigneeResult = await fetchGetProjectExecutionAssignees(projectId.value, detail.id); editingExecutionAssignees.value = assigneeResult.error || !assigneeResult.data ? [] : assigneeResult.data; @@ -245,7 +357,6 @@ async function openEditExecution(row: Api.Project.ProjectExecution) { async function openViewExecution(row: Api.Project.ProjectExecution) { const detail = await getExecutionDetail(row); - editingExecution.value = detail; const assigneeResult = await fetchGetProjectExecutionAssignees(projectId.value, detail.id); editingExecutionAssignees.value = assigneeResult.error || !assigneeResult.data ? [] : assigneeResult.data; @@ -262,12 +373,10 @@ async function openMemberDialog(row: Api.Project.ProjectExecution) { async function openExecutionStatus(row: Api.Project.ProjectExecution, action: ExecutionAction | null) { const detail = await getExecutionDetail(row); const targetAction = action || detail.availableActions[0] || null; - if (!targetAction) { window.$message?.warning('当前执行暂无可用状态操作'); return; } - statusExecution.value = detail; statusAction.value = targetAction; statusVisible.value = true; @@ -278,9 +387,7 @@ async function loadExecutionAssignees(executionId: string) { executionAssignees.value = []; return; } - assigneeLoading.value = true; - try { const { error, data: assignees } = await fetchGetProjectExecutionAssignees(projectId.value, executionId); executionAssignees.value = error || !assignees ? [] : assignees; @@ -290,10 +397,7 @@ async function loadExecutionAssignees(executionId: string) { } async function handleExecutionSubmit(payload: Api.Project.SaveProjectExecutionParams) { - if (!projectId.value) { - return; - } - + if (!projectId.value) return; const result = editingExecution.value ? await fetchUpdateProjectExecution(projectId.value, editingExecution.value.id, { executionName: payload.executionName, @@ -316,12 +420,8 @@ async function handleExecutionSubmit(payload: Api.Project.SaveProjectExecutionPa } async function handleChangeOwner(payload: Api.Project.ChangeExecutionOwnerParams) { - if (!projectId.value || !selectedExecution.value) { - return; - } - + if (!projectId.value || !selectedExecution.value) return; const result = await fetchChangeProjectExecutionOwner(projectId.value, selectedExecution.value.id, payload); - if (!result.error) { selectedExecution.value = await getExecutionDetail(selectedExecution.value); await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]); @@ -329,15 +429,11 @@ async function handleChangeOwner(payload: Api.Project.ChangeExecutionOwnerParams } async function handleExecutionStatusSubmit(reason: string | null) { - if (!projectId.value || !statusExecution.value || !statusAction.value) { - return; - } - + if (!projectId.value || !statusExecution.value || !statusAction.value) return; const result = await fetchChangeProjectExecutionStatus(projectId.value, statusExecution.value.id, { actionCode: statusAction.value.actionCode, reason }); - if (!result.error) { statusVisible.value = false; await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]); @@ -345,12 +441,8 @@ async function handleExecutionStatusSubmit(reason: string | null) { } async function handleAddExecutionAssignee(payload: Api.Project.CreateExecutionAssigneeParams) { - if (!projectId.value || !selectedExecution.value) { - return; - } - + if (!projectId.value || !selectedExecution.value) return; const result = await fetchCreateProjectExecutionAssignee(projectId.value, selectedExecution.value.id, payload); - if (!result.error) { await loadExecutionAssignees(selectedExecution.value.id); } @@ -360,15 +452,11 @@ async function handleInactiveExecutionAssignee( assignee: Api.Project.ExecutionAssignee, payload: Api.Project.InactiveExecutionAssigneeParams ) { - if (!projectId.value || !selectedExecution.value) { - return; - } - + if (!projectId.value || !selectedExecution.value) return; const result = await fetchInactiveProjectExecutionAssignee(projectId.value, selectedExecution.value.id, { assigneeId: assignee.id, data: payload }); - if (!result.error) { await loadExecutionAssignees(selectedExecution.value.id); } @@ -376,8 +464,6 @@ async function handleInactiveExecutionAssignee( async function handleDeleteExecution(row: Api.Project.ProjectExecution) { if (!projectId.value) return; - - // 无下挂走简单二次确认;有/查询异常走原重型弹层 const precheck = await fetchPrecheckDeleteProjectExecution(projectId.value, row.id); const canDirectDelete = !precheck.error && precheck.data && !precheck.data.hasDependentData; @@ -393,11 +479,7 @@ async function handleDeleteExecution(row: Api.Project.ProjectExecution) { await window.$messageBox?.confirm( `确定要删除执行“${row.executionName}”吗?删除后将不可见,如需恢复请联系管理员。`, '删除确认', - { - confirmButtonText: '确认删除', - cancelButtonText: '取消', - type: 'warning' - } + { confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' } ); } catch { return; @@ -409,7 +491,6 @@ async function handleDeleteExecution(row: Api.Project.ProjectExecution) { reason: '无下挂数据,用户已二次确认' }); if (error) { - // 简化路径出错多为缓存陈旧(completed 被改/并发删),兜底刷新让用户看到最新状态 await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]); return; } @@ -446,10 +527,14 @@ async function handleExecutionChangedByTask() { await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]); return; } - await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]); } +// 左侧 chip / 执行 选择变化 → 视角按钮上的「我参与的 / 所有任务」计数跟着刷,保持与 task-workspace 任务列表口径一致 +watch([scopedExecutionIdsForTasks, scopedExecutionStatusCodesForTasks], () => { + loadCrossExecutionCounts(); +}); + watch( () => projectId.value, async () => { @@ -464,36 +549,51 @@ watch(