后台管理系统开发(菜单列表 前端篇)

92 阅读3分钟

实现效果

image.png

image.png

image.png

image.png

页面实现

pages/menu/index.vue

<script lang="ts" setup>
  import { type TableInstance } from '@arco-design/web-vue';
  import { menuMeta } from './menuMeta';
  import DeleteMenu from './components/DeleteMenu.vue';
  import UpdateMenu from './components/UpdateMenu.vue';
  import { getMenuTreeApi } from '~/api/menu';
  import { useRequest } from '#imports';

  const {
    loading,
    data: menuTree,
    refresh: menuTreeRefresh,
  } = useRequest(getMenuTreeApi, {
    manual: false,
  });

  const { tableColumns, menuTypeColors } = menuMeta();

  const { isDesktop } = useLayout();

  const tableBoxRef = ref<HTMLElement>();
  const tableRef = ref<TableInstance>();
  const updateMenuRef = ref<InstanceType<typeof UpdateMenu>>();

  const loadingMenuInfo = computed(() => {
    return updateMenuRef.value!.loadMenuLoading || false;
  });

  // 展开的节点 id
  const expandedKeys = ref([]);

  // 触发全屏
  const { toggle } = useFullscreen(tableBoxRef);

  /**
   * 展开全部节点
   */
  const onExpandAll = () => {
    tableRef.value!.expandAll();
  };

  /**
   * 折叠全部节点
   */
  const onFoldingAll = () => {
    expandedKeys.value = [];
  };

  // loading 指向, 哪个按钮需要加载显示
  const selectMenuId = ref('');

  /**
   * [Button]点击更新菜单按钮
   *
   * @param mid 菜单 id
   */
  const onUpdate = (mid: string) => {
    selectMenuId.value = mid;

    // 请求获取单个菜单数据
    updateMenuRef.value!.loadMenuRunAsync(mid);
  };

  // [Callback]更新成功触发事件
  const onUpdateSuccess = () => {
    // 提示消息
    Message.success('更新成功');
    menuTreeRefresh();
  };

  /**
   * [Button]点击创建菜单按钮
   */
  const onCreateMenu = (parentId: string) => {
    // 显示创建菜单窗体
    updateMenuRef.value!.onCreateMenuVisible(parentId);
  };

  /**
   * [Callback]创建菜单成功触发事件
   */
  const onCreateSuccess = () => {
    Message.success('创建菜单成功');
    menuTreeRefresh();
  };
</script>

<template>
  <a-card ref="tableBoxRef" :bordered="false" :size="'small'">
    <UpdateMenu
      ref="updateMenuRef"
      :menu-tree="menuTree"
      @on-update-success="onUpdateSuccess"
      @on-create-success="onCreateSuccess"
    />

    <div class="flex justify-between py-4">
      <a-space>
        <a-button :size="'small'" type="primary" @click="onCreateMenu('0')">
          <span v-if="isDesktop">新建菜单</span>
          <template #icon>
            <i class="i-tabler-copy-plus" />
          </template>
        </a-button>

        <a-button @click="onFoldingAll">
          <span v-if="isDesktop">折叠全部</span>

          <template #icon>
            <i class="i-tabler-arrow-autofit-up" />
          </template>
        </a-button>

        <a-button @click="onExpandAll">
          <span v-if="isDesktop">展开全部</span>
          <template #icon>
            <i class="i-tabler-arrow-autofit-height" />
          </template>
        </a-button>
      </a-space>

      <a-space>
        <a-button shape="circle" @click="menuTreeRefresh">
          <i class="i-tabler-reload" />
        </a-button>

        <a-button shape="circle" @click="toggle">
          <i class="i-tabler-arrows-maximize" />
        </a-button>
      </a-space>
    </div>

    <a-table
      ref="tableRef"
      v-model:expanded-keys="expandedKeys"
      :columns="tableColumns"
      :data="menuTree"
      :loading="loading"
      filter-icon-align-left
      hide-expand-button-on-empty
      row-key="menuId"
    >
      <template #menuType="{ record }">
        <a-tag
          :color="
            menuTypeColors[record.menuType?.code as keyof typeof menuTypeColors]
          "
        >
          <a-space>
            <Iconify :name="record.menuType?.icon" />
            <span>{{ record.menuType?.label }}</span>
          </a-space>
        </a-tag>
      </template>

      <template #menuName="{ record }">
        <a-space>
          <Iconify :name="record.menuIcon" />
          <a-typography-text>{{ record.menuName }}</a-typography-text>
        </a-space>
      </template>

      <template #optional="{ record }">
        <a-button-group :size="'mini'">
          <a-space :size="'mini'">
            <a-button @click="onCreateMenu(record.menuId as string)">
              添加
            </a-button>
            <a-button
              :loading="selectMenuId == record.menuId && loadingMenuInfo"
              @click="onUpdate(record.menuId as string)"
            >
              修改
            </a-button>
            <DeleteMenu
              :disabled="record.children.length !== 0"
              :menu-id="record.menuId"
              :menu-name="record.menuName"
              @success="menuTreeRefresh"
            />
          </a-space>
        </a-button-group>
      </template>

      <!--  展开行图标  -->
      <template #expand-icon="{ expanded }">
        <i
          v-if="expanded"
          class="i-material-symbols-arrow-forward-ios-rounded rotate-90"
        />
        <i v-else class="i-material-symbols-arrow-forward-ios-rounded" />
      </template>
    </a-table>
  </a-card>
