dayComprehensiveStatistics.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640
  1. <template>
  2. <div class="app-container">
  3. <div class="app-content">
  4. <div class="title">综合统计看板</div>
  5. <el-form class="search-form" :inline="true" label-width="90px">
  6. <el-row :gutter="10">
  7. <!-- 时间范围选择 -->
  8. <el-col :span="6">
  9. <el-form-item label="时间范围">
  10. <el-date-picker
  11. v-model="dateRange"
  12. type="daterange"
  13. range-separator="至"
  14. start-placeholder="开始日期"
  15. end-placeholder="结束日期"
  16. value-format="yyyy-MM-dd"
  17. :picker-options="pickerOptions"
  18. style="width: 100%">
  19. </el-date-picker>
  20. </el-form-item>
  21. </el-col>
  22. <!-- 统计维度选择 -->
  23. <el-col :span="4">
  24. <el-form-item label="统计维度">
  25. <el-select
  26. v-model="queryParams.dimension"
  27. placeholder="请选择统计维度"
  28. @change="handleDimensionChange"
  29. clearable
  30. style="width: 100%">
  31. <el-option label="个人" :value="1"></el-option>
  32. <el-option label="公司" :value="2"></el-option>
  33. <el-option label="部门" :value="3"></el-option>
  34. </el-select>
  35. </el-form-item>
  36. </el-col>
  37. <!-- 公司选择 -->
  38. <el-col :span="4" v-if="showCompanySelect">
  39. <el-form-item label="选择公司">
  40. <el-select
  41. v-model="queryParams.companyId"
  42. placeholder="请选择公司"
  43. @change="handleCompanyChange"
  44. clearable
  45. :disabled="!queryParams.dimension"
  46. style="width: 100%">
  47. <el-option
  48. v-for="company in companyList"
  49. :key="company.companyId"
  50. :label="company.companyName"
  51. :value="company.companyId">
  52. </el-option>
  53. </el-select>
  54. </el-form-item>
  55. </el-col>
  56. <!-- 部门选择 -->
  57. <el-col :span="4" v-if="showDepartmentSelect">
  58. <el-form-item label="选择部门">
  59. <el-cascader
  60. v-model="selectedDeptIds"
  61. :options="deptTreeData"
  62. :props="deptCascaderProps"
  63. placeholder="请选择部门"
  64. clearable
  65. style="width: 100%"
  66. @change="handleDeptChange">
  67. </el-cascader>
  68. </el-form-item>
  69. </el-col>
  70. <!-- 操作按钮 -->
  71. <el-col :span="6">
  72. <el-form-item label-width="0" style="padding-top: 35px;">
  73. <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
  74. <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
  75. <el-button type="success" icon="el-icon-download" size="mini" @click="handleExport" :loading="exportLoading">导出</el-button>
  76. </el-form-item>
  77. </el-col>
  78. </el-row>
  79. </el-form>
  80. </div>
  81. <!-- 数据表格 -->
  82. <div class="table-section">
  83. <el-table v-loading="loading" :data="paginatedTableData" border style="width: 100%" height="600">
  84. <!-- 添加时间列 -->
  85. <el-table-column prop="statisticsTime" label="统计时间" width="180">
  86. <template slot-scope="scope">
  87. {{ formatDateTime(scope.row.statisticsTime) }}
  88. </template>
  89. </el-table-column>
  90. <el-table-column prop="companyName" label="公司名称"/>
  91. <el-table-column prop="deptName" label="部门名称" v-if="showDeptNameColumn" />
  92. <!-- 根据维度决定是否显示人员姓名 -->
  93. <el-table-column prop="companyUserName" label="人员名称" v-if="showUserNameColumn" />
  94. <!-- 更新以下列为新的字段名 -->
  95. <el-table-column prop="sendCount" label="发课数">
  96. <template slot-scope="scope">
  97. {{ scope.row.sendCount || 0 }}
  98. </template>
  99. </el-table-column>
  100. <el-table-column prop="completeNum" label="完课数">
  101. <template slot-scope="scope">
  102. {{ scope.row.completeNum || 0 }}
  103. </template>
  104. </el-table-column>
  105. <!-- 完播率 -->
  106. <el-table-column prop="completeRate" label="完播率" width="100">
  107. <template slot-scope="scope">
  108. {{ calculateCompleteRate(scope.row) }}
  109. </template>
  110. </el-table-column>
  111. <el-table-column prop="answerNum" label="答题数">
  112. <template slot-scope="scope">
  113. {{ scope.row.answerNum || 0 }}
  114. </template>
  115. </el-table-column>
  116. <!-- 答题率 -->
  117. <el-table-column prop="answerRate" label="答题率" width="100">
  118. <template slot-scope="scope">
  119. {{ calculateAnswerRate(scope.row) }}
  120. </template>
  121. </el-table-column>
  122. <el-table-column prop="redPacketNum" label="红包领取数">
  123. <template slot-scope="scope">
  124. {{ scope.row.redPacketNum || 0 }}
  125. </template>
  126. </el-table-column>
  127. <el-table-column prop="redPacketAmount" label="红包金额(元)">
  128. <template slot-scope="scope">
  129. {{ scope.row.redPacketAmount || 0 }}
  130. </template>
  131. </el-table-column>
  132. </el-table>
  133. <el-pagination
  134. @size-change="handleSizeChange"
  135. @current-change="handleCurrentChange"
  136. :current-page="currentPage"
  137. :page-sizes="[10, 20, 50, 100]"
  138. :page-size="pageSize"
  139. layout="total, sizes, prev, pager, next, jumper"
  140. :total="tableData.length" style="margin-top: 20px; text-align: right;">
  141. </el-pagination>
  142. </div>
  143. </div>
  144. </template>
  145. <script>
  146. import { getStatisticsData, getSearchUserInfo, getSearchCompanyInfo, getSearchDeptInfo } from "@/api/statistics/statistics";
  147. export default {
  148. data() {
  149. return {
  150. exportLoading: false, // 添加导出加载状态
  151. loading: false,
  152. lastQueryDimension: null, // 记录上次查询的维度
  153. companyList: [],
  154. deptList: [], // 原始部门数据
  155. deptTreeData: [], // 树形部门数据
  156. selectedDeptIds: [], // 选中的部门ID路径
  157. userList: [],
  158. rawData: [],
  159. tableData: [],
  160. currentPage: 1,
  161. pageSize: 20,
  162. dateRange: [],
  163. pickerOptions: {
  164. shortcuts: [{
  165. text: '最近一周',
  166. onClick(picker) {
  167. const end = new Date();
  168. const start = new Date();
  169. start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
  170. picker.$emit('pick', [start, end]);
  171. }
  172. }, {
  173. text: '最近一个月',
  174. onClick(picker) {
  175. const end = new Date();
  176. const start = new Date();
  177. start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
  178. picker.$emit('pick', [start, end]);
  179. }
  180. }, {
  181. text: '最近三个月',
  182. onClick(picker) {
  183. const end = new Date();
  184. const start = new Date();
  185. start.setTime(start.getTime() - 3600 * 1000 * 24 * 90);
  186. picker.$emit('pick', [start, end]);
  187. }
  188. }]
  189. },
  190. deptCascaderProps: {
  191. value: 'deptId',
  192. label: 'deptName',
  193. children: 'children',
  194. expandTrigger: 'hover',
  195. checkStrictly: true, // 允许选择任意一级
  196. emitPath: false // 只返回选中节点的值,而不是整个路径
  197. },
  198. queryParams: {
  199. dimension: null,
  200. companyId: null,
  201. deptId: null,
  202. userId: null
  203. }
  204. };
  205. },
  206. computed: {
  207. paginatedTableData() {
  208. const start = (this.currentPage - 1) * this.pageSize;
  209. const end = start + this.pageSize;
  210. return this.tableData.slice(start, end);
  211. },
  212. showCompanySelect() {
  213. // 公司维度不需要显示公司选择,其他维度都需要
  214. return this.queryParams.dimension && this.queryParams.dimension !== 2;
  215. },
  216. showDepartmentSelect() {
  217. // 部门维度不需要显示部门选择,个人维度需要显示
  218. return this.queryParams.dimension === 1 && this.queryParams.companyId;
  219. },
  220. // 控制部门名称列显示
  221. showDeptNameColumn() {
  222. // 基于上次查询的维度来决定是否显示部门名称列
  223. return this.lastQueryDimension && this.lastQueryDimension !== 2;
  224. },
  225. // 控制人员姓名列显示
  226. showUserNameColumn() {
  227. // 只有个人维度显示人员姓名列
  228. return this.lastQueryDimension === 1;
  229. }
  230. },
  231. mounted() {
  232. // 设置默认时间为当天
  233. const today = new Date();
  234. this.dateRange = [today, today];
  235. // 默认设置统计维度为公司
  236. this.queryParams.dimension = 2;
  237. this.loadData();
  238. // 页面初始化时查询一次数据
  239. this.fetchStatisticsData();
  240. },
  241. methods: {
  242. // 在 loadData 方法中也移除自动查询
  243. loadData() {
  244. // 使用 getSearchCompanyInfo 获取公司下拉数据
  245. getSearchCompanyInfo().then(response => {
  246. this.companyList = response.data || [];
  247. // 默认选择第一个公司(如果存在)
  248. if (this.companyList.length > 0 && this.queryParams.dimension !== 2) {
  249. this.queryParams.companyId = this.companyList[0].companyId;
  250. // 加载该公司的部门数据
  251. return this.loadDeptData(this.queryParams.companyId);
  252. }
  253. }).then(() => {
  254. // 移除自动查询调用,只保留初始加载
  255. // return this.fetchStatisticsData();
  256. }).catch(error => {
  257. console.error('数据加载失败:', error);
  258. this.$message.error('数据加载失败');
  259. });
  260. },
  261. loadDeptData(companyId) {
  262. if (!companyId) return Promise.resolve();
  263. return getSearchDeptInfo({ id: companyId }).then(response => {
  264. const deptData = response.data || [];
  265. this.deptList = deptData;
  266. // 如果后端直接返回树形结构(第一层数据有children),直接使用
  267. // 否则通过buildDeptTree构建树形结构
  268. const hasTreeStructure = deptData.some(dept => dept.children && dept.children.length > 0);
  269. if (hasTreeStructure) {
  270. this.deptTreeData = deptData.filter(dept => dept.parentId === 0 || dept.parentId === null);
  271. } else {
  272. this.deptTreeData = this.buildDeptTree(deptData);
  273. }
  274. // 默认选择第一个部门(如果存在)
  275. if (this.deptTreeData.length > 0 && this.queryParams.dimension === 1) {
  276. // 选择第一个顶级部门
  277. this.queryParams.deptId = this.deptTreeData[0].deptId;
  278. this.selectedDeptIds = [this.queryParams.deptId];
  279. }
  280. }).catch(error => {
  281. console.error('部门数据加载失败:', error);
  282. this.$message.error('部门数据加载失败');
  283. });
  284. },
  285. // 构建部门树形结构
  286. buildDeptTree(deptList) {
  287. if (!deptList || deptList.length === 0) return [];
  288. // 创建部门映射
  289. const deptMap = {};
  290. deptList.forEach(dept => {
  291. // 如果后端返回的数据中已经有children字段且有值,则使用后端的children
  292. // 否则初始化为空数组
  293. deptMap[dept.deptId] = {
  294. ...dept,
  295. children: (dept.children && dept.children.length > 0) ? dept.children : []
  296. };
  297. });
  298. // 构建树形结构
  299. const tree = [];
  300. deptList.forEach(dept => {
  301. if (dept.parentId === 0 || dept.parentId === null) {
  302. // 顶级部门
  303. tree.push(deptMap[dept.deptId]);
  304. } else {
  305. // 子部门 - 只有在后端没有返回children的情况下才手动构建
  306. if (deptMap[dept.parentId] && (!dept.children || dept.children.length === 0)) {
  307. deptMap[dept.parentId].children.push(deptMap[dept.deptId]);
  308. }
  309. }
  310. });
  311. // 清理空的children数组,保持数据结构整洁
  312. const cleanEmptyChildren = (nodes) => {
  313. nodes.forEach(node => {
  314. if (node.children && node.children.length === 0) {
  315. delete node.children;
  316. } else if (node.children && node.children.length > 0) {
  317. cleanEmptyChildren(node.children);
  318. }
  319. });
  320. };
  321. cleanEmptyChildren(tree);
  322. return tree;
  323. },
  324. // 添加时间格式化方法
  325. formatDateTime(dateString) {
  326. if (!dateString) return '';
  327. // 移除时区信息并格式化日期时间
  328. const date = new Date(dateString.replace(/\.\d{3}\+\d{4}$/, ''));
  329. const year = date.getFullYear();
  330. const month = String(date.getMonth() + 1).padStart(2, '0');
  331. const day = String(date.getDate()).padStart(2, '0');
  332. return `${year}-${month}-${day}`;
  333. },
  334. formatDate(date) {
  335. if (!date) return '';
  336. const d = new Date(date);
  337. const year = d.getFullYear();
  338. const month = String(d.getMonth() + 1).padStart(2, '0');
  339. const day = String(d.getDate()).padStart(2, '0');
  340. return `${year}-${month}-${day}`;
  341. },
  342. fetchStatisticsData() {
  343. this.loading = true; // 开始请求时设置 loading 为 true
  344. // 构造请求参数对象
  345. const params = {
  346. dimension: this.queryParams.dimension,
  347. startTime: this.formatDate(this.dateRange[0]),
  348. endTime: this.formatDate(this.dateRange[1])
  349. };
  350. // 根据新规则设置id参数
  351. if (this.queryParams.dimension === 1 && this.queryParams.deptId) {
  352. // 个人维度传递部门ID
  353. params.id = this.queryParams.deptId;
  354. } else if (this.queryParams.dimension === 3 && this.queryParams.companyId) {
  355. // 部门维度传递公司ID
  356. params.id = this.queryParams.companyId;
  357. }
  358. // 公司维度不传递ID参数
  359. // 以POST方式发送请求体
  360. return getStatisticsData(params).then(response => {
  361. this.rawData = response.data || [];
  362. this.tableData = [...this.rawData];
  363. this.currentPage = 1;
  364. }).catch(error => {
  365. console.error('统计数据加载失败:', error);
  366. this.$message.error('统计数据加载失败');
  367. }).finally(() => {
  368. this.loading = false; // 请求完成后设置 loading 为 false
  369. });
  370. },
  371. handleCompanyChange(companyId) {
  372. this.queryParams.deptId = null;
  373. this.selectedDeptIds = [];
  374. this.queryParams.userId = null;
  375. this.deptList = [];
  376. this.deptTreeData = [];
  377. this.userList = [];
  378. if (!companyId) return;
  379. // 使用 getSearchDeptInfo 获取部门下拉数据
  380. getSearchDeptInfo({ id: companyId }).then(response => {
  381. const deptData = response.data || [];
  382. this.deptList = deptData;
  383. // 如果后端直接返回树形结构(第一层数据有children),直接使用
  384. // 否则通过buildDeptTree构建树形结构
  385. const hasTreeStructure = deptData.some(dept => dept.children && dept.children.length > 0);
  386. if (hasTreeStructure) {
  387. this.deptTreeData = deptData.filter(dept => dept.parentId === 0 || dept.parentId === null);
  388. } else {
  389. this.deptTreeData = this.buildDeptTree(deptData);
  390. }
  391. // 默认选择第一个部门(如果存在)
  392. // 仅在个人维度下默认选择第一个部门
  393. if (this.deptTreeData.length > 0 && this.queryParams.dimension === 1) {
  394. // 选择第一个顶级部门
  395. this.queryParams.deptId = this.deptTreeData[0].deptId;
  396. this.selectedDeptIds = [this.queryParams.deptId];
  397. }
  398. }).catch(error => {
  399. console.error('部门数据加载失败:', error);
  400. this.$message.error('部门数据加载失败');
  401. });
  402. // 移除.then()后面的自动查询调用
  403. },
  404. handleDeptChange(selectedDeptId) {
  405. // 更新选中的部门ID
  406. this.queryParams.deptId = selectedDeptId;
  407. },
  408. handleQuery() {
  409. // 记录当前查询的维度
  410. this.lastQueryDimension = this.queryParams.dimension;
  411. // 触发统计数据请求
  412. this.fetchStatisticsData();
  413. },
  414. handleDimensionChange() {
  415. // 重置后续选择项
  416. this.queryParams.companyId = null;
  417. this.queryParams.deptId = null;
  418. this.selectedDeptIds = [];
  419. this.queryParams.userId = null;
  420. this.deptList = [];
  421. this.deptTreeData = [];
  422. this.userList = [];
  423. },
  424. resetQuery() {
  425. this.queryParams = {
  426. dimension: 2, // 重置为公司维度
  427. companyId: null,
  428. deptId: null,
  429. userId: null
  430. };
  431. this.selectedDeptIds = []; // 重置部门选择
  432. // 重置时间为当天
  433. const today = new Date();
  434. this.dateRange = [today, today];
  435. this.deptList = [];
  436. this.deptTreeData = [];
  437. this.userList = [];
  438. this.tableData = [...this.rawData];
  439. this.currentPage = 1;
  440. // 重置后立即发送请求
  441. this.lastQueryDimension = 2; // 同时更新lastQueryDimension
  442. this.fetchStatisticsData();
  443. },
  444. handleSizeChange(val) {
  445. this.pageSize = val;
  446. this.currentPage = 1;
  447. },
  448. handleCurrentChange(val) {
  449. this.currentPage = val;
  450. },
  451. // 计算完播率:完课数 / 发送数
  452. calculateCompleteRate(row) {
  453. const sendCount = row.sendCount || 0;
  454. const completeNum = row.completeNum || 0;
  455. if (sendCount === 0) {
  456. return '0%';
  457. }
  458. const rate = (completeNum / sendCount * 100).toFixed(2);
  459. return rate + '%';
  460. },
  461. // 计算答题率:答题数 / 完课数
  462. calculateAnswerRate(row) {
  463. const completeNum = row.completeNum || 0;
  464. const answerNum = row.answerNum || 0;
  465. if (completeNum === 0) {
  466. return '0%';
  467. }
  468. const rate = (answerNum / completeNum * 100).toFixed(2);
  469. return rate + '%';
  470. },
  471. // 添加导出方法
  472. handleExport() {
  473. if (this.tableData.length === 0) {
  474. this.$message.warning('没有可导出的数据');
  475. return;
  476. }
  477. this.exportLoading = true;
  478. try {
  479. // 创建 CSV 内容
  480. const headers = [
  481. '公司名称', '部门名称', '人员名称',
  482. '发课数', '完课数', '完播率', '答题数', '答题率',
  483. '红包领取数', '红包金额(元)'
  484. ];
  485. const csvContent = [
  486. headers.join(','),
  487. ...this.tableData.map(row => [
  488. row.companyName || '',
  489. row.deptName || '',
  490. row.companyUserName || '',
  491. row.sendCount || 0,
  492. row.completeNum || 0,
  493. this.calculateCompleteRate(row),
  494. row.answerNum || 0,
  495. this.calculateAnswerRate(row),
  496. row.redPacketNum || 0,
  497. row.redPacketAmount || 0
  498. ].map(field => `"${field}"`).join(','))
  499. ].join('\n');
  500. // 创建下载链接
  501. const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
  502. const link = document.createElement('a');
  503. const url = URL.createObjectURL(blob);
  504. link.setAttribute('href', url);
  505. // link.setAttribute('download', `综合统计看板_${new Date().toISOString().slice(0, 10)}.csv`);
  506. // 修改为:
  507. const now = new Date();
  508. const timestamp = now.getFullYear() +
  509. String(now.getMonth() + 1).padStart(2, '0') +
  510. String(now.getDate()).padStart(2, '0') +
  511. String(now.getHours()).padStart(2, '0') +
  512. String(now.getMinutes()).padStart(2, '0') +
  513. String(now.getSeconds()).padStart(2, '0');
  514. link.setAttribute('download', `综合统计看板_${timestamp}.csv`);
  515. link.style.visibility = 'hidden';
  516. document.body.appendChild(link);
  517. link.click();
  518. document.body.removeChild(link);
  519. this.$message.success('导出成功');
  520. } catch (error) {
  521. console.error('导出失败:', error);
  522. this.$message.error('导出失败');
  523. } finally {
  524. this.exportLoading = false;
  525. }
  526. }
  527. }
  528. };
  529. </script>
  530. <style scoped>
  531. .app-container {
  532. border: 1px solid #e6e6e6;
  533. padding: 12px;
  534. background-color: #f5f7fa;
  535. }
  536. .app-content {
  537. background-color: white;
  538. padding: 20px;
  539. border-radius: 4px;
  540. margin-bottom: 20px;
  541. }
  542. .title {
  543. text-align: center;
  544. font-size: 24px;
  545. font-weight: bold;
  546. color: #333;
  547. margin-bottom: 20px;
  548. }
  549. .search-form {
  550. margin-bottom: 0;
  551. }
  552. .search-form .el-form-item {
  553. margin-bottom: 18px;
  554. width: 100%;
  555. }
  556. .search-form .el-row {
  557. margin-left: 0 !important;
  558. margin-right: 0 !important;
  559. }
  560. .table-section {
  561. background-color: white;
  562. padding: 20px;
  563. border-radius: 4px;
  564. }
  565. .table-section .el-table {
  566. width: 100% !important;
  567. }
  568. @media (min-width: 1200px) {
  569. .table-section {
  570. padding: 20px 50px;
  571. }
  572. }
  573. </style>