Files
cn-rdms-web/src/views/project/project/execution/modules/task-worklog-content.vue

323 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { fetchGetProjectTaskWorklogPage } from '@/service/api/project';
import { useAuthStore } from '@/store/modules/auth';
import { formatDate, getProgressText, getTaskStatusName, getTaskStatusTagType } from '../shared';
import type { WorklogChangedPayload } from '../shared';
import TaskWorklogPanel from './task-worklog-panel.vue';
defineOptions({ name: 'ProjectExecutionTaskWorklogContent' });
interface Props {
task: Api.Project.ProjectTask | null;
/** 是否激活;放进 tab 时由父级控制按需加载 */
active?: boolean;
}
interface Emits {
(e: 'changed', payload: WorklogChangedPayload): void;
}
const props = withDefaults(defineProps<Props>(), {
active: true
});
const emit = defineEmits<Emits>();
const authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || '');
const isOwner = computed(() => Boolean(props.task?.ownerId && props.task.ownerId === currentUserId.value));
const records = ref<Api.Project.TaskWorklog[]>([]);
const recordsLoading = ref(false);
const ownerName = computed(() => props.task?.ownerNickname?.trim() || props.task?.ownerId || '--');
const statusName = computed(() => (props.task ? getTaskStatusName(props.task) : ''));
const statusTagType = computed(() => (props.task ? getTaskStatusTagType(props.task.statusCode) : 'info'));
const progressText = computed(() => getProgressText(props.task?.progressRate));
const plannedStartText = computed(() =>
props.task?.plannedStartDate ? formatDate(props.task.plannedStartDate) : '--'
);
const plannedEndText = computed(() => (props.task?.plannedEndDate ? formatDate(props.task.plannedEndDate) : '--'));
const actualStartText = computed(() => (props.task?.actualStartDate ? formatDate(props.task.actualStartDate) : '--'));
const actualEndText = computed(() => (props.task?.actualEndDate ? formatDate(props.task.actualEndDate) : '--'));
// 协办人视角 records 只含自身;责任人视角 records 含全员
const totalHours = computed(() => records.value.reduce((sum, item) => sum + (item.durationHours ?? 0), 0));
const totalHoursText = computed(() => {
if (recordsLoading.value) return '...';
return `${totalHours.value.toFixed(1)} h`;
});
// 责任人视角下"总工时" hover 展示按用户分组的明细;协办人视角不计算
// 候选范围:责任人 + 所有协办人 + records 中出现过的用户(兜底已退出协办人);
// 没填过工时的显示 0h
const hoursByUserDetail = computed(() => {
if (!isOwner.value) return [];
const sumMap = new Map<string, number>();
for (const item of records.value) {
sumMap.set(item.userId, (sumMap.get(item.userId) ?? 0) + (item.durationHours ?? 0));
}
const nicknameMap = new Map<string, string>();
const userIds: string[] = [];
const pushUser = (userId: string | null | undefined, name: string | null | undefined) => {
if (!userId || nicknameMap.has(userId)) return;
nicknameMap.set(userId, name?.trim() || userId);
userIds.push(userId);
};
pushUser(props.task?.ownerId, props.task?.ownerNickname);
for (const assignee of props.task?.assignees ?? []) {
pushUser(assignee.userId, assignee.nickname);
}
// records 中可能存在已退出协办人,按 worklog 自身昵称回填
for (const item of records.value) {
pushUser(item.userId, item.userNickname);
}
const arr = userIds.map(userId => ({
userId,
name: nicknameMap.get(userId) || userId,
hours: sumMap.get(userId) ?? 0
}));
// 责任人置顶其余按工时降序0h 自然落在最后)
arr.sort((a, b) => {
if (a.userId === props.task?.ownerId) return -1;
if (b.userId === props.task?.ownerId) return 1;
return b.hours - a.hours;
});
return arr;
});
async function loadRecords() {
if (!props.task) {
records.value = [];
return;
}
if (!currentUserId.value) {
records.value = [];
return;
}
recordsLoading.value = true;
const params: Api.Project.TaskWorklogSearchParams = {
pageNo: 1,
pageSize: -1
};
// 协办人视角:只看自己的 worklogowner 视角:全量加载
if (!isOwner.value) {
params.userId = currentUserId.value;
}
const { error, data } = await fetchGetProjectTaskWorklogPage(
props.task.projectId,
props.task.executionId,
props.task.id,
params
);
recordsLoading.value = false;
records.value = error || !data ? [] : data.list;
}
function handleWorklogChanged(payload: WorklogChangedPayload) {
loadRecords();
emit('changed', payload);
}
watch(
() => [props.active, props.task?.id] as const,
([isActive]) => {
if (isActive) {
loadRecords();
}
},
{ immediate: true }
);
</script>
<template>
<div class="task-worklog-content">
<div v-if="task" class="task-worklog-content__cards">
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">负责人</span>
<span class="task-worklog-content__card-value" :title="ownerName">{{ ownerName }}</span>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">任务状态</span>
<ElTag :type="statusTagType" size="small" effect="light" class="task-worklog-content__card-tag">
{{ statusName || '--' }}
</ElTag>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">计划开始</span>
<span class="task-worklog-content__card-value">{{ plannedStartText }}</span>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">计划结束</span>
<span class="task-worklog-content__card-value">{{ plannedEndText }}</span>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">当前进度</span>
<span class="task-worklog-content__card-value">{{ progressText }}</span>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">总工时</span>
<ElTooltip
v-if="isOwner && hoursByUserDetail.length > 0"
placement="top"
effect="light"
popper-class="task-worklog-content__hours-popper"
>
<span
class="task-worklog-content__card-value task-worklog-content__card-value--accent task-worklog-content__card-value--hoverable"
>
{{ totalHoursText }}
</span>
<template #content>
<div class="task-worklog-content__hours-detail">
<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 }"
:title="item.name"
>
{{ item.name }}
</span>
<span class="task-worklog-content__hours-detail-hours">{{ item.hours.toFixed(1) }}h</span>
</div>
</div>
</template>
</ElTooltip>
<span v-else class="task-worklog-content__card-value task-worklog-content__card-value--accent">
{{ totalHoursText }}
</span>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">实际开始</span>
<span class="task-worklog-content__card-value">{{ actualStartText }}</span>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">实际结束</span>
<span class="task-worklog-content__card-value">{{ actualEndText }}</span>
</div>
</div>
<TaskWorklogPanel
v-if="task"
:project-id="task.projectId"
:execution-id="task.executionId"
:task-id="task.id"
:task-owner-id="task.ownerId"
:owner-nickname="task.ownerNickname"
:assignees="task.assignees"
:task-progress-rate="task.progressRate"
:can-submit="true"
:external-list="records"
:show-assignee-column="isOwner"
@changed="handleWorklogChanged"
/>
</div>
</template>
<style scoped lang="scss">
.task-worklog-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.task-worklog-content__cards {
display: grid;
grid-template-columns: repeat(4, 1fr); // 统一 8 卡 4×2 布局
gap: 12px;
}
.task-worklog-content__card {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
padding: 12px 14px;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
}
.task-worklog-content__card-label {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.2;
}
.task-worklog-content__card-value {
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 600;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-worklog-content__card-value--accent {
color: var(--el-color-primary);
}
.task-worklog-content__card-tag {
align-self: flex-start;
}
.task-worklog-content__card-value--hoverable {
cursor: default;
border-bottom: 1px dashed currentColor;
align-self: flex-start;
}
</style>
<style lang="scss">
// tooltip popper 走 teleport必须用全局样式
.task-worklog-content__hours-popper.el-popper {
max-width: 280px;
padding: 8px 10px;
}
.task-worklog-content__hours-detail {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 160px;
}
.task-worklog-content__hours-detail-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 12px;
line-height: 1.4;
font-variant-numeric: tabular-nums;
}
.task-worklog-content__hours-detail-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
color: var(--el-text-color-primary);
&.is-owner {
font-weight: 700;
}
}
.task-worklog-content__hours-detail-hours {
flex: 0 0 auto;
color: var(--el-color-primary);
font-weight: 500;
}
</style>