| | |
| | | <span class="filter-label">区间类型:</span> |
| | | <el-radio-group v-model="queryParams.interval" size="small" @change="refreshAll"> |
| | | <el-radio-button label="day">按天</el-radio-button> |
| | | <el-radio-button label="week">按周</el-radio-button> |
| | | <el-radio-button label="month">按月</el-radio-button> |
| | | <el-radio-button label="year">按年</el-radio-button> |
| | | </el-radio-group> |
| | | <span class="filter-label">统计维度:</span> |
| | | <el-radio-group v-model="queryParams.dimension" size="small" @change="onDimensionChange"> |
| | | <el-radio-button label="ward">按病区统计</el-radio-button> |
| | | <el-radio-button label="dept">按科室统计</el-radio-button> |
| | | </el-radio-group> |
| | | <el-button type="primary" size="small" icon="el-icon-search" @click="refreshAll">查询</el-button> |
| | | <el-button size="small" icon="el-icon-refresh" @click="handleReset">重置</el-button> |
| | |
| | | <el-col :span="12"> |
| | | <el-card shadow="never" class="chart-card"> |
| | | <div slot="header" class="card-header"> |
| | | <span>时间维度 · 门诊患者满意度趋势</span> |
| | | <span>患者满意度综合得分趋势</span> |
| | | <el-tag type="success" size="small">环比 {{ trendMoM }}</el-tag> |
| | | </div> |
| | | <div class="chart-wrap" v-loading="trendLoading" element-loading-text="趋势加载中..."> |
| | | <div ref="chart1" class="chart-box"></div> |
| | | <div v-if="!trendLoading && !trend.buckets.length" class="chart-empty">暂无趋势数据</div> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | |
| | |
| | | <div slot="header" class="card-header"> |
| | | <span>分类维度 · 各指标{{ intervalLabel }}对比</span> |
| | | </div> |
| | | <div class="chart-wrap" v-loading="categoryLoading" element-loading-text="分类维度加载中..."> |
| | | <div ref="chart2" class="chart-box"></div> |
| | | <div v-if="!categoryLoading && !categoryCompare.categories.length" class="chart-empty">暂无分类维度数据</div> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | </el-row> |
| | |
| | | <div slot="header" class="card-header"> |
| | | <span>单题 · 选项统计</span> |
| | | <div class="header-right"> |
| | | <el-select v-model="singleQuestionId" size="small" class="sel sel-lg" @change="refreshAll"> |
| | | <el-option v-for="q in questionOptions" :key="q.id" :label="q.title" :value="q.id" /> |
| | | <el-select v-model="singleQuestionId" size="small" class="sel sel-lg" filterable remote clearable |
| | | :remote-method="searchQuestions" :loading="questionLoading" placeholder="请输入问题内容搜索" @change="onSingleQuestionChange"> |
| | | <el-option v-for="q in questionOptions" :key="q.id" :label="q.scriptContent || q.title" :value="q.id" /> |
| | | </el-select> |
| | | <el-select v-model="singleDeptWards" multiple collapse-tags size="small" class="sel" clearable filterable placeholder="全部科室/病区" @change="refreshAll"> |
| | | <el-option-group label="科室"> |
| | | <el-select v-model="singleDeptWards" multiple collapse-tags size="small" class="sel" clearable filterable :placeholder="queryParams.dimension === 'dept' ? '全部科室' : '全部病区'" @change="refreshAll"> |
| | | <template v-if="queryParams.dimension === 'dept'"> |
| | | <el-option v-for="d in deptOptions" :key="'dept:' + d" :label="d" :value="'dept:' + d" /> |
| | | </el-option-group> |
| | | <el-option-group label="病区"> |
| | | </template> |
| | | <template v-else> |
| | | <el-option v-for="w in wardOptions" :key="'ward:' + w" :label="w" :value="'ward:' + w" /> |
| | | </el-option-group> |
| | | </template> |
| | | </el-select> |
| | | </div> |
| | | </div> |
| | | <el-table :data="singleQuestionData" border size="small"> |
| | | <el-table :data="singleQuestionData" border size="small" v-loading="singleLoading" element-loading-text="选项统计加载中..."> |
| | | <el-table-column prop="option" label="选项" /> |
| | | <el-table-column prop="count" label="合计数量" width="120" align="center" /> |
| | | <el-table-column prop="rate" label="占比比例" width="120" align="center" /> |
| | | </el-table> |
| | | <div class="table-footer">本题有效填写人次:<b>{{ singleQuestionMeta.total }}</b> 综合满意度:<b>{{ singleQuestionMeta.score }}</b></div> |
| | | <div class="table-footer">本题有效填写人次:<b>{{ singleQuestionMeta.total }}</b> 综合得分:<b>{{ singleQuestionMeta.score }}</b></div> |
| | | </el-card> |
| | | </el-col> |
| | | |
| | |
| | | <div slot="header" class="card-header"> |
| | | <span>院区维度 · 各院区{{ intervalLabel }}对比</span> |
| | | </div> |
| | | <div class="chart-wrap" v-loading="hospitalLoading" element-loading-text="院区维度加载中..."> |
| | | <div ref="chart4" class="chart-box"></div> |
| | | <div v-if="!hospitalLoading && !hospitalCompare.hospitals.length" class="chart-empty">暂无院区维度数据</div> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | </el-row> |
| | |
| | | </el-select> |
| | | </div> |
| | | </div> |
| | | <el-table :data="rankRows" border size="small" max-height="400"> |
| | | <el-table-column type="index" label="排名" width="60" align="center" /> |
| | | <el-table :data="rankRows" border size="small" max-height="400" v-loading="rankLoading" element-loading-text="排名加载中..."> |
| | | <el-table-column prop="rank" label="排名" width="60" align="center" /> |
| | | <el-table-column prop="name" :label="rankDimLabel" /> |
| | | <el-table-column prop="score" label="综合得分" width="120" align="center" /> |
| | | <el-table-column prop="itemCount" label="项数" width="100" align="center" /> |
| | | </el-table> |
| | | </el-card> |
| | | </el-col> |
| | |
| | | </el-select> |
| | | </div> |
| | | </div> |
| | | <el-table :data="crossRows" border size="small" max-height="400"> |
| | | <el-table-column type="index" label="排名" width="60" align="center" /> |
| | | <el-table :data="crossRows" border size="small" max-height="400" v-loading="crossLoading" element-loading-text="排名加载中..."> |
| | | <el-table-column prop="rank" label="排名" width="60" align="center" /> |
| | | <el-table-column prop="name" :label="crossDimLabel" /> |
| | | <el-table-column prop="score" label="综合得分" width="120" align="center" /> |
| | | <el-table-column prop="itemCount" label="项数" width="100" align="center" /> |
| | | </el-table> |
| | | </el-card> |
| | | </el-col> |
| | |
| | | |
| | | <script> |
| | | import * as echarts from 'echarts'; |
| | | import { mydTrend, sltdMydTotalByDimension, sltdMydTotalByScore ,mzMydScoreRank} from "@/api/AiCentre/satisfaction"; |
| | | import { getissuelist } from "@/api/AiCentre/questionnaire"; |
| | | |
| | | // 单题选项 |
| | | const QUESTION_OPTIONS = ['非常满意', '满意', '一般', '不满意']; |
| | | // 可选题目 |
| | | const QUESTIONS = [ |
| | | { id: 22, title: '第22题:抱怨和不满能否及时得到回应?' }, |
| | | { id: 1, title: '第1题:您对我院工作人员的服务质量满意不满意?' }, |
| | | { id: 5, title: '第5题:您对医院服务设施的总体印象如何?' }, |
| | | { id: 8, title: '第8题:医生对您是否尊重?' }, |
| | | { id: 12, title: '第12题:护士对您是否尊重?' }, |
| | | { id: 15, title: '第15题:您对预约挂号是否满意?' }, |
| | | ]; |
| | | 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; |
| | | }; |
| | | } |
| | | |
| | | export default { |
| | | name: 'ChartsPanel', |
| | | dicts: ['dimensionality_type'], |
| | | data() { |
| | | return { |
| | | queryParams: { |
| | | dateRange: [], |
| | | interval: 'month', |
| | | dimension: 'ward', // 统计维度:ward=按病区统计 dept=按科室统计 |
| | | }, |
| | | deptOptions: DEPTS, |
| | | wardOptions: WARDS, |
| | | questionOptions: QUESTIONS, |
| | | questionOptions: [], |
| | | |
| | | // 单题筛选 |
| | | singleQuestionId: 22, |
| | | singleQuestionId: null, |
| | | singleQuestionContent: '', |
| | | singleDeptWards: [], |
| | | |
| | | // 排名维度筛选 |
| | |
| | | crossDeptWards: [], |
| | | |
| | | // 图表数据 |
| | | trendLoading: false, |
| | | categoryLoading: false, |
| | | hospitalLoading: false, |
| | | trend: { buckets: [], data: [], seriesName: '门诊患者满意度' }, |
| | | categoryCompare: { categories: [], seriesNames: [], series1: [], series2: [] }, |
| | | hospitalCompare: { hospitals: [], seriesNames: [], series1: [], series2: [] }, |
| | | |
| | | // 单题 / 排名 / 交叉表加载状态 |
| | | singleLoading: false, |
| | | questionLoading: false, |
| | | rankLoading: false, |
| | | crossLoading: false, |
| | | |
| | | // 表格数据 |
| | | singleQuestionData: [], |
| | | singleQuestionMeta: { total: '0', score: '0.00%' }, |
| | | deptRankData: [], |
| | | wardRankData: [], |
| | | crossDeptData: [], |
| | | crossWardData: [], |
| | | singleQuestionMeta: { total: '0', score: '0.00' }, |
| | | rankData: [], |
| | | crossData: [], |
| | | }; |
| | | }, |
| | | computed: { |
| | | // 科室/病区编码:优先取当前用户所属,无则兜底名称本身作为编码 |
| | | deptCodeList() { |
| | | const list = this.$store.getters.belongDepts || []; |
| | | return list.length ? list : DEPTS.map((d) => ({ deptName: d, deptCode: d })); |
| | | }, |
| | | wardCodeList() { |
| | | const list = this.$store.getters.belongWards || []; |
| | | return list.length ? list : WARDS.map((w) => ({ districtName: w, districtCode: w })); |
| | | }, |
| | | defaultDeptCodes() { |
| | | return Array.from(new Set(this.deptCodeList.map((d) => d.deptCode))); |
| | | }, |
| | | defaultWardCodes() { |
| | | return Array.from(new Set(this.wardCodeList.map((w) => w.districtCode))); |
| | | }, |
| | | intervalLabel() { |
| | | return { day: '按天', month: '按月', year: '按年' }[this.queryParams.interval] || '按月'; |
| | | return { day: '按天', week: '按周', month: '按月', year: '按年' }[this.queryParams.interval] || '按月'; |
| | | }, |
| | | trendMoM() { |
| | | const d = this.trend.data; |
| | |
| | | return this.crossDimension === 'dept' ? this.deptOptions : this.wardOptions; |
| | | }, |
| | | rankRows() { |
| | | const list = this.rankDimension === 'dept' ? this.deptRankData : this.wardRankData; |
| | | if (!this.rankDeptWards.length) return list; |
| | | return list.filter((r) => this.rankDeptWards.includes(r.name)); |
| | | return this.rankData; |
| | | }, |
| | | crossRows() { |
| | | const list = this.crossDimension === 'dept' ? this.crossDeptData : this.crossWardData; |
| | | if (!this.crossDeptWards.length) return list; |
| | | return list.filter((r) => this.crossDeptWards.includes(r.name)); |
| | | return this.crossData; |
| | | }, |
| | | }, |
| | | mounted() { |
| | |
| | | this.queryParams.dateRange = this.defaultRange(); |
| | | } |
| | | this.initCharts(); |
| | | // 初始化即显示各数据块的加载状态,避免先闪现“暂无数据” |
| | | this.trendLoading = true; |
| | | this.categoryLoading = true; |
| | | this.hospitalLoading = true; |
| | | this.singleLoading = true; |
| | | this.rankLoading = true; |
| | | this.crossLoading = true; |
| | | // 数据块与题目列表并行加载:数据块不依赖题目列表,无需串行等待 |
| | | this.refreshAll(); |
| | | this.loadQuestions('').then(() => { |
| | | if (this.singleQuestionContent) { |
| | | // 单题选项统计 / 单题交叉排名依赖问题文本,题目加载完成后补拉一次 |
| | | this.fetchSingleQuestion(); |
| | | this.fetchCross(); |
| | | } else { |
| | | // 题目列表为空或加载失败,结束单题相关加载状态 |
| | | this.singleLoading = false; |
| | | this.crossLoading = false; |
| | | } |
| | | }); |
| | | }); |
| | | }, |
| | | methods: { |
| | |
| | | if (c) c.resize(); |
| | | }); |
| | | }, |
| | | // 维度编码 → 字典名称(dimensionality_type) |
| | | getDimensionLabel(code) { |
| | | const dict = this.dict && this.dict.type && this.dict.type.dimensionality_type; |
| | | if (dict) { |
| | | const hit = dict.find((item) => String(item.value) === String(code)); |
| | | if (hit) return hit.label; |
| | | } |
| | | return code == null ? '' : String(code); |
| | | }, |
| | | // 可选题目:远程模糊搜索(按问题内容 scriptContent) |
| | | searchQuestions(keyword) { |
| | | this.loadQuestions(keyword || ''); |
| | | }, |
| | | // 加载可选题目(参考问题列表页 getissuelist) |
| | | async loadQuestions(keyword) { |
| | | this.questionLoading = true; |
| | | try { |
| | | const res = await getissuelist({ |
| | | pageNum: 1, |
| | | pageSize: 50, |
| | | isavailable: '', |
| | | scriptContent: keyword || '', |
| | | }); |
| | | const rows = (res && res.rows) || []; |
| | | this.questionOptions = rows.map((q) => ({ |
| | | id: q.id, |
| | | scriptContent: q.scriptContent || q.title || '', |
| | | })); |
| | | // 无关键字时默认选中第一题 |
| | | if (!keyword && this.questionOptions.length && !this.singleQuestionContent) { |
| | | const first = this.questionOptions[0]; |
| | | this.singleQuestionId = first.id; |
| | | this.singleQuestionContent = first.scriptContent; |
| | | } |
| | | } catch (e) { |
| | | this.questionOptions = []; |
| | | } finally { |
| | | this.questionLoading = false; |
| | | } |
| | | }, |
| | | onSingleQuestionChange(val) { |
| | | const q = this.questionOptions.find((o) => String(o.id) === String(val)); |
| | | this.singleQuestionContent = q ? q.scriptContent : ''; |
| | | this.refreshAll(); |
| | | }, |
| | | // 科室/病区名称 → 编码 |
| | | mapDeptNames(names) { |
| | | return names.map((n) => { |
| | | const hit = this.deptCodeList.find((d) => d.deptName === n); |
| | | return hit ? hit.deptCode : n; |
| | | }); |
| | | }, |
| | | mapWardNames(names) { |
| | | return names.map((n) => { |
| | | const hit = this.wardCodeList.find((w) => w.districtName === n); |
| | | return hit ? hit.districtCode : n; |
| | | }); |
| | | }, |
| | | // 综合得分(真实口径):Σ(选项分值 × 填报量) / Σ(填报量) |
| | | scoreOfOptions(options) { |
| | | let num = 0; |
| | | let den = 0; |
| | | (options || []).forEach((o) => { |
| | | const c = Number(o && o.count) || 0; |
| | | const s = Number(o && o.score); |
| | | if (isNaN(s)) return; |
| | | num += s * c; |
| | | den += c; |
| | | }); |
| | | if (!den) return '0.00'; |
| | | return (num / den).toFixed(2); |
| | | }, |
| | | // 解析比例字符串:"14.29%"、"0"、"0.5" → 数值(百分比);无效返回 null |
| | | parsePercent(value) { |
| | | if (value == null || value === '') return null; |
| | | const n = Number(String(value).replace(/%/g, '').trim()); |
| | | return isNaN(n) ? null : n; |
| | | }, |
| | | // 单题返回 List<QuestionResultDTO> → { options, total } |
| | | normalizeSingleQuestion(list) { |
| | | const arr = Array.isArray(list) ? list : (list && (list.list || list.rows)) || []; |
| | | const options = []; |
| | | let total = 0; |
| | | arr.forEach((item) => { |
| | | const details = Array.isArray(item && item.subtaskDetailRatioExportList) |
| | | ? item.subtaskDetailRatioExportList |
| | | : []; |
| | | details.forEach((d) => { |
| | | const option = (d && (d.optionresult || d.targetvalue || d.optioncontent || d.questiontext)) || ''; |
| | | const count = Number(d && d.count) || 0; |
| | | const rawScore = Number(d && d.score); |
| | | const ratio = this.parsePercent(d && d.ratio); |
| | | options.push({ option, count, score: isNaN(rawScore) ? null : rawScore, ratio }); |
| | | total += count; |
| | | }); |
| | | }); |
| | | return { options, total }; |
| | | }, |
| | | // 单题选项统计(真实接口 /smartor/serviceSubtaskDetail/sltdMydTotalByScore) |
| | | async fetchSingleQuestion() { |
| | | const range = this.queryParams.dateRange || []; |
| | | if (range.length !== 2 || !range[0] || !range[1] || !this.singleQuestionContent) { |
| | | this.singleQuestionData = []; |
| | | this.singleQuestionMeta = { total: '0', score: '0.00' }; |
| | | return; |
| | | } |
| | | this.singleLoading = true; |
| | | try { |
| | | const payload = { |
| | | deptOrDistrict: '1', |
| | | serviceTypeList: ['6', '14'], |
| | | startFinishTime: range[0], |
| | | endFinishTime: range[1], |
| | | questionContent: this.singleQuestionContent, |
| | | }; |
| | | const sel = this.singleDeptWards || []; |
| | | if (this.queryParams.dimension === 'dept') { |
| | | const names = sel.map((s) => String(s).replace(/^dept:/, '')); |
| | | payload.leaveldeptcodes = names.length ? this.mapDeptNames(names) : this.defaultDeptCodes; |
| | | } else { |
| | | const names = sel.map((s) => String(s).replace(/^ward:/, '')); |
| | | payload.leavehospitaldistrictcodes = names.length ? this.mapWardNames(names) : this.defaultWardCodes; |
| | | } |
| | | const res = await sltdMydTotalByScore(payload); |
| | | console.log('[sltdMydTotalByScore 单题] code=', res && res.code, 'data=', JSON.stringify(res && res.data).slice(0, 1500)); |
| | | const list = res && res.code === 200 ? res.data : []; |
| | | const { options, total } = this.normalizeSingleQuestion(list); |
| | | this.singleQuestionData = options.map((o) => ({ |
| | | option: o.option, |
| | | count: o.count, |
| | | rate: o.ratio != null |
| | | ? o.ratio.toFixed(2) + '%' |
| | | : (total ? ((o.count / total) * 100).toFixed(2) + '%' : '0.00%'), |
| | | })); |
| | | this.singleQuestionMeta = { |
| | | total: total.toLocaleString(), |
| | | score: this.scoreOfOptions(options), |
| | | }; |
| | | if (!options.length) this.notify('当前条件下暂无该题的选项统计数据', 'warning'); |
| | | } catch (e) { |
| | | this.singleQuestionData = []; |
| | | this.singleQuestionMeta = { total: '0', score: '0.00' }; |
| | | this.notify('单题选项统计数据请求失败,请稍后重试', 'error'); |
| | | } finally { |
| | | this.singleLoading = false; |
| | | } |
| | | }, |
| | | handleReset() { |
| | | this.queryParams.dateRange = this.defaultRange(); |
| | | this.queryParams.interval = 'month'; |
| | | this.queryParams.dimension = 'ward'; |
| | | this.singleDeptWards = []; |
| | | this.refreshAll(); |
| | | }, |
| | | // 切换全局统计维度时清空单题筛选,保证科室/病区互斥 |
| | | onDimensionChange() { |
| | | this.singleDeptWards = []; |
| | | this.refreshAll(); |
| | | }, |
| | | onRankDimensionChange() { |
| | |
| | | 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'); |
| | |
| | | intervalConfig() { |
| | | const buckets = this.genBuckets(this.queryParams.interval); |
| | | if (this.queryParams.interval === 'day') return { buckets, seriesNames: ['昨日', '今日'] }; |
| | | if (this.queryParams.interval === 'week') return { buckets, seriesNames: ['上周', '本周'] }; |
| | | if (this.queryParams.interval === 'year') return { buckets, seriesNames: ['去年', '今年'] }; |
| | | return { buckets, seriesNames: ['上月', '本月'] }; |
| | | }, |
| | |
| | | d.setDate(end.getDate() - i); |
| | | buckets.push(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`); |
| | | } |
| | | } else if (type === 'week') { |
| | | for (let i = 5; i >= 0; i--) { |
| | | const d = new Date(end); |
| | | d.setDate(end.getDate() - i * 7); |
| | | buckets.push(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} 周`); |
| | | } |
| | | } else if (type === 'year') { |
| | | for (let i = 3; i >= 0; i--) buckets.push(String(end.getFullYear() - i)); |
| | | } else { |
| | |
| | | } |
| | | return buckets; |
| | | }, |
| | | // 刷新全部数据(模拟,接口就绪后替换为真实请求) |
| | | refreshAll() { |
| | | const baseChart = JSON.stringify(this.queryParams); |
| | | const baseTime = JSON.stringify({ dateRange: this.queryParams.dateRange }); |
| | | |
| | | // 图表:区间类型 + 时间范围 |
| | | this.genChartData(mulberry32(hashString(baseChart + 'charts'))); |
| | | // 单题表:时间范围 + 题目 + 科室/病区 |
| | | this.genSingleData(mulberry32(hashString(baseTime + JSON.stringify({ sq: this.singleQuestionId, sdw: this.singleDeptWards })))); |
| | | // 科室/病区排名:时间范围 + 维度 + 选择 |
| | | this.genRankData(mulberry32(hashString(baseTime + JSON.stringify({ rd: this.rankDimension, rdw: this.rankDeptWards })))); |
| | | // 单题×科室/病区排名:时间范围 + 维度 + 选择 + 题目 |
| | | this.genCrossData(mulberry32(hashString(baseTime + JSON.stringify({ cd: this.crossDimension, cdw: this.crossDeptWards, sq: this.singleQuestionId })))); |
| | | // 刷新全部数据(趋势、分类维度、院区维度、单题、排名、交叉均为真实接口) |
| | | async refreshAll() { |
| | | // 六个数据块并行拉取,各区块同时显示各自的加载状态 |
| | | await Promise.all([ |
| | | this.fetchTrend(), |
| | | this.fetchCategoryCompare(), |
| | | this.fetchHospitalCompare(), |
| | | this.fetchSingleQuestion(), |
| | | this.fetchRank(), |
| | | this.fetchCross(), |
| | | ]); |
| | | |
| | | this.renderCharts(); |
| | | }, |
| | | genChartData(rnd) { |
| | | const jitter = (lo, hi) => lo + rnd() * (hi - lo); |
| | | const cfg = this.intervalConfig(); |
| | | const buckets = cfg.buckets; |
| | | // 满意度趋势(真实接口):data = [{ period, totalScore }] |
| | | async fetchTrend() { |
| | | const range = this.queryParams.dateRange || []; |
| | | if (range.length !== 2 || !range[0] || !range[1]) { |
| | | this.trend = { buckets: [], data: [], seriesName: '门诊患者满意度' }; |
| | | return; |
| | | } |
| | | |
| | | this.trendLoading = true; |
| | | try { |
| | | const res = await mydTrend({ |
| | | startFinishTime: range[0], |
| | | endFinishTime: range[1], |
| | | type: this.queryParams.interval, // day / week / month / year |
| | | }); |
| | | const list = res && res.code === 200 && Array.isArray(res.data) ? res.data : []; |
| | | this.trend = { |
| | | buckets, |
| | | seriesName: '门诊患者满意度', |
| | | data: buckets.map(() => Math.round(jitter(86, 90) * 100) / 100), |
| | | buckets: list.map((i) => String((i && i.period) || '')), |
| | | data: list.map((i) => { |
| | | const n = Number(i && i.totalScore); |
| | | return isNaN(n) ? 0 : Math.round(n * 100) / 100; |
| | | }), |
| | | }; |
| | | |
| | | const categories = ['隐私保护', '环境与标识', '医务人员回应', '医生沟通', '挂号体验', '护士沟通']; |
| | | this.categoryCompare = { |
| | | categories, |
| | | seriesNames: cfg.seriesNames, |
| | | series1: categories.map(() => Math.round(jitter(82, 88) * 100) / 100), |
| | | series2: categories.map(() => Math.round(jitter(83, 89) * 100) / 100), |
| | | }; |
| | | |
| | | const hospitals = ['院区一', '院区二', '院区三']; |
| | | this.hospitalCompare = { |
| | | hospitals, |
| | | seriesNames: cfg.seriesNames, |
| | | series1: hospitals.map(() => Math.round(jitter(85, 89) * 100) / 100), |
| | | series2: hospitals.map(() => Math.round(jitter(86, 90) * 100) / 100), |
| | | if (!list.length) this.notify('当前条件下暂无趋势数据', 'warning'); |
| | | } catch (e) { |
| | | this.trend = { buckets: [], data: [], seriesName: '门诊患者满意度' }; |
| | | this.notify('趋势数据请求失败,请稍后重试', 'error'); |
| | | } finally { |
| | | this.trendLoading = false; |
| | | } |
| | | }, |
| | | notify(msg, type) { |
| | | const fn = { warning: 'warning', error: 'error', success: 'success' }[type] || 'info'; |
| | | if (this.$message && this.$message[fn]) this.$message[fn](msg); |
| | | }, |
| | | // 分类维度对比(真实接口):dimensionType=1,data = [{ period, dimension, totalScore }] |
| | | async fetchCategoryCompare() { |
| | | const range = this.queryParams.dateRange || []; |
| | | if (range.length !== 2 || !range[0] || !range[1]) { |
| | | this.categoryCompare = { categories: [], seriesNames: [], series1: [], series2: [] }; |
| | | return; |
| | | } |
| | | this.categoryLoading = true; |
| | | try { |
| | | const res = await sltdMydTotalByDimension({ |
| | | startFinishTime: range[0], |
| | | endFinishTime: range[1], |
| | | type: this.queryParams.interval, // day / week / month / year |
| | | dimensionType: "1", // 1=按满意度维度统计 |
| | | }); |
| | | // 排查用:打印接口原始返回,便于核对字段名(确认无误后可删除) |
| | | console.log("[sltdMydTotalByDimension dimensionType=1] code=", res && res.code, "data=", JSON.stringify(res && res.data).slice(0, 2000)); |
| | | const list = res && res.code === 200 && Array.isArray(res.data) ? res.data : []; |
| | | // 维度编码 → 字典名称,并过滤两期均无数据的维度 |
| | | const d = this.buildCompareData(list, (code) => this.getDimensionLabel(code)); |
| | | this.categoryCompare = { categories: d.labels, seriesNames: d.seriesNames, series1: d.series1, series2: d.series2 }; |
| | | if (!list.length) this.notify('当前条件下暂无分类维度数据', 'warning'); |
| | | } catch (e) { |
| | | this.categoryCompare = { categories: [], seriesNames: [], series1: [], series2: [] }; |
| | | this.notify('分类维度数据请求失败,请稍后重试', 'error'); |
| | | } finally { |
| | | this.categoryLoading = false; |
| | | } |
| | | }, |
| | | // 院区维度对比(真实接口):dimensionType=2,data = [{ period, dimension(院区名), totalScore }] |
| | | async fetchHospitalCompare() { |
| | | const range = this.queryParams.dateRange || []; |
| | | if (range.length !== 2 || !range[0] || !range[1]) { |
| | | this.hospitalCompare = { hospitals: [], seriesNames: [], series1: [], series2: [] }; |
| | | return; |
| | | } |
| | | this.hospitalLoading = true; |
| | | try { |
| | | const res = await sltdMydTotalByDimension({ |
| | | startFinishTime: range[0], |
| | | endFinishTime: range[1], |
| | | type: this.queryParams.interval, // day / week / month / year |
| | | dimensionType: "2", // 2=按院区统计 |
| | | }); |
| | | // 排查用:打印接口原始返回,便于核对字段名(确认无误后可删除) |
| | | console.log("[sltdMydTotalByDimension dimensionType=2] code=", res && res.code, "data=", JSON.stringify(res && res.data).slice(0, 2000)); |
| | | const list = res && res.code === 200 && Array.isArray(res.data) ? res.data : []; |
| | | const d = this.buildCompareData(list); |
| | | this.hospitalCompare = { hospitals: d.labels, seriesNames: d.seriesNames, series1: d.series1, series2: d.series2 }; |
| | | if (!list.length) this.notify('当前条件下暂无院区维度数据', 'warning'); |
| | | } catch (e) { |
| | | this.hospitalCompare = { hospitals: [], seriesNames: [], series1: [], series2: [] }; |
| | | this.notify('院区维度数据请求失败,请稍后重试', 'error'); |
| | | } finally { |
| | | this.hospitalLoading = false; |
| | | } |
| | | }, |
| | | // 将 [{ period, dimension, totalScore }] 组装为分组柱状图数据(上期/本期两组对比) |
| | | // labelFn:可选,用于把维度编码映射为字典名称(分类维度图传入) |
| | | buildCompareData(list, labelFn) { |
| | | const labels = []; |
| | | const byCat = {}; |
| | | list.forEach((i) => { |
| | | // 维度编码(dimensionType=1)或院区名称(dimensionType=2);若后端字段命名不同,兜底取 period |
| | | const key = (i && (i.dimension || i.campusName || i.campusid)) || (i && i.period) || ''; |
| | | if (!key) return; |
| | | if (!byCat[key]) { byCat[key] = {}; labels.push(key); } |
| | | const n = Number(i.totalScore); |
| | | byCat[key][i.period] = isNaN(n) ? 0 : Math.round(n * 100) / 100; |
| | | }); |
| | | // 后端按时间升序返回,取最后两个周期作为“上期 / 本期” |
| | | const periods = [...new Set(list.map((i) => i && i.period).filter(Boolean))]; |
| | | const cur = periods[periods.length - 1]; |
| | | const prev = periods[periods.length - 2]; |
| | | const val = (cat, p) => (p != null && byCat[cat][p] != null ? byCat[cat][p] : 0); |
| | | // 过滤两期均无数据的维度 |
| | | const cats = labels.filter((c) => val(c, prev) !== 0 || val(c, cur) !== 0); |
| | | const display = labelFn ? cats.map((c) => labelFn(c)) : cats; |
| | | return { |
| | | labels: display, |
| | | seriesNames: this.intervalConfig().seriesNames, |
| | | series1: cats.map((c) => val(c, prev)), |
| | | series2: cats.map((c) => val(c, cur)), |
| | | }; |
| | | }, |
| | | genSingleData(rnd) { |
| | | const jitter = (lo, hi) => lo + rnd() * (hi - lo); |
| | | const scopeFactor = this.singleDeptWards.length ? 0.18 : 1; |
| | | const total = Math.round(jitter(20000, 30000) * scopeFactor); |
| | | const weights = [0.62, 0.27, 0.07, 0.04]; |
| | | const counts = weights.map((w) => Math.max(0, Math.round(total * w * jitter(0.92, 1.08)))); |
| | | const good = counts[0] + counts[1]; |
| | | this.singleQuestionData = QUESTION_OPTIONS.map((opt, i) => ({ |
| | | option: opt, |
| | | count: counts[i], |
| | | rate: ((counts[i] / total) * 100).toFixed(2) + '%', |
| | | // 科室/病区维度 · 门诊患者满意度得分排名(真实接口 /mzMydScoreRank,不带问题文本) |
| | | async fetchRank() { |
| | | const range = this.queryParams.dateRange || []; |
| | | if (range.length !== 2 || !range[0] || !range[1]) { |
| | | this.rankData = []; |
| | | return; |
| | | } |
| | | this.rankLoading = true; |
| | | try { |
| | | const res = await mzMydScoreRank(this.buildRankPayload(this.rankDimension, this.rankDeptWards, '')); |
| | | this.rankData = this.normalizeRank(res); |
| | | if (!this.rankData.length) this.notify('当前条件下暂无排名数据', 'warning'); |
| | | } catch (e) { |
| | | this.rankData = []; |
| | | this.notify('排名数据请求失败,请稍后重试', 'error'); |
| | | } finally { |
| | | this.rankLoading = false; |
| | | } |
| | | }, |
| | | // 单题×科室/病区 · 得分排名(真实接口 /mzMydScoreRank,带问题文本) |
| | | async fetchCross() { |
| | | const range = this.queryParams.dateRange || []; |
| | | if (range.length !== 2 || !range[0] || !range[1] || !this.singleQuestionContent) { |
| | | this.crossData = []; |
| | | return; |
| | | } |
| | | this.crossLoading = true; |
| | | try { |
| | | const res = await mzMydScoreRank(this.buildRankPayload(this.crossDimension, this.crossDeptWards, this.singleQuestionContent)); |
| | | this.crossData = this.normalizeRank(res); |
| | | if (!this.crossData.length) this.notify('当前条件下暂无排名数据', 'warning'); |
| | | } catch (e) { |
| | | this.crossData = []; |
| | | this.notify('排名数据请求失败,请稍后重试', 'error'); |
| | | } finally { |
| | | this.crossLoading = false; |
| | | } |
| | | }, |
| | | // 组装排名接口请求体(dimensionType/serviceType/deptCodes/districtCodes/时间/questionText) |
| | | buildRankPayload(dimension, selectedNames, questionText) { |
| | | const range = this.queryParams.dateRange || []; |
| | | const payload = { |
| | | dimensionType: dimension === 'dept' ? '1' : '2', |
| | | serviceTypes: ['6', '14'], |
| | | startFinishTime: range[0], |
| | | endFinishTime: range[1], |
| | | }; |
| | | if (questionText) payload.questionText = questionText; |
| | | if (selectedNames && selectedNames.length) { |
| | | if (dimension === 'dept') { |
| | | payload.deptCodes = this.mapDeptNames(selectedNames); |
| | | } else { |
| | | payload.districtCodes = this.mapWardNames(selectedNames); |
| | | } |
| | | } |
| | | return payload; |
| | | }, |
| | | // 归一化排名返回 List<MzMydScoreRankDTO> → [{ rank, code, name, score, itemCount }] |
| | | normalizeRank(res) { |
| | | const list = res && res.code === 200 && Array.isArray(res.data) ? res.data : []; |
| | | return list.map((item) => ({ |
| | | rank: item.rank, |
| | | code: item.code, |
| | | name: item.name, |
| | | score: item.score, |
| | | itemCount: item.itemCount, |
| | | })); |
| | | this.singleQuestionMeta = { |
| | | total: total.toLocaleString(), |
| | | score: ((good / total) * 100).toFixed(2) + '%', |
| | | }; |
| | | }, |
| | | genRankData(rnd) { |
| | | const jitter = (lo, hi) => lo + rnd() * (hi - lo); |
| | | this.deptRankData = DEPTS |
| | | .map((d) => ({ name: d, score: jitter(70, 85).toFixed(2) })) |
| | | .sort((a, b) => Number(b.score) - Number(a.score)); |
| | | this.wardRankData = WARDS |
| | | .map((w) => ({ name: w, score: jitter(70, 85).toFixed(2) })) |
| | | .sort((a, b) => Number(b.score) - Number(a.score)); |
| | | }, |
| | | genCrossData(rnd) { |
| | | const jitter = (lo, hi) => lo + rnd() * (hi - lo); |
| | | this.crossDeptData = DEPTS |
| | | .map((d) => ({ name: d, score: jitter(70, 85).toFixed(2) })) |
| | | .sort((a, b) => Number(b.score) - Number(a.score)); |
| | | this.crossWardData = WARDS |
| | | .map((w) => ({ name: w, score: jitter(70, 85).toFixed(2) })) |
| | | .sort((a, b) => Number(b.score) - Number(a.score)); |
| | | }, |
| | | renderCharts() { |
| | | const c1 = echarts.getInstanceByDom(this.$refs.chart1); |
| | |
| | | }); |
| | | |
| | | // 2. 分组柱状图 - 分类维度 |
| | | c2.setOption({ |
| | | // 分类较多(如十几个满意度维度)时底部名称会重叠,这里按数量动态适配: |
| | | // ≤6 个:正常水平标签; |
| | | // 7~10 个:x 轴标签倾斜 35° + 底部留白加大 + 柱子变细; |
| | | // >10 个:再启用横向缩放(初始显示约 8 个分类,可拖动/滚动查看其余)。 |
| | | const catCategories = this.categoryCompare.categories || []; |
| | | const catN = catCategories.length; |
| | | const catRotate = catN > 6 ? 35 : 0; |
| | | const catEnableZoom = catN > 10; |
| | | const catBarWidth = catN > 10 ? 18 : catN > 6 ? 20 : 22; |
| | | const catLegend = catEnableZoom |
| | | ? { data: this.categoryCompare.seriesNames, top: 4, textStyle: { color: '#666', fontSize: 12 } } |
| | | : { data: this.categoryCompare.seriesNames, bottom: 8, textStyle: { color: '#666', fontSize: 12 } }; |
| | | const catGrid = catEnableZoom |
| | | ? { top: 36, right: 30, bottom: 80, left: 85 } |
| | | : { top: 48, right: 30, bottom: catRotate ? 100 : 60, left: 85 }; |
| | | const catOption = { |
| | | color: ['#6DC8EC', '#5B8FF9'], |
| | | grid: barGrid, |
| | | grid: catGrid, |
| | | tooltip: { ...baseTooltip, trigger: 'axis', axisPointer: { type: 'shadow', shadowStyle: { color: 'rgba(0,0,0,0.03)' } } }, |
| | | legend: { data: this.categoryCompare.seriesNames, bottom: 8, textStyle: { color: '#666', fontSize: 12 } }, |
| | | xAxis: [{ ...baseXAxis, data: this.categoryCompare.categories, axisLabel: { ...baseXAxis.axisLabel, interval: 0 } }], |
| | | legend: catLegend, |
| | | xAxis: [{ ...baseXAxis, data: catCategories, axisLabel: { ...baseXAxis.axisLabel, interval: 0, rotate: catRotate } }], |
| | | yAxis: [{ ...baseYAxis, name: '得分', nameTextStyle: { color: '#999', fontSize: 11 } }], |
| | | series: [ |
| | | { name: this.categoryCompare.seriesNames[0], type: 'bar', barWidth: 22, barGap: '30%', |
| | | { name: this.categoryCompare.seriesNames[0], type: 'bar', barWidth: catBarWidth, barGap: '30%', |
| | | itemStyle: { borderRadius: [6, 6, 0, 0], color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#6DC8EC' }, { offset: 1, color: '#48B0D5' }]) }, |
| | | data: this.categoryCompare.series1 }, |
| | | { name: this.categoryCompare.seriesNames[1], type: 'bar', barWidth: 22, |
| | | { name: this.categoryCompare.seriesNames[1], type: 'bar', barWidth: catBarWidth, |
| | | itemStyle: { borderRadius: [6, 6, 0, 0], color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#5B8FF9' }, { offset: 1, color: '#3D6FDB' }]) }, |
| | | data: this.categoryCompare.series2 }, |
| | | ], |
| | | }); |
| | | }; |
| | | if (catEnableZoom) { |
| | | // 初始只展示前 8 个分类宽度,避免柱子挤成一团,用户可横向滚动/拖动查看其余 |
| | | const zoomEnd = catN > 0 ? Math.min(100, Math.round((8 / catN) * 100)) : 100; |
| | | catOption.dataZoom = [ |
| | | { type: 'inside', start: 0, end: zoomEnd }, |
| | | { type: 'slider', height: 16, bottom: 4, start: 0, end: zoomEnd }, |
| | | ]; |
| | | } |
| | | c2.setOption(catOption); |
| | | |
| | | // 4. 院区维度 - 分组柱状图 |
| | | c4.setOption({ |
| | |
| | | .filter-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; .filter-label { font-size: 13px; color: #606266; } .filter-date { width: 260px; } } |
| | | .chart-card { margin-bottom: 0; } |
| | | .chart-box { width: 100%; height: 360px; } |
| | | .chart-wrap { position: relative; width: 100%; } |
| | | .chart-empty { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: center; background: #fff; color: #909399; font-size: 14px; } |
| | | .card-header { display: flex; justify-content: space-between; align-items: center; span { font-size: 15px; font-weight: 600; color: #303133; } } |
| | | .header-right { display: flex; align-items: center; gap: 10px; .sel { width: 140px; } .sel-lg { width: 240px; } } |
| | | .table-footer { text-align: right; padding: 10px 0 0; font-size: 13px; color: #606266; } |