第12章 nestjs服务端开发:RBAC权限系统设计

前端内容页:创建角色页面(创建路由)

前端内容页:创建角色页面&菜单管理内容页面

src\views\roles\index.vue

javascript 复制代码
<template>
  <div>
    <div class="mb-3">
      <button type="button" class="btn btn-primary px-3" @click="openModal('add')">
        <i class="fas fa-plus"></i>新增
      </button>
    </div>
    <table class="table table-bordered table-hover table-striped">
      <thead>
        <tr>
          <th scope="col">#</th>
          <th scope="col">名称</th>
          <th scope="col">分配权限</th>
          <th scope="col">操作</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>1</td>
          <td>管理员</td>
          <td></td>
          <td>
            <button type="button" class="btn btn-secondary px-3" @click="openModal('edit')">
              <i class="far fa-edit me-2"></i>编辑</button>
            <button type="button" class="btn btn-danger px-3 ms-3" @click="openModal('delete')">
              <i class="far fa-trash-alt me-2"></i>删除
            </button>
          </td>
        </tr>
      </tbody>
    </table>
    <nav aria-label="Page navigation example">
      <ul class="pagination">
        <li class="page-item"><a class="page-link" href="#">Previous</a></li>
        <li class="page-item"><a class="page-link" href="#">1</a></li>
        <li class="page-item"><a class="page-link" href="#">2</a></li>
        <li class="page-item"><a class="page-link" href="#">3</a></li>
        <li class="page-item"><a class="page-link" href="#">Next</a></li>
      </ul>
    </nav>
    <DeleteModal v-model:show="deleteShow" @delete="deleteSubmit"></DeleteModal>
    <EditAddModal v-model:show="editShow" :schema="formSchema" :msg="msg" @submit="editSubmit"></EditAddModal>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from "vue";

import DeleteModal from "@/components/modal/DeleteModal.vue";
import EditAddModal from "@/components/modal/EditAddModal.vue";

import { FormItem } from "@/components/Form.vue";

export default defineComponent({
  components: {
    DeleteModal,
    EditAddModal,
  },
  setup() {
    const deleteShow = ref(false);
    const editShow = ref(false);

    // 模态框的控制handler
    const msg = ref("新增");

    let localType = "";

    const formSchema = [
      {
        field: "角色名",
        type: "input",
        prop: "username",
        value: "",
        attr: {
          placeholder: "请输入角色名称",
        },
      },
      {
        field: "菜单1",
        type: "checkbox",
        prop: "roles",
        value: [],
        children: [
          {
            value: 1,
            field: "CREATE",
          },
          {
            value: 2,
            field: "UPDATE",
          },
          {
            value: 3,
            field: "DELETE",
          },
          {
            value: 4,
            field: "READ",
          },
          {
            value: 5,
            field: "MANAGE",
          },
        ],
      },
      {
        field: "菜单2",
        type: "checkbox",
        prop: "roles1",
        value: [],
        children: [
          {
            value: 1,
            field: "CREATE",
          },
          {
            value: 2,
            field: "UPDATE",
          },
          {
            value: 3,
            field: "DELETE",
          },
          {
            value: 4,
            field: "READ",
          },
          {
            value: 5,
            field: "MANAGE",
          },
        ],
      },
    ] as FormItem[];

    const deleteSubmit = () => {
      console.log("delete");
    };

    // 控制模态框
    const openModal = (type: string) => {
      localType = type;
      // console.log(item);
      if (type === "delete") {
        deleteShow.value = true;
      } else if (type === "edit") {
        msg.value = "编辑";
        editShow.value = true;
      } else if (type === "add") {
        msg.value = "新增";
        editShow.value = true;
      }
    };

    const editSubmit = async (val: any) => {
      console.log("🚀 ~ file: index.vue ~ line 221 ~ editSubmit ~ val", val);
    };

    return {
      deleteShow,
      deleteHandler: () => {
        deleteShow.value = true;
      },
      deleteSubmit,
      formSchema,
      editShow,
      msg,
      editSubmit,
      openModal,
    };
  },
});
</script>

<style scoped>
</style>

src\views\menus\index.vue

javascript 复制代码
<template>
  <div>
    <div>
      <div class="mb-3">
        <button type="button" class="btn btn-primary px-3" @click="openModal('add')">
          <i class="fas fa-plus"></i>新增
        </button>
      </div>
      <table class="table table-bordered table-hover table-striped">
        <thead>
          <tr>
            <th scope="col">#</th>
            <th scope="col">菜单名称</th>
            <th scope="col">菜单路径</th>
            <th scope="col">排序</th>
            <th scope="col">操作策略</th>
            <th scope="col">操作</th>
          </tr>
        </thead>
        <tbody>
          <tr>
            <td>1</td>
            <td>首页</td>
            <td>/dashboard</td>
            <td>1</td>
            <td>READ,UPDATE</td>
            <td>
              <button type="button" class="btn btn-secondary px-3" @click="openModal('edit')">
                <i class="far fa-edit me-2"></i>编辑</button>
              <button type="button" class="btn btn-danger px-3 ms-3" @click="openModal('delete')">
                <i class="far fa-trash-alt me-2"></i>删除
              </button>
            </td>
          </tr>
        </tbody>
      </table>
      <nav aria-label="Page navigation example">
        <ul class="pagination">
          <li class="page-item"><a class="page-link" href="#">Previous</a></li>
          <li class="page-item"><a class="page-link" href="#">1</a></li>
          <li class="page-item"><a class="page-link" href="#">2</a></li>
          <li class="page-item"><a class="page-link" href="#">3</a></li>
          <li class="page-item"><a class="page-link" href="#">Next</a></li>
        </ul>
      </nav>
    </div>
    <DeleteModal v-model:show="deleteShow" @delete="deleteSubmit"></DeleteModal>
    <EditAddModal v-model:show="editShow" :schema="formSchema" :msg="msg" @submit="editSubmit"></EditAddModal>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from "vue";

import DeleteModal from "@/components/modal/DeleteModal.vue";
import EditAddModal from "@/components/modal/EditAddModal.vue";
import { FormItem } from "@/components/Form.vue";

export default defineComponent({
  components: {
    DeleteModal,
    EditAddModal,
  },
  setup() {
    const deleteShow = ref(false);
    const editShow = ref(false);

    // 模态框的控制handler
    const msg = ref("新增");

    let localType = "";

    const formSchema = [
      {
        field: "菜单名",
        type: "input",
        prop: "name",
        value: "",
        attr: {
          placeholder: "请输入菜单名称",
        },
      },
      {
        field: "菜单路径",
        type: "input",
        prop: "path",
        value: "",
        attr: {
          placeholder: "请输入菜单路径",
        },
      },
      {
        field: "菜单排序",
        type: "input",
        prop: "order",
        value: "",
        attr: {
          placeholder: "请输入菜单排序",
        },
      },
      {
        field: "允许的操作策略",
        type: "checkbox",
        prop: "roles",
        value: [],
        children: [
          {
            value: 1,
            field: "CREATE",
          },
          {
            value: 2,
            field: "UPDATE",
          },
          {
            value: 3,
            field: "DELETE",
          },
          {
            value: 4,
            field: "READ",
          },
          {
            value: 5,
            field: "MANAGE",
          },
        ],
      },
    ] as FormItem[];

    const deleteSubmit = () => {
      console.log("delete");
    };

    // 控制模态框
    const openModal = (type: string) => {
      localType = type;
      // console.log(item);
      if (type === "delete") {
        deleteShow.value = true;
      } else if (type === "edit") {
        msg.value = "编辑";
        editShow.value = true;
      } else if (type === "add") {
        msg.value = "新增";
        editShow.value = true;
      }
    };

    const editSubmit = async (val: any) => {
      console.log("🚀 ~ file: index.vue ~ line 221 ~ editSubmit ~ val", val);
    };

    return {
      deleteShow,
      deleteSubmit,
      formSchema,
      editShow,
      msg,
      editSubmit,
      openModal,
    };
  },
});
</script>

