|
|
@@ -0,0 +1,371 @@
|
|
|
+<script lang="ts" setup>
|
|
|
+import type { MenuEntity } from '@vben/types';
|
|
|
+
|
|
|
+import { computed, onMounted, ref } from 'vue';
|
|
|
+
|
|
|
+import { VbenTree } from '@vben/common-ui';
|
|
|
+
|
|
|
+import { ElButton, ElDialog, ElInput, ElTag } from 'element-plus';
|
|
|
+
|
|
|
+import { getMenuListApi } from '#/api/menu';
|
|
|
+
|
|
|
+// 扩展菜单类型,添加children属性
|
|
|
+interface MenuTreeNode extends MenuEntity {
|
|
|
+ children?: MenuTreeNode[];
|
|
|
+}
|
|
|
+
|
|
|
+interface Props {
|
|
|
+ /** 当前选中的权限ID字符串,以逗号分隔 */
|
|
|
+ modelValue?: string;
|
|
|
+ /** 是否禁用 */
|
|
|
+ disabled?: boolean;
|
|
|
+ /** 占位符文本 */
|
|
|
+ placeholder?: string;
|
|
|
+}
|
|
|
+
|
|
|
+const props = withDefaults(defineProps<Props>(), {
|
|
|
+ modelValue: '',
|
|
|
+ disabled: false,
|
|
|
+ placeholder: '请选择权限',
|
|
|
+});
|
|
|
+
|
|
|
+const emit = defineEmits<{
|
|
|
+ 'update:modelValue': [value: string];
|
|
|
+}>();
|
|
|
+
|
|
|
+const treeData = ref<MenuTreeNode[]>([]);
|
|
|
+const loading = ref(false);
|
|
|
+const dialogVisible = ref(false);
|
|
|
+const treeRef = ref();
|
|
|
+
|
|
|
+// 临时选中的权限(用于弹窗中的操作)
|
|
|
+const tempSelectedPermissions = ref<number[]>([]);
|
|
|
+
|
|
|
+// 将字符串转换为数组
|
|
|
+const selectedPermissions = computed({
|
|
|
+ get() {
|
|
|
+ return tempSelectedPermissions.value;
|
|
|
+ },
|
|
|
+ set(value: number[]) {
|
|
|
+ tempSelectedPermissions.value = value;
|
|
|
+ },
|
|
|
+});
|
|
|
+
|
|
|
+// 显示文本
|
|
|
+const displayText = computed(() => {
|
|
|
+ if (!props.modelValue) return '';
|
|
|
+ const count = props.modelValue.split(',').filter((id) => id.trim()).length;
|
|
|
+ return count > 0 ? `已选择 ${count} 项权限` : '';
|
|
|
+});
|
|
|
+
|
|
|
+// 选中数量
|
|
|
+const selectedCount = computed(() => {
|
|
|
+ return tempSelectedPermissions.value.length;
|
|
|
+});
|
|
|
+
|
|
|
+// 获取菜单类型标签样式
|
|
|
+function getMenuTypeTag(type: number) {
|
|
|
+ const typeMap = {
|
|
|
+ 1: { text: '目录', type: 'primary' },
|
|
|
+ 2: { text: '菜单', type: 'success' },
|
|
|
+ 3: { text: '按钮', type: 'warning' },
|
|
|
+ 4: { text: 'API接口', type: 'info' },
|
|
|
+ } as const;
|
|
|
+ return (
|
|
|
+ typeMap[type as keyof typeof typeMap] || { text: '未知', type: 'info' }
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// 将平铺的菜单数据转换为树形结构
|
|
|
+function buildMenuTree(menus: MenuEntity[]): MenuTreeNode[] {
|
|
|
+ const menuMap = new Map<number, MenuTreeNode>();
|
|
|
+ const rootMenus: MenuTreeNode[] = [];
|
|
|
+
|
|
|
+ // 先创建所有节点的映射
|
|
|
+ menus.forEach((menu) => {
|
|
|
+ menuMap.set(menu.menu_id, { ...menu, children: [] });
|
|
|
+ });
|
|
|
+
|
|
|
+ // 构建树形结构
|
|
|
+ menus.forEach((menu) => {
|
|
|
+ const menuNode = menuMap.get(menu.menu_id)!;
|
|
|
+ if (menu.parent_id === 0) {
|
|
|
+ // 根节点
|
|
|
+ rootMenus.push(menuNode);
|
|
|
+ } else {
|
|
|
+ // 子节点
|
|
|
+ const parent = menuMap.get(menu.parent_id);
|
|
|
+ if (parent) {
|
|
|
+ parent.children = parent.children || [];
|
|
|
+ parent.children.push(menuNode);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 递归排序
|
|
|
+ function sortTree(nodes: MenuTreeNode[]): MenuTreeNode[] {
|
|
|
+ return nodes
|
|
|
+ .sort((a, b) => (a.order_num || 0) - (b.order_num || 0))
|
|
|
+ .map((node) => {
|
|
|
+ if (node.children && node.children.length > 0) {
|
|
|
+ node.children = sortTree(node.children);
|
|
|
+ }
|
|
|
+ return node;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ return sortTree(rootMenus);
|
|
|
+}
|
|
|
+
|
|
|
+// 获取菜单列表
|
|
|
+async function fetchMenuList() {
|
|
|
+ try {
|
|
|
+ loading.value = true;
|
|
|
+ const res = await getMenuListApi({
|
|
|
+ pageindex: 1,
|
|
|
+ rows: 10_000, // 获取所有菜单
|
|
|
+ });
|
|
|
+
|
|
|
+ if (res && res.Data) {
|
|
|
+ treeData.value = buildMenuTree(res.Data);
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取菜单列表失败', error);
|
|
|
+ } finally {
|
|
|
+ loading.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 获取所有菜单ID
|
|
|
+function getAllMenuIds(): number[] {
|
|
|
+ const ids: number[] = [];
|
|
|
+ function traverse(nodes: MenuTreeNode[]) {
|
|
|
+ nodes.forEach((node) => {
|
|
|
+ ids.push(node.menu_id);
|
|
|
+ if (node.children && node.children.length > 0) {
|
|
|
+ traverse(node.children);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ traverse(treeData.value);
|
|
|
+ return ids;
|
|
|
+}
|
|
|
+
|
|
|
+// 打开弹窗
|
|
|
+function openDialog() {
|
|
|
+ if (props.disabled) return;
|
|
|
+
|
|
|
+ // 初始化临时选中状态
|
|
|
+ tempSelectedPermissions.value = props.modelValue
|
|
|
+ ? props.modelValue
|
|
|
+ .split(',')
|
|
|
+ .map(Number)
|
|
|
+ .filter((id) => !Number.isNaN(id))
|
|
|
+ : [];
|
|
|
+
|
|
|
+ dialogVisible.value = true;
|
|
|
+
|
|
|
+ // 如果还没有加载菜单数据,则加载
|
|
|
+ if (treeData.value.length === 0) {
|
|
|
+ fetchMenuList();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 确认选择
|
|
|
+function confirmDialog() {
|
|
|
+ const stringValue = tempSelectedPermissions.value.join(',');
|
|
|
+ emit('update:modelValue', stringValue);
|
|
|
+ dialogVisible.value = false;
|
|
|
+}
|
|
|
+
|
|
|
+// 取消选择
|
|
|
+function cancelDialog() {
|
|
|
+ dialogVisible.value = false;
|
|
|
+ // 恢复原始选中状态
|
|
|
+ tempSelectedPermissions.value = props.modelValue
|
|
|
+ ? props.modelValue
|
|
|
+ .split(',')
|
|
|
+ .map(Number)
|
|
|
+ .filter((id) => !Number.isNaN(id))
|
|
|
+ : [];
|
|
|
+}
|
|
|
+
|
|
|
+// 全选
|
|
|
+function selectAll() {
|
|
|
+ tempSelectedPermissions.value = getAllMenuIds();
|
|
|
+}
|
|
|
+
|
|
|
+// 清空
|
|
|
+function clearAll() {
|
|
|
+ tempSelectedPermissions.value = [];
|
|
|
+}
|
|
|
+
|
|
|
+// 展开全部
|
|
|
+function expandAll() {
|
|
|
+ if (treeRef.value && treeRef.value.expandAll) {
|
|
|
+ treeRef.value.expandAll();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 收起全部
|
|
|
+function collapseAll() {
|
|
|
+ if (treeRef.value && treeRef.value.collapseAll) {
|
|
|
+ treeRef.value.collapseAll();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ // 组件挂载时不立即加载数据,等用户点击时再加载
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <div class="permission-tree-wrapper">
|
|
|
+ <!-- 触发按钮 -->
|
|
|
+ <div class="permission-trigger" @click="openDialog">
|
|
|
+ <ElInput
|
|
|
+ :model-value="displayText"
|
|
|
+ readonly
|
|
|
+ :placeholder="placeholder"
|
|
|
+ class="cursor-pointer"
|
|
|
+ >
|
|
|
+ <template #suffix>
|
|
|
+ <span class="cursor-pointer text-gray-400 hover:text-gray-600">
|
|
|
+ ⚙️
|
|
|
+ </span>
|
|
|
+ </template>
|
|
|
+ </ElInput>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 权限选择弹窗 -->
|
|
|
+ <ElDialog
|
|
|
+ v-model="dialogVisible"
|
|
|
+ title="权限设置"
|
|
|
+ :close-on-click-modal="false"
|
|
|
+ :close-on-press-escape="false"
|
|
|
+ :append-to-body="true"
|
|
|
+ >
|
|
|
+ <div class="permission-dialog-content">
|
|
|
+ <!-- 操作栏 -->
|
|
|
+ <div class="permission-actions mb-4">
|
|
|
+ <div class="flex items-center justify-between">
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <ElButton size="small" @click="selectAll">全选</ElButton>
|
|
|
+ <ElButton size="small" @click="clearAll">清空</ElButton>
|
|
|
+ <ElButton size="small" @click="expandAll">展开全部</ElButton>
|
|
|
+ <ElButton size="small" @click="collapseAll">收起全部</ElButton>
|
|
|
+ </div>
|
|
|
+ <div class="text-sm text-gray-500">
|
|
|
+ 已选择: {{ selectedCount }} 项
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 权限树 -->
|
|
|
+ <div class="permission-tree-container">
|
|
|
+ <VbenTree
|
|
|
+ ref="treeRef"
|
|
|
+ v-model="selectedPermissions"
|
|
|
+ :tree-data="treeData"
|
|
|
+ :multiple="true"
|
|
|
+ :check-strictly="false"
|
|
|
+ :default-expanded-level="2"
|
|
|
+ label-field="remark"
|
|
|
+ value-field="menu_id"
|
|
|
+ children-field="children"
|
|
|
+ :bordered="true"
|
|
|
+ class="permission-tree"
|
|
|
+ >
|
|
|
+ <template #node="{ value }">
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <span class="text-sm">{{
|
|
|
+ value.remark || value.menu_name
|
|
|
+ }}</span>
|
|
|
+ <ElTag
|
|
|
+ v-if="value.menu_type"
|
|
|
+ :type="getMenuTypeTag(value.menu_type).type"
|
|
|
+ size="small"
|
|
|
+ effect="light"
|
|
|
+ >
|
|
|
+ {{ getMenuTypeTag(value.menu_type).text }}
|
|
|
+ </ElTag>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </VbenTree>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 加载状态 -->
|
|
|
+ <div v-if="loading" class="flex items-center justify-center py-8">
|
|
|
+ <span class="text-sm text-gray-500">加载中...</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template #footer>
|
|
|
+ <div class="dialog-footer">
|
|
|
+ <ElButton @click="cancelDialog">取消</ElButton>
|
|
|
+ <ElButton type="primary" @click="confirmDialog">确定</ElButton>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </ElDialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.permission-tree-wrapper {
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.permission-trigger :deep(.el-input__wrapper) {
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.permission-trigger :deep(.el-input__inner) {
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.permission-dialog-content {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ height: 100%; /* 使用父容器的全部高度 */
|
|
|
+}
|
|
|
+
|
|
|
+.permission-tree-container {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0; /* 确保flex子元素可以收缩 */
|
|
|
+ max-height: 80vh; /* 最大高度不超过80vh */
|
|
|
+ padding: 8px;
|
|
|
+ overflow: hidden;
|
|
|
+ background-color: #fff;
|
|
|
+ border: 1px solid #d9d9d9;
|
|
|
+ border-radius: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+.permission-tree-container :deep(.tree-node) {
|
|
|
+ padding: 4px 8px;
|
|
|
+ border-radius: 4px;
|
|
|
+ transition: background-color 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.permission-tree-container :deep(.tree-node:hover) {
|
|
|
+ background-color: #f5f5f5;
|
|
|
+}
|
|
|
+
|
|
|
+.permission-tree {
|
|
|
+ height: 100%;
|
|
|
+ max-height: calc(80vh - 100px); /* 减去容器padding和其他元素高度 */
|
|
|
+ overflow: hidden auto; /* 隐藏x轴滚动 */ /* 设置为y轴滚动 */
|
|
|
+}
|
|
|
+
|
|
|
+/* 确保弹窗内容区域正确布局 */
|
|
|
+:deep(.el-dialog__body) {
|
|
|
+ height: calc(100vh - 140px); /* 减去头部(60px)和底部(80px)的高度 */
|
|
|
+ padding: 20px;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-dialog__footer) {
|
|
|
+ flex-shrink: 0; /* 防止底部按钮被压缩 */
|
|
|
+ padding: 15px 20px;
|
|
|
+ border-top: 1px solid #e4e7ed;
|
|
|
+}
|
|
|
+</style>
|