首页 > 其他分享 >Tlias-前端开发

Tlias-前端开发

时间:2024-03-14 20:56:42浏览次数:23  
标签:const name Tlias value id dept result 前端开发

Tlias

image.png

准备工作

image.png

  • 安装依赖
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';

页面布局

image.png

公共的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>

image.png

<!--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

image.png

   <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>

当前项目的需求:

image.png

四个子菜单,没有分组

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>

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

image.png

因为默认的请求路径是: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

部门管理功能实现

查询所有

页面布局

image.png

需要的组件: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"; 

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

image.png

<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功能:

image.png

复制链接作为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下会提示错误:

image.png

因为没有指定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对象的类型:

image.png

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对话框

image.png

<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>

此时的效果:

image.png

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

在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();  
}

表单校验

image.png

需要对表单进行校验,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 = '新增部门';  
}

员工管理

image.png

分页查询

页面布局

页面布局流程

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

image.png

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

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

image.png

需要使用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进行赋值

image.png

此处的赋值需要使用[[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})
表格及分页
表格

image.png

  <!-- 列表展示 -->
  <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事件

页面交互

分页查询功能

image.png

需要的数据模型:

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

image.png

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

//分页数据模型
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}`)
  • 查询:

image.png

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

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

清空功能

image.png

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

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

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

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

  • 页面加载完成自动查询

新增员工

页面布局流程:

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

页面布局

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

image.png

涉及的数据模型:

//员工工作经历数据接口  
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 : []  
});
用户名/姓名布局

image.png

<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>

当前显示的效果:

image.png

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

image.png

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

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

image.png

<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>
性别/职位布局:列表优化

image.png

之前的布局方式:

<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>
部门布局

image.png

与上文中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

头像布局

image.png

<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>
工作经历布局

image.png

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

这个功能看起来比较复杂,需要谨记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);  
  }  
}
表单校验

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

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

image.png

总结出如下的校验规则:

image.png

表单校验的流程:

  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. 点击保存,执行修改操作

image.png

数据回显

接口层:

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('表单校验失败,不能提交');  
    }  
  })  
}

删除员工

image.png

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

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

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

接口文档:

/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>

打包部署

前端项目需要部署在前端服务器Nginx上,先对tlias项目打包:

//package.json
{  
  "name": "tlias-management",  
  "version": "0.0.0",  
  "private": true,  
  "type": "module",  
  "scripts": {  
    "dev": "vite",  
    "build": "run-p type-check \"build-only {@}\" --",  //打包
    "preview": "vite preview",  
    "build-only": "vite build",  
    "type-check": "vue-tsc --build --force"  
  },

image.png

打包后会在项目根路径下生成dist文件夹,这是压缩后的项目。

Nginx是轻量级的Web服务器/反向代理服务器及电子邮件(IMAP/POP3)代理服务器,内存占用少、并发能力强

部署

image.png

  1. 部署:将dist文件夹下的文件复制到nginx安装目录的html目录下
  2. 启动:双击nginx.exe

直接启动Nginx可能会失败,使用 netstat -ano | findStr 80 查看到System进程占用了80端口

image.png

在日志文件中可以看到nginx启动失败,端口被占用。

修改nginx.conf文件夹:


events {
    worker_connections  1024;
}


http {
    include       mime.types;
    default_type  application/octet-stream;

    #log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
    #                  '$status $body_bytes_sent "$http_referer" '
    #                  '"$http_user_agent" "$http_x_forwarded_for"';

    #access_log  logs/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    #keepalive_timeout  0;
    keepalive_timeout  65;

    #gzip  on;

    server {
	    listen       88;  
	    server_name  localhost;
	    client_max_body_size 10m;

	    location / {
	        root   html;
	        index  index.html index.htm;
	    }
	    error_page   500 502 503 504  /50x.html;
	    location = /50x.html {
	    	root   html;
	    }
	}

}

将nginx端口修改为88,启动nginx,进行登录会报错404,请求地址:

http://localhost:88/api/login

因为我们的反向代理是在vite上设置的,此处就需要再次设置nginx的反向代理:

server {
    listen       90;
    server_name  localhost;
    client_max_body_size 10m;


    location / {
        root   html;
        index  index.html index.htm;
    }

	//反向代理
    location ^~ /api/ {
        rewrite ^/api/(.*)$ /$1 break;
        proxy_pass http://localhost:8080;
    }
	
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
    	root   html;
    }
}

这样就能成功访问了

标签:const,name,Tlias,value,id,dept,result,前端开发
From: https://www.cnblogs.com/euneirophran/p/18073939

相关文章

  • Tlias-后端开发
    开发规范前后端混合开发沟通成本高分工不明确:前端发起请求、数据响应的渲染一般都是后端程序员完成的不便管理难以维护前后端分离开发产品经理提供界面原型+需求,前端/后端分析并设计出接口文档,有了接口文档前端后端就可以并行开发了接口文档中的接口是功能性接口,按......
  • nuxt3前端开发教程
    源码请移步:springboot+vue3+nuxt3+ts+minio开发的dsblog3.0前后端博客-java大师(javaman.cn)目录结构如下:一、nuxt3配置文件这段代码是一个Nuxt.js的配置文件,用于定义Nuxt.js项目的配置选项。Nuxt.js是一个基于Vue.js的通用应用框架,它简化了Vue.js项目的创建和开发过程。......
  • 高级前端开发工程师必须要熟练掌握的数组知识
    昨天分享了对象相关的基础知识,今天我们来了解一下JavaScript中另外一个非常重要的数据类型— 数组。1. 什么是数组数组是一种数据结构,用于存储和组织一组相关的元素。在编程中,数组提供了一个有效的方式来处理大量相似或相关的数据。每个值在数组中都有一个唯一的索引......
  • pickBy 在前端开发中的最佳实践和使用场景举例说明
    pickBy是lodash中的一个函数,其作用是过滤对象中的属性,只保留符合条件的属性。它的用法如下:_.pickBy(object,[predicate=_.identity])其中,object是要过滤的对象,predicate是一个可选的函数,用于定义过滤条件。如果没有传入predicate函数,则默认使用_.identity函数,即返回......
  • 前端开发环境配置 nvm | npm 镜像 | git
    安装nvmnvm是一个node版本管理工具,它可以让我们安装多个node版本并在需要的时候切换#nvm下载地址https://github.com/coreybutler/nvm-windows/releases#nvm切换镜像nvmnpm_mirrorhttps://npmmirror.com/mirrors/npm/nvmnode_mirrorhttps://npmmirror.com/mirror......
  • 【前端开发】VSCode下载安装教程,新手入门(超详细)附安装包
    ​1.VSCode简介        VSCode,全称VisualStudioCode,是一款由微软开发的跨平台源代码编辑器,可用于Windows、Linux和macOS操作系统。以下是对VSCode的详细介绍:功能丰富:VSCode支持语法高亮、代码自动补全(又称IntelliSense)、代码重构、查看定义功能,并内置了命令行工......
  • 分享二十个web前端开发日常必备网站
    TNTWeb-全称腾讯新闻前端团队,组内小伙伴在Web前端、NodeJS开发、UI设计、移动APP等大前端领域都有所实践和积累。目前团队主要支持腾讯新闻各业务的前端开发,业务开发之余也积累沉淀了一些前端基础设施,赋能业务提效和产品创新。团队倡导开源共建,拥有各种技术大牛,团队Github地......
  • VSCOde+Nodejs+Typescript前端开发环境
    1.安装Node.js下载地址:https://nodejs.org/enlts版本:长久稳定版本安装:默认安装就可以了验证:node2.VSCode下载地址:https://code.visualstudio.com/Download安装:默认安装语言切换:安装中文插件,重启 2.1修改终端cmd模式:1.点击设置图标,选择CommandPalette 2.输入:Ter......
  • 前端开发时,什么时候url需要使用encodeURIComponent?
    在前端开发时,当需要将用户输入或者动态生成的字符串作为URL的一部分(特别是查询参数或路径片段)发送到服务器时,应当使用encodeURIComponent函数对字符串进行编码。以下是一些具体场景:查询参数:当你在URL中添加查询参数(queryparameters),例如通过?key=value的形式附加到URL末......
  • 前端开发时,点击重置按钮,列表会回到第一页查询呢,还是查询当前页,只是把输入框的条件清空
    点击重置按钮时,列表的行为取决于具体的应用需求和设计。通常有以下两种常见情况:回到第一页并清空查询条件:当用户点击重置按钮后,应用会清除所有输入框中的查询条件,并将列表刷新回第一页的数据(通常是默认排序或无条件查询的结果)。这种设计在用户想要从头开始检索数据或者需要查看......