ElementPlus

简介

ElementPlus是饿了么团队研发的,基于Vue3的组件库

准备工作:

  1. 创建工程化的Vue项目 选择 TypeScript

  2. 参照官方文档安装ElementPlus组件库(当前工程的目录下)

npm install element-plus --save
  1. main.ts中引入Element Plus组件库 参照官方文档
//main.ts
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'

const app = createApp(App)

app.use(ElementPlus)
app.mount('#app')
  1. 复制组件代码,调整

常用组件

Button组件

对应的代码:

<template>  
    <el-row class="mb-4">  
       <el-button>Default</el-button>  
       <el-button type="primary">Primary</el-button>  
       <el-button type="success">Success</el-button>  
       <el-button type="info">Info</el-button>  
       <el-button type="warning">Warning</el-button>  
       <el-button type="danger">Danger</el-button>  
    </el-row>  
      
    <el-row class="mb-4">  
       <el-button plain>Plain</el-button>  
       <el-button type="primary" plain>Primary</el-button>  
       <el-button type="success" plain>Success</el-button>  
       <el-button type="info" plain>Info</el-button>  
       <el-button type="warning" plain>Warning</el-button>  
       <el-button type="danger" plain>Danger</el-button>  
    </el-row>  
</template>

plain 控制背景色变淡,添加边框

表格组件

表格用于展示多条结构类似的数据,可以对数据进行排序、筛选、对比或自定义操作

<template>
  <!--data:数据源数组,border:带有纵向边框-->
  <el-table :data="tableData" border style="width: 100%">
    <!--prop:数组中每一个对象的属性名  label:表头的名称-->
    <el-table-column prop="date" label="Date" width="180" />
    <el-table-column prop="name" label="Name" width="180" />
    <el-table-column prop="address" label="Address" />
	<!--有几个标签就渲染几列,源数组中有几个对象就渲染几行-->
  </el-table>
</template>

