You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

507 lines
18 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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 聚合评分次数(不涉及工时)
*
* 为什么前三种走 aggregateWorklogsscore 走 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>