笔记参考:https://cyborg2077.github.io/2022/09/29/ReggieTakeOut/

准备工作

  1. 建表

    • 方式一:通过数据库图形软件直接导入sql文件

      • 点击运行sql文件后弹框,选择本地的sql文件即可

    • 方式二:通过sql命令行工具进行

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      //创建数据库
      mysql> create database reggie character set utf8mb4;

      //展示所有数据库
      mysql> show databases;
      +--------------------+
      | Database |
      +--------------------+
      | information_schema |
      | mysql |
      | performance_schema |
      | reggie |
      | ssm |
      | sys |
      +--------------------+
      6 rows in set (0.00 sec)

      //使用指定数据库
      mysql> use reggie
      Database changed

      //导入预先的sql文件(注意,文件路径不能包含中文)
      mysql> source D:\CodeTools\03.Resource\db_reggie.sql
  2. 创建SpringBoot的工程,勾选Spring Web,MySql,在pom.xml中导入druidlombokMyBatisPlus坐标

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.4.2</version>
    </dependency>

    <dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.2.6</version>
    </dependency>

    <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    </dependency>
  3. 导入前端资源

    • 直接放在resources/static目录下

    • 如果放在resources下,需要配置资源映射

      config.WebMvcConfig.java

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      @Configuration
      @Slf4j
      public class WebMvcConfig extends WebMvcConfigurationSupport {
      @Override
      protected void addResourceHandlers(ResourceHandlerRegistry registry) {
      log.info("开始进行静态资源映射...");
      registry.addResourceHandler("/backend/**").addResourceLocations("classpath:/backend/");
      registry.addResourceHandler("/front/**").addResourceLocations("classpath:/front/");
      }
      }
  4. 完成配置文件application.yml,配置端口和数据库,驼峰映射和主键策略

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    server:
    port: 8080
    spring:
    datasource:
    druid:
    username: root
    password: wzy123
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/reggie?serverTimezone=UTC

    mybatis-plus:
    configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 日志
    map-underscore-to-camel-case: true # 将表名和字段名中下划线去掉,按照驼峰命名规则与属性名映射
    global-config:
    db-config:
    id-type: assign_id

后台系统登录功能

登录测试

目前数据库中只有一条管理者信息,username为admin,password为123456(数据库中显示不一致是因为经过了MD5加密)

浏览器中输http://localhost:8080/backend/page/login/login.html即可访问静态页面

login.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
methods: {
async handleLogin() {
this.$refs.loginForm.validate(async (valid) => {
if (valid) {
this.loading = true
let res = await loginApi(this.loginForm)
if (String(res.code) === '1') { // 1代表登录成功
localStorage.setItem('userInfo',JSON.stringify(res.data))
window.location.href= '/backend/index.html' //当前页面打开URL页面
} else {
this.$message.error(res.msg)
this.loading = false
}
}
})
}
}

注意这里的res,发现其有code、data、msg属性,需要对应后续R.java的内容

logout

1
2
3
4
5
6
7
8
9
10
11
12
logout() {
logoutApi().then((res)=>{
if(res.code === 1){
this.$confirm("是否确认登出账户?", "提示", {type: "info"}).then(() => {
localStorage.removeItem('userInfo')
window.location.href = '/backend/page/login/login.html'
}).catch(() => {
this.$message.info("取消操作");
});
}
})
},

进行了优化,退出账户时弹窗确认

其中loginApi和logoutApi内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function loginApi(data) {
return $axios({
'url': '/employee/login',
'method': 'post',
data
})
}

function logoutApi(){
return $axios({
'url': '/employee/logout',
'method': 'post',
})
}

创建对应的实体类

对应数据库中employee表创建实体类,这里创建entity包,等同于以前使用的pojo

entity.Employee.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Data //lombok
public class Employee implements Serializable {

private static final long serialVersionUID = 1L;
private Long id;
private String username;
private String name;
private String password;
private String phone;
private String sex;
private String idNumber;
private Integer status;
private LocalDateTime createTime;
private LocalDateTime updateTime;

@TableField(fill = FieldFill.INSERT)
private Long createUser;

@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateUser;
}

注意,这里属性有idNumber,而表中为id_number,这就用到了前面配置的驼峰映射

创建对应Service和Mapper

注意,这里的BaseMapperIServiceServiceImpl都是MP的功能

mapper.EmployeeMapper

声明Mapper注解,并且继承BaseMapper

1
2
3
@Mapper
public interface EmployeeMapper extends BaseMapper<Employee> {
}

service.EmployeeService

继承IService

1
2
public interface EmployeeService extends IService<Employee> {
}

service.impl.EmployeeServiceImpl

声明Service注解,继承ServiceImpl(传入mapper和entity泛型),实现EmployeeService接口

1
2
3
@Service
public class EmployeeServiceImpl extends ServiceImpl<EmployeeMapper, Employee> implements EmployeeService {
}

统一封装结果

这一步的作用主要是将返回结果(数据库信息或者异常信息)封装成统一的格式,便于前端人员使用。

common.R

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Data
public class R<T> {

private Integer code; //编码:1成功,0和其它数字为失败
private String msg; //错误信息
private T data; //数据
private Map map = new HashMap(); //动态数据

public static <T> R<T> success(T object) {
R<T> r = new R<T>();
r.data = object;
r.code = 1;
return r;
}

public static <T> R<T> error(String msg) {
R r = new R();
r.msg = msg;
r.code = 0;
return r;
}

public R<T> add(String key, Object value) {
this.map.put(key, value);
return this;
}

}

这里对应了前面登录测试中所说res的三个属性

创建对应Controller

  • 登录逻辑:

    点击登录按钮会发送请求,请求地址为/employee/login,请求方式为post。这时在后端受到请求后需要做如下判断:

  • 登出逻辑:

    用户点击页面退出按钮,发送请求,请求地址为/employee/logout,请求方式为post。在controller的logout方法中需要完成:

    • 清除session的用户id
    • 返回登录页面

controller.EmployeeController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@Slf4j
@RestController
@RequestMapping("/employee")
public class EmployeeController {

@Autowired
private EmployeeService employeeService;

/**
* 用户登录
*
* @param request:存储employee的id
* @param employee:接收前端传来的username和password
* @return
*/
@PostMapping("/login")
public R<Employee> login(HttpServletRequest request, @RequestBody Employee employee) {
//1.将页面提交的密码进行md5加密
String password = employee.getPassword();
password = DigestUtils.md5DigestAsHex(password.getBytes());

//2.根据用户提交的username查询数据库
LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<Employee>();//条件查询
queryWrapper.eq(Employee::getUsername, employee.getUsername());
Employee emp = employeeService.getOne(queryWrapper);

//3.如果没有查询到则返回到登录失败页面
if (emp == null) {
return R.error("登陆失败");
}
//4.密码比对,如果不一致则返回登录失败结果
if (!emp.getPassword().equals(password)) {
return R.error("登陆失败");
}
//5.查看员工状态,如果已经为禁用状态,则返回员工禁用结果
if (emp.getStatus() == 0) {
return R.error("账号已禁用");
}
//6.登陆成功,将员工id存入session并返回登录成功结果
request.getSession().setAttribute("employee", emp.getId());
return R.success(emp);
}

/**
* 用户退出
* @param request
* @return
*/
@PostMapping("/logout")
public R<String> logout(HttpServletRequest request){
//清除session中保存的的用户id
request.getSession().removeAttribute("employee");
return R.success("退出成功");
}

}
  • @RequestBody:主要用于接收前端传递给后端的json字符串。这里使用Employee接收相同的字段
  • HttpServletRequest :如果登录成功,将员工对应的id存到session一份,这样想获取一份登录用户的信息就可以随时获取出来

注意:单步调试时,会因时间过长而响应超时,这时可以在backend/js/request.js中修改timeout属性值,改完之后清除页面的缓存

完善登录功能

问题分析:

之前的登录功能,如果不登录直接访问 http://localhost/backend/index.html 也可以正常访问,这显然是不合理的。我们希望看到的效果是,只有登录成功才能看到页面,未登录状态则跳转到登录页面。那么具体改如何实现呢?

使用过滤器或拦截器,在过滤器或拦截器中判断用户是否登录,然后在选择是否跳转到对应页面

整体流程

测试filter拦截路径

filter.loginCheckFilter

在这个过滤器上添加@Componet让spring扫描,或者在启动类添加@ServletComponentScan注解后,会自动将带有@WebFilter的注解进行注入

1
2
3
4
5
6
7
8
9
10
11
12
13
@Slf4j
@Component //让spring扫描到这个filter
@WebFilter(filterName = "loginCheckFilter", urlPatterns = "/*")
public class LoginCheckFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
//将拦截到的URI输出到日志,{}是占位符,将自动填充request.getRequestURI()的内容
log.info("拦截到的URI:{}", request.getRequestURI());
filterChain.doFilter(request, response);
}
}

此时访问index页面,查看日志

1
2
3
2023-06-11 23:24:57.211  INFO 8800  : 拦截到的URI:/backend/plugins/axios/axios.min.map
2023-06-11 23:24:57.613 INFO 8800 : 拦截到的URI:/backend/plugins/axios/axios.min.map
2023-06-11 23:24:57.637 INFO 8800 : 拦截到的URI:/employee/page

编写Filter逻辑

  1. 导一下fastjson的坐标

    1
    2
    3
    4
    5
    <dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.62</version>
    </dependency>
  2. 获取本次请求的URI

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //获取本次请求的URI
    String uri = request.getRequestURI();
    //定义不需要被拦截的请求
    String[] urls = new String[]{
    "/employee/login.html",
    "/employee/logout.html",
    "/backend/**",
    "/front/**"
    };
  3. 判断本次请求是否需要处理

    PathMatcher 路径匹配器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    //路径匹配器
    public static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();

    private boolean check(String[] urls, String uri) {
    for (String url : urls) {
    boolean match = PATH_MATCHER.match(url, uri);
    if (match)
    return true;
    }
    return false;
    }
  4. 如果不需要处理,则直接放行

    1
    2
    3
    4
    if (check) {
    filterChain.doFilter(request, response);
    return;
    }
  5. 判断登录状态,如果已登录,则直接放行

    1
    2
    3
    4
    5
    //我们当初存的session是employee,所以这里就拿它判断
    if (request.getSession().getAttribute("employee") != null) {
    filterChain.doFilter(request,response);
    return;
    }
  6. 如果未登录则返回未登录结果

    1
    response.getWriter().write(JSON.toJSONString(R.error("NOTLOGIN")));

    当符合未登录状态的条件时,会自动重定向到登录页面。JS代码如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 响应拦截器
    service.interceptors.response.use(res => {
    if (res.data.code === 0 && res.data.msg === 'NOTLOGIN') {// 返回登录页面
    console.log('---/backend/page/login/login.html---')
    localStorage.removeItem('userInfo')
    window.top.location.href = '/backend/page/login/login.html'
    } else {
    return res.data
    }
    }
  7. 完整代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    @WebFilter(filterName = "loginCheckFilter",urlPatterns = "/*")
    @Slf4j
    @Component
    public class LoginCheckFilter implements Filter {

    //路径匹配
    public static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {

    //强转
    HttpServletRequest request = (HttpServletRequest) servletRequest;
    HttpServletResponse response = (HttpServletResponse) servletResponse;

    //1.获取本次请求的URI
    String requestURI = request.getRequestURI();
    log.info("拦截到请求:{}",requestURI);

    //定义不需要处理的请求
    String[] urls = new String[]{
    "/employee/login",
    "/employee/logout",
    "/backend/**", //静态资源
    "/front/**"
    };

    //2.判断本次请求是否需要处理
    boolean check = check(urls, requestURI);

    //3.如果不需要处理,则直接放行
    if (check) {
    log.info("本次请求:{},不需要处理",requestURI);
    filterChain.doFilter(request,response);
    return;
    }

    //4.判断登录状态,如果已登录,则直接放行
    if (request.getSession().getAttribute("employee") != null) {
    log.info("用户已登录,id为{}",request.getSession().getAttribute("employee"));
    filterChain.doFilter(request,response);
    return;
    }

    //5.如果未登录则返回未登录结果,通过输出流方式向客户端页面响应数据
    log.info("用户未登录");
    log.info("用户id{}",request.getSession().getAttribute("employee"));
    response.getWriter().write(JSON.toJSONString(R.error("NOTLOGIN")));

    }

    public boolean check(String[] urls, String requestURI){
    for (String url : urls) {
    boolean match = PATH_MATCHER.match(url, requestURI);
    if (match) {
    //匹配
    return true;
    }
    }
    //不匹配
    return false;
    }
    }

测试跳过登录直接访问index

直接输入http://localhost:8080/backend/index.html,输入日志如下:

1
2
3
INFO 11008 --- [nio-8080-exec-8] com.wzy.filter.LoginCheckFilter          : 拦截到请求:/employee/page
INFO 11008 --- [nio-8080-exec-8] com.wzy.filter.LoginCheckFilter : 用户未登录
INFO 11008 --- [nio-8080-exec-8] com.wzy.filter.LoginCheckFilter : 用户idnull

现象就是网页直接跳转到登录界面


添加员工

实现功能之前,我们先梳理一下整个执行流程

  1. 页面发送ajax请求,将新增员工页面中输入的数据以json的形式提交到服务端
  2. 服务端Controller接收页面提交的数据并调用Service将数据进行保存
  3. Service调用Mapper操作数据库,保存数据

流程分析

登录后会看到员工列表页面(backend/page/member/list.html),右上角有员工信息添加按钮

  1. list.html:添加员工按钮
1
2
3
4
5
6
<el-button
type="primary"
@click="addMemberHandle('add')"
>
+ 添加员工
</el-button>

点击后触发addMemberHandle方法

  1. list.htmladdMemberHandle方法, 确认参数是add调用后menuHandle方法(在index中声明)跳转指定页面
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 // 添加
addMemberHandle (st) {
if (st === 'add'){
window.parent.menuHandle({
id: '2',
url: '/backend/page/member/add.html',
name: '添加员工'
},true)
} else {
window.parent.menuHandle({
id: '2',
url: '/backend/page/member/add.html?id='+st,
name: '修改员工'
},true)
}
},

此时跳转到/backend/page/member/add.html

  1. add.html:信息输入表单
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
<el-form
ref="ruleForm"
:model="ruleForm"
:rules="rules"
:inline="false"
label-width="180px"
class="demo-ruleForm"
>
<el-form-item label="账号:" prop="username">
<el-input v-model="ruleForm.username" placeholder="请输入账号" maxlength="20"/>
</el-form-item>
<el-form-item
label="员工姓名:"
prop="name"
>
<el-input
v-model="ruleForm.name"
placeholder="请输入员工姓名"
maxlength="20"
/>
</el-form-item>

<el-form-item
label="手机号:"
prop="phone"
>
<el-input
v-model="ruleForm.phone"
placeholder="请输入手机号"
maxlength="20"
/>
</el-form-item>
<el-form-item
label="性别:"
prop="sex"
>
<el-radio-group v-model="ruleForm.sex">
<el-radio label="男"></el-radio>
<el-radio label="女"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
label="身份证号:"
prop="idNumber"
>
<el-input
v-model="ruleForm.idNumber"
placeholder="请输入身份证号"
maxlength="20"
/>
</el-form-item>
<div class="subBox address">
<el-form-item>
<el-button @click="goBack()">
取消
</el-button>
<el-button
type="primary"
@click="submitForm('ruleForm', false)"
>
保存
</el-button>
<el-button
v-if="actionType == 'add'"
type="primary"
class="continue"
@click="submitForm('ruleForm', true)"
>
保存并继续添加
</el-button>
</el-form-item>
</div>
</el-form>

注意:其中点击保存按钮后会触发submitForm函数;数据模型绑定的是ruleForm

  1. add.html: submitForm函数, 将表单的数据封装成json对象,传递给addEmployee函数,发送添加请求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
submitForm (formName, st) {
this.$refs[formName].validate((valid) => {
if (valid) {
if (this.actionType === 'add') {
//将表单的数据封装成json对象
const params = {
...this.ruleForm,
sex: this.ruleForm.sex === '女' ? '0' : '1'
}
//传递给`addEmployee`函数,发送添加请求
addEmployee(params).then(res => {
if (res.code === 1) {
this.$message.success('员工添加成功!')
if (!st) {
this.goBack()
} else {
this.ruleForm = {
username: '',
'name': '',
'phone': '',
// 'password': '',
// 'rePassword': '',/
'sex': '男',
'idNumber': ''
}
}
} else {
this.$message.error(res.msg || '操作失败')
}
}).catch(err => {
this.$message.error('请求出错了:' + err)
})
} else {
const params = {
...this.ruleForm,
sex: this.ruleForm.sex === '女' ? '0' : '1'
}
editEmployee(params).then(res => {
if (res.code === 1) {
this.$message.success('员工信息修改成功!')
this.goBack()
} else {
this.$message.error(res.msg || '操作失败')
}
}).catch(err => {
this.$message.error('请求出错了:' + err)
})
}
} else {
console.log('error submit!!')
return false
}
})
}

可以看出前端通过判断code属性来确定是否添加员工,所以controller返回R即可

  1. backend/api/member.js
1
2
3
4
5
6
7
8
// 新增---添加员工
function addEmployee (params) {
return $axios({
url: '/employee',
method: 'post',
data: { ...params }
})
}

这就像通常的方法,使用axios传递请求给controller,进行员工添加

控制器方法

  • 前端传来账号名、用户名、手机号、性别、身份证号信息,封装在json格式中

  • 后端主要处理默认密码(MD5加密)、注册时间、更新时间、创建人ID和修改人ID即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@PostMapping
public R<String> save(HttpServletRequest request,@RequestBody Employee employee){
log.info("新增员工信息:{}",employee.toString());

//设置初始密码,使用md5加密
employee.setPassword(DigestUtils.md5DigestAsHex("123456".getBytes()));

employee.setCreateTime(LocalDateTime.now());
employee.setUpdateTime(LocalDateTime.now());

//获取当前登录用户的id,因为登陆后会将用户信息存入session,所以这里可以获取到
Long empId = (Long) request.getSession().getAttribute("employee");

employee.setCreateUser(empId);
employee.setUpdateUser(empId);

employeeService.save(employee);
return R.success("新增员工成功");
}

这时控制台可以看出使用了sql添加语句:

1
2
3
4
JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@3d0f16eb] will not be managed by Spring
==> Preparing: INSERT INTO employee ( id, username, name, password, phone, sex, id_number, create_time, update_time, create_user, update_user ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )
==> Parameters: 1669688676585836545(Long), wzy(String), 武志洋(String), e10adc3949ba59abbe56e057f20f883e(String), 13264429299(String), 1(String), 420574200005070010(String), 2023-06-16T20:49:36.465289700(LocalDateTime), 2023-06-16T20:49:36.465289700(LocalDateTime), 1(Long), 1(Long)
<== Updates: 1