<style scoped>
</style>

src\router\index.ts

javascript 复制代码
    {
        name: 'menus',
        path: 'menus',
        component: () => import('@/views/menus/index.vue'),
        meta: {
          icon: 'fas fa-bars',
        },
      },
      {
        name: 'roles',
        path: 'roles',
        component: () => import('@/views/roles/index.vue'),
        meta: {
          icon: 'fas fa-tools',
        },
      }

前端内容页:封装删除模态框组件

src\components\modal\DeleteModal.vue

javascript 复制代码
<template>
  <!-- Delete Modal -->
  <div class="modal fade" ref="deleteRef" tabindex="-1" aria-labelledby="DeleteModalLabel" aria-hidden="true">
    <div class="modal-dialog">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title" id="DeleteModalLabel">删除</h5>
          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
        </div>
        <div class="modal-body">
          确定删除该记录吗?
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @click="cancel()">取消</button>
          <button type="button" class="btn btn-primary" @click="deleteSubmit()">确定</button>
        </div>
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, onMounted, ref, toRefs, watch } from "vue";
// import * as bootstrap from "bootstrap";
import { useModal } from "@/hooks";

export default defineComponent({
  props: {
    show: {
      type: Boolean,
      default: false,
    },
  },
  emits: ["delete"],
  setup(_props, { emit }) {
    const { show } = toRefs(_props);

    const { modalRef: deleteRef, hide } = useModal(show, "show");

    // 删除该条数据
    const deleteSubmit = async () => {
      // @delete
      emit("delete");
    };

    // 取消
    const cancel = () => {
      hide();
    };

    return {
      deleteRef,
      deleteSubmit,
      cancel,
    };
  },
});
</script>

<style scoped>
</style>

src\views\roles\index.vue

javascript 复制代码
<template>
  <div>
    <DeleteModal v-model:show="deleteShow" @delete="deleteSubmit"></DeleteModal>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from "vue";

import DeleteModal from "@/components/modal/DeleteModal.vue";

import { FormItem } from "@/components/Form.vue";

export default defineComponent({
  components: {
    DeleteModal
  },
  setup() {
    const deleteShow = ref(false);
    const editShow = ref(false);

    // 模态框的控制handler
    const msg = ref("新增");

    let localType = "";

    const formSchema = [
      {
        field: "角色名",
        type: "input",
        prop: "username",
        value: "",
        attr: {
          placeholder: "请输入角色名称",
        },
      },
      {
        field: "菜单1",
        type: "checkbox",
        prop: "roles",
        value: [],
        children: [
          {
            value: 1,
            field: "CREATE",
          },
          {
            value: 2,
            field: "UPDATE",
          },
          {
            value: 3,
            field: "DELETE",
          },
          {
            value: 4,
            field: "READ",
          },
          {
            value: 5,
            field: "MANAGE",
          },
        ],
      },
      {
        field: "菜单2",
        type: "checkbox",
        prop: "roles1",
        value: [],
        children: [
          {
            value: 1,
            field: "CREATE",
          },
          {
            value: 2,
            field: "UPDATE",
          },
          {
            value: 3,
            field: "DELETE",
          },
          {
            value: 4,
            field: "READ",
          },
          {
            value: 5,
            field: "MANAGE",
          },
        ],
      },
    ] as FormItem[];

    const deleteSubmit = () => {
      console.log("delete");
    };

    // 控制模态框
    const openModal = (type: string) => {
      localType = type;
      // console.log(item);
      if (type === "delete") {
        deleteShow.value = true;
      } else if (type === "edit") {
        msg.value = "编辑";
        editShow.value = true;
      } else if (type === "add") {
        msg.value = "新增";
        editShow.value = true;
      }
    };

    const editSubmit = async (val: any) => {
      console.log("🚀 ~ file: index.vue ~ line 221 ~ editSubmit ~ val", val);
    };

    return {
      deleteShow,
      deleteHandler: () => {
        deleteShow.value = true;
      },
      deleteSubmit,
      formSchema,
      editShow,
      msg,
      editSubmit,
      openModal,
    };
  },
});
</script>

<style scoped>
</style>

前端内容页:封装useModal方法

javascript 复制代码
import { onMounted, ref, toRefs, getCurrentInstance, watch, Ref } from 'vue';
import * as bootstrap from 'bootstrap';

export function useModal(refValue: Ref<boolean>, key: string) {
  // 1.定义modal与modalRef
  const modalRef = ref();
  // modal是实例
  const modal = ref();
  const vm = getCurrentInstance();

  // const { [key]: isShow } = toRefs(props);

  watch(
    () => refValue.value,
    () => {
      if (refValue.value) {
        show();
      } else {
        hide();
      }
    }
  );

  // 2.初始化modal
  onMounted(async () => {
    // 模态框创建
    modal.value = new bootstrap.Modal(modalRef.value, {
      backdrop: true,
    });

    modalRef.value.addEventListener('hidden.bs.modal', function () {
      // do something...
      vm?.emit(`update:${key}`, false);
    });
  });

  // 3.定义hide与show方法
  function show() {
    modal.value.show();
  }

  function hide() {
    modal.value.hide();
  }
  // 4.返回一个show显示与否的状态量
  return {
    modal,
    modalRef,
    show,
    hide,
  };
}
Vue 3 响应式 API --- ref
javascript 复制代码
import { onMounted, ref, toRefs, getCurrentInstance, watch, Ref } from 'vue';
// ...
  const modalRef = ref();
  const modal = ref();

ref() 创建一个响应式引用,包裹基本类型或对象。模板中绑定的 DOM 元素通过 ref 获取,这里 modalRef 用于拿到模板中模态框的 DOM 节点,modal 用于存放 Bootstrap Modal 实例。

