<template>
|
<div class="data-overview">
|
<!-- 顶部筛选:时间范围 + 科室/病区,控制本页所有数据 -->
|
<el-card shadow="never" class="overview-card filter-card">
|
<el-form inline class="filter-form">
|
<el-form-item label="出院日期">
|
<el-date-picker
|
v-model="queryParams.dateRange"
|
type="daterange"
|
range-separator="至"
|
start-placeholder="开始日期"
|
end-placeholder="结束日期"
|
value-format="yyyy-MM-dd"
|
size="small"
|
/>
|
</el-form-item>
|
<el-form-item label="统计维度">
|
<el-radio-group
|
v-model="queryParams.statMode"
|
size="small"
|
@change="onStatModeChange"
|
>
|
<el-radio-button label="ward">按病区统计</el-radio-button>
|
<el-radio-button label="dept">按科室统计</el-radio-button>
|
</el-radio-group>
|
</el-form-item>
|
<el-form-item label="科室" v-if="queryParams.statMode === 'dept'">
|
<el-select
|
v-model="queryParams.deptCodes"
|
multiple
|
collapse-tags
|
clearable
|
filterable
|
placeholder="全部科室"
|
size="small"
|
class="filter-select"
|
>
|
<el-option
|
v-for="d in deptOptions"
|
:key="d.value"
|
:label="d.label"
|
:value="d.value"
|
/>
|
</el-select>
|
</el-form-item>
|
<el-form-item label="病区" v-else>
|
<el-select
|
v-model="queryParams.wardCodes"
|
multiple
|
collapse-tags
|
clearable
|
filterable
|
placeholder="全部病区"
|
size="small"
|
class="filter-select"
|
>
|
<el-option
|
v-for="w in wardOptions"
|
:key="w.value"
|
:label="w.label"
|
:value="w.value"
|
/>
|
</el-select>
|
</el-form-item>
|
<el-form-item label="服务类型">
|
<el-select
|
v-model="queryParams.serviceTypeList"
|
multiple
|
collapse-tags
|
clearable
|
filterable
|
placeholder="全部类型"
|
size="small"
|
class="filter-select"
|
>
|
<el-option
|
v-for="s in serviceTypeOptions"
|
:key="s.value"
|
:label="s.label"
|
:value="s.value"
|
/>
|
</el-select>
|
</el-form-item>
|
<el-form-item>
|
<el-button
|
type="primary"
|
size="small"
|
icon="el-icon-search"
|
:loading="loading"
|
@click="fetchData"
|
>查询</el-button
|
>
|
<el-button size="small" icon="el-icon-refresh" @click="handleReset"
|
>重置</el-button
|
>
|
</el-form-item>
|
</el-form>
|
</el-card>
|
|
<!-- 表格一:各服务项目满意度统计(固定六项选项的题目) -->
|
<el-card shadow="never" class="overview-card">
|
<div slot="header" class="card-header">
|
<span class="card-title">各服务项目满意度统计</span>
|
<el-button
|
type="warning"
|
size="small"
|
icon="el-icon-download"
|
@click="exportTable('table1')"
|
>导出Excel</el-button
|
>
|
</div>
|
<el-table
|
id="table1"
|
v-loading="loading"
|
element-loading-text="统计中..."
|
:data="serviceSatisfaction"
|
border
|
size="small"
|
:header-cell-style="{ background: '#f5f7fa', color: '#303133' }"
|
>
|
<el-table-column
|
prop="category"
|
label="分类/题目"
|
min-width="220"
|
fixed="left"
|
/>
|
<el-table-column label="很满意" align="center">
|
<el-table-column prop="veryGoodCount" label="数量" width="90" />
|
<el-table-column prop="veryGoodRate" label="比例" width="90" />
|
</el-table-column>
|
<el-table-column 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="veryBadCount" label="数量" width="90" />
|
<el-table-column prop="veryBadRate" label="比例" width="90" />
|
</el-table-column>
|
<el-table-column label="未经历" align="center">
|
<el-table-column prop="naCount" label="数量" width="90" />
|
<el-table-column prop="naRate" label="比例" width="90" />
|
</el-table-column>
|
<el-table-column
|
prop="score"
|
label="综合得分"
|
width="100"
|
align="center"
|
fixed="right"
|
/>
|
</el-table>
|
</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-input
|
v-model="questionSearch"
|
size="small"
|
clearable
|
prefix-icon="el-icon-search"
|
placeholder="按题目模糊查询"
|
class="search-input"
|
/>
|
<el-button
|
type="warning"
|
size="small"
|
icon="el-icon-download"
|
@click="exportTable('table2')"
|
>导出Excel</el-button
|
>
|
</div>
|
</div>
|
<div id="table2" v-loading="loading" element-loading-text="统计中...">
|
<div class="table2-scroll">
|
<div
|
class="question-block"
|
v-for="(q, qi) in filteredQuestionStats"
|
:key="qi"
|
>
|
<h4 class="q-title">{{ q.title }}</h4>
|
<el-table
|
:data="q.options"
|
border
|
size="small"
|
:header-cell-style="{ background: '#f5f7fa', color: '#303133' }"
|
>
|
<el-table-column prop="option" label="选项" min-width="180" />
|
<el-table-column
|
prop="count"
|
label="数量"
|
width="100"
|
align="center"
|
/>
|
<el-table-column
|
prop="rate"
|
label="比例"
|
width="100"
|
align="center"
|
/>
|
</el-table>
|
<div v-if="q.total" class="q-footer">
|
本题填报总量:<b>{{ q.total }}</b>
|
</div>
|
</div>
|
<div v-if="!loading && !filteredQuestionStats.length" class="empty-tip">
|
{{ questionStats.length ? "未找到匹配的题目" : "暂无单选/多选题统计数据" }}
|
</div>
|
</div>
|
</div>
|
</el-card>
|
|
<!-- 表格三:各科室/病区满意度评分对比 -->
|
<el-card shadow="never" class="overview-card">
|
<div slot="header" class="card-header">
|
<span class="card-title">各科室/病区满意度评分对比</span>
|
<div class="header-right">
|
<el-radio-group
|
v-model="statDimension"
|
size="small"
|
@change="onStatDimensionChange"
|
>
|
<el-radio-button label="dept">按科室</el-radio-button>
|
<el-radio-button label="ward">按病区</el-radio-button>
|
</el-radio-group>
|
<el-select
|
v-model="statDeptWard"
|
size="small"
|
class="stat-select"
|
placeholder="沿用顶部条件"
|
>
|
<el-option label="沿用顶部条件" value="inherit" />
|
<template v-if="statDimension === 'dept'">
|
<el-option
|
v-for="d in deptOptions"
|
:key="'dept:' + d.value"
|
:label="d.label"
|
:value="'dept:' + d.value"
|
/>
|
</template>
|
<template v-else>
|
<el-option
|
v-for="w in wardOptions"
|
:key="'ward:' + w.value"
|
:label="w.label"
|
:value="'ward:' + w.value"
|
/>
|
</template>
|
</el-select>
|
<el-button
|
type="warning"
|
size="small"
|
icon="el-icon-download"
|
@click="exportTable('table3')"
|
>导出Excel</el-button
|
>
|
</div>
|
</div>
|
<el-table
|
id="table3"
|
v-loading="loading"
|
element-loading-text="统计中..."
|
:data="table3Rows"
|
border
|
size="small"
|
max-height="500"
|
:header-cell-style="{ background: '#f5f7fa', color: '#303133' }"
|
>
|
<el-table-column
|
prop="name"
|
:label="statDimension === 'dept' ? '科室' : '病区'"
|
fixed="left"
|
align="center"
|
/>
|
<el-table-column
|
v-for="q in table3Questions"
|
:key="q.title"
|
:label="q.title"
|
align="center"
|
>
|
<el-table-column
|
v-for="opt in q.options"
|
:key="opt"
|
:label="opt"
|
width="100"
|
>
|
<template slot-scope="{ row }">
|
{{
|
(row.cells[q.title] &&
|
row.cells[q.title][opt] &&
|
row.cells[q.title][opt].count) ||
|
0
|
}}
|
</template>
|
</el-table-column>
|
<el-table-column label="得分" width="90">
|
<template slot-scope="{ row }">
|
{{ (row.cells[q.title] && row.cells[q.title]._score) || "0.00" }}
|
</template>
|
</el-table-column>
|
</el-table-column>
|
<el-table-column label="综合得分" align="center">
|
<template slot-scope="{ row }">
|
{{ row.totalScore }}
|
</template>
|
</el-table-column>
|
</el-table>
|
</el-card>
|
</div>
|
</template>
|
|
<script>
|
import { sltdMydTotalByScore,statQuestionOptionByKsOrBq } from "@/api/AiCentre/satisfaction";
|
import XLSX from "xlsx";
|
|
// 满意度题的固定选项(顺序即表格列顺序)——这些选项的题目进表一,其余进表二
|
const FIXED_OPTIONS = ["很满意", "满意", "一般", "不满意", "很不满意", "未经历"];
|
// 无分值选项(如"未经历")是否计入综合得分分母:默认 false,避免拉低得分
|
const NO_SCORE_IN_DENOMINATOR = false;
|
|
// 服务类型字典(接口 serviceType)
|
const SERVICE_TYPES = [
|
{ value: "1", label: "监测评估" },
|
{ value: "2", label: "出院随访" },
|
{ value: "3", label: "门诊随访" },
|
{ value: "4", label: "宣教关怀" },
|
{ value: "5", label: "复诊通知" },
|
{ value: "6", label: "满意度调查" },
|
{ value: "7", label: "患者报告" },
|
{ value: "8", label: "其他通知" },
|
{ value: "9", label: "体检随访" },
|
{ value: "10", label: "医技随访" },
|
{ value: "11", label: "影像专科随访" },
|
{ value: "12", label: "心电专科随访" },
|
{ value: "13", label: "专科随访" },
|
];
|
|
// 服务类型默认选中项(按 label 匹配,适配不同字典的 value 差异)
|
const DEFAULT_SERVICE_LABELS = ["住院满意度", "门诊满意度"];
|
|
// 兜底:store 中无科室/病区数据时使用(表三对比 + 筛选下拉)
|
const DEPTS = ["内分泌科", "皮肤科", "肿瘤内科", "消化内科", "肾内科"];
|
const WARDS = ["一病区", "二病区", "三病区", "四病区", "五病区"];
|
|
/**
|
* 接口数据 → 页面内部题目结构
|
* 入参:接口 data(List<QuestionResultDTO>)
|
* 出参:[{ title, type, total, options: [{ option, count, score }] }]
|
* 同一题目可能分散在多个任务(taskname)里,这里按「题目 + 选项」合并累加数量
|
*/
|
function normalizeQuestions(list) {
|
const map = new Map();
|
|
// 入参可能是数组 / {list} / {rows} / 单对象,统一规整成数组
|
const arr = Array.isArray(list)
|
? list
|
: (list &&
|
(Array.isArray(list.list)
|
? list.list
|
: Array.isArray(list.rows)
|
? list.rows
|
: [list])) ||
|
[];
|
|
// 题目文本字段候选(本系统题目内容统一叫 scriptContent,兼容历史命名)
|
const pickTitle = (item) => {
|
const keys = [
|
"scriptContent",
|
"questionText",
|
"qeustionText",
|
"questiontext",
|
"scriptcontent",
|
"title",
|
"name",
|
"questionName",
|
];
|
for (const k of keys) {
|
if (item && item[k] != null && String(item[k]).trim()) {
|
return String(item[k]).trim();
|
}
|
}
|
return "";
|
};
|
|
// 选项明细列表字段候选
|
const pickDetails = (item) => {
|
const keys = [
|
"subtaskDetailRatioExportList",
|
"details",
|
"optionList",
|
"options",
|
"optionResultList",
|
"subtaskDetailRatioExportlist",
|
"list",
|
];
|
for (const k of keys) {
|
if (item && Array.isArray(item[k])) return item[k];
|
}
|
return [];
|
};
|
|
// 选项文本字段候选
|
const pickOption = (d) => {
|
const keys = [
|
"optionresult",
|
"optioncontent",
|
"optionText",
|
"targetvalue",
|
"optionName",
|
"name",
|
"option",
|
"content",
|
];
|
for (const k of keys) {
|
if (d && d[k] != null && String(d[k]).trim()) {
|
return String(d[k]).trim();
|
}
|
}
|
return "";
|
};
|
|
// 数量字段候选
|
const pickCount = (d) => {
|
const keys = ["count", "chosenQuantity", "num", "total", "chosenquantity"];
|
for (const k of keys) {
|
if (d && d[k] != null && !isNaN(Number(d[k]))) {
|
return Number(d[k]) || 0;
|
}
|
}
|
return 0;
|
};
|
|
arr.forEach((item) => {
|
if (!item || typeof item !== "object") return;
|
const title = pickTitle(item) || "未命名题目";
|
const details = pickDetails(item);
|
let node = map.get(title);
|
if (!node) {
|
node = { title, type: "", options: new Map() };
|
map.set(title, node);
|
}
|
|
details.forEach((d) => {
|
const option = pickOption(d);
|
if (!option) return;
|
const count = pickCount(d);
|
const rawScore = Number(d && d.score);
|
const score = isNaN(rawScore) ? null : rawScore;
|
// 题型:优先 scriptType(1单选 2多选 4问答),其次 valueType(1单选 2多选 3问答 4填空 5其它)
|
const typeVal = String(
|
(item && item.scriptType) ||
|
(d && d.scriptType) ||
|
(d && d.valueType) ||
|
(item && item.valueType) ||
|
""
|
);
|
if (!node.type) {
|
node.type =
|
typeVal === "2" ? "multiple" : typeVal === "1" ? "single" : "qa";
|
}
|
|
const old = node.options.get(option);
|
if (old) {
|
old.count += count;
|
if (old.score == null) old.score = score;
|
} else {
|
node.options.set(option, { option, count, score });
|
}
|
});
|
});
|
|
return Array.from(map.values()).map((n) => {
|
const options = Array.from(n.options.values());
|
const item = { title: n.title, type: n.type, options };
|
// 多选题:用各选项数量之和作为填报总量(接口未单独提供人次)
|
if (n.type === "multiple")
|
item.total = options.reduce((s, o) => s + o.count, 0);
|
return item;
|
});
|
}
|
|
export default {
|
name: "DataOverview",
|
data() {
|
return {
|
// 顶部全局筛选
|
queryParams: {
|
dateRange: [],
|
deptCodes: [], // 科室编码集合 => leaveldeptcodes
|
wardCodes: [], // 出院病区编码集合 => leavehospitaldistrictcodes(接口必填)
|
serviceTypeList: [], // 服务类型多选,提交时用逗号拼接
|
deptOrDistrict: "1", // 科室/病区组合方式,默认 1:离院病区 and 离院科室
|
statMode: "ward", // 统计维度:ward=按病区统计 dept=按科室统计
|
},
|
SERVICE_TYPES,
|
loading: false,
|
|
// 表二:题目模糊查询关键字(纯前端过滤,不请求后端)
|
questionSearch: "",
|
|
// 表格三:统计维度 + 独立科室/病区条件('inherit' = 沿用顶部)
|
statDimension: "ward",
|
statDeptWard: "inherit",
|
|
// 接口返回的原始数据(已归一化的题目列表)
|
questionList: [],
|
// 表三:科室/病区评分对比(rows=行数据,questions=动态列)
|
deptComparison: { rows: [], questions: [] },
|
wardComparison: { rows: [], questions: [] },
|
};
|
},
|
computed: {
|
// 科室选项:优先取当前用户所属科室,无则兜底
|
deptOptions() {
|
const list = this.$store.getters.belongDepts || [];
|
return list.length
|
? list.map((d) => ({ label: d.deptName, value: d.deptCode }))
|
: DEPTS.map((d) => ({ label: d, value: d }));
|
},
|
// 病区选项:优先取当前用户所属病区,无则兜底
|
wardOptions() {
|
const list = this.$store.getters.belongWards || [];
|
return list.length
|
? list.map((w) => ({ label: w.districtName, value: w.districtCode }))
|
: WARDS.map((w) => ({ label: w, value: w }));
|
},
|
// 服务类型下拉:取 store 的随访类型字典 tasktypes,取不到时兜底本地 SERVICE_TYPES
|
serviceTypeOptions() {
|
const toOptions = (list) =>
|
(Array.isArray(list) ? list : [])
|
.map((i) => ({
|
label: String(i.label || "").trim(),
|
value: String(i.value),
|
}))
|
.filter((i) => i.label && i.value && i.value !== "undefined");
|
const taskTypes = toOptions(this.$store.getters.tasktypes);
|
return taskTypes.length ? taskTypes : SERVICE_TYPES;
|
},
|
// 未选病区时的默认查询范围:全部病区编码
|
defaultWardCodes() {
|
return Array.from(new Set(this.wardOptions.map((w) => w.value)));
|
},
|
// 未选科室时的默认查询范围:全部科室编码
|
defaultDeptCodes() {
|
return Array.from(new Set(this.deptOptions.map((d) => d.value)));
|
},
|
// 固定六项选项的题目 => 表一
|
satisfactionQuestions() {
|
return this.questionList.filter((q) => this.isSatisfactionQuestion(q));
|
},
|
// 其余题目 => 表二
|
otherQuestions() {
|
return this.questionList.filter((q) => !this.isSatisfactionQuestion(q));
|
},
|
// 表一:满意度统计(含总计行)
|
serviceSatisfaction() {
|
// 取指定选项的 { count, score },未返回则该项为 0
|
const pick = (q, name) => {
|
const hit = q.options.find((o) => o.option === name);
|
return hit
|
? { count: Number(hit.count) || 0, score: hit.score }
|
: { count: 0, score: null };
|
};
|
const NAMES = FIXED_OPTIONS;
|
|
const rows = this.satisfactionQuestions.map((q) => {
|
const veryGood = pick(q, "很满意");
|
const good = pick(q, "满意");
|
const normal = pick(q, "一般");
|
const bad = pick(q, "不满意");
|
const veryBad = pick(q, "很不满意");
|
const na = pick(q, "未经历");
|
const items = [veryGood, good, normal, bad, veryBad, na];
|
const total = items.reduce((s, it) => s + it.count, 0);
|
return {
|
category: q.title,
|
veryGoodCount: veryGood.count,
|
veryGoodRate: this.rateOf(veryGood.count, total),
|
goodCount: good.count,
|
goodRate: this.rateOf(good.count, total),
|
normalCount: normal.count,
|
normalRate: this.rateOf(normal.count, total),
|
badCount: bad.count,
|
badRate: this.rateOf(bad.count, total),
|
veryBadCount: veryBad.count,
|
veryBadRate: this.rateOf(veryBad.count, total),
|
naCount: na.count,
|
naRate: this.rateOf(na.count, total),
|
score: this.scoreOfItems(items),
|
};
|
});
|
|
// 总计行:各题同选项数量累加,分值沿用该选项分值
|
const sum = NAMES.map(() => ({ count: 0, score: null }));
|
this.satisfactionQuestions.forEach((q) => {
|
NAMES.forEach((name, i) => {
|
const it = pick(q, name);
|
sum[i].count += it.count;
|
if (sum[i].score == null) sum[i].score = it.score;
|
});
|
});
|
const total = sum.reduce((s, it) => s + it.count, 0);
|
rows.push({
|
category: "总计",
|
veryGoodCount: sum[0].count,
|
veryGoodRate: this.rateOf(sum[0].count, total),
|
goodCount: sum[1].count,
|
goodRate: this.rateOf(sum[1].count, total),
|
normalCount: sum[2].count,
|
normalRate: this.rateOf(sum[2].count, total),
|
badCount: sum[3].count,
|
badRate: this.rateOf(sum[3].count, total),
|
veryBadCount: sum[4].count,
|
veryBadRate: this.rateOf(sum[4].count, total),
|
naCount: sum[5].count,
|
naRate: this.rateOf(sum[5].count, total),
|
score: this.scoreOfItems(sum),
|
});
|
return rows;
|
},
|
// 表二:单选/多选统计
|
questionStats() {
|
return this.otherQuestions.map((q) => {
|
const total = this.questionTotal(q);
|
return {
|
title: q.title,
|
total,
|
options: q.options.map((o) => ({
|
option: o.option,
|
count: o.count,
|
rate: this.rateOf(o.count, total),
|
})),
|
};
|
});
|
},
|
// 表二:按题目模糊查询(纯前端过滤,不请求后端)
|
filteredQuestionStats() {
|
const kw = (this.questionSearch || "").trim().toLowerCase();
|
if (!kw) return this.questionStats;
|
return this.questionStats.filter((q) =>
|
(q.title || "").toLowerCase().indexOf(kw) > -1
|
);
|
},
|
// 表三:按维度 + 独立条件过滤后的行
|
table3Rows() {
|
const comp =
|
this.statDimension === "dept"
|
? this.deptComparison
|
: this.wardComparison;
|
const list = (comp && comp.rows) || [];
|
const names = this.statFilterNames();
|
if (!names || !names.length) return list;
|
return list.filter((r) => names.indexOf(r.name) > -1);
|
},
|
// 表三:当前维度下的题目列(动态列)
|
table3Questions() {
|
const comp =
|
this.statDimension === "dept"
|
? this.deptComparison
|
: this.wardComparison;
|
return (comp && comp.questions) || [];
|
},
|
},
|
created() {
|
if (!this.queryParams.dateRange || !this.queryParams.dateRange.length) {
|
this.queryParams.dateRange = this.defaultRange();
|
}
|
this.queryParams.serviceTypeList = this.defaultServiceTypes();
|
},
|
mounted() {
|
this.fetchData();
|
},
|
methods: {
|
// 默认选中的服务类型(按 label 匹配,取不到就返回空数组 = 不限)
|
defaultServiceTypes() {
|
const hit = DEFAULT_SERVICE_LABELS.map((label) => {
|
const item = this.serviceTypeOptions.find((o) => o.label === label);
|
return item ? item.value : null;
|
}).filter((v) => v != null);
|
return Array.from(new Set(hit));
|
},
|
// 表三独立条件:'inherit' 时沿用顶部所选;返回选中的名称数组(接口只回名称),空数组表示不限
|
statFilterNames() {
|
const opts =
|
this.statDimension === "dept" ? this.deptOptions : this.wardOptions;
|
const nameOf = (code) => {
|
const hit = opts.find((o) => String(o.value) === String(code));
|
return hit ? hit.label : null;
|
};
|
if (this.statDeptWard === "inherit") {
|
const codes =
|
this.statDimension === "dept"
|
? this.queryParams.deptCodes
|
: this.queryParams.wardCodes;
|
return (codes || []).map(nameOf).filter(Boolean);
|
}
|
// 切换维度后,若已选条件不属于当前维度则视为不限
|
if (this.statDeptWard.startsWith(this.statDimension + ":")) {
|
const name = nameOf(this.statDeptWard.slice(this.statDimension.length + 1));
|
return name ? [name] : [];
|
}
|
return [];
|
},
|
onStatDimensionChange() {
|
// 切换维度后,若当前已选条件不属于新维度,则恢复“沿用顶部”
|
if (
|
this.statDeptWard !== "inherit" &&
|
!this.statDeptWard.startsWith(this.statDimension + ":")
|
) {
|
this.statDeptWard = "inherit";
|
}
|
},
|
handleReset() {
|
this.queryParams.dateRange = this.defaultRange();
|
this.queryParams.deptCodes = [];
|
this.queryParams.wardCodes = [];
|
this.queryParams.statMode = "ward";
|
this.statDimension = "ward";
|
this.statDeptWard = "inherit";
|
this.queryParams.serviceTypeList = this.defaultServiceTypes();
|
this.fetchData();
|
},
|
// 切换统计维度(科室/病区)时清空另一维度的选择,保证互斥
|
onStatModeChange() {
|
if (this.queryParams.statMode === "dept") {
|
this.queryParams.wardCodes = [];
|
} else {
|
this.queryParams.deptCodes = [];
|
}
|
// 单向同步到「各科室/病区满意度评分对比」表:顶部 → 表三
|
this.statDimension = this.queryParams.statMode;
|
this.onStatDimensionChange();
|
},
|
// 判断题目是否为满意度六项选项题:所有选项都落在固定六项内即归表一
|
isSatisfactionQuestion(q) {
|
if (!q.options || !q.options.length) return false;
|
return q.options.every((o) => FIXED_OPTIONS.includes(o.option));
|
},
|
questionTotal(q) {
|
if (q.total != null) return q.total;
|
return q.options.reduce((s, o) => s + o.count, 0);
|
},
|
rateOf(count, total) {
|
if (!total) return "0.00%";
|
return ((count / total) * 100).toFixed(2) + "%";
|
},
|
/**
|
* 综合得分(真实口径):Σ(各选项分值 × 填报量) / Σ(填报量)
|
* items: [{ count, score }],score 取接口返回的 score 字段
|
* 无分值选项(如"未经历")默认不计入分子与分母,避免拉低得分
|
*/
|
scoreOfItems(items) {
|
let numerator = 0;
|
let denominator = 0;
|
items.forEach((it) => {
|
const count = Number(it && it.count) || 0;
|
const score = Number(it && it.score);
|
const hasScore = !isNaN(score);
|
if (!hasScore && !NO_SCORE_IN_DENOMINATOR) return;
|
numerator += (hasScore ? score : 0) * count;
|
denominator += count;
|
});
|
if (!denominator) return "0.00";
|
return (numerator / denominator).toFixed(2);
|
},
|
defaultRange() {
|
const end = new Date();
|
const start = new Date();
|
start.setDate(start.getDate() - 91);
|
const f = (d) => {
|
const y = d.getFullYear();
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
const day = String(d.getDate()).padStart(2, "0");
|
return `${y}-${m}-${day}`;
|
};
|
return [f(start), f(end)];
|
},
|
// 提示信息(兼容 $modal 与 $message)
|
notify(msg, type) {
|
const map = {
|
warning: ["msgWarning", "warning"],
|
error: ["msgError", "error"],
|
success: ["msgSuccess", "success"],
|
};
|
const [modalFn, msgFn] = map[type] || map.success;
|
if (this.$modal && this.$modal[modalFn]) {
|
this.$modal[modalFn](msg);
|
} else if (this.$message && this.$message[msgFn]) {
|
this.$message[msgFn](msg);
|
}
|
},
|
// 拉取满意度统计数据(接口:/smartor/serviceSubtaskDetail/sltdMydTotalByScore)
|
async fetchData() {
|
const range = this.queryParams.dateRange || [];
|
if (range.length !== 2 || !range[0] || !range[1]) {
|
this.notify("请先选择出院日期范围", "warning");
|
return;
|
}
|
|
// 病区 => leavehospitaldistrictcodes,科室 => leaveldeptcodes
|
// 科室/病区互斥:按病区只传 leavehospitaldistrictcodes,按科室只传 leaveldeptcodes
|
const wards = this.queryParams.wardCodes || [];
|
const depts = this.queryParams.deptCodes || [];
|
|
const payload = {
|
deptOrDistrict: this.queryParams.deptOrDistrict || "1",
|
startFinishTime: range[0],
|
endFinishTime: range[1],
|
};
|
if (this.queryParams.statMode === "dept") {
|
payload.leaveldeptcodes = depts.length
|
? Array.from(new Set(depts))
|
: this.defaultDeptCodes;
|
} else {
|
payload.leavehospitaldistrictcodes = wards.length
|
? Array.from(new Set(wards))
|
: this.defaultWardCodes;
|
}
|
// 服务类型多选:逗号分隔(接口字段为 serviceType 字符串)
|
const serviceTypes = this.queryParams.serviceTypeList || [];
|
if (serviceTypes.length) payload.serviceType = serviceTypes.join(",");
|
payload.serviceTypeList = serviceTypes;
|
this.loading = true;
|
try {
|
const res = await sltdMydTotalByScore(payload);
|
// 排查用:打印接口原始返回,便于核对字段名(确认无误后可删除)
|
console.log(
|
"[sltdMydTotalByScore] code=",
|
res && res.code,
|
"data=",
|
JSON.stringify(res && res.data).slice(0, 2000)
|
);
|
if (res && res.code === 200) {
|
this.questionList = normalizeQuestions(res.data);
|
if (!this.questionList.length)
|
this.notify("当前条件下暂无统计数据", "warning");
|
} else {
|
this.questionList = [];
|
this.notify((res && res.msg) || "统计数据获取失败", "error");
|
}
|
// 表三:各科室/病区满意度评分对比(真实接口,内部自行处理错误)
|
await this.fetchComparison();
|
} catch (e) {
|
this.questionList = [];
|
this.notify("统计数据请求失败,请稍后重试", "error");
|
} finally {
|
this.loading = false;
|
}
|
},
|
// 拉取各科室/病区满意度评分对比(接口:/smartor/serviceSubtaskDetail/statQuestionOptionByKsOrBq)
|
async fetchComparison() {
|
const range = this.queryParams.dateRange || [];
|
if (range.length !== 2 || !range[0] || !range[1]) {
|
this.deptComparison = { rows: [], questions: [] };
|
this.wardComparison = { rows: [], questions: [] };
|
return;
|
}
|
const base = {
|
deptOrDistrict: this.queryParams.deptOrDistrict || "1",
|
startFinishTime: range[0],
|
endFinishTime: range[1],
|
};
|
try {
|
// 按科室统计(leaveldeptcodes 优先)与按病区统计(leavehospitaldistrictcodes)各查一次
|
const [deptRes, wardRes] = await Promise.all([
|
statQuestionOptionByKsOrBq({ ...base, leaveldeptcodes: this.defaultDeptCodes }),
|
statQuestionOptionByKsOrBq({ ...base, leavehospitaldistrictcodes: this.defaultWardCodes }),
|
]);
|
// 排查用:打印接口原始返回,便于核对两级嵌套 Map 结构(确认无误后可删除)
|
console.log(
|
"[statQuestionOptionByKsOrBq] dept code=",
|
deptRes && deptRes.code,
|
"data=",
|
JSON.stringify(deptRes && deptRes.data).slice(0, 2000),
|
"| ward code=",
|
wardRes && wardRes.code,
|
"data=",
|
JSON.stringify(wardRes && wardRes.data).slice(0, 2000)
|
);
|
this.deptComparison = this.buildComparison(
|
deptRes && deptRes.code === 200 ? deptRes.data : {},
|
"dept"
|
);
|
this.wardComparison = this.buildComparison(
|
wardRes && wardRes.code === 200 ? wardRes.data : {},
|
"ward"
|
);
|
} catch (e) {
|
this.deptComparison = { rows: [], questions: [] };
|
this.wardComparison = { rows: [], questions: [] };
|
console.error("获取科室/病区评分对比失败:", e);
|
}
|
},
|
// 组装为表三所需的行/列结构:行=科室/病区,列=题目×选项 + 得分 + 综合得分
|
// 新接口返回两级嵌套 Map:{ 科室/病区名称: { 题目内容: [QuestionCountKsOrBqDTO] } }
|
// 兼容旧扁平数组:[{ questionText, deptname, leavehospitaldistrictname, targetvalue, answerCount, singleScore }]
|
buildComparison(data, mode) {
|
const questions = [];
|
const qMap = new Map();
|
const rowMap = new Map();
|
|
// 将一条 (名称, 题目, 选项, 数量, 得分) 明细累加进行/列结构
|
const collect = (name, title, option, count, single) => {
|
if (!title || !name) return;
|
let q = qMap.get(title);
|
if (!q) {
|
q = { title, options: [] };
|
qMap.set(title, q);
|
questions.push(q);
|
}
|
if (option && q.options.indexOf(option) === -1) q.options.push(option);
|
|
let row = rowMap.get(name);
|
if (!row) {
|
row = { name, code: this.codeFor(mode, name), cells: {} };
|
rowMap.set(name, row);
|
}
|
if (!row.cells[title]) row.cells[title] = {};
|
const cell = row.cells[title][option] || { count: 0, score: 0 };
|
cell.count += count;
|
cell.score += single;
|
row.cells[title][option] = cell;
|
};
|
|
if (Array.isArray(data)) {
|
// 旧结构:扁平数组
|
data.forEach((it) => {
|
const title = it.questionText || it.qeustionText || "";
|
const name =
|
mode === "dept"
|
? it.deptname || ""
|
: it.leavehospitaldistrictname || "";
|
const option = it.targetvalue || it.asrtext || "";
|
collect(name, title, option, Number(it.answerCount) || 0, Number(it.singleScore) || 0);
|
});
|
} else if (data && typeof data === "object") {
|
// 新结构:两级嵌套 Map,第一层 key=科室/病区名称,第二层 key=题目,value=选项明细列表
|
Object.keys(data).forEach((name) => {
|
const questionMap = data[name] || {};
|
Object.keys(questionMap).forEach((title) => {
|
const list = Array.isArray(questionMap[title]) ? questionMap[title] : [];
|
list.forEach((it) => {
|
const option = it.targetvalue || it.asrtext || "";
|
collect(name, title, option, Number(it.answerCount) || 0, Number(it.singleScore) || 0);
|
});
|
});
|
});
|
}
|
|
const rows = Array.from(rowMap.values());
|
rows.forEach((row) => {
|
let totalCount = 0;
|
let totalScore = 0;
|
questions.forEach((q) => {
|
const cells = row.cells[q.title] || {};
|
let qCount = 0;
|
let qScore = 0;
|
q.options.forEach((opt) => {
|
const c = cells[opt];
|
if (c) {
|
qCount += c.count;
|
qScore += c.score;
|
}
|
});
|
// 该题目加权平均分:Σ singleScore / Σ answerCount
|
cells._score = qCount ? (qScore / qCount).toFixed(2) : "0.00";
|
totalCount += qCount;
|
totalScore += qScore;
|
row.cells[q.title] = cells;
|
});
|
row.totalScore = totalCount ? (totalScore / totalCount).toFixed(2) : "0.00";
|
});
|
|
return { rows, questions };
|
},
|
// 名称 → 编码(用于表三按顶部所选编码过滤)
|
codeFor(mode, name) {
|
const opts = mode === "dept" ? this.deptOptions : this.wardOptions;
|
const hit = opts.find((o) => String(o.label) === String(name));
|
return hit ? hit.value : name;
|
},
|
exportTable(tableId) {
|
const tableNames = {
|
table1: "各服务项目满意度统计",
|
table2: "单选题多选题统计",
|
table3: "各科室满意度评分对比",
|
};
|
const name = tableNames[tableId] || "统计数据";
|
const 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: 220px;
|
}
|
.filter-select-sm {
|
width: 150px;
|
}
|
}
|
.empty-tip {
|
padding: 40px 0;
|
text-align: center;
|
color: #909399;
|
font-size: 13px;
|
}
|
.table2-scroll {
|
max-height: 560px;
|
overflow-y: auto;
|
}
|
.card-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
}
|
.card-title {
|
font-size: 16px;
|
font-weight: 600;
|
color: #303133;
|
}
|
.header-right {
|
display: flex;
|
align-items: center;
|
.stat-select {
|
width: 180px;
|
margin: 0 12px;
|
}
|
.search-input {
|
width: 200px;
|
margin-right: 12px;
|
}
|
}
|
.question-block {
|
margin-bottom: 24px;
|
.q-title {
|
margin: 0 0 12px 0;
|
font-size: 14px;
|
color: #606266;
|
}
|
.q-footer {
|
margin-top: 8px;
|
font-size: 13px;
|
color: #909399;
|
text-align: right;
|
}
|
}
|
</style>
|