Merge branch 'subtask' of http://116.62.18.175:6699/r/~yxh/smartor-web into subtask
| | |
| | | </template> |
| | | |
| | | <script> |
| | | import XLSX from "xlsx"; |
| | | import * as XLSX from "xlsx"; |
| | | export default { |
| | | name: "UploadExcel", |
| | | props: { |
| | |
| | | <el-button type="primary" size="small" icon="el-icon-search" @click="refreshAll">查询</el-button> |
| | | <el-button size="small" icon="el-icon-refresh" @click="handleReset">重置</el-button> |
| | | </div> |
| | | <el-button type="danger" size="small" icon="el-icon-download" @click="exportPDF">导出PDF</el-button> |
| | | <el-button type="danger" size="small" icon="el-icon-download" @click="exportExcel">导出Excel</el-button> |
| | | </div> |
| | | |
| | | <el-row :gutter="20"> |
| | |
| | | |
| | | <script> |
| | | import * as echarts from 'echarts'; |
| | | import ExcelJS from 'exceljs'; |
| | | import { saveAs } from 'file-saver'; |
| | | import { mydTrend, sltdMydTotalByDimension, sltdMydTotalByScore ,mzMydScoreRank} from "@/api/AiCentre/satisfaction"; |
| | | import { getissuelist } from "@/api/AiCentre/questionnaire"; |
| | | |
| | |
| | | singleQuestionContent: '', |
| | | singleDeptWards: [], |
| | | |
| | | // 排名维度筛选 |
| | | rankDimension: 'dept', |
| | | // 排名维度筛选(默认按病区) |
| | | rankDimension: 'ward', |
| | | rankDeptWards: [], |
| | | crossDimension: 'dept', |
| | | crossDimension: 'ward', |
| | | crossDeptWards: [], |
| | | |
| | | // 图表数据 |
| | |
| | | }); |
| | | }, |
| | | |
| | | async exportPDF() { |
| | | const chartRefs = [ |
| | | { ref: this.$refs.chart1, id: 'chart1' }, |
| | | { ref: this.$refs.chart2, id: 'chart2' }, |
| | | { ref: this.$refs.chart4, id: 'chart4' }, |
| | | ]; |
| | | const chartImages = {}; |
| | | for (const { ref, id } of chartRefs) { |
| | | if (ref) { |
| | | const instance = echarts.getInstanceByDom(ref); |
| | | if (instance) { |
| | | chartImages[id] = instance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' }); |
| | | // 导出:图表转图片 + 表格,内容与页面一致(直接下载 xlsx,不依赖打印) |
| | | async exportExcel() { |
| | | try { |
| | | const wb = new ExcelJS.Workbook(); |
| | | |
| | | // 图表 sheet |
| | | const addImageSheet = (sheetName, title, refEl, tip) => { |
| | | 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'); |
| | | let anchorRow = 2.2; |
| | | if (tip) { |
| | | ws.getCell('A2').value = tip; |
| | | ws.mergeCells('A2:F2'); |
| | | anchorRow = 2.8; |
| | | } |
| | | } |
| | | for (let c = 1; c <= 6; c++) { |
| | | ws.getColumn(c).width = 18; |
| | | } |
| | | const inst = refEl ? echarts.getInstanceByDom(refEl) : null; |
| | | if (inst) { |
| | | const url = inst.getDataURL({ |
| | | type: 'png', |
| | | pixelRatio: 2, |
| | | backgroundColor: '#fff', |
| | | }); |
| | | const imgId = wb.addImage({ |
| | | base64: url.split(',')[1], |
| | | extension: 'png', |
| | | }); |
| | | ws.addImage(imgId, { |
| | | tl: { col: 0.1, row: anchorRow }, |
| | | ext: { width: 900, height: 400 }, |
| | | }); |
| | | } else { |
| | | ws.getCell(`A${Math.ceil(anchorRow)}`).value = '暂无数据'; |
| | | } |
| | | return ws; |
| | | }; |
| | | |
| | | addImageSheet( |
| | | '综合得分趋势', |
| | | '患者满意度综合得分趋势', |
| | | this.$refs.chart1, |
| | | `环比 ${this.trendMoM}` |
| | | ); |
| | | addImageSheet( |
| | | '分类维度对比', |
| | | `分类维度 · 各指标${this.intervalLabel}对比`, |
| | | this.$refs.chart2, |
| | | '' |
| | | ); |
| | | addImageSheet( |
| | | '院区维度对比', |
| | | `院区维度 · 各院区${this.intervalLabel}对比`, |
| | | this.$refs.chart4, |
| | | '' |
| | | ); |
| | | |
| | | // 单题 · 选项统计 |
| | | const wsQ = wb.addWorksheet('单题选项统计'); |
| | | wsQ.addRow(['单题 · 选项统计']).font = { size: 14, bold: true }; |
| | | wsQ.mergeCells(1, 1, 1, 3); |
| | | const qHead = wsQ.addRow(['选项', '合计数量', '占比比例']); |
| | | qHead.font = { bold: true }; |
| | | (this.singleQuestionData || []).forEach((r) => { |
| | | wsQ.addRow([r.option, r.count, r.rate]); |
| | | }); |
| | | wsQ.addRow([]); |
| | | wsQ.addRow([ |
| | | `本题有效填写人次:${this.singleQuestionMeta.total} 综合得分:${this.singleQuestionMeta.score}`, |
| | | '', |
| | | '', |
| | | ]); |
| | | wsQ.mergeCells(wsQ.rowCount, 1, wsQ.rowCount, 3); |
| | | wsQ.columns.forEach((c, i) => { |
| | | c.width = i === 0 ? 36 : 16; |
| | | }); |
| | | |
| | | // 排名表格 |
| | | const addRankSheet = (sheetName, title, rows, dimLabel) => { |
| | | const ws = wb.addWorksheet(sheetName); |
| | | ws.addRow([title]).font = { size: 14, bold: true }; |
| | | ws.mergeCells(1, 1, 1, 4); |
| | | const head = ws.addRow(['排名', dimLabel, '综合得分', '项数']); |
| | | head.font = { bold: true }; |
| | | (rows || []).forEach((r) => { |
| | | ws.addRow([r.rank, r.name, r.score, r.itemCount]); |
| | | }); |
| | | if (!rows || !rows.length) { |
| | | ws.addRow(['暂无数据', '', '', '']); |
| | | } |
| | | ws.columns.forEach((c, i) => { |
| | | c.width = i === 1 ? 30 : 12; |
| | | }); |
| | | }; |
| | | addRankSheet( |
| | | `${this.rankDimLabel}排名`, |
| | | `${this.rankDimLabel}维度 · 门诊患者满意度得分排名`, |
| | | this.rankRows, |
| | | this.rankDimLabel |
| | | ); |
| | | addRankSheet( |
| | | `单题×${this.crossDimLabel}排名`, |
| | | `单题×${this.crossDimLabel} · 得分排名`, |
| | | this.crossRows, |
| | | this.crossDimLabel |
| | | ); |
| | | |
| | | const buffer = await wb.xlsx.writeBuffer(); |
| | | const blob = new Blob([buffer], { |
| | | type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', |
| | | }); |
| | | const range = (this.queryParams.dateRange || []).join('_'); |
| | | saveAs(blob, `各项统计表${range ? '_' + range : ''}.xlsx`); |
| | | this.$message.success('导出成功'); |
| | | } catch (e) { |
| | | console.error('导出失败:', e); |
| | | this.$message.error('导出失败:' + (e.message || e)); |
| | | } |
| | | |
| | | const printContent = this.$refs.pdfTarget.cloneNode(true); |
| | | const chartBoxes = printContent.querySelectorAll('.chart-box'); |
| | | const imgKeys = Object.keys(chartImages); |
| | | chartBoxes.forEach((box, i) => { |
| | | if (imgKeys[i] && chartImages[imgKeys[i]]) { |
| | | box.innerHTML = `<img src="${chartImages[imgKeys[i]]}" style="width:100%;height:auto;" />`; |
| | | } |
| | | }); |
| | | |
| | | const win = window.open('', '_blank', 'width=900,height=800'); |
| | | if (!win) { this.$message.warning('请允许弹出窗口以导出PDF'); return; } |
| | | win.document.write(` |
| | | <html><head><meta charset="utf-8"><title>各项统计表</title> |
| | | <style> |
| | | body { font-family: 'Microsoft YaHei',sans-serif; padding: 20px; } |
| | | .chart-card { break-inside: avoid; margin-bottom: 20px; border: 1px solid #ebeef5; border-radius: 6px; } |
| | | .card-header { padding: 12px 16px; border-bottom: 1px solid #ebeef5; font-size: 15px; font-weight: 600; color: #303133; background: #f5f7fa; } |
| | | .chart-box img { max-width: 100%; display: block; } |
| | | table { width: 100%; border-collapse: collapse; margin: 12px 0; } |
| | | th, td { border: 1px solid #d9d9d9; padding: 8px 12px; text-align: center; font-size: 12px; } |
| | | th { background: #f0f2f5; font-weight: 600; } |
| | | .table-footer { text-align: right; padding: 8px 0; font-size: 13px; color: #606266; } |
| | | .export-bar, .filter-bar, .el-tag, .el-select, .el-radio-group { display: none; } |
| | | @media print { @page { size: A4; margin: 10mm; } } |
| | | </style></head><body> |
| | | ${printContent.innerHTML} |
| | | <script>window.onload=function(){window.print();}<\/script> |
| | | </body></html> |
| | | `); |
| | | win.document.close(); |
| | | }, |
| | | }, |
| | | }; |
| | |
| | | <el-button size="small" icon="el-icon-refresh" @click="handleReset" |
| | | >重置</el-button |
| | | > |
| | | <el-button |
| | | type="warning" |
| | | size="small" |
| | | icon="el-icon-download" |
| | | @click="exportTable()" |
| | | >导出Excel</el-button |
| | | > |
| | | </el-form-item> |
| | | </el-form> |
| | | </el-card> |
| | |
| | | <div slot="header" class="card-header"> |
| | | <span class="card-title">各服务项目满意度统计</span> |
| | | <span class="card-subtitle">(六档评分题:很满意/满意/一般/不满意/很不满意/未经历)</span> |
| | | <el-button |
| | | type="warning" |
| | | size="small" |
| | | icon="el-icon-download" |
| | | @click="exportTable('table1')" |
| | | >导出Excel</el-button |
| | | > |
| | | </div> |
| | | <el-table |
| | | id="table1" |
| | |
| | | placeholder="按题目模糊查询" |
| | | class="search-input" |
| | | /> |
| | | <el-button |
| | | type="warning" |
| | | size="small" |
| | | icon="el-icon-download" |
| | | @click="exportTable('table2')" |
| | | >导出Excel</el-button |
| | | > |
| | | </div> |
| | | </div> |
| | | <div id="table2" v-loading="loading" element-loading-text="统计中..."> |
| | |
| | | /> |
| | | </template> |
| | | </el-select> |
| | | <el-button |
| | | type="warning" |
| | | size="small" |
| | | icon="el-icon-download" |
| | | @click="exportTable('table3')" |
| | | >导出Excel</el-button |
| | | > |
| | | </div> |
| | | </div> |
| | | <el-table |
| | |
| | | <script> |
| | | import { sltdMydTotalByScore,statQuestionOptionByKsOrBq } from "@/api/AiCentre/satisfaction"; |
| | | import { deptTreeSelect } from "@/api/system/user"; |
| | | import XLSX from "xlsx"; |
| | | import * as XLSX from "xlsx"; |
| | | |
| | | // 满意度题的固定选项(顺序即表格列顺序)——这些选项的题目进表一,其余进表二 |
| | | const FIXED_OPTIONS = ["很满意", "满意", "一般", "不满意", "很不满意", "未经历"]; |
| | |
| | | const hit = opts.find((o) => String(o.label) === String(name)); |
| | | return hit ? hit.value : name; |
| | | }, |
| | | // 导出 Excel:一次导出整页三个表(各一个 sheet),和页面显示保持一致 |
| | | exportTable(tableId) { |
| | | const tableNames = { |
| | | table1: "各服务项目满意度统计", |
| | | table2: "其他题目统计", |
| | | table3: "各科室满意度评分对比", |
| | | }; |
| | | const name = tableNames[tableId] || "统计数据"; |
| | | const table = document.getElementById(tableId); |
| | | if (!table) return; |
| | | |
| | | try { |
| | | // 从表格提取数据构建带样式的 Excel |
| | | const order = ["table1", "table2", "table3"]; |
| | | if (tableId && order.indexOf(tableId) > 0) { |
| | | order.splice(order.indexOf(tableId), 1); |
| | | order.unshift(tableId); |
| | | } |
| | | const builders = { |
| | | table1: () => this.buildExportTable1(), |
| | | table2: () => this.buildExportTable2(), |
| | | table3: () => this.buildExportTable3(), |
| | | }; |
| | | const wb = XLSX.utils.book_new(); |
| | | const data = []; |
| | | table.querySelectorAll("tr").forEach((tr) => { |
| | | const row = []; |
| | | tr.querySelectorAll("th,td").forEach((td) => { |
| | | row.push((td.innerText || "").trim()); |
| | | }); |
| | | if (row.length) data.push(row); |
| | | let count = 0; |
| | | order.forEach((id) => { |
| | | const sheet = builders[id](); |
| | | if (!sheet) return; |
| | | const ws = XLSX.utils.aoa_to_sheet(sheet.data); |
| | | ws["!merges"] = sheet.merges || []; |
| | | const widthRow = sheet.data.find((r) => r && r.length) || []; |
| | | ws["!cols"] = widthRow.map((_, i) => ({ wch: i === 0 ? 32 : 12 })); |
| | | XLSX.utils.book_append_sheet(wb, ws, sheet.name); |
| | | count++; |
| | | }); |
| | | |
| | | const ws = XLSX.utils.aoa_to_sheet(data); |
| | | // 设置列宽 |
| | | const colWidths = data[0] ? data[0].map(() => ({ wch: 16 })) : []; |
| | | ws["!cols"] = colWidths; |
| | | |
| | | XLSX.utils.book_append_sheet(wb, ws, "Sheet1"); |
| | | XLSX.writeFile(wb, name + ".xlsx"); |
| | | if (!count) { |
| | | this.$message.warning("没有可导出的数据"); |
| | | return; |
| | | } |
| | | XLSX.writeFile(wb, "满意度数据总览.xlsx"); |
| | | this.$message.success("导出成功"); |
| | | } catch (e) { |
| | | // fallback: CSV |
| | | let csv = ""; |
| | | table.querySelectorAll("tr").forEach((tr) => { |
| | | const row = []; |
| | | tr.querySelectorAll("th,td").forEach((td) => |
| | | row.push('"' + (td.innerText || "").replace(/"/g, '""') + '"') |
| | | ); |
| | | csv += row.join(",") + "\n"; |
| | | }); |
| | | const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); |
| | | const link = document.createElement("a"); |
| | | link.href = URL.createObjectURL(blob); |
| | | link.download = name + ".csv"; |
| | | link.click(); |
| | | console.error("导出失败:", e); |
| | | this.$message.error("导出失败:" + (e.message || e)); |
| | | } |
| | | }, |
| | | |
| | | // 表一:各服务项目满意度统计 |
| | | buildExportTable1() { |
| | | const rows = this.serviceSatisfaction || []; |
| | | if (!rows.length) return null; |
| | | const data = [ |
| | | ["各服务项目满意度统计(六档评分题:很满意/满意/一般/不满意/很不满意/未经历)"], |
| | | [ |
| | | "分类/题目", |
| | | "很满意", "", "满意", "", "一般", "", |
| | | "不满意", "", "很不满意", "", "未经历", "", |
| | | "综合得分", |
| | | ], |
| | | [ |
| | | "", |
| | | "数量", "比例", "数量", "比例", "数量", "比例", |
| | | "数量", "比例", "数量", "比例", "数量", "比例", |
| | | "", |
| | | ], |
| | | ]; |
| | | rows.forEach((r) => { |
| | | data.push([ |
| | | r.category, |
| | | r.veryGoodCount, r.veryGoodRate, |
| | | r.goodCount, r.goodRate, |
| | | r.normalCount, r.normalRate, |
| | | r.badCount, r.badRate, |
| | | r.veryBadCount, r.veryBadRate, |
| | | r.naCount, r.naRate, |
| | | r.score, |
| | | ]); |
| | | }); |
| | | const merges = [ |
| | | { s: { r: 0, c: 0 }, e: { r: 0, c: 13 } }, |
| | | { s: { r: 1, c: 0 }, e: { r: 2, c: 0 } }, |
| | | ]; |
| | | for (let i = 0; i < 6; i++) { |
| | | merges.push({ s: { r: 1, c: 1 + i * 2 }, e: { r: 1, c: 2 + i * 2 } }); |
| | | } |
| | | merges.push({ s: { r: 1, c: 13 }, e: { r: 2, c: 13 } }); |
| | | return { name: "各服务项目满意度", data, merges }; |
| | | }, |
| | | |
| | | // 表二:其他题目统计(每题一块:题目标题 → 选项/数量/比例 → 本题填报总量) |
| | | buildExportTable2() { |
| | | const list = this.filteredQuestionStats || []; |
| | | if (!list.length) return null; |
| | | const data = [ |
| | | ["其他题目统计(六档满意度题已在上表,这里只列其余题型:推荐、抱怨回应、意见等)"], |
| | | [], |
| | | ]; |
| | | const merges = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }]; |
| | | list.forEach((q) => { |
| | | data.push([q.title, "", ""]); |
| | | merges.push({ |
| | | s: { r: data.length - 1, c: 0 }, |
| | | e: { r: data.length - 1, c: 2 }, |
| | | }); |
| | | data.push(["选项", "数量", "比例"]); |
| | | const opts = |
| | | q.options && q.options.length |
| | | ? q.options |
| | | : [{ option: "(无选项)", count: 0, rate: "-" }]; |
| | | opts.forEach((o) => { |
| | | data.push([o.option, o.count, o.rate]); |
| | | }); |
| | | data.push([`本题填报总量:${q.total}`, "", ""]); |
| | | merges.push({ |
| | | s: { r: data.length - 1, c: 0 }, |
| | | e: { r: data.length - 1, c: 2 }, |
| | | }); |
| | | data.push([]); |
| | | }); |
| | | return { name: "其他题目统计", data, merges }; |
| | | }, |
| | | |
| | | // 表三:各科室/病区满意度评分对比(两行动态表头) |
| | | buildExportTable3() { |
| | | const rows = this.table3Rows || []; |
| | | const questions = this.table3Questions || []; |
| | | if (!rows.length) return null; |
| | | const head1 = [this.statDimension === "dept" ? "科室" : "病区"]; |
| | | const head2 = [""]; |
| | | const groups = []; |
| | | questions.forEach((q) => { |
| | | const opts = q.options || []; |
| | | const startCol = head1.length; |
| | | head1.push(q.title); |
| | | for (let i = 1; i < opts.length + 1; i++) head1.push(""); |
| | | groups.push({ start: startCol, end: startCol + opts.length }); |
| | | opts.forEach((opt) => head2.push(opt)); |
| | | head2.push("得分"); |
| | | }); |
| | | head1.push("综合得分"); |
| | | head2.push(""); |
| | | const data = [["各科室/病区满意度评分对比"], head1, head2]; |
| | | const merges = [ |
| | | { s: { r: 0, c: 0 }, e: { r: 0, c: head1.length - 1 } }, |
| | | { s: { r: 1, c: 0 }, e: { r: 2, c: 0 } }, |
| | | { |
| | | s: { r: 1, c: head1.length - 1 }, |
| | | e: { r: 2, c: head1.length - 1 }, |
| | | }, |
| | | ]; |
| | | groups.forEach((g) => { |
| | | if (g.end > g.start) { |
| | | merges.push({ s: { r: 1, c: g.start }, e: { r: 1, c: g.end } }); |
| | | } |
| | | }); |
| | | rows.forEach((row) => { |
| | | const line = [row.name]; |
| | | questions.forEach((q) => { |
| | | const cell = (row.cells && row.cells[q.title]) || {}; |
| | | (q.options || []).forEach((opt) => { |
| | | line.push((cell[opt] && cell[opt].count) || 0); |
| | | }); |
| | | line.push(cell._score || "0.00"); |
| | | }); |
| | | line.push(row.totalScore); |
| | | data.push(line); |
| | | }); |
| | | return { name: "各科室病区评分对比", data, merges }; |
| | | }, |
| | | }, |
| | | }; |
| | | </script> |
| | |
| | | data() { |
| | | return { |
| | | // 满意度类型:leave=离院满意度(按出院病区统计),hospital=在院满意度(按在院病区统计) |
| | | // 默认在院满意度 |
| | | satisfactionScope: "hospital", |
| | | // 默认离院满意度 |
| | | satisfactionScope: "leave", |
| | | |
| | | // 查询参数 |
| | | queryParams: { |
| | |
| | | options: [ |
| | | { |
| | | value: 6, |
| | | label: "住院满意度", |
| | | label: "出入院满意度", |
| | | raw: { |
| | | cssClass: "", |
| | | listClass: "", |
| | |
| | | |
| | | // 获取科室树 |
| | | getDeptTree() { |
| | | // 获取服务类型 |
| | | this.options = this.$store.getters.tasktypes || []; |
| | | // 获取服务类型(字典里的「住院满意度」在本页显示为「出入院满意度」) |
| | | const labelFix = { 住院满意度: "出入院满意度" }; |
| | | this.options = (this.$store.getters.tasktypes || []).map((o) => |
| | | labelFix[o.label] ? { ...o, label: labelFix[o.label] } : o |
| | | ); |
| | | |
| | | // 获取科室列表 |
| | | this.flatArraydept = (this.$store.getters.belongDepts || []).map( |
| | |
| | | this.getList(); |
| | | }, |
| | | |
| | | // 重置查询(恢复默认:在院满意度 + 服务类型 6) |
| | | // 重置查询(恢复默认:离院满意度 + 服务类型 6) |
| | | resetQuery() { |
| | | this.satisfactionScope = "hospital"; |
| | | this.satisfactionScope = "leave"; |
| | | this.queryParams = { |
| | | statisticaltype: 1, |
| | | leavehospitaldistrictcodes: [], |
| | |
| | | }, |
| | | }; |
| | | |
| | | // 添加总标题(两列维度 + 八个指标列,共 10 列) |
| | | worksheet.mergeCells(1, 1, 1, 10); |
| | | const titleCell = worksheet.getCell(1, 1); |
| | | titleCell.value = `满意度随访统计表(${sheetNameSuffix})`; |
| | | titleCell.style = titleStyle; |
| | | worksheet.getRow(1).height = 35; |
| | | |
| | | // 添加表头(科室统计导出在院科室+离院科室,病区统计导出在院病区+离院病区) |
| | | // 表头按页面列动态生成:科室/病区统计列不同,「及时率」只有景宁院区页面才显示 |
| | | const isWardStatistics = this.queryParams.statisticaltype == 1; |
| | | const showTimelyRate = this.orgname === "景宁畲族自治县人民医院"; |
| | | const headers = [ |
| | | isWardStatistics ? "在院病区" : "在院科室", |
| | | isWardStatistics ? "离院病区" : "离院科室", |
| | |
| | | "无需随访人次", |
| | | "应随访人次", |
| | | "随访率", |
| | | "及时率", |
| | | "满意度题目总量", |
| | | "满意度填报量", |
| | | "完成比率", |
| | | ]; |
| | | if (showTimelyRate) { |
| | | headers.push("及时率"); |
| | | } |
| | | headers.push("满意度题目总量", "满意度填报量", "完成比率"); |
| | | const colCount = headers.length; |
| | | |
| | | // 添加总标题 |
| | | worksheet.mergeCells(1, 1, 1, colCount); |
| | | const titleCell = worksheet.getCell(1, 1); |
| | | titleCell.value = `满意度随访统计表(${sheetNameSuffix})`; |
| | | titleCell.style = titleStyle; |
| | | worksheet.getRow(1).height = 35; |
| | | |
| | | const headerRow = worksheet.addRow(headers); |
| | | headerRow.eachCell((cell) => { |
| | |
| | | }); |
| | | headerRow.height = 25; |
| | | |
| | | // 添加数据行 |
| | | // 添加数据行(空值统一显示“-”,与页面保持一致) |
| | | const dash = (v, formatter) => { |
| | | if (v === null || v === undefined || v === "") return "-"; |
| | | return formatter ? formatter(v) : v; |
| | | }; |
| | | this.userList.forEach((item) => { |
| | | const dataRow = worksheet.addRow([ |
| | | const rowData = [ |
| | | isWardStatistics |
| | | ? item.hospitaldistrictname || "-" |
| | | : item.hospitaldeptname || "-", |
| | |
| | | item.dischargeCount || 0, |
| | | item.nonFollowUp || 0, |
| | | item.followUpNeeded || 0, |
| | | item.followUpRate || "0%", |
| | | item.rate ? this.formatPercent(item.rate) : "0%", |
| | | dash(item.followUpRate), |
| | | ]; |
| | | if (showTimelyRate) { |
| | | rowData.push(dash(item.rate, (v) => this.formatPercent(v))); |
| | | } |
| | | rowData.push( |
| | | item.joyAllCount || 0, |
| | | item.joyCount || 0, |
| | | item.joyTotal ? this.formatPercent(item.joyTotal) : "0%", |
| | | ]); |
| | | dash(item.joyTotal, (v) => this.formatPercent(v)) |
| | | ); |
| | | const dataRow = worksheet.addRow(rowData); |
| | | |
| | | dataRow.eachCell((cell) => { |
| | | cell.style = cellStyle; |
| | |
| | | dataRow.height = 22; |
| | | }); |
| | | |
| | | // 设置列宽 |
| | | worksheet.columns = [ |
| | | { width: 20 }, |
| | | { width: 20 }, |
| | | { width: 12 }, |
| | | { width: 12 }, |
| | | { width: 12 }, |
| | | { width: 12 }, |
| | | { width: 12 }, |
| | | { width: 15 }, |
| | | { width: 15 }, |
| | | { width: 12 }, |
| | | ]; |
| | | // 设置列宽(跟列数一致) |
| | | worksheet.columns = headers.map((h, i) => ({ |
| | | width: i < 2 ? 20 : h.length > 6 ? 15 : 12, |
| | | })); |
| | | |
| | | // 生成并下载文件 |
| | | const buffer = await workbook.xlsx.writeBuffer(); |
| | |
| | | <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 "-"; |
| | |
| | | }, |
| | | }; |
| | | |
| | | // 添加总标题 |
| | | worksheet.mergeCells(1, 1, 1, 10); |
| | | const titleCell = worksheet.getCell(1, 1); |
| | | titleCell.value = `复诊通知统计表(${sheetNameSuffix})`; |
| | | titleCell.style = titleStyle; |
| | | worksheet.getRow(1).height = 35; |
| | | |
| | | // 添加表头 |
| | | // 表头按页面列动态生成:「及时率」只有景宁院区页面才显示 |
| | | const showTimelyRate = this.orgname === "景宁畲族自治县人民医院"; |
| | | const headers = [ |
| | | this.queryParams.statisticaltype == 1 ? "出院病区" : "科室", |
| | | "出院人次", |
| | | "无需随访人次", |
| | | "应随访人次", |
| | | "随访率", |
| | | "及时率", |
| | | "复诊通知题目总量", |
| | | "复诊通知填报量", |
| | | "完成比率", |
| | | ]; |
| | | if (showTimelyRate) { |
| | | headers.push("及时率"); |
| | | } |
| | | headers.push("复诊通知题目总量", "复诊通知填报量", "完成比率"); |
| | | const colCount = headers.length; |
| | | |
| | | // 添加总标题 |
| | | worksheet.mergeCells(1, 1, 1, colCount); |
| | | const titleCell = worksheet.getCell(1, 1); |
| | | titleCell.value = `复诊通知统计表(${sheetNameSuffix})`; |
| | | titleCell.style = titleStyle; |
| | | worksheet.getRow(1).height = 35; |
| | | |
| | | const headerRow = worksheet.addRow(headers); |
| | | headerRow.eachCell((cell) => { |
| | |
| | | }); |
| | | headerRow.height = 25; |
| | | |
| | | // 添加数据行 |
| | | // 添加数据行(空值统一显示“-”,与页面保持一致) |
| | | const dash = (v, formatter) => { |
| | | if (v === null || v === undefined || v === "") return "-"; |
| | | return formatter ? formatter(v) : v; |
| | | }; |
| | | this.userList.forEach((item) => { |
| | | const dataRow = worksheet.addRow([ |
| | | const rowData = [ |
| | | this.queryParams.statisticaltype == 1 |
| | | ? item.leavehospitaldistrictname |
| | | : item.deptname, |
| | | item.dischargeCount || 0, |
| | | item.nonFollowUp || 0, |
| | | item.followUpNeeded || 0, |
| | | item.followUpRate || "0%", |
| | | item.rate ? this.formatPercent(item.rate) : "0%", |
| | | dash(item.followUpRate), |
| | | ]; |
| | | if (showTimelyRate) { |
| | | rowData.push(dash(item.rate, (v) => this.formatPercent(v))); |
| | | } |
| | | rowData.push( |
| | | item.joyAllCount || 0, |
| | | item.joyCount || 0, |
| | | item.joyTotal ? this.formatPercent(item.joyTotal) : "0%", |
| | | ]); |
| | | dash(item.joyTotal, (v) => this.formatPercent(v)) |
| | | ); |
| | | const dataRow = worksheet.addRow(rowData); |
| | | |
| | | dataRow.eachCell((cell) => { |
| | | cell.style = cellStyle; |
| | |
| | | dataRow.height = 22; |
| | | }); |
| | | |
| | | // 设置列宽 |
| | | worksheet.columns = [ |
| | | { width: 20 }, |
| | | { width: 12 }, |
| | | { width: 12 }, |
| | | { width: 12 }, |
| | | { width: 12 }, |
| | | { width: 12 }, |
| | | { width: 15 }, |
| | | { width: 15 }, |
| | | { width: 12 }, |
| | | ]; |
| | | // 设置列宽(跟列数一致) |
| | | worksheet.columns = headers.map((h, i) => ({ |
| | | width: i === 0 ? 20 : h.length > 6 ? 15 : 12, |
| | | })); |
| | | |
| | | // 生成并下载文件 |
| | | const buffer = await workbook.xlsx.writeBuffer(); |