TypeScript 类型 --- Ref<T>
javascript 复制代码
export function useModal(refValue: Ref<boolean>, key: string) {

Ref<boolean> 是 Vue 提供的泛型类型,表示一个值为 boolean 的响应式引用。这约束了传入的 refValue 必须是一个布尔类型的 ref,不能随便传其他类型,体现了 TypeScript 的类型安全。

getCurrentInstance() --- 获取当前组件实例

getCurrentInstance() 返回当前正在执行的组件实例(类比 Vue 2 中的 this)。

这里用它来访问组件的 emit 方法,实现在 hook 内部向父组件发送事件

注意:这个 API 主要用于内部/高级场景,官方不建议在应用代码中滥用。在 composable 中需要触发事件时,它是为数不多的选择之一。

watch() --- 监听响应式数据变化
javascript 复制代码
  watch(
    () => refValue.value,
    () => {
      if (refValue.value) {
        show();
      } else {
        hide();
      }
    }
  );

watch(source, callback) 监听响应式数据源的变化,当值改变时执行回调。

这里监听外部传入的 refValue(通常绑定 v-model),当父组件把值改为 true 就调用 show() 打开模态框,改为 false 就调用 hide() 关闭。实现了外部数据驱动模态框显隐。

onMounted() --- 生命周期钩子
javascript 复制代码
  onMounted(async () => {
    modal.value = new bootstrap.Modal(modalRef.value, {
      backdrop: true,
    });
    modalRef.value.addEventListener('hidden.bs.modal', function () {
      vm?.emit(`update:${key}`, false);
    });
  });

onMounted() 在组件挂载完成后执行,此时 DOM 已经渲染完毕,可以安全地通过 modalRef.value 拿到 DOM 元素。

javascript 复制代码
    modal.value = new bootstrap.Modal(modalRef.value, {
      backdrop: true,
    });

new bootstrap.Modal(element, options) 通过 JavaScript 程序化创建一个 Bootstrap 模态框实例。

backdrop: true 表示点击背景遮罩层可以关闭模态框。

将实例存在 modal 这个 ref 中,后续可以通过 modal.value.show() / modal.value.hide() 控制显隐。

Bootstrap 自定义事件 --- hidden.bs.modal
javascript 复制代码
    modalRef.value.addEventListener('hidden.bs.modal', function () {
      vm?.emit(`update:${key}`, false);
    });

Bootstrap 5 提供了一系列原生 DOM 事件,如 show.bs.modal、shown.bs.modal、hide.bs.modal、hidden.bs.modal(完全隐藏后触发)

这里监听 hidden.bs.modal,当用户通过点击背景、按 ESC 或点击关闭按钮关闭模态框时,向父组件发送 update:xxx 事件,把对应的值改回 false。

v-model 的双向绑定原理 --- update:xxx 事件
javascript 复制代码
      vm?.emit(`update:${key}`, false);

Vue 3 中 v-model:xxx="value" 本质是:

把 xxx 作为 prop 传入,

监听 update:xxx 事件来更新值。

这里的 key 就是 prop 名(如 visible),vm?.emit('update:visible', false) 等价于通知父组件「请把 visible 改为 false」,从而让外部状态与模态框的实际状态保持同步。

Composable 设计模式

整个文件是一个典型的 Vue 3 Composable(组合式函数):

输入:接收一个布尔 ref(控制显隐)和一个 key(v-model 的 prop 名)。

封装:将 Bootstrap Modal 的创建、事件监听、显隐控制全部封装在一起。

输出:返回 { modal, modalRef, show, hide },其中 modalRef 需要绑定到模板的 ref,show/hide 对外暴露手动控制能力。

这样任何需要使用模态框的组件,只需调用 useModal(visibleRef, 'visible'),并在模板中 ref="modalRef" 绑定 DOM,就能复用整套模态框逻辑,避免重复代码。

前端内容页:完成动态Form组件及模态框数据传递

src\components\Form.vue

html 复制代码
<template>
  <div v-for="(item,index) in schema" :key="index">
    <!-- schema(json) -> form结构 -> 动态形成form表单组件 -->
    <!-- username input -->
    <div class="mb-3" v-if="item.type === 'input'">
      <label for="exampleFormControlInput1" class="form-label">{{item.field}}</label>
      <input type="username" class="form-control" id="exampleFormControlInput1" :placeholder="item.attr?item.attr.placeholder: ''" v-model="model[item.prop]">
    </div>
    <!-- role checkbox -->
    <div class="mb-3" v-else-if="item.type === 'checkbox'">
      <label for="exampleFormControlInput1" class="form-label">{{item.field}}</label>
      <div class="form-control border-0">
        <div class="form-check form-check-inline" v-for="(citem,cidx) in item.children" :key="cidx">
          <input class="form-check-input" type="checkbox" :value="citem.value" :id="citem.id" v-model="model[item.prop]">
          <label class="form-check-label" :for="citem.id">
            {{citem.field}}
          </label>
        </div>
      </div>
    </div>
    <!-- gender radio -->
    <div class="mb-3" v-else-if="item.type === 'radio'">
      <label for="exampleFormControlInput1" class="form-label">{{item.field}}</label>
      <div class="form-control border-0">
        <div class="form-check form-check-inline" v-for="(ritem,ridx) in item.children" :key="ridx">
          <input class="form-check-input" type="radio" name="flexRadioDefault" :id="ritem.id" :value="ritem.value" v-model="model[item.prop]">
          <label class="form-check-label" :for="ritem.id">
            {{ritem.field}}
          </label>
        </div>
      </div>
    </div>
    <!-- 扩展其他的schema组件 -->
  </div>
</template>

<script lang="ts">
import { PropType, ref, watch } from "vue";
import { cloneDeep } from "lodash-es";

export interface FormItem {
  field: string;
  type: "input" | "checkbox" | "radio";
  prop: string;
  attr?: {
    placeholder: string;
  };
  value?: string | number;
  children?: Array<any>;
}

export default {
  props: {
    schema: {
      type: Array as PropType<FormItem[]>,
      default: () => [],
    },
  },
  emits: ["change"],
  expose: ["clearForm"],
  setup(_props, { emit }) {
    const rand = () => {
      return Math.random().toString(36).substring(2);
    };

    const model = ref<any>({
      ...cloneDeep(
        _props.schema.reduce((prev, cur) => {
          prev[cur.prop] = cur.value;
          if (["radio", "checkbox"].includes(cur.type)) {
            cur.children?.forEach((o) => {
              o.id = rand();
            });
          }
          return prev;
        }, {} as Record<string, any>)
      ),
    });
    // const model = ref({
    //  [prop]: item.value
    // })

    watch(
      () => model.value,
      () => {
        emit("change", model.value);
      },
      {
        deep: true,
      }
    );

    const clearForm = () => {
      model.value = cloneDeep(
        _props.schema.reduce((prev, cur) => {
          prev[cur.prop] = cur.value;
          return prev;
        }, {} as Record<string, any>)
      );
    };

    return {
      model,
      clearForm,
    };
  },
};
</script>

<style lang="scss" scoped>
</style>

src\components\modal\EditAddModal.vue

javascript 复制代码
<template>
  <!-- Edit/Add Model -->
  <div class="modal fade" ref="editRef" tabindex="-1" aria-labelledby="EditAndAddModalLabel" aria-hidden="true">
    <div class="modal-dialog">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title" id="EditAndAddModalLabel">{{msg}}</h5>
          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
        </div>
        <div class="modal-body">
          <Form :schema="schema" @change="change" ref="formRef"></Form>
          <div class="modal-footer">
            <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @click="cancel">取消</button>
            <button type="button" class="btn btn-primary" @click="submit()">确定</button>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, PropType, ref, toRefs } from "vue";

import { useModal } from "@/hooks";

import Form, { FormItem } from "../Form.vue";

export default defineComponent({
  props: {
    show: {
      type: Boolean,
      default: false,
    },
    msg: {
      type: String,
      default: "新增",
    },
    schema: {
      type: Array as PropType<FormItem[]>,
      default: () => [],
    },
  },
  components: {
    Form,
  },
  emits: ["submit"],
  setup(_props, { emit }) {
    const { show } = toRefs(_props);
    const formRef = ref();

    const { modalRef: editRef, hide } = useModal(show, "show");

    const formData = ref();

    // 取消
    const cancel = () => {
      hide();
    };

    // 确定
    const submit = () => {
      emit("submit", formData.value);
      hide();
      formData.value = {};
      formRef.value?.clearForm();
    };

    return {
      editRef,
      cancel,
      submit,
      formRef,
      change: (val: any) => {
        formData.value = val;
      },
    };
  },
});
</script>

<style scoped>
</style>

src\views\roles\index.vue

javascript 复制代码
<template>
  <div>
    <EditAddModal v-model:show="editShow" :schema="formSchema" :msg="msg" @submit="editSubmit"></EditAddModal>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from "vue";

import EditAddModal from "@/components/modal/EditAddModal.vue";

import { FormItem } from "@/components/Form.vue";

