|
|
<template>
|
|
|
<div class="p-2">
|
|
|
<el-card shadow="never" class="mb-3">
|
|
|
<el-form :model="queryParams" :inline="true" label-width="90px">
|
|
|
<el-form-item label="关闭月份">
|
|
|
<el-date-picker v-model="finishMonth" type="month" value-format="YYYY-MM" placeholder="请选择月份" clearable @change="handleQuery" />
|
|
|
</el-form-item>
|
|
|
<el-form-item label="任务类型">
|
|
|
<el-select v-model="queryParams.taskType" placeholder="请选择任务类型" clearable>
|
|
|
<el-option v-for="dict in temp_task_type" :key="dict.value" :label="dict.label" :value="dict.value" />
|
|
|
</el-select>
|
|
|
</el-form-item>
|
|
|
<el-form-item label="主执行人">
|
|
|
<el-input v-model="queryParams.assigneeName" placeholder="请输入主执行人" clearable @keyup.enter="handleQuery" />
|
|
|
</el-form-item>
|
|
|
<el-form-item label="项目">
|
|
|
<el-input v-model="queryParams.projectName" placeholder="请输入项目名称" clearable @keyup.enter="handleQuery" />
|
|
|
</el-form-item>
|
|
|
<el-form-item label="归集部门">
|
|
|
<el-select v-model="queryParams.deptId" placeholder="请选择部门" clearable filterable>
|
|
|
<el-option v-for="dept in deptList" :key="dept.deptId" :label="dept.deptName" :value="dept.deptId" />
|
|
|
</el-select>
|
|
|
</el-form-item>
|
|
|
<el-form-item>
|
|
|
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
|
|
|
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
|
|
|
<el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['oa/erp:tempTask:export']">导出明细</el-button>
|
|
|
</el-form-item>
|
|
|
</el-form>
|
|
|
</el-card>
|
|
|
|
|
|
<div class="metric-grid mb-3">
|
|
|
<el-card v-for="item in metrics" :key="item.label" shadow="never">
|
|
|
<div class="metric-label">{{ item.label }}</div>
|
|
|
<div class="metric-value">{{ item.value }}</div>
|
|
|
</el-card>
|
|
|
</div>
|
|
|
|
|
|
<el-card shadow="never">
|
|
|
<el-tabs v-model="activeDimension">
|
|
|
<el-tab-pane label="按人员工时" name="member" />
|
|
|
<el-tab-pane label="按项目" name="project" />
|
|
|
<el-tab-pane label="按归集部门" name="dept" />
|
|
|
<el-tab-pane label="按评分" name="score" />
|
|
|
</el-tabs>
|
|
|
|
|
|
<el-table v-loading="loading" :data="activeRows" border>
|
|
|
<el-table-column type="index" label="序号" width="70" align="center" />
|
|
|
<el-table-column :label="dimensionLabel" prop="name" min-width="220" show-overflow-tooltip align="center" />
|
|
|
<el-table-column label="任务数" prop="taskCount" width="100" align="center" />
|
|
|
<el-table-column label="工时合计" prop="hours" width="120" align="center" />
|
|
|
<el-table-column label="平均工时" prop="avgHours" width="120" align="center" />
|
|
|
<el-table-column label="评分数" prop="scoreCount" width="100" align="center" />
|
|
|
<el-table-column label="评分分布" prop="scoreSummary" min-width="180" show-overflow-tooltip align="center" />
|
|
|
</el-table>
|
|
|
|
|
|
<el-divider />
|
|
|
|
|
|
<el-table :data="filteredTasks" border>
|
|
|
<el-table-column type="index" label="序号" width="70" align="center" />
|
|
|
<el-table-column label="任务编号" prop="tempTaskCode" width="150" align="center" />
|
|
|
<el-table-column label="任务类型" prop="taskType" width="100" align="center">
|
|
|
<template #default="scope">
|
|
|
<dict-tag :options="temp_task_type" :value="scope.row.taskType" />
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
<el-table-column label="任务描述" prop="taskDesc" min-width="240" show-overflow-tooltip align="center" />
|
|
|
<el-table-column label="主执行人" prop="assigneeName" width="110" align="center" />
|
|
|
<el-table-column label="项目名称" prop="projectName" min-width="170" show-overflow-tooltip align="center" />
|
|
|
<el-table-column label="归集部门" prop="deptName" width="130" align="center" />
|
|
|
<el-table-column label="工时合计" prop="totalHours" width="100" align="center" />
|
|
|
<el-table-column label="评分摘要" min-width="180" show-overflow-tooltip align="center">
|
|
|
<template #default="scope">{{ buildTaskScoreSummary(scope.row) }}</template>
|
|
|
</el-table-column>
|
|
|
<el-table-column label="关闭时间" prop="actualFinishTime" width="160" align="center">
|
|
|
<template #default="scope">
|
|
|
<span>{{ formatDateTime(scope.row.actualFinishTime) }}</span>
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
</el-table>
|
|
|
</el-card>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup name="TempTaskReport" lang="ts">
|
|
|
import { listTempTask } from '@/api/oa/erp/tempTask';
|
|
|
import type { TempTaskQuery, TempTaskScoreVO, TempTaskVO, TempTaskWorklogVO } from '@/api/oa/erp/tempTask/types';
|
|
|
import type { DeptVO } from '@/api/system/dept/types';
|
|
|
import { allListDept } from '@/api/system/dept';
|
|
|
|
|
|
interface ReportWorklogRow extends TempTaskWorklogVO {
|
|
|
taskId: string | number;
|
|
|
taskCode: string;
|
|
|
projectName?: string;
|
|
|
deptName?: string;
|
|
|
}
|
|
|
|
|
|
interface ReportScoreRow extends TempTaskScoreVO {
|
|
|
taskId: string | number;
|
|
|
}
|
|
|
|
|
|
interface AggregateRow {
|
|
|
name: string;
|
|
|
taskCount: number;
|
|
|
hours: number;
|
|
|
avgHours: number;
|
|
|
scoreCount: number;
|
|
|
scoreSummary: string;
|
|
|
}
|
|
|
|
|
|
interface AggregateDraft {
|
|
|
name: string;
|
|
|
taskIds: Set<string | number>;
|
|
|
hours: number;
|
|
|
scoreGrades: string[];
|
|
|
}
|
|
|
|
|
|
const { proxy } = getCurrentInstance() as any;
|
|
|
const { temp_task_type, temp_task_score } = toRefs<any>(proxy?.useDict('temp_task_type', 'temp_task_score'));
|
|
|
|
|
|
const loading = ref(false);
|
|
|
const activeDimension = ref('member');
|
|
|
const deptList = ref<DeptVO[]>([]);
|
|
|
const currentMonth = () => {
|
|
|
const now = new Date();
|
|
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
|
};
|
|
|
const finishMonth = ref(currentMonth());
|
|
|
const detailRows = ref<TempTaskVO[]>([]);
|
|
|
const runningTotal = ref(0);
|
|
|
|
|
|
const queryParams = ref<TempTaskQuery>({
|
|
|
pageNum: 1,
|
|
|
pageSize: 999999,
|
|
|
taskStatus: '5',
|
|
|
taskType: undefined,
|
|
|
assigneeName: undefined,
|
|
|
projectName: undefined,
|
|
|
deptId: undefined,
|
|
|
params: {}
|
|
|
});
|
|
|
|
|
|
/**
|
|
|
* 根据所选关闭月份推导 monthRange(月初到月末的精确时间戳)。
|
|
|
*
|
|
|
* 为什么用 monthRange 而不直接用 finishMonth:
|
|
|
* actualFinishTime 是 datetime 字段,数据库查询需要精确的起止时间戳。
|
|
|
* `new Date(year, month, 0)` 获取的是该月最后一天的 Date 对象,
|
|
|
* 通过 getDate() 得到当月天数(自动处理 28/29/30/31 天的差异)。
|
|
|
*
|
|
|
* monthRange 通过 queryParams.params 传递到后端 MyBatis XML 的 SQL 条件中,
|
|
|
* 用于 `actualFinishTime BETWEEN beginActualFinishTime AND endActualFinishTime`。
|
|
|
*/
|
|
|
const monthRange = computed(() => {
|
|
|
if (!finishMonth.value) {
|
|
|
return {};
|
|
|
}
|
|
|
const [year, month] = finishMonth.value.split('-').map(Number);
|
|
|
// new Date(year, month, 0) -> 当月最后一天,getDate() 自动处理闰年2月等
|
|
|
const end = new Date(year, month, 0);
|
|
|
return {
|
|
|
beginActualFinishTime: `${finishMonth.value}-01 00:00:00`,
|
|
|
endActualFinishTime: `${finishMonth.value}-${String(end.getDate()).padStart(2, '0')} 23:59:59`
|
|
|
};
|
|
|
});
|
|
|
|
|
|
/**
|
|
|
* 前筛:过滤掉无 actualFinishTime 的任务 + 按部门筛选。
|
|
|
* 为什么 deptId 过滤放在前端而非 SQL:
|
|
|
* 报表需要展示按人/项目/部门/评分四种聚合维度,
|
|
|
* 如果部门过滤放在后端,切换维度时前端无法按其他维度重新聚合已过滤数据。
|
|
|
* 前端一次性拉取全量数据后在内存做多维度聚合,部门过滤只是视图层的一个切片。
|
|
|
*/
|
|
|
const filteredTasks = computed(() => {
|
|
|
return detailRows.value.filter((row) => {
|
|
|
if (!row.actualFinishTime) {
|
|
|
return false;
|
|
|
}
|
|
|
if (queryParams.value.deptId && String(row.deptId || '') !== String(queryParams.value.deptId)) {
|
|
|
return false;
|
|
|
}
|
|
|
return true;
|
|
|
});
|
|
|
});
|
|
|
|
|
|
/**
|
|
|
* 将任务列表展平为工时明细行,每行携带所属任务的快照字段(projectName/deptName)。
|
|
|
* flatMap 将 [task, task, ...] 展开为 [worklog, worklog, ...],
|
|
|
* 用于后续按人/项目/部门维度聚合。
|
|
|
*/
|
|
|
const worklogRows = computed<ReportWorklogRow[]>(() => {
|
|
|
return filteredTasks.value.flatMap((task) =>
|
|
|
(task.worklogs || []).map((worklog) => ({
|
|
|
...worklog,
|
|
|
taskId: task.tempTaskId,
|
|
|
taskCode: task.tempTaskCode,
|
|
|
projectName: task.projectName,
|
|
|
deptName: task.deptName
|
|
|
}))
|
|
|
);
|
|
|
});
|
|
|
|
|
|
/**
|
|
|
* 将任务列表展平为评分记录行,用于评分维度的聚合和分布统计。
|
|
|
*/
|
|
|
const scoreRows = computed<ReportScoreRow[]>(() => {
|
|
|
return filteredTasks.value.flatMap((task) =>
|
|
|
(task.scores || []).map((score) => ({
|
|
|
...score,
|
|
|
taskId: task.tempTaskId
|
|
|
}))
|
|
|
);
|
|
|
});
|
|
|
|
|
|
const totalHours = computed(() => sumHours(worklogRows.value));
|
|
|
|
|
|
const metrics = computed(() => [
|
|
|
{ label: '关闭任务数', value: filteredTasks.value.length },
|
|
|
{ label: '工时合计', value: formatNumber(totalHours.value) },
|
|
|
{ label: '评分记录数', value: scoreRows.value.length },
|
|
|
{ label: '未关闭任务数', value: runningTotal.value }
|
|
|
]);
|
|
|
|
|
|
const dimensionLabel = computed(() => {
|
|
|
const labelMap: Record<string, string> = {
|
|
|
member: '人员',
|
|
|
project: '项目',
|
|
|
dept: '归集部门',
|
|
|
score: '评分等级'
|
|
|
};
|
|
|
return labelMap[activeDimension.value] || '维度';
|
|
|
});
|
|
|
|
|
|
/**
|
|
|
* 根据当前激活的聚合维度计算上表格的数据行。
|
|
|
*
|
|
|
* 四种维度:
|
|
|
* - member(按人员工时): 以填报人姓名为 key 聚合工时和次数
|
|
|
* - project(按项目): 以关联项目名称为 key 聚合工时和次数
|
|
|
* - dept(按归集部门): 以归集部门名称为 key 聚合工时和次数
|
|
|
* - score(按评分): 以评分等级为 key 聚合评分次数(不涉及工时)
|
|
|
*
|
|
|
* 为什么前三种走 aggregateWorklogs,score 走 aggregateScores:
|
|
|
* 评分维度的聚合对象是评分记录而非工时记录,两者的聚合逻辑和数据源完全不同,
|
|
|
* 分开处理避免在 aggregateWorklogs 中塞入评分聚合的 if-else 分支。
|
|
|
*/
|
|
|
const activeRows = computed(() => {
|
|
|
if (activeDimension.value === 'score') {
|
|
|
return aggregateScores();
|
|
|
}
|
|
|
const fieldMap: Record<string, (row: ReportWorklogRow) => string> = {
|
|
|
member: (row) => row.userName || '未识别人员',
|
|
|
project: (row) => row.projectName || '未绑定项目',
|
|
|
dept: (row) => row.deptName || '未填写归集部门'
|
|
|
};
|
|
|
return aggregateWorklogs(fieldMap[activeDimension.value]);
|
|
|
});
|
|
|
|
|
|
const scoreLabel = (value?: string) => {
|
|
|
const dictList = unref(temp_task_score) || [];
|
|
|
const target = dictList.find((item: any) => item.value === value);
|
|
|
return target?.label || value || '未评分';
|
|
|
};
|
|
|
|
|
|
const buildScoreSummary = (grades: string[]) => {
|
|
|
const countMap = grades.reduce<Record<string, number>>((map, grade) => {
|
|
|
const key = scoreLabel(grade);
|
|
|
map[key] = (map[key] || 0) + 1;
|
|
|
return map;
|
|
|
}, {});
|
|
|
return Object.entries(countMap)
|
|
|
.map(([grade, count]) => `${grade}:${count}`)
|
|
|
.join(',');
|
|
|
};
|
|
|
|
|
|
const buildTaskScoreSummary = (row: TempTaskVO) => {
|
|
|
return buildScoreSummary((row.scores || []).map((score) => score.scoreGrade));
|
|
|
};
|
|
|
|
|
|
const sumHours = (rows: Array<{ hours?: number }>) => {
|
|
|
return rows.reduce((sum, row) => sum + Number(row.hours || 0), 0);
|
|
|
};
|
|
|
|
|
|
const formatNumber = (value: number) => {
|
|
|
return Number(value.toFixed(2));
|
|
|
};
|
|
|
|
|
|
const formatDateTime = (value?: string) => {
|
|
|
if (!value) {
|
|
|
return '';
|
|
|
}
|
|
|
return String(value).replace('T', ' ').slice(0, 16);
|
|
|
};
|
|
|
|
|
|
/**
|
|
|
* 按指定维度聚合工时明细。
|
|
|
*
|
|
|
* keyGetter 决定分组键:
|
|
|
* - member: 按填报人姓名分组
|
|
|
* - project: 按关联项目名称分组
|
|
|
* - dept: 按归集部门名称分组
|
|
|
*
|
|
|
* 聚合字段:
|
|
|
* - taskCount: 通过 Set 去重 taskId 计数(多个 worklog 属于同一任务也算 1 个任务)
|
|
|
* - hours: 累加所有工时
|
|
|
* - scoreGrades: 通过 appendScores 追加评分数据,用于评分分布统计
|
|
|
*
|
|
|
* 为什么 taskCount 用 Set 去重:
|
|
|
* 一个任务下可能有多个 worklog,如果直接 count 行数会多算任务数。
|
|
|
* 使用 Set 保证每个任务只计一次。
|
|
|
*/
|
|
|
const aggregateWorklogs = (keyGetter: (row: ReportWorklogRow) => string) => {
|
|
|
const result = new Map<string, AggregateDraft>();
|
|
|
worklogRows.value.forEach((row) => {
|
|
|
const key = keyGetter(row);
|
|
|
const current =
|
|
|
result.get(key) ||
|
|
|
({
|
|
|
name: key,
|
|
|
taskIds: new Set<string | number>(),
|
|
|
hours: 0,
|
|
|
scoreGrades: []
|
|
|
} as AggregateDraft);
|
|
|
current.taskIds.add(row.taskId);
|
|
|
current.hours += Number(row.hours || 0);
|
|
|
result.set(key, current);
|
|
|
});
|
|
|
appendScores(result, (score) => {
|
|
|
const member = memberNameById(score.memberId);
|
|
|
if (activeDimension.value === 'member') {
|
|
|
return member || '未识别人员';
|
|
|
}
|
|
|
const task = filteredTasks.value.find((item) => String(item.tempTaskId) === String(score.taskId));
|
|
|
if (activeDimension.value === 'project') {
|
|
|
return task?.projectName || '未绑定项目';
|
|
|
}
|
|
|
return task?.deptName || '未填写归集部门';
|
|
|
});
|
|
|
return toAggregateRows(result);
|
|
|
};
|
|
|
|
|
|
const aggregateScores = () => {
|
|
|
const result = new Map<string, AggregateDraft>();
|
|
|
scoreRows.value.forEach((score) => {
|
|
|
const key = scoreLabel(score.scoreGrade);
|
|
|
const current =
|
|
|
result.get(key) ||
|
|
|
({
|
|
|
name: key,
|
|
|
taskIds: new Set<string | number>(),
|
|
|
hours: 0,
|
|
|
scoreGrades: []
|
|
|
} as AggregateDraft);
|
|
|
current.taskIds.add(score.taskId);
|
|
|
current.scoreGrades.push(score.scoreGrade);
|
|
|
result.set(key, current);
|
|
|
});
|
|
|
return toAggregateRows(result);
|
|
|
};
|
|
|
|
|
|
const appendScores = (result: Map<string, AggregateDraft>, keyGetter: (score: ReportScoreRow) => string) => {
|
|
|
scoreRows.value.forEach((score) => {
|
|
|
const key = keyGetter(score);
|
|
|
const current =
|
|
|
result.get(key) ||
|
|
|
({
|
|
|
name: key,
|
|
|
taskIds: new Set<string | number>(),
|
|
|
hours: 0,
|
|
|
scoreGrades: []
|
|
|
} as AggregateDraft);
|
|
|
current.taskIds.add(score.taskId);
|
|
|
current.scoreGrades.push(score.scoreGrade);
|
|
|
result.set(key, current);
|
|
|
});
|
|
|
};
|
|
|
|
|
|
const memberNameById = (memberId: string | number) => {
|
|
|
for (const task of filteredTasks.value) {
|
|
|
const member = (task.members || []).find((item) => String(item.memberId) === String(memberId));
|
|
|
if (member?.userName) {
|
|
|
return member.userName;
|
|
|
}
|
|
|
}
|
|
|
return '';
|
|
|
};
|
|
|
|
|
|
/**
|
|
|
* 将 Map<分组键, AggregateDraft> 转换为用于渲染的 AggregateRow[]。
|
|
|
* 排序规则:工时降序,工时相同时按评分记录数降序。
|
|
|
*/
|
|
|
const toAggregateRows = (result: Map<string, AggregateDraft>): AggregateRow[] => {
|
|
|
return Array.from(result.values())
|
|
|
.map((item) => {
|
|
|
const taskCount = item.taskIds.size;
|
|
|
return {
|
|
|
name: item.name,
|
|
|
taskCount,
|
|
|
hours: formatNumber(item.hours),
|
|
|
avgHours: taskCount ? formatNumber(item.hours / taskCount) : 0,
|
|
|
scoreCount: item.scoreGrades.length,
|
|
|
scoreSummary: buildScoreSummary(item.scoreGrades)
|
|
|
};
|
|
|
})
|
|
|
.sort((a, b) => b.hours - a.hours || b.scoreCount - a.scoreCount);
|
|
|
};
|
|
|
|
|
|
/**
|
|
|
* 构建报表查询参数。
|
|
|
*
|
|
|
* 关键参数说明:
|
|
|
* - monthRange: 通过 params 传递关闭月份范围,后端 SQL WHERE 条件中使用
|
|
|
* - deptId: 设为 undefined,因为 deptId 的过滤在 filteredTasks 中前端处理,
|
|
|
* 不在后端 SQL 中做(避免部门维度与其他维度的交叉过滤产生歧义)
|
|
|
* - includeDetail: true,要求后端返回 members/worklogs/scores 子表数据,
|
|
|
* 报表需要这些明细数据做前端聚合运算(按人/项目/部门/评分多维度统计)
|
|
|
*
|
|
|
* 为什么 includeDetail 设为 true:
|
|
|
* 列表页查询不需要子表数据(性能优化),但报表页的前端聚合统计需要完整的工时明细和评分数据,
|
|
|
* 所以通过 includeDetail 参数要求后端返回子表嵌套数据。
|
|
|
*/
|
|
|
const buildQuery = () => {
|
|
|
queryParams.value.params = { ...monthRange.value };
|
|
|
return {
|
|
|
...queryParams.value,
|
|
|
deptId: undefined,
|
|
|
params: {
|
|
|
...queryParams.value.params,
|
|
|
includeDetail: true
|
|
|
}
|
|
|
};
|
|
|
};
|
|
|
|
|
|
const getDeptList = async () => {
|
|
|
const res = await allListDept({ status: 0 } as any);
|
|
|
deptList.value = res.data || [];
|
|
|
};
|
|
|
|
|
|
/**
|
|
|
* 获取报表数据列表。
|
|
|
*
|
|
|
* 主查询拉取已关闭(taskStatus=5)的任务,同时附带 includeDetail=true 获取子表明细。
|
|
|
* 并行查询未关闭任务数(runningTotal)用于面板展示:
|
|
|
* - taskStatus=2: 审批中
|
|
|
* - taskStatus=3: 执行中
|
|
|
* - taskStatus=4: 待领导审核
|
|
|
*/
|
|
|
const getList = async () => {
|
|
|
loading.value = true;
|
|
|
try {
|
|
|
const res: any = await listTempTask(buildQuery());
|
|
|
detailRows.value = res.rows || [];
|
|
|
// 并行查询三种未关闭状态的任务数量,用于面板的"未关闭任务数"展示
|
|
|
const runningResList = await Promise.all(
|
|
|
['2', '3', '4'].map((status) => listTempTask({ pageNum: 1, pageSize: 1, taskStatus: status } as TempTaskQuery))
|
|
|
);
|
|
|
runningTotal.value = runningResList.reduce((sum: number, item: any) => sum + Number(item.total || 0), 0);
|
|
|
} finally {
|
|
|
loading.value = false;
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const handleQuery = () => {
|
|
|
queryParams.value.pageNum = 1;
|
|
|
getList();
|
|
|
};
|
|
|
|
|
|
const resetQuery = () => {
|
|
|
finishMonth.value = currentMonth();
|
|
|
queryParams.value.taskType = undefined;
|
|
|
queryParams.value.assigneeName = undefined;
|
|
|
queryParams.value.projectName = undefined;
|
|
|
queryParams.value.deptId = undefined;
|
|
|
handleQuery();
|
|
|
};
|
|
|
|
|
|
const handleExport = () => {
|
|
|
proxy?.download('oa/erp/tempTask/export', buildQuery(), `tempTask_report_${new Date().getTime()}.xlsx`);
|
|
|
};
|
|
|
|
|
|
onMounted(async () => {
|
|
|
await getDeptList();
|
|
|
await getList();
|
|
|
});
|
|
|
</script>
|
|
|
|
|
|
<style scoped>
|
|
|
.metric-grid {
|
|
|
display: grid;
|
|
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
|
gap: 12px;
|
|
|
}
|
|
|
|
|
|
.metric-label {
|
|
|
color: var(--el-text-color-secondary);
|
|
|
font-size: 13px;
|
|
|
}
|
|
|
|
|
|
.metric-value {
|
|
|
margin-top: 8px;
|
|
|
color: var(--el-text-color-primary);
|
|
|
font-size: 26px;
|
|
|
font-weight: 600;
|
|
|
line-height: 1;
|
|
|
}
|
|
|
</style>
|