SpringBoot + Vue 前后端分离项目实战:权限管理、工作流与报表系统
介绍基于 SpringBoot 和 Vue 的前后端分离企业级管理系统实战。涵盖 RBAC 权限模型实现、Flowable 工作流引擎集成及动态报表系统构建。技术栈包括 Spring Security、JWT、MyBatis-Plus、Vue 3、Pinia 等。详细阐述了安全配置、流程定义、数据可视化及 Docker 部署方案,提供核心代码示例与架构设计,适用于中大型企业管理系统的开发参考。

介绍基于 SpringBoot 和 Vue 的前后端分离企业级管理系统实战。涵盖 RBAC 权限模型实现、Flowable 工作流引擎集成及动态报表系统构建。技术栈包括 Spring Security、JWT、MyBatis-Plus、Vue 3、Pinia 等。详细阐述了安全配置、流程定义、数据可视化及 Docker 部署方案,提供核心代码示例与架构设计,适用于中大型企业管理系统的开发参考。

前后端分离架构已成为企业级应用开发的主流选择。本文将通过一个完整的企业管理系统实战项目,详细介绍如何使用 SpringBoot + Vue 技术栈,实现权限管理、工作流引擎和报表系统三大核心功能。
| 层次 | 技术选型 | 说明 |
|---|---|---|
| 前端框架 | 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 | 数据可视化 |
enterprise-system/
├── enterprise-admin/ # 前端项目
│ ├── src/
│ │ ├── api/ # 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/ # 项目文档
@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);
}
}
@Component
public class JwtTokenUtil {
private static final String SECRET = "enterprise-secret-key-2024";
private static final long EXPIRATION = 86400000; // 24 小时
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 {
// 需要 USER 角色
@PreAuthorize("hasRole('USER')")
@GetMapping("/profile")
public Result<UserProfile> getProfile() {
return Result.success(userService.getProfile());
}
// 需要 USER_READ 权限
@PreAuthorize("hasAuthority('USER_READ')")
@GetMapping("/{id}")
public Result<User> getUserById(@PathVariable Long id) {
return Result.success(userService.getById(id));
}
// 需要 ADMIN 角色或当前用户 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();
}
}
// router/guard.ts
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.)) {
()
} {
()
}
}
})
// directives/permission.ts
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
<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>
<dependencies>
<!-- Flowable 核心依赖 -->
<dependency>
<groupId>org.flowable</groupId>
<artifactId>flowable-spring-boot-starter</artifactId>
<version>7.0.0</version>
</dependency>
<!-- Flowable REST API -->
<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 API 配置
rest:
app:
enable: true
@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(this::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);
}
}
<?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="判断天数"/>
<!-- 部门主管审批(3 天以内) -->
${leaveDays <= 3}
${leaveDays > 3 && leaveDays <= 7}
${leaveDays > 7}
${approved == true}
${approved == false}
<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>
@Service
public class ReportService {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private ReportConfigMapper reportConfigMapper;
/**
* 执行动态 SQL 查询
*/
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;
}
}
@Data
@TableName("sys_report_config")
public class ReportConfig {
@TableId(type = IdType.AUTO)
private Long id;
private String reportName;
private String reportType; // table-表格,bar-柱状图,line-折线图,pie-饼图
private String dataSourceType; // database, api
private String sqlTemplate;
private String chartType;
private String xAxisField;
private String yAxisField;
private String nameField;
private String valueField;
private String paramConfig;
private LocalDateTime createTime;
}
<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>
| 第一季度 | 第二季度 | 第三季度 | 第四季度 |
|---|---|---|---|
| 30% | 25% | 25% | 20% |
@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());
}
}
@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("系统错误,请联系管理员");
}
}
@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, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
};
}
}
FROM openjdk:11-jre-slim
LABEL maintainer="enterprise-system"
WORKDIR /app
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"]
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: jdbc:mysql://mysql:3306/enterprise_db?useSSL=false
SPRING_DATASOURCE_USERNAME:
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;
}
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 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 -> 前后端分离 -> 工作流引擎 -> 报表系统

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online