项目介绍:餐补发票报销管理系统
源码地址: gitee.com/lee-good/ex…
技术栈: Vue 3 + TypeScript + Vite + Pinia + NestJS + Prisma + SQLite
角色: 独立开发(全栈)
项目背景
这是我独立开发的一个发票报销管理工具,起因是公司里每个月报销发票太麻烦——纸质发票容易丢、电子发票散落在各个邮箱和微信里、手动填 Excel 容易错、领导审批流程慢。我想做一个能自动识别发票信息、统一管理、一键生成报销单的工具。
项目从 2024 年底开始,断断续续做了两个月,现在已经是团队内部每天都在用的工具。
核心功能
1. 发票管理
系统支持两种发票录入方式:
自动识别上传:支持 XML 和 PDF 格式的电子发票,上传后自动解析发票代码、发票号码、销方名称、含税金额、开票日期等关键字段。
手动录入:对于无法自动识别的发票(比如纸质发票拍照),提供手动录入表单。
发票状态流转:
UNUSED(未使用):刚录入,尚未关联报销单LINKED(已关联):已加入某张报销单USED(已使用):报销单已提交,发票正式归档EXPIRED(已过期):超过系统配置的发票过期月数,自动标记
发票列表支持按日期范围筛选、按状态过滤,顶部统计卡片实时显示总金额、各状态发票数量和金额。
2. 报销单管理
按月创建报销单,每张报销单包含:
- 目标金额:预设的月度报销预算
- 实际金额:已关联发票的合计金额
- 金额差:实际与目标的差额(正数表示超支)
- 关联发票:从发票池中选择未使用的发票关联到报销单
报销单状态流转:
DRAFT(创建) → PENDING(待提交) → SUBMITTED(已提交) → COMPLETED(完成)
↑___________________________________________|
(支持撤销回 DRAFT)
3. 智能匹配(动态规划算法)
这是我最得意的功能。用户设定目标金额后,系统从可用发票中自动选出最优组合,使得总金额最接近且不低于目标金额。
核心算法是0/1 背包问题的变种:
// backend/src/expense-claim/expense-claim.service.ts
async match(userId: number, claimId: number) {
// 将金额转为整数分避免浮点问题
const amounts = validInvoices.map((inv) => Math.round(inv.total_amount * 100));
const target = Math.round(targetAmount * 100);
const maxSum = amounts.reduce((a, b) => a + b, 0);
// 动态规划:dp[i] = 达到金额i的最小总金额
const dpSize = target + maxSum + 1;
const dp = new Array(dpSize).fill(Infinity);
dp[0] = 0;
// chosen[i] 记录达到金额i时最后选中的发票索引
const chosen = new Array(dpSize).fill(-1);
for (let i = 0; i < amounts.length; i++) {
for (let j = dpSize - 1; j >= amounts[i]; j--) {
if (dp[j - amounts[i]] + amounts[i] < dp[j]) {
dp[j] = dp[j - amounts[i]] + amounts[i];
chosen[j] = i;
}
}
}
// 找到 >= target 的最小值
let bestAmount = -1;
for (let i = target; i < dpSize; i++) {
if (dp[i] < Infinity) {
bestAmount = i;
break;
}
}
// 回溯找出选中的发票索引
const selectedIndices = new Set<number>();
let current = bestAmount;
while (current > 0 && chosen[current] !== -1) {
selectedIndices.add(chosen[current]);
current -= amounts[chosen[current]];
}
}
为什么用动态规划而不是贪心?因为发票金额不是固定的"物品价值",我们需要的是组合后的总金额刚好覆盖目标,而不是单张发票越大越好。贪心算法可能会选一张大额发票导致严重超支,而 DP 能找到最接近目标的组合。
4. AI 配置管理
系统预留了 AI 能力配置模块,目前接入了 MiniMax 的 Claude 兼容协议 API,用于:
- 发票信息的智能校验
- 报销规则的智能提示
- 异常发票的自动标记
配置项包括:协议类型、模型名称、API 地址、激活状态,支持多配置切换。
5. 系统配置
- 发票过期月数:超过设定月数的发票自动标记为
EXPIRED,防止积压
技术架构
前端
frontend/
├── src/
│ ├── api/ # Axios 客户端 + 响应拦截器
│ ├── components/ # 公共组件(NeoButton、NeoTable、NeoInput 等)
│ ├── composables/ # 组合式函数(useMessage、useConfirm)
│ ├── views/ # 页面级组件
│ │ ├── Dashboard/ # 仪表盘
│ │ ├── InvoiceList/ # 发票管理
│ │ ├── InvoiceUpload/ # 上传发票
│ │ ├── ClaimList/ # 报销单列表
│ │ ├── ClaimDetail/ # 报销单详情
│ │ └── Settings/ # 系统设置
│ ├── stores/ # Pinia 状态管理
│ │ └── auth.ts # 认证状态
│ ├── router/ # Vue Router 配置
│ └── main.ts
技术选型理由:
| 技术 | 选择理由 |
|---|---|
| Vue 3 + Composition API | 逻辑复用方便,TypeScript 支持好 |
| Vite | 冷启动快,HMR 体验好 |
| Pinia | 比 Vuex 简洁,Setup Store 写法直观 |
| 自研 Neo 组件库 | 新粗野主义(Neo-Brutalism)设计风格,统一的 3px 黑边框 + 硬阴影 |
| Lucide Vue | 图标库,轻量且风格统一 |
| Day.js | 日期处理,比 moment 小很多 |
后端
backend/
├── src/
│ ├── auth/ # JWT 认证 + Passport
│ ├── invoice/ # 发票管理(上传、解析、CRUD)
│ ├── expense-claim/ # 报销单模块(含智能匹配算法)
│ ├── admin/ # AI/系统配置
│ └── prisma/ # Prisma 服务
├── prisma/
│ ├── schema.prisma # 数据库模型
│ └── seed.ts # 种子数据
└── uploads/ # 上传的发票文件
技术选型理由:
| 技术 | 选择理由 |
|---|---|
| NestJS | 模块化架构,装饰器语法优雅,适合中大型项目 |
| Prisma | 类型安全的 ORM,迁移方便 |
| SQLite | 单机部署简单,不需要额外数据库服务 |
| JWT + Passport | 无状态认证,前后端分离标准方案 |
| Archiver | 报销单打包下载(发票 PDF + XML 归档) |
数据库模型
model User {
id Int @id @default(autoincrement())
username String @unique
password_hash String
role String @default("USER")
invoices Invoice[]
expense_claims ExpenseClaim[]
}
model Invoice {
id Int @id @default(autoincrement())
user_id Int
invoice_code String
invoice_number String
amount Float
tax_amount Float
total_amount Float
invoice_date DateTime
seller_name String
seller_tax_no String
file_path String
status String @default("UNUSED")
user User @relation(fields: [user_id], references: [id])
expense_claim_items ExpenseClaimItem[]
}
model ExpenseClaim {
id Int @id @default(autoincrement())
user_id Int
month String
target_amount Float
actual_total Float @default(0)
over_amount Float @default(0)
status String @default("DRAFT")
items ExpenseClaimItem[]
}
model ExpenseClaimItem {
id Int @id @default(autoincrement())
claim_id Int
invoice_id Int
claim ExpenseClaim @relation(fields: [claim_id], references: [id])
invoice Invoice @relation(fields: [invoice_id], references: [id])
}
关键实现细节
1. 响应拦截器:下划线转驼峰
后端用 snake_case,前端用 camelCase,我在 Axios 响应拦截器里做了自动转换:
// frontend/src/api/index.ts
function convertKeysToCamel(obj: any): any {
if (obj === null || obj === undefined) return obj;
if (Array.isArray(obj)) return obj.map(convertKeysToCamel);
if (typeof obj === "object") {
const aliasMap: Record<string, string> = {
actual_total: "actualAmount",
over_amount: "excessAmount",
invoice_code: "invoiceCode",
invoice_number: "invoiceNumber",
// ... 其他字段映射
};
return Object.keys(obj).reduce((result, key) => {
const camelKey = snakeToCamel(key);
const aliasKey = aliasMap[key] || camelKey;
result[aliasKey] = convertKeysToCamel(obj[key]);
return result;
}, {} as any);
}
return obj;
}
request.interceptors.response.use(
(response: AxiosResponse) => {
return convertKeysToCamel(response.data);
}
);
这样前端消费 API 时完全不用关心后端的命名规范,直接 claim.actualAmount 就行。
2. 金额精度处理
发票金额涉及财务计算,精度不能丢:
// 将金额转为整数分避免浮点问题
const amounts = validInvoices.map((inv) => Math.round(inv.total_amount * 100));
const target = Math.round(targetAmount * 100);
所有金额计算在"分"的维度上进行,最后展示时再除以 100。
3. 自研 Neo 组件库
项目没有引入 Element Plus 或 Ant Design,而是自研了一套新粗野主义风格的组件:
<!-- NeoButton -->
<button class="neo-btn neo-btn--accent">
上传发票
</button>
<style>
.neo-btn {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 700;
border: 3px solid #1a1a1a;
border-radius: 16px;
box-shadow: 4px 4px 0px #1a1a1a;
transition: all 0.15s ease;
}
.neo-btn:hover {
transform: translate(2px, 2px);
box-shadow: 2px 2px 0px #1a1a1a;
}
</style>
特点:
- 3px 纯黑边框
- 硬阴影(不模糊)
- 高对比度配色(主色
#a4e853荧光绿) - 悬停时按钮"按下"的位移动效
4. 发票 XML 解析
电子发票的 XML 结构不统一,不同地区、不同版本的格式有差异:
// 支持多种 XML 命名空间
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_'
});
const parsed = parser.parse(xml);
// 尝试多种路径获取字段
const code = getField(parsed, ['Invoice.Code', '发票代码', '@_发票代码']);
const number = getField(parsed, ['Invoice.Number', '发票号码', '@_发票号码']);
5. 状态管理的精简设计
没有滥用 Pinia,只有认证状态用全局 Store,其他状态在组件内用 ref/reactive 管理:
// stores/auth.ts - 唯一全局 Store
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null);
const token = ref<string>(localStorage.getItem('token') || '');
const isLoggedIn = computed(() => !!token.value);
const isAdmin = computed(() => user.value?.role === 'ADMIN');
async function login(username: string, password: string) {
const data = await loginApi({ username, password });
token.value = data.accessToken;
user.value = data.user;
localStorage.setItem('token', data.accessToken);
localStorage.setItem('user', JSON.stringify(data.user));
}
function logout() {
token.value = '';
user.value = null;
localStorage.removeItem('token');
localStorage.removeItem('user');
}
return { user, token, isLoggedIn, isAdmin, login, logout };
});
遇到的挑战
1. 发票格式兼容性
电子发票有 PDF 版式文件、XML 数据文件、OFD 格式等多种形态。第一阶段我只支持了 XML 和 PDF,OFD 格式因为解析库不成熟暂时放弃。
解决方案:优先支持最常见的 XML 格式(税务局标准版式),PDF 提取文本做 fallback。
2. 大文件上传
有些 PDF 发票扫描件 10MB+,直接上传会超时。
解决方案:
- 前端用 Axios 的
onUploadProgress显示进度条 - 后端用 Multer 分片接收
- Nginx 配置
client_max_body_size 50M
3. 数据安全
发票包含敏感信息(税号、金额、交易对手)。
解决方案:
- JWT Token 过期机制(7 天)
- 发票文件存储在私有目录,通过 API 鉴权访问
- 数据库敏感字段加密存储
4. 动态规划算法的性能
发票数量多的时候,DP 数组会很大。
解决方案:
- 金额转"分"后,DP 数组大小是
target + maxSum - 实际场景中发票数量通常不超过 50 张,金额不超过几千元,性能完全够用
- 如果未来发票量增大,可以考虑贪心 + DP 的混合策略
项目截图
(以下为系统主要页面截图,展示新粗野主义设计风格)
登录页
登录页采用简洁的左右分栏布局,左侧展示系统品牌信息,右侧为登录表单。新粗野主义风格的 3px 黑色边框和硬阴影贯穿整个设计。
仪表盘
仪表盘展示四张统计卡片:本月发票数、已报销金额、待处理报销单、可用发票数。卡片采用白色背景 + 3px 黑边框 + 4px 硬阴影,悬停时有位移动效。
发票管理
发票列表页展示发票代码、发票号码、销方名称、含税金额、开票日期、状态等信息。顶部统计栏显示总金额、各状态发票数量和金额。支持按日期范围和状态筛选。
报销单管理
报销单列表页展示月份、目标金额、实际金额、金额差、状态等信息。支持创建新的报销单和查看详情。
上传发票
上传页面支持拖拽上传 XML 和 PDF 格式的电子发票,上传后自动解析发票信息。
系统设置
系统设置页包含 AI 配置管理和系统参数配置,支持配置发票过期月数等参数。
- 动态规划算法解决实际业务问题:不是炫技,是真的能算出最优发票组合
- 自研 Neo 组件库:新粗野主义设计风格,从 0 写了 Button、Table、Input、Modal 等 10+ 组件
- 响应拦截器自动转换命名规范:前后端字段命名不统一的问题一次性解决
- 金额精度处理:整数分存储,避免 JavaScript 浮点数陷阱
- 模块化 NestJS 架构:发票、报销单、认证、配置四个模块完全解耦
后续规划
- OCR 识别:接入多模态 AI,支持拍照发票的自动识别
- 数据导出:支持导出 Excel、PDF 报销单
- 移动端适配:目前只有桌面端,计划用响应式布局适配手机
总结
这个项目是我"vibe coding"风格的典型代表——从一个真实痛点出发,快速验证 MVP,然后逐步迭代。技术选型不求最新,只求最合适:Vue 3 + Pinia 做状态管理够用了,NestJS 的模块化让后端代码很清晰,SQLite 单机部署零成本。