<script lang="ts" setup>
const tableData = [
  {
    date: '2016-05-03',
    name: 'Tom',
    address: 'No. 189, Grove St, Los Angeles',
  },
  {
    date: '2016-05-02',
    name: 'Tom',
    address: 'No. 189, Grove St, Los Angeles',
  }
</script>

在以上基础上再增加一列:Author,需要变动数据源数组和增加一个el-table-column标签:

<template>
	<el-table :data="tableData" border style="width: 100%">  
	    <el-table-column prop="date" label="Date" width="180" />  
	    <el-table-column prop="name" label="Name" width="180" />  
	    <el-table-column prop="address" label="Address" />  
	    <el-table-column prop="author" label="Author" />  <!--增加一列-->
	</el-table>
</template>

<script setup lang="ts">  
  
const tableData = [  
    {  
       date: '2016-05-03',  
       name: 'Tom',  
       address: 'No. 189, Grove St, Los Angeles',  
       author : 'EUNEIR'  //增加一个属性
    },  
    {  
       date: '2016-05-02',  
       name: 'Tom',  
       address: 'No. 189, Grove St, Los Angeles',  
       author : 'EUNEIR'  
    } 
]  
  
</script>

ElementPlus还提供了非常多的表格和表格属性

分页组件

开启中文语言包:

import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs' //报错

app.use(ElementPlus, {
  locale: zhCn,
})

需要额外配置env.d.ts:

/// <reference types="vite/client" />  
declare module 'element-plus/dist/locale/zh-cn.mjs'

<el-pagination  
        v-model:current-page="currentPage"   
	    v-model:page-size="pageSize"  
        :page-sizes="[100, 200, 300, 400]"  
        :small="small"    
		:disabled="disabled"  
        :background="background"  
        layout="total, sizes, prev, pager, next, jumper"     
        :total="400"  
        @size-change="handleSizeChange"  
        @current-change="handleCurrentChange"  
/>

分页组件中的各部分及其顺序是由layout指定的,如果页码/分页记录数变化会触发@size-change/@current-change事件的回调函数执行

import { ref } from 'vue'  
  
const currentPage = ref(4)  
const pageSize = ref(100)  
const small = ref(false)  
const background = ref(false)  
const disabled = ref(false)  
const total = ref(400)  
  
const handleSizeChange = (val: number) => {  
    console.log(`${val} items per page`)  
}  
const handleCurrentChange = (val: number) => {  
    console.log(`current page: ${val}`)  
}

对话框组件

<!--对应Button组件中的设置-->
<el-button text @click="dialogTableVisible = true">  
    打开对话框  
</el-button>  

<!--dialogTableVisible控制对话框的显示与因此,title是对话框的标题-->
<el-dialog v-model="dialogTableVisible" title="Shipping address">  
	<!--以下内容就是表格组件中的设置-->
    <el-table :data="tableData">  
       <el-table-column property="date" label="Date" width="150" />  
       <el-table-column property="name" label="Name" width="200" />  
       <el-table-column property="address" label="Address" />  
    </el-table>  
</el-dialog>

显示的效果不甚明显:

可以根据上文Button组件的设置来更改这个Button组件的样式:

<el-button type="primary" @click="dialogTableVisible = true">  
    打开对话框  
</el-button>  

表单组件

<!--inline:行内表单,model:表单数据对象-->
<el-form :inline="true" :model="formInline" class="demo-form-inline">  
    <el-form-item label="用户名">  
       <!--表单项双向绑定-->
       <el-input v-model="formInline.user" placeholder="用户名" clearable/>  
    </el-form-item>  
    <el-form-item label="区域">  
       <el-select  
             v-model="formInline.region"  
             placeholder="区域"  
             clearable  
       >  
          <el-option label="上海" value="shanghai"/>  
          <el-option label="北京" value="beijing"/>  
       </el-select>  
    </el-form-item>  
    <el-form-item label="时间">  
       <el-date-picker  
             v-model="formInline.date"  
             type="date"  
             placeholder="选择时间"  
             clearable  
       />  
    </el-form-item>  
    <el-form-item>  
       <el-button type="primary" @click="onSubmit">查询</el-button>  
    </el-form-item>  
</el-form>
const formInline = ref({  
    user : '',  
    region : '',  
    date : ''  
})  
const onSubmit = () => {  
    console.log(formInline.value)  
};

案例

请求地址:

http://47.98.197.202/api/emps/list?name=&gender=&job=
<template>  
    <el-form :inline="true" :model="emp" class="demo-form-inline">  
       <el-form-item label="姓名">  
          <el-input v-model="emp.name" placeholder="请输入姓名" clearable />  
       </el-form-item>  
       <el-form-item label="性别">  
          <el-select  
                v-model="emp.gender"  
                placeholder="请选择"  
                clearable  
          >  
             <el-option label="男" value="1" />  
             <el-option label="女" value="2" />  
          </el-select>  
       </el-form-item>  
       <el-form-item label="职位">  
          <el-select  
                v-model="emp.job"  
                placeholder="请选择"  
                clearable>  
             <!--下拉列表的选项-->  
             <el-option label="班主任" value="1" />  
             <el-option label="讲师" value="2" />  
             <el-option label="其他" value="3" />  
          </el-select>  
       </el-form-item>  
       <el-form-item>  
          <el-button type="primary" @click="query">查询</el-button>  
          <el-button type="primary" @click="clear">清空</el-button>  
       </el-form-item>  
    </el-form>  
      
    <el-table :data="empList" border style="width: 100%">  
       <!--五列 对应5个column-->  
       <el-table-column prop="id" label="ID" width="180" />  
       <el-table-column prop="name" label="姓名" width="180" />  
       <el-table-column prop="img" label="头像" />   
       <el-table-column prop="gender" label="性别" />  
       <el-table-column prop="job" label="职位" />  
       <el-table-column prop="entrydate" label="入职日期" />  
       <el-table-column prop="updatetime" label="更新时间" />  
    </el-table>  
</template>

如果要显示图片,就必须使用img标签,ElementPlus封装的el-table-column不能显示图片

需要使用ElementPlus提供的自定义列模板用来自定义这一列的展示内容

<template #default="scope">
</template>

#default 是 插槽 slot,通过插槽可以获取到row、column、$index、store

最终表单部分:

<el-table :data="empList" border style="width: 100%">  
    <!--五列 对应5个column-->  
    <el-table-column prop="id" label="ID" width="180" align="center"/>  
    <el-table-column prop="name" label="姓名" width="180" align="center"/>  
    <el-table-column prop="image" label="头像" align="center">  
       <template #default="scope">  
          <img :src="scope.row.image" width="50px">  
       </template>  
    </el-table-column>  
    <el-table-column prop="gender" label="性别" align="center">  
       <template #default="scope">  
          {{scope.row.gender == 1 ? '男' : '女'}}  
       </template>  
    </el-table-column>  
    <el-table-column prop="job" label="职位" align="center">  
       <template #default="scope">  
          {{scope.row.job == 1 ? '班主任' : scope.row.job == 2 ? '讲师' : '其他'}}  
       </template>  
    </el-table-column>  
    <el-table-column prop="entrydate" label="入职日期" align="center"/>  
    <el-table-column prop="updatetime" label="更新时间" align="center"/>  
</el-table>

Tlias

准备工作

  • 安装依赖
npm install element-plus --save
npm install axios 
  • 配置ElementPlus
//main.ts

import { createApp } from 'vue'  
import { createPinia } from 'pinia'  
  
import App from './App.vue'  
import router from './router'  
import './assets/main.css'  
  
//导入elementPlus  
import ElementPlus from 'element-plus'  
import 'element-plus/dist/index.css'  
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'  
import * as ElementPlusIconsVue from '@element-plus/icons-vue'  
  
const app = createApp(App)  
  
  
//注册ElementPlus的Icon组件  
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {  
    app.component(key, component)  
}  
  
app.use(createPinia())  
app.use(router)  
app.use(ElementPlus, {locale: zhCn})  
  
app.mount('#app')



//env.d.ts
declare module 'element-plus/dist/locale/zh-cn.mjs';

页面布局

公共的css属性可以定义在main.css中:

main.css

*{  
  margin: 0;  
  }

Container

布局需要使用Container布局容器:

  • <el-container> : 外层容器
  • <el-header> : 顶栏容器
  • <el-aside> :侧边栏容器
  • <el-container>:主要区域容器
  • <el-footer> : 底栏容器
<!--IndexView.vue-->
<template>  
    <div class="common-layout">  
       <el-container>  
          <el-header class="header"> <HeaderComponent/> </el-header>  
          <el-container>  
             <el-aside width="200px" class="aside">  
                <AsideComponent/>  
             </el-aside>  
             <el-main>Main</el-main>  
          </el-container>  
       </el-container>  
    </div>  
</template>

Header

<!--HeaderComponent.vue-->
<script setup lang="ts">  
  
</script>  
  
<template>  
    <span class="title">Tlias智能学习辅助系统</span>  
      
    <span class="right_tool">  
          <a href="">  
            <!--图标-->
            <el-icon><EditPen /></el-icon> 修改密码 &nbsp;&nbsp;&nbsp; |  &nbsp;&nbsp;&nbsp;  
          </a>  
          <a href="">  
            <el-icon><SwitchButton /></el-icon> 退出登录  
          </a>  
        </span>  
</template>  
  
<style scoped>  
.title {  
    color: white;  
    font-size: 40px;  
    font-family: 楷体;  
    line-height: 60px;  
    }  
  
.right_tool{  
    float: right;  
    line-height: 60px;  
    }  
  
a {  
    color: white;  
    text-decoration: none;  
    }  
</style>

修改密码和退出登录 需要使用ElementPlus提供的图标,官网提供的使用方式:

需要从 @element-plus/icons-vue 中导入所有图标并进行全局注册。

//main.ts

// 如果您正在使用CDN引入,请删除下面一行。
import * as ElementPlusIconsVue from '@element-plus/icons-vue'

const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

图标合集中选择图标就可以直接使用:

    <a href="">  
            <el-icon><SwitchButton /></el-icon> 退出登录  
  </a>  

Aside

   <el-aside width="200px">
      <el-scrollbar>
        <el-menu :default-openeds="['1', '3']">
        <!--el-sub-menu是一个子菜单-->
          <el-sub-menu index="1">
            <template #title>
              <el-icon><message /></el-icon>Navigator One
            </template>
            <!--el-menu-item-group是子菜单的一组-->
            <el-menu-item-group>
              <template #title>Group 1</template>
              <!--el-menu-item是一个菜单项-->
              <el-menu-item index="1-1">Option 1</el-menu-item>
              <el-menu-item index="1-2">Option 2</el-menu-item>
            </el-menu-item-group>
            <el-menu-item-group title="Group 2">
              <el-menu-item index="1-3">Option 3</el-menu-item>
            </el-menu-item-group>
            <el-sub-menu index="1-4">
              <template #title>Option4</template>
              <el-menu-item index="1-4-1">Option 4-1</el-menu-item>
            </el-sub-menu>
          </el-sub-menu>
          <el-sub-menu index="2">
            <template #title>
              <el-icon><icon-menu /></el-icon>Navigator Two
            </template>
            <el-menu-item-group>
              <template #title>Group 1</template>
              <el-menu-item index="2-1">Option 1</el-menu-item>
              <el-menu-item index="2-2">Option 2</el-menu-item>
            </el-menu-item-group>
            <el-menu-item-group title="Group 2">
              <el-menu-item index="2-3">Option 3</el-menu-item>
            </el-menu-item-group>
            <el-sub-menu index="2-4">
              <template #title>Option 4</template>
              <el-menu-item index="2-4-1">Option 4-1</el-menu-item>
            </el-sub-menu>
          </el-sub-menu>
          <el-sub-menu index="3">
            <template #title>
              <el-icon><setting /></el-icon>Navigator Three
            </template>
            <el-menu-item-group>
              <template #title>Group 1</template>
              <el-menu-item index="3-1">Option 1</el-menu-item>
              <el-menu-item index="3-2">Option 2</el-menu-item>
            </el-menu-item-group>
            <el-menu-item-group title="Group 2">
              <el-menu-item index="3-3">Option 3</el-menu-item>
            </el-menu-item-group>
            <el-sub-menu index="3-4">
              <template #title>Option 4</template>
              <el-menu-item index="3-4-1">Option 4-1</el-menu-item>
            </el-sub-menu>
          </el-sub-menu>
        </el-menu>
      </el-scrollbar>
    </el-aside>

当前项目的需求:

四个子菜单,没有分组

Main

配置嵌套路由:

const router = createRouter({  
  history: createWebHistory(import.meta.env.BASE_URL),  
  routes: [  
      path : '/',  
      name : 'home',  
      component : () => import('../views/layout/IndexView.vue'),  
      children : [ //嵌套路由  
        {  
          path : 'index',  
          name : 'index',  
          component : () => import('../views/index/WelcomePageIndex.vue')  
        },  
        {  
          path : 'emp',  
          name : 'emp',  
          component : () => import('../views/emp/EmpIndex.vue')  
        },  
        {  
          path : 'dept',  
          name : 'dept',  
          component : () => import('../views/dept/DeptIndex.vue')  
        },  
        {  
          path : 'clazz',  
          name : 'clazz',  
          component : () => import('../views/clazz/ClazzIndex.vue')  
        },  
      ]  
    })  
  export default router

App.vue:

<script setup lang="ts">  
  
</script>  
  
<template>  
	<!--IndexView-->
    <RouterView/>  
</template>  
  
<style scoped>  
  
</style>

IndexView.vue:

<template>  
    <div class="common-layout">  
       <el-container>  
          <el-header class="header"> <HeaderComponent/> </el-header>  
          <el-container>  
             <el-aside width="200px" class="aside">  
                <AsideComponent/>  
             </el-aside>  
             <el-main> <RouterView/> </el-main>  
          </el-container>  
       </el-container>  
    </div>  
</template>
<el-scrollbar>  
  <el-menu router>  
   <!-- 首页菜单 -->  
     <!--启用vue-router模式,将index作为path进行跳转-->  
   <el-menu-item index="/index">  
    <el-icon><Promotion /></el-icon> 首页  
   </el-menu-item>  
  
   <!-- 班级管理菜单 -->  
   <el-sub-menu index="/manage">  
    <template #title>  
     <el-icon><Menu /></el-icon> 班级学员管理  
    </template>  
    <el-menu-item index="/clazz">  
     <el-icon><HomeFilled /></el-icon> 班级管理  
    </el-menu-item>  
    <el-menu-item index="/stu">  
     <el-icon><UserFilled /></el-icon>学员管理  
    </el-menu-item>  
   </el-sub-menu>  
  
   <!-- 系统信息管理 -->  
   <el-sub-menu index="/system">  
    <template #title>  
     <el-icon><Tools /></el-icon>系统信息管理  
    </template>  
    <el-menu-item index="/dept">  
     <el-icon><HelpFilled /></el-icon> 部门管理  
    </el-menu-item>  
    <el-menu-item index="/emp">  
     <el-icon><Avatar /></el-icon> 员工管理  
    </el-menu-item>  
   </el-sub-menu>  
  
   <!-- 数据统计管理 -->  
   <el-sub-menu index="/report">  
    <template #title>  
     <el-icon><Histogram /></el-icon>数据统计管理  
    </template>  
    <el-menu-item index="/empReport">  
     <el-icon><InfoFilled /></el-icon>员工信息统计  
    </el-menu-item>  
    <el-menu-item index="/stuReport">  
     <el-icon><Share /></el-icon>学员信息统计  
    </el-menu-item>  
    <el-menu-item index="/log">  
     <el-icon><Document /></el-icon>日志信息统计  
    </el-menu-item>  
   </el-sub-menu>  
  </el-menu>  
</el-scrollbar>

但是当前直接访问系统的界面:

因为默认的请求路径是:http://127.0.0.1:5173/,路由能匹配到IndexView.vue,匹配不到IndexView内部的RouterView,所以只渲染了IndexView

解决办法:对路由 / 进行重定向:

{  
  path : '/',  
  name : 'home',  
  component : () => import('../views/layout/IndexView.vue'),  
  redirect : '/index',  
  children : [ //嵌套路由  
    {  
      path : 'index',  
      name : 'index',  
      component : () => import('../views/index/WelcomePageIndex.vue')  
    }
}

访问 / 就会访问到index

部门管理功能实现

查询所有

页面布局

需要的组件:Button、Table

<script setup lang="ts">  
  import {ref} from "vue";  
  //声明表格的数据模型  
  let deptList = ref([]);  
</script>  
  
<template>  
  <h1>部门管理</h1>  
  
  <el-button type="primary">+ 新增部门</el-button>  
  
  <el-table :data="deptList" border style="width: 100%">  
    <el-table-column prop="date" label="Date" width="180" />  
    <el-table-column prop="name" label="Name" width="180" />  
    <el-table-column prop="address" label="Address" />  
  </el-table>  
</template>  
  
<style scoped>  
  
</style>

但是我们目前使用的是ts,对于ref可以指定泛型,用来规定其中存储的数据类型,而deptList是请求服务器返回的数据,接口文档中规定了响应数据的格式:

{
  "code": 1,
  "msg": "success",
  "data": [
    {
      "id": 1,
      "name": "学工部",
      "createTime": "2022-09-01T23:06:29",
      "updateTime": "2022-09-01T23:06:29"
    },
    {
      "id": 2,
      "name": "教研部",
      "createTime": "2022-09-01T23:06:29",
      "updateTime": "2022-09-01T23:06:29"
    }
  ]
}

此处的泛型就是数组类型,数组中存储的元素类型是我们定义的:

interface deptModel{
	id?: number,
	name: string,
	updateTime?: string
}
  • 没有定义createTime:前端不需要展示createTime
  • updateTime和id定义为可选参数,因为dept不仅只有查询的部门,也会有新增的部门(新增的部门没有id和更新时间),这是交给后端定义的字段

定义泛型:

//声明部门的数据类型  
interface deptModel{  
  id?: number,  
  name: string,  
  updateTime?: string  
}  
  
//声明表格的数据模型  
//泛型是deptModel类型的数组  
let deptList = ref<deptModel[]>([]);

一般会将所有的泛型和类型别名定义在单独的ts文件中,一般在api/model/model.ts中:

//api/model/model.ts

// ----------------------- 部门数据相关接口及类型 ---------------------
//部门数据接口  
// ? 新增  
export interface DeptModel {  
  id?: number,  
  name: string,  
  updateTime?: string  
}  
  
//部门数据数组的类型别名 
export type DeptModelArray = DeptModel[]

在需要的地方引入即可:

import {ref} from "vue";  
  
//引入类型/接口需要使用type关键字  
import type {DeptModelArray} from "../../api/model/model";  
  
//声明表格的数据模型  
//泛型是deptModel类型的数组  
let deptList = ref<DeptModelArray>([]);

在此处引入DeptModelArray的时候,需要回退两级目录,可以用@代表根目录src,直接在根目录下引入:

//@代表src目录  
import type {DeptModelArray} from "@/api/model/model"; 

接下来继续完善表格的数据显示,界面原型显示需要四列:

<el-table :data="deptList" border style="width: 100%">  
  <el-table-column prop="date" label="序号" width="180" align="center"/>  
  <el-table-column prop="name" label="部门名称" width="180" align="center"/>  
  <el-table-column prop="address" label="最后操作时间" align="center"/>  
  <el-table-column prop="address" label="操作" align="center"/>  
</el-table>

prop指定的是属性名,而属性名在 数据类型接口 interface DeptModel中指定了:

//api/model/model.ts

// ----------------------- 部门数据相关接口及类型 ---------------------
//部门数据接口  
// ? 新增  
export interface DeptModel {  
  id?: number,  
  name: string,  
  updateTime?: string  
}  
  
//部门数据数组的类型别名 
export type DeptModelArray = DeptModel[]

其中的序号并不是id属性,ElementPlus给出了显示序号的解决办法:设置 type 属性为 index 即可显示从 1 开始的索引号。

<el-table :data="deptList" border style="width: 100%">  
  <el-table-column prop="" label="序号" width="180" align="center">  
    <template #default="scope">  
       
    </template>  
  </el-table-column>  
  <el-table-column prop="name" label="部门名称" width="180" align="center"/>  
  <el-table-column prop="updateTime" label="最后操作时间" align="center"/>  
  <el-table-column prop="" label="操作" align="center">  
    <template #default="scope">  
  
    </template>  
  </el-table-column>

加载数据

需求:

  1. 增删改完毕后,加载最新的部门数据
  2. 打开页面后,加载最新的部门数据

定义查询部门列表的函数:

//查询部门列表  
const search = async ()=> {  
  let promise = await axios.get('/api/depts');  
  //返回了一个Promise对象,data字段封装了响应的数据,在后端是Result格式的JSON字符串  
  console.log(promise)  
  //promise.data 是Result,再.data是结果  
  deptList.value = promise.data.data;  
}  
  
onMounted(() => {  
  search();  
});

在后端没有开发好的情况下,可以使用Apifox的Mock功能:

复制链接作为get方法的入参就可以进行测试了

search方法最好加一个判断,根据Result的code字段进行判断:

//查询部门列表  
const search = async () => {  
  let promise = await axios.get('https://mock.apifox.com/m1/3708703-0-default/depts');  
  //返回了一个Promise对象,data字段封装了响应的数据,在后端是Result格式的JSON字符串  
  console.log(promise)  
  
  if (promise.data.code) {  
    //promise.data 是Result,再.data是结果  
    deptList.value = promise.data.data;  
  }  
  
}  
  
onMounted(() => {  
  search();  
});

当前访问的是服务器的接口,需要进行跨域的处理:

//vite.config.ts

export default defineConfig({  
  plugins: [  
    vue(),  
    vueJsx(),  
  ],  
  resolve: {  
    alias: {  
      '@': fileURLToPath(new URL('./src', import.meta.url))  
    }  
  },  
  //跨域  
  server: {  
    cors: true,  
    open: true,  
    port: 5173,  
    proxy: {  
      '^/api': {  
        target: 'http://localhost:8080/',  
        changeOrigin: true,
        //需要对/api的/进行转义  
        rewrite: (path) => path.replace(/^\/api/, '')  
      }  
    }  
  }

以上配置的含义是,匹配到/api开始的请求都将目的地址改为:http://localhost:8080/api/path,以/api/dept为例:

axios.get('/api/dept') -拦截请求-> http://localhost:8080/api/dept -/api替换为空字符串-> http://localhost:8080/dept

所以请求的方法可以直接请求/api/dept:

//查询部门列表  
const search = async () => {  
  let promise = await axios.get('/api/depts');  
  //返回了一个Promise对象,data字段封装了响应的数据,在后端是Result格式的JSON字符串  
  console.log(promise)  
  
  if (promise.data.code) {  
    //promise.data 是Result,再.data是结果  
    deptList.value = promise.data.data;  
  }  
  
} 
初步优化:泛型

但是每次请求都带有/api还是比较繁琐的,并且promise.data是后端的Result对象,每次都要从promise中把Result提取出来再 .data获取数据,提取Result的操作是相同的,可以对程序进行初步优化:

  • 封装请求工具类utils/request.ts:
const request = axios.create({
	//请求均以/api开始
	baseURL : '/api',
	timeout : 60000
});

//axios的响应response的拦截器
request.interceptors.response.use(
	//成功回调
	(response) => {
		//提取Result,await request.get()的返回值就是Result对象
		return response.data;
	},
	//失败回调
	(error) => {
	    //拿到错误信息,继续失败回调
		return Promise.reject(error);
	}
);

export default request;

/api是为了区分ajax请求,其他请求不需要Tomcat处理

axios被封装为request对象,请求可以直接通过request发起,响应的数据经过拦截器的提取,只取出服务器端响应的纯数据,我们得到的就是Result对象,此时发起请求:

const search = async () => {  
  //拦截器提取出Result对象
  let dept = await request.get('/depts');  
  console.log(dept);  
  if (dept.code){  
    deptList.value = dept.data;  
  }  
}

但是这样做,在ts下会提示错误:

因为没有指定get方法的返回值类型为ResultModel,ts无法得知其中是否有code属性

axios的get方法是有泛型的:

get<T = any, R = AxiosResponse<T>, D = any>(url: string, config?: AxiosRequestConfig<D>): Promise<R>;

axios的get方法实际上是对axios.request的一层封装,request方法:

request<T = any, R = AxiosResponse<T>, D = any>(config: AxiosRequestConfig<D>): Promise<R>;

request方法有三个泛型,T、R、D,接受AxiosRequestConfig类型的参数作为配置对象,返回值是接受泛型R的Promise类型

R的默认类型 AxiosResponse:

export interface AxiosResponse<T = any, D = any> {
    data: T;
    status: number;
    statusText: string;
    headers: RawAxiosResponseHeaders | AxiosResponseHeaders;
    config: AxiosRequestConfig<D>;
    request?: any;
   }

AxiosResponse就是响应拦截器用到的response对象的类型:

T就是服务器端返回的数据的类型,而服务器端返回的类型是不确定的,所以定义为any

再看request方法的定义:

request<T = any, R = AxiosResponse<T>, D = any>(config: AxiosRequestConfig<D>): Promise<R>;
  • T:服务器返回数据的类型
  • R:服务器返回的数据经过axios一层封装得到的response对象的类型

request方法的返回值是Promise,值就是成功态的R,也就是response对象


{  // <- AxiosResponse
	data: {
		code : '',
		msg : '',
		data : any 
	},
    status: number,
    statusText: string,
    headers: RawAxiosResponseHeaders | AxiosResponseHeaders,
    config: AxiosRequestConfig<D>,
    request?: any
}

所以get、post、put方法的返回值都是Promise,值均为成功态的R,也就是response对象

再看我们的封装:

const request = axios.create({  
  baseURL: '/api',  
  timeout: 600000  
})  
  
//axios的响应 response 拦截器  
request.interceptors.response.use(  
  (response) => { //成功回调  
    return response.data  
  },  
  (error) => { //失败回调  
    return Promise.reject(error)  
  }  
)  
  
export default request

其实就是将response中的data提取出来了,上文中提到data的类型是T=any,这样get请求得到的结果类型一定是T,因为get请求的结果就是Promise,也就是成功态的R,而R已经被我们在拦截器中转换为T了,所以我们可以直接指定T和R的类型:

const search = async () => {  
							//改变了await request.get方法的返回值
  let dept = await request.get<ResultModel,ResultModel>('/depts') ;  
  console.log(dept);  
  if (dept.code){  
    deptList.value = dept.data;  
  }  
}

此时就不会报错了。

但是这种做法是不正确的,axios的拦截器可以配置多个,多个拦截器会形成一个拦截器链,每个拦截器链的参数都是AxiosResponse类型,如果在响应回调里直接return response.data,R就变为T了,应该保证每个拦截器的签名一直,否则对下游的拦截器可能产生影响,不建议这样操作,应该将axios的get、put、post方法统一封装,返回最终需要指定的类型。

分层优化

现代前端开发会将和服务器端交互的逻辑定义在单独的api中,例如:api/dept.ts

//其实是拦截器将R变为T了,此处才能写ResultModel
export const queryAllDepts = () => request.get<any,ResultModel>('/depts');

调用:

const search = async () => {  
  //直接调用该函数发送请求即可
  //await 拿到的就是成功态的R,拦截器已经将R变为T了
  let result = await queryAllDepts();  
  if (result.code) {  
    deptList.value = result.data;  
  }  
}

新增部门

点击新增部门按钮,弹出Dialog对话框

<script setup lang="ts">  

//新增部门  
  
// 1. 对话框  
let dialogFormVisible = ref<boolean>(false);  
// 表单数据,类型限定必须指定name  
let dept = ref<DeptModel>({name:''});  
  
// 2. 弹窗  
let add = () => {  
  dialogFormVisible.value = true;   
}  
</script>  
  
<template>  
  <h1>部门管理</h1>  
  
  <el-button type="primary" @click="add">+ 新增部门</el-button>  
  
  <el-table :data="deptList" border style="width: 100%">  
		...
    <el-table-column prop="" label="操作" align="center">  
      <template #default="scope">  
        <el-button type="success" size="small">编辑</el-button>  
        <el-button type="danger" size="small">删除</el-button>  
      </template>  
    </el-table-column>  
  </el-table>  
  
  <el-dialog v-model="dialogFormVisible" title="Shipping address">  
    <el-form :model="dept">  
      <el-form-item label="Promotion name" >  
        <el-input v-model="dept.name" autocomplete="off" />  
      </el-form-item>  
    </el-form>  
    <template #footer>  
      <span class="dialog-footer">  
        <el-button @click="dialogFormVisible = false">Cancel</el-button>  
        <el-button type="primary" @click="dialogFormVisible = false">  
          Confirm  
        </el-button>  
      </span>  
    </template>  
  </el-dialog>  
</template>  
  
<style scoped>  
  
</style>

此时的效果:

对话框的标题不应该直接指定为 新增部门 ,编辑按钮弹出的对话框和这个相同,编辑时标题应该为 修改部门

在add方法中赋值为新增部门,在update方法中赋值为修改部门

title应该是v-bind绑定的。

完成新增功能:

//   api/dept.ts
//接口文档指明参数为dept类型  
export const addApi = (dept:DeptModel) => request.post<any,ResultModel>('/depts',dept)
<script setup lang="ts">
//新增部门

// 1. 对话框
let dialogFormVisible = ref<boolean>(false);
// 表单数据,类型限定必须指定name
let dept = ref<DeptModel>({name:''});
// 对话框标题,可能是新增部门/编辑部门
let formTitle = ref<string>('');

// 2. 弹窗
let add = () => {
  //显示对话框
  dialogFormVisible.value = true;
  //标题赋值
  formTitle.value = '新增部门';
}
// 3. 保存
let save = async () => {
  //调用交互层保存数据,数据在dept对象中
  //体现了TS的强大之处,此处很容易写为dept
  let result = await addApi(dept.value);

  //成功关闭弹窗
  if (result.code){
    //关闭弹窗
    dialogFormVisible.value = false;
    //提示操作成功
    ElMessage.success('保存成功');
    //列表刷新
    search();
  }else {
    //不关闭弹窗:给用户修改的机会
    //提示操作失败
    ElMessage.error(result.msg);
  }
}
</script>

<template>
  <h1>部门管理</h1>

  <el-button type="primary" @click="add">+ 新增部门</el-button>

  <el-table :data="deptList" border style="width: 100%">
      ...
      <template #default="scope">
        <el-button type="success" size="small">编辑</el-button>
        <el-button type="danger" size="small">删除</el-button>
      </template>
    </el-table-column>
  </el-table>

  <el-dialog v-model="dialogFormVisible" :title="formTitle" width="30%">
    <el-form :model="dept">
      <el-form-item label="部门名称" >
        <el-input v-model="dept.name" autocomplete="off" />
      </el-form-item>
    </el-form>
    <template #footer>
      <span class="dialog-footer">
        <!--取消直接设置为false-->
        <el-button @click="dialogFormVisible = false">取消</el-button>
        <!--确认是有逻辑的-->
        <el-button type="primary" @click="save">
          确定
        </el-button>
      </span>
    </template>
  </el-dialog>
</template>

<style scoped>

</style>

但是还是存在问题的:下一次弹窗还会显示dept.value.name的值,因为这次没有清空数据。

  • 应该在何处设置清空dept.value.name?

不能在保存成功后清空,如果保存失败用户直接关闭窗口,下一次打开还是原先的数据

应该在弹出对话框时清空

// 2. 弹窗  
let add = () => {  
  //清空之前的dept.value.name  
  dept.value.name = '';  
  //显示对话框  
  dialogFormVisible.value = true;  
  //标题赋值  
  formTitle.value = '新增部门';  
}

在后端的增/删/改也是有必要返回Result的,可以在前端给用户提供信息参考。

修改部门

分为两步:

  1. 查询回显
  2. 保存修改

查询回显

点击编辑按钮,需要查询回显,为编辑按钮绑定update回调函数,需要为其传递参数id

<el-table :data="deptList" border style="width: 100%">  
  <el-table-column type="index" label="序号" width="100" align="center"/>  
  <el-table-column prop="name" label="部门名称" width="250" align="center"/>  
  <el-table-column prop="updateTime" label="最后操作时间" align="center" width="350"/>  
  <el-table-column prop="" label="操作" align="center">  
    <template #default="scope">                   <!--传递id-->
      <el-button type="success" size="small" @click="update(scope.row.id)">编辑</el-button>  
      <el-button type="danger" size="small">删除</el-button>  
    </template>  
  </el-table-column>  
</el-table>

也体现了后端返回给前端的数据是必须带有id的,这样针对某些数据的操作才能让后端辨别数据身份

//三、修改部门  
// 1.1 数据回显  
const update = async (id:number)=> {  
  //清空之前的dept.value.name  
  dept.value.name = '';  
  //显示对话框  
  dialogFormVisible.value = true;  
  //设置标题  
  formTitle.value = '修改部门';  
  
  //dept.value = (await getInfoById(id)).data  
  //其实byId应该不能是失败的  
  let result = await getInfoById(id);  
  if (result.code){  
    //直接替换dept对象,替换name TS会报错  
    //dept.value.name = result.data.name  
    dept.value = result.data;  
  }  
}
  • 注意:时刻注意接口文档中/类型注解中规定的类型

保存修改

点击对话框的保存,触发修改的逻辑,但是新增部门和修改部门的对话框是同一个,在新增部门中,我们已经为对话框的保存绑定了save方法:

<el-dialog v-model="dialogFormVisible" :title="formTitle" width="30%">  
  <el-form :model="dept">  
    <el-form-item label="部门名称" >  
      <el-input v-model="dept.name" autocomplete="off" />  
    </el-form-item>  
  </el-form>  
  <template #footer>  
    <span class="dialog-footer">  
      <!--取消直接设置为false-->  
      <el-button @click="dialogFormVisible = false">取消</el-button>  
      <!--确认是有逻辑的-->  
      <el-button type="primary" @click="save">  
        确定  
      </el-button>  
    </span>  
  </template>  
</el-dialog>
let save = async () => {  
  //调用交互层保存数据,数据在dept对象中  
  //体现了TS的强大之处,此处很容易写为dept  
  let result = await addApi(dept.value);  
  
  //成功关闭弹窗  
  if (result.code){  
    //关闭弹窗  
    dialogFormVisible.value = false;  
    //提示操作成功  
    ElMessage.success('保存成功');  
    //列表刷新  
    search();  
  
  }else {  
    //不关闭弹窗:给用户修改的机会  
    //提示操作失败  
    ElMessage.error(result.msg);  
  }  
}

也就是说,在对话框的save方法中既要完成新增,又要完成修改,先定义交互层的修改方法:

export const modifyInfoApi = (dept:DeptModel) => request.put<any,ResultModel>('/depts',dept);
// 保存  
let save = async () => {  
  
  let result;  
  //新增和修改的区别是dept.value的id属性是否有值  
  if (dept.value.id){  
    //有id修改  
    result = await modifyInfoApi(dept.value);  
  }else {  
    //无id新增  
    result = await addApi(dept.value);  
  }  
  //调用交互层保存数据,数据在dept对象中  
  //体现了TS的强大之处,此处很容易写为dept  
    //成功关闭弹窗  
  if (result.code){  
    //关闭弹窗  
    dialogFormVisible.value = false;  
    //提示操作成功  
    ElMessage.success('保存成功');  
    //列表刷新  
    search();  
  
  }else {  
    //不关闭弹窗:给用户修改的机会  
    //提示操作失败  
    ElMessage.error(result.msg);  
  }  
}
//三、修改部门  
const update = async (id:number)=> {  
  // 1. 数据回显  
  //清空之前的dept.value.name  
  dept.value.name = '';  
  //显示对话框  
  dialogFormVisible.value = true;  
  //设置标题  
  formTitle.value = '修改部门';  
  
  dept.value = (await getInfoById(id)).data  
  //其实byId应该不能是失败的  
/*  let result = await getInfoById(id);  
  if (result.code){    //直接替换dept对象,替换name TS会报错  
    //dept.value.name = result.data.name    dept.value = result.data;  }*/  
}

删除部门

  • 根据id删除,删除完毕刷新页面

  • 点击删除之后弹出确认框 ElMessageBox

<template>
  <el-button text @click="open">Click to open the Message Box</el-button>
</template>

<script lang="ts" setup>
import { ElMessage, ElMessageBox } from 'element-plus'

const open = () => {
  ElMessageBox.confirm(
    'proxy will permanently delete the file. Continue?',
    'Warning', //警告图标
    { //确认按钮文本
      confirmButtonText: 'OK',
      //取消按钮文本
      cancelButtonText: 'Cancel',
      type: 'warning',
    }
  )
    .then(() => {
      ElMessage({
        type: 'success',
        message: 'Delete completed',
      })
    })
    .catch(() => {
      ElMessage({
        type: 'info',
        message: 'Delete canceled',
      })
    })
}
</script>
// 四、删除部门  
  
const deleteById = (id:number) => {  
  //确认是否删除  
  ElMessageBox.confirm(  
      '是否确认删除?',  
      'Warning',  
      {  
        confirmButtonText: '确认',  
        cancelButtonText: '取消',  
        type: 'warning',  
      }  
  )   //注意async的位置  
      .then(async () => {  
        let result = await removeByIdApi(id);  
        if (result.code){  
          ElMessage({  
            type: 'success',  
            message: '删除成功',  
          })  
        }else{  
          ElMessage.error(result.msg)  
        }  
      })  
      .catch(() => {  
        ElMessage({  
          type: 'info',  
          message: '取消删除',  
        })  
      })  
  //刷新页面  
  search();  
}

表单校验

需要对表单进行校验,ElementPlus给了表单校验的方案:

为rules属性传入约定的验证规则,并且将form-item的prop属性设置为需要验证的特殊键值即可。

<template>
<!--rules属性-->
  <el-form
    ref="ruleFormRef"
    :model="ruleForm"
    :rules="rules"
    label-width="120px"
    class="demo-ruleForm"
    :size="formSize"
    status-icon
  >                                  <!--设置name属性-->
    <el-form-item label="Activity name" prop="name">
      <el-input v-model="ruleForm.name" />
    </el-form-item>
    
    <el-form-item>
      <el-button type="primary" @click="submitForm(ruleFormRef)">
        Create
      </el-button>
      <el-button @click="resetForm(ruleFormRef)">Reset</el-button>
    </el-form-item>
  </el-form>
</template>

<script lang="ts" setup>
import { reactive, ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'

interface RuleForm {
  name: string
}

const formSize = ref('default')
const ruleFormRef = ref<FormInstance>()
const ruleForm = reactive<RuleForm>({
  name: 'Hello',
})

const rules = reactive<FormRules<RuleForm>>({
  name: [            
    { required: true, message: 'Please input Activity name', trigger: 'blur' },
    { min: 3, max: 5, message: 'Length should be 3 to 5', trigger: 'blur' },
  ]
})

const submitForm = async (formEl: FormInstance | undefined) => {
  if (!formEl) return
  await formEl.validate((valid, fields) => {
    if (valid) {
      console.log('submit!')
    } else {
      console.log('error submit!', fields)
    }
  })
}

const resetForm = (formEl: FormInstance | undefined) => {
  if (!formEl) return
  formEl.resetFields()
}

const options = Array.from({ length: 10000 }).map((_, idx) => ({
  value: `${idx + 1}`,
  label: `${idx + 1}`,
}))
</script>

<el-dialog v-model="dialogFormVisible" :title="formTitle" width="30%"> 
  <!--rules绑定校验规则-->
  <el-form  
      :model="dept"  
      :rules="rules"  
  >  
    <!--prop指定使用哪条校验规则-->  
    <el-form-item label="部门名称" prop="name">  
      <el-input v-model="dept.name" autocomplete="off"/>  
    </el-form-item>  
  </el-form>  
  <template #footer>  
    <span class="dialog-footer">  
      <el-button @click="dialogFormVisible = false">取消</el-button>  
      <el-button type="primary" @click="save">  
        确定  
      </el-button>  
    </span>  
  </template>  
</el-dialog>

rules:FormRules的泛型需要指定针对哪个类型的校验规则,已经定义了DeptModel可以直接使用

const rules = ref<FormRules<DeptModel>>({  
  name: [  
    { required: true, message: '请输入部门名称', trigger: 'blur' },  
    { min: 2, max: 10, message: '部门名称长度在2-10位之间', trigger: 'blur' },  
  ]})
  • required:必填
  • message:校验失败的提示信息
  • triggr:触发校验的事件

但是此时的表单虽然校验不通过,点击保存按钮还是可以发起请求的,在save方法中我们应该判断表单校验是否通过,需要拿到表单的实例,通过实例进行校验

定义表单的实例引用对象:

const deptForm = ref<FormInstance>();

保存按钮:

<el-dialog v-model="dialogFormVisible" :title="formTitle" width="30%">  
  <el-form  
      :model="dept"  
      :rules="rules"  
  >  
    <el-form-item label="部门名称" prop="name">  
      <el-input v-model="dept.name" autocomplete="off"/>  
    </el-form-item>  
  </el-form>  
  <template #footer>  
    <span class="dialog-footer">  
      <el-button @click="dialogFormVisible = false">取消</el-button>  
      <!--保存按钮传递表单的校验规则-->  
      <el-button type="primary" @click="save(deptForm)">  <!--也可以不定义这个参数-->
        确定  
      </el-button>  
    </span>  
  </template>  
</el-dialog>

save方法进行校验:

let save = async (form:FormInstance | undefined) => {  
  
  if (!form) return  
  await form.validate(async (valid, fields) => {  
    if (valid) {  //valid -> true 校验 通过
      //校验通过  
      let result;  
  
      if (dept.value.id) {  
        result = await modifyInfoApi(dept.value);  
      } else {  
        result = await addApi(dept.value);  
      }  
  
      if (result.code) {  
        dialogFormVisible.value = false;  
        ElMessage.success('保存成功');  
        search();  
  
      } else {  
        ElMessage.error(result.msg);  
      }  
  
    } else {  
      //校验失败  
      ElMessage.error('校验失败,不能提交')  
    }  
  })  
  
}

实际上save方法不传递form实例也可以,直接使用

但是当前还是存在问题的:

用户第一次验证失败后,点击关闭,再次打开弹窗表单中存在的还是上一次的校验错误提示,表单的状态没有被重置

ElementPlus给出了表单状态重置的方法:

const resetForm = (formEl: FormInstance | undefined) => {
  if (!formEl) return
  formEl.resetFields()
}

根据前文的经验,我们应该在打开表单的时候进行状态重置:

// 2. 弹窗  
let add = () => {  
  //清空之前的dept.value.name  
  dept.value.name = '';  
  //显示对话框  
  dialogFormVisible.value = true;  
  //标题赋值  
  formTitle.value = '新增部门';  
  
  resetForm(deptForm.value);  
}

const update = async (id: number) => {  
  // 1. 数据回显  
  //清空之前的dept.value.name  
  dept.value.name = '';  
  //显示对话框  
  dialogFormVisible.value = true;  
  //设置标题  
  formTitle.value = '修改部门';  
    
  resetForm(deptForm.value);  
    
  dept.value = (await getInfoByIdApi(id)).data  
}

可以发现很多代码都是重复的,可以抽取为单独的方法:

//打开对话框的通用操作  
const openForm = ()=> {  
  //清空之前的dept.value.name  
  dept.value.name = '';  
  //显示对话框  
  dialogFormVisible.value = true;  
  //重置表单状态  
  resetForm(deptForm.value);  
}  
  
const update = async (id: number) => {  
  //重置  
  openForm();  
    
  //设置标题  
  formTitle.value = '修改部门';  
  dept.value = (await getInfoByIdApi(id)).data;  
}

let add = () => {  
  openForm();  
  //标题赋值  
  formTitle.value = '新增部门';  
}

员工管理

分页查询

页面布局

页面布局流程

  • 确定页面布局时所使用的Element组件
  • 确定涉及到的数据模型(接口、响应式数据)
搜索栏

如果表单封装的数据较多,建议封装在一个对象中

SearchEmpModel:专门用来封装搜索栏的表单数据

需要使用ElementPlus提供的日期组件el-date-picker,type=daterange得到的是两个时间:开始时间和结束时间,这两个时间对应了searchEmp中的一个属性date数组

<script setup lang="ts">  
  import {ref} from "vue";  
  import type {SearchEmpModel} from "@/api/model/model";  
  
  let searchEmp = ref<SearchEmpModel>({  
    name: '',  
    gender : '',  
    begin : '',  
    end : '',  
    date : []  
  });  
</script>  
  
<template>  
  <!-- 搜索栏 model指定封装在哪个对象中-->  
  <el-form :inline="true" :model="searchEmp" class="demo-form-inline">  
    <el-form-item label="姓名">  
      <el-input v-model="searchEmp.name" placeholder="请输入姓名"/>  
    </el-form-item>  
  
    <el-form-item label="性别">  
      <el-select v-model="searchEmp.gender" placeholder="请选择">  
        <el-option label="男" value="1" />  
        <el-option label="女" value="2" />  
      </el-select>  
    </el-form-item>  
  
    <el-form-item label="入职时间">  
      <el-date-picker  
          v-model="searchEmp.date"  
          type="daterange"  
          range-separator="到"  
          start-placeholder="开始时间"  
          end-placeholder="结束时间"  
          value-format="YYYY-MM-DD"  
      />  
    </el-form-item>  
  
    <el-form-item>  
      <el-button type="primary" @click="">查询</el-button>  
      <el-button @click="">清空</el-button>  
    </el-form-item>  
  </el-form>  
  
  <!-- 功能按钮 -->  
  <el-button type="success" @click="">+ 新增员工</el-button>  
  <el-button type="danger" @click="">- 批量删除</el-button>  
  <br><br>  
  
</template>

日期数据封装在date数组中,传递给服务器端的数据应该是begin和end,现在需要给begin、end进行赋值

此处的赋值需要使用[[Vue3#监听属性|监听属性]]

watch(() => searchEmp.value.date, (newValue, oldValue) => {  
  
	/*   
	if (newValue.length !== 2){  
		searchEmp.value.begin = '';        
		searchEmp.value.end = '';      
	}else {        
		searchEmp.value.begin = newValue[0];        
		searchEmp.value.end = newValue[1];      
	}*/  
	if (newValue.length != 2) {  
	newValue.push('', '');  
	}  
	searchEmp.value.begin = newValue[0];  
	searchEmp.value.end = newValue[1];  
  
}, {deep: true})
表格及分页
表格

  <!-- 列表展示 -->
  <el-table :data="empList" border style="width: 100%" fit >
    <el-table-column prop="name" label="姓名" align="center" width="130px" />
    <el-table-column prop="gender" label="性别" align="center" width="100px"/>
    <el-table-column prop="image" label="头像" align="center"/>
    <el-table-column prop="deptName" label="所属部门" align="center" />
    <el-table-column prop="job" label="职位" align="center" width="100px"/>
    <el-table-column prop="entryDate" label="入职时间" align="center" width="130px" />
    <el-table-column prop="updateTime" label="最后修改时间" align="center" />
    <el-table-column label="操作" align="center">
      <template #default="scope">
        <el-button type="primary" size="small" @click="">编辑</el-button>
        <el-button type="danger" size="small" @click="">删除</el-button>
      </template>
    </el-table-column>
  </el-table>
  <br>

  <!-- 分页组件Pagination -->
  <el-pagination
    v-model:current-page="pagination.currentPage"
    v-model:page-size="pagination.pageSize"
    :page-sizes="[5, 10, 20, 50, 100]"
    layout="total, sizes, prev, pager, next, jumper"
    :total="pagination.total"
    @size-change="handleSizeChange"
    @current-change="handleCurrentChange"
  />

表格第一列需要一个多选框,实现多选非常简单: 手动添加一个 el-table-column,设 type 属性为 selection 即可。

但是多选框的选中项是要向服务器提交的数据,在选中项变化的时候应该更新数据:

<!-- 列表展示 -->  
<el-table  
    :data="empList"  
    border  
    style="width: 100%"  
    fit  
    @selection-change="handleSelectionChange"  
>  
  <!--多选框-->  
  <el-table-column type="selection" width="55" />  
  <el-table-column prop="name" label="姓名" align="center" width="130px" />  
  <el-table-column prop="gender" label="性别" align="center" width="100px"/>  
  <el-table-column prop="image" label="头像" align="center"/>  
  <el-table-column prop="deptName" label="所属部门" align="center" />  
  <el-table-column prop="job" label="职位" align="center" width="100px"/>  
  <el-table-column prop="entryDate" label="入职时间" align="center" width="130px" />  
  <el-table-column prop="updateTime" label="最后修改时间" align="center" />  
  <el-table-column label="操作" align="center">  
    <template #default="scope">  
      <el-button type="primary" size="small" @click="">编辑</el-button>  
      <el-button type="danger" size="small" @click="">删除</el-button>  
    </template>  
  </el-table-column>  
</el-table>

@selection-change指定多选框选中项变化时的回调函数

分页
<!-- 分页组件Pagination -->  
<el-pagination  
    v-model:current-page="pagination.currentPage"  
    v-model:page-size="pagination.pageSize"  
    :page-sizes="[5, 10, 20, 50, 100]"  
    layout="total, sizes, prev, pager, next, jumper"  
    :total="pagination.total"  
    @size-change="handleSizeChange"  
    @current-change="handleCurrentChange"  
/>

分页组件的数据模型需要三个属性:

//分页参数接口  
export interface PaginationParam {  
  currentPage: number,  
  pageSize: number,  
  total: number  
}

currentPage和pageSize需要指定默认值,而total是在后端传递过来的:

<!-- 分页组件Pagination -->  
<el-pagination  
    v-model:current-page="pagination.currentPage"  
    v-model:page-size="pagination.pageSize"  
    :page-sizes="[5, 10, 20, 50, 100]"  
    layout="total, sizes, prev, pager, next, jumper"  
    :total="pagination.total"  
    @size-change="handleSizeChange"  
    @current-change="handleCurrentChange"  
/>
//分页条组件的数据模型  
let pagination = ref<PaginationParam>({
    //指定默认值
	currentPage : 1,
	pageSize : 5,
	total : 0
});

分页组件的current-page和page-size是v-model双向数据绑定,在用户点击的时候自定变为用户点击的值,并且触发@size-change和@current-change事件

页面交互

分页查询功能

需要的数据模型:

根据接口文档可以定义请求参数的数据模型:

而我们在上文中定义了两个数据模型:

//分页数据模型
export interface PaginationParam {  
  currentPage: number,  
  pageSize: number,  
  total: number  
}

//搜索栏数据模型
export interface SearchEmpModel {  
  name: string, //姓名  
  gender: string, //性别  
  begin: string, //开始时间  
  end: string, //结束时间  
  date: string[] //时间范围  
}

//继承这两个数据模型
export interface EmpPageQueryParam extends SearchEmpModel,PaginationParam{  
  
}

根据接口文档可以定义响应数据的数据模型:

//响应的数据:
{
  "code": 1,
  "msg": "success",
  "data": {
    "total": 1,
    "rows": [
       {
        "id": 1,
        "username": "jinyong",
        "password": "123456",
        "name": "金庸",
        "gender": 1,
        "image": "https://web-framework.oss-cn-hangzhou.aliyuncs.com/2022-09-02-00-27-53B.jpg",
        "job": 2,
        "salary": 8000,
        "entryDate": "2015-01-01",
        "deptId": 2,
        "deptName": "教研部",
        "createTime": "2022-09-01T23:06:30",
        "updateTime": "2022-09-02T00:29:04"
      }
  ]
}

数据模型:

//分页结果接口  
export interface PageModel {  
  total: number,  
  rows: any[]  
}  
  
//统一响应结果接口  
export interface PageResultModel {  
  code: number,  
  msg: string,  
  data: PageModel  
}

或者可以定义为:

export interface ResultModel<T> {  
    code: number,  
    msg: string,  
    data: T  
}  
export interface PageModel {  
    total: number,  
    rows: any[]  
}

提高了复用性

API接口层:

export const pageQueryApi =  
    (param:EmpPageQueryParam) => request.get<any,PageResultModel>  
(`/emps?name=${param.name}&gender=${param.gender}&begin=${param.begin}
                                &end=${param.end}&page=${param.currentPage}&pageSize=${param.pageSize}`)

或者是:

export const myPageQueryApi =  
    (param:EmpPageQueryParam) => request.get<any,ResultModel<PageModel>>  
(`/emps?name=${param.name}&gender=${param.gender}&begin=${param.begin}&end=${param.end}
&page=${param.currentPage}&pageSize=${param.pageSize}`)
  • 查询:

let search = async () => {  
  
  let pageBean = await pageQueryApi({...searchEmp.value, ...pagination.value});  
  
  //更新列表  
  empList.value = pageBean.data.rows;  
  //更新记录条数  
  pagination.value.total = pageBean.data.total;  
}

页码、条数变化的时候也需要调用search

清空功能

let clear = async ()=> {  
  //清空搜索栏
  searchEmp.value = {  
    name: '',  
    gender: '',  
    begin: '',  
    end: '',  
    date: []  
  };  
  //再次查询
  search();  
}

在清空之后,以下属性都变为了空字符串:

    name: '',  
    gender: '',  
    begin: '',  
    end: '',  

而我们在后端mybatis的动态SQL中对空字符串进行了判断。

  • 页面加载完成自动查询

新增员工

页面布局流程:

  • 确定要使用的Element组件
  • 确定涉及到的数据模型

页面布局

点击按钮 弹出对话框,新增/编辑员工,需要的数据有两部分:员工信息和工作经历信息

涉及的数据模型:

//员工工作经历数据接口  
export interface EmpExprModel {  
  id?: number,  
  empId?: number,  
  exprDate: string[], //时间范围  
  begin: string,  
  end: string,  
  company: string,  
  job: string  
}  
  
//员工数据接口  
export interface EmpModel {  
  id?: number,  
  username: string,  
  password: string,  
  name: string,  
  gender: string,  
  phone: string,  
  job: string,  
  salary: string,  
  image: string,  
  entryDate: string,  
  deptId: string,  
  deptName?: string,  
  exprList: EmpExprModel[]  
}

注意:数据模型中属性名的定义要参照接口文档

定义响应式对象:

let formTitle = ref<string>('');  
let dialogFormVisible = ref<boolean>(true);  
let labelWidth = ref<number>(80);  
  
let emp = ref<EmpModel>({  
  username : '',  
  password : '',  
  name : '',  
  gender : '',  
  phone: '',  
  job: '',  
  salary: '',  
  image: '',  
  entryDate: '',  
  deptId: '',  
  deptName: '',  
  exprList : []  
});
用户名/姓名布局

<el-dialog v-model="dialogFormVisible" :title="formTitle">  
  <el-form :model="emp">    <el-form-item label="用户名" :label-width="formLabelWidth">  
      <el-input v-model="emp.username" autocomplete="off" />    </el-form-item>  
    <el-form-item label="姓名" :label-width="formLabelWidth">  
      <el-input v-model="emp.name" autocomplete="off" />    </el-form-item>  
    <el-form-item label="性别" :label-width="formLabelWidth">  
      <el-select v-model="emp.gender" placeholder="请选择">  
        <el-option label="男" value="1" />  
        <el-option label="女" value="2" />  
      </el-select>    
    </el-form-item>  
 </el-form>  
 <template #footer>    
  <span class="dialog-footer">      
	  <el-button @click="dialogFormVisible = false">取消</el-button>  
	  <el-button type="primary" @click="dialogFormVisible = false">
	          保存  
	  </el-button>    
  </span>  
 </template>
</el-dialog>

当前显示的效果:

页面原型要求的显示效果:

要在一行中显示两个表单组件,就需要ElementPlus提供的Layout布局组件:通过基础的 24 分栏,迅速简便地创建布局。

Layout布局将一行(一个el-row)等分为24份,如果想设置两个组件大小相等,只需要分别设置两个组件(el-col)的属性 :span = 12

<el-dialog v-model="dialogFormVisible" :title="formTitle">  
  <el-form :model="emp">  
  
    <el-row>  
      <el-col :span="12">  
        <el-form-item label="用户名" :label-width="formLabelWidth">  
          <el-input v-model="emp.username" autocomplete="off" />  
        </el-form-item>  
      </el-col>  
      <el-col :span="12">  
        <el-form-item label="姓名" :label-width="formLabelWidth">  
          <el-input v-model="emp.name" autocomplete="off" />  
        </el-form-item>  
      </el-col>  
    </el-row>  
  
    <el-form-item label="性别" :label-width="formLabelWidth">  
      <el-select v-model="emp.gender" placeholder="请选择">  
        <el-option label="男" value="1" />  
        <el-option label="女" value="2" />  
      </el-select>  
    </el-form-item>  
  </el-form>  
  <template #footer>  
    <span class="dialog-footer">  
      <el-button @click="dialogFormVisible = false">取消</el-button>  
      <el-button type="primary" @click="dialogFormVisible = false">  
        保存  
      </el-button>  
    </span>  
  </template>  
</el-dialog>
性别/职位布局:列表优化

之前的布局方式:

<el-form-item label="性别">  
  <el-select v-model="searchEmp.gender" placeholder="请选择">  
    <el-option label="男" value="1"/>  
    <el-option label="女" value="2"/>  
  </el-select>  
</el-form-item>

这样做是没有问题的,但是如果后续要求 “男” 变为 “男士”,就要在HTML结构中一个一个修改,这样做太麻烦了。

建议做法:下拉列表的多个选项在数据模型中统一维护,好处是如果要添加选项/修改选项,就不需要在HTML中进行更改了

定义gender和job的响应式数据:

const genders = ref([{name : '男', value : '1'},{name : '女', value : '2'}])

const jobs = ref([  
  { name: '班主任', value: 1 },  
  { name: '讲师', value: 2 },  
  { name: '学工主管', value: 3 },  
  { name: '教研主管', value: 4 },  
  { name: '咨询师', value: 5 },  
  { name: '其他', value: 6 }  
])

在下拉列表中展示时:

<!--性别:第二行的第一列-->
<el-col :span="12">  
  <el-form-item label="性别" :label-width="labelWidth">  
    <el-select v-model="emp.gender" placeholder="请选择" style="width: 100%;">       <!--label属性:选项显示的内容需要动态绑定-->
      <el-option v-for="gender in genders" :key="gender.value" :value="gender.value" :label="gender.name"/>  
    </el-select>  
  </el-form-item>  
</el-col>

<!--职位:第四行的第二列-->
<el-col :span="12">  
  <el-form-item label="职位" :label-width="labelWidth">  
    <el-select v-model="emp.job" placeholder="请选择" style="width: 100%;">  
      <el-option v-for="job in jobs" :key="job.value" :label="job.name" :value="job.value" />  
    </el-select>  
  </el-form-item>  
</el-col>
部门布局

与上文中jobs、genders不同的是,部门数据应该是在后端查询后返回的,在api/dept.ts定义了查询所有部门的方法:

//dept.ts
export const queryAllApi = () => request.get<any,ResultModel>('/depts');

我们需要引入这个方法,但是引入这个方法名:queryAllApi 可能与本文件中其他的方法名冲突,可以指定别名:

import {queryAllApi as queryAllDeptsApi} from '@/api/dept'

let depts = ref<DeptModelArray>([]);
const queryAllDepts = async ()=> {     
  let result = await queryAllDeptsApi();  
  depts.value = result.data;  
}
  • queryAllDepts方法应该何时调用?

点击编辑和新增都会使用到这个对话框,也就是都需要使用部门数据,应该放在EmpIndexView的onMounted方法中调用:

onMounted(() => {  
  search();  
  queryAllDepts();  
})

此时所有的信息都被封装在depts中了,在下拉列表中渲染选项:

<el-col :span="12">  
  <el-form-item label="所属部门" :label-width="labelWidth">  
    <el-select v-model="emp.deptId" placeholder="请选择" style="width: 100%;">  
      <el-option v-for="dept in depts" :key="dept.id" :label="dept.name" :value="dept.id" />  <!--value指定为id-->
    </el-select>  
  </el-form-item>  
</el-col>

value属性是最终提交的值,需要指定为id

头像布局

<template>
  <el-upload
    class="avatar-uploader"
    action="https://run.mocky.io/v3/9d059bf9-4660-45f2-925d-ce80ad6c4d15"
    :show-file-list="false"
    :on-success="handleAvatarSuccess"  
    :before-upload="beforeAvatarUpload"
  >
  <!--
    action:上传地址
	on-success:上传成功hook
	before-upload:上传之前的hook
  -->
    <img v-if="imageUrl" :src="imageUrl" class="avatar" />
    <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
  </el-upload>
</template>

<script lang="ts" setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'

import type { UploadProps } from 'element-plus'

const imageUrl = ref('')

//成功上传的回调函数
const handleAvatarSuccess: UploadProps['onSuccess'] = (
  response,
  uploadFile
) => {
  imageUrl.value = URL.createObjectURL(uploadFile.raw!)
}

//上传之前的回调函数,返回false不进行上传,返回true进行上传
const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => {
  if (rawFile.type !== 'image/jpeg') {
    ElMessage.error('Avatar picture must be JPG format!')
    return false
  } else if (rawFile.size / 1024 / 1024 > 2) {
    ElMessage.error('Avatar picture size can not exceed 2MB!')
    return false
  }
  return true
}
</script>
  • before-upload:上传之前的回调函数,一般在该函数中进行文件校验
  • on-success:在该函数中写回URL路径

上传的效果:点击Icon上传,上传成功后显示上传的图片,核心的逻辑就是以下代码控制的:

    <img v-if="imageUrl" :src="imageUrl" class="avatar" />
    <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>

未上传时URL是空值,v-if不渲染img,渲染上传的Icon Plus,上传成功后,handleAvatarSuccess回调函数会将URL写入imageUrl,v-if渲染img,不渲染Icon

上传的核心属性:action,对于本系统的后端接口/upload来说:

  <el-upload
    class="avatar-uploader"
    action="/upload"
    :show-file-list="false"
    :on-success="handleAvatarSuccess"
    :before-upload="beforeAvatarUpload"
  >

这样是无法访问到我们的接口的,因为请求路径是:http://127.0.0.1:5173/upload

这个请求不是经过axios发送的,是el-upload组件发送的,不会加上/api路径,如果想让服务器进行跨域代理,需要设置action为:/api/upload

<!-- 第五行 -->  
<el-row>  
  <el-col :span="12">  
    <el-form-item label="头像"  :label-width="labelWidth">  
      <el-upload  
          class="avatar-uploader"  
          action="/api/upload"  
          :show-file-list="false"  
          :on-success="handleAvatarSuccess"  
          :before-upload="beforeAvatarUpload"  
      >  
        <img v-if="emp.image" :src="emp.image" class="avatar" /> <!--有url就显示图片-->  
        <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>  <!--没有url就显示图标-->  
      </el-upload>  
    </el-form-item>  
  </el-col>  
</el-row>
工作经历布局

点击添加工作经历,工作经历表单多一条记录;点击删除按钮,删除对应的记录

这个功能看起来比较复杂,需要谨记Vue的原则:Vue是基于数据驱动视图展示的

数据改变引起了视图的改变,对于工作经历来说,这个数组是具有响应式的:

  • 添加时,向数组里添加元素
  • 删除时,删除数组里的元素

一旦数据发生变化,视图中展示的数据就会发生变化

布局:

<!-- 第六行 -->  
<el-row>  
  <el-col :span="24">  
    <el-form-item label="工作经历" :label-width="labelWidth">  
      <el-button type="success" size="small" @click="addEmpExpr">+ 添加工作经历</el-button>  
    </el-form-item>  
  </el-col>  
</el-row>  
  
<!-- 遍历emp.exprList数组,渲染每一条工作经历 -->  
<el-row v-for="(expr,index) in emp.exprList" :gutter="5">  
  <el-col :span="10">  
    <el-form-item label="时间" size="small" :label-width="labelWidth">  
      <el-date-picker 
	      v-model="expr.exprDate" 
	      type="daterange" 
	      range-separator="至" 
	      start-placeholder="开始时间" 
	      end-placeholder="结束时间" 
	      value-format="YYYY-MM-DD"
	  />  
    </el-form-item>  
  </el-col>  
  
  <el-col :span="6">  
    <el-form-item label="公司" size="small">  
      <el-input placeholder="公司名称" v-model="expr.company" />  
    </el-form-item>  
  </el-col>  
  
  <el-col :span="6">  
    <el-form-item label="职位" size="small">  
      <el-input placeholder="职位名称" v-model="expr.job" />  
    </el-form-item>  
  </el-col>  
  
  <el-col :span="2">  
    <el-form-item size="small">  
      <el-button type="danger" @click="del(index/expr)">- 删除</el-button>  
    </el-form-item>  
  </el-col>  
</el-row>

函数:

//添加工作经历的函数
const addEmpExpr = ()=> {
	emp.value.exprList.push({exprDate : [],begin : '',end : '',company : '', job : ''})
}

//删除

//根据索引删除
const del = (index:number)=> {
	emp.value.exprList.splice(index,0,1);
}

/*
严格模式下不能使用
const del = (expr:EmpExprModel)=> {  
  with (emp.value.exprList) {  
    splice(indexOf(expr),1);  
  }  
}
*/

//根据对象删除
const del = (expr:EmpExprModel)=> {
	let index = emp.value.exprList.indexOf(expr);
	emp.value.exprList.splice(index,0,1);
}

接口文档要求的请求参数:

{
  "image": "https://web-framework.oss-cn-hangzhou.aliyuncs.com/2022-09-03-07-37-38222.jpg",
  "username": "linpingzhi",
  "name": "林平之",
  "gender": 1,
  "job": 1,
  "entrydate": "2022-09-18",
  "deptId": 1,
  "phone": "18809091234",
  "salary": 8000,
  "exprList": [
      {
         "company": "百度科技股份有限公司",
         "job": "java开发",
         "begin": "2012-07-01",
         "end": "2019-03-03"
      },
      {
         "company": "阿里巴巴科技股份有限公司",
         "job": "架构师",
         "begin": "2019-03-15",
         "end": "2023-03-01"
      }
   ]
}

我们当前的EmpExpr数据模型:

export interface EmpExprModel {  
  id?: number,  
  empId?: number,  
  exprDate: string[], //时间范围  
  begin: string,  
  end: string,  
  company: string,  
  job: string  
}

就需要对emp.value.exprList进行操作,将每一条数据的exprDate转变为end和begin

watch(emp,(newVal,oldVal) => {  
  if (emp.value.exprList.length > 0){  
    emp.value.exprList.forEach(expr => {  
      expr.end = expr.exprDate[0];  
      expr.begin = expr.exprDate[1];  
    })  
  }  
},{deep : true})

页面交互

完成新增员工的功能

为新增员工按钮绑定事件:

const addEmp = ()=> {  
  //清空上一次的表单数据  
  emp.value = {  
    username : '',  
    password : '',  
    name : '',  
    gender : '',  
    phone: '',  
    job: '',  
    salary: '',  
    image: '',  
    entryDate: '',  
    deptId: '',  
    deptName: '',  
    exprList : []  
  }  
    
  dialogFormVisible.value = true;  
}

打开对话框,为保存按钮添加事件

接口层:

export const createEmpApi = (emp:EmpModel) => request.post<any,ResultModel>('/emps',emp);

调用:

const save = async ()=> {  
  //一定注意传递的入参是emp.value  
  let result = await createEmpApi(emp.value);  
  if (result.code){  
    ElMessage.success('保存成功');  
    dialogFormVisible.value = false;  
  
    //重新查询  
    search();  
  }else {  
    ElMessage.error(result.msg);  
  }  
}
表单校验

在提交之前还需要进行表单校验

对新增员工进行表单校验需要参照界面原型的要求:

总结出如下的校验规则:

表单校验的流程:

  1. 定义表单实例 empFormRef,赋值给ref属性,用来在save方法中校验表单和在openDialog方法中重置表单状态
  2. 定义校验规则 FormRules,其中的泛型指定表单对应的数据模型,在需要校验的表单项上通过prop指定规则名称
<el-form :model="emp" ref="empFormRef" :rules="rules">
<el-form-item prop='校验规则名称'>

表单验证时机:

  1. 保存(新增/编辑)时,校验通过提交数据,不通过提示信息
  2. 打开对话框(新增/修改)时,重置表单校验规则

验证时机:

const save = async ()=> {  
  //注意async的位置  
  await empFormRef.value?.validate(async (valid,fields) => {  
    if (valid){  
      //一定注意传递的入参是emp.value  
      let result = await createEmpApi(emp.value);  
      if (result.code){  
        ElMessage.success('保存成功');  
        dialogFormVisible.value = false;  
        search();  
      }else {  
        ElMessage.error(result.msg);  
      }  
    }else {  
      ElMessage.error('表单校验失败,不能提交');  
    }  
  })  
}

重置表单校验规则:

const openForm = ()=> {  
  emp.value = {  
    username : '',  
    password : '',  
    name : '',  
    gender : '',  
    phone: '',  
    job: '',  
    salary: '',  
    image: '',  
    entryDate: '',  
    deptId: '',  
    deptName: '',  
    exprList : []  
  };  
  //重置表单  
  empFormRef.value?.resetFields();  
  dialogFormVisible.value = true;  
}

//新增员工按钮
const addEmp = ()=> {  
  formTitle.value = '新增员工';
  openForm();  
}

//编辑员工按钮
const update = async (id:number) => {  
  formTitle.value = '编辑员工';  
  openForm();  
  
  let result = await queryByIdApi(id);  
  if (result.code){  
    emp.value = result.data;  
    //后端会返回exprList,不需要判断空  
    emp.value.exprList.forEach(expr => {  
      expr.exprDate = [expr.begin,expr.end];  
    })  
  }else {  
    ElMessage.error('查询失败')  
  }  
}

修改员工

  1. 点击编辑按钮,数据回显:根据ID查询员工信息
  2. 点击保存,执行修改操作

数据回显

接口层:

export const queryByIdApi = (id:number) => request.get<any,ResultModel>(`/emps/${id}`)

更新方法:

const update = async (id:number) => {  
  formTitle.value = '编辑员工';  
  openForm();  
  let result = await queryByIdApi(id);  
  
  if (result.code){  
    emp.value = result.data;  
    //后端会返回exprList,不需要判断空  
    emp.value.exprList.forEach(expr => {  
      expr.exprDate = [expr.begin,expr.end];  
    })  
  }else {  
    ElMessage.error('查询失败')  
  }  
}

但是这样做是有问题的,数据回显不能显示。

之前的watch监听器将工作经历的exprDate转化为begin和end的代码:

watch(emp,(newVal,oldVal) => {  
  if (emp.value.exprList.length > 0){  
    emp.value.exprList.map(expr => {  
      expr.end = expr.exprDate[0];  
      expr.begin = expr.exprDate[1];  
    })  
  }  
},{deep : true})

只要emp发生变化,就对emp.value.exprList进行遍历,遍历时将exprDate数组分别赋值给begin、end

emp变化的三种清空:

  • 新增员工时发生变化,exprList可能是空数组,不会进行map,但最好判断exprList的长度 > 0
  • 清空emp时发生变化,exprList是空数组,不进行map
  • 数据回显时发生变化,exprList不是空数组,进行map,访问exprDate数组的元素

但是在数据回显的时候,后端接口没有返回exprDate属性,此时就是访问了undefined的元素,就会报错。

所以需要对watch再加一次判断,在exprDate不为空的时候进行赋值:

watch(() => emp.value.exprList,(newVal,oldVal) => {  
  if (emp.value.exprList.length > 0){  
    emp.value.exprList.map(expr => {  
      if (!expr.exprDate){  
        return;  
      }  
      expr.end = expr.exprDate[0];  
      expr.begin = expr.exprDate[1];  
    })  
  }  
},{deep : true})

这样就避免了在数据回显时导致emp发生变化触发此监听器,从而导致访问undefined。

保存修改

和新增员工使用同一个对话框,form表单的保存按钮绑定的是一个方法:

<!--保存/取消-->  
<template #footer>  
  <span class="dialog-footer">  
    <el-button @click="dialogFormVisible = false">取消</el-button>  
    <el-button type="primary" @click="save">保存</el-button>  
  </span>  
</template>

新增员工时的保存方法:

const save = async ()=> {  
  //注意async的位置  
  await empFormRef.value?.validate(async (valid,fields) => {  
    if (valid){  
      //一定注意传递的入参是emp.value  
      let result = await createEmpApi(emp.value);  
      if (result.code){  
        ElMessage.success('保存成功');  
        dialogFormVisible.value = false;  
        search();  
      }else {  
        ElMessage.error(result.msg);  
      }  
    }else {  
      ElMessage.error('表单校验失败,不能提交');  
    }  
  })  
}

新增和修改的区别就是新增是没有id的,修改有id,所以可以根据有无id的区别来调用新增和修改的不同接口层方法:

const save = async ()=> {  
  //注意async的位置  
  await empFormRef.value?.validate(async (valid,fields) => {  
    if (valid){  
      let result;  
  
      if (!emp.value.id){  
        //无id新增  
        result = await createEmpApi(emp.value);  
      }else {  
        //有id修改  
        result = await modifyEmpApi(emp.value);  
      }  
        
      if (result.code){  
        ElMessage.success('保存成功');  
        dialogFormVisible.value = false;  
        search();  
      }else {  
        ElMessage.error(result.msg);  
      }  
    }else {  
      ElMessage.error('表单校验失败,不能提交');  
    }  
  })  
}

删除员工

删除员工信息有两个操作入口:

  • 点击每条记录之后的 删除 按钮,删除当前条记录。
  • 点击多选框选中要删除的员工,点击批量删除,批量删除员工信息

批量删除或删除最终只需要调用服务端的同一个批量删除接口即可。

接口文档:

/emps?ids=1,2,3

删除的数据以get默认形式传递,接口层:

export const deleteApi = (ids:number[]) => request.delete<any,ResultModel>(`/emp/${ids}`)

以number[] 作为路径参数会自动将数组元素转化为 /emp/1,2,3

单个删除:点击删除按钮,删除单个数据

const deleteById = (id:number) => {  
  ElMessageBox.confirm(  
      '是否确认删除?',  
      'Warning',  
      {  
        confirmButtonText: '确认',  
        cancelButtonText: '取消',  
        type: 'warning',  
      }  
  )   //注意async的位置  
      .then(async () => {  
        //确认删除的回调函数 
	    //接口层入参是数组形式
        let result = await deleteApi([id]);  
        if (result.code){  
          //删除成功  
          ElMessage.success('删除成功')  
  
          search();  
        }else{  
          //删除失败:展示服务器响应的信息  
          ElMessage.error(result.msg)  
        }  
      })  
      .catch(() => {  
        ElMessage({  
          type: 'info',  
          message: '取消删除',  
        })  
      })  
}
  • 批量删除

多选框的实现参照ElementPlus官网:实现多选非常简单,手动添加一个 el-table-column,设 type 属性为 selection 即可。

多选框选项发生变化时会发生change事件,在ElementPlus中通过属性@selection-change指定回调函数:

<el-table  
    :data="empList"  
    border  
    style="width: 100%"  
    fit  
    @selection-change="handleSelectionChange"  
>  
  <!--多选框-->  
  <el-table-column type="selection" width="55" />  
  <el-table-column prop="name" label="姓名" align="center" width="130px" />  
  <el-table-column prop="gender" label="性别" align="center" width="100px"/>  
  <el-table-column prop="image" label="头像" align="center"/>  
  <el-table-column prop="deptName" label="所属部门" align="center" />  
  <el-table-column prop="job" label="职位" align="center" width="100px"/>  
  <el-table-column prop="entryDate" label="入职时间" align="center" width="130px" />  
  <el-table-column prop="updateTime" label="最后修改时间" align="center" />  
  <el-table-column label="操作" align="center">  
    <template #default="scope">  
      <el-button type="primary" size="small" @click="update(scope.row.id)">编辑</el-button>  
      <el-button type="danger" size="small" @click="deleteById(scope.row.id)">删除</el-button>  
    </template>  
  </el-table-column>  
</el-table>

回调函数应该将所有选中项的id存储在数组中

let ids = ref<(number|undefined)[]>([]);  
  
//多选框选择项变化  
const handleSelectionChange = (selectedEmps:EmpModelArray)=> {  
  //每次选中元素都会触发该方法  
  ids.value = selectedEmps.map(e => e.id);  
}

批量删除的方法和单个删除的方法只有一个地方不同:

//单个删除
let result = await deleteApi([id]);  

//批量删除
let result = await deleteApi(ids.value);  

可以抽取为deleteEmpBatch方法:

const deleteEmpBatch = (id?:number) => {  
  ElMessageBox.confirm(  
      '是否确认删除?',  
      'Warning',  
      {  
        confirmButtonText: '确认',  
        cancelButtonText: '取消',  
        type: 'warning',  
      }  
  )    
      .then(async () => {  
        let result;  
  
        if (id){  //传递了入参id就单个删除
          result = await deleteApi([id]);  
        }else {  //否则就多个删除
          //接口层为字符串入参:result = await deleteApi(ids.value.join(','))  
          result = await deleteApi(ids.value)  
        }  
  
        if (result.code){  
          //删除成功  
          ElMessage.success('删除成功')  
  
          search();  
        }else{  
          //删除失败:展示服务器响应的信息  
          ElMessage.error(result.msg)  
        }  
      })  
      .catch(() => {  
        ElMessage({  
          type: 'info',  
          message: '取消删除',  
        })  
      })  
}

但是这样做是有问题的,在此处只判断id是否存在的话,如果id不存在会将事件对象event传递进来,所以还需要判断id是否为number类型的

可以通过三目运算符简化:

const deleteEmpBatch = (id?:number) => {  
  ElMessageBox.confirm(  
      '是否确认删除?',  
      'Warning',  
      {  
        confirmButtonText: '确认',  
        cancelButtonText: '取消',  
        type: 'warning',  
      }  
  )    
      .then(async () => {  
  
        let result;  
  
        result = await deleteApi(id && type of id === 'number' ? [id] : ids.value)  
  
        if (result.code){  
          //删除成功  
          ElMessage.success('删除成功')  
          search();  
        }else{  
          //删除失败:展示服务器响应的信息  
          ElMessage.error(result.msg)  
        }  
      })  
      .catch(() => {  
        ElMessage({  
          type: 'info',  
          message: '取消删除',  
        })  
      })  
}

如果接口层的入参是string类型,需要传递: ids.value.join(‘,’)

绑定事件:

<el-button type="danger" @click="deleteEmpBatch">- 批量删除</el-button>

<el-button type="danger" size="small" @click="deleteEmpBatch(scope.row.id)">删除</el-button>

登录

页面布局

<script setup lang="ts">  
  import { ref } from 'vue'  
  import type { LoginEmp } from '@/api/model/model'  
  let loginForm = ref<LoginEmp>({username:'', password:''})  
  
</script>  
  
<template>  
  <div id="container">  
    <div class="login-form">  
      <el-form label-width="80px">  
        <p class="title">Tlias智能学习辅助系统</p>  
        <el-form-item label="用户名" prop="username">  
          <el-input v-model="loginForm.username" placeholder="请输入用户名"></el-input>  
        </el-form-item>  
  
        <el-form-item label="密码" prop="password">  
          <el-input type="password" v-model="loginForm.password" placeholder="请输入密码"></el-input>  
        </el-form-item>  
  
        <el-form-item>  
          <el-button class="button" type="primary" @click="">登 录</el-button>  
          <el-button class="button" type="info" @click="">重 置</el-button>  
        </el-form-item>  
      </el-form>  
    </div>  
  </div>  
</template>  
  
<style scoped>  
#container {  
  padding: 10%;  
  height: 410px;  
  background-image: url('../../assets/bg1.jpg');  
  background-repeat: no-repeat;  
  background-size: cover;  
}  
  
.login-form {  
  max-width: 400px;  
  padding: 30px;  
  margin: 0 auto;  
  border: 1px solid #e0e0e0;  
  border-radius: 10px;  
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);  
  background-color: white;  
}  
  
.title {  
  font-size: 30px;  
  font-family: '楷体';  
  text-align: center;  
  margin-bottom: 30px;  
  font-weight: bold;  
}  
  
.button {  
  margin-top: 30px;  
  width: 120px;  
}  
</style>

页面交互

用户登录成功后跳转到主页面,并且以后每次请求都要携带token

  • 完成基本的员工登录操作
import { ref } from 'vue'  
import type { LoginEmp } from '@/api/model/model'  
import {loginApi} from "@/api/login";  
import {ElMessage} from "element-plus";  
import {useRouter} from "vue-router";  
  
let loginForm = ref<LoginEmp>({username:'', password:''})  
//获取当前应用的路由实例  
let router = useRouter();  
  
const login = async () => {  
  let result = await loginApi(loginForm.value);  
  if (result.code){  
    ElMessage.success('登录成功');  
	//1. 存储token  
	  
	//2. 跳转页面  
	router.push('/index');
    }else {  
    ElMessage.error('登录失败')  
  }  
}
  • 将登陆成功后获取到的登录信息存储起来,方便在其他组件中使用

如果在项目的多个组件中共享数据,可以使用Vue3提供的[[Vue#状态管理:pinia|状态管理库Pinia]]

在pinia中保存用户的登录信息:

//loginEmp.ts
export const useLoginEmpStore = defineStore('loginEmp', () => {  
  
  //登录信息  
  const loginEmp = ref<LoginInfo>({});  
  
  //设置登录信息  
  const setLoginEmp = (loginEmpInfo:LoginInfo) => {  
    loginEmp.value = loginEmpInfo;  
  }  
  
  //获取登录信息  
  const getLoginEmp = () => {  
    return loginEmp.value;  
  }  
  
  //删除登录信息  
  const delLoginEmp = () => {  
    loginEmp.value = {}  
  }  
  
  return { loginEmp, setLoginEmp, getLoginEmp,delLoginEmp }  
})

建议使用use + 名字 + Store的形式

const login = async () => {  
  let result = await loginApi(loginForm.value);  
  if (result.code){  
    ElMessage.success('登录成功');  
    //1. 存储token  
    let loginEmpStore = useLoginEmpStore();  
    loginEmpStore.setLoginEmp(result.data);  
      
    //2. 跳转页面  
    router.push('/index');  
  
  }else {  
    ElMessage.error('登录失败')  
  }  
}

token已经被存储在pinia中了,只需要在后续的请求中携带pinia中的token就可以。

现在的问题是如何在请求头中携带token,我们将所有的交互逻辑抽取到api层了:

export const queryAllApi = () => request.get<any,ResultModel>('/depts');  
  
//接口文档指明参数为dept类型  
export const addApi = (dept:DeptModel) => request.post<any,ResultModel>('/depts',dept);  
  
export const getInfoByIdApi = (id:number) => request.get<any,ResultModel>(`/depts/${id}`);  
  
export const modifyInfoApi = (dept:DeptModel) => request.put<any,ResultModel>('/depts',dept);  
  
export const removeByIdApi = (id:number) => request.delete<any,ResultModel>(`/depts?id=${id}`)

在请求时调用的是我们封装的request:

import axios from 'axios'  
  
//创建axios实例对象  
const request = axios.create({  
  baseURL: '/api',  
  timeout: 600000  
})  
  
//axios的响应 response 拦截器  
request.interceptors.response.use(  
  (response) => { //成功回调  
    return response.data  
  },  
  (error) => { //失败回调  
    return Promise.reject(error)  
  }  
)  
  
export default request

在之前设置了响应拦截器,将AxiosResponse替换为服务器端响应的数据,也可以定义一个请求拦截器,为所有请求添加请求头token:

import axios from 'axios'  
import {useLoginEmpStore} from "@/stores/loginEmp";  
  
//创建axios实例对象  
const request = axios.create({  
    baseURL: '/api',  
    timeout: 600000  
})  
  
request.interceptors.request.use((config) => {  
        let loginEmpStore = useLoginEmpStore();  
        let loginEmp = loginEmpStore.getLoginEmp();  
          
        //如果登录信息存在并且有token  
        if (loginEmp && loginEmp.token){  
          config.headers['token'] = loginEmp.token;  
        }  
        return config;  
        
    }, (error) => {  
        return Promise.reject(error);  
    }  
)  
  
//axios的响应 response 拦截器  
request.interceptors.response.use(  
    (response) => { //成功回调  
        return response.data  
    },  
    (error) => { //失败回调  
        return Promise.reject(error)  
    }  
)  
  
export default request

这样所有的请求都会携带token(如果用户的登录信息存在的话)

  • 如果用户没有登录,直接访问组件的路径,比如/index,服务器会响应401状态码,此时应该让页面跳转到登录界面

第一种拦截方式:响应拦截器进行拦截

在响应拦截器中进行统一的拦截,如果是401状态码就跳转到登录界面:

//axios的响应 response 拦截器  
request.interceptors.response.use(  
    (response) => { //成功回调  
        return response.data  
    },  
    (error) => {   
        //非2xx状态码会进入次回调  
        //error是AxiosError对象,封装了response和request  
        if (error.response.status == 401){  
            ElMessage.error('登录失效,请重新登录');  
            router.push('/login');  
        }else {  
            ElMessage.error('接口访问异常');  //访问失败给用户提示
        }  
        return Promise.reject(error)  
    }  
)

注意:此处不能使用useRouter()函数获取router对象,需要导入router对象:

//index.ts
import { createRouter, createWebHistory } from 'vue-router'  
import {useLoginEmpStore} from "@/stores/loginEmp";  
import {ElMessage} from "element-plus";  
  
const router = createRouter({  
  ...
})  
  
export default router

在router/index.ts中导出了router对象,其他地方使用也可以导入这个对象:

//request.ts
import axios from 'axios'  
import {useLoginEmpStore} from "@/stores/loginEmp";  
import {ElMessage} from "element-plus";  
import router from "@/router";   //导入了 @/router/index.ts,index.ts可以省略
  
//创建axios实例对象  
const request = axios.create({  
    baseURL: '/api',  
    timeout: 600000  
})  
  
request.interceptors.request.use((config) => {  
	...
);  
  
//axios的响应 response 拦截器  
request.interceptors.response.use(  
	...
)  
  
export default request

第二种拦截方式:全局前置路由守卫

//router/index.ts

router.beforeEach((to, from, next) => {  

	//不是跳转到登录页面的路由都需要判断是否登录
    if (!to.path.match('/login')){  
  
    let loginEmpStore = useLoginEmpStore();  
    let loginEmp = loginEmpStore.getLoginEmp();  
    if (loginEmp && loginEmp.token){  
      //登录后继续路由跳转
      next();  
    }else{  
      ElMessage.error('请先登录');  
      //未登录跳转到登录界面
      router.push('/login');  
    }  
  
  }else {  
	//去往登录页面的路由直接跳转
    next();  
  }  
  
})

相比之下,第二种路由跳转方式不会向服务器端发起请求,但是实际开发中两种方式往往结合使用

退出登录

点击退出登录按钮,清空员工的登录信息,跳转到登录页面

<script setup lang="ts">  
  import {useLoginEmpStore} from "@/stores/loginEmp";  
  import router from "@/router";  
  import {ElMessage} from "element-plus";  
  import {ref} from "vue";  
  
  let loginEmpStore = useLoginEmpStore();  
  let name = ref<string>(loginEmpStore.getLoginEmp().name);  
  
  
  const logout = () => {  
    //1. 清空登录信息  
    loginEmpStore.delLoginEmp();  
    //2. 跳转到登录界面  
    ElMessage.success(`退出登录成功,${name.value}`);  
    router.push('/login');  
  }  
</script>  
  
<template>  
  <span class="title">Tlias智能学习辅助系统</span>  
    
  <span class="right_tool">  
          <a href="">  
            <el-icon><EditPen /></el-icon> 修改密码 &nbsp;&nbsp;&nbsp; |  &nbsp;&nbsp;&nbsp;  
          </a>  
          <!--让超链接失效-->
          <a href="javascript:void(0)" @click="logout">   
            <el-icon><SwitchButton /></el-icon> 退出登录 【{{name}}】  
          </a>  
        </span>  
</template>

打包部署