跨国团队的发薪链路通常比国内复杂得多:一个员工可能注册在德国,客户付款币种是美元,而实际薪资以欧元结算,同时还需要满足当地社保、个税和劳动合同备案要求。Deel 把这类全球用工与合规发薪抽象成 API,但 Vue 3 项目要稳定承接这些能力,必须做工程化封装,而不是在组件里随手调用接口。否则接口认证、错误处理、币种转换和审计日志会散落在各个页面,后续维护成本会快速膨胀。

本文讨论的工程化方案围绕 Vue 3、TypeScript、Pinia 和 Axios 展开。目标是让薪资批次、合同状态、付款指令等 Deel 资源在前端拥有明确的模型边界、可测试的服务层和可复用的状态管理,从而让业务开发专注于交互而非重复造轮子。
用领域模型划分 Deel 集成边界
直接根据接口返回的 JSON 来编写组件,短时间内很快,但一旦 Deel API 升级或者后端增加了本地汇率、内部审批等字段,前端就会陷入类型漂移。更合理的做法是先把 Deel 涉及的核心对象沉淀为 TypeScript 接口,作为前端领域模型。比如合同、薪资周期、支付批次、费用明细都可以单独定义。
export interface DeelContract {
id: string;
workerId: string;
contractType: 'employee' | 'contractor';
currency: string;
baseAmount: number;
status: 'active' | 'paused' | 'terminated';
startDate: string;
endDate?: string;
}
export interface PayrollCycle {
cycleId: string;
contractId: string;
periodStart: string;
periodEnd: string;
totalAmount: number;
currency: string;
status: 'draft' | 'processing' | 'completed';
}
export interface PaymentBatch {
batchId: string;
cycleIds: string[];
payoutCurrency: string;
totalPayout: number;
status: 'pending' | 'submitted' | 'settled';
}
这些类型并不要求与 Deel 响应字段完全一一对应。工程化的重点是把稳定字段和易变字段分开,例如将 Deel 原始返回的数据放在 raw 字段中,前端只消费明确的、经过校验的字段。这样当 Deel 增加额外元数据时,不会破坏现有业务逻辑。类型定义也可以统一放在 shared 或 domain 目录下,由后端和前端共同维护契约。
领域模型的另一个好处是方便做单元测试。可以针对 PayrollCycle 编写纯函数来计算总成本、判断是否可提交或是否超过预算。避免把这类规则写在 Vue 组件里,从而导致逻辑难以复用和验证。
封装可复用的 Deel SDK 与请求拦截
组件不应该直接持有 Deel 的 API 地址和令牌。建议建立一个独立的 client 层,使用 Axios 实例封装认证、超时、错误归一化和幂等处理。认证信息通常由后端代理注入,前端只负责携带短期会话令牌,并在响应 401 时跳转登录或刷新令牌。
import axios, { AxiosError, AxiosInstance } from 'axios';
const deelClient: AxiosInstance = axios.create({
baseURL: '/api/deel',
timeout: 15000,
headers: {
'Content-Type': 'application/json',
},
});
deelClient.interceptors.request.use((config) => {
const token = localStorage.getItem('deel_access_token');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
if (config.method === 'post') {
config.headers['Idempotency-Key'] = crypto.randomUUID();
}
return config;
});
deelClient.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response?.status === 401) {
window.dispatchEvent(new Event('auth:unauthorized'));
}
return Promise.reject(normalizeDeelError(error));
}
);
function normalizeDeelError(error: AxiosError) {
const status = error.response?.status ?? 'network';
const message = error.response?.data?.message ?? error.message;
return {
status,
message,
timestamp: Date.now(),
};
}
export default deelClient;
这里的幂等键对于付款相关请求尤其重要。支付场景中网络抖动可能导致重复提交,Deel 支持通过幂等键保证同一请求只被处理一次。拦截器里统一生成 UUID,可以避免业务组件遗漏。需要注意的是,幂等键应当在用户确认提交时就固定下来,而不能每次重试都生成新值。上述示例适合简单场景,复杂场景需要把幂等键存到 Pinia 或组件状态中,保证重试复用。
错误归一化同样不能省。Deel 的错误结构可能随接口版本变化,前端要有一套统一的错误模型,包含状态码、业务错误码和用户可读信息。这样组件里只需根据 error.status 做分支,而不是反复解析嵌套的响应体。对于 403、404、429 等状态,可以在 SDK 层就给出默认提示策略。
基于 Pinia 的薪资批次与汇率缓存
薪资批次列表和汇率转换是 Vue 3 中高频使用的状态。如果不做缓存,每次切换页面都会重新请求,不仅拖慢交互,也会给 Deel 接口带来不必要压力。Pinia 很适合管理这类服务端状态,但需要明确缓存时长和失效策略,避免展示过期汇率。
import { defineStore } from 'pinia';
import deelClient from '@/services/deelClient';
import type { PayrollCycle, PaymentBatch } from '@/domain/deel';
interface PayrollState {
cycles: PayrollCycle[];
batches: PaymentBatch[];
exchangeRates: Record<string, number>;
lastFetchedCycles: number | null;
lastFetchedRates: number | null;
}
export const usePayrollStore = defineStore('payroll', {
state: (): PayrollState => ({
cycles: [],
batches: [],
exchangeRates: {},
lastFetchedCycles: null,
lastFetchedRates: null,
}),
actions: {
async fetchCycles(force = false) {
const now = Date.now();
const cacheValid = this.lastFetchedCycles && now - this.lastFetchedCycles < 5 * 60 * 1000;
if (!force && cacheValid) return;
const { data } = await deelClient.get('/payroll-cycles');
this.cycles = data.cycles;
this.lastFetchedCycles = now;
},
async fetchExchangeRates(force = false) {
const now = Date.now();
const cacheValid = this.lastFetchedRates && now - this.lastFetchedRates < 15 * 60 * 1000;
if (!force && cacheValid) return;
const { data } = await deelClient.get('/exchange-rates');
this.exchangeRates = data.rates;
this.lastFetchedRates = now;
},
},
});
上例中,薪资周期缓存 5 分钟,汇率缓存 15 分钟。这个时间并不固定,应根据实际业务对新鲜度的要求调整。如果用户需要实时提交付款批次,提交前应当强制刷新或调用单独的校验接口,而不是依赖缓存。缓存失效策略也可以加入用户操作触发,例如手动刷新按钮或页面重新聚焦时更新。
另外,汇率展示要使用 Intl.NumberFormat 统一格式,避免不同地区小数位和货币符号混乱。金额计算阶段不要在前端做高精度小数运算,可以使用整数分或专门的 decimal 库,展示阶段再转换。Deel 返回的金额大多是字符串或数字,但跨币种换算时最好由后端或独立服务完成,前端只负责展示结果,降低精度风险。
权限控制与审计日志
全球合规薪资模块通常涉及敏感数据,前端必须配合后端做按钮级权限控制。Vue 3 中可以通过自定义指令或组合式函数判断当前用户是否拥有提交付款、查看合同、导出批次等权限。权限枚举应与 Deel 角色映射保持一致,而不是硬编码用户邮箱或部门。
import { computed } from 'vue';
import { useAuthStore } from '@/stores/auth';
export function usePayrollPermission() {
const authStore = useAuthStore();
const canViewPayroll = computed(() => {
return authStore.permissions.includes('payroll:view');
});
const canSubmitPayment = computed(() => {
return authStore.permissions.includes('payroll:submit') && !authStore.isReadonlyMode;
});
const canExportBatch = computed(() => {
return authStore.permissions.includes('payroll:export');
});
return { canViewPayroll, canSubmitPayment, canExportBatch };
}
审计日志同样需要在前端记录关键操作,例如用户点击提交付款、修改批次备注或导出工资单。日志应包含操作类型、资源 ID、时间戳和用户标识,但不要记录敏感字段的完整值,如完整银行账号或身份证号。前端记录可以参考以下结构,并通过统一 logger 发送到后端审计中心。
import { createAuditLog } from '@/services/audit';
export function logPaymentAction(action: string, resourceId: string) {
createAuditLog({
action,
resourceType: 'payroll_batch',
resourceId,
userId: 'current-user-id',
timestamp: new Date().toISOString(),
});
}
前端审计日志只能作为辅助证据,不能替代后端审计。合规要求通常以服务端记录为准,前端的价值在于补充用户操作路径和界面状态。因此发送日志时最好使用 sendBeacon 或低优先级请求,避免阻塞业务请求,也要保证在页面卸载时尽量送达。
多币种显示与本地化策略
全球薪资的前端界面往往需要同时展示原币种、结算币种和员工本地币种。设计组件时可以把金额封装成一个 MoneyDisplay 组件,内部根据当前语言环境和币种做格式化。这样业务页面不需要重复写 Intl.NumberFormat 配置。
import { computed } from 'vue';
interface MoneyProps {
amount: number;
currency: string;
locale?: string;
}
export function useMoneyFormat(props: MoneyProps) {
const formatted = computed(() => {
const locale = props.locale ?? navigator.language ?? 'en-US';
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: props.currency,
}).format(props.amount);
});
return { formatted };
}
汇率展示还要带出数据更新时间戳。用户可以接受汇率不是实时值,但需要知道当前价格基于什么时间。UI 上可以设计类似“汇率更新于 10:32”的提示,避免用户误解为提交时的最终汇率。实际扣款金额通常以 Deel 后端在提交时刻拉取的汇率为准,前端展示值只做参考。
综合来看,Vue 3 工程化接入 Deel 全球合规薪资,最重要的是把认证、类型、缓存、权限和审计这些横切关注点从业务组件中抽离出来。这样无论后续是切换服务商、增加新的国家发薪规则,还是升级 Deel API 版本,都能在固定位置调整,而不是满项目搜索接口调用。