Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

配置角色权限 #6421

Closed
BlackCatCats opened this issue Mar 12, 2024 · 7 comments
Closed

配置角色权限 #6421

BlackCatCats opened this issue Mar 12, 2024 · 7 comments

Comments

@BlackCatCats
Copy link

版本号: [v3.6.3 AI版本]
前端版本:vue3版
问题描述: 只勾选了菜单 没勾选按钮 在右上角选择层级关联后勾选上按钮 退出登录去新增 仍提示没有权限
截图&代码:
@zhangdaiscott
Copy link
Member

截图

@BlackCatCats
Copy link
Author

image
第一次进来 默认是层级独立 只选了菜单
第二次进来选择 层级关联 会吧下面的按钮自动关联上 但是去新增会提示没有权限

@BlackCatCats
Copy link
Author

截图

好多地方的层级都不好用 你们多试试

@zhangdaiscott zhangdaiscott transferred this issue from jeecgboot/JeecgBoot Mar 29, 2024
@zhangdaiscott
Copy link
Member

zy

@liaozhiyang
Copy link

经测试,没复现。查看下后端注解和前面按钮编码是否一样

@liaozhiyang
Copy link

角色权限有个bug。就是层级独立时,勾选了父级,然后点击层级关联,视觉上子级都选中了,其实保存时子级都没存上。
你说的是这种情况吗?
image
image

对该bug已进行了修复:

src/components/Tree/src/BasicTree.vue 文件代码整体覆盖

