WXL (wul)
7 天以前 7b38cbd5c0af64e2f11e02d4d2e0ea6cbb1c09f5
src/views/Satisfaction/sfstatistics/components/DataOverview.vue
@@ -3,6 +3,19 @@
    <!-- 顶部筛选:时间范围 + 科室/病区,控制本页所有数据 -->
    <el-card shadow="never" class="overview-card filter-card">
      <el-form inline class="filter-form">
        <el-form-item label="满意度类型">
          <el-select
            v-model="satisfactionScope"
            size="small"
            style="width: 140px"
            clearable
            placeholder="默认离院"
            @change="fetchData"
          >
            <el-option label="离院满意度" value="leave" />
            <el-option label="在院满意度" value="hospital" />
          </el-select>
        </el-form-item>
        <el-form-item label="出院日期">
          <el-date-picker
            v-model="queryParams.dateRange"
@@ -93,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>
@@ -101,13 +121,7 @@
    <el-card shadow="never" class="overview-card">
      <div slot="header" class="card-header">
        <span class="card-title">各服务项目满意度统计</span>
        <el-button
          type="warning"
          size="small"
          icon="el-icon-download"
          @click="exportTable('table1')"
          >导出Excel</el-button
        >
        <span class="card-subtitle">(六档评分题:很满意/满意/一般/不满意/很不满意/未经历)</span>
      </div>
      <el-table
        id="table1"
@@ -161,7 +175,8 @@
    <!-- 表格二:单选题/多选题统计(其余题目) -->
    <el-card shadow="never" class="overview-card">
      <div slot="header" class="card-header">
        <span class="card-title">单选题/多选题统计</span>
        <span class="card-title">其他题目统计</span>
        <span class="card-subtitle">(六档满意度题已在上表,这里只列其余题型:推荐、抱怨回应、意见等)</span>
        <div class="header-right">
          <el-input
            v-model="questionSearch"
