WXL (wul)
17 小时以前 a9d2b856e0f6be6475319c2dc36bf405c2f908fc
src/views/Satisfaction/sfstatistics/components/DataOverview.vue
@@ -3,7 +3,7 @@
    <!-- 顶部筛选:时间范围 + 科室/病区,控制本页所有数据 -->
    <el-card shadow="never" class="overview-card filter-card">
      <el-form inline class="filter-form">
        <el-form-item label="时间范围">
        <el-form-item label="出院日期">
          <el-date-picker
            v-model="queryParams.dateRange"
            type="daterange"
@@ -14,49 +14,117 @@
            size="small"
          />
        </el-form-item>
        <el-form-item label="科室/病区">
        <el-form-item label="统计维度">
          <el-radio-group
            v-model="queryParams.statMode"
            size="small"
            @change="onStatModeChange"
          >
            <el-radio-button label="ward">按病区统计</el-radio-button>
            <el-radio-button label="dept">按科室统计</el-radio-button>
          </el-radio-group>
        </el-form-item>
        <el-form-item label="科室" v-if="queryParams.statMode === 'dept'">
          <el-select
            v-model="queryParams.deptWard"
            v-model="queryParams.deptCodes"
            multiple
            collapse-tags
            clearable
            filterable
            placeholder="全部"
            placeholder="全部科室"
            size="small"
            class="filter-select"
          >
            <el-option-group label="科室">
              <el-option
                v-for="d in deptOptions"
                :key="'dept:' + d"
                :label="d"
                :value="'dept:' + d"
              />
            </el-option-group>
            <el-option-group label="病区">
              <el-option
                v-for="w in wardOptions"
                :key="'ward:' + w"
                :label="w"
                :value="'ward:' + w"
              />
            </el-option-group>
            <el-option
              v-for="d in deptOptions"
              :key="d.value"
              :label="d.label"
              :value="d.value"
            />
          </el-select>
        </el-form-item>
        <el-form-item label="病区" v-else>
          <el-select
            v-model="queryParams.wardCodes"
            multiple
            collapse-tags
            clearable
            filterable
            placeholder="全部病区"
            size="small"
            class="filter-select"
          >
            <el-option
              v-for="w in wardOptions"
              :key="w.value"
              :label="w.label"
              :value="w.value"
            />
          </el-select>
        </el-form-item>
        <el-form-item label="服务类型">
          <el-select
            v-model="queryParams.serviceTypeList"
            multiple
            collapse-tags
            clearable
            filterable
            placeholder="全部类型"
            size="small"
            class="filter-select"
          >
            <el-option
              v-for="s in serviceTypeOptions"
              :key="s.value"
              :label="s.label"
              :value="s.value"
            />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" size="small" icon="el-icon-search" @click="fetchData">查询</el-button>
          <el-button size="small" icon="el-icon-refresh" @click="handleReset">重置</el-button>
          <el-button
            type="primary"
            size="small"
            icon="el-icon-search"
            :loading="loading"
            @click="fetchData"
            >查询</el-button
          >
          <el-button size="small" icon="el-icon-refresh" @click="handleReset"
            >重置</el-button
          >
        </el-form-item>
      </el-form>
    </el-card>
    <!-- 表格一:各服务项目满意度统计(固定五项选项的题目) -->
    <!-- 表格一:各服务项目满意度统计(固定六项选项的题目) -->
    <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>
        <el-button
          type="warning"
          size="small"
          icon="el-icon-download"
          @click="exportTable('table1')"
          >导出Excel</el-button
        >
      </div>
      <el-table id="table1" :data="serviceSatisfaction" border size="small" :header-cell-style="{ background: '#f5f7fa', color: '#303133' }">
        <el-table-column prop="category" label="分类/题目" min-width="220" fixed="left" />
        <el-table-column label="非常满意" align="center">
      <el-table
        id="table1"
        v-loading="loading"
        element-loading-text="统计中..."
        :data="serviceSatisfaction"
        border
        size="small"
        :header-cell-style="{ background: '#f5f7fa', color: '#303133' }"
      >
        <el-table-column
          prop="category"
          label="分类/题目"
          min-width="220"
          fixed="left"
        />
        <el-table-column label="很满意" align="center">
          <el-table-column prop="veryGoodCount" label="数量" width="90" />
          <el-table-column prop="veryGoodRate" label="比例" width="90" />
        </el-table-column>
@@ -72,11 +140,21 @@
          <el-table-column prop="badCount" label="数量" width="90" />
          <el-table-column prop="badRate" label="比例" width="90" />
        </el-table-column>
        <el-table-column label="不适用" align="center">
        <el-table-column label="很不满意" align="center">
          <el-table-column prop="veryBadCount" label="数量" width="90" />
          <el-table-column prop="veryBadRate" label="比例" width="90" />
        </el-table-column>
        <el-table-column label="未经历" align="center">
          <el-table-column prop="naCount" label="数量" width="90" />
          <el-table-column prop="naRate" label="比例" width="90" />
        </el-table-column>
        <el-table-column prop="score" label="综合得分" width="100" align="center" />
        <el-table-column
          prop="score"
          label="综合得分"
          width="100"
          align="center"
          fixed="right"
        />
      </el-table>
    </el-card>
