SpringBoot + Vue3 + MyBatis 打造企业级项目管理系统实战
发布时间:2026/10/5 0:35:19 作者:尧图编辑部 阅读量:1,286

1. 系统要解决的问题与整体架构设计1.1 拆解“企业项目管理”到底在管什么我在帮不少企业做过内部管理系统发现一个规律不管公司是做软件外包、建筑工程、制造业还是广告策划所谓“项目管理系统”的本质都差不多都是围绕项目立项、任务拆分、人员分工、进度跟踪、文档沉淀这几个基础动作在打转。只不过业务领域不同术语换了换而已。这套 SpringBoot Vue3 MyBatis 的项目管理系统核心也是落到这几个对象上项目Project、任务Task、用户User、部门Department、以及贯穿始终的权限角色Role。从源码的组织方式看它走的也是典型的管理后台路线——后端提供 RESTful 接口前端用 Vue3 做单页应用数据落在 MySQL。这类系统的价值不在“功能有多炫”而在边界划得清不清楚。项目、任务、用户之间是什么关系谁能看哪些项目任务是归属到项目还是归属到人这些业务规则如果没在数据库设计和接口设计阶段想明白后面写多少代码都是补丁摞补丁。以这套系统为例最基本的业务链路是这样的管理员创建项目指定项目负责人项目负责人在项目下面拆任务分配给具体成员成员更新任务状态管理层通过看板查看整体进度。这个链路听起来简单但落到代码里至少要处理项目的创建与归档、任务状态的流转、成员的项目权限校验、以及列表查询时的多条件过滤。1.2 三个维度看这套技术栈的搭配合理性先聊聊为什么是 SpringBoot Vue3 MyBatis MySQL 这个组合。站在2024年的时间点往回看这套组合在国内企业级后台里依旧是性价比极高的选择没有之一。**后端用 SpringBoot胜在开箱即用和生态成熟。**SpringBoot 解决了传统 SSMSpring SpringMVC MyBatis项目里大量 XML 配置的繁琐问题起步依赖一把梭内嵌 Tomcat用 java -jar 就能跑起来。对一个企业管理系统来说它需要的东西——分页插件、参数校验、AOP 切面、全局异常处理、接口文档——Spring 生态里全都有对应组件不用自己造轮子。**持久层用 MyBatis核心优势是灵活控制 SQL。**很多人纠结 JPA 和 MyBatis 选哪个我的看法是如果你的系统查询场景复杂、报表多、SQL 需要人工调优MyBatis 更顺手。企业项目管理系统恰好就是这种典型任务列表往往要按项目、状态、负责人、时间范围多条件组合查询甚至还有拼接动态排序的需求。MyBatis 的 XML 动态 SQL 在这种场景下控制力非常强SQL 长什么样、走不走索引、怎么 join开发人员一目了然。**MySQL 作为存储层没什么悬念。**并发量没有大到需要上分布式数据库团队对 MySQL 的运维经验最丰富用 InnoDB 引擎配合 utf8mb4 编码就能扛住绝大多数企业应用场景。这套系统选 MySQL成本低、好招人、出了问题网上一搜全是答案这就是中小企业最看重的确定性。前端选 Vue3好处是组合式 API 让逻辑复用变得干净。Vue2 时代的混入mixin写多了改名冲突问题很突出Vue3 的 setup composable组合式函数方式解决得非常彻底。配合 Vite 的开发体验和 npm Vite 的构建速度做后台管理系统非常轻快。2. 数据库设计从业务对象到 MySQL 表结构的落地2.1 项目管理主链路的三张核心表打开这套系统的数据库脚本值得先看的是project项目、task任务、user用户这三张表以及它们之间的关联关系。这不是拍脑袋定的而是从业务链路上推下来的。项目表的核心字段除了 id、name、code 之外一定要有status、start_date、end_date、owner_id这几个。status用来标记项目处于待启动、进行中、已暂停还是已归档owner_id是项目负责人外键这个字段直接决定了后续“我负责的项目”这类查询怎么组织。任务表与项目形成多对一关系用project_id外键关联。任务本身要有title、description、priority、status、assignee_id、deadline。这里面有几个容易被忽略的点priority虽然只是一个枚举值但设计成tinyint而不是字符串排序效率更高、存储更省assignee_id单独拎出来而不是在设计里默认“任务一定属于创建者”是因为真实场景里任务经常被转派。成员关系不能简单地在 task 表和 project 表里各放一个 user_id 就完事。需要一个project_member中间表来维护“谁在哪个项目里是什么角色”。一张标准的中间表字段就够project_id、user_id、role_in_project项目内角色、joined_at。建议这三张表的主键都用bigint自增配合 MySQL 的 InnoDB性能完全没有问题。有些团队喜欢用雪花 ID 或者 UUID但在单库单体架构下自增主键的 B 树插入特性最好别为了“未来可能分库”去牺牲现在的简单性。2.2 权限模型RBAC 在管理后台里的表设计企业管理系统的权限往往比业务报表还要复杂因为它要管的是“谁能看什么、谁能点哪个按钮”。这套系统用的经典 RBAC基于角色的访问控制模型总共五张表user、role、menu、user_role、role_menu。menu表比较有讲究它的字段包括name、path、component、icon、parent_id、sort_order、type。注意type这个字段用它来区分“目录”“菜单”“按钮”三种层级目录是一级导航菜单是可点击的页面路由按钮是页面里的操作权限比如“新增任务”“删除项目”。按钮权限塞进同一张菜单表比单独建一张 permission 表要省事前端做权限指令的时候直接查 type 就行。user_role和role_menu都是纯关联表不需要额外业务字段。有个实践细节设计关联表时在两张关联表里都加上idx_user_id、idx_role_id这类索引。原因很简单权限校验是高频动作一次登录后可能要查询很多次角色和权限索引直接决定了接口响应速度。2.3 建表时的几个通用约定这套系统的建表 SQL 里有几个值得沿用的习惯所有表都有create_time、update_time两个时间字段所有业务表都带软删除标记位deleted默认值尽量给全。这三个约定在企业项目里非常实用。软删除是很多人忽略但运维时非常感激的设计。用deleted字段0 表示正常1 表示已删除替代物理 DELETE好处是误删可恢复、数据可审计。代价是每次查询都要记得加where deleted 0。MyBatis 的通用 Mappertk.mybatis 或 MyBatis-Plus会自动拼这个条件但如果用的是原生 MyBatis就需要在 XML 里手动维护这也是为什么很多团队在原生 MyBatis 上二次封装了一层 BaseMapper。时间字段的类型建议用datetime而不是timestamp虽然timestamp占用更少明确带时区的场景也够用但它的范围只有 1970 到 2038 年而datetime的范围大得多也没有时区转换的坑。3. 后端代码组织SpringBoot 与 MyBatis 的关键实践3.1 后端分层边界怎么划这套系统的后端目录通常长这样src/main/java/com/company/project ├── controller ├── service ├── mapper ├── entity ├── dto ├── config ├── common └── utilsentity对应数据库表dto对应接口入参和出参。这个区分很重要一定不要把数据库字段原封不动地暴露给前端。举例来说用户表的password字段就不应该出现在查询用户的响应里所以在查询用户列表时需要有个UserDTO只包含 id、username、realName、deptName 这些对外安全的字段。controller层只负责参数接收和结果封装不写业务逻辑。service层做业务编排。mapper层只与数据库打交道。这套分层的意义容易被低估但当你遇到“一个接口要在多个地方复用同一段业务查询”时就会体会到边界清晰的舒适感。在 controller 层使用一个高频技巧参数校验用 Spring 自带的ValidatedValid注解配合 DTO 上的NotNull、NotBlank、Email等注解能在进入 service 层之前就拦截掉大部分非法请求。3.2 MyBatis 动态 SQL多条件查询的核心企业项目管理系统的查询场景特别多任务列表可能要按“项目 状态 负责人 截止日期”四个条件任意组合筛选用户列表可能按“姓名 部门 状态”筛选。这种查询用动态 SQL 实现最合理。以任务列表查询为例对应的 Mapper XML 核心逻辑是select idselectTaskPage resultTypecom.company.project.dto.TaskDTO SELECT t.id, t.title, t.priority, t.status, t.deadline, p.name AS project_name, u.real_name AS assignee_name FROM task t LEFT JOIN project p ON t.project_id p.id LEFT JOIN user u ON t.assignee_id u.id where t.deleted 0 if testprojectId ! null AND t.project_id #{projectId} /if if teststatus ! null AND t.status #{status} /if if testassigneeId ! null AND t.assignee_id #{assigneeId} /if if teststartDate ! null AND t.deadline gt; #{startDate} /if if testendDate ! null AND t.deadline lt; #{endDate} /if /where ORDER BY t.create_time DESC /select这里的核心是where和if的组合。where会自动去掉第一个多余的和AND 或 OR避免你在 Java 代码里重复拼接条件。分页建议用 PageHelper拦截器方式在查询前自动拼接 limit业务代码里调用PageHelper.startPage(pageNum, pageSize)即可。但有个坑必须提醒PageHelper 的分页线程变量是放在 ThreadLocal 里的如果在一个方法里配置了分页却执行了多条查询可能会造成分页错乱正确做法是分页代码紧跟要分页的那一条查询中间不要插入其他 mapper 调用。3.3 统一返回结构和全局异常处理这套系统的接口返回结构设计值得参考无论成功失败统一返回{ code, message, data }格式。前端可以用 axios 拦截器统一处理不用每个接口写一遍错误分支。public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 200; result.message success; result.data data; return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.code code; result.message message; return result; } }全局异常处理用RestControllerAdvice在类里按异常类型定义处理函数。比较常规的做法是为每种业务异常绑定一个错误码比如参数错误 400、未认证 401、无权限 403、业务异常 500。一个容易被忽视的地方是所有异常处理函数都必须返回统一的Result结构千万不能在 Controller 层手动 throw 一些未包装的异常否则前端如果统一按格式解包遇到非标准结构就直接解析失败。3.4 登录态与接口权限拦截器 JWT 的实际组合这套系统前后端分离登录态不能依赖 Session 和 Cookie最通用的方案是 JWTJSON Web Token。简单来说用户登录成功后后端签发一个带签名和过期时间的 token 返回给前端前端把 token 存到 localStorage 或 Pinia 状态里后续每次请求在 header 里带上Authorization: Bearer token后端拦截器校验 token 合法性并解析出用户信息放入请求上下文。JWT 不需要服务端保存会话状态天然适合前后端分离的架构。但要注意三个实际问题第一token 里不要放敏感数据userId、用户名、角色列表就够了密码等隐私信息坚决不放第二token 有过期时间业务上通常设 2 小时到 24 小时过期后前端拦截 401 状态码跳回登录页让用户重新登录第三服务端要预留一个 token 中用户信息变化的处理机制比如用户被禁用已经在途的 token 还能不能继续用这个问题各项目取舍不同严谨的业务一般会加 Redis 黑名单。后端拦截器实现要点Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口和静态资源 if (request.getRequestURI().contains(/auth/login)) { return true; } String token request.getHeader(Authorization); if (StringUtils.isBlank(token) || !token.startsWith(Bearer )) { throw new UnauthorizedException(未登录或token已过期); } // 解析并校验token将用户信息放入ThreadLocal UserContext user JwtUtil.parseToken(token.replace(Bearer , )); UserContextHolder.set(user); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 每次请求结束后清理ThreadLocal避免内存泄漏 UserContextHolder.clear(); } }配置拦截器时记得除了登录接口还要放行错误页和静态资源路径否则会出现页面资源被拦截导致样式丢失这种低级问题。4. Vue3 前端工程从 Vite 项目到后台交互实现4.1 Vue3 项目基础结构与状态管理怎么选后端写完之后部署在 8080 端口前端跑在 5173 端口两者通过 HTTP 接口通信这就完成了前后端分离的第一步。前端工程用 Vite 脚手架初始化命令很简单npm create vitelatest project-web -- --template vue建议选vue-ts模板给项目加上 TypeScript。说实话中小型后台项目不强制上 TS但当你维护过一个不断迭代的项目管理系统之后就会明白类型约束在重构时有多重要。项目的核心目录划分views放页面组件、components放公共组件、api放请求封装模块、router放路由配置、stores放 Pinia 状态管理。状态管理这块后台管理系统里最需要全局保存的就是用户信息和菜单权限这两类数据几乎每个页面都要读放 Pinia 是标准做法。Vue3 引入 Pinia 替代 Vuex写法更简洁。一个典型的用户 Storeimport { defineStore } from pinia interface UserState { token: string userInfo: Recordstring, any permissions: string[] } export const useUserStore defineStore(user, { state: (): UserState ({ token: localStorage.getItem(token) || , userInfo: {}, permissions: [] }), actions: { setToken(token: string) { this.token token localStorage.setItem(token, token) }, async fetchUserInfo() { const res await getUserInfoApi() this.userInfo res.data this.permissions res.data.permissions || [] } } })4.2 Axios 请求封装与会话恢复前端请求层是 Vue3 项目里很值得打磨的一层。创建一个 axios 实例统一配置 baseURL、超时时间、请求头请求拦截器里自动从 Store 取 token 并加到 header响应拦截器里统一拆包遇到 code 非 200 时提示错误信息遇到 401 时清空登录态跳登录页。import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code 200) { return res } if (res.code 401) { // token失效跳转登录页 localStorage.removeItem(token) router.push(/login) } ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } )一个推荐的实践是 baseURL 用相对路径/api配合 Vite 的 proxy 配置指向后端服务地址。这样开发环境和生产环境都不需要改前端代码只要改代理或 Nginx 转发配置即可避免把接口 IP 硬编码在前端。4.3 动态路由和按钮级权限控制登录后系统要按用户的角色动态渲染菜单这是管理后台的标准能力。后端登录接口返回用户信息时一般会把角色列表和菜单权限一并返回。前端拿到菜单数据后通过router.addRoute动态注册路由再配合菜单组件递归渲染出侧边栏。Vue3 中动态路由的核心代码大致是function buildRoutes(menus: MenuItem[]) { const routes menus.map(menu ({ path: menu.path, name: menu.name, component: () import(/views/${menu.component}.vue), meta: { title: menu.name, icon: menu.icon } })) routes.forEach(route router.addRoute(route)) }这里有个坑使用动态 import 的字符串路径时Vite 需要静态分析模块依赖直接把字符串传进去可能无法正确打包。更稳妥的方式是先建一个页面组件映射表const modules import.meta.glob(/views/**/*.vue) function loadComponent(componentPath: string) { return modules[/src/views/${componentPath}.vue] }按钮级权限控制Vue3 里推荐写一个自定义指令v-permission。指令内部检查用户权限列表是否包含按钮权限标识不满足就移除 DOM 节点。这个比在模板里写一堆v-ifpermissions.includes(task:add)要整洁得多。5. 环境搭建、联调和部署阶段容易卡住的细节5.1 MySQL 部署中的版本、时区和连接问题这套系统配 MySQL 时我在实测里遇到过几个比较典型的坑。第一个是时区问题连接串里如果少了serverTimezoneAsia/Shanghai会抛出The server time zone value contains unrecognized or ambiguous之类异常。第二个是字符编码建库时一定要设置utf8mb4CREATE DATABASE project_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;第三个是驱动版本匹配。SpringBoot 2.x 默认用的是 MySQL Connector/J 8.x如果你是老手习惯在依赖里手动写 5.1.49 这种版本注意和 MySQL 服务端版本、JDK 版本配套不然会报Unsupported major.minor version或者 SLL 握手失败。遇到 MySQL 连接 SSL 报错连接串里显式关掉 SSLjdbc:mysql://localhost:3306/project_db?useSSLfalseallowPublicKeyRetrievaltrueserverTimezoneAsia/ShanghaicharacterEncodingutf8allowPublicKeyRetrievaltrue这个参数尤其容易漏MySQL 8.x 使用 caching_sha2_password 认证方式时不加上它新版驱动会报Public Key Retrieval is not allowed。5.2 开发环境跨域与生产环境部署的两套解法前后端分离后跨域是大问题。开发阶段最简单的方案是 Vite proxy 转发不需要后端做任何 CORS 配置// vite.config.ts export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } } })生产环境推荐的方式是 Nginx 反代前端打包成静态文件放在 Nginx 的 html 目录Nginx 把/api前缀的请求转发给后端 Java 服务。这样能避免在生产环境开启 CORS安全性也更好。Nginx 配置片段server { listen 80; server_name yourdomain.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files $uri $uri/ /index.html;这行非常关键。Vue Router 在 history 模式下刷新页面会直接请求一个真实不存在的路径没有这条配置就会 404。这是后台管理项目部署到线上后最常出现的问题。5.3 启动顺序和配置文件差异实际启动这套系统时建议按这个顺序先启动 MySQL确认服务正常并且能连接再启动 Redis如果项目用到了接着启动后端 SpringBoot 应用看到启动成功的日志最后启动前端开发服务器或者部署静态文件。开发环境和生产环境切分配置用 Spring 的多 profile 机制application-dev.yml和application-prod.yml。开发环境日志级别设为 DEBUG可以打印 MyBatis 执行的 SQL排查问题时非常直观。设置方式是在application.yml里加logging: level: com.company.project.mapper: debug这样 MyBatis 每次执行的 SQL 和参数都会打印到控制台配合 PageHelper 能看到实际拼接后的分页语句排查动态 SQL 拼接错误特别好用。5.4 上线前必须检查的若干安全项这类系统上线前有几处安全检测项必须具备。第一默认密码策略管理员初始密码必须强制修改第二登录接口要做防暴力破解限制比如同一个 IP 连续输错五次密码锁定一段时间第三前后端都要对输入长度做限制特别是文本域防止超长字符串写入数据库拖垮查询第四MySQL 用户不要用 root 连接应用单独建一个业务账号只授最小权限。6. 拿到源码之后如何制定合适的二开路线6.1 建议的源码阅读顺序拿到这套源码不要从第一个文件顺序读那样容易陷入细节。我建议按这条路径来先看数据库脚本。表结构能反映 80% 的业务设计把项目、任务、用户、角色这四组表的关系画出来整体脉络就清楚了。然后看后端application.yml确认端口、数据源配置、MyBatis 配置。接着看JwtInterceptor和统一返回封装Result这两个文件是几乎所有接口的前置和后置处理理解它们就等于拿到了理解接口的钥匙。再挑一个核心业务模块走完整链路比如任务列表查询Controller → Service → Mapper XML → 前端 API 封装 → Vue 页面。顺着这条链路读完基本就知道整套系统的代码风格和调用方式了。最后看工具类和公共配置比如加密工具、日期工具、异常码定义。这些模块不直接参与业务但后续开发新接口一定会用到。6.2 从这套系统继续扩展的方向如果要在这套源码基础上做二次开发最常见的有三个方向。第一个方向是工作流审批。项目管理系统通常需要审批流程项目立项审批、任务变更审批、项目结项审批。原生实现一个审批引擎成本很高可以集成 Flowable 或 Activiti 这类开源工作流引擎把审批节点和业务表关联起来。第二个方向是多租户改造。如果你打算把这套源码做成 SaaS 产品让多个企业客户各自管理自己的项目数据那就要在所有业务表加一个tenant_id字段并在查询层做统一拦截。这个过程涉及大量 SQL 改动用 MyBatis 拦截器在 StatementHandler 层面自动拼条件是最优解。第三个方向是任务看板和报表统计。基于现有任务表的状态和截止日期可以扩展出甘特图视图、人员负载报表、项目逾期率统计等功能。这些功能后端就是几条聚合 SQL前端用 ECharts 做可视化属于性价比很高的增量功能。我个人的经验是二开前先跑通一遍系统的完整流程从建项目到分配任务到完成任务用真实数据走一遍不要只跑完接口就然后才去看前端页面。因为企业管理系统里容易出问题的地方往往在数据流转和状态转换边界比如项目归档后任务没有同步关闭任务完成百分比没有更新到项目进度——这些逻辑需要端到端跑过才暴露得了。这套源码的价值恰恰是提供了一个可以随意折腾的基线让你不用从零开始踩那些建表、联调、部署的基础坑。