WXL (wul)
16 小时以前 a9d2b856e0f6be6475319c2dc36bf405c2f908fc
src/views/Satisfaction/sfstatistics/components/ChartsPanel.vue
@@ -1,7 +1,34 @@
<template>
  <div class="charts-panel" ref="pdfTarget">
    <!-- PDF导出按钮 -->
    <!-- 顶部筛选:时间区间 + 区间类型 + PDF导出 -->
    <div class="export-bar">
      <div class="filter-bar">
        <span class="filter-label">时间范围:</span>
        <el-date-picker
          v-model="queryParams.dateRange"
          type="daterange"
          range-separator="至"
          start-placeholder="开始日期"
          end-placeholder="结束日期"
          value-format="yyyy-MM-dd"
          size="small"
          class="filter-date"
        />
        <span class="filter-label">区间类型:</span>
        <el-radio-group v-model="queryParams.interval" size="small" @change="refreshAll">
          <el-radio-button label="day">按天</el-radio-button>
          <el-radio-button label="week">按周</el-radio-button>
          <el-radio-button label="month">按月</el-radio-button>
          <el-radio-button label="year">按年</el-radio-button>
        </el-radio-group>
        <span class="filter-label">统计维度:</span>
        <el-radio-group v-model="queryParams.dimension" size="small" @change="onDimensionChange">
          <el-radio-button label="ward">按病区统计</el-radio-button>
          <el-radio-button label="dept">按科室统计</el-radio-button>
        </el-radio-group>
        <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>
    </div>
@@ -10,10 +37,13 @@
      <el-col :span="12">
        <el-card shadow="never" class="chart-card">
          <div slot="header" class="card-header">
            <span>时间维度 · 门诊患者满意度趋势</span>
            <el-tag type="success" size="small">环比 +0.97</el-tag>
            <span>患者满意度综合得分趋势</span>
            <el-tag type="success" size="small">环比 {{ trendMoM }}</el-tag>
          </div>
          <div ref="chart1" class="chart-box"></div>
          <div class="chart-wrap" v-loading="trendLoading" element-loading-text="趋势加载中...">
            <div ref="chart1" class="chart-box"></div>
            <div v-if="!trendLoading && !trend.buckets.length" class="chart-empty">暂无趋势数据</div>
          </div>
        </el-card>
      </el-col>
@@ -21,26 +51,43 @@
      <el-col :span="12">
        <el-card shadow="never" class="chart-card">
          <div slot="header" class="card-header">
            <span>分类维度 · 各指标年度对比</span>
            <span>分类维度 · 各指标{{ intervalLabel }}对比</span>
          </div>
          <div ref="chart2" class="chart-box"></div>
          <div class="chart-wrap" v-loading="categoryLoading" element-loading-text="分类维度加载中...">
            <div ref="chart2" class="chart-box"></div>
            <div v-if="!categoryLoading && !categoryCompare.categories.length" class="chart-empty">暂无分类维度数据</div>
          </div>
        </el-card>
      </el-col>
    </el-row>
    <el-row :gutter="20" style="margin-top: 20px">
      <!-- 3. 单题选项 - 数据表格 -->
      <!-- 3. 单题选项 - 数据表格(可选择题目、科室/病区) -->
      <el-col :span="12">
        <el-card shadow="never" class="chart-card">
          <div slot="header" class="card-header">
            <span>单题 · 第22题:抱怨和不满能否及时得到回应?</span>
            <span>单题 · 选项统计</span>
            <div class="header-right">
              <el-select v-model="singleQuestionId" size="small" class="sel sel-lg" filterable remote clearable
                :remote-method="searchQuestions" :loading="questionLoading" placeholder="请输入问题内容搜索" @change="onSingleQuestionChange">
                <el-option v-for="q in questionOptions" :key="q.id" :label="q.scriptContent || q.title" :value="q.id" />
              </el-select>
              <el-select v-model="singleDeptWards" multiple collapse-tags size="small" class="sel" clearable filterable :placeholder="queryParams.dimension === 'dept' ? '全部科室' : '全部病区'" @change="refreshAll">
                <template v-if="queryParams.dimension === 'dept'">
                  <el-option v-for="d in deptOptions" :key="'dept:' + d" :label="d" :value="'dept:' + d" />
                </template>
                <template v-else>
                  <el-option v-for="w in wardOptions" :key="'ward:' + w" :label="w" :value="'ward:' + w" />
                </template>
              </el-select>
            </div>
          </div>
          <el-table :data="singleQuestionData" border size="small">
          <el-table :data="singleQuestionData" border size="small" v-loading="singleLoading" element-loading-text="选项统计加载中...">
            <el-table-column prop="option" label="选项" />
            <el-table-column prop="count" label="合计数量" width="120" align="center" />
            <el-table-column prop="rate" label="占比比例" width="120" align="center" />
          </el-table>
          <div class="table-footer">本题有效填写人次:<b>26,233</b> 综合满意度:<b>83.20%</b></div>
          <div class="table-footer">本题有效填写人次:<b>{{ singleQuestionMeta.total }}</b> 综合得分:<b>{{ singleQuestionMeta.score }}</b></div>
        </el-card>
      </el-col>
@@ -48,38 +95,61 @@
      <el-col :span="12">
        <el-card shadow="never" class="chart-card">
          <div slot="header" class="card-header">
            <span>院区维度 · 各院区年度对比</span>
            <span>院区维度 · 各院区{{ intervalLabel }}对比</span>
          </div>
          <div ref="chart4" class="chart-box"></div>
          <div class="chart-wrap" v-loading="hospitalLoading" element-loading-text="院区维度加载中...">
            <div ref="chart4" class="chart-box"></div>
            <div v-if="!hospitalLoading && !hospitalCompare.hospitals.length" class="chart-empty">暂无院区维度数据</div>
          </div>
        </el-card>
      </el-col>
    </el-row>
    <el-row :gutter="20" style="margin-top: 20px">
      <!-- 5. 科室维度 - 排名表格 -->
      <!-- 5. 科室/病区维度 - 排名表格 -->
      <el-col :span="12">
        <el-card shadow="never" class="chart-card">
          <div slot="header" class="card-header">
            <span>科室维度 · 门诊患者满意度得分排名</span>
            <span>{{ rankDimLabel }}维度 · 门诊患者满意度得分排名</span>
            <div class="header-right">
              <el-radio-group v-model="rankDimension" size="mini" @change="onRankDimensionChange">
                <el-radio-button label="dept">按科室</el-radio-button>
                <el-radio-button label="ward">按病区</el-radio-button>
              </el-radio-group>
              <el-select v-model="rankDeptWards" multiple collapse-tags size="small" class="sel" clearable filterable :placeholder="'全部' + rankDimLabel" @change="refreshAll">
                <el-option v-for="name in rankOptions" :key="name" :label="name" :value="name" />
              </el-select>
            </div>
          </div>
          <el-table :data="deptRankData" border size="small">
            <el-table-column type="index" label="排名" width="60" align="center" />
            <el-table-column prop="dept" label="科室" />
          <el-table :data="rankRows" border size="small" max-height="400" v-loading="rankLoading" element-loading-text="排名加载中...">
            <el-table-column prop="rank" label="排名" width="60" align="center" />
            <el-table-column prop="name" :label="rankDimLabel" />
            <el-table-column prop="score" label="综合得分" width="120" align="center" />
            <el-table-column prop="itemCount" label="项数" width="100" align="center" />
          </el-table>
        </el-card>
      </el-col>
      <!-- 6. 单题科室 - 交叉分析表格 -->
      <!-- 6. 单题×科室/病区 - 交叉排名表格 -->
      <el-col :span="12">
        <el-card shadow="never" class="chart-card">
          <div slot="header" class="card-header">
            <span>单题×科室 · 第22题各科室得分</span>
            <span>单题×{{ crossDimLabel }} · 得分排名</span>
            <div class="header-right">
              <el-radio-group v-model="crossDimension" size="mini" @change="onCrossDimensionChange">
                <el-radio-button label="dept">按科室</el-radio-button>
                <el-radio-button label="ward">按病区</el-radio-button>
              </el-radio-group>
              <el-select v-model="crossDeptWards" multiple collapse-tags size="small" class="sel" clearable filterable :placeholder="'全部' + crossDimLabel" @change="refreshAll">
                <el-option v-for="name in crossOptions" :key="name" :label="name" :value="name" />
              </el-select>
            </div>
          </div>
          <el-table :data="crossDeptData" border size="small" max-height="400">
            <el-table-column type="index" label="排名" width="60" align="center" />
            <el-table-column prop="dept" label="科室" />
          <el-table :data="crossRows" border size="small" max-height="400" v-loading="crossLoading" element-loading-text="排名加载中...">
            <el-table-column prop="rank" label="排名" width="60" align="center" />
            <el-table-column prop="name" :label="crossDimLabel" />
            <el-table-column prop="score" label="综合得分" width="120" align="center" />
            <el-table-column prop="itemCount" label="项数" width="100" align="center" />
          </el-table>
        </el-card>
      </el-col>