export default defineComponent({
  components: {
    DeleteModal,
    EditAddModal,
  },
  setup() {
    const deleteShow = ref(false);
    const editShow = ref(false);

    // 模态框的控制handler
    const msg = ref("新增");

    let localType = "";

    const formSchema = [
      {
        field: "角色名",
        type: "input",
        prop: "username",
        value: "",
        attr: {
          placeholder: "请输入角色名称",
        },
      },
      {
        field: "菜单1",
        type: "checkbox",
        prop: "roles",
        value: [],
        children: [
          {
            value: 1,
            field: "CREATE",
          },
          {
            value: 2,
            field: "UPDATE",
          },
          {
            value: 3,
            field: "DELETE",
          },
          {
            value: 4,
            field: "READ",
          },
          {
            value: 5,
            field: "MANAGE",
          },
        ],
      },
      {
        field: "菜单2",
        type: "checkbox",
        prop: "roles1",
        value: [],
        children: [
          {
            value: 1,
            field: "CREATE",
          },
          {
            value: 2,
            field: "UPDATE",
          },
          {
            value: 3,
            field: "DELETE",
          },
          {
            value: 4,
            field: "READ",
          },
          {
            value: 5,
            field: "MANAGE",
          },
        ],
      },
    ] as FormItem[];

    const deleteSubmit = () => {
      console.log("delete");
    };

    // 控制模态框
    const openModal = (type: string) => {
      localType = type;
      // console.log(item);
      if (type === "delete") {
        deleteShow.value = true;
      } else if (type === "edit") {
        msg.value = "编辑";
        editShow.value = true;
      } else if (type === "add") {
        msg.value = "新增";
        editShow.value = true;
      }
    };

    const editSubmit = async (val: any) => {
      console.log("🚀 ~ file: index.vue ~ line 221 ~ editSubmit ~ val", val);
    };

    return {
      deleteShow,
      deleteHandler: () => {
        deleteShow.value = true;
      },
      deleteSubmit,
      formSchema,
      editShow,
      msg,
      editSubmit,
      openModal,
    };
  },
});
</script>

<style scoped>
</style>

前端内容页:菜单&角色新增与编辑模态框

src\views\menus\index.vue

html 复制代码
<template>
  <div>
    <div>
      <div class="mb-3">
        <button type="button" class="btn btn-primary px-3" @click="openModal('add')">
          <i class="fas fa-plus"></i>新增
        </button>
      </div>
      <table class="table table-bordered table-hover table-striped">
        <thead>
          <tr>
            <th scope="col">#</th>
            <th scope="col">菜单名称</th>
            <th scope="col">菜单路径</th>
            <th scope="col">排序</th>
            <th scope="col">操作策略</th>
            <th scope="col">操作</th>
          </tr>
        </thead>
        <tbody>
          <tr>
            <td>1</td>
            <td>首页</td>
            <td>/dashboard</td>
            <td>1</td>
            <td>READ,UPDATE</td>
            <td>
              <button type="button" class="btn btn-secondary px-3" @click="openModal('edit')">
                <i class="far fa-edit me-2"></i>编辑</button>
              <button type="button" class="btn btn-danger px-3 ms-3" @click="openModal('delete')">
                <i class="far fa-trash-alt me-2"></i>删除
              </button>
            </td>
          </tr>
        </tbody>
      </table>
      <nav aria-label="Page navigation example">
        <ul class="pagination">
          <li class="page-item"><a class="page-link" href="#">Previous</a></li>
          <li class="page-item"><a class="page-link" href="#">1</a></li>
          <li class="page-item"><a class="page-link" href="#">2</a></li>
          <li class="page-item"><a class="page-link" href="#">3</a></li>
          <li class="page-item"><a class="page-link" href="#">Next</a></li>
        </ul>
      </nav>
    </div>
    <DeleteModal v-model:show="deleteShow" @delete="deleteSubmit"></DeleteModal>
    <EditAddModal v-model:show="editShow" :schema="formSchema" :msg="msg" @submit="editSubmit"></EditAddModal>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from "vue";

import DeleteModal from "@/components/modal/DeleteModal.vue";
import EditAddModal from "@/components/modal/EditAddModal.vue";
import { FormItem } from "@/components/Form.vue";

export default defineComponent({
  components: {
    DeleteModal,
    EditAddModal,
  },
  setup() {
    const deleteShow = ref(false);
    const editShow = ref(false);

    // 模态框的控制handler
    const msg = ref("新增");

    let localType = "";

    const formSchema = [
      {
        field: "菜单名",
        type: "input",
        prop: "name",
        value: "",
        attr: {
          placeholder: "请输入菜单名称",
        },
      },
      {
        field: "菜单路径",
        type: "input",
        prop: "path",
        value: "",
        attr: {
          placeholder: "请输入菜单路径",
        },
      },
      {
        field: "菜单排序",
        type: "input",
        prop: "order",
        value: "",
        attr: {
          placeholder: "请输入菜单排序",
        },
      },
      {
        field: "允许的操作策略",
        type: "checkbox",
        prop: "roles",
        value: [],
        children: [
          {
            value: 1,
            field: "CREATE",
          },
          {
            value: 2,
            field: "UPDATE",
          },
          {
            value: 3,
            field: "DELETE",
          },
          {
            value: 4,
            field: "READ",
          },
          {
            value: 5,
            field: "MANAGE",
          },
        ],
      },
    ] as FormItem[];

    const deleteSubmit = () => {
      console.log("delete");
    };

    // 控制模态框
    const openModal = (type: string) => {
      localType = type;
      // console.log(item);
      if (type === "delete") {
        deleteShow.value = true;
      } else if (type === "edit") {
        msg.value = "编辑";
        editShow.value = true;
      } else if (type === "add") {
        msg.value = "新增";
        editShow.value = true;
      }
    };

    const editSubmit = async (val: any) => {
      console.log("🚀 ~ file: index.vue ~ line 221 ~ editSubmit ~ val", val);
    };

    return {
      deleteShow,
      deleteSubmit,
      formSchema,
      editShow,
      msg,
      editSubmit,
      openModal,
    };
  },
});
</script>

<style scoped>
</style>

src\views\roles\index.vue

html 复制代码
<template>
  <div>
    <div class="mb-3">
      <button type="button" class="btn btn-primary px-3" @click="openModal('add')">
        <i class="fas fa-plus"></i>新增
      </button>
    </div>
    <table class="table table-bordered table-hover table-striped">
      <thead>
        <tr>
          <th scope="col">#</th>
          <th scope="col">名称</th>
          <th scope="col">分配权限</th>
          <th scope="col">操作</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>1</td>
          <td>管理员</td>
          <td></td>
          <td>
            <button type="button" class="btn btn-secondary px-3" @click="openModal('edit')">
              <i class="far fa-edit me-2"></i>编辑</button>
            <button type="button" class="btn btn-danger px-3 ms-3" @click="openModal('delete')">
              <i class="far fa-trash-alt me-2"></i>删除
            </button>
          </td>
        </tr>
      </tbody>
    </table>
    <nav aria-label="Page navigation example">
      <ul class="pagination">
        <li class="page-item"><a class="page-link" href="#">Previous</a></li>
        <li class="page-item"><a class="page-link" href="#">1</a></li>
        <li class="page-item"><a class="page-link" href="#">2</a></li>
        <li class="page-item"><a class="page-link" href="#">3</a></li>
        <li class="page-item"><a class="page-link" href="#">Next</a></li>
      </ul>
    </nav>
    <DeleteModal v-model:show="deleteShow" @delete="deleteSubmit"></DeleteModal>
    <EditAddModal v-model:show="editShow" :schema="formSchema" :msg="msg" @submit="editSubmit"></EditAddModal>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from "vue";

import DeleteModal from "@/components/modal/DeleteModal.vue";
import EditAddModal from "@/components/modal/EditAddModal.vue";

import { FormItem } from "@/components/Form.vue";

