From 4a7f54b0edbb65d903d5f1c0160a35f56639203a Mon Sep 17 00:00:00 2001
From: hongawen <83944980@qq.com>
Date: Mon, 22 Jun 2026 19:28:39 +0800
Subject: [PATCH] =?UTF-8?q?refactor(projects):=201=20=E5=B7=A5=E4=BD=9C?=
=?UTF-8?q?=E5=8F=B0=E7=BC=93=E5=AD=98=E4=BC=98=E5=8C=96=EF=BC=9B2=20?=
=?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=97=A5=E5=BF=97=E6=80=BB=E5=B7=A5=E6=97=B6?=
=?UTF-8?q?=E4=B8=8B=E8=BF=9B=E5=BA=A6=E6=98=BE=E7=A4=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../_builtin/login/modules/pwd-login.vue | 3 -
.../execution/modules/task-operate-dialog.vue | 154 ++++++++++++------
.../modules/task-worklog-content.vue | 71 +++++++-
.../execution/modules/task-workspace.vue | 73 +++++++--
.../modules/workbench-my-execution.vue | 5 +-
.../modules/workbench-project-grid.vue | 5 +-
.../workbench/modules/workbench-team-load.vue | 5 +-
.../modules/workbench-todo-panel.vue | 12 +-
8 files changed, 242 insertions(+), 86 deletions(-)
diff --git a/src/views/_builtin/login/modules/pwd-login.vue b/src/views/_builtin/login/modules/pwd-login.vue
index a646837..c3c655d 100644
--- a/src/views/_builtin/login/modules/pwd-login.vue
+++ b/src/views/_builtin/login/modules/pwd-login.vue
@@ -56,9 +56,6 @@ async function handleSubmit() {
-
- {{ $t('page.login.pwdLogin.rememberMe') }}
-
(), {
defaultParentTaskId: null,
+ canManageAssignee: false,
plannedEndShortcuts: () => [
{ text: '三天', days: 3 },
{ text: '一星期', days: 7 },
@@ -94,6 +97,56 @@ const dialogTitle = computed(() => {
const selectableParentTasks = computed(() => props.taskOptions.filter(item => item.id !== props.rowData?.id));
+/** 编辑态 + 任务已开始(statusCode 离开 pending)→ 负责人锁定不可切换 */
+const ownerLocked = computed(() => {
+ const status = props.rowData?.statusCode;
+ return props.mode === 'edit' && Boolean(status) && status !== 'pending';
+});
+
+/**
+ * 负责人下拉选项 = 执行协办人池 ∪ 当前任务负责人(兜底)。
+ * owner 可能已不在执行协办人池里,用任务自带 ownerNickname 兜底回显,避免锁定态显示成裸 userId。
+ */
+const ownerSelectOptions = computed(() => {
+ const ownerId = props.rowData?.ownerId;
+ if (props.mode === 'create' || !ownerId || props.userOptions.some(item => item.id === ownerId)) {
+ return props.userOptions;
+ }
+ return [...props.userOptions, { id: ownerId, nickname: props.rowData?.ownerNickname || ownerId }];
+});
+
+/** 编辑态无协办人管理权限时只读回显(create 恒可交互) */
+const assigneeSelectDisabled = computed(() => props.mode !== 'create' && !props.canManageAssignee);
+
+const assigneePlaceholder = computed(() => (assigneeSelectDisabled.value ? '暂无协办人' : '请选择协办人'));
+
+/**
+ * 候选项 = 执行协办人池 ∪ 当前任务已有协办人中已不在池里的成员 ∪ 当前任务负责人。
+ * 已有协办人用任务自带 nickname 兜底回显,避免协办人被移出执行后在编辑态显示成裸 userId;
+ * 负责人并入是为了让 owner 在协办人下拉里以「负责人」标记展示(不可移除),用 ownerNickname 兜底。
+ */
+const assigneeSelectOptions = computed(() => {
+ const options = props.userOptions.map(item => ({ id: item.id, nickname: item.nickname }));
+ const known = new Set(options.map(item => item.id));
+
+ if (props.mode !== 'create' && props.rowData) {
+ props.rowData.assignees?.forEach(assignee => {
+ if (assignee.userId && !known.has(assignee.userId)) {
+ options.push({ id: assignee.userId, nickname: assignee.nickname || assignee.userId });
+ known.add(assignee.userId);
+ }
+ });
+
+ const ownerId = props.rowData.ownerId;
+ if (ownerId && !known.has(ownerId)) {
+ options.push({ id: ownerId, nickname: props.rowData.ownerNickname || ownerId });
+ known.add(ownerId);
+ }
+ }
+
+ return options;
+});
+
/** 左栏容器 ref:用其高度动态驱动右侧富文本,让两栏视觉等高 */
const leftColRef = ref();
const editorHeight = ref('45vh');
@@ -212,14 +265,11 @@ function normalizeAssigneeIds(ids: string[]) {
const autoOwnerAssigneeId = ref(null);
/**
- * UI 层把 owner 也加进 model.assigneeUserIds,让协办人 select 视觉上显示 owner
- * (体验上让用户感知"负责人也在团队里")。提交时由 normalizeAssigneeIds 过滤掉 owner。
+ * UI 层把 owner 也并进 model.assigneeUserIds,让协办人 select 视觉上显示 owner(带「负责人」标记、不可移除),
+ * 让用户感知"负责人也在团队里"。create / edit 同口径;提交时由 normalizeAssigneeIds 过滤掉 owner。
+ * 切换 owner 时按 previousOwnerId(上一任已并入的 owner)移除旧值,避免旧负责人残留在协办人里。
*/
function syncOwnerAssignee(ownerId: string | null, previousOwnerId: string | null = autoOwnerAssigneeId.value) {
- if (props.mode !== 'create') {
- return;
- }
-
const current = Array.from(new Set((model.assigneeUserIds ?? []).filter(Boolean)));
const withoutPrevious = previousOwnerId ? current.filter(userId => userId !== previousOwnerId) : current;
model.assigneeUserIds = ownerId ? Array.from(new Set([...withoutPrevious, ownerId])) : withoutPrevious;
@@ -246,17 +296,16 @@ async function handleConfirm() {
attachments: [...model.attachments]
};
- if (props.mode === 'create') {
- payload.assigneeUserIds = normalizeAssigneeIds(model.assigneeUserIds);
- }
+ // create:作为创建入参直接生效;edit:父级据此与原协办人 diff 出加入/失效调用(更新接口本身忽略此字段)
+ payload.assigneeUserIds = normalizeAssigneeIds(model.assigneeUserIds);
emit('submit', payload);
}
function handleAssigneeChange(value: string[]) {
- // UI 层保持 owner 不掉队;提交时再由 normalizeAssigneeIds 过滤
+ // UI 层保持 owner 不掉队(owner 选项 disabled,正常无法取消,这里兜底);提交时再由 normalizeAssigneeIds 过滤
const cleaned = Array.from(new Set(value.filter(Boolean)));
- if (props.mode === 'create' && model.ownerId && !cleaned.includes(model.ownerId)) {
+ if (model.ownerId && !cleaned.includes(model.ownerId)) {
cleaned.push(model.ownerId);
}
model.assigneeUserIds = cleaned;
@@ -276,7 +325,10 @@ function applyRowDataToModel() {
const row = props.rowData;
model.parentTaskId = props.mode === 'create' ? (props.defaultParentTaskId ?? null) : row?.parentTaskId || null;
applyBasicFieldsFromRow(row);
- model.assigneeUserIds = [];
+ // 编辑态回显任务现有协办人 + 并入负责人(owner 仅做视觉展示,提交时过滤);创建态留空
+ const baseAssignees = props.mode === 'create' ? [] : (row?.assignees ?? []).map(item => item.userId);
+ model.assigneeUserIds =
+ model.ownerId && !baseAssignees.includes(model.ownerId) ? [...baseAssignees, model.ownerId] : baseAssignees;
model.attachments = row?.attachments ? [...row.attachments] : [];
}
@@ -288,7 +340,8 @@ watch(
}
applyRowDataToModel();
- autoOwnerAssigneeId.value = null;
+ // 记录已自动并入协办人列表的 owner,供后续切换负责人时移除上一任
+ autoOwnerAssigneeId.value = model.ownerId || null;
await nextTick();
// 让附件组件把当前 model 视作 original,必须在 model 填充之后
@@ -300,8 +353,9 @@ watch(
watch(
() => model.ownerId,
- (ownerId, previousOwnerId) => {
- syncOwnerAssignee(ownerId || null, previousOwnerId || null);
+ ownerId => {
+ // previousOwnerId 取 autoOwnerAssigneeId(上一任已并入的 owner),避免跨任务残留误删协办人
+ syncOwnerAssignee(ownerId || null);
}
);
@@ -365,35 +419,33 @@ defineExpose({
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ 负责人
+
+
+
+
+
+
+
+
+
@@ -405,15 +457,25 @@ defineExpose({
+ :placeholder="assigneePlaceholder"
+ @change="handleAssigneeChange"
+ >
+
+
diff --git a/src/views/project/project/execution/modules/task-worklog-content.vue b/src/views/project/project/execution/modules/task-worklog-content.vue
index dfbad43..898609c 100644
--- a/src/views/project/project/execution/modules/task-worklog-content.vue
+++ b/src/views/project/project/execution/modules/task-worklog-content.vue
@@ -63,9 +63,30 @@ const totalHoursText = computed(() => {
return `${totalHours.value.toFixed(1)} h`;
});
+// 每个 userId 在 records 中"最近一条" worklog 的 progressRate。
+// records 与 panel 的 externalList 同源,已由后端按 end_date desc, id desc 排序,第一条即最近。
+const latestProgressByUser = computed(() => {
+ const map = new Map();
+ for (const item of records.value) {
+ if (!map.has(item.userId)) {
+ map.set(item.userId, item.progressRate);
+ }
+ }
+ return map;
+});
+
+// 单个用户在工时明细里的进度文案:责任人取任务整体进度,协办人取最近 worklog 进度,从未填报标记"未填报"
+function resolveUserProgressText(rowIsOwner: boolean, latest: number | undefined) {
+ if (rowIsOwner) return getProgressText(props.task?.progressRate);
+ if (latest !== undefined) return getProgressText(latest);
+ return '未填报';
+}
+
// "总工时" hover 展示按用户分组的明细(查看全部开放,所有人都看得到)。
// 候选范围:责任人 + 所有协办人 + records 中出现过的用户(兜底已退出协办人);
-// 没填过工时的显示 0h
+// 没填过工时的显示 0h。进度口径对齐「填报人」筛选:
+// - 责任人:任务整体进度(责任人填报会回写任务进度)
+// - 协办人/其他:本人最近一条 worklog 进度;从未填报显示"未填报"
const hoursByUserDetail = computed(() => {
const sumMap = new Map();
for (const item of records.value) {
@@ -89,11 +110,20 @@ const hoursByUserDetail = computed(() => {
pushUser(item.userId, item.userNickname);
}
- const arr = userIds.map(userId => ({
- userId,
- name: nicknameMap.get(userId) || userId,
- hours: sumMap.get(userId) ?? 0
- }));
+ const arr = userIds.map(userId => {
+ const rowIsOwner = userId === props.task?.ownerId;
+ const latest = latestProgressByUser.value.get(userId);
+ // 责任人恒显示任务进度;协办人取最近 worklog 进度,未填报则标记
+ const hasProgress = rowIsOwner || latest !== undefined;
+ return {
+ userId,
+ name: nicknameMap.get(userId) || userId,
+ isOwner: rowIsOwner,
+ hours: sumMap.get(userId) ?? 0,
+ progressText: resolveUserProgressText(rowIsOwner, latest),
+ hasProgress
+ };
+ });
// 责任人置顶,其余按工时降序(0h 自然落在最后)
arr.sort((a, b) => {
@@ -192,12 +222,18 @@ watch(
{{ item.name }}
- {{ item.hours.toFixed(1) }}h
+
+ {{ item.hours.toFixed(1) }}h
+ ·
+
+ {{ item.progressText }}
+
+
@@ -325,9 +361,28 @@ watch(
}
}
+.task-worklog-content__hours-detail-meta {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 4px;
+ flex: 0 0 auto;
+}
+
.task-worklog-content__hours-detail-hours {
flex: 0 0 auto;
color: var(--el-color-primary);
font-weight: 500;
}
+
+.task-worklog-content__hours-detail-sep {
+ color: var(--el-text-color-placeholder);
+}
+
+.task-worklog-content__hours-detail-progress {
+ color: var(--el-text-color-secondary);
+
+ &.is-empty {
+ color: var(--el-text-color-placeholder);
+ }
+}
diff --git a/src/views/project/project/execution/modules/task-workspace.vue b/src/views/project/project/execution/modules/task-workspace.vue
index b9713aa..1a2db86 100644
--- a/src/views/project/project/execution/modules/task-workspace.vue
+++ b/src/views/project/project/execution/modules/task-workspace.vue
@@ -116,6 +116,14 @@ const currentAssigneeTask = ref(null);
const currentAssignees = ref([]);
const assigneesLoading = ref(false);
+/** 编辑弹层内联失效协办人时使用的默认原因(无提示,统一审计文案) */
+const TASK_ASSIGNEE_INLINE_INACTIVE_REASON = '编辑任务时调整协办人';
+
+/** 编辑弹层是否允许内联管理协办人(与列表「协办人」按钮同权限口径) */
+const canManageCurrentTaskAssignee = computed(() =>
+ currentTask.value ? canManageTaskAssignee(currentTask.value) : false
+);
+
const worklogDialogVisible = ref(false);
const worklogDialogTask = ref(null);
@@ -466,25 +474,61 @@ async function handleStatusAction(row: Api.Project.ProjectTask, action: TaskStat
statusActionVisible.value = true;
}
+/**
+ * 编辑态把弹层选中的协办人与任务原协办人做 diff,翻译成加入 / 失效调用。
+ * 更新任务接口本身忽略 assigneeUserIds,协办人只能走独立端点;失效用默认原因、无提示。
+ * 返回是否全部成功(含无变更)。
+ */
+async function applyTaskAssigneeDiff(task: Api.Project.ProjectTask, nextUserIds: string[]) {
+ const originalAssignees = task.assignees ?? [];
+ const nextSet = new Set(nextUserIds);
+ const originalSet = new Set(originalAssignees.map(item => item.userId));
+
+ const toAdd = nextUserIds.filter(userId => !originalSet.has(userId));
+ const toInactivate = originalAssignees.filter(item => !nextSet.has(item.userId));
+
+ if (!toAdd.length && !toInactivate.length) return true;
+
+ const requests = [
+ ...toAdd.map(userId => fetchCreateProjectTaskAssignee(props.projectId, task.executionId, task.id, { userId })),
+ ...toInactivate.map(assignee =>
+ fetchInactiveProjectTaskAssignee(props.projectId, task.executionId, task.id, assignee.id, {
+ reason: TASK_ASSIGNEE_INLINE_INACTIVE_REASON
+ })
+ )
+ ];
+
+ const results = await Promise.all(requests);
+ return results.every(item => !item.error);
+}
+
async function handleOperateSubmit(payload: Api.Project.SaveProjectTaskParams) {
- // 新建必须锚定到具体执行;编辑跟随任务自身的 executionId(跨执行视角下 props.execution 为 null)
+ let assigneeOk = true;
+
if (operateMode.value === 'create') {
+ // 新建必须锚定到具体执行
if (!props.execution) return;
- } else if (!currentTask.value) {
- return;
+ const result = await fetchCreateProjectTask(props.projectId, props.execution.id, payload);
+ if (result.error) return;
+ window.$message?.success('任务创建成功');
+ } else {
+ // 编辑跟随任务自身的 executionId(跨执行视角下 props.execution 为 null)
+ const task = currentTask.value;
+ if (!task) return;
+
+ // 协办人不走更新接口,单独 diff;其余字段照常更新
+ const { assigneeUserIds = [], ...taskData } = payload;
+ const result = await fetchUpdateProjectTask(props.projectId, task.executionId, { taskId: task.id, data: taskData });
+ if (result.error) return;
+
+ assigneeOk = !canManageTaskAssignee(task) || (await applyTaskAssigneeDiff(task, assigneeUserIds));
+ if (assigneeOk) {
+ window.$message?.success('任务更新成功');
+ } else {
+ window.$message?.warning('任务已更新,部分协办人调整未成功,请在「协办人」中确认');
+ }
}
- const result =
- operateMode.value === 'create'
- ? await fetchCreateProjectTask(props.projectId, props.execution!.id, payload)
- : await fetchUpdateProjectTask(props.projectId, currentTask.value!.executionId, {
- taskId: currentTask.value!.id,
- data: payload
- });
-
- if (result.error) return;
-
- window.$message?.success(operateMode.value === 'create' ? '任务创建成功' : '任务更新成功');
await taskOperateDialogRef.value?.commit();
operateVisible.value = false;
await Promise.all([refreshTableData(), loadTaskStatusBoard()]);
@@ -918,6 +962,7 @@ defineExpose({
:default-parent-task-id="presetParentTaskId"
:user-options="executionAssigneeOptions"
:task-options="taskOptions"
+ :can-manage-assignee="canManageCurrentTaskAssignee"
@submit="handleOperateSubmit"
/>
diff --git a/src/views/workbench/modules/workbench-my-execution.vue b/src/views/workbench/modules/workbench-my-execution.vue
index 392cb47..09de87b 100644
--- a/src/views/workbench/modules/workbench-my-execution.vue
+++ b/src/views/workbench/modules/workbench-my-execution.vue
@@ -1,5 +1,5 @@