Files
his/openhis-ui-vue3/src/components/PatientList/index.vue
chenqi 1c07108e58 refactor(PatientList): 重构患者列表卡片布局结构
- 将原有的 header-top 和 header-bottom 结构替换为 info-row 统一布局
- 新增姓名、性别年龄、房间床号、住院号、保险类型等独立信息行
- 使用 el-text 组件优化姓名显示效果
- 为性别标签添加女性样式标识
- 调整床位信息展示方式,支持溢出省略
- 修改溢出属性从 hidden 为 visible 确保内容正常显示
- 优化标签样式和间距布局
- 隐藏已废弃的旧布局元素
- 调整 pending 患者列表的换行和对齐方式
2026-01-21 17:50:51 +08:00

794 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="patientList-container" :class="{ 'patientList-container-unexpand': !currentExpand }">
<div
class="patientList-operate"
:class="{ 'patientList-operate-unexpand': !currentExpand }"
v-if="currentExpand"
>
<el-input
class="patientList-search-input"
placeholder="床号/住院号/姓名"
v-model="searchKeyword"
@keyup.enter="handleSearch"
:prefix-icon="Search"
/>
<el-button class="icon-btn" circle @click="handleRefresh" type="text" plain>
<el-icon icon-class="Refresh" size="24" :class="{ 'is-rotating': refreshing }">
<Refresh />
</el-icon>
</el-button>
</div>
<transition name="patient-list-toggle" mode="out-in">
<div key="expanded" class="patientList-list" v-if="currentExpand">
<div class="patient-cards" v-loading="isLoading">
<template v-if="filteredCardData && filteredCardData.length > 0">
<el-scrollbar ref="expandScrollbarRef" class="patient-cards-scrollbar">
<div
class="patient-card"
v-for="item in filteredCardData"
:key="item.encounterId"
:id="item.encounterId"
@click="handleItemClick(item)"
:class="{ actived: activeCardId === item.encounterId }"
>
<div class="patient-card-header">
<!-- 第1行姓名 -->
<div class="info-row name-row">
<div class="name">
<el-text :text="item.patientName" tclass="name" width="auto">
{{ item.patientName || '-' }}
</el-text>
</div>
</div>
<!-- 第2行性别 年龄 + 入院状态 -->
<div class="info-row gender-age-row">
<div class="age">
<el-tag
size="small"
class="age-tag"
effect="plain"
:class="{ 'age-tag-female': item.genderEnum_enumText === '女性' }"
>
{{ item.genderEnum_enumText || '-' }}
<span v-if="item.age"> · {{ item.age }}</span>
</el-tag>
</div>
<!-- 入院状态放在性别年龄旁边 -->
<div class="status-inline" v-if="item.statusEnum_enumText">
<el-tag
size="small"
class="payer-tag-status"
effect="light"
:style="getStatusStyle(item.statusEnum)"
>
{{ item.statusEnum_enumText }}
</el-tag>
</div>
</div>
<!-- 第3行房间号-分床状态 -->
<div class="info-row room-bed-row">
<div class="bed-info">
<div v-if="item.houseName" class="house-name">{{ item.houseName }}</div>
<div class="bed-name">{{ item.bedName || '未分床' }}</div>
</div>
</div>
<!-- 第4行住院号 -->
<div class="info-row busno-row">
<span class="bus-no">住院号{{ item.busNo || '-' }}</span>
</div>
<!-- 第5行居民保险类型 -->
<div class="info-row insurance-row" v-if="item.contractName">
<el-tag
size="small"
class="payer-tag"
effect="light"
>
{{ item.contractName }}
</el-tag>
</div>
</div>
<div class="doctor-parent-line" />
<div class="patient-card-body">
<div class="personal-info-container">
<div class="name-container">
<div class="name">
<el-text :text="item.patientName" tclass="name" width="auto">
{{ item.patientName || '-' }}
</el-text>
</div>
<div class="age">
<el-tag
size="small"
class="age-tag"
effect="plain"
:class="{ 'age-tag-female': item.genderEnum_enumText === '女性' }"
>
{{ item.genderEnum_enumText || '-' }}
<span v-if="item.age"> · {{ item.age }}</span>
</el-tag>
</div>
</div>
<!-- 添加入院日期等关键信息 -->
<div class="admission-info" v-if="item.admissionDate">
<span class="admission-date">入院日期{{ item.admissionDate }}</span>
</div>
<!-- 添加主治医生信息 -->
<div class="attending-doctor" v-if="item.attendingDoctorName">
<span class="doctor-name">主管医生{{ item.attendingDoctorName }}</span>
</div>
</div>
</div>
</div>
</el-scrollbar>
</template>
<el-empty v-else description="暂无数据" />
</div>
</div>
<div
key="collapsed"
class="patientList-list"
v-else
v-loading="isLoading"
:class="{ 'patientList-list-unexpand': !currentExpand }"
>
<el-scrollbar ref="contractScrollbarRef" class="patient-cards-scrollbar">
<template v-if="filteredCardData && filteredCardData.length > 0">
<el-tooltip
v-for="item in filteredCardData"
:show-after="200"
:key="item.encounterId"
:show-arrow="true"
placement="right"
effect="light"
:offset="4"
>
<template #content>
<div class="card-tooltip">
<div class="card-tooltip-main">
<span class="card-tooltip-bed">{{ item.bedName }}</span>
<span class="card-tooltip-name">{{ item.patientName }}</span>
</div>
<div class="card-tooltip-sex">
<span class="card-tooltip-sex-text">
{{ item.genderEnum_enumText || '-' }}
<span v-if="item.age"> · {{ item.age }}</span>
</span>
</div>
</div>
</template>
<div>
<div
class="card-small"
:class="{ 'patient-active': activeCardId === item.encounterId }"
@click="handleSmallCardClick(item)"
:key="item.encounterId"
>
{{ item.bedName }}
</div>
<div class="patient-card-small-border"></div>
</div>
</el-tooltip>
</template>
<el-empty v-else description="暂无数据" :image-size="50" />
</el-scrollbar>
</div>
</transition>
<div
class="patientList-toggle-btn-wrap"
:class="{ 'patientList-toggle-btn-wrap-unexpand': !currentExpand }"
>
<el-button class="icon-btn" circle @click="updateExpand">
<el-icon class="svg-sty-menu" size="24">
<Expand v-if="!currentExpand" />
<Fold v-if="currentExpand" />
</el-icon>
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import {computed, ref, watch} from 'vue';
import {Expand, Fold, Refresh, Search} from '@element-plus/icons-vue';
import type {ElScrollbar} from 'element-plus';
interface PatientCardItem {
encounterId: string;
bedName?: string;
busNo?: string;
patientName?: string;
genderEnum_enumText?: string;
age?: number | string;
contractName?: string;
[key: string]: any;
}
interface Props {
// 过滤后的卡片数据
filteredCardData?: PatientCardItem[];
// 当前激活的卡片ID
activeCardId?: string;
// 是否展开(不传则为不受控模式,组件内部自己管理)
expand?: boolean;
// 加载状态
loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
filteredCardData: () => [],
activeCardId: '',
expand: undefined,
loading: false,
});
interface Emits {
(e: 'item-click', item: PatientCardItem): void;
(e: 'search', keyword: string): void;
(e: 'refresh'): void;
(e: 'update:expand', value: boolean): void;
}
const emit = defineEmits<Emits>();
const searchKeyword = ref<string>('');
const refreshing = ref<boolean>(false);
const refreshLoading = ref<boolean>(false);
const internalExpand = ref<boolean>(true);
const isControlled = computed<boolean>(() => props.expand !== undefined);
const currentExpand = computed<boolean>(() => {
return isControlled.value ? props.expand! : internalExpand.value;
});
const isLoading = computed<boolean>(() => {
return props.loading || refreshLoading.value;
});
const handleItemClick = (item: PatientCardItem): void => {
emit('item-click', item);
};
const handleSmallCardClick = (item: PatientCardItem): void => {
emit('item-click', item);
};
const handleSearch = (): void => {
emit('search', searchKeyword.value);
};
const handleRefresh = (): void => {
if (refreshing.value) return;
refreshing.value = true;
refreshLoading.value = true;
emit('refresh');
setTimeout(() => {
refreshing.value = false;
}, 600);
setTimeout(() => {
if (!props.loading) {
refreshLoading.value = false;
}
}, 500);
};
const updateExpand = (): void => {
const newValue = !currentExpand.value;
if (isControlled.value) {
emit('update:expand', newValue);
} else {
internalExpand.value = newValue;
}
};
// 根据状态枚举值返回文本颜色
const getStatusColor = (statusEnum?: number): string => {
if (statusEnum === undefined || statusEnum === null) {
return '';
}
switch (statusEnum) {
case 2: // REGISTERED - 待入科
return '#E6A23C'; // 橙色
case 3: // AWAITING_DISCHARGE - 待出院
return '#F56C6C'; // 红色
case 4: // DISCHARGED_FROM_HOSPITAL - 待出院结算
return '#909399'; // 灰色
case 5: // ADMITTED_TO_THE_HOSPITAL - 已入院
return '#67C23A'; // 绿色
case 6: // PENDING_TRANSFER - 待转科
return '#409EFF'; // 蓝色
default:
return '';
}
};
// 根据状态枚举值返回带透明背景的样式
const getStatusStyle = (statusEnum?: number): Record<string, string> => {
const color = getStatusColor(statusEnum);
if (!color) {
return {};
}
// 不同状态对应的半透明背景色
let backgroundColor = '';
switch (statusEnum) {
case 2: // 橙色
backgroundColor = 'rgba(230, 162, 60, 0.12)';
break;
case 3: // 红色
backgroundColor = 'rgba(245, 108, 108, 0.12)';
break;
case 4: // 灰色
backgroundColor = 'rgba(144, 147, 153, 0.12)';
break;
case 5: // 绿色
backgroundColor = 'rgba(103, 194, 58, 0.12)';
break;
case 6: // 蓝色
backgroundColor = 'rgba(64, 158, 255, 0.12)';
break;
default:
backgroundColor = 'rgba(148, 163, 184, 0.12)';
}
return {
color,
backgroundColor,
borderColor: 'transparent',
};
};
// 保险类型映射表
const insuranceTypeMap: Record<number, string> = {
310: '职工基本医疗保险',
320: '公务员医疗补助',
330: '大额医疗费用补助',
340: '离休人员医疗保障',
350: '一至六级残废军人医疗补助',
360: '老红军医疗保障',
370: '企业补充医疗保险',
380: '新型农村合作医疗',
390: '城乡居民基本医疗保险',
391: '城镇居民基本医疗保险',
392: '城乡居民大病医疗保险',
399: '其他特殊人员医疗保障',
410: '长期照护保险',
};
watch(
() => props.loading,
(newLoading) => {
if (!newLoading && refreshLoading.value) {
refreshLoading.value = false;
}
}
);
</script>
<style lang="scss" scoped>
.patient-card {
width: 100%;
overflow: visible; /* 改为visible以确保内容可见 */
background-color: #fff;
border-radius: 6px;
border: 1px solid rgba(0, 0, 0, 0.04);
box-shadow: 0 2px 6px 0 rgba(15, 35, 52, 0.12);
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgba(15, 35, 52, 0.18);
transform: translateY(-1px);
}
&.actived {
background-color: rgba(7, 155, 140, 0.06);
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px rgba(7, 155, 140, 0.3), 0 4px 14px rgba(7, 155, 140, 0.25);
}
.patient-card-header {
display: flex;
flex-direction: column;
padding: 10px 12px 4px;
overflow: visible; /* 确保内容可见 */
gap: 4px; /* 行与行之间的间距 */
.info-row {
display: flex;
width: 100%;
&.room-bed-row {
align-items: center;
font-weight: 600;
font-size: 16px;
color: #1f2933;
.bed-info {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
min-width: 0; /* 允许收缩 */
.house-name {
color: #1f2933;
font-weight: 600;
font-size: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bed-name {
color: #1f2933;
font-weight: 600;
font-size: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
&.insurance-row {
align-items: center;
margin-left: 8px; /* 添加一点左边距,与第一行对齐 */
}
&.busno-row {
color: #6b7280;
font-size: 12px;
align-items: center;
.bus-no {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
&.name-row {
align-items: center;
color: #111827;
.name {
color: #111827;
font-weight: 700 !important; /* 更粗的字体使用important确保优先级 */
font-size: 18px !important; /* 更大的字体使用important确保优先级 */
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
:deep(.el-text),
:deep(.el-text__inner) {
font-weight: 700 !important; /* 确保el-text组件也应用粗体 */
font-size: 18px !important; /* 确保el-text组件也应用更大字体 */
}
}
}
&.gender-age-row {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.age {
flex-shrink: 0;
.age-tag {
border-radius: 999px;
padding: 0 8px;
}
.age-tag-female {
border-color: rgb(255, 55, 158);
color: rgb(255, 126, 184);
}
}
.status-inline {
flex-shrink: 0;
margin-left: 8px;
}
}
}
.payer-tag {
max-width: 100%;
font-size: 12px;
border-radius: 999px;
font-weight: bolder;
}
.payer-tag-status {
font-weight: bolder;
border-radius: 999px;
}
/* 隐藏旧的布局结构因为已被新的info-row结构替代 */
.header-top,
.header-bottom,
.bed-container,
.tags-container {
display: none;
}
}
.doctor-parent-line {
margin: 6px 12px 0;
border-bottom: 1px dashed #e5e7eb;
}
.patient-card-body {
padding: 8px 12px 10px;
.personal-info-container {
display: flex;
flex-direction: column;
gap: 4px;
.name-container {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
display: none; /* 新的布局中name-container已在header中显示这里隐藏 */
.name {
color: #111827;
font-weight: 600;
font-size: 16px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.age {
flex-shrink: 0;
.age-tag {
border-radius: 999px;
padding: 0 8px;
}
.age-tag-female {
border-color: rgb(255, 55, 158);
color: rgb(255, 126, 184);
}
}
}
.admission-info, .attending-doctor {
display: flex;
font-size: 12px;
color: #6b7280;
margin-top: 2px;
.admission-date, .doctor-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
}
.patientList-container {
height: 100%;
display: flex;
flex-direction: column;
border-right: 1px solid #ebeef5;
background-color: #ffffff;
width: 240px;
min-width: 240px;
&-unexpand {
width: 84px;
min-width: 84px;
}
.patientList-operate {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 44px;
border-bottom: 1px solid #ebeef5;
flex: none;
padding: 0 0 0 8px;
&-unexpand {
justify-content: space-around;
}
}
.patientList-list {
display: flex;
flex: 1;
flex-direction: column;
height: 0;
width: 240px;
&-unexpand {
width: 84px;
}
.patient-cards {
flex: 1;
padding: 0 8px;
overflow: hidden;
:deep(.patient-cards-scrollbar) {
width: 100%;
height: 100%;
.el-scrollbar__bar {
width: 0;
}
}
}
.card-small {
height: 44px;
padding: 0 10px 0 12px;
overflow: hidden;
font-size: 14px;
line-height: 44px;
white-space: nowrap;
text-overflow: ellipsis;
border-right: none;
cursor: pointer;
}
.patient-active {
background-color: #e6f7ff;
font-weight: 600;
color: var(--el-color-primary);
}
.patient-card-small-border {
display: block;
width: 100%;
height: 2px;
background-color: #f1faff;
}
}
}
.patientList-toggle-btn-wrap {
display: flex;
justify-content: flex-end;
padding: 4px 16px 8px;
&-unexpand {
justify-content: center;
}
}
.patient-list-toggle-enter-active,
.patient-list-toggle-leave-active {
transition: transform 0.2s ease;
}
.patient-list-toggle-enter-from,
.patient-list-toggle-leave-to {
opacity: 0;
transform: translateY(-8px);
}
.card-tooltip {
display: inline-flex;
align-items: center;
justify-content: space-between;
min-width: 140px;
max-width: 220px;
padding: 6px 10px;
border-radius: 6px;
background-color: #ffffff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.card-tooltip-main {
display: flex;
flex-direction: column;
margin-right: 8px;
}
.card-tooltip-bed {
font-size: 15px;
font-weight: 600;
color: #111827;
margin-bottom: 2px;
}
.card-tooltip-name {
font-size: 13px;
color: #4b5563;
}
.card-tooltip-sex {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
}
.card-tooltip-sex-text {
font-size: 12px;
color: #6b7280;
}
.svg-gray {
fill: var(--hip-color-text-unit);
}
.icon-btn {
border: none;
background-color: transparent;
box-shadow: none;
padding: 4px;
}
.is-rotating {
animation: patient-refresh-rotate 0.6s linear;
}
@keyframes patient-refresh-rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
:deep(.scrollbar) {
width: 100%;
height: 100%;
.el-scrollbar__bar {
width: 0;
}
}
.f-16 {
font-weight: 600;
font-size: 16px;
}
.f-14 {
font-size: 14px;
}
.empty-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
.empty-text-sty {
margin-top: 0;
}
}
</style>