@@ -84,17 +162,59 @@
    <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('table2')">导出Excel</el-button>
        <div class="header-right">
          <el-input
            v-model="questionSearch"
            size="small"
            clearable
            prefix-icon="el-icon-search"
            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">
        <div class="question-block" v-for="(q, qi) in questionStats" :key="qi">
          <h4 class="q-title">{{ q.title }}</h4>
          <el-table :data="q.options" border size="small" :header-cell-style="{ background: '#f5f7fa', color: '#303133' }">
            <el-table-column prop="option" label="选项" min-width="180" />
            <el-table-column prop="count" label="数量" width="100" align="center" />
            <el-table-column prop="rate" label="比例" width="100" align="center" />
          </el-table>
          <div v-if="q.total" class="q-footer">本题有效填写人次:<b>{{ q.total }}</b></div>
      <div id="table2" v-loading="loading" element-loading-text="统计中...">
        <div class="table2-scroll">
          <div
            class="question-block"
            v-for="(q, qi) in filteredQuestionStats"
            :key="qi"
          >
            <h4 class="q-title">{{ q.title }}</h4>
            <el-table
              :data="q.options"
              border
              size="small"
              :header-cell-style="{ background: '#f5f7fa', color: '#303133' }"
            >
              <el-table-column prop="option" label="选项" min-width="180" />
              <el-table-column
                prop="count"
                label="数量"
                width="100"
                align="center"
              />
              <el-table-column
                prop="rate"
                label="比例"
                width="100"
                align="center"
              />
            </el-table>
            <div v-if="q.total" class="q-footer">
              本题填报总量:<b>{{ q.total }}</b>
            </div>
          </div>
          <div v-if="!loading && !filteredQuestionStats.length" class="empty-tip">
            {{ questionStats.length ? "未找到匹配的题目" : "暂无单选/多选题统计数据" }}
          </div>
        </div>
      </div>
    </el-card>
@@ -104,149 +224,336 @@
      <div slot="header" class="card-header">
        <span class="card-title">各科室/病区满意度评分对比</span>
        <div class="header-right">
          <el-radio-group v-model="statDimension" size="small" @change="onStatDimensionChange">
          <el-radio-group
            v-model="statDimension"
            size="small"
            @change="onStatDimensionChange"
          >
            <el-radio-button label="dept">按科室</el-radio-button>
            <el-radio-button label="ward">按病区</el-radio-button>
          </el-radio-group>
          <el-select v-model="statDeptWard" size="small" class="stat-select" placeholder="沿用顶部条件">
          <el-select
            v-model="statDeptWard"
            size="small"
            class="stat-select"
            placeholder="沿用顶部条件"
          >
            <el-option label="沿用顶部条件" value="inherit" />
            <template v-if="statDimension === 'dept'">
              <el-option v-for="d in deptOptions" :key="'dept:' + d" :label="d" :value="'dept:' + d" />
              <el-option
                v-for="d in deptOptions"
                :key="'dept:' + d.value"
                :label="d.label"
                :value="'dept:' + d.value"
              />
            </template>
            <template v-else>
              <el-option v-for="w in wardOptions" :key="'ward:' + w" :label="w" :value="'ward:' + w" />
              <el-option
                v-for="w in wardOptions"
                :key="'ward:' + w.value"
                :label="w.label"
                :value="'ward:' + w.value"
              />
            </template>
          </el-select>
          <el-button type="warning" size="small" icon="el-icon-download" @click="exportTable('table3')">导出Excel</el-button>
          <el-button
            type="warning"
            size="small"
            icon="el-icon-download"
            @click="exportTable('table3')"
            >导出Excel</el-button
          >
        </div>
      </div>
      <el-table id="table3" :data="table3Rows" border size="small" :header-cell-style="{ background: '#f5f7fa', color: '#303133' }">
        <el-table-column prop="name" :label="statDimension === 'dept' ? '科室' : '病区'" fixed="left" align="center" />
        <el-table-column label="第1题" align="center">
          <el-table-column prop="q1VeryGood" label="非常满意" width="100" />
          <el-table-column prop="q1Good" label="满意" width="90" />
          <el-table-column prop="q1Normal" label="一般" width="90" />
          <el-table-column prop="q1Bad" label="不满意" width="90" />
          <el-table-column prop="q1Score" label="得分" width="100" />
      <el-table
        id="table3"
        v-loading="loading"
        element-loading-text="统计中..."
        :data="table3Rows"
        border
        size="small"
        max-height="500"
        :header-cell-style="{ background: '#f5f7fa', color: '#303133' }"
      >
        <el-table-column
          prop="name"
          :label="statDimension === 'dept' ? '科室' : '病区'"
          fixed="left"
          align="center"
        />
        <el-table-column
          v-for="q in table3Questions"
          :key="q.title"
          :label="q.title"
          align="center"
        >
          <el-table-column
            v-for="opt in q.options"
            :key="opt"
            :label="opt"
            width="100"
          >
            <template slot-scope="{ row }">
              {{
                (row.cells[q.title] &&
                  row.cells[q.title][opt] &&
                  row.cells[q.title][opt].count) ||
                  0
              }}
            </template>
          </el-table-column>
          <el-table-column label="得分" width="90">
            <template slot-scope="{ row }">
              {{ (row.cells[q.title] && row.cells[q.title]._score) || "0.00" }}
            </template>
          </el-table-column>
        </el-table-column>
        <el-table-column label="第2题" align="center">
          <el-table-column prop="q2VeryGood" label="非常满意" width="100" />
          <el-table-column prop="q2Good" label="满意" width="90" />
          <el-table-column prop="q2Normal" label="一般" width="90" />
          <el-table-column prop="q2Bad" label="不满意" width="90" />
          <el-table-column prop="q2Score" label="得分" width="100" />
        <el-table-column label="综合得分" align="center">
          <template slot-scope="{ row }">
            {{ row.totalScore }}
          </template>
        </el-table-column>
        <el-table-column prop="totalScore" label="综合得分" align="center" />
      </el-table>
    </el-card>
  </div>