export default defineComponent({
  components: {
    DeleteModal,
    EditAddModal,
  },
  setup() {
    const deleteShow = ref(false);
    const editShow = ref(false);

    // 模态框的控制handler
    const msg = ref("新增");

    let localType = "";

    const formSchema = [
      {
        field: "角色名",
        type: "input",
        prop: "username",
        value: "",
        attr: {
          placeholder: "请输入角色名称",
        },
      },
      {
        field: "菜单1",
        type: "checkbox",
        prop: "roles",
        value: [],
        children: [
          {
            value: 1,
            field: "CREATE",
          },
          {
            value: 2,
            field: "UPDATE",
          },
          {
            value: 3,
            field: "DELETE",
          },
          {
            value: 4,
            field: "READ",
          },
          {
            value: 5,
            field: "MANAGE",
          },
        ],
      },
      {
        field: "菜单2",
        type: "checkbox",
        prop: "roles1",
        value: [],
        children: [
          {
            value: 1,
            field: "CREATE",
          },
          {
            value: 2,
            field: "UPDATE",
          },
          {
            value: 3,
            field: "DELETE",
          },
          {
            value: 4,
            field: "READ",
          },
          {
            value: 5,
            field: "MANAGE",
          },
        ],
      },
    ] as FormItem[];

    const deleteSubmit = () => {
      console.log("delete");
    };

    // 控制模态框
    const openModal = (type: string) => {
      localType = type;
      // console.log(item);
      if (type === "delete") {
        deleteShow.value = true;
      } else if (type === "edit") {
        msg.value = "编辑";
        editShow.value = true;
      } else if (type === "add") {
        msg.value = "新增";
        editShow.value = true;
      }
    };

    const editSubmit = async (val: any) => {
      console.log("🚀 ~ file: index.vue ~ line 221 ~ editSubmit ~ val", val);
    };

    return {
      deleteShow,
      deleteHandler: () => {
        deleteShow.value = true;
      },
      deleteSubmit,
      formSchema,
      editShow,
      msg,
      editSubmit,
      openModal,
    };
  },
});
</script>

<style scoped>
</style>

创建角色:CURD控制器及服务对接数据库

src\roles\dto\create-role.dto.ts

创建角色信息的字段定义

javascript 复制代码
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';

// 角色创建DTO
export class CreateRoleDto {
  @IsString()
  @IsNotEmpty({ message: '角色名称不能为空' })
  @MaxLength(50, { message: '角色名称不能超过50个字符' })
  name: string;

  @IsString()
  @IsNotEmpty({ message: '角色描述不能为空' })
  @MaxLength(200, { message: '角色描述不能超过200个字符' })
  description: string;
}

src\roles\roles.entity.ts

创建角色的实体类。

javascript 复制代码
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany } from 'typeorm';
import { User } from '../user/user.entity';
import { Menu } from '../menu/menu.entity';

// 角色实体类
@Entity()
export class Roles {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column()
  description: string;

  // 与用户的多对多关系
  @ManyToMany(() => User, (user) => user.roles)
  users: User[];

  // 与菜单的多对多关系
  @ManyToMany(() => Menu, (menu) => menu.roles)
  menus: Menu[];
}

src\roles\dto\update-role.dto.ts

更新角色信息的字段定义

javascript 复制代码
import { IsOptional, IsString, MaxLength } from 'class-validator';

// 角色更新DTO
export class UpdateRoleDto {
  @IsOptional()
  @IsString()
  @MaxLength(50, { message: '角色名称不能超过50个字符' })
  name?: string;

  @IsOptional()
  @IsString()
  @MaxLength(200, { message: '角色描述不能超过200个字符' })
  description?: string;
}

src\roles\roles.controller.ts

角色控制器

javascript 复制代码
import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  Post,
  Put,
} from '@nestjs/common';
import { RolesService } from './roles.service';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';

// 角色控制器
@Controller('roles')
export class RolesController {
  constructor(private readonly rolesService: RolesService) {}

  // 查询所有角色
  @Get()
  findAll() {
    return this.rolesService.findAll();
  }

  // 根据ID查询角色
  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.rolesService.findOne(+id);
  }

  // 创建角色
  @Post()
  create(@Body() createRoleDto: CreateRoleDto) {
    return this.rolesService.create(createRoleDto);
  }

  // 更新角色
  @Put(':id')
  update(@Param('id') id: string, @Body() updateRoleDto: UpdateRoleDto) {
    return this.rolesService.update(+id, updateRoleDto);
  }

  // 删除角色
  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.rolesService.remove(+id);
  }
}

src\roles\roles.service.ts

角色服务。

javascript 复制代码
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Roles } from './roles.entity';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';

// 角色服务
@Injectable()
export class RolesService {
  constructor(
    @InjectRepository(Roles)
    private readonly rolesRepository: Repository<Roles>,
  ) {}

  // 查询所有角色
  findAll() {
    return this.rolesRepository.find({
      relations: {
        users: true,
      },
    });
  }

  // 根据ID查询角色
  findOne(id: number) {
    return this.rolesRepository.findOne({
      where: { id },
      relations: {
        users: true,
      },
    });
  }

  // 根据名称查询角色
  findByName(name: string) {
    return this.rolesRepository.findOne({
      where: { name },
    });
  }

  // 创建角色
  create(role: CreateRoleDto) {
    const roleTmp = this.rolesRepository.create(role);
    return this.rolesRepository.save(roleTmp);
  }

  // 更新角色
  update(id: number, role: UpdateRoleDto) {
    return this.rolesRepository.update(id, role);
  }

  // 删除角色
  remove(id: number) {
    return this.rolesRepository.delete(id);
  }
}
菜单模块的 CURD

src\menu\dto\create-menu.dto.ts

TypeScript 复制代码
import { IsString, IsNotEmpty, IsOptional, IsBoolean, IsNumber, MaxLength } from 'class-validator';

// 菜单创建DTO
export class CreateMenuDto {
  @IsString()
  @IsNotEmpty({ message: '菜单名称不能为空' })
  @MaxLength(50, { message: '菜单名称不能超过50个字符' })
  name: string;

  @IsString()
  @IsNotEmpty({ message: '菜单路径不能为空' })
  @MaxLength(200, { message: '菜单路径不能超过200个字符' })
  path: string;

  @IsString()
  @IsOptional()
  @MaxLength(50, { message: '菜单图标不能超过50个字符' })
  icon?: string;

  @IsNumber()
  @IsOptional()
  parentId?: number;

  @IsNumber()
  @IsOptional()
  sortOrder?: number;

  @IsBoolean()
  @IsOptional()
  status?: boolean;
}

src\menu\dto\update-menu.dto.ts

TypeScript 复制代码
import { IsString, IsOptional, IsBoolean, IsNumber, MaxLength } from 'class-validator';

// 菜单更新DTO
export class UpdateMenuDto {
  @IsString()
  @IsOptional()
  @MaxLength(50, { message: '菜单名称不能超过50个字符' })
  name?: string;

  @IsString()
  @IsOptional()
  @MaxLength(200, { message: '菜单路径不能超过200个字符' })
  path?: string;

  @IsString()
  @IsOptional()
  @MaxLength(50, { message: '菜单图标不能超过50个字符' })
  icon?: string;

  @IsNumber()
  @IsOptional()
  parentId?: number;

  @IsNumber()
  @IsOptional()
  sortOrder?: number;

  @IsBoolean()
  @IsOptional()
  status?: boolean;
}

src\menu\menu.entity.ts

TypeScript 复制代码
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany, JoinTable } from 'typeorm';
import { Roles } from '../roles/roles.entity';

// 菜单实体类
@Entity()
export class Menu {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column()
  path: string;

  @Column({ nullable: true })
  icon?: string;

  @Column({ nullable: true })
  parentId?: number;

  @Column({ default: 1 })
  sortOrder: number;

  @Column({ default: true })
  status: boolean;

  // 与角色的多对多关系
  @ManyToMany(() => Roles, (role) => role.menus)
  @JoinTable({ name: 'roles_menus' })
  roles: Roles[];
}

src\menu\menu.controller.ts

TypeScript 复制代码
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { MenuService } from './menu.service';
import { CreateMenuDto } from './dto/create-menu.dto';
import { UpdateMenuDto } from './dto/update-menu.dto';

