人力资本管理系统(HCM)是典型的企业级中后台应用,其前端需要处理大量员工数据、复杂表单、角色权限和审批流程。Workday 作为行业标杆,它的交互体验和模块化设计常常被参考。从零开始用 Vue 3 构建一个类似 Workday 的系统,如果缺少工程化思维,很快就会陷入组件臃肿、状态混乱、重复请求等问题。Vue 3 的 Composition API、Pinia 状态管理和 Vite 构建工具为工程化提供了坚实的基础,配合 TypeScript 和规范的目录设计,完全能够支撑起大型 HCM 项目的前端架构。

一、脚手架与目录结构:奠定工程化基础
使用 Vite 初始化 Vue 3 项目是最快捷的方式,命令为 npm create vite@latest hcm-frontend -- --template vue-ts。之后需要安装核心依赖:pinia 用于状态管理,vue-router 用于路由,element-plus 作为 UI 组件库,axios 负责 HTTP 请求。建议同时引入 eslint 和 prettier 统一代码风格,避免多人协作时出现不必要的 diff 噪音。
目录结构是工程化的重要部分。推荐采用按功能模块划分的方式,而不是把所有组件堆在 components 目录下。以下是一个典型的 HCM 项目目录结构:
src/ ├── api/ # 接口请求封装,按模块划分 │ ├── employee.ts │ └── auth.ts ├── components/ # 通用业务组件 │ ├── EmployeeTable.vue │ └── DepartmentTree.vue ├── composables/ # 组合式函数 │ ├── useEmployeeForm.ts │ └── usePermission.ts ├── layouts/ # 布局组件 │ └── MainLayout.vue ├── router/ # 路由配置 │ └── index.ts ├── stores/ # Pinia 状态 │ ├── employee.ts │ └── user.ts ├── types/ # TypeScript 类型定义 │ └── employee.d.ts ├── utils/ # 工具函数 │ └── request.ts ├── App.vue └── main.ts
这种结构让每个模块的职责清晰,后续新增功能时只需在对应目录下添加文件,不会出现单个文件超过上千行的情况。同时,TypeScript 的类型定义集中管理,方便维护接口返回的数据结构。
为了进一步约束代码质量,可以在 vite.config.ts 中配置路径别名,例如将 @ 指向 src 目录,减少相对路径的深度。同时开启 eslint 的 Vue 3 推荐规则,并在 .prettierrc 中设置单引号、无分号等风格选项,让格式化工具与 ESLint 规则保持一致。
二、员工信息模块:表单工程化实践
员工信息管理是 HCM 系统的核心模块,涉及的字段包括姓名、工号、部门、职位、入职日期、薪资等,表单复杂度很高。直接在一个 <template> 中堆砌几十个输入框并手动管理每个输入值的做法极其低效。正确的做法是使用 <script setup> 语法配合组合式函数,将表单逻辑抽离到独立文件中。
以下是一个基于 Element Plus 和 VeeValidate 的表单组合式函数示例:
// composables/useEmployeeForm.ts
import { reactive, ref } from 'vue';
import { useField, useForm } from 'vee-validate';
import * as yup from 'yup';
export function useEmployeeForm() {
const formRef = ref();
const formData = reactive({
name: '',
employeeId: '',
department: '',
position: '',
hireDate: '',
baseSalary: 0,
});
const schema = yup.object({
name: yup.string().required('姓名不能为空').min(2, '姓名至少2个字符'),
employeeId: yup.string().required('工号不能为空').matches(/^[A-Z0-9]{6,10}$/, '工号格式不正确'),
department: yup.string().required('部门不能为空'),
position: yup.string().required('职位不能为空'),
hireDate: yup.date().required('入职日期不能为空'),
baseSalary: yup.number().min(0, '薪资不能为负数').required('薪资不能为空'),
});
const { handleSubmit, errors } = useForm({
validationSchema: schema,
initialValues: formData,
});
const submit = handleSubmit(async (values) => {
// 调用 API 提交
await saveEmployee(values);
});
return {
formRef,
formData,
submit,
errors,
};
}
在组件中只需要引入这个组合式函数,然后绑定到模板上即可。这样表单的校验规则、提交逻辑与视图完全分离,同一个表单可以在新建和编辑场景下复用,只需要传入不同的初始数据。例如:
<template>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form-item label="姓名" prop="name">
<el-input v-model="formData.name" />
</el-form-item>
<!-- 其他字段类似 -->
<el-button type="primary" @click="submit">保存</el-button>
</el-form>
</template>
<script setup lang="ts">
import { useEmployeeForm } from '@/composables/useEmployeeForm';
const { formRef, formData, submit, errors } = useEmployeeForm();
</script>
注意 <template> 中的代码在 pre 块内已经进行了转义,实际使用时无需担心。这种模式极大提高了代码复用率,也便于单元测试。
除了表单逻辑,员工列表的展示也适合抽象成通用组件。例如创建一个 EmployeeTable.vue,接收 data 和 loading 属性,内部封装分页、排序、列配置等逻辑。这样在不同页面(如在职员工、离职员工、待入职员工)都可以直接使用同一个表格组件,只需要传入不同的数据源。
三、状态管理与 API 请求封装:解决数据流混乱
在 HCM 系统中,员工数据经常被多个页面共享,例如员工详情页、编辑页、列表页以及组织架构图。如果每个页面都独立请求数据,不仅造成重复请求,还会出现数据不一致的问题。Pinia 作为 Vue 3 的官方状态管理库,可以很好地解决这个问题。
以员工模块为例,创建一个 employee store:
// stores/employee.ts
import { defineStore } from 'pinia';
import { getEmployeeList, getEmployeeDetail, saveEmployee } from '@/api/employee';
import type { Employee, EmployeeQuery } from '@/types/employee';
interface EmployeeState {
list: Employee[];
currentEmployee: Employee | null;
loading: boolean;
query: EmployeeQuery;
total: number;
}
export const useEmployeeStore = defineStore('employee', {
state: (): EmployeeState => ({
list: [],
currentEmployee: null,
loading: false,
query: {
page: 1,
pageSize: 20,
keyword: '',
department: undefined,
},
total: 0,
}),
actions: {
async fetchEmployeeList() {
this.loading = true;
try {
const res = await getEmployeeList(this.query);
this.list = res.data.items;
this.total = res.data.total;
} finally {
this.loading = false;
}
},
async fetchEmployeeDetail(id: string) {
const res = await getEmployeeDetail(id);
this.currentEmployee = res.data;
},
async saveEmployee(data: Employee) {
await saveEmployee(data);
// 保存成功后刷新列表
await this.fetchEmployeeList();
},
},
});
页面组件中只需要调用 store.fetchEmployeeList(),状态变化会自动驱动视图更新。由于请求参数也保存在 store 中,切换分页、搜索关键词时只需修改 query 并重新调用方法,无需手动管理请求竞态。
API 层统一使用 Axios 封装,在 utils/request.ts 中创建实例,并添加请求拦截器和响应拦截器:
// utils/request.ts
import axios from 'axios';
import { ElMessage } from 'element-plus';
import { useUserStore } from '@/stores/user';
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000,
});
request.interceptors.request.use((config) => {
const userStore = useUserStore();
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`;
}
return config;
});
request.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
// 跳转登录
} else {
ElMessage.error(error.response?.data?.message || '请求失败');
}
return Promise.reject(error);
}
);
export default request;
这样所有 API 函数都可以直接基于 request 实例编写,统一的错误处理和 token 注入避免了每个请求函数重复代码。例如 api/employee.ts:
import request from '@/utils/request';
import type { Employee, EmployeeQuery, PageResult } from '@/types/employee';
export function getEmployeeList(params: EmployeeQuery) {
return request.get<PageResult<Employee>>('/employees', { params });
}
export function getEmployeeDetail(id: string) {
return request.get<Employee>(`/employees/${id}`);
}
export function saveEmployee(data: Employee) {
return request.post<void>('/employees', data);
}
这种分层设计让数据流清晰可控,后续接入接口变更时只需要修改 API 函数,不会影响页面组件。
四、路由权限与动态菜单:构建安全的访问控制
人力资本管理系统通常包含敏感的员工薪资、绩效等数据,必须严格限制不同角色的访问范围。Vue Router 提供的全局前置守卫可以配合后端返回的权限数据动态生成路由。
在 router/index.ts 中定义基础路由和动态路由:
import { createRouter, createWebHistory } from 'vue-router';
import MainLayout from '@/layouts/MainLayout.vue';
import { useUserStore } from '@/stores/user';
export const constantRoutes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
},
{
path: '/',
component: MainLayout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '首页', icon: 'home' },
},
],
},
];
export const asyncRoutes = [
{
path: '/employee',
component: MainLayout,
children: [
{
path: 'list',
name: 'EmployeeList',
component: () => import('@/views/employee/list.vue'),
meta: { title: '员工列表', permission: 'employee:list' },
},
{
path: 'detail/:id',
name: 'EmployeeDetail',
component: () => import('@/views/employee/detail.vue'),
meta: { title: '员工详情', permission: 'employee:detail' },
hidden: true,
},
],
},
// 其他模块类似
];
const router = createRouter({
history: createWebHistory(),
routes: constantRoutes,
});
export default router;
在 main.ts 或路由守卫中添加动态路由逻辑。守卫函数中,根据用户角色从后端获取权限列表,然后过滤 asyncRoutes 并动态添加:
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore();
if (!userStore.isLoggedIn) {
if (to.path === '/login') next();
else next('/login');
return;
}
if (!userStore.routesLoaded) {
try {
const permissions = await userStore.fetchUserPermissions();
const accessibleRoutes = filterAsyncRoutes(asyncRoutes, permissions);
accessibleRoutes.forEach((route) => router.addRoute(route));
userStore.setRoutesLoaded(true);
next({ ...to, replace: true });
} catch (error) {
userStore.logout();
next('/login');
}
} else {
next();
}
});
同时,后端返回的菜单数据也可以用于渲染侧边栏,确保用户只能看到自己有权限访问的页面。动态路由的添加需要处理页面刷新后的状态恢复,可以在 userStore 中持久化 routesLoaded 标记或重新请求权限。
五、构建优化与部署:提升生产环境性能
工程化的最终环节是构建与部署。Vite 默认使用 Rollup 进行生产构建,可以通过配置 build.rollupOptions 进行代码分割,将第三方库和业务代码分离,减少首屏加载时间。例如:
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
'element-plus': ['element-plus'],
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'axios': ['axios'],
},
},
},
},
});
对于路由组件,使用动态 import() 可以自动实现按需加载,Vite 会根据路由拆分代码块。此外,环境变量通过 .env.development 和 .env.production 管理,例如 VITE_API_BASE_URL 可以在不同环境下切换 API 地址。部署时通常将构建产物上传到 Nginx 或 CDN,对于 SPA 应用还需要配置 history 模式回退。
为了进一步提升性能,可以开启 gzip 压缩、使用 CDN 引入静态资源、配置 PWA 缓存等。不过这些都属于进阶优化,核心的工程化框架已经能够支撑大多数中小型 HCM 项目的需求。
通过上述五个方面的实践,一个类 Workday 的人力资本管理系统前端就具备了良好的工程化基础。从脚手架到构建部署,每一步都遵循了模块化、可维护性和可扩展性的原则。实际项目中还可以继续引入单元测试(Vitest)、组件测试(Cypress)和可视化监控,让工程质量达到更高水平。