Vue 3的工程化不是套一个Vite模板就结束,而是要让业务代码在持续迭代中保持结构清晰。HR与风险管理平台通常需要同时处理员工全生命周期、审批流配置、风险指标计算和审计追踪,如果每个页面都各自为战,很快就会出现重复的表单校验、不一致的接口调用和难以定位的权限漏洞。TriNet作为专业雇主服务产品,其HR与风险管理的功能边界正好可以作为前端工程化的参照对象。下面会把重点放在如何用Vue 3的组合式API、TypeScript、Pinia和自建组件体系,搭建一个可维护的TriNet式系统。

工程化的第一步是确定目录边界和依赖规则。一个常见的误区是把所有逻辑都塞进页面组件,导致单文件超过千行。更合理的做法是按照业务域拆分模块:员工管理、组织架构、审批中心、风险预警、审计日志各自独立,但通过统一的core层共享类型、工具函数和API客户端。这样HR模块调整字段时不会影响风险模块的图表渲染,新增审批类型也不需要修改全局状态。
一、项目基础与目录分层:让TriNet式系统从一开始就可控
初始化项目时,Vite配合TypeScript是当前Vue 3工程的默认起点。TypeScript在这里不是可选增强,而是必需约束:员工编号、部门ID、风险等级这些字段如果只是any类型,一旦后端调整字段类型,前端很难在编译期发现问题。ESLint和Prettier负责统一代码风格,Stylelint可以约束自建组件的样式规范,Husky配合lint-staged能在提交前自动执行格式化和类型检查,避免把低级错误带进仓库。这一套工具链在HR系统中尤其重要,因为多团队协作时,代码风格不一致会显著拖慢代码评审速度。
目录结构建议按业务域而非纯技术类型划分。下面是一个适合中大型团队的简化版本,核心思想是让业务边界显式化:
src/ ├── api/ # 统一接口层,按业务域拆分 │ ├── hr.ts │ └── risk.ts ├── stores/ # Pinia状态,按领域模块组织 │ ├── employee.ts │ └── risk.ts ├── components/ # 通用组件,与业务无关 ├── views/ # 页面级组件 │ ├── hr/ │ └── risk/ ├── composables/ # 可复用组合式函数 ├── types/ # 全局类型定义 ├── utils/ # 纯工具函数 └── router/ # 路由及守卫
依赖管理上需要保持克制。Pinia作为状态管理已经足够应付大多数HR和风险场景,不需要引入沉重的全局状态方案。Vue Router负责路由级权限控制,动态表单可以用JSON Schema加自建渲染器解决,不一定要绑定某个重型UI库。ECharts这类图表库建议按需引入,只打包用到的图表类型,避免首屏体积膨胀。自封装业务组件虽然前期成本高,但能精确匹配HR和风险管理中的特殊交互,长期收益更大。
二、HR模块工程化:动态表单、Pinia状态和权限指令
员工信息表单是HR模块最复杂的部分,字段数量多,而且不同国家或地区的合规要求会导致字段差异很大。如果把每个国家的表单都写成一个独立组件,维护成本会成倍增加。更合理的做法是采用JSON Schema驱动动态表单:把字段定义、校验规则和布局信息集中放在schema配置里,由统一的表单渲染器负责生成对应控件。新增一个字段只需要修改schema,不用动渲染逻辑。下面是一个精简的schema示例:
export interface FieldSchema {
name: string
label: string
type: 'text' | 'select' | 'date' | 'number'
rules?: {
required?: boolean
pattern?: string
min?: number
max?: number
}
options?: Array<{ label: string; value: string | number }>
}
export const employeeBaseSchema: FieldSchema[] = [
{ name: 'employeeNo', label: '员工编号', type: 'text', rules: { required: true, pattern: '^EMP-\\d{6}$' } },
{ name: 'department', label: '所属部门', type: 'select', options: [] },
{ name: 'hireDate', label: '入职日期', type: 'date', rules: { required: true } },
{ name: 'salaryBand', label: '薪酬区间', type: 'text', rules: { pattern: '^[A-F]$' } }
]
Pinia的store设计要遵循单一职责。员工store只负责员工列表、当前选中员工和基础增删改查动作,不要在里面直接处理接口错误或全局提示,那些统一交给API层。使用组合式store风格可以让类型推断更自然:
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Employee } from '@/types'
import { fetchEmployees, updateEmployee } from '@/api/hr'
export const useEmployeeStore = defineStore('employee', () => {
const list = ref<Employee[]>([])
const loading = ref(false)
const selectedId = ref<string | null>(null)
const selectedEmployee = computed(() => {
return list.value.find(item => item.id === selectedId.value) ?? null
})
async function loadEmployees(params?: Record<string, unknown>) {
loading.value = true
try {
list.value = await fetchEmployees(params)
} finally {
loading.value = false
}
}
async function saveEmployee(payload: Partial<Employee>) {
const updated = await updateEmployee(payload)
const index = list.value.findIndex(item => item.id === updated.id)
if (index >= 0) list.value[index] = updated
else list.value.unshift(updated)
}
return { list, loading, selectedId, selectedEmployee, loadEmployees, saveEmployee }
})
权限控制在HR系统中不能只靠路由守卫,按钮级别的控制同样重要。比如普通HR专员可以查看员工信息但不能导出薪酬数据,这就需要一个轻量的权限指令。指令的实现思路很简单:读取全局权限集合,如果当前用户缺少指定权限,就把元素从DOM中移除。下面是一个基础版本:
import type { Directive } from 'vue'
import { useAuthStore } from '@/stores/auth'
export const vPermission: Directive<HTMLElement, string> = {
mounted(el, binding) {
const auth = useAuthStore()
if (!auth.hasPermission(binding.value)) {
el.parentNode?.removeChild(el)
}
}
}
指令虽然方便,但在复杂场景下会隐藏业务意图,也不方便单元测试。更推荐的做法是同时保留函数式权限判断,例如auth.can('employee.export'),在模板中通过v-if控制,逻辑更清晰。路由守卫只处理页面级阻断,按钮级权限则放在组件内部判断,避免权限规则散落得到处都是。
三、风险管理模块:指标计算、实时预警与可视化封装
风险管理的核心是让管理者快速识别异常指标。前端通常不承担最终风险计算,但需要根据后端返回的指标值和阈值做实时预警展示。一个通用的RiskMetric类型可以这样定义:包含指标ID、名称、当前值、阈值和风险等级。预警规则可以放在前端配置中,通过computed自动判断当前值是否超过阈值以及落入哪个风险区间。这样后端只需要推送原始数据,前端负责展示和交互。
export type RiskLevel = 'low' | 'medium' | 'high'
export interface RiskMetric {
id: string
name: string
value: number
threshold: {
warning: number
critical: number
}
unit?: string
trend?: 'up' | 'down' | 'stable'
}
export function getRiskLevel(metric: RiskMetric): RiskLevel {
if (metric.value >= metric.threshold.critical) return 'high'
if (metric.value >= metric.threshold.warning) return 'medium'
return 'low'
}
图表组件应当保持纯展示,不直接访问store,也不处理业务判断。以风险趋势图为例,封装一个RiskTrendChart组件,接收数据数组作为prop,使用watch深度监听数据变化并更新图表。ECharts实例需要在组件卸载时销毁,避免内存泄漏。下面是一个简化的实现:
<template>
<div ref="chartRef" class="risk-trend-chart"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import * as echarts from 'echarts/core'
import { LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer])
const props = defineProps<{ data: Array<{ time: string; value: number }> }>()
const chartRef = ref<HTMLDivElement | null>(null)
let chartInstance: echarts.ECharts | null = null
function renderChart() {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value)
chartInstance.setOption({
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: props.data.map(item => item.time) },
yAxis: { type: 'value' },
series: [{ type: 'line', data: props.data.map(item => item.value), smooth: true }]
})
}
watch(() => props.data, () => {
chartInstance?.setOption({
xAxis: { data: props.data.map(item => item.time) },
series: [{ data: props.data.map(item => item.value) }]
})
}, { deep: true })
onMounted(renderChart)
onBeforeUnmount(() => {
chartInstance?.dispose()
chartInstance = null
})
</script>
当风险指标数量很多时,频繁的响应式更新会带来性能压力。Vue 3的v-memo指令可以缓存子树,只有在依赖变化时才重新渲染,适合风险指标卡片这种静态结构但数据频繁更新的场景。另外,大列表可以使用虚拟滚动组件,只渲染可视区域,减少DOM节点数量。实时数据建议通过WebSocket推送,但前端要做节流,不能每秒触发多次图表重绘。
四、统一API层、错误兜底与测试策略
HR和风险管理系统的接口数量多,且对错误处理的一致性要求很高。直接在每个组件里调用axios会让超时、401、403、业务错误码的处理逻辑大量重复。统一封装一个API客户端,使用Axios实例统一注入认证信息,并在响应拦截器里规范化错误:
import axios, { type AxiosError } from 'axios'
import { useAuthStore } from '@/stores/auth'
const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000
})
http.interceptors.request.use(config => {
const auth = useAuthStore()
if (auth.token) {
config.headers.Authorization = `Bearer ${auth.token}`
}
return config
})
http.interceptors.response.use(
response => response.data,
(error: AxiosError<{ code: string; message: string }>) => {
if (error.response?.status === 401) {
useAuthStore().logout()
return Promise.reject(new Error('登录已过期,请重新登录'))
}
if (error.response?.status === 403) {
return Promise.reject(new Error('没有权限执行此操作'))
}
return Promise.reject(new Error(error.response?.data?.message || '网络异常,请稍后重试'))
}
)
export default http
异步状态管理同样需要统一。每个页面都手写loading、error、data三个变量并不优雅,可以封装一个通用的useAsyncState组合式函数,把请求执行、加载状态和错误捕获集中处理:
import { ref, type Ref } from 'vue'
export function useAsyncState<T>(fetcher: () => Promise<T>) {
const data = ref<T | null>(null) as Ref<T | null>
const loading = ref(false)
const error = ref<string | null>(null)
async function execute() {
loading.value = true
error.value = null
try {
data.value = await fetcher()
} catch (err) {
error.value = err instanceof Error ? err.message : '请求失败'
} finally {
loading.value = false
}
}
return { data, loading, error, execute }
}
测试策略在HR系统中不能省略,尤其是动态表单渲染和权限指令这类容易因配置改动而回归的模块。使用Vitest做单元测试,配合Vue Test Utils挂载组件,Mock Service Worker拦截接口请求。表单渲染器至少要覆盖不同字段类型是否生成正确控件、必填校验是否触发、选择项是否按schema渲染。权限指令则需要测试有权限时元素保留、无权限时元素被移除。完整的测试能显著降低大型模块重构时的风险,这也是工程化成熟度的重要标志。
把上述几个层面串起来后,Vue 3工程化就不再是一个空泛的概念,而是能够支撑TriNet式HR与风险管理业务持续演进的坚实基础。目录分层让模块边界清晰,动态表单和权限指令解决了HR模块的灵活性与安全性,风险指标的可视化封装让前端能够快速响应阈值变化,统一API层和测试策略则保证了系统在多人协作下的稳定性。实际落地时可以根据团队规模裁剪,但核心原则不变:把变与不变分离,把重复逻辑收口,让每一处代码都处在可解释、可测试的位置。