</template>

<style lang="scss" scoped>
  :deep(.arco-table-cell-inline-icon) {
    @apply pr-2;
  }

  :deep(.arco-table-expand-btn) {
    background-color: transparent;
  }
</style>

pages/menu/components/DeleteMenu.vue

<script lang="ts" setup>
  import { delMenuApi } from '~/api/menu';
  import { useRequest } from '#imports';

  const props = defineProps<{
    menuId: string;
    menuName: string;
    disabled: boolean;
  }>();

  const emits = defineEmits<{
    (e: 'success'): void;
  }>();

  const { loading, run } = useRequest(delMenuApi, {
    onError(error) {
      Message.error(error.message);
    },
    onSuccess() {
      emits('success');
    },
  });

  /**
   * [button] 确认删除
   */
  const onConfirm = () => {
    run(props.menuId);
  };
</script>

<template>
  <a-popconfirm
    :cancel-button-props="{
      type: 'secondary',
    }"
    :ok-button-props="{
      status: 'danger',
    }"
    :position="'left'"
    type="error"
    @ok="onConfirm"
  >
    <template #content>
      <span>
        确认删除
        <span class="highlight">{{ menuName }}</span>
        菜单吗
      </span>
    </template>

    <a-tooltip v-if="disabled" :content="`${menuName} 存在子节点`">
      <a-button
        :disabled="disabled"
        :loading="loading"
        :status="disabled ? 'normal' : 'danger'"
      >
        删除
      </a-button>
    </a-tooltip>

    <a-button v-else :loading="loading" :status="'danger'">删除</a-button>
  </a-popconfirm>
</template>

<style scoped>
  .highlight {
    color: rgb(var(--danger-6));
  }
</style>

pages/menu/components/UpdateMenu.vue

<script lang="ts" setup>
  import type { FormInstance, TreeNodeData } from '@arco-design/web-vue';
  import type { MenuDto } from '~/types/dto/menu';
  import { MenuTreeUtil } from '~/utils/MenuTreeUtil';
  import { getMenusApi, postMenuApi, putMenuApi } from '~/api/menu';
  import { useRequest } from '#imports';
  import { SYS_MENU_TYPE, SYS_VISIBLE } from '~/key/dict';

  const props = defineProps<{
    menuTree: MenuEntity[] | undefined;
  }>();

  const emits = defineEmits<{
    (e: 'onUpdateSuccess'): void;
    (e: 'onCreateSuccess'): void;
  }>();

  const { isTablet } = useLayout();

  const formRef = ref<FormInstance>();

  // 是否显示窗体
  const visible = ref(false);

  // 系统字典
  const systemDictStore = useSystemDictStore();

  // 表单数据
  const formData = ref<Partial<MenuDto> & { menuId?: string }>({});

  /**
   * 处于创建菜单 模式
   */
  const isCreateMenu = computed(() => {
    return !formData.value?.menuId;
  });

  // 获取菜单请求
  const {
    runAsync: loadMenuRunAsync,
    loading: loadMenuLoading,
    refresh,
  } = useRequest(getMenusApi, {
    onSuccess: (data) => {
      // 表单数据更新
      formData.value = {
        menuName: data.menuName,
        menuType: data.menuType.code,
        parentId: data.parentId,
        menuSort: data.menuSort,
        component: data.component,
        menuIcon: data.menuIcon,
        visible: data.visible.code,
        menuId: data.menuId,
      };

      // 显示窗体
      visible.value = true;
    },
  });
  // 创建菜单请求
  const { runAsync: createMenuRunAsync, loading: createMenuLoading } =
    useRequest(postMenuApi);

  // 更新菜单请求
  const { runAsync: updateMenuRunAsync, loading: updateMenuLoading } =
    useRequest(putMenuApi);

  // 转换菜单树数据
  const treeData = computed<TreeNodeData[]>(() => {
    return MenuTreeUtil.toTreeNodeData(props.menuTree);
  });

  // 点击确认
  const onUpdateConfirm = async () => {
    // 发送更新请求
    await updateMenuRunAsync(formData.value.menuId as string, {
      ...unref(formData),
    });

    // 关闭窗体
    visible.value = false;

    // 通知父组件 更新成功
    emits('onUpdateSuccess');

    // 清空表单数据
    onResetForm();
  };

  // 点击离开按钮
  const onCancel = () => {
    // 关闭窗体
    visible.value = false;

    // 清空表单数据
    onResetForm();
  };

  /**
   * 显示创建菜单窗体
   *
   * @param parentId 上级菜单 id
   */
  const onCreateMenuVisible = (parentId: string) => {
    formData.value.parentId = parentId;

    visible.value = true;

    onResetForm();
  };

  /**
   * 发送创建菜单请求
   */
  const onCreateMenu = async () => {
    await createMenuRunAsync({ ...unref(formData) });
    emits('onCreateSuccess');

    visible.value = false;

    onResetForm();
  };

  /**
   * 重置表单数据
   */
  const onResetForm = () => {
    // formRef.value!.resetFields();
    formData.value = {
      menuType: systemDictStore.getDefaultValue(SYS_MENU_TYPE),
      visible: systemDictStore.getDefaultValue(SYS_VISIBLE),
    };
  };

  defineExpose({
    /**
     * 加载菜单状态
     */
    loadMenuLoading,
    /**
     * 加载菜单同步请求
     */
    loadMenuRunAsync,
    /**
     * 显示创建菜单窗体
     */
    onCreateMenuVisible,
  });