// 菜单控制器
@Controller('menu')
export class MenuController {
  constructor(private readonly menuService: MenuService) {}

  // 查询所有菜单
  @Get()
  findAll() {
    return this.menuService.findAll();
  }

  // 查询菜单树
  @Get('tree')
  findTree() {
    return this.menuService.buildMenuTree(null);
  }

  // 查询根菜单
  @Get('root')
  findRootMenus() {
    return this.menuService.findRootMenus();
  }

  // 根据ID查询菜单
  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.menuService.findOne(+id);
  }

  // 根据父级ID查询子菜单
  @Get('parent/:parentId')
  findByParentId(@Param('parentId') parentId: string) {
    return this.menuService.findByParentId(+parentId);
  }

  // 根据角色ID查询菜单
  @Get('roles/:roleIds')
  findMenusByRoles(@Param('roleIds') roleIds: string) {
    const ids = roleIds.split(',').map(id => +id);
    return this.menuService.findMenusByRoles(ids);
  }

  // 创建菜单
  @Post()
  create(@Body() createMenuDto: CreateMenuDto) {
    return this.menuService.create(createMenuDto);
  }

  // 更新菜单
  @Put(':id')
  update(@Param('id') id: string, @Body() updateMenuDto: UpdateMenuDto) {
    return this.menuService.update(+id, updateMenuDto);
  }

  // 删除菜单
  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.menuService.remove(+id);
  }
}

src\menu\menu.service.ts

TypeScript 复制代码
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Menu } from './menu.entity';
import { UpdateMenuDto } from './dto/update-menu.dto';

// 菜单服务
@Injectable()
export class MenuService {
  constructor(
    @InjectRepository(Menu)
    private readonly menuRepository: Repository<Menu>,
  ) {}

  // 查询所有菜单
  findAll() {
    return this.menuRepository.find({
      relations: {
        roles: true,
      },
      order: {
        sortOrder: 'ASC',
        id: 'ASC',
      },
    });
  }

  // 根据ID查询菜单
  findOne(id: number) {
    return this.menuRepository.findOne({
      where: { id },
      relations: {
        roles: true,
      },
    });
  }

  // 根据路径查询菜单
  findByPath(path: string) {
    return this.menuRepository.findOne({
      where: { path },
      relations: {
        roles: true,
      },
    });
  }

  // 查询父菜单下的子菜单
  findByParentId(parentId: number) {
    return this.menuRepository.find({
      where: { parentId },
      relations: {
        roles: true,
      },
      order: {
        sortOrder: 'ASC',
        id: 'ASC',
      },
    });
  }

  // 查询根菜单(parentId 为 null 的菜单)
  findRootMenus() {
    return this.menuRepository
      .createQueryBuilder('menu')
      .leftJoinAndSelect('menu.roles', 'roles')
      .where('menu.parentId IS NULL')
      .orderBy('menu.sortOrder', 'ASC')
      .addOrderBy('menu.id', 'ASC')
      .getMany();
  }

  // 创建菜单
  async create(menu: Partial<Menu>) {
    const menuTmp = await this.menuRepository.create(menu);
    return this.menuRepository.save(menuTmp);
  }

  // 更新菜单
  async update(id: number, updateMenuDto: UpdateMenuDto) {
    const menu = await this.findOne(id);
    const newMenu = this.menuRepository.merge(menu, updateMenuDto);
    return this.menuRepository.save(newMenu);
  }

  // 删除菜单
  remove(id: number) {
    return this.menuRepository.delete(id);
  }

  // 查询指定角色的菜单
  findMenusByRoles(roleIds: number[]) {
    return this.menuRepository
      .createQueryBuilder('menu')
      .leftJoinAndSelect('menu.roles', 'roles')
      .where('roles.id IN (:...roleIds)', { roleIds })
      .orderBy('menu.sortOrder', 'ASC')
      .addOrderBy('menu.id', 'ASC')
      .getMany();
  }
}
角色模块的 CURD

菜单与角色关系:多对多
src\roles\dto\create-role.dto.ts

TypeScript 复制代码
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';

// 角色创建DTO
export class CreateRoleDto {
  @IsString()
  @IsNotEmpty({ message: '角色名称不能为空' })
  @MaxLength(50, { message: '角色名称不能超过50个字符' })
  name: string;

  @IsString()
  @IsNotEmpty({ message: '角色描述不能为空' })
  @MaxLength(200, { message: '角色描述不能超过200个字符' })
  description: string;
}

src\roles\dto\update-role.dto.ts

TypeScript 复制代码
import { IsOptional, IsString, MaxLength } from 'class-validator';

// 角色更新DTO
export class UpdateRoleDto {
  @IsOptional()
  @IsString()
  @MaxLength(50, { message: '角色名称不能超过50个字符' })
  name?: string;

  @IsOptional()
  @IsString()
  @MaxLength(200, { message: '角色描述不能超过200个字符' })
  description?: string;
}

src\roles\roles.entity.ts

TypeScript 复制代码
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany } from 'typeorm';
import { User } from '../user/user.entity';
import { Menu } from '../menu/menu.entity';

// 角色实体类
@Entity()
export class Roles {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column()
  description: string;

  // 与用户的多对多关系
  @ManyToMany(() => User, (user) => user.roles)
  users: User[];

  // 与菜单的多对多关系
  @ManyToMany(() => Menu, (menu) => menu.roles)
  menus: Menu[];
}

src\roles\roles.controller.ts

TypeScript 复制代码
import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
import { RolesService } from './roles.service';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';

// 角色控制器
@Controller('roles')
export class RolesController {
  constructor(private readonly rolesService: RolesService) {}

  // 查询所有角色
  @Get()
  findAll() {
    return this.rolesService.findAll();
  }

  // 根据ID查询角色
  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.rolesService.findOne(+id);
  }

  // 创建角色
  @Post()
  create(@Body() createRoleDto: CreateRoleDto) {
    return this.rolesService.create(createRoleDto);
  }

  // 更新角色
  @Put(':id')
  update(@Param('id') id: string, @Body() updateRoleDto: UpdateRoleDto) {
    return this.rolesService.update(+id, updateRoleDto);
  }

  // 删除角色
  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.rolesService.remove(+id);
  }
}

src\roles\roles.service.ts

TypeScript 复制代码
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Roles } from './roles.entity';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';

// 角色服务
@Injectable()
export class RolesService {
  constructor(
    @InjectRepository(Roles)
    private readonly rolesRepository: Repository<Roles>,
  ) {}

  // 查询所有角色
  findAll() {
    return this.rolesRepository.find({
      relations: {
        users: true,
      },
    });
  }

  // 根据ID查询角色
  findOne(id: number) {
    return this.rolesRepository.findOne({
      where: { id },
      relations: {
        users: true,
      },
    });
  }

  // 根据名称查询角色
  findByName(name: string) {
    return this.rolesRepository.findOne({
      where: { name },
    });
  }

  // 创建角色
  create(role: CreateRoleDto) {
    const roleTmp = this.rolesRepository.create(role);
    return this.rolesRepository.save(roleTmp);
  }

  // 更新角色
  update(id: number, role: UpdateRoleDto) {
    return this.rolesRepository.update(id, role);
  }

  // 删除角色
  remove(id: number) {
    return this.rolesRepository.delete(id);
  }
}

src\roles\roles.module.ts

TypeScript 复制代码
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RolesController } from './roles.controller';
import { RolesService } from './roles.service';
import { Roles } from './roles.entity';
import { Menu } from '../menu/menu.entity';

@Module({
  imports: [TypeOrmModule.forFeature([Roles, Menu])],
  controllers: [RolesController],
  providers: [RolesService],
  exports: [RolesService],
})
export class RolesModule {}

创建菜单及数据对接:数据库migrations相关