<script lang="tsx">
  import type { CSSProperties } from 'vue';
  import type {
    FieldNames,
    TreeState,
    TreeItem,
    KeyType,
    CheckKeys,
    TreeActionType,
  } from './types/tree';

  import {
    defineComponent,
    reactive,
    computed,
    unref,
    ref,
    watchEffect,
    toRaw,
    watch,
    onMounted,
    nextTick,
  } from 'vue';
  import TreeHeader from './components/TreeHeader.vue';
  import { Tree, Spin, Empty } from 'ant-design-vue';
  import { TreeIcon } from './TreeIcon';
  import { ScrollContainer } from '/@/components/Container';
  import { omit, get, difference, cloneDeep } from 'lodash-es';
  import { isArray, isBoolean, isEmpty, isFunction } from '/@/utils/is';
  import { extendSlots, getSlot } from '/@/utils/helper/tsxHelper';
  import { filter, treeToList, eachTree } from '/@/utils/helper/treeHelper';
  import { useTree } from './hooks/useTree';
  import { useContextMenu } from '/@/hooks/web/useContextMenu';
  import { CreateContextOptions } from '/@/components/ContextMenu';
  import { treeEmits, treeProps } from './types/tree';
  import { createBEM } from '/@/utils/bem';

  export default defineComponent({
    name: 'BasicTree',
    inheritAttrs: false,
    props: treeProps,
    emits: treeEmits,
    setup(props, { attrs, slots, emit, expose }) {
      const [bem] = createBEM('tree');

      const state = reactive<TreeState>({
        checkStrictly: props.checkStrictly,
        expandedKeys: props.expandedKeys || [],
        selectedKeys: props.selectedKeys || [],
        checkedKeys: props.checkedKeys || [],
      });

      const searchState = reactive({
        startSearch: false,
        searchText: '',
        searchData: [] as TreeItem[],
      });

      const treeDataRef = ref<TreeItem[]>([]);

      const [createContextMenu] = useContextMenu();

      const getFieldNames = computed((): Required<FieldNames> => {
        const { fieldNames } = props;
        return {
          children: 'children',
          title: 'title',
          key: 'key',
          ...fieldNames,
        };
      });
      const treeRef = ref<any>(null);

      const getBindValues = computed(() => {
        let propsData = {
          blockNode: true,
          ...attrs,
          ...props,
          expandedKeys: state.expandedKeys,
          selectedKeys: state.selectedKeys,
          checkedKeys: state.checkedKeys,
          checkStrictly: state.checkStrictly,
          fieldNames: unref(getFieldNames),
          'onUpdate:expandedKeys': (v: KeyType[]) => {
            state.expandedKeys = v;
            emit('update:expandedKeys', v);
          },
          'onUpdate:selectedKeys': (v: KeyType[]) => {
            state.selectedKeys = v;
            emit('update:selectedKeys', v);
          },
          onCheck: (v: CheckKeys, e) => {
            handleCheck(v, e);
          },
          onRightClick: handleRightClick,
        };
        return omit(propsData, 'treeData', 'class');
      });
      /**
       * 2024-04-25
       * liaozhiyang
       * 【issues/1151】层级独立时勾选了父级,然后点击层级关联子级视觉上勾选了,但是保存子级没存上(把函数独立出来复用)
       * */
      const handleCheck = (v: CheckKeys, e?) => {
        let currentValue = toRaw(state.checkedKeys) as KeyType[];
        if (isArray(currentValue) && searchState.startSearch && e) {
          // update-begin-author:liusq---date:20230404--for: [issue/429]树搜索点击事件失效---
          const value = e.node.eventKey;
          currentValue = difference(currentValue, getChildrenKeys(value));
          if (e.checked) {
            currentValue.push(value);
          }
          // update-begin-author:liusq---date:20230404--for: [issue/429]树搜索点击事件失效---
          state.checkedKeys = currentValue;
        } else {
          state.checkedKeys = v;
        }
        const rawVal = toRaw(state.checkedKeys);
        emit('update:value', rawVal);
        emit('check', rawVal, e);
      };

      const getTreeData = computed((): TreeItem[] =>
        searchState.startSearch ? searchState.searchData : unref(treeDataRef),
      );

      const getNotFound = computed((): boolean => {
        return !getTreeData.value || getTreeData.value.length === 0;
      });

      const {
        deleteNodeByKey,
        insertNodeByKey,
        insertNodesByKey,
        filterByLevel,
        updateNodeByKey,
        getAllKeys,
        getChildrenKeys,
        getEnabledKeys,
        getSelectedNode,
      } = useTree(treeDataRef, getFieldNames);

      function getIcon(params: Recordable, icon?: string) {
        if (!icon) {
          if (props.renderIcon && isFunction(props.renderIcon)) {
            return props.renderIcon(params);
          }
        }
        return icon;
      }

      async function handleRightClick({ event, node }: Recordable) {
        const { rightMenuList: menuList = [], beforeRightClick } = props;
        let contextMenuOptions: CreateContextOptions = { event, items: [] };

        if (beforeRightClick && isFunction(beforeRightClick)) {
          let result = await beforeRightClick(node, event);
          if (Array.isArray(result)) {
            contextMenuOptions.items = result;
          } else {
            Object.assign(contextMenuOptions, result);
          }
        } else {
          contextMenuOptions.items = menuList;
        }
        if (!contextMenuOptions.items?.length) return;
        contextMenuOptions.items = contextMenuOptions.items.filter((item) => !item.hidden);
        createContextMenu(contextMenuOptions);
      }

      function setExpandedKeys(keys: KeyType[]) {
        state.expandedKeys = keys;
      }

      function getExpandedKeys() {
        return state.expandedKeys;
      }
      function setSelectedKeys(keys: KeyType[]) {
        state.selectedKeys = keys;
      }

      function getSelectedKeys() {
        return state.selectedKeys;
      }

      function setCheckedKeys(keys: CheckKeys) {
        state.checkedKeys = keys;
      }

      function getCheckedKeys() {
        return state.checkedKeys;
      }

      function checkAll(checkAll: boolean) {
        state.checkedKeys = checkAll ? getEnabledKeys() : ([] as KeyType[]);
      }

      function expandAll(expandAll: boolean) {
        state.expandedKeys = expandAll ? getAllKeys() : ([] as KeyType[]);
      }

      function onStrictlyChange(strictly: boolean) {
        state.checkStrictly = strictly;
      }

      watch(
        () => props.searchValue,
        (val) => {
          if (val !== searchState.searchText) {
            searchState.searchText = val;
          }
        },
        {
          immediate: true,
        },
      );

      watch(
        () => props.treeData,
        (val) => {
          if (val) {
            handleSearch(searchState.searchText);
          }
        },
      );

      function handleSearch(searchValue: string) {
        if (searchValue !== searchState.searchText) searchState.searchText = searchValue;
        emit('update:searchValue', searchValue);
        if (!searchValue) {
          searchState.startSearch = false;
          return;
        }
        const { filterFn, checkable, expandOnSearch, checkOnSearch, selectedOnSearch } =
          unref(props);
        searchState.startSearch = true;
        const { title: titleField, key: keyField } = unref(getFieldNames);

        const matchedKeys: string[] = [];
        searchState.searchData = filter(
          unref(treeDataRef),
          (node) => {
            const result = filterFn
              ? filterFn(searchValue, node, unref(getFieldNames))
              : node[titleField]?.includes(searchValue) ?? false;
            if (result) {
              matchedKeys.push(node[keyField]);
            }
            return result;
          },
          unref(getFieldNames),
        );

        if (expandOnSearch) {
          const expandKeys = treeToList(searchState.searchData).map((val) => {
            return val[keyField];
          });
          if (expandKeys && expandKeys.length) {
            setExpandedKeys(expandKeys);
          }
        }

        if (checkOnSearch && checkable && matchedKeys.length) {
          setCheckedKeys(matchedKeys);
        }

        if (selectedOnSearch && matchedKeys.length) {
          setSelectedKeys(matchedKeys);
        }
      }

      function handleClickNode(key: string, children: TreeItem[]) {
        if (!props.clickRowToExpand || !children || children.length === 0) return;
        if (!state.expandedKeys.includes(key)) {
          setExpandedKeys([...state.expandedKeys, key]);
        } else {
          const keys = [...state.expandedKeys];
          const index = keys.findIndex((item) => item === key);
          if (index !== -1) {
            keys.splice(index, 1);
          }
          setExpandedKeys(keys);
        }
      }

      watchEffect(() => {
        treeDataRef.value = props.treeData as TreeItem[];
      });

      onMounted(() => {
        const level = parseInt(props.defaultExpandLevel);
        if (level > 0) {
          state.expandedKeys = filterByLevel(level);
        } else if (props.defaultExpandAll) {
          expandAll(true);
        }
      });

      watchEffect(() => {
        state.expandedKeys = props.expandedKeys;
      });

      watchEffect(() => {
        state.selectedKeys = props.selectedKeys;
      });

      watchEffect(() => {
        state.checkedKeys = props.checkedKeys;
      });

      watch(
        () => props.value,
        () => {
          // update-end--author:liaozhiyang---date:20231122---for:【issues/863】关闭选择部门弹窗,再打开之前勾选的消失了
          state.checkedKeys = toRaw(props.value || props.checkedKeys || []);
          // update-end--author:liaozhiyang---date:20231122---for:【issues/863】关闭选择部门弹窗,再打开之前勾选的消失了
        },
        { immediate: true },
      );

      watch(
        () => state.checkedKeys,
        () => {
          const v = toRaw(state.checkedKeys);
          emit('update:value', v);
          emit('change', v);
        },
      );
      // update-begin--author:liaozhiyang---date:20240425---for:【issues/1151】层级独立时勾选了父级,然后点击层级关联子级视觉上勾选了,但是保存子级没存上
      watch(
        () => props.checkStrictly,
        () => {
          state.checkStrictly = props.checkStrictly;
          nextTick(() => {
            const value = treeRef.value?.checkedKeys;
            handleCheck([...value]);
          });
        }
      );
      // update-end--author:liaozhiyang---date:20240425---for:【issues/1151】层级独立时勾选了父级,然后点击层级关联子级视觉上勾选了,但是保存子级没存上

      const instance: TreeActionType = {
        setExpandedKeys,
        getExpandedKeys,
        setSelectedKeys,
        getSelectedKeys,
        setCheckedKeys,
        getCheckedKeys,
        insertNodeByKey,
        insertNodesByKey,
        deleteNodeByKey,
        updateNodeByKey,
        getSelectedNode,
        checkAll,
        expandAll,
        filterByLevel: (level: number) => {
          state.expandedKeys = filterByLevel(level);
        },
        setSearchValue: (value: string) => {
          handleSearch(value);
        },
        getSearchValue: () => {
          return searchState.searchText;
        },
      };

      function renderAction(node: TreeItem) {
        const { actionList } = props;
        if (!actionList || actionList.length === 0) return;
        return actionList.map((item, index) => {
          let nodeShow = true;
          if (isFunction(item.show)) {
            nodeShow = item.show?.(node);
          } else if (isBoolean(item.show)) {
            nodeShow = item.show;
          }

          if (!nodeShow) return null;

          return (
            <span key={index} class={bem('action')}>
              {item.render(node)}
            </span>
          );
        });
      }

      const treeData = computed(() => {
        const data = cloneDeep(getTreeData.value);
        eachTree(data, (item, _parent) => {
          const searchText = searchState.searchText;
          const { highlight } = unref(props);
          const {
            title: titleField,
            key: keyField,
            children: childrenField,
          } = unref(getFieldNames);

          const icon = getIcon(item, item.icon);
          const title = get(item, titleField);

          const searchIdx = searchText ? title.indexOf(searchText) : -1;
          const isHighlight =
            searchState.startSearch && !isEmpty(searchText) && highlight && searchIdx !== -1;
          const highlightStyle = `color: ${isBoolean(highlight) ? '#f50' : highlight}`;

          const titleDom = isHighlight ? (
            <span class={unref(getBindValues)?.blockNode ? `${bem('content')}` : ''}>
              <span>{title.substr(0, searchIdx)}</span>
              <span style={highlightStyle}>{searchText}</span>
              <span>{title.substr(searchIdx + (searchText as string).length)}</span>
            </span>
          ) : (
            title
          );
          item[titleField] = (
            <span
              class={`${bem('title')} pl-2`}
              onClick={handleClickNode.bind(null, item[keyField], item[childrenField])}
            >
              {slots?.title ? (
                getSlot(slots, 'title', item)
              ) : (
                <>
                  {icon && <TreeIcon icon={icon} />}
                  {titleDom}
                  <span class={bem('actions')}>{renderAction(item)}</span>
                </>
              )}
            </span>
          );
          return item;
        });
        return data;
      });

      expose(instance);

      return () => {
        const { title, helpMessage, toolbar, search, checkable } = props;
        const showTitle = title || toolbar || search || slots.headerTitle;
        const scrollStyle: CSSProperties = { height: 'calc(100% - 38px)' };
        return (
          <div class={[bem(), 'h-full', attrs.class]}>
            {showTitle && (
              <TreeHeader
                checkable={checkable}
                checkAll={checkAll}
                expandAll={expandAll}
                title={title}
                search={search}
                toolbar={toolbar}
                helpMessage={helpMessage}
                onStrictlyChange={onStrictlyChange}
                onSearch={handleSearch}
                onClickSearch={($event) => emit('search', $event)}
                searchText={searchState.searchText}
              >
                {extendSlots(slots)}
              </TreeHeader>
            )}
            <Spin spinning={unref(props.loading)} tip="加载中...">
              <ScrollContainer style={scrollStyle} v-show={!unref(getNotFound)}>
                <Tree ref={treeRef} {...unref(getBindValues)} showIcon={false} treeData={treeData.value} />
              </ScrollContainer>
              <Empty
                v-show={unref(getNotFound)}
                image={Empty.PRESENTED_IMAGE_SIMPLE}
                class="!mt-4"
              />
            </Spin>
          </div>
        );
      };
    },
  });
