feat: 招商银行 PDF 账单导入、跨格式去重与导入性能优化

- 新增招商银行「交易流水」PDF 解析(PDFBox 坐标还原,支持多页与折行),CSV/PDF 去重标识统一,支持跨格式去重
- 账单导入改为批量插入,MyBatis 日志改 Slf4j,导入接口超时放宽至 120s
- 交易导出 Excel 增加样式、冻结表头与自动筛选,导出前二次确认
- 交易列表展示不计入统计金额,导入预览支持设置「计入统计」
This commit is contained in:
2026-09-19 14:30:09 +08:00
parent 9981e77bf1
commit 196afb682d
16 changed files with 1072 additions and 107 deletions
+4 -2
View File
@@ -124,7 +124,8 @@ export function parseBill(file: File, billType: BillType, password?: string) {
if (password) {
formData.append('password', password)
}
return request.post('/transaction/import/parse', formData)
// 大账单解析耗时较长,单独放宽超时
return request.post('/transaction/import/parse', formData, { timeout: 120000 })
}
/** 确认导入用户勾选的账单行 */
@@ -135,7 +136,8 @@ export function confirmImport(data: {
tagIds?: number[]
items: BillImportItem[]
}) {
return request.post('/transaction/import/confirm', data)
// 大批量导入耗时较长,单独放宽超时
return request.post('/transaction/import/confirm', data, { timeout: 120000 })
}
/** 批量设置交易记录的分类(按类型分别应用支出/收入分类) */
+7 -1
View File
@@ -332,6 +332,7 @@ export default {
pageExpense: { zh: '本页支出', en: 'Page Expense', ja: 'このページの支出' },
pageIncome: { zh: '本页收入', en: 'Page Income', ja: 'このページの収入' },
pageBalance: { zh: '本页结余', en: 'Page Balance', ja: 'このページの収支' },
pageExcluded: { zh: '(不计入统计:支出 {expense} · 收入 {income})', en: '(Excluded: expense {expense} · income {income})', ja: '(集計対象外:支出 {expense} · 収入 {income})' },
column: {
index: { zh: '序号', en: 'No.', ja: 'No.' },
transactionTime: { zh: '交易时间', en: 'Time', ja: '取引日時' },
@@ -396,6 +397,8 @@ export default {
batchDeleteNone: { zh: '请先选择要删除的记录', en: 'Please select records to delete', ja: '削除する記録を選択してください' },
batchDeleteConfirm: { zh: '确定删除选中的 {count} 条记录吗?', en: 'Delete the {count} selected record(s)?', ja: '選択した {count} 件を削除しますか?' },
batchDeleted: { zh: '已删除 {count} 条记录', en: 'Deleted {count} record(s)', ja: '{count} 件を削除しました' },
exportConfirmTitle: { zh: '导出确认', en: 'Export Confirmation', ja: 'エクスポート確認' },
exportConfirm: { zh: '确定要按当前筛选条件导出账单吗?', en: 'Export bills with the current filters?', ja: '現在の絞り込み条件で明細をエクスポートしますか?' },
exportSuccess: { zh: '导出成功', en: 'Exported successfully', ja: 'エクスポートしました' },
exportFailed: { zh: '导出失败', en: 'Export failed', ja: 'エクスポートに失敗しました' },
exportFileName: { zh: '交易记录', en: 'transactions', ja: '取引記録' },
@@ -485,7 +488,7 @@ export default {
exportHintALIPAY: { zh: '导出路径:支付宝 App → 我的 → 账单 → 开具交易流水证明 / 账单导出,选择 CSV 格式。', en: 'Export path: Alipay App → Me → Bills → Issue Transaction Record / Export Bill, choose CSV format.', ja: 'エクスポート手順:Alipay アプリ → マイ → 請求 → 取引履歴証明の発行 / 明細のエクスポート で CSV 形式を選択。' },
exportHintWECHAT: { zh: '导出路径:微信 App → 我 → 服务 → 钱包 → 账单 → 常见问题 → 下载账单 → 用于个人对账,选择 xlsx 格式。', en: 'Export path: WeChat App → Me → Services → Wallet → Bills → FAQ → Download Bills → For personal reconciliation, choose xlsx format.', ja: 'エクスポート手順:WeChat アプリ → 自分 → サービス → ウォレット → 請求 → よくある質問 → 請求書のダウンロード → 個人の照合用 で xlsx 形式を選択。' },
exportHintMEITUAN: { zh: '导出路径:美团 App → 我的 → 钱包 → 账单 → 开具账单证明(申请记录),选择 CSV 格式并设置解压密码。', en: 'Export path: Meituan App → Me → Wallet → Bills → Issue Bill Certificate (Application Record), choose CSV format and set an archive password.', ja: 'エクスポート手順:Meituan アプリ → マイ → ウォレット → 請求 → 請求証明の発行(申請履歴)で CSV 形式を選択し、解凍パスワードを設定。' },
exportHintCMB: { zh: '导出路径:招商银行 App → 我的 → 全部 → 交易流水,选择 CSV 格式导出(如需压缩包请设置解压密码)。', en: 'Export path: China Merchants Bank App → Me → All → Transaction Records, choose CSV format (set an archive password if zipped).', ja: 'エクスポート手順:招商銀行アプリ → マイ → すべて → 取引明細 で CSV 形式を選択(圧縮する場合は解凍パスワードを設定)。' },
exportHintCMB: { zh: '导出路径:招商银行 App → 我的 → 全部 → 交易流水,支持 CSV 或 PDF 格式导出(如需压缩包请设置解压密码)。', en: 'Export path: China Merchants Bank App → Me → All → Transaction Records; CSV or PDF is supported (set an archive password if zipped).', ja: 'エクスポート手順:招商銀行アプリ → マイ → すべて → 取引明細。CSV または PDF 形式に対応(圧縮する場合は解凍パスワードを設定)。' },
exportHintBOCOM: { zh: '导出路径:交通银行 App → 我的 → 银行卡 → 交易明细,选择查询时间段后导出 Excel(xls)。', en: 'Export path: Bank of Communications App → Me → Bank Cards → Transaction Details, choose a date range and export Excel (xls).', ja: 'エクスポート手順:交通銀行アプリ → マイ → 銀行カード → 取引明細 で期間を選択し Excel(xls)でエクスポート。' },
parse: { zh: '解析', en: 'Parse', ja: '解析' },
expenseDefaultCategory: { zh: '支出默认分类(可不选):', en: 'Default expense category (optional):', ja: '支出の既定カテゴリ(任意):' },
@@ -494,6 +497,8 @@ export default {
notSetCategory: { zh: '未选则不计分类', en: 'No category if unselected', ja: '未選択の場合は分類なし' },
notSetTag: { zh: '未选则不打标签', en: 'No tags if unselected', ja: '未選択の場合はタグなし' },
selectAll: { zh: '全选', en: 'Select All', ja: 'すべて選択' },
setIncluded: { zh: '设为计入', en: 'Set Included', ja: '集計に含める' },
setExcluded: { zh: '设为不计入', en: 'Set Excluded', ja: '集計から除外' },
previewSummary: { zh: '共 {total} 条,筛选后 {filtered} 条,已勾选 {checked} 条', en: 'Total {total}, {filtered} after filtering, {checked} selected', ja: '全 {total} 件、絞り込み後 {filtered} 件、選択済み {checked} 件' },
searchPlaceholder: { zh: '搜索交易对方/商品/单号', en: 'Search counterparty / product / order no.', ja: '取引相手/商品/注文番号を検索' },
typePlaceholder: { zh: '类型', en: 'Type', ja: '種類' },
@@ -522,6 +527,7 @@ export default {
product: { zh: '商品名称', en: 'Product', ja: '商品名' },
status: { zh: '状态', en: 'Status', ja: 'ステータス' },
matchedRule: { zh: '匹配规则/建议', en: 'Matched Rule', ja: '一致ルール' },
includeInStats: { zh: '计入统计', en: 'Include in Stats', ja: '集計に含める' },
},
errorFileType: { zh: '账单类型「{label}」仅支持 {exts} 格式的文件', en: 'Bill type "{label}" only supports files in {exts} format', ja: '明細の種類「{label}」は {exts} 形式のファイルのみ対応しています' },
selectFileFirst: { zh: '请先选择账单文件', en: 'Please select a bill file first', ja: '先に明細ファイルを選択してください' },
+35 -1
View File
@@ -66,7 +66,7 @@ const allowedExtensions: Record<BillType, string[]> = {
ALIPAY: ['.csv', '.zip'],
WECHAT: ['.xlsx', '.zip'],
MEITUAN: ['.csv', '.zip'],
CMB: ['.csv', '.zip'],
CMB: ['.csv', '.pdf', '.zip'],
BOCOM: ['.xls', '.zip'],
}
@@ -221,6 +221,18 @@ const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
)
},
},
{
title: t('import.column.includeInStats'),
key: 'includeInStats',
width: 100,
align: 'center',
render: (row) =>
h(NCheckbox, {
checked: !!row.includeInStats,
disabled: !row.importable,
onUpdateChecked: (v: boolean) => toggleIncludeInStats(row, v),
}),
},
{
title: t('import.column.amount'),
key: 'amount',
@@ -389,6 +401,22 @@ function toggleAll(checked: boolean) {
checkedRowNos.value = checked ? selectableRows.value.map((r) => r.rowNo) : []
}
/** 切换单行是否计入统计 */
function toggleIncludeInStats(row: ParsedBillRow, value: boolean) {
row.includeInStats = value
}
/** 批量设置已勾选行的计入统计标记 */
function setCheckedIncludeInStats(value: boolean) {
if (checkedRows.value.length === 0) {
message.warning(t('import.selectAtLeastOne'))
return
}
checkedRows.value.forEach((row) => {
row.includeInStats = value
})
}
/** 确认导入 */
async function handleConfirm() {
if (checkedRows.value.length === 0) {
@@ -544,6 +572,12 @@ onMounted(() => {
>
{{ t('import.selectAll') }}
</NCheckbox>
<NButton size="small" :disabled="checkedRows.length === 0" @click="setCheckedIncludeInStats(true)">
{{ t('import.setIncluded') }}
</NButton>
<NButton size="small" :disabled="checkedRows.length === 0" @click="setCheckedIncludeInStats(false)">
{{ t('import.setExcluded') }}
</NButton>
<NText depth="3">{{ t('import.previewSummary', { total: rows.length, filtered: filteredRows.length, checked: checkedRows.length }) }}</NText>
</NSpace>
+35 -3
View File
@@ -76,6 +76,18 @@ const pageSummary = computed(() => {
}
return { income, expense, balance: income - expense }
})
// 当前页不计入统计的金额汇总(仅统计 includeInStats=false 的记录)
const pageExcludedSummary = computed(() => {
let income = 0
let expense = 0
for (const t of transactions.value) {
if (t.includeInStats) continue
const amount = Number(t.amount) || 0
if (t.type === 'INCOME') income += amount
else expense += amount
}
return { income, expense }
})
const loading = ref(false) // 列表加载状态
const showModal = ref(false) // 新增/编辑弹窗显示状态
const showImportModal = ref(false) // 账单导入弹窗显示状态
@@ -1555,8 +1567,23 @@ async function handleBatchSubmit() {
}
}
/** 导出交易记录为 Excel 文件 */
async function handleExport() {
/** 导出中状态,防止重复点击 */
const exporting = ref(false)
/** 导出交易记录:弹出二次确认,确认后执行导出 */
function handleExport() {
dialog.warning({
title: t('transaction.exportConfirmTitle'),
content: t('transaction.exportConfirm'),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
onPositiveClick: () => doExport(),
})
}
/** 实际执行导出交易记录为 Excel 文件 */
async function doExport() {
exporting.value = true
try {
const res = await exportTransaction({
types: filterType.value.length > 0 ? filterType.value : undefined,
@@ -1584,6 +1611,8 @@ async function handleExport() {
message.success(t('transaction.exportSuccess'))
} catch (e: any) {
message.error(e.message || t('transaction.exportFailed'))
} finally {
exporting.value = false
}
}
@@ -1614,7 +1643,7 @@ onMounted(() => {
{{ t('transaction.batchDelete') }}
</NButton>
<NButton @click="showImportModal = true">{{ t('transaction.importBill') }}</NButton>
<NButton @click="handleExport">{{ t('transaction.exportExcel') }}</NButton>
<NButton :loading="exporting" @click="handleExport">{{ t('transaction.exportExcel') }}</NButton>
<NButton v-if="aiEnabled" @click="handleAiRecordOpen">
<template #icon>
<NIcon :component="SparklesOutline" />
@@ -1802,6 +1831,9 @@ onMounted(() => {
{{ t('transaction.pageExpense') }} <span style="color: #d03050;">{{ pageSummary.expense.toFixed(2) }}</span>
· {{ t('transaction.pageIncome') }} <span style="color: #18a058;">{{ pageSummary.income.toFixed(2) }}</span>
· {{ t('transaction.pageBalance') }} <span :style="{ color: pageSummary.balance < 0 ? '#d03050' : '#18a058' }">{{ pageSummary.balance.toFixed(2) }}</span>
<span style="margin-left: 8px;">
{{ t('transaction.pageExcluded', { expense: pageExcludedSummary.expense.toFixed(2), income: pageExcludedSummary.income.toFixed(2) }) }}
</span>
</span>
</NSpace>