comprehensiveStatistics.vue 20 KB

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