liusheng
2026-10-30 d725dc65cf7f74df3aa3f5a0297fd6f8b767cdbc
Merge branch 'subtask' of http://116.62.18.175:6699/r/~yxh/smartor-web into subtask
已修改6个文件
888 ■■■■■ 文件已修改
src/components/UploadExcel/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/Satisfaction/sfstatistics/components/ChartsPanel.vue 175 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/Satisfaction/sfstatistics/components/DataOverview.vue 221 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/Satisfaction/sfstatistics/components/FollowupStatistics.vue 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/Satisfaction/sfstatistics/components/SatisfactionStatistics.vue 346 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/Satisfaction/sfstatistics/components/visitStatistics.vue 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/UploadExcel/index.vue
@@ -32,7 +32,7 @@
</template>
<script>
import XLSX from "xlsx";
import * as XLSX from "xlsx";
export default {
  name: "UploadExcel",
  props: {
src/views/Satisfaction/sfstatistics/components/ChartsPanel.vue
@@ -41,7 +41,7 @@
        <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">
@@ -171,6 +171,8 @@
<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";
@@ -198,10 +200,10 @@
      singleQuestionContent: '',
      singleDeptWards: [],
      // 排名维度筛选
      rankDimension: 'dept',
      // 排名维度筛选(默认按病区)
      rankDimension: 'ward',
      rankDeptWards: [],
      crossDimension: 'dept',
      crossDimension: 'ward',
      crossDeptWards: [],
      // 图表数据
@@ -845,52 +847,129 @@
      });
    },
    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();
    },
  },
};
src/views/Satisfaction/sfstatistics/components/DataOverview.vue
@@ -106,6 +106,13 @@
          <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>
@@ -115,13 +122,6 @@
      <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"
@@ -186,13 +186,6 @@
            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="统计中...">
@@ -271,13 +264,6 @@
              />
            </template>
          </el-select>
          <el-button
            type="warning"
            size="small"
            icon="el-icon-download"
            @click="exportTable('table3')"
            >导出Excel</el-button
          >
        </div>
      </div>
      <el-table
@@ -336,7 +322,7 @@
<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 = ["很满意", "满意", "一般", "不满意", "很不满意", "未经历"];
@@ -1128,52 +1114,165 @@
      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>
src/views/Satisfaction/sfstatistics/components/FollowupStatistics.vue
@@ -401,8 +401,8 @@
  data() {
    return {
      // 满意度类型:leave=离院满意度(按出院病区统计),hospital=在院满意度(按在院病区统计)
      // 默认在院满意度
      satisfactionScope: "hospital",
      // 默认离院满意度
      satisfactionScope: "leave",
      // 查询参数
      queryParams: {
@@ -432,7 +432,7 @@
      options: [
        {
          value: 6,
          label: "住院满意度",
          label: "出入院满意度",
          raw: {
            cssClass: "",
            listClass: "",
@@ -539,8 +539,11 @@
    // 获取科室树
    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(
@@ -854,9 +857,9 @@
      this.getList();
    },
    // 重置查询(恢复默认:在院满意度 + 服务类型 6)
    // 重置查询(恢复默认:离院满意度 + 服务类型 6)
    resetQuery() {
      this.satisfactionScope = "hospital";
      this.satisfactionScope = "leave";
      this.queryParams = {
        statisticaltype: 1,
        leavehospitaldistrictcodes: [],
@@ -1087,15 +1090,9 @@
          },
        };
        // 添加总标题(两列维度 + 八个指标列,共 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 ? "离院病区" : "离院科室",
@@ -1103,11 +1100,19 @@
          "无需随访人次",
          "应随访人次",
          "随访率",
          "及时率",
          "满意度题目总量",
          "满意度填报量",
          "完成比率",
        ];
        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) => {
@@ -1115,9 +1120,13 @@
        });
        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 || "-",
@@ -1127,12 +1136,17 @@
            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;
@@ -1140,19 +1154,10 @@
          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();
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 "-";
src/views/Satisfaction/sfstatistics/components/visitStatistics.vue
@@ -936,25 +936,27 @@
          },
        };
        // 添加总标题
        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) => {
@@ -962,21 +964,30 @@
        });
        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;
@@ -984,18 +995,10 @@
          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();