注意:此时如果添加相同账号名的数据就会抛出异常,因为在建表的时候设定了unique,只能存在唯一的username

添加全局异常处理器

针对上面的问题,这里建立全局异常处理器,集中处理异常。在common包下创建一个全局异常处理类GlobalExceptionHandler,并添加exceptionHandler方法用来捕获异常,并返回结果

common/GlobalExceptionHandler.java

1
2
3
4
5
6
7
8
9
10
11
@Slf4j
@ResponseBody
@ControllerAdvice(annotations = {RestController.class, Controller.class})
public class GlobalExceptionHandler {

@ExceptionHandler(SQLIntegrityConstraintViolationException.class)
public R<String> exceptionHandler(SQLIntegrityConstraintViolationException ex){
log.error(ex.getMessage());
return R.error("用户名已存在");
}
}
  • @ControllerAdvice表示处理器处理哪些类
  • @ExceptionHandler表示处理的异常类型

如果这时输入已存在的用户名,就会弹窗“用户名已存在”,且控制台报错:

1
ERROR 16952 --- [nio-8080-exec-5] com.wzy.common.GlobalExceptionHandler    : Duplicate entry 'wzy' for key 'employee.idx_username'

我们希望给出的错误信息为该用户名已存在,所以我们就需要对错误信息来进行判断,如果错误信息中包含Duplicate entry,则说明有条目是重复的,所以用split()方法来对错误信息切片,取出重复的username,采用字符串拼接的方式,告知该用户已经存在了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Slf4j
@ResponseBody
@ControllerAdvice(annotations = {RestController.class, Controller.class})
public class GlobalExceptionHandler {

@ExceptionHandler(SQLIntegrityConstraintViolationException.class)
public R<String> exceptionHandler(SQLIntegrityConstraintViolationException ex){
log.error(ex.getMessage());
//如果包含Duplicate entry,则说明有条目重复
if (ex.getMessage().contains("Duplicate entry")) {
//对字符串切片
String[] split = ex.getMessage().split(" ");
//字符串格式是固定的,所以这个位置必然是username
String msg = split[2] + "已存在";
//拼串作为错误信息返回
return R.error(msg);
}
//如果是别的错误那我也没招儿了
return R.error("未知错误");
}
}

此时输入重复用户就会报错:

image-20230616210928499


员工信息分页查询

梳理一下整个程序的执行过程:

  1. 页面发送ajax请求,将分页查询参数(page、pageSize、name)提交到服务
  2. 服务端Controller接收页面提交的数据并调用Service查询数据
  3. Service调用Mapper操作数据库,查询分页数据
  4. Controller将查询到的分页数据响应给页面
  5. 页面接收到分页数据并通过ElementUI的Table组件展示到页面上

前端代码分析

当进入到员工页面后,会发现前端发送请求(F12查看Network):

当登录后进入到员工页面,会自动展示员工信息,这时因为vue的created方法自动调用的缘故:

  1. backend/page/member/list.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
created() {
this.init() //
this.user = JSON.parse(localStorage.getItem('userInfo')).username
},
mounted() {
},
methods: {
async init () { //
const params = {
page: this.page,
pageSize: this.pageSize,
name: this.input ? this.input : undefined
}
await getMemberList(params).then(res => { //
if (String(res.code) === '1') {
this.tableData = res.data.records || []
this.counts = res.data.total
}
}).catch(err => {
this.$message.error('请求出错了:' + err)
})
},

其中init函数中封装了分页查询的相关信息,并且是json格式,这里和请求url对应是设置了全局的get请求拦截器,将json内容转成拼接形式;getMemberList函数传递参数给后端处理

  1. backend/api/member.js: getMemberList函数
1
2
3
4
5
6
7
function getMemberList (params) {
return $axios({
url: '/employee/page',
method: 'get',
params
})
}
  1. backend/page/member/list.html
1
2
3
4
5
await getMemberList(params).then(res => {
if (String(res.code) === '1') {
this.tableData = res.data.records || []
this.counts = res.data.total
}

后端处理后返回数据,函数接收后判断code是否正常,然后封装到tableData(E-UI的组件)中展示

到这里我们可以看出,后端拦截/employee/page即可,接收分页相关数据

后端实现

配置MyBatisPlus分页插件

config/MybatisPlusConfig.java

1
2
3
4
5
6
7
8
9
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(){
MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor();
mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return mybatisPlusInterceptor;
}
}

控制器方法

首先分析传给后端的数据,除了分页数据page和pageSize,还会包括条件查询的name

初步测试

1
2
3
4
5
@GetMapping("/page")
public R<Page> page(int page, int pageSize, String name){
log.info("page={},pageSize={},name={}", page, pageSize, name);
return null;
}

注意,这里的Page是MP内置的类,包含前面前端页面中res.data.recordsres.data.total

这里进行初步测试,控制台输出如下,可以看出接收到了前端传来的信息

1
INFO 2832 --- [io-8080-exec-10] com.wzy.controller.EmployeeController : page=1,pageSize=10,name=null

完善功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@GetMapping("/page")
public R<Page> page(int page, int pageSize, String name){
log.info("page={},pageSize={},name={}", page, pageSize, name);

//构造分页构造器
Page<Employee> pageInfo = new Page<>(page, pageSize);
//构造条件构造器
LambdaQueryWrapper<Employee> lqw = new LambdaQueryWrapper<>();
//添加过滤条件
lqw.like(!(name == null || "".equals(name)), Employee::getName, name);
//并对查询的结果进行降序排序,根据更新时间
lqw.orderByDesc(Employee::getUpdateTime);
//执行查询
employeeService.page(pageInfo, lqw);
return R.success(pageInfo);
}

image-20230616215834595


启用/禁用员工账号

禁用数据类型

现象:前端传给页面的status数据为Integer类型,到页面展示效果的时候显示的是已禁用或者正常

原因:前端动态获取每条数据,将其中的state属性使用三位运算符进行替换处理

1
2
3
4
5
<el-table-column label="账号状态">
<template slot-scope="scope">
{{ String(scope.row.status) === '0' ? '已禁用' : '正常' }}
</template>
</el-table-column>

需求分析

  1. 在员工管理列表页面,可以对某个员工账号进行启用或者禁用操作。账号禁用的员工不能登录系统,启用后的员工可以正常登录。
  2. 需要注意,只有管理员(admin用户)可以对其他普通用户进行启用、禁用操作,所以普通用户登录系统后启用、禁用按钮不显示。
  3. 管理员admin登录系统可以对所有员工账号进行启用、禁用操作。
  4. 如果某个员工账号状态为正常,则按钮显示为“禁用”,如果员工账号状态为已禁用,则按钮显示为“启用”

image-20230616215834595

按钮动态显示效果

前面说到,当登录用户为管理员时,右侧会显示对员工账号的禁用或启用操作,而普通用户则没有这一按钮。这个功能通过动态按钮实现。

  1. 在vue的created函数中,除了之前的init函数实现分页查询外,还有一句从localStorage中获取登录用户信息的语句,并获取其username
1
2
3
4
created() {
this.init()
this.user = JSON.parse(localStorage.getItem('userInfo')).username
},
  1. 在login登录界面,登录成功后会将用户信息保存在localStorage中,key名为”userInfo”
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
methods: {
async handleLogin() {
this.$refs.loginForm.validate(async (valid) => {
if (valid) {
this.loading = true
let res = await loginApi(this.loginForm)
if (String(res.code) === '1') {
localStorage.setItem('userInfo',JSON.stringify(res.data))
window.location.href= '/backend/index.html' //当前页面打开URL页面
} else {
this.$message.error(res.msg)
this.loading = false
}
}
})
}
}
  1. 登录后可以看到,将登录用户的信息保存在localStorage中

image-20230618151807087

  1. 然后这里按钮判断user是否时管理员,从而显示不同的效果
1
2
3
4
5
6
7
8
9
<el-button
type="text"
size="small"
class="delBut non"
@click="statusHandle(scope.row)"
v-if="user === 'admin'"
>
{{ scope.row.status == '1' ? '禁用' : '启用' }}
</el-button>

请求执行过程

  1. 首先点击禁用按钮,F12查看请求内容

    • 请求路径和方式

    • 携带参数(id和status)

  2. 禁用按钮点击后会触发点击事件statusHandle()

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <el-button
    type="text"
    size="small"
    class="delBut non"
    @click="statusHandle(scope.row)"
    v-if="user === 'admin'"
    >
    {{ scope.row.status == '1' ? '禁用' : '启用' }}
    </el-button>

    statusHandle()

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    //状态修改
    statusHandle (row) {
    this.id = row.id
    this.status = row.status
    this.$confirm('确认调整该账号的状态?', '提示', {
    'confirmButtonText': '确定',
    'cancelButtonText': '取消',
    'type': 'warning'
    }).then(() => {
    enableOrDisableEmployee({ 'id': this.id, 'status': !this.status ? 1 : 0 }).then(res => {
    console.log('enableOrDisableEmployee',res)
    if (String(res.code) === '1') {
    this.$message.success('账号状态更改成功!')
    this.handleQuery()
    }
    }).catch(err => {
    this.$message.error('请求出错了:' + err)
    })
    })
    },

    这里将每行数据拿到,然后弹窗确认,如果确认就会调用enableOrDisableEmployee方法实现status的启用或禁用

    enableOrDisableEmployee()

    1
    2
    3
    4
    5
    6
    7
    8
    // 修改---启用禁用接口
    function enableOrDisableEmployee (params) {
    return $axios({
    url: '/employee',
    method: 'put',
    data: { ...params }
    })
    }

    可以看出这里发送ajax请求,将param传递给后端,后端进行数据库的修改即可

控制器方法

启用、禁用员工账号,本质上就是一个更新操作,也就是对status状态字段进行操作在Controller中创建update方法,此方法是一个通用的修改员工信息的方法

只不过现在我们的update只需要修改status,而后面我们还有修改员工其他信息的业务,根据传进来的employee

初步测试

1
2
3
4
5
@PutMapping
public R<String> update(@RequestBody Employee employee){
log.info("employee:{}", employee.toString());
return null;
}

输出日志:

1
employee:Employee(id=1670279472049610800, username=null, name=null, password=null, phone=null, sex=null, idNumber=null, status=0, createTime=null, updateTime=null, createUser=null, updateUser=null)

其中前端只传递了id和status

功能实现

1
2
3
4
5
6
7
8
9
10
@PutMapping
public R<String> update(HttpServletRequest request,@RequestBody Employee employee){
log.info("employee:{}", employee.toString());
//获取当前账户id
Long id = (Long) request.getSession().getAttribute("employee");
employee.setUpdateUser(id);
employee.setUpdateTime(LocalDateTime.now());
employeeService.updateById(employee);
return R.success("员工信息修改成功");
}

更新:更新时间、更新用户,然后根据id更新数据库

输出效果

点击禁用后,虽然弹出禁用成功,但是实际status并没有变化,查看输出日志发现更新数据为0,说明更新失败

1
2
3
4
JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@3bb5fe38] will not be managed by Spring
==> Preparing: UPDATE employee SET status=?, update_time=?, update_user=? WHERE id=?
==> Parameters: 0(Integer), 2023-06-18T16:21:58.486309100(LocalDateTime), 1(Long), 1670279472049610800(Long)
<== Updates: 0

观察数据库中内容,发现二者id存在差别:数据库:1670279472049610753 VS 查询:1670279472049610800。发现二者后三位存在差异

问题的原因:

  • JS对Long型数据进行处理时丢失精度(id为19位,而js处理long16位),导致提交的id和数据库中的id不一致。

如何解决这个问题?

  • 我们可以在服务端给页面响应json数据时进行处理,将Long型数据统一转为String字符串

配置状态转换器

根据前面的问题,这里配置状态转换器进行转换

  1. 配置对象映射器JacksonObjectMapper,继承ObjectMapper。基于Jackson进行java对象到json数据的转换

    直接粘贴在common包下即可

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    import com.fasterxml.jackson.databind.DeserializationFeature;
    import com.fasterxml.jackson.databind.module.SimpleModule;
    import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
    import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
    import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
    import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
    import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
    import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
    import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
    import java.math.BigInteger;
    import java.time.LocalDate;
    import java.time.LocalDateTime;
    import java.time.LocalTime;
    import java.time.format.DateTimeFormatter;
    import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES;

    /**
    * 对象映射器:基于jackson将Java对象转为json,或者将json转为Java对象
    * 将JSON解析为Java对象的过程称为 [从JSON反序列化Java对象]
    * 从Java对象生成JSON的过程称为 [序列化Java对象到JSON]
    */
    public class JacksonObjectMapper extends ObjectMapper {

    public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
    public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
    public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";

    public JacksonObjectMapper() {
    super();
    //收到未知属性时不报异常
    this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);

    //反序列化时,属性不存在的兼容处理
    this.getDeserializationConfig().withoutFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);


    SimpleModule simpleModule = new SimpleModule()
    .addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
    .addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
    .addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)))

    .addSerializer(BigInteger.class, ToStringSerializer.instance)
    .addSerializer(Long.class, ToStringSerializer.instance)
    .addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
    .addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
    .addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)));

    //注册功能模块 例如,可以添加自定义序列化器和反序列化器
    this.registerModule(simpleModule);
    }
    }
  2. WebMvcConfig配置类中扩展Spring MVC的消息转换器,在此消息转换器中使用提供的对象转换器进行java对象到json数据的转换

    这里在config包下创建WebMvcConfig类,前面0-准备工作中导入前端页面部分也涉及这个类。

    • SSM方式:如果没有配置前面的资源映射,使用springboot默认方式,则此时运行会找不到静态页面
    • Springboot方式:不需要配置资源映射
    • SSM方式:extends WebMvcConfigurationSupport
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    @Slf4j
    @Configuration
    public class WebMvcConfig extends WebMvcConfigurationSupport {

    @Override
    protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
    //创建消息转换器对象
    MappingJackson2HttpMessageConverter messageConverter = new MappingJackson2HttpMessageConverter();
    //设置对象转化器,底层使用jackson将java对象转为json
    messageConverter.setObjectMapper(new JacksonObjectMapper());
    //将上面的消息转换器对象追加到mvc框架的转换器集合当中(index设置为0,表示设置在第一个位置,避免被其它转换器接 收,从而达不到想要的功能)
    converters.add(0, messageConverter);
    }
    }
    • SpringBoot方式implements WebMvcConfigurer

      以后默认使用下述方式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    @Slf4j
    @Configuration
    public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
    //创建消息转换器对象
    MappingJackson2HttpMessageConverter messageConverter = new MappingJackson2HttpMessageConverter();
    //设置对象转化器,底层使用jackson将java对象转为json
    messageConverter.setObjectMapper(new JacksonObjectMapper());
    //将上面的消息转换器对象追加到mvc框架的转换器集合当中(index设置为0,表示设置在第一个位置,避免被其它转换器接收,从而达不到想要的功能)
    converters.add(0, messageConverter);
    }
    }

最终效果

此时点击禁用后,页面的状态就会发生变化

目前存在问题:管理员也可以禁用自己,如果此时登出下次就无法登录,所以需要在前端进行相应条件判断,如果是管理员就不显示禁用按钮。


编辑员工信息

在开发代码之前,我们先来梳理一下整个操作流程与对应程序的执行顺序

  1. 点击编辑按钮时,页面将跳转到add.html,并在url中携带参数员工id
  2. add.html页面中获取url中的参数员工id
  3. 发送ajax请求,请求服务端,同时提交员工id参数
  4. 服务端接受请求,并根据员工id查询员工信息,并将员工信息以json形式响应给页面
  5. 页面接收服务端响应的json数据,并通过Vue的双向绑定进行员工信息回显
  6. 点击保存按钮,发送ajax请求,将页面中的员工信息以json形式提交给服务端
  7. 服务端接受员工信息,并进行处理,完成后给页面响应
  8. 页面接收到服务端响应信息后进行相应处理

注意add.html为公共页面,新增员工和编辑员工都在此页面进行