</template>
<script>
import XLSX from 'xlsx';
import { sltdMydTotalByScore,statQuestionOptionByKsOrBq } from "@/api/AiCentre/satisfaction";
import XLSX from "xlsx";
// 满意度题的固定选项(顺序即表格列顺序)
const FIXED_OPTIONS = ['非常满意', '满意', '一般', '不满意', '不适用'];
const DEPTS = ['内分泌科', '皮肤科', '肿瘤内科', '消化内科', '肾内科'];
const WARDS = ['一病区', '二病区', '三病区', '四病区', '五病区'];
// 满意度题的固定选项(顺序即表格列顺序)——这些选项的题目进表一,其余进表二
const FIXED_OPTIONS = ["很满意", "满意", "一般", "不满意", "很不满意", "未经历"];
// 无分值选项(如"未经历")是否计入综合得分分母:默认 false,避免拉低得分
const NO_SCORE_IN_DENOMINATOR = false;
// 满意度题(固定五项选项,counts 顺序对应 FIXED_OPTIONS)
const SATISFACTION_BASE = [
  ['诊疗过程中,医务人员是否注意保护您的隐私?', [3219, 1351, 142, 33, 0]],
  ['您对医院服务设施的总体印象如何?', [3110, 1369, 217, 49, 0]],
  ['医院的厕所是否清洁无异味?', [2973, 1504, 235, 33, 0]],
  ['医院内的路标和指示是否明确?', [3234, 1289, 196, 26, 0]],
  ['医院的空间布局是否便利?', [3037, 1460, 209, 39, 0]],
  ['看病期间,您的抱怨和不满能否及时得到回应?', [3019, 1317, 294, 115, 0]],
  ['医生是否用您听得懂的方式解释问题?', [3402, 967, 254, 122, 0]],
  ['医生对您是否尊重?', [3495, 897, 249, 104, 0]],
  ['医生是否仔细倾听您讲话?', [3422, 937, 251, 135, 0]],
  ['对挂号服务人员服务态度是否满意', [3458, 1205, 52, 30, 0]],
  ['您对预约挂号是否满意?', [3527, 1088, 72, 58, 0]],
  ['护士对您是否尊重?', [3420, 1184, 113, 28, 0]],
  ['护士是否用您听得懂的方式解释问题?', [3346, 1250, 122, 27, 0]],
  ['护士是否仔细倾听您讲话?', [3378, 1205, 134, 28, 0]],
// 服务类型字典(接口 serviceType)
const SERVICE_TYPES = [
  { value: "1", label: "监测评估" },
  { value: "2", label: "出院随访" },
  { value: "3", label: "门诊随访" },
  { value: "4", label: "宣教关怀" },
  { value: "5", label: "复诊通知" },
  { value: "6", label: "满意度调查" },
  { value: "7", label: "患者报告" },
  { value: "8", label: "其他通知" },
  { value: "9", label: "体检随访" },
  { value: "10", label: "医技随访" },
  { value: "11", label: "影像专科随访" },
  { value: "12", label: "心电专科随访" },
  { value: "13", label: "专科随访" },
];
// 其余题目(单选/多选,非固定满意度选项)
const OTHER_BASE = [
  {
    title: '您对我院工作人员的服务质量满意不满意?【分值单选题】',
    type: 'single',
    options: [['非常满意', 1084], ['满意', 1959], ['一般', 167], ['不满意', 153]],
  },
  {
    title: '您对哪部分服务不满意?【多选题】',
    type: 'multiple',
    total: 408,
    options: [['医生诊断和诊疗', 131], ['护理服务', 47], ['其他医务人员技术水平', 30], ['工人', 72]],
  },
  {
    title: '医生诊断和治疗中哪些服务质量不满意?【多选题】',
    type: 'multiple',
    total: 131,
    options: [['医生诊断错误', 23], ['对治疗效果不满意', 72], ['其他', 54]],
  },
];
// 服务类型默认选中项(按 label 匹配,适配不同字典的 value 差异)
const DEFAULT_SERVICE_LABELS = ["住院满意度", "门诊满意度"];
// 统一接口返回的题目(模拟):固定五项选项 => 表一,其余 => 表二
const RAW_QUESTIONS = [
  ...SATISFACTION_BASE.map(([title, counts]) => ({
    title,
    type: 'single',
    options: FIXED_OPTIONS.map((opt, i) => ({ option: opt, count: counts[i] })),
  })),
  ...OTHER_BASE.map((q) => ({
    title: q.title,
    type: q.type,
    total: q.total,
    options: q.options.map(([option, count]) => ({ option, count })),
  })),
];
// 兜底:store 中无科室/病区数据时使用(表三对比 + 筛选下拉)
const DEPTS = ["内分泌科", "皮肤科", "肿瘤内科", "消化内科", "肾内科"];
const WARDS = ["一病区", "二病区", "三病区", "四病区", "五病区"];
// 确定性伪随机(便于筛选条件固定时结果稳定)
function hashString(str) {
  let h = 2166136261;
  for (let i = 0; i < str.length; i++) {
    h ^= str.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return h >>> 0;
}
function mulberry32(a) {
  return function () {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
/**
 * 接口数据 → 页面内部题目结构
 * 入参:接口 data(List<QuestionResultDTO>)
 * 出参:[{ title, type, total, options: [{ option, count, score }] }]
 * 同一题目可能分散在多个任务(taskname)里,这里按「题目 + 选项」合并累加数量
 */
function normalizeQuestions(list) {
  const map = new Map();
  // 入参可能是数组 / {list} / {rows} / 单对象,统一规整成数组
  const arr = Array.isArray(list)
    ? list
    : (list &&
      (Array.isArray(list.list)
        ? list.list
        : Array.isArray(list.rows)
        ? list.rows
        : [list])) ||
      [];
  // 题目文本字段候选(本系统题目内容统一叫 scriptContent,兼容历史命名)
  const pickTitle = (item) => {
    const keys = [
      "scriptContent",
      "questionText",
      "qeustionText",
      "questiontext",
      "scriptcontent",
      "title",
      "name",
      "questionName",
    ];
    for (const k of keys) {
      if (item && item[k] != null && String(item[k]).trim()) {
        return String(item[k]).trim();
      }
    }
    return "";
  };
  // 选项明细列表字段候选
  const pickDetails = (item) => {
    const keys = [
      "subtaskDetailRatioExportList",
      "details",
      "optionList",
      "options",
      "optionResultList",
      "subtaskDetailRatioExportlist",
      "list",
    ];
    for (const k of keys) {
      if (item && Array.isArray(item[k])) return item[k];
    }
    return [];
  };
  // 选项文本字段候选
  const pickOption = (d) => {
    const keys = [
      "optionresult",
      "optioncontent",
      "optionText",
      "targetvalue",
      "optionName",
      "name",
      "option",
      "content",
    ];
    for (const k of keys) {
      if (d && d[k] != null && String(d[k]).trim()) {
        return String(d[k]).trim();
      }
    }
    return "";
  };
  // 数量字段候选
  const pickCount = (d) => {
    const keys = ["count", "chosenQuantity", "num", "total", "chosenquantity"];
    for (const k of keys) {
      if (d && d[k] != null && !isNaN(Number(d[k]))) {
        return Number(d[k]) || 0;
      }
    }
    return 0;
  };
  arr.forEach((item) => {
    if (!item || typeof item !== "object") return;
    const title = pickTitle(item) || "未命名题目";
    const details = pickDetails(item);
    let node = map.get(title);
    if (!node) {
      node = { title, type: "", options: new Map() };
      map.set(title, node);
    }
    details.forEach((d) => {
      const option = pickOption(d);
      if (!option) return;
      const count = pickCount(d);
      const rawScore = Number(d && d.score);
      const score = isNaN(rawScore) ? null : rawScore;
      // 题型:优先 scriptType(1单选 2多选 4问答),其次 valueType(1单选 2多选 3问答 4填空 5其它)
      const typeVal = String(
        (item && item.scriptType) ||
          (d && d.scriptType) ||
          (d && d.valueType) ||
          (item && item.valueType) ||
          ""
      );
      if (!node.type) {
        node.type =
          typeVal === "2" ? "multiple" : typeVal === "1" ? "single" : "qa";
      }
      const old = node.options.get(option);
      if (old) {
        old.count += count;
        if (old.score == null) old.score = score;
      } else {
        node.options.set(option, { option, count, score });
      }
    });
  });
  return Array.from(map.values()).map((n) => {
    const options = Array.from(n.options.values());
    const item = { title: n.title, type: n.type, options };
    // 多选题:用各选项数量之和作为填报总量(接口未单独提供人次)
    if (n.type === "multiple")
      item.total = options.reduce((s, o) => s + o.count, 0);
    return item;
  });
}
export default {
  name: 'DataOverview',
  name: "DataOverview",
  data() {
    return {
      // 顶部全局筛选
      queryParams: {
        dateRange: [],
        deptWard: '',
        deptCodes: [], // 科室编码集合 => leaveldeptcodes
        wardCodes: [], // 出院病区编码集合 => leavehospitaldistrictcodes(接口必填)
        serviceTypeList: [], // 服务类型多选,提交时用逗号拼接
        deptOrDistrict: "1", // 科室/病区组合方式,默认 1:离院病区 and 离院科室
        statMode: "ward", // 统计维度:ward=按病区统计 dept=按科室统计
      },
      deptOptions: DEPTS,
      wardOptions: WARDS,
      SERVICE_TYPES,
      loading: false,
      // 表二:题目模糊查询关键字(纯前端过滤,不请求后端)
      questionSearch: "",
      // 表格三:统计维度 + 独立科室/病区条件('inherit' = 沿用顶部)
      statDimension: 'dept',
      statDeptWard: 'inherit',
      statDimension: "ward",
      statDeptWard: "inherit",
      // 统一接口返回的数据(模拟)
      // 接口返回的原始数据(已归一化的题目列表)
      questionList: [],
      deptComparison: [],
      wardComparison: [],
      // 表三:科室/病区评分对比(rows=行数据,questions=动态列)
      deptComparison: { rows: [], questions: [] },
      wardComparison: { rows: [], questions: [] },
    };
  },
  computed: {
    // 固定五项选项的题目 => 表一
    // 科室选项:优先取当前用户所属科室,无则兜底
    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 }));
    },
    // 病区选项:优先取当前用户所属病区,无则兜底
    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 }));
    },
    // 服务类型下拉:取 store 的随访类型字典 tasktypes,取不到时兜底本地 SERVICE_TYPES
    serviceTypeOptions() {
      const toOptions = (list) =>
        (Array.isArray(list) ? list : [])
          .map((i) => ({
            label: String(i.label || "").trim(),
            value: String(i.value),
          }))
          .filter((i) => i.label && i.value && i.value !== "undefined");
      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)));
    },
    // 未选科室时的默认查询范围:全部科室编码
    defaultDeptCodes() {
      return Array.from(new Set(this.deptOptions.map((d) => d.value)));
    },
    // 固定六项选项的题目 => 表一
    satisfactionQuestions() {
      return this.questionList.filter((q) => this.isSatisfactionQuestion(q));
    },