@@ -171,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="统计中...">
@@ -256,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
@@ -320,7 +321,8 @@
<script>
import { sltdMydTotalByScore,statQuestionOptionByKsOrBq } from "@/api/AiCentre/satisfaction";
import XLSX from "xlsx";
import { deptTreeSelect } from "@/api/system/user";
import * as XLSX from "xlsx";
// 满意度题的固定选项(顺序即表格列顺序)——这些选项的题目进表一,其余进表二
const FIXED_OPTIONS = ["很满意", "满意", "一般", "不满意", "很不满意", "未经历"];
@@ -347,9 +349,10 @@
// 服务类型默认选中项(按 label 匹配,适配不同字典的 value 差异)
const DEFAULT_SERVICE_LABELS = ["住院满意度", "门诊满意度"];
// 兜底:store 中无科室/病区数据时使用(表三对比 + 筛选下拉)
const DEPTS = ["内分泌科", "皮肤科", "肿瘤内科", "消化内科", "肾内科"];
const WARDS = ["一病区", "二病区", "三病区", "四病区", "五病区"];
// 注意:这里以前用 DEPTS / WARDS 两个「演示用」名称数组兜底,
// 结果被当成编码塞进 leavehospitaldistrictcodes / leaveldeptcodes,
// 后端按 leavehospitaldistrictcode IN ('一病区','二病区'…) 过滤 → 一条也匹配不上。
// 科室/病区一律只用真实编码:有归属取 store,无归属(如管理员)取 deptTreeSelect 字典。
/**
 * 接口数据 → 页面内部题目结构
@@ -445,8 +448,13 @@
    const details = pickDetails(item);
    let node = map.get(title);
    if (!node) {
      node = { title, type: "", options: new Map() };
      node = { title, type: "", options: new Map(), avgScore: null };
      map.set(title, node);
    }
    // 综合得分:后端返回的题目级 avgScore(与题目同级)
    if (node.avgScore == null) {
      const rawAvg = Number(item && item.avgScore);
      node.avgScore = isNaN(rawAvg) ? null : rawAvg;
    }
    details.forEach((d) => {
@@ -480,7 +488,7 @@
  return Array.from(map.values()).map((n) => {
    const options = Array.from(n.options.values());
    const item = { title: n.title, type: n.type, options };
    const item = { title: n.title, type: n.type, options, avgScore: n.avgScore };
    // 多选题:用各选项数量之和作为填报总量(接口未单独提供人次)
    if (n.type === "multiple")
      item.total = options.reduce((s, o) => s + o.count, 0);
@@ -492,11 +500,14 @@
  name: "DataOverview",
  data() {
    return {
      // 满意度类型:leave=离院满意度(离院科室/病区),hospital=在院满意度(在院科室/病区)
      satisfactionScope: "leave",
      // 顶部全局筛选
      queryParams: {
        dateRange: [],
        deptCodes: [], // 科室编码集合 => leaveldeptcodes
        wardCodes: [], // 出院病区编码集合 => leavehospitaldistrictcodes(接口必填)
        // 病区编码集合:住院满意度按在院病区(hospitaldistrictcodes),其余按出院病区(leavehospitaldistrictcodes)
        wardCodes: [],
        serviceTypeList: [], // 服务类型多选,提交时用逗号拼接
        deptOrDistrict: "1", // 科室/病区组合方式,默认 1:离院病区 and 离院科室
        statMode: "ward", // 统计维度:ward=按病区统计 dept=按科室统计
@@ -516,22 +527,27 @@
      // 表三:科室/病区评分对比(rows=行数据,questions=动态列)
      deptComparison: { rows: [], questions: [] },
      wardComparison: { rows: [], questions: [] },
      // 科室/病区字典:当前账号没有归属科室/病区时(如管理员)从 deptTreeSelect 拉取,
      // 保证下拉里放的是真实编码(deptCode / districtCode),而不是名称
      deptDict: [],
      wardDict: [],
    };
  },
  computed: {
    // 科室选项:优先取当前用户所属科室,无则兜底
    // 科室选项:优先取当前用户所属科室;没有归属时用科室字典(真实 deptCode)
    deptOptions() {
      const list = this.$store.getters.belongDepts || [];
      return list.length
        ? list.map((d) => ({ label: d.deptName, value: d.deptCode }))
        : DEPTS.map((d) => ({ label: d, value: d }));
      const list = (this.$store.getters.belongDepts || [])
        .map((d) => ({ label: d.deptName, value: d.deptCode }))
        .filter((d) => d.label && d.value);
      return list.length ? list : this.deptDict;
    },
    // 病区选项:优先取当前用户所属病区,无则兜底
    // 病区选项:优先取当前用户所属病区;没有归属时用病区字典(真实 districtCode)
    wardOptions() {
      const list = this.$store.getters.belongWards || [];
      return list.length
        ? list.map((w) => ({ label: w.districtName, value: w.districtCode }))
        : WARDS.map((w) => ({ label: w, value: w }));
      const list = (this.$store.getters.belongWards || [])
        .map((w) => ({ label: w.districtName, value: w.districtCode }))
        .filter((w) => w.label && w.value);
      return list.length ? list : this.wardDict;
    },
    // 服务类型下拉:取 store 的随访类型字典 tasktypes,取不到时兜底本地 SERVICE_TYPES
    serviceTypeOptions() {
@@ -545,13 +561,22 @@
      const taskTypes = toOptions(this.$store.getters.tasktypes);
      return taskTypes.length ? taskTypes : SERVICE_TYPES;
    },
    // 未选病区时的默认查询范围:全部病区编码
    // 未选病区时的默认查询范围:全部病区编码(空数组 = 不限,后端不拼过滤条件)
    defaultWardCodes() {
      return Array.from(new Set(this.wardOptions.map((w) => w.value)));
      return Array.from(
        new Set(this.wardOptions.map((w) => w.value).filter(Boolean))
      );
    },
    // 未选科室时的默认查询范围:全部科室编码
    // 是否按「在院满意度」口径查询:在院满意度用 deptcodes/hospitaldistrictcodes,
    // 离院满意度(以及清空下拉时的默认口径)用 leaveldeptcodes/leavehospitaldistrictcodes
    isInHospitalSatisfaction() {
      return this.satisfactionScope === "hospital";
    },
    // 未选科室时的默认查询范围:全部科室编码(空数组 = 不限)
    defaultDeptCodes() {
      return Array.from(new Set(this.deptOptions.map((d) => d.value)));
      return Array.from(
        new Set(this.deptOptions.map((d) => d.value).filter(Boolean))
      );
    },
    // 固定六项选项的题目 => 表一
    satisfactionQuestions() {
@@ -595,7 +620,8 @@
          veryBadRate: this.rateOf(veryBad.count, total),
          naCount: na.count,
          naRate: this.rateOf(na.count, total),
          score: this.scoreOfItems(items),
          total,
          score: this.formatScore(q.avgScore),
        };
      });
@@ -609,6 +635,17 @@
        });
      });
      const total = sum.reduce((s, it) => s + it.count, 0);
      // 总计综合得分:按各题填报量加权平均各题 avgScore
      let totalScoreNum = 0;
      let totalScoreDen = 0;
      this.satisfactionQuestions.forEach((q, i) => {
        const avg = Number(q.avgScore);
        const qTotal = rows[i] ? rows[i].total : 0;
        if (!isNaN(avg) && qTotal) {
          totalScoreNum += avg * qTotal;
          totalScoreDen += qTotal;
        }
      });
      rows.push({
        category: "总计",
        veryGoodCount: sum[0].count,
@@ -623,7 +660,7 @@
        veryBadRate: this.rateOf(sum[4].count, total),
        naCount: sum[5].count,
        naRate: this.rateOf(sum[5].count, total),
        score: this.scoreOfItems(sum),
        score: totalScoreDen ? (totalScoreNum / totalScoreDen).toFixed(2) : "0.00",
      });
      return rows;
    },
@@ -676,7 +713,9 @@
    }
    this.queryParams.serviceTypeList = this.defaultServiceTypes();
  },
  mounted() {
  async mounted() {
    // 先把科室/病区字典补齐,保证首次查询用的就是真实编码
    await this.ensureOrgDict();
    this.fetchData();
  },
  methods: {
@@ -687,6 +726,52 @@
        return item ? item.value : null;
      }).filter((v) => v != null);
      return Array.from(new Set(hit));
    },
    // 归一化科室/病区编码:选中项优先,其次默认全部;去重、去空
    // 返回空数组表示「不限」(后端 mapper 用 size() > 0 判断,空集合不拼过滤条件)
    codeList(selected, fallback) {
      const list = (selected && selected.length ? selected : fallback) || [];
      return Array.from(
        new Set(
          list.filter((v) => v !== null && v !== undefined && String(v).trim() !== "")
        )
      );
    },
    // 补全科室/病区字典:仅当 store 里没有归属数据时(典型是管理员账号)才请求
    async ensureOrgDict() {
      const tasks = [];
      if (!(this.$store.getters.belongDepts || []).length) {
        tasks.push(this.loadOrgDict(1));
      }
      if (!(this.$store.getters.belongWards || []).length) {
        tasks.push(this.loadOrgDict(2));
      }
      if (tasks.length) await Promise.all(tasks);
    },
    // deptType:1=科室,2=病区;结果既作下拉选项,也作「全部」时的编码来源
    async loadOrgDict(deptType) {
      let flat = [];
      try {
        const res = await deptTreeSelect(deptType);
        const walk = (nodes) => {
          (Array.isArray(nodes) ? nodes : []).forEach((n) => {
            // deptTreeSelect 返回 TreeSelect:{ id, label, deptCode, children }
            // 只认 deptCode,缺失就跳过——用 deptId 冒充编码同样查不到数据
            if (n.label && n.deptCode) {
              flat.push({ label: n.label, value: String(n.deptCode) });
            }
            if (n.children && n.children.length) walk(n.children);
          });
        };
        walk(res && res.data);
      } catch (e) {
        console.warn("[DataOverview] 获取科室/病区字典失败,将按“全部”查询", e);
      }
      if (deptType === 1) {
        this.deptDict = flat;
      } else {
        this.wardDict = flat;
      }
    },
    // 表三独立条件:'inherit' 时沿用顶部所选;返回选中的名称数组(接口只回名称),空数组表示不限
    statFilterNames() {
@@ -772,6 +857,11 @@
      if (!denominator) return "0.00";
      return (numerator / denominator).toFixed(2);
    },
    // 综合得分展示:直接使用后端 avgScore,格式化为两位小数
    formatScore(val) {
      const n = Number(val);
      return isNaN(n) ? "0.00" : n.toFixed(2);
    },
    defaultRange() {
      const end = new Date();
      const start = new Date();
@@ -806,8 +896,10 @@
        return;
      }
      // 病区 => leavehospitaldistrictcodes,科室 => leaveldeptcodes
      // 科室/病区互斥:按病区只传 leavehospitaldistrictcodes,按科室只传 leaveldeptcodes
      // 科室/病区条件随「满意度类型」切换:
      // 在院满意度 => deptcodes / hospitaldistrictcodes(在院科室、在院病区)
      // 离院满意度 => leaveldeptcodes / leavehospitaldistrictcodes(离院科室、离院病区)
      // 科室/病区互斥:按病区只传病区编码,按科室只传科室编码
      const wards = this.queryParams.wardCodes || [];
      const depts = this.queryParams.deptCodes || [];
@@ -817,13 +909,27 @@
        endFinishTime: range[1],
      };
      if (this.queryParams.statMode === "dept") {
        payload.leaveldeptcodes = depts.length
          ? Array.from(new Set(depts))
          : this.defaultDeptCodes;
        const deptCodes = this.codeList(depts, this.defaultDeptCodes);
        // 有编码才传;空数组等价于「全部」(后端 mapper 对空集合不拼条件)
        if (deptCodes.length) {
          if (this.isInHospitalSatisfaction) {
            payload.deptcodes = deptCodes;
          } else {
            payload.leaveldeptcodes = deptCodes;
          }
        }
      } else {
        payload.leavehospitaldistrictcodes = wards.length
          ? Array.from(new Set(wards))
          : this.defaultWardCodes;
        // 只传「用户真正选中的病区」:没选(= 全部病区)就不传,后端按不限处理。
        // 门诊满意度等任务的 leavehospitaldistrictcode 为空,
        // 原来把「全部病区编码」传下去会让这些任务被 IN 条件整段过滤掉(25 题只剩 10 题)。
        const wardCodes = this.codeList(wards, []);
        if (wardCodes.length) {
          if (this.isInHospitalSatisfaction) {
            payload.hospitaldistrictcodes = wardCodes;
          } else {
            payload.leavehospitaldistrictcodes = wardCodes;
          }
        }
      }
      // 服务类型多选:逗号分隔(接口字段为 serviceType 字符串)
      const serviceTypes = this.queryParams.serviceTypeList || [];
@@ -870,10 +976,30 @@
        endFinishTime: range[1],
      };
      try {
        // 按科室统计(leaveldeptcodes 优先)与按病区统计(leavehospitaldistrictcodes)各查一次
        // 按科室统计、按病区统计各查一次,条件随「满意度类型」切换:
        // 在院满意度 => deptcodes / hospitaldistrictcodes;离院满意度 => leaveldeptcodes / leavehospitaldistrictcodes
        // 没有真实编码时不传该字段(= 全部),绝不把名称当编码传
        const deptParam = {};
        const deptCodes = this.codeList([], this.defaultDeptCodes);
        if (deptCodes.length) {
          if (this.isInHospitalSatisfaction) {
            deptParam.deptcodes = deptCodes;
          } else {
            deptParam.leaveldeptcodes = deptCodes;
          }
        }
        const wardParam = {};
        const wardCodes = this.codeList([], this.defaultWardCodes);
        if (wardCodes.length) {
          if (this.isInHospitalSatisfaction) {
            wardParam.hospitaldistrictcodes = wardCodes;
          } else {
            wardParam.leavehospitaldistrictcodes = wardCodes;
          }
        }
        const [deptRes, wardRes] = await Promise.all([
          statQuestionOptionByKsOrBq({ ...base, leaveldeptcodes: this.defaultDeptCodes }),
          statQuestionOptionByKsOrBq({ ...base, leavehospitaldistrictcodes: this.defaultWardCodes }),
          statQuestionOptionByKsOrBq({ ...base, ...deptParam }),
          statQuestionOptionByKsOrBq({ ...base, ...wardParam }),
        ]);
        // 排查用:打印接口原始返回,便于核对两级嵌套 Map 结构(确认无误后可删除)
        console.log(
@@ -988,51 +1114,164 @@
      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 };
    },
  },
};
@@ -1075,6 +1314,12 @@
  font-weight: 600;
  color: #303133;
}
.card-subtitle {
  margin-left: 6px;
  font-size: 12px;
  font-weight: 400;
  color: #909399;
}
.header-right {
  display: flex;
  align-items: center;