SpringBoot + Vue 前后端分离项目实战

前言
前后端分离架构已成为企业级应用开发的主流选择。本文将通过一个完整的企业管理系统实战项目,详细介绍如何使用 SpringBoot + Vue 技术栈,实现权限管理、工作流引擎和报表系统三大核心功能。

项目特色
- 前后端分离:RESTful API 设计,便于扩展和维护
- RBAC 权限模型:细粒度的权限控制体系
- Flowable 工作流:可视化流程设计与执行
- 动态报表:灵活配置的数据可视化方案
一、项目背景与技术选型
1.1 技术栈总览
| 层次 | 技术选型 | 说明 |
|---|
| 前端框架 | Vue 3 + TypeScript | 渐进式框架 |
| UI 组件库 | Element Plus | 企业级组件库 |
| 状态管理 | Pinia | Vue 3 官方推荐 |
| 后端框架 | Spring Boot 2.7 | Java 微服务框架 |
| ORM 框架 | MyBatis-Plus | 持久层增强 |
| 数据库 | MySQL 8.0 | 关系型数据库 |
| 缓存 | Redis 6.0 | 高性能缓存 |
| 工作流引擎 | Flowable 7.0 | 开源 BPMN 平台 |
| 报表工具 | ECharts + UReport2 | 数据可视化 |
1.2 项目结构
enterprise-system/
├── enterprise-admin/
│ ├── src/
│ │ ├── api/
│ │ ├── assets/
│ │ ├── components/
│ │ ├── views/
│ │ ├── router/
│ │ ├── store/
│ │ └── utils/
│ └── package.json
├── enterprise-server/
│ ├── src/main/java/com/enterprise/
│ │ ├── controller/
│ │ ├── service/
│ │ ├── mapper/
│ │ ├── entity/
│ │ ├── dto/
│ │ ├── config/
│ │ └── security/
│ └── pom.xml
└── docs/
二、系统架构设计
2.1 整体架构图
- 前端层: Vue 3 应用,Element Plus UI, Vue Router, Pinia Store
- 网关层: Nginx 反向代理,API Gateway
- 应用层: 认证服务,用户服务,权限服务,工作流服务,报表服务
- 数据层: MySQL 数据库,Redis 缓存,Flowable DB
2.2 数据库设计
ER 图
USER (id, username, password, email, phone, create_time)
has -> USER_ROLE
ROLE (id, role_name, role_code, description)
belongs -> ROLE_PERMISSION
PERMISSION (id, permission_name, resource_type, resource_url, permission_code)
WORKFLOW_INSTANCE (id, instance_id, definition_id, starter_id, status)
WORKFLOW_DEFINITION (id, process_key, process_name, bpmn_xml, version)
三、权限管理模块
3.1 RBAC 权限模型
- 用户 分配 角色
- 角色 拥有 权限
- 权限 关联 资源 (菜单、按钮、接口、数据)
3.2 Spring Security + JWT 实现
安全配置类
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Autowired
private JwtRequestFilter jwtRequestFilter;
@Autowired
private UserDetailsService jwtUserDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.csrf().disable()
.exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests()
.antMatchers("/api/auth/**", "/api/public/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
.antMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
}
}
JWT 工具类
@Component
public class JwtTokenUtil {
private static final String SECRET = "enterprise-secret-key-2024";
private static final long EXPIRATION = 86400000;
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put("username", userDetails.getUsername());
return createToken(claims, userDetails.getUsername());
}
private String createToken(Map<String, Object> claims, String subject) {
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
.signWith(SignatureAlgorithm.HS512, SECRET)
.compact();
}
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
public String extractUsername(String token) {
extractClaims(token).getSubject();
}
Claims {
Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
}
Boolean {
extractClaims(token).getExpiration().before( ());
}
}
权限注解使用
@RestController
@RequestMapping("/api/user")
public class UserController {
@PreAuthorize("hasRole('USER')")
@GetMapping("/profile")
public Result<UserProfile> getProfile() {
return Result.success(userService.getProfile());
}
@PreAuthorize("hasAuthority('USER_READ')")
@GetMapping("/{id}")
public Result<User> getUserById(@PathVariable Long id) {
return Result.success(userService.getById(id));
}
@PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
@PutMapping("/{id}")
public Result<Void> updateUser(@PathVariable Long id, @RequestBody UserDTO userDTO) {
userService.updateUser(id, userDTO);
return Result.success();
}
}
3.3 前端权限控制
路由守卫
import router from './index'
import { useUserStore } from '@/store/user'
import { getToken } from '@/utils/auth'
import { ElMessage } from 'element-plus'
const whiteList = ['/login', '/404', '/403']
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
const hasToken = getToken()
if (hasToken) {
if (to.path === '/login') {
next({ path: '/' })
} else {
if (userStore.userId) {
next()
} else {
try {
await userStore.getUserInfo()
await userStore.generateRoutes()
next({ ...to, replace: true })
} catch (error) {
await userStore.logout()
ElMessage.error()
()
}
}
}
} {
(whiteList.(to.)) {
()
} {
()
}
}
})
自定义权限指令
import type { Directive, DirectiveBinding } from 'vue'
import { useUserStore } from '@/store/user'
const permission: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const { value } = binding
const userStore = useUserStore()
const permissions = userStore.permissions
if (value && value instanceof Array && value.length > 0) {
const hasPermission = permissions.some((permission: string) => {
return value.includes(permission)
})
if (!hasPermission) {
el.parentNode?.removeChild(el)
}
} else {
throw new Error('需要权限!如 v-permission="[\'user:add\']"')
}
}
}
export default permission
import permission from
app.(, permission)
组件中使用
<template>
<div>
<!-- 有删除权限时显示删除按钮 -->
<el-button v-permission="['user:delete']" type="danger" @click="handleDelete"> 删除 </el-button>
<!-- 多个权限满足其一即可 -->
<el-button v-permission="['user:edit', 'user:audit']" type="primary" @click="handleEdit"> 编辑 </el-button>
</div>
</template>
四、工作流引擎集成
4.1 Flowable 集成配置
Maven 依赖
<dependencies>
<dependency>
<groupId>org.flowable</groupId>
<artifactId>flowable-spring-boot-starter</artifactId>
<version>7.0.0</version>
</dependency>
<dependency>
<groupId>org.flowable</groupId>
<artifactId>flowable-rest</artifactId>
<version>7.0.0</version>
</dependency>
</dependencies>
配置文件
flowable:
database-schema-update: true
database-type: mysql
async-executor-activate: true
async-history-enabled: true
process:
definition-cache-limit: 100
mail:
server:
host: smtp.example.com
port: 587
username: [email protected]
password: password
rest:
app:
enable: true
4.2 流程定义服务
@Service
public class WorkflowService {
@Autowired
private ProcessEngine processEngine;
@Autowired
private RuntimeService runtimeService;
@Autowired
private TaskService taskService;
@Autowired
private HistoryService historyService;
public String deployProcess(String processName, MultipartFile bpmnFile) {
Deployment deployment = processEngine.getRepositoryService().createDeployment()
.name(processName)
.addInputStream(bpmnFile.getOriginalFilename(), bpmnFile.getInputStream())
.deploy();
return deployment.getId();
}
public String startProcess(String processKey, Map<String, Object> variables) {
ProcessInstance instance = runtimeService.startProcessInstanceByKey(processKey, variables);
return instance.getId();
}
public void completeTask(String taskId, Map<String, Object> variables) {
taskService.complete(taskId, variables);
}
public List<TaskDTO> getUserTasks(String userId) {
List<Task> tasks = taskService.createTaskQuery()
.taskAssignee(userId)
.orderByTaskCreateTime()
.desc()
.list();
return tasks.stream().map(::convertToDTO).collect(Collectors.toList());
}
List<HistoricActivityDTO> {
List<HistoricActivityInstance> activities = historyService
.createHistoricActivityInstanceQuery()
.processInstanceId(processInstanceId)
.orderByHistoricActivityInstanceStartTime()
.asc()
.list();
activities.stream().map(::convertToDTO).collect(Collectors.toList());
}
List<String> {
runtimeService.getActiveActivityIds(processInstanceId);
}
}
4.3 请假审批流程示例
BPMN 流程定义 (leave-request.bpmn20.xml)
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:flowable="http://flowable.org/bpmn"
targetNamespace="Examples">
<process id="leaveRequest" name="请假审批流程" isExecutable="true">
<startEvent id="startEvent" name="提交申请"/>
<userTask id="fillLeaveForm" name="填写请假单" flowable:assignee="${applicant}">
<extensionElements>
<flowable:taskListener event="create" class="com.enterprise.workflow.listener.LeaveTaskListener"/>
</extensionElements>
</userTask>
<exclusiveGateway id="checkDaysGateway" name="判断天数"/>
${leaveDays <= 3}
${leaveDays > 3 && leaveDays <= 7}
${leaveDays > 7}
${approved == true}
${approved == false}
4.4 前端流程图组件
<template>
<div>
<div ref="bpmnCanvas"></div>
<!-- 当前任务高亮 -->
<div>
<el-tag>当前环节:{{ currentTaskName }}</el-tag>
<el-tag type="success">处理人:{{ currentAssignee }}</el-tag>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import BpmnJS from 'bpmn-js/lib/NavigatedViewer'
import 'bpmn-js/dist/assets/diagram-js.css'
import 'bpmn-js/dist/assets/bpmn-font/css/bpmn.css'
interface Props {
processInstanceId: string
xml: string
activeActivityIds: string[]
}
const props = defineProps<Props>()
const bpmnCanvas = ref<HTMLElement>()
const currentTaskName = ref('')
const currentAssignee = ref('')
let bpmnViewer: BpmnJS | null = null
onMounted(async () => {
if (bpmnCanvas.value) {
bpmnViewer = new BpmnJS({ container: bpmnCanvas.value })
try {
await bpmnViewer.importXML(props.xml)
// 高亮当前活动节点
const canvas = bpmnViewer.get('canvas')
const elementRegistry = bpmnViewer.get('elementRegistry')
props.activeActivityIds.forEach(id => {
const element = elementRegistry.get(id)
if (element) {
canvas.addMarker(id, 'highlight')
}
})
} catch (error) {
console.error('流程图渲染失败:', error)
}
}
})
</script>
<style>
.bpmn-canvas {
height: 600px;
border: 1px solid #ddd;
}
.highlight {
fill: #67C23A !important;
stroke: #67C23A !important;
}
</style>
五、报表系统实现
5.1 报表系统架构
- 配置层: 报表配置,数据源配置,报表设计器
- 引擎层: 报表引擎,数据查询,数据处理,格式转换
- 展示层: 前端展示,ECharts 图表,数据表格,导出功能
- 数据层: MySQL, API 接口,Excel 导入
5.2 动态报表服务
@Service
public class ReportService {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private ReportConfigMapper reportConfigMapper;
public List<Map<String, Object>> executeQuery(Long reportId, Map<String, Object> params) {
ReportConfig config = reportConfigMapper.selectById(reportId);
String sql = replaceParams(config.getSqlTemplate(), params);
return jdbcTemplate.queryForList(sql);
}
public ChartDataVO generateChartData(Long reportId, Map<String, Object> params) {
ReportConfig config = reportConfigMapper.selectById(reportId);
List<Map<String, Object>> data = executeQuery(reportId, params);
ChartDataVO chartData = new ChartDataVO();
chartData.setType(config.getChartType());
if ("bar".equals(config.getChartType()) || "line".equals(config.getChartType())) {
List<String> xAxis = new ArrayList<>();
List<BigDecimal> yAxis = new ArrayList<>();
for (Map<String, Object> row : data) {
xAxis.add(row.get(config.getXAxisField()).toString());
yAxis.add((BigDecimal) row.get(config.getYAxisField()));
}
chartData.setXAxis(xAxis);
chartData.setSeries(Collections.singletonList( (, yAxis)));
} (.equals(config.getChartType())) {
List<PieDataVO> pieData = <>();
(Map<String, Object> row : data) {
pieData.add( (
row.get(config.getNameField()).toString(),
((BigDecimal) row.get(config.getValueField())).doubleValue()));
}
chartData.setData(pieData);
}
chartData;
}
IOException {
List<Map<String, Object>> data = executeQuery(reportId, params);
response.setContentType();
response.setCharacterEncoding();
response.setHeader(, );
EasyExcel.write(response.getOutputStream()).sheet().doWrite(data);
}
String {
template;
(Map.Entry<String, Object> entry : params.entrySet()) {
result = result.replace( + entry.getKey() + , String.valueOf(entry.getValue()));
}
result;
}
}
5.3 报表配置实体
@Data
@TableName("sys_report_config")
public class ReportConfig {
@TableId(type = IdType.AUTO)
private Long id;
private String reportName;
private String reportType;
private String dataSourceType;
private String sqlTemplate;
private String chartType;
private String xAxisField;
private String yAxisField;
private String nameField;
private String valueField;
private String paramConfig;
private LocalDateTime createTime;
}
5.4 前端报表组件
<template>
<div>
<!-- 查询条件 -->
<el-form :model="queryParams" inline>
<el-form-item v-for="param in paramList" :key="param.name" :label="param.label">
<el-input v-if="param.type === 'input'" v-model="queryParams[param.name]" />
<el-date-picker v-else-if="param.type === 'date'" v-model="queryParams[param.name]" type="daterange" />
<el-select v-else-if="param.type === 'select'" v-model="queryParams[param.name]">
<el-option v-for="opt in param.options" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadReport">查询</el-button>
<el-button @click="exportReport">导出</el-button>
</el-form-item>
</el-form>
<!-- 图表展示 -->
<div v-if="reportConfig.reportType === 'chart'" ref="chartRef"></div>
<!-- 表格展示 -->
<el-table v-else :data="tableData" border>
<el-table-column v-for="col in columns" :key="col.prop" :prop="col.prop" :label="col.label" />
</el-table>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
import { getReportData, exportReportData } from '@/api/report'
interface Props {
reportId: number
}
const props = defineProps<Props>()
const chartRef = ref<HTMLElement>()
const chartInstance = ref<echarts.ECharts>()
const queryParams = ref<Record<string, any>>({})
const paramList = ref<any[]>([])
const reportConfig = ref<any>({})
const tableData = ref<any[]>([])
const columns = ref<any[]>([])
onMounted(async () => {
// 加载报表配置
const config = await getReportConfig(props.reportId)
reportConfig.value = config
paramList.value = JSON.parse(config.paramConfig || '[]')
// 初始化图表
if (config.reportType === 'chart' && chartRef.value) {
chartInstance.value = echarts.init(chartRef.value)
}
// 加载初始数据
await loadReport()
})
onUnmounted(() => {
chartInstance.value?.dispose()
})
async function loadReport() {
const data = await getReportData(props.reportId, queryParams.value)
if (reportConfig.value.reportType === 'chart') {
renderChart(data)
} else {
tableData.value = data.list
columns.value = data.columns
}
}
function renderChart(data: any) {
const option = generateChartOption(reportConfig.value.chartType, data)
chartInstance.value?.setOption(option)
}
function generateChartOption(type: string, data: any) {
const baseOption = {
tooltip: { trigger: 'axis' },
legend: { data: data.series?.map((s: any) => s.name) },
}
if (type === 'bar' || type === 'line') {
return {
...baseOption,
xAxis: { type: 'category', data: data.xAxis },
yAxis: { type: 'value' },
series: data.series?.map((s: any) => ({ name: s.name, type: type, data: s.data }))
}
} else if (type === 'pie') {
return {
tooltip: { trigger: 'item' },
series: [{ type: 'pie', data: data.data }]
}
}
return baseOption
}
async function exportReport() {
await exportReportData(props.reportId, queryParams.value)
}
</script>
<style scoped>
.chart {
height: 500px;
margin-top: 20px;
}
</style>
5.5 销售统计报表示例
[图表:2025 年季度销售占比]
六、核心代码实现
6.1 统一响应格式
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {
private Integer code;
private String message;
private T data;
private Long timestamp;
public static <T> Result<T> success(T data) {
return new Result<>(200, "操作成功", data, System.currentTimeMillis());
}
public static <T> Result<T> success() {
return success(null);
}
public static <T> Result<T> error(String message) {
return new Result<>(500, message, null, System.currentTimeMillis());
}
public static <T> Result<T> error(Integer code, String message) {
return new Result<>(code, message, null, System.currentTimeMillis());
}
}
6.2 全局异常处理
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
log.error("业务异常:{}", e.getMessage());
return Result.error(e.getCode(), e.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidationException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
log.error("参数校验失败:{}", message);
return Result.error(400, message);
}
@ExceptionHandler(AccessDeniedException.class)
public Result<Void> handleAccessDeniedException(AccessDeniedException e) {
log.error("权限不足:{}", e.getMessage());
return Result.error(403, "权限不足");
}
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常", e);
return Result.error("系统错误,请联系管理员");
}
}
6.3 MyBatis-Plus 配置
@Configuration
@MapperScan("com.enterprise.mapper")
public class MyBatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
@Bean
public MetaObjectHandler metaObjectHandler() {
return new MetaObjectHandler() {
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, , LocalDateTime.class, LocalDateTime.now());
}
};
}
}
七、部署与运维
7.1 Docker 容器化部署
后端 Dockerfile
FROM openjdk:11-jre-slim
LABEL maintainer="enterprise-system"
WORKDIR /app
# 添加 JAR 文件
COPY target/enterprise-server.jar app.jar
# 设置时区
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# 暴露端口
EXPOSE 8080
# 启动应用
ENTRYPOINT ["java", "-jar", "-Xms512m", "-Xmx1024m", "app.jar"]
Docker Compose 编排
version: '3.8'
services:
mysql:
image: mysql:8.0
container_name: enterprise-mysql
environment:
MYSQL_ROOT_PASSWORD: root123456
MYSQL_DATABASE: enterprise_db
ports:
- "3306:3306"
volumes:
- mysql-data:/var/lib/mysql
- ./sql:/docker-entrypoint-initdb.d
networks:
- enterprise-network
redis:
image: redis:6.2-alpine
container_name: enterprise-redis
ports:
- "6379:6379"
volumes:
- redis-data:/data
networks:
- enterprise-network
backend:
build: ./enterprise-server
container_name: enterprise-backend
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL:
7.2 CI/CD 流程
CI/CD 流程:开发提交代码 -> Gitlab 触发 -> Maven 构建 -> 单元测试 -> 测试通过 -> Docker 构建 -> 推送镜像 -> 部署到测试环境 -> 集成测试 -> 测试通过 -> 部署到生产环境 -> 健康检查
7.3 Nginx 配置
upstream backend {
server backend:8080;
}
server {
listen 80;
server_name your-domain.com;
# 前端静态资源
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# API 代理
location /api/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 文件上传大小限制
client_max_body_size 100M;
# Gzip 压缩
gzip on;
gzip_types text/plain text/css application/json application/javascript;
}
八、总结
项目技术要点
| 模块 | 核心技术 | 难点 |
|---|
| 权限管理 | Spring Security + JWT | 细粒度权限控制、动态路由 |
| 工作流 | Flowable BPMN | 流程设计、复杂网关、任务监听 |
| 报表系统 | ECharts + 动态 SQL | 灵活配置、大数据量性能 |
学习路径
Java 基础 -> Spring Boot -> MyBatis-Plus -> Spring Security -> 前后端分离 -> 工作流引擎 -> 报表系统
参考文档