Files
his/openhis-ui-vue3/src/views/features/config.vue
chenqi f3d56bff45 feat(menu): 添加菜单缓存刷新功能和拖拽排序支持
- 在SysMenuController中添加refreshCache和refreshCurrentUserMenuCache接口
- 实现菜单缓存的按需刷新和用户级别缓存清理功能
- 优化菜单列表查询的缓存key策略,支持更精确的缓存命中
- 为菜单树查询添加缓存注解提升性能
- 在菜单增删改操作中完善缓存清理逻辑
- 添加allocateMenuToRole方法实现菜单角色分配功能
- 在前端DictTag组件中修复标签类型验证逻辑
- 为首页配置页面添加拖拽排序功能,支持快捷功能重新排列
- 集成Sortable.js实现拖拽交互和排序保存
- 优化菜单管理页面的缓存刷新机制和数据展示
- 完善配置更新事件处理,支持实时配置同步
2026-02-05 23:07:31 +08:00

865 lines
25 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="config-container">
<div class="page-header">
<h2>首页功能配置</h2>
<p>选择要在首页快捷功能区域显示的功能</p>
</div>
<div class="config-content">
<el-card class="config-card">
<template #header>
<div class="card-header">
<span>功能选择</span>
<el-button class="button" type="primary" @click="saveConfig">保存配置</el-button>
</div>
</template>
<div class="config-layout">
<div class="menu-tree-section">
<el-input
v-model="filterText"
placeholder="输入关键字进行过滤"
size="default"
style="margin-bottom: 16px;"
/>
<el-card v-loading="loading" class="tree-card">
<el-tree
ref="treeRef"
:data="menuTree"
:props="treeProps"
show-checkbox
node-key="menuId"
:default-expanded-keys="expandedKeys"
:default-checked-keys="checkedKeys"
:filter-node-method="filterNode"
:expand-on-click-node="false"
@check="handleCheckChange"
>
<template #default="{ node, data }">
<span class="custom-tree-node">
<div class="tree-node-info">
<div class="node-main">
<svg-icon :icon-class="data.icon" :style="{ color: getIconColor(data) }" />
<span class="menu-label" style="margin-left: 8px;">{{ node.label }}</span>
<el-tag v-if="data.fullPath" type="info" size="small" effect="plain" class="path-tag-inline">
{{ data.fullPath }}
</el-tag>
<el-tag v-else-if="data.path" type="info" size="small" effect="plain" class="path-tag-inline">
{{ data.path }}
</el-tag>
</div>
</div>
<span>
<el-tag
v-if="data.menuType === 'M'"
type="info"
size="small"
style="margin-right: 8px;">
目录
</el-tag>
<el-tag
v-if="data.menuType === 'C'"
type="success"
size="small"
style="margin-right: 8px;">
菜单
</el-tag>
<el-tag
v-if="data.menuType === 'F'"
type="warning"
size="small">
按钮
</el-tag>
</span>
</span>
</template>
</el-tree>
</el-card>
</div>
<div class="selected-functions-section">
<div class="selected-functions-header">
<h4>已选择的功能</h4>
<el-tag type="info" size="small">{{ selectedFunctions.length }}/8</el-tag>
</div>
<div
class="selected-functions-list"
@dragover.prevent
@drop="handleDrop"
>
<div
v-for="(item, index) in selectedFunctions"
:key="item.menuId"
class="selected-function-item"
draggable
@dragstart="handleDragStart($event, index)"
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
@drop="handleDrop"
@dragend="handleDragEnd"
>
<div class="drag-handle">
<el-icon><Rank /></el-icon>
</div>
<div class="function-info">
<svg-icon :icon-class="item.icon" :style="{ color: getIconColor(item) }" />
<div class="function-details">
<span class="function-name">{{ item.menuName }}</span>
<el-tag v-if="item.fullPath" type="info" size="small" class="function-path-below">
{{ item.fullPath }}
</el-tag>
<el-tag v-else-if="item.path" type="info" size="small" class="function-path-below">
{{ item.path }}
</el-tag>
</div>
</div>
<el-button
type="danger"
size="small"
icon="Delete"
circle
@click="removeSelected(item.menuId)"
/>
</div>
<div v-if="selectedFunctions.length === 0" class="no-selected">
暂无选择功能
</div>
</div>
</div>
</div>
</el-card>
</div>
</div>
</template>
<script setup>
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 { saveCurrentUserConfig, getCurrentUserConfig } from '@/api/system/userConfig'
import {
Menu,
Grid,
Folder,
Tickets,
Document,
Setting,
User,
Goods,
ChatDotSquare,
Histogram,
Wallet,
OfficeBuilding,
Postcard,
Collection,
VideoPlay,
Camera,
Headset,
Phone,
Message,
ChatLineSquare,
ChatRound,
Guide,
Help,
InfoFilled,
CircleCheck,
CircleClose,
Warning,
QuestionFilled,
Star,
Link,
Position,
Picture,
Upload,
Download,
CaretLeft,
CaretRight,
More,
Close,
Check,
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight,
Plus,
Minus,
ZoomIn,
ZoomOut,
Refresh,
Search,
Edit,
Delete,
Share,
View,
SwitchButton,
Hide,
Finished,
CirclePlus,
Remove,
CircleCheckFilled,
CircleCloseFilled,
WarningFilled,
InfoFilled as InfoFilledIcon,
SuccessFilled,
QuestionFilled as QuestionFilledIcon,
Rank
} from '@element-plus/icons-vue'
import SvgIcon from '@/components/SvgIcon'
// 添加 loading 状态
const loading = ref(false)
const treeRef = ref()
const menuTree = ref([])
const expandedKeys = ref([])
const checkedKeys = ref([])
const selectedFunctions = ref([]) // 已选择的功能
const filterText = ref('') // 过滤文本
const userStore = useUserStore()
const treeProps = {
children: 'children',
label: 'menuName'
}
// Sortable实例
let sortable = null
// 监听过滤文本变化
watch(filterText, (val) => {
treeRef.value?.filter(val)
})
// 监听selectedFunctions的变化重新初始化Sortable
watch(selectedFunctions, () => {
nextTick(() => {
if (sortable) {
sortable.destroy();
}
initSortable();
});
}, { deep: true })
// 获取图标颜色
const getIconColor = (data) => {
if (data.menuType === 'M') return '#409EFF' // 目录蓝色
if (data.menuType === 'C') return '#67C23A' // 菜单绿色
if (data.menuType === 'F') return '#E6A23C' // 按钮橙色
return '#909399' // 默认灰色
}
// 加载菜单数据
const loadMenuData = async () => {
loading.value = true
try {
// 尝试从本地缓存获取菜单数据
const cachedMenuData = localStorage.getItem('menuTreeCache');
const cacheTimestamp = localStorage.getItem('menuTreeCacheTimestamp');
// 检查缓存是否有效24小时内
if (cachedMenuData && cacheTimestamp) {
const cacheAge = Date.now() - parseInt(cacheTimestamp);
if (cacheAge < 24 * 60 * 60 * 1000) { // 24小时
menuTree.value = JSON.parse(cachedMenuData);
// 展开所有节点
expandedKeys.value = getAllNodeIds(menuTree.value);
// 获取已保存的配置
await loadSavedConfig();
loading.value = false;
return;
}
}
const response = await listMenu({})
if (response.code === 200) {
// 过滤掉隐藏的菜单项、目录和按钮类型的菜单,只保留当前角色可访问的菜单项
const filteredMenus = filterVisibleMenus(response.data)
menuTree.value = filteredMenus
// 展开所有节点
expandedKeys.value = getAllNodeIds(filteredMenus)
// 将菜单数据缓存到本地存储
localStorage.setItem('menuTreeCache', JSON.stringify(filteredMenus));
localStorage.setItem('menuTreeCacheTimestamp', Date.now().toString());
// 获取已保存的配置
await loadSavedConfig()
} else {
ElMessage.error('获取菜单数据失败: ' + response.msg)
}
} catch (error) {
console.error('加载菜单数据失败:', error)
ElMessage.error('加载菜单数据失败')
} finally {
loading.value = false
}
}
// 过滤可见的菜单项(非隐藏且非按钮类型,仅显示当前角色可访问的菜单)
const filterVisibleMenus = (menus) => {
return menus.filter(menu => {
// 过滤掉隐藏的菜单项和按钮类型的菜单保留目录M类型和菜单C类型
// visible为'0'表示可见,'1'表示隐藏
return menu.visible !== '1' && menu.menuType !== 'F'
}).map(menu => {
// 保留完整路径信息
if (menu.fullPath) {
menu.fullPath = menu.fullPath;
}
// 递归处理子菜单
if (menu.children && menu.children.length > 0) {
menu.children = filterVisibleMenus(menu.children)
}
return menu
})
}
// 获取所有节点ID
const getAllNodeIds = (nodes) => {
const ids = []
nodes.forEach(node => {
ids.push(node.menuId)
if (node.children) {
ids.push(...getAllNodeIds(node.children))
}
})
return ids
}
// 保存配置
const saveConfig = async () => {
loading.value = true
try {
// 获取所有选中的节点,包括半选状态的节点
const checkedNodes = treeRef.value.getCheckedNodes()
// 只保留菜单类型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;
}
});
// 等待所有完整路径获取完成
const menuDataWithPaths = await Promise.all(menuPromises);
// 添加调试信息
console.log('准备保存的菜单数据:', menuDataWithPaths);
// 检查每个对象是否包含 fullPath 属性
menuDataWithPaths.forEach((item, index) => {
console.log(`菜单项 ${index} 包含 fullPath:`, item.hasOwnProperty('fullPath'), '值为:', item.fullPath);
});
// 对配置值进行URL编码以避免特殊字符问题
const encodedConfigValue = encodeURIComponent(JSON.stringify(menuDataWithPaths))
console.log('编码后的配置值:', encodedConfigValue);
// 保存到数据库
const saveResult = await saveCurrentUserConfig('homeFeaturesConfig', encodedConfigValue)
if (saveResult.code === 200) {
// 只有在数据库保存成功后,才保存到本地存储
localStorage.setItem('homeFeaturesConfig', JSON.stringify(menuDataWithPaths))
// 同时更新缓存配置
localStorage.setItem('homeFeaturesConfigCache', JSON.stringify(menuDataWithPaths));
localStorage.setItem('homeFeaturesConfigCacheTimestamp', Date.now().toString());
// 清除菜单树缓存,以便下次加载最新数据
localStorage.removeItem('menuTreeCache');
localStorage.removeItem('menuTreeCacheTimestamp');
ElMessage.success('配置保存成功')
// 触发全局事件,通知首页更新快捷功能
window.dispatchEvent(new CustomEvent('homeFeaturesConfigUpdated', {
detail: { config: menuDataWithPaths } // 传递最新配置数据
}));
} else {
console.error('保存到数据库失败:', saveResult);
ElMessage.error('保存到数据库失败')
}
} catch (error) {
console.error('保存配置失败:', error)
ElMessage.error('保存配置失败')
} finally {
loading.value = false
}
}
// 处理复选框变化
const handleCheckChange = () => {
// 获取当前选中的节点
const checkedNodes = treeRef.value.getCheckedNodes()
// 只保留菜单类型C的节点排除目录M和按钮F
selectedFunctions.value = checkedNodes.filter(node => node.menuType === 'C')
}
// 移除已选择的功能
const removeSelected = (menuId) => {
// 从树中取消勾选该节点
treeRef.value.setChecked(menuId, false, false)
// 更新已选择的功能列表
selectedFunctions.value = selectedFunctions.value.filter(item => item.menuId !== menuId)
}
// 获取默认选择项(前几个菜单项)
const getDefaultSelections = (nodes, count = 8) => {
const selections = []
const traverse = (items) => {
for (const item of items) {
if (selections.length >= count) break
if (item.menuType === 'C') { // 只选择菜单类型C不选择目录M或按钮F
selections.push(item.menuId)
}
if (item.children && item.children.length > 0) {
traverse(item.children)
}
}
}
traverse(nodes)
return selections.slice(0, count)
}
// 过滤节点方法
const filterNode = (value, data) => {
if (!value) return true
return data.menuName.indexOf(value) !== -1
}
// 加载已保存的配置
const loadSavedConfig = async () => {
try {
// 尝试从本地缓存获取配置
const cachedConfig = localStorage.getItem('homeFeaturesConfigCache');
const cacheTimestamp = localStorage.getItem('homeFeaturesConfigCacheTimestamp');
// 检查缓存是否有效1小时内
if (cachedConfig && cacheTimestamp) {
const cacheAge = Date.now() - parseInt(cacheTimestamp);
if (cacheAge < 60 * 60 * 1000) { // 1小时
const parsedConfig = JSON.parse(cachedConfig);
// 检查数据格式如果是包含对象的数组新格式提取菜单ID
if (parsedConfig && Array.isArray(parsedConfig)) {
if (parsedConfig.length > 0 && typeof parsedConfig[0] === 'object' && parsedConfig[0].hasOwnProperty('menuId')) {
// 新格式:[{menuId: 1, fullPath: "...", ...}, ...]
checkedKeys.value = parsedConfig.map(item => item.menuId);
} else {
// 旧格式:[1, 2, 3, ...]
checkedKeys.value = parsedConfig;
}
// 根据保存的配置初始化已选择的功能
const allNodes = getAllNodes(menuTree.value)
const checkedNodes = allNodes.filter(node =>
checkedKeys.value.includes(node.menuId) && node.menuType === 'C'
)
selectedFunctions.value = checkedNodes
}
return; // 使用缓存数据,直接返回
}
}
// 优先从数据库获取已保存的配置
const response = await getCurrentUserConfig('homeFeaturesConfig')
let savedConfig = null;
if (response.code === 200) {
savedConfig = response.data; // 从数据库获取的配置
}
// 如果数据库中没有配置,尝试从本地存储获取
if (!savedConfig) {
savedConfig = localStorage.getItem('homeFeaturesConfig')
}
if (savedConfig) {
// 尝试解码配置值如果是URL编码的
let parsedConfig;
try {
// 先尝试解码
const decodedConfig = decodeURIComponent(savedConfig);
parsedConfig = JSON.parse(decodedConfig);
} catch (e) {
// 如果解码失败,尝试直接解析(兼容旧数据)
try {
parsedConfig = JSON.parse(savedConfig);
} catch (e2) {
console.error('解析配置失败:', e2);
parsedConfig = null;
}
}
if (parsedConfig && Array.isArray(parsedConfig)) {
// 检查数据格式如果是包含对象的数组新格式提取菜单ID
if (parsedConfig.length > 0 && typeof parsedConfig[0] === 'object' && parsedConfig[0].hasOwnProperty('menuId')) {
// 新格式:[{menuId: 1, fullPath: "...", ...}, ...]
checkedKeys.value = parsedConfig.map(item => item.menuId);
} else {
// 旧格式:[1, 2, 3, ...]
checkedKeys.value = parsedConfig;
}
// 根据保存的配置初始化已选择的功能
const allNodes = getAllNodes(menuTree.value)
const checkedNodes = allNodes.filter(node =>
checkedKeys.value.includes(node.menuId) && node.menuType === 'C'
)
selectedFunctions.value = checkedNodes
// 将配置缓存到本地存储
localStorage.setItem('homeFeaturesConfigCache', JSON.stringify(parsedConfig));
localStorage.setItem('homeFeaturesConfigCacheTimestamp', Date.now().toString());
} else {
// 如果解析失败,使用默认配置
const defaultSelections = getDefaultSelections(menuTree.value)
checkedKeys.value = defaultSelections
// 初始化已选择的功能
const allNodes = getAllNodes(menuTree.value)
const checkedNodes = allNodes.filter(node =>
defaultSelections.includes(node.menuId) && node.menuType === 'C'
)
selectedFunctions.value = checkedNodes
}
} else {
// 如果没有配置,使用默认配置
const defaultSelections = getDefaultSelections(menuTree.value)
checkedKeys.value = defaultSelections
// 初始化已选择的功能
const allNodes = getAllNodes(menuTree.value)
const checkedNodes = allNodes.filter(node =>
defaultSelections.includes(node.menuId) && node.menuType === 'C'
)
selectedFunctions.value = checkedNodes
}
} catch (error) {
console.error('加载配置失败:', error)
// 如果加载失败,使用默认配置
const defaultSelections = getDefaultSelections(menuTree.value)
checkedKeys.value = defaultSelections
// 初始化已选择的功能
const allNodes = getAllNodes(menuTree.value)
const checkedNodes = allNodes.filter(node =>
defaultSelections.includes(node.menuId)
)
selectedFunctions.value = checkedNodes
} finally {
// 确保在数据加载完成后初始化Sortable
// 使用 nextTick 确保DOM已更新
nextTick(() => {
if (sortable) {
sortable.destroy();
}
initSortable();
});
}
}
// 获取所有节点(递归)
const getAllNodes = (nodes) => {
if (!nodes || !Array.isArray(nodes)) {
return []
}
let result = []
nodes.forEach(node => {
result.push(node)
if (node.children && Array.isArray(node.children)) {
result = result.concat(getAllNodes(node.children))
}
})
return result
}
// 初始化Sortable
const initSortable = () => {
const container = document.querySelector('.selected-functions-list')
if (container && selectedFunctions.value.length > 0) {
sortable = Sortable.create(container, {
animation: 150, // 动画时长
ghostClass: 'sortable-ghost', // 拖拽时的样式
chosenClass: 'sortable-chosen', // 选中时的样式
dragClass: 'sortable-drag', // 拖拽过程中的样式
onEnd: function(evt) {
// 拖拽结束时更新数组顺序
const oldIndex = evt.oldIndex
const newIndex = evt.newIndex
if (oldIndex !== newIndex) {
// 移动数组元素
const movedItem = selectedFunctions.value.splice(oldIndex, 1)[0]
selectedFunctions.value.splice(newIndex, 0, movedItem)
// 更新树形控件的选中状态
updateTreeCheckState()
}
}
})
}
}
// 更新树形控件的选中状态
const updateTreeCheckState = () => {
// 获取当前所有选中的菜单ID
const checkedIds = selectedFunctions.value.map(item => item.menuId)
// 更新 checkedKeys 和树形控件的选中状态
checkedKeys.value = checkedIds
// 如果 treeRef 存在,手动设置选中状态
if (treeRef.value) {
// 清除当前选中状态
treeRef.value.setCheckedKeys([])
// 设置新的选中状态
checkedIds.forEach(id => {
treeRef.value.setChecked(id, true, false)
})
}
}
onMounted(() => {
loadMenuData()
})
// 在组件卸载时销毁Sortable实例
onUnmounted(() => {
if (sortable) {
sortable.destroy()
sortable = null
}
})
</script>
<style scoped lang="scss">
.config-container {
padding: 20px;
background: #f5f7fa;
min-height: calc(100vh - 120px);
.page-header {
margin-bottom: 20px;
text-align: center;
h2 {
font-size: 24px;
color: #303133;
margin-bottom: 8px;
}
p {
font-size: 14px;
color: #909399;
}
}
.config-content {
.config-card {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.menu-tree {
margin-top: 20px;
}
:deep(.el-card__body) {
padding: 20px 0;
}
}
.custom-tree-node {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
padding-right: 8px;
}
.tree-node-info {
display: flex;
align-items: center;
flex: 1;
min-width: 0; /* 允许flex项目收缩到其内容的固有尺寸以下 */
}
.node-main {
display: flex;
align-items: center;
flex: 1;
min-width: 0; /* 允许收缩 */
}
.menu-label {
margin: 0 8px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.path-tag-inline {
font-size: 12px;
margin-left: 8px;
flex-shrink: 0; /* 防止路径标签被压缩 */
}
}
}
.config-layout {
display: flex;
gap: 20px;
}
.menu-tree-section {
flex: 1;
}
.tree-card {
min-height: 400px;
}
.selected-functions-section {
width: 400px; /* 增加宽度 */
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 16px;
background-color: #fafafa;
}
.selected-functions-list {
max-height: 500px;
overflow-y: auto;
margin-top: 10px;
}
.selected-function-item {
display: flex;
align-items: center;
padding: 8px;
margin-bottom: 8px;
background: white;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
cursor: move; /* 添加拖拽光标 */
transition: all 0.2s ease;
}
.selected-function-item.drag-over {
border: 2px dashed #409eff;
background-color: #ecf5ff;
}
.drag-handle {
cursor: move;
padding: 0 8px;
color: #909399;
display: flex;
align-items: center;
justify-content: center;
}
.function-info {
display: flex;
align-items: flex-start; /* 改为flex-start以适应多行内容 */
flex: 1;
}
.function-details {
display: flex;
flex-direction: column;
margin: 0 8px;
flex: 1;
}
.function-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.function-path-below {
margin-top: 4px;
font-size: 12px;
align-self: flex-start; /* 让路径标签左对齐 */
}
.no-selected {
text-align: center;
color: #999;
font-style: italic;
padding: 20px 0;
}
.selected-functions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
/* Sortable相关样式 */
.sortable-ghost {
opacity: 0.4;
}
.sortable-chosen {
background-color: #ecf5ff;
border: 1px solid #409eff;
box-shadow: 0 0 8px rgba(64, 158, 255, 0.3);
}
.sortable-drag {
user-select: none;
}
</style>