Procházet zdrojové kódy

fix: BsUi-组织人员控件初始化

hanxiaohui před 5 měsíci
rodič
revize
57d01de094

+ 8 - 0
src/api/system/table-api.js

@@ -7,3 +7,11 @@ export const getTableDataApi = (url, data) => {
     data,
   });
 };
+
+export const queryListByParams = (data) => {
+  return request({
+    url: '/supports/framework/query',
+    method: 'post',
+    data,
+  });
+};

+ 207 - 0
src/components/BsUi/OrgUserSelector/components/ModalSelector.vue

@@ -0,0 +1,207 @@
+<template>
+  <bs-modal
+    :visible="modalOptions.visible"
+    :width="modalOptions.width"
+    :title="modalOptions.title"
+    :modal-extra-props="modalOptions.modalExtraProps"
+    @cancel="handleCancel"
+    @ok="handleOk"
+  >
+    <div class="content">
+      <a-input type="text" placeholder="请输入关键字" v-model:value="keyWord" :allow-clear="true" />
+      <div class="content-bottom">
+        <div class="cb-content">
+          <div class="cb-c-top">
+            <div class="tree" v-show="isEmpty(keyWord)">
+              <org-tree ref="treeRef" />
+            </div>
+            <org-user-list
+              :keyWord="keyWord"
+              ref="orgUserListRef"
+              :multiple="multiple"
+              :sceneType="sceneType"
+              :idKey="idKey"
+              :label-key="labelKey"
+              :oldSelectData="selectedOldData"
+              @change="handleSelectChange"
+              :selected-tree-id="selectedTreeId"
+            />
+          </div>
+
+          <div class="cb-c-bottom" v-if="orgUserListRef">
+            <selected-data
+              :idKey="idKey"
+              :labelKey="labelKey"
+              :selected-data="orgUserListRef.allSelectedData"
+              :selected-keys="orgUserListRef.allSelectedKeys"
+              :multiple="multiple"
+              @reset="handleReset"
+              @remove="handleRemove"
+            />
+          </div>
+        </div>
+      </div>
+    </div>
+  </bs-modal>
+</template>
+<script setup lang="jsx">
+  import BsModal, { useBsModal } from '/@/components/BsUi/Modal/index.js';
+  import OrgTree from '/@/components/BsUi/OrgUserSelector/components/OrgTree.vue';
+  import OrgUserList from '/@/components/BsUi/OrgUserSelector/components/OrgUserList.vue';
+  import SelectedData from '/@/components/BsUi/OrgUserSelector/components/SelectedData.vue';
+  import { computed, nextTick, ref, watch } from 'vue';
+  import { isEmpty } from 'lodash';
+  import {SCENE_TYPE, SELECT_MULTIPLE} from '/@/components/BsUi/constant.js';
+
+  const treeRef = ref(null);
+  const emit = defineEmits(['change', 'ok']);
+
+  const props = defineProps({
+    sceneType: {
+      required: false,
+      default: SCENE_TYPE.USER,
+    },
+    multiple: {
+      required: false,
+      default: SELECT_MULTIPLE.MORE,
+    },
+    idKey: {
+      required: false,
+      default: 'id',
+    },
+    selectedOldData: {
+      required: false,
+      default: [],
+    },
+    labelKey: {
+      required: false,
+      default: 'name',
+    },
+  });
+
+  const {
+    modalOptions,
+    getModalPropsValue: getMVal,
+    setModalPropsValue: setMVal,
+  } = useBsModal({
+    modalOptions: {
+      width: '70%',
+      title: '标题',
+      visible: false,
+      modalExtraProps: {
+        destroyOnClose: true,
+        okButtonProps: {
+          loading: false,
+        },
+        bodyStyle: {
+          height: '100%',
+        },
+      },
+    },
+  });
+
+  const keyWord = ref('');
+  const orgUserListRef = ref(null);
+
+  const handleRemove = (node) => {
+    orgUserListRef.value.handleRemove(node);
+  };
+
+  const handleSelectChange = ($event) => {
+    const { selectedKeys } = $event;
+    // setMVal('modalExtraProps.okButtonProps.disabled', selectedKeys.length === 0);
+    emit('change', $event);
+  };
+
+  // 当前选中的树节点
+  const selectedTreeId = computed(() => {
+    if (treeRef.value) {
+      let selectedKeys = treeRef.value?.selectedKeys;
+      return isEmpty(selectedKeys) ? null : selectedKeys[0];
+    }
+    return null;
+  });
+
+  const showModal = () => {
+    setMVal('width', '1200px');
+    setMVal('title', '人员');
+    setMVal('visible', true);
+    // setMVal('modalExtraProps.okButtonProps.disabled', true);
+
+    nextTick(() => {
+      //  加载左侧树
+      // 加载右侧表格
+      // orgUserListRef.value.init(true, {
+      //   parentId: selectedTreeId.value
+      // }).then((res) => {
+      //   const selectedKeys = orgUserListRef.value.allSelectedKeys;
+      //   const selectedData = orgUserListRef.value.allSelectedData;
+      //   // setMVal('modalExtraProps.okButtonProps.disabled', selectedKeys.length === 0);
+      // });
+    });
+  };
+
+  const handleCancel = () => {
+    setMVal('visible', false);
+  };
+
+  const handleOk = () => {
+    const selectedKeys = orgUserListRef.value.allSelectedKeys;
+    const selectedData = orgUserListRef.value.allSelectedData;
+    emit('ok', { selectedKeys, selectedData });
+    setMVal('visible', false);
+  };
+
+  const handleReset = () => {
+    orgUserListRef.value?.handleReset();
+  };
+
+  // 监听关键字的变化
+  watch(keyWord, () => {});
+
+  watch(
+    () => props.selectedOldData,
+    (val) => {}
+  );
+
+  defineExpose({
+    showModal,
+    orgUserListRef,
+  });
+</script>
+
+<style lang="scss" scoped>
+  .content {
+    width: 100%;
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+
+    .content-bottom {
+      width: 100%;
+      display: flex;
+      height: 100%;
+
+      .cb-content {
+        width: 100%;
+        height: 100%;
+        .cb-c-top {
+          width: 100%;
+          height: 500px;
+          display: flex;
+          gap: 20px;
+
+          .tree {
+            width: 300px;
+          }
+        }
+
+        .cb-c-bottom {
+          width: 100%;
+          border-top: 1px solid rgba(0, 0, 0, 0.1);
+          padding: 10px;
+        }
+      }
+    }
+  }
+</style>

