- 在SysMenuController中添加refreshCache和refreshCurrentUserMenuCache接口 - 实现菜单缓存的按需刷新和用户级别缓存清理功能 - 优化菜单列表查询的缓存key策略,支持更精确的缓存命中 - 为菜单树查询添加缓存注解提升性能 - 在菜单增删改操作中完善缓存清理逻辑 - 添加allocateMenuToRole方法实现菜单角色分配功能 - 在前端DictTag组件中修复标签类型验证逻辑 - 为首页配置页面添加拖拽排序功能,支持快捷功能重新排列 - 集成Sortable.js实现拖拽交互和排序保存 - 优化菜单管理页面的缓存刷新机制和数据展示 - 完善配置更新事件处理,支持实时配置同步
865 lines
25 KiB
Vue
865 lines
25 KiB
Vue
<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> |