请求执行过程

  1. list.html页面的编辑按钮点击事件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <el-button
    type="text"
    size="small"
    class="blueBug"
    @click="addMemberHandle(scope.row.id)"
    :class="{notAdmin:user !== 'admin'}"
    >
    编辑
    </el-button>

    点击后触发addMemberHandle()方法,并传递此行用户的id

  2. addMemberHandle函数,因为公用了add.html页面,所以这里判断传递的参数,如果是add就是新增,如果是id就是修改

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
     // 添加
    addMemberHandle (st) {
    if (st === 'add'){
    window.parent.menuHandle({
    id: '2',
    url: '/backend/page/member/add.html',
    name: '添加员工'
    },true)
    } else {
    window.parent.menuHandle({
    id: '2',
    url: '/backend/page/member/add.html?id='+st,
    name: '修改员工'
    },true)
    }
    },

    如果是修改,就跳转到add.html页面,并且携带id参数

  3. add.html页面

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    created() {
    this.id = requestUrlParam('id')
    this.actionType = this.id ? 'edit' : 'add'
    if (this.id) {
    this.init()
    }
    },
    methods: {
    async init () {
    queryEmployeeById(this.id).then(res => {
    console.log(res)
    if (String(res.code) === '1') {
    console.log(res.data)
    this.ruleForm = res.data
    this.ruleForm.sex = res.data.sex === '0' ? '女' : '男'
    // this.ruleForm.password = ''
    } else {
    this.$message.error(res.msg || '操作失败')
    }
    })
    },

    vue的created函数调用requestUrlParam判断是否存在id参数,如果存在就是edit,否则是add

    然后调用下面的init,根据id查询数据,加载在页面上

  4. requestUrlParam方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    //获取url地址上面的参数
    function requestUrlParam(argname){
    var url = location.href
    var arrStr = url.substring(url.indexOf("?")+1).split("&")
    for(var i =0;i<arrStr.length;i++)
    {
    var loc = arrStr[i].indexOf(argname+"=")
    if(loc!=-1){
    return arrStr[i].replace(argname+"=","").replace("?","")
    }
    }
    return ""
    }

    这里使用location.href获取当前url地址,然后将携带参数保存在数组中,获取指定参数key对应的value

  5. queryEmployeeById方法,发送ajax请求给后端,对响应数据加载在前端

    1
    2
    3
    4
    5
    6
    7
    // 修改页面反查详情接口
    function queryEmployeeById (id) {
    return $axios({
    url: `/employee/${id}`,
    method: 'get'
    })
    }

    这里就得出后端需要拦截的请求,也就是/employee/${id},并且返回应该是实体类内容

  6. 修改数据后,点击保存按钮,触发submitForm

    1
    2
    3
    4
    5
    6
    <el-button
    type="primary"
    @click="submitForm('ruleForm', false)"
    >
    保存
    </el-button>

    添加和修改的保存按钮,都是用的同一个表单提交事件submitForm

  7. submitForm方法判断是add还是edit,然后使用不同方法:addEmployee和editEmployee

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    submitForm (formName, st) {
    this.$refs[formName].validate((valid) => {
    if (valid) {
    if (this.actionType === 'add') {
    const params = {
    ...this.ruleForm,
    sex: this.ruleForm.sex === '女' ? '0' : '1'
    }
    addEmployee(params).then(res => {
    if (res.code === 1) {
    this.$message.success('员工添加成功!')
    if (!st) {
    this.goBack()
    } else {
    this.ruleForm = {
    username: '',
    'name': '',
    'phone': '',
    // 'password': '',
    // 'rePassword': '',/
    'sex': '男',
    'idNumber': ''
    }
    }
    } else {
    this.$message.error(res.msg || '操作失败')
    }
    }).catch(err => {
    this.$message.error('请求出错了:' + err)
    })
    } else { //这里开始时edit
    const params = {
    ...this.ruleForm,
    sex: this.ruleForm.sex === '女' ? '0' : '1'
    }
    editEmployee(params).then(res => {
    if (res.code === 1) {
    this.$message.success('员工信息修改成功!')
    this.goBack()
    } else {
    this.$message.error(res.msg || '操作失败')
    }
    }).catch(err => {
    this.$message.error('请求出错了:' + err)
    })
    }
    } else {
    console.log('error submit!!')
    return false
    }
    })
    }

    addEmployee和editEmployee的内容

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 新增---添加员工
    function addEmployee (params) {
    return $axios({
    url: '/employee',
    method: 'post',
    data: { ...params }
    })
    }

    // 修改---添加员工
    function editEmployee (params) {
    return $axios({
    url: '/employee',
    method: 'put',
    data: { ...params }
    })
    }
  8. 可以看出编辑员工发送的是/employee路径的put请求,这和前面禁用启用的发送的请求一致,都是修改员工信息,所以这里直接调用了之前的更新方法,对数据库内容进行了更新

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @PutMapping
    public R<String> update(HttpServletRequest request,@RequestBody Employee employee){
    log.info("employee:{}", employee.toString());
    //获取当前账户id
    Long id = (Long) request.getSession().getAttribute("employee");
    employee.setUpdateUser(id);
    employee.setUpdateTime(LocalDateTime.now());
    employeeService.updateById(employee);
    return R.success("员工信息修改成功");
    }

控制器方法

1
2
3
4
5
6
7
8
9
@GetMapping("/{id}")
public R<Employee> getById(@PathVariable Long id){
log.info("根据id查询员工信息..");
Employee employee = employeeService.getById(id);
if (employee != null) {
return R.success(employee);
}
return R.error("未查询到该员工信息");
}

公共字段自动填充

前面我们已经完成了对员工数据的添加与修改,在添加/修改员工数据的时候,都需要指定一下创建人、创建时间、修改人、修改时间等字段,而这些字段又属于公共字段,不仅员工表有这些字段,在菜品表、分类表等其他表中,也拥有这些字段。

那我们有没有办法让这些字段在一个地方统一管理呢?这样可以简化我们的开发。答案就是使用MybatisPlus给我们提供的公共字段自动填充功能

初步实现

  1. 在实体类的属性上方加入@TableFiled注解,指定自动填充的策略
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Data
public class Employee implements Serializable {

private static final long serialVersionUID = 1L;

private Long id;

private String username;

private String name;

private String password;

private String phone;

private String sex;

private String idNumber;

private Integer status;

@TableField(fill = FieldFill.INSERT) //插入时自动填充
private LocalDateTime createTime;

@TableField(fill = FieldFill.INSERT_UPDATE)//插入或更新时自动填充
private LocalDateTime updateTime;

//这两个先不用管,后面再说
@TableField(fill = FieldFill.INSERT)
private Long createUser;

@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateUser;
}

  1. 按照框架要求编写元数据对象处理器,在此类中统一对公共字段赋值,此类需要实现MetaObjectHandler接口
    实现接口之后,重写两个方法,一个是插入时填充,一个是修改时填充

    common/MyMetaObjectHandler.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    @Component
    @Slf4j
    public class MyMetaObjectHandler implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
    log.info("公共字段自动填充(insert)...");
    log.info(metaObject.toString());
    metaObject.setValue("createTime", LocalDateTime.now());
    metaObject.setValue("updateTime", LocalDateTime.now());
    }

    @Override
    public void updateFill(MetaObject metaObject) {
    log.info("公共字段自动填充(update)...");
    log.info(metaObject.toString());
    metaObject.setValue("updateTime", LocalDateTime.now());
    }
    }

    使用metaObject的setValue来实现字段填充方式

    关于id的获取,我们之前是存到session里的,但在MyMetaObjectHandler类中不能获得HttpSession对象,所以我们需要用其他方式来获取登录用户Id

现在就可以把之前save和update中的时间、修改者字段的赋值代码去掉,统一在这里进行处理

功能完善

如何获取当前登录用户的id值?我们可以使用ThreadLocal来解决这个问题

  • 在学习ThreadLocal之前,我们需要先确认一个事情,就是客户端发送的每次http请求,对应的在服务端都会分配一个新的线程来处理,在处理过程中涉及到下面类中的方法都属于相同的一个线程:

    1. LocalCheekFilter中的doFilter方法

    2. EmployeeController中的update方法

    3. MyMetaObjectHandler中的updateFill方法

  • 可以通过在这些方法中添加以下语句以验证:

    1
    2
    long id = Thread.currentThread().getId();
    log.info("doFilter的线程id为:{}", id);
  • 那么什么是ThreadLocal?

    • ThreadLocal并不是一个Thread,而是Thread的局部变量
    • 当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本
    • 所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本
    • ThreadLocal为每个线程提供单独一份存储空间,具有线程隔离的效果,只有在线程内才能获取到对应的值,线程外则不能访问。
  • ThreadLocal常用方法:

    • public void set(T value) 设置当前线程的线程局部变量的值

    • public T get() 返回当前线程所对应的线程局部变量的值

  • 那么如何用ThreadLocal来解决我们上述的问题呢?

    • 可以在LoginCheckFilterdoFilter方法中获取当前登录用户id, 调用ThreadLocalset方法来设置当前线程的线程局部变量的值(用户id)
    • 然后在MyMetaObjectHandlerupdateFill方法中调用ThreadLocalget方法来获得当前线程所对应的线程局部变量的值(用户id)。

具体实现

  1. common包下新建BaseContext

    作用:基于ThreadLocal的封装工具类,用于保护和获取当前用户id

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class BaseContext {
    public static ThreadLocal<Long> threadLocal = new ThreadLocal<>();

    public static void setCurrentId(Long id){
    threadLocal.set(id);
    }

    public static Long getCurrentId(){
    return threadLocal.get();
    }
    }
  2. LoginCheckFilter类中添加代码, 使用request.getSession来获取当前登录用户的id值

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
     //4.判断登录状态,如果已登录,则直接放行
    if (request.getSession().getAttribute("employee") != null) {
    log.info("用户已登录,id为{}",request.getSession().getAttribute("employee"));

    //根据session来获取之前我们存的id值
    Long empId = (Long) request.getSession().getAttribute("employee");
    BaseContext.setCurrentId(empId);

    filterChain.doFilter(request,response);
    return;
    }
  3. 在MyMetaObjectHandler类中,添加设置id的代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    @Component
    @Slf4j
    public class MyMetaObjectHandler implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
    log.info("公共字段自动填充(insert)...");
    log.info(metaObject.toString());
    metaObject.setValue("createTime", LocalDateTime.now());
    metaObject.setValue("updateTime", LocalDateTime.now());

    metaObject.setValue("createUser", BaseContext.getCurrentId());
    metaObject.setValue("updateUser", BaseContext.getCurrentId());
    }

    @Override
    public void updateFill(MetaObject metaObject) {
    log.info("公共字段自动填充(update)...");
    log.info(metaObject.toString());
    metaObject.setValue("updateTime", LocalDateTime.now());
    metaObject.setValue("updateUser", BaseContext.getCurrentId());
    }
    }

此时就完成了公共字段的自动填充


新增菜品分类

需求分析

  • 后台系统中可以管理分类信息,分类包括两种类型,分别是菜品分类和套餐分类
  • 当我们在后台系统中添加菜品时,需要选择一个菜品分类
  • 当我们在后台系统中天啊及一个套餐时,需要选择一个套餐分类
  • 在移动端也会按照菜品分类和套餐分类来战士对应的菜品和套餐

数据模型

category表中的数据

Field Type Collation Null Key Default Comment
id bigint (NULL) NO PRI (NULL) 主键
type int (NULL) YES 类型 1 菜品分类 2 套餐分类
name varchar(64) utf8_bin NO UNI (NULL) 分类名称
sort int (NULL) NO 0 顺序
create_time datetime (NULL) NO (NULL) 创建时间
update_time datetime (NULL) NO (NULL) 更新时间
create_user bigint (NULL) NO (NULL) 创建人
update_user bigint (NULL) NO (NULL) 修改人

id是主键,name分类名称是unique唯一的,type为1表示菜品分类,type为2表示套餐分类

准备工作

  1. 实体类Category,对应上表来创建

    菜品分类也有createUsercreateTime等字段,也可以用上面的公共字段自动填充

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    /**
    * 分类
    */
    @Data
    public class Category implements Serializable {

    private static final long serialVersionUID = 1L;
    private Long id;

    //类型 1 菜品分类 2 套餐分类
    private Integer type;
    //分类名称
    private String name;
    //顺序
    private Integer sort;
    //创建时间
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;

    //更新时间
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;

    //创建人
    @TableField(fill = FieldFill.INSERT)
    private Long createUser;

    //修改人
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateUser;

    //是否删除(注意,这里数据库没有对应field)
    //private Integer isDeleted;
    }

  2. Mapper接口CategoryMapper

    1
    2
    3
    @Mapper
    public interface CategoryMapper extends BaseMapper<Category> {
    }
  3. 业务层接口CategoryService

    1
    2
    public interface CategoryService extends IService<Category> {
    }
  4. 业务层实现类CatrgoryServiceImpl

    1
    2
    3
    @Service
    public class CategoryServiceImpl extends ServiceImpl<CategoryMapper, Category> implements CategoryService{
    }
  5. 控制层CategoryController

    1
    2
    3
    4
    5
    6
    7
    @Slf4j
    @RestController
    @RequestMapping("/category")
    public class CategoryController {
    @Autowired
    private CategoryService categoryService;
    }

流程分析

整个流程

  1. 页面发送ajax请求,将新增分类窗口输入的数据以json形式提交给服务端
  2. 服务端Controller接收页面提交的数据并调用Service将数据存储到数据库
  3. Service调用Mapper操作数据库,保存数据

监测请求

  • 点击新增菜品后,会看到发送的url请求,请求方式,请求参数

  • 点击新增套餐后,会看到发送的url请求,请求方式,请求参数

可以看出,二者的请求url都相同,区别在于请求参数中的type,这与前面的数据库相对应

代码实现

1
2
3
4
5
6
@PostMapping
public R<String> save(@RequestBody Category category){
log.info(category.toString());
categoryService.save(category);
return R.success("新增分类成功");
}

目前在分类管理页面会报错,因为目前还没有进行分页显示操作,所以添加数据后在数据库中查看

1
2
3
==>  Preparing: INSERT INTO category ( id, type, name, sort, create_time, update_time, create_user, update_user ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ? )
==> Parameters: 1670793257571090434(Long), 1(Integer), 北京烤鸭(String), 1(Integer), 2023-06-19T21:58:49.222517(LocalDateTime), 2023-06-19T21:58:49.222517(LocalDateTime), 1(Long), 1(Long)
<== Updates: 1

image-20230619220133907

这里如果输入重复的名称,也会被前面的2.3全局异常处理器捕获并报错


分类信息分页查询

流程

  1. 页面发送Ajax请求,将分页查询的参数(page、pageSize)提交到服务端
  2. 服务端Controller接受到页面提交的数据之后,调用Service进行查询
  3. Service调用Mapper操作数据库,查询分页数据
  4. Controller将查询到的分页数据响应给页面
  5. 页面接收分页数据,并通过ElementUI的Table组件展示到页面上

可以看到,分页发的送的请求如下:

前端代码分析

  1. 页面加载完毕之后调用created钩子函数, 钩子函数内又调用的是init进行初始化
1
2
3
created() {
this.init()
},
  1. init函数
1
2
3
4
5
6
7
8
9
10
11
12
async init () {
await getCategoryPage({'page': this.page, 'pageSize': this.pageSize}).then(res => {
if (String(res.code) === '1') {
this.tableData = res.data.records
this.counts = Number(res.data.total)
} else {
this.$message.error(res.msg || '操作失败')
}
}).catch(err => {
this.$message.error('请求出错了:' + err)
})
},
  1. getCategoryPage方法

api/category.js

1
2
3
4
5
6
7
8
// 查询列表接口
const getCategoryPage = (params) => {
return $axios({
url: '/category/page',
method: 'get',
params
})
}

控制器方法

1
2
3
4
5
6
7
8
9
10
11
12
@PutMapping("/page")
public R<Page> page(int page, int pageSize){
//分页构造器
Page<Category> pageInfo = new Page<>(page, pageSize);
//条件查询器
LambdaQueryWrapper<Category> lqw = new LambdaQueryWrapper<>();
//添加排序条件
lqw.orderByDesc(Category::getSort);
//分页构造器
categoryService.page(pageInfo, lqw);
return R.success(pageInfo);
}

效果:


删除分类

  • 在分类管理列表页面,可以对某个分类进行删除操作
  • 需要注意的是:当分类关联了菜品或者套餐时,此分类将不允许被删除

前端代码分析

1.backend/page/category/list.html

1
2
3
4
5
6
7
8
<el-button
type="text"
size="small"
class="delBut non"
@click="deleteHandle(scope.row.id)"
>
删除
</el-button>
  1. deleteHandle()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
deleteHandle(id) {
this.$confirm('此操作将永久删除该文件, 是否继续?', '提示', {
'confirmButtonText': '确定',
'cancelButtonText': '取消',
'type': 'warning'
}).then(() => {
deleCategory(id).then(res => {
if (res.code === 1) {
this.$message.success('删除成功!')
this.handleQuery()
} else {
this.$message.error(res.msg || '操作失败')
}
}).catch(err => {
this.$message.error('请求出错了:' + err)
})
})
},

调用deleCategory方法传入id

  1. deleCategory()
1
2
3
4
5
6
7
8
// 删除当前列的接口
const deleCategory = (ids) => {
return $axios({
url: '/category',
method: 'delete',
params: { ids }
})
}

可以看出请求方式和url以及请求参数

初步实现

1
2
3
4
5
6
7
8
9
10
11
/**
* 根据id删除分类信息
* @param id
* @return
*/
@DeleteMapping
public R<String> delete(Long ids){
log.info("将被删除的id:{}", ids);
categoryService.removeById(ids);
return R.success("分类信息删除成功");
}

功能完善

当菜品分类或套餐分类关联了其他菜品或套餐时,该分类将不允许被删除。那么我们如何实现这个功能呢?

  • 其实也很简单,我们只需要在删除的时候,拿着当前分类的id值,去对应的菜品/套餐表中进行查询,如果能查询到数据,则说明该分类关联了菜品,不允许被删除,否则则可以删除

  1. 首先根据数据表创建菜品和套餐对应的模型类Dish.javaSetmeal.java;编写对应的Mapper接口;编写对应的Service接口及Impl实现类

  2. 完善CategoryService,接口中自己写一个remove方法,判断该id对应的菜品分类或者套餐分类是否关联了菜品或套餐

    1
    2
    3
    public interface CategoryService extends IService<Category> {
    public void remove(Long id);
    }
  3. CategoryServiceImpl中来写具体业务逻辑,在删除数据之前,根据id值,去Dish表和Setmeal表中查询是否关联了数据
    如果存在关联数据,则不能删除,并抛一个异常; 如果不存在关联数据(也就是查询到的数据条数为0),正常删除即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Service
@Slf4j
public class CategoryServiceImpl extends ServiceImpl<CategoryMapper, Category> implements CategoryService{
@Autowired
private DishService dishService;

@Autowired
private SetmealService setmealService;

/**
* 根据id查询是否关联
* @param id
*/
@Override
public void remove(Long id) {
LambdaQueryWrapper<Dish> dishlqw = new LambdaQueryWrapper<>();
//添加dish查询条件,根据分类id进行查询
dishlqw.eq(Dish::getCategoryId, id);
int count1 = dishService.count(dishlqw);

//查看当前分类是否关联了菜品,如果已经关联,则抛出异常
log.info("dish查询条件,查询到的条目数为:{}",count1);
if (count1 > 0){
//已关联菜品,抛出一个业务异常
throw new CustomException("当前分类下关联了菜品,不能删除");
}

LambdaQueryWrapper<Setmeal> setmeallqw = new LambdaQueryWrapper<>();
setmeallqw.eq(Setmeal::getCategoryId, id);
int count2 = setmealService.count(setmeallqw);

//查看当前分类是否关联了套餐,如果已经关联,则抛出异常
log.info("setmeal查询条件,查询到的条目数为:{}",count2);
if (count2 > 0){
//已关联菜品,抛出一个业务异常
throw new CustomException("当前分类下关联了套餐,不能删除");
}

//正常删除
super.removeById(id);
}
}

这里面通过条件查询获取类别id对应的菜品或者套餐的数量count,如果不为零则说明有关联,则抛出业务异常,反之进行常规的按照id删除数据

  1. 上面抛出了业务异常CustomException

    common/CustomException

    1
    2
    3
    4
    5
    public class CustomException extends RuntimeException {
    public CustomException(String msg) {
    super(msg);
    }
    }
  2. 然后在全局异常处理器新添加一个方法捕获异常

    1
    2
    3
    4
    5
    @ExceptionHandler(CustomException.class)
    public R<String> CustHandler(CustomException ex){
    log.error(ex.getMessage());
    return R.error(ex.getMessage());
    }

    类比前面的用户已存在异常的写法,标注@ExceptionHandler注解,其中填上自定义的业务异常,然后返回异常信息的R类对象即可

  3. 最后,优化之前的初步实现,更改原本的removeById()为自定义的remove(),实现有条件的删除

    1
    2
    3
    4
    5
    6
    @DeleteMapping
    public R<String> delete(Long ids){
    log.info("将被删除的id:{}", ids);
    categoryService.remove(ids); //
    return R.success("分类信息删除成功");
    }

效果展示:


修改分类

