| | |
| | | <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-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.deptCodes" |
| | | multiple |
| | | collapse-tags |
| | | clearable |
| | | filterable |
| | | placeholder="全部科室" |
| | | size="small" |
| | | class="filter-select" |
| | | > |
| | | <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" |
| | | :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 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> |
| | | |
| | | <!-- 表格三:各科室满意度评分对比 --> |
| | | <!-- 表格三:各科室/病区满意度评分对比 --> |
| | | <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.value" |
| | | :label="d.label" |
| | | :value="'dept:' + d.value" |
| | | /> |
| | | </template> |
| | | <template v-else> |
| | | <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 |
| | | > |
| | | </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-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 = ["很满意", "满意", "一般", "不满意", "很不满意", "未经历"]; |
| | | // 无分值选项(如"未经历")是否计入综合得分分母:默认 false,避免拉低得分 |
| | | const NO_SCORE_IN_DENOMINATOR = false; |
| | | |
| | | // 服务类型字典(接口 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: "专科随访" }, |
| | | ]; |
| | | |
| | | // 服务类型默认选中项(按 label 匹配,适配不同字典的 value 差异) |
| | | const DEFAULT_SERVICE_LABELS = ["住院满意度", "门诊满意度"]; |
| | | |
| | | // 兜底:store 中无科室/病区数据时使用(表三对比 + 筛选下拉) |
| | | const DEPTS = ["内分泌科", "皮肤科", "肿瘤内科", "消化内科", "肾内科"]; |
| | | const WARDS = ["一病区", "二病区", "三病区", "四病区", "五病区"]; |
| | | |
| | | /** |
| | | * 接口数据 → 页面内部题目结构 |
| | | * 入参:接口 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", |
| | | 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: [], |
| | | deptCodes: [], // 科室编码集合 => leaveldeptcodes |
| | | wardCodes: [], // 出院病区编码集合 => leavehospitaldistrictcodes(接口必填) |
| | | serviceTypeList: [], // 服务类型多选,提交时用逗号拼接 |
| | | deptOrDistrict: "1", // 科室/病区组合方式,默认 1:离院病区 and 离院科室 |
| | | statMode: "ward", // 统计维度:ward=按病区统计 dept=按科室统计 |
| | | }, |
| | | SERVICE_TYPES, |
| | | loading: false, |
| | | |
| | | // 表二:题目模糊查询关键字(纯前端过滤,不请求后端) |
| | | questionSearch: "", |
| | | |
| | | // 表格三:统计维度 + 独立科室/病区条件('inherit' = 沿用顶部) |
| | | statDimension: "ward", |
| | | statDeptWard: "inherit", |
| | | |
| | | // 接口返回的原始数据(已归一化的题目列表) |
| | | questionList: [], |
| | | // 表三:科室/病区评分对比(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)); |
| | | }, |
| | | // 其余题目 => 表二 |
| | | otherQuestions() { |
| | | 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 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.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 = 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.reduce((s, it) => s + it.count, 0); |
| | | rows.push({ |
| | | 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; |
| | | }, |
| | | // 表二:单选/多选统计 |
| | | 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), |
| | | })), |
| | | }; |
| | | }); |
| | | }, |
| | | // 表二:按题目模糊查询(纯前端过滤,不请求后端) |
| | | 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 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) || []; |
| | | }, |
| | | }, |
| | | created() { |
| | | if (!this.queryParams.dateRange || !this.queryParams.dateRange.length) { |
| | | this.queryParams.dateRange = this.defaultRange(); |
| | | } |
| | | this.queryParams.serviceTypeList = this.defaultServiceTypes(); |
| | | }, |
| | | mounted() { |
| | | this.fetchData(); |
| | | }, |
| | | methods: { |
| | | // 默认选中的服务类型(按 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"; |
| | | } |
| | | }, |
| | | handleReset() { |
| | | this.queryParams.dateRange = this.defaultRange(); |
| | | 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 || !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) + "%"; |
| | | }, |
| | | /** |
| | | * 综合得分(真实口径):Σ(各选项分值 × 填报量) / Σ(填报量) |
| | | * 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() - 91); |
| | | const f = (d) => { |
| | | const y = d.getFullYear(); |
| | | const m = String(d.getMonth() + 1).padStart(2, "0"); |
| | | const day = String(d.getDate()).padStart(2, "0"); |
| | | return `${y}-${m}-${day}`; |
| | | }; |
| | | return [f(start), f(end)]; |
| | | }, |
| | | // 提示信息(兼容 $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); |
| | | } |
| | | }, |
| | | // 拉取满意度统计数据(接口:/smartor/serviceSubtaskDetail/sltdMydTotalByScore) |
| | | async fetchData() { |
| | | const range = this.queryParams.dateRange || []; |
| | | if (range.length !== 2 || !range[0] || !range[1]) { |
| | | this.notify("请先选择出院日期范围", "warning"); |
| | | return; |
| | | } |
| | | |
| | | // 病区 => 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"); |
| | | } |
| | | // 表三:各科室/病区满意度评分对比(真实接口,内部自行处理错误) |
| | | 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"; |
| | | }); |
| | | |
| | | return { rows, questions }; |
| | | }, |
| | | // 名称 → 编码(用于表三按顶部所选编码过滤) |
| | | 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'); |
| | | link.href = URL.createObjectURL(blob); link.download = name + '.csv'; link.click(); |
| | | 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; } } |
| | | .card-header { display: flex; justify-content: space-between; align-items: center; } |
| | | .card-title { font-size: 16px; font-weight: 600; color: #303133; } |
| | | .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> |