zhengnaiwen_citu пре 4 месеци
родитељ
комит
fdfb696a6b

+ 2 - 0
components.d.ts

@@ -26,11 +26,13 @@ declare module 'vue' {
     CtMenu: typeof import('./src/components/CtVuetify/CtMenu/index.vue')['default']
     CtPagination: typeof import('./src/components/CtPagination/index.vue')['default']
     CtSearch: typeof import('./src/components/CtSearch/index.vue')['default']
+    CtSelect: typeof import('./src/components/CtVuetify/CtSelect/index.vue')['default']
     CtTable: typeof import('./src/components/CtTable/index.vue')['default']
     CtTextField: typeof import('./src/components/CtVuetify/CtTextField/index.vue')['default']
     DatePicker: typeof import('./src/components/DatePicker/index.vue')['default']
     Echarts: typeof import('./src/components/Echarts/index.vue')['default']
     ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
+    ElTree: typeof import('element-plus/es')['ElTree']
     Empty: typeof import('./src/components/Empty/index.vue')['default']
     File: typeof import('./src/components/Upload/file.vue')['default']
     HeadSearch: typeof import('./src/components/headSearch/index.vue')['default']

+ 93 - 0
src/api/recruit/enterprise/system/role/index.js

@@ -0,0 +1,93 @@
+import request from '@/config/axios'
+// 角色管理-获得角色分页
+export const getRolePage = async (params) => {
+  return await request.get({
+    url: '/app-api/menduner/system/recruit/role/page',
+    params
+  })
+}
+
+// 角色管理-获得角色精简列表
+export const getRolePageSimple = async () => {
+  return await request.get({
+    url: '/app-api/menduner/system/recruit/role/list-all-simple'
+  })
+}
+
+// 角色管理-添加角色
+export const addRole = async (data) => {
+  return await request.post({
+    url: '/app-api/menduner/system/recruit/role/create',
+    data
+  })
+}
+
+// 角色管理-更新角色
+export const updateRole = async (data) => {
+  return await request.put({
+    url: '/app-api/menduner/system/recruit/role/update',
+    data
+  })
+}
+
+// 角色管理-删除角色
+export const deleteRole = async (params) => {
+  return await request.delete({
+    url: '/app-api/menduner/system/recruit/role/delete',
+    params
+  })
+}
+
+// 获取菜单精简信息列表
+export const getMenu = async () => {
+  return await request.get({
+    url: '/app-api/menduner/system/recruit/menu/simple-list'
+  })
+}
+
+// 获取菜单精简信息列表
+export const getRoleMenu = async (params) => {
+  return await request.get({
+    url: '/app-api/menduner/system/recruit/permission/list-role-menus',
+    params
+  })
+}
+
+// 角色管理-赋予角色菜单
+export const saveRoleMenu = async (data) => {
+  return await request.post({
+    url: '/app-api/menduner/system/recruit/permission/assign-role-menu',
+    data
+  })
+}
+
+// 获取数据权限树
+export const getDataMenuTree = async () => {
+  return await request.get({
+    url: '/app-api/menduner/system/recruit/enterprise/get/tree'
+  })
+}
+
+// 角色管理-赋予角色数据权限
+export const saveDataPermission = async (data) => {
+  return await request.post({
+    url: '/app-api/menduner/system/recruit/permission/assign-role-data-scope',
+    data
+  })
+}
+
+// 角色管理-获取用户角色编号列表
+export const getRoleList = async (params) => {
+  return await request.get({
+    url: '/app-api/menduner/system/recruit/permission/list-user-roles',
+    params
+  })
+}
+
+// 角色管理-赋予用户角色
+export const saveUserRole = async (data) => {
+  return await request.post({
+    url: '/app-api/menduner/system/recruit/permission/assign-user-role',
+    data
+  })
+}

+ 75 - 48
src/components/CtTable/index.vue

@@ -3,7 +3,8 @@
     <div v-if="isTools" class="text-end mb-3">
       <v-btn class="ml-2" color="primary" @click="emit('add')">
         <v-icon left>mdi-plus</v-icon>
-        新增
+        {{ t('common.add') }} 
+        <!-- 新增 -->
       </v-btn>
       <slot name="addToTools"></slot>
     </div>
@@ -26,7 +27,7 @@
       fixed-header
       :disable-sort="disableSort"
       :items-per-page="itemsPerPage"
-      :no-data-text="noDataText"
+      :no-data-text="noDataText || t('common.noData')"
       :hide-default-header="hideDefaultHeader"
       @update:modelValue="handleSelect"
     >
@@ -38,8 +39,8 @@
       </template>
       <template v-if="!Object.keys(slot).includes('actions')" v-slot:[`item.actions`]="{ item }">
         <td>
-          <v-btn variant="text" color="primary" @click="edit(item)">编辑</v-btn>
-          <v-btn variant="text" color="error" @click="del(item)">删除</v-btn>
+          <v-btn variant="text" color="primary" @click="edit(item)">{{ t('common.edit') }}</v-btn>
+          <v-btn variant="text" color="error" @click="del(item)">{{ t('common.delete') }}</v-btn>
         </td>
       </template>
       <template #bottom>
@@ -55,7 +56,9 @@
 <script setup>
 defineOptions({ name: 'CtTable'})
 import { ref, computed, useSlots, watch, onMounted } from 'vue'
+import { useI18n } from '@/hooks/web/useI18n'
 
+const { t } = useI18n()
 const selected = ref([])
 const emit = defineEmits(['pageHandleChange', 'del', 'edit', 'add', 'selected'])
 const props = defineProps({
@@ -117,7 +120,7 @@ const props = defineProps({
   },
   noDataText: {
     type: String,
-    default: '暂无数据'
+    default: ''
   },
   showSelect: {
     type: Boolean,
@@ -201,52 +204,76 @@ const handleSelect = (e) => {
   :deep(.v-table.v-table--hover > .v-table__wrapper > table > tbody > tr > td) {
     white-space: nowrap !important;
   }
-  :deep {
-    table > thead > tr > th:last-child {
-      border-bottom: 1px solid #e0e0e0 !important;
-    }
+  :deep(table > thead > tr > th:last-child) {
+    border-bottom: 1px solid #e0e0e0 !important;
   }
 
   .fixed-last-item {
-    :deep {
-      .v-table__wrapper {
-        position: relative;
-        &::-webkit-scrollbar:horizontal {
-          height: 8px;
-        }
-        &:not(:hover)::-webkit-scrollbar:horizontal {
-          display: none;
-        }
-      }
-
-      .v-table__wrapper:not(:hover), 
-      .v-table__wrapper::-webkit-scrollbar-thumb:horizontal {
-        background: transparent;
-      }
-
-      table > tbody > tr > td:last-child,
-      table > thead > tr > th:last-child {
-        position: sticky !important;
-        position: -webkit-sticky !important;
-        right: 0;
-        z-index: 1;
-        background: white !important;
-        box-shadow: none;
-      }
-
-      .v-table__wrapper.hasScroll {
-        table > tbody > tr > td:last-child,
-        table > thead > tr > th:last-child {
-          border-left: 1px solid #e0e0e0 !important;
-          // box-shadow: inset 10px 0 10px -10px rgba(0, 0, 0, .35) !important;
-        }
-
-        table > thead > tr > th:last-child {
-          z-index: 10 !important;
-          // box-shadow: inset 10px 0 10px -10px rgba(0, 0, 0, .35) !important;
-          border-bottom: 1px solid #e0e0e0 !important;
-        }
-      }
+    :deep(.v-table__wrapper) { position: relative; }
+    :deep(.v-table__wrapper::-webkit-scrollbar:horizontal) { height: 8px; }
+    :deep(.v-table__wrapper:not(:hover)::-webkit-scrollbar:horizontal) { display: none; }
+    :deep(.v-table__wrapper:not(:hover), .v-table__wrapper::-webkit-scrollbar-thumb:horizontal) { background: transparent; }
+    :deep(table > tbody > tr > td:last-child) { 
+      position: sticky !important;
+      position: -webkit-sticky !important;
+      right: 0;
+      z-index: 1;
+      background: white !important;
+      box-shadow: none;
+    }
+    :deep(table > thead > tr > th:last-child) { 
+      position: sticky !important;
+      position: -webkit-sticky !important;
+      right: 0;
+      z-index: 1;
+      background: white !important;
+      box-shadow: none;
+    }
+    :deep(.v-table__wrapper.hasScroll table > tbody > tr > td:last-child) {
+      border-left: 1px solid #e0e0e0 !important;
+    }
+    :deep(.v-table__wrapper.hasScroll table > thead > tr > th:last-child) {
+      border-left: 1px solid #e0e0e0 !important;
     }
+    // :deep {
+    //   .v-table__wrapper {
+    //     position: relative;
+    //     &::-webkit-scrollbar:horizontal {
+    //       height: 8px;
+    //     }
+    //     &:not(:hover)::-webkit-scrollbar:horizontal {
+    //       display: none;
+    //     }
+    //   }
+
+    //   .v-table__wrapper:not(:hover), 
+    //   .v-table__wrapper::-webkit-scrollbar-thumb:horizontal {
+    //     background: transparent;
+    //   }
+
+    //   table > tbody > tr > td:last-child,
+    //   table > thead > tr > th:last-child {
+    //     position: sticky !important;
+    //     position: -webkit-sticky !important;
+    //     right: 0;
+    //     z-index: 1;
+    //     background: white !important;
+    //     box-shadow: none;
+    //   }
+
+    //   .v-table__wrapper.hasScroll {
+    //     table > tbody > tr > td:last-child,
+    //     table > thead > tr > th:last-child {
+    //       border-left: 1px solid #e0e0e0 !important;
+    //       // box-shadow: inset 10px 0 10px -10px rgba(0, 0, 0, .35) !important;
+    //     }
+
+    //     // table > thead > tr > th:last-child {
+    //     //   z-index: 10 !important;
+    //     //   // box-shadow: inset 10px 0 10px -10px rgba(0, 0, 0, .35) !important;
+    //     //   border-bottom: 1px solid #e0e0e0 !important;
+    //     // }
+    //   }
+    // }
   }
 </style>

+ 21 - 0
src/components/CtVuetify/CtSelect/index.vue

@@ -0,0 +1,21 @@
+<template>
+  <v-select v-bind="attr">
+    <template v-for="(item, key, i) in slots" :key="i" v-slot:[key]="data">
+      <slot v-if="key" :name="key" v-bind="data"></slot>
+    </template>
+  </v-select>
+</template>
+
+<script setup>
+import { useAttrs, useSlots } from 'vue'
+defineOptions({name: 'ct-select'})
+const attr = useAttrs()
+const slots = useSlots()
+// slots.forEach(e => {
+//   console.log(e)
+// })
+</script>
+
+<style lang="scss" scoped>
+
+</style>

+ 10 - 0
src/router/modules/components/recruit/enterprise.js

@@ -273,6 +273,16 @@ const enterprise = [
       icon: 'mdi-cog-outline'
     },
     children: [
+      // 角色管理
+      {
+        path: '/recruit/enterprise/systemManagement/roleManagement',
+        meta: {
+          title: '角色管理',
+          enName: 'Role Management',
+          isAdmin: true // 企业管理员菜单
+        },
+        component: () => import('@/views/recruit/enterprise/systemManagement/roleManagement/index.vue')
+      },
       // 集团
       {
         path: '/recruit/enterprise/systemManagement/groupAccount',

+ 403 - 0
src/utils/tree.ts

@@ -0,0 +1,403 @@
+interface TreeHelperConfig {
+  id: string
+  children: string
+  pid: string
+}
+
+type Fn = Function
+
+const DEFAULT_CONFIG: TreeHelperConfig = {
+  id: 'id',
+  children: 'children',
+  pid: 'pid'
+}
+export const defaultProps = {
+  children: 'children',
+  label: 'name',
+  value: 'id',
+  isLeaf: 'leaf',
+  emitPath: false // 用于 cascader 组件:在选中节点改变时,是否返回由该节点所在的各级菜单的值所组成的数组,若设置 false,则只返回该节点的值
+}
+
+const getConfig = (config: Partial<TreeHelperConfig>) => Object.assign({}, DEFAULT_CONFIG, config)
+
+// tree from list
+export const listToTree = <T = any>(list: any[], config: Partial<TreeHelperConfig> = {}): T[] => {
+  const conf = getConfig(config) as TreeHelperConfig
+  const nodeMap = new Map()
+  const result: T[] = []
+  const { id, children, pid } = conf
+
+  for (const node of list) {
+    node[children] = node[children] || []
+    nodeMap.set(node[id], node)
+  }
+  for (const node of list) {
+    const parent = nodeMap.get(node[pid])
+    ;(parent ? parent.children : result).push(node)
+  }
+  return result
+}
+
+export const treeToList = <T = any>(tree: any, config: Partial<TreeHelperConfig> = {}): T => {
+  config = getConfig(config)
+  const { children } = config
+  const result: any = [...tree]
+  for (let i = 0; i < result.length; i++) {
+    if (!result[i][children!]) continue
+    result.splice(i + 1, 0, ...result[i][children!])
+  }
+  return result
+}
+
+export const findNode = <T = any>(
+  tree: any,
+  func: Fn,
+  config: Partial<TreeHelperConfig> = {}
+): T | null => {
+  config = getConfig(config)
+  const { children } = config
+  const list = [...tree]
+  for (const node of list) {
+    if (func(node)) return node
+    node[children!] && list.push(...node[children!])
+  }
+  return null
+}
+
+export const findNodeAll = <T = any>(
+  tree: any,
+  func: Fn,
+  config: Partial<TreeHelperConfig> = {}
+): T[] => {
+  config = getConfig(config)
+  const { children } = config
+  const list = [...tree]
+  const result: T[] = []
+  for (const node of list) {
+    func(node) && result.push(node)
+    node[children!] && list.push(...node[children!])
+  }
+  return result
+}
+
+export const findPath = <T = any>(
+  tree: any,
+  func: Fn,
+  config: Partial<TreeHelperConfig> = {}
+): T | T[] | null => {
+  config = getConfig(config)
+  const path: T[] = []
+  const list = [...tree]
+  const visitedSet = new Set()
+  const { children } = config
+  while (list.length) {
+    const node = list[0]
+    if (visitedSet.has(node)) {
+      path.pop()
+      list.shift()
+    } else {
+      visitedSet.add(node)
+      node[children!] && list.unshift(...node[children!])
+      path.push(node)
+      if (func(node)) {
+        return path
+      }
+    }
+  }
+  return null
+}
+
+export const findPathAll = (tree: any, func: Fn, config: Partial<TreeHelperConfig> = {}) => {
+  config = getConfig(config)
+  const path: any[] = []
+  const list = [...tree]
+  const result: any[] = []
+  const visitedSet = new Set(),
+    { children } = config
+  while (list.length) {
+    const node = list[0]
+    if (visitedSet.has(node)) {
+      path.pop()
+      list.shift()
+    } else {
+      visitedSet.add(node)
+      node[children!] && list.unshift(...node[children!])
+      path.push(node)
+      func(node) && result.push([...path])
+    }
+  }
+  return result
+}
+
+export const filter = <T = any>(
+  tree: T[],
+  func: (n: T) => boolean,
+  config: Partial<TreeHelperConfig> = {}
+): T[] => {
+  config = getConfig(config)
+  const children = config.children as string
+
+  function listFilter(list: T[]) {
+    return list
+      .map((node: any) => ({ ...node }))
+      .filter((node) => {
+        node[children] = node[children] && listFilter(node[children])
+        return func(node) || (node[children] && node[children].length)
+      })
+  }
+
+  return listFilter(tree)
+}
+
+export const forEach = <T = any>(
+  tree: T[],
+  func: (n: T) => any,
+  config: Partial<TreeHelperConfig> = {}
+): void => {
+  config = getConfig(config)
+  const list: any[] = [...tree]
+  const { children } = config
+  for (let i = 0; i < list.length; i++) {
+    // func 返回true就终止遍历,避免大量节点场景下无意义循环,引起浏览器卡顿
+    if (func(list[i])) {
+      return
+    }
+    children && list[i][children] && list.splice(i + 1, 0, ...list[i][children])
+  }
+}
+
+/**
+ * @description: Extract tree specified structure
+ */
+export const treeMap = <T = any>(
+  treeData: T[],
+  opt: { children?: string; conversion: Fn }
+): T[] => {
+  return treeData.map((item) => treeMapEach(item, opt))
+}
+
+/**
+ * @description: Extract tree specified structure
+ */
+export const treeMapEach = (
+  data: any,
+  { children = 'children', conversion }: { children?: string; conversion: Fn }
+) => {
+  const haveChildren = Array.isArray(data[children]) && data[children].length > 0
+  const conversionData = conversion(data) || {}
+  if (haveChildren) {
+    return {
+      ...conversionData,
+      [children]: data[children].map((i: number) =>
+        treeMapEach(i, {
+          children,
+          conversion
+        })
+      )
+    }
+  } else {
+    return {
+      ...conversionData
+    }
+  }
+}
+
+/**
+ * 递归遍历树结构
+ * @param treeDatas 树
+ * @param callBack 回调
+ * @param parentNode 父节点
+ */
+export const eachTree = (treeDatas: any[], callBack: Fn, parentNode = {}) => {
+  treeDatas.forEach((element) => {
+    const newNode = callBack(element, parentNode) || element
+    if (element.children) {
+      eachTree(element.children, callBack, newNode)
+    }
+  })
+}
+
+/**
+ * 构造树型结构数据
+ * @param {*} data 数据源
+ * @param {*} id id字段 默认 'id'
+ * @param {*} parentId 父节点字段 默认 'parentId'
+ * @param {*} children 孩子节点字段 默认 'children'
+ */
+export const handleTree = (data: any[], id?: string, parentId?: string, children?: string) => {
+  if (!Array.isArray(data)) {
+    console.warn('data must be an array')
+    return []
+  }
+  const config = {
+    id: id || 'id',
+    parentId: parentId || 'parentId',
+    childrenList: children || 'children'
+  }
+
+  const childrenListMap = {}
+  const nodeIds = {}
+  const tree: any[] = []
+
+  for (const d of data) {
+    const parentId = d[config.parentId]
+    if (childrenListMap[parentId] == null) {
+      childrenListMap[parentId] = []
+    }
+    nodeIds[d[config.id]] = d
+    childrenListMap[parentId].push(d)
+  }
+
+  for (const d of data) {
+    const parentId = d[config.parentId]
+    if (nodeIds[parentId] == null) {
+      const { id, name, children } = d
+      tree.push({ id, name, children })
+    }
+  }
+
+  for (const t of tree) {
+    adaptToChildrenList(t)
+  }
+
+  function adaptToChildrenList(o) {
+    if (childrenListMap[o[config.id]] !== null) {
+      o[config.childrenList] = childrenListMap[o[config.id]]
+    }
+    if (o[config.childrenList]) {
+      for (const c of o[config.childrenList]) {
+        adaptToChildrenList(c)
+      }
+    }
+  }
+
+  return tree
+}
+
+/**
+ * 构造树型结构数据
+ * @param {*} data 数据源
+ * @param {*} id id字段 默认 'id'
+ * @param {*} parentId 父节点字段 默认 'parentId'
+ * @param {*} children 孩子节点字段 默认 'children'
+ * @param {*} rootId 根Id 默认 0
+ */
+// @ts-ignore
+export const handleTree2 = (data, id, parentId, children, rootId) => {
+  id = id || 'id'
+  parentId = parentId || 'parentId'
+  // children = children || 'children'
+  rootId =
+    rootId ||
+    Math.min(
+      ...data.map((item) => {
+        return item[parentId]
+      })
+    ) ||
+    0
+  // 对源数据深度克隆
+  const cloneData = JSON.parse(JSON.stringify(data))
+  // 循环所有项
+  const treeData = cloneData.filter((father) => {
+    const branchArr = cloneData.filter((child) => {
+      // 返回每一项的子级数组
+      return father[id] === child[parentId]
+    })
+    branchArr.length > 0 ? (father.children = branchArr) : ''
+    // 返回第一层
+    return father[parentId] === rootId
+  })
+  return treeData !== '' ? treeData : data
+}
+
+/**
+ * 校验选中的节点,是否为指定 level
+ *
+ * @param tree 要操作的树结构数据
+ * @param nodeId 需要判断在什么层级的数据
+ * @param level 检查的级别, 默认检查到二级
+ * @return true 是;false 否
+ */
+export const checkSelectedNode = (tree: any[], nodeId: any, level = 2): boolean => {
+  if (typeof tree === 'undefined' || !Array.isArray(tree) || tree.length === 0) {
+    console.warn('tree must be an array')
+    return false
+  }
+
+  // 校验是否是一级节点
+  if (tree.some((item) => item.id === nodeId)) {
+    return false
+  }
+
+  // 递归计数
+  let count = 1
+
+  // 深层次校验
+  function performAThoroughValidation(arr: any[]): boolean {
+    count += 1
+    for (const item of arr) {
+      if (item.id === nodeId) {
+        return true
+      } else if (typeof item.children !== 'undefined' && item.children.length !== 0) {
+        if (performAThoroughValidation(item.children)) {
+          return true
+        }
+      }
+    }
+    return false
+  }
+
+  for (const item of tree) {
+    count = 1
+    if (performAThoroughValidation(item.children)) {
+      // 找到后对比是否是期望的层级
+      if (count >= level) {
+        return true
+      }
+    }
+  }
+
+  return false
+}
+
+/**
+ * 获取节点的完整结构
+ * @param tree 树数据
+ * @param nodeId 节点 id
+ */
+export const treeToString = (tree: any[], nodeId) => {
+  if (typeof tree === 'undefined' || !Array.isArray(tree) || tree.length === 0) {
+    console.warn('tree must be an array')
+    return ''
+  }
+  // 校验是否是一级节点
+  const node = tree.find((item) => item.id === nodeId)
+  if (typeof node !== 'undefined') {
+    return node.name
+  }
+  let str = ''
+
+  function performAThoroughValidation(arr) {
+    for (const item of arr) {
+      if (item.id === nodeId) {
+        str += ` / ${item.name}`
+        return true
+      } else if (typeof item.children !== 'undefined' && item.children.length !== 0) {
+        str += ` / ${item.name}`
+        if (performAThoroughValidation(item.children)) {
+          return true
+        }
+      }
+    }
+    return false
+  }
+
+  for (const item of tree) {
+    str = `${item.name}`
+    if (performAThoroughValidation(item.children)) {
+      break
+    }
+  }
+  return str
+}

+ 175 - 0
src/views/recruit/enterprise/systemManagement/roleManagement/components/DataPermission.vue

@@ -0,0 +1,175 @@
+<template>
+  <div>
+    <div class="mb-3">角色名称:<v-chip color="primary" label>{{ props.editItem.name }}</v-chip></div>
+    <div class="mb-3">角色标识:<v-chip color="primary" label>{{ props.editItem.code }}</v-chip></div>
+    <div class="mb-3 d-flex align-center">权限范围:
+      <v-select
+        :items="items"
+        variant="outlined"
+        density="compact"
+        placeholder="请选择权限范围"
+        label="权限范围"
+        color="primary"
+        item-title="label"
+        item-value="value"
+        hide-details
+        v-model="selected"
+        @update:modelValue="handleChangeSelect"
+      ></v-select>
+    </div>
+    <div v-if="show">
+      <v-card style="overflow: auto;" class="pa-3" elevation="5"  max-height="500" :loading="deptLoading">
+        <div class="pa-3 d-flex">
+          <div class="d-flex align-center mr-5">
+            <div class="mr-3">全选 / 全不选</div>
+            <v-switch
+              v-model="choose"
+              color="primary"
+              hide-details
+              @update:modelValue="handleChoose"
+            ></v-switch>
+          </div>
+          <div class="d-flex align-center mr-5">
+            <div class="mr-3">全部展开 / 折叠</div>
+            <v-switch
+              v-model="deptExpand"
+              color="primary"
+              hide-details
+              @update:modelValue="handleExpand"
+            ></v-switch>
+          </div>
+          <div class="d-flex align-center">
+            <div class="mr-3">父子联动</div>
+            <v-switch
+              v-model="checkStrictly"
+              color="primary"
+              hide-details
+            ></v-switch>
+          </div>
+        </div>
+        <v-divider></v-divider>
+        <div class="pa-3">
+          <el-tree
+            ref="treeRef"
+            :check-strictly="!checkStrictly"
+            :data="deptOptions"
+            :props="defaultProps"
+            default-expand-all
+            empty-text="没有数据"
+            node-key="id"
+            show-checkbox
+          />
+        </div>
+      </v-card>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, nextTick } from 'vue'
+import { getDict } from '@/hooks/web/useDictionaries'
+import { getDataMenuTree } from '@/api/recruit/enterprise/system/role'
+// import { handleTree } from '@/utils/tree.ts'
+const props = defineProps({
+  editItem: {
+    type: Object,
+    default: null
+  }
+})
+
+const items = ref([])
+const selected = ref(null)
+const show = ref(false)
+const treeRef = ref()
+
+const deptOptions = ref([])
+const deptLoading = ref(false)
+const defaultProps = ref({
+  children: 'children',
+  label: 'name',
+  value: 'id',
+  isLeaf: 'leaf',
+  class: 'nodes',
+  emitPath: false // 用于 cascader 组件:在选中节点改变时,是否返回由该节点所在的各级菜单的值所组成的数组,若设置 false,则只返回该节点的值
+})
+
+const choose = ref(false)
+const deptExpand = ref(true)
+const checkStrictly = ref(true)
+
+/** 全选 */
+const handleChoose = (val) => {
+  treeRef.value.setCheckedNodes(choose.value ? deptOptions.value : [])
+}
+/** 展开/折叠全部 */
+const handleExpand = (val) => {
+  const nodes = treeRef.value?.store.nodesMap
+  for (let node in nodes) {
+    if (nodes[node].expanded === deptExpand.value) {
+      continue
+    }
+    nodes[node].expanded = deptExpand.value
+  }
+}
+
+
+const getTypeDict = async () => {
+  const { data } = await getDict('mde_data_scope', null)
+  items.value = data
+  init()
+}
+
+const init = async () => {
+
+  // 重置
+  deptExpand.value = true
+  checkStrictly.value = true
+  deptLoading.value = true
+  deptOptions.value = []
+  selected.value = props.editItem.dataScope + ''
+  handleChangeSelect()
+}
+
+// 选中
+const handleChangeSelect = async () => {
+  show.value = selected.value === '2'
+  if (show.value) {
+    choose.value = false
+    try {
+      const data = await getDataMenuTree()
+      deptOptions.value = [data]
+      await nextTick()
+      const dataScopeEnterpriseIds = props.editItem.dataScopeEnterpriseIds || []
+      dataScopeEnterpriseIds.forEach((deptId) => {
+        treeRef.value.setChecked(deptId, true, false)
+      })
+    } finally {
+      deptLoading.value = false
+    }
+  }
+}
+
+
+
+
+getTypeDict()
+
+const submit = () => {
+  return {
+    roleId: props.editItem.id,
+    dataScope: selected.value,
+    dataScopeEnterpriseIds: selected.value !== '2' ? [] : treeRef.value.getCheckedKeys(false)
+  }
+}
+
+defineExpose({
+  submit
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(.nodes) {
+  font-size: 16px;
+  padding: 5px 0;
+}
+</style>

+ 108 - 0
src/views/recruit/enterprise/systemManagement/roleManagement/components/MenuPermission.vue

@@ -0,0 +1,108 @@
+<template>
+  <div>
+    <div class="mb-3">角色名称:<v-chip color="primary" label>{{ props.editItem.name }}</v-chip></div>
+    <div class="mb-3">角色标识:<v-chip color="primary" label>{{ props.editItem.code }}</v-chip></div>
+    <div class="d-flex">
+      <div class="mr-3">菜单权限: </div>
+      <v-card style="flex: 1; overflow: auto;" class="pa-3" elevation="5" max-height="500">
+        <div class="pa-3 d-flex">
+          <div class="d-flex align-center mr-5">
+            <div class="mr-3">全选 / 全不选</div>
+            <v-switch
+              v-model="choose"
+              color="primary"
+              hide-details
+              @change="handleChoose"
+            ></v-switch>
+          </div>
+          <div class="d-flex align-center">
+            <div class="mr-3">全部展开 / 折叠</div>
+            <v-switch
+              v-model="all"
+              color="primary"
+              hide-details
+              @change="handleOpen"
+            ></v-switch>
+          </div>
+        </div>
+        <v-divider></v-divider>
+        <div class="pa-3">
+          <el-tree
+            ref="treeRef"
+            :data="items"
+            :props="defaultProps"
+            empty-text="加载中,请稍候"
+            node-key="id"
+            show-checkbox
+          />
+        </div>
+      </v-card>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { getMenu, getRoleMenu } from '@/api/recruit/enterprise/system/role'
+import { handleTree } from '@/utils/tree.ts'
+const props = defineProps({
+  editItem: {
+    type: Object,
+    default: () => ({})
+  }
+})
+const defaultProps = {
+  children: 'children',
+  label: 'name',
+  value: 'id',
+  isLeaf: 'leaf',
+  class: 'nodes',
+  emitPath: false // 用于 cascader 组件:在选中节点改变时,是否返回由该节点所在的各级菜单的值所组成的数组,若设置 false,则只返回该节点的值
+}
+
+const items = ref([])
+const all = ref(false)
+const choose = ref(false)
+const itemsOrigin = ref([])
+
+const treeRef = ref()
+
+
+const handleChoose = () => {
+  treeRef.value.setCheckedNodes(choose.value ? items.value : [])
+}
+
+const handleOpen = () => {
+  const nodes = treeRef.value?.store.nodesMap
+  for (let node in nodes) {
+    if (nodes[node].expanded === all.value) {
+      continue
+    }
+    nodes[node].expanded = all.value
+  }
+}
+
+getMenu().then(res => {
+  items.value.push(...handleTree(res))
+  itemsOrigin.value = res
+  getRoleMenu({ roleId: props.editItem.id }).then(data => {
+    data.forEach(menuId => {
+      treeRef.value.setChecked(menuId, true, false)
+    })
+  })
+})
+
+defineExpose({
+  treeRef
+})
+
+
+</script>
+
+<style lang="scss" scoped>
+
+:deep(.nodes) {
+  font-size: 16px;
+  padding: 5px 0;
+}
+</style>

+ 103 - 0
src/views/recruit/enterprise/systemManagement/roleManagement/components/RoleManagementEdit.vue

@@ -0,0 +1,103 @@
+<template>
+  <CtForm ref="CtFormRef" :items="formItems"></CtForm>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { getDict } from '@/hooks/web/useDictionaries'
+import {
+  addRole,
+  updateRole
+} from '@/api/recruit/enterprise/system/role'
+import Snackbar from '@/plugins/snackbar'
+
+const props = defineProps({
+  editItem: {
+    type: Object,
+    default: null
+  }
+})
+
+const formItems = ref({
+  options: [
+    {
+      type: 'text',
+      key: 'name',
+      value: null,
+      label: '角色名称 *',
+      rules: [v => !!v || '请输入角色名称']
+    },
+    {
+      type: 'text',
+      key: 'code',
+      value: null,
+      label: '角色标识 *',
+      rules: [v => !!v || '请输入角色标识']
+    },
+    {
+      type: 'text',
+      key: 'sort',
+      value: null,
+      label: '角色顺序 *',
+      rules: [v => !!v || '请输入角色顺序']
+    },
+    {
+      type: 'text',
+      key: 'remark',
+      value: null,
+      label: '备注'
+    },
+    {
+      type: 'ifRadio',
+      key: 'status',
+      value: '0',
+      label: '角色状态 *',
+      items: [],
+      rules: [v => !!v || '请选择角色状态']
+    }
+  ]
+})
+
+if (props.editItem) {
+  formItems.value.options.forEach(e => {
+    e.value = props.editItem[e.key]
+  })
+}
+
+const getStatusDict = async () => {
+  const { data } = await getDict('menduner_status', null)
+  formItems.value.options.find(e => e.key === 'status').items = data || []
+}
+
+const submit = async () => {
+  const obj = formItems.value.options.reduce((res, cur) => {
+    res[cur.key] = cur.value
+    return res
+  }, {})
+
+  if (props.editItem) {
+    obj.id = props.editItem.id
+  }
+
+  const subApi = props.editItem ? updateRole : addRole
+  return new Promise((resolve, reject) => {
+    subApi(obj).then(data => {
+      resolve(data)
+      Snackbar.success(props.editItem ? '保存成功' : '提交成功' )
+    }).catch(err => {
+      Snackbar.error(err.msg)
+    })
+  })
+}
+
+
+getStatusDict()
+
+defineExpose({
+  submit
+})
+</script>
+
+<style lang="scss" scoped>
+
+</style>

+ 240 - 0
src/views/recruit/enterprise/systemManagement/roleManagement/index.vue

@@ -0,0 +1,240 @@
+<template>
+  <v-card class="pa-5 card-box">
+    <div class="d-flex justify-space-between">
+      <div class="d-flex mb-3">
+        <CtTextField class="mr-3" v-model="query.name" v-bind="nameItem"></CtTextField>
+        <CtTextField class="mr-3" v-model="query.code" v-bind="codeItem"></CtTextField>
+        <Autocomplete v-model="query.status" :item="statusItem"></Autocomplete>
+        <v-btn color="primary" class="half-button ml-3" @click="handleSearch">查 询</v-btn>
+        <v-btn class="half-button mx-3" variant="outlined" color="primary" @click="handleReset">重 置</v-btn>
+      </div>
+    </div>
+    <CtTable
+      class="mt-3"
+      :items="items"
+      :headers="headers"
+      :loading="loading"
+      :showPage="true"
+      :total="total"
+      :page-info="pageInfo"
+      itemKey="id"
+      @add="handleAdd"
+      @pageHandleChange="handleChangePage"
+    >
+      <template #status="{ item }">
+        <v-chip :color="item.status === '0' ? 'success' : 'error'" size="small" label>{{ item.statusName }}</v-chip>
+      </template>
+      <template #actions="{ item }">
+        <v-btn variant="text" color="primary" @click="handleEdit(item)">编辑</v-btn>
+        <v-btn variant="text" color="info" @click="handleMenu(item)">菜单权限</v-btn>
+        <v-btn variant="text" color="info" @click="handleData(item)">数据权限</v-btn>
+        <v-btn variant="text" color="error" @click="handleDel(item)">删除</v-btn>
+      </template>
+    </CtTable>
+  </v-card>
+  <CtDialog :visible="show" :widthType="3" :title="editItem ? '编辑角色' : '新增角色'" submitText="确认" @close="show = false" @submit="handleSubmit">
+    <RoleManagementEdit v-if="show" ref="roleManagementEdit" :edit-item="editItem"></RoleManagementEdit>
+  </CtDialog>
+  <CtDialog :visible="showMenu" :widthType="3" title="菜单权限" submitText="确认" @close="showMenu = false" @submit="handleSubmitMenu">
+    <MenuPermission v-if="showMenu" ref="menuPermissionRef" :edit-item="menuItem" v-loading="loadingMenu"></MenuPermission>
+  </CtDialog>
+  <CtDialog :visible="showData" :widthType="3" title="菜单权限" submitText="确认" @close="showData = false" @submit="handleSubmitData">
+    <DataPermission v-if="showData" ref="dataPermissionRef" :edit-item="dataItem" v-loading="loadingData"></DataPermission>
+  </CtDialog>
+</template>
+
+<script setup>
+defineOptions({ name: 'system-management-role'})
+import { ref } from 'vue'
+
+import { useI18n } from '@/hooks/web/useI18n'
+import Confirm from '@/plugins/confirm'
+import Snackbar from '@/plugins/snackbar'
+import RoleManagementEdit from './components/RoleManagementEdit'
+import MenuPermission from './components/MenuPermission'
+import DataPermission from './components/DataPermission'
+
+import {
+  getRolePage,
+  deleteRole,
+  saveRoleMenu,
+  saveDataPermission
+} from '@/api/recruit/enterprise/system/role'
+import { getDict } from '@/hooks/web/useDictionaries'
+import { formatDate } from '@/utils/date'
+
+
+const { t } = useI18n()
+const total = ref(10)
+const items = ref([])
+const show = ref(false)
+const loading = ref(false)
+const editItem = ref(null)
+const roleManagementEdit = ref()
+
+// 菜单权限
+const showMenu = ref(false)
+const loadingMenu = ref(false)
+const menuItem = ref(null)
+const menuPermissionRef = ref()
+
+// 数据权限
+const showData = ref(false)
+const dataItem = ref(null)
+const loadingData = ref(false)
+const dataPermissionRef = ref()
+
+const nameItem = ref({
+  width: 300,
+  clearable: true,
+  hireDetails: true,
+  label: '角色名称',
+  variant: 'outlined',
+  density: 'compact',
+  color: 'primary',
+  placeholder: '请输入角色名称'
+})
+
+const codeItem = ref({
+  width: 300,
+  clearable: true,
+  hireDetails: true,
+  label: '角色标识',
+  variant: 'outlined',
+  density: 'compact',
+  color: 'primary',
+  placeholder: '请输入角色标识'
+})
+const statusItem = ref({ width: 300, items: [], clearable: true, hireDetails: true, label: '状态', placeholder: '请选择状态' })
+
+const query = ref({
+  name: null,
+  status: null,
+  code: null
+})
+const pageInfo = ref({
+  pageNo: 1,
+  pageSize: 10
+})
+
+const headers = [
+  { title: '角色编号', key: 'id', align: 'left' },
+  { title: '角色名称', key: 'name' },
+  { title: '角色标识', key: 'code' },
+  { title: '显示顺序', key: 'sort', align: 'center' },
+  { title: '备注', key: 'remark' },
+  { title: '状态', key: 'status', align: 'center' },
+  { title: '创建时间', key: 'createTime', value: (e) => formatDate(e.createTime, 'YYYY-MM-DD HH:mm:ss') },
+  { title: t('common.actions'), key: 'actions' }
+]
+// 字典
+const getStatusDict = async () => {
+  const { data } = await getDict('menduner_status', null)
+  statusItem.value.items = data || []
+  getPage()
+}
+// 列表
+const getPage = async () => {
+  loading.value = true
+  try {
+    const res = await getRolePage({ ...pageInfo.value, ...query.value })
+    items.value = res.list.map(e => {
+      e.statusName = statusItem.value.items.find(_e => _e.value === e.status)?.label ?? ''
+      return e
+    })
+    total.value = res.total
+  } finally {
+    loading.value = false
+  }
+}
+// 检索
+const handleSearch = () => {
+  pageInfo.value.pageNo = 1
+  getPage()
+}
+// 重置
+const handleReset = () => {
+  query.value = {
+    name: null,
+    status: null,
+    code: null
+  }
+  handleSearch()
+}
+// 新增
+const handleAdd = () => {
+  editItem.value = null
+  show.value = true
+}
+// 编辑
+const handleEdit = (item) => {
+  editItem.value = item
+  show.value = true
+}
+// 删除
+const handleDel = async (item) => {
+  Confirm(t('common.confirmTitle'), '是否确定删除?').then(async () => {
+    await deleteRole({ id: item.id })
+    Snackbar.success('删除成功')
+    getPage()
+  })
+}
+
+// 菜单权限
+const handleMenu = (item) => {
+  menuItem.value = item
+  showMenu.value = true
+}
+// 数据权限
+const handleData = (item) => {
+  dataItem.value = item
+  showData.value = true
+}
+// 分页
+const handleChangePage = (index) => {
+  pageInfo.value.pageNo = index
+  getPage()
+}
+
+// 保存角色
+const handleSubmit = async () => {
+  await roleManagementEdit.value.submit()
+  show.value = false
+  getPage()
+}
+
+// 保存菜单权限
+const handleSubmitMenu = async () => {
+  loadingMenu.value = true
+  try {
+    await saveRoleMenu({roleId: menuItem.value.id, menuIds: [
+      ...(menuPermissionRef.value.treeRef.getCheckedKeys(false)), // 获得当前选中节点
+      ...(menuPermissionRef.value.treeRef.getHalfCheckedKeys()) // 获得半选中的父节点
+    ]})
+    Snackbar.success('保存成功')
+    showMenu.value = false
+    getPage()
+  } finally {
+    loadingMenu.value = false
+  }
+}
+// 保存数据权限
+const handleSubmitData = async () => {
+  loadingData.value = true
+  try {
+    await saveDataPermission(dataPermissionRef.value.submit())
+    Snackbar.success('保存成功')
+    showData.value = false
+    getPage()
+  } finally {
+    loadingData.value = false
+  }
+}
+
+getStatusDict()
+
+</script>
+
+<style scoped lang="scss">
+
+</style>