前端代码分析

  1. 修改按钮

    1
    2
    3
    4
    5
    6
    7
    8
    <el-button
    type="text"
    size="small"
    class="blueBug"
    @click="editHandle(scope.row)"
    >
    修改
    </el-button>
  2. 点击事件

    1
    2
    3
    4
    5
    6
    7
    8
    editHandle(dat) {
    this.classData.title = '修改分类'
    this.action = 'edit'
    this.classData.name = dat.name
    this.classData.sort = dat.sort
    this.classData.id = dat.id
    this.classData.dialogVisible = true
    },

    这里会指定弹窗的名称,传入指定数据的name、sort及id,并且action为edit(因为edit和add是共用的,通过这个属性区别)

  3. 确定取消按钮

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <el-button
    size="medium"
    @click="classData.dialogVisible = false"
    >取 消</el-button>
    <el-button
    type="primary"
    size="medium"
    @click="submitForm()"
    >确 定</el-button>
    <el-button

    确定点击事件会将前端表单数据通过ajax请求传递给后端

  4. 提交表单方法(add和edit公用)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    //数据提交
    submitForm(st) {
    const classData = this.classData
    const valid = (classData.name === 0 ||classData.name) && (classData.sort === 0 || classData.sort)
    if (this.action === 'add') {
    if (valid) {
    const reg = /^\d+$/
    if (reg.test(classData.sort)) {
    addCategory({'name': classData.name,'type':this.type, sort: classData.sort}).then(res => {
    console.log(res)
    if (res.code === 1) {
    this.$message.success('分类添加成功!')
    if (!st) {
    this.classData.dialogVisible = false
    } else {
    this.classData.name = ''
    this.classData.sort = ''
    }
    this.handleQuery()
    } else {
    this.$message.error(res.msg || '操作失败')
    }
    }).catch(err => {
    this.$message.error('请求出错了:' + err)
    })
    } else {
    this.$message.error('排序只能输入数字类型')
    }

    } else {
    this.$message.error('请输入分类名称或排序')
    }
    } else if (valid) { //从这行开始是edit
    const reg = /^\d+$/
    if (reg.test(this.classData.sort)) {
    editCategory({'id':this.classData.id,'name': this.classData.name, sort: this.classData.sort}).then(res => {
    if (res.code === 1) {
    this.$message.success('分类修改成功!')
    this.classData.dialogVisible = false
    this.handleQuery()
    } else {
    this.$message.error(res.msg || '操作失败')
    }
    }).catch(err => {
    this.$message.error('请求出错了:' + err)
    })
    } else {
    this.$message.error('排序只能输入数字类型')
    }
    } else {
    this.$message.error('请输入分类名称或排序')
    }
    },

    调用editCategory方法发送ajax请求,传递json参数

    {'id':this.classData.id,'name': this.classData.name, sort: this.classData.sort}

  5. editCategory传递ajax请求

    1
    2
    3
    4
    5
    6
    7
    8
    // 修改接口
    const editCategory = (params) => {
    return $axios({
    url: '/category',
    method: 'put',
    data: { ...params }
    })
    }

控制器方法

1
2
3
4
5
6
@PutMapping
public R<String> update(@RequestBody Category category){
log.info("修改分类的参数{}",category.toString());
categoryService.updateById(category);
return R.success("修改分类信息成功");
}

文件的上传和下载

简介

上传

  • 文件上传时,对页面的form表单有如下要求:

    1. method="post",采用post方式提交数据
    2. enctype="multipart/form-data",采用multipart格式上传文件
    3. type="file",使用input的file控件上传
  • 目前一些前端组件库elementUI也提供了相应的上传组件,但是底层原理还是基于form表单的文件上传,这里我们就用提供好的组件就行了

  • 服务端要接收客户端页面上传的文件,通常都会使用Apache的两个组件:

    • commons-fileupload
    • commons-io
  • Spring框架在spring-web包中对文件上传进行了封装,大大简化了服务端代码,我们只需要在Controller的方法中声明一个MultipartFile类型的参数即可接收上传的文件。

    1
    2
    3
    4
    5
    @PostMapping("/upload")
    public Result<String> upload(MultipartFile file) {
    log.info("获取文件:{}", file.toString());
    return null;
    }

    注意:这里形参file名字不能随意更改,需要和前端的请求中的参数一致

下载

  • 通过浏览器进行文件下载,通常有两种表现形式
    1. 以附件形式下载,弹出保存对话框,将文件保存到指定磁盘目录
    2. 直接在浏览器中打开
  • 通过浏览器进行文件下载,本质上就是服务端将文件以流的形式写回浏览器的过程

上传代码实现

前端分析

  • 首先将资料中的上传页面复制到工程目录下backend/page/demo/upload.html(新建demo目录)

  • 浏览器输入对应url地址后进行上传页面的访问,页面效果如下:

  • 然后点击上传图片,查看请求发送内容:

    可以看出请求地址是/common/upload,方式为POST

控制器方法初步

创建CommonTroller管理上传下载的请求,这里先拦截上传请求

1
2
3
4
5
6
7
8
9
10
11
@RestController
@Slf4j
@RequestMapping("/common")
public class CommonController {
@PostMapping("/upload")
public R<String> upload(MultipartFile file){
//file是个临时文件,我们在断点调试的时候可以看到,但是执行完整个方法之后就消失了
log.info("上传图片信息{}",file.toString());
return null;
}
}

但是此时控制台并没有收到信息,而是被拦截了,这就想到之前创建的filter拦截器,把我们这里的请求拦截了,然后看network中发现传回msg信息”NOTLOGIN”,这是因为我们直接访问而没有登录导致的:

有两种方法解决这个问题:

  1. 先登录再上传:

    1
    INFO 18232 --- [nio-8080-exec-3] com.wzy.controller.CommonController      : 上传图片信息org.springframework.web.multipart.support.StandardMultipartHttpServletRequest$StandardMultipartFile@daddd5b

    这时捕获到日志信息

  2. 修改拦截器中的内容

上传的文件会被保存在本地的一个临时文件中,我们可以对其进行转存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
@RequestMapping("/common")
@Slf4j
public class CommonController {
@PostMapping("/upload")
//file是个临时文件,我们在断点调试的时候可以看到,但是执行完整个方法之后就消失了
public R<String> upload(MultipartFile file) {
log.info("获取文件:{}", file.toString());
//方法会抛异常,我们这里用try/catch处理一下
try {
//我们将其转存为E盘下的test.jpg
file.transferTo(new File("E:\\test.jpg"));
} catch (IOException e) {
throw new RuntimeException(e);
}
return null;
}
}

控制器方法优化

系列优化:

  • 文件转存的位置改为动态可配置的,通过配置文件的方式指定,在application.yml文件中加入以下内容

    1
    2
    reggie:
    path: E:\\reggie\\img\\
  • 使用 @Value(“${reggie.path}”)读取到配置文件中的动态转存位置

  • 使用uuid方式重新生成文件名,避免文件名重复造成文件覆盖

  • 通过获取原文件名来截取文件后缀

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@RestController
@RequestMapping("/common")
@Slf4j
public class CommonController {

@Value("${reggie.path}")
private String basepath;

@PostMapping("/upload")
//file是个临时文件,我们在断点调试的时候可以看到,但是执行完整个方法之后就消失了
public R<String> upload(MultipartFile file) {
log.info("获取文件:{}", file.toString());

//判断一下当前目录是否存在,不存在则创建
File dir = new File(basepath);
if (!dir.exists()) {
dir.mkdirs();
}

//获取一下传入的原文件名
String originalFilename = file.getOriginalFilename();
//我们只需要获取一下格式后缀,取子串,起始点为最后一个.
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
//为了防止出现重复的文件名,我们需要使用UUID
String fileName = UUID.randomUUID() + suffix;
try {
//我们将其转存到我们的指定目录下
file.transferTo(new File(basepath + fileName));
} catch (IOException e) {
throw new RuntimeException(e);
}
//将文件名返回给前端,便于后期的开发
return R.success(fileName);
}
}

这时上传就会再资源管理器中看见创建的目录及保存的文件

image-20230622141710917

下载代码实现

前端分析

我们在上传的同时,也自动将上传的文件下载到服务器,然后加载在页面中,从下面的请求可以看出

  1. 上传界面

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <el-upload class="avatar-uploader"
    action="/common/upload"
    :show-file-list="false"
    :on-success="handleAvatarSuccess"
    :before-upload="beforeUpload"
    ref="upload">
    <img v-if="imageUrl" :src="imageUrl" class="avatar"></img>
    <i v-else class="el-icon-plus avatar-uploader-icon"></i>
    </el-upload>

    这里上传成功后会调用handleAvatarSuccess放法

  2. handleAvatarSuccess()

    1
    2
    3
    handleAvatarSuccess (response, file, fileList) {
    this.imageUrl = `/common/download?name=${response.data}`
    },

    imageUrl赋值为/common/download?name=${response.data}。这对应了1中标签的src属性,就会发送这个请求,加载对应的response,而前面的response是文件名称,所以再开始的F12图中的url链接name后是文件名称

控制器方法实现

因为前面可以看出download的请求连接包含了name,这个name是上传时返回给的name,所以这里接收name参数,并且需要HttpServletResponse将数据流进行展示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@GetMapping("/download")
public void download(String name, HttpServletResponse response){
try {
//输入流,通过输入流读取文件内容
FileInputStream fis = new FileInputStream(new File(basepath + name));
//输出流,通过输出流将文件写回浏览器,在浏览器展示图片
ServletOutputStream os = response.getOutputStream();

//设置响应回去的数据类型
response.setContentType("image/jpeg");

int len = 0;
byte[] bytes = new byte[1024];
while ((len = fis.read(bytes)) != -1) {
os.write(bytes, 0, len);
os.flush();
}
//关闭资源
fis.close();
os.close();
} catch (Exception e) {
throw new RuntimeException(e);
}
}

页面效果:


新增菜品

准备工作

  • 新增菜品,其实就是将新增页面录入的菜品信息插入到dish表,如果添加了口味做法,还需要向dish_flavor表中插入数据。所以在新增菜品时涉及到两个表:dish、dish_flavor

  • 导入DishFlavor实体类,然后创建Mapper、Service、ServiceImpl,controller还用DishController

整个流程

  1. 页面(backend/page/food/add.html)发送ajax请求,请求服务端获取菜品分类数据并展示到下拉框中
  2. 页面发送请求进行图片上传,请求服务端将图片保存到服务器
  3. 页面发送请求进行图片下载,并回显上传的图片
  4. 点击保存按钮,发送ajax请求,将菜品相关数据以json形式提交到服务端

查询分类数据

可以看出新增时需要先选择菜品的分类,这部分在Category中有所定义,type=1为菜品,type=2为套餐,这里默认type=1,然后使用type作为条件查询字段从数据库中获取所有的菜品类别,展示在下拉框中,以供选择。

我们首次进入添加页面会发送如下请求,表明先在Category中进行了查询

前端分析

  1. 下拉框部分内容

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    ![image-20230622161453625](image-20230622161453625.png)![image-20230622161453625](image-20230622161453625.png)<el-form-item
    label="菜品分类:"
    prop="categoryId"
    >
    <el-select
    v-model="ruleForm.categoryId"
    placeholder="请选择菜品分类"
    >
    <el-option v-for="(item,index) in dishList" :key="index" :label="item.name" :value="item.id" />
    </el-select>
    </el-form-item>
  2. vue钩子函数部分

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    created() {
    this.getDishList()
    // 口味临时数据
    this.getFlavorListHand()
    this.id = requestUrlParam('id')
    this.actionType = this.id ? 'edit' : 'add'
    if (this.id) {
    this.init()
    }
    },

    调用getDishList函数获取类别

  3. getDishList()

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 获取菜品分类
    getDishList () {
    getCategoryList({ 'type': 1 }).then(res => {
    if (res.code === 1) {
    this.dishList = res.data
    } else {
    this.$message.error(res.msg || '操作失败')
    }
    })
    },

    调用getCategoryList, 可以看出默认传递type=1,也就是菜品类别,然后将返回的数据封装在下拉列表中展示,注意这里存放在list中,所以控制器方法返回应该是list类型

  4. getCategoryList()

    1
    2
    3
    4
    5
    6
    7
    8
    // 获取菜品分类列表
    const getCategoryList = (params) => {
    return $axios({
    url: '/category/list',
    method: 'get',
    params
    })
    }

    这里看到url连接,发现是/category,所以在CategoryController中进行对应请求的处理

控制器方法

CategoryController

1
2
3
4
5
6
7
8
9
10
11
12
@GetMapping("/list")
public R<List<Category>> list(Category category){
//条件构造器
LambdaQueryWrapper<Category> lqw = new LambdaQueryWrapper<>();
//添加条件,这里只需要判断是否为菜品
lqw.eq(category.getType()!=null,Category::getType,category.getType());
//添加排序条件
lqw.orderByAsc(Category::getSort).orderByDesc(Category::getUpdateTime);
//查询数据
List<Category> list = categoryService.list(lqw);
return R.success(list);
}

注意:这里参数不要标注@RequestBody,因为前端并不是传的JSON数据,而是在地址栏传递的参数。其次,get方法没有请求体。

效果:

如何判断参数是JSON格式

对比以下两种ajax请求:

  • 第一种参数使用data:{...params}格式,封装成了JSON格式
  • 第二种则是直接传递参数params,所以就是url的携带参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 新增接口
const addDish = (params) => {
return $axios({
url: '/dish',
method: 'post',
data: { ...params }
})
}

// 获取菜品分类列表
const getCategoryList = (params) => {
return $axios({
url: '/category/list',
method: 'get',
params
})
}

提交数据到服务器

目前填好数据点击确定,前端发送请求如下

并且发送参数如下:

可以看到发送的参数不光包括dish,而且还有flavors,此时控制器方法就不能直接拿Dish来作为形参

前端分析

  1. 前端按钮

    1
    2
    3
    4
    5
    6
    <el-button
    type="primary"
    @click="submitForm('ruleForm')"
    >
    保存
    </el-button>
  2. submitForm

    注意这里的price*100

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    submitForm(formName, st) {
    this.$refs[formName].validate((valid) => {
    if (valid) {
    let params = {...this.ruleForm}
    // params.flavors = this.dishFlavors
    params.status = this.ruleForm ? 1 : 0
    params.price *= 100
    params.categoryId = this.ruleForm.categoryId
    params.flavors = this.dishFlavors.map(obj => ({ ...obj, value: JSON.stringify(obj.value) }))
    delete params.dishFlavors
    if(!this.imageUrl){
    this.$message.error('请上传菜品图片')
    return
    }
    if (this.actionType == 'add') {
    delete params.id
    addDish(params).then(res => {
    if (res.code === 1) {
    this.$message.success('菜品添加成功!')
    if (!st) {
    this.goBack()
    } else {
    this.dishFlavors = []
    // this.dishFlavorsData = []
    this.imageUrl = ''
    this.ruleForm = {
    'name': '',
    'id': '',
    'price': '',
    'code': '',
    'image': '',
    'description': '',
    'dishFlavors': [],
    'status': true,
    categoryId: ''
    }
    }
    } else {
    this.$message.error(res.msg || '操作失败')
    }
    }).catch(err => {
    this.$message.error('请求出错了:' + err)
    })
    } else {
    delete params.updateTime
    editDish(params).then(res => {
    if (res.code === 1) {
    this.$message.success('菜品修改成功!')
    this.goBack()
    } else {
    this.$message.error(res.msg || '操作失败')
    }
    }).catch(err => {
    this.$message.error('请求出错了:' + err)
    })
    }
    } else {
    return false
    }
    })
    },

    根据表单项初始化param,然后封装输入的内容,传递给addDish方法,发送ajax请求给后端

  3. addDish

    1
    2
    3
    4
    5
    6
    7
    8
    // 新增接口
    const addDish = (params) => {
    return $axios({
    url: '/dish',
    method: 'post',
    data: { ...params }
    })
    }

引入DTO

前面说到,此时新增的表单数据不仅包括了dish,还有flavors,不能单用一个dish形参去接收,所以这里引入了DTO,全称为Data Transfer Object,即数据传输对象,一般用于展示层与服务层之间的数据传输。这里新建dto包,用于后续存放dto类

dto.DishDto

1
2
3
4
5
6
7
8
9
@Data
public class DishDto extends Dish {

private List<DishFlavor> flavors = new ArrayList<>();

private String categoryName;

private Integer copies;
}

这里DishDto继承了Dish,并且新增了DishFlavor的列表属性,用于封装flavors数据

控制器方法

初步实现

1
2
3
4
5
@PostMapping
public R<String> save(@RequestBody DishDto dishDto){
log.info("菜品新增数据{}", dishDto.toString());
return null;
}

debug结果,可以看出此时dishDto中接收到了表单输入内容

从上面结果可以看出DishFlavor中的dishId为null。但实际保存这些数据需要对DishFlavor中的dishId进行赋值才能直到这些口味对应哪些dish

后续需要改善的地方:

  • 将菜品数据保存到dish

  • 将菜品口味数据保存到dish_flavor

    • 但是dish_flavor表中需要一个dishId字段值,这个字段值需要我们从dishDto中获取
    • 获取方式为:取出dishDtodishId,对每一组flavordishId赋值

完善结果

  • DishService中编写一个saveWithFlavor方法

    1
    2
    3
    public interface DishService extends IService<Dish> {
    void saveWithFlavor(DishDto dishDto);
    }
  • DishServiceImpl中实现这个方法

    这里注入了DishFlavorService,以便对口味表数据保存

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    @Service
    public class DishServiceImpl extends ServiceImpl<DishServiceMapper, Dish> implements DishService {

    @Autowired
    private DishFlavorService dishFlavorService;

    @Override
    @Transactional
    public void saveWithFlavor(DishDto dishDto) {
    //将菜品数据保存到dish表
    this.save(dishDto);
    //获取dishId
    Long id = dishDto.getId();

    //将获取到的dishId赋值给dishFlavor的dishId属性
    List<DishFlavor> flavors = dishDto.getFlavors();
    for(DishFlavor dishFlavor : flavors){
    dishFlavor.setDishId(id);
    }
    //同时将菜品口味数据保存到dish_flavor表
    dishFlavorService.saveBatch(flavors);
    }
    }

    注意,将这个方法标注@Transactional,即声明为事务,也就是这个方法内的数据库操作有一个失败,都会回退到原始状态

    如果不是Springboot项目,还需要再启动类上标注@EnableTransactionManagement,开启事务注解驱动;而SpringBoot默认开启

  • 修改控制器方法,使用这个saveWithFlavor方法

    1
    2
    3
    4
    5
    6
    @PostMapping
    public R<String> save(@RequestBody DishDto dishDto){
    log.info("菜品新增数据{}", dishDto.toString());
    dishService.saveWithFlavor(dishDto);
    return R.success("菜品新增成功");
    }

可以看到,数据新增成功

image-20230622224454838

菜品分页查询

初步实现

1
2
3
4
5
6
7
8
9
@GetMapping("/page")
public R<Page> page(int page, int pageSize,String name){
Page<Dish> pageInfo = new Page<>(page, pageSize);
LambdaQueryWrapper<Dish> lqw = new LambdaQueryWrapper<>();
lqw.like(name != null, Dish::getName, name);
lqw.orderByDesc(Dish::getSort);
dishService.page(pageInfo, lqw);
return R.success(pageInfo);
}

