diff --git a/build.gradle b/build.gradle index 1f67785..2f609d1 100644 --- a/build.gradle +++ b/build.gradle @@ -41,6 +41,8 @@ dependencies { implementation 'org.apache.commons:commons-csv:1.12.0' // zip 压缩包解析(支持带密码的 ZipCrypto / AES 加密压缩包) implementation 'net.lingala.zip4j:zip4j:2.11.5' + // PDF 账单解析(招商银行「交易流水」PDF 为文本型,按字符坐标还原表格) + implementation 'org.apache.pdfbox:pdfbox:3.0.3' implementation 'io.jsonwebtoken:jjwt-api:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' diff --git a/frontend/src/api/transaction.ts b/frontend/src/api/transaction.ts index 4f4b84e..00c8191 100644 --- a/frontend/src/api/transaction.ts +++ b/frontend/src/api/transaction.ts @@ -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 }) } /** 批量设置交易记录的分类(按类型分别应用支出/收入分类) */ diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts index 045897f..ad23253 100644 --- a/frontend/src/locales/messages.ts +++ b/frontend/src/locales/messages.ts @@ -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: '先に明細ファイルを選択してください' }, diff --git a/frontend/src/views/BillImportPanel.vue b/frontend/src/views/BillImportPanel.vue index 694f32f..5865eba 100644 --- a/frontend/src/views/BillImportPanel.vue +++ b/frontend/src/views/BillImportPanel.vue @@ -66,7 +66,7 @@ const allowedExtensions: Record = { 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>(() => [ ) }, }, + { + 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') }} + + {{ t('import.setIncluded') }} + + + {{ t('import.setExcluded') }} + {{ t('import.previewSummary', { total: rows.length, filtered: filteredRows.length, checked: checkedRows.length }) }} diff --git a/frontend/src/views/TransactionList.vue b/frontend/src/views/TransactionList.vue index da9ae7b..0aef4ec 100644 --- a/frontend/src/views/TransactionList.vue +++ b/frontend/src/views/TransactionList.vue @@ -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') }} {{ t('transaction.importBill') }} - {{ t('transaction.exportExcel') }} + {{ t('transaction.exportExcel') }}