Justworks 这类 PEO 平台的业务本质,是把原本分散在中小企业内部的雇佣合规工作集中化处理:工资核算要同时满足联邦和各州税法、员工福利要符合 ACA 报告要求、入职流程要覆盖 I-9 身份核验等环节。这意味着前端系统不是简单的增删改查界面,而是一套带有强合规约束的状态机。Vue 3 在这个领域的工程化价值,主要体现在它能让复杂业务规则以可测试、可组合的方式沉淀下来,而不是散落在几百个组件里。本文结合实际项目经验,聊聊如何用 Vue 3 搭建这类系统的工程化骨架。

一、项目架构:用 monorepo 隔离业务域
HR 合规系统的第一个特点是业务域非常多且相互独立:薪酬、福利、考勤、合规文档、员工档案,每个域都有自己的数据模型和更新节奏。如果全部塞进一个应用,构建时间和代码耦合都会失控。实践中比较成熟的做法是 pnpm workspace 加 monorepo,把公共逻辑抽成 packages,业务模块各自成包。
典型的目录结构如下:
hr-platform/ ├── packages/ │ ├── ui-kit/ # 基础组件库(表单、表格、弹窗) │ ├── compliance-core/ # 合规规则引擎,纯 TS 无框架依赖 │ └── api-client/ # 接口层,统一处理鉴权与错误码 ├── apps/ │ ├── payroll/ # 薪酬模块 │ ├── benefits/ # 福利模块 │ └── onboarding/ # 入职合规模块 └── pnpm-workspace.yaml
这里有个关键设计:compliance-core 包不含任何 Vue 代码,只是纯粹的 TypeScript 函数库。比如各州最低工资校验、加班费计算规则,全部写成纯函数。这样做的好处是这些规则可以用 Jest 直接做单元测试,覆盖上百个测试用例而无需启动浏览器,合规逻辑的正确性验证成本极低。当某州法规更新时,只需改这一个包并升级版本号,各业务应用按需升级。
另一个容易被忽视的点是 Vite 的按模块构建。薪酬模块涉及大量数值计算和图表,体积偏大,而合规文档查看模块很轻量。通过 Rollup 的 manualChunks 把计算引擎单独分包,首屏只加载核心交互代码,实际项目中能把首包从 2.3MB 压到 800KB 以内。
二、状态管理与数据校验链路
合规系统的第二个特点是表单极其复杂。一个员工入职表单可能包含三四十个字段,且字段之间存在级联合规关系:选择了某个州之后,免税申报的选项集合会变化;勾选了 1099 承包商身份后,整块 W-4 税表区域要隐藏并替换为 W-9。如果用 Vue 2 的 Options API,这些联动逻辑会分散在 watch 和 computed 里,难以维护。Vue 3 的组合式 API 允许把一个完整业务流程封装成可复用的 composable:
// composables/useTaxForm.ts
import { ref, computed, watch } from 'vue'
import { getTaxRulesForState } from '@hr/compliance-core'
export function useTaxForm(stateCode: Ref<string>) {
const rules = computed(() => getTaxRulesForState(stateCode.value))
const formData = ref<Record<string, any>>({})
// 州切换时重置不兼容的字段值
watch(stateCode, (newVal, oldVal) => {
const allowed = new Set(rules.value.fields.map(f => f.key))
for (const key of Object.keys(formData.value)) {
if (!allowed.has(key)) delete formData.value[key]
}
})
const errors = computed(() => {
return rules.value.fields
.filter(f => f.required && !formData.value[f.key])
.map(f => ({ field: f.key, message: f.label + '不能为空' }))
})
return { formData, rules, errors }
}
这个模式的核心价值在于:useTaxForm 把「读取规则、联动重置、校验」三件事封装成一个整体,任何组件调用它都获得一致的行为。合规规则来自后端还是本地计算引擎,组件完全不关心,替换数据源时只改 composable 内部实现。
状态管理方面,Pinia 相比 Vuex 的优势不只是 API 更简洁,更重要的是它天然支持在 store 外部直接访问,这让组合式函数可以在不依赖组件实例的情况下读写全局状态。比如审计日志就是一个典型场景:任何关键合规字段的修改都必须留痕,可以在 Pinia 的 action 里统一拦截:
export const useAuditStore = defineStore('audit', () => {
const logs = ref<AuditEntry[]>([])
function track(entity: string, before: any, after: any, operator: string) {
logs.value.push({
entity,
operator,
timestamp: Date.now(),
diff: computeDiff(before, after) // 只记录变更字段
})
}
return { logs, track }
})
注意这里用 computeDiff 只记录变更字段而不是全量快照,一方面减少存储压力,另一方面合规审计时审查者只关心「谁在什么时间改了什么」,增量记录的可读性远高于全量对比。
三、权限控制与合规 UI 的组件化
PEO 平台的用户角色非常复杂:企业主、HR 管理员、普通员工、Justworks 的客服人员,同一页面不同角色看到的字段集和操作按钮都不同。硬编码 v-if="role === 'admin'" 会迅速失控。工程化做法是自定义指令统一收口权限判断:
// directives/permission.ts
import { Directive } from 'vue'
import { useUserStore } from '@/stores/user'
export const vPermission: Directive<HTMLElement, string[]> = {
mounted(el, binding) {
const user = useUserStore()
const required = binding.value // 例如 ['payroll:edit']
const ok = required.some(p => user.permissions.includes(p))
if (!ok) {
el.parentNode?.removeChild(el)
// 同时记录被拦截的操作,供安全审计使用
useAuditStore().track('permission_denied', null, required, user.id)
}
}
}
用指令的好处是声明式、侵入性低,而且权限点命名(如 payroll:edit)可以和后端的权限系统保持同一套编码。权限拦截本身也写入审计日志,这在 SOC 2 审计中是常见的要求项。
最后谈谈合规提示的 UI 沉淀。法规要求的关键警告(比如「该州要求在入职后 3 天内完成 I-9 核验」)不应散落在各页面,而应做成 ComplianceBanner 这类语义化组件,由合规规则引擎驱动内容。组件只负责展示样式和严重级别,文案与触发条件全部来自 compliance-core 包。当法规变化时,前端组件一行不改,只更新规则数据,这正是把「合规逻辑」和「展示逻辑」彻底分离带来的收益。
总结来看,Vue 3 对这类系统的支撑力来自三点:组合式 API 让复杂联动逻辑可封装可测试,TypeScript 加 monorepo 让合规规则独立演进,指令与 Pinia 则提供了权限和审计的统一收口。如果你的团队正在做类似的企业级 HR 系统,建议从第一天就把合规规则抽成纯函数包,这个早期投入会在后续的每次法规更新中持续产生回报。