注意:此时图片和菜品分类两列没有正常显示。此时将资源中的图片保存在之前设定的图片下载路径中即可正常显示

菜品分类内容显示

  • 为什么不显示菜品分类内容?

    • 控制器方法传递的是一个Dish对象,dish对象没有菜品分类名称属性,但是有菜品分类id
    • 那我们就可以根据这个菜品分类id,去菜品分类表中查询对应的菜品分类名称
  • 所以我们之前的DishDto类中的另外一个属性就派上用场了,我们返回一个DishDto对象就有菜品分类名称数据了

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Data
    public class DishDto extends Dish {
    //菜品口味
    private List<DishFlavor> flavors = new ArrayList<>();
    //菜品分类名称
    private String categoryName;

    private Integer copies;
    }
  • 现在就可以把DishDto看做是Dish类的基础上,增加了一个categoryName属性,到时候返回DishDto
    具体实现思路就是:将查询出来的dish数据,赋给dishDto,然后在根据dish数据中的category_id,去菜品分类表中查询到category_name,将其赋给dishDto

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@GetMapping("/page")
public R<Page> page(int page, int pageSize,String name){
//原本的分页信息,但是不包含类别名称
Page<Dish> pageInfo = new Page<>(page, pageSize);
//新建一个dishDto的分页构造器,其包含了categoryName,后续将原本分页信息赋值给这里
Page<DishDto> dishDtoPage = new Page<>(page, pageSize);

//分页查询常规流程
LambdaQueryWrapper<Dish> lqw = new LambdaQueryWrapper<>();
lqw.like(name != null, Dish::getName, name);
lqw.orderByDesc(Dish::getSort);
dishService.page(pageInfo, lqw);

//对象拷贝,这里只需要拷贝一下查询到的条目数,不拷贝records,也就是数据本身,因为此时dish数据缺少一项类别内容
BeanUtils.copyProperties(pageInfo, dishDtoPage,"records");
//这里通过分页信息获取records,也就是具体的每条信息
List<Dish> records = pageInfo.getRecords();
//新建一个DishDto的列表,后续添加dishDto数据(既包含dish也有categoryName)
List<DishDto> list = new ArrayList<>();

//遍历records数据dish,获取其类别id,然后调用类别控制器根据id查找对应的类别,然后获取其name
for(Dish dish : records){
//获取一下dish对象的category_id属性
Long categoryId = dish.getCategoryId();
//根据这个属性,获取到Category对象(这里需要用@Autowired注入一个CategoryService对象)
Category category = categoryService.getById(categoryId);
//随后获取Category对象的name属性,也就是菜品分类名称
String categoryName = category.getName();

DishDto dishDto = new DishDto();
//将数据赋给dishDto对象
BeanUtils.copyProperties(dish, dishDto);
//并且设置其类别名称
dishDto.setCategoryName(categoryName);
//将dishDto对象封装成一个集合,作为我们的最终结果
list.add(dishDto);
}
//将新的分页信息中原本被忽略的records属性进行赋值,也就是新的dishDto数据
dishDtoPage.setRecords(list);
return R.success(dishDtoPage);
}

菜品修改

整个流程

  1. 页面发送ajax请求,请求服务器获取分类数据,用于菜品分类下拉框的数据回显(之前我们已经实现过了)
  2. 页面发送ajax请求,请求服务端,根据id查询当前菜品信息,用于菜品信息回显
  3. 页面发送请求,请求服务端进行图片下载,用于页面图片回显(之前我们已经实现过了)
  4. 点击保存按钮,页面发送ajax请求,将修改后的菜品相关数据以json形式提交到服务端

菜品信息回显

前端分析

  1. list.html中vue的init方法,会调用queryDishById,根据id获取内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async init () {
queryDishById(this.id).then(res => {
console.log(res)
if (String(res.code) === '1') {
this.ruleForm = { ...res.data }
this.ruleForm.price = String(res.data.price/100)
this.ruleForm.status = res.data.status == '1'
this.dishFlavors = res.data.flavors && res.data.flavors.map(obj => ({ ...obj, value: JSON.parse(obj.value),showOption: false }))
console.log('this.dishFlavors',this.dishFlavors)
// this.ruleForm.id = res.data.data.categoryId
// this.imageUrl = res.data.data.image
this.imageUrl = `/common/download?name=${res.data.image}`
} else {
this.$message.error(res.msg || '操作失败')
}
})
},
  1. queryDishById发送ajax请求
1
2
3
4
5
6
7
// 查询详情
const queryDishById = (id) => {
return $axios({
url: `/dish/${id}`,
method: 'get'
})
}

我们先点击修改按钮,发现如下的请求:可以看出首先根据id进行数据的回显

控制器方法

分析

  • 菜品信息回显功能,需要我们先根据id来查询到对应的菜品信息才能回显
  • 但修改表单中有一个菜品口味属性,普通的Dish类没有这个属性,所以还是要用到DishDto
  • 那我们这里先在DishServiceImpl编写一个getByIdWithFlavor方法
  • 菜品口味需要根据dish_iddish_flavor表中查询,将查询到的菜品口味数据赋给我们的DishDto对象即可
  1. DishServiceImpl编写一个getByIdWithFlavor方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public DishDto getByIdWithFlavor(Long id) {
Dish dish = this.getById(id);
DishDto dishDto = new DishDto();

BeanUtils.copyProperties(dish, dishDto);

//查询当前菜品对应的口味信息,从dish_flavor表查询
LambdaQueryWrapper<DishFlavor> lqw = new LambdaQueryWrapper<>();
lqw.eq(id!=null,DishFlavor::getDishId, id);
List<DishFlavor> flavors = dishFlavorService.list(lqw);

dishDto.setFlavors(flavors);
return dishDto;
}
  1. 控制器方法
1
2
3
4
5
6
7
@GetMapping("/{id}")
public R<DishDto> update(@PathVariable Long id){
DishDto dishDto = dishService.getByIdWithFlavor(id);
log.info("dishDto:{}",dishDto);

return R.success(dishDto);
}

菜品信息修改

由于Dish表中没有Flavor这个属性,所以修改的时候,我们也是需要修改两张表

前端分析

  1. 修改按钮

    1
    2
    3
    4
    5
    6
    7
    8
    <el-button
    type="text"
    size="small"
    class="blueBug"
    @click="addFoodtype(scope.row.id)"
    >
    修改
    </el-button>
  2. 点击事件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
     // 添加
    addFoodtype (st) {
    if (st === 'add'){
    window.parent.menuHandle({
    id: '4',
    url: '/backend/page/food/add.html',
    name: '添加菜品'
    },true)
    } else { //修改部分
    window.parent.menuHandle({
    id: '4',
    url: '/backend/page/food/add.html?id='+st,
    name: '修改菜品'
    },true)
    }
    },

    跳转到/backend/page/food/add.html?id=页面,并携带前端传来的id参数

  3. add.html中vue钩子函数,判断是否传来id,如果传来id,则类型为edit,也就是修改

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    created() {
    this.getDishList()
    // 口味临时数据
    this.getFlavorListHand()
    this.id = requestUrlParam('id')
    this.actionType = this.id ? 'edit' : 'add'
    if (this.id) {
    this.init()
    }
    },
  4. 参数提交方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    else { //前面是add逻辑,这里是edit
    delete params.updateTime
    editDish(params).then(res => {
    if (res.code === 1) {
    this.$message.success('菜品修改成功!')
    this.goBack()
    } else {
    this.$message.error(res.msg || '操作失败')
    }
    }).catch(err => {
    this.$message.error('请求出错了:' + err)
    })
    }

    这里把前端修改后的参数传递给后端

  5. editDish()发送ajax请求

    1
    2
    3
    4
    5
    6
    7
    8
    // 修改接口
    const editDish = (params) => {
    return $axios({
    url: '/dish',
    method: 'put',
    data: { ...params }
    })
    }

控制器方法

分析:

  • 此时修改菜品,不仅包含dish的数据,还包括flavors,所以不仅需要更改dish数据库,还需要更改flavor数据库。所以需要传入DishDto,但是这时的flavor不包含类别id,所以还需要从中获取类别id遍历赋值给flavor,然后更新口味表。
  • 这里对于口味内容进行更改,可能存在项目减少或增多,对应的更新就会有点复杂,所以这里不管怎样,先将口味内容清除,然后再提交新的口味。

实现:

  1. 首先去DishService中创建updateWithFlavor方法,然后在DishServiceImpl中重写方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
@Transactional
public void updateWithFlavor(DishDto dishDto) {
//更新当前菜品数据(dish表)
this.updateById(dishDto);

//清理当前菜品的口味数据----dish_flavor的delete
LambdaQueryWrapper<DishFlavor> lqw = new LambdaQueryWrapper<>();
lqw.like(DishFlavor::getDishId, dishDto.getId());
dishFlavorService.remove(lqw);

//添加当前提交过来的口味数据----dish_flavor的insert
List<DishFlavor> flavors = dishDto.getFlavors();
for(DishFlavor dishFlavor : flavors){
dishFlavor.setDishId(dishDto.getId());
}
dishFlavorService.saveBatch(flavors);
}
  1. 控制器方法
1
2
3
4
5
6
@PutMapping
public R<String> update(@RequestBody DishDto dishDto){
log.info("菜品修改数据{}", dishDto.toString());
dishService.updateWithFlavor(dishDto);
return R.success("菜品修改成功");
}

删除/启停菜品

停售/启售实现

这里单个停售/启售和批量停售/启售共用的一个方法,只是传入的id是一个还是多个,所以接收id的list

实现:参考套餐管理4.2

注解@RequestParam与@RequestBody的使用场景

1
2
3
4
5
6
7
8
9
10
11
12
13
![image-20230701172004587](image-20230701172004587.png)@PostMapping("/status/{status}")
public R<String> stop(@PathVariable int status, @RequestParam List<Long> ids){
LambdaUpdateWrapper<Dish> luw = new LambdaUpdateWrapper<>();
luw.in(Dish::getId, ids);

if (status==0){
luw.set( Dish::getStatus, 0);
}else {
luw.set( Dish::getStatus, 1);
}
dishService.update(luw);
return R.success("状态更新成功");
}

删除

类似上面,单个和批量一个方法实现

实现

1
2
3
4
5
6
7
@DeleteMapping
public R<String> delete(@RequestParam List<Long> ids){
LambdaQueryWrapper<Dish> lqw = new LambdaQueryWrapper<>();
lqw.in(Dish::getId, ids);
dishService.remove(lqw);
return R.success("删除成功");
}

新增套餐

前端页面与服务端的交互过程

  1. 页面发送ajax请求,请求服务端,获取套餐分类数据并展示到下拉框中(这个之前做过)
  2. 页面发送ajax请求,请求服务端,获取菜品分类数据并展示到添加菜品窗口
  3. 页面发送ajax请求,请求服务端,根据菜品分类查询对应的菜品数据并展示到添加菜品窗口中
  4. 页面发送请求进行图片上传,请求服务端将图片保存到服务器(已完成)
  5. 页面发送请求进行图片下载,将上传的图片进行回显(已完成)
  6. 点击保存按钮,发送ajax请求,将套餐相关数据以json形式提交到服务端

新增套餐页面:

添加菜品页面

  • 这个页面是发送的GET请求,且路径为dish/list?categoryId=xxx

  • 所以先去DishController中编写对应的get方法来正确显示菜品数据

添加菜品页面

可以看出,根据类别id查找出所有该类的菜品,作为list返回给前端页面

前端

  1. 按键

    1
    <span v-if="dishTable.length == 0" class="addBut" @click="openAddDish"> + 添加菜品</span>
  2. 点击事件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 添加菜品
    openAddDish() {
    this.seachKey = ''
    this.dialogVisible = true
    //搜索条件清空,菜品重新查询,菜品类别选第一个重新查询
    this.value = ''
    this.keyInd = 0
    this.getDishList(this.dishType[0].id)
    },
  3. getDishList根据id获取菜品

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // 通过套餐ID获取菜品列表分类
    getDishList (id) {
    queryDishList({categoryId: id}).then(res => {
    if (res.code === 1) {
    if (res.data.length == 0) {
    this.dishAddList = []
    return
    }
    let newArr = res.data;
    newArr.forEach((n) => {
    n.dishId = n.id
    n.copies = 1
    // n.dishCopies = 1
    n.dishName = n.name
    })
    this.dishAddList = newArr
    } else {
    this.$message.error(res.msg)
    }
    })
    },
  4. ajax请求

    1
    2
    3
    4
    5
    6
    7
    8
    // 查菜品列表的接口
    const queryDishList = (params) => {
    return $axios({
    url: '/dish/list',
    method: 'get',
    params
    })
    }

后端

DishController中编写对应的get方法来正确显示菜品数据

1
2
3
4
5
6
7
8
9
10
@GetMapping("/list")
public R<List<Dish>> get(Dish dish){
LambdaQueryWrapper<Dish> lqw = new LambdaQueryWrapper<>();
lqw.eq(Dish::getCategoryId, dish.getCategoryId());
lqw.eq(Dish::getStatus, 1);
lqw.orderByAsc(Dish::getSort).orderByDesc(Dish::getUpdateTime);

List<Dish> list = dishService.list(lqw);
return R.success(list);
}

效果

