Files
his/openhis-ui-vue3/src/views/index.vue
chenqi 5534a71c7d feat(menu): 优化菜单服务性能并新增医生排班功能
- 添加菜单缓存注解以提升查询性能
- 实现菜单完整路径计算优化,解决 N+1 查询问题
- 新增 selectAllMenus 方法供路径计算使用
- 添加今日医生排班查询功能
- 重构前端图标显示逻辑,使用 SVG 图标替代 Element 图标
- 添加前端菜单数据本地缓存机制
- 更新菜单管理界面的表单组件绑定方式
- 新增预约管理、门诊管理和药房管理路由配置
2026-02-02 08:46:33 +08:00

1416 lines
43 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="home-container">
<!-- 顶部欢迎区域 -->
<div class="welcome-section">
<div class="welcome-content">
<div class="greeting">
<h1>{{ getGreeting() }}{{ userStore.nickName || userStore.name }}</h1>
<p>欢迎使用{{ userStore.hospitalName || '医院' }}管理系统</p>
</div>
<div class="role-badge" v-if="userStore.roles.length > 0">
<el-tag :type="getRoleTagType(userStore.roles[0])" size="large">
{{ getRoleName(userStore.roles[0]) }}
</el-tag>
</div>
</div>
</div>
<!-- 关键数据统计 -->
<div class="stats-grid">
<div
v-for="stat in currentStats"
:key="stat.key"
class="stat-card"
:class="`stat-${stat.type}`"
@click="handleStatClick(stat)"
>
<div class="stat-icon">
<el-icon :size="36" :color="stat.iconColor">
<component :is="stat.icon" />
</el-icon>
</div>
<div class="stat-content">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
<div class="stat-trend" v-if="stat.trend">
<span :class="stat.trend > 0 ? 'trend-up' : 'trend-down'">
{{ stat.trend > 0 ? '↑' : '↓' }} {{ Math.abs(stat.trend) }}%
</span>
<span class="trend-label">较昨日</span>
</div>
</div>
</div>
</div>
<!-- 快捷功能入口 -->
<div class="quick-access-section">
<div class="section-header">
<h3>快捷功能</h3>
<div>
<el-button text type="primary" @click="showConfig">配置</el-button>
</div>
</div>
<div v-loading="quickAccessLoading" element-loading-text="加载快捷功能...">
<div class="quick-access-grid">
<div
v-for="func in quickAccess"
:key="func.key"
class="quick-access-card"
@click="handleQuickAccess(func)"
>
<div class="quick-icon">
<svg-icon :icon-class="func.icon" :style="{ fontSize: '28px', color: func.iconColor }" />
</div>
<div class="quick-label">{{ func.label }}</div>
</div>
</div>
</div>
</div>
<!-- 待办事项 -->
<div class="todo-section">
<div class="section-header">
<h3>待办事项</h3>
<el-badge :value="todoList.length" class="todo-badge">
<el-button text type="primary" @click="showAllTodos">查看全部</el-button>
</el-badge>
</div>
<div class="todo-list">
<div
v-for="todo in todoList.slice(0, 5)"
:key="todo.id"
class="todo-item"
:class="`priority-${todo.priority}`"
@click="handleTodoClick(todo)"
>
<div class="todo-icon">
<el-icon :size="20">
<component :is="todo.icon" />
</el-icon>
</div>
<div class="todo-content">
<div class="todo-title">{{ todo.title }}</div>
<div class="todo-desc">{{ todo.desc }}</div>
</div>
<div class="todo-time">{{ todo.time }}</div>
</div>
<div v-if="todoList.length === 0" class="empty-todo">
<el-empty description="暂无待办事项" :image-size="60" />
</div>
</div>
</div>
<!-- 今日日程 -->
<div class="schedule-section">
<div class="section-header">
<h3>今日日程</h3>
<el-button text type="primary" @click="showSchedule">管理日程</el-button>
</div>
<div class="schedule-list">
<div
v-for="item in scheduleList"
:key="item.id"
class="schedule-item"
>
<div class="schedule-time">{{ item.time }}</div>
<div class="schedule-content">
<div class="schedule-title">{{ item.title }}</div>
<div class="schedule-location">
<el-icon><Location /></el-icon>
<span>{{ item.location }}</span>
</div>
</div>
<el-tag :type="item.type" size="small">{{ item.tag }}</el-tag>
</div>
</div>
</div>
</div>
</template>
<script setup name="Home">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { markRaw } from 'vue'
import useUserStore from '@/store/modules/user'
import { getHomeStatistics, getPendingEmrCount } from '@/api/home'
import { listTodo } from '@/api/workflow/task.js'
import { getCurrentUserConfig } from '@/api/system/userConfig'
import { listMenu, getMenuFullPath } from '@/api/system/menu'
import { getTodayDoctorScheduleList } from '@/api/appointmentmanage/doctorSchedule'
import { ElDivider } from 'element-plus'
import {
User,
Document,
Calendar,
Money,
Warning,
TrendCharts,
Monitor,
Clock,
ChatDotRound,
Files,
Box,
Operation,
Location,
Notification,
DataLine,
ShoppingCart,
Wallet,
Van,
Bell,
Setting,
Search,
Menu,
Grid,
Folder,
Tickets,
ChatDotSquare,
Histogram,
OfficeBuilding,
Postcard,
Collection,
VideoPlay,
Camera,
Headset,
Phone,
Message,
ChatLineSquare,
ChatRound,
Guide,
Help,
InfoFilled,
CircleCheck,
CircleClose,
QuestionFilled,
Star,
Link,
Position,
Picture,
Upload,
Download,
CaretLeft,
CaretRight,
More,
Close,
Check,
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight,
Plus,
Minus,
ZoomIn,
ZoomOut,
Refresh,
Edit,
Delete,
Share,
View,
SwitchButton,
Hide,
Finished,
CirclePlus,
Remove,
CircleCheckFilled,
CircleCloseFilled,
WarningFilled,
Goods
} from '@element-plus/icons-vue'
// 为别名单独导入
import { InfoFilled as InfoFilledIcon, QuestionFilled as QuestionFilledIcon, SuccessFilled } from '@element-plus/icons-vue'
import SvgIcon from '@/components/SvgIcon'
const userStore = useUserStore()
const router = useRouter()
// 统计数据(从后端获取)
const statisticsData = ref({
totalPatients: 0,
yesterdayPatients: 0,
patientTrend: 0,
todayRevenue: '¥ 0',
yesterdayRevenue: '¥ 0',
revenueTrend: 0,
todayAppointments: 0,
yesterdayAppointments: 0,
appointmentTrend: 0,
pendingApprovals: 0
})
// 不同角色的统计数据配置
const roleStatsConfig = {
admin: [
{ key: 'totalPatients', label: '在院患者', icon: markRaw(User), type: 'primary', iconColor: '#409eff' },
{ key: 'todayRevenue', label: '今日收入', icon: markRaw(Money), type: 'success', iconColor: '#67c23a' },
{ key: 'appointments', label: '今日预约', icon: markRaw(Calendar), type: 'warning', iconColor: '#e6a23c' },
{ key: 'pendingApprovals', label: '待审核', icon: markRaw(Document), type: 'danger', iconColor: '#f56c6c' }
],
doctor: [
{ key: 'myPatients', label: '我的患者', icon: markRaw(User), type: 'primary', iconColor: '#409eff' },
{ key: 'todayAppointments', label: '今日门诊', icon: markRaw(Calendar), type: 'success', iconColor: '#67c23a' },
{ key: 'pendingEmr', label: '待写病历', icon: markRaw(Document), type: 'warning', iconColor: '#e6a23c' },
{ key: 'prescriptions', label: '今日处方', icon: markRaw(Box), type: 'info', iconColor: '#909399' }
],
nurse: [
{ key: 'wardPatients', label: '病房患者', icon: markRaw(User), type: 'primary', iconColor: '#409eff' },
{ key: 'todayTreatments', label: '今日治疗', icon: markRaw(Operation), type: 'success', iconColor: '#67c23a' },
{ key: 'vitalSigns', label: '待测体征', icon: markRaw(Monitor), type: 'warning', iconColor: '#e6a23c' },
{ key: 'drugDistribution', label: '发药次数', icon: markRaw(Box), type: 'info', iconColor: '#909399' }
],
pharmacist: [
{ key: 'todayPrescriptions', label: '今日处方', icon: markRaw(Box), type: 'primary', iconColor: '#409eff' },
{ key: 'pendingReview', label: '待审核', icon: markRaw(Document), type: 'warning', iconColor: '#e6a23c' },
{ key: 'outOfStock', label: '缺货药品', icon: markRaw(Warning), type: 'danger', iconColor: '#f56c6c' },
{ key: 'nearExpiry', label: '近效期', icon: markRaw(Clock), type: 'info', iconColor: '#909399' }
],
cashier: [
{ key: 'todayPayments', label: '今日缴费', icon: markRaw(Money), type: 'primary', iconColor: '#409eff' },
{ key: 'refundRequests', label: '退费申请', icon: markRaw(Document), type: 'warning', iconColor: '#e6a23c' },
{ key: 'pendingInvoices', label: '待开发票', icon: markRaw(Files), type: 'info', iconColor: '#909399' },
{ key: 'insuranceClaims', label: '医保结算', icon: markRaw(Wallet), type: 'success', iconColor: '#67c23a' }
]
}
// 从数据库获取用户配置的快捷功能ID列表
const getUserQuickAccessConfig = async () => {
try {
// 优先从数据库获取配置
const response = await getCurrentUserConfig('homeFeaturesConfig');
console.log('从数据库获取的配置响应:', response);
// 检查响应结构,数据可能在 data 或 msg 字段中
let configData = null;
if (response.code === 200) {
if (response.data) {
configData = response.data;
} else if (response.msg) {
// 如果数据在 msg 字段中可能是URL编码的
configData = response.msg;
}
}
if (configData) {
// 解码配置值如果是URL编码的
let decodedData = configData;
try {
decodedData = decodeURIComponent(configData);
} catch (decodeError) {
console.warn('解码配置数据失败,使用原始数据:', decodeError);
decodedData = configData;
}
console.log('解码后的配置数据:', decodedData);
const parsedData = JSON.parse(decodedData);
console.log('解析后的配置数据:', parsedData);
return parsedData;
}
} catch (error) {
console.error('从数据库获取用户配置失败:', error);
}
// 如果数据库中没有配置,尝试从本地存储获取
try {
const savedConfig = localStorage.getItem('homeFeaturesConfig');
console.log('从本地存储获取的配置:', savedConfig);
if (savedConfig) {
const parsedData = JSON.parse(savedConfig);
console.log('从本地存储解析的配置数据:', parsedData);
return parsedData;
}
} catch (error) {
console.error('从本地存储获取用户配置失败:', error);
}
// 如果没有配置,返回空数组
console.log('没有找到用户配置,返回空数组');
return [];
};
// 响应式数据存储快捷访问功能
const quickAccessData = ref([]);
// 根据用户配置获取快捷功能
const quickAccess = computed(() => {
return quickAccessData.value;
});
// 添加 loading 状态
const quickAccessLoading = ref(false);
// 异步加载用户配置
const loadUserQuickAccessConfig = async () => {
quickAccessLoading.value = true;
try {
console.log('开始加载用户快捷访问配置...');
const userConfig = await getUserQuickAccessConfig();
console.log('获取到的用户配置:', userConfig);
// 如果用户没有配置任何功能,返回默认配置
if (!userConfig || userConfig.length === 0) {
console.log('用户没有配置任何功能,使用默认配置');
quickAccessData.value = getDefaultQuickAccessConfig();
return;
}
// 如果用户配置了功能ID列表需要从菜单中获取详细信息
console.log('开始转换菜单ID为快捷访问格式...');
const convertedFeatures = await convertMenuIdsToQuickAccess(userConfig);
console.log('转换后的功能:', convertedFeatures);
// 如果转换后没有功能可能是因为配置的ID没有对应的菜单项返回默认配置
if (!convertedFeatures || convertedFeatures.length === 0) {
console.log('转换后没有功能,使用默认配置');
quickAccessData.value = getDefaultQuickAccessConfig();
return;
}
console.log('设置最终的快捷访问数据:', convertedFeatures);
quickAccessData.value = convertedFeatures;
} catch (error) {
console.error('加载用户快捷访问配置失败:', error);
// 出错时使用默认配置
quickAccessData.value = getDefaultQuickAccessConfig();
} finally {
quickAccessLoading.value = false;
}
};
// 将菜单ID转换为快捷访问格式
const convertMenuIdsToQuickAccess = async (menuIds) => {
if (!menuIds || menuIds.length === 0) {
return [];
}
try {
// 检查 menuIds 是否已经是包含完整路径的对象数组(新格式)
if (menuIds.length > 0 && typeof menuIds[0] === 'object') {
// 检查是否包含 fullPath 属性(新格式)或 menuId 属性(新格式但可能还没获取完整路径)
if (menuIds[0].hasOwnProperty('fullPath') || menuIds[0].hasOwnProperty('menuId')) {
// 如果是新格式,直接转换为快捷功能格式
return menuIds.map(menuItem => {
// 如果没有 fullPath使用 path 作为备选
let route = menuItem.fullPath || menuItem.path;
// 确保路径格式正确,去除多余的斜杠
if (route && typeof route === 'string') {
// 将多个连续的斜杠替换为单个斜杠,但保留协议部分的双斜杠(如 http://
route = route.replace(/([^:])\/{2,}/g, '$1/');
}
return {
key: menuItem.menuId,
label: menuItem.menuName,
icon: menuItem.icon || 'document', // 使用数据库中的图标类名
iconColor: menuItem.iconColor || getIconColorByMenuType(menuItem.menuType) || '#67C23A', // 优先使用数据库中的颜色,否则使用菜单类型的颜色
route: route
};
}).filter(item => item.route); // 过滤掉 route 为空的项
}
}
// 如果是旧格式仅包含菜单ID的数组则按原方式处理
// 获取所有菜单数据
const response = await listMenu({});
if (response.code === 200) {
const allMenus = response.data;
// 扁平化菜单树结构,获取所有叶子节点(菜单项)
const flatMenus = flattenMenuTree(allMenus);
// 根据用户选择的菜单ID过滤并返回对应的功能对象
const menuPromises = menuIds.map(async (id) => {
// 查找匹配的菜单项
const matchedMenu = flatMenus.find(menu => menu.menuId == id);
if (matchedMenu) {
// 获取完整路径
try {
const fullPathResponse = await getMenuFullPath(matchedMenu.menuId);
// 确保返回的路径不为空
const fullPath = (fullPathResponse.code === 200 && fullPathResponse.data)
? fullPathResponse.data
: (matchedMenu.path || matchedMenu.fullPath);
// 将菜单数据转换为快捷功能格式
return {
key: matchedMenu.perms || matchedMenu.path || `menu_${matchedMenu.menuId}`,
label: matchedMenu.menuName,
icon: matchedMenu.icon || 'document', // 使用数据库中的图标类名
iconColor: matchedMenu.iconColor || getIconColorByMenuType(matchedMenu.menuType),
route: fullPath || matchedMenu.path // 确保 route 不为空
};
} catch (error) {
console.error(`获取菜单 ${matchedMenu.menuName} 的完整路径失败:`, error);
// 如果获取完整路径失败,使用现有路径作为备选
return {
key: matchedMenu.perms || matchedMenu.path || `menu_${matchedMenu.menuId}`,
label: matchedMenu.menuName,
icon: matchedMenu.icon || 'document', // 使用数据库中的图标类名
iconColor: matchedMenu.iconColor || getIconColorByMenuType(matchedMenu.menuType),
route: matchedMenu.path || matchedMenu.fullPath || '/' // 确保 route 不为空
};
}
}
return null;
});
// 等待所有完整路径获取完成
const convertedMenus = await Promise.all(menuPromises);
// 过滤掉 route 为空的项和未找到的菜单ID
return convertedMenus.filter(item => item !== null && item.route);
}
} catch (error) {
console.error('获取菜单数据失败:', error);
}
// 如果获取失败,返回空数组
return [];
};
// 将菜单树结构扁平化
const flattenMenuTree = (menuTree) => {
const result = [];
const flatten = (items) => {
items.forEach(item => {
// 只处理菜单类型为'C'(菜单)的项目,忽略目录('M')和按钮('F')
if (item.menuType === 'C') {
result.push(item);
}
// 递归处理子菜单
if (item.children && item.children.length > 0) {
flatten(item.children);
}
});
};
flatten(menuTree);
return result;
};
// 根据菜单类型获取图标颜色
const getIconColorByMenuType = (menuType) => {
if (menuType === 'M') return '#409EFF'; // 目录蓝色
if (menuType === 'C') return '#67C23A'; // 菜单绿色
if (menuType === 'F') return '#E6A23C'; // 按钮橙色
return '#909399'; // 默认灰色
};
// 获取默认快捷功能配置
const getDefaultQuickAccessConfig = () => {
// 根据不同角色返回默认配置
const role = userStore.roles[0] || 'admin';
switch (role) {
case 'doctor':
return [
{ key: 'outpatient', label: '门诊接诊', icon: 'chat-dot-round', iconColor: '#409eff', route: '/doctorstation' },
{ key: 'emr', label: '病历管理', icon: 'document', iconColor: '#67c23a', route: '/doctorstation/doctorphrase' },
{ key: 'prescription', label: '开立处方', icon: 'box', iconColor: '#e6a23c', route: '/clinicmanagement/ePrescribing' },
{ key: 'history', label: '历史处方', icon: 'clock', iconColor: '#f56c6c', route: '/clinicmanagement/historicalPrescription' },
{ key: 'schedule', label: '排班管理', icon: 'calendar', iconColor: '#909399', route: '/appoinmentmanage/deptManage' },
{ key: 'inquiry', label: '患者查询', icon: 'search', iconColor: '#409eff', route: '/patientmanagement' }
];
case 'nurse':
return [
{ key: 'ward', label: '病房管理', icon: 'user', iconColor: '#409eff', route: '/inpatientNurse/inpatientNurseStation' },
{ key: 'execution', label: '医嘱执行', icon: 'operation', iconColor: '#67c23a', route: '/inpatientNurse/medicalOrderExecution' },
{ key: 'proofread', label: '医嘱核对', icon: 'document', iconColor: '#e6a23c', route: '/inpatientNurse/medicalOrderProofread' },
{ key: 'drugCollect', label: '领药管理', icon: 'box', iconColor: '#f56c6c', route: '/inpatientNurse/medicineCollect' },
{ key: 'tpr', label: '体温单', icon: 'monitor', iconColor: '#909399', route: '/inpatientNurse/tprsheet' },
{ key: 'nursing', label: '护理记录', icon: 'chat-dot-round', iconColor: '#409eff', route: '/inpatientNurse/nursingRecord' }
];
case 'pharmacist':
return [
{ key: 'dispensing', label: '发药管理', icon: 'box', iconColor: '#409eff', route: '/pharmacymanagement' },
{ key: 'prescription', label: '处方审核', icon: 'document', iconColor: '#67c23a', route: '/pharmacymanagement' },
{ key: 'inventory', label: '库存管理', icon: 'van', iconColor: '#e6a23c', route: '/medicineStorage' },
{ key: 'purchase', label: '采购管理', icon: 'shopping-cart', iconColor: '#f56c6c', route: '/medicineStorage' },
{ key: 'warning', label: '效期预警', icon: 'warning', iconColor: '#f56c6c', route: '/medicationmanagement/statisticalManagement/statisticalManagement' },
{ key: 'statistics', label: '用药统计', icon: 'data-line', iconColor: '#909399', route: '/monitor' }
];
case 'cashier':
return [
{ key: 'registration', label: '挂号收费', icon: 'money', iconColor: '#409eff', route: '/charge/outpatientregistration' },
{ key: 'clinicCharge', label: '门诊收费', icon: 'wallet', iconColor: '#67c23a', route: '/charge/cliniccharge' },
{ key: 'refund', label: '退费管理', icon: 'document', iconColor: '#e6a23c', route: '/charge/clinicrefund' },
{ key: 'invoice', label: '发票打印', icon: 'files', iconColor: '#f56c6c', route: '/basicmanage/InvoiceManagement' },
{ key: 'record', label: '收费记录', icon: 'clock', iconColor: '#909399', route: '/charge/clinicRecord' },
{ key: 'insurance', label: '医保结算', icon: 'bell', iconColor: '#409eff', route: '/ybmanagement' }
];
default: // admin
return [
{ key: 'patient', label: '患者管理', icon: 'user', iconColor: '#409eff', route: '/patient/patientmgr' },
{ key: 'appointment', label: '预约管理', icon: 'calendar', iconColor: '#67c23a', route: '/appoinmentmanage' },
{ key: 'doctor', label: '医生管理', icon: 'user', iconColor: '#e6a23c', route: '/doctorstation' },
{ key: 'surgery', label: '手术管理', icon: 'operation', iconColor: '#f56c6c', route: '/surgerymanage' },
{ key: 'drug', label: '药品管理', icon: 'box', iconColor: '#909399', route: '/pharmacymanagement' },
{ key: 'statistic', label: '数据统计', icon: 'trend-charts', iconColor: '#409eff', route: '/monitor' }
];
}
};
// 待办事项
const todoList = ref([])
// 更新待办事项中的待写病历数量
const updatePendingEmrTodo = () => {
const pendingEmrTodo = todoList.value.find(item => item.title === '待写病历' || item.desc?.includes('患者等待写病历'));
if (pendingEmrTodo) {
pendingEmrTodo.desc = `${statisticsData.value.pendingEmr || 0}个患者等待写病历`;
}
}
// 今日日程
const scheduleList = ref([])
// 获取问候语
const getGreeting = () => {
const hour = new Date().getHours()
if (hour < 6) return '夜深了'
if (hour < 9) return '早上好'
if (hour < 12) return '上午好'
if (hour < 14) return '中午好'
if (hour < 17) return '下午好'
if (hour < 19) return '傍晚好'
return '晚上好'
}
// 获取角色名称
const getRoleName = (role) => {
const roleMap = {
admin: '管理员',
doctor: '医生',
nurse: '护士',
pharmacist: '药剂师',
cashier: '收费员'
}
return roleMap[role] || role
}
// 获取角色标签类型
const getRoleTagType = (role) => {
const typeMap = {
admin: 'danger',
doctor: 'primary',
nurse: 'success',
pharmacist: 'warning',
cashier: 'info'
}
return typeMap[role] || ''
}
// 根据角色获取当前统计数据
const currentStats = computed(() => {
const role = userStore.roles[0] || 'admin'
const baseConfig = roleStatsConfig[role] || roleStatsConfig.admin
// 合并配置和实际数据
return baseConfig.map(stat => {
const statWith = { ...stat }
// 根据不同的 key 获取对应的值
switch (stat.key) {
case 'totalPatients':
statWith.value = statisticsData.value.totalPatients;
statWith.trend = statisticsData.value.patientTrend;
break;
case 'todayRevenue':
statWith.value = statisticsData.value.todayRevenue;
statWith.trend = statisticsData.value.revenueTrend;
break;
case 'appointments':
statWith.value = statisticsData.value.todayAppointments;
statWith.trend = statisticsData.value.appointmentTrend;
break;
case 'pendingApprovals':
statWith.value = statisticsData.value.pendingApprovals;
break;
case 'myPatients':
statWith.value = statisticsData.value.totalPatients;
statWith.trend = statisticsData.value.patientTrend;
break;
case 'todayAppointments':
statWith.value = statisticsData.value.todayAppointments;
statWith.trend = statisticsData.value.appointmentTrend;
break;
case 'pendingEmr':
statWith.value = statisticsData.value.pendingEmr;
break;
case 'prescriptions':
statWith.value = statisticsData.value.todayAppointments;
statWith.trend = statisticsData.value.appointmentTrend;
break;
case 'wardPatients':
statWith.value = statisticsData.value.totalPatients;
statWith.trend = statisticsData.value.patientTrend;
break;
case 'todayTreatments':
statWith.value = statisticsData.value.todayAppointments;
statWith.trend = statisticsData.value.appointmentTrend;
break;
case 'vitalSigns':
statWith.value = statisticsData.value.pendingApprovals;
break;
case 'drugDistribution':
statWith.value = statisticsData.value.todayAppointments;
statWith.trend = statisticsData.value.appointmentTrend;
break;
case 'todayPrescriptions':
statWith.value = statisticsData.value.todayAppointments;
statWith.trend = statisticsData.value.appointmentTrend;
break;
case 'pendingReview':
statWith.value = statisticsData.value.pendingApprovals;
break;
case 'outOfStock':
statWith.value = statisticsData.value.pendingApprovals;
break;
case 'nearExpiry':
statWith.value = statisticsData.value.pendingApprovals;
break;
case 'todayPayments':
statWith.value = statisticsData.value.todayRevenue;
statWith.trend = statisticsData.value.revenueTrend;
break;
case 'refundRequests':
statWith.value = statisticsData.value.pendingApprovals;
break;
case 'pendingInvoices':
statWith.value = statisticsData.value.pendingApprovals;
break;
case 'insuranceClaims':
statWith.value = statisticsData.value.todayAppointments;
statWith.trend = statisticsData.value.appointmentTrend;
break;
default:
statWith.value = '0';
statWith.trend = 0;
}
return statWith
})
})
// 处理统计卡片点击
const handleStatClick = (stat) => {
console.log('Stat clicked:', stat)
// 根据不同的统计项跳转到相应的详情页面
if (stat.key === 'totalPatients' || stat.key === 'myPatients' || stat.key === 'wardPatients') {
// 在院患者/我的患者/病房患者 - 跳转到患者管理页面
router.push('/patient/patientmgr')
} else if (stat.key === 'todayRevenue' || stat.key === 'todayPayments') {
// 跳转到收费页面
router.push('/charge/cliniccharge')
} else if (stat.key === 'appointments') {
// 跳转到预约管理页面
router.push('/appoinmentmanage')
} else if (stat.key === 'todayAppointments') {
// 跳转到今日门诊模块
router.push('/doctorstation/today-outpatient')
} else if (stat.key === 'pendingApprovals' || stat.key === 'pendingReview') {
// 跳转到待审核页面
router.push('/clinicmanagement/ePrescribing')
} else if (stat.key === 'pendingEmr') {
// 跳转到待写病历页面
router.push('/doctorstation/pending-emr')
}
}
// 处理快捷功能点击
const handleQuickAccess = (func) => {
if (func.route) {
// 检查是否为外部链接
if (func.route.startsWith('http://') || func.route.startsWith('https://')) {
// 如果是外部链接,使用 window.open 打开
window.open(func.route, '_blank');
} else {
// 确保路径格式正确,去除多余的斜杠
let normalizedPath = func.route;
if (normalizedPath && typeof normalizedPath === 'string') {
// 将多个连续的斜杠替换为单个斜杠,但保留协议部分的双斜杠(如 http://
normalizedPath = normalizedPath.replace(/([^:])\/{2,}/g, '$1/');
}
// 确保内部路径以 / 开头,以保证正确的路由跳转
if (!normalizedPath.startsWith('/')) {
normalizedPath = '/' + normalizedPath;
}
try {
router.push(normalizedPath);
} catch (error) {
console.error('路由跳转失败:', error);
// 如果路径跳转失败,尝试使用原始路径
router.push(func.route);
}
}
} else {
console.warn('快捷功能没有配置路由路径:', func);
}
}
// 处理待办事项点击
const handleTodoClick = (todo) => {
console.log('Todo clicked:', todo)
// 跳转到相应的处理页面
if (todo.id === 6) { // 待写病历
router.push('/doctorstation?tab=pendingEmr')
}
}
// 显示功能配置
const showConfig = () => {
// 跳转到功能配置页面
router.push('/features/config')
}
// 显示全部待办
const showAllTodos = () => {
// 跳转到待办事项页面
router.push('/todo')
}
// 管理日程
const showSchedule = () => {
// 跳转到日程管理页面
}
// 获取统计数据
const fetchStatsData = async () => {
try {
const res = await getHomeStatistics()
if (res.data) {
statisticsData.value = res.data
console.log('统计数据:', statisticsData.value)
}
} catch (error) {
console.error('获取统计数据失败:', error)
}
try {
// 获取待写病历数量
const pendingEmrRes = await getPendingEmrCount();
if (pendingEmrRes.code === 200) {
// 确保统计数据对象中有pendingEmr字段
if (!statisticsData.value) {
statisticsData.value = {}
}
statisticsData.value.pendingEmr = pendingEmrRes.data || 0;
} else {
statisticsData.value.pendingEmr = 0;
}
// 更新待办事项中的待写病历数量
updatePendingEmrTodo();
} catch (error) {
console.error('获取待写病历数量失败:', error)
// 确保统计数据对象中有pendingEmr字段
if (!statisticsData.value) {
statisticsData.value = {}
}
statisticsData.value.pendingEmr = 0;
// 更新待办事项中的待写病历数量
updatePendingEmrTodo();
}
}
// 获取待办事项实际应用中应该从API获取
const fetchTodoList = async () => {
// TODO: 调用API获取真实数据
console.log('Fetching todo list...')
try {
const response = await listTodo({ pageNum: 1, pageSize: 5 })
if (response.code === 200) {
// 将工作流任务数据转换为待办事项格式
const rows = response.rows || [];
const todoData = rows.slice(0, 5).map((task, index) => ({
id: task.id || index,
title: task.taskName || task.name || '待办事项',
desc: task.description || '暂无描述',
priority: getPriorityFromTask(task),
status: getTaskStatus(task.status || task.state),
icon: getTaskIcon(task.category),
time: parseTime(task.createTime || task.createTimeStr, '{y}-{m}-{d} {h}:{i}'),
taskInfo: task // 保存原始任务信息,便于后续处理
}))
// 检查是否已经有"待写病历"任务,如果没有则添加
const hasPendingEmrTask = todoData.some(task => task.title === '待写病历' || task.desc?.includes('患者等待写病历'));
if (!hasPendingEmrTask && statisticsData.value.pendingEmr > 0) {
// 添加待写病历任务
const pendingEmrTask = {
id: Date.now(), // 使用时间戳作为唯一ID
title: '待写病历',
desc: `${statisticsData.value.pendingEmr || 0}个患者等待写病历`,
priority: 'high',
icon: markRaw(Document),
time: '刚刚',
taskInfo: null
};
// 如果数组未满5个添加到末尾否则替换最后一个
if (todoData.length < 5) {
todoData.push(pendingEmrTask);
} else {
todoData[4] = pendingEmrTask;
}
}
todoList.value = todoData;
}
} catch (error) {
console.error('获取待办事项失败:', error)
// 如果获取真实数据失败,保留空数组,但模块框架仍会显示
todoList.value = [];
}
}
// 根据任务信息确定优先级
const getPriorityFromTask = (task) => {
// 根据任务的某些属性来确定优先级,这里可以根据实际业务调整
if (task.priority && task.priority > 50) return 'high'
if (task.priority && task.priority > 20) return 'medium'
return 'low'
}
// 获取任务状态
const getTaskStatus = (status) => {
// 根据实际返回的状态值映射
if (status === 'completed' || status === 'finish') return 'completed'
if (status === 'processing' || status === 'active') return 'processing'
return 'pending'
}
// 获取任务图标
const getTaskIcon = (category) => {
// 根据任务分类确定图标
if (category && category.includes('approval')) return markRaw(Document)
if (category && category.includes('patient')) return markRaw(User)
if (category && category.includes('feedback')) return markRaw(ChatDotRound)
if (category && category.includes('warning')) return markRaw(Warning)
if (category && category.includes('report')) return markRaw(Files)
if (category && category.includes('data')) return markRaw(DataLine)
if (category && category.includes('operation')) return markRaw(Operation)
if (category && category.includes('system')) return markRaw(Setting)
return markRaw(Document) // 默认图标
}
// 获取日程数据实际应用中应该从API获取
const fetchScheduleList = async () => {
try {
console.log('Fetching schedule list...')
const response = await getTodayDoctorScheduleList()
if (response.code === 200) {
// 将API返回的数据转换为前端所需的格式
const scheduleData = response.data.map((schedule, index) => {
// 根据排班类型设置标签类型
let tagType = 'info'
if (schedule.weekday) {
tagType = schedule.weekday.toLowerCase()
} else if (schedule.timePeriod) {
tagType = schedule.timePeriod.toLowerCase()
}
// 确定标题
const title = schedule.doctor ? `${schedule.doctor}医生排班` : '医生排班'
// 确定位置
const location = schedule.clinic || schedule.deptId || '未知科室'
return {
id: schedule.id || index,
time: schedule.startTime || '未知时间',
title: title,
location: location,
type: tagType,
tag: schedule.timePeriod || '排班'
}
})
// 更新日程列表
scheduleList.value = scheduleData
} else {
console.error('获取排班信息失败:', response.msg)
// 如果API调用失败使用默认数据
scheduleList.value = [
{ id: 1, time: '09:00', title: '科室晨会', location: '第一会议室', type: 'info', tag: '日常' },
{ id: 2, time: '10:30', title: '病例讨论', location: '第二会议室', type: 'primary', tag: '会议' },
{ id: 3, time: '14:00', title: '专家查房', location: '住院部3楼', type: 'warning', tag: '重要' },
{ id: 4, time: '16:00', title: '新药培训', location: '培训中心', type: 'success', tag: '培训' }
]
}
} catch (error) {
console.error('获取排班信息异常:', error)
// 如果出现异常,使用默认数据
scheduleList.value = [
{ id: 1, time: '09:00', title: '科室晨会', location: '第一会议室', type: 'info', tag: '日常' },
{ id: 2, time: '10:30', title: '病例讨论', location: '第二会议室', type: 'primary', tag: '会议' },
{ id: 3, time: '14:00', title: '专家查房', location: '住院部3楼', type: 'warning', tag: '重要' },
{ id: 4, time: '16:00', title: '新药培训', location: '培训中心', type: 'success', tag: '培训' }
]
}
}
// 监听本地存储变化,以便在其他标签页或窗口中修改配置后更新当前页面
const handleStorageChange = (event) => {
if (event.key === 'homeFeaturesConfig') {
console.log('检测到快捷功能配置更新,正在重新加载...');
loadUserQuickAccessConfig();
}
};
// 监听配置更新事件
const handleConfigUpdate = () => {
console.log('检测到快捷功能配置更新事件,正在重新加载...');
loadUserQuickAccessConfig();
};
onMounted(async () => {
fetchStatsData()
await fetchTodoList()
fetchScheduleList()
await loadUserQuickAccessConfig()
// 添加本地存储变化监听器
window.addEventListener('storage', handleStorageChange);
// 添加配置更新事件监听器
window.addEventListener('homeFeaturesConfigUpdated', handleConfigUpdate);
})
// 在组件卸载前移除监听器
onUnmounted(() => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('homeFeaturesConfigUpdated', handleConfigUpdate);
});
</script>
<style scoped lang="scss">
.home-container {
padding: 20px;
background: #f5f7fa;
min-height: calc(100vh - 120px);
}
/* 顶部欢迎区域 */
.welcome-section {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
padding: 30px;
margin-bottom: 20px;
color: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
.welcome-content {
display: flex;
justify-content: space-between;
align-items: center;
.greeting {
h1 {
font-size: 28px;
font-weight: 600;
margin: 0 0 8px 0;
}
p {
font-size: 16px;
opacity: 0.9;
margin: 0;
}
}
.role-badge {
:deep(.el-tag) {
font-size: 16px;
padding: 12px 24px;
height: auto;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
}
}
}
}
/* 统计卡片网格 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
margin-bottom: 20px;
.stat-card {
background: white;
border-radius: 12px;
padding: 24px;
display: flex;
align-items: center;
gap: 16px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border-left: 4px solid transparent;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
&.stat-primary {
border-left-color: #409eff;
}
&.stat-success {
border-left-color: #67c23a;
}
&.stat-warning {
border-left-color: #e6a23c;
}
&.stat-danger {
border-left-color: #f56c6c;
}
&.stat-info {
border-left-color: #909399;
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
}
.stat-content {
flex: 1;
.stat-value {
font-size: 24px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #909399;
margin-bottom: 8px;
}
.stat-trend {
font-size: 12px;
.trend-up {
color: #67c23a;
margin-right: 4px;
}
.trend-down {
color: #f56c6c;
margin-right: 4px;
}
.trend-label {
color: #c0c4cc;
}
}
}
}
}
/* 快捷功能区域 */
.quick-access-section {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h3 {
font-size: 18px;
font-weight: 600;
color: #303133;
margin: 0;
}
.divider {
color: #d8d8d8;
margin: 0 8px;
}
}
.quick-access-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 16px;
.quick-access-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 16px;
border-radius: 8px;
background: #f5f7fa;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: #ecf5ff;
transform: translateY(-4px);
.quick-label {
color: #409eff;
}
}
.quick-icon {
margin-bottom: 12px;
}
.quick-label {
font-size: 14px;
color: #606266;
text-align: center;
transition: color 0.3s;
}
}
}
}
/* 待办事项区域 */
.todo-section {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h3 {
font-size: 18px;
font-weight: 600;
color: #303133;
margin: 0;
}
.divider {
color: #d8d8d8;
margin: 0 8px;
}
}
.todo-list {
.todo-item {
display: flex;
align-items: center;
padding: 16px;
border-radius: 8px;
margin-bottom: 12px;
background: #f5f7fa;
cursor: pointer;
transition: all 0.3s ease;
border-left: 3px solid transparent;
&:hover {
background: #ecf5ff;
}
&.priority-high {
border-left-color: #f56c6c;
}
&.priority-medium {
border-left-color: #e6a23c;
}
&.priority-low {
border-left-color: #67c23a;
}
&:last-child {
margin-bottom: 0;
}
.todo-icon {
margin-right: 12px;
color: #909399;
}
.todo-content {
flex: 1;
.todo-title {
font-size: 14px;
color: #303133;
font-weight: 500;
margin-bottom: 4px;
}
.todo-desc {
font-size: 12px;
color: #909399;
}
}
.todo-time {
font-size: 12px;
color: #c0c4cc;
}
}
.empty-todo {
padding: 20px 0;
text-align: center;
}
}
}
/* 日程区域 */
.schedule-section {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h3 {
font-size: 18px;
font-weight: 600;
color: #303133;
margin: 0;
}
.divider {
color: #d8d8d8;
margin: 0 8px;
}
}
.schedule-list {
.schedule-item {
display: flex;
align-items: center;
padding: 16px;
border-radius: 8px;
margin-bottom: 12px;
background: #f5f7fa;
transition: all 0.3s ease;
&:hover {
background: #ecf5ff;
}
&:last-child {
margin-bottom: 0;
}
.schedule-time {
width: 80px;
font-size: 18px;
font-weight: 600;
color: #409eff;
}
.schedule-content {
flex: 1;
margin: 0 16px;
.schedule-title {
font-size: 14px;
color: #303133;
font-weight: 500;
margin-bottom: 4px;
}
.schedule-location {
display: flex;
align-items: center;
font-size: 12px;
color: #909399;
.el-icon {
margin-right: 4px;
}
}
}
}
}
}
/* 响应式设计 */
@media (max-width: 1200px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.quick-access-grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (max-width: 768px) {
.home-container {
padding: 12px;
}
.welcome-section {
padding: 20px;
.welcome-content {
flex-direction: column;
align-items: flex-start;
gap: 16px;
.greeting {
h1 {
font-size: 22px;
}
p {
font-size: 14px;
}
}
}
}
.stats-grid {
grid-template-columns: 1fr;
}
.quick-access-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 480px) {
.quick-access-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>