@@ -256,54 +563,67 @@
    },
    // 表一:满意度统计(含总计行)
    serviceSatisfaction() {
      // 取指定选项的 { count, score },未返回则该项为 0
      const pick = (q, name) => {
        const hit = q.options.find((o) => o.option === name);
        return hit
          ? { count: Number(hit.count) || 0, score: hit.score }
          : { count: 0, score: null };
      };
      const NAMES = FIXED_OPTIONS;
      const rows = this.satisfactionQuestions.map((q) => {
        const m = {};
        q.options.forEach((o) => (m[o.option] = o.count));
        const veryGood = m['非常满意'] || 0;
        const good = m['满意'] || 0;
        const normal = m['一般'] || 0;
        const bad = m['不满意'] || 0;
        const na = m['不适用'] || 0;
        const total = veryGood + good + normal + bad + na;
        const veryGood = pick(q, "很满意");
        const good = pick(q, "满意");
        const normal = pick(q, "一般");
        const bad = pick(q, "不满意");
        const veryBad = pick(q, "很不满意");
        const na = pick(q, "未经历");
        const items = [veryGood, good, normal, bad, veryBad, na];
        const total = items.reduce((s, it) => s + it.count, 0);
        return {
          category: q.title,
          veryGoodCount: veryGood,
          veryGoodRate: this.rateOf(veryGood, total),
          goodCount: good,
          goodRate: this.rateOf(good, total),
          normalCount: normal,
          normalRate: this.rateOf(normal, total),
          badCount: bad,
          badRate: this.rateOf(bad, total),
          naCount: na,
          naRate: this.rateOf(na, total),
          score: this.scoreOf([veryGood, good, normal, bad, na]),
          veryGoodCount: veryGood.count,
          veryGoodRate: this.rateOf(veryGood.count, total),
          goodCount: good.count,
          goodRate: this.rateOf(good.count, total),
          normalCount: normal.count,
          normalRate: this.rateOf(normal.count, total),
          badCount: bad.count,
          badRate: this.rateOf(bad.count, total),
          veryBadCount: veryBad.count,
          veryBadRate: this.rateOf(veryBad.count, total),
          naCount: na.count,
          naRate: this.rateOf(na.count, total),
          score: this.scoreOfItems(items),
        };
      });
      // 总计行
      const sum = { veryGood: 0, good: 0, normal: 0, bad: 0, na: 0 };
      rows.forEach((r) => {
        sum.veryGood += r.veryGoodCount;
        sum.good += r.goodCount;
        sum.normal += r.normalCount;
        sum.bad += r.badCount;
        sum.na += r.naCount;
      // 总计行:各题同选项数量累加,分值沿用该选项分值
      const sum = NAMES.map(() => ({ count: 0, score: null }));
      this.satisfactionQuestions.forEach((q) => {
        NAMES.forEach((name, i) => {
          const it = pick(q, name);
          sum[i].count += it.count;
          if (sum[i].score == null) sum[i].score = it.score;
        });
      });
      const total = sum.veryGood + sum.good + sum.normal + sum.bad + sum.na;
      const total = sum.reduce((s, it) => s + it.count, 0);
      rows.push({
        category: '总计',
        veryGoodCount: sum.veryGood,
        veryGoodRate: this.rateOf(sum.veryGood, total),
        goodCount: sum.good,
        goodRate: this.rateOf(sum.good, total),
        normalCount: sum.normal,
        normalRate: this.rateOf(sum.normal, total),
        badCount: sum.bad,
        badRate: this.rateOf(sum.bad, total),
        naCount: sum.na,
        naRate: this.rateOf(sum.na, total),
        score: this.scoreOf([sum.veryGood, sum.good, sum.normal, sum.bad, sum.na]),
        category: "总计",
        veryGoodCount: sum[0].count,
        veryGoodRate: this.rateOf(sum[0].count, total),
        goodCount: sum[1].count,
        goodRate: this.rateOf(sum[1].count, total),
        normalCount: sum[2].count,
        normalRate: this.rateOf(sum[2].count, total),
        badCount: sum[3].count,
        badRate: this.rateOf(sum[3].count, total),
        veryBadCount: sum[4].count,
        veryBadRate: this.rateOf(sum[4].count, total),
        naCount: sum[5].count,
        naRate: this.rateOf(sum[5].count, total),
        score: this.scoreOfItems(sum),
      });
      return rows;
    },
@@ -322,138 +642,359 @@
        };
      });
    },
    // 表二:按题目模糊查询(纯前端过滤,不请求后端)
    filteredQuestionStats() {
      const kw = (this.questionSearch || "").trim().toLowerCase();
      if (!kw) return this.questionStats;
      return this.questionStats.filter((q) =>
        (q.title || "").toLowerCase().indexOf(kw) > -1
      );
    },
    // 表三:按维度 + 独立条件过滤后的行
    table3Rows() {
      const list = this.statDimension === 'dept' ? this.deptComparison : this.wardComparison;
      const f = this.statFilter();
      if (!f) return list;
      const [type, name] = f.split(':');
      if (type !== this.statDimension) return list;
      return list.filter((r) => r.name === name);
      const comp =
        this.statDimension === "dept"
          ? this.deptComparison
          : this.wardComparison;
      const list = (comp && comp.rows) || [];
      const names = this.statFilterNames();
      if (!names || !names.length) return list;
      return list.filter((r) => names.indexOf(r.name) > -1);
    },
    // 表三:当前维度下的题目列(动态列)
    table3Questions() {
      const comp =
        this.statDimension === "dept"
          ? this.deptComparison
          : this.wardComparison;
      return (comp && comp.questions) || [];
    },
  },
  mounted() {
  created() {
    if (!this.queryParams.dateRange || !this.queryParams.dateRange.length) {
      this.queryParams.dateRange = this.defaultRange();
    }
    this.queryParams.serviceTypeList = this.defaultServiceTypes();
  },
  mounted() {
    this.fetchData();
  },
  methods: {
    // 表三独立条件:'inherit' 时沿用顶部条件
    statFilter() {
      const v = this.statDeptWard === 'inherit' ? this.queryParams.deptWard : this.statDeptWard;
      return v || '';
    // 默认选中的服务类型(按 label 匹配,取不到就返回空数组 = 不限)
    defaultServiceTypes() {
      const hit = DEFAULT_SERVICE_LABELS.map((label) => {
        const item = this.serviceTypeOptions.find((o) => o.label === label);
        return item ? item.value : null;
      }).filter((v) => v != null);
      return Array.from(new Set(hit));
    },
    // 表三独立条件:'inherit' 时沿用顶部所选;返回选中的名称数组(接口只回名称),空数组表示不限
    statFilterNames() {
      const opts =
        this.statDimension === "dept" ? this.deptOptions : this.wardOptions;
      const nameOf = (code) => {
        const hit = opts.find((o) => String(o.value) === String(code));
        return hit ? hit.label : null;
      };
      if (this.statDeptWard === "inherit") {
        const codes =
          this.statDimension === "dept"
            ? this.queryParams.deptCodes
            : this.queryParams.wardCodes;
        return (codes || []).map(nameOf).filter(Boolean);
      }
      // 切换维度后,若已选条件不属于当前维度则视为不限
      if (this.statDeptWard.startsWith(this.statDimension + ":")) {
        const name = nameOf(this.statDeptWard.slice(this.statDimension.length + 1));
        return name ? [name] : [];
      }
      return [];
    },
    onStatDimensionChange() {
      // 切换维度后,若当前已选条件不属于新维度,则恢复“沿用顶部”
      if (this.statDeptWard !== 'inherit' && !this.statDeptWard.startsWith(this.statDimension + ':')) {
        this.statDeptWard = 'inherit';
      if (
        this.statDeptWard !== "inherit" &&
        !this.statDeptWard.startsWith(this.statDimension + ":")
      ) {
        this.statDeptWard = "inherit";
      }
    },
    handleReset() {
      this.queryParams.dateRange = this.defaultRange();
      this.queryParams.deptWard = '';
      this.queryParams.deptCodes = [];
      this.queryParams.wardCodes = [];
      this.queryParams.statMode = "ward";
      this.statDimension = "ward";
      this.statDeptWard = "inherit";
      this.queryParams.serviceTypeList = this.defaultServiceTypes();
      this.fetchData();
    },
    // 判断题目选项是否为固定满意度五项
    // 切换统计维度(科室/病区)时清空另一维度的选择,保证互斥
    onStatModeChange() {
      if (this.queryParams.statMode === "dept") {
        this.queryParams.wardCodes = [];
      } else {
        this.queryParams.deptCodes = [];
      }
      // 单向同步到「各科室/病区满意度评分对比」表:顶部 → 表三
      this.statDimension = this.queryParams.statMode;
      this.onStatDimensionChange();
    },
    // 判断题目是否为满意度六项选项题:所有选项都落在固定六项内即归表一
    isSatisfactionQuestion(q) {
      if (q.options.length !== FIXED_OPTIONS.length) return false;
      const set = q.options.map((o) => o.option);
      return FIXED_OPTIONS.every((o) => set.includes(o));
      if (!q.options || !q.options.length) return false;
      return q.options.every((o) => FIXED_OPTIONS.includes(o.option));
    },
    questionTotal(q) {
      if (q.total != null) return q.total;
      return q.options.reduce((s, o) => s + o.count, 0);
    },
    rateOf(count, total) {
      if (!total) return '0.00%';
      return ((count / total) * 100).toFixed(2) + '%';
      if (!total) return "0.00%";
      return ((count / total) * 100).toFixed(2) + "%";
    },
    // 综合得分:非常满意5分、满意4分、一般3分、不满意2分,换算百分制(不适用不计入)——占位公式,接口就绪后可按后端口径替换
    scoreOf(counts) {
      const [a, b, c, d] = counts;
      const total = a + b + c + d;
      if (!total) return '0.00';
      return (((a * 5 + b * 4 + c * 3 + d * 2) / total) * 20).toFixed(2);
    /**
     * 综合得分(真实口径):Σ(各选项分值 × 填报量) / Σ(填报量)
     * items: [{ count, score }],score 取接口返回的 score 字段
     * 无分值选项(如"未经历")默认不计入分子与分母,避免拉低得分
     */
    scoreOfItems(items) {
      let numerator = 0;
      let denominator = 0;
      items.forEach((it) => {
        const count = Number(it && it.count) || 0;
        const score = Number(it && it.score);
        const hasScore = !isNaN(score);
        if (!hasScore && !NO_SCORE_IN_DENOMINATOR) return;
        numerator += (hasScore ? score : 0) * count;
        denominator += count;
      });
      if (!denominator) return "0.00";
      return (numerator / denominator).toFixed(2);
    },
    defaultRange() {
      const end = new Date();
      const start = new Date();
      start.setDate(start.getDate() - 29);
      start.setDate(start.getDate() - 91);
      const f = (d) => {
        const y = d.getFullYear();
        const m = String(d.getMonth() + 1).padStart(2, '0');
        const day = String(d.getDate()).padStart(2, '0');
        const m = String(d.getMonth() + 1).padStart(2, "0");
        const day = String(d.getDate()).padStart(2, "0");
        return `${y}-${m}-${day}`;
      };
      return [f(start), f(end)];
    },
    dateRangeDays(range) {
      if (Array.isArray(range) && range.length === 2 && range[0] && range[1]) {
        const ms = new Date(range[1]).getTime() - new Date(range[0]).getTime();
        return Math.max(1, Math.round(ms / 86400000));
    // 提示信息(兼容 $modal 与 $message)
    notify(msg, type) {
      const map = {
        warning: ["msgWarning", "warning"],
        error: ["msgError", "error"],
        success: ["msgSuccess", "success"],
      };
      const [modalFn, msgFn] = map[type] || map.success;
      if (this.$modal && this.$modal[modalFn]) {
        this.$modal[modalFn](msg);
      } else if (this.$message && this.$message[msgFn]) {
        this.$message[msgFn](msg);
      }
      return 30;
    },
    // 拉取数据(当前为模拟数据,接口就绪后替换为真实请求)
    fetchData() {
      const data = this.mockFetch(this.queryParams);
      this.questionList = data.questionList;
      this.deptComparison = data.deptComparison;
      this.wardComparison = data.wardComparison;
    },
    mockFetch(params) {
      const seed = hashString(JSON.stringify(params));
      const rnd = mulberry32(seed);
      const days = this.dateRangeDays(params.dateRange);
      const timeFactor = Math.max(0.05, days / 30);
      const scopeFactor = params.deptWard ? 0.18 : 1;
      const jitter = () => 0.9 + rnd() * 0.2;
    // 拉取满意度统计数据(接口:/smartor/serviceSubtaskDetail/sltdMydTotalByScore)
    async fetchData() {
      const range = this.queryParams.dateRange || [];
      if (range.length !== 2 || !range[0] || !range[1]) {
        this.notify("请先选择出院日期范围", "warning");
        return;
      }
      const questionList = RAW_QUESTIONS.map((q) => {
        const options = q.options.map((o) => ({
          option: o.option,
          count: Math.round(o.count * timeFactor * scopeFactor * jitter()),
        }));
        const item = { title: q.title, type: q.type, options };
        if (q.type === 'multiple' && q.total != null) {
          item.total = Math.round(q.total * timeFactor * scopeFactor);
      // 病区 => leavehospitaldistrictcodes,科室 => leaveldeptcodes
      // 科室/病区互斥:按病区只传 leavehospitaldistrictcodes,按科室只传 leaveldeptcodes
      const wards = this.queryParams.wardCodes || [];
      const depts = this.queryParams.deptCodes || [];
      const payload = {
        deptOrDistrict: this.queryParams.deptOrDistrict || "1",
        startFinishTime: range[0],
        endFinishTime: range[1],
      };
      if (this.queryParams.statMode === "dept") {
        payload.leaveldeptcodes = depts.length
          ? Array.from(new Set(depts))
          : this.defaultDeptCodes;
      } else {
        payload.leavehospitaldistrictcodes = wards.length
          ? Array.from(new Set(wards))
          : this.defaultWardCodes;
      }
      // 服务类型多选:逗号分隔(接口字段为 serviceType 字符串)
      const serviceTypes = this.queryParams.serviceTypeList || [];
      if (serviceTypes.length) payload.serviceType = serviceTypes.join(",");
      payload.serviceTypeList = serviceTypes;
      this.loading = true;
      try {
        const res = await sltdMydTotalByScore(payload);
        // 排查用:打印接口原始返回,便于核对字段名(确认无误后可删除)
        console.log(
          "[sltdMydTotalByScore] code=",
          res && res.code,
          "data=",
          JSON.stringify(res && res.data).slice(0, 2000)
        );
        if (res && res.code === 200) {
          this.questionList = normalizeQuestions(res.data);
          if (!this.questionList.length)
            this.notify("当前条件下暂无统计数据", "warning");
        } else {
          this.questionList = [];
          this.notify((res && res.msg) || "统计数据获取失败", "error");
        }
        return item;
        // 表三:各科室/病区满意度评分对比(真实接口,内部自行处理错误)
        await this.fetchComparison();
      } catch (e) {
        this.questionList = [];
        this.notify("统计数据请求失败,请稍后重试", "error");
      } finally {
        this.loading = false;
      }
    },
    // 拉取各科室/病区满意度评分对比(接口:/smartor/serviceSubtaskDetail/statQuestionOptionByKsOrBq)
    async fetchComparison() {
      const range = this.queryParams.dateRange || [];
      if (range.length !== 2 || !range[0] || !range[1]) {
        this.deptComparison = { rows: [], questions: [] };
        this.wardComparison = { rows: [], questions: [] };
        return;
      }
      const base = {
        deptOrDistrict: this.queryParams.deptOrDistrict || "1",
        startFinishTime: range[0],
        endFinishTime: range[1],
      };
      try {
        // 按科室统计(leaveldeptcodes 优先)与按病区统计(leavehospitaldistrictcodes)各查一次
        const [deptRes, wardRes] = await Promise.all([
          statQuestionOptionByKsOrBq({ ...base, leaveldeptcodes: this.defaultDeptCodes }),
          statQuestionOptionByKsOrBq({ ...base, leavehospitaldistrictcodes: this.defaultWardCodes }),
        ]);
        // 排查用:打印接口原始返回,便于核对两级嵌套 Map 结构(确认无误后可删除)
        console.log(
          "[statQuestionOptionByKsOrBq] dept code=",
          deptRes && deptRes.code,
          "data=",
          JSON.stringify(deptRes && deptRes.data).slice(0, 2000),
          "| ward code=",
          wardRes && wardRes.code,
          "data=",
          JSON.stringify(wardRes && wardRes.data).slice(0, 2000)
        );
        this.deptComparison = this.buildComparison(
          deptRes && deptRes.code === 200 ? deptRes.data : {},
          "dept"
        );
        this.wardComparison = this.buildComparison(
          wardRes && wardRes.code === 200 ? wardRes.data : {},
          "ward"
        );
      } catch (e) {
        this.deptComparison = { rows: [], questions: [] };
        this.wardComparison = { rows: [], questions: [] };
        console.error("获取科室/病区评分对比失败:", e);
      }
    },
    // 组装为表三所需的行/列结构:行=科室/病区,列=题目×选项 + 得分 + 综合得分
    // 新接口返回两级嵌套 Map:{ 科室/病区名称: { 题目内容: [QuestionCountKsOrBqDTO] } }
    // 兼容旧扁平数组:[{ questionText, deptname, leavehospitaldistrictname, targetvalue, answerCount, singleScore }]
    buildComparison(data, mode) {
      const questions = [];
      const qMap = new Map();
      const rowMap = new Map();
      // 将一条 (名称, 题目, 选项, 数量, 得分) 明细累加进行/列结构
      const collect = (name, title, option, count, single) => {
        if (!title || !name) return;
        let q = qMap.get(title);
        if (!q) {
          q = { title, options: [] };
          qMap.set(title, q);
          questions.push(q);
        }
        if (option && q.options.indexOf(option) === -1) q.options.push(option);
        let row = rowMap.get(name);
        if (!row) {
          row = { name, code: this.codeFor(mode, name), cells: {} };
          rowMap.set(name, row);
        }
        if (!row.cells[title]) row.cells[title] = {};
        const cell = row.cells[title][option] || { count: 0, score: 0 };
        cell.count += count;
        cell.score += single;
        row.cells[title][option] = cell;
      };
      if (Array.isArray(data)) {
        // 旧结构:扁平数组
        data.forEach((it) => {
          const title = it.questionText || it.qeustionText || "";
          const name =
            mode === "dept"
              ? it.deptname || ""
              : it.leavehospitaldistrictname || "";
          const option = it.targetvalue || it.asrtext || "";
          collect(name, title, option, Number(it.answerCount) || 0, Number(it.singleScore) || 0);
        });
      } else if (data && typeof data === "object") {
        // 新结构:两级嵌套 Map,第一层 key=科室/病区名称,第二层 key=题目,value=选项明细列表
        Object.keys(data).forEach((name) => {
          const questionMap = data[name] || {};
          Object.keys(questionMap).forEach((title) => {
            const list = Array.isArray(questionMap[title]) ? questionMap[title] : [];
            list.forEach((it) => {
              const option = it.targetvalue || it.asrtext || "";
              collect(name, title, option, Number(it.answerCount) || 0, Number(it.singleScore) || 0);
            });
          });
        });
      }
      const rows = Array.from(rowMap.values());
      rows.forEach((row) => {
        let totalCount = 0;
        let totalScore = 0;
        questions.forEach((q) => {
          const cells = row.cells[q.title] || {};
          let qCount = 0;
          let qScore = 0;
          q.options.forEach((opt) => {
            const c = cells[opt];
            if (c) {
              qCount += c.count;
              qScore += c.score;
            }
          });
          // 该题目加权平均分:Σ singleScore / Σ answerCount
          cells._score = qCount ? (qScore / qCount).toFixed(2) : "0.00";
          totalCount += qCount;
          totalScore += qScore;
          row.cells[q.title] = cells;
        });
        row.totalScore = totalCount ? (totalScore / totalCount).toFixed(2) : "0.00";
      });
      const deptComparison = DEPTS.map((d) => this.genCompareRow(d, rnd));
      const wardComparison = WARDS.map((w) => this.genCompareRow(w, rnd));
      return { questionList, deptComparison, wardComparison };
      return { rows, questions };
    },
    genCompareRow(name, rnd) {
      const r = (lo, hi) => Math.round(lo + rnd() * (hi - lo));
      const q1VeryGood = r(20, 180);
      const q1Good = r(5, 55);
      const q1Normal = r(0, 4);
      const q1Bad = r(0, 3);
      const q2VeryGood = r(18, 175);
      const q2Good = r(5, 55);
      const q2Normal = r(0, 4);
      const q2Bad = r(0, 3);
      return {
        name,
        q1VeryGood, q1Good, q1Normal, q1Bad,
        q1Score: this.scoreOf([q1VeryGood, q1Good, q1Normal, q1Bad]),
        q2VeryGood, q2Good, q2Normal, q2Bad,
        q2Score: this.scoreOf([q2VeryGood, q2Good, q2Normal, q2Bad]),
        totalScore: this.scoreOf([
          q1VeryGood + q2VeryGood,
          q1Good + q2Good,
          q1Normal + q2Normal,
          q1Bad + q2Bad,
        ]),
      };
    // 名称 → 编码(用于表三按顶部所选编码过滤)
    codeFor(mode, name) {
      const opts = mode === "dept" ? this.deptOptions : this.wardOptions;
      const hit = opts.find((o) => String(o.label) === String(name));
      return hit ? hit.value : name;
    },
    exportTable(tableId) {
      const tableNames = { table1: '各服务项目满意度统计', table2: '单选题多选题统计', table3: '各科室满意度评分对比' };
      const name = tableNames[tableId] || '统计数据';
      const tableNames = {
        table1: "各服务项目满意度统计",
        table2: "单选题多选题统计",
        table3: "各科室满意度评分对比",
      };
      const name = tableNames[tableId] || "统计数据";
      const table = document.getElementById(tableId);
      if (!table) return;
@@ -461,10 +1002,10 @@
        // 从表格提取数据构建带样式的 Excel
        const wb = XLSX.utils.book_new();
        const data = [];
        table.querySelectorAll('tr').forEach((tr) => {
        table.querySelectorAll("tr").forEach((tr) => {
          const row = [];
          tr.querySelectorAll('th,td').forEach((td) => {
            row.push((td.innerText || '').trim());
          tr.querySelectorAll("th,td").forEach((td) => {
            row.push((td.innerText || "").trim());
          });
          if (row.length) data.push(row);
        });
@@ -472,22 +1013,24 @@
        const ws = XLSX.utils.aoa_to_sheet(data);
        // 设置列宽
        const colWidths = data[0] ? data[0].map(() => ({ wch: 16 })) : [];
        ws['!cols'] = colWidths;
        ws["!cols"] = colWidths;
        XLSX.utils.book_append_sheet(wb, ws, 'Sheet1');
        XLSX.writeFile(wb, name + '.xlsx');
        XLSX.utils.book_append_sheet(wb, ws, "Sheet1");
        XLSX.writeFile(wb, name + ".xlsx");
      } catch (e) {
        // fallback: CSV
        let csv = '';
        table.querySelectorAll('tr').forEach((tr) => {
        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';
          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');
        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.download = name + ".csv";
        link.click();
      }
    },
@@ -496,13 +1039,66 @@
</script>
<style lang="scss" scoped>
.data-overview { .overview-card { margin-bottom: 20px; } }
.filter-card { .filter-form { margin-bottom: -10px; } .filter-select { width: 200px; } }
.card-header { display: flex; justify-content: space-between; align-items: center; }
.card-title { font-size: 16px; font-weight: 600; color: #303133; }
.header-right { display: flex; align-items: center; .stat-select { width: 180px; margin: 0 12px; } }
.question-block { margin-bottom: 24px;
  .q-title { margin: 0 0 12px 0; font-size: 14px; color: #606266; }
  .q-footer { margin-top: 8px; font-size: 13px; color: #909399; text-align: right; }
.data-overview {
  .overview-card {
    margin-bottom: 20px;
  }
}
.filter-card {
  .filter-form {
    margin-bottom: -10px;
  }
  .filter-select {
    width: 220px;
  }
  .filter-select-sm {
    width: 150px;
  }
}
.empty-tip {
  padding: 40px 0;
  text-align: center;
  color: #909399;
  font-size: 13px;
}
.table2-scroll {
  max-height: 560px;
  overflow-y: auto;
}
.card-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.card-title {
  font-size: 16px;
  font-weight: 600;
  color: #303133;
}
.header-right {
  display: flex;
  align-items: center;
  .stat-select {
    width: 180px;
    margin: 0 12px;
  }
  .search-input {
    width: 200px;
    margin-right: 12px;
  }
}
.question-block {
  margin-bottom: 24px;
  .q-title {
    margin: 0 0 12px 0;
    font-size: 14px;
    color: #606266;
  }
  .q-footer {
    margin-top: 8px;
    font-size: 13px;
    color: #909399;
    text-align: right;
  }
}
</style>