在项目开发中,“开荒”通常意味着不依赖旧项目的包袱,从零开始搭一个新工程;“失控”则指向新工程最容易出现的依赖版本冲突、接口约定不清、前端后端各自跑通却联调失败;“进化”是希望这个临时工程能在短时间内快速变成可演示、可迭代的最小可用版本。这篇博客就把这三个词放在一个具体的周末任务里:用两天时间,从零鼓捣一套基于 Spring Boot 3 + Vue 3 + MySQL 的后台管理 MVP。文章会按照“先定技术基线,再搭后端,再搭前端,然后联调验证,最后复盘如何继续扩展”的顺序展开。看完之后,你可以直接把这个步骤搬到自己的临时项目里,也可以把它当作快速启动新项目的 checklist。
1. 为什么把临时项目当作“周末开荒”而不是“正式开发”
周末开荒和平时的迭代开发是不同的。平时开发有现成的工程结构、固定的代码规范、完整的 CI/CD 流程,你只需要在已有轨道上做增量改动。但临时项目往往从空目录开始,时间有限,交付目标模糊,很容易在环境准备和依赖选择阶段消耗大量时间,最后真正写业务的时间反而不够。
1.1 临时项目的失控点主要出现在哪里
结合多数从零搭建的项目来看,“失控”不是某个瞬间崩盘,而是多个小问题叠加后的结果。
- 依赖版本失控:Spring Boot 2 和 3 的兼容性差异、MyBatis 和 MyBatis-Plus 的 starter 选择、前端脚手架和 UI 组件库版本不匹配,任何一个不一致都可能导致启动失败或页面白屏。
- 数据库设计失控:临时项目常常是边写边加字段,需求来回变化后,表的字段越加越多,后续要么写一堆 if 判断,要么干脆重构表结构,浪费时间。
- 前后端接口失控:后端的字段命名、前端使用的大小写、分页参数的名字,只要没有提前约定,联调时大概率会花掉半天时间去对接口。
- 环境配置失控:JDK 版本、MySQL 编码、端口占用、代理配置,这些内容看似基础,但往往最容易让人卡住。
所以周末开荒的第一原则是:少做选择,多定基线。尽量使用自己熟悉的技术栈和稳定的默认配置,让所有不确定性都尽量收敛到业务实现上。
1.2 本文选用的技术基线和目标形态
为了让读者能复现整个流程,这里直接给出本文使用的技术基线。不同版本会影响具体写法,因此落地时要先确认本地环境是否符合。
| 技术模块 | 选型建议 | 说明 |
|---|---|---|
| JDK | 17 及以上 | Spring Boot 3 要求 JDK 17,版本过低会导致启动报错 |
| 后端框架 | Spring Boot 3.2.x | 采用稳定版本,避免使用刚发布的功能 |
| ORM | MyBatis-Plus 3.5.x | 减少简单 CRUD 代码量,内置分页和条件构造器 |
| 数据库 | MySQL 8.0.x | 注意字符集设置为 utf8mb4 |
| 前端脚手架 | Vite + Vue 3 | 启动速度快,适合快速开荒 |
| UI 组件库 | Element Plus | 成熟稳定,表单、表格、弹窗都有现成组件 |
| HTTP 客户端 | Axios | 统一封装请求和响应拦截器 |
最终目标形态是一个任务管理系统的最小 MVP:管理员可以查看任务列表,可以新增、编辑、删除任务,可以按状态和负责人筛选。这个功能看起来简单,但已经覆盖了数据库设计、后端 CRUD、分页、前端表格、表单弹窗、接口联调这几块最常见的开发环节。
1.3 开荒前要先确认的五个问题
动手写代码之前,建议先花十分钟确认下面的信息,否则写到一半返工成本更高。
- 本地 JDK 版本是否为 17 以上:用
java -version确认。 - MySQL 服务是否启动:用
mysql -uroot -p能否正常连接。 - 8080 端口是否被占用:后端默认会占用 8080,如果被占用,要么关闭旧进程,要么改端口。
- 前端是否安装了 Node.js 18 以上:Vite 5 需要较新的 Node 版本。
- 包名和目录结构是否确定:例如
com.example.taskadmin,后面所有包路径都围绕它。
这些问题确认完,才可以进入实际搭建阶段。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 后端最小工程搭建:先让数据模型稳定下来
后端是整个系统的数据入口。先写数据库和实体类的好处是:实体类确定了,Mapper、Service、Controller、前端接口名称都跟着确定,后续不会频繁修改结构。临时项目里最常见的浪费,就是先搭了 Controller 然后回头改表结构,导致前端也跟着改。
2.1 创建 Spring Boot 工程的基础依赖
这里不使用在线生成的脚手架,直接用 Maven 手动创建工程,以便看清楚每个依赖的作用。新建目录后,在 pom.xml 中填入下面的内容。
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.2.4</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>task-admin-backend</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.5</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</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>
这里的重点是 mybatis-plus-spring-boot3-starter。如果你使用的是 Spring Boot 2,需要用 mybatis-plus-boot-starter,这两个依赖的 groupId 相同但 artifactId 不同,选错后会出现 MybatisPlusInterceptor 不生效或启动直接失败。Locale 问题也是临时项目里最常见的坑。
2.2 数据库设计:任务管理表 DDL
任务管理表只需要保存最基础的字段,不需要为了刻意展示技巧设计复杂的表结构。但字段类型和约束要提前定义好,尤其是状态和优先级这类枚举值,建议用 TINYINT 而不是字符串。
sql复制CREATE DATABASE IF NOT EXISTS task_admin DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE task_admin;
CREATE TABLE task (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
title VARCHAR(128) NOT NULL COMMENT '任务标题',
description TEXT COMMENT '任务描述',
status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待处理 1进行中 2已完成',
priority TINYINT NOT NULL DEFAULT 1 COMMENT '优先级:0低 1中 2高',
owner VARCHAR(64) COMMENT '负责人',
due_date DATE COMMENT '截止日期',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务表';
这里有几个值得解释的设计点。status 和 priority 使用 TINYINT,是为了在前端通过字典映射显示中文,而不是在后端直接存中文,这样后续改成枚举、加国际化都会容易很多。created_at 和 updated_at 使用数据库默认值,避免了应用层每次都要手动维护时间字段。description 使用 TEXT 是因为任务描述可能很长,用 VARCHAR(255) 可能不够。临时项目里最容易犯的错误,就是状态字段用 VARCHAR 存中文,然后前端显示的时候又要做一层字符串匹配。
2.3 三层结构与实体、Mapper、Service、Controller
后端结构按常见的 controller、service、mapper、entity 分层即可。这里的包名统一为 com.example.taskadmin。
实体类直接映射表 task:
java复制package com.example.taskadmin.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("task")
public class Task {
@TableId(type = IdType.AUTO)
private Long id;
private String title;
private String description;
private Integer status;
private Integer priority;
private String owner;
private LocalDate dueDate;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
Mapper 只需要继承 BaseMapper:
java复制package com.example.taskadmin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.taskadmin.entity.Task;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface TaskMapper extends BaseMapper<Task> {
}
Controller 提供接口。为了让代码尽量简短,直接在 Controller 中注入 Mapper,省略 Service 层。正式项目不建议这样做,临时项目先跑通最重要:
java复制package com.example.taskadmin.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.taskadmin.entity.Task;
import com.example.taskadmin.mapper.TaskMapper;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskMapper taskMapper;
public TaskController(TaskMapper taskMapper) {
this.taskMapper = taskMapper;
}
@GetMapping
public Map<String, Object> list(@RequestParam(defaultValue = "1") long current,
@RequestParam(defaultValue = "10") long size,
@RequestParam(required = false) Integer status,
@RequestParam(required = false) String owner) {
QueryWrapper<Task> wrapper = new QueryWrapper<>();
if (status != null) {
wrapper.eq("status", status);
}
if (owner != null && !owner.isEmpty()) {
wrapper.like("owner", owner);
}
wrapper.orderByDesc("id");
IPage<Task> page = taskMapper.selectPage(new Page<>(current, size), wrapper);
Map<String, Object> result = new HashMap<>();
result.put("records", page.getRecords());
result.put("total", page.getTotal());
result.put("current", page.getCurrent());
result.put("size", page.getSize());
return result;
}
@GetMapping("/{id}")
public Task detail(@PathVariable Long id) {
return taskMapper.selectById(id);
}
@PostMapping
public Task create(@Valid @RequestBody Task task) {
taskMapper.insert(task);
return task;
}
@PutMapping("/{id}")
public Task update(@PathVariable Long id, @Valid @RequestBody Task task) {
task.setId(id);
taskMapper.updateById(task);
return task;
}
@DeleteMapping("/{id}")
public void delete(@PathVariable Long id) {
taskMapper.deleteById(id);
}
}
Controller 里有几个细节需要注意。list 接收 current 和 size,分别对应页码和每页条数,前端默认传 1 和 10。QueryWrapper 是 MyBatis-Plus 的条件构造器,eq 表示精确匹配,like 表示模糊查询。orderByDesc("id") 让新数据排在前面,符合任务管理的习惯。
2.4 配置 application.yaml
在 src/main/resources 下创建 application.yaml:
yaml复制server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/task_admin?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
global-config:
db-config:
id-type: auto
map-underscore-to-camel-case 用于把数据库字段 due_date 自动映射为实体属性 dueDate,没有这个配置,查询出来 dueDate 可能为 null。allowPublicKeyRetrieval=true 是 MySQL 8 连接时常见的参数,不加会出现公钥检索报错。密码部分在本地开发可以直接写,生产环境必须改成环境变量或者配置中心。
到这里后端的骨架已经完成。启动前还需要注册 MyBatis-Plus 的分页插件,否则 selectPage 虽然不报错,但分页不会生效,返回的 total 会是 0 或者查询结果不是按页返回。
java复制package com.example.taskadmin.config;
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() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
在 TaskAdminApplication.java 中写主启动类:
java复制package com.example.taskadmin;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TaskAdminApplication {
public static void main(String[] args) {
SpringApplication.run(TaskAdminApplication.class, args);
}
}
运行 mvn spring-boot:run,看到 Started TaskAdminApplication 就代表后端启动成功。此时可以直接访问 http://localhost:8080/api/tasks,返回空列表数据。
3. 前端最小工程搭建:用列表页暴露后端接口
后端接口已经有了,前端的目标是尽快用一个页面展示数据。这里选择 Vue 3 + Vite + Element Plus,原因很简单:Vite 启动快,Element Plus 提供了现成的表格、分页、弹窗和表单校验,能减少大量样式和交互代码。开荒阶段不要自己写组件库,也不要花时间配置复杂的工程化规则。
3.1 用 Vite 创建 Vue 3 工程
在项目根目录下执行:
bash复制npm create vite@latest task-admin-frontend -- --template vue
创建完成后进入目录,安装依赖:
bash复制cd task-admin-frontend
npm install
npm install element-plus axios
安装完成后,先清理掉 Vue 默认生成的 HelloWorld.vue 和样式代码,保持目录干净。最终页面只需要一个 TaskList.vue,所以这里不再引入 vue-router,直接把 App.vue 改成业务页面即可。如果后续有多个页面,再引入路由也不迟。
在 src/main.js 中引入 Element Plus:
javascript复制import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
const app = createApp(App)
app.use(ElementPlus)
app.mount('#app')
3.2 封装 Axios 请求
临时项目容易犯的错误是每个页面单独写 axios.get,后续接口路径变了要改多处。这里先把请求封装成一层,无论项目怎么扩展,只需要改一个地方。
在 src/utils/request.js 中写入:
javascript复制import axios from 'axios'
const request = axios.create({
baseURL: '/api',
timeout: 10000
})
request.interceptors.response.use(
response => response.data,
error => {
console.error('请求异常:', error)
return Promise.reject(error)
}
)
export default request
在 src/api/task.js 中定义任务相关接口:
javascript复制import request from '../utils/request'
export function fetchTaskList(params) {
return request({
url: '/tasks',
method: 'get',
params
})
}
export function createTask(data) {
return request({
url: '/tasks',
method: 'post',
data
})
}
export function updateTask(id, data) {
return request({
url: `/tasks/${id}`,
method: 'put',
data
})
}
export function deleteTask(id) {
return request({
url: `/tasks/${id}`,
method: 'delete'
})
}
3.3 页面结构:查询区、表格区、弹窗区
App.vue 中直接编写页面。结构分成三块:顶部查询区,中间表格区,底部弹窗表单区。
vue复制<template>
<div class="task-page">
<el-card>
<el-form inline>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable>
<el-option label="待处理" :value="0" />
<el-option label="进行中" :value="1" />
<el-option label="已完成" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="负责人">
<el-input v-model="query.owner" placeholder="模糊匹配负责人" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button type="success" @click="handleAdd">新增任务</el-button>
</el-form-item>
</el-form>
<el-table :data="tableData" border stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="title" label="标题" min-width="160" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="100">
<template #default="{ row }">
{{ priorityText(row.priority) }}
</template>
</el-table-column>
<el-table-column prop="owner" label="负责人" width="100" />
<el-table-column prop="dueDate" label="截止日期" width="110" />
<el-table-column label="操作" width="160">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="query.current"
v-model:page-size="query.size"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="fetchList"
@current-change="fetchList"
/>
</el-card>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px">
<el-form :model="form" label-width="80px">
<el-form-item label="标题" required>
<el-input v-model="form.title" placeholder="请输入任务标题" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option label="待处理" :value="0" />
<el-option label="进行中" :value="1" />
<el-option label="已完成" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="优先级">
<el-select v-model="form.priority">
<el-option label="低" :value="0" />
<el-option label="中" :value="1" />
<el-option label="高" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="负责人">
<el-input v-model="form.owner" />
</el-form-item>
<el-form-item label="截止日期">
<el-date-picker v-model="form.dueDate" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
对应的脚本逻辑:
javascript复制<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchTaskList, createTask, updateTask, deleteTask } from './api/task'
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const dialogVisible = ref(false)
const dialogTitle = ref('')
const query = reactive({
current: 1,
size: 10,
status: null,
owner: ''
})
const form = reactive({
id: null,
title: '',
description: '',
status: 0,
priority: 1,
owner: '',
dueDate: ''
})
const statusMap = {
0: { text: '待处理', type: 'info' },
1: { text: '进行中', type: 'primary' },
2: { text: '已完成', type: 'success' }
}
const priorityMap = {
0: '低',
1: '中',
2: '高'
}
const statusText = status => statusMap[status]?.text ?? '未知'
const statusType = status => statusMap[status]?.type ?? 'info'
const priorityText = priority => priorityMap[priority] ?? '未知'
async function fetchList() {
loading.value = true
try {
const data = await fetchTaskList(query)
tableData.value = data.records
total.value = data.total
} finally {
loading.value = false
}
}
function handleSearch() {
query.current = 1
fetchList()
}
function handleReset() {
query.status = null
query.owner = ''
query.current = 1
fetchList()
}
function handleAdd() {
Object.assign(form, {
id: null,
title: '',
description: '',
status: 0,
priority: 1,
owner: '',
dueDate: ''
})
dialogTitle.value = '新增任务'
dialogVisible.value = true
}
function handleEdit(row) {
Object.assign(form, {
id: row.id,
title: row.title,
description: row.description,
status: row.status,
priority: row.priority,
owner: row.owner,
dueDate: row.dueDate
})
dialogTitle.value = '编辑任务'
dialogVisible.value = true
}
async function handleSubmit() {
if (!form.title || form.title.trim() === '') {
ElMessage.warning('标题不能为空')
return
}
if (form.id) {
await updateTask(form.id, form)
ElMessage.success('修改成功')
} else {
await createTask(form)
ElMessage.success('新增成功')
}
dialogVisible.value = false
fetchList()
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确认删除任务“${row.title}”?`, '提示', {
type: 'warning'
})
await deleteTask(row.id)
ElMessage.success('删除成功')
fetchList()
}
onMounted(fetchList)
</script>
在 handleEdit 中有一个容易踩的坑:不要把 row 直接赋值给 form,否则弹窗中的输入框会直接修改表格行数据,取消弹窗时原行数据已经变了。这里使用 Object.assign 复制字段,确保表单和表格数据相互独立。
3.4 配置 Vite 开发代理
前端开发时访问 http://localhost:5173,后端在 http://localhost:8080,直接请求会产生跨域问题。开荒阶段最简单的方案是在 vite.config.js 中配置代理,把 /api 开头的请求转发到后端,这样浏览器看到的是同源请求,不用在后端写 CORS 配置。
javascript复制import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
配置完成后,前端请求 /api/tasks 会被代理到 http://localhost:8080/api/tasks。这里不建议同时在后端开启 CORS,因为开发阶段用代理、生产阶段用 Nginx 反代是更干净的做法,后端保持纯接口服务即可。
4. 联调验证与经典“失控”现场排查
前端和后端都启动后,真正的验证才刚开始。很多人觉得“能把页面打开”就算成功,实际上还要验证查询条件是否生效、分页是否准确、新增后列表是否刷新、修改后数据是否真的更新、删除后的提示是否正常。这一节按从易到难的顺序,给出联调步骤和常见问题排查路径。
4.1 启动顺序与验证清单
建议按下面的顺序启动和验证。
- 启动 MySQL,确认数据库
task_admin和表task已创建。 - 启动后端
mvn spring-boot:run,确认端口 8080 正常监听。 - 用 Postman 或浏览器访问
http://localhost:8080/api/tasks,确认返回 JSON。 - 启动前端
npm run dev,打开http://localhost:5173。 - 点击“新增任务”,填写表单,确认保存成功。
- 修改刚创建的任务,确认表格行数据改变。
- 删除任务,确认列表刷新。
- 输入负责人关键字查询,确认筛选结果正确。
第 3 步很多人会跳过,这是不对的。先验证后端接口,再联调前端,这样出现问题可以快速判断是哪一端的问题。
4.2 数据出不来、新增 400、删除 404 这类问题的排查表
联调阶段常见的现象和原因,整理成下面的速查表。
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 前端列表没有数据,但后端接口直接访问有数据 | Vite 代理未生效或请求路径错误 | 打开浏览器开发者工具查看网络请求地址 | 确认 vite.config.js 代理中 /api 配置正确 |
| 新增任务时返回 400 | 请求体字段名和后端实体不一致,或日期格式不对 | 查看后端日志中的具体异常信息 | 将表单 dueDate 格式改为 YYYY-MM-DD,确认字段名一致 |
| 删除任务返回 405 | 前端请求方法错误 | 查看 Network 面板中删除请求的 method | 确认调用的是 deleteTask,且 URL 为 /api/tasks/{id} |
| 分页 total 一直为 0 | 未注册 MyBatis-Plus 分页插件 | 查看执行 SQL 是否包含 LIMIT | 注册 PaginationInnerInterceptor 并设置数据库类型 |
| 查询条件不生效 | 后端没有接收 status 或 owner 参数 |
在后端接口打断点或打印参数 | 检查前端 query 参数名是否与后端 @RequestParam 一致 |
| 页面白屏且控制台报错 | 前端代码引用错误或版本不兼容 | 查看控制台错误栈 | 确认 Element Plus 版本与 Vue 3 兼容,并重新构建 |
其中 400 问题是最容易发生的。后续如果前端传了 dueDate 为 null,后端校验会报错 JSON parse error 或直接提示字段类型转换失败。临时项目里建议前端日期组件显式配置 value-format="YYYY-MM-DD",这样传给后端的就是字符串,Jackson 可以正确解析为 LocalDate。
4.3 前端常见错误样例
下面这段错误在 Element Plus 和 Axios 联调时经常出现:
text复制Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'records')
出现这个问题的原因通常是:后端返回的是 ResponseEntity 包装过的对象,而前端 request.js 的拦截器已经返回了 response.data,如果后端又包了一层 data,前端取 data.records 就会报错。解决办法是统一后端返回结构,或者在拦截器里只返回 response.data,前端按固定在 records 取数据。临时项目要记住一个原则:返回结构一旦确定,前后端都不要再额外包装。
4.4 周末开发最该守住的底线
临时项目时间有限,不要追求复杂的单元测试、不要一开始就写权限体系、不要提前优化性能。但要守住几条底线:
- 所有接口都要有基础校验,不能因为时间紧就省略新增和更新时的非空判断。
- 登录状态和权限控制可以暂缓,但数据操作不能绕过后端直接暴露全表数据。
- 数据库连接信息、密码等敏感配置不要硬编码在前端或后端代码里,至少要放到环境变量。
- 前后端接口返回结构保持一致,避免一个接口返回
{code, data},另一个接口直接返回数组,否则前端很容易写出错误逻辑。
5. 周末开荒后的快速进化路线与复盘清单
两天时间搭建的 MVP 只是起点。如果项目后续真的要长期使用,调整数据结构、补充权限、丰富查询条件会成为必然需求。这里给出从 MVP 继续进化的顺序建议,以及一份可以直接复用的复盘清单。
5.1 从最小 MVP 进化的顺序建议
不要试图一次性把系统做完。对于任务管理这类系统,建议按下面的优先级推进。
- 第一优先级:登录认证和用户表。没有登录,任何数据进系统都无法追溯操作人,后续想加审计就很难。
- 第二优先级:操作审计和软删除。至少记录谁在什么时候修改了什么数据,删除任务时不要物理删除表记录。
- 第三优先级:更丰富的筛选和排序。比如按创建时间范围、按优先级、按多个负责人筛选,这些查询条件的扩展并不复杂,但能显著提升使用体验。
- 第四优先级:前端路由和菜单。当页面超过两个时再引入,否则会白白增加复杂度。
- 第五优先级:单元测试和接口文档。项目稳定后补上,临时阶段可以用 Postman 导出接口文档。
5.2 防止依赖版本失控的操作方式
临时项目最容易踩的就是依赖版本问题。这里给出一套可执行的方法。
- 每次新建工程时先确认 JDK、Node.js、MySQL 的大版本,然后锁定框架版本组合。
- 依赖不要只写版本号,还要把关键依赖的作用写在注释或 README 中。
- 前后端接口字段变更时,立刻同步更新前端类型和后端实体,不要只改后端,让前端对着错误数据猜。
- 升级依赖时,按“全局升级一次,启动一次,跑一遍核心功能”的方式操作,不要一次性升级多个模块。
- 所有第三方依赖尽量使用稳定版本,不要使用 beta 或 RC 版本。
一个稳定的技术栈组合,比如本文使用的 Spring Boot 3.2.4 + Vue 3 + Element Plus + MyBatis-Plus 3.5.5,只要固定下来,后续新项目可以复制同一组版本,能省掉大量排查依赖的时间。
5.3 复盘清单
周末结束前,建议按下面这个清单快速过一遍,确认项目交付状态。
- 后端是否能在干净环境启动:清掉本地缓存和数据库数据,再看能否重新跑通。
- 数据库脚本是否可重复执行:
task.sql是否保存到项目目录,是否有初始化数据。 - 前端构建是否正常:运行
npm run build,确认没有缺失依赖或打包错误。 - 敏感配置是否外置:数据库密码是否从
application.yaml中提取到环境变量。 - 接口是否都有异常处理:后端是否统一捕获异常,前端是否有错误提示。
- README 是否完整:启动命令、端口、数据库初始化步骤、默认账号密码是否都写清楚。
- 是否有遗留的 TODO:代码中如果还有未完成逻辑,要写明任务内容和下一步计划。
如果上述清单全部通过,这个周末开荒的临时项目就不仅是一个能运行的 demo,而是一个可以交给别人继续维护的初始版本。相反,如果只是“能跑”,那么下一个人接手时会需要重新整理环境、确认版本、理解页面逻辑,会再次进入一次新的“失控进化”。项目能不能从“能用”走向“好用”,很多时候不在于最初写了多少代码,而在于变更发生时是否有清晰的边界、稳定的基线和可追溯的记录。