下拉列表

  1. 按钮

    1
    2
    3
    4
    5
    6
    ![image-20230623235037784](image-20230623235037784.png) <el-button
    type="primary"
    @click="addSetMeal('add')"
    >
    + 新建套餐
    </el-button>
  2. addSetMeal

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 添加
    addSetMeal (st) {
    if (st === 'add'){
    window.parent.menuHandle({
    id: '5',
    url: '/backend/page/combo/add.html',
    name: '添加套餐'
    },true)
    } else {
    window.parent.menuHandle({
    id: '5',
    url: '/backend/page/combo/add.html?id='+st,
    name: '修改套餐'
    },true)
    }

    add方法,跳转到add页面

  3. 这里因为前面已经写过了类别自动加载的下拉列表部分

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 获取套餐分类
    getDishTypeList() {
    getCategoryList({ type: 2, page: 1, pageSize: 1000 }).then((res) => {
    if (res.code === 1) {
    this.setMealList = res.data.map((obj) => ({ ...obj, idType: obj.id }))
    } else {
    this.$message.error(res.msg || '操作失败')
    }
    })
    },

    getCategoryList发送ajax请求

    1
    2
    3
    4
    5
    6
    7
    8
    // 获取菜品分类列表
    const getCategoryList = (params) => {
    return $axios({
    url: '/category/list',
    method: 'get',
    params
    })
    }

    可以看出,这个请求我们在CategoryController中已经处理过了,只不过前面传入的Type不一样

数据提交

url请求:

发送的数据

可以看出,发送的内容不光包括setmeal本身的属性,还包括了setmealDishes,类似于前面的菜品口味,这里也需要创建对应的Dto以综合两个表的数据。

准备工作

  1. 导入SetmealDto到dto包
1
2
3
4
5
6
7
@Data
public class SetmealDto extends Setmeal {

private List<SetmealDish> setmealDishes;

private String categoryName;
}
  1. 这里多个setmealDishes属性,用到了对应的SetmealDish类,也需要导入到entity包下,这个类对应了数据库中的setmeal_dish表

  2. 创建SetmealDish的mapper、service、serviceImpl等,因为后续需要对其进行表数据存储

控制器方法

SetmealService

1
2
3
4
public interface SetmealService extends IService<Setmeal> {
//保存套餐时保存对应的菜品
void saveWithDish(SetmealDto setmealDto);
}

SetmealServiceImpl

这里类比前面的菜品口味表即可实现,主要是先保存setmeal数据,然后获取套餐id,通过dto获取到套餐菜品列表,然后逐个对其套餐id进行赋值(原本没有套餐id内容),然后将使用setmealDishService对套餐菜品数据进行保存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Service
public class SetmealServiceImpl extends ServiceImpl<SetmealMapper, Setmeal> implements SetmealService {

@Autowired
private SetmealDishService setmealDishService;
@Override
public void saveWithDish(SetmealDto setmealDto) {
this.save(setmealDto);
Long id = setmealDto.getId();

List<SetmealDish> setmealDishes = setmealDto.getSetmealDishes();
for(SetmealDish setmealDish : setmealDishes){
setmealDish.setSetmealId(id);

}

setmealDishService.saveBatch(setmealDishes);
}
}

可以看到,套餐表和套餐菜品表都有新增的内容,且id对应

并且展示在页面上

套餐信息分页查询

初步实现

可以看到请求url

所以初步实现

1
2
3
4
5
6
7
8
9
10
11
@GetMapping("/page")
public R<Page> page(int page, int pageSize, String name){
log.info("page:{}, pageSize:{}, name:{}",page,pageSize,name);

Page<Setmeal> pageInfo = new Page<Setmeal>(page,pageSize);
LambdaQueryWrapper<Setmeal> lqw = new LambdaQueryWrapper<>();
lqw.like(name!=null,Setmeal::getName, name);

setmealService.page(pageInfo,lqw);
return R.success(pageInfo);
}

页面展示如下

可以看出,此时套餐分类一栏没有正常显示;图片只需要将资源放置在原来设定的文件夹下即可。

查看发送的数据

可以看出,此时传入的数据包含了类别id,但是前端需要显示的是类别name,所以后端部分需要进行一个转换,但是setmeal中并没有CategoryName属性,所以又用到了前面引入的SetmealDto类了!其中引入了categoryName,所以类比于之前的菜品管理页面,这里同理实现。

套餐分类显示

类别与13.2内容。注意需要引入CategoryService以根据类别id获取name

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@GetMapping("/page")
public R<Page> page(int page, int pageSize, String name){
log.info("page:{}, pageSize:{}, name:{}",page,pageSize,name);
//原本的分页信息,但是不包含类别名称
Page<Setmeal> pageInfo = new Page<Setmeal>(page,pageSize);
//新建一个setmealDto的分页构造器,其包含了categoryName,后续将原本分页信息赋值给这里
Page<SetmealDto> setmealDtoPage = new Page<>(page, pageSize);

//分页查询常规流程
LambdaQueryWrapper<Setmeal> lqw = new LambdaQueryWrapper<>();
lqw.like(name!=null,Setmeal::getName, name);
setmealService.page(pageInfo,lqw);

//对象拷贝,这里只需要拷贝一下查询到的条目数,不拷贝records,也就是数据本身,因为此时dish数据缺少一项类别内容
BeanUtils.copyProperties(pageInfo, setmealDtoPage, "records");

//这里通过分页信息获取records,也就是具体的每条信息
List<Setmeal> records = pageInfo.getRecords();
//新建一个SetmealDto的列表,后续添加SetmealDto数据(既包含Setmeal也有categoryName)
List<SetmealDto> list = new ArrayList<>();

//遍历records数据setmeal,获取其类别id,然后调用类别控制器根据id查找对应的类别,然后获取其name
for(Setmeal setmeal : records){
Long categoryId = setmeal.getCategoryId();
Category category = categoryService.getById(categoryId);
SetmealDto setmealDto = new SetmealDto();

//将数据赋给setmealDto对象
BeanUtils.copyProperties(setmeal, setmealDto);
//设置类别名称
setmealDto.setCategoryName(category.getName());
list.add(setmealDto);
}
//将新的分页信息中原本被忽略的records属性进行赋值,也就是新的setmealDto数据
setmealDtoPage.setRecords(list);

return R.success(setmealDtoPage);
}

效果:


修改套餐

套餐信息回显

前端分析

  1. 根据id获取套餐信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async init() {
querySetmealById(this.id).then((res) => {
if (String(res.code) === '1') {
this.ruleForm = res.data
this.ruleForm.status = res.data.status === '1'
this.ruleForm.price = res.data.price / 100
this.imageUrl = `/common/download?name=${res.data.image}`
this.checkList = res.data.setmealDishes
this.dishTable = res.data.setmealDishes
this.ruleForm.idType = res.data.categoryId
// this.ruleForm.password = ''
} else {
this.$message.error(res.msg || '操作失败')
}
})
},
  1. querySetmealById
1
2
3
4
5
6
7
// 查询详情接口
const querySetmealById = (id) => {
return $axios({
url: `/setmeal/${id}`,
method: 'get'
})
}

控制器方法

因为回显内容不仅涉及setmeal,还包括了setmealDish的内容,所以需要用到SetmealDto类进行整合,但是直接用dto接收后端数据的化setmealDish中的套餐id就会为null,所以需要专门进行迭代赋值。

  1. SetmealService添加方法
1
2
//修改套餐内容时回显套餐详情,需要联合setmeal和setmealDish两个表
SetmealDto getByIdWithFlavor(Long id);
  1. 实现

总的流程就是接收被修改套餐的id,然后根据id获取setmeal,拷贝给setmealDto,然后根据这个id对setmealDish进行查询,获取对应的数据,然后将这些dish数据通过dto赋值给setmealdish属性,从而获得完整的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public SetmealDto getByIdWithFlavor(Long id) {
Setmeal setmeal = this.getById(id);
SetmealDto setmealDto = new SetmealDto();

BeanUtils.copyProperties(setmeal, setmealDto);

LambdaQueryWrapper<SetmealDish> lqw = new LambdaQueryWrapper<>();
lqw.eq(SetmealDish::getSetmealId, setmeal.getId());
List<SetmealDish> list = setmealDishService.list(lqw);

setmealDto.setSetmealDishes(list);
return setmealDto;
}
  1. 控制器方法
1
2
3
4
5
6
7
@GetMapping("/{id}")
public R<SetmealDto> getById(@PathVariable Long id){
log.info("套餐id:{}",id);
SetmealDto setmealDto = setmealService.getByIdWithFlavor(id);
log.info("内容回显:{}",setmealDto.toString());
return R.success(setmealDto);
}

套餐信息修改

类似于14.2内容

  1. 提交表单
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
else {
delete prams.updateTime
editSetmeal(prams)
.then((res) => {
if (res.code === 1) {
this.$message.success('套餐修改成功!')
this.goBack()
} else {
this.$message.error(res.msg || '操作失败')
}
})
.catch((err) => {
this.$message.error('请求出错了:' + err)
})
}
  1. editSetmeal
1
2
3
4
5
6
7
8
// 修改数据接口
const editSetmeal = (params) => {
return $axios({
url: '/setmeal',
method: 'put',
data: { ...params }
})
}

删除/启停套餐

删除实现

业务分析:

  • 在套餐管理列表页面点击删除按钮,可以删除对应的套餐信息
  • 也可以通过复选框选择多个套餐,选择批量删除一次性删除多个套餐

注意:对于在售中的套餐不能删除,需要先停售,然后才能删除

交互过程

在代码开发之前,需要梳理一下删除套餐时前端页面和服务器的交互过程:

  1. 删除单个套餐时,页面发送ajax请求,根据套餐id删除对应的套餐

  2. 删除多个套餐,页面发送ajax请求,根据提交的多个套餐id删除多个对应的套餐

删除单个套餐和批量删除这两种请求的地址和请求方式都是相同的,不同的则是传递的id个数,所以在服务端可以提供一个方法来统一处理


SetmealService

1
2
//删除套餐,不仅删除setmeal数据,还需要删除setmealDish数据
void removeWithDish(List<Long> ids);

SetmealServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
public void removeWithDish(List<Long> ids) {
//先判断一下能不能删,如果status为1,则套餐在售,不能删
//select * from setmeal where id in (ids) and status = 1
LambdaQueryWrapper<Setmeal> lqw = new LambdaQueryWrapper<>();
lqw.in(Setmeal::getId, ids);
lqw.eq(Setmeal::getStatus, 1);

//如果不可以删除,则抛出一个业务异常
int count = this.count(lqw);
if (count>0){
throw new CustomException("套餐正在售卖中,不能删除");
}

//如果可以删除,先删除套餐表中的数据
this.removeByIds(ids);

//删除关系表(SetmealDish)中的数据
LambdaQueryWrapper<SetmealDish> dishLqw = new LambdaQueryWrapper<>();
dishLqw.in(SetmealDish::getSetmealId, ids);
setmealDishService.remove(dishLqw);
}

SetmealController

1
2
3
4
5
6
@DeleteMapping
public R<String> delete(@RequestParam List<Long> ids){
log.info("删除套餐的id:{}",ids);
setmealService.removeWithDish(ids);
return R.success("删除成功");
}

此时,删除并不能实现,因为默认所有菜品处于启售状态,停售功能还没有实现。

注意:在SetmealServiceImpl类上方添加事务注解@Transactional,因为设计了多个表的数据删除

停售/启售实现

停售类似前面的删除,也是分为单个停售和批量停售,实现类似上述。

  • 单个停售

  • 批量停售

也是使用一个统一方法来处理,传入参数是列表封装的ids


前端

  1. 前端传来一个status(这里不同于套餐的属性status,这是前端的一个参数,如果)和待修改套餐的ids
1
2
3
4
5
6
7
8
![image-20230625152145404](image-20230625152145404.png)![image-20230625152119397](image-20230625152119397.png)<el-button
type="text"
size="small"
class="blueBug"
@click="statusHandle(scope.row)"
>
{{ scope.row.status == '0' ? '启售' : '停售' }}
</el-button>

如果套餐status为0即停售,则按键显示启售;反之同理

  1. 点击事件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
//状态更改
statusHandle (row) {
let params = {}
if (typeof row === 'string' ){
if (this.checkList.length == 0){
this.$message.error('批量操作,请先勾选操作菜品!')
return false
}
params.ids = this.checkList.join(',')
params.status = row
} else {
params.ids = row.id
params.status = row.status ? '0' : '1'
}
this.$confirm('确认更改该套餐状态?', '提示', {
'confirmButtonText': '确定',
'cancelButtonText': '取消',
'type': 'warning'
}).then(() => {
// 起售停售---批量起售停售接口
setmealStatusByStatus(params).then(res => {
if (res.code === 1) {
this.$message.success('套餐状态已经更改成功!')
this.handleQuery()
} else {
this.$message.error(res.msg || '操作失败')
}
}).catch(err => {
this.$message.error('请求出错了:' + err)
})
})
},

这里将参数传递给setmealStatusByStatus

  1. 传递ajax请求
1
2
3
4
5
6
7
8
// 批量起售禁售
const setmealStatusByStatus = (params) => {
return $axios({
url: `/setmeal/status/${params.status}`,
method: 'post',
params: { ids: params.ids }
})
}

可以看出,路径上传递status,然后ids以拼接参数传递

后端

SetmealController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@PostMapping("/status/{status}")
public R<String> stop(@PathVariable int status, @RequestParam List<Long> ids){
log.info("套餐状态:{},套餐ids:{}",status,ids);

LambdaUpdateWrapper<Setmeal> luw = new LambdaUpdateWrapper<>();
luw.in(Setmeal::getId, ids);
if (status==0){
luw.set( Setmeal::getStatus, 0);
}else {
luw.set( Setmeal::getStatus, 1);
}
setmealService.update(luw);
return R.success("修改销售状态成功");
}

UpdateWrapper

在上面引入了UpdateWrapper,可以通过set方法去修改数据属性,然后使用Service中update方法接收UpdateWrapper实现数据的更新


手机/邮件验证码

需求分析

为了方便用户登录,移动端通常都会提供通过手机验证码登录的功能(可以平替成邮箱验证码)

  • 手机(邮箱)验证码登录的优点:
    • 方便快捷,无需注册,直接登录
    • 使用短信验证码作为登录凭证,无需记忆密码
    • 安全
  • 登录流程: 登录页面(front/page/login.html)输入手机号(邮箱) > 获取验证码 > 输入验证码 > 点击登录 > 登录成功

因为短信验证码服务收费,所以这里参考[这篇文章](瑞吉外卖 | Kyle’s Blog (cyborg2077.github.io))使用邮箱验证码。具体操作需要开启POP3/STMP服务,获取一个16位的授权码

  • 设置->账户->开启服务

  • 获取一个16位授权码:pjgratlrjjcyecfb

数据模型

通过手机号(邮箱)验证码登陆时,涉及的表为user表,结构如下:

  • 手机号(邮箱)是区分不同用户的标识,在用户登录的时候判断所输入的手机号(邮箱)是否存储在表中
  • 如果不在表中,说明该用户为一个新的用户,将该用户自动保在user表中

准备工作

  1. 导入实体类User.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Data
public class User implements Serializable {

private static final long serialVersionUID = 1L;
private Long id;

//姓名
private String name;

//手机号
private String phone;

//性别 0 女 1 男
private String sex;

//身份证号
private String idNumber;

//头像
private String avatar;

//状态 0:禁用,1:正常
private Integer status;
}
  1. 创建对应UserMapper、UserService、UserServiceImpl、UserController

  2. 导入maven坐标依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- https://mvnrepository.com/artifact/javax.activation/activation -->
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/javax.mail/mail -->
<dependency>
<groupId>javax.mail</groupId>
<artifactId>mail</artifactId>
<version>1.4.7</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-email -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-email</artifactId>
<version>1.4</version>
</dependency>
  1. 创建utils包,存放信息/邮件验证码发送工具类、随机生成验证码工具

MainUtils

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Properties;

import javax.mail.Authenticator;
import javax.mail.MessagingException;
import javax.mail.PasswordAuthentication;
import javax.mail.Session;
import javax.mail.Transport;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import javax.mail.internet.MimeMessage.RecipientType;

public class MailUtils {
public static void main(String[] args) throws MessagingException {
//可以在这里直接测试方法,填自己的邮箱即可
sendTestMail("2214978386@qq.com", new MailUtils().achieveCode());
}

public static void sendTestMail(String email, String code) throws MessagingException {
// 创建Properties 类用于记录邮箱的一些属性
Properties props = new Properties();
// 表示SMTP发送邮件,必须进行身份验证
props.put("mail.smtp.auth", "true");
//此处填写SMTP服务器
props.put("mail.smtp.host", "smtp.qq.com");
//端口号,QQ邮箱端口587
props.put("mail.smtp.port", "587");
// 此处填写,写信人的账号
props.put("mail.user", "2214978386@qq.com");
// 此处填写16位STMP口令
props.put("mail.password", "pjgratlrjjcyecfb");
// 构建授权信息,用于进行SMTP进行身份验证
Authenticator authenticator = new Authenticator() {
protected PasswordAuthentication getPasswordAuthentication() {
// 用户名、密码
String userName = props.getProperty("mail.user");
String password = props.getProperty("mail.password");
return new PasswordAuthentication(userName, password);
}
};
// 使用环境属性和授权信息,创建邮件会话
Session mailSession = Session.getInstance(props, authenticator);
// 创建邮件消息
MimeMessage message = new MimeMessage(mailSession);
// 设置发件人
InternetAddress form = new InternetAddress(props.getProperty("mail.user"));
message.setFrom(form);
// 设置收件人的邮箱
InternetAddress to = new InternetAddress(email);
message.setRecipient(RecipientType.TO, to);
// 设置邮件标题
message.setSubject("wzy的邮件测试");
// 设置邮件的内容体
message.setContent("尊敬的用户:你好!\n注册验证码为:" + code + "(有效期为一分钟,请勿告知他人)", "text/html;charset=UTF-8");
// 最后当然就是发送邮件啦
Transport.send(message);
}

public static String achieveCode() {
String[] beforeShuffle = new String[]{"0","1","2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F",
"G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a",
"b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v",
"w", "x", "y", "z"};
List<String> list = Arrays.asList(beforeShuffle);//将数组转换为集合
Collections.shuffle(list); //打乱集合顺序
StringBuilder sb = new StringBuilder();
for (String s : list) {
sb.append(s); //将集合转化为字符串
}
return sb.substring(3, 8);
}
}

测试结果:

  1. 查看前端页面front/page/login.html

    • 直接访问效果如下:

      这是因为页面由h5编写,查看需要使用浏览器的手机模式:打开F12,点击如下按钮切换为手机模式

    • 手机模式显示如下:

修改拦截器

  • 对用户登录操作放行

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    //定义不需要处理的请求
    String[] urls = new String[]{
    "/employee/login",
    "/employee/logout",
    "/backend/**", //静态资源
    "/front/**",

    //对用户登陆操作放行
    "/user/login",
    "/user/sendMsg"
    };
  • 判断用户是否登录,已登录就直接放行

    1
    2
    3
    4
    5
    6
    7
    8
    //4.2 判断用户是否登录
    if(request.getSession().getAttribute("user") != null){
    log.info("用户已登录,用户id为:{}",request.getSession().getAttribute("user"));
    Long userId = (Long)request.getSession().getAttribute("user");
    BaseContext.setCurrentId(userId);
    filterChain.doFilter(request,response);
    return;
    }

验证码发送

login.html中判断手机号的正则表达式换成判断邮箱的正则表达式^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$

注意:使用day5下的前端资源替换front前端资源,并且清除浏览器缓存,然后再访问登录页面

  1. 点击获取验证发就会发送sendMsg的ajax请求

  1. 处理sendMsg请求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@PostMapping("/sendMsg")
public R<String> sendMsg(@RequestBody User user, HttpSession session) throws MessagingException {
//获取手机号/邮箱
String phone = user.getPhone();

//调用工具类完成验证码发送
if (!phone.isEmpty()) {
//随机生成一个验证码
String code = MailUtils.achieveCode();

//这里的phone其实就是邮箱,code是我们生成的验证码
MailUtils.sendTestMail(phone, code); //这部分抛出异常

//将要发送的验证码保存在session,然后与用户填入的验证码进行对比
session.setAttribute(phone, code);
return R.success("验证码发送成功");
}
return R.error("验证码发送失败");
}

然后点击获取验证码,就会往邮箱发送验证码,且可以查看session中保存的验证码

  1. 输入验证码后,点击登录,发送如下ajax请求,并且携带请求参数

  1. 处理login请求

可以直接用一个map接收请求参数;或者像以前一样创建UserDto,新增code属性

1
2
3
4
5
@PostMapping("/login")
public R<String> login(@RequestBody Map map, HttpSession session){
log.info(map.toString());
return null;
}

可以接收到请求参数

1
INFO 10716 --- [nio-8080-exec-5] com.wzy.controller.UserController        : {phone=2214978386@qq.com, code=tH4W5}

继续优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@PostMapping("/login")
public R<User> login(@RequestBody Map map, HttpSession session){
log.info(map.toString());
//获取邮箱
String phone = map.get("phone").toString();
//获取验证码
String code = map.get("code").toString();

//从session中获取保存的验证码
Object codeInSession = session.getAttribute(phone);

//进行验证码的比对
if (codeInSession !=null && codeInSession.equals(code)){
//判断一下当前用户是否存在
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
//从数据库中查询是否有其邮箱
queryWrapper.eq(User::getPhone, phone);
User user = userService.getOne(queryWrapper);
//如果不存在,则创建一个,存入数据库
if (user == null) {
user = new User();
user.setPhone(phone);
user.setStatus(1);
userService.save(user);
user.setName("游客" + codeInSession);
}
//登录成功后,需要保存session,表示登录状态,因为前面的过滤器进行了用户登录判断
session.setAttribute("user",user.getId()); //注意
//并将其作为结果返回
return R.success(user);
}
return R.error("登录失败");
}

其中标注注意的部分:在登录成功后需要将用户信息存入session,因为之前的过滤器中设定了相应地方法,防止直接输连接进入主页面,所以添加了用户登录判断部分,所以登陆成功后需要将信息存入session中

  1. 此时点击登录,进入主页面


地址簿

需求分析

  • 地址簿,指的是移动端消费者用户的地址信息(外卖快递的收货地址)
  • 用户登录成功后可以维护自己的地址信息(自己修改删除新增等)
  • 同一个用户可以有多个地址信息,但是只能有一个默认地址。(有默认地址的话会很方便)

地址簿数据库

注意:因为前面使用的邮箱验证,所以这里的phone字段长度11有些不够了,所以需要修改其长度。我这里修改为20

完成以下准备:

  1. 导入实体类AddressBook
  2. 创建Mapper、Service、ServiceImpl、Controller

完善地址管理页面

  1. 初始进入地址管理页面front/page/address.html,并发送请求

  1. 完善list请求

这里的需求是查找登录用户的所有地址并显示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@GetMapping("/list")
public R<List<AddressBook>> list(AddressBook addressBook){
//首先根据线程中保存的用户id,对地址簿的userid进行设置
// addressBook.setUserId(BaseContext.getCurrentId());
log.info("addressBook={}", addressBook);

LambdaQueryWrapper<AddressBook> lqw = new LambdaQueryWrapper<>();
lqw.eq(addressBook.getUserId()!=null, AddressBook::getUserId, addressBook.getUserId());
lqw.orderByDesc(AddressBook::getUpdateTime);

List<AddressBook> list = addressBookService.list(lqw);

return R.success(list);
}

新增收货地址

  1. 点击按键后跳转新增页面,填好表单,提交数据时,前端发送如下请求,并且携带了对应填入的参数

  1. 实现该请求
1
2
3
4
5
6
7
@PostMapping
public R<String> save(@RequestBody AddressBook addressBook){
log.info("填入的地址信息:{}",addressBook.toString());
addressBook.setUserId(BaseContext.getCurrentId());
addressBookService.save(addressBook);
return R.success("地址保存成功");
}

默认地址设置

  • 默认地址,按理说数据库中,有且仅有一条数据为默认地址,也就是is_default字段为1
  • 如何保证整个表中的is_default字段只有一条为1
    • 每次设置默认地址的时候,将当前用户所有地址的is_default字段设为0,随后将当前地址的is_default字段设为1
  1. 点击设为默认地址按钮,发送如下请求

触发点击事件方法,判断是否存在id

1
2
3
4
5
6
7
8
9
10
async setDefaultAddress(item){
if(item.id){
const res = await setDefaultAddressApi({id:item.id})
if(res.code === 1){
this.initData()
}else{
this.$message.error(res.msg)
}
}
},

对应的api方法发送ajax请求

1
2
3
4
5
6
7
8
//设置默认地址
function setDefaultAddressApi(data){
return $axios({
'url': '/addressBook/default',
'method': 'put',
data
})
}
  1. 实现default请求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@PutMapping("/default")
public R<AddressBook> setDefault(@RequestBody AddressBook addressBook){
Long userId = addressBook.getUserId();
log.info("当前用户id:{}", userId);

LambdaUpdateWrapper<AddressBook> luw = new LambdaUpdateWrapper<>();
luw.eq(addressBook.getUserId() != null, AddressBook::getUserId, userId);
//将默认地址字段全设置为0
luw.set(AddressBook::getIsDefault, 0);
addressBookService.update(luw);

//然后将本地址设为默认地址
addressBook.setIsDefault(1);
addressBookService.updateById(addressBook);
return R.success(addressBook);
}

image-20230628201639744

此时点击设为默认地址,原来的默认地址就会取消勾选,并且被勾选的会排在第一个

修改/删除地址

点击修改地址,首先会跳转到地址编辑页面,并发送如下请求:

image-20230701134144327

所以首先要根据地址的id将信息回显在地址编辑页面

地址回显

地址编辑页面

1
2
3
4
5
6
7
8
9
10
11
12
13
async initData(){
const params = parseUrl(window.location.search)
this.id = params.id
if(params.id){
this.title = '编辑收货地址'
const res = await addressFindOneApi(params.id)
if(res.code === 1){
this.form = res.data
}else{
this.$notify({ type:'warning', message:res.msg});
}
}
},

发送ajax请求

1
2
3
4
5
6
7
//查询单个地址
function addressFindOneApi(id) {
return $axios({
'url': `/addressBook/${id}`,
'method': 'get',
})
}

控制器方法:

1
2
3
4
5
6
7
8
@GetMapping("/{id}")
public R<AddressBook> edit(@PathVariable Long id){
AddressBook addressBook = addressBookService.getById(id);
if (addressBook == null){
throw new CustomException("地址信息不存在");
}
return R.success(addressBook);
}

image-20230701142149868

编辑地址

修改地址后点击保存,发送如下请求:同时携带数据

前端的saveAddress方法中的判断:

此时是编辑,所以会传来id,所以调用更新地址api

1
2
3
4
5
6
7
8
9
10
11
12
13
if(this.id){
res = await updateAddressApi(this.form)
}else{
res = await addAddressApi(this.form)
}

if(res.code === 1){
window.requestAnimationFrame(()=>{
window.location.replace('/front/page/address.html')
})
}else{
this.$notify({ type:'warning', message:res.msg});
}

ajax请求

1
2
3
4
5
6
7
8
//修改地址
function updateAddressApi(data){
return $axios({
'url': '/addressBook',
'method': 'put',
data
})
}

后端实现:

1
2
3
4
5
6
7
8
@PutMapping
public R<String> updateSave(@RequestBody AddressBook addressBook){
if (addressBook == null) {
throw new CustomException("地址信息不存在,请刷新重试");
}
addressBookService.updateById(addressBook);
return R.success("地址修改成功");
}

image-20230701143048516

删除地址

点击删除地址:

实现

1
2
3
4
5
6
7
8
9
10
11
12
@DeleteMapping
public R<String> delete(Long ids){ //因为前端传来的参数是ids
if (ids == null) {
throw new CustomException("地址信息不存在,请刷新重试");
}
AddressBook addressBook = addressBookService.getById(ids);
if (addressBook == null) {
throw new CustomException("地址信息不存在,请刷新重试");
}
addressBookService.removeById(ids);
return R.success("地址删除成功");
}

菜品展示

需求分析:

  • 用户登陆成功之后,跳转到菜品页面,根据菜品分类来展示菜品和套餐
  • 如果菜品设置了口味信息,则需要展示选择规格按钮,否则只展示+按钮(这部分是前端实现的)

前端分析(Promise异步请求)

页面(front/index.html)发送ajax请求,获取分类数据(菜品分类和套餐分类)

进入首页后,会发送两个ajax请求:分别是分类请求和购物车请求

  1. index页面信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//初始化数据
initData(){
Promise.all([categoryListApi(),cartListApi({})]).then(res=>{
//获取分类数据
if(res[0].code === 1){
this.categoryList = res[0].data
if(Array.isArray(res[0].data) && res[0].data.length > 0){
this.categoryId = res[0].data[0].id
if(res[0].data[0].type === 1){
this.getDishList()
}else{
this.getSetmealData()
}
}
}else{
this.$notify({ type:'warning', message:res[0].msg});
}
//获取菜品数据
if(res[1].code === 1){
this.cartData = res[1].data
}else{
this.$notify({ type:'warning', message:res[1].msg});
}
})
},

可以看出,发送两个请求,分别是categoryListApi()cartListApi()

  1. categoryListApi()
1
2
3
4
5
6
7
//获取所有的菜品分类
function categoryListApi() {
return $axios({
'url': '/category/list',
'method': 'get',
})
}

可以看出发送类别的list请求,但这个请求前面已经写过了,之所以没有正常显示是因为:

Promise.all在处理多个异步请求时,需要等待绑定的每个ajax请求返回数据以后才能正常显示
虽然categoryListApi可以正常返回数据,但是cartListApi并没有写

  1. cartListApi 获取购物车商品信息
1
2
3
4
5
6
7
8
9
//获取购物车内商品的集合
function cartListApi(data) {
return $axios({
'url': '/shoppingCart/list',
//'url': '/front/cartData.json',
'method': 'get',
params:{...data}
})
}

购物车相关功能还没写,所以这里我们用一个写死了的json数据(cartData.json)骗骗它。将url换成我们注释掉的那个就好了

  1. cartData.json
1
{"code":1,"msg":null,"data":[],"map":{}}

此时重启服务器,可以看到菜品已经加载出来

目前存在一个问题,因为菜品是有口味数据的,所以选菜按钮不该是一个+,而应该是选择规格

1
![image-20230628204630500](image-20230628204630500.png)<div class="divTypes" v-if="detailsDialog.item.flavors && detailsDialog.item.flavors.length > 0 && !detailsDialog.item.number " @click ="chooseFlavorClick(detailsDialog.item)">选择规格</div>

但是代码中实际上写了这个按钮,但是选择规格按钮,是根据服务端返回数据中是否有flavors字段来决定的,但我们返回的是一个List<Dish>,其中并没有flavors属性,所以我们需要修改前面的方法返回值为DishDtoDishDto继承了Dish,且新增了flavors属性

选择规格

前端请求

点击具体分类时,页面会发送请求,查询对应的dish

  1. 获取菜品数据,可以看出返回数据类型是list,其中类型是dish
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//获取菜品数据
async getDishList(){
if(!this.categoryId){
return
}
const res = await dishListApi({categoryId:this.categoryId,status:1})
if(res.code === 1){
let dishList = res.data
const cartData = this.cartData
if(dishList.length > 0 && cartData.length > 0){
dishList.forEach(dish=>{
cartData.forEach(cart=>{
if(dish.id === cart.dishId){
dish.number = cart.number
}
})
})
}
this.dishList = dishList
}else{
this.$notify({ type:'warning', message:res.msg});
}
},
  1. dishListApi获取菜品信息,传入的数据是data,并且参数封装为json格式,控制器参数可以使用dish类
1
2
3
4
5
6
7
8
//获取菜品分类对应的菜品
function dishListApi(data) {
return $axios({
'url': '/dish/list',
'method': 'get',
params:{...data}
})
}

后端实现

像前面分析的一样,因为之前写的dish的list方法的返回为dish,不包含flavor属性,所以选择规格这一按钮都不显示

  • 修改前
1
2
3
4
5
6
7
8
9
10
@GetMapping("/list")
public R<List<Dish>> get(Dish dish){
LambdaQueryWrapper<Dish> lqw = new LambdaQueryWrapper<>();
lqw.eq(Dish::getCategoryId, dish.getCategoryId());
lqw.eq(Dish::getStatus, 1);
lqw.orderByAsc(Dish::getSort).orderByDesc(Dish::getUpdateTime);

List<Dish> list = dishService.list(lqw);
return R.success(list);
}
  • 修改后

实现思路,类似前面的13.2 菜品分类内容显示和16.2 套餐分类显示。需要将返回的类型修改为DishDto,主要涉及对象内容的拷贝,根据菜品id在口味表中查询对应的口味,封装在DishDto的flavor属性上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@GetMapping("/list")
public R<List<DishDto>> get(Dish dish){
//
LambdaQueryWrapper<Dish> lqw = new LambdaQueryWrapper<>();
lqw.eq(dish.getCategoryId() != null, Dish::getCategoryId, dish.getCategoryId());
lqw.eq(Dish::getStatus, 1);
lqw.orderByAsc(Dish::getSort).orderByDesc(Dish::getUpdateTime);
List<Dish> list = dishService.list(lqw);

List<DishDto> dishDtoList = new ArrayList<>();
for(Dish dish1 : list){
DishDto dishDto = new DishDto();
BeanUtils.copyProperties(dish1, dishDto);

Long id = dish1.getId();
LambdaQueryWrapper<DishFlavor> flavorlqw = new LambdaQueryWrapper<>();
flavorlqw.eq(DishFlavor::getDishId, id);
List<DishFlavor> flavors = dishFlavorService.list(flavorlqw);

dishDto.setFlavors(flavors);
dishDtoList.add(dishDto);
}
return R.success(dishDtoList);
}

显示效果


套餐展示

展示

之前处理的是category的请求,套餐的请求还未处理,此时点击儿童/商务套餐,会发送如下请求

这部分内容比较简单,在SetmealController中实现

什么时候用@RequestBody ? 首先用于接收请求体的数据,因为get方法没有请求体,所以get一定不会标注,通常是post方法

1
2
3
4
5
6
7
8
9
10
11
12
@GetMapping("/list")
public R<List<Setmeal>> list(Setmeal setmeal){
//条件构造器
LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();
//添加条件
queryWrapper.eq(setmeal.getCategoryId() != null, Setmeal::getCategoryId, setmeal.getCategoryId());
queryWrapper.eq(setmeal.getStatus() != null, Setmeal::getStatus, 1);
//排序
queryWrapper.orderByDesc(Setmeal::getUpdateTime);
List<Setmeal> setmealList = setmealService.list(queryWrapper);
return R.success(setmealList);
}

点击图片查看详情

前端分析

点击套餐图片后发送请求;

点击图片会触发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<div>
<div class="divItem" v-for="(item,index) in dishList" :key="index" @click="dishDetails(item)">
<el-image :src="imgPathConvert(item.image)" >
<div slot="error" class="image-slot">
<img src="./images/noImg.png"/>
</div>
</el-image>
<div>
<div class="divName">{{item.name}}</div>
<div class="divDesc">{{item.description}}</div>
<div class="divDesc">{{'月销' + (item.saleNum ? item.saleNum : 0) }}</div>
<div class="divBottom"><span></span><span>{{item.price/100}}</span></div>
<div class="divNum">
<div class="divSubtract" v-if="item.number > 0">
<img src="./images/subtract.png" @click.prevent.stop="subtractCart(item)"/>
</div>
<div class="divDishNum">{{item.number}}</div>
<div class="divTypes" v-if="item.flavors && item.flavors.length > 0 && !item.number " @click.prevent.stop="chooseFlavorClick(item)">选择规格</div>
<div class="divAdd" v-else>
<img src="./images/add.png" @click.prevent.stop="addCart(item)"/>
</div>
</div>
</div>
</div>
</div>

dishDetails()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
async dishDetails(item){
//先清除对象数据,如果不行的话dialog使用v-if
this.detailsDialog.item = {}
this.setMealDialog.item = {}
if(Array.isArray(item.flavors)){
this.detailsDialog.item = item
this.detailsDialog.show = true
}else{
//显示套餐的数据
const res = await setMealDishDetailsApi(item.id)
if(res.code === 1){
this.setMealDialog.item = {...item,list:res.data}
this.setMealDialog.show = true
}else{
this.$notify({ type:'warning', message:res.msg});
}
}
}

setMealDishDetailsApi

1
2
3
4
5
6
7
//获取套餐的全部菜品
function setMealDishDetailsApi(id) {
return $axios({
'url': `/setmeal/dish/${id}`,
'method': 'get',
})
}

后端实现

初步实现

从前面可以看出,需要显示的数据包括:图片、菜品名称、菜品描述即可,这些数据在dish菜品中有,但给的是setmeal的id,所以需要根据setmealId从setmealDish表中查出对应的菜品,然后进而获取对应dish的详细信息,封装在list中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@GetMapping("/dish/{id}")
public R<List<Dish>> setmealShow(@PathVariable Long id){
LambdaQueryWrapper<SetmealDish> lqw = new LambdaQueryWrapper<>();
lqw.eq(SetmealDish::getSetmealId, id);
List<SetmealDish> list = setmealDishService.list(lqw);

List<Dish> dishList = new ArrayList<>();

for(SetmealDish setmealDish : list){
Long dishId = setmealDish.getDishId();
Dish dish = dishService.getById(dishId);
dishList.add(dish);
}
return R.success(dishList);
}

初步结果:

可以看到,菜品名称后面多了个undefined份,说明份数字段读取失败。经过查看,发现这里的属性是copies,该字段属于setmealDish表,dish中不含有这个字段,所以之前的dishDto又派上了用场,其中包含了copies属性

优化后的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@GetMapping("/dish/{id}")
public R<List<DishDto>> setmealShow(@PathVariable Long id){
LambdaQueryWrapper<SetmealDish> lqw = new LambdaQueryWrapper<>();
lqw.eq(SetmealDish::getSetmealId, id);
List<SetmealDish> list = setmealDishService.list(lqw);

List<DishDto> dishList = new ArrayList<>();

for(SetmealDish setmealDish : list){
Long dishId = setmealDish.getDishId();
Dish dish = dishService.getById(dishId);

DishDto dishDto = new DishDto();
BeanUtils.copyProperties(dish, dishDto);
dishDto.setCopies(setmealDish.getCopies());
dishList.add(dishDto);
}
return R.success(dishList);
}

效果:


购物车

  • 移动端用户可以将菜品/套餐添加到购物车
  • 对于菜品来说,如果设置了口味信息,则需要选择规格后才能加入购物车(前端实现)
  • 对于套餐来说,可以直接点击当前套餐加入购物车
  • 在购物车中可以修改菜品/套餐的数量,也可以清空购物车

准备工作:

导入ShoppingCart实体类,实现mapper、service、serviceImpl、controller

加入购物车

其中number默认为1

请求分析

首先选择规格,这里必须选择才能加入购物车(前端实现):

然后可以看到发送如下请求及携带参数:

后端实现

初步实现

1
2
3
4
5
@PostMapping("/add")
public R<String> add(@RequestBody ShoppingCart shoppingCart){
log.info("加入购物车的数据:{}", shoppingCart.toString());
return null;
}

后端接收:

1
INFO 2084 --- [nio-8080-exec-6] c.wzy.controller.ShoppingCartController  : 加入购物车的数据:ShoppingCart(id=null, name=正宗凉茶加多宝, userId=null, dishId=1672232482787053570, setmealId=null, dishFlavor=中辣, number=null, amount=11, image=6d7c7a12-b0dc-4557-8809-346bda446035.png, createTime=null)

可以看到,包含了菜品的信息,但是用户id、添加数量、菜品或者套餐的判断这些内容并没有实现,仍需进一步完善

进一步完善:

点了两个菜,不需要存储两条数据,只需要对属性number增加即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@PostMapping("/add")
public R<ShoppingCart> add(@RequestBody ShoppingCart shoppingCart){
log.info("加入购物车的数据:{}", shoppingCart.toString());
// 设置用户id,指定当前是哪个用户的购物车数据
Long currentId = BaseContext.getCurrentId();
shoppingCart.setUserId(currentId);
//获取当前菜品id
Long dishId = shoppingCart.getDishId();

//条件查询,查询当前用户的购物车信息
LambdaQueryWrapper<ShoppingCart> lqw = new LambdaQueryWrapper<>();
lqw.eq(ShoppingCart::getUserId, currentId);

//获取当前菜品id
if (dishId!=null){
//添加的是菜品
lqw.eq(ShoppingCart::getDishId, dishId);
}else {
//添加的是套餐
lqw.eq(ShoppingCart::getSetmealId, shoppingCart.getSetmealId());
}

//查询当前菜品或者套餐是否已经在购物车中了
ShoppingCart cartServiceOne = shoppingCartService.getOne(lqw);

if (cartServiceOne!=null){
//如果已存在就在当前的数量上加1
Integer number = cartServiceOne.getNumber();
cartServiceOne.setNumber(number+1);
shoppingCartService.save(cartServiceOne);
}else {
//如果不存在,则添加到购物车,数量默认为1; 并且设置创建时间
shoppingCart.setCreateTime(LocalDateTime.now());
shoppingCartService.save(shoppingCart);
//这里是为了统一结果,最后都返回cartServiceOne会比较方便
cartServiceOne = shoppingCart;
}
return R.success(cartServiceOne);
}

因为还没有实现购物车显示功能,所以需要到数据库中查看效果:(注意创建时间在新增时添加)

image-20230629201622776

查看购物车

之前为了不报错,我们将查看购物车的地址换成了一个死数据。那现在我们要做的就是换成真数据

cartListApi(): 获取购物车商品信息

1
2
3
4
5
6
7
8
9
//获取购物车内商品的集合
function cartListApi(data) {
return $axios({
'url': '/shoppingCart/list',
//'url': '/front/cartData.json',
'method': 'get',
params:{...data}
})
}

front/index.html中,找到vue的method,其中包含initDate,其中通过异步请求实现类别加载和购物车加载的功能

实现请求:

1
2
3
4
5
6
7
@GetMapping("/list")
public R<List<ShoppingCart>> list(){
LambdaQueryWrapper<ShoppingCart> lqw = new LambdaQueryWrapper<>();
lqw.eq(ShoppingCart::getUserId, BaseContext.getCurrentId());
List<ShoppingCart> list = shoppingCartService.list(lqw);
return R.success(list);
}

效果:添加一次后再添加就会显示该菜品已存在,说明前面的add功能有bug

经过代码查看,发现是前面add方法L30处书写错误

第一个之所以错误是因为save是根据id判断的,因为id已经存在,所以不能保存,而第二个只是更新数据

1
2
3
shoppingCartService.save(cartServiceOne);
//下面正确
shoppingCartService.updateById(cartServiceOne);

效果:此时可以点多个

==注意==:此时减的功能并没实现,后续需要完善

去掉某个商品

也就是前面说的减号功能,点击减号触发请求以及参数

功能实现:

主要思路:因为前端只传来dishId或者setmealId,所以用ShoppingCart接收参数,然后根据id去获取对应的shoppingCart,然后根据id类型不同获取实例,然后判断数量进行减功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
![image-20230701143305020](image-20230701143305020.png)@PostMapping("/sub")
public R<String> sub(@RequestBody ShoppingCart shoppingCart){
Long dishId = shoppingCart.getDishId();
Long setmealId = shoppingCart.getSetmealId();

LambdaQueryWrapper<ShoppingCart> lqw = new LambdaQueryWrapper<>();
lqw.eq(ShoppingCart::getUserId, BaseContext.getCurrentId());

ShoppingCart item = null;

if (dishId != null){
lqw.eq(ShoppingCart::getDishId, dishId);
item = shoppingCartService.getOne(lqw);
}

if (setmealId != null){
lqw.eq(ShoppingCart::getSetmealId, setmealId);
item = shoppingCartService.getOne(lqw);
}

Integer number = item.getNumber();
log.info("num:{}", number);
if (number == 1){
shoppingCartService.removeById(item);
}else {
item.setNumber(number-1);
shoppingCartService.updateById(item);
}
return R.success("修改成功");
}

清空购物车

点击购物车的清空图标,发送如下请求:

实现很简单,只需要将当前用户的所有菜品、套餐全部delete即可

1
2
3
4
5
6
7
8
@DeleteMapping("/clean")
public R<String> clean(){
Long currentId = BaseContext.getCurrentId();
LambdaQueryWrapper<ShoppingCart> lqw = new LambdaQueryWrapper<>();
lqw.eq(ShoppingCart::getUserId, currentId);
shoppingCartService.remove(lqw);
return R.success("购物车清空成功!");
}

用户下单

移动端用户将菜品或者套餐加入购物车后,可以点击购物车中的去结算按钮,页面跳转到订单确认页面,点击去支付按钮,完成下单操作

  1. 在购物车中点击去结算按钮,页面跳转到订单确认页面
  2. 在订单确认页面中,发送ajax请求,请求服务端,获取当前登录用户的默认地址
  3. 在订单确认页面,发送ajax请求,请求服务端,获取当前登录用户的购物车数据
  4. 在订单确认页面点击去支付按钮,发送ajax请求,请求服务端,完成下单操作

准备工作

用户下单业务对应的数据表为orders表和order_detail

orders

更多的是用户的信息:地址、订单号、下单时间等

order_detail

与点单的菜品更相关

导入这两个实体类,然后分别完成这两个表的mapper、service、serviceImpl、controller

默认地址加载

首先点击前往去结算,触发点击事件,然后页面跳转

1
2
3
4
5
6
7
8
//跳转到去结算界面
toAddOrderPage(){
if(this.cartData.length > 0){
window.requestAnimationFrame(()=>{
window.location.href ='/front/page/add-order.html'
})
}
},

进入该页面,前端首先发送如下请求:

页面跳转到确认订单页面,发送ajax请求,用于获取用户的默认地址,但是请求失败,服务端没有对应的映射

AddressBookController中编写对应方法

1
2
3
4
5
6
7
8
9
@GetMapping("/default")
public R<AddressBook> getAddress(){
Long id = BaseContext.getCurrentId();
LambdaQueryWrapper<AddressBook> lqw = new LambdaQueryWrapper<>();
lqw.eq(id!=null, AddressBook::getUserId, id);
lqw.eq(AddressBook::getIsDefault, 1);
AddressBook addressBook = addressBookService.getOne(lqw);
return R.success(addressBook);
}

效果:

结算

后端实现

此时点击去支付,点击事件触发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async goToPaySuccess(){
const params = {
remark:this.note,
payMethod:1,
addressBookId:this.address.id
}
const res = await addOrderApi(params)
if(res.code === 1){
window.requestAnimationFrame(()=>{
window.location.replace('/front/page/pay-success.html')
})
}else{
this.$notify({ type:'warning', message:res.msg});
}
},

则前端发送如下请求:

具体的submit方法放在OrderService写,OrderController调用写好的submit方法,可以看出接收order参数

  1. OrderService
1
2
3
public interface OrdersService extends IService<Orders> {
void submit(Orders orders);
}
  1. OrderServiceImpl
1
2
3
4
5
6
7
@Service
public class OrdersServiceImpl extends ServiceImpl<OrdersMapper, Orders> implements OrdersService {
@Override
public void submit(Orders orders) {

}
}
  1. OrderController
1
2
3
4
5
6
@PostMapping("/submit")
public R<String> submit(Orders orders){
log.info("订单详情:{}", orders.toString());
ordersService.submit(orders);
return R.success("用户下单成功");
}

编写具体的submit方法的逻辑代码,我们先来分析一下下单功能,都需要做什么事情

  • 获取当前用户id
  • 根据用户id查询其购物车数据
  • 根据查询到的购物车数据,对订单表插入数据(1条)
  • 根据查询到的购物车数据,对订单明细表插入数据(多条)
  • 清空购物车数据

OrdersServiceImpl中完整的submit实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
@Service
public class OrdersServiceImpl extends ServiceImpl<OrdersMapper, Orders> implements OrdersService {

@Autowired
private ShoppingCartService shoppingCartService;

@Autowired
private UserService userService;

@Autowired
private AddressBookService addressBookService;

@Autowired
private OrderDetailService orderDetailService;


@Transactional //因为涉及多个数据库操作,所以添加事务
@Override
public void submit(Orders orders) {
//获取当前用户id
Long userId = BaseContext.getCurrentId();

//查询当前用户的购物车
LambdaQueryWrapper<ShoppingCart> lqw = new LambdaQueryWrapper<>();
lqw.eq(ShoppingCart::getUserId, userId);
List<ShoppingCart> shoppingCartList = shoppingCartService.list(lqw);

//判断一下购物车是否为空
if (shoppingCartList == null || shoppingCartList.size() == 0) {
throw new CustomException("购物车数据为空,不能下单");
}
//查询用户数据
User user = userService.getById(userId);

//查询地址数据
Long addressBookId = orders.getAddressBookId();
AddressBook addressBook = addressBookService.getById(addressBookId);
if (addressBook == null) {
throw new CustomException("地址为空,不能下单");
}
//向订单表插入数据,一条数据(因为前端传递的order属性就三个,所以需要自己丰富)
long orderId = IdWorker.getId();//订单号

//金额累加
AtomicInteger amount = new AtomicInteger(0);

//向订单细节表设置属性(遍历购物车中的菜品,然后对订单细节表进行赋值,同时进行金额的累加)
List<OrderDetail> orderDetailList = shoppingCartList.stream().map((item) -> {
OrderDetail orderDetail = new OrderDetail();
orderDetail.setOrderId(orderId);
orderDetail.setName(item.getName());
orderDetail.setImage(item.getImage());
orderDetail.setDishId(item.getDishId());
orderDetail.setSetmealId(item.getSetmealId());
orderDetail.setDishFlavor(item.getDishFlavor());
orderDetail.setNumber(item.getNumber());
orderDetail.setAmount(item.getAmount());
amount.addAndGet(item.getAmount().multiply(new BigDecimal(item.getNumber())).intValue());

return orderDetail;
}).collect(Collectors.toList());

//向订单表设置属性
orders.setId(orderId);
orders.setNumber(String.valueOf(orderId));//表中订单号为Number,而非主键id
orders.setStatus(2);
orders.setUserId(userId);
orders.setAddressBookId(addressBookId);
orders.setOrderTime(LocalDateTime.now());
orders.setCheckoutTime(LocalDateTime.now());
orders.setAmount(new BigDecimal(amount.get()));
orders.setPhone(addressBook.getPhone());
orders.setUserName(user.getName());
orders.setConsignee(addressBook.getConsignee());
orders.setAddress(
(addressBook.getProvinceName() == null ? "":addressBook.getProvinceName())+
(addressBook.getCityName() == null ? "":addressBook.getCityName())+
(addressBook.getDistrictName() == null ? "":addressBook.getDistrictName())+
(addressBook.getDetail() == null ? "":addressBook.getDetail())
);

this.save(orders);

//向订单明细表插入数据,每一个菜品就是一条数据
orderDetailService.saveBatch(orderDetailList);

//清空购物车
shoppingCartService.remove(lqw);
}
}
  • IdWorker:mybatis-plus中引入的分布式ID生成框架idworker,进一步增强实现生成分布式唯一ID
  • AtomicInteger:原子操作类,多线程条件下的同步操作
  • BigDecimal:用来对超过16位有效位的数进行精确的运算,不会丢失精度

错误解决

此时点击结算,发现报错:地址为空

说明Impl中设置的判定条件符合,但是前端的确传来了addressBookId,但是发现addressBook为空

打印addressBook的内容

1
INFO 22936 --- [nio-8080-exec-5] com.wzy.service.impl.OrdersServiceImpl   : 下单地址数据:null

说明没有接收到前端传来的参数,经过查看OrdersController后,发现传入的形参orders没有使用@RequestBody注解!!!

1
2
3
4
5
6
@PostMapping("/submit")
public R<String> submit(@RequestBody Orders orders){
log.info("订单详情:{}", orders.toString());
ordersService.submit(orders);
return R.success("用户下单成功");
}

成功!

历史订单

  • 访问个人中心的时候,会自动发送请求:

通过分页查询获取最新订单信息

  • 点击历史订单,发送如下请求:

可以看出此时分页查询每次查询5条数据,与上述有所区别

因为二者请求一致,区别在于前端传来的分页查询数据,所以后端使用一个方法就可以实现

通过前端页面内容,可以看出需要展示的内容包括下单时间、下单状态(正在派送、已派送、已完成、已取消)、orderDetails、订单金额、菜品数量。可以看出,这些内容不仅包含在orders表中,也包含了orderDeatils表。所以这里就需要用到DTO了

  1. 导入OrderDto
1
2
3
4
5
6
7
8
9
@Data
public class OrdersDto extends Orders {

private String userName;
private String phone;
private String address;
private String consignee;
private List<OrderDetail> orderDetails;
}
  1. 实现

自动注入OrderDetailService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@GetMapping("/userPage")
public R<Page> page(int page, int pageSize){
Long userId = BaseContext.getCurrentId();

Page<Orders> pageInfo = new Page<>(page, pageSize);
Page<OrdersDto> ordersDtoPage = new Page<>(page,pageSize);

//拷贝分页数据,但不拷贝数据
BeanUtils.copyProperties(pageInfo, ordersDtoPage, "records");

//条件构造器
LambdaQueryWrapper<Orders> lqw = new LambdaQueryWrapper<>();
//查询当前用户id订单数据
lqw.eq(userId != null, Orders::getUserId, userId);
//按时间降序排序
lqw.orderByDesc(Orders::getOrderTime);
ordersService.page(pageInfo, lqw);

List<OrdersDto> ordersDtoList = new ArrayList<>();
for (Orders order : pageInfo.getRecords()) {
OrdersDto ordersDto = new OrdersDto();

BeanUtils.copyProperties(order, ordersDto);

//获取orderId,然后根据这个id,去orderDetail表中查数据
Long id = order.getId();
LambdaQueryWrapper<OrderDetail> orderDeatillqw = new LambdaQueryWrapper<>();
orderDeatillqw.eq(OrderDetail::getOrderId, id);
List<OrderDetail> details = orderDetailService.list(orderDeatillqw);

ordersDto.setOrderDetails(details);
ordersDtoList.add(ordersDto);
}

ordersDtoPage.setRecords(ordersDtoList);
return R.success(ordersDtoPage);
}

以上涉及Dto内容的实现都很相似,多练!

效果:分别为最新订单和历史订单


登出

点击个人页面中的退出登录,发送如下请求:

点击事件

1
2
3
4
5
6
7
8
9
10
async toPageLogin(){
const res = await loginoutApi()
if(res.code === 1){
window.requestAnimationFrame(()=>{
window.location.href = '/front/page/login.html'
})
}else{
this.$notify({ type:'warning', message:res.msg});
}
}

发送ajax请求

1
2
3
4
5
6
function loginoutApi() {
return $axios({
'url': '/user/loginout',
'method': 'post',
})
}

代码实现:

因为前面登录是把用户id保存在了session中,所以登出只需要清除即可

1
2
//登录成功后,需要保存session,表示登录状态,因为前面的过滤器进行了用户登录判断
session.setAttribute("user",user.getId());
1
2
3
4
5
@PostMapping("/loginout")
public R<String> loginout(HttpServletRequest request){
request.getSession().removeAttribute("user");
return R.success("退出成功");
}

清除后页面自动跳转到登录页面


订单明细

分页查询

初步实现

1
2
3
4
5
6
7
8
@GetMapping("/page")
public R<Page> backendPage(int page, int pageSize){
Page<Orders> pageInfo = new Page<>(page, pageSize);
LambdaQueryWrapper<Orders> lqw = new LambdaQueryWrapper<>();
lqw.orderByDesc(Orders::getOrderTime);
Page<Orders> ordersPage = ordersService.page(pageInfo, lqw);
return R.success(ordersPage);
}

效果如下,发现用户名字不显示,这是因为orders表中只有userId,没有userName,这就用到了OrdersDto

优化版本

用到OrderDto,首先copy属性,然后根据order的userId到user表中查询uerName,然后赋值给orderDto

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@GetMapping("/page")
public R<Page> backendPage(int page, int pageSize){
Page<Orders> pageInfo = new Page<>(page, pageSize);
Page<OrdersDto> ordersDtoPage = new Page<>(page, pageSize);

BeanUtils.copyProperties(pageInfo, ordersDtoPage, "records");
LambdaQueryWrapper<Orders> lqw = new LambdaQueryWrapper<>();
lqw.orderByDesc(Orders::getOrderTime);

List<OrdersDto> orderDtoList = new ArrayList<>();

for (Orders order : ordersService.list(lqw)) {
OrdersDto ordersDto = new OrdersDto();
BeanUtils.copyProperties(order, ordersDto);

Long userId = order.getUserId();
String userName = userService.getById(userId).getName();
ordersDto.setUserName(userName);
orderDtoList.add(ordersDto);
}

ordersDtoPage.setRecords(orderDtoList);
return R.success(ordersDtoPage);
}

注意:在这个过程中,发现了一个问题,就是前端登录用户注册后没有给默认name,后续需要优化

条件查询

写到这里发现忽略了上面的条件查询。。。

输入订单号并且选择下单时间区间,发送请求:

前端内容:

1
2
3
4
5
6
7
8
9
10
async init () {
getOrderDetailPage({ page: this.page, pageSize: this.pageSize, number: this.input || undefined, beginTime: this.beginTime || undefined, endTime: this.endTime || undefined }).then(res => {
if (String(res.code) === '1') {
this.tableData = res.data.records || []
this.counts = res.data.total
}
}).catch(err => {
this.$message.error('请求出错了:' + err)
})
},

getOrderDetailPage发送ajax请求,传递五个参数

后端实现:

注意:order中的下单时间类型是LocalDateTime,而这里我们接收前端的条件查询是string,二者可以直接比较大小吗?

首先可以通过String.valueOf进行转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@GetMapping("/page")
public R<Page> backendPage(int page, int pageSize, Long number, String beginTime, String endTime){
Page<Orders> pageInfo = new Page<>(page, pageSize);
Page<OrdersDto> ordersDtoPage = new Page<>(page, pageSize);

BeanUtils.copyProperties(pageInfo, ordersDtoPage, "records");
LambdaQueryWrapper<Orders> lqw = new LambdaQueryWrapper<>();

lqw.like(number!=null,Orders::getId, number);//订单条件查询
lqw.ge(!StringUtils.isEmpty(beginTime),Orders::getOrderTime, beginTime);//起始时间
lqw.le(!StringUtils.isEmpty(endTime),Orders::getOrderTime, endTime);//终止时间
lqw.orderByDesc(Orders::getOrderTime);
ordersService.page(pageInfo, lqw);

List<OrdersDto> orderDtoList = new ArrayList<>();

for (Orders order : ordersService.list(lqw)) {
OrdersDto ordersDto = new OrdersDto();
BeanUtils.copyProperties(order, ordersDto);

Long userId = order.getUserId();
String userName = userService.getById(userId).getName();
ordersDto.setUserName(userName);
orderDtoList.add(ordersDto);
}

ordersDtoPage.setRecords(orderDtoList);
return R.success(ordersDtoPage);
}

修改订单状态

点击派送按钮,发送如下请求和请求参数

Request URL: http://localhost:8080/order

Request Method: PUT

Status Code: 404

1
{"status": 3, "id": "1675070866677088257"}

传来当前状态status和订单id。这个status是order表中的一个字段,默认是2即正在派送

请求分析

根据status作为条件显示两种按钮

1
2
3
4
5
6
7
8
9
<el-divider v-if="row.status === 2" direction="vertical"></el-divider>
<el-button v-if="row.status === 2" type="text" @click="cancelOrDeliveryOrComplete(3, row.id)" class="blueBug">
派送
</el-button>

<el-divider v-if="row.status === 3" direction="vertical"></el-divider>
<el-button v-if="row.status === 3" type="text" @click="cancelOrDeliveryOrComplete(4, row.id)" class="blueBug">
完成
</el-button>

四种不同状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
switch(row.status){
case 1:
str = '待付款'
break;
case 2:
str = '正在派送'
break;
case 3:
str = '已派送'
break;
case 4:
str = '已完成'
break;
case 5:
str = '已取消'
break;
}

cancelOrDeliveryOrComplete()点击事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 取消,派送,完成
cancelOrDeliveryOrComplete (status, id) {
this.$confirm('确认更改该订单状态?', '提示', {
'confirmButtonText': '确定',
'cancelButtonText': '取消',
'type': 'warning'
}).then(() => {
editOrderDetail(params).then(res => {
if (res.code === 1) {
this.$message.success(status === 3 ? '订单已派送' : '订单已完成')
this.init()
} else {
this.$message.error(res.msg || '操作失败')
}
}).catch(err => {
this.$message.error('请求出错了:' + err)
})
})
const params = {
status,
id
}
},

editOrderDetail()发送ajax请求。这里传递的参数就是status和id

1
2
3
4
5
6
7
8
// 取消,派送,完成接口
const editOrderDetail = (params) => {
return $axios({
url: '/order',
method: 'put',
data: { ...params }
})
}

后端实现

根据上面的分析,我们只需要对status字段进行修改即可

1
2
3
4
5
6
7
8
9
@PutMapping
public R<String> stateChange(@RequestBody Orders order){
log.info("前端传递的参数:{}", order.toString());
Long id = order.getId();
Orders realOrder = ordersService.getById(id);
realOrder.setStatus(order.getStatus());
ordersService.updateById(realOrder);
return R.success("订单状态修改成功");
}

实现效果:

image-20230702222342822