</script>

<template>
  <a-modal
    v-model:visible="visible"
    :footer="false"
    :fullscreen="isTablet"
    :hide-title="true"
    :modal-style="{
      padding: isTablet ? '1.5rem !important' : '',
    }"
    :simple="true"
    @cancel="onCancel"
  >
    <a-space class="justify-between" fill>
      <a-space v-if="isCreateMenu" class="text-lg">
        <i class="i-line-md:document-add" />
        <span>创建菜单</span>
      </a-space>
      <a-space v-else class="text-lg">
        <i class="i-line-md:edit-twotone" />
        <span>编辑菜单</span>
      </a-space>

      <a-button :loading="loadMenuLoading" shape="circle" @click="refresh">
        <template #icon>
          <i class="i-gg-sync" />
        </template>
      </a-button>
    </a-space>

    <a-divider />

    <a-form
      ref="formRef"
      :layout="isTablet ? 'vertical' : 'horizontal'"
      :model="formData"
    >
      <a-form-item field="parentId" label="上级菜单">
        <MenuTreeSelect
          v-model:selected-key="formData.parentId"
          :tree-data="treeData"
        />
      </a-form-item>

      <a-form-item field="menuName" label="菜单名称">
        <a-input v-model="formData.menuName" placeholder="请输入菜单名称">
          <template #prefix>
            <i class="i-gg-rename" />
          </template>
        </a-input>
      </a-form-item>

      <a-form-item field="component" label="路由地址">
        <a-input v-model="formData.component" placeholder="请输入路由地址">
          <template #prefix>
            <i class="i-gg-list-tree" />
          </template>
        </a-input>
      </a-form-item>

      <a-form-item field="visible" label="显示">
        <a-select v-model="formData.visible" placeholder="菜单是否显示">
          <a-option
            v-for="dictData in systemDictStore.getDictTypeData(SYS_VISIBLE)"
            :key="dictData.value"
            :value="dictData.value"
          >
            <span>{{ dictData.label }}</span>
            <template #icon>
              <Iconify :name="dictData.icon" />
            </template>
          </a-option>

          <template #prefix>
            <i class="i-gg-eye" />
          </template>
        </a-select>
      </a-form-item>

      <a-form-item field="menuIcon" label="菜单图标">
        <a-input
          v-model="formData.menuIcon"
          placeholder="icones.js.org 图标(`gg:user-add`)"
        >
          <template v-if="formData.menuIcon" #prefix>
            <Iconify :name="formData.menuIcon" />
          </template>
        </a-input>
      </a-form-item>

      <a-form-item field="menuSort" label="排序" placeholder="请输入序号">
        <a-input-number v-model="formData.menuSort" placeholder="请输入序号">
          <template #prefix>
            <i class="i-gg-sort-za" />
          </template>
        </a-input-number>
      </a-form-item>

      <a-form-item field="menuType" label="菜单类型">
        <a-radio-group v-model="formData.menuType" :type="'button'">
          <a-radio
            v-for="dictData in systemDictStore.getDictTypeData(SYS_MENU_TYPE)"
            :key="dictData.value"
            :value="dictData.value"
          >
            <a-space>
              <Iconify :name="dictData.icon" />
              <span>{{ dictData.label }}</span>
            </a-space>
          </a-radio>
        </a-radio-group>
      </a-form-item>
    </a-form>

    <a-space class="justify-end pt-4" fill>
      <a-button :size="'small'" @click="onCancel">离开</a-button>
      <a-button
        v-if="isCreateMenu"
        :loading="createMenuLoading"
        :size="'small'"
        type="primary"
        @click="onCreateMenu"
      >
        创建菜单
        <template #icon>
          <i class="i-streamline-braille-blind" />
        </template>
      </a-button>
      <a-button
        v-else
        :loading="updateMenuLoading"
        :size="'small'"
        type="primary"
        @click="onUpdateConfirm"
      >
        提交修改
        <template #icon>
          <i class="i-streamline-braille-blind" />
        </template>
      </a-button>
    </a-space>
  </a-modal>
</template>

<style scoped></style>