小型企业 HR 系统不需要从零开始造轮子,但需要清晰的工程化边界。本文以 Vue 3 技术栈为基础,结合 BambooHR 的功能抽象,给出一个可扩展的实现方案。核心目标不是复刻 BambooHR 的所有功能,而是快速搭建一个员工主数据、组织架构、审批流转和考勤统计都能跑通的内部系统。工程化重点包括目录分层、类型定义、状态管理和接口抽象,让后续迭代不至于失控。

项目初始化与目录结构设计
使用 Vite 初始化 Vue 3 项目并启用 TypeScript 是第一步。执行 npm create vite@latest hr-system -- --template vue-ts 后,安装 Vue Router、Pinia、Element Plus 和 Axios。目录结构不要按照默认的 components、views 随意堆放,而应按业务域划分模块。推荐采用 src/modules/employee、src/modules/approval、src/modules/attendance 的方式,每个模块内部包含自己的组件、服务、类型和 store。这样后续新增绩效、招聘等模块时不会互相干扰。
共享层放在 src/shared,包括通用组件、工具函数、HTTP 客户端和全局类型定义。环境变量通过 .env.development 与 .env.production 管理,使用 import.meta.env.VITE_API_BASE_URL 读取。下面的目录树展示了推荐结构,其中 api 层只负责请求和响应类型,不包含业务逻辑。
src/ ├── modules/ │ ├── employee/ │ │ ├── components/ │ │ ├── services/ │ │ ├── types/ │ │ └── store/ │ ├── approval/ │ └── attendance/ ├── shared/ │ ├── components/ │ ├── utils/ │ └── http/ ├── router/ ├── stores/ ├── App.vue └── main.ts
这里没有采用按文件类型划分的顶层目录,例如所有页面都放在 views,所有组件都放在 components。对于 HR 系统这类业务边界清晰的中后台项目,按模块划分能显著降低跨模块改动的风险。初始化时还可以通过 ESLint 和 Prettier 统一代码风格,保证团队协作的一致性。
权限模型与动态路由设计
小型企业 HR 系统通常涉及 HR、部门主管、普通员工三种角色。BambooHR 的权限粒度很细,但我们不必一开始就做得复杂。推荐使用 RBAC 模型,后端返回当前用户的角色编码和权限点列表,前端根据权限点动态生成菜单和路由。登录成功后,Pinia 中的 authStore 保存 token、用户信息和权限数组。路由守卫在 beforeEach 中检查登录状态,如果未登录则跳转登录页,已登录则根据权限动态添加路由。
动态路由的核心是在 router/index.ts 中定义静态路由和异步路由两个数组。静态路由如登录页、404 页对所有用户可见;异步路由则声明 meta.roles 或 meta.permissions,通过 addRoute 按需注册。下面是一个简化示例,展示如何过滤异步路由并添加到路由实例。
import type { RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const asyncRoutes: RouteRecordRaw[] = [
{
path: '/employee',
component: () => import('@/modules/employee/EmployeeList.vue'),
meta: { permission: 'employee:view' }
},
{
path: '/approval',
component: () => import('@/modules/approval/ApprovalList.vue'),
meta: { permission: 'approval:view' }
}
]
function filterRoutes(routes: RouteRecordRaw[], permissions: string[]) {
return routes.filter(route => {
const perm = route.meta?.permission as string | undefined
return !perm || permissions.includes(perm)
})
}
export function setupRouterGuard(router: Router) {
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (!authStore.token && to.path !== '/login') {
next('/login')
} else if (to.path === '/login' && authStore.token) {
next('/')
} else {
next()
}
})
}
按钮级权限可以用自定义指令 v-permission 实现。指令内部读取 authStore.permissions,判断是否包含指定权限点,没有则从 DOM 中移除元素。这样可以避免在模板中到处写 v-if 判断。权限点建议由后端统一下发,前端只做映射,不要硬编码角色名称,否则角色调整时前端需要频繁发版。
员工信息管理与表单工程化
员工主数据是 HR 系统的核心。字段包括姓名、工号、部门、职位、入职日期、联系方式、紧急联系人等。BambooHR 的一大亮点是员工档案的灵活性,但自建系统初期可以固定核心字段,后续通过配置化的自定义字段扩展。前端表单不应手动逐个绑定字段,而是用 schema 驱动的思路。定义一个 FormField 类型,描述字段名、标签、组件类型、校验规则和默认值,然后由通用表单渲染器遍历 schema 生成表单项。这样新增字段时只需修改 schema 配置。
Element Plus 的 el-form 已经提供了校验机制,但将校验规则从业务组件中抽离出来更利于维护。以下示例展示了员工表单 schema 的一部分和渲染组件的关键逻辑。
import type { FormField } from '@/shared/types/form'
export const employeeFormSchema: FormField[] = [
{
name: 'name',
label: '姓名',
component: 'el-input',
rules: [{ required: true, message: '请输入姓名', trigger: 'blur' }]
},
{
name: 'department',
label: '部门',
component: 'el-select',
options: [
{ label: '技术部', value: 'tech' },
{ label: '人事部', value: 'hr' },
{ label: '财务部', value: 'finance' }
],
rules: [{ required: true, message: '请选择部门', trigger: 'change' }]
},
{
name: 'hireDate',
label: '入职日期',
component: 'el-date-picker',
rules: [{ required: true, message: '请选择入职日期', trigger: 'change' }]
}
]
通用表单渲染器通过 component :is 动态加载 Element Plus 组件,并将字段值绑定到 v-model。对于日期、下拉等不同类型的值,需要在渲染器内部做一层转换。提交时统一处理为空字符串、null 和默认值,避免后端收到不一致的数据。员工头像上传可以使用 el-upload 配合 OSS 或本地存储,前端只传文件 key,后端返回 URL。注意文件大小和类型限制,以及预览和删除逻辑。
审批流与考勤统计
请假审批是小型企业 HR 系统的高频功能。一个简单的审批流可以用状态机表示:pending(待审批)、approved(已通过)、rejected(已驳回)、cancelled(已撤销)。不要一开始就引入复杂的流程引擎,对于小型企业来说,线性审批或一级审批完全够用。前端在详情页根据当前状态展示不同的操作按钮,例如待审批时部门主管可以点击通过或驳回,申请人可以撤销。状态变更通过 API 提交,成功后更新 Pinia 中的列表数据。
考勤统计通常涉及打卡记录、请假记录和加班时长。数据量不大时可以直接在前端做聚合,但最好由后端提供聚合接口,前端只负责展示表格和图表。使用 ECharts 或 AntV 绘制月度出勤趋势图,表格展示每位员工的出勤天数、请假天数和迟到次数。导出功能可以调用后端接口生成 Excel 文件,前端用 window.open 或者 axios 下载 blob。下面是一个考勤汇总表格的数据处理示例,展示如何将接口返回的原始记录按员工分组。
interface AttendanceRecord {
employeeId: string
date: string
status: 'present' | 'late' | 'leave' | 'absent'
}
function summarizeAttendance(records: AttendanceRecord[]) {
const summary = new Map<string, { present: number; late: number; leave: number; absent: number }>()
records.forEach(record => {
if (!summary.has(record.employeeId)) {
summary.set(record.employeeId, { present: 0, late: 0, leave: 0, absent: 0 })
}
const item = summary.get(record.employeeId)!
item[record.status]++
})
return Array.from(summary.entries()).map(([employeeId, stat]) => ({ employeeId, ...stat }))
}
审批流和考勤模块看似独立,实际共享员工主数据和部门树。建议在 Pinia 中维护一个 employeeStore 作为基础数据源,供审批人和考勤组件读取,避免重复请求接口。同时,所有涉及日期和时间的计算统一使用 dayjs,防止时区问题导致统计错误。报表导出时需要处理异步加载状态和失败提示,提升用户体验。
工程化一个类似 BambooHR 的小型企业 HR 系统,关键在于先定义好模块边界和数据结构,再逐步填充业务逻辑。不要试图一次性覆盖所有功能,而是以员工主数据为中心,围绕审批和考勤两个高频场景快速迭代。Vue 3 的组合式 API、TypeScript 的类型约束和 Pinia 的轻量状态管理,足以支撑这类系统的长期维护。后续可以考虑引入自定义字段、多级审批和消息通知等增强能力,但每一步都要评估团队的实际需求和维护成本。