前端内容页:创建角色页面(创建路由)
前端内容页:创建角色页面&菜单管理内容页面
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 元素。
Bootstrap 5 Modal 实例化
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)
表示用户对资源可以执行的操作集合,通过can和cannot方法定义权限规则
主题(Subject)
被访问的资源类型,如'Post'、'User'等
动作(Action)
用户对资源执行的操作,包括create、read、update、delete以及特殊关键字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;
}
}