migration 指令
TypeScript 复制代码
    "migration:generate": "f() { npm run typeorm migration:generate -p \"./src/migrations/$@\"; }; f",
    "migration:create": "typeorm-ts-node-commonjs migration:create",
    "migration:run": "npm run typeorm migration:run",
    "migration:revert": "npm run typeorm migration:revert",
    "schema:drop": "npm run typeorm schema:drop"
migration:generate

生成迁移文件

TypeScript 复制代码
npm run migration:generate <迁移文件名>

作用:

  • 根据实体(Entity)的变化自动生成迁移文件
  • 对比当前实体定义和数据库结构,生成差异的 SQL 语句
  • 不会执行迁移,只生成迁移文件

使用场景:

  • 新增、修改或删除实体后
  • 实体字段类型变更
  • 修改索引、关系等

示例:

TypeScript 复制代码
npm run migration:generate AddRolesTable
npm run migration:generate UpdateUserPassword

生成的文件位置:

  • src/migrations/ 目录下
  • 文件名格式:时间戳-迁移名.ts
migration:create

创建空白迁移文件

TypeScript 复制代码
npm run migration:create <迁移文件名>

作用:

  • 创建一个空的迁移文件模板
  • 不会自动检测实体变化
  • 适合编写自定义 SQL 的场景

使用场景:

  • 需要执行原始 SQL 语句
  • 复杂的数据迁移逻辑
  • 修改现有数据
  • 创建存储过程、触发器等

示例:

TypeScript 复制代码
npm run migration:create SeedAdminUser
npm run migration:create UpdateUserStatus
migration:run

读取迁移文件,执行 SQL 到数据库

migration:revert

回滚最近一次迁移

执行 `migration:generate` 指令。

TypeScript 复制代码
pnpm run migration:generate menus

会在src/migrations 目录生成对应的迁移文件

执行 `pnpm run migration:run` 读取迁移文件,执行 SQL 到数据库

TypeScript 复制代码
pnpm run migration:run

权限控制:RABC权限关联路由与控制器

src\enum\roles.enum.ts

TypeScript 复制代码
/* eslint-disable prettier/prettier */
export enum Role {
  User = 2,
  Admin = 1,
}

src\guards\role.guard.ts

TypeScript 复制代码
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { UserService } from '../user/user.service';
import { Reflector } from '@nestjs/core';
import { Role } from '../enum/roles.enum';
import { ROLES_KEY } from '../decorators/roles.decorator';

@Injectable()
export class RoleGuard implements CanActivate {
  constructor(private reflector: Reflector, private userService: UserService) {}
  async canActivate(context: ExecutionContext): Promise<boolean> {
    // jwt -> userId -> user -> roles
    // getAllAndOverride -> 获取所有装饰器的值,包括继承的值
    const requireRoles = this.reflector.getAllAndMerge<Role[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (!requireRoles) {
      return true;
    }
    const req = context.switchToHttp().getRequest();
    const user = await this.userService.findOne(req.user.username);
    const roleIds = user.roles.map(o => o.id);
    const flag = requireRoles.some((role) => roleIds.includes(role));
    return flag;
  }
}

src\roles\roles.controller.ts

TypeScript 复制代码
import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  Post,
  Put,
  UseGuards,
} from '@nestjs/common';
import { RolesService } from './roles.service';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
import { Roles } from '../decorators/roles.decorator';
import { Role } from '../enum/roles.enum';
import { JwtGuard } from '../guards/jwt.guard';
import { RoleGuard } from '../guards/role.guard';

// 角色控制器
@Controller('roles')
@Roles(Role.Admin)
@UseGuards(RoleGuard)
export class RolesController {
  constructor(private readonly rolesService: RolesService) {}

  // 查询所有角色
  @Get()
  findAll() {
    return this.rolesService.findAll();
  }

  // 根据ID查询角色
  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.rolesService.findOne(+id);
  }

  // 创建角色
  @Post()
  create(@Body() createRoleDto: CreateRoleDto) {
    return this.rolesService.create(createRoleDto);
  }

  // 更新角色
  @Put(':id')
  update(@Param('id') id: string, @Body() updateRoleDto: UpdateRoleDto) {
    return this.rolesService.update(+id, updateRoleDto);
  }

  // 删除角色
  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.rolesService.remove(+id);
  }
}

基于策略的控制:流行的casl策略库介绍

CASL(Climate Access Control List)是一个流行的同构授权库,专为JavaScript/TypeScript应用设计,在NestJS生态中被广泛用于实现基于策略的访问控制。它支持从简单的基于声明的授权到复杂的基于主题和属性的授权,具有高度灵活性和可扩展性

CASL核心概念
能力(Ability)

表示用户对资源可以执行的操作集合,通过cancannot方法定义权限规则

主题(Subject)

被访问的资源类型,如'Post'、'User'等

动作(Action)

用户对资源执行的操作,包括createreadupdatedelete以及特殊关键字manage(表示任何操作)

NestJS集成CASL
安装配置
TypeScript 复制代码
npm install @casl/ability
# 或使用专门为NestJS封装的库
npm install nest-casl

对于TypeScript项目,还需要安装类型定义:

TypeScript 复制代码
npm install --save-dev @types/casl__ability
基础集成模式
创建Ability工厂
TypeScript 复制代码
import { Ability, AbilityBuilder, AbilityClass, InferSubjects } from '@casl/ability';
import { Injectable } from '@nestjs/common';
import { Article, User } from './entities';

export enum Action {
  Manage = 'manage',
  Create = 'create',
  Read = 'read',
  Update = 'update',
  Delete = 'delete',
}

type Subjects = InferSubjects<typeof Article | typeof User> | 'all';
export type AppAbility = Ability<[Action, Subjects]>;

@Injectable()
export class CaslAbilityFactory {
  createForUser(user: User) {
    const { can, cannot, build } = new AbilityBuilder<AppAbility>(Ability as AbilityClass);
    
    if (user.isAdmin) {
      can(Action.Manage, 'all'); // 管理员可以做任何操作
    } else {
      can(Action.Read, 'all'); // 普通用户只有读取权限
    }
    
    // 用户可以更新自己的文章
    can(Action.Update, Article, { authorId: user.id });
    // 已发布的文章不能删除
    cannot(Action.Delete, Article, { isPublished: true });
    
    return build({
      detectSubjectType: (item) => item.constructor as ExtractSubjectType<Subjects>,
    });
  }
}
创建策略守卫
TypeScript 复制代码
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CaslAbilityFactory } from './casl-ability.factory';

@Injectable()
export class PoliciesGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private caslAbilityFactory: CaslAbilityFactory,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const policyHandlers = this.reflector.get(
      'check_policy',
      context.getHandler(),
    ) || [];

    const { user } = context.switchToHttp().getRequest();
    const ability = this.caslAbilityFactory.createForUser(user);

    return policyHandlers.every((handler) => handler.handle(ability));
  }
}
使用装饰器保护路由
TypeScript 复制代码
import { Controller, Get, UseGuards } from '@nestjs/common';
import { CheckPolicies } from './check-policies.decorator';
import { PoliciesGuard } from './policies.guard';
import { ReadArticlePolicyHandler } from './policies/read-article.policy';

@Controller('articles')
export class ArticlesController {
  @Get()
  @UseGuards(PoliciesGuard)
  @CheckPolicies(new ReadArticlePolicyHandler())
  findAll() {
    return this.articlesService.findAll();
  }
}
CASL核心特性
细粒度字段控制

可以限制用户只能访问或修改资源的特定字段:

TypeScript 复制代码
can('update', 'Article', ['title', 'content'], { authorId: user.id });
条件判断

支持基于资源属性的动态权限判断

TypeScript 复制代码
can('read', 'Article', { published: true }); // 只能读取已发布的文章
cannot('delete', 'Article', { isPublished: true }); // 不能删除已发布的文章
角色继承机制

