<template>
|
<div class="data-overview">
|
<!-- 顶部筛选:时间范围 + 科室/病区,控制本页所有数据 -->
|
<el-card shadow="never" class="overview-card filter-card">
|
<el-form inline class="filter-form">
|
<el-form-item label="时间范围">
|
<el-date-picker
|
v-model="queryParams.dateRange"
|
type="daterange"
|
range-separator="至"
|
start-placeholder="开始日期"
|
end-placeholder="结束日期"
|
value-format="yyyy-MM-dd"
|
size="small"
|
/>
|
</el-form-item>
|
<el-form-item label="科室/病区">
|
<el-select
|
v-model="queryParams.deptWard"
|
clearable
|
filterable
|
placeholder="全部"
|
size="small"
|
class="filter-select"
|
>
|
<el-option-group label="科室">
|
<el-option
|
v-for="d in deptOptions"
|
:key="'dept:' + d"
|
:label="d"
|
:value="'dept:' + d"
|
/>
|
</el-option-group>
|
<el-option-group label="病区">
|
<el-option
|
v-for="w in wardOptions"
|
:key="'ward:' + w"
|
:label="w"
|
:value="'ward:' + w"
|
/>
|
</el-option-group>
|
</el-select>
|
</el-form-item>
|
<el-form-item>
|
<el-button type="primary" size="small" icon="el-icon-search" @click="fetchData">查询</el-button>
|
<el-button size="small" icon="el-icon-refresh" @click="handleReset">重置</el-button>
|
</el-form-item>
|
</el-form>
|
</el-card>
|
|
<!-- 表格一:各服务项目满意度统计(固定五项选项的题目) -->
|
<el-card shadow="never" class="overview-card">
|
<div slot="header" class="card-header">
|
<span class="card-title">各服务项目满意度统计</span>
|
<el-button type="warning" size="small" icon="el-icon-download" @click="exportTable('table1')">导出Excel</el-button>
|
</div>
|
<el-table id="table1" :data="serviceSatisfaction" border size="small" :header-cell-style="{ background: '#f5f7fa', color: '#303133' }">
|
<el-table-column prop="category" label="分类/题目" min-width="220" fixed="left" />
|
<el-table-column label="非常满意" align="center">
|
<el-table-column prop="veryGoodCount" label="数量" width="90" />
|
<el-table-column prop="veryGoodRate" label="比例" width="90" />
|
</el-table-column>
|
<el-table-column label="满意" align="center">
|
<el-table-column prop="goodCount" label="数量" width="90" />
|
<el-table-column prop="goodRate" label="比例" width="90" />
|
</el-table-column>
|
<el-table-column label="一般" align="center">
|
<el-table-column prop="normalCount" label="数量" width="90" />
|
<el-table-column prop="normalRate" label="比例" width="90" />
|
</el-table-column>
|
<el-table-column label="不满意" align="center">
|
<el-table-column prop="badCount" label="数量" width="90" />
|
<el-table-column prop="badRate" label="比例" width="90" />
|
</el-table-column>
|
<el-table-column label="不适用" align="center">
|
<el-table-column prop="naCount" label="数量" width="90" />
|
<el-table-column prop="naRate" label="比例" width="90" />
|
</el-table-column>
|
<el-table-column prop="score" label="综合得分" width="100" align="center" />
|
</el-table>
|
</el-card>
|
|
<!-- 表格二:单选题/多选题统计(其余题目) -->
|
<el-card shadow="never" class="overview-card">
|
<div slot="header" class="card-header">
|
<span class="card-title">单选题/多选题统计</span>
|
<el-button type="warning" size="small" icon="el-icon-download" @click="exportTable('table2')">导出Excel</el-button>
|
</div>
|
<div id="table2">
|
<div class="question-block" v-for="(q, qi) in questionStats" :key="qi">
|
<h4 class="q-title">{{ q.title }}</h4>
|
<el-table :data="q.options" border size="small" :header-cell-style="{ background: '#f5f7fa', color: '#303133' }">
|
<el-table-column prop="option" label="选项" min-width="180" />
|
<el-table-column prop="count" label="数量" width="100" align="center" />
|
<el-table-column prop="rate" label="比例" width="100" align="center" />
|
</el-table>
|
<div v-if="q.total" class="q-footer">本题有效填写人次:<b>{{ q.total }}</b></div>
|
</div>
|
</div>
|
</el-card>
|
|
<!-- 表格三:各科室/病区满意度评分对比 -->
|
<el-card shadow="never" class="overview-card">
|
<div slot="header" class="card-header">
|
<span class="card-title">各科室/病区满意度评分对比</span>
|
<div class="header-right">
|
<el-radio-group v-model="statDimension" size="small" @change="onStatDimensionChange">
|
<el-radio-button label="dept">按科室</el-radio-button>
|
<el-radio-button label="ward">按病区</el-radio-button>
|
</el-radio-group>
|
<el-select v-model="statDeptWard" size="small" class="stat-select" placeholder="沿用顶部条件">
|
<el-option label="沿用顶部条件" value="inherit" />
|
<template v-if="statDimension === 'dept'">
|
<el-option v-for="d in deptOptions" :key="'dept:' + d" :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>
|
<el-button type="warning" size="small" icon="el-icon-download" @click="exportTable('table3')">导出Excel</el-button>
|
</div>
|
</div>
|
<el-table id="table3" :data="table3Rows" border size="small" :header-cell-style="{ background: '#f5f7fa', color: '#303133' }">
|
<el-table-column prop="name" :label="statDimension === 'dept' ? '科室' : '病区'" fixed="left" align="center" />
|
<el-table-column label="第1题" align="center">
|
<el-table-column prop="q1VeryGood" label="非常满意" width="100" />
|
<el-table-column prop="q1Good" label="满意" width="90" />
|
<el-table-column prop="q1Normal" label="一般" width="90" />
|
<el-table-column prop="q1Bad" label="不满意" width="90" />
|
<el-table-column prop="q1Score" label="得分" width="100" />
|
</el-table-column>
|
<el-table-column label="第2题" align="center">
|
<el-table-column prop="q2VeryGood" label="非常满意" width="100" />
|
<el-table-column prop="q2Good" label="满意" width="90" />
|
<el-table-column prop="q2Normal" label="一般" width="90" />
|
<el-table-column prop="q2Bad" label="不满意" width="90" />
|
<el-table-column prop="q2Score" label="得分" width="100" />
|
</el-table-column>
|
<el-table-column prop="totalScore" label="综合得分" align="center" />
|
</el-table>
|
</el-card>
|
</div>
|
</template>
|
|
<script>
|
import XLSX from 'xlsx';
|
|
// 满意度题的固定选项(顺序即表格列顺序)
|
const FIXED_OPTIONS = ['非常满意', '满意', '一般', '不满意', '不适用'];
|
const DEPTS = ['内分泌科', '皮肤科', '肿瘤内科', '消化内科', '肾内科'];
|
const WARDS = ['一病区', '二病区', '三病区', '四病区', '五病区'];
|
|
// 满意度题(固定五项选项,counts 顺序对应 FIXED_OPTIONS)
|
const SATISFACTION_BASE = [
|
['诊疗过程中,医务人员是否注意保护您的隐私?', [3219, 1351, 142, 33, 0]],
|
['您对医院服务设施的总体印象如何?', [3110, 1369, 217, 49, 0]],
|
['医院的厕所是否清洁无异味?', [2973, 1504, 235, 33, 0]],
|
['医院内的路标和指示是否明确?', [3234, 1289, 196, 26, 0]],
|
['医院的空间布局是否便利?', [3037, 1460, 209, 39, 0]],
|
['看病期间,您的抱怨和不满能否及时得到回应?', [3019, 1317, 294, 115, 0]],
|
['医生是否用您听得懂的方式解释问题?', [3402, 967, 254, 122, 0]],
|
['医生对您是否尊重?', [3495, 897, 249, 104, 0]],
|
['医生是否仔细倾听您讲话?', [3422, 937, 251, 135, 0]],
|
['对挂号服务人员服务态度是否满意', [3458, 1205, 52, 30, 0]],
|
['您对预约挂号是否满意?', [3527, 1088, 72, 58, 0]],
|
['护士对您是否尊重?', [3420, 1184, 113, 28, 0]],
|
['护士是否用您听得懂的方式解释问题?', [3346, 1250, 122, 27, 0]],
|
['护士是否仔细倾听您讲话?', [3378, 1205, 134, 28, 0]],
|
];
|
|
// 其余题目(单选/多选,非固定满意度选项)
|
const OTHER_BASE = [
|
{
|
title: '您对我院工作人员的服务质量满意不满意?【分值单选题】',
|
type: 'single',
|
options: [['非常满意', 1084], ['满意', 1959], ['一般', 167], ['不满意', 153]],
|
},
|
{
|
title: '您对哪部分服务不满意?【多选题】',
|
type: 'multiple',
|
total: 408,
|
options: [['医生诊断和诊疗', 131], ['护理服务', 47], ['其他医务人员技术水平', 30], ['工人', 72]],
|
},
|
{
|
title: '医生诊断和治疗中哪些服务质量不满意?【多选题】',
|
type: 'multiple',
|
total: 131,
|
options: [['医生诊断错误', 23], ['对治疗效果不满意', 72], ['其他', 54]],
|
},
|
];
|
|
// 统一接口返回的题目(模拟):固定五项选项 => 表一,其余 => 表二
|
const RAW_QUESTIONS = [
|
...SATISFACTION_BASE.map(([title, counts]) => ({
|
title,
|
type: 'single',
|
options: FIXED_OPTIONS.map((opt, i) => ({ option: opt, count: counts[i] })),
|
})),
|
...OTHER_BASE.map((q) => ({
|
title: q.title,
|
type: q.type,
|
total: q.total,
|
options: q.options.map(([option, count]) => ({ option, count })),
|
})),
|
];
|
|
// 确定性伪随机(便于筛选条件固定时结果稳定)
|
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: 'DataOverview',
|
data() {
|
return {
|
// 顶部全局筛选
|
queryParams: {
|
dateRange: [],
|
deptWard: '',
|
},
|
deptOptions: DEPTS,
|
wardOptions: WARDS,
|
|
// 表格三:统计维度 + 独立科室/病区条件('inherit' = 沿用顶部)
|
statDimension: 'dept',
|
statDeptWard: 'inherit',
|
|
// 统一接口返回的数据(模拟)
|
questionList: [],
|
deptComparison: [],
|
wardComparison: [],
|
};
|
},
|
computed: {
|
// 固定五项选项的题目 => 表一
|
satisfactionQuestions() {
|
return this.questionList.filter((q) => this.isSatisfactionQuestion(q));
|
},
|
// 其余题目 => 表二
|
otherQuestions() {
|
return this.questionList.filter((q) => !this.isSatisfactionQuestion(q));
|
},
|
// 表一:满意度统计(含总计行)
|
serviceSatisfaction() {
|
const rows = this.satisfactionQuestions.map((q) => {
|
const m = {};
|
q.options.forEach((o) => (m[o.option] = o.count));
|
const veryGood = m['非常满意'] || 0;
|
const good = m['满意'] || 0;
|
const normal = m['一般'] || 0;
|
const bad = m['不满意'] || 0;
|
const na = m['不适用'] || 0;
|
const total = veryGood + good + normal + bad + na;
|
return {
|
category: q.title,
|
veryGoodCount: veryGood,
|
veryGoodRate: this.rateOf(veryGood, total),
|
goodCount: good,
|
goodRate: this.rateOf(good, total),
|
normalCount: normal,
|
normalRate: this.rateOf(normal, total),
|
badCount: bad,
|
badRate: this.rateOf(bad, total),
|
naCount: na,
|
naRate: this.rateOf(na, total),
|
score: this.scoreOf([veryGood, good, normal, bad, na]),
|
};
|
});
|
|
// 总计行
|
const sum = { veryGood: 0, good: 0, normal: 0, bad: 0, na: 0 };
|
rows.forEach((r) => {
|
sum.veryGood += r.veryGoodCount;
|
sum.good += r.goodCount;
|
sum.normal += r.normalCount;
|
sum.bad += r.badCount;
|
sum.na += r.naCount;
|
});
|
const total = sum.veryGood + sum.good + sum.normal + sum.bad + sum.na;
|
rows.push({
|
category: '总计',
|
veryGoodCount: sum.veryGood,
|
veryGoodRate: this.rateOf(sum.veryGood, total),
|
goodCount: sum.good,
|
goodRate: this.rateOf(sum.good, total),
|
normalCount: sum.normal,
|
normalRate: this.rateOf(sum.normal, total),
|
badCount: sum.bad,
|
badRate: this.rateOf(sum.bad, total),
|
naCount: sum.na,
|
naRate: this.rateOf(sum.na, total),
|
score: this.scoreOf([sum.veryGood, sum.good, sum.normal, sum.bad, sum.na]),
|
});
|
return rows;
|
},
|
// 表二:单选/多选统计
|
questionStats() {
|
return this.otherQuestions.map((q) => {
|
const total = this.questionTotal(q);
|
return {
|
title: q.title,
|
total,
|
options: q.options.map((o) => ({
|
option: o.option,
|
count: o.count,
|
rate: this.rateOf(o.count, total),
|
})),
|
};
|
});
|
},
|
// 表三:按维度 + 独立条件过滤后的行
|
table3Rows() {
|
const list = this.statDimension === 'dept' ? this.deptComparison : this.wardComparison;
|
const f = this.statFilter();
|
if (!f) return list;
|
const [type, name] = f.split(':');
|
if (type !== this.statDimension) return list;
|
return list.filter((r) => r.name === name);
|
},
|
},
|
mounted() {
|
if (!this.queryParams.dateRange || !this.queryParams.dateRange.length) {
|
this.queryParams.dateRange = this.defaultRange();
|
}
|
this.fetchData();
|
},
|
methods: {
|
// 表三独立条件:'inherit' 时沿用顶部条件
|
statFilter() {
|
const v = this.statDeptWard === 'inherit' ? this.queryParams.deptWard : this.statDeptWard;
|
return v || '';
|
},
|
onStatDimensionChange() {
|
// 切换维度后,若当前已选条件不属于新维度,则恢复“沿用顶部”
|
if (this.statDeptWard !== 'inherit' && !this.statDeptWard.startsWith(this.statDimension + ':')) {
|
this.statDeptWard = 'inherit';
|
}
|
},
|
handleReset() {
|
this.queryParams.dateRange = this.defaultRange();
|
this.queryParams.deptWard = '';
|
this.fetchData();
|
},
|
// 判断题目选项是否为固定满意度五项
|
isSatisfactionQuestion(q) {
|
if (q.options.length !== FIXED_OPTIONS.length) return false;
|
const set = q.options.map((o) => o.option);
|
return FIXED_OPTIONS.every((o) => set.includes(o));
|
},
|
questionTotal(q) {
|
if (q.total != null) return q.total;
|
return q.options.reduce((s, o) => s + o.count, 0);
|
},
|
rateOf(count, total) {
|
if (!total) return '0.00%';
|
return ((count / total) * 100).toFixed(2) + '%';
|
},
|
// 综合得分:非常满意5分、满意4分、一般3分、不满意2分,换算百分制(不适用不计入)——占位公式,接口就绪后可按后端口径替换
|
scoreOf(counts) {
|
const [a, b, c, d] = counts;
|
const total = a + b + c + d;
|
if (!total) return '0.00';
|
return (((a * 5 + b * 4 + c * 3 + d * 2) / total) * 20).toFixed(2);
|
},
|
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)];
|
},
|
dateRangeDays(range) {
|
if (Array.isArray(range) && range.length === 2 && range[0] && range[1]) {
|
const ms = new Date(range[1]).getTime() - new Date(range[0]).getTime();
|
return Math.max(1, Math.round(ms / 86400000));
|
}
|
return 30;
|
},
|
// 拉取数据(当前为模拟数据,接口就绪后替换为真实请求)
|
fetchData() {
|
const data = this.mockFetch(this.queryParams);
|
this.questionList = data.questionList;
|
this.deptComparison = data.deptComparison;
|
this.wardComparison = data.wardComparison;
|
},
|
mockFetch(params) {
|
const seed = hashString(JSON.stringify(params));
|
const rnd = mulberry32(seed);
|
const days = this.dateRangeDays(params.dateRange);
|
const timeFactor = Math.max(0.05, days / 30);
|
const scopeFactor = params.deptWard ? 0.18 : 1;
|
const jitter = () => 0.9 + rnd() * 0.2;
|
|
const questionList = RAW_QUESTIONS.map((q) => {
|
const options = q.options.map((o) => ({
|
option: o.option,
|
count: Math.round(o.count * timeFactor * scopeFactor * jitter()),
|
}));
|
const item = { title: q.title, type: q.type, options };
|
if (q.type === 'multiple' && q.total != null) {
|
item.total = Math.round(q.total * timeFactor * scopeFactor);
|
}
|
return item;
|
});
|
|
const deptComparison = DEPTS.map((d) => this.genCompareRow(d, rnd));
|
const wardComparison = WARDS.map((w) => this.genCompareRow(w, rnd));
|
|
return { questionList, deptComparison, wardComparison };
|
},
|
genCompareRow(name, rnd) {
|
const r = (lo, hi) => Math.round(lo + rnd() * (hi - lo));
|
const q1VeryGood = r(20, 180);
|
const q1Good = r(5, 55);
|
const q1Normal = r(0, 4);
|
const q1Bad = r(0, 3);
|
const q2VeryGood = r(18, 175);
|
const q2Good = r(5, 55);
|
const q2Normal = r(0, 4);
|
const q2Bad = r(0, 3);
|
return {
|
name,
|
q1VeryGood, q1Good, q1Normal, q1Bad,
|
q1Score: this.scoreOf([q1VeryGood, q1Good, q1Normal, q1Bad]),
|
q2VeryGood, q2Good, q2Normal, q2Bad,
|
q2Score: this.scoreOf([q2VeryGood, q2Good, q2Normal, q2Bad]),
|
totalScore: this.scoreOf([
|
q1VeryGood + q2VeryGood,
|
q1Good + q2Good,
|
q1Normal + q2Normal,
|
q1Bad + q2Bad,
|
]),
|
};
|
},
|
exportTable(tableId) {
|
const tableNames = { table1: '各服务项目满意度统计', table2: '单选题多选题统计', table3: '各科室满意度评分对比' };
|
const name = tableNames[tableId] || '统计数据';
|
const table = document.getElementById(tableId);
|
if (!table) return;
|
|
try {
|
// 从表格提取数据构建带样式的 Excel
|
const wb = XLSX.utils.book_new();
|
const data = [];
|
table.querySelectorAll('tr').forEach((tr) => {
|
const row = [];
|
tr.querySelectorAll('th,td').forEach((td) => {
|
row.push((td.innerText || '').trim());
|
});
|
if (row.length) data.push(row);
|
});
|
|
const ws = XLSX.utils.aoa_to_sheet(data);
|
// 设置列宽
|
const colWidths = data[0] ? data[0].map(() => ({ wch: 16 })) : [];
|
ws['!cols'] = colWidths;
|
|
XLSX.utils.book_append_sheet(wb, ws, 'Sheet1');
|
XLSX.writeFile(wb, name + '.xlsx');
|
} catch (e) {
|
// fallback: CSV
|
let csv = '';
|
table.querySelectorAll('tr').forEach((tr) => {
|
const row = [];
|
tr.querySelectorAll('th,td').forEach((td) => row.push('"' + (td.innerText || '').replace(/"/g, '""') + '"'));
|
csv += row.join(',') + '\n';
|
});
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
const link = document.createElement('a');
|
link.href = URL.createObjectURL(blob);
|
link.download = name + '.csv';
|
link.click();
|
}
|
},
|
},
|
};
|
</script>
|
|
<style lang="scss" scoped>
|
.data-overview { .overview-card { margin-bottom: 20px; } }
|
.filter-card { .filter-form { margin-bottom: -10px; } .filter-select { width: 200px; } }
|
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
.card-title { font-size: 16px; font-weight: 600; color: #303133; }
|
.header-right { display: flex; align-items: center; .stat-select { width: 180px; margin: 0 12px; } }
|
.question-block { margin-bottom: 24px;
|
.q-title { margin: 0 0 12px 0; font-size: 14px; color: #606266; }
|
.q-footer { margin-top: 8px; font-size: 13px; color: #909399; text-align: right; }
|
}
|
</style>
|