添加Excel记账模板生成脚本和下载入口

This commit is contained in:
a273825743
2026-05-16 12:48:59 +08:00
parent ac7b616d02
commit adef7b5af2
5 changed files with 298 additions and 1 deletions
+1
View File
@@ -12,6 +12,7 @@
"cors": "^2.8.6", "cors": "^2.8.6",
"cos-nodejs-sdk-v5": "^2.15.4", "cos-nodejs-sdk-v5": "^2.15.4",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"exceljs": "^4.4.0",
"express": "^4.18.2", "express": "^4.18.2",
"express-validator": "^7.3.1", "express-validator": "^7.3.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
+292
View File
@@ -0,0 +1,292 @@
const ExcelJS = require('exceljs');
const path = require('path');
const db = require('../db');
async function generateTemplate() {
const workbook = new ExcelJS.Workbook();
workbook.creator = '公司财务管理系统';
workbook.created = new Date();
const projectResult = await db.query('SELECT name FROM projects ORDER BY name');
const projectNames = projectResult.rows.map(r => r.name);
const categoryResult = await db.query('SELECT * FROM expense_categories WHERE is_active = true ORDER BY category_level1, sort_order');
const categories = categoryResult.rows;
const incomeCategories = categories.filter(c => c.category_level1 === 'income').map(c => c.label);
const projectCategories = categories.filter(c => c.category_level1 === 'project').map(c => c.label);
const companyCategories = categories.filter(c => c.category_level1 === 'company').map(c => c.label);
// ========== Sheet2: 项目名称列表 ==========
const sheet2 = workbook.addWorksheet('项目列表', {
properties: { tabColor: { argb: '4472C4' } }
});
sheet2.getColumn(1).width = 40;
sheet2.getCell('A1').value = '项目名称';
sheet2.getCell('A1').font = { bold: true, size: 12, color: { argb: 'FFFFFF' } };
sheet2.getCell('A1').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: '4472C4' } };
sheet2.getCell('A1').alignment = { horizontal: 'center' };
projectNames.forEach((name, i) => {
const cell = sheet2.getCell(`A${i + 2}`);
cell.value = name;
cell.border = { bottom: { style: 'thin', color: { argb: 'D9D9D9' } } };
});
sheet2.getCell('C1').value = '收支类型';
sheet2.getCell('C1').font = { bold: true };
['收入', '支出'].forEach((v, i) => { sheet2.getCell(`C${i + 2}`).value = v; });
sheet2.getCell('D1').value = '一级分类';
sheet2.getCell('D1').font = { bold: true };
['收入', '项目支出', '公司支出'].forEach((v, i) => { sheet2.getCell(`D${i + 2}`).value = v; });
sheet2.getCell('F1').value = '收入分类';
sheet2.getCell('F1').font = { bold: true };
incomeCategories.forEach((v, i) => { sheet2.getCell(`F${i + 2}`).value = v; });
sheet2.getCell('G1').value = '项目支出分类';
sheet2.getCell('G1').font = { bold: true };
projectCategories.forEach((v, i) => { sheet2.getCell(`G${i + 2}`).value = v; });
sheet2.getCell('H1').value = '公司支出分类';
sheet2.getCell('H1').font = { bold: true };
companyCategories.forEach((v, i) => { sheet2.getCell(`H${i + 2}`).value = v; });
sheet2.getCell('J1').value = '币种';
sheet2.getCell('J1').font = { bold: true };
['CNY', 'LAK', 'USD', 'THB'].forEach((v, i) => { sheet2.getCell(`J${i + 2}`).value = v; });
sheet2.getCell('K1').value = '对方类型';
sheet2.getCell('K1').font = { bold: true };
['供应商', '分包商', '客户', '员工', '物流公司', '股东', '其他'].forEach((v, i) => { sheet2.getCell(`K${i + 2}`).value = v; });
const projectListEnd = Math.max(projectNames.length + 1, 2);
const categoryListEnd = Math.max(incomeCategories.length, projectCategories.length, companyCategories.length) + 1;
// ========== Sheet1: 记账主表 ==========
const sheet1 = workbook.addWorksheet('财务记账', {
properties: { tabColor: { argb: '52C41A' } }
});
const headers = [
{ key: 'date', header: '日期', width: 13 },
{ key: 'txn_type', header: '收支类型', width: 11 },
{ key: 'level1', header: '一级分类', width: 11 },
{ key: 'level2', header: '二级分类', width: 14 },
{ key: 'project', header: '项目名称', width: 35 },
{ key: 'amount', header: '金额', width: 14 },
{ key: 'currency', header: '币种', width: 8 },
{ key: 'rate', header: '汇率', width: 10 },
{ key: 'amount_cny', header: '等效人民币', width: 14 },
{ key: 'counterparty', header: '对方名称', width: 18 },
{ key: 'counterparty_type', header: '对方类型', width: 10 },
{ key: 'user', header: '人员姓名', width: 10 },
{ key: 'desc', header: '描述', width: 30 },
{ key: 'voucher', header: '凭证编号', width: 14 },
];
sheet1.columns = headers;
const headerRow = sheet1.getRow(1);
headerRow.height = 28;
headerRow.eachCell((cell) => {
cell.font = { bold: true, size: 11, color: { argb: 'FFFFFF' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: '4472C4' } };
cell.alignment = { horizontal: 'center', vertical: 'middle' };
cell.border = {
top: { style: 'thin' }, bottom: { style: 'thin' },
left: { style: 'thin' }, right: { style: 'thin' }
};
});
sheet1.views = [{ state: 'frozen', ySplit: 1 }];
const dataRows = 500;
for (let i = 2; i <= dataRows + 1; i++) {
const row = sheet1.getRow(i);
// I列: 等效人民币 = 金额 × 汇率(自动计算)
const cnyCell = row.getCell(9);
cnyCell.value = { formula: `F${i}*H${i}` };
cnyCell.numFmt = '#,##0.00';
cnyCell.protection = { locked: true };
// 金额列格式
row.getCell(6).numFmt = '#,##0.00';
// 汇率列格式
row.getCell(8).numFmt = '0.000000';
// 日期列格式
row.getCell(1).numFmt = 'YYYY-MM-DD';
// 交替行背景色
if (i % 2 === 0) {
row.eachCell({ includeEmpty: true }, (cell) => {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'F2F7FB' } };
});
}
// 边框
row.eachCell({ includeEmpty: true }, (cell) => {
cell.border = {
bottom: { style: 'thin', color: { argb: 'D9D9D9' } },
vertical: { style: 'thin', color: { argb: 'D9D9D9' } }
};
});
}
// 数据验证 - 收支类型
sheet1.getDataValidator = function() {};
for (let i = 2; i <= dataRows + 1; i++) {
// B列: 收支类型
sheet1.getCell(`B${i}`).dataValidation = {
type: 'list',
allowBlank: true,
formulae: ['"收入,支出"'],
showErrorMessage: true,
errorTitle: '输入错误',
error: '请从下拉列表中选择:收入 或 支出'
};
// C列: 一级分类
sheet1.getCell(`C${i}`).dataValidation = {
type: 'list',
allowBlank: true,
formulae: ['"收入,项目支出,公司支出"'],
showErrorMessage: true,
errorTitle: '输入错误',
error: '请从下拉列表中选择'
};
// D列: 二级分类(收入+项目+公司 全部列出,用户根据一级分类选择对应的)
const allCategories = [...incomeCategories, ...projectCategories, ...companyCategories];
const catStr = allCategories.join(',');
sheet1.getCell(`D${i}`).dataValidation = {
type: 'list',
allowBlank: true,
formulae: [`"${catStr}"`],
showErrorMessage: true,
errorTitle: '输入错误',
error: '请从下拉列表中选择二级分类'
};
// E列: 项目名称(从Sheet2引用)
sheet1.getCell(`E${i}`).dataValidation = {
type: 'list',
allowBlank: true,
formulae: [`项目列表!$A$2:$A$${projectListEnd}`],
showErrorMessage: true,
errorTitle: '输入错误',
error: '请从下拉列表中选择项目名称'
};
// G列: 币种
sheet1.getCell(`G${i}`).dataValidation = {
type: 'list',
allowBlank: true,
formulae: ['"CNY,LAK,USD,THB"'],
showErrorMessage: true,
errorTitle: '输入错误',
error: '请选择币种:CNY / LAK / USD / THB'
};
// K列: 对方类型
sheet1.getCell(`K${i}`).dataValidation = {
type: 'list',
allowBlank: true,
formulae: ['"供应商,分包商,客户,员工,物流公司,股东,其他"'],
showErrorMessage: true,
errorTitle: '输入错误',
error: '请从下拉列表中选择对方类型'
};
// H列: 汇率 - CNY时自动填1
// A列: 日期验证
sheet1.getCell(`A${i}`).dataValidation = {
type: 'date',
allowBlank: true,
operator: 'greaterThan',
formulae: [new Date(2020, 0, 1)],
showErrorMessage: true,
errorTitle: '日期格式错误',
error: '请输入有效日期,格式:YYYY-MM-DD'
};
}
// 示例数据(第2行)
const exampleRow = sheet1.getRow(2);
exampleRow.getCell(1).value = new Date(2026, 2, 15);
exampleRow.getCell(2).value = '支出';
exampleRow.getCell(3).value = '项目支出';
exampleRow.getCell(4).value = '材料采购';
exampleRow.getCell(5).value = projectNames[0] || '';
exampleRow.getCell(6).value = 150000;
exampleRow.getCell(7).value = 'CNY';
exampleRow.getCell(8).value = 1;
exampleRow.getCell(10).value = '云南XX电力器材有限公司';
exampleRow.getCell(11).value = '供应商';
exampleRow.getCell(13).value = '采购190×12m电杆50根';
// 示例数据(第3行)
const exampleRow2 = sheet1.getRow(3);
exampleRow2.getCell(1).value = new Date(2026, 2, 20);
exampleRow2.getCell(2).value = '支出';
exampleRow2.getCell(3).value = '项目支出';
exampleRow2.getCell(4).value = '食宿费用';
exampleRow2.getCell(5).value = projectNames[0] || '';
exampleRow2.getCell(6).value = 100000;
exampleRow2.getCell(7).value = 'LAK';
exampleRow2.getCell(8).value = 0.0003;
exampleRow2.getCell(10).value = 'XX招待所';
exampleRow2.getCell(11).value = '其他';
exampleRow2.getCell(12).value = '李四';
exampleRow2.getCell(13).value = '3月驻现场住宿费';
// 示例数据(第4行)
const exampleRow3 = sheet1.getRow(4);
exampleRow3.getCell(1).value = new Date(2026, 1, 20);
exampleRow3.getCell(2).value = '收入';
exampleRow3.getCell(3).value = '收入';
exampleRow3.getCell(4).value = '项目合同收款';
exampleRow3.getCell(5).value = projectNames[0] || '';
exampleRow3.getCell(6).value = 300000;
exampleRow3.getCell(7).value = 'CNY';
exampleRow3.getCell(8).value = 1;
exampleRow3.getCell(10).value = '老挝国家电力公司';
exampleRow3.getCell(11).value = '客户';
exampleRow3.getCell(13).value = '合同首付款30%';
// 保护工作表(锁定I列公式)
sheet1.protect('', {
selectLockedCells: true,
selectUnlockedCells: true,
formatCells: true,
formatColumns: true,
formatRows: true,
insertRows: true,
insertColumns: false,
deleteRows: false,
deleteColumns: false,
sort: true,
autoFilter: true,
});
// 设置可编辑列的单元格为未锁定
for (let i = 2; i <= dataRows + 1; i++) {
[1,2,3,4,5,6,7,8,10,11,12,13,14].forEach(col => {
sheet1.getRow(i).getCell(col).protection = { locked: false };
});
}
const outputPath = path.join(__dirname, '..', 'public', 'templates', '财务记账导入模板.xlsx');
const fs = require('fs');
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
await workbook.xlsx.writeFile(outputPath);
console.log('✅ Excel模板已生成:', outputPath);
return outputPath;
}
generateTemplate().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1); });
+5 -1
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Card, Upload, Button, Table, Tag, message, Typography, Space, Alert, Descriptions, Progress, Select, DatePicker } from 'antd'; import { Card, Upload, Button, Table, Tag, message, Typography, Space, Alert, Descriptions, Progress, Select, DatePicker } from 'antd';
import { UploadOutlined, FileExcelOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; import { UploadOutlined, FileExcelOutlined, CheckCircleOutlined, CloseCircleOutlined, DownloadOutlined } from '@ant-design/icons';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import apiClient from '../../utils/request'; import apiClient from '../../utils/request';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -233,6 +233,10 @@ const ExcelImport: React.FC = () => {
<Button icon={<FileExcelOutlined />} type="primary"> Excel </Button> <Button icon={<FileExcelOutlined />} type="primary"> Excel </Button>
</Upload> </Upload>
<a href="/templates/财务记账导入模板.xlsx" download="财务记账导入模板.xlsx" style={{ marginLeft: 12 }}>
<Button icon={<DownloadOutlined />}></Button>
</a>
{parsedData.length > 0 && ( {parsedData.length > 0 && (
<> <>
<div style={{ margin: '16px 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ margin: '16px 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>