| | |
| | | <el-form-item label="满意度类型" prop="satisfactionScope"> |
| | | <el-select |
| | | v-model="satisfactionScope" |
| | | placeholder="默认在院" |
| | | placeholder="默认离院" |
| | | clearable |
| | | style="width: 140px" |
| | | @change="handleSearch" |
| | |
| | | </el-button> |
| | | <el-button icon="el-icon-refresh" @click="handleReset"> |
| | | 重置 |
| | | </el-button> |
| | | <el-button |
| | | type="warning" |
| | | icon="el-icon-download" |
| | | :loading="exporting" |
| | | @click="handleExportExcel" |
| | | > |
| | | 导出Excel |
| | | </el-button> |
| | | </el-form-item> |
| | | </el-form> |
| | |
| | | |
| | | <script> |
| | | import * as echarts from "echarts"; |
| | | import * as XLSX from "xlsx"; |
| | | import ExcelJS from "exceljs"; |
| | | import { saveAs } from "file-saver"; |
| | | import { |
| | | statistics, |
| | | satisfactionGraph, |
| | |
| | | return { |
| | | // 满意度类型:leave=离院满意度(leaveldeptcodes/leavehospitaldistrictcodes), |
| | | // hospital=在院满意度(deptcodes/hospitaldistrictcodes) |
| | | // 默认在院:保持该页原有请求参数不变 |
| | | satisfactionScope: "hospital", |
| | | // 默认离院满意度 |
| | | satisfactionScope: "leave", |
| | | // 查询参数 |
| | | queryParams: { |
| | | type: 2, |
| | |
| | | |
| | | // 题目明细数据 |
| | | questionDetailData: [], |
| | | // 题目明细全量数据(导出用,不受分页影响) |
| | | questionDetailAll: [], |
| | | |
| | | // 维度题目明细数据 |
| | | dimensionDetailData: [], |
| | |
| | | |
| | | // 各类型统计明细数据 |
| | | typeDetailData: [], |
| | | |
| | | // 导出中(按钮 loading) |
| | | exporting: false, |
| | | |
| | | // 柱状图数据 |
| | | chartData: [], |
| | |
| | | const endIndex = startIndex + this.detailQueryParams.pageSize; |
| | | const paginatedData = detailData.slice(startIndex, endIndex); |
| | | |
| | | // 导出用:保留不分页的全量数据 |
| | | this.questionDetailAll = detailData; |
| | | this.questionDetailData = paginatedData; |
| | | this.detailTotal = detailData.length; |
| | | this.totalAnswerCount = apiData.totalPerson || 0; |
| | |
| | | // 处理重置 |
| | | handleReset() { |
| | | this.$refs.queryForm.resetFields(); |
| | | // 重置后回到默认的离院满意度 |
| | | this.satisfactionScope = "leave"; |
| | | this.queryParams.dateRange = []; |
| | | this.queryParams.deptCodes = []; // 重置为数组 |
| | | this.queryParams.wardCodes = []; // 重置为数组 |
| | |
| | | this.$message.info(`查看类型详情:${row.typeName}`); |
| | | }, |
| | | |
| | | // 处理导出数据 |
| | | // 处理导出数据:按页面这张类型表的列导出该行 |
| | | handleExportData(row) { |
| | | this.$message.success(`正在导出 ${row.typeName} 数据...`); |
| | | if (!row) { |
| | | this.$message.warning("没有可导出的数据"); |
| | | return; |
| | | } |
| | | try { |
| | | const data = [ |
| | | [`满意度类型统计(${row.typeName || ""})`], |
| | | ["序号", "满意度类型", "发送问卷数", "回收问卷数", "回收率", "平均分", "满意度等级"], |
| | | [ |
| | | row.id, |
| | | row.typeName || "", |
| | | Number(row.sendCount) || 0, |
| | | Number(row.receiveCount) || 0, |
| | | this.formatPercent(row.recoveryRate), |
| | | Number(row.averageScore || 0).toFixed(1), |
| | | row.satisfactionLevel || "", |
| | | ], |
| | | ]; |
| | | const ws = XLSX.utils.aoa_to_sheet(data); |
| | | ws["!merges"] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 6 } }]; |
| | | ws["!cols"] = [ |
| | | { wch: 8 }, |
| | | { wch: 22 }, |
| | | { wch: 14 }, |
| | | { wch: 14 }, |
| | | { wch: 12 }, |
| | | { wch: 10 }, |
| | | { wch: 12 }, |
| | | ]; |
| | | const wb = XLSX.utils.book_new(); |
| | | XLSX.utils.book_append_sheet(wb, ws, "Sheet1"); |
| | | XLSX.writeFile(wb, `满意度类型统计_${row.typeName || "导出"}.xlsx`); |
| | | this.$message.success("导出成功"); |
| | | } catch (e) { |
| | | console.error("导出失败:", e); |
| | | this.$message.error("导出失败:" + (e.message || e)); |
| | | } |
| | | }, |
| | | |
| | | // 导出:两张图表(满意度类型、维度统计,图和页面一致)+ 三张明细表(全量,不分页),多 sheet |
| | | async handleExportExcel() { |
| | | if (this.exporting) return; |
| | | this.exporting = true; |
| | | try { |
| | | // 三张明细表是切到对应页签才加载的,导出前先补齐数据,避免导出空表 |
| | | await this.ensureDetailDataLoaded(); |
| | | const wb = new ExcelJS.Workbook(); |
| | | |
| | | // 1、2:图表 sheet(满意度类型 / 维度统计) |
| | | const typeInfo = await this.captureChartImage("satisfactionType"); |
| | | this.addChartSheet(wb, "满意度类型", "满意度类型填报比例统计", typeInfo); |
| | | const dimInfo = await this.captureChartImage("dimension"); |
| | | this.addChartSheet(wb, "维度统计", "维度填报比例统计", dimInfo); |
| | | |
| | | // 3:题目明细统计(全量) |
| | | const qList = |
| | | this.questionDetailAll && this.questionDetailAll.length |
| | | ? this.questionDetailAll |
| | | : this.questionDetailData || []; |
| | | this.addTableSheet( |
| | | wb, |
| | | "题目明细统计", |
| | | ["序号", "题目", "平均得分", "最高得分", "最低得分", "答题人数", "未答题人数", "答题率"], |
| | | qList.map((r, i) => [ |
| | | i + 1, |
| | | r.scriptContent || "", |
| | | this.fix1(r.averageScore), |
| | | this.fix1(r.maxScore), |
| | | this.fix1(r.minScore), |
| | | r.answerCount || 0, |
| | | r.noAnswerPerson || 0, |
| | | this.formatPercent(r.answerRate), |
| | | ]) |
| | | ); |
| | | |
| | | // 4:维度题目明细(序号、题目、维度、平均/最高/最低得分、答题人数、未答题人数、答题率) |
| | | this.addTableSheet( |
| | | wb, |
| | | "维度题目明细", |
| | | [ |
| | | "序号", |
| | | "题目", |
| | | "维度", |
| | | "平均得分", |
| | | "最高得分", |
| | | "最低得分", |
| | | "答题人数", |
| | | "未答题人数", |
| | | "答题率", |
| | | ], |
| | | (this.dimensionDetailData || []).map((r, i) => [ |
| | | i + 1, |
| | | r.scriptContent || "", |
| | | r.dimension ? this.getDimensionLabel(r.dimension) : r.dimensionName || "", |
| | | this.fix1(r.averageScore), |
| | | this.fix1(r.maxScore), |
| | | this.fix1(r.minScore), |
| | | r.answerPerson || 0, |
| | | r.noAnswerPerson || 0, |
| | | this.formatPercent(r.answerRate), |
| | | ]) |
| | | ); |
| | | |
| | | // 5:各类型统计明细 |
| | | this.addTableSheet( |
| | | wb, |
| | | "各类型统计明细", |
| | | ["序号", "满意度类型", "发送问卷数", "回收问卷数", "回收率", "平均分", "满意度等级"], |
| | | (this.typeDetailData || []).map((r, i) => [ |
| | | i + 1, |
| | | r.typeName || "", |
| | | r.sendCount || 0, |
| | | r.receiveCount || 0, |
| | | this.formatPercent(r.recoveryRate), |
| | | this.fix1(r.averageScore), |
| | | r.satisfactionLevel || "", |
| | | ]) |
| | | ); |
| | | |
| | | const buffer = await wb.xlsx.writeBuffer(); |
| | | const blob = new Blob([buffer], { |
| | | type: |
| | | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", |
| | | }); |
| | | const suffix = |
| | | this.startTime && this.endTime |
| | | ? `_${String(this.startTime).slice(0, 10)}_${String(this.endTime).slice(0, 10)}` |
| | | : ""; |
| | | saveAs(blob, `满意度明细统计${suffix}.xlsx`); |
| | | this.$message.success("导出成功"); |
| | | } catch (e) { |
| | | console.error("导出失败:", e); |
| | | this.$message.error("导出失败:" + (e.message || e)); |
| | | } finally { |
| | | this.exporting = false; |
| | | } |
| | | }, |
| | | |
| | | // 导出前补齐三张明细表的数据(页面是切页签才加载,没切过的表是空的) |
| | | async ensureDetailDataLoaded() { |
| | | const tasks = []; |
| | | if (!this.questionDetailAll || !this.questionDetailAll.length) { |
| | | tasks.push(this.loadQuestionDetailData()); |
| | | } |
| | | if (!this.dimensionDetailData || !this.dimensionDetailData.length) { |
| | | tasks.push(this.loadDimensionDetailData()); |
| | | } |
| | | if (!this.typeDetailData || !this.typeDetailData.length) { |
| | | tasks.push(this.loadTypeDetailData()); |
| | | } |
| | | if (tasks.length) { |
| | | await Promise.all(tasks); |
| | | } |
| | | }, |
| | | |
| | | // 保留一位小数,空值显示 - |
| | | fix1(v) { |
| | | if (v === null || v === undefined || v === "") return "-"; |
| | | const n = Number(v); |
| | | return isNaN(n) ? "-" : n.toFixed(1); |
| | | }, |
| | | |
| | | // 图表 sheet:标题 + 汇总 + 图表图片 |
| | | addChartSheet(wb, sheetName, title, info) { |
| | | const ws = wb.addWorksheet(sheetName, { |
| | | views: [{ showGridLines: false }], |
| | | }); |
| | | ws.getCell("A1").value = title; |
| | | ws.getCell("A1").font = { size: 14, bold: true }; |
| | | ws.mergeCells("A1:F1"); |
| | | const rate = |
| | | info && info.totalSend |
| | | ? this.formatPercent(info.totalReceive / info.totalSend) |
| | | : "-"; |
| | | ws.getCell("A2").value = `发送题目总量:${(info && info.totalSend) || 0} 回收题目总量:${(info && info.totalReceive) || 0} 总体回收率:${rate}`; |
| | | ws.mergeCells("A2:F2"); |
| | | for (let c = 1; c <= 6; c++) { |
| | | ws.getColumn(c).width = 18; |
| | | } |
| | | if (info && info.url) { |
| | | const imgId = wb.addImage({ |
| | | base64: info.url.split(",")[1], |
| | | extension: "png", |
| | | }); |
| | | ws.addImage(imgId, { |
| | | tl: { col: 0.1, row: 3.1 }, |
| | | ext: { width: 940, height: 420 }, |
| | | }); |
| | | } else { |
| | | ws.getCell("A4").value = "暂无数据"; |
| | | } |
| | | return ws; |
| | | }, |
| | | |
| | | // 普通表格 sheet:标题 + 表头 + 数据 |
| | | addTableSheet(wb, sheetName, headers, rows) { |
| | | const ws = wb.addWorksheet(sheetName); |
| | | ws.addRow([sheetName]).font = { size: 14, bold: true }; |
| | | ws.mergeCells(1, 1, 1, Math.max(headers.length, 1)); |
| | | const headerRow = ws.addRow(headers); |
| | | headerRow.eachCell((cell) => { |
| | | cell.font = { bold: true }; |
| | | cell.alignment = { horizontal: "center", vertical: "middle" }; |
| | | cell.fill = { |
| | | type: "pattern", |
| | | pattern: "solid", |
| | | fgColor: { argb: "FFF5F7FA" }, |
| | | }; |
| | | }); |
| | | if (!rows || !rows.length) { |
| | | ws.addRow(["暂无数据"]); |
| | | } else { |
| | | rows.forEach((r) => { |
| | | const row = ws.addRow(r); |
| | | row.eachCell((cell) => { |
| | | cell.alignment = { horizontal: "center", vertical: "middle" }; |
| | | }); |
| | | }); |
| | | } |
| | | ws.columns.forEach((col, i) => { |
| | | col.width = i === 1 ? 40 : 14; |
| | | }); |
| | | return ws; |
| | | }, |
| | | |
| | | // 取某个图表页签的数据(不改变页面状态) |
| | | async fetchChartRows(chartType) { |
| | | const rows = []; |
| | | let totalSend = 0; |
| | | let totalReceive = 0; |
| | | let index = 0; |
| | | |
| | | if (chartType === "dimension") { |
| | | const params = { |
| | | type: this.queryParams.type, |
| | | startTime: this.startTime, |
| | | endTime: this.endTime, |
| | | ...this.buildScopeParams(), |
| | | templateid: this.templateId, |
| | | questionType: this.queryParams.type || null, |
| | | serviceTypes: this.defaultServiceTypes, |
| | | dimensionList: this.dimensionList, |
| | | }; |
| | | const res = await satisfactionGraphDimension(params); |
| | | Object.entries((res && res.rows) || {}).forEach(([code, stat]) => { |
| | | const sendCount = stat.subidAll || 0; |
| | | const receiveCount = stat.fillCountAll || 0; |
| | | const rate = stat.receiveRate || 0; |
| | | rows.push({ |
| | | name: this.getDimensionLabel(code), |
| | | value: (rate * 100).toFixed(2), |
| | | sendCount: sendCount, |
| | | receiveCount: receiveCount, |
| | | averageScore: stat.averageScore || 0, |
| | | dimension: code, |
| | | itemStyle: { color: this.getChartColor(index++) }, |
| | | }); |
| | | totalSend += sendCount; |
| | | totalReceive += receiveCount; |
| | | }); |
| | | } else { |
| | | const params = { |
| | | type: this.queryParams.type, |
| | | startTime: this.startTime, |
| | | endTime: this.endTime, |
| | | ...this.buildScopeParams(), |
| | | templateid: this.templateId, |
| | | }; |
| | | const res = await satisfactionGraph(params); |
| | | Object.entries((res && res.rows) || {}).forEach(([name, stat]) => { |
| | | const sendCount = stat.subidAll || 0; |
| | | const receiveCount = stat.fillCountAll || 0; |
| | | const rate = stat.receiveRate || 0; |
| | | rows.push({ |
| | | name: name, |
| | | value: (rate * 100).toFixed(2), |
| | | sendCount: sendCount, |
| | | receiveCount: receiveCount, |
| | | averageScore: stat.averageScore || 0, |
| | | itemStyle: { color: this.getChartColor(index++) }, |
| | | }); |
| | | totalSend += sendCount; |
| | | totalReceive += receiveCount; |
| | | }); |
| | | } |
| | | return { rows, totalSend, totalReceive }; |
| | | }, |
| | | |
| | | // 离屏渲染图表并导出 base64 图片(复用页面 renderChart 的样式,保证图和页面一致) |
| | | async captureChartImage(chartType) { |
| | | const info = await this.fetchChartRows(chartType); |
| | | if (!info.rows.length) { |
| | | return { ...info, url: null }; |
| | | } |
| | | const div = document.createElement("div"); |
| | | div.style.cssText = |
| | | "position:absolute;left:-99999px;top:0;width:1000px;height:400px;"; |
| | | document.body.appendChild(div); |
| | | const inst = echarts.init(div); |
| | | const prevChart = this.barChart; |
| | | try { |
| | | this.barChart = inst; |
| | | // 关闭动画:否则 getDataURL 截到的是柱子还没长出来的第一帧(图里有坐标轴但没有柱子) |
| | | inst.setOption({ animation: false }); |
| | | this.renderChart(info.rows, chartType); |
| | | // 等一帧,确保 canvas 已经画完 |
| | | await new Promise((resolve) => setTimeout(resolve, 120)); |
| | | const url = inst.getDataURL({ |
| | | type: "png", |
| | | pixelRatio: 2, |
| | | backgroundColor: "#fff", |
| | | }); |
| | | return { ...info, url }; |
| | | } finally { |
| | | this.barChart = prevChart; |
| | | inst.dispose(); |
| | | document.body.removeChild(div); |
| | | } |
| | | }, |
| | | // 格式化百分比 |
| | | formatPercent(value) { |
| | | if (value === null || value === undefined) return "-"; |