| | |
| | | <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-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 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"> |
| | | <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> |
| | | <el-button type="warning" size="small" icon="el-icon-download" @click="exportTable('table3')">导出Excel</el-button> |
| | | <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="deptComparison" border size="small" :header-cell-style="{ background: '#f5f7fa', color: '#303133' }"> |
| | | <el-table-column prop="dept" label="科室"fixed="left" align="center" /> |
| | | <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="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-column prop="totalScore" label="综合得分" align="center" /> |
| | | </el-table> |
| | | </el-card> |
| | | </div> |
| | |
| | | <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", |
| | | name: 'DataOverview', |
| | | data() { |
| | | return { |
| | | serviceSatisfaction: [ |
| | | { category: "诊疗过程中,医务人员是否注意保护您的隐私?", veryGoodCount: 3219, veryGoodRate: "67.84%", goodCount: 1351, goodRate: "28.47%", normalCount: 142, normalRate: "2.99%", badCount: 33, badRate: "0.70%", naCount: 0, naRate: "0.00%", score: "87.82" }, |
| | | { category: "您对医院服务设施的总体印象如何?", veryGoodCount: 3110, veryGoodRate: "65.54%", goodCount: 1369, goodRate: "28.85%", normalCount: 217, normalRate: "4.57%", badCount: 49, badRate: "1.03%", naCount: 0, naRate: "0.00%", score: "86.30" }, |
| | | { category: "医院的厕所是否清洁无异味?", veryGoodCount: 2973, veryGoodRate: "62.66%", goodCount: 1504, goodRate: "31.70%", normalCount: 235, normalRate: "4.95%", badCount: 33, badRate: "0.70%", naCount: 0, naRate: "0.00%", score: "85.44" }, |
| | | { category: "医院内的路标和指示是否明确?", veryGoodCount: 3234, veryGoodRate: "68.16%", goodCount: 1289, goodRate: "27.17%", normalCount: 196, normalRate: "4.13%", badCount: 26, badRate: "0.55%", naCount: 0, naRate: "0.00%", score: "87.64" }, |
| | | { category: "医院的空间布局是否便利?", veryGoodCount: 3037, veryGoodRate: "64.00%", goodCount: 1460, goodRate: "30.77%", normalCount: 209, normalRate: "4.40%", badCount: 39, badRate: "0.82%", naCount: 0, naRate: "0.00%", score: "85.99" }, |
| | | { category: "看病期间,您的抱怨和不满能否及时得到回应?", veryGoodCount: 3019, veryGoodRate: "63.62%", goodCount: 1317, goodRate: "27.76%", normalCount: 294, normalRate: "6.20%", badCount: 115, badRate: "2.42%", naCount: 0, naRate: "0.00%", score: "84.19" }, |
| | | { category: "医生是否用您听得懂的方式解释问题?", veryGoodCount: 3402, veryGoodRate: "71.70%", goodCount: 967, goodRate: "20.38%", normalCount: 254, normalRate: "5.35%", badCount: 122, badRate: "2.57%", naCount: 0, naRate: "0.00%", score: "87.07" }, |
| | | { category: "医生对您是否尊重?", veryGoodCount: 3495, veryGoodRate: "73.66%", goodCount: 897, goodRate: "18.90%", normalCount: 249, normalRate: "5.25%", badCount: 104, badRate: "2.19%", naCount: 0, naRate: "0.00%", score: "88.01" }, |
| | | { category: "医生是否仔细倾听您讲话?", veryGoodCount: 3422, veryGoodRate: "72.12%", goodCount: 937, goodRate: "19.75%", normalCount: 251, normalRate: "5.29%", badCount: 135, badRate: "2.85%", naCount: 0, naRate: "0.00%", score: "87.05" }, |
| | | { category: "对挂号服务人员服务态度是否满意", veryGoodCount: 3458, veryGoodRate: "72.88%", goodCount: 1205, goodRate: "25.40%", normalCount: 52, normalRate: "1.10%", badCount: 30, badRate: "0.63%", naCount: 0, naRate: "0.00%", score: "90.17" }, |
| | | { category: "您对预约挂号是否满意?", veryGoodCount: 3527, veryGoodRate: "74.33%", goodCount: 1088, goodRate: "22.93%", normalCount: 72, normalRate: "1.52%", badCount: 58, badRate: "1.22%", naCount: 0, naRate: "0.00%", score: "90.12" }, |
| | | { category: "护士对您是否尊重?", veryGoodCount: 3420, veryGoodRate: "72.08%", goodCount: 1184, goodRate: "24.95%", normalCount: 113, normalRate: "2.38%", badCount: 28, badRate: "0.59%", naCount: 0, naRate: "0.00%", score: "89.50" }, |
| | | { category: "护士是否用您听得懂的方式解释问题?", veryGoodCount: 3346, veryGoodRate: "70.52%", goodCount: 1250, goodRate: "26.34%", normalCount: 122, normalRate: "2.57%", badCount: 27, badRate: "0.57%", naCount: 0, naRate: "0.00%", score: "88.94" }, |
| | | { category: "护士是否仔细倾听您讲话?", veryGoodCount: 3378, veryGoodRate: "71.19%", goodCount: 1205, goodRate: "25.40%", normalCount: 134, normalRate: "2.82%", badCount: 28, badRate: "0.59%", naCount: 0, naRate: "0.00%", score: "89.06" }, |
| | | { category: "总计", veryGoodCount: 54863, veryGoodRate: "69.02%", goodCount: 20800, goodRate: "26.17%", normalCount: 2920, normalRate: "3.67%", badCount: 908, badRate: "1.14%", naCount: 0, naRate: "0.00%", score: "87.66" }, |
| | | ], |
| | | questionStats: [ |
| | | { |
| | | title: "第1题:您对我院工作人员的服务质量满意不满意?【分值单选题】- 综合得分", |
| | | options: [ |
| | | { option: "非常满意", count: 1084, rate: "75.66%" }, |
| | | { option: "满意", count: 1959, rate: "20.92%" }, |
| | | { option: "一般", count: 167, rate: "1.78%" }, |
| | | { option: "不满意", count: 153, rate: "1.63%" }, |
| | | ], |
| | | total: 3363 |
| | | }, |
| | | { |
| | | title: "第2题:您对哪部分服务不满意?【多选题】", |
| | | options: [ |
| | | { option: "医生诊断和诊疗", count: 131, rate: "32.11%" }, |
| | | { option: "护理服务", count: 47, rate: "11.52%" }, |
| | | { option: "其他医务人员技术水平", count: 30, rate: "7.33%" }, |
| | | { option: "工人", count: 72, rate: "17.65%" }, |
| | | ], |
| | | total: 408 |
| | | }, |
| | | { |
| | | title: "第3题:医生诊断和治疗中哪些服务质量不满意?【多选题】", |
| | | options: [ |
| | | { option: "医生诊断错误", count: 23, rate: "15.44%" }, |
| | | { option: "对治疗效果不满意", count: 72, rate: "48.32%" }, |
| | | { option: "其他", count: 54, rate: "36.24%" }, |
| | | ], |
| | | total: 131 |
| | | }, |
| | | ], |
| | | deptComparison: [ |
| | | { dept: "内分泌科", q1VeryGood: 103, q1Good: 34, q1Normal: 0, q1Bad: 0, q1Score: "377.00", q2VeryGood: 101, q2Good: 35, q2Normal: 1, q2Bad: 0, q2Score: "374.00", totalScore: "75.25" }, |
| | | { dept: "皮肤科", q1VeryGood: 112, q1Good: 25, q1Normal: 2, q1Bad: 3, q1Score: "388.00", q2VeryGood: 111, q2Good: 27, q2Normal: 3, q2Bad: 1, q2Score: "390.00", totalScore: "76.12" }, |
| | | { dept: "肿瘤内科", q1VeryGood: 24, q1Good: 12, q1Normal: 2, q1Bad: 0, q1Score: "98.00", q2VeryGood: 25, q2Good: 12, q2Normal: 1, q2Bad: 0, q2Score: "100.00", totalScore: "70.22" }, |
| | | { dept: "消化内科", q1VeryGood: 180, q1Good: 52, q1Normal: 1, q1Bad: 0, q1Score: "645.00", q2VeryGood: 168, q2Good: 62, q2Normal: 3, q2Bad: 0, q2Score: "631.00", totalScore: "73.56" }, |
| | | { dept: "肾内科", q1VeryGood: 21, q1Good: 4, q1Normal: 0, q1Bad: 0, q1Score: "71.00", q2VeryGood: 19, q2Good: 6, q2Normal: 0, q2Bad: 0, q2Score: "65.00", totalScore: "68.50" }, |
| | | ], |
| | | // 顶部全局筛选 |
| | | 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] || '统计数据'; |
| | |
| | | // 从表格提取数据构建带样式的 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 => { |
| | | tr.querySelectorAll('th,td').forEach((td) => { |
| | | row.push((td.innerText || '').trim()); |
| | | }); |
| | | if (row.length) data.push(row); |
| | |
| | | } catch (e) { |
| | | // fallback: CSV |
| | | let csv = ''; |
| | | table.querySelectorAll('tr').forEach(tr => { |
| | | table.querySelectorAll('tr').forEach((tr) => { |
| | | const row = []; |
| | | tr.querySelectorAll('th,td').forEach(td => row.push('"' + (td.innerText || '').replace(/"/g, '""') + '"')); |
| | | 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(); |
| | | link.href = URL.createObjectURL(blob); |
| | | link.download = name + '.csv'; |
| | | link.click(); |
| | | } |
| | | }, |
| | | }, |
| | |
| | | |
| | | <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; } |