</script>

@liaozhiyang
Copy link

liaozhiyang commented Apr 26, 2024

1.重写了层级关联不使用自带的。(自带的层级关联,取消了子级的勾选,父级也会取消勾选,这种关联在这种场景下不适合)
2.默认就是层级关联
src/views/system/role/components/RolePermissionDrawer.vue 文件代码直接覆盖:


<template>
  <BasicDrawer v-bind="$attrs" @register="registerDrawer" width="650px" destroyOnClose showFooter>
    <template #title>
      角色权限配置
      <a-dropdown>
        <Icon icon="ant-design:more-outlined" class="more-icon" />
        <template #overlay>
          <a-menu @click="treeMenuClick">
            <a-menu-item key="checkAll">选择全部</a-menu-item>
            <a-menu-item key="cancelCheck">取消选择</a-menu-item>
            <div class="line"></div>
            <a-menu-item key="openAll">展开全部</a-menu-item>
            <a-menu-item key="closeAll">折叠全部</a-menu-item>
            <div class="line"></div>
            <a-menu-item key="relation">层级关联</a-menu-item>
            <a-menu-item key="standAlone">层级独立</a-menu-item>
          </a-menu>
        </template>
      </a-dropdown>
    </template>
    <BasicTree
      ref="treeRef"
      checkable
      :treeData="treeData"
      :checkedKeys="checkedKeys"
      :expandedKeys="expandedKeys"
      :selectedKeys="selectedKeys"
      :clickRowToExpand="false"
      :checkStrictly="true"
      title="所拥有的的权限"
      @check="onCheck"
      @select="onTreeNodeSelect"
    >
      <template #title="{ slotTitle, ruleFlag }">
        {{ slotTitle }}
        <Icon v-if="ruleFlag" icon="ant-design:align-left-outlined" style="margin-left: 5px; color: red"></Icon>
      </template>
    </BasicTree>
    <!--右下角按钮-->
    <template #footer>
      <!-- <PopConfirmButton title="确定放弃编辑?" @confirm="closeDrawer" okText="确定" cancelText="取消"></PopConfirmButton> -->
      <a-button @click="closeDrawer">取消</a-button>
      <a-button @click="handleSubmit(false)" type="primary" :loading="loading" ghost style="margin-right: 0.8rem">仅保存</a-button>
      <a-button @click="handleSubmit(true)" type="primary" :loading="loading">保存并关闭</a-button>
    </template>
    <RoleDataRuleDrawer @register="registerDrawer1" />
  </BasicDrawer>
