| | |
| | | <template> |
| | | <div class="charts-panel" ref="pdfTarget"> |
| | | <!-- PDF导出按钮 --> |
| | | <!-- 顶部筛选:时间区间 + 区间类型 + PDF导出 --> |
| | | <div class="export-bar"> |
| | | <div class="filter-bar"> |
| | | <span class="filter-label">时间范围:</span> |
| | | <el-date-picker |
| | | v-model="queryParams.dateRange" |
| | | type="daterange" |
| | | range-separator="至" |
| | | start-placeholder="开始日期" |
| | | end-placeholder="结束日期" |
| | | value-format="yyyy-MM-dd" |
| | | size="small" |
| | | class="filter-date" |
| | | /> |
| | | <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="month">按月</el-radio-button> |
| | | <el-radio-button label="year">按年</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> |
| | | </div> |
| | | <el-button type="danger" size="small" icon="el-icon-download" @click="exportPDF">导出PDF</el-button> |
| | | </div> |
| | | |
| | |
| | | <el-card shadow="never" class="chart-card"> |
| | | <div slot="header" class="card-header"> |
| | | <span>时间维度 · 门诊患者满意度趋势</span> |
| | | <el-tag type="success" size="small">环比 +0.97</el-tag> |
| | | <el-tag type="success" size="small">环比 {{ trendMoM }}</el-tag> |
| | | </div> |
| | | <div ref="chart1" class="chart-box"></div> |
| | | </el-card> |
| | |
| | | <el-col :span="12"> |
| | | <el-card shadow="never" class="chart-card"> |
| | | <div slot="header" class="card-header"> |
| | | <span>分类维度 · 各指标年度对比</span> |
| | | <span>分类维度 · 各指标{{ intervalLabel }}对比</span> |
| | | </div> |
| | | <div ref="chart2" class="chart-box"></div> |
| | | </el-card> |
| | |
| | | </el-row> |
| | | |
| | | <el-row :gutter="20" style="margin-top: 20px"> |
| | | <!-- 3. 单题选项 - 数据表格 --> |
| | | <!-- 3. 单题选项 - 数据表格(可选择题目、科室/病区) --> |
| | | <el-col :span="12"> |
| | | <el-card shadow="never" class="chart-card"> |
| | | <div slot="header" class="card-header"> |
| | | <span>单题 · 第22题:抱怨和不满能否及时得到回应?</span> |
| | | <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> |
| | | <el-select v-model="singleDeptWards" multiple collapse-tags size="small" class="sel" clearable filterable placeholder="全部科室/病区" @change="refreshAll"> |
| | | <el-option-group label="科室"> |
| | | <el-option v-for="d in deptOptions" :key="'dept:' + d" :label="d" :value="'dept:' + d" /> |
| | | </el-option-group> |
| | | <el-option-group label="病区"> |
| | | <el-option v-for="w in wardOptions" :key="'ward:' + w" :label="w" :value="'ward:' + w" /> |
| | | </el-option-group> |
| | | </el-select> |
| | | </div> |
| | | </div> |
| | | <el-table :data="singleQuestionData" border size="small"> |
| | | <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>26,233</b> 综合满意度:<b>83.20%</b></div> |
| | | <div class="table-footer">本题有效填写人次:<b>{{ singleQuestionMeta.total }}</b> 综合满意度:<b>{{ singleQuestionMeta.score }}</b></div> |
| | | </el-card> |
| | | </el-col> |
| | | |
| | |
| | | <el-col :span="12"> |
| | | <el-card shadow="never" class="chart-card"> |
| | | <div slot="header" class="card-header"> |
| | | <span>院区维度 · 各院区年度对比</span> |
| | | <span>院区维度 · 各院区{{ intervalLabel }}对比</span> |
| | | </div> |
| | | <div ref="chart4" class="chart-box"></div> |
| | | </el-card> |
| | |
| | | </el-row> |
| | | |
| | | <el-row :gutter="20" style="margin-top: 20px"> |
| | | <!-- 5. 科室维度 - 排名表格 --> |
| | | <!-- 5. 科室/病区维度 - 排名表格 --> |
| | | <el-col :span="12"> |
| | | <el-card shadow="never" class="chart-card"> |
| | | <div slot="header" class="card-header"> |
| | | <span>科室维度 · 门诊患者满意度得分排名</span> |
| | | <span>{{ rankDimLabel }}维度 · 门诊患者满意度得分排名</span> |
| | | <div class="header-right"> |
| | | <el-radio-group v-model="rankDimension" size="mini" @change="onRankDimensionChange"> |
| | | <el-radio-button label="dept">按科室</el-radio-button> |
| | | <el-radio-button label="ward">按病区</el-radio-button> |
| | | </el-radio-group> |
| | | <el-select v-model="rankDeptWards" multiple collapse-tags size="small" class="sel" clearable filterable :placeholder="'全部' + rankDimLabel" @change="refreshAll"> |
| | | <el-option v-for="name in rankOptions" :key="name" :label="name" :value="name" /> |
| | | </el-select> |
| | | </div> |
| | | <el-table :data="deptRankData" border size="small"> |
| | | </div> |
| | | <el-table :data="rankRows" border size="small" max-height="400"> |
| | | <el-table-column type="index" label="排名" width="60" align="center" /> |
| | | <el-table-column prop="dept" label="科室" /> |
| | | <el-table-column prop="name" :label="rankDimLabel" /> |
| | | <el-table-column prop="score" label="综合得分" width="120" align="center" /> |
| | | </el-table> |
| | | </el-card> |
| | | </el-col> |
| | | |
| | | <!-- 6. 单题科室 - 交叉分析表格 --> |
| | | <!-- 6. 单题×科室/病区 - 交叉排名表格 --> |
| | | <el-col :span="12"> |
| | | <el-card shadow="never" class="chart-card"> |
| | | <div slot="header" class="card-header"> |
| | | <span>单题×科室 · 第22题各科室得分</span> |
| | | <span>单题×{{ crossDimLabel }} · 得分排名</span> |
| | | <div class="header-right"> |
| | | <el-radio-group v-model="crossDimension" size="mini" @change="onCrossDimensionChange"> |
| | | <el-radio-button label="dept">按科室</el-radio-button> |
| | | <el-radio-button label="ward">按病区</el-radio-button> |
| | | </el-radio-group> |
| | | <el-select v-model="crossDeptWards" multiple collapse-tags size="small" class="sel" clearable filterable :placeholder="'全部' + crossDimLabel" @change="refreshAll"> |
| | | <el-option v-for="name in crossOptions" :key="name" :label="name" :value="name" /> |
| | | </el-select> |
| | | </div> |
| | | <el-table :data="crossDeptData" border size="small" max-height="400"> |
| | | </div> |
| | | <el-table :data="crossRows" border size="small" max-height="400"> |
| | | <el-table-column type="index" label="排名" width="60" align="center" /> |
| | | <el-table-column prop="dept" label="科室" /> |
| | | <el-table-column prop="name" :label="crossDimLabel" /> |
| | | <el-table-column prop="score" label="综合得分" width="120" align="center" /> |
| | | </el-table> |
| | | </el-card> |
| | |
| | | <script> |
| | | import * as echarts from 'echarts'; |
| | | |
| | | // 单题选项 |
| | | 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", |
| | | name: 'ChartsPanel', |
| | | data() { |
| | | return { |
| | | singleQuestionData: [ |
| | | { option: "非常满意", count: 16513, rate: "62.95%" }, |
| | | { option: "满意", count: 7088, rate: "27.02%" }, |
| | | { option: "一般", count: 1760, rate: "6.71%" }, |
| | | { option: "不满意", count: 872, rate: "3.32%" }, |
| | | ], |
| | | deptRankData: [ |
| | | { dept: "保健科", score: "81.54" }, { dept: "介入治疗科", score: "80.72" }, |
| | | { dept: "专科护士门诊", score: "79.27" }, { dept: "康复医学科", score: "78.65" }, |
| | | { dept: "皮肤科", score: "78.12" }, { dept: "内分泌科", score: "77.89" }, |
| | | { dept: "消化内科", score: "77.34" }, { dept: "肾内科", score: "76.50" }, |
| | | { dept: "呼吸内科", score: "75.88" }, { dept: "心血管内科", score: "75.21" }, |
| | | ], |
| | | crossDeptData: [ |
| | | { dept: "保健科", score: "81.24" }, { dept: "介入治疗科", score: "81.22" }, |
| | | { dept: "专科护士门诊", score: "80.15" }, { dept: "康复医学科", score: "79.88" }, |
| | | { dept: "皮肤科", score: "79.01" }, { dept: "内分泌科", score: "78.45" }, |
| | | { dept: "消化内科", score: "77.92" }, { dept: "肾内科", score: "77.13" }, |
| | | { dept: "呼吸内科", score: "76.80" }, { dept: "心血管内科", score: "76.05" }, |
| | | { dept: "神经内科", score: "75.62" }, { dept: "血液科", score: "75.01" }, |
| | | { dept: "风湿免疫科", score: "74.38" }, { dept: "肿瘤内科", score: "73.85" }, |
| | | { dept: "感染科", score: "73.12" }, { dept: "老年科", score: "72.56" }, |
| | | { dept: "普外科", score: "71.90" }, { dept: "骨科", score: "71.23" }, |
| | | { dept: "泌尿外科", score: "70.55" }, { dept: "妇产科", score: "69.88" }, |
| | | ], |
| | | queryParams: { |
| | | dateRange: [], |
| | | interval: 'month', |
| | | }, |
| | | deptOptions: DEPTS, |
| | | wardOptions: WARDS, |
| | | questionOptions: QUESTIONS, |
| | | |
| | | // 单题筛选 |
| | | singleQuestionId: 22, |
| | | singleDeptWards: [], |
| | | |
| | | // 排名维度筛选 |
| | | rankDimension: 'dept', |
| | | rankDeptWards: [], |
| | | crossDimension: 'dept', |
| | | crossDeptWards: [], |
| | | |
| | | // 图表数据 |
| | | trend: { buckets: [], data: [], seriesName: '门诊患者满意度' }, |
| | | categoryCompare: { categories: [], seriesNames: [], series1: [], series2: [] }, |
| | | hospitalCompare: { hospitals: [], seriesNames: [], series1: [], series2: [] }, |
| | | |
| | | // 表格数据 |
| | | singleQuestionData: [], |
| | | singleQuestionMeta: { total: '0', score: '0.00%' }, |
| | | deptRankData: [], |
| | | wardRankData: [], |
| | | crossDeptData: [], |
| | | crossWardData: [], |
| | | }; |
| | | }, |
| | | mounted() { this.$nextTick(() => { this.initCharts(); }); }, |
| | | computed: { |
| | | intervalLabel() { |
| | | return { day: '按天', month: '按月', year: '按年' }[this.queryParams.interval] || '按月'; |
| | | }, |
| | | trendMoM() { |
| | | const d = this.trend.data; |
| | | if (d.length < 2) return '0.00'; |
| | | const diff = d[d.length - 1] - d[d.length - 2]; |
| | | return (diff >= 0 ? '+' : '') + diff.toFixed(2); |
| | | }, |
| | | currentSingleQuestion() { |
| | | return this.questionOptions.find((q) => q.id === this.singleQuestionId) || {}; |
| | | }, |
| | | rankDimLabel() { |
| | | return this.rankDimension === 'dept' ? '科室' : '病区'; |
| | | }, |
| | | crossDimLabel() { |
| | | return this.crossDimension === 'dept' ? '科室' : '病区'; |
| | | }, |
| | | rankOptions() { |
| | | return this.rankDimension === 'dept' ? this.deptOptions : this.wardOptions; |
| | | }, |
| | | crossOptions() { |
| | | 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)); |
| | | }, |
| | | 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)); |
| | | }, |
| | | }, |
| | | mounted() { |
| | | this.$nextTick(() => { |
| | | if (!this.queryParams.dateRange || !this.queryParams.dateRange.length) { |
| | | this.queryParams.dateRange = this.defaultRange(); |
| | | } |
| | | this.initCharts(); |
| | | this.refreshAll(); |
| | | }); |
| | | }, |
| | | methods: { |
| | | initCharts() { |
| | | const quarters = ['第一季度', '第二季度', '第三季度', '第四季度']; |
| | | echarts.init(this.$refs.chart1); |
| | | echarts.init(this.$refs.chart2); |
| | | echarts.init(this.$refs.chart4); |
| | | window.addEventListener('resize', this.handleResize); |
| | | }, |
| | | handleResize() { |
| | | ['chart1', 'chart2', 'chart4'].forEach((ref) => { |
| | | const c = echarts.getInstanceByDom(this.$refs[ref]); |
| | | if (c) c.resize(); |
| | | }); |
| | | }, |
| | | handleReset() { |
| | | this.queryParams.dateRange = this.defaultRange(); |
| | | this.queryParams.interval = 'month'; |
| | | this.refreshAll(); |
| | | }, |
| | | onRankDimensionChange() { |
| | | this.rankDeptWards = []; |
| | | this.refreshAll(); |
| | | }, |
| | | onCrossDimensionChange() { |
| | | this.crossDeptWards = []; |
| | | this.refreshAll(); |
| | | }, |
| | | defaultRange() { |
| | | const end = new Date(); |
| | | const start = new Date(); |
| | | start.setDate(start.getDate() - 29); |
| | | const f = (d) => { |
| | | const y = d.getFullYear(); |
| | | const m = String(d.getMonth() + 1).padStart(2, '0'); |
| | | const day = String(d.getDate()).padStart(2, '0'); |
| | | return `${y}-${m}-${day}`; |
| | | }; |
| | | return [f(start), f(end)]; |
| | | }, |
| | | // 区间类型 => 时间桶 + 柱状图对比序列名 |
| | | intervalConfig() { |
| | | const buckets = this.genBuckets(this.queryParams.interval); |
| | | if (this.queryParams.interval === 'day') return { buckets, seriesNames: ['昨日', '今日'] }; |
| | | if (this.queryParams.interval === 'year') return { buckets, seriesNames: ['去年', '今年'] }; |
| | | return { buckets, seriesNames: ['上月', '本月'] }; |
| | | }, |
| | | genBuckets(type) { |
| | | const end = this.queryParams.dateRange && this.queryParams.dateRange[1] |
| | | ? new Date(this.queryParams.dateRange[1]) |
| | | : new Date(); |
| | | const buckets = []; |
| | | if (type === 'day') { |
| | | for (let i = 14; i >= 0; i--) { |
| | | const d = new Date(end); |
| | | d.setDate(end.getDate() - i); |
| | | 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 { |
| | | for (let i = 5; i >= 0; i--) { |
| | | const d = new Date(end.getFullYear(), end.getMonth() - i, 1); |
| | | buckets.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`); |
| | | } |
| | | } |
| | | 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 })))); |
| | | |
| | | this.renderCharts(); |
| | | }, |
| | | genChartData(rnd) { |
| | | const jitter = (lo, hi) => lo + rnd() * (hi - lo); |
| | | const cfg = this.intervalConfig(); |
| | | const buckets = cfg.buckets; |
| | | |
| | | this.trend = { |
| | | buckets, |
| | | seriesName: '门诊患者满意度', |
| | | data: buckets.map(() => Math.round(jitter(86, 90) * 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 = ['院区一', '院区二', '院区三']; |
| | | const baseGrid = { top: 20, right: 30, bottom: 30, left: 50 }; |
| | | 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), |
| | | }; |
| | | }, |
| | | 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) + '%', |
| | | })); |
| | | 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); |
| | | const c2 = echarts.getInstanceByDom(this.$refs.chart2); |
| | | const c4 = echarts.getInstanceByDom(this.$refs.chart4); |
| | | if (!c1 || !c2 || !c4) return; |
| | | |
| | | const lineGrid = { top: 32, right: 30, bottom: 32, left: 85 }; |
| | | const barGrid = { top: 48, right: 30, bottom: 60, left: 85 }; |
| | | const baseTooltip = { backgroundColor: 'rgba(255,255,255,0.95)', borderColor: '#e8e8e8', textStyle: { color: '#333', fontSize: 13 }, extraCssText: 'box-shadow: 0 2px 12px rgba(0,0,0,0.1); border-radius: 6px; padding: 10px 14px;' }; |
| | | const baseXAxis = { axisLine: { lineStyle: { color: '#e0e0e0' } }, axisTick: { show: false }, axisLabel: { color: '#666', fontSize: 12 } }; |
| | | const baseYAxis = { splitLine: { lineStyle: { color: '#f0f0f0', type: 'dashed' } }, axisLine: { show: false }, axisTick: { show: false }, axisLabel: { color: '#999', fontSize: 11 } }; |
| | | const areaGrad = new echarts.graphic.LinearGradient(0, 0, 0, 1, [ |
| | | { offset: 0, color: 'rgba(91,143,249,0.25)' }, |
| | | { offset: 1, color: 'rgba(91,143,249,0.02)' }, |
| | | ]); |
| | | |
| | | // 1. 折线图 - 时间维度 |
| | | const c1 = echarts.init(this.$refs.chart1); |
| | | c1.setOption({ |
| | | color: ['#5B8FF9'], |
| | | grid: { ...baseGrid, top: 25 }, |
| | | grid: lineGrid, |
| | | tooltip: { ...baseTooltip, trigger: 'axis' }, |
| | | xAxis: [{ ...baseXAxis, data: quarters, boundaryGap: false }], |
| | | yAxis: [{ ...baseYAxis, min: 86, max: 91, name: '满意度得分', nameTextStyle: { color: '#999', fontSize: 11 } }], |
| | | xAxis: [{ ...baseXAxis, data: this.trend.buckets, boundaryGap: false }], |
| | | yAxis: [{ ...baseYAxis, name: '满意度得分', nameTextStyle: { color: '#999', fontSize: 11 } }], |
| | | series: [{ |
| | | name: '门诊患者满意度', type: 'line', smooth: true, symbol: 'circle', symbolSize: 8, |
| | | data: [88.20, 87.32, 88.50, 89.17], |
| | | name: this.trend.seriesName, type: 'line', smooth: true, symbol: 'circle', symbolSize: 8, |
| | | data: this.trend.data, |
| | | lineStyle: { width: 3, shadowBlur: 8, shadowColor: 'rgba(91,143,249,0.3)' }, |
| | | itemStyle: { color: '#5B8FF9' }, |
| | | areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ |
| | | { offset: 0, color: 'rgba(91,143,249,0.25)' }, { offset: 1, color: 'rgba(91,143,249,0.02)' } |
| | | ])}, |
| | | areaStyle: { color: areaGrad }, |
| | | }], |
| | | }); |
| | | |
| | | // 2. 分组柱状图 - 分类维度 |
| | | const c2 = echarts.init(this.$refs.chart2); |
| | | c2.setOption({ |
| | | color: ['#6DC8EC', '#5B8FF9'], |
| | | grid: baseGrid, |
| | | grid: barGrid, |
| | | tooltip: { ...baseTooltip, trigger: 'axis', axisPointer: { type: 'shadow', shadowStyle: { color: 'rgba(0,0,0,0.03)' } } }, |
| | | legend: { data: ['2023年', '2024年'], bottom: 0, textStyle: { color: '#666', fontSize: 12 } }, |
| | | xAxis: [{ ...baseXAxis, data: categories, axisLabel: { ...baseXAxis.axisLabel, interval: 0 } }], |
| | | legend: { data: this.categoryCompare.seriesNames, bottom: 8, textStyle: { color: '#666', fontSize: 12 } }, |
| | | xAxis: [{ ...baseXAxis, data: this.categoryCompare.categories, axisLabel: { ...baseXAxis.axisLabel, interval: 0 } }], |
| | | yAxis: [{ ...baseYAxis, name: '得分', nameTextStyle: { color: '#999', fontSize: 11 } }], |
| | | series: [ |
| | | { name: '2023年', type: 'bar', barWidth: 22, barGap: '30%', |
| | | { name: this.categoryCompare.seriesNames[0], type: 'bar', barWidth: 22, 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: [86.34, 85.12, 83.45, 86.22, 88.55, 87.33] }, |
| | | { name: '2024年', type: 'bar', barWidth: 22, |
| | | data: this.categoryCompare.series1 }, |
| | | { name: this.categoryCompare.seriesNames[1], type: 'bar', barWidth: 22, |
| | | itemStyle: { borderRadius: [6, 6, 0, 0], color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#5B8FF9' }, { offset: 1, color: '#3D6FDB' }]) }, |
| | | data: [88.06, 84.57, 82.88, 87.77, 88.12, 87.00] }, |
| | | data: this.categoryCompare.series2 }, |
| | | ], |
| | | }); |
| | | |
| | | // 4. 院区维度 - 分组柱状图 |
| | | const c4 = echarts.init(this.$refs.chart4); |
| | | c4.setOption({ |
| | | color: ['#6DC8EC', '#5B8FF9'], |
| | | grid: baseGrid, |
| | | grid: barGrid, |
| | | tooltip: { ...baseTooltip, trigger: 'axis', axisPointer: { type: 'shadow' } }, |
| | | legend: { data: ['2023年', '2024年'], bottom: 0, textStyle: { color: '#666', fontSize: 12 } }, |
| | | xAxis: [{ ...baseXAxis, data: hospitals }], |
| | | legend: { data: this.hospitalCompare.seriesNames, bottom: 8, textStyle: { color: '#666', fontSize: 12 } }, |
| | | xAxis: [{ ...baseXAxis, data: this.hospitalCompare.hospitals }], |
| | | yAxis: [{ ...baseYAxis, name: '满意度得分', nameTextStyle: { color: '#999', fontSize: 11 } }], |
| | | series: [ |
| | | { name: '2023年', type: 'bar', barWidth: 24, barGap: '30%', |
| | | { name: this.hospitalCompare.seriesNames[0], type: 'bar', barWidth: 24, 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: [86.20, 87.69, 85.55], |
| | | label: { show: true, position: 'top', color: '#666', fontSize: 12, fontWeight: 500, formatter: p => p.value.toFixed(2) } }, |
| | | { name: '2024年', type: 'bar', barWidth: 24, |
| | | data: this.hospitalCompare.series1, |
| | | label: { show: true, position: 'top', color: '#666', fontSize: 12, fontWeight: 500, formatter: (p) => p.value.toFixed(2) } }, |
| | | { name: this.hospitalCompare.seriesNames[1], type: 'bar', barWidth: 24, |
| | | itemStyle: { borderRadius: [6, 6, 0, 0], color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#5B8FF9' }, { offset: 1, color: '#3D6FDB' }]) }, |
| | | data: [88.15, 88.14, 85.88], |
| | | label: { show: true, position: 'top', color: '#666', fontSize: 12, fontWeight: 500, formatter: p => p.value.toFixed(2) } }, |
| | | data: this.hospitalCompare.series2, |
| | | label: { show: true, position: 'top', color: '#666', fontSize: 12, fontWeight: 500, formatter: (p) => p.value.toFixed(2) } }, |
| | | ], |
| | | }); |
| | | |
| | | // 响应式 |
| | | window.addEventListener('resize', () => { c1.resize(); c2.resize(); c4.resize(); }); |
| | | }, |
| | | |
| | | async exportPDF() { |
| | | // 先将 ECharts 实例转为图片 dataURL |
| | | const chartRefs = [ |
| | | { ref: this.$refs.chart1, id: 'chart1' }, |
| | | { ref: this.$refs.chart2, id: 'chart2' }, |
| | |
| | | } |
| | | } |
| | | |
| | | // 克隆内容并替换图表为图片 |
| | | const printContent = this.$refs.pdfTarget.cloneNode(true); |
| | | for (const { ref, id } of chartRefs) { |
| | | if (chartImages[id] && ref) { |
| | | const boxes = printContent.querySelectorAll('.chart-box'); |
| | | for (const box of boxes) { |
| | | if (box.innerHTML.includes(id) || boxes.length === chartRefs.length) { |
| | | box.innerHTML = `<img src="${chartImages[id]}" style="width:100%;height:auto;" />`; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | // 更可靠的方式:按位置匹配 |
| | | const chartBoxes = printContent.querySelectorAll('.chart-box'); |
| | | const imgKeys = Object.keys(chartImages); |
| | | chartBoxes.forEach((box, i) => { |
| | |
| | | th, td { border: 1px solid #d9d9d9; padding: 8px 12px; text-align: center; font-size: 12px; } |
| | | th { background: #f0f2f5; font-weight: 600; } |
| | | .table-footer { text-align: right; padding: 8px 0; font-size: 13px; color: #606266; } |
| | | .export-bar, .el-tag { display: none; } |
| | | .export-bar, .filter-bar, .el-tag, .el-select, .el-radio-group { display: none; } |
| | | @media print { @page { size: A4; margin: 10mm; } } |
| | | </style></head><body> |
| | | ${printContent.innerHTML} |
| | |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | .export-bar { text-align: right; margin-bottom: 16px; } |
| | | .export-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } |
| | | .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; } |
| | | .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; } |
| | | </style> |