← 返回首页
Java学习#Java

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

2026-10-20·2 次浏览
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 点击添加以后,发生了什么

现在两个工程都能运行了,可以把前面分开看的代码接起来。

在输入框写下“整理学习笔记”,点击添加,执行顺序如下:

  1. v-model 已经把文字保存到 Vue 的 title 状态中。
  2. 表单触发 addTodo,先检查输入,再调用 createTodo(text)。
  3. Axios 发送 POST /api/todos,请求体为 {"title":"整理学习笔记"}。
  4. 浏览器把请求发到前端地址,Vite 代理将它转发到 Spring Boot。
  5. Controller 读取 JSON、触发校验,Service 创建并保存任务。
  6. 后端返回 201 和新任务的 JSON,前端接着 GET 列表。
  7. todos.value 被替换成查询结果,Vue 更新列表,页面出现新任务。
sequenceDiagram participant U as 用户 participant V as 浏览器中的 Vue participant P as Vite 开发服务器 participant B as Spring Boot U->>V: 输入标题,点击添加 V->>P: POST /api/todos,JSON 标题 P->>B: 转发请求 B-->>P: 201,新任务 JSON P-->>V: 返回响应 V->>P: GET /api/todos P->>B: 转发查询 B-->>P: 200,任务数组 P-->>V: 返回数组 V-->>U: 更新页面列表

页面没有整体跳转,任务却出现了,因为 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. 按什么顺序完成一次前后端分离开发

学会这个例子以后,可以把同样的过程用到学生名单、商品管理或文章列表中。业务字段会变化,协作顺序可以保持清楚:

  1. 先列出页面要完成的操作,确定每个操作需要哪些输入和输出。
  2. 约定接口路径、HTTP 方法、字段、状态码和错误格式。
  3. 后端实现并独立验证接口,前端根据约定数据完成页面。
  4. 把接口调用集中到前端模块,再接入页面事件和状态。
  5. 配置本地代理,通过 Network 和后端日志核对完整请求过程。
  6. 验证成功、空数据、错误输入和连接失败等情况,再分别构建部署。

这里最值得掌握的是:页面展示的数据从哪里来,用户操作变成什么请求,后端如何处理,处理结果又怎样回到页面。用户管理示例中的查询、分页和表单都沿着同一条过程工作,之后学习上传、登录和更复杂的页面,也可以据此逐步扩展。

13. 官方资料

── 完 ──

评论

还没有评论,来说点什么
发表评论
0 / 1000