Przeglądaj źródła

feat: 新增权限树组件,支持权限选择和展示,优化角色管理表单

laiqi 11 miesięcy temu
rodzic
commit
a0d25e8895

+ 6 - 0
apps/web-ele/src/adapter/component/index.ts

@@ -34,6 +34,8 @@ import {
   ElUpload,
 } from 'element-plus';
 
+import PermissionTree from '../../components/permission-tree/index.vue';
+
 const withDefaultPlaceholder = <T extends Component>(
   component: T,
   type: 'input' | 'select',
@@ -75,6 +77,7 @@ export type ComponentType =
   | 'IconPicker'
   | 'Input'
   | 'InputNumber'
+  | 'PermissionTree'
   | 'RadioGroup'
   | 'Select'
   | 'Space'
@@ -164,6 +167,9 @@ async function initComponentAdapter() {
     },
     Input: withDefaultPlaceholder(ElInput, 'input'),
     InputNumber: withDefaultPlaceholder(ElInputNumber, 'input'),
+    PermissionTree: (props, { attrs, slots }) => {
+      return h(PermissionTree, { ...props, ...attrs }, slots);
+    },
     RadioGroup: (props, { attrs, slots }) => {
       let defaultSlot;
       if (Reflect.has(slots, 'default')) {

+ 106 - 0
apps/web-ele/src/components/permission-tree/README.md

@@ -0,0 +1,106 @@
+# 权限树组件 (PermissionTree)
+
+## 概述
+
+权限树组件是一个用于展示和选择菜单权限的弹窗式树形结构组件。它可以将以逗号分隔的权限ID字符串转换为可视化的树形结构,通过弹窗的方式提供更好的用户体验。
+
+## 功能特性
+
+- 🌳 **树形结构展示**: 将菜单权限以层级树形结构展示,优先显示备注(remark)字段
+- 🏷️ **类型标签**: 显示菜单类型(目录、菜单、按钮、API接口)
+- ✅ **多选支持**: 支持多选权限,父子关联选择
+- 🔄 **双向绑定**: 支持 v-model 双向数据绑定
+- 📱 **弹窗展示**: 通过弹窗方式展示,节省表单空间
+- 🎛️ **操作便捷**: 提供全选、清空、展开/收起等快捷操作
+- 📊 **实时统计**: 显示已选择权限数量
+
+## 使用方法
+
+### 基本用法
+
+```vue
+<template>
+  <PermissionTree v-model="selectedPermissions" />
+</template>
+
+<script setup>
+import { ref } from 'vue';
+import PermissionTree from '@/components/permission-tree/index.vue';
+
+const selectedPermissions = ref('1,2,3,4,5');
+</script>
+```
+
+### 在表单中使用
+
+```vue
+<template>
+  <VbenForm>
+    <!-- 其他表单项 -->
+    <FormItem component="PermissionTree" fieldName="popedom" label="权限设置" />
+  </VbenForm>
+</template>
+```
+
+## Props
+
+| 参数        | 类型    | 默认值       | 说明                           |
+| ----------- | ------- | ------------ | ------------------------------ |
+| modelValue  | string  | ''           | 选中的权限ID字符串,以逗号分隔 |
+| disabled    | boolean | false        | 是否禁用                       |
+| placeholder | string  | '请选择权限' | 输入框占位符文本               |
+
+## Events
+
+| 事件名            | 说明               | 回调参数        |
+| ----------------- | ------------------ | --------------- |
+| update:modelValue | 选中权限变化时触发 | (value: string) |
+
+## 数据格式
+
+### 输入格式
+
+权限ID以逗号分隔的字符串:
+
+```
+"1,2,3,4,5,6,7,8,9,10"
+```
+
+### 菜单类型
+
+- `1`: 目录 (蓝色标签)
+- `2`: 菜单 (绿色标签)
+- `3`: 按钮 (橙色标签)
+- `4`: API接口 (灰色标签)
+
+## 样式定制
+
+组件提供了基本的样式,可以通过CSS变量或深度选择器进行定制:
+
+```css
+.permission-tree {
+  /* 自定义边框 */
+  border: 1px solid #your-color;
+
+  /* 自定义圆角 */
+  border-radius: 8px;
+}
+
+.permission-tree :deep(.tree-node:hover) {
+  /* 自定义悬停效果 */
+  background-color: #your-hover-color;
+}
+```
+
+## 注意事项
+
+1. 组件会自动从后端获取菜单列表数据,首次点击时加载
+2. 权限ID必须是有效的数字,无效ID会被自动过滤
+3. 组件支持父子关联选择,选中父节点会自动选中所有子节点
+4. 弹窗内树形结构最大高度限制为 384px (24rem),超出会显示滚动条
+5. 节点名称优先显示 `remark` 字段,如果为空则显示 `menu_name` 字段
+6. 弹窗操作支持取消,只有点击确定才会保存选择结果
+
+## 示例
+
+完整的使用示例可以参考 `test-permission-tree.vue` 文件。

+ 371 - 0
apps/web-ele/src/components/permission-tree/index.vue

@@ -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>

+ 6 - 4
apps/web-ele/src/views/system-manage/role-manage/form.vue

@@ -76,13 +76,15 @@ const [BaseForm, baseFormApi] = useVbenForm({
       },
     },
     {
-      component: 'Input',
+      component: 'PermissionTree',
       fieldName: 'popedom',
       label: '权限设置',
       componentProps: {
-        type: 'textarea',
-        placeholder: '请输入权限设置',
-        rows: 4,
+        placeholder: '请选择权限',
+      },
+      // @ts-ignore wrapperProps属性在运行时有效,但类型定义中缺失
+      wrapperProps: {
+        class: 'col-span-2',
       },
       // @ts-ignore ifShow属性在运行时有效,但类型定义中缺失
       ifShow: ({ formType }: { formType: string }) => formType !== 'create',