feat(menu): 添加用户可访问菜单树接口并优化界面展示

- 新增 /userMenus 接口供普通用户获取自身权限范围内的菜单树
- 修复菜单ID路径参数正则表达式匹配问题
- 优化门诊挂号患者列表表格列宽和滚动显示
- 更新患者主索引界面搜索表单和表格展示逻辑
- 调整挂号记录表格高度计算和列固定布局
- 更新未闭环医嘱统计界面提示信息和分页功能
- 修复用户医院名称获取逻辑优先级问题
- 添加EMPI合并日志创建时间字段迁移脚本
This commit is contained in:
2026-06-16 16:08:40 +08:00
parent bf5a9674df
commit c4ca097bf6
9 changed files with 189 additions and 124 deletions

View File

@@ -26,6 +26,18 @@ public class SysMenuController extends BaseController {
@Autowired
private ISysMenuService menuService;
/**
* 获取当前用户可访问的菜单树(无需管理员权限)
* 用于功能配置页面,让普通用户也能选择自己有权限的菜单
*/
@GetMapping("/userMenus")
public AjaxResult userMenus() {
Long userId = getUserId();
List<SysMenu> menus = menuService.selectMenuList(new SysMenu(), userId);
List<SysMenu> menuTreeWithFullPath = menuService.buildMenuTreeWithFullPath(menus);
return success(menuTreeWithFullPath);
}
/**
* 获取菜单列表
*/
@@ -42,7 +54,7 @@ public class SysMenuController extends BaseController {
* 根据菜单编号获取详细信息
*/
@PreAuthorize("@ss.hasPermi('system:menu:query')")
@GetMapping(value = "/{menuId}")
@GetMapping(value = "/{menuId:\\d+}")
public AjaxResult getInfo(@PathVariable Long menuId) {
return success(menuService.selectMenuById(menuId));
}

View File

@@ -0,0 +1 @@
ALTER TABLE empi_merge_log ADD COLUMN IF NOT EXISTS create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP;

View File

@@ -1,5 +1,13 @@
import request from '@/utils/request'
// 获取当前用户可访问的菜单树(无需管理员权限)
export function getUserMenus() {
return request({
url: '/system/menu/userMenus',
method: 'get'
})
}
// 查询菜单列表
export function listMenu(query) {
return request({

View File

@@ -67,7 +67,7 @@ const useUserStore = defineStore(
this.avatar = avatar
this.optionMap = res.optionMap || {}
// 优先从optionMap获取配置如果没有则从optionJson获取
this.hospitalName = this.optionMap.hospitalName || res.optionJson.hospitalName || ''
this.hospitalName = res.tenantName || this.optionMap.hospitalName || res.optionJson.hospitalName || ''
this.tenantName = res.tenantName || ''
resolve(res)

View File

@@ -1,39 +1,46 @@
<template>
<template>
<div>
<vxe-table
height="400"
height="420"
:data="patientList"
:row-config="{ keyField: 'id' }"
:scroll-x="{ enabled: true }"
@cell-click="clickRow"
>
<vxe-column
title="姓名"
align="center"
field="name"
:min-width="80"
/>
<vxe-column
title="就诊卡号"
align="center"
field="identifierNo"
:min-width="140"
/>
<vxe-column
title="性别"
align="center"
field="genderEnum_enumText"
:min-width="60"
/>
<vxe-column
title="证件号"
align="center"
field="idCard"
:min-width="180"
/>
<vxe-column
title="联系电话"
align="center"
field="phone"
:min-width="130"
/>
<vxe-column
title="年龄"
align="center"
:min-width="60"
>
<template #default="scope">
{{ scope.row.age ? `${scope.row.age}` : '-' }}

View File

@@ -117,7 +117,7 @@
placement="bottom-start"
:visible="showPopover"
trigger="manual"
:width="1200"
:width="1600"
>
<patientList
:searchkey="patientSearchKey"
@@ -634,7 +634,8 @@
<vxe-table
:row-config="{ isCurrent: true }" v-loading="loading"
:data="outpatientRegistrationList"
max-height="250"
:max-height="Math.max(470, Math.min(outpatientRegistrationList.length, 10) * 42 + 50)"
:scroll-x="{ enabled: true }"
>
<!-- <vxe-column
title="租户ID"
@@ -659,6 +660,7 @@
title=""
align="center"
width="50"
fixed="left"
>
<template #default="scope">
{{ scope.rowIndex + 1 }}
@@ -669,14 +671,14 @@
title="患者姓名"
align="center"
field="patientName"
width="120"
:min-width="100"
/>
<vxe-column
key="age"
title="年龄"
align="center"
field="age"
width="120"
:min-width="60"
>
<template #default="scope">
{{ scope.row.age ? `${scope.row.age}` : '-' }}
@@ -687,18 +689,20 @@
title="患者性别"
align="center"
field="genderEnum_enumText"
:min-width="100"
/>
<vxe-column
key="phone"
title="联系电话"
align="center"
field="phone"
:min-width="120"
/>
<vxe-column
key="identifierNo"
title="就诊卡号"
align="center"
width="150"
:min-width="150"
>
<template #default="scope">
{{ scope.row.identifierNo || scope.row.cardNo || scope.row.card || scope.row.patientCardNo || scope.row.patient?.identifierNo || '-' }}
@@ -710,6 +714,7 @@
align="center"
field="organizationName"
:show-overflow="true"
:min-width="120"
/>
<vxe-column
key="healthcareName"
@@ -717,7 +722,7 @@
align="center"
field="healthcareName"
:show-overflow="true"
width="200"
:min-width="140"
>
<template #default="scope">
<span>
@@ -736,18 +741,21 @@
title="专家"
align="center"
field="practitionerName"
:min-width="80"
/>
<vxe-column
key="contractName"
title="费用性质"
align="center"
field="contractName"
:min-width="90"
/>
<vxe-column
key="totalPrice"
title="挂号金额"
align="center"
field="totalPrice"
:min-width="100"
>
<template #default="scope">
<span>
@@ -760,6 +768,7 @@
title="收款人"
align="center"
field="entererName"
:min-width="80"
/>
<!-- <vxe-column
title="收款方式"
@@ -779,6 +788,7 @@
title="就诊状态"
align="center"
field="statusEnum_enumText"
:min-width="90"
>
<template #default="scope">
<el-tag
@@ -819,6 +829,7 @@
key="operation" title="操作"
align="center"
field="" width="150"
fixed="right"
>
<template #default="scope">
<!-- <el-tooltip

View File

@@ -6,6 +6,7 @@
<el-col :span="6"><el-card shadow="hover"><el-statistic title="待合并" :value="stats.pendingMerges || 0" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="重复率" :value="stats.duplicateRate || 0" suffix="%" /></el-card></el-col>
</el-row>
<el-card>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
@@ -13,18 +14,49 @@
<el-button type="primary" icon="Plus" @click="handleRegister">注册患者</el-button>
</div>
</template>
<el-form :model="searchForm" :inline="true" class="mb8">
<el-form-item label="全局ID"><el-input v-model="searchForm.globalId" placeholder="全局ID" clearable /></el-form-item>
<el-form-item label="身份证号"><el-input v-model="searchForm.idCardNo" placeholder="身份证号" clearable /></el-form-item>
<el-form-item label="姓名"><el-input v-model="searchForm.name" placeholder="患者姓名" clearable @keyup.enter="handleSearch" /></el-form-item>
<el-form-item label="身份证号"><el-input v-model="searchForm.idCardNo" placeholder="身份证号" clearable @keyup.enter="handleSearch" /></el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button type="primary" icon="Search" @click="handleSearch">查询</el-button>
<el-button icon="Refresh" @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<el-descriptions v-if="patientData" :column="2" border>
<el-table :data="patientList" border stripe v-loading="loading" @row-click="handleRowClick" highlight-current-row>
<el-table-column prop="globalId" label="全局ID" width="180" show-overflow-tooltip />
<el-table-column prop="name" label="姓名" width="100" />
<el-table-column prop="gender" label="性别" width="60">
<template #default="{row}">{{ row.gender === 'M' ? '男' : row.gender === 'F' ? '女' : row.gender === '1' ? '男' : row.gender === '2' ? '女' : row.gender }}</template>
</el-table-column>
<el-table-column prop="birthDate" label="出生日期" width="120" />
<el-table-column prop="idCardNo" label="身份证号" width="180" show-overflow-tooltip />
<el-table-column prop="phone" label="电话" width="130" />
<el-table-column prop="sourceSystem" label="来源" width="80" />
<el-table-column prop="mergeStatus" label="状态" width="80">
<template #default="{row}">
<el-tag :type="row.mergeStatus === 'ACTIVE' ? 'success' : 'warning'" size="small">
{{ row.mergeStatus === 'ACTIVE' ? '正常' : row.mergeStatus === 'MERGED' ? '已合并' : row.mergeStatus }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="170" />
</el-table>
</el-card>
<!-- 患者详情 -->
<el-card v-if="patientData" style="margin-top:16px">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<span>患者详情</span>
<el-button text @click="patientData = null; linkedPatients = []; mappings = []">关闭</el-button>
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="全局ID">{{ patientData.globalId }}</el-descriptions-item>
<el-descriptions-item label="姓名">{{ patientData.name }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ patientData.gender === 'M' ? '男' : '女' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ patientData.gender === 'M' ? '男' : patientData.gender === 'F' ? '女' : patientData.gender === '1' ? '男' : patientData.gender === '2' ? '女' : patientData.gender }}</el-descriptions-item>
<el-descriptions-item label="出生日期">{{ patientData.birthDate }}</el-descriptions-item>
<el-descriptions-item label="身份证号">{{ patientData.idCardNo }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ patientData.phone }}</el-descriptions-item>
@@ -36,14 +68,11 @@
</el-tag>
</el-descriptions-item>
</el-descriptions>
<el-empty v-else description="请输入查询条件" />
</el-card>
<!-- 关联院内患者记录 -->
<el-card v-if="linkedPatients.length > 0" style="margin-top:16px">
<template #header>
<span>关联院内患者记录 ({{ linkedPatients.length }})</span>
</template>
<template #header><span>关联院内患者记录 ({{ linkedPatients.length }})</span></template>
<el-table :data="linkedPatients" border stripe>
<el-table-column prop="id" label="院内ID" width="100" />
<el-table-column prop="busNo" label="病历号" width="140" />
@@ -62,9 +91,7 @@
<!-- ID映射列表 -->
<el-card v-if="mappings.length > 0" style="margin-top:16px">
<template #header>
<span>ID映射关系</span>
</template>
<template #header><span>ID映射关系</span></template>
<el-table :data="mappings" border stripe>
<el-table-column prop="globalId" label="全局ID" width="180" />
<el-table-column prop="localPatientId" label="院内患者ID" width="120" />
@@ -99,14 +126,16 @@
import { useDict } from '@/utils/dict'
import { ref, reactive, onMounted } from 'vue'
import {
registerPerson, findByGlobalId, findByIdCard, getStatistics,
registerPerson, findByGlobalId, findByIdCard, getStatistics, listPersons,
findLinkedPatientsByGlobalId, findLinkedPatientsByIdCard, getMappings
} from '../api'
import { ElMessage } from 'element-plus'
const { sys_user_sex } = useDict('sys_user_sex')
const stats = ref({})
const searchForm = reactive({ globalId: '', idCardNo: '' })
const loading = ref(false)
const patientList = ref([])
const searchForm = reactive({ name: '', idCardNo: '' })
const patientData = ref(null)
const linkedPatients = ref([])
const mappings = ref([])
@@ -115,41 +144,49 @@ const formData = ref({})
const loadStats = async () => { const res = await getStatistics(); stats.value = res.data || {} }
const handleSearch = async () => {
linkedPatients.value = []
mappings.value = []
if (searchForm.globalId) {
const res = await findByGlobalId(searchForm.globalId)
patientData.value = res.data
if (res.data) {
const lp = await findLinkedPatientsByGlobalId(searchForm.globalId)
linkedPatients.value = lp.data || []
const mp = await getMappings(searchForm.globalId)
mappings.value = mp.data || []
}
} else if (searchForm.idCardNo) {
const res = await findByIdCard(searchForm.idCardNo)
patientData.value = res.data
if (res.data) {
const lp = await findLinkedPatientsByIdCard(searchForm.idCardNo)
linkedPatients.value = lp.data || []
const mp = await getMappings(res.data.globalId)
mappings.value = mp.data || []
}
} else {
ElMessage.warning('请输入查询条件')
const loadPatientList = async (params = {}) => {
loading.value = true
try {
const res = await listPersons(params)
patientList.value = res.data || []
} finally {
loading.value = false
}
}
const handleSearch = async () => {
patientData.value = null
linkedPatients.value = []
mappings.value = []
const params = {}
if (searchForm.name) params.name = searchForm.name
if (searchForm.idCardNo) params.idCardNo = searchForm.idCardNo
await loadPatientList(params)
}
const handleReset = () => {
searchForm.globalId = ''
searchForm.name = ''
searchForm.idCardNo = ''
patientData.value = null
linkedPatients.value = []
mappings.value = []
loadPatientList()
}
const handleRowClick = async (row) => {
patientData.value = row
linkedPatients.value = []
mappings.value = []
try {
const lp = await findLinkedPatientsByGlobalId(row.globalId)
linkedPatients.value = lp.data || []
const mp = await getMappings(row.globalId)
mappings.value = mp.data || []
} catch (e) { /* ignore */ }
}
const handleRegister = () => { formData.value = {}; dialogVisible.value = true }
const submitForm = async () => { await registerPerson(formData.value); ElMessage.success('注册成功'); dialogVisible.value = false; loadStats() }
onMounted(() => loadStats())
const submitForm = async () => { await registerPerson(formData.value); ElMessage.success('注册成功'); dialogVisible.value = false; loadStats(); loadPatientList() }
onMounted(() => { loadStats(); loadPatientList() })
</script>

View File

@@ -33,6 +33,7 @@
class="tree-card"
>
<el-tree
:key="treeKey"
ref="treeRef"
:data="menuTree"
:props="treeProps"
@@ -189,8 +190,7 @@ import { ref, onMounted, nextTick, watch, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import Sortable from 'sortablejs'
import useUserStore from '@/store/modules/user'
import { listMenu } from '@/api/system/menu'
import { getMenuFullPath } from '@/api/system/menu'
import { getUserMenus } from '@/api/system/menu'
import { saveCurrentUserConfig, getCurrentUserConfig } from '@/api/system/userConfig'
import {
Menu,
@@ -265,6 +265,7 @@ import SvgIcon from '@/components/SvgIcon'
const loading = ref(false)
const treeRef = ref()
const treeKey = ref(0)
const menuTree = ref([])
const expandedKeys = ref([])
const checkedKeys = ref([])
@@ -310,14 +311,17 @@ const loadMenuData = async () => {
// 尝试从本地缓存获取菜单数据
const cachedMenuData = localStorage.getItem('menuTreeCache');
const cacheTimestamp = localStorage.getItem('menuTreeCacheTimestamp');
const cacheVersion = localStorage.getItem('menuTreeCacheVersion');
// 检查缓存是否有效24小时内
if (cachedMenuData && cacheTimestamp) {
// 检查缓存是否有效24小时内且版本匹配
if (cachedMenuData && cacheTimestamp && cacheVersion === 'v2') {
const cacheAge = Date.now() - parseInt(cacheTimestamp);
if (cacheAge < 24 * 60 * 60 * 1000) { // 24小时
menuTree.value = JSON.parse(cachedMenuData);
// 展开所有节点
expandedKeys.value = getAllNodeIds(menuTree.value);
// 强制树重渲染以应用展开
treeKey.value++
// 获取已保存的配置
await loadSavedConfig();
loading.value = false;
@@ -325,7 +329,7 @@ const loadMenuData = async () => {
}
}
const response = await listMenu({})
const response = await getUserMenus()
if (response.code === 200) {
// 过滤掉隐藏的菜单项、目录和按钮类型的菜单,只保留当前角色可访问的菜单项
const filteredMenus = filterVisibleMenus(response.data)
@@ -337,6 +341,10 @@ const loadMenuData = async () => {
// 将菜单数据缓存到本地存储
localStorage.setItem('menuTreeCache', JSON.stringify(filteredMenus));
localStorage.setItem('menuTreeCacheTimestamp', Date.now().toString());
localStorage.setItem('menuTreeCacheVersion', 'v2');
// 强制树重渲染以应用展开
treeKey.value++
// 获取已保存的配置
await loadSavedConfig()
@@ -370,6 +378,21 @@ const filterVisibleMenus = (menus) => {
})
}
// 程序化展开所有树节点
const expandAllNodes = (nodes) => {
if (!treeRef.value) return
const store = treeRef.value.store
const traverse = (nodeList) => {
nodeList.forEach(node => {
store.nodesMap[node.menuId] && (store.nodesMap[node.menuId].expanded = true)
if (node.children && node.children.length > 0) {
traverse(node.children)
}
})
}
traverse(nodes)
}
// 获取所有节点ID
const getAllNodeIds = (nodes) => {
const ids = []
@@ -392,69 +415,25 @@ const saveConfig = async () => {
// 只保留菜单类型C类型的节点
const validMenuNodes = checkedNodes.filter(node => node.menuType === 'C')
// 创建包含菜单ID和完整路径的对象数组
const menuPromises = validMenuNodes.map(async (node) => {
try {
console.log(`开始获取菜单 ${node.menuName} (ID: ${node.menuId}) 的完整路径`);
console.log(`节点对象:`, node);
console.log(`节点的 path 属性:`, node.path);
// 获取菜单的完整路径
const fullPathResponse = await getMenuFullPath(node.menuId);
console.log(`菜单 ${node.menuName} 的完整路径响应:`, fullPathResponse);
let fullPath = fullPathResponse.code === 200 ? (fullPathResponse.data || fullPathResponse.msg) : node.path;
// 确保路径格式正确,去除多余的斜杠
if (fullPath && typeof fullPath === 'string') {
// 将多个连续的斜杠替换为单个斜杠,但保留协议部分的双斜杠(如 http://
fullPath = fullPath.replace(/([^:])\/{2,}/g, '$1/');
}
console.log(`菜单 ${node.menuName} 的完整路径:`, fullPath);
const menuItem = {
menuId: node.menuId,
fullPath: fullPath,
menuName: node.menuName,
path: node.path,
icon: node.icon, // 保存数据库中的图标类名
menuType: node.menuType // 保存菜单类型信息
};
console.log(`构造的菜单项对象:`, menuItem);
return menuItem;
} catch (error) {
console.error(`获取菜单 ${node.menuName} 的完整路径失败:`, error);
console.error(`错误堆栈:`, error.stack);
// 如果获取完整路径失败,使用现有路径作为备选
console.log(`在错误处理中,节点的 path 属性:`, node.path);
const menuItem = {
menuId: node.menuId,
fullPath: node.path,
menuName: node.menuName,
path: node.path,
icon: node.icon, // 保存数据库中的图标类名
menuType: node.menuType // 保存菜单类型信息
};
console.log(`构造的菜单项对象(错误处理):`, menuItem);
return menuItem;
// fullPath 已在 userMenus 接口返回时填充到节点数据上,直接使用
const menuDataWithPaths = validMenuNodes.map((node) => {
let fullPath = node.fullPath || node.path || ''
// 将多个连续的斜杠替换为单个斜杠,但保留协议部分的双斜杠
if (fullPath && typeof fullPath === 'string') {
fullPath = fullPath.replace(/([^:])\/{2,}/g, '$1/')
}
});
// 等待所有完整路径获取完成
const menuDataWithPaths = await Promise.all(menuPromises);
// 添加调试信息
console.log('准备保存的菜单数据:', menuDataWithPaths);
// 检查每个对象是否包含 fullPath 属性
menuDataWithPaths.forEach((item, index) => {
console.log(`菜单项 ${index} 包含 fullPath:`, item.hasOwnProperty('fullPath'), '值为:', item.fullPath);
});
return {
menuId: node.menuId,
fullPath: fullPath,
menuName: node.menuName,
path: node.path,
icon: node.icon,
menuType: node.menuType
}
})
// 对配置值进行URL编码以避免特殊字符问题
const encodedConfigValue = encodeURIComponent(JSON.stringify(menuDataWithPaths))
console.log('编码后的配置值:', encodedConfigValue);
// 保存到数据库
const saveResult = await saveCurrentUserConfig('homeFeaturesConfig', encodedConfigValue)

View File

@@ -76,7 +76,7 @@
<template #header>
<div class="card-header">
<span>未闭环医嘱预警</span>
<el-tag type="danger" size="small">{{ unclosedWarnings.length }} 条待处理</el-tag>
<el-tag type="danger" size="small">{{ warningTotal }} 条待处理</el-tag>
</div>
</template>
<el-table v-loading="warningLoading" :data="unclosedWarnings" border>
@@ -97,6 +97,16 @@
</el-table-column>
<el-table-column label="开具时间" align="center" prop="orderTime" width="180" />
</el-table>
<el-pagination
style="margin-top:16px;justify-content:flex-end"
v-model:current-page="warningPage"
v-model:page-size="warningPageSize"
:total="warningTotal"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadWarnings"
@current-change="loadWarnings"
/>
</el-card>
</div>
</template>
@@ -167,25 +177,25 @@ function loadWarnings() {
}
function handleRemind(row) {
ElMessageBox.confirm('??? ' + row.doctorName + ' ???????', '????', {
ElMessageBox.confirm('确认提醒 ' + row.doctorName + ' 处理未闭环医嘱?', '催办提醒', {
type: 'warning',
confirmButtonText: '????',
cancelButtonText: '??'
confirmButtonText: '确认催办',
cancelButtonText: '取消'
}).then(() => {
apiRemindOrder({ orderNo: row.orderNo, message: '?????????????' }).then(res => {
apiRemindOrder({ orderNo: row.orderNo, message: '您有未闭环医嘱需要处理' }).then(res => {
if (res.code === 200) {
ElMessage.success('???????? ' + row.doctorName)
ElMessage.success('催办消息已发送给 ' + row.doctorName)
} else {
ElMessage.error(res.msg || '????')
ElMessage.error(res.msg || '催办失败')
}
}).catch(() => {
ElMessage.error('??????')
ElMessage.error('催办失败')
})
}).catch(() => {})
}
function handleViewDetail(row) {
ElMessage.info('???: ' + row.orderNo + ' | ??: ' + row.patientName + ' | ????: ' + row.currentStep)
ElMessage.info('医嘱号: ' + row.orderNo + ' | 患者: ' + row.patientName + ' | 当前环节: ' + row.currentStep)
}
onMounted(() => {