提到小微企业的HR管理,我脑子里最先蹦出来的不是那些高大上的专业术语,而是一个我从项目里救回来的Excel表格。行政兼人事的同事每个月月底都得熬夜核对考勤,一张请假条一张请假条地翻出来手工扣减,再用VLOOKUP逐行补工资表,表格里还藏着十几个版本的历史记录。这个场景我见过不止一次,所以后来才动手做了这套基于Spring Boot + Java + Vue 的小微企业人事管理系统。它不是那种功能堆到天花板的HR SaaS,而是真正贴合几十人规模公司、能在一台2核4G服务器上跑得很稳的小系统。这篇就把当时从需求梳理到上线维护的完整经验拆开讲,适合正在纠结“要不要自己写一套人事系统”的技术负责人,也适合想找一个完整前后端分离项目练手的开发者。
1. 小微企业的Excel之痛:为什么不能直接买现成的HR系统
1.1 三十人公司的人力资源日常
绝大多数小微企业的人事管理,并不是因为“没人管”,而是因为“一个人什么都要管”。我在一家三十人左右的贸易公司调研时,发现所谓的人事系统就是一台公用电脑上的Excel文件夹。每个月的考勤原始数据下载下来,要用数据透视表汇总,再把请假、加班、调休各种情况手工标注。老板临时要一份“近三个月各个部门的离职率”,行政同事要花一个下午才能拼出来。更麻烦的是,员工信息分散在入职登记表、社保表格、工资表好几份文件里,一个人改了手机号,其他文件不会自动同步。
这个阶段用人来补Excel的不足,看起来也能转,但只要公司规模从二十人涨到五十人,出错概率会呈指数上升。最典型的一次是某员工休假回来,发现工资少算了半天,行政在排班表里翻了一中午,最后发现是考勤导出数据里有两个人的打卡时间被合并单元格搞乱了。这种问题本质上不是Excel操作能力差,而是表格本身缺少数据校验、权限隔离和操作留痕。
1.2 市售HR系统的功能错位
小微企业不是没考虑过买现成的HR系统。市面上的SaaS产品功能确实很全:招聘、绩效、OKR、培训、薪酬核算、社保公积金代算,一应俱全。但小微企业真正高频使用的,通常只有员工档案、考勤、请假、工资条和简单的统计报表。订阅费按人头算,三十人的公司一年可能也要大几千,对于老板来说“好像也没干什么事,钱就花出去了”。更麻烦的是,很多系统的操作路径是按大公司HRBP的流程设计的,一个考勤异常要设置审批流、审批组、代理人,光配置就要半天。
我也见过一些公司为了省订阅费,买一套本地部署的旧版HR软件,但那些软件界面停留在上一个时代,数据存在本地虽然安心,却通常不开放源码级修改。老板想调整一个考勤规则,供应商要么报价,要么说“我们产品不支持”。这种封闭性让小微公司非常难受,因为小微企业的管理规则变化极快,今年还在用固定打卡,明年就可能变成弹性工作制。
1.3 自研系统的边界定义
所以自研这套系统的第一件事,不是写代码,而是明确边界:哪些功能做,哪些功能坚决不做。我划定的范围是实用且数据相对独立的五个模块:员工档案、部门岗位、考勤打卡、请假审批、简单的统计报表。绩效、招聘这种流程复杂且每个公司差异极大的模块,一开始就不碰;薪酬计算也先不做自动算税,只保留“应发工资导入+工资条发布”功能,避免陷入各地社保政策的泥潭。
边界清楚之后,技术难度一下子降下来了:数据模型不复杂,业务规则也不深,核心价值在于把“人”的信息管清楚、把考勤和请假的流程跑通。这也是为什么最终选择“Spring Boot + Java + Vue ”这个组合——它能用最小的团队、最快的速度,做出一套可交付、可维护、可长期演进的前后端分离项目,而不是一上来就引入一堆中间件,把系统重量堆到员工人数十倍以上。
2. 技术选型不跟风:Spring Boot + Vue的前后端分离怎么定下来
2.1 为什么坚持单体应用
做这种规模的人事管理系统,最忌讳的就是为了技术面子引入微服务。我有段时间也比较迷信Spring Cloud一套,后来发现几十个人的系统拆成网关、注册中心、多个服务,光运维成本就超过了业务价值。单体应用不仅部署简单,而且事务边界更容易控制——员工档案和部门在同一个服务里,改个部门,逻辑就是一个事务。Java生态在这个领域仍然是最稳的选择:Spring Boot 的自动配置能减少大量样板代码,配合 MyBatis-Plus 后,CRUD 几乎不用手写SQL,适合快速交付。
有人会问为什么不用Node或者Python写后端。说实话,这种内部管理系统对并发要求低,重点是数据严谨性和可维护性。Java的类型系统和优秀的事务管理,在多人同时更新员工信息、考勤记录插入时能提供更好的确定性。而前端用Vue,则是因为组件化开发和Element Plus这类现成后台组件库,能让我们用极短时间搭出整齐的管理界面,不需要从零手搓表格和弹窗。
2.2 后端技术栈明细与版本选择
我当时用的版本组合,是大量项目中验证过、踩坑比较少的:
| 技术件 | 选型 | 说明 |
|---|---|---|
| JDK | Java 8 或 17 | 老项目用Java 8最稳,新项目建议Java 17 |
| Spring Boot | 2.7.x | 稳定、资料多,也能平滑升级到3.x |
| ORM | MyBatis-Plus 3.5.x | 分页、代码生成、逻辑删除都很省事 |
| 数据库 | MySQL 8.0 | 性能足够,备份生态成熟 |
| 鉴权 | JWT + 拦截器 | 比Spring Security轻,几十人用户够用 |
| 导出 | EasyExcel | 阿里开源的Excel工具,能控制内存消耗 |
| 工具包 | Hutool | 日期、加密、Excel、常用工具一站式解决 |
Spring Boot 3.x在JDK 17基础上性能略有提升,但当时客户服务器上已经有JDK8环境,为了减少部署阻力,我选了2.7。这里建议新项目直接上JDK17+Spring Boot3,因为MyBatis-Plus、EasyExcel这些组件已经全面兼容,没必要守着老版本。
2.3 前端工程结构与目录规划
前端用的是Vue 3 + Vite + Pinia + Element Plus。Vite比Webpack快是常识,尤其在开发阶段,热更新几乎是秒级。项目结构我习惯这样分:
src/ api/ // 按模块拆分的接口请求 assets/ // 静态资源 components/ // 通用组件 layout/ // 后台整体布局 router/ // 路由配置 store/ // Pinia状态管理 utils/ // axios封装、权限指令、工具函数 views/ // 页面 permission.js // 全局路由守卫这套结构的核心逻辑是:视图层只负责渲染,所有请求都走api目录,所有状态都交给Pinia管理。页面一旦多了,最怕的是在组件里到处直接写axios,后面统一改接口地址或者加请求头就非常痛苦。工具的沉淀也很重要,比如日期格式化、字典转换这类函数,放到utils里统一维护,比每个页面复制一遍强得多。
3. 数据模型设计:把“人”和“事”拆成表
3.1 员工、部门、岗位三张核心表
人事系统的地基是员工表。员工一定属于某个部门,也一定有一个岗位,但同一个部门下可以有多个岗位,同一个岗位也可以跨部门存在,所以我设计了部门表和岗位表分开维护,员工表里用部门ID和岗位ID关联。字段设计上有一个容易被忽略的点:员工状态。入职、试用、离职不只是页面上的一个标签,它直接决定考勤统计和登录权限,所以必须有独立字段,状态变更时写操作日志。
employee 表大致长这样:
CREATE TABLE `employee` ( `id` bigint NOT NULL AUTO_INCREMENT, `emp_no` varchar(20) NOT NULL COMMENT '工号', `name` varchar(50) NOT NULL COMMENT '姓名', `gender` tinyint DEFAULT NULL COMMENT '性别 1男 2女', `mobile` varchar(20) DEFAULT NULL COMMENT '手机号', `dept_id` bigint DEFAULT NULL COMMENT '部门ID', `position_id` bigint DEFAULT NULL COMMENT '岗位ID', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1在职 2试用 3离职', `hire_date` date DEFAULT NULL COMMENT '入职日期', `leave_date` date DEFAULT NULL COMMENT '离职日期', `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;工号我建议用纯数字序列,不要用部门和入职日期拼接,因为部门调整后工号会变得没有规则。数据库中所有业务表都加deleted字段,配合MyBatis-Plus的逻辑删除注解,这样误删数据还能找回,不删记录只隐藏记录。
3.2 考勤与请假的数据流转
考勤模块的核心不是打卡记录本身,而是“日汇总”。每天把所有原始打卡记录按员工、日期聚合,生成一条考勤日报,包含上班时间、下班时间、考勤状态(正常、迟到、早退、缺卡、请假)。然后按月份汇总生成月度考勤表。这样做的好处是,老板查看统计报表时不需要去扫描原始打卡流水,报表查询效率高,也不会因为某个打卡设备导出的格式不同而影响上层业务。
请假表则要记录请假类型(事假、病假、年假、调休)、开始时间、结束时间、时长(按天或按小时)、审批状态。审批流程我做得比较务实:提交人 -> 部门负责人 -> 人事确认,三步而已。用一张 leave_apply 表加一条审批记录表即可,不需要引入Activiti工作流。小微企业的审批链相对固定,工作流引擎反而增加学习成本。
3.3 软删除、审计字段与事务边界
设计表时我把create_time、update_time这两个审计字段定为标配,并使用MyBatis-Plus的自动填充功能,在插入和更新时自动写入,不需要业务代码各自维护。员工档案、部门、岗位这些基础数据的修改操作都要记录操作日志。这里我建了一张简单的操作日志表:谁、在什么时候、修改了哪条数据、改前内容、改后内容。一开始项目比较紧张,只做了员工档案的字段级改动记录,后续发现部门调整导致所有下属员工的部门发生变化时,也需要追溯,所以日志表的范围应该是所有核心基础数据。
事务边界方面,批量导入员工信息和批量调整部门等操作,必须放在同一个事务里。举个例子,某次导入Excel时,前面一百条都成功了,第一百零一条手机号格式错误,如果不去catch整个事务,就会产生“部分员工已经入库,页面却提示失败”的脏数据。我用一个简单的@Transactional把批量操作包起来,任意一条失败就整体回滚,再由前端提示下载错误明细。虽然牺牲了一点“部分成功”的灵活度,但数据一致性在小系统里更重要。
4. 后端实现:登录鉴权、核心接口与Excel导出
4.1 JWT登录与一个小而够用的权限拦截器
有人会觉得Spring Security是标配,但对这种只有四五个角色、没有第三方登录需求的系统,自定义拦截器加JWT反而更清爽。登录接口先通过手机号/工号加密码校验用户,密码用BCrypt加密存入数据库,登录成功生成一个token,放进响应头。前端每次请求在axios拦截器里带上Authorization: Bearer token,后端拦截器统一校验。
拦截器的写法很直白:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equals(request.getMethod())) { return true; // 放行预检请求 } String token = request.getHeader("Authorization"); // 解析JWT,失败则返回401 return true; } }这里有个细节必须提醒:CORS配置里如果放行了所有接口,但拦截器没有放行OPTIONS请求,前端照样会报跨域。因为浏览器在发起真实请求前,会先发一个OPTIONS预检请求,后端如果在这个阶段返回401,后面什么都做不了。所以拦截器里第一条就要判断OPTIONS并放行。
权限方面,我没有做细粒度的菜单权限表,只做了角色编码,比如admin、manager、hr、employee,再用拦截器判断请求路径的前缀,如/api/employee/**要求角色为admin或hr。角色少的时候,这种方式比动态配置权限更直观,也更容易让使用方理解。
4.2 员工档案CRUD与分页查询的落地写法
员工列表页的核心接口是分页加条件查询。MyBatis-Plus的Page对象配合LambdaQueryWrapper,代码量非常少:
public Result page(EmployeeQuery query) { Page<Employee> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<Employee> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(StringUtils.isNotBlank(query.getName()), Employee::getName, query.getName()) .eq(query.getDeptId() != null, Employee::getDeptId, query.getDeptId()) .eq(query.getStatus() != null, Employee::getStatus, query.getStatus()) .orderByDesc(Employee::getCreateTime); employeeMapper.selectPage(page, wrapper); return Result.success(page); }这里需要把MyBatis-Plus的分页插件注入到容器里,否则selectPage查出来的所有记录根本不是分页效果,这一点后面专门讲坑。返回给前端时,员工列表里不能直接裸查所有字段,手机号这种敏感信息,在列表中最好做脱敏,比如只显示前3后4位;员工详情接口再返回完整手机号。这种细小的设计,在合规上能给企业减少不少风险。
4.3 Excel导出时最容易被忽略的内存问题
很多后台系统提供“导出全部员工”的功能,如果直接查询所有数据然后用POI写入Excel,几万条数据就会让内存吃紧。我用的是EasyExcel,它支持一行一行地写,配合MyBatis-Plus的流式查询,可以控制内存占用。具体做法是先从数据库查出最终页面上需要导出的数据集合,再到EasyExcel的write方法写入响应流。
导出时还需要注意响应头的设置,中文文件名要做URL编码,否则浏览器可能出现乱码:
String fileName = URLEncoder.encode("员工信息", "UTF-8").replaceAll("\\+", "%20"); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + fileName + ".xlsx");导出是典型的“看起来简单、做起来坑多”的功能。后来我把导出操作做成了异步任务,导出的文件先生成到临时目录,给前端返回下载链接。因为如果导出一万条数据,同步接口会让前端等十几秒,超出常见的超时时间。异步导出虽然增加了一点代码量,但用户体验完全不一样。
5. 前端实现:Vue3 + Element Plus把后台管理页面做精致
5.1 Axios封装与401统一处理
前端最基础的一层就是axios封装。我把所有请求都挂到一个实例上,设置基础地址和超时时间,请求拦截器里从Pinia拿token,响应拦截器里统一处理错误。尤其要处理两种状态:401表示登录失效,跳回登录页并清空本地存储;403表示没有权限,弹提示而不是白屏。
// utils/request.js const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000, }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) config.headers.Authorization = `Bearer ${token}` return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response?.status === 401) { localStorage.clear() router.push('/login') } return Promise.reject(error) } )做这个封装时,我最想强调的是“每个页面不要自己处理401”。如果没有统一处理,登录过期后用户可能看到一个空白接口报错,而不是被送回登录页。前后端分离项目里,这种体验问题很掉价。
5.2 员工列表页从零搭建的细节
员工列表页是整个系统的门面,也是我花时间最多的页面。顶部是搜索条件:姓名、部门、状态;中间是操作按钮:新增、导入、导出、批量离职;下方是表格和分页。Element Plus的表格、表单、分页组件都能直接用,但有几个细节值得注意。
表格的列不是越多越好,我默认只显示工号、姓名、部门、岗位、手机号(脱敏)、状态、入职时间,更多字段放到“详情”抽屉里。这样列表加载更快,也不会让屏幕显得拥挤。分页数据的处理,我用一个queryParams对象保存查询条件,每次点击搜索、重置、跳页都围绕这同一个对象操作,避免多个变量互相覆盖导致条件错乱。
新增弹窗里,部门用的是一棵级联树,岗位要根据所选部门动态过滤。这里有一个Vue3的常见问题:树选择组件绑定的值是数组,而部门表存的是单个deptId,提交前要判断到底选了哪一层节点,防止把父节点id直接保存进去。我在项目里用了一个递归函数,把树节点全部展平后再校验叶子节点,这个问题困扰过不少前端初学者。
5.3 路由与按钮级权限的前端控制
后端接口已经做了角色控制,前端为什么还要再做一层权限?主要目的不是安全,而是用户体验。如果员工登录后能看到“系统管理”的菜单,点击进去却报403,会让人觉得系统很蠢。前端权限我用的是动态路由方案:登录后根据角色拿到可访问的路由表,动态添加到router实例上;同时用Pinia保存按钮权限,写一个v-permission自定义指令,控制“新增”“删除”“导出”这类按钮是否显示。
// 自定义权限指令 app.directive('permission', { mounted(el, binding) { const required = binding.value const userStore = useUserStore() const hasPermission = userStore.permissions.includes(required) if (!hasPermission) el.parentNode?.removeChild(el) } })这个方案对数据安全没有决定作用,真正的安全屏障仍然在后端接口校验上,但它能避免让普通员工看到一堆用不了的功能入口。小系统的用户本来就不太适应复杂界面,少显示一点反而更清晰。权限控制从后端到前端做两层,也算是一种纵深防御。
6. 部署上线与日常维护:一台2核4G服务器足够吗
6.1 Docker Compose编排后端、前端与MySQL
交付这种系统,最省心的部署方式是用Docker Compose,把后端jar包、前端nginx、MySQL三个容器管在一起。前端项目构建后生成dist目录,放到nginx容器里做静态资源托管,后端API通过反向代理转发到Spring Boot容器。我写了一个docker-compose.yml,核心服务定义如下:
version: "3.8" services: mysql: image: mysql:8.0 container_name: hr-mysql environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: hr_system volumes: - ./mysql/data:/var/lib/mysql restart: always backend: build: ./backend container_name: hr-backend depends_on: - mysql environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/hr_system ports: - "8080:8080" restart: always nginx: image: nginx:1.24 container_name: hr-nginx ports: - "80:80" volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf - ./frontend/dist:/usr/share/nginx/html depends_on: - backend restart: alwaysnginx里的关键是把/api/的请求反向代理到backend:8080,同时try_files让前端路由在刷新时不会404。这套方案在2核4G的云服务器上跑过,日常三四十个人同时使用,后端接口响应时间基本都在几十毫秒,MySQL连接池配置20个上限足够。Spring Boot的JVM堆内存给到1GB,整个系统占用大概1.5GB内存,服务器还有余量跑监控程序。
6.2 数据备份与隐私保护的具体做法
系统上线后最重要的一件事是备份。人事数据一旦丢失,影响的不只是考勤,还有劳动合同和工资记录,所以必须有自动备份机制。我写了一个简单的cron脚本,每天凌晨用mysqldump导出全量SQL,保留最近30天,再通过rsync同步到另一台存储机或者对象存储。脚本核心只有三行:
mysqldump -u$MYSQL_USER -p$MYSQL_PASSWORD hr_system > /backup/hr_$(date +%Y%m%d).sql find /backup -name "*.sql" -mtime +30 -delete rsync -av /backup backup@backup-server:/backup/hr_system/隐私保护上,密码用BCrypt加密已经是基本操作,手机号在列表、导出Excel里要脱敏,身份证号要完全隐藏只保留后四位。如果公司有员工离职,账号不是直接删除,而是停用并保留历史数据,防止考勤和工资记录中间断档。权限上要严格控制“管理员”角色数量,最好只有老板和人事负责人有权限查看完整薪资数据。
6.3 性能与并发的小规模优化
不管系统多小,数据库连接池、线程池、缓存这几项还是要稍微配置一下。连接池不要用默认的10个连接,如果MySQL的连接数和用户数不匹配,高峰期会出现获取连接超时。改成20个,加上Spring Boot的Tomcat线程池最大线程数设为100,足够支撑两三百人的公司使用。缓存方面,员工、部门、岗位这类很少变化的数据,可以在员工列表查询时加入本地缓存,使用Caffeine设置5分钟过期,效果非常明显,原本每次列表加载都要查一次部门表,现在直接命中缓存。
不过缓存不是越多越好。考勤和请假这种实时性要求高的数据不要缓存,否则员工打完卡看不到当天记录,会被骂得很惨。另外MySQL的慢查询日志要开启,把超过1秒的SQL捞出来。我曾经发现某个统计报表的SQL因为日期字段没走索引,查询要两秒多,加一个联合索引后降到几十毫秒。小系统优化到“人操作起来没感觉”的程度就够了,不要为了技术KPI做无意义的微调。
7. 开发中踩过的真实坑:时区、分页、跨域、日期组件
7.1 MySQL时区导致考勤天数错一天
第一个大坑是时区。考勤系统里,员工的打卡时间是每天的关键数据。开发阶段,后端用的本机时间和MySQL是同一个时区,没发现问题。部署到云服务器后,有员工反馈考勤记录里的打卡日期总体往前错了一天。排查了半天,发现是后端连接MySQL的JDBC参数里没有设置时区,MySQL默认使用了服务器的UTC时区,而业务代码里用的是LocalDateTime,到了数据库被当成UTC时间存进去,取出来再转换就少了8小时。
这个问题验证起来也简单,打开数据库执行select now(),如果返回的时间和服务器本地时间差8小时,说明时区设置错了。最终在JDBC连接串里加上serverTimezone=Asia/Shanghai,并且在Spring Boot的配置里显式设置spring.jackson.time-zone=GMT+8。前端展示的日期格式化再统一走yyyy-MM-dd HH:mm:ss,彻底解决了错位问题。这个坑提醒我,只要是涉及时区的系统,第一件事就要把数据库时区、应用时区、前端时区三者拉齐。
7.2 MyBatis-Plus分页失效的根因
第二个坑是分页失效。员工列表第一版上线后,同事发现“下一页”按钮点了没反应,数据还是第一页的内容。我下意识以为是前端分页组件传参有问题,但看了控制台请求,pageNum和pageSize都传对了,接口返回的数据也是对的?仔细一查,才发现MyBatis-Plus的分页插件根本没有被注册到MyBatis的执行器里,导致selectPage实际执行成了普通查询,所有数据一次性返回,页面上的分页效果只是前端把当前页的数据切出来显示,看起来好像分页了,但实际上全量数据已经加载到内存。
解决办法是在配置类里显式添加分页插件:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这个问题不难解决,但排查时容易被前端的假分页误导。经验是出现分页异常,先看后端日志里有没有执行带 LIMIT 的SQL,如果没有,基本就是插件没配好。
7.3 CORS与拦截器的顺序博弈
跨域问题是我在前后端分离第一次联调时必踩的坑。开发环境前端vite在5173端口,后端在8080端口,请求跨域很正常。我一开始在后端配置了CORS,允许所有来源,前端却还是报错。后来发现问题不是CORS配置,而是拦截器先把OPTIONS预检请求拦住了。Spring处理CORS的顺序是先走HandlerInterceptor,如果拦截器拦截了OPTIONS,CORS的响应头就带不出来。
解决办法已经在前面说了:拦截器第一行判断request.getMethod().equals("OPTIONS")直接放行。这个细节表面上看很小,但如果不理解请求的生命周期,排查会花掉至少一晚上。生产环境同样会遇到,只不过浏览器本地开发最明显。所以我把这条直接写进了项目的开发规范,只要加拦截器,就必须放行OPTIONS。
7.4 Element Plus日期范围组件的值格式
最后一个坑发生在前端请假页面。Element Plus的日期范围选择器返回的绑定值,在不同版本里可能是数组或字符串组合。我在做请假申请时,原本希望拿到两个日期字符串['2024-05-01', '2024-05-03'],结果发现组件返回的是两个Date对象,直接传给后端时,JSON序列化成了时间戳,后端用@RequestBody接收时类型转换直接报错。
后来统一在提交前做了格式化,并规定接口入参为标准字符串。类似这种前后端类型不匹配的问题,在多语言技术栈里几乎是日常。我在utils里写了日期格式化函数,所有涉及日期传递的地方统一使用,不再依赖某个组件默认的行为。这也是为什么我坚持前后端分离项目的接口设计要“显式传参”,不用一些过于“智能”的隐式转换,越简单越可靠。
最后说几句实在话
如果让我重新做一遍这个项目,我会在一开始就把考勤规则的设计提得更靠前,比如迟到几分钟算迟到、每月允许几次漏打卡、调休怎么累积,这些看似简单的规则,业务上扯皮时远远比技术实现复杂。项目的技术选型其实没有太多炫技空间,Spring Boot + Vue 的组合之所以适合小微企业人事系统,是因为它把复杂藏在框架里,把简单留给业务。实际运行下来,最让我有成就感的是人事同事说“这个月终于不用加班对考勤了”,而不是代码写了多少行。这套经验基本都可以直接复制到其他类似的内部管理系统,比如简单OA、小型进销存。如果手头正遇到同样的需求,不用犹豫,从小而完整的功能边界开始,用这套技术栈稳稳做出来,远比一开始就追求大而全靠谱得多。