20.Vue3与SpringBoot前后端分离开发

前后端分离开发:Vue 3、Spring Boot 与用户管理实战
做一个待办清单,页面要能显示任务,还要能新增、标记完成和删除。用 Spring Boot 配合 Thymeleaf,可以在后端把数据填进模板,再把生成的 HTML 返回给浏览器。
如果页面交给 Vue 来生成,后端只返回任务数据,双方又该怎样配合?按钮点下去以后,数据经过哪些地方?本地能运行以后,部署时又要怎样连接起来?
先从这个小需求入手,把前后端分离开发的过程走一遍:约定接口、开发后端和前端,再联调、构建和部署。待办清单先用后端内存保存任务,便于看清前后端如何协作。
掌握基本过程后,再做一个用户管理页面,接入 MySQL 和 MyBatis-Plus。通过姓名、年龄这两个简单字段,练习 Element Plus 表格展示、模糊查询、后端分页,以及新增、编辑和删除,让页面上的每一个操作都有对应的接口与数据库操作。
1. 前后端分离,究竟分开了什么
1.1 从返回页面,变成返回数据
先看服务端渲染页面的常见做法。浏览器访问一个地址,Spring MVC 的 Controller 查询数据,通过 Model 把数据交给 Thymeleaf,模板引擎生成 HTML,浏览器收到后显示页面。
这里的 Model 是 Spring MVC 为页面渲染提供的数据容器,例如往里面放一份任务列表。它服务于后端模板渲染,并不是前端展示对象的统一名称。
使用 Vue 做前后端分离时,页面的生成工作交给浏览器中的 Vue。浏览器先取得 HTML、JavaScript 和 CSS,再由 JavaScript 请求后端接口,拿到 JSON 数据,最后把数据显示到页面上。
例如,后端返回的内容可以只是:
json
[
{
"id": 1,
"title": "学习接口联调",
"completed": false
}
]
JSON 是一种文本形式的数据交换格式。这里的数组表示任务列表,每个对象表示一条任务;字符串用双引号,布尔值直接写 true 或 false。它不负责页面颜色、按钮位置和交互效果,这些交给前端处理。
因此,前后端分离首先是职责的分工:前端负责页面展示和用户交互,后端负责业务处理、数据存取和权限检查,双方通过 HTTP 接口交换数据。
1.2 为什么要这样开发
假设要给待办清单增加一个手机页面。只要接口仍然返回相同的任务数据,新的页面就能继续调用它,不必让后端重新生成另一套 HTML。
同样,如果把任务列表的样式从普通列表改成卡片,通常只需要修改前端。后端只要继续遵守接口约定,就可以保持原来的实现。
这也方便多人协作。后端开发接口,前端根据约定的数据先做页面,等接口可用后再连接起来。不过,双方能分开开发的前提,是提前说清楚接口格式,而不是各自猜测对方会怎样写。
对于内容较少、交互简单的页面,使用 Thymeleaf 等服务端模板仍然合适。前后端分离多出了前端工程、接口联调和部署配置,应该根据实际需求选择。
1.3 这次各自负责哪些工作
| 需求 | Vue 前端负责 | Spring Boot 后端负责 |
|---|---|---|
| 查看任务 | 请求列表,把每项显示出来 | 读取任务,返回 JSON 数组 |
| 新增任务 | 获取输入,提交标题 | 校验标题,分配编号,保存任务 |
| 修改完成状态 | 提交目标状态,更新显示 | 找到任务,保存新的完成状态 |
| 删除任务 | 提交编号,刷新列表 | 删除对应任务,返回处理结果 |
注意,前端不能因为已经检查了输入,就要求后端省略校验。别人可以绕过页面直接发送 HTTP 请求,后端仍然必须检查数据是否合法。
浏览器也不应该直接连接 MySQL。数据库账号留在后端,由后端完成查询和业务检查,再返回允许展示的数据。
2. 开始写代码前,先约定接口
2.1 先确定一条任务长什么样
任务只保留三个字段:
| 字段 | 类型 | 含义 |
|---|---|---|
id |
数字 | 后端分配的任务编号 |
title |
字符串 | 任务标题,不能全是空白,最多 50 个字符 |
completed |
布尔值 | 是否完成,新增时由后端设为 false |
前端新增时只传标题,不传编号;修改状态时只传完成状态。这样就不会出现“为了改一个状态,把整个对象都传回去,顺便覆盖了其他字段”的问题。
2.2 约定请求与响应
接口可以理解为一个约定:向哪个地址,使用哪种 HTTP 方法,提交什么内容,会得到什么结果。
本例使用下面四个接口。{id} 是路径中的任务编号,真正请求时要换成具体数字,例如 /api/todos/1。
| 操作 | HTTP 方法与路径 | 请求内容 | 成功响应 |
|---|---|---|---|
| 查询列表 | GET /api/todos |
无请求体 | 200,任务数组 |
| 新增任务 | POST /api/todos |
{"title":"整理学习笔记"} |
201,新增后的任务 |
| 修改状态 | PATCH /api/todos/{id}/completed |
{"completed":true} |
200,修改后的任务 |
| 删除任务 | DELETE /api/todos/{id} |
无请求体 | 204,没有响应体 |
GET 用于读取,POST 用于创建,PATCH 用于部分修改,DELETE 用于删除。状态码描述这次 HTTP 请求的处理结果:200 表示成功,201 表示创建成功,204 表示成功但没有需要返回的内容。
新增时,请求头使用 Content-Type: application/json,告诉后端请求体是 JSON。后端创建成功后,返回的对象例如:
json
{
"id": 2,
"title": "整理学习笔记",
"completed": false
}
如果标题为空,返回 400 Bad Request,响应体为:
json
{
"message": "标题不能为空"
}
如果要修改或删除的任务不存在,返回 404 Not Found:
json
{
"message": "待办不存在"
}
前端既要看状态码,也要读取响应体。不能把“请求发出去了”当作“业务成功了”,也不能把 404 当成一个空列表直接显示。
这里成功时直接返回任务或数组,失败时返回包含 message 的错误对象。项目也可以采用统一的响应包装,但双方必须约定一致,不能后端直接返回数组,前端却按 result.data.list 去取。
2.3 分清路径参数、查询参数和请求体
这三个位置都能传数据,但用途不同。
路径参数常用来确定操作哪条数据,例如 /api/todos/1 中的 1。查询参数常用来描述查询条件,例如 /api/todos?completed=false 中的 completed。请求体适合提交新增或修改的数据,例如 {"title":"整理学习笔记"}。
本例实现的是完整列表查询,没有实现按完成状态筛选。后面的代码可以直接使用前面四个接口;如果以后要加筛选,需要前后端一起补上查询参数的处理。
现在双方已经知道各自要接收和返回什么了。下面先把后端做出来,让这些约定变成可以真正访问的接口。
3. 用 Spring Boot 实现后端接口
3.1 准备后端工程
示例使用 Java 17 或 21、Spring Boot 3.5.16、Maven。Java 示例按 Java 17 语法编写,使用 Spring Boot 3 的 jakarta.validation 包,不要混入旧版的 javax.validation。
创建一个名为 todo-server 的 Maven 工程,目录安排如下。所有 Java 类型都放在 com.example.todo 包中,避免启动时扫描不到 Controller 和 Service。
text
todo-server/
├─ pom.xml
└─ src/main/java/com/example/todo/
├─ TodoApplication.java
├─ TodoCreateDTO.java
├─ TodoStatusDTO.java
├─ TodoVO.java
├─ ErrorVO.java
├─ TodoService.java
├─ TodoController.java
└─ ApiExceptionHandler.java
pom.xml 使用下面的配置。Web starter 提供 Spring MVC、JSON 转换和内嵌服务器;Validation starter 提供请求参数校验。
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>todo-server</artifactId>
<version>1.0.0</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
先写启动类 TodoApplication:
java
package com.example.todo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TodoApplication {
public static void main(String[] args) {
SpringApplication.run(TodoApplication.class, args);
}
}
这个工程暂时不需要配置数据源,也不需要 Thymeleaf,因为它只提供 JSON 接口。
3.2 用 DTO 接收输入,用 VO 组织输出
DTO 是数据传输对象,这里用来接收前端提交的数据;VO 在这里表示交给前端展示的数据对象。后缀是项目命名约定,Spring 不会因为类名带了 DTO 或 VO 就自动改变行为。
为了让例子少一些重复的访问方法,这几个简单对象使用 Java 的 record。它会生成构造器和访问方法,例如 input.title(),而不是 getTitle();没有用于修改字段的 setter。这里需要的是传递数据,所以这种写法比较合适。
先定义新增时接收的 TodoCreateDTO:
java
package com.example.todo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record TodoCreateDTO(
@NotBlank(message = "标题不能为空")
@Size(max = 50, message = "标题不能超过50个字符")
String title
) {
}
@NotBlank 拒绝 null、空字符串和全是空白的标题;@Size 限制长度。它们在后面的 @Valid 触发校验时生效。
再定义修改状态时接收的 TodoStatusDTO:
java
package com.example.todo;
import jakarta.validation.constraints.NotNull;
public record TodoStatusDTO(
@NotNull(message = "请提供完成状态")
Boolean completed
) {
}
这里用 Boolean,是为了区分“明确提交了 false”和“没有提供这个字段”。前者是合法的未完成状态,后者需要提示补全参数。接口约定提交 JSON 布尔值,不要写成字符串 "true"。
输出给页面的是 TodoVO,它包含显示任务所需的三个字段:
java
package com.example.todo;
public record TodoVO(Long id, String title, boolean completed) {
}
错误提示也用一个明确的输出类型 ErrorVO:
java
package com.example.todo;
public record ErrorVO(String message) {
}
输入和输出分开后,前端只能提交接口允许修改的字段。以后任务增加创建人、创建时间等信息,也不用让新增接口接收整个数据库对象。
3.3 用 Service 保存和操作任务
Service 负责具体业务。Controller 接到请求以后,把数据交给它处理,而不是把存储和修改逻辑全写在请求方法里。
先写 TodoService 的骨架。下面四组方法都加到这个类的花括号内,替换末尾的提示注释;导入已经列全。
java
package com.example.todo;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
@Service
public class TodoService {
// 按加入顺序保存,便于页面按顺序展示。
private final Map<Long, TodoVO> todos = new LinkedHashMap<>();
private long nextId = 1;
public TodoService() {
todos.put(nextId, new TodoVO(nextId, "学习接口联调", false));
nextId++;
}
// 把下面的查询、新增、修改、删除方法放在这里。
}
先加查询方法。返回一份列表,前端拿到的 JSON 数组就来自这里:
java
public synchronized List<TodoVO> list() {
return new ArrayList<>(todos.values());
}
再加新增方法。编号和初始状态由后端决定,标题去掉首尾空白后保存:
java
public synchronized TodoVO create(TodoCreateDTO input) {
long id = nextId++;
TodoVO todo = new TodoVO(id, input.title().strip(), false);
todos.put(id, todo);
return todo;
}
修改状态时,先确认任务存在,再保留原来的编号和标题,只替换完成状态:
java
public synchronized TodoVO changeCompleted(Long id, boolean completed) {
TodoVO old = todos.get(id);
if (old == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "待办不存在");
}
// record 没有 setter,创建一个新对象替换旧对象。
TodoVO updated = new TodoVO(old.id(), old.title(), completed);
todos.put(id, updated);
return updated;
}
最后加删除方法:
java
public synchronized void delete(Long id) {
if (todos.remove(id) == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "待办不存在");
}
}
这些方法使用 synchronized,是因为服务器可能同时处理多个请求,需要避免同时读写这个内存 Map。它只是这个小示例的保护方式,不是数据库事务的替代品。
当前数据保存在后端进程中。刷新浏览器不会清空任务,重启后端会重新回到初始任务。所有访问者也会看到同一份清单,例子还没有账号隔离。正式业务需要持久保存或区分用户时,再接数据库和登录校验。
3.4 用 Controller 把 HTTP 请求接到业务方法
Controller 是接口入口。@RestController 表示方法返回值作为响应内容输出;在当前 Web 配置下,Spring 使用 Jackson 把这些对象转成 JSON,不再把返回值当作 Thymeleaf 的模板名。
先写 TodoController 骨架,接下来四个方法都加入类中,导入已列全:
java
package com.example.todo;
import java.util.List;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/todos")
public class TodoController {
private final TodoService service;
public TodoController(TodoService service) {
this.service = service;
}
// 把下面的四个接口方法放在这里。
}
查询接口最直接:收到 GET 请求,调用 Service,把任务列表作为响应返回。
java
@GetMapping
public List<TodoVO> list() {
return service.list();
}
新增接口需要读取请求体。@RequestBody 把 JSON 转成 TodoCreateDTO,@Valid 在调用业务方法前触发校验。
java
@PostMapping
public ResponseEntity<TodoVO> create(@Valid @RequestBody TodoCreateDTO input) {
TodoVO created = service.create(input);
// 201 表示创建成功,响应体仍然直接放任务对象。
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
ResponseEntity 是 Spring 提供的响应类型,用来一起指定状态码、响应头和响应体。前端收到的 JSON 仍然只有任务的三个字段,不会多出一层 body。
修改状态使用路径参数确定任务,用请求体提供新的状态:
java
@PatchMapping("/{id}/completed")
public TodoVO changeCompleted(
@PathVariable("id") Long id,
@Valid @RequestBody TodoStatusDTO input) {
return service.changeCompleted(id, input.completed());
}
删除接口成功后没有需要返回的数据,使用 204:
java
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable("id") Long id) {
service.delete(id);
}
这里不需要 Model。Controller 直接返回 JSON,页面怎样把任务显示出来,下一步交给 Vue。
3.5 把常见错误变成前端能显示的提示
标题为空时,校验会抛出异常;任务不存在时,Service 也会抛出异常。如果错误响应格式各不相同,前端就很难稳定读取提示。
用 @RestControllerAdvice 定义一个集中处理这些接口异常的类。它也是 Spring 提供的机制,下面的方法在匹配的异常发生时返回 JSON 错误响应。
java
package com.example.todo;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorVO> validation(MethodArgumentNotValidException ex) {
var error = ex.getBindingResult().getFieldError();
String message = error == null ? "参数不合法" : error.getDefaultMessage();
return ResponseEntity.badRequest().body(new ErrorVO(message));
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorVO> invalidJson(HttpMessageNotReadableException ex) {
return ResponseEntity.badRequest()
.body(new ErrorVO("请求体应为符合接口约定的 JSON"));
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorVO> business(ResponseStatusException ex) {
return ResponseEntity.status(ex.getStatusCode())
.body(new ErrorVO(ex.getReason()));
}
}
这里覆盖了示例中的输入校验、JSON 读取和任务不存在三类错误。正式项目还需要处理其他参数绑定错误、未知地址和服务器内部异常,并记录必要日志;不要把所有异常都改成 400,否则数据库故障也会被误报成用户输入有问题。
3.6 先单独确认后端能工作
在 todo-server 目录运行:
shell
mvn spring-boot:run
默认端口是 8080。用浏览器访问 http://localhost:8080/api/todos,应看到初始任务数组。
浏览器地址栏主要用于发送 GET。要验证新增,可以在接口调试工具中选择 POST,地址填写 http://localhost:8080/api/todos,请求体选择 JSON,输入标题。对应的 HTTP 内容是:
http
POST /api/todos HTTP/1.1
Host: localhost:8080
Content-Type: application/json
{"title":"整理学习笔记"}
创建成功返回 201。再次 GET 列表,就能看到刚才新增的任务。接着把请求体改为 {"title":" "},应得到 400 和标题不能为空的提示。
先独立确认接口,是为了把问题范围分开:此时如果新增失败,应先检查后端和请求内容;接口本身可用以后,再去接前端。
4. 建立 Vue 前端工程
4.1 Vue、Vite、Axios 分别做什么
Vue 用来组织页面和交互。它可以根据任务数组生成列表,也可以在数据变化时更新页面,不需要手动寻找每一个 HTML 元素再修改文字。
Vite 负责前端开发服务器和构建。开发时它提供本地访问地址,修改代码后可以快速更新页面;交付时它把前端代码处理成浏览器可以加载的静态资源。
Axios 是发送 HTTP 请求的 JavaScript 库。Vue 不会因为有了一个任务数组,就自动去找 Spring Boot 取数据;需要在合适的时机用 Axios 发请求,再把响应保存到 Vue 的状态里。
本例使用 Vue 3.5.44、Vite 8.3.4、Vue 插件 6.0.10、Axios 1.20.0。Vite 8 要求 Node.js 20.19+ 或 22.12+ 等满足其引擎条件的版本,建议直接使用 Node.js 24 LTS。先运行 node -v 和 npm -v 确认环境。
4.2 创建最小前端工程
在 todo-server 旁边创建 todo-client。两个工程各自启动,也各自管理依赖。
text
todo-client/
├─ package.json
├─ vite.config.js
├─ index.html
└─ src/
├─ main.js
├─ App.vue
├─ style.css
└─ api/
└─ todo-api.js
为了让目录和配置容易对照,这里直接给出最小工程内容,不额外加入路由、状态管理库或组件库。
先写 package.json,声明依赖和启动命令:
json
{
"name": "todo-client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "1.20.0",
"vue": "3.5.44"
},
"devDependencies": {
"@vitejs/plugin-vue": "6.0.10",
"vite": "8.3.4"
}
}
在 todo-client 目录执行安装:
shell
npm install
生成的 package-lock.json 记录实际依赖版本,团队开发时应一同提交到版本管理,方便成员使用一致的依赖。
4.3 配置开发服务器,让接口请求能找到后端
本地前端使用 5173,后端使用 8080。先在 vite.config.js 中配置 /api 的代理:
javascript
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
strictPort: true,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
});
后面前端请求 /api/todos 时,浏览器先把请求发给 localhost:5173,Vite 再转发到 localhost:8080/api/todos。
strictPort: true 表示端口被占用就报错,方便始终按同一个地址联调。这里不需要重写路径,因为后端的接口本来就带 /api。
代理只在开发服务器运行时起作用。后面部署前端静态文件时,需要另外安排接口转发,不能以为打包以后这个配置仍然存在。
4.4 页面从哪里开始运行
index.html 提供页面入口。id="app" 的元素是 Vue 要接管的位置:
html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>待办清单</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
再写 src/main.js。它创建 Vue 应用,把根组件 App.vue 显示到刚才的元素里:
javascript
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
createApp(App).mount('#app');
.vue 文件是 Vue 单文件组件,可以放页面逻辑、模板和样式。本例把交互逻辑和模板放在 App.vue,把简单样式单独放在 style.css。
入口已经准备好了。接下来先封装接口调用,再让页面使用这些调用,避免在按钮事件里反复拼接地址。
5. 用 Axios 调接口,用 Vue 显示结果
5.1 把四个接口写成容易调用的函数
在 src/api/todo-api.js 中创建一个 Axios 实例,统一接口前缀和超时时间:
javascript
import axios from 'axios';
const http = axios.create({
baseURL: '/api',
timeout: 5000
});
export async function getTodos() {
const response = await http.get('/todos');
return response.data;
}
export async function createTodo(title) {
const response = await http.post('/todos', { title });
return response.data;
}
export async function setTodoCompleted(id, completed) {
const response = await http.patch('/todos/' + id + '/completed', { completed });
return response.data;
}
export async function deleteTodo(id) {
// 后端返回 204,没有 JSON 响应体,不需要再读取任务对象。
await http.delete('/todos/' + id);
}
baseURL 和每次调用的地址会组合起来,例如 '/api' + '/todos' 得到 /api/todos。传入普通对象时,Axios 会按 JSON 发送这些 POST、PATCH 数据。
await 表示等待异步请求完成,再继续执行当前函数后面的代码;它不会让整个浏览器停止响应。response.data 是 HTTP 响应体,本例查询接口直接返回数组,所以这里拿到的就是任务数组。
如果请求得到 400、404 等响应,Axios 默认会让这个调用抛出错误。页面可以在 catch 中读取后端的提示。网络连接失败、请求超时也会抛错,但不一定有后端响应体。
5.2 先让页面加载任务列表
Vue 的 ref 用来创建响应式状态。可以把它理解成“Vue 会跟踪的变量”:JavaScript 中通过 .value 读写,模板中可以直接使用变量名。状态变化后,Vue 会更新用到它的页面部分。
onMounted 是 Vue 提供的生命周期函数。在组件挂载后执行指定操作,这里用它在页面初次打开时查询列表。
先在 App.vue 中写下面的 <script setup>。随后 5.3、5.4 的三个事件方法也加入同一个 <script setup> 内,替换末尾的提示注释;5.5 的 <template> 则放在 </script> 后面。
vue
<script setup>
import { onMounted, ref } from 'vue';
import { getTodos, createTodo, setTodoCompleted, deleteTodo } from './api/todo-api.js';
const todos = ref([]);
const title = ref('');
const loading = ref(false);
const busy = ref(false);
const error = ref('');
function errorMessage(err) {
// 有后端响应时显示业务提示;没有响应时提醒检查连接。
return err.response?.data?.message || '请求失败,请检查后端和网络后重试';
}
async function refreshTodos() {
todos.value = await getTodos();
}
async function loadTodos() {
loading.value = true;
error.value = '';
try {
await refreshTodos();
} catch (err) {
error.value = errorMessage(err);
} finally {
loading.value = false;
}
}
onMounted(loadTodos);
// 把下面的 addTodo、toggleTodo、removeTodo 方法放在这里。
</script>
todos 保存列表,title 保存输入框内容,loading 表示正在加载,busy 表示正在提交修改,error 保存提示文字。
try / catch / finally 分别处理请求、失败提示和收尾。无论成功还是失败,最后都要恢复加载状态,否则页面可能一直显示“正在加载”。
5.3 新增:读取输入,提交,再刷新列表
输入框有内容,不等于后端已经保存。需要先调用新增接口,等待成功,再重新查询列表。
把 addTodo 放进前面的 <script setup>:
javascript
async function addTodo() {
if (loading.value || busy.value) return;
const text = title.value.trim();
if (!text) {
error.value = '请先输入任务标题';
return;
}
busy.value = true;
error.value = '';
try {
await createTodo(text);
title.value = '';
// 保存成功后重新查询,让页面显示后端实际保存的数据。
await refreshTodos();
} catch (err) {
error.value = errorMessage(err);
} finally {
busy.value = false;
}
}
前端先拦住空标题,是为了让用户马上得到提示;后端仍然保留校验,是为了保证接口本身可靠。
这个例子选择“修改成功后重新查询”,虽然多发一次 GET,但过程容易观察。以后列表很大时,可以利用接口返回的 TodoVO 更新当前数组,不必每次都重新查询。
5.4 修改和删除:把任务编号带上
标记完成时,不需要重新提交标题。把任务编号和目标状态传给接口即可。这里提交 !todo.completed,所以已完成的任务也能恢复成未完成。
把 toggleTodo 继续加入同一个 <script setup>:
javascript
async function toggleTodo(todo) {
if (loading.value || busy.value) return;
busy.value = true;
error.value = '';
try {
await setTodoCompleted(todo.id, !todo.completed);
await refreshTodos();
} catch (err) {
error.value = errorMessage(err);
} finally {
busy.value = false;
}
}
删除也是同样的顺序:先等后端删除成功,再更新页面。把 removeTodo 加在前面的方法后:
javascript
async function removeTodo(todo) {
if (loading.value || busy.value) return;
busy.value = true;
error.value = '';
try {
await deleteTodo(todo.id);
await refreshTodos();
} catch (err) {
error.value = errorMessage(err);
} finally {
busy.value = false;
}
}
三个修改方法都把 busy 恢复放进 finally,并在请求期间禁用操作,减少重复点击。它不能代替后端处理重复请求的能力,只是当前页面的交互保护。
另外,一次操作包含“提交修改”和“查询列表”两个请求。如果修改成功,但随后查询失败,页面也会出现错误提示,这不代表修改已经回滚。联调时要分别查看两个请求的结果,不能看到提示就立刻重复新增。
5.5 把状态和事件接到模板
现在逻辑已经准备好了,还需要页面触发这些方法。在 App.vue 的 </script> 后面添加下面的模板:
vue
<template>
<main class="todo-page">
<h1>待办清单</h1>
<p>写下一件小事,完成后把它标记出来。</p>
<form @submit.prevent="addTodo">
<label for="title">任务标题</label>
<div class="input-row">
<input
id="title"
v-model="title"
maxlength="50"
placeholder="例如:整理学习笔记"
:disabled="loading || busy"
>
<button type="submit" :disabled="loading || busy">添加</button>
</div>
</form>
<p v-if="error" class="error" role="alert">{{ error }}</p>
<div class="list-heading">
<h2>我的任务</h2>
<button type="button" @click="loadTodos" :disabled="loading || busy">
刷新列表
</button>
</div>
<p v-if="loading">正在加载任务……</p>
<ul v-else-if="todos.length > 0">
<li v-for="todo in todos" :key="todo.id">
<div class="task-text">
<span :class="{ completed: todo.completed }">{{ todo.title }}</span>
<small>{{ todo.completed ? '已完成' : '未完成' }}</small>
</div>
<div class="actions">
<button type="button" @click="toggleTodo(todo)" :disabled="busy">
{{ todo.completed ? '恢复未完成' : '标记完成' }}
</button>
<button type="button" @click="removeTodo(todo)" :disabled="busy">
删除
</button>
</div>
</li>
</ul>
<p v-else-if="!error">还没有任务,先添加一条吧。</p>
<p v-if="busy">正在保存,请稍候……</p>
</main>
</template>
这里用到的模板语法可以结合页面动作来理解:
v-model="title"把输入框与title连接起来,输入变化后,状态跟着变化。@submit.prevent="addTodo"在提交表单时调用方法,并阻止浏览器按传统表单方式跳转页面。点击添加或在输入框按回车,都可以提交。v-for根据任务数组生成列表,:key="todo.id"用稳定编号帮助 Vue 识别每条任务。{{ todo.title }}把标题作为文字显示;v-if根据条件显示加载、错误或空列表提示。:disabled和:class把状态绑定到 HTML 属性或样式;@click绑定按钮点击事件。
这些指令只负责浏览器中的显示和交互。真正保存任务的动作,仍然是脚本里发出的 HTTP 请求。
5.6 用 Element Plus 做成常见的后台页面
前面的页面用普通 HTML 输入框和按钮,代码少,正好能先看清楚 Vue 状态与接口怎样配合。做管理后台时,还会反复用到表格、表单、按钮、提示框和对话框。如果每个页面都自己处理尺寸、颜色、禁用状态和交互细节,页面之间容易不统一。
Element Plus 是一套面向 Vue 3 的组件库,提供输入框、按钮、表格、标签、提示框等现成组件。它负责页面控件的外观和一部分交互,不会替代 Vue 的状态管理,也不会替代 Axios 和后端接口。实际工作中常见的管理后台,会用这类组件快速搭出风格一致的表单和数据表格。
常有人把组件库统称为“Element UI”,但要注意版本:Element UI 是 Vue 2 组件库;这篇文章使用 Vue 3,应安装 Element Plus。 两者名称相近,不能因为旧教程写着 element-ui 就把它装进 Vue 3 工程。
在 todo-client 目录安装 Element Plus:
shell
npm install element-plus
安装命令会把依赖记录到 package.json 和锁文件中。本文编写时 npm 官方源上的 Element Plus 版本为 2.14.7;团队项目应提交 package-lock.json,让其他成员安装到一致的依赖版本。
然后修改 src/main.js,导入组件库和样式,并通过 app.use 注册。这里使用全局注册,入门时可以直接在页面中使用 el-button、el-table 等组件:
javascript
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import App from './App.vue';
import './style.css';
const app = createApp(App);
app.use(ElementPlus);
app.mount('#app');
如果没有导入 Element Plus 的 CSS,组件功能可能还在,但样式不会按组件库预期呈现。全局注册适合这篇小文章;大型项目也可以按需导入组件,减少不需要的代码。全局注册的代价是组件库相关代码会一起进入构建结果,文章示例构建时也会提示 JavaScript 资源较大;页面变多后,可以按 Element Plus 官方方式改成按需导入,再比较打包体积。
现在把前面 5.5 节的 <template> 整块替换为下面的版本,<script setup> 中的请求方法和状态不用改:
vue
<template>
<main class="todo-page">
<h1>待办清单</h1>
<p>写下一件小事,完成后把它标记出来。</p>
<el-form @submit.prevent="addTodo">
<el-form-item label="任务标题">
<el-input
v-model="title"
maxlength="50"
show-word-limit
placeholder="例如:整理学习笔记"
:disabled="loading || busy"
/>
</el-form-item>
<el-button
native-type="submit"
type="primary"
:disabled="loading || busy"
:loading="busy"
>
添加任务
</el-button>
<el-button @click="loadTodos" :disabled="loading || busy">
刷新列表
</el-button>
</el-form>
<el-alert
v-if="error"
:title="error"
type="error"
:closable="false"
show-icon
/>
<h2>我的任务</h2>
<el-table
:data="todos"
row-key="id"
v-loading="loading"
empty-text="还没有任务,先添加一条吧。"
>
<el-table-column prop="title" label="任务标题" min-width="220">
<template #default="{ row }">
<span :class="{ completed: row.completed }">{{ row.title }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.completed ? 'success' : 'info'">
{{ row.completed ? '已完成' : '未完成' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="220">
<template #default="{ row }">
<el-button
link
type="primary"
:disabled="busy"
@click="toggleTodo(row)"
>
{{ row.completed ? '恢复未完成' : '标记完成' }}
</el-button>
<el-button link type="danger" :disabled="busy" @click="removeTodo(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<p v-if="busy">正在保存,请稍候……</p>
</main>
</template>
这段模板仍然调用前面写好的 addTodo、toggleTodo 和 removeTodo,所以更换控件没有改变接口流程。变化的是页面控件:el-input 是带样式的输入框,el-button 是按钮,el-table 根据 todos 生成表格,el-table-column 描述每一列,el-tag 展示任务状态,el-alert 展示请求错误。
<template #default="{ row }"> 是表格列的插槽。表格把当前行交给 row,我们才能在状态列和操作列里显示这一项任务的状态,并把同一个任务传给 toggleTodo(row)。
v-loading="loading" 在表格上显示加载状态;empty-text 指定列表为空时的提示。native-type="submit" 让添加按钮提交表单,回车也可以沿用表单提交行为。type="primary"、type="danger" 和 link 则选择 Element Plus 提供的按钮样式。
因此,换成组件库后,ref、v-model、事件方法和 Axios 调用仍然是 Vue 应用自己的逻辑。Element Plus 只让常见控件更整齐、更快组合,不会自动调用接口,也不会帮后端做输入校验。
5.7 保留页面自己的少量样式,然后启动
Element Plus 已经负责输入框、按钮和表格的主要外观,自己的 CSS 只保留页面宽度和已完成任务的文字样式即可。在 src/style.css 中使用:
css
body {
margin: 0;
font-family: system-ui, sans-serif;
color: #263238;
background: #f5f6f7;
}
.todo-page {
max-width: 680px;
margin: 40px auto;
padding: 24px;
background: white;
}
.completed { color: #778; text-decoration: line-through; }
@media (max-width: 560px) {
.todo-page { margin: 12px; padding: 16px; }
}
保持 Spring Boot 运行,再在 todo-client 目录启动前端:
shell
npm run dev
访问 http://localhost:5173,应看到初始任务“学习接口联调”。输入“整理学习笔记”并点击添加,再依次尝试标记完成、恢复未完成和删除。
如果后端没有启动,页面会显示请求失败提示。先启动后端,再点击刷新列表,就能重新加载;不需要把浏览器关闭重开。
6. 联调:把一次页面操作完整跟下来
6.1 点击添加以后,发生了什么
现在两个工程都能运行了,可以把前面分开看的代码接起来。
在输入框写下“整理学习笔记”,点击添加,执行顺序如下:
v-model已经把文字保存到 Vue 的title状态中。- 表单触发
addTodo,先检查输入,再调用createTodo(text)。 - Axios 发送
POST /api/todos,请求体为{"title":"整理学习笔记"}。 - 浏览器把请求发到前端地址,Vite 代理将它转发到 Spring Boot。
- Controller 读取 JSON、触发校验,Service 创建并保存任务。
- 后端返回
201和新任务的 JSON,前端接着 GET 列表。 todos.value被替换成查询结果,Vue 更新列表,页面出现新任务。
页面没有整体跳转,任务却出现了,因为 JavaScript 在当前页面内发起了请求,再修改 Vue 状态。这也是很多管理系统中搜索、分页、弹窗保存等交互的基本过程。
6.2 用 Network 面板核对请求
打开浏览器开发者工具,进入 Network,选择 Fetch/XHR,再点击一次添加。重点看 POST 和随后的 GET。
对于 POST,检查以下内容:
- Request URL:本例应是
http://localhost:5173/api/todos。这是浏览器访问的地址,Vite 向8080的转发发生在服务器一侧。 - Request Method:应为 POST,不能因为地址相同就用 GET 代替。
- Payload:应包含
title,字段名和接口约定一致。 - Status Code:创建成功应为
201。 - Response:应看到后端生成的
id、title和completed。
随后 GET 应返回包含新任务的数组。如果 POST 已经成功,GET 也包含新任务,但页面没有显示,应检查前端是否把响应赋给了 todos.value,以及 Console 中有没有 JavaScript 错误。
反过来,如果 POST 根本没有出现,就先看表单事件是否触发、前端检查是否提前返回,而不是先去改数据库或后端逻辑。
6.3 按现象定位问题
请求一直失败或连接被拒绝。 先直接访问后端的列表地址,确认服务和端口;再检查 Vite 的代理目标。修改 vite.config.js 后重新启动前端开发服务器。Axios 超时还需要结合后端日志判断,不能一律当成用户输入问题。
返回 404。 对照请求路径和后端映射,尤其检查有没有重复 /api 或错误地去掉 /api。如果响应是“待办不存在”,则路径可能已经匹配成功,只是该编号被删除了。这两类 404 的排查方向不同。
返回 400。 查看响应提示和 Payload。常见原因是空标题、超过长度、漏传完成状态或 JSON 格式错误。如果路径编号不是数字,也可能在参数转换时被拒绝。
返回 405。 地址可能正确,但 HTTP 方法不匹配,例如把修改状态发成 POST,而后端只接受 PATCH。
返回 200,但内容是 HTML。 可能请求打到了前端页面回退,或者登录配置把接口请求重定向到了登录页。先看响应内容和跳转记录,不要继续按任务数组处理。
后端出现 500。 先查看服务端异常日志,确认是哪一段业务失败。前端可以显示通用提示,但不能通过修改提示文字来解决后端故障。
联调的关键,是沿着“事件有没有触发 → 请求是否正确 → 后端如何响应 → 状态是否更新”逐段确认。这样每次修改都有依据,不必同时改两端碰运气。
7. 前后端怎样分开开发,又能接到一起
7.1 先交付接口约定,再交付实现
团队里不必等后端全部写完,前端才开始做页面。第 2 章的接口表和 JSON 示例,就可以作为双方最初的共同约定。
除了成功数据,还应约定错误格式、字段是否可以为空、编号类型、日期格式,以及分页时的页码和总数。约定可以维护在 Markdown、接口管理工具中,也可以进一步使用 OpenAPI 描述;工具的作用是减少误解,不能替代双方确认业务含义。
比如 completed 到底表示“已经完成”还是“是否允许完成”,必须先说清楚。字段名字一样,并不代表理解一定一样。
7.2 后端没完成时,前端先用约定数据
只想先验证列表展示时,可以临时把 todo-api.js 中的 getTodos 替换成下面的实现:
javascript
export async function getTodos() {
// 临时样例只用于开发列表展示,字段与真实接口保持一致。
return [
{ id: 1, title: '学习接口联调', completed: false },
{ id: 2, title: '整理学习笔记', completed: true }
];
}
页面仍然调用 getTodos(),所以不用把假数据直接写进模板。后端接口准备好后,再恢复第 5.1 节的 HTTP 实现,页面展示逻辑通常不需要跟着重写。
这段样例只模拟列表,不会保存新增、修改和删除。测试完整流程时应恢复真实接口,或者使用能模拟这些操作的 Mock 服务;不要拿固定数组验证“保存后是否还在”。
7.3 接口变化时,修改哪一层
假设后端内部把 Map 换成 MyBatis 查询,只要仍返回约定的任务数组,前端可以保持原样。
如果接口地址变成 /api/tasks,可以集中修改接口模块;如果字段 title 改成 name,除了接口约定,还会影响页面取值和请求内容,就需要同步调整。
有旧页面或其他客户端仍在使用接口时,不能只改后端字段名就直接发布。可以保留旧接口一段时间,或者明确增加接口版本,再安排调用方迁移。
所谓分开开发,并不是两边互不关心,而是把共同依赖集中到清楚、稳定的接口约定上。
8. 跨域:为什么两个端口会影响浏览器请求
8.1 先认识同源和跨源
浏览器判断两个地址是否同源,要比较协议、主机和端口。http://localhost:5173 与 http://localhost:8080 的端口不同,因此不同源;localhost 和 127.0.0.1 也不是同一个主机名。
如果 Vue 页面直接请求 http://localhost:8080/api/todos,就涉及跨源访问。浏览器会根据同源策略和后端的 CORS 响应头,决定页面脚本能否读取响应。某些请求还会先发送 OPTIONS 预检,例如使用 JSON 的 POST 和本例的 PATCH。
接口调试工具能请求成功,不代表浏览器页面一定能读取结果,因为它们通常不执行浏览器的这套跨源读取限制。
8.2 本例为什么优先使用代理
本例的 Axios 请求地址是相对路径 /api/todos,浏览器访问的是 5173,与页面同源。Vite 再在服务器一侧转发到 8080,浏览器不需要直接跨源读取后端响应。
因此,按前面配置运行时,不需要为了本例额外加一套 CORS 配置。changeOrigin 调整的是代理发给目标服务的 Host 等请求信息,不等于开启后端 CORS。
不要为了省事使用 no-cors 或关闭浏览器安全限制。no-cors 不能让页面正常读取任意跨源 JSON,反而会得到无法按正常接口读取的响应。
8.3 如果业务确实需要直接跨源调用
如果前端必须直接访问后端域名,可以由后端明确允许前端来源。
这是另一种接法:将 Axios 的 baseURL 改为 http://localhost:8080/api,并在后端同一个包中新建 WebConfig。它与前面的开发代理方案二选一即可。
java
package com.example.todo;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("Content-Type");
}
}
WebMvcConfigurer 是 Spring MVC 提供的扩展接口,这里用它配置跨源规则。allowedOrigins 填允许访问的页面来源,包含协议和端口,不填具体页面路径;上线后应换成实际前端来源。
本例没有登录 Cookie,所以没有启用跨源凭据。以后加入 Spring Security 或基于 Cookie 的登录时,还需要让安全配置、凭据和 CSRF 防护一起配合,不能只加这段代码就认为登录问题解决了。
如果以后用 Authorization 请求头发送令牌,也要把它加入 allowedHeaders,并确认 Spring Security 放行 OPTIONS 预检。跨域配置必须允许页面实际发送的请求头和方法,不能只看普通 GET 是否成功。
CORS 管的是浏览器跨源读取,不负责判断用户是否登录、是否有权删除任务。权限仍然要由后端根据身份和业务数据检查。
8.4 把跨域配置落实到实际请求
选择哪一种方案,可以根据页面地址和接口地址来判断。
本地使用 Vite 代理时,页面打开 http://localhost:5173,Axios 保持相对地址:
javascript
const http = axios.create({
baseURL: '/api',
timeout: 5000
});
配合 4.3 节的代理,浏览器请求 http://localhost:5173/api/users,Vite 转发到后端。用户管理页面可以复用同一条 /api 代理规则,不需要给每种业务再配一个代理。浏览器中仍显示前端端口,是正常现象。
选择浏览器直接访问后端时,将接口模块中的 Axios 配置替换成:
javascript
const http = axios.create({
baseURL: 'http://localhost:8080/api',
timeout: 5000
});
然后启用 8.3 节的 WebConfig 并重启后端。此时浏览器请求地址变成 http://localhost:8080/api/users,后端必须返回允许页面来源的 CORS 响应头。后面用户编辑接口使用 PUT,所以允许方法中也包含 PUT。
如果仍提示跨域,先在 Network 查看 OPTIONS 和真正的业务请求。预检响应应允许实际页面的 Origin、使用的方法和请求头;例如页面开在 http://127.0.0.1:5173,而后端只允许 http://localhost:5173,来源就没有匹配上。可以统一用 localhost,或按实际开发环境额外明确列出允许来源。
同时确认后端确实在运行。有时服务没启动,或者请求被安全过滤器提前拒绝,浏览器也可能同时显示跨域相关提示。应结合请求状态和后端日志判断,不能看到“跨域”就直接把所有来源放开。
开发代理配置只管本地开发。部署时如果继续使用相对 /api 地址,就按第 9 章让 Nginx 转发;如果部署后前后端仍然不同源,就把后端允许来源改成真实的前端来源。
9. 部署:让静态页面和接口在实际环境中配合
9.1 前端打包,后端打包
开发时需要 Vite 处理源码,交付时通常先构建静态资源。在 todo-client 中运行:
shell
npm run build
构建成功后生成 dist,里面是 HTML、JavaScript 和 CSS 等静态文件。它不包含可运行的 Spring Boot 服务,也不需要把整个 node_modules 交给浏览器。
在 todo-server 中运行:
shell
mvn package
java -jar target/todo-server-1.0.0.jar
后端 JAR 需要在服务器上运行。前端的 npm run preview 可以检查构建产物的显示效果,但不能把它当作正式部署方案,也不能依赖开发代理在生产环境继续转发请求。
9.2 用同一个站点提供页面和接口入口
一种常见部署方式是:Nginx 提供 dist 中的静态文件,并把 /api/ 请求转给 Spring Boot。
例如访问 https://todo.example.com 取得页面,页面再请求 https://todo.example.com/api/todos。浏览器看到的是同一个来源,内部后端服务可以运行在 127.0.0.1:8080。
下面是 Nginx 的站点配置示意,放在其 http 配置范围内。将域名和静态目录换成实际值;HTTPS 证书和监听需要按部署环境另行配置。
nginx
server {
listen 80;
server_name todo.example.com;
# 把 dist 里面的内容部署到这个目录。
root /srv/todo-client/dist;
index index.html;
location /api/ {
# 这里不带末尾路径斜杠,让 /api/todos 原样转发给后端。
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
前端继续使用 baseURL: '/api',不需要把服务器内网地址写进浏览器代码。
这里 proxy_pass 的写法要与后端路径一致。对于这个 /api/ location,如果写成 http://127.0.0.1:8080/,匹配的 /api/ 前缀会被替换,后端收到的可能是 /todos,与当前 Controller 不匹配。
try_files 优先寻找静态文件,找不到时返回 index.html。本例只有一个页面,没有使用 Vue Router;以后增加浏览器端路由时,这种回退可以帮助处理刷新页面的问题。接口请求由单独的 /api/ 规则转发,不能也回退成 HTML。
在实际服务器上应先检查 Nginx 配置语法,再加载配置,分别验证页面、静态资源和接口。开发代理连通,不代表生产反向代理已经配置正确。
9.3 前端环境变量不是保密配置
如果不同环境确实要使用不同接口域名,可以通过 Vite 环境变量配置地址,例如使用 import.meta.env.VITE_API_BASE_URL。不过,这些值会进入前端构建产物,用户能够查看。
因此,前端可以保存接口地址、公开的站点配置,不能保存数据库密码、服务端密钥或管理员凭据。需要保密的信息应留在后端运行环境中。
对于当前例子,同一个站点下使用 /api 已经足够,不必为了区分环境再增加一套配置。
10. 接入数据库和登录时,哪些职责继续保留
10.1 数据库改变存储方式,接口保持约定
内存清单适合学习调用流程,但服务器一重启任务就消失了。需要长期保存时,可以建立任务表,在 Service 后面接入 MyBatis、MyBatis-Plus 或 Spring Data JPA。
此时调用过程变成:Controller 接收 DTO,Service 处理业务,Mapper 或 Repository 操作数据库,最后组织 TodoVO 返回给前端。
例如数据库表里还可以有创建时间、所属用户等字段,接口只返回页面需要的部分。数据库实体不必直接作为请求参数或响应对象,尤其不要把密码、内部状态等字段一并暴露出去。
如果接口的路径、字段和错误约定保持不变,Vue 不需要关心底层从 Map 换成了哪一种持久层技术。
10.2 request、session 与 Vue 状态分别存在哪里
页面改用 Vue 后,服务器的 request 域和 session 域仍然存在,但它们不会自动成为浏览器中的变量。
request 域用于当前请求内共享数据。以一个 Controller 方法中的语句为例:
java
// request 是当前请求的 HttpServletRequest。
request.setAttribute("tip", "保存成功");
String tip = (String) request.getAttribute("tip");
同一次服务端转发可以继续读取这个请求属性;响应结束后,下次请求不能指望它还在。重定向会让浏览器发起新请求,也不能直接读取上次 request 中的属性。
而且,仅仅调用 setAttribute 不会把 tip 自动加进 JSON。前端需要提示时,应明确通过响应对象返回相应字段,让 Axios 读取。
session 域用于在同一个会话的多个请求间保留服务端数据。假设登录已经由后端校验成功,可以使用 HttpSession 保存身份标识:
java
// session 是当前请求取得的 HttpSession。
session.setAttribute("loginUserId", 1L);
// 同一个有效会话的后续请求可以再次读取。
Long userId = (Long) session.getAttribute("loginUserId");
// 需要移除这一项时使用;退出整个会话还可以使用 invalidate()。
session.removeAttribute("loginUserId");
这些语句用于说明域的存取,不是当前清单已经实现了登录。实际项目通常交给 Spring Security 等认证机制管理身份,不能把“保存一个编号”直接当成完整认证流程。
服务器一般通过浏览器携带的会话 Cookie 找到对应 session;Vue 的 ref 则保存在当前页面的 JavaScript 内存中,刷新页面后会重新创建。刷新后能再次看到登录信息,通常是页面重新请求了当前用户接口,而不是 Vue 状态自己永久保存了身份。
10.3 前后端分离不强制要求 JWT
前后端分离也可以使用服务端 Session 登录。页面提交账号密码,后端认证成功后建立会话,后续请求携带 Cookie,后端继续确认身份和权限。
如果页面和 API 通过同一个站点提供,通常可以让同源请求携带对应 Cookie。确实采用跨源 Cookie 时,Axios 需要配合 withCredentials,后端需要允许指定来源和凭据,Cookie 的 SameSite、Secure 等属性也必须符合访问方式;同时仍要保留适当的 CSRF 防护。
JWT 等 Token 方案有自己的适用场景,但它不是前后端分离的必选项。无论使用哪一种,前端隐藏删除按钮都不能代替后端检查“这个用户能否删除这条任务”。
接口需要登录而用户未登录时,适合返回 401 和明确错误信息;已经登录但没有权限时,通常返回 403。前端据此提示或跳转登录页。若后端把接口请求重定向成 HTML 登录页,前端又按 JSON 读取,就会出现“状态看似成功,数据却完全不对”的问题。
11. 用户信息管理:让表格、分页、查询和表单一起工作
11.1 从小清单走到管理页面
待办清单已经说明了如何发送请求、接收 JSON 和更新页面。现在换一个更接近后台开发的需求:维护用户姓名和年龄,按姓名搜索,分页显示,还能新增、编辑和删除。
这一次,后端使用 MyBatis-Plus 操作 MySQL,前端用 Element Plus 表格显示当前页。用户信息只包含编号、姓名和年龄,不涉及账号密码和登录系统,便于集中看清楚每一步的数据交互。
这一章继续使用前面的 Spring Boot 工程,新增用户相关类型,待办接口可以继续保留。前端复用 Vue、Element Plus、Axios 和 Vite 配置,新建 UserManage.vue 作为用户管理组件,并在 App.vue 中显示它。
先约定接口:
| 操作 | 方法与路径 | 参数或请求体 | 成功响应 |
|---|---|---|---|
| 分页与模糊查询 | GET /api/users |
current、size、keyword 查询参数 |
200,分页对象 |
| 查询一名用户 | GET /api/users/{id} |
路径编号 | 200,用户对象 |
| 新增用户 | POST /api/users |
{"name":"小林","age":20} |
201,新增对象 |
| 修改用户 | PUT /api/users/{id} |
{"name":"小林","age":21} |
200,修改后对象 |
| 删除用户 | DELETE /api/users/{id} |
路径编号 | 204,无响应体 |
current 从 1 开始,size 表示每页条数,keyword 表示姓名查询词。查询和分页共用一个接口,例如:
http
GET /api/users?current=1&size=5&keyword=小 HTTP/1.1
Host: localhost:8080
响应对象约定为下面的形状。这里表示共有两名姓名包含“小”的用户,都在第一页中:
json
{
"records": [
{ "id": "1", "name": "小林", "age": 20 },
{ "id": "2", "name": "小周", "age": 22 }
],
"total": 2,
"current": 1,
"size": 5
}
records 是当前页要展示的行,total 是符合当前查询条件的总记录数,不是当前页的长度。前端用前者填表格,用后者计算有多少页。
编号输出为字符串,避免以后改用较大的 Long 编号时超出 JavaScript 安全整数范围;数据库里仍使用数值主键。前端只需要把这个字符串放进路径,不要为了显示再转成数字。
11.2 建表并接入 MyBatis-Plus
先在本地 MySQL 8 中创建一个专门的练习库,不要往已有业务表里直接插入测试记录。下面的 SQL 初始化六名用户,足够观察每页 5 条时的翻页效果:
sql
CREATE DATABASE IF NOT EXISTS study_user_demo
CHARACTER SET utf8mb4;
USE study_user_demo;
CREATE TABLE user_info (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(30) NOT NULL,
age INT NOT NULL
);
INSERT INTO user_info (name, age) VALUES
('小林', 20),
('小周', 22),
('张三', 23),
('李四', 24),
('王五', 25),
('赵六', 26);
初始化 SQL 执行一次即可;已经建好表时,不要为了重复练习随意删表。姓名不是登录账号,所以本例允许重名。
在已有 pom.xml 的 <dependencies> 中加入下面三项。示例使用 MyBatis-Plus 3.5.17 和 Spring Boot 3,不要再同时引入普通 MyBatis starter,避免两套自动配置混在一起。
xml
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.17</version>
</dependency>
<!-- MP 3.5.9 起,分页需要单独的解析器依赖。 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>3.5.17</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
然后在 src/main/resources/application.yml 配置连接。这里按本地练习环境的 3307 端口举例,地址、端口和凭据必须换成自己实际使用的值;数据库密码不要放进前端代码。
yaml
spring:
datasource:
url: jdbc:mysql://localhost:3307/study_user_demo?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
username: ${DB_USERNAME:root}
password: ${DB_PASSWORD}
mybatis-plus:
configuration:
# 练习时便于观察分页 SQL;正式项目使用合适的日志配置。
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
${DB_PASSWORD} 表示从后端运行环境中读取密码。可以在 IDE 运行配置中设置 DB_PASSWORD,或者在启动后端的终端里设置环境变量。例如 PowerShell 中:
powershell
$env:DB_PASSWORD = '替换为本地数据库密码'
mvn spring-boot:run
如果数据库在 WSL 中,先确认 Windows 能访问配置的地址和端口;Java 工程连接的是 JDBC 地址,不会因为 WSL 内的 mysql 命令能连接,就自动保证 Windows 连接也正常。
11.3 实体、Mapper 和分页插件各负责哪一步
实体类 UserInfo 对应数据库表,放在现有的 com.example.todo 包中:
java
package com.example.todo;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
@TableName("user_info")
public class UserInfo {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private Integer age;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
}
@TableName 指明表名,IdType.AUTO 表示由数据库自增生成编号。输入表单不会要求用户填写编号,插入后 MyBatis-Plus 会把生成的主键写回实体。
再定义 UserInfoMapper。BaseMapper 是 MyBatis-Plus 提供的接口,继承后可以调用 selectPage、selectById、insert、updateById 和 deleteById 等单表方法。
java
package com.example.todo;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface UserInfoMapper extends BaseMapper<UserInfo> {
}
最后配置分页插件。单有前端分页条不会让数据库分页,后端要把 Page 参数交给 Mapper,并让分页插件参与 SQL 执行。
java
package com.example.todo;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
var pagination = new PaginationInnerInterceptor(DbType.MYSQL);
pagination.setMaxLimit(100L);
// 超出末页时不偷偷返回第一页,由前端决定如何调整页码。
pagination.setOverflow(false);
var interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(pagination);
return interceptor;
}
}
插件会根据页码和每页条数生成 MySQL 分页查询,并按需要查询总数。如果后端总是返回全部记录,要检查这个配置类有没有被扫描,以及分页解析器依赖是否加入。
11.4 定义接收表单和返回页面的数据
前端新增和修改都提交姓名、年龄,可以共用 UserSaveDTO:
java
package com.example.todo;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record UserSaveDTO(
@NotBlank(message = "姓名不能为空")
@Size(max = 30, message = "姓名不能超过30个字符")
String name,
@NotNull(message = "请填写年龄")
@Min(value = 0, message = "年龄不能小于0")
@Max(value = 150, message = "年龄不能超过150")
Integer age
) {
}
年龄用 Integer,这样漏填时可以识别为 null,而不是把“没填”误当成 0 岁。前端提交数字,不能把姓名和年龄都拼成一个字符串。
给表格展示的每一行使用 UserVO:
java
package com.example.todo;
public record UserVO(String id, String name, Integer age) {
}
分页输出使用 UserPageVO,把当前页记录和分页信息放在一起:
java
package com.example.todo;
import java.util.List;
public record UserPageVO(
List<UserVO> records,
long total,
long current,
long size
) {
}
Page<UserInfo> 是后端使用的分页对象,UserPageVO 是双方约定的响应形状。明确转换以后,前端就不需要依赖 MyBatis-Plus 分页对象中其他暂时用不到的字段。
11.5 后端怎样把模糊查询与分页放在一起
先写 UserService 骨架,后面的业务方法都加入这个类的花括号内,替换末尾注释。导入已经列全:
java
package com.example.todo;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
@Service
public class UserService {
private final UserInfoMapper mapper;
public UserService(UserInfoMapper mapper) {
this.mapper = mapper;
}
private UserVO toVO(UserInfo user) {
return new UserVO(String.valueOf(user.getId()), user.getName(), user.getAge());
}
// 把分页查询、单条查询、新增、修改、删除方法加在这里。
}
先加入分页查询方法:
java
public UserPageVO page(long current, long size, String keyword) {
if (current < 1 || current > 1_000_000 || size < 1 || size > 100) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "页码或每页条数不合法");
}
String text = keyword == null ? "" : keyword.strip();
if (text.length() > 30) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "查询词不能超过30个字符");
}
var condition = Wrappers.<UserInfo>lambdaQuery()
// 有查询词才加 LIKE;没有查询词就查询全部用户。
.like(!text.isEmpty(), UserInfo::getName, text)
.orderByAsc(UserInfo::getId);
Page<UserInfo> result = mapper.selectPage(new Page<>(current, size), condition);
var records = result.getRecords().stream().map(this::toVO).toList();
return new UserPageVO(records, result.getTotal(), result.getCurrent(), result.getSize());
}
查询词和页码必须进入同一次查询。输入“小”后,数据库先按姓名筛选,再统计匹配总数、读取当前页,不能先取全表第一页,再只在这一页里找“小”。
like 会在查询词两边加 %。搜索“小”相当于 WHERE name LIKE '%小%',SQL 参数仍通过绑定传入,不是把用户输入直接拼进 SQL。这里沿用 SQL LIKE 语义,用户输入 % 和 _ 时会被当作通配符;如果业务要求这两个字符只按普通文字搜索,需要另外约定并实现转义。
orderByAsc(UserInfo::getId) 指定稳定排序,不让每页记录的顺序随意变化。getTotal() 得到的是当前条件下的总数,因此搜索以后分页条也会跟着变化。
再加入单条查询和新增方法:
java
public UserVO detail(Long id) {
UserInfo user = mapper.selectById(id);
if (user == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在");
}
return toVO(user);
}
public UserVO create(UserSaveDTO input) {
UserInfo user = new UserInfo();
user.setName(input.name().strip());
user.setAge(input.age());
mapper.insert(user);
return toVO(user);
}
编辑页面可以用单条接口读取最新信息。新增时不给 id 赋值,由数据库产生编号,返回给前端的对象中则包含生成后的编号。
修改方法也加入同一个类:
java
public UserVO update(Long id, UserSaveDTO input) {
UserInfo user = new UserInfo();
user.setId(id);
user.setName(input.name().strip());
user.setAge(input.age());
// 按编号更新,不让前端通过请求体更换要修改的编号。
if (mapper.updateById(user) == 0) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在");
}
return toVO(user);
}
这个 PUT 接口约定完整提交姓名和年龄;不是只传一个字段就任意修改。MySQL Connector/J 默认返回匹配行数,因此姓名、年龄保持原样提交,也不会被误判为不存在;如果自行启用 useAffectedRows=true,就需要另行处理“值没变但用户存在”的情况。
最后加入删除:
java
public void delete(Long id) {
if (mapper.deleteById(id) == 0) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在");
}
}
11.6 用 Controller 接收查询参数和 JSON 表单
创建 UserController。先写骨架,再把下面三个代码块中的接口方法依次加入类中:
java
package com.example.todo;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService service;
public UserController(UserService service) {
this.service = service;
}
// 在这里加入以下接口方法。
}
查询参数从 URL 读取,缺省时查询第一页、每页 5 条、不限制姓名:
java
@GetMapping
public UserPageVO page(
@RequestParam(name = "current", defaultValue = "1") long current,
@RequestParam(name = "size", defaultValue = "5") long size,
@RequestParam(name = "keyword", defaultValue = "") String keyword) {
return service.page(current, size, keyword);
}
@GetMapping("/{id}")
public UserVO detail(@PathVariable("id") Long id) {
return service.detail(id);
}
新增和修改从请求体读取 JSON。@Valid 触发刚才的姓名、年龄校验:
java
@PostMapping
public ResponseEntity<UserVO> create(@Valid @RequestBody UserSaveDTO input) {
return ResponseEntity.status(HttpStatus.CREATED).body(service.create(input));
}
@PutMapping("/{id}")
public UserVO update(
@PathVariable("id") Long id,
@Valid @RequestBody UserSaveDTO input) {
return service.update(id, input);
}
删除只要编号,不需要再传一整行用户对象:
java
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable("id") Long id) {
service.delete(id);
}
校验失败和业务上的 404 继续由 3.5 节的 ApiExceptionHandler 返回 ErrorVO。另外,current=abc 或 /api/users/abc 会在数字转换时失败,可以在原异常处理类中再加入下面的方法,并导入 org.springframework.web.method.annotation.MethodArgumentTypeMismatchException:
java
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ErrorVO> invalidNumber(MethodArgumentTypeMismatchException ex) {
return ResponseEntity.badRequest()
.body(new ErrorVO("编号、页码和每页条数应为合法数字"));
}
现在先访问 http://localhost:8080/api/users?current=1&size=5,应返回前五名用户和 total: 6。改为 current=2,应只返回赵六;增加 keyword=小,应返回小林、小周且 total: 2。这三步能先确认后端分页与筛选确实生效。
11.7 前端怎样传查询条件、接收分页结果
在 src/api/user-api.js 中定义用户接口模块。继续采用 /api 开发代理,所以不用额外写跨域代码:
javascript
import axios from 'axios';
const http = axios.create({
baseURL: '/api',
timeout: 5000
});
export async function getUsers(current, size, keyword) {
const response = await http.get('/users', {
// Axios 帮我们编码查询参数,不手动拼接中文和特殊字符。
params: { current, size, keyword }
});
return response.data;
}
export async function getUser(id) {
const response = await http.get('/users/' + id);
return response.data;
}
export async function createUser(form) {
const response = await http.post('/users', form);
return response.data;
}
export async function updateUser(id, form) {
const response = await http.put('/users/' + id, form);
return response.data;
}
export async function deleteUser(id) {
await http.delete('/users/' + id);
}
“把模糊查询的数据传回来”包含两个方向:Vue 把输入的查询词作为 keyword 发送给后端;后端查询后,把匹配用户放进 records,把匹配总数放进 total 返回。前端不必再对 records 做一遍 filter,否则容易只筛选当前页而漏掉其他页的匹配用户。
接下来创建 src/UserManage.vue。它的脚本和模板会分段展示,下面 11.8~11.10 的 JavaScript 方法都放进同一个 <script setup>,11.11 的模板放在它后面。
11.8 把响应放进 Vue 状态,再交给表格
先写 UserManage.vue 的脚本骨架。users 用于表格,page 保存分页参数,form 保存表单输入;reactive 是 Vue 提供的响应式对象,访问属性时不需要写 .value。
vue
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { getUsers, getUser, createUser, updateUser, deleteUser } from './api/user-api.js';
const users = ref([]);
const total = ref(0);
const page = reactive({ current: 1, size: 5 });
const searchText = ref('');
const keyword = ref('');
const loading = ref(false);
const saving = ref(false);
const locked = computed(() => loading.value || saving.value);
const error = ref('');
const dialogVisible = ref(false);
const editingId = ref(null);
const form = reactive({ name: '', age: 18 });
const formRef = ref(null);
const formError = ref('');
const rules = {
name: [
{ required: true, whitespace: true, message: '请填写姓名', trigger: 'blur' },
{ max: 30, message: '姓名最多30个字符', trigger: 'blur' }
],
age: [
{ required: true, type: 'number', min: 0, max: 150,
message: '请填写0到150之间的年龄', trigger: 'change' }
]
};
function messageOf(err) {
return err.response?.data?.message || '请求失败,请检查后端和网络后重试';
}
onMounted(loadUsers);
// 在这里加入 11.8~11.10 的查询、分页和表单方法。
</script>
computed 根据加载和提交状态计算 locked,用它统一禁止正在请求时的重复操作。searchText 是输入框里的文字,keyword 是已经点击查询后真正生效的条件。分开保存,是为了防止用户只是输入了一半、还没查询,翻页时条件却悄悄变了。
先把下面两个方法加进脚本。真正“接收后端数据”发生在 getUsers 返回之后:
javascript
async function fetchPage() {
let result = await getUsers(page.current, page.size, keyword.value);
// 删除末页最后一条记录后,当前页可能已经不存在,需要退回有效页。
const lastPage = Math.max(1, Math.ceil(result.total / page.size));
if (page.current > lastPage) {
page.current = lastPage;
result = await getUsers(page.current, page.size, keyword.value);
}
users.value = result.records;
total.value = result.total;
page.current = result.current;
page.size = result.size;
}
async function loadUsers() {
loading.value = true;
error.value = '';
try {
await fetchPage();
} catch (err) {
// 查询失败时清空旧行,避免旧数据看起来像新条件的查询结果。
users.value = [];
total.value = 0;
error.value = messageOf(err);
} finally {
loading.value = false;
}
}
Axios 的 response.data 已经是整个分页对象;进入组件后再取 result.records 才是数组。表格的 :data 必须绑定这个数组,不能直接绑定含有 records、total 的整个对象。
例如后面模板写 :data="users" 和 prop="name",Element Plus 就会遍历每一行,用行对象的 name 属性显示姓名。字段名必须与 JSON 一致,后端返回 name,前端写 prop="username" 就会显示空白。
11.9 分页和模糊查询怎样联动
继续往同一个脚本加入查询、重置和分页事件:
javascript
async function searchUsers() {
if (locked.value) return;
keyword.value = searchText.value.trim();
page.current = 1;
await loadUsers();
}
async function resetSearch() {
if (locked.value) return;
searchText.value = '';
keyword.value = '';
page.current = 1;
await loadUsers();
}
async function changePage(current) {
if (locked.value) return;
page.current = current;
await loadUsers();
}
async function changeSize(size) {
if (locked.value) return;
page.size = size;
page.current = 1;
await loadUsers();
}
这里有三个关键约定。点击查询时回到第一页,因为新的结果可能没有旧条件下的那么多页;翻页时继续传 keyword.value,这样翻到第二页也仍然是在当前条件中查询;修改每页条数时重新从第一页计算,避免旧页码突然越界。
后面的分页组件只在 update:current-page 和 update:page-size 事件中发请求,不再同时加另一组 current-change、size-change 或 watch,以免一次点击发出两次相同查询。
这是后端分页:每次点击页码都会请求新的一页,由数据库完成筛选和分页。不能先把所有记录拉到浏览器,再用 slice 截取几条,却认为 MyBatis-Plus 分页已经参与了查询。
11.10 用同一个表单完成新增和修改
新增时打开空表单,编辑时按编号查询最新数据再回填。继续加入两个打开弹窗的方法:
javascript
function openCreate() {
if (locked.value) return;
editingId.value = null;
form.name = '';
form.age = 18;
formError.value = '';
dialogVisible.value = true;
}
async function openEdit(row) {
if (locked.value) return;
loading.value = true;
error.value = '';
try {
const user = await getUser(row.id);
editingId.value = user.id;
// 复制展示字段,编辑表单时不直接修改表格里的那一行。
form.name = user.name;
form.age = user.age;
formError.value = '';
dialogVisible.value = true;
} catch (err) {
error.value = messageOf(err);
} finally {
loading.value = false;
}
}
editingId 为空表示新增,有编号表示编辑。表单通过 v-model 修改 form,保存时把姓名、年龄单独组织成 JSON,请求体中不夹带页码、弹窗状态等页面数据。
加入保存方法:
javascript
async function saveUser() {
if (locked.value) return;
const valid = await formRef.value.validate().catch(() => false);
if (!valid) return;
saving.value = true;
formError.value = '';
const payload = { name: form.name.trim(), age: form.age };
try {
if (editingId.value === null) {
await createUser(payload);
page.current = 1;
} else {
await updateUser(editingId.value, payload);
}
} catch (err) {
formError.value = messageOf(err);
saving.value = false;
return;
}
dialogVisible.value = false;
ElMessage.success('保存成功');
try {
// loadUsers 自己展示刷新失败提示,与刚刚保存成功的结果分开。
await loadUsers();
} finally {
saving.value = false;
}
}
formRef 指向 Element Plus 的表单组件,validate() 按 rules 检查输入;这只是方便用户及时改错,后端的 @Valid 仍要保留。
例如新增“小陈、20岁”,Axios 提交的是:
json
{
"name": "小陈",
"age": 20
}
新增用 POST,编辑用 PUT 加路径编号。JSON 由 @RequestBody 接收,校验后交给 Service,再调用 MyBatis-Plus 写入数据库。这个流程是“页面表单通过 JavaScript 提交 JSON”,不是浏览器传统的整页跳转表单。
保存成功后重新查询时继续保留当前模糊查询条件。如果新姓名不匹配条件,或者排序后不在当前页,表格中暂时看不到它也是正常的;可以重置查询再查找,不能因此认定保存失败。
再加入删除方法。删除前先确认,取消就不发送 DELETE:
javascript
async function removeUser(row) {
if (locked.value) return;
try {
await ElMessageBox.confirm('确定删除用户“' + row.name + '”吗?', '删除确认', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
});
} catch {
return;
}
if (locked.value) return;
saving.value = true;
error.value = '';
try {
await deleteUser(row.id);
} catch (err) {
error.value = messageOf(err);
saving.value = false;
return;
}
ElMessage.success('删除成功');
try {
await loadUsers();
} finally {
saving.value = false;
}
}
删除后仍查当前条件下的当前页;如果页码已经超过最新末页,11.8 节的 fetchPage 会再请求最后一个有效页。不要仅在前端删掉一行就结束,否则总数和分页条可能还停留在旧状态。
11.11 把搜索框、表格、分页条和弹窗接起来
脚本准备好后,在 UserManage.vue 的 </script> 后加入模板。先看查询区和表格:
vue
<template>
<main class="user-page">
<h1>用户信息管理</h1>
<p>按姓名查询用户,或新增、修改和删除用户信息。</p>
<el-form inline @submit.prevent="searchUsers" :disabled="locked">
<el-form-item label="姓名">
<el-input v-model="searchText" placeholder="输入姓名关键字" maxlength="30" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" native-type="submit">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
<el-button type="success" @click="openCreate">新增用户</el-button>
</el-form-item>
</el-form>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<el-table :data="users" row-key="id" v-loading="loading" empty-text="暂无符合条件的用户">
<el-table-column prop="id" label="编号" width="90" />
<el-table-column prop="name" label="姓名" min-width="150" />
<el-table-column prop="age" label="年龄" width="100" />
<el-table-column label="操作" width="170">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)" :disabled="locked">编辑</el-button>
<el-button link type="danger" @click="removeUser(row)" :disabled="locked">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 在这里继续加入下面的分页组件和编辑弹窗。 -->
</main>
</template>
prop="name" 对应每一行 JSON 的 name,不需要自己循环创建表格行。操作列通过插槽取得 row,按钮把这一行的编号交给编辑或删除逻辑。
把下面的分页组件放到刚才模板的提示注释处:
vue
<el-pagination
:current-page="page.current"
:page-size="page.size"
:page-sizes="[5, 10, 20]"
:total="total"
:disabled="locked"
layout="total, sizes, prev, pager, next"
@update:current-page="changePage"
@update:page-size="changeSize"
/>
它的显示由三个值控制:当前页 page.current、每页条数 page.size、匹配总数 total。点击页码只是触发事件,真正更新表格还要由事件方法请求后端,再把响应放进 users。
继续在分页组件后、</main> 前加入弹窗:
vue
<el-dialog
v-model="dialogVisible"
:title="editingId === null ? '新增用户' : '编辑用户'"
width="min(440px, 92vw)"
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="!saving"
:show-close="!saving"
>
<el-alert v-if="formError" :title="formError" type="error" :closable="false" />
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="60px"
:disabled="saving"
@submit.prevent="saveUser"
>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" maxlength="30" placeholder="例如:小陈" />
</el-form-item>
<el-form-item label="年龄" prop="age">
<el-input-number v-model="form.age" :min="0" :max="150" :precision="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false" :disabled="saving">取消</el-button>
<el-button type="primary" @click="saveUser" :loading="saving">保存</el-button>
</template>
</el-dialog>
v-model="dialogVisible" 控制弹窗开关;v-model="form.name" 和 v-model="form.age" 把输入保存到响应式表单对象中。el-form-item 的 prop 对应规则里的字段名,不能一个写 name、另一个却写 username。
destroy-on-close 让弹窗关闭后销毁内部表单,下一次打开时重新创建校验状态;自己的 form 数据则由 openCreate 或 openEdit 明确填入。
组件底部可以补上少量布局样式:
vue
<style scoped>
.user-page {
max-width: 900px;
margin: 32px auto;
padding: 24px;
background: white;
}
.el-pagination {
margin-top: 20px;
overflow-x: auto;
}
</style>
最后将 App.vue 替换为下面的根组件,切换到用户管理页面。这个组件有自己的脚本和模板,不要把前面待办清单的脚本再拼进来;main.js 继续沿用已经注册 Element Plus 的版本。还可以把 index.html 中的 <title> 改为“用户信息管理”,让浏览器标签页与页面内容一致。
vue
<script setup>
import UserManage from './UserManage.vue';
</script>
<template>
<UserManage />
</template>
11.12 沿着一个具体操作看数据如何来回
启动数据库、后端和前端以后,访问 http://localhost:5173,初始每页 5 条,应显示前五名用户,总数 6。点击第二页,Network 中应出现 current=2&size=5,表格只显示赵六。
然后在搜索框输入“小”并查询。Vue 把生效查询词保存到 keyword,把页码改回 1,发送 current=1&size=5&keyword=小;MyBatis-Plus 在姓名条件下查出两条,后端返回 records 和 total: 2。Vue 把数组赋给 users,表格显示小林和小周,分页条也显示总数 2。
点击新增,输入“小陈”、年龄 20,保存时 POST 发送 JSON。后端校验后写入数据库,再返回新增用户;前端提示保存成功,按“小”的条件重新查询,总数变成 3。点击小陈的编辑,先 GET 单条,修改年龄后 PUT 回传,再查询表格观察年龄变化。
点击删除时会弹出确认框。取消不会调用删除接口;确认后 DELETE 成功返回 204,接着 GET 当前页更新列表与总数。试着重置查询、每页 5 条,在最后一页删除最后一条记录,可以观察页码自动退回有效页。
这几步要同时看页面和 Network:页面验证用户看见了什么,请求验证传了什么、后端返回什么。两者能对上,才能说明表格、查询、分页和表单真正连接起来了。
11.13 联调时最容易弄错的地方
表格为空,但接口有数据。 先检查 response.data 的实际形状。本例分页数组在 result.records;如果把整个 result 绑定到 el-table,或者把姓名列写成不匹配的 prop,就不能正确显示。
翻页后搜索条件丢了。 每一次列表请求都要同时传页码、每页条数和已生效的查询词。不能只有查询按钮传 keyword,分页按钮却只传 current。
搜索明明有结果,却停在空白页。 新条件下总页数可能变少,点击查询时应重置 current=1。删除导致末页消失,则重新请求最后一个有效页。
分页条页数不对。 total 必须是数据库按当前条件统计的总数,不能用 records.length 代替;更不能用一个查询词筛行、另一个条件统计总数。
表单保存后提示成功,列表却没出现新行。 先确认当前查询词和排序,再看 POST 或 PUT 与随后的 GET。可能保存成功但新姓名不匹配,也可能列表刷新失败;这两者都不能直接解释为写入失败。
新增或编辑出现跨域错误。 先确认当前采用代理还是直连。使用代理时保持相对 /api 地址;直连时检查后端来源、方法和请求头。编辑使用 PUT,不能只允许 GET、POST 后就认为全部操作都可用。
12. 按什么顺序完成一次前后端分离开发
学会这个例子以后,可以把同样的过程用到学生名单、商品管理或文章列表中。业务字段会变化,协作顺序可以保持清楚:
- 先列出页面要完成的操作,确定每个操作需要哪些输入和输出。
- 约定接口路径、HTTP 方法、字段、状态码和错误格式。
- 后端实现并独立验证接口,前端根据约定数据完成页面。
- 把接口调用集中到前端模块,再接入页面事件和状态。
- 配置本地代理,通过 Network 和后端日志核对完整请求过程。
- 验证成功、空数据、错误输入和连接失败等情况,再分别构建部署。
这里最值得掌握的是:页面展示的数据从哪里来,用户操作变成什么请求,后端如何处理,处理结果又怎样回到页面。用户管理示例中的查询、分页和表单都沿着同一条过程工作,之后学习上传、登录和更复杂的页面,也可以据此逐步扩展。

评论