| | |
| | | <!-- 顶部筛选:时间范围 + 科室/病区,控制本页所有数据 --> |
| | | <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" |
| | |
| | | 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> |
| | |
| | | <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> |
| | | |
| | |
| | | <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> |
| | |
| | | <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)); |
| | | }, |
| | |
| | | }, |
| | | // 表一:满意度统计(含总计行) |
| | | 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; |
| | | }, |
| | |
| | | }; |
| | | }); |
| | | }, |
| | | // 表二:按题目模糊查询(纯前端过滤,不请求后端) |
| | | 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; |
| | | |
| | |
| | | // 从表格提取数据构建带样式的 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); |
| | | }); |
| | |
| | | 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(); |
| | | } |
| | | }, |
| | |
| | | </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> |