CASL支持复杂的权限继承关系,可以通过组合策略实现多级权限控制

类与实例检查

既可以对资源类进行权限检查,也可以对具体实例进行检查:

TypeScript 复制代码
// 类检查
ability.can('create', Article); // true/false

// 实例检查
const article = new Article({ authorId: user.id });
ability.can('update', article); // true/false

权限控制:与casl集成完成自定义装饰器与守卫

src\decorators\casl.decorators.ts

TypeScript 复制代码
/* eslint-disable prettier/prettier */
import { AnyMongoAbility, InferSubjects} from "@casl/ability";
import { SetMetadata } from "@nestjs/common";
import {Action} from '../enum/action.enum'

export enum CHECK_POLICIES_KEY {
    HANDLER = 'CHECK_POLICIES_HANDLER',
    CAN = 'CHECK_POLICIES_CAN',
    CANNOT = 'CHECK_POLICIES_CANNOT'
}

// 定义一个类型,表示策略处理程序的回调函数
export type PolicyHandlerCallback = (ability: AnyMongoAbility) => boolean;

export type CaslHandlerType = PolicyHandlerCallback | PolicyHandlerCallback[];


// 定义一个装饰器,用于设置策略处理程序的元数据
export const CheckPolicies = (...handlers: PolicyHandlerCallback[]) => 
    SetMetadata(CHECK_POLICIES_KEY.HANDLER, handlers);

// 定义两个装饰器,分别用于设置允许和禁止的权限规则
export const Can = (action: Action, subject: InferSubjects<any>, conditions?: any) =>
    SetMetadata(CHECK_POLICIES_KEY.CAN, (ability: AnyMongoAbility) => ability.can(action, subject, conditions));

export const Cannot = (action: Action, subject: InferSubjects<any>, conditions?: any) =>
    SetMetadata(CHECK_POLICIES_KEY.CANNOT, (ability: AnyMongoAbility) => ability.cannot(action, subject, conditions));

src\guards\casl\casl.guard.ts

CASL权限守卫,用于检查用户是否具有执行操作的权限。

TypeScript 复制代码
/* eslint-disable prettier/prettier */
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CaslAbilityService } from '../../auth/casl-ability.service';
import { CaslHandlerType, CHECK_POLICIES_KEY } from '../../decorators/casl.decorators';
/**
 * CASL权限守卫,用于检查用户是否具有执行操作的权限
 */
@Injectable()
export class CaslGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly abilityService: CaslAbilityService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    // 获取当前用户的权限
    const handlers = this.reflector.getAllAndOverride<any[]>(
      CHECK_POLICIES_KEY.HANDLER,
      [context.getHandler(), context.getClass()],
    );

    // 获取允许的策略处理函数
    const canHandlers = this.reflector.getAllAndOverride<any[]>(
      CHECK_POLICIES_KEY.CAN,
      [context.getHandler(), context.getClass()],
    ) as CaslHandlerType;

    // 获取不允许的策略处理函数
    const cannotHandlers = this.reflector.getAllAndOverride<any[]>(
      CHECK_POLICIES_KEY.CANNOT,
      [context.getHandler(), context.getClass()],
    ) as CaslHandlerType;

    // 如果没有任何策略处理函数,默认允许访问
    if (!handlers || !canHandlers || !cannotHandlers) {
      return true;
    }

    // 获取用户权限
    const req = context.switchToHttp().getRequest();
    const ability = await this.abilityService.forRoot(req.user.username);
    let flag = true;
    if (handlers) {
      // 如果有策略处理函数,则需要满足所有策略处理函数的结果为true
      flag = flag && handlers.every((handler) => handler(ability));
    }

    if (flag && canHandlers) {
      // 如果有允许的策略处理函数,则需要满足所有允许的策略处理函数的结果为true
      // canHandlers 可能是一个函数,也可能是一个函数数组,因此需要进行类型判断
      if (typeof canHandlers === 'function') {
        flag = flag && canHandlers(ability);
      } else if (Array.isArray(canHandlers)) {
        flag = flag && canHandlers.every((handler) => handler(ability));
      }
    }

    if (flag && cannotHandlers) {
      // 如果有不允许的策略处理函数,则需要满足所有不允许的策略处理函数的结果为false
      if (typeof cannotHandlers === 'function') {
        flag = flag && cannotHandlers(ability);
      } else if (Array.isArray(cannotHandlers)) {
        flag = flag && cannotHandlers.every((handler) => handler(ability));
      }
    }

    return flag;
  }
}

src\logs\logs.controller.ts

在需要使用权限守卫的控制器进行权限配置

TypeScript 复制代码
@Controller('logs')
@UseGuards(CaslGuard)
@CheckPolicies((ability) => ability.can(Action.Read, Logs))
export class LogsController {
    @Get()
    @Can(Action.Read, Logs)
    getLogs() {
        return 'get logs';
    }

权限控制:与casl集成并完成策略权限控制

src\auth\casl-ability.service.ts

TypeScript 复制代码
import { Injectable } from '@nestjs/common';
import { AbilityBuilder, createMongoAbility } from '@casl/ability';
import { UserService } from '../user/user.service';
import { getEntities } from '../utils/common';
import { Menu } from '../menu/menu.entity';

@Injectable()
export class CaslAbilityService {
  constructor(private userService: UserService) {}

  async forRoot(username: string) {
    // 针对于整个系统的 -> createUser XX SYStem
    const { can, build } = new AbilityBuilder(createMongoAbility);

    // can('manage', 'all');
    // menu 名称、路径、acl ->actions -> 名称、路径->实体对应
    // path -> prefix -> 写死在项目代码里

    // 其他思路:acl -> 表来进行存储 -> LogController + Action
    // log -> sys:log -> sys:log:read, sys:log:write ...
    const user = await this.userService.find(username);
    // user -> 1:n roles -> 1:n menus -> 去重 {}
    const obj = {} as Record<string, unknown>;
    user.roles.forEach((o) => {
      o.menus.forEach((menu) => {
        // path -> acl -> actions
        // 通过Id去重
        obj[menu.id] = menu;
      });
    });
    const menus = Object.values(obj) as Menu[];
    menus.forEach((menu) => {
      const actions = menu.acl.split(',');
      for (let i = 0; i < actions.length; i++) {
        const action = actions[i];
        can(action, getEntities(menu.path));
      }
    });
    // can('read', Logs);
    // cannot('update', Logs);
    // can('manage', 'all');

    const ability = build({
      detectSubjectType: (object) => object.constructor as any,
    });

    // ability.can
    // @CheckPolicies((ability) => ability.cannot(Action, User, ['']))

    return ability;
  }
}
相关推荐
Freak嵌入式22 分钟前
实战对比:用 mpremote/Putty/MobaXterm 连接树莓派 Pico REPL,谁更省心?
java·大数据·开发语言·科技·单片机·嵌入式硬件
tt一点通22 分钟前
前端常用设计模式大全
前端·vue.js
feilieren26 分钟前
ubuntu 安装 docker
linux·ubuntu·docker
张文君28 分钟前
ubuntu26.04坏块分区隔离急速版
java·前端·python
两只羊ovo30 分钟前
给 Agent 装上手脚:手写 mini-Cursor,从建项目到跑起来
前端
Tanner_SL31 分钟前
Linux笔记之BASH命令行操作
linux·bash
旧梦952735 分钟前
Java 枚举类详解:从基础语法到高级用法
java·开发语言·python
raindayinrain37 分钟前
深入理解Linux内核--文件描述符,文件对象,索引节点,虚拟文件系统,性能优化
linux·虚拟文件系统
Hive_MOM41 分钟前
制造企业库存精益化管理数字化:从“库存压资金、找货靠人翻“到“库存水位实时可见、资金周转提速“
前端·制造