</template>
<script lang="ts" setup>
  import { ref, computed, unref, onMounted } from 'vue';
  import { BasicDrawer, useDrawer, useDrawerInner } from '/@/components/Drawer';
  import { BasicTree, TreeItem } from '/@/components/Tree';
  import { PopConfirmButton } from '/@/components/Button';
  import RoleDataRuleDrawer from './RoleDataRuleDrawer.vue';
  import { queryTreeListForRole, queryRolePermission, saveRolePermission } from '../role.api';
  import { useI18n } from "/@/hooks/web/useI18n";

  const emit = defineEmits(['register']);
  //树的信息
  const treeData = ref<TreeItem[]>([]);
  //树的全部节点信息
  const allTreeKeys = ref([]);
  //树的选择节点信息
  const checkedKeys = ref<any>([]);
  const defaultCheckedKeys = ref([]);
  //树的选中的节点信息
  const selectedKeys = ref([]);
  const roleId = ref('');
  //树的实例
  const treeRef = ref(null);
  const loading = ref(false);

  //展开折叠的key
  const expandedKeys = ref<any>([]);
  //父子节点选中状态是否关联 true不关联,false关联
  const checkStrictly = ref<boolean>(false);
  const [registerDrawer1, { openDrawer: openDataRuleDrawer }] = useDrawer();
  const [registerDrawer, { setDrawerProps, closeDrawer }] = useDrawerInner(async (data) => {
    await reset();
    setDrawerProps({ confirmLoading: false, loading: true });
    roleId.value = data.roleId;
    //初始化数据
    const roleResult = await queryTreeListForRole();
    // update-begin--author:liaozhiyang---date:20240228---for:【QQYUN-8355】角色权限配置的菜单翻译
    treeData.value = translateTitle(roleResult.treeList);
    // update-end--author:liaozhiyang---date:20240228---for:【QQYUN-8355】角色权限配置的菜单翻译
    allTreeKeys.value = roleResult.ids;
    expandedKeys.value = roleResult.ids;
    //初始化角色菜单数据
    const permResult = await queryRolePermission({ roleId: unref(roleId) });
    checkedKeys.value = permResult;
    defaultCheckedKeys.value = permResult;
    setDrawerProps({ loading: false });
  });
  /**
  * 2024-02-28
  * liaozhiyang
  * 翻译菜单名称
   */
  function translateTitle(data) {
    if (data?.length) {
      data.forEach((item) => {
        if (item.slotTitle) {
          const { t } = useI18n();
          if (item.slotTitle.includes("t('") && t) {
            item.slotTitle = new Function('t', `return ${item.slotTitle}`)(t);
          }
        }
        if (item.children?.length) {
          translateTitle(item.children);
        }
      });
    }
    return data;
  }
  /**
   * 点击选中
   */
  function onCheck(o, e) {
    // checkStrictly: true=>层级独立,false=>层级关联.
    if (checkStrictly.value) {
      checkedKeys.value = o.checked ? o.checked : o;
    } else {
      const keys = getNodeAllKey(e.node, 'children', 'key');
      if (e.checked) {
        // 反复操作下可能会有重复的keys,得用new Set去重下
        checkedKeys.value = [...new Set([...checkedKeys.value, ...keys])];
      } else {
        const result = removeMatchingItems(checkedKeys.value, keys);
        checkedKeys.value = result;
      }
    }
  }
  function removeMatchingItems(arr1, arr2) {
    // 使用哈希表记录 arr2 中的元素
    const hashTable = {};
    for (const item of arr2) {
      hashTable[item] = true;
    }
    // 使用 filter 方法遍历第一个数组,过滤出不在哈希表中存在的项
    return arr1.filter((item) => !hashTable[item]);
  }
  function getNodeAllKey(node: any, children: any, key: string) {
    const result: any = [];
    result.push(node[key]);
    const recursion = (data) => {
      data.forEach((item: any) => {
        result.push(item[key]);
        if (item[children]?.length) {
          recursion(item[children]);
        }
      });
    };
    node[children]?.length && recursion(node[children]);
    return result;
  }

  /**
   * 选中节点,打开数据权限抽屉
   */
  function onTreeNodeSelect(key) {
    if (key && key.length > 0) {
      selectedKeys.value = key;
    }
    openDataRuleDrawer(true, { functionId: unref(selectedKeys)[0], roleId: unref(roleId) });
  }
  /**
   * 数据重置
   */
  function reset() {
    treeData.value = [];
    allTreeKeys.value = [];
    checkedKeys.value = [];
    defaultCheckedKeys.value = [];
    selectedKeys.value = [];
    roleId.value = '';
  }
  /**
   * 获取tree实例
   */
  function getTree() {
    const tree = unref(treeRef);
    if (!tree) {
      throw new Error('tree is null!');
    }
    return tree;
  }
  /**
   * 提交
   */
  async function handleSubmit(exit) {
    let params = {
      roleId: unref(roleId),
      permissionIds: unref(getTree().getCheckedKeys()).join(','),
      lastpermissionIds: unref(defaultCheckedKeys).join(','),
    };
    //update-begin-author:taoyan date:2023-2-11 for: issues/352 VUE角色授权重复保存
    if(loading.value===false){
      await doSave(params)
    }else{
      console.log('请等待上次执行完毕!');
    }
    if(exit){
      // 如果关闭
      closeDrawer();
    }else{
      // 没有关闭需要重新获取选中数据
      const permResult = await queryRolePermission({ roleId: unref(roleId) });
      defaultCheckedKeys.value = permResult;
    }
  }
  
  // VUE角色授权重复保存 jeecgboot/JeecgBoot-vue3#352
  async function doSave(params) {
    loading.value = true;
    await saveRolePermission(params);
    setTimeout(()=>{
      loading.value = false;
    }, 500)
  }
  //update-end-author:taoyan date:2023-2-11 for: issues/352 VUE角色授权重复保存

  /**
   * 树菜单选择
   * @param key
   */
  function treeMenuClick({ key }) {
    if (key === 'checkAll') {
      checkedKeys.value = allTreeKeys.value;
    } else if (key === 'cancelCheck') {
      checkedKeys.value = [];
    } else if (key === 'openAll') {
      expandedKeys.value = allTreeKeys.value;
    } else if (key === 'closeAll') {
      expandedKeys.value = [];
    } else if (key === 'relation') {
      checkStrictly.value = false;
    } else {
      checkStrictly.value = true;
    }
  }
</script>

<style lang="less" scoped>
  /** 固定操作按钮 */
  .jeecg-basic-tree {
    position: absolute;
    width: 618px;
  }
  //update-begin---author:wangshuai ---date:20230202  for:抽屉弹窗标题图标下拉样式------------
  .line {
    height: 1px;
    width: 100%;
    border-bottom: 1px solid #f0f0f0;
  }
  .more-icon {
    font-size: 20px !important;
    color: black;
    display: inline-flex;
    float: right;
    margin-right: 2px;
    cursor: pointer;
  }
  :deep(.jeecg-tree-header){
    border-bottom: none;
  }
  //update-end---author:wangshuai ---date:20230202  for:抽屉弹窗标题图标下拉样式------------
</style>

@zhangdaiscott zhangdaiscott transferred this issue from jeecgboot/JeecgBoot-vue3 Jun 21, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants