WXL (wul)
11 小时以前 e9f35f9782f3d99d742c294fe503fc1294f203c4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
<template>
  <div class="data-overview">
    <!-- 顶部筛选:时间范围 + 科室/病区,控制本页所有数据 -->
    <el-card shadow="never" class="overview-card filter-card">
      <el-form inline class="filter-form">
        <el-form-item label="时间范围">
          <el-date-picker
            v-model="queryParams.dateRange"
            type="daterange"
            range-separator="至"
            start-placeholder="开始日期"
            end-placeholder="结束日期"
            value-format="yyyy-MM-dd"
            size="small"
          />
        </el-form-item>
        <el-form-item label="科室/病区">
          <el-select
            v-model="queryParams.deptWard"
            clearable
            filterable
            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-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-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>
      </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-column prop="veryGoodCount" label="数量" width="90" />
          <el-table-column prop="veryGoodRate" label="比例" width="90" />
        </el-table-column>
        <el-table-column label="满意" align="center">
          <el-table-column prop="goodCount" label="数量" width="90" />
          <el-table-column prop="goodRate" label="比例" width="90" />
        </el-table-column>
        <el-table-column label="一般" align="center">
          <el-table-column prop="normalCount" label="数量" width="90" />
          <el-table-column prop="normalRate" label="比例" width="90" />
        </el-table-column>
        <el-table-column label="不满意" align="center">
          <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 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>
    </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('table2')">导出Excel</el-button>
      </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>
      </div>
    </el-card>
 
    <!-- 表格三:各科室/病区满意度评分对比 -->
    <el-card shadow="never" class="overview-card">
      <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-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-option label="沿用顶部条件" value="inherit" />
            <template v-if="statDimension === '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>
          <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-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>
        <el-table-column prop="totalScore" label="综合得分" align="center" />
      </el-table>
    </el-card>
  </div>
</template>
 
<script>
import XLSX from 'xlsx';
 
// 满意度题的固定选项(顺序即表格列顺序)
const FIXED_OPTIONS = ['非常满意', '满意', '一般', '不满意', '不适用'];
const DEPTS = ['内分泌科', '皮肤科', '肿瘤内科', '消化内科', '肾内科'];
const WARDS = ['一病区', '二病区', '三病区', '四病区', '五病区'];
 
// 满意度题(固定五项选项,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]],
];
 
// 其余题目(单选/多选,非固定满意度选项)
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]],
  },
];
 
// 统一接口返回的题目(模拟):固定五项选项 => 表一,其余 => 表二
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 })),
  })),
];
 
// 确定性伪随机(便于筛选条件固定时结果稳定)
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;
  };
}
 
export default {
  name: 'DataOverview',
  data() {
    return {
      // 顶部全局筛选
      queryParams: {
        dateRange: [],
        deptWard: '',
      },
      deptOptions: DEPTS,
      wardOptions: WARDS,
 
      // 表格三:统计维度 + 独立科室/病区条件('inherit' = 沿用顶部)
      statDimension: 'dept',
      statDeptWard: 'inherit',
 
      // 统一接口返回的数据(模拟)
      questionList: [],
      deptComparison: [],
      wardComparison: [],
    };
  },
  computed: {
    // 固定五项选项的题目 => 表一
    satisfactionQuestions() {
      return this.questionList.filter((q) => this.isSatisfactionQuestion(q));
    },
    // 其余题目 => 表二
    otherQuestions() {
      return this.questionList.filter((q) => !this.isSatisfactionQuestion(q));
    },
    // 表一:满意度统计(含总计行)
    serviceSatisfaction() {
      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;
        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]),
        };
      });
 
      // 总计行
      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 total = sum.veryGood + sum.good + sum.normal + sum.bad + sum.na;
      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]),
      });
      return rows;
    },
    // 表二:单选/多选统计
    questionStats() {
      return this.otherQuestions.map((q) => {
        const total = this.questionTotal(q);
        return {
          title: q.title,
          total,
          options: q.options.map((o) => ({
            option: o.option,
            count: o.count,
            rate: this.rateOf(o.count, total),
          })),
        };
      });
    },
    // 表三:按维度 + 独立条件过滤后的行
    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);
    },
  },
  mounted() {
    if (!this.queryParams.dateRange || !this.queryParams.dateRange.length) {
      this.queryParams.dateRange = this.defaultRange();
    }
    this.fetchData();
  },
  methods: {
    // 表三独立条件:'inherit' 时沿用顶部条件
    statFilter() {
      const v = this.statDeptWard === 'inherit' ? this.queryParams.deptWard : this.statDeptWard;
      return v || '';
    },
    onStatDimensionChange() {
      // 切换维度后,若当前已选条件不属于新维度,则恢复“沿用顶部”
      if (this.statDeptWard !== 'inherit' && !this.statDeptWard.startsWith(this.statDimension + ':')) {
        this.statDeptWard = 'inherit';
      }
    },
    handleReset() {
      this.queryParams.dateRange = this.defaultRange();
      this.queryParams.deptWard = '';
      this.fetchData();
    },
    // 判断题目选项是否为固定满意度五项
    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));
    },
    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) + '%';
    },
    // 综合得分:非常满意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);
    },
    defaultRange() {
      const end = new Date();
      const start = new Date();
      start.setDate(start.getDate() - 29);
      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)];
    },
    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));
      }
      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;
 
      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);
        }
        return item;
      });
 
      const deptComparison = DEPTS.map((d) => this.genCompareRow(d, rnd));
      const wardComparison = WARDS.map((w) => this.genCompareRow(w, rnd));
 
      return { questionList, deptComparison, wardComparison };
    },
    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,
        ]),
      };
    },
    exportTable(tableId) {
      const tableNames = { table1: '各服务项目满意度统计', table2: '单选题多选题统计', table3: '各科室满意度评分对比' };
      const name = tableNames[tableId] || '统计数据';
      const table = document.getElementById(tableId);
      if (!table) return;
 
      try {
        // 从表格提取数据构建带样式的 Excel
        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);
        });
 
        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');
      } 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();
      }
    },
  },
};
</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; }
}
</style>