Vue3 + vxe-table 实战:5分钟搞定财务凭证打印功能(含完整代码)
Vue3 + vxe-table:从零构建高保真财务凭证打印模块的实战指南
最近在重构一个老旧的财务系统,客户对凭证打印功能的要求近乎苛刻:既要保持传统纸质凭证的严谨格式,又要支持在线实时编辑和快速打印。我尝试过多种方案,从原生的window.print到各种打印插件,要么样式控制太麻烦,要么无法处理复杂的表格结构。直到深入研究了vxe-table的打印模块,才发现原来实现一个专业级的财务凭证打印功能可以如此优雅。
如果你正在开发财务类管理系统,需要处理凭证录入、金额拆分、自动合计和格式化的打印输出,那么这篇文章正是为你准备的。我不会给你一个简单的Demo复制粘贴,而是带你从业务理解开始,一步步构建一个可复用、易维护的财务凭证打印模块。我们将重点解决几个核心痛点:如何将金额按“亿万千百十元角分”拆分显示、如何实现实时编辑与自动合计、如何控制打印样式达到财务凭证的专业要求。
1. 理解财务凭证的业务逻辑与数据结构设计
在动手写代码之前,我们需要先搞清楚财务凭证到底是什么。传统的记账凭证通常包含摘要、会计科目、借方金额、贷方金额等核心字段,而金额部分往往需要按位数拆分显示——这是财务系统的特殊要求。用户需要在“亿、千、百、十、万、千、百、十、元、角、分”每个位置上单独输入数字,系统则需要将这些分散的数字重新组合计算。
1.1 金额拆分与组合的数据模型
财务凭证最复杂的地方在于金额的表示方式。一个金额如“1234567.89”需要拆分成:
| 位数 | 字段名 | 对应值 | 说明 |
|---|---|---|---|
| 亿位 | p9 | 0 | 最高位,通常为0 |
| 千万位 | p8 | 0 | |
| 百万位 | p7 | 1 | |
| 十万位 | p6 | 2 | |
| 万位 | p5 | 3 | |
| 千位 | p4 | 4 | |
| 百位 | p3 | 5 | |
| 十位 | p2 | 6 | |
| 元位 | p1 | 7 | |
| 角位 | m1 | 8 | 小数点后第一位 |
| 分位 | m2 | 9 | 小数点后第二位 |
这种拆分方式虽然看起来繁琐,但符合财务人员的输入习惯。我们需要设计两个核心函数:一个用于将数字金额拆分成对象,另一个用于将对象重新组合成数字。
// 金额拆分函数 const splitAmount = (amount) => { const str = String(amount || '0') const [integerPart, decimalPart = '00'] = str.split('.') // 补齐小数部分 const decimal = decimalPart.padEnd(2, '0').slice(0, 2) // 反转整数部分以便从个位开始处理 const reversedInteger = integerPart.split('').reverse().join('') return { p9: reversedInteger[8] || '0', // 亿位 p8: reversedInteger[7] || '0', // 千万位 p7: reversedInteger[6] || '0', // 百万位 p6: reversedInteger[5] || '0', // 十万位 p5: reversedInteger[4] || '0', // 万位 p4: reversedInteger[3] || '0', // 千位 p3: reversedInteger[2] || '0', // 百位 p2: reversedInteger[1] || '0', // 十位 p1: reversedInteger[0] || '0', // 元位 m1: decimal[0] || '0', // 角位 m2: decimal[1] || '0' // 分位 } } // 金额组合函数 const combineAmount = (amountObj) => { const { p9, p8, p7, p6, p5, p4, p3, p2, p1, m1, m2 } = amountObj // 构建整数部分 const integerStr = [p9, p8, p7, p6, p5, p4, p3, p2, p1] .reverse() .join('') .replace(/^0+/, '') || '0' // 构建小数部分 const decimalStr = `${m1 || '0'}${m2 || '0'}` return parseFloat(`${integerStr}.${decimalStr}`) }注意:财务系统中金额处理必须精确,避免浮点数精度问题。在实际项目中,我建议使用decimal.js或big.js这类库来处理高精度计算。
1.2 凭证行的数据结构设计
一个完整的凭证行需要包含以下信息:
interface VoucherItem { id: string | number // 唯一标识 seq: number // 序号 summary: string // 摘要 subject: string // 会计科目 debtorAmount: number // 借方金额(数字形式) creditAmount: number // 贷方金额(数字形式) debtorObj: AmountObject // 借方金额拆分对象 creditObj: AmountObject // 贷方金额拆分对象 checked?: boolean // 核对标记 } interface AmountObject { p9: string // 亿 p8: string // 千万 p7: string // 百万 p6: string // 十万 p5: string // 万 p4: string // 千 p3: string // 百 p2: string // 十 p1: string // 元 m1: string // 角 m2: string // 分 }这种设计既满足了显示需求(拆分显示),又满足了计算需求(数字形式存储)。在实际操作中,用户修改拆分后的单个数字时,我们需要实时更新对应的数字金额,并重新计算合计行。
2. 基于Vue3 Composition API构建可复用的凭证组件
Vue3的组合式API让我们能够更好地组织财务凭证的逻辑。我将整个功能拆分成几个独立的composable函数,每个函数负责一个明确的职责。
2.1 创建金额处理Hook
首先,我们把金额的拆分和组合逻辑封装成一个独立的hook:
// useAmountHandler.js import { computed } from 'vue' export function useAmountHandler() { // 拆分金额(前面已实现) const splitAmount = (amount) => { /* ... */ } // 组合金额(前面已实现) const combineAmount = (amountObj) => { /* ... */ } // 格式化金额显示(添加千分位分隔符) const formatAmount = (amount) => { if (amount === null || amount === undefined) return '0.00' const [integer, decimal = '00'] = String(amount).split('.') const formattedInteger = integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',') const formattedDecimal = decimal.padEnd(2, '0').slice(0, 2) return `${formattedInteger}.${formattedDecimal}` } // 验证金额对象是否有效 const validateAmountObject = (obj) => { const keys = ['p9', 'p8', 'p7', 'p6', 'p5', 'p4', 'p3', 'p2', 'p1', 'm1', 'm2'] return keys.every(key => { const value = obj[key] return value === '' || (Number.isInteger(Number(value)) && Number(value) >= 0 && Number(value) <= 9) }) } return { splitAmount, combineAmount, formatAmount, validateAmountObject } }2.2 构建凭证数据管理Hook
接下来,我们创建一个管理凭证数据的hook,它负责处理数据的增删改查以及合计计算:
// useVoucherData.js import { ref, computed } from 'vue' import { useAmountHandler } from './useAmountHandler' export function useVoucherData(initialData = []) { const { splitAmount, combineAmount } = useAmountHandler() // 凭证数据 const voucherData = ref([]) // 初始化数据 const initData = (data) => { voucherData.value = data.map(item => ({ ...item, debtorObj: splitAmount(item.debtorAmount || 0), creditObj: splitAmount(item.creditAmount || 0) })) } // 添加新行 const addRow = (rowData = {}) => { const newRow = { id: Date.now() + Math.random(), seq: voucherData.value.length + 1, summary: '', subject: '', debtorAmount: 0, creditAmount: 0, debtorObj: splitAmount(0), creditObj: splitAmount(0), checked: false, ...rowData } voucherData.value.push(newRow) updateSequences() return newRow } // 删除行 const removeRow = (id) => { const index = voucherData.value.findIndex(item => item.id === id) if (index > -1) { voucherData.value.splice(index, 1) updateSequences() } } // 更新行数据 const updateRow = (id, updates) => { const index = voucherData.value.findIndex(item => item.id === id) if (index > -1) { const updatedRow = { ...voucherData.value[index], ...updates } // 如果更新了金额对象,需要同步更新数字金额 if (updates.debtorObj) { updatedRow.debtorAmount = combineAmount(updates.debtorObj) } if (updates.creditObj) { updatedRow.creditAmount = combineAmount(updates.creditObj) } voucherData.value[index] = updatedRow } } // 更新序号 const updateSequences = () => { voucherData.value.forEach((item, index) => { item.seq = index + 1 }) } // 计算借方合计 const debtorTotal = computed(() => { return voucherData.value.reduce((sum, row) => { return sum + (row.debtorAmount || 0) }, 0) }) // 计算贷方合计 const creditTotal = computed(() => { return voucherData.value.reduce((sum, row) => { return sum + (row.creditAmount || 0) }, 0) }) // 检查借贷平衡 const isBalanced = computed(() => { return Math.abs(debtorTotal.value - creditTotal.value) < 0.01 }) // 获取合计行的金额对象 const totalRow = computed(() => { return { debtorObj: splitAmount(debtorTotal.value), creditObj: splitAmount(creditTotal.value), debtorAmount: debtorTotal.value, creditAmount: creditTotal.value } }) // 初始化数据 if (initialData.length > 0) { initData(initialData) } return { voucherData, debtorTotal, creditTotal, isBalanced, totalRow, initData, addRow, removeRow, updateRow, updateSequences } }这个hook提供了完整的数据管理能力,包括数据的初始化、增删改查、合计计算和平衡检查。我们可以轻松地在多个组件中复用这些逻辑。
3. 使用vxe-table构建专业的凭证编辑表格
vxe-table的强大之处在于它提供了丰富的表格功能和灵活的配置选项。对于财务凭证这种复杂表格,我们需要充分利用它的列配置、编辑功能和自定义渲染能力。
3.1 配置基础表格结构
首先,我们创建一个基础的vxe-table配置:
// voucherTableConfig.js import { ref } from 'vue' export function useVoucherTableConfig() { const gridRef = ref() const baseConfig = { border: true, showOverflow: true, showFooter: true, keepSource: true, height: 'auto', rowConfig: { isCurrent: true, isHover: true }, columnConfig: { resizable: true }, editConfig: { mode: 'cell', trigger: 'dblclick', showStatus: true, autoClear: false }, keyboardConfig: { isArrow: true, isEnter: true, isTab: true, isEdit: true, isDel: true, isEsc: true } } return { gridRef, baseConfig } }3.2 设计金额拆分列
金额拆分列是财务凭证的核心,我们需要为借方和贷方分别创建11个子列(亿到分):
// 创建金额列配置 const createAmountColumn = (field, title) => { return { field: field, title: title, children: [ { field: `${field}.p9`, title: '亿', width: 50, align: 'center' }, { field: `${field}.p8`, title: '千', width: 50, align: 'center' }, { field: `${field}.p7`, title: '百', width: 50, align: 'center' }, { field: `${field}.p6`, title: '十', width: 50, align: 'center' }, { field: `${field}.p5`, title: '万', width: 50, align: 'center' }, { field: `${field}.p4`, title: '千', width: 50, align: 'center' }, { field: `${field}.p3`, title: '百', width: 50, align: 'center' }, { field: `${field}.p2`, title: '十', width: 50, align: 'center' }, { field: `${field}.p1`, title: '元', width: 50, align: 'center' }, { field: `${field}.m1`, title: '角', width: 50, align: 'center' }, { field: `${field}.m2`, title: '分', width: 50, align: 'center' } ].map(col => ({ ...col, editRender: { name: 'VxeNumberInput', props: { type: 'integer', min: 0, max: 9, controls: false, maxLength: 1, align: 'center', placeholder: '0' }, events: { change: ({ row, column, cellValue }) => { // 金额变化时触发合计重新计算 handleAmountChange(row, field) } } }, formatter: ({ cellValue }) => cellValue || '0' })) } } // 处理金额变化 const handleAmountChange = (row, field) => { const amountObj = row[field] const amount = combineAmount(amountObj) if (field === 'debtorObj') { row.debtorAmount = amount } else { row.creditAmount = amount } // 触发合计更新 updateTotals() }3.3 完整的列配置
结合所有需求,我们得到完整的列配置:
const columns = [ { type: 'seq', width: 60, title: '序号', fixed: 'left' }, { field: 'summary', title: '摘要', minWidth: 180, editRender: { name: 'VxeInput' }, fixed: 'left' }, { field: 'subject', title: '会计科目', minWidth: 200, editRender: { name: 'VxeInput' } }, createAmountColumn('debtorObj', '借方金额'), { field: 'checked', title: '√', width: 40, cellRender: { name: 'VxeCheckbox' } }, createAmountColumn('creditObj', '贷方金额'), { field: 'checked2', title: '√', width: 40, cellRender: { name: 'VxeCheckbox' } }, { type: 'operate', title: '操作', width: 120, fixed: 'right', slots: { default: ({ row }) => [ h('vxe-button', { type: 'text', onClick: () => handleEdit(row) }, '编辑'), h('vxe-button', { type: 'text', status: 'danger', onClick: () => handleDelete(row.id) }, '删除') ] } } ]4. 实现专业级的打印功能与样式控制
打印功能是财务凭证模块的关键。vxe-table内置了打印功能,但默认样式往往不符合财务凭证的要求。我们需要深度定制打印输出。
4.1 配置打印参数
vxe-table的printConfig提供了丰富的配置选项:
const printConfig = { sheetName: '记账凭证', // 打印页名称 beforePrintMethod: ({ html }) => { // 在打印前修改HTML内容 return customizePrintHTML(html) }, styles: [ // 自定义打印样式 `.vxe-table--print-wrapper { font-family: 'SimSun', '宋体', serif; font-size: 14px; }`, `.vxe-table--print-body { border: 2px solid #000; }`, `.amount-cell { text-align: center; border-right: 1px solid #ddd; }`, `.total-row { font-weight: bold; background-color: #f5f5f5; }` ], columns: columns.filter(col => col.type !== 'operate'), // 不打印操作列 footerMethod: ({ columns, data }) => { // 自定义表尾 return [ { seq: '合计', debtorObj: totalRow.value.debtorObj, creditObj: totalRow.value.creditObj } ] } }4.2 自定义打印模板
财务凭证通常有固定的表头和表尾格式,我们需要在打印时添加这些内容:
const customizePrintHTML = (tableHTML) => { const headerHTML = ` <div class="voucher-header" style="margin-bottom: 20px; border-bottom: 2px solid #000; padding-bottom: 10px;"> <div style="text-align: center; font-size: 20px; font-weight: bold; margin-bottom: 10px;"> 记账凭证 </div> <div style="display: flex; justify-content: space-between; font-size: 14px;"> <div>凭证号:${voucherNumber.value}</div> <div>日期:${formatDate(new Date())}</div> <div>附单据数:${attachmentCount.value}张</div> </div> </div> ` const footerHTML = ` <div class="voucher-footer" style="margin-top: 30px; border-top: 1px solid #000; padding-top: 15px;"> <div style="display: flex; justify-content: space-between;"> <div style="width: 25%;"> <div>财务主管:${financialManager.value}</div> <div style="margin-top: 20px;">签字:</div> </div> <div style="width: 25%;"> <div>记账:${accountant.value}</div> <div style="margin-top: 20px;">签字:</div> </div> <div style="width: 25%;"> <div>出纳:${cashier.value}</div> <div style="margin-top: 20px;">签字:</div> </div> <div style="width: 25%;"> <div>审核:${auditor.value}</div> <div style="margin-top: 20px;">签字:</div> </div> </div> <div style="margin-top: 20px; text-align: center; font-size: 12px; color: #666;"> 第 <span style="font-weight: bold;">1</span> 页 / 共 <span style="font-weight: bold;">1</span> 页 </div> </div> ` return ` <div class="voucher-print-container"> ${headerHTML} ${tableHTML} ${footerHTML} </div> ` }4.3 处理打印样式问题
打印时常见的样式问题包括分页断行、边框不显示、字体不一致等。我们需要通过CSS媒体查询来优化打印样式:
/* 打印样式 */ @media print { @page { size: A4 portrait; margin: 20mm; } body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } .voucher-print-container { width: 100%; page-break-inside: avoid; } .vxe-table--print-body { border-collapse: collapse; width: 100%; } .vxe-table--print-body td, .vxe-table--print-body th { border: 1px solid #000; padding: 8px; page-break-inside: avoid; page-break-after: auto; } .total-row td { font-weight: bold; background-color: #f5f5f5 !important; -webkit-print-color-adjust: exact; } /* 隐藏不需要打印的元素 */ .no-print { display: none !important; } /* 确保表格不被分页截断 */ table { page-break-inside: avoid; } tr { page-break-inside: avoid; page-break-after: auto; } }4.4 实现打印预览功能
除了直接打印,我们还可以提供打印预览功能,让用户在打印前确认效果:
const handlePrintPreview = async () => { const $grid = gridRef.value if (!$grid) return try { // 获取打印HTML const printHTML = await $grid.getPrintHtml(printConfig) // 在新窗口中打开预览 const previewWindow = window.open('', '_blank') if (previewWindow) { previewWindow.document.write(` <!DOCTYPE html> <html> <head> <title>凭证打印预览</title> <style> ${printConfig.styles.join('\n')} @media screen { body { padding: 20px; background: #f5f5f5; } .preview-container { background: white; padding: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); max-width: 210mm; margin: 0 auto; } .print-actions { position: fixed; top: 20px; right: 20px; z-index: 1000; } } </style> </head> <body> <div class="print-actions"> <button onclick="window.print()" style="padding: 10px 20px; background: #409eff; color: white; border: none; border-radius: 4px; cursor: pointer; margin-right: 10px;"> 打印 </button> <button onclick="window.close()" style="padding: 10px 20px; background: #f56c6c; color: white; border: none; border-radius: 4px; cursor: pointer;"> 关闭 </button> </div> <div class="preview-container"> ${printHTML} </div> </body> </html> `) previewWindow.document.close() } } catch (error) { console.error('打印预览失败:', error) VxeUI.modal.message({ content: '打印预览生成失败', status: 'error' }) } }5. 高级功能:数据验证、导入导出与性能优化
一个完整的财务凭证模块还需要考虑数据验证、批量操作和性能优化。这些功能虽然不直接体现在UI上,但对用户体验至关重要。
5.1 实现严格的数据验证
财务数据容不得半点差错,我们需要在用户输入时进行实时验证:
const validationRules = { summary: [ { required: true, message: '摘要不能为空' }, { max: 100, message: '摘要长度不能超过100字符' } ], subject: [ { required: true, message: '会计科目不能为空' }, { validator: (value) => { // 验证科目代码格式 return /^[A-Za-z0-9]{4,10}$/.test(value) }, message: '科目代码格式不正确' } ], debtorObj: [ { validator: (value) => validateAmountObject(value), message: '借方金额格式错误' } ], creditObj: [ { validator: (value) => validateAmountObject(value), message: '贷方金额格式错误' } ] } // 实时验证函数 const validateRow = (row) => { const errors = [] Object.keys(validationRules).forEach(field => { const rules = validationRules[field] const value = row[field] rules.forEach(rule => { if (rule.required && (!value || (typeof value === 'string' && value.trim() === ''))) { errors.push({ field, message: rule.message }) } if (rule.max && value && value.length > rule.max) { errors.push({ field, message: rule.message }) } if (rule.validator && !rule.validator(value)) { errors.push({ field, message: rule.message }) } }) }) // 检查借贷平衡 if (row.debtorAmount === 0 && row.creditAmount === 0) { errors.push({ field: 'amount', message: '借方和贷方不能同时为0' }) } return errors } // 批量验证所有行 const validateAllRows = () => { const allErrors = [] voucherData.value.forEach((row, index) => { const errors = validateRow(row) if (errors.length > 0) { allErrors.push({ rowIndex: index + 1, errors }) } }) // 检查整体平衡 if (!isBalanced.value) { allErrors.push({ rowIndex: '合计', errors: [{ field: 'balance', message: '借贷不平衡' }] }) } return allErrors }5.2 实现Excel导入导出
财务人员经常需要从Excel导入数据或导出凭证:
const exportToExcel = () => { const $grid = gridRef.value if (!$grid) return const exportConfig = { filename: `记账凭证_${formatDate(new Date(), 'YYYYMMDD')}`, sheetName: '凭证数据', types: ['xlsx'], original: true, modes: ['current', 'selected'], columns: columns.filter(col => !['operate', 'checked', 'checked2'].includes(col.type)) } $grid.exportData(exportConfig) } const importFromExcel = async (file) => { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = (e) => { try { const data = new Uint8Array(e.target.result) const workbook = XLSX.read(data, { type: 'array' }) const firstSheet = workbook.Sheets[workbook.SheetNames[0]] const jsonData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 }) // 转换Excel数据为凭证格式 const importedData = jsonData.slice(1) // 跳过标题行 .filter(row => row.length >= 4) .map((row, index) => ({ id: Date.now() + index, seq: index + 1, summary: row[0] || '', subject: row[1] || '', debtorAmount: parseFloat(row[2]) || 0, creditAmount: parseFloat(row[3]) || 0, debtorObj: splitAmount(parseFloat(row[2]) || 0), creditObj: splitAmount(parseFloat(row[3]) || 0) })) resolve(importedData) } catch (error) { reject(new Error('Excel文件解析失败')) } } reader.onerror = () => { reject(new Error('文件读取失败')) } reader.readAsArrayBuffer(file) }) }5.3 性能优化策略
当凭证行数较多时(比如超过100行),我们需要考虑性能优化:
// 虚拟滚动配置 const scrollConfig = { enabled: true, gt: 50, // 超过50行启用虚拟滚动 mode: 'wheel', scrollToTopOnChange: true } // 数据分页 const paginationConfig = { enabled: true, pageSize: 20, pageSizes: [10, 20, 50, 100], layouts: ['PrevJump', 'PrevPage', 'Number', 'NextPage', 'NextJump', 'Sizes', 'FullJump', 'Total'] } // 防抖处理金额计算 const debouncedUpdateTotals = debounce(() => { updateTotals() }, 300) // 使用计算属性缓存合计结果 const cachedTotals = computed(() => { return { debtor: voucherData.value.reduce((sum, row) => sum + (row.debtorAmount || 0), 0), credit: voucherData.value.reduce((sum, row) => sum + (row.creditAmount || 0), 0) } }) // 懒加载列配置 const getColumnConfig = (useVirtualScroll) => { const baseColumns = [ { type: 'seq', width: 60, fixed: 'left' }, { field: 'summary', title: '摘要', minWidth: 180, fixed: 'left' }, { field: 'subject', title: '会计科目', minWidth: 200 } ] if (!useVirtualScroll) { // 非虚拟滚动时显示完整金额列 baseColumns.push( createAmountColumn('debtorObj', '借方金额'), { field: 'checked', title: '√', width: 40 }, createAmountColumn('creditObj', '贷方金额'), { field: 'checked2', title: '√', width: 40 } ) } else { // 虚拟滚动时简化列显示 baseColumns.push( { field: 'debtorAmount', title: '借方金额', width: 120, formatter: ({ cellValue }) => formatAmount(cellValue) }, { field: 'creditAmount', title: '贷方金额', width: 120, formatter: ({ cellValue }) => formatAmount(cellValue) } ) } return baseColumns }6. 完整组件集成与最佳实践
最后,我们将所有功能集成到一个完整的Vue组件中,并提供一些实际使用中的最佳实践建议。
6.1 主组件实现
<template> <div class="voucher-container"> <!-- 工具栏 --> <div class="toolbar"> <vxe-button status="primary" @click="handleAdd">新增凭证行</vxe-button> <vxe-button @click="handleSave">保存凭证</vxe-button> <vxe-button @click="handlePrint">打印</vxe-button> <vxe-button @click="handlePrintPreview">打印预览</vxe-button> <vxe-button @click="exportToExcel">导出Excel</vxe-button> <div class="toolbar-right"> <span>借贷平衡: </span> <span :class="['balance-status', isBalanced ? 'balanced' : 'unbalanced']"> {{ isBalanced ? '已平衡' : '不平衡' }} </span> <span class="total-amount"> 借方合计: {{ formatAmount(debtorTotal) }} 贷方合计: {{ formatAmount(creditTotal) }} </span> </div> </div> <!-- 凭证表格 --> <vxe-grid ref="gridRef" v-bind="gridOptions" :columns="columns" :data="voucherData" :scroll-config="scrollConfig" :pager-config="paginationConfig" @edit-closed="handleEditClosed" > <!-- 自定义表尾 --> <template #footer> <div class="custom-footer"> <div class="footer-row"> <div class="footer-label">合计:</div> <div class="footer-amount"> {{ formatAmount(debtorTotal) }} </div> <div class="footer-check"></div> <div class="footer-amount"> {{ formatAmount(creditTotal) }} </div> <div class="footer-check"></div> </div> </div> </template> </vxe-grid> <!-- 凭证信息 --> <div class="voucher-info"> <div class="info-item"> <label>凭证号:</label> <vxe-input v-model="voucherNumber" placeholder="请输入凭证号"></vxe-input> </div> <div class="info-item"> <label>日期:</label> <vxe-input v-model="voucherDate" placeholder="选择日期"></vxe-input> </div> <div class="info-item"> <label>附单据数:</label> <vxe-input v-model="attachmentCount" type="number" min="0"></vxe-input> </div> </div> <!-- 审批信息 --> <div class="approval-info"> <div class="approver"> <div>财务主管:{{ financialManager }}</div> <div class="signature-line"></div> </div> <div class="approver"> <div>记账:{{ accountant }}</div> <div class="signature-line"></div> </div> <div class="approver"> <div>出纳:{{ cashier }}</div> <div class="signature-line"></div> </div> <div class="approver"> <div>审核:{{ auditor }}</div> <div class="signature-line"></div> </div> </div> </div> </template> <script setup> import { ref, computed, onMounted } from 'vue' import { useVoucherData } from './hooks/useVoucherData' import { useAmountHandler } from './hooks/useAmountHandler' import { useVoucherTableConfig } from './configs/voucherTableConfig' // 初始化数据 const initialData = [ { id: 1, summary: '购买办公用品', subject: '管理费用-办公费', debtorAmount: 1200.00, creditAmount: 0 }, { id: 2, summary: '购买办公用品', subject: '银行存款', debtorAmount: 0, creditAmount: 1200.00 } ] // 使用自定义hook const { voucherData, debtorTotal, creditTotal, isBalanced, addRow, updateRow } = useVoucherData(initialData) const { formatAmount } = useAmountHandler() const { gridRef, baseConfig, columns } = useVoucherTableConfig() // 凭证信息 const voucherNumber = ref('记-001') const voucherDate = ref(new Date().toLocaleDateString()) const attachmentCount = ref(1) const financialManager = ref('张主管') const accountant = ref('李会计') const cashier = ref('王出纳') const auditor = ref('赵审核') // 表格配置 const gridOptions = { ...baseConfig, printConfig: { sheetName: '记账凭证', beforePrintMethod: ({ html }) => customizePrintHTML(html), styles: [ // 打印样式... ] } } // 事件处理 const handleAdd = () => { const newRow = addRow() // 滚动到最后一行 nextTick(() => { const $grid = gridRef.value if ($grid) { $grid.scrollToRow(newRow) } }) } const handleSave = () => { const errors = validateAllRows() if (errors.length > 0) { VxeUI.modal.message({ content: `发现${errors.length}处错误,请检查`, status: 'warning' }) return } // 保存逻辑... VxeUI.modal.message({ content: '保存成功', status: 'success' }) } const handlePrint = () => { const $grid = gridRef.value if ($grid) { $grid.print(gridOptions.printConfig) } } const handleEditClosed = ({ row, column }) => { // 编辑完成后更新数据 updateRow(row.id, { [column.field]: row[column.field] }) } // 初始化 onMounted(() => { // 可以在这里加载远程数据 console.log('凭证组件已加载') }) </script> <style scoped> .voucher-container { padding: 20px; background: #fff; } .toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding: 10px; background: #f5f5f5; border-radius: 4px; } .toolbar-right { display: flex; align-items: center; gap: 20px; } .balance-status { padding: 4px 8px; border-radius: 4px; font-weight: bold; } .balance-status.balanced { background: #e1f3d8; color: #67c23a; } .balance-status.unbalanced { background: #fde2e2; color: #f56c6c; } .voucher-info { display: flex; gap: 20px; margin: 20px 0; padding: 15px; background: #f9f9f9; border-radius: 4px; } .info-item { display: flex; align-items: center; gap: 8px; } .approval-info { display: flex; justify-content: space-between; margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd; } .approver { text-align: center; min-width: 150px; } .signature-line { width: 100px; height: 1px; background: #000; margin: 20px auto 0; } .custom-footer { background: #f5f5f5; padding: 10px; border-top: 2px solid #ddd; } .footer-row { display: flex; align-items: center; } .footer-label { width: 100px; font-weight: bold; } .footer-amount { flex: 1; text-align: center; font-weight: bold; } .footer-check { width: 40px; } </style>6.2 实际项目中的经验分享
在多个财务系统项目中实施这个凭证模块后,我总结了一些实用经验:
关于性能:当凭证行数超过200行时,建议启用虚拟滚动。vxe-table的虚拟滚动性能很好,但要注意固定列的数量不要太多,否则会影响渲染性能。
关于打印:不同浏览器的打印效果可能有差异。Chrome对CSS打印支持最好,如果需要兼容其他浏览器,建议提供PDF导出作为备选方案。可以使用html2canvas + jspdf生成PDF,虽然效果不如直接打印,但能保证一致性。
关于数据验证:除了前端验证,后端一定要做双重验证。金额计算要使用高精度库,避免JavaScript浮点数精度问题。我在项目中遇到过0.1 + 0.2 ≠ 0.3的经典问题,最后用decimal.js解决了。
关于用户体验:财务人员往往习惯键盘操作,所以要充分利用vxe-table的键盘配置。我通常会把Tab键设置为跳到下一个可编辑单元格,Enter键保存当前行并新增下一行,这样能大幅提升录入效率。
关于错误处理:金额输入时经常会出现格式错误,最好的做法是实时验证并给出明确提示。我在每个金额单元格旁边添加了一个小的提示图标,鼠标悬停时显示具体的错误信息,这样用户能立即知道问题所在。
这个凭证模块现在已经在我们公司的三个财务系统中稳定运行,每天处理上千张凭证的录入和打印。最让我满意的是它的可维护性——当财务制度变化需要调整凭证格式时,我只需要修改配置,不需要重写核心逻辑。