@@ -89,114 +159,659 @@
<script>
import * as echarts from 'echarts';
import { mydTrend, sltdMydTotalByDimension, sltdMydTotalByScore ,mzMydScoreRank} from "@/api/AiCentre/satisfaction";
import { getissuelist } from "@/api/AiCentre/questionnaire";
const DEPTS = ['保健科', '介入治疗科', '专科护士门诊', '康复医学科', '皮肤科', '内分泌科', '消化内科', '肾内科', '呼吸内科', '心血管内科', '神经内科', '血液科', '风湿免疫科', '肿瘤内科', '感染科', '老年科', '普外科', '骨科', '泌尿外科', '妇产科'];
const WARDS = ['一病区', '二病区', '三病区', '四病区', '五病区', '六病区', '七病区', '八病区'];
export default {
  name: "ChartsPanel",
  name: 'ChartsPanel',
  dicts: ['dimensionality_type'],
  data() {
    return {
      singleQuestionData: [
        { option: "非常满意", count: 16513, rate: "62.95%" },
        { option: "满意", count: 7088, rate: "27.02%" },
        { option: "一般", count: 1760, rate: "6.71%" },
        { option: "不满意", count: 872, rate: "3.32%" },
      ],
      deptRankData: [
        { dept: "保健科", score: "81.54" }, { dept: "介入治疗科", score: "80.72" },
        { dept: "专科护士门诊", score: "79.27" }, { dept: "康复医学科", score: "78.65" },
        { dept: "皮肤科", score: "78.12" }, { dept: "内分泌科", score: "77.89" },
        { dept: "消化内科", score: "77.34" }, { dept: "肾内科", score: "76.50" },
        { dept: "呼吸内科", score: "75.88" }, { dept: "心血管内科", score: "75.21" },
      ],
      crossDeptData: [
        { dept: "保健科", score: "81.24" }, { dept: "介入治疗科", score: "81.22" },
        { dept: "专科护士门诊", score: "80.15" }, { dept: "康复医学科", score: "79.88" },
        { dept: "皮肤科", score: "79.01" }, { dept: "内分泌科", score: "78.45" },
        { dept: "消化内科", score: "77.92" }, { dept: "肾内科", score: "77.13" },
        { dept: "呼吸内科", score: "76.80" }, { dept: "心血管内科", score: "76.05" },
        { dept: "神经内科", score: "75.62" }, { dept: "血液科", score: "75.01" },
        { dept: "风湿免疫科", score: "74.38" }, { dept: "肿瘤内科", score: "73.85" },
        { dept: "感染科", score: "73.12" }, { dept: "老年科", score: "72.56" },
        { dept: "普外科", score: "71.90" }, { dept: "骨科", score: "71.23" },
        { dept: "泌尿外科", score: "70.55" }, { dept: "妇产科", score: "69.88" },
      ],
      queryParams: {
        dateRange: [],
        interval: 'month',
        dimension: 'ward', // 统计维度:ward=按病区统计 dept=按科室统计
      },
      deptOptions: DEPTS,
      wardOptions: WARDS,
      questionOptions: [],
      // 单题筛选
      singleQuestionId: null,
      singleQuestionContent: '',
      singleDeptWards: [],
      // 排名维度筛选
      rankDimension: 'dept',
      rankDeptWards: [],
      crossDimension: 'dept',
      crossDeptWards: [],
      // 图表数据
      trendLoading: false,
      categoryLoading: false,
      hospitalLoading: false,
      trend: { buckets: [], data: [], seriesName: '门诊患者满意度' },
      categoryCompare: { categories: [], seriesNames: [], series1: [], series2: [] },
      hospitalCompare: { hospitals: [], seriesNames: [], series1: [], series2: [] },
      // 单题 / 排名 / 交叉表加载状态
      singleLoading: false,
      questionLoading: false,
      rankLoading: false,
      crossLoading: false,
      // 表格数据
      singleQuestionData: [],
      singleQuestionMeta: { total: '0', score: '0.00' },
      rankData: [],
      crossData: [],
    };
  },
  mounted() { this.$nextTick(() => { this.initCharts(); }); },
  computed: {
    // 科室/病区编码:优先取当前用户所属,无则兜底名称本身作为编码
    deptCodeList() {
      const list = this.$store.getters.belongDepts || [];
      return list.length ? list : DEPTS.map((d) => ({ deptName: d, deptCode: d }));
    },
    wardCodeList() {
      const list = this.$store.getters.belongWards || [];
      return list.length ? list : WARDS.map((w) => ({ districtName: w, districtCode: w }));
    },
    defaultDeptCodes() {
      return Array.from(new Set(this.deptCodeList.map((d) => d.deptCode)));
    },
    defaultWardCodes() {
      return Array.from(new Set(this.wardCodeList.map((w) => w.districtCode)));
    },
    intervalLabel() {
      return { day: '按天', week: '按周', month: '按月', year: '按年' }[this.queryParams.interval] || '按月';
    },
    trendMoM() {
      const d = this.trend.data;
      if (d.length < 2) return '0.00';
      const diff = d[d.length - 1] - d[d.length - 2];
      return (diff >= 0 ? '+' : '') + diff.toFixed(2);
    },
    currentSingleQuestion() {
      return this.questionOptions.find((q) => q.id === this.singleQuestionId) || {};
    },
    rankDimLabel() {
      return this.rankDimension === 'dept' ? '科室' : '病区';
    },
    crossDimLabel() {
      return this.crossDimension === 'dept' ? '科室' : '病区';
    },
    rankOptions() {
      return this.rankDimension === 'dept' ? this.deptOptions : this.wardOptions;
    },
    crossOptions() {
      return this.crossDimension === 'dept' ? this.deptOptions : this.wardOptions;
    },
    rankRows() {
      return this.rankData;
    },
    crossRows() {
      return this.crossData;
    },
  },
  mounted() {
    this.$nextTick(() => {
      if (!this.queryParams.dateRange || !this.queryParams.dateRange.length) {
        this.queryParams.dateRange = this.defaultRange();
      }
      this.initCharts();
      // 初始化即显示各数据块的加载状态,避免先闪现“暂无数据”
      this.trendLoading = true;
      this.categoryLoading = true;
      this.hospitalLoading = true;
      this.singleLoading = true;
      this.rankLoading = true;
      this.crossLoading = true;
      // 数据块与题目列表并行加载:数据块不依赖题目列表,无需串行等待
      this.refreshAll();
      this.loadQuestions('').then(() => {
        if (this.singleQuestionContent) {
          // 单题选项统计 / 单题交叉排名依赖问题文本,题目加载完成后补拉一次
          this.fetchSingleQuestion();
          this.fetchCross();
        } else {
          // 题目列表为空或加载失败,结束单题相关加载状态
          this.singleLoading = false;
          this.crossLoading = false;
        }
      });
    });
  },
  methods: {
    initCharts() {
      const quarters = ['第一季度', '第二季度', '第三季度', '第四季度'];
      const categories = ['隐私保护', '环境与标识', '医务人员回应', '医生沟通', '挂号体验', '护士沟通'];
      const hospitals = ['院区一', '院区二', '院区三'];
      const baseGrid = { top: 20, right: 30, bottom: 30, left: 50 };
      echarts.init(this.$refs.chart1);
      echarts.init(this.$refs.chart2);
      echarts.init(this.$refs.chart4);
      window.addEventListener('resize', this.handleResize);
    },
    handleResize() {
      ['chart1', 'chart2', 'chart4'].forEach((ref) => {
        const c = echarts.getInstanceByDom(this.$refs[ref]);
        if (c) c.resize();
      });
    },
    // 维度编码 → 字典名称(dimensionality_type)
    getDimensionLabel(code) {
      const dict = this.dict && this.dict.type && this.dict.type.dimensionality_type;
      if (dict) {
        const hit = dict.find((item) => String(item.value) === String(code));
        if (hit) return hit.label;
      }
      return code == null ? '' : String(code);
    },
    // 可选题目:远程模糊搜索(按问题内容 scriptContent)
    searchQuestions(keyword) {
      this.loadQuestions(keyword || '');
    },
    // 加载可选题目(参考问题列表页 getissuelist)
    async loadQuestions(keyword) {
      this.questionLoading = true;
      try {
        const res = await getissuelist({
          pageNum: 1,
          pageSize: 50,
          isavailable: '',
          scriptContent: keyword || '',
        });
        const rows = (res && res.rows) || [];
        this.questionOptions = rows.map((q) => ({
          id: q.id,
          scriptContent: q.scriptContent || q.title || '',
        }));
        // 无关键字时默认选中第一题
        if (!keyword && this.questionOptions.length && !this.singleQuestionContent) {
          const first = this.questionOptions[0];
          this.singleQuestionId = first.id;
          this.singleQuestionContent = first.scriptContent;
        }
      } catch (e) {
        this.questionOptions = [];
      } finally {
        this.questionLoading = false;
      }
    },
    onSingleQuestionChange(val) {
      const q = this.questionOptions.find((o) => String(o.id) === String(val));
      this.singleQuestionContent = q ? q.scriptContent : '';
      this.refreshAll();
    },
    // 科室/病区名称 → 编码
    mapDeptNames(names) {
      return names.map((n) => {
        const hit = this.deptCodeList.find((d) => d.deptName === n);
        return hit ? hit.deptCode : n;
      });
    },
    mapWardNames(names) {
      return names.map((n) => {
        const hit = this.wardCodeList.find((w) => w.districtName === n);
        return hit ? hit.districtCode : n;
      });
    },
    // 综合得分(真实口径):Σ(选项分值 × 填报量) / Σ(填报量)
    scoreOfOptions(options) {
      let num = 0;
      let den = 0;
      (options || []).forEach((o) => {
        const c = Number(o && o.count) || 0;
        const s = Number(o && o.score);
        if (isNaN(s)) return;
        num += s * c;
        den += c;
      });
      if (!den) return '0.00';
      return (num / den).toFixed(2);
    },
    // 解析比例字符串:"14.29%"、"0"、"0.5" → 数值(百分比);无效返回 null
    parsePercent(value) {
      if (value == null || value === '') return null;
      const n = Number(String(value).replace(/%/g, '').trim());
      return isNaN(n) ? null : n;
    },
    // 单题返回 List<QuestionResultDTO> → { options, total }
    normalizeSingleQuestion(list) {
      const arr = Array.isArray(list) ? list : (list && (list.list || list.rows)) || [];
      const options = [];
      let total = 0;
      arr.forEach((item) => {
        const details = Array.isArray(item && item.subtaskDetailRatioExportList)
          ? item.subtaskDetailRatioExportList
          : [];
        details.forEach((d) => {
          const option = (d && (d.optionresult || d.targetvalue || d.optioncontent || d.questiontext)) || '';
          const count = Number(d && d.count) || 0;
          const rawScore = Number(d && d.score);
          const ratio = this.parsePercent(d && d.ratio);
          options.push({ option, count, score: isNaN(rawScore) ? null : rawScore, ratio });
          total += count;
        });
      });
      return { options, total };
    },
    // 单题选项统计(真实接口 /smartor/serviceSubtaskDetail/sltdMydTotalByScore)
    async fetchSingleQuestion() {
      const range = this.queryParams.dateRange || [];
      if (range.length !== 2 || !range[0] || !range[1] || !this.singleQuestionContent) {
        this.singleQuestionData = [];
        this.singleQuestionMeta = { total: '0', score: '0.00' };
        return;
      }
      this.singleLoading = true;
      try {
        const payload = {
          deptOrDistrict: '1',
          serviceTypeList: ['6', '14'],
          startFinishTime: range[0],
          endFinishTime: range[1],
          questionContent: this.singleQuestionContent,
        };
        const sel = this.singleDeptWards || [];
        if (this.queryParams.dimension === 'dept') {
          const names = sel.map((s) => String(s).replace(/^dept:/, ''));
          payload.leaveldeptcodes = names.length ? this.mapDeptNames(names) : this.defaultDeptCodes;
        } else {
          const names = sel.map((s) => String(s).replace(/^ward:/, ''));
          payload.leavehospitaldistrictcodes = names.length ? this.mapWardNames(names) : this.defaultWardCodes;
        }
        const res = await sltdMydTotalByScore(payload);
        console.log('[sltdMydTotalByScore 单题] code=', res && res.code, 'data=', JSON.stringify(res && res.data).slice(0, 1500));
        const list = res && res.code === 200 ? res.data : [];
        const { options, total } = this.normalizeSingleQuestion(list);
        this.singleQuestionData = options.map((o) => ({
          option: o.option,
          count: o.count,
          rate: o.ratio != null
            ? o.ratio.toFixed(2) + '%'
            : (total ? ((o.count / total) * 100).toFixed(2) + '%' : '0.00%'),
        }));
        this.singleQuestionMeta = {
          total: total.toLocaleString(),
          score: this.scoreOfOptions(options),
        };
        if (!options.length) this.notify('当前条件下暂无该题的选项统计数据', 'warning');
      } catch (e) {
        this.singleQuestionData = [];
        this.singleQuestionMeta = { total: '0', score: '0.00' };
        this.notify('单题选项统计数据请求失败,请稍后重试', 'error');
      } finally {
        this.singleLoading = false;
      }
    },
    handleReset() {
      this.queryParams.dateRange = this.defaultRange();
      this.queryParams.interval = 'month';
      this.queryParams.dimension = 'ward';
      this.singleDeptWards = [];
      this.refreshAll();
    },
    // 切换全局统计维度时清空单题筛选,保证科室/病区互斥
    onDimensionChange() {
      this.singleDeptWards = [];
      this.refreshAll();
    },
    onRankDimensionChange() {
      this.rankDeptWards = [];
      this.refreshAll();
    },
    onCrossDimensionChange() {
      this.crossDeptWards = [];
      this.refreshAll();
    },
    defaultRange() {
      const end = new Date();
      const start = new Date();
      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');
        return `${y}-${m}-${day}`;
      };
      return [f(start), f(end)];
    },
    // 区间类型 => 时间桶 + 柱状图对比序列名
    intervalConfig() {
      const buckets = this.genBuckets(this.queryParams.interval);
      if (this.queryParams.interval === 'day') return { buckets, seriesNames: ['昨日', '今日'] };
      if (this.queryParams.interval === 'week') return { buckets, seriesNames: ['上周', '本周'] };
      if (this.queryParams.interval === 'year') return { buckets, seriesNames: ['去年', '今年'] };
      return { buckets, seriesNames: ['上月', '本月'] };
    },
    genBuckets(type) {
      const end = this.queryParams.dateRange && this.queryParams.dateRange[1]
        ? new Date(this.queryParams.dateRange[1])
        : new Date();
      const buckets = [];
      if (type === 'day') {
        for (let i = 14; i >= 0; i--) {
          const d = new Date(end);
          d.setDate(end.getDate() - i);
          buckets.push(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
        }
      } else if (type === 'week') {
        for (let i = 5; i >= 0; i--) {
          const d = new Date(end);
          d.setDate(end.getDate() - i * 7);
          buckets.push(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} 周`);
        }
      } else if (type === 'year') {
        for (let i = 3; i >= 0; i--) buckets.push(String(end.getFullYear() - i));
      } else {
        for (let i = 5; i >= 0; i--) {
          const d = new Date(end.getFullYear(), end.getMonth() - i, 1);
          buckets.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
        }
      }
      return buckets;
    },
    // 刷新全部数据(趋势、分类维度、院区维度、单题、排名、交叉均为真实接口)
    async refreshAll() {
      // 六个数据块并行拉取,各区块同时显示各自的加载状态
      await Promise.all([
        this.fetchTrend(),
        this.fetchCategoryCompare(),
        this.fetchHospitalCompare(),
        this.fetchSingleQuestion(),
        this.fetchRank(),
        this.fetchCross(),
      ]);
      this.renderCharts();
    },
    // 满意度趋势(真实接口):data = [{ period, totalScore }]
    async fetchTrend() {
      const range = this.queryParams.dateRange || [];
      if (range.length !== 2 || !range[0] || !range[1]) {
        this.trend = { buckets: [], data: [], seriesName: '门诊患者满意度' };
        return;
      }
      this.trendLoading = true;
      try {
        const res = await mydTrend({
          startFinishTime: range[0],
          endFinishTime: range[1],
          type: this.queryParams.interval, // day / week / month / year
        });
        const list = res && res.code === 200 && Array.isArray(res.data) ? res.data : [];
        this.trend = {
          seriesName: '门诊患者满意度',
          buckets: list.map((i) => String((i && i.period) || '')),
          data: list.map((i) => {
            const n = Number(i && i.totalScore);
            return isNaN(n) ? 0 : Math.round(n * 100) / 100;
          }),
        };
        if (!list.length) this.notify('当前条件下暂无趋势数据', 'warning');
      } catch (e) {
        this.trend = { buckets: [], data: [], seriesName: '门诊患者满意度' };
        this.notify('趋势数据请求失败,请稍后重试', 'error');
      } finally {
        this.trendLoading = false;
      }
    },
    notify(msg, type) {
      const fn = { warning: 'warning', error: 'error', success: 'success' }[type] || 'info';
      if (this.$message && this.$message[fn]) this.$message[fn](msg);
    },
    // 分类维度对比(真实接口):dimensionType=1,data = [{ period, dimension, totalScore }]
    async fetchCategoryCompare() {
      const range = this.queryParams.dateRange || [];
      if (range.length !== 2 || !range[0] || !range[1]) {
        this.categoryCompare = { categories: [], seriesNames: [], series1: [], series2: [] };
        return;
      }
      this.categoryLoading = true;
      try {
        const res = await sltdMydTotalByDimension({
          startFinishTime: range[0],
          endFinishTime: range[1],
          type: this.queryParams.interval, // day / week / month / year
          dimensionType: "1", // 1=按满意度维度统计
        });
        // 排查用:打印接口原始返回,便于核对字段名(确认无误后可删除)
        console.log("[sltdMydTotalByDimension dimensionType=1] code=", res && res.code, "data=", JSON.stringify(res && res.data).slice(0, 2000));
        const list = res && res.code === 200 && Array.isArray(res.data) ? res.data : [];
        // 维度编码 → 字典名称,并过滤两期均无数据的维度
        const d = this.buildCompareData(list, (code) => this.getDimensionLabel(code));
        this.categoryCompare = { categories: d.labels, seriesNames: d.seriesNames, series1: d.series1, series2: d.series2 };
        if (!list.length) this.notify('当前条件下暂无分类维度数据', 'warning');
      } catch (e) {
        this.categoryCompare = { categories: [], seriesNames: [], series1: [], series2: [] };
        this.notify('分类维度数据请求失败,请稍后重试', 'error');
      } finally {
        this.categoryLoading = false;
      }
    },
    // 院区维度对比(真实接口):dimensionType=2,data = [{ period, dimension(院区名), totalScore }]
    async fetchHospitalCompare() {
      const range = this.queryParams.dateRange || [];
      if (range.length !== 2 || !range[0] || !range[1]) {
        this.hospitalCompare = { hospitals: [], seriesNames: [], series1: [], series2: [] };
        return;
      }
      this.hospitalLoading = true;
      try {
        const res = await sltdMydTotalByDimension({
          startFinishTime: range[0],
          endFinishTime: range[1],
          type: this.queryParams.interval, // day / week / month / year
          dimensionType: "2", // 2=按院区统计
        });
        // 排查用:打印接口原始返回,便于核对字段名(确认无误后可删除)
        console.log("[sltdMydTotalByDimension dimensionType=2] code=", res && res.code, "data=", JSON.stringify(res && res.data).slice(0, 2000));
        const list = res && res.code === 200 && Array.isArray(res.data) ? res.data : [];
        const d = this.buildCompareData(list);
        this.hospitalCompare = { hospitals: d.labels, seriesNames: d.seriesNames, series1: d.series1, series2: d.series2 };
        if (!list.length) this.notify('当前条件下暂无院区维度数据', 'warning');
      } catch (e) {
        this.hospitalCompare = { hospitals: [], seriesNames: [], series1: [], series2: [] };
        this.notify('院区维度数据请求失败,请稍后重试', 'error');
      } finally {
        this.hospitalLoading = false;
      }
    },
    // 将 [{ period, dimension, totalScore }] 组装为分组柱状图数据(上期/本期两组对比)
    // labelFn:可选,用于把维度编码映射为字典名称(分类维度图传入)
    buildCompareData(list, labelFn) {
      const labels = [];
      const byCat = {};
      list.forEach((i) => {
        // 维度编码(dimensionType=1)或院区名称(dimensionType=2);若后端字段命名不同,兜底取 period
        const key = (i && (i.dimension || i.campusName || i.campusid)) || (i && i.period) || '';
        if (!key) return;
        if (!byCat[key]) { byCat[key] = {}; labels.push(key); }
        const n = Number(i.totalScore);
        byCat[key][i.period] = isNaN(n) ? 0 : Math.round(n * 100) / 100;
      });
      // 后端按时间升序返回,取最后两个周期作为“上期 / 本期”
      const periods = [...new Set(list.map((i) => i && i.period).filter(Boolean))];
      const cur = periods[periods.length - 1];
      const prev = periods[periods.length - 2];
      const val = (cat, p) => (p != null && byCat[cat][p] != null ? byCat[cat][p] : 0);
      // 过滤两期均无数据的维度
      const cats = labels.filter((c) => val(c, prev) !== 0 || val(c, cur) !== 0);
      const display = labelFn ? cats.map((c) => labelFn(c)) : cats;
      return {
        labels: display,
        seriesNames: this.intervalConfig().seriesNames,
        series1: cats.map((c) => val(c, prev)),
        series2: cats.map((c) => val(c, cur)),
      };
    },
    // 科室/病区维度 · 门诊患者满意度得分排名(真实接口 /mzMydScoreRank,不带问题文本)
    async fetchRank() {
      const range = this.queryParams.dateRange || [];
      if (range.length !== 2 || !range[0] || !range[1]) {
        this.rankData = [];
        return;
      }
      this.rankLoading = true;
      try {
        const res = await mzMydScoreRank(this.buildRankPayload(this.rankDimension, this.rankDeptWards, ''));
        this.rankData = this.normalizeRank(res);
        if (!this.rankData.length) this.notify('当前条件下暂无排名数据', 'warning');
      } catch (e) {
        this.rankData = [];
        this.notify('排名数据请求失败,请稍后重试', 'error');
      } finally {
        this.rankLoading = false;
      }
    },
    // 单题×科室/病区 · 得分排名(真实接口 /mzMydScoreRank,带问题文本)
    async fetchCross() {
      const range = this.queryParams.dateRange || [];
      if (range.length !== 2 || !range[0] || !range[1] || !this.singleQuestionContent) {
        this.crossData = [];
        return;
      }
      this.crossLoading = true;
      try {
        const res = await mzMydScoreRank(this.buildRankPayload(this.crossDimension, this.crossDeptWards, this.singleQuestionContent));
        this.crossData = this.normalizeRank(res);
        if (!this.crossData.length) this.notify('当前条件下暂无排名数据', 'warning');
      } catch (e) {
        this.crossData = [];
        this.notify('排名数据请求失败,请稍后重试', 'error');
      } finally {
        this.crossLoading = false;
      }
    },
    // 组装排名接口请求体(dimensionType/serviceType/deptCodes/districtCodes/时间/questionText)
    buildRankPayload(dimension, selectedNames, questionText) {
      const range = this.queryParams.dateRange || [];
      const payload = {
        dimensionType: dimension === 'dept' ? '1' : '2',
        serviceTypes: ['6', '14'],
        startFinishTime: range[0],
        endFinishTime: range[1],
      };
      if (questionText) payload.questionText = questionText;
      if (selectedNames && selectedNames.length) {
        if (dimension === 'dept') {
          payload.deptCodes = this.mapDeptNames(selectedNames);
        } else {
          payload.districtCodes = this.mapWardNames(selectedNames);
        }
      }
      return payload;
    },
    // 归一化排名返回 List<MzMydScoreRankDTO> → [{ rank, code, name, score, itemCount }]
    normalizeRank(res) {
      const list = res && res.code === 200 && Array.isArray(res.data) ? res.data : [];
      return list.map((item) => ({
        rank: item.rank,
        code: item.code,
        name: item.name,
        score: item.score,
        itemCount: item.itemCount,
      }));
    },
    renderCharts() {
      const c1 = echarts.getInstanceByDom(this.$refs.chart1);
      const c2 = echarts.getInstanceByDom(this.$refs.chart2);
      const c4 = echarts.getInstanceByDom(this.$refs.chart4);
      if (!c1 || !c2 || !c4) return;
      const lineGrid = { top: 32, right: 30, bottom: 32, left: 85 };
      const barGrid = { top: 48, right: 30, bottom: 60, left: 85 };
      const baseTooltip = { backgroundColor: 'rgba(255,255,255,0.95)', borderColor: '#e8e8e8', textStyle: { color: '#333', fontSize: 13 }, extraCssText: 'box-shadow: 0 2px 12px rgba(0,0,0,0.1); border-radius: 6px; padding: 10px 14px;' };
      const baseXAxis = { axisLine: { lineStyle: { color: '#e0e0e0' } }, axisTick: { show: false }, axisLabel: { color: '#666', fontSize: 12 } };
      const baseYAxis = { splitLine: { lineStyle: { color: '#f0f0f0', type: 'dashed' } }, axisLine: { show: false }, axisTick: { show: false }, axisLabel: { color: '#999', fontSize: 11 } };
      const areaGrad = new echarts.graphic.LinearGradient(0, 0, 0, 1, [
        { offset: 0, color: 'rgba(91,143,249,0.25)' },
        { offset: 1, color: 'rgba(91,143,249,0.02)' },
      ]);
      // 1. 折线图 - 时间维度
      const c1 = echarts.init(this.$refs.chart1);
      c1.setOption({
        color: ['#5B8FF9'],
        grid: { ...baseGrid, top: 25 },
        grid: lineGrid,
        tooltip: { ...baseTooltip, trigger: 'axis' },
        xAxis: [{ ...baseXAxis, data: quarters, boundaryGap: false }],
        yAxis: [{ ...baseYAxis, min: 86, max: 91, name: '满意度得分', nameTextStyle: { color: '#999', fontSize: 11 } }],
        xAxis: [{ ...baseXAxis, data: this.trend.buckets, boundaryGap: false }],
        yAxis: [{ ...baseYAxis, name: '满意度得分', nameTextStyle: { color: '#999', fontSize: 11 } }],
        series: [{
          name: '门诊患者满意度', type: 'line', smooth: true, symbol: 'circle', symbolSize: 8,
          data: [88.20, 87.32, 88.50, 89.17],
          name: this.trend.seriesName, type: 'line', smooth: true, symbol: 'circle', symbolSize: 8,
          data: this.trend.data,
          lineStyle: { width: 3, shadowBlur: 8, shadowColor: 'rgba(91,143,249,0.3)' },
          itemStyle: { color: '#5B8FF9' },
          areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
            { offset: 0, color: 'rgba(91,143,249,0.25)' }, { offset: 1, color: 'rgba(91,143,249,0.02)' }
          ])},
          areaStyle: { color: areaGrad },
        }],
      });
      // 2. 分组柱状图 - 分类维度
      const c2 = echarts.init(this.$refs.chart2);
      c2.setOption({
      // 分类较多(如十几个满意度维度)时底部名称会重叠,这里按数量动态适配:
      //   ≤6 个:正常水平标签;
      //   7~10 个:x 轴标签倾斜 35° + 底部留白加大 + 柱子变细;
      //   >10 个:再启用横向缩放(初始显示约 8 个分类,可拖动/滚动查看其余)。
      const catCategories = this.categoryCompare.categories || [];
      const catN = catCategories.length;
      const catRotate = catN > 6 ? 35 : 0;
      const catEnableZoom = catN > 10;
      const catBarWidth = catN > 10 ? 18 : catN > 6 ? 20 : 22;
      const catLegend = catEnableZoom
        ? { data: this.categoryCompare.seriesNames, top: 4, textStyle: { color: '#666', fontSize: 12 } }
        : { data: this.categoryCompare.seriesNames, bottom: 8, textStyle: { color: '#666', fontSize: 12 } };
      const catGrid = catEnableZoom
        ? { top: 36, right: 30, bottom: 80, left: 85 }
        : { top: 48, right: 30, bottom: catRotate ? 100 : 60, left: 85 };
      const catOption = {
        color: ['#6DC8EC', '#5B8FF9'],
        grid: baseGrid,
        grid: catGrid,
        tooltip: { ...baseTooltip, trigger: 'axis', axisPointer: { type: 'shadow', shadowStyle: { color: 'rgba(0,0,0,0.03)' } } },
        legend: { data: ['2023年', '2024年'], bottom: 0, textStyle: { color: '#666', fontSize: 12 } },
        xAxis: [{ ...baseXAxis, data: categories, axisLabel: { ...baseXAxis.axisLabel, interval: 0 } }],
        legend: catLegend,
        xAxis: [{ ...baseXAxis, data: catCategories, axisLabel: { ...baseXAxis.axisLabel, interval: 0, rotate: catRotate } }],
        yAxis: [{ ...baseYAxis, name: '得分', nameTextStyle: { color: '#999', fontSize: 11 } }],
        series: [
          { name: '2023年', type: 'bar', barWidth: 22, barGap: '30%',
          { name: this.categoryCompare.seriesNames[0], type: 'bar', barWidth: catBarWidth, barGap: '30%',
            itemStyle: { borderRadius: [6, 6, 0, 0], color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#6DC8EC' }, { offset: 1, color: '#48B0D5' }]) },
            data: [86.34, 85.12, 83.45, 86.22, 88.55, 87.33] },
          { name: '2024年', type: 'bar', barWidth: 22,
            data: this.categoryCompare.series1 },
          { name: this.categoryCompare.seriesNames[1], type: 'bar', barWidth: catBarWidth,
            itemStyle: { borderRadius: [6, 6, 0, 0], color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#5B8FF9' }, { offset: 1, color: '#3D6FDB' }]) },
            data: [88.06, 84.57, 82.88, 87.77, 88.12, 87.00] },
            data: this.categoryCompare.series2 },
        ],
      });
      };
      if (catEnableZoom) {
        // 初始只展示前 8 个分类宽度,避免柱子挤成一团,用户可横向滚动/拖动查看其余
        const zoomEnd = catN > 0 ? Math.min(100, Math.round((8 / catN) * 100)) : 100;
        catOption.dataZoom = [
          { type: 'inside', start: 0, end: zoomEnd },
          { type: 'slider', height: 16, bottom: 4, start: 0, end: zoomEnd },
        ];
      }
      c2.setOption(catOption);
      // 4. 院区维度 - 分组柱状图
      const c4 = echarts.init(this.$refs.chart4);
      c4.setOption({
        color: ['#6DC8EC', '#5B8FF9'],
        grid: baseGrid,
        grid: barGrid,
        tooltip: { ...baseTooltip, trigger: 'axis', axisPointer: { type: 'shadow' } },
        legend: { data: ['2023年', '2024年'], bottom: 0, textStyle: { color: '#666', fontSize: 12 } },
        xAxis: [{ ...baseXAxis, data: hospitals }],
        legend: { data: this.hospitalCompare.seriesNames, bottom: 8, textStyle: { color: '#666', fontSize: 12 } },
        xAxis: [{ ...baseXAxis, data: this.hospitalCompare.hospitals }],
        yAxis: [{ ...baseYAxis, name: '满意度得分', nameTextStyle: { color: '#999', fontSize: 11 } }],
        series: [
          { name: '2023年', type: 'bar', barWidth: 24, barGap: '30%',
          { name: this.hospitalCompare.seriesNames[0], type: 'bar', barWidth: 24, barGap: '30%',
            itemStyle: { borderRadius: [6, 6, 0, 0], color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#6DC8EC' }, { offset: 1, color: '#48B0D5' }]) },
            data: [86.20, 87.69, 85.55],
            label: { show: true, position: 'top', color: '#666', fontSize: 12, fontWeight: 500, formatter: p => p.value.toFixed(2) } },
          { name: '2024年', type: 'bar', barWidth: 24,
            data: this.hospitalCompare.series1,
            label: { show: true, position: 'top', color: '#666', fontSize: 12, fontWeight: 500, formatter: (p) => p.value.toFixed(2) } },
          { name: this.hospitalCompare.seriesNames[1], type: 'bar', barWidth: 24,
            itemStyle: { borderRadius: [6, 6, 0, 0], color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#5B8FF9' }, { offset: 1, color: '#3D6FDB' }]) },
            data: [88.15, 88.14, 85.88],
            label: { show: true, position: 'top', color: '#666', fontSize: 12, fontWeight: 500, formatter: p => p.value.toFixed(2) } },
            data: this.hospitalCompare.series2,
            label: { show: true, position: 'top', color: '#666', fontSize: 12, fontWeight: 500, formatter: (p) => p.value.toFixed(2) } },
        ],
      });
      // 响应式
      window.addEventListener('resize', () => { c1.resize(); c2.resize(); c4.resize(); });
    },
    async exportPDF() {
      // 先将 ECharts 实例转为图片 dataURL
      const chartRefs = [
        { ref: this.$refs.chart1, id: 'chart1' },
        { ref: this.$refs.chart2, id: 'chart2' },
@@ -212,19 +827,7 @@
        }
      }
      // 克隆内容并替换图表为图片
      const printContent = this.$refs.pdfTarget.cloneNode(true);
      for (const { ref, id } of chartRefs) {
        if (chartImages[id] && ref) {
          const boxes = printContent.querySelectorAll('.chart-box');
          for (const box of boxes) {
            if (box.innerHTML.includes(id) || boxes.length === chartRefs.length) {
              box.innerHTML = `<img src="${chartImages[id]}" style="width:100%;height:auto;" />`;
            }
          }
        }
      }
      // 更可靠的方式:按位置匹配
      const chartBoxes = printContent.querySelectorAll('.chart-box');
      const imgKeys = Object.keys(chartImages);
      chartBoxes.forEach((box, i) => {
@@ -246,7 +849,7 @@
          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, .el-tag { display: none; }
          .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}
@@ -260,9 +863,13 @@
</script>
<style lang="scss" scoped>
.export-bar { text-align: right; margin-bottom: 16px; }
.export-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.filter-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; .filter-label { font-size: 13px; color: #606266; } .filter-date { width: 260px; } }
.chart-card { margin-bottom: 0; }
.chart-box { width: 100%; height: 360px; }
.chart-wrap { position: relative; width: 100%; }
.chart-empty { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: center; background: #fff; color: #909399; font-size: 14px; }
.card-header { display: flex; justify-content: space-between; align-items: center; span { font-size: 15px; font-weight: 600; color: #303133; } }
.header-right { display: flex; align-items: center; gap: 10px; .sel { width: 140px; } .sel-lg { width: 240px; } }
.table-footer { text-align: right; padding: 10px 0 0; font-size: 13px; color: #606266; }
</style>