+ 251 - 0
src/components/BsUi/OrgUserSelector/components/OrgTree.vue

@@ -0,0 +1,251 @@
+<!--
+  * 部门树形结构
+  *
+  * @Author:    DCCloud
+  * @Date:      2022-08-08 20:46:18
+-->
+<template>
+  <div class="tree-container">
+    <a-row class="smart-margin-bottom10">
+      <a-input v-model:value.trim="keywords" placeholder="请输入部门名称" />
+    </a-row>
+    <a-tree
+      v-if="!_.isEmpty(departmentTreeData)"
+      v-model:selectedKeys="selectedKeys"
+      v-model:checkedKeys="checkedKeys"
+      class="tree"
+      :treeData="departmentTreeData"
+      :fieldNames="{ title: 'name', key: 'departmentId', value: 'departmentId' }"
+      style="width: 100%; overflow-x: auto"
+      :style="[!height ? '' : { height: `${height}px`, overflowY: 'auto' }]"
+      :checkable="props.checkable"
+      :checkStrictly="props.checkStrictly"
+      :selectable="!props.checkable"
+      :defaultExpandAll="true"
+      @select="treeSelectChange"
+    >
+      <template #title="item">
+        <div>{{ item.name }}</div>
+      </template>
+    </a-tree>
+    <div class="no-data" v-else>暂无结果</div>
+  </div>
+</template>
+<script setup>
+  import { onMounted, onUnmounted, ref, watch } from 'vue';
+  import _ from 'lodash';
+  import { departmentApi } from '/@/api/system/department-api';
+  import mitt from 'mitt';
+
+  const departmentEmitter= mitt();
+
+  const DEPARTMENT_PARENT_ID = 0;
+
+  // ----------------------- 组件参数 ---------------------
+
+  const props = defineProps({
+    // 是否可以选中
+    checkable: {
+      type: Boolean,
+      default: false,
+    },
+    // 父子节点选中状态不再关联
+    checkStrictly: {
+      type: Boolean,
+      default: false,
+    },
+    // 树高度 超出出滚动条
+    height: Number,
+    // 显示菜单
+    showMenu: {
+      type: Boolean,
+      default: false,
+    },
+  });
+
+  // ----------------------- 部门树的展示 ---------------------
+  const topDepartmentId = ref();
+  // 所有部门列表
+  const departmentList = ref([]);
+  // 部门树形数据
+  const departmentTreeData = ref([]);
+  // 存放部门id和部门,用于查找
+  const idInfoMap = ref(new Map());
+
+  onMounted(() => {
+    queryDepartmentTree();
+  });
+
+  // 刷新
+  async function refresh() {
+    await queryDepartmentTree();
+    if (currentSelectedDepartmentId.value) {
+      selectTree(currentSelectedDepartmentId.value);
+    }
+  }
+
+  // 查询部门列表并构建 部门树
+  async function queryDepartmentTree() {
+    let res = await departmentApi.queryAllDepartment();
+    let data = res.data;
+    departmentList.value = data;
+    departmentTreeData.value = buildDepartmentTree(data, DEPARTMENT_PARENT_ID);
+
+    data.forEach((e) => {
+      idInfoMap.value.set(e.departmentId, e);
+    });
+
+    // 默认显示 最顶级ID为列表中返回的第一条数据的ID
+    if (!_.isEmpty(departmentTreeData.value) && departmentTreeData.value.length > 0) {
+      topDepartmentId.value = departmentTreeData.value[0].departmentId;
+    }
+
+    selectTree(departmentTreeData.value[0].departmentId);
+  }
+
+  // 构建部门树
+  function buildDepartmentTree(data, parentId) {
+    let children = data.filter((e) => e.parentId === parentId) || [];
+    children.forEach((e) => {
+      e.children = buildDepartmentTree(data, e.departmentId);
+    });
+    updateDepartmentPreIdAndNextId(children);
+    return children;
+  }
+
+  // 更新树的前置id和后置id
+  function updateDepartmentPreIdAndNextId(data) {
+    for (let index = 0; index < data.length; index++) {
+      if (index === 0) {
+        data[index].nextId = data.length > 1 ? data[1].departmentId : undefined;
+        continue;
+      }
+
+      if (index === data.length - 1) {
+        data[index].preId = data[index - 1].departmentId;
+        data[index].nextId = undefined;
+        continue;
+      }
+
+      data[index].preId = data[index - 1].departmentId;
+      data[index].nextId = data[index + 1].departmentId;
+    }
+  }
+
+  // ----------------------- 树的选中 ---------------------
+  const selectedKeys = ref([]);
+  const checkedKeys = ref([]);
+  const breadcrumb = ref([]);
+  const currentSelectedDepartmentId = ref();
+  const selectedDepartmentChildren = ref([]);
+
+  departmentEmitter.on('selectTree', selectTree);
+
+  function selectTree(id) {
+
+    console.log("selectTree", id);
+
+
+    selectedKeys.value = [id];
+    treeSelectChange(selectedKeys.value);
+  }
+
+  function treeSelectChange(idList) {
+    if (_.isEmpty(idList)) {
+      breadcrumb.value = [];
+      selectedDepartmentChildren.value = [];
+      return;
+    }
+    let id = idList[0];
+    selectedDepartmentChildren.value = departmentList.value.filter((e) => e.parentId == id);
+    let filterDepartmentList = [];
+    recursionFilterDepartment(filterDepartmentList, id, true);
+    breadcrumb.value = filterDepartmentList.map((e) => e.name);
+  }
+
+  // -----------------------  筛选 ---------------------
+  const keywords = ref('');
+  watch(
+    () => keywords.value,
+    () => {
+      onSearch();
+    }
+  );
+
+  // 筛选
+  function onSearch() {
+    if (!keywords.value) {
+      departmentTreeData.value = buildDepartmentTree(departmentList.value, DEPARTMENT_PARENT_ID);
+      return;
+    }
+    let originData = departmentList.value.concat();
+    if (!originData) {
+      return;
+    }
+    // 筛选出名称符合的部门
+    let filterDepartment = originData.filter((e) => e.name.indexOf(keywords.value) > -1);
+    let filterDepartmentList = [];
+    // 循环筛选出的部门 构建部门树
+    filterDepartment.forEach((e) => {
+      recursionFilterDepartment(filterDepartmentList, e.departmentId, false);
+    });
+
+    departmentTreeData.value = buildDepartmentTree(filterDepartmentList, DEPARTMENT_PARENT_ID);
+  }
+
+  // 根据ID递归筛选部门
+  function recursionFilterDepartment(resList, id, unshift) {
+    let info = idInfoMap.value.get(id);
+    if (!info || resList.some((e) => e.departmentId == id)) {
+      return;
+    }
+    if (unshift) {
+      resList.unshift(info);
+    } else {
+      resList.push(info);
+    }
+    if (info.parentId && info.parentId != 0) {
+      recursionFilterDepartment(resList, info.parentId, unshift);
+    }
+  }
+
+  onUnmounted(() => {
+    departmentEmitter.all.clear();
+  });
+
+  // ----------------------- 以下是暴露的方法内容 ----------------------------
+  defineExpose({
+    queryDepartmentTree,
+    selectedDepartmentChildren,
+    breadcrumb,
+    selectedKeys,
+    checkedKeys,
+    keywords,
+  });
+</script>
+<style scoped lang="less">
+  .tree-container {
+    height: 100%;
+    border-right: 1px solid rgba(#000, .1);
+    padding-right: 10px;
+
+    .tree {
+      height: 618px;
+      margin-top: 10px;
+      overflow-x: hidden;
+    }
+
+    .sort-flag-row {
+      margin-top: 10px;
+      margin-bottom: 10px;
+    }
+
+    .sort-span {
+      margin-left: 5px;
+    }
+
+    .no-data {
+      margin: 10px;
+    }
+  }
+</style>

+ 414 - 0
src/components/BsUi/OrgUserSelector/components/OrgUserList.vue

@@ -0,0 +1,414 @@
+<template>
+  <div class="org-user-list" v-loading="isLoading">
+    <bs-empty v-if="searchData.length === 0" />
+    <div v-else class="org-user-content">
+      <div class="top">
+        <div class="top-left">
+          <a-space>
+            <a-checkbox
+              @change="handleSelectAllChange"
+              :indeterminate="isIndeterminate"
+              v-model:checked="isSelectAll"
+              v-if="multiple === SELECT_MULTIPLE.MORE"
+              :disabled="disabled"
+            >
+              全选
+            </a-checkbox>
+            <a-checkbox v-model:checked="isInclude" :disabled="disabled"> 包含下级 </a-checkbox>
+          </a-space>
+        </div>
+        <div class="top-right">
+          <a-space>
+            <span style="font-size: 12px; color: #1677ff">
+              <span>全部</span>
+              <span>({{ searchData.length }})</span>
+            </span>
+
+            <span style="font-size: 12px" v-if="sceneType === SCENE_TYPE.USER">
+              <span>人员</span>
+              <span>({{ searchData.length }})</span>
+            </span>
+          </a-space>
+        </div>
+      </div>
+
+      <div class="bottom">
+        <div class="bot-i-item" v-for="(item, index) in searchData" :key="item[idKey]" @click="selectNode(item)">
+          <a-checkbox
+            v-if="multiple === SELECT_MULTIPLE.MORE"
+            :checked="allSelectedKeys.findIndex((v) => v === item[idKey]) > -1"
+            :disabled="disabled"
+          ></a-checkbox>
+          <a-radio
+            v-if="multiple === SELECT_MULTIPLE.ONE"
+            :checked="allSelectedKeys.findIndex((v) => v === item[idKey]) > -1"
+            :disabled="disabled"
+          ></a-radio>
+          <a-avatar style="color: #fff; background-color: #1677ff">{{ item[labelKey] && item[labelKey][0] }}</a-avatar>
+          <div class="bot-item">
+            <span class="bot-item-top">{{ item[labelKey] }}</span>
+            <span class="bot-item-bottom">{{ item.parentName }}</span>
+          </div>
+        </div>
+      </div>
+
+      <div class="pager-config" v-if="false">
+        <a-pagination
+          size="small"
+          v-model:current="pageInfo.pageNum"
+          :total="pageInfo.total"
+          v-model:pageSize="pageInfo.pageSize"
+          @change="handlePagerChange"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import { cloneDeep, isEmpty } from 'lodash';
+  import { queryListByParams } from '/@/api/system/table-api.js';
+  import BsEmpty from '/@/components/BsUi/Empty/index.vue';
+  import { computed, nextTick, onMounted, reactive, ref, toRefs, watch } from 'vue';
+  import { SCENE_TYPE, SELECT_MULTIPLE } from '/@/components/BsUi/constant.js';
+
+  const emit = defineEmits(['change']);
+
+  const isLoaded = ref(false);
+
+  const props = defineProps({
+    multiple: {
+      required: false,
+      // default: SELECT_MULTIPLE.ONE,
+      default: SELECT_MULTIPLE.MORE,
+    },
+    disabled: {
+      required: false,
+      default: false,
+    },
+    idKey: {
+      required: false,
+      default: 'id',
+    },
+    labelKey: {
+      required: false,
+      default: 'name',
+    },
+    keyWord: {
+      required: false,
+      default: '',
+    },
+    sceneType: {
+      required: false,
+      default: SCENE_TYPE.USER,
+    },
+    oldSelectData: {
+      required: false,
+      default: [],
+    },
+    selectedTreeId: {
+      required: false,
+      default: '',
+    },
+  });
+
+  const isLoading = ref(false);
+  const isSelectAll = ref(false);
+  const isInclude = ref(false);
+  const isIndeterminate = ref(false);
+
+  const pageInfo = reactive({
+    pageNum: 1,
+    pageSize: 20,
+    total: 100,
+  });
+
+  const searchData = ref([]);
+  const currentSelectedKeys = ref([]);
+  const currentSelectedData = ref([]);
+
+  const uniqueByIdKey = (arr) => {
+    return [...new Map(arr.map((item) => [item[props.idKey], item])).values()];
+  };
+
+  const handleRemove = (node) => {
+    searchData.value.forEach((v) => {
+      if (node[props.idKey] === v[props.idKey]) {
+        v.isSelected = false;
+      }
+    });
+
+    props.oldSelectData.forEach((v) => {
+      if (node[props.idKey] === v[props.idKey]) {
+        v.isSelected = false;
+      }
+    });
+  };
+
+  const handleReset = () => {
+    searchData.value.forEach((v) => {
+      v.isSelected = false;
+    });
+
+    props.oldSelectData.forEach((v) => {
+      v.isSelected = false;
+    });
+  };
+
+  const handlePagerChange = (pageNum, pageSize) => {
+    pageInfo.pageNum = pageNum;
+    pageInfo.pageSize = pageSize;
+    init(false);
+  };
+
+  const selectNode = (node) => {
+    if (props.disabled) {
+      return false;
+    }
+
+    if (props.multiple === SELECT_MULTIPLE.ONE) {
+      handleReset();
+      node.isSelected = true;
+    } else {
+      props.oldSelectData.forEach((v) => {
+        if (v[props.idKey] === node[props.idKey]) {
+          v.isSelected = !v.isSelected;
+        }
+      });
+      node.isSelected = !node.isSelected;
+    }
+  };
+
+  const handleSelectAllChange = (event) => {
+    const checked = event.target.checked;
+    searchData.value.forEach((v) => {
+      v.isSelected = checked;
+    });
+
+    currentSelectedData.value.forEach((v) => {
+      v.isSelected = checked;
+    });
+
+    props.oldSelectData.forEach((v) => {
+      if (searchData.value.findIndex((vv) => vv[props.idKey] === v[props.idKey]) > -1) {
+        v.isSelected = checked;
+      }
+    });
+  };
+
+  const fetchData = (data) => {
+    isLoading.value = true;
+
+    const params = {
+      include: false,
+      nodeType: [props.sceneType],
+      ...data,
+    };
+
+    return new Promise((resolve, reject) => {
+      queryListByParams(params).then((res) => {
+        isLoading.value = false;
+        if (res?.code === 0) {
+          searchData.value = res?.data;
+          pageInfo.total = searchData.value.length;
+          resolve();
+        } else {
+          reject("数据获取失败")
+        }
+      });
+    });
+  };
+
+  watch(
+    [currentSelectedData],
+    ([cur]) => {
+      currentSelectedKeys.value = cur.map((v) => v[props.idKey]);
+    },
+    { deep: true }
+  );
+
+  watch(
+    [searchData],
+    ([value]) => {
+      const cloneDeepCurSelData = cloneDeep(currentSelectedData.value);
+      const currentSelData = cloneDeep(value.filter((v) => v.isSelected));
+      let newArr = [];
+      cloneDeepCurSelData.forEach((v) => {
+        const it = value.find((vv) => vv[props.idKey] === v[props.idKey]);
+        if (isEmpty(it)) {
+          newArr.push(v);
+        } else if (it?.isSelected) {
+          newArr.push(it);
+        }
+      });
+
+      if (props.multiple === SELECT_MULTIPLE.MORE) {
+        currentSelectedData.value = uniqueByIdKey([...currentSelData, ...newArr]);
+      } else {
+        currentSelectedData.value = currentSelData;
+      }
+      // 全选,反选逻辑
+      if (value.every((v) => v?.isSelected)) {
+        isSelectAll.value = true;
+        isIndeterminate.value = false;
+      } else if (value.some((v) => v?.isSelected)) {
+        isSelectAll.value = false;
+        isIndeterminate.value = true;
+      } else {
+        isSelectAll.value = false;
+        isIndeterminate.value = false;
+      }
+    },
+    { deep: true }
+  );
+
+  watch(
+    () => props.keyWord,
+    (value) => {
+      // 监听关键字变化
+      if (!isEmpty(value)) {
+        init(false, {
+          keyword: value,
+        });
+      }
+    }
+  );
+  watch(
+    () => props.selectedTreeId,
+    (val) => {
+      init(false, {
+        parentId: val
+      });
+    },
+    {
+      immediate: false,
+    }
+  );
+
+  const init = (isInit = true, params = {}) => {
+    isLoaded.value = false;
+    searchData.value = [];
+    const curSelData = cloneDeep(currentSelectedData.value);
+    const oldSelectData = cloneDeep(props.oldSelectData);
+    return new Promise((resolve, reject) => {
+      fetchData(params).then((res) => {
+        currentSelectedKeys.value = Array.from(new Set([...curSelData.map((v) => v[props.idKey]), ...oldSelectData.map((v) => v[props.idKey])]));
+        // 数据的回显
+        searchData.value.forEach((a) => {
+          if (currentSelectedKeys.value.findIndex((v) => v === a[props.idKey]) > -1) {
+            a.isSelected = true;
+          }
+        });
+
+        isLoaded.value = true;
+        resolve();
+      });
+    });
+  };
+
+  const getOldSelectData = () => {
+    if (props.oldSelectData.length === 0) {
+      return [];
+    }
+
+    return cloneDeep(props.oldSelectData).filter((v) => v.isSelected);
+  };
+
+  const allSelectedData = computed(() => {
+    return uniqueByIdKey([...currentSelectedData.value, ...getOldSelectData()]);
+  });
+
+  const allSelectedKeys = computed(() => {
+    return allSelectedData.value.map((v) => v[props.idKey]);
+  });
+
+  watch(allSelectedKeys, (val) => {
+    isLoaded.value &&
+      emit('change', {
+        selectedKeys: val,
+        selectedData: allSelectedData.value,
+      });
+  });
+
+  // 是否开启下一级,监听
+  watch(isInclude, (val) => {
+    init(false);
+  });
+
+  defineExpose({
+    fetchData,
+    init,
+    allSelectedData,
+    allSelectedKeys,
+    handleReset,
+    handleRemove,
+  });
+</script>
+
+<style lang="scss" scoped>
+  .org-user-list {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    .org-user-content {
+      width: 100%;
+      height: 100%;
+      display: flex;
+      flex-direction: column;
+      .top {
+        display: flex;
+        justify-content: space-between;
+      }
+
+      .pager-config {
+        width: 100%;
+        display: flex;
+        justify-content: flex-end;
+      }
+
+      .bottom {
+        margin-top: 10px;
+        display: grid;
+        gap: 10px;
+        flex: 1;
+        overflow: scroll;
+        grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); /* 自动填充列 */
+        grid-template-rows: repeat(auto-fill, minmax(50px, 1fr)); /* 自动填充列 */
+        .bot-i-item {
+          width: 270px;
+          height: 50px;
+          display: flex;
+          gap: 10px;
+          align-items: center;
+          cursor: pointer;
+          .bot-item {
+            display: flex;
+            flex-direction: column;
+
+            .bot-item-top {
+              color: #000;
+            }
+
+            .bot-item-bottom {
+              color: rgba(#000, 0.6);
+            }
+          }
+        }
+      }
+
+      .bb-bottom {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        .select-num {
+          display: flex;
+          align-items: center;
+          justify-content: space-between;
+          gap: 5px;
+        }
+      }
+    }
+  }
+</style>

+ 122 - 0
src/components/BsUi/OrgUserSelector/components/SelectedData.vue

@@ -0,0 +1,122 @@
+<template>
+  <div class="selected-data">
+    <div class="select-num">
+      <div>已选: {{ selectedKeys.length }}</div>
+      <div>
+        <a-button type="text" size="small" :disabled="selectedKeys.length === 0" @click="isView = !isView"
+          >{{ isView ? '隐藏' : '查看' }}已选</a-button
+        >
+        <a-button type="text" danger size="small" @click="handleReset" :disabled="selectedKeys.length === 0">清空</a-button>
+      </div>
+    </div>
+
+    <div class="select-user" v-if="isView">
+      <div class="selected-user-item" v-for="(user, idx) in selectedData" :key="user[idKey]">
+        <div class="selected-user-icon">
+          <CloseCircleOutlined class="close-icon" @click="handleDel(user)"/>
+          <a-avatar shape="square" style="color: #fff; background-color: #1677ff">{{ user[labelKey] && user[labelKey][0] }}</a-avatar>
+        </div>
+        <div class="title">{{ user[labelKey] }}</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import { UserOutlined, CloseCircleOutlined } from '@ant-design/icons-vue';
+  import { SELECT_MULTIPLE } from '/@/components/BsUi/constant.js';
+  import { ref } from 'vue';
+
+  const isView = ref(true);
+
+  const emit = defineEmits(['reset']);
+
+  const props = defineProps({
+    multiple: {
+      required: false,
+      default: SELECT_MULTIPLE.MORE,
+    },
+    selectedKeys: {
+      required: true,
+      default: [],
+    },
+    selectedData: {
+      required: true,
+      default: [],
+    },
+    idKey: {
+      required: false,
+      default: 'id',
+    },
+    labelKey: {
+      required: false,
+      default: 'name',
+    },
+  });
+
+  const handleReset = () => {
+    emit('reset');
+  };
+
+  const handleDel = (user) => {
+    emit('remove', user)
+  }
+</script>
+
+<style scoped lang="less">
+  .selected-data {
+    width: 100%;
+    height: 100%;
+    .select-num {
+      width: 100%;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      gap: 5px;
+    }
+
+    .select-user {
+      width: 100%;
+      display: flex;
+      overflow-x: scroll;
+      align-items: center;
+      gap: 10px;
+      padding: 10px;
+      .selected-user-item {
+        width: 70px;
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        cursor: pointer;
+
+        .title {
+          width: 100%;
+          color: rgba(#000, 0.8);
+          font-size: 12px;
+          white-space: nowrap;
+          text-align: center;
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+        .selected-user-icon {
+          position: relative;
+          .close-icon {
+            display: none;
+            position: absolute;
+            top: -5px;
+            right: -10px;
+            color: #f56a00;
+            z-index: 10;
+            &:hover {
+              transform: scale(1.2);
+            }
+          }
+        }
+
+        &:hover .close-icon {
+          display: block;
+        }
+      }
+    }
+  }
+</style>

+ 154 - 0
src/components/BsUi/OrgUserSelector/index.vue

@@ -0,0 +1,154 @@
+<template>
+  <div class="selector">
+    <div class="selector-field">
+      <a-select
+        :value="selVal"
+        :mode="multiple === SELECT_MULTIPLE.MORE ? 'multiple' : undefined"
+        style="width: 100%"
+        placeholder="请选择"
+        :open="false"
+        :allow-clear="true"
+        @change="handleChange"
+        :disabled="disabled"
+        :options="options"
+        :max-tag-count="10"
+        :showArrow="false"
+      >
+        <template #tagRender="{ value: val, label, closable, onClose, option }">
+          <a-tag :closable="closable" style="margin-right: 3px" @close="closeTags(onClose, val, option, label)">
+            {{ label }}
+          </a-tag>
+        </template>
+      </a-select>
+      <a-button @click="handleClkSelector">
+        <template #icon>
+          <UserOutlined />
+        </template>
+      </a-button>
+    </div>
+    <modal-selector
+      ref="modalSelectorRef"
+      :multiple="multiple"
+      :selectedOldData="oldSelectedData"
+      :id-key="idKey"
+      :label-key="labelKey"
+      @change="selectChange"
+      @ok="confirm"
+    />
+  </div>
+</template>
+
+<script setup>
+  import { SELECT_MULTIPLE } from '/@/components/BsUi/constant.js';
+  import ModalSelector from '/@/components/BsUi/OrgUserSelector/components/ModalSelector.vue';
+  import { onMounted, ref, watch } from 'vue';
+  import { isArray, isEmpty } from 'lodash';
+  import { UserOutlined } from '@ant-design/icons-vue';
+
+  const oldSelectedData = ref([]);
+  const selVal = ref([]);
+  const options = ref([]);
+
+  const props = defineProps({
+    selectedData: {
+      required: true,
+      default: null,
+    },
+    multiple: {
+      required: false,
+      default: SELECT_MULTIPLE.ONE,
+    },
+    disabled: {
+      required: false,
+      default: false,
+    },
+    idKey: {
+      required: false,
+      default: 'id',
+    },
+    labelKey: {
+      required: false,
+      default: 'name',
+    },
+  });
+
+  const modalSelectorRef = ref(null);
+
+  const emit = defineEmits();
+
+  const selectChange = (value) => {};
+
+  const setOptions = (valObj) => {
+    if (isArray(valObj)) {
+      options.value = valObj.map((v) => ({
+        label: v[props.labelKey],
+        value: v[props.idKey],
+      }));
+    } else {
+      options.value = [
+        {
+          label: valObj[props.labelKey],
+          value: valObj[props.idKey],
+        },
+      ];
+    }
+  };
+
+  const closeTags = (onClose, val, option, label) => {
+    selVal.value = selVal.value.filter((v) => v !== val);
+    const valObj = oldSelectedData.value.filter((v) => v[props.idKey] !== val);
+    setOptions(valObj);
+    emit('update:selectedData', valObj);
+  };
+
+  const handleChange = (value) => {
+    if (isEmpty(value)) {
+      selVal.value = undefined;
+      emit('update:selectedData', []);
+    }
+  };
+
+  const handleClkSelector = () => {
+    if (props.disabled) {
+      return;
+    }
+    modalSelectorRef.value.showModal();
+  };
+
+  const confirm = ({ selectedKeys, selectedData }) => {
+    setOptions(selectedData);
+    if (selectedKeys.length === 1) {
+      selVal.value = selectedKeys[0];
+      emit('update:selectedData', selectedData[0]);
+    } else {
+      selVal.value = selectedKeys;
+      emit('update:selectedData', selectedData);
+    }
+  };
+
+  watch(
+    () => props.selectedData,
+    (val) => {
+      setOptions(val);
+      if (isEmpty(val)) {
+        oldSelectedData.value = [];
+        selVal.value = [];
+      } else {
+        oldSelectedData.value = isArray(val) ? val : [val];
+        selVal.value = isArray(val) ? val.map((v) => v[props.idKey]) : val[props.idKey];
+      }
+    },
+    { immediate: true }
+  );
+</script>
+
+<style lang="scss" scoped>
+  .selector {
+    width: 100%;
+
+    .selector-field {
+      width: 100%;
+      display: flex;
+    }
+  }
+</style>

+ 7 - 0
src/components/BsUi/constant.js

@@ -12,3 +12,10 @@ export const REQUIRED_STATE = Object.freeze({
   REQUIRED: '1',
   NO_REQUIRED: '0',
 });
+
+export const SCENE_TYPE = Object.freeze({
+  ORG: 'ORG', // 组织
+  USER: 'USER', // 人员
+  GROUP: 'GROUP', // 群组
+  JOB: 'JOB', // 岗位
+})

+ 24 - 0
src/components/BsUi/uitl.js

@@ -24,3 +24,27 @@ export function formatPercentage(num, decimalPlaces = 2) {
   const percentage = (num * 100).toFixed(decimalPlaces);
   return `${percentage}%`;
 }
+
+export const getPathName = (params) => {
+  const { sourceTreeData, labelKey = 'name', idKey = 'id', parentIdKey = 'parentId', childId, joinName = '/' } = params;
+
+  // 构建映射表
+  const map = new Map();
+  sourceTreeData.forEach((node) => map.set(node[idKey], node));
+
+  // 定义获取路径函数
+  function getPath(childId) {
+    const path = [];
+    let currentId = childId;
+    while (true) {
+      const node = map.get(currentId);
+      if (!node) break; // 若不存在该节点,终止
+      path.unshift(node[labelKey]); // 从头部插入名称,保证顺序正确
+      if (node[parentIdKey] === '0') break; // 到达根节点,终止
+      currentId = node[parentIdKey]; // 切换到父节点
+    }
+    return path.join(joinName); // 拼接为路径字符串
+  }
+
+  return getPath(childId);
+};