【Vue3 + SpringBoot】搭建企业日报管理saas系统
目录起因经过技术栈源码感谢项目介绍登录注册前端后端选择机构前端后端首页前端后端个人信息页前端后端角色管理前端后端用户管理前端后端机构管理前端后端组织架构前端后端项目模块管理前端后端日报模块消息模块前端后端起因每天上班的第一件事情,相信很多小伙伴跟我一样:写日报。要先想想昨天做了什么,然后还要写今天的计划安排,最后写完了还要排版(除非你啥也不整理就丢出去)……早就对这种日复一日的事情厌倦了,于是抄起
目录
可行性研究与计划
作为企业的员工,相信很多小伙伴们每天上班的第一件事情,就是需要写工作日报,来向上级汇报今天的工作计划、上个工作日的工作完成情况以及汇报工作中遇到的项目延误等问题。工作日报不仅是对工作的目标有一个更清晰的规划也是上级了解工作情况的重要的信息来源。
那么,仅仅通过简单的聊天工具发送日报,既不能直观方便地对每个人的每天工作日报进行一个归类和管理,也不方便上级查看历史的工作情况,从而对整个团队的工作不好进行一个整体的分析。
综合以上几点,那么一个企业可以管理所有员工的工作情况,进行方便分类管理和查阅是有必要的。
需求分析
首先系统面对的是企业所有的员工。以企业为独立单位,每个企业有对应的部门组织架构,每个组织架构中包含管理者。每个组织中的管理者可以收集和查阅对应组织以及他的下级发送的日报。譬如:
一个企业中包含技术部组织,下面有前端组、后端组、产品组、测试组,前端组包含一个组织管理者,他可以查看其组织的其他成员所发送的日报,但不能查看其他组织的成员发送的日报。而技术部组织的管理者则可以查看其下所有组织成员的发送的日报。并且要做到及时通知,否则将不能保证日报的发送及时性。
需要实现的功能:企业机构管理,用户管理,角色管理,权限管理,项目模块管理,组织管理,日报管理,导出Excel,消息模块等功能。
软件设计
前端架构设计
技术栈:Vue3 + vuex + vue-router + less + element-plus + axios + echats + mitt + websocket + webworker + canvas
使用了 vite 作为开发和打包工具
- — src:
- |— api:存放请求的接口;方便统一管理接口。
- |— assets:存放静态文件;图片、字体图标等。
- |— components:存放公共组件;方便组件复用。
- |— config:存放配置文件;请求域名、文件访问路径、websocket请求路径等。
- |— router:存放页面路由、路由拦截。
- |— store:存放公共状态管理。
- |— utils:存放通用的js文件,工具函数等。
- |— views:存放页面
在main.js 中,通过使用 vite 工具的 import.meta.globEager
自动导入api下的模块,挂载到app.config.globalProperties下,方便全局使用:
api/index.js:
const modulesFiles = import.meta.globEager("./*/*api.js");
for (const modulePath in modulesFiles) {
const path = modulePath.replace(/^\.\/(.*)\.api\.\w+$/, "$1");
const pathArr = path.split("/");
const moduleName = pathArr.length ? pathArr[pathArr.length - 1] : pathArr;
apiObj[moduleName] = modulesFiles[modulePath];
}
export default apiObj;
main.js:
// 引入api模块
import api from "./api/index.js";
app.config.globalProperties.$api = api;
引入自定义指令:
import directives from "./utils/directives";
Object.keys(directives).forEach((key) => {
app.directive(key, directives[key]);
});
引入权限编码:
import permission from "./utils/permission";
app.config.globalProperties.$permission = permission;
权限判断:
/**
* 是否是超级管理员
*
* @returns Boolean
*/
app.config.globalProperties.$isSupperAdmin = function () {
if (store.state.user.userInfo.isSupperAdmin) {
return true;
}
return false;
};
/**
* 是否有某个权限
*
* @param {*} permissionCode 权限编码
* @returns Boolean
*/
app.config.globalProperties.$hasPermission = function (permissionCode) {
if (store.state.user.userInfo.isSupperAdmin) {
return true;
}
let has = false;
const userPermission = store.state.user.userInfo.permissions;
if (userPermission && userPermission.includes(permissionCode)) {
has = true;
}
return has;
};
/**
* 是否有多个权限中至少一个
*
* @param {*} permissionCodeList 权限编码数组
* @returns Boolean
*/
app.config.globalProperties.$hasOneOfPermissions = function (
permissionCodeList
) {
if (store.state.user.userInfo.isSupperAdmin) {
return true;
}
let has = false;
const userPermission = store.state.user.userInfo.permissions;
if (
userPermission &&
permissionCodeList &&
Array.isArray(permissionCodeList)
) {
has = permissionCodeList.some((item) => userPermission.includes(item));
}
return has;
};
/**
* 是否有全部权限
*
* @param {*} permissionCodeList 权限编码数组
* @returns Boolean
*/
app.config.globalProperties.$havePermissions = function (permissionCodeList) {
if (store.state.user.userInfo.isSupperAdmin) {
return true;
}
let has = false;
const userPermission = store.state.user.userInfo.permissions;
if (
userPermission &&
permissionCodeList &&
Array.isArray(permissionCodeList)
) {
has = permissionCodeList.every((item) => userPermission.includes(item));
}
return has;
};
因为Vue3已经不再支持过滤器,这里写个全局方法代替过滤器的功能:
/**
* 全局过滤器
*/
app.config.globalProperties.$filters = {
// 性别过滤
sexFilter(val) {
let name = "";
switch (val) {
case "1":
name = "男";
break;
case "0":
name = "女";
break;
default:
name = "未填写";
}
return name;
},
};
数据库设计
使用MySQL作为数据库
后端架构设计
技术栈:springBoot + myBatisPlus + MySQL + easyexcel + websocket + mybatis-plus-generator
使用 swagger 作为接口文档工具
- — cofig :存放配置文件;接口登录拦截配置、跨域配置、mybatisPlus的自动填充配置等。
- — interceptor :存放拦截器类;对需要进行登录验证或者权限验证的接口,进行拦截并验证,通过才给放行。
- — modules :存放业务模块;对不同的业务模块进行分开管理,方便多人进行开发。
- |— controller :存放对应模块的前端控制器。
- |— dto : 存放对应模块的数据交换类。
- |— entity:存放对应模块的数据库表对应的实体类。
- |— enums:存放对应模块使用到的枚举类。
- |— exception:存放对应模块使用到的异常类。
- |— vo:存放返回给前端的数据类。
- |— service:存放对应模块的业务接口。
- |— | — impl:存放对应业务接口实现类。
- — util :存放工具类。
- — ws: 存放 wobsocket 模块相关。
通过实现 WebMvcConfigurer 接口,覆写 addInterceptors 和 addCorsMappings 配置请求拦截和跨域:
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authHandlerInterceptor)
// 拦截所有请求,通过判断是否有 @UserLoginToken 注解 决定是否需要登录
.addPathPatterns("/**");
// .excludePathPatterns("/user/**");
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedHeaders("Content-Type", "X-Requested-With", "accept,Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers", "login-token")
.allowedMethods("*")
.allowedOrigins("*")
.allowCredentials(true);
}
使用拦截器通过自定义 @UserLoginToken 注解来判断是否需要进行登录和权限验证:
@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface UserLoginToken {
/**
* 是否开启token验证 (默认开启)
* @return
*/
boolean required() default true;
/**
* 用户角色权限 (默认普通用户)
* @return
*/
PermissionEnum[] permission() default {};
}
@Log4j2
@Component
public class AuthHandlerInterceptor implements HandlerInterceptor {
@Autowired
IUserService userService;
@Autowired
IRoleService roleService;
@Autowired
TokenUtil tokenUtil;
@Autowired
TokenConfiguration tokenConfiguration;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("login-token");
// 如果不是映射到方法直接通过
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
Class clazz = handlerMethod.getBeanType();
// 1. 检查请求的【方法】中是否有 passtoken 注解,有则直接跳过认证
if (method.isAnnotationPresent(PassToken.class)) {
PassToken passToken = method.getAnnotation(PassToken.class);
if (passToken.required()) {
return true;
}
}
// 2. 检查请求的【方法】或者【类】中有没有需要用户权限 UserLoginToken 的注解
if (method.isAnnotationPresent(UserLoginToken.class) || clazz.isAnnotationPresent(UserLoginToken.class)) {
UserLoginToken userLoginToken;
if (method.isAnnotationPresent(UserLoginToken.class)) {
userLoginToken = method.getAnnotation(UserLoginToken.class);
} else {
userLoginToken = (UserLoginToken) clazz.getAnnotation(UserLoginToken.class);
}
if (userLoginToken.required()) {
// 执行 token 认证
if (null == token || "".equals(token.trim())) {
throw new TokenAuthExpiredException("需要登录才能访问,请登录!");
}
Map<String, Long> tokenDataMap = tokenUtil.parseToken(token);
Integer userId = Math.toIntExact(tokenDataMap.get("userId"));
long timeOfToken = System.currentTimeMillis() - tokenDataMap.get("timeStamp");
// 1.判断 token 是否过期
// 年轻 token
if (timeOfToken < tokenConfiguration.getYangToken()) {
// log.info("token 未过期且不需要刷新");
System.out.println("\t年轻 token 不需要刷新");
}
// 老年 token 就刷新 token
else if (timeOfToken >= tokenConfiguration.getYangToken() && timeOfToken <= tokenConfiguration.getOldToken()) {
System.out.println("\t老年 token 需要刷新");
response.addHeader("login-token", tokenUtil.getToken(userId));
}
// 过期 token 就返回 token 无效
else {
throw new TokenAuthExpiredException("token 已过期,请重新登录!");
}
// 根据 token 中的 userId 获取用户信息
UserEntity user = userService.getById(userId);
// 拦截不存在或已被停用的用户
if (ObjectUtil.isEmpty(user) || IsEnum.YES.equals(user.getDeleted())) {
throw new TokenAuthExpiredException("用户不存在,请重新登录");
}
// 把 用户信息 存在当前线程的缓存中
UserChacheFromToken.setUser(user);
// 超级管理员跳过权限验证
if (!ObjectUtil.isEmpty(user.getIsSupperAdmin()) && user.getIsSupperAdmin()) {
log.info("超级管理员跳过权限验证");
return true;
}
// 2.角色匹配
PermissionEnum[] needPermissionList = userLoginToken.permission();
System.err.println("\t当前接口需要的权限 ====>" + Arrays.toString(needPermissionList));
// 接口需要权限
if (needPermissionList.length > 0) {
// 因为角色权限是跟机构绑定,如果没有绑定机构,则优先提示机构未绑定
if (ObjectUtil.isEmpty(user.getOrgId())) {
throw new HasNoPermissionException("当前用户未关联机构,请先关联");
}
if (ObjectUtil.isEmpty(user.getRoleId())) {
throw new HasNoPermissionException("当前用户未关联角色,请联系管理员");
}
Role userRole = roleService.getById(user.getRoleId());
if (ObjectUtil.isEmpty(userRole)) {
throw new HasNoPermissionException("当前用户关联角色不存在,请联系管理员");
}
List<String> userPermissionList = userRole.getPermissions();
log.info("当前用户权限列表 ===>" + userPermissionList);
for (PermissionEnum needPermission: needPermissionList) {
for (String userPermission: userPermissionList) {
if (needPermission.getValue().equals(userPermission)) {
return true;
}
}
}
throw new HasNoPermissionException("抱歉,当前用户没有权限,请联系管理员");
}
return true;
}
}
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 执行结束后释放 ThreadLocal 资源防止oom(资源溢出)
UserChacheFromToken.removeUser();
}
}
编程开发
功能
登录注册
前端
- 做了记住密码的功能,加密处理之后把密码存入 localstorage中,取出时再进行解密。
登录成功之后存入
const loginMethod = () => {
$api.users
.login(formData)
.then((res) => {
ElMessage.success("登录成功");
window.localStorage.setItem(
REMEMBER_PASSWORD,
JSON.stringify({
username: formData.username || "",
password: encryptData(formData.password),
})
);
// 登录之后把用户信息存入 store 中
store.commit("user/login", res.data);
router.replace("/");
})
.catch((err) => {
console.error("login error: ", err);
})
.finally(() => {
submitLoading.value = false;
});
};
打开页面时取出:
// 取出
let rememberPassword = window.localStorage.getItem(REMEMBER_PASSWORD);
if (rememberPassword) {
rememberPassword = JSON.parse(rememberPassword);
formData.username = rememberPassword.username || "";
formData.password = decryptData(rememberPassword.password);
}
- 使用了动态背景图片,每次打开或者刷新会从图库里随机抽取一张图作为背景。
// 随机背景图
const pageBgIndex = getRandom(0, imgArr.length - 1, true);
const pageBgUrl = ref(imgArr[pageBgIndex]);
<div
ref="containerRef"
class="login-wrap"
:style="{
backgroundImage: `url(${pageBgUrl})`,
}"
>
- 加了个个人认为比较好看的烟花特效 。
// 烟花背景参数
const fireworksData = {
canvas: "",
ctx: "",
canvasWidth: "",
canvasHeight: "",
fworks: null,
lastTime: "",
newTime: "",
};
const initFireworks = (w, h) => {
fireworksData.fworks = new Fireworks(w, h);
fireworksData.lastTime = new Date();
animateStart();
};
const animateStart = () => {
timer = window.requestAnimFrame(animateStart);
fireworksData.newTime = new Date();
if (
fireworksData.newTime - fireworksData.lastTime >
800 + (fireworksData.canvasHeight - 2200) / 2
) {
fireworksData.fworks.color = Math.random() * 10 > 2 ? 1 : 0;
const x = getRandom(
fireworksData.canvasWidth / 10,
(fireworksData.canvasWidth * 9) / 10
);
const y = getRandom(50, (fireworksData.canvasHeight * 2) / 3);
const bigboom = fireworksData.fworks.createFireworks(
x,
fireworksData.canvasHeight,
x,
y
);
fireworksData.lastTime = fireworksData.newTime;
}
};
onMounted(() => {
fireworksData.canvas = document.getElementById("canvas");
fireworksData.canvas.width = fireworksData.canvasWidth =
containerRef.value.offsetWidth;
fireworksData.canvas.height = fireworksData.canvasHeight =
containerRef.value.offsetHeight;
initFireworks(fireworksData.canvasWidth, fireworksData.canvasHeight);
});
onUnmounted(() => {
if (scrollTimer) clearInterval(scrollTimer);
if (timer) window.cancelAnimationFrame(timer);
});
- 图形验证码,使用了canvas的一些绘图技巧。
<PuzzleVerification
v-model:modelValue="isVerificationShow"
:puzzleImgList="puzzleImgList"
@success="loginMethod"
@close="submitLoading = false"
/>
后端
- 由于是小项目没有使用 Shiro 和 Spring Security 框架,使用了比较简单的 JWT 生成 token 做的登录。
- 使用 UserLoginToken 注解 + 拦截器对请求进行是否登录的检验,配置了token过期时间和自动刷新的时间。
- 接口除了检验登录之外,还会对配有权限的接口进行校验角色权限,也是在拦截器中进行。
- 后期 token 和角色权限都可以使用 radis 缓存,这样不用频繁地去查库,减少IO消耗。
- 使用md5 + 盐值 进行密码的加密。
选择机构
前端
- 因为是个 saas 系统,所以登录完必须要选择机构才能使用,这给了任意人新建机构的入口。默认新建机构的人会得到机构管理员的权限。一个账号只能绑定一个机构。
- 使用了路由守卫,如果没有绑定机构是进入不了其他页面(除了登录)。
- 可以为加入机构的人设置问题验证(嘿嘿,是不是很熟悉
^_^
),选择开启或者关闭验证。 - 关闭验证之后其他用户可以直接进入关联你的机构。
后端
- 机构是单独作为一个表去维护,机构关联了很多的业务模块。包括角色,项目模块等等,每个机构都不同,需要区分。
- 创建机构会初始化一些必要的数据,譬如该机构的初始化角色……
首页
前端
- 首页在原来的基础上是没有太多的改动的,中间代办事项那里重新使用 vxe-table 重构了一下,这里写了个 webworker 线程,原本打算根据时间去使用子线程去监听未做的事情,虽然代码基本实现了,但是功能没有用到,因为感觉前端做这个意义不大。
- 左侧菜单栏做了权限隐藏控制,普通用户没有对应的查看权限即不会显示对应菜单。
- 左下方做了一个精小玲珑的小时钟,嘿嘿,感觉挺有意思
^_^
。 - 菜单的折叠状态都充分地使用了 vuex + sessionStorage 进行缓存处理(又是一个小细节……)。
- 顶部tag标签栏,在原来的基础上重新进行了封装,增加了左右滑动按钮控制。
后端
- 增加了登录时间记录,和根据登录的ip查询归属地,记录登录地点(这个可以做个日志记录,暂时没有做)。
- 消息数量统计,当前一周的日报提交数量统计和当前年份的每个月的日报提交数量统计。
个人信息页
前端
- 编辑个人信息,点编辑按钮进入编辑,如果修改了,没有保存,会回复原始状态。
- 上传头像,做了一些限制,头像过大(超过1M)不能上传,尽管后端已经已经把质量压缩到了0.2
^_^
,但是谁会嫌自己的服务器硬盘够大呢? - 这里显示了当前用户的机构信息,机构管理员还可以编辑机构信息,可以修改验证等等。
- 修改密码,做了一个密码强度的验证,规则嘛,自己试试看
^_^
~
后端
- 用户信息的修改保存。
- 机构信息的修改保存。
- 文件的上传处理,保存文件流。
角色管理
前端
- 可以新建角色,授权,删除角色等操作。
- 默认的角色不可被删除。
后端
- 角色权限目前我设计的是使用的是枚举来控制的,针对每一个接口或者按钮都可以做到权限的控制。
- 具体可看代码,不过多介绍。
用户管理
前端
- 使用 vxe-table 的好处之一在于方便进行列筛选,可以控制哪些列需要进行展示,防止表格列过多的横向拖动。
- 用户列表只对有查看用户列表权限的人才开放,可以进行修改用户基本信息,授权角色,重置密码,设为停用等操作。
后端
- 用户列表的条件查询,对应的修改信息、授权、停用等接口。
机构管理
前端
- 机构管理只有超级管理员才有此查看权限。
- 可以修改、设为不可加入、停用、删除等操作。
后端
- 机构列表的查询修改,设为不可加入、停用、删除等接口。
组织架构
前端
- 通过递归组件实现了这个组织架构图的功能。
- 可以不限层级地添加组织和人员,每个组织可以设置一名组织管理员,拥有对应角色的权限。
- 下拉菜单实现了按钮级权限。
后端
- 通过 parentId 关联组织数据,形成树型结构数据,通过递归遍历查询数据。当前数据较少,也可以一次查出当前机构所有组织数据,再进行数据处理。
项目模块管理
前端
- 写日报必须要告诉工作的具体项目模块,所以得有一个列表。
- 多个根节点的树型数据,采用树型数据列表展示,之前使用懒加载,但实际使用效果不怎么好,取消了。
后端
- 通过一次查出该机构所有项目模块数据,再进行树型数据的处理。
日报模块
发布日报
可以发布今天的日报
我的日报
可以根据日期发布日报,左下角显示当前的周数,右上角的左右箭头可以切换周数。
点击不同星期,展现不同颜色,每一天对应一种颜色,代表每天不一样的心情^_^
~
我的日报-新建
发布日报必须填写姓名,否则不知道是谁。这里单独使用了一个字段保存,避免影响到昵称等。
我的日报-填写姓名
这里跳转过来做了一个提示动画,又是一个小细节^_^
~
我的日报-新建-选择模块
根据模块管理配置的级联选择
我的日报-编辑
快捷拖动进度条选择进度
发送日报
自动获取今天和上个工作日的日报,可以取消掉不发送的日报
组长可以自动获取到所有组员的日报,同样可以编辑处理
已发送日报管理
可查看历史发送记录,每天只能发送一次哦,只可以撤回今天的。这里做了一个复制的功能,直接帮助排版好,粘贴到聊天工具也是可以的哦^_^
~
已发送日报管理-折叠
我的日报-一键添加周报
可以自动获取本周一到今天的所有日报记录,添加到周报列表
我的日报-周报数据预览
预览一周的日报,点击导出,可以导出Excel文件
日报管理列表
这里更方便的看到自己的日报记录
发送日报-通知
组员发送和撤回日报,组长会受到一个实时的消息通知
日报汇总
这里组长可以看到所有组员和下级组员的已发送的日报,组员可以看到自己组员发送的日报,再也不用一个一个去问了
消息模块
前端
- 前端使用了stompjs 和 sockjs.js 模块实现了websoket通信。
- 采用消息订阅的方式接收服务端转发的消息。
- 目前消息没有做详情,只是简单做了已读。
后端
- 使用 spring-boot-starter-websocket 模块实现 socket 通信。
- 使用 simpMessagingTemplate 给指定用户发送消息。
- 也可以使用 Netty 去实现更多的功能。
预览
线上地址:
https://workreport.yunfengzhijia.cn/
源码
gitee:
前端: https://gitee.com/Kevin-269581661/work-report-front
后端:https://gitee.com/Kevin-269581661/work-report-svc
感谢
也是在前人的肩膀上,经过了多番实践和创造才得来的成果,虽然仍然还有很多可以继续改进的。
如果这篇文章有帮助到您,请简单给个赞吧,谢谢~
更多推荐
所有评论(0)