refactor(projects): 1 工作台缓存优化;2 工作日志总工时下进度显示

This commit is contained in:
2026-06-22 19:28:39 +08:00
parent 61fe9ef143
commit 4a7f54b0ed
8 changed files with 242 additions and 86 deletions

View File

@@ -56,9 +56,6 @@ async function handleSubmit() {
</template>
</ElInput>
</ElFormItem>
<div class="pb-18px">
<ElCheckbox>{{ $t('page.login.pwdLogin.rememberMe') }}</ElCheckbox>
</div>
<ElButton
type="primary"
size="large"

View File

@@ -31,6 +31,8 @@ interface Props {
userOptions: Api.SystemManage.UserSimple[];
taskOptions: Api.Project.ProjectTask[];
plannedEndShortcuts?: PlannedEndShortcutOffset[];
/** 编辑态是否可内联管理协办人(对应 canManageTaskAssigneefalse 时只读回显 */
canManageAssignee?: boolean;
}
interface Emits {
@@ -39,6 +41,7 @@ interface Emits {
const props = withDefaults(defineProps<Props>(), {
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<Api.SystemManage.UserSimple[]>(() => {
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<HTMLElement>();
const editorHeight = ref<string>('45vh');
@@ -212,14 +265,11 @@ function normalizeAssigneeIds(ids: string[]) {
const autoOwnerAssigneeId = ref<string | null>(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({
</ElFormItem>
<ElFormItem label="负责人" prop="ownerId">
<BusinessUserSelect v-model="model.ownerId" :options="userOptions" placeholder="请选择负责人" />
</ElFormItem>
<ElFormItem v-if="mode === 'create'" label="协办人" prop="assigneeUserIds">
<ElSelect
v-model="model.assigneeUserIds"
multiple
filterable
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="2"
class="w-full"
placeholder="请选择协办人"
@change="handleAssigneeChange"
>
<ElOption
v-for="item in userOptions"
:key="item.id"
:label="item.id === model.ownerId ? `${item.nickname}(负责人)` : item.nickname"
:value="item.id"
:disabled="item.id === model.ownerId"
/>
</ElSelect>
</ElFormItem>
<ElFormItem v-else>
<template #label>
<template v-if="ownerLocked" #label>
<span class="business-form-label-with-tip">
<ElTooltip
content="如需调整协办人,请关闭此弹层后点击列表「协办人」按钮。"
content="任务已开始,负责人不可变更。"
popper-class="business-form-label-tooltip"
placement="top-start"
>
<span class="business-form-label-tip">
<icon-fe:question />
</span>
</ElTooltip>
<span>负责人</span>
</span>
</template>
<BusinessUserSelect
v-model="model.ownerId"
:options="ownerSelectOptions"
:disabled="ownerLocked"
placeholder="请选择负责人"
/>
</ElFormItem>
<ElFormItem label="协办人" prop="assigneeUserIds">
<template v-if="mode !== 'create'" #label>
<span class="business-form-label-with-tip">
<ElTooltip
content="协办人的加入 / 失效变更历史可在列表「协办人」按钮中查看。"
popper-class="business-form-label-tooltip"
placement="top-start"
>
@@ -405,15 +457,25 @@ defineExpose({
</span>
</template>
<ElSelect
:model-value="model.assigneeUserIds"
v-model="model.assigneeUserIds"
multiple
disabled
filterable
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="2"
:disabled="assigneeSelectDisabled"
class="w-full"
placeholder="暂无协办人"
/>
:placeholder="assigneePlaceholder"
@change="handleAssigneeChange"
>
<ElOption
v-for="item in assigneeSelectOptions"
:key="item.id"
:label="item.id === model.ownerId ? `${item.nickname}(负责人)` : item.nickname"
:value="item.id"
:disabled="item.id === model.ownerId"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="计划开始日期" prop="plannedStartDate">

View File

@@ -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<string, number>();
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<string, number>();
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(
<div v-for="item in hoursByUserDetail" :key="item.userId" class="task-worklog-content__hours-detail-row">
<span
class="task-worklog-content__hours-detail-name"
:class="{ 'is-owner': item.userId === task?.ownerId }"
:class="{ 'is-owner': item.isOwner }"
:title="item.name"
>
{{ item.name }}
</span>
<span class="task-worklog-content__hours-detail-hours">{{ item.hours.toFixed(1) }}h</span>
<span class="task-worklog-content__hours-detail-meta">
<span class="task-worklog-content__hours-detail-hours">{{ item.hours.toFixed(1) }}h</span>
<span class="task-worklog-content__hours-detail-sep">·</span>
<span class="task-worklog-content__hours-detail-progress" :class="{ 'is-empty': !item.hasProgress }">
{{ item.progressText }}
</span>
</span>
</div>
</div>
</template>
@@ -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);
}
}
</style>

View File

@@ -116,6 +116,14 @@ const currentAssigneeTask = ref<Api.Project.ProjectTask | null>(null);
const currentAssignees = ref<Api.Project.TaskAssigneeRef[]>([]);
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<Api.Project.ProjectTask | null>(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"
/>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { computed, onActivated, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
@@ -31,7 +31,8 @@ const { loading, refresh } = useWorkbenchRefresh(async () => {
items.value = buildWorkbenchMyExecutionItems(data?.list ?? []);
});
onMounted(refresh);
// 工作台路由 keepAlive切回不重挂用 onActivated 替代 onMounted每次激活重拉首挂也会触发不漏首屏
onActivated(refresh);
// 按项目归类:未完成执行数多的项目在前;项目内按计划结束日升序(更紧的在前)
const groups = computed<Array<{ projectId: string; projectName: string; items: MyExecutionItem[] }>>(() => {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { computed, onActivated, ref, watch } from 'vue';
import { fetchGetMyOwnedProjectPage, fetchGetMyParticipatedProjectPage } from '@/service/api';
import { useRouterPush } from '@/hooks/common/router';
import {
@@ -46,7 +46,8 @@ const { loading, refresh } = useWorkbenchRefresh(async () => {
}
});
onMounted(refresh);
// 工作台路由 keepAlive切回不重挂用 onActivated 替代 onMounted每次激活重拉首挂也会触发不漏首屏
onActivated(refresh);
const currentOwnedId = ref<string>('');
watch(ownedItems, list => {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onActivated, ref } from 'vue';
import { fetchGetMyTeamLoad } from '@/service/api';
import { getWorkbenchItemColor } from '../composables/use-workbench-colors';
import {
@@ -45,7 +45,8 @@ function toTeamLoadSource(member: Api.Project.TeamLoadMember, index: number): Wo
const view = computed(() => buildWorkbenchTeamLoadView({ members: teamLoadMembers.value.map(toTeamLoadSource) }));
onMounted(loadTeamLoad);
// 工作台路由 keepAlive切回不重挂用 onActivated 替代 onMounted每次激活经 refresh 重拉(首挂也会触发,不漏首屏)
onActivated(refresh);
const LEVEL_LABEL: Record<WorkbenchTeamLoadLevel, string> = {
high: '高负载',

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { type Component, computed, markRaw, onMounted, ref, watch } from 'vue';
import { type Component, computed, markRaw, onActivated, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { RouteKey } from '@elegant-router/types';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
@@ -865,14 +865,8 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
return 'workbench-todo__deadline--slate';
}
onMounted(async () => {
await Promise.all([
loadMyTaskItems(),
loadPersonalTodoItems(),
loadOvertimeApprovalItems(),
loadWorkReportApprovalItems()
]);
});
// 工作台路由 keepAlive切回不重挂用 onActivated 替代 onMounted每次激活经 refresh 重拉(首挂也会触发,不漏首屏)
onActivated(refresh);
</script>
<template>