Vue 3 生态成熟之后,越来越多的财务类 SaaS 项目开始选择它作为前端框架。Xero 作为全球知名的在线会计平台,其核心界面由发票管理、银行对账、报表看板三大部分组成,涉及大量表格渲染、复杂的表单联动以及与后端 API 的实时同步。这类系统对数据精度、渲染性能和状态管理的要求,远高于普通的管理后台。本文将围绕如何用 Vue 3 工程化地实现一个类似 Xero 的在线会计平台,从架构设计、金额精度处理、表格性能优化到 Xero API 的对接,逐一展开。

一、项目架构与技术选型
财务系统的特点是模块多、数据关联强、表单复杂。如果一开始就用脚手架默认的单层结构,后期必然难以维护。推荐采用基于业务域的目录划分方式,将 invoices(发票)、banking(银行对账)、reports(报表)、contacts(联系人)分别作为独立模块,每个模块内部再细分 views、components、store 和 api 层。
技术栈方面,推荐组合是:Vite 作为构建工具(冷启动快,开发体验远优于 Webpack)、Vue Router 4 管理路由、Pinia 替代 Vuex 管理全局状态、TypeScript 保证金额字段的类型安全。TypeScript 在财务系统里不是可选项而是必选项——把金额统一约束为 string 类型可以避免隐式浮点运算带来的精度事故,这一点后面会详细说明。
src/ ├── modules/ │ ├── invoices/ │ │ ├── views/InvoiceList.vue │ │ ├── views/InvoiceDetail.vue │ │ ├── stores/invoiceStore.ts │ │ └── api/invoiceApi.ts │ ├── banking/ │ ├── reports/ │ └── contacts/ ├── shared/ // 公共组件与工具 │ ├── components/MoneyInput.vue │ └── utils/money.ts └── main.ts
Pinia 的 store 建议按业务域拆分而不是按技术层拆分。比如发票的 store 里只放发票相关的状态,银行对账有自己独立的 store。这样做的好处是模块可以按需懒加载,首屏只加载登录和仪表盘的代码,发票模块等用户真正访问时再加载,对万级功能的 SaaS 平台来说打包体积控制非常关键。
二、金额精度:财务系统的生命线
Xero 这类会计平台对金额的处理有一条铁律:前端任何地方都不能用 JavaScript 原生的浮点数直接做加减乘除。0.1 + 0.2 = 0.30000000000000004 这个经典问题在展示页面里只是难看,但在对账场景里就是真金白银的差错。正确做法是引入 decimal.js,并且约定:金额从后端拿到的永远是字符串,参与运算时转为 Decimal 对象,展示时统一走格式化函数。
import Decimal from 'decimal.js'
// 金额工具函数,全项目统一入口
export function addMoney(a: string, b: string): string {
return new Decimal(a).plus(new Decimal(b)).toFixed(2)
}
export function subMoney(a: string, b: string): string {
return new Decimal(a).minus(new Decimal(b)).toFixed(2)
}
// 含税计算:净额 * (1 + 税率)
export function grossFromNet(net: string, taxRate: string): string {
return new Decimal(net)
.times(new Decimal(1).plus(taxRate))
.toFixed(2)
}
// 展示格式化:千分位 + 货币符号
export function formatMoney(value: string, currency = 'CNY'): string {
const d = new Decimal(value).toFixed(2)
const formatted = d.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return currency === 'CNY' ? `¥${formatted}` : `$${formatted}`
}发票明细行的金额联动是另一个高频坑点:用户修改数量或单价时,行金额、税额、发票总额要自动重算。建议用 Vue 3 的 computed 派生而非在 input 事件里手动累加,这样数据流是单向的,任何一行的修改都会自动触发总额更新,不会出现状态不同步的情况。
<script setup lang="ts">
import { computed, reactive } from 'vue'
import Decimal from 'decimal.js'
const lines = reactive([
{ description: '咨询服务', qty: '2', unitPrice: '1500.00', taxRate: '0.06' }
])
// 发票总净额、总税额、含税总额全部派生计算
const totals = computed(() => {
let net = new Decimal(0)
let tax = new Decimal(0)
for (const line of lines) {
const lineNet = new Decimal(line.qty).times(line.unitPrice)
net = net.plus(lineNet)
tax = tax.plus(lineNet.times(line.taxRate))
}
return {
net: net.toFixed(2),
tax: tax.toFixed(2),
gross: net.plus(tax).toFixed(2)
}
})
</script>此外要特别注意舍入策略。会计上常用的四舍五入与银行家舍入结果可能不同,decimal.js 默认四舍五入可用,但如果后端用的是银行家舍入,前端必须统一配置 Decimal.set({ rounding: Decimal.ROUND_HALF_EVEN }),否则前端展示的总额与后端入库的总额差一分钱,对账就会报警。
三、海量账目表格的性能优化
会计平台的核心界面是账目列表,一年下来几万条流水很正常。如果直接用 el-table 渲染全部数据,页面会直接卡死。优化思路有三个层次:虚拟滚动、分页加载、列缓存。
虚拟滚动是首选方案,即只渲染可视区域内的行,滚动时动态替换。推荐使用 vueuse 的 useVirtualList 或者 el-table-v2(Element Plus 的虚拟化表格)。实测在 3 万行数据下,el-table-v2 首屏渲染时间可以从 4 秒以上降到 200 毫秒以内,滚动帧率稳定在 60fps。
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElTableV2 } from 'element-plus'
const transactions = ref([])
onMounted(async () => {
// 一次性拉取后本地虚拟滚动,适合需要前端全文搜索的场景
const res = await fetch('/api/transactions?limit=30000')
transactions.value = await res.json()
})
</script>
<template>
<el-table-v2
:data="transactions"
:width="1200"
:height="600"
:columns="columns"
fixed
/>
</template>第二层优化是前端全文搜索。会计在核对账目时经常需要按金额、备注关键词快速过滤,如果每次输入都发请求到后端,体验很差。可以把全部数据缓存在 Pinia store 里,用 computed 做本地过滤,配合防抖处理输入,几百毫秒内就能出结果。第三层是对金额列做单元格级缓存,金额格式化是纯函数,可以用 memoize 包一层,避免虚拟滚动重渲时重复计算。
四、对接 Xero API 的 OAuth2 流程设计
如果平台需要从 Xero 同步发票和银行流水,就必须处理 Xero 的 OAuth2 认证。Xero 使用授权码模式加 PKCE,整体流程是:跳转 Xero 授权页、用户确认、回调携带 code、后端换取 access_token、后续请求携带 tenant_id 访问租户数据。前端的职责主要是发起授权和处理回调后的状态更新。
// 发起 Xero 授权
export function startXeroAuth() {
const clientId = import.meta.env.VITE_XERO_CLIENT_ID
const redirectUri = encodeURIComponent(window.location.origin + '/xero/callback')
const state = crypto.randomUUID()
sessionStorage.setItem('xero_state', state)
const url = `https://login.xero.com/identity/connect/authorize?` +
`response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}` +
`&scope=offline_access openid profile accounting.transactions&state=${state}`
window.location.href = url
}
// 回调页校验 state 防止 CSRF
export function handleXeroCallback(code: string, state: string) {
if (state !== sessionStorage.getItem('xero_state')) {
throw new Error('state 校验失败,可能存在 CSRF 攻击')
}
// code 发给自家后端换取 token,前端不接触密钥
return fetch('/api/xero/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
})
}这里有三条安全红线:第一,client_secret 绝不能放在前端,换取 token 必须经过自己的后端代理;第二,state 参数必须校验,防止授权码被劫持注入;第三,token 过期后的刷新也要走后端,前端只感知登录态本身。Xero 的 access_token 有效期 30 分钟,刷新 token 有效期 60 天,建议后端做定时任务主动刷新,前端完全无感。
数据同步层面,Xero 提供的 webhook 会在发票创建、支付完成时推送事件。前端可以配合 WebSocket 或轮询展示同步进度,并在界面上明确标注哪些数据来自 Xero、哪些是本地录入,这对会计人员排查差异非常重要。
五、总结
用 Vue 3 做一个类 Xero 的在线会计平台,工程化的重点可以归纳为四点:以业务域划分模块的架构设计、以 decimal.js 和字符串类型为基石的金额精度体系、以虚拟滚动为核心的海量表格渲染方案、以及严格走后端代理的 Xero OAuth2 对接流程。财务系统的用户对数字极度敏感,一分钱的误差都可能摧毁信任,因此在写每一行金额相关代码时都要多问一句:这里会不会丢精度、会不会与后端舍入不一致。把这些细节做扎实,平台的可靠性才有保障。