liusheng
2026-10-30 d725dc65cf7f74df3aa3f5a0297fd6f8b767cdbc
src/views/Satisfaction/sfstatistics/components/SatisfactionStatistics.vue
@@ -14,7 +14,7 @@
          <el-form-item label="满意度类型" prop="satisfactionScope">
            <el-select
              v-model="satisfactionScope"
              placeholder="默认在院"
              placeholder="默认离院"
              clearable
              style="width: 140px"
              @change="handleSearch"
@@ -104,6 +104,14 @@
            </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>
@@ -683,6 +691,9 @@
<script>
import * as echarts from "echarts";
import * as XLSX from "xlsx";
import ExcelJS from "exceljs";
import { saveAs } from "file-saver";
import {
  statistics,
  satisfactionGraph,
@@ -698,8 +709,8 @@
    return {
      // 满意度类型:leave=离院满意度(leaveldeptcodes/leavehospitaldistrictcodes),
      // hospital=在院满意度(deptcodes/hospitaldistrictcodes)
      // 默认在院:保持该页原有请求参数不变
      satisfactionScope: "hospital",
      // 默认离院满意度
      satisfactionScope: "leave",
      // 查询参数
      queryParams: {
        type: 2,
@@ -737,6 +748,8 @@
      // 题目明细数据
      questionDetailData: [],
      // 题目明细全量数据(导出用,不受分页影响)
      questionDetailAll: [],
      // 维度题目明细数据
      dimensionDetailData: [],
@@ -771,6 +784,9 @@
      // 各类型统计明细数据
      typeDetailData: [],
      // 导出中(按钮 loading)
      exporting: false,
      // 柱状图数据
      chartData: [],
@@ -1229,6 +1245,8 @@
      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;
@@ -2059,6 +2077,8 @@
    // 处理重置
    handleReset() {
      this.$refs.queryForm.resetFields();
      // 重置后回到默认的离院满意度
      this.satisfactionScope = "leave";
      this.queryParams.dateRange = [];
      this.queryParams.deptCodes = []; // 重置为数组
      this.queryParams.wardCodes = []; // 重置为数组
@@ -2120,11 +2140,327 @@
      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 "-";