<template>
|
<div class="charts-panel" ref="pdfTarget">
|
<!-- 顶部筛选:时间区间 + 区间类型 + 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="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>
|
</div>
|
<el-button type="danger" size="small" icon="el-icon-download" @click="exportPDF">导出PDF</el-button>
|
</div>
|
|
<el-row :gutter="20">
|
<!-- 1. 时间维度 - 折线图 -->
|
<el-col :span="12">
|
<el-card shadow="never" class="chart-card">
|
<div slot="header" class="card-header">
|
<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>
|
|
<!-- 2. 分类维度 - 分组柱状图 -->
|
<el-col :span="12">
|
<el-card shadow="never" class="chart-card">
|
<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>
|
|
<el-row :gutter="20" style="margin-top: 20px">
|
<!-- 3. 单题选项 - 数据表格(可选择题目、科室/病区) -->
|
<el-col :span="12">
|
<el-card shadow="never" class="chart-card">
|
<div slot="header" class="card-header">
|
<span>单题 · 选项统计</span>
|
<div class="header-right">
|
<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="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" />
|
</template>
|
<template v-else>
|
<el-option v-for="w in wardOptions" :key="'ward:' + w" :label="w" :value="'ward:' + w" />
|
</template>
|
</el-select>
|
</div>
|
</div>
|
<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>
|
</el-card>
|
</el-col>
|
|
<!-- 4. 院区维度 - 分组柱状图 -->
|
<el-col :span="12">
|
<el-card shadow="never" class="chart-card">
|
<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-row :gutter="20" style="margin-top: 20px">
|
<!-- 5. 科室/病区维度 - 排名表格 -->
|
<el-col :span="12">
|
<el-card shadow="never" class="chart-card">
|
<div slot="header" class="card-header">
|
<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>
|
</div>
|
<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>
|
|
<!-- 6. 单题×科室/病区 - 交叉排名表格 -->
|
<el-col :span="12">
|
<el-card shadow="never" class="chart-card">
|
<div slot="header" class="card-header">
|
<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>
|
</div>
|
<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>
|
</el-row>
|
</div>
|
</template>
|
|
<script>
|
import * as echarts from 'echarts';
|
import { mydTrend, sltdMydTotalByDimension, sltdMydTotalByScore ,mzMydScoreRank} from "@/api/AiCentre/satisfaction";
|
import { getissuelist } from "@/api/AiCentre/questionnaire";
|
|
const DEPTS = ['保健科', '介入治疗科', '专科护士门诊', '康复医学科', '皮肤科', '内分泌科', '消化内科', '肾内科', '呼吸内科', '心血管内科', '神经内科', '血液科', '风湿免疫科', '肿瘤内科', '感染科', '老年科', '普外科', '骨科', '泌尿外科', '妇产科'];
|
const WARDS = ['一病区', '二病区', '三病区', '四病区', '五病区', '六病区', '七病区', '八病区'];
|
|
export default {
|
name: 'ChartsPanel',
|
dicts: ['dimensionality_type'],
|
data() {
|
return {
|
queryParams: {
|
dateRange: [],
|
interval: 'month',
|
dimension: 'ward', // 统计维度:ward=按病区统计 dept=按科室统计
|
},
|
deptOptions: DEPTS,
|
wardOptions: WARDS,
|
questionOptions: [],
|
|
// 单题筛选
|
singleQuestionId: null,
|
singleQuestionContent: '',
|
singleDeptWards: [],
|
|
// 排名维度筛选
|
rankDimension: 'dept',
|
rankDeptWards: [],
|
crossDimension: 'dept',
|
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' },
|
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: '按天', week: '按周', 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() {
|
return this.rankData;
|
},
|
crossRows() {
|
return this.crossData;
|
},
|
},
|
mounted() {
|
this.$nextTick(() => {
|
if (!this.queryParams.dateRange || !this.queryParams.dateRange.length) {
|
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: {
|
initCharts() {
|
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();
|
});
|
},
|
// 维度编码 → 字典名称(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() {
|
this.rankDeptWards = [];
|
this.refreshAll();
|
},
|
onCrossDimensionChange() {
|
this.crossDeptWards = [];
|
this.refreshAll();
|
},
|
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)];
|
},
|
// 区间类型 => 时间桶 + 柱状图对比序列名
|
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: ['上月', '本月'] };
|
},
|
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 === '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 {
|
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;
|
},
|
// 刷新全部数据(趋势、分类维度、院区维度、单题、排名、交叉均为真实接口)
|
async refreshAll() {
|
// 六个数据块并行拉取,各区块同时显示各自的加载状态
|
await Promise.all([
|
this.fetchTrend(),
|
this.fetchCategoryCompare(),
|
this.fetchHospitalCompare(),
|
this.fetchSingleQuestion(),
|
this.fetchRank(),
|
this.fetchCross(),
|
]);
|
|
this.renderCharts();
|
},
|
// 满意度趋势(真实接口):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 = {
|
seriesName: '门诊患者满意度',
|
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;
|
}),
|
};
|
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)),
|
};
|
},
|
// 科室/病区维度 · 门诊患者满意度得分排名(真实接口 /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,
|
}));
|
},
|
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. 折线图 - 时间维度
|
c1.setOption({
|
color: ['#5B8FF9'],
|
grid: lineGrid,
|
tooltip: { ...baseTooltip, trigger: 'axis' },
|
xAxis: [{ ...baseXAxis, data: this.trend.buckets, boundaryGap: false }],
|
yAxis: [{ ...baseYAxis, name: '满意度得分', nameTextStyle: { color: '#999', fontSize: 11 } }],
|
series: [{
|
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: areaGrad },
|
}],
|
});
|
|
// 2. 分组柱状图 - 分类维度
|
// 分类较多(如十几个满意度维度)时底部名称会重叠,这里按数量动态适配:
|
// ≤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: catGrid,
|
tooltip: { ...baseTooltip, trigger: 'axis', axisPointer: { type: 'shadow', shadowStyle: { color: 'rgba(0,0,0,0.03)' } } },
|
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: 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: 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({
|
color: ['#6DC8EC', '#5B8FF9'],
|
grid: barGrid,
|
tooltip: { ...baseTooltip, trigger: 'axis', axisPointer: { type: 'shadow' } },
|
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: 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: 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: this.hospitalCompare.series2,
|
label: { show: true, position: 'top', color: '#666', fontSize: 12, fontWeight: 500, formatter: (p) => p.value.toFixed(2) } },
|
],
|
});
|
},
|
|
async exportPDF() {
|
const chartRefs = [
|
{ ref: this.$refs.chart1, id: 'chart1' },
|
{ ref: this.$refs.chart2, id: 'chart2' },
|
{ ref: this.$refs.chart4, id: 'chart4' },
|
];
|
const chartImages = {};
|
for (const { ref, id } of chartRefs) {
|
if (ref) {
|
const instance = echarts.getInstanceByDom(ref);
|
if (instance) {
|
chartImages[id] = instance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' });
|
}
|
}
|
}
|
|
const printContent = this.$refs.pdfTarget.cloneNode(true);
|
const chartBoxes = printContent.querySelectorAll('.chart-box');
|
const imgKeys = Object.keys(chartImages);
|
chartBoxes.forEach((box, i) => {
|
if (imgKeys[i] && chartImages[imgKeys[i]]) {
|
box.innerHTML = `<img src="${chartImages[imgKeys[i]]}" style="width:100%;height:auto;" />`;
|
}
|
});
|
|
const win = window.open('', '_blank', 'width=900,height=800');
|
if (!win) { this.$message.warning('请允许弹出窗口以导出PDF'); return; }
|
win.document.write(`
|
<html><head><meta charset="utf-8"><title>各项统计表</title>
|
<style>
|
body { font-family: 'Microsoft YaHei',sans-serif; padding: 20px; }
|
.chart-card { break-inside: avoid; margin-bottom: 20px; border: 1px solid #ebeef5; border-radius: 6px; }
|
.card-header { padding: 12px 16px; border-bottom: 1px solid #ebeef5; font-size: 15px; font-weight: 600; color: #303133; background: #f5f7fa; }
|
.chart-box img { max-width: 100%; display: block; }
|
table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
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, .filter-bar, .el-tag, .el-select, .el-radio-group { display: none; }
|
@media print { @page { size: A4; margin: 10mm; } }
|
</style></head><body>
|
${printContent.innerHTML}
|
<script>window.onload=function(){window.print();}<\/script>
|
</body></html>
|
`);
|
win.document.close();
|
},
|
},
|
};
|
</script>
|
|
<style lang="scss" scoped>
|
.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; }
|
.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; }
|
</style>
|