feat: 扩展账单导入(zip/美团/招行/交行)并新增批量删除与动态支付方式

- 账单导入支持 zip 压缩包,加密包自动检测并弹窗输入解压密码(引入 zip4j)
- 新增美团、招商银行、交通银行账单解析器及对应账单类型
- 支付方式列表改为后端动态获取,前端筛选与新增/编辑表单动态渲染
- 支付方式筛选从「更多筛选」移至常用筛选栏,移除「银行卡」选项
- 收支记录支持多选批量删除
- 补充相关三语文案并透传业务错误码
This commit is contained in:
2026-09-19 02:46:35 +08:00
parent c69e76ed4c
commit 9981e77bf1
18 changed files with 1558 additions and 55 deletions
+4 -1
View File
@@ -34,7 +34,10 @@ request.interceptors.response.use(
}
})
}
return Promise.reject(new Error(data.message || '请求失败'))
// 在 Error 上保留业务错误码,便于调用方按 code 做差异化处理
const bizError: any = new Error(data.message || '请求失败')
bizError.code = data.code
return Promise.reject(bizError)
}
return data
},
+17 -4
View File
@@ -3,6 +3,11 @@ import request from './request'
import axios from 'axios'
import { useUserStore } from '../store/user'
/** 获取系统支持的支付方式编码列表(含账单导入来源与人工支付方式) */
export function getPaymentMethods() {
return request.get('/transaction/payment-methods')
}
/** 分页获取交易记录列表,支持按类型、分类、支付方式、时间范围、计入统计、金额区间多选筛选 */
export function getTransactionPage(params: {
page?: number
@@ -57,6 +62,11 @@ export function deleteTransaction(id: number) {
return request.delete(`/transaction/${id}`)
}
/** 批量删除交易记录,返回实际删除数量 */
export function batchDeleteTransactions(ids: number[]) {
return request.delete('/transaction/batch', { data: { ids } })
}
/** 切换指定交易记录的"计入统计"标记 */
export function toggleTransactionStats(id: number, includeInStats: boolean) {
return request.put(`/transaction/${id}/stats`, { includeInStats })
@@ -67,8 +77,8 @@ export function toggleRemarkHidden(id: number, remarkHidden: boolean) {
return request.put(`/transaction/${id}/remark-hidden`, { remarkHidden })
}
/** 支持的账单类型,当前支持支付宝与微信 */
export type BillType = 'ALIPAY' | 'WECHAT'
/** 支持的账单类型,当前支持支付宝、微信、美团、招商银行与交通银行 */
export type BillType = 'ALIPAY' | 'WECHAT' | 'MEITUAN' | 'CMB' | 'BOCOM'
/** 账单解析结果行 */
export interface ParsedBillRow {
@@ -106,11 +116,14 @@ export interface BillImportItem {
rawData: string | null
}
/** 解析账单文件(不落库),返回预览数据 */
export function parseBill(file: File, billType: BillType) {
/** 解析账单文件(不落库),返回预览数据;支持 zip 压缩包,加密时需传解压密码 */
export function parseBill(file: File, billType: BillType, password?: string) {
const formData = new FormData()
formData.append('file', file)
formData.append('billType', billType)
if (password) {
formData.append('password', password)
}
return request.post('/transaction/import/parse', formData)
}
+19 -1
View File
@@ -37,6 +37,9 @@ export default {
alipay: { zh: '支付宝', en: 'Alipay', ja: 'Alipay' },
cash: { zh: '现金', en: 'Cash', ja: '現金' },
bank: { zh: '银行卡', en: 'Bank Card', ja: '銀行カード' },
meituan: { zh: '美团', en: 'Meituan', ja: 'Meituan' },
cmb: { zh: '招商银行', en: 'China Merchants Bank', ja: '招商銀行' },
bocom: { zh: '交通银行', en: 'Bank of Communications', ja: '交通銀行' },
},
},
@@ -389,6 +392,10 @@ export default {
noRecordsToSet: { zh: '当前表格没有可设置的记录', en: 'No records available in the current table', ja: '現在のテーブルに設定できる記録がありません' },
batchNothingSelected: { zh: '请至少选择分类、计入统计或标签', en: 'Please select a category, stats option or tag', ja: 'カテゴリ、集計設定、タグのいずれかを選択してください' },
batchUpdated: { zh: '已更新 {count} 条记录 | 已更新 {count} 条记录', en: 'Updated {count} record | Updated {count} records', ja: '{count} 件を更新しました' },
batchDelete: { zh: '批量删除', en: 'Batch Delete', ja: '一括削除' },
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} 件を削除しました' },
exportSuccess: { zh: '导出成功', en: 'Exported successfully', ja: 'エクスポートしました' },
exportFailed: { zh: '导出失败', en: 'Export failed', ja: 'エクスポートに失敗しました' },
exportFileName: { zh: '交易记录', en: 'transactions', ja: '取引記録' },
@@ -468,12 +475,18 @@ export default {
billType: { zh: '账单类型:', en: 'Bill type:', ja: '明細の種類:' },
billTypeALIPAY: { zh: '支付宝', en: 'Alipay', ja: 'Alipay' },
billTypeWECHAT: { zh: '微信', en: 'WeChat', ja: 'WeChat' },
billTypeMEITUAN: { zh: '美团', en: 'Meituan', ja: 'Meituan' },
billTypeCMB: { zh: '招商银行', en: 'China Merchants Bank', ja: '招商銀行' },
billTypeBOCOM: { zh: '交通银行', en: 'Bank of Communications', ja: '交通銀行' },
billFile: { zh: '账单文件:', en: 'Bill file:', ja: '明細ファイル:' },
dropText: { zh: '点击或拖拽文件到此处', en: 'Click or drag a file here', ja: 'クリックまたはドラッグでファイルを選択' },
fileHint: { zh: '仅支持{label}导出的账单文件({exts})', en: 'Only {label} bill files are supported ({exts})', ja: '{label}からエクスポートした明細ファイルのみ対応({exts})' },
fileHint: { zh: '仅支持{label}导出的账单文件或 zip 压缩包({exts})', en: 'Only {label} bill files or zip archives are supported ({exts})', ja: '{label}からエクスポートした明細ファイルまたは zip 圧縮ファイルのみ対応({exts})' },
extSeparator: { zh: '、', en: ', ', ja: '、' },
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 形式を選択(圧縮する場合は解凍パスワードを設定)。' },
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: '支出の既定カテゴリ(任意):' },
incomeDefaultCategory: { zh: '收入默认分类(可不选):', en: 'Default income category (optional):', ja: '収入の既定カテゴリ(任意):' },
@@ -517,6 +530,11 @@ export default {
importFailed: { zh: '导入失败', en: 'Import failed', ja: 'インポートに失敗しました' },
loadCategoriesFailed: { zh: '加载分类失败', en: 'Failed to load categories', ja: 'カテゴリの読み込みに失敗しました' },
loadTagsFailed: { zh: '加载标签失败', en: 'Failed to load tags', ja: 'タグの読み込みに失敗しました' },
passwordTitle: { zh: '解压密码', en: 'Archive Password', ja: '解凍パスワード' },
passwordTip: { zh: '该压缩包已加密,请输入解压密码。', en: 'This archive is encrypted. Please enter the password.', ja: 'この圧縮ファイルは暗号化されています。パスワードを入力してください。' },
passwordPlaceholder: { zh: '请输入解压密码', en: 'Enter the archive password', ja: 'パスワードを入力してください' },
passwordRequired: { zh: '请输入解压密码', en: 'Please enter the archive password', ja: 'パスワードを入力してください' },
passwordWrong: { zh: '解压密码错误', en: 'Incorrect archive password', ja: 'パスワードが正しくありません' },
},
// AI 智能建议
+90 -16
View File
@@ -5,7 +5,7 @@ import { ref, computed, h, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, NSpace, NButton, NSelect, NUpload, NUploadDragger, NDataTable,
NCheckbox, NTag, NAlert, NText, NIcon, NInput, NInputNumber,
NCheckbox, NTag, NAlert, NText, NIcon, NInput, NInputNumber, NModal,
} from 'naive-ui'
import type { DataTableColumns, UploadFileInfo } from 'naive-ui'
import { CloudUploadOutline } from '@vicons/ionicons5'
@@ -25,25 +25,49 @@ const { t } = useI18n() // 国际化
// 账单类型偏好存储键
const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType'
// 账单类型选项,当前支持支付宝与微信
// 账单类型选项,当前支持支付宝、微信、美团、招商银行与交通银行
const billTypeOptions = computed(() => [
{ label: t('import.billTypeALIPAY'), value: 'ALIPAY' },
{ label: t('import.billTypeWECHAT'), value: 'WECHAT' },
{ label: t('import.billTypeMEITUAN'), value: 'MEITUAN' },
{ label: t('import.billTypeCMB'), value: 'CMB' },
{ label: t('import.billTypeBOCOM'), value: 'BOCOM' },
])
// 账单类型对应的名称与导出路径文案 key
const billTypeLabelKeys: Record<BillType, string> = {
ALIPAY: 'import.billTypeALIPAY',
WECHAT: 'import.billTypeWECHAT',
MEITUAN: 'import.billTypeMEITUAN',
CMB: 'import.billTypeCMB',
BOCOM: 'import.billTypeBOCOM',
}
const billTypeExportHintKeys: Record<BillType, string> = {
ALIPAY: 'import.exportHintALIPAY',
WECHAT: 'import.exportHintWECHAT',
MEITUAN: 'import.exportHintMEITUAN',
CMB: 'import.exportHintCMB',
BOCOM: 'import.exportHintBOCOM',
}
/** 读取上次选择的账单类型偏好,非法值回退为支付宝 */
function loadBillTypePref(): BillType {
const saved = localStorage.getItem(BILL_TYPE_PREFS_KEY)
return saved === 'ALIPAY' || saved === 'WECHAT' ? saved : 'ALIPAY'
return saved === 'ALIPAY' || saved === 'WECHAT' || saved === 'MEITUAN' || saved === 'CMB' || saved === 'BOCOM'
? saved
: 'ALIPAY'
}
// 当前账单类型,初始值取上次选择(个人偏好)
const billType = ref<BillType>(loadBillTypePref())
// 各账单类型允许上传的文件扩展名
// 各账单类型允许上传的文件扩展名(支持原始文件或 zip 压缩包)
const allowedExtensions: Record<BillType, string[]> = {
ALIPAY: ['.csv'],
WECHAT: ['.xlsx'],
ALIPAY: ['.csv', '.zip'],
WECHAT: ['.xlsx', '.zip'],
MEITUAN: ['.csv', '.zip'],
CMB: ['.csv', '.zip'],
BOCOM: ['.xls', '.zip'],
}
// 当前账单类型允许的扩展名,用于上传组件 accept 属性
@@ -51,15 +75,13 @@ const acceptTypes = computed(() => (allowedExtensions[billType.value] || []).joi
// 当前账单类型的文件格式提示
const fileHint = computed(() => {
const label = billType.value === 'WECHAT' ? t('import.billTypeWECHAT') : t('import.billTypeALIPAY')
const label = t(billTypeLabelKeys[billType.value])
const exts = (allowedExtensions[billType.value] || []).join(t('import.extSeparator'))
return t('import.fileHint', { label, exts })
})
// 当前账单类型的导出路径提示
const exportHint = computed(() =>
billType.value === 'WECHAT' ? t('import.exportHintWECHAT') : t('import.exportHintALIPAY'),
)
const exportHint = computed(() => t(billTypeExportHintKeys[billType.value]))
// 面板阶段:选择文件 -> 预览勾选 -> 导入结果
const stage = ref<'select' | 'preview' | 'result'>('select')
@@ -69,6 +91,10 @@ const uploadFileList = ref<UploadFileInfo[]>([])
const parsing = ref(false)
const importing = ref(false)
// 压缩包解压密码弹窗状态
const passwordModalVisible = ref(false)
const archivePassword = ref('')
// 解析结果与勾选状态
const rows = ref<ParsedBillRow[]>([])
const checkedRowNos = ref<number[]>([])
@@ -295,7 +321,7 @@ function isAllowedFile(name: string) {
function handleFileChange(options: { fileList: UploadFileInfo[] }) {
const last = options.fileList[options.fileList.length - 1]
if (last?.file && !isAllowedFile(last.name)) {
const label = billType.value === 'WECHAT' ? t('import.billTypeWECHAT') : t('import.billTypeALIPAY')
const label = t(billTypeLabelKeys[billType.value])
const exts = allowedExtensions[billType.value].join(t('import.extSeparator'))
message.error(t('import.errorFileType', { label, exts }))
uploadFileList.value = []
@@ -306,27 +332,47 @@ function handleFileChange(options: { fileList: UploadFileInfo[] }) {
file.value = last?.file ?? null
}
/** 解析账单 */
async function handleParse() {
/** 解析账单,password 用于加密压缩包(由密码弹窗传入) */
async function handleParse(password?: string) {
if (!file.value) {
message.warning(t('import.selectFileFirst'))
return
}
parsing.value = true
try {
const res: any = await parseBill(file.value, billType.value)
const res: any = await parseBill(file.value, billType.value, password)
rows.value = res.data.rows || []
// 默认勾选解析器建议勾选的行
checkedRowNos.value = rows.value.filter((r) => r.selected).map((r) => r.rowNo)
resetFilters()
stage.value = 'preview'
// 解析成功时关闭密码弹窗并清空密码
passwordModalVisible.value = false
archivePassword.value = ''
} catch (e: any) {
message.error(e.message || t('import.parseFailed'))
// 4001:压缩包已加密,需要用户输入密码;4002:密码错误,留在弹窗内重试
if (e.code === 4001) {
passwordModalVisible.value = true
} else if (e.code === 4002) {
message.error(e.message || t('import.passwordWrong'))
passwordModalVisible.value = true
} else {
message.error(e.message || t('import.parseFailed'))
}
} finally {
parsing.value = false
}
}
/** 提交解压密码并重新解析 */
async function submitPassword() {
if (!archivePassword.value) {
message.warning(t('import.passwordRequired'))
return
}
await handleParse(archivePassword.value)
}
/** 切换单行勾选 */
function toggleRow(row: ParsedBillRow, checked: boolean) {
if (checked) {
@@ -389,6 +435,8 @@ function reset() {
expenseCategoryId.value = null
incomeCategoryId.value = null
importTagIds.value = []
passwordModalVisible.value = false
archivePassword.value = ''
resetFilters()
}
@@ -450,7 +498,7 @@ onMounted(() => {
<NSpace justify="end">
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="parsing" :disabled="!file" @click="handleParse">{{ t('import.parse') }}</NButton>
<NButton type="primary" :loading="parsing" :disabled="!file" @click="handleParse()">{{ t('import.parse') }}</NButton>
</NSpace>
</NSpace>
@@ -541,4 +589,30 @@ onMounted(() => {
<NButton type="primary" @click="handleClose">{{ t('import.done') }}</NButton>
</NSpace>
</NSpace>
<!-- 压缩包加密时弹出,要求输入解压密码 -->
<NModal
v-model:show="passwordModalVisible"
preset="card"
:title="t('import.passwordTitle')"
style="width: 420px; max-width: 92vw;"
:mask-closable="false"
>
<NSpace vertical :size="12">
<NText depth="3">{{ t('import.passwordTip') }}</NText>
<NInput
v-model:value="archivePassword"
type="password"
show-password-on="click"
:placeholder="t('import.passwordPlaceholder')"
@keyup.enter="submitPassword"
/>
</NSpace>
<template #footer>
<NSpace justify="end">
<NButton @click="passwordModalVisible = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="parsing" @click="submitPassword">{{ t('import.parse') }}</NButton>
</NSpace>
</template>
</NModal>
</template>
+68 -23
View File
@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NDropdown } from 'naive-ui'
import { EyeOutline, EyeOffOutline, CloseCircleOutline, SparklesOutline, BulbOutline } from '@vicons/ionicons5'
import type { DataTableColumns, AutoCompleteOption } from 'naive-ui'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags } from '../api/transaction'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getPaymentMethods, batchDeleteTransactions } from '../api/transaction'
import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi } from '../api/ai'
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
import { getCategoryList, createCategory } from '../api/category'
@@ -158,6 +158,7 @@ const editingId = ref<number | null>(null) // 当前编辑的记录 ID(null
const categories = ref<CategoryOption[]>([]) // 分类选项列表
const allTags = ref<TagOption[]>([]) // 全部标签(用于筛选、编辑、批量)
const revealedRemarkIds = ref<number[]>([]) // 临时展开查看原文的备注行 ID(不持久,刷新/翻页后重置)
const checkedTransactionIds = ref<number[]>([]) // 表格多选选中的记录ID(用于批量删除)
// 表单数据(新增/编辑共用)
const form = ref({
@@ -177,13 +178,13 @@ const typeOptions = computed(() => [
{ label: t('common.types.income'), value: 'INCOME' },
])
// 支付方式选项
const paymentOptions = computed(() => [
{ label: t('common.payment.wechat'), value: 'WECHAT' },
{ label: t('common.payment.alipay'), value: 'ALIPAY' },
{ label: t('common.payment.cash'), value: 'CASH' },
{ label: t('common.payment.bank'), value: 'BANK' },
])
// 支付方式编码列表(从后端动态获取,含账单导入来源与人工支付方式)
const paymentMethodCodes = ref<string[]>([])
// 支付方式选项:由后端返回的编码映射为当前语言标签
const paymentOptions = computed(() =>
paymentMethodCodes.value.map((code) => ({ label: getPaymentLabel(code), value: code })),
)
// 筛选条件状态
const filterType = ref<string[]>([]) // 类型筛选(多选)
@@ -206,7 +207,6 @@ const showMoreFilter = ref(false) // 更多筛选弹层显示状态
// 更多筛选中已激活的条件数量
const moreFilterCount = computed(() => {
let count = 0
if (filterPaymentMethod.value.length > 0) count++
if (filterTag.value.length > 0) count++
if (filterIncludeInStats.value !== null) count++
if (filterMinAmount.value !== null || filterMaxAmount.value !== null) count++
@@ -215,7 +215,6 @@ const moreFilterCount = computed(() => {
/** 清空「更多筛选」中的条件 */
function clearMoreFilters() {
filterPaymentMethod.value = []
filterTag.value = []
filterIncludeInStats.value = null
filterMinAmount.value = null
@@ -483,6 +482,8 @@ async function setRemarkHidden(row: TransactionItem, hidden: boolean) {
/** 创建表格列配置 */
function createColumns(): DataTableColumns<TransactionItem> {
return [
// 多选列:用于批量操作(如批量删除)
{ type: 'selection' },
{
// 序号列:按分页计算全局序号
title: t('transaction.column.index'), key: 'index', width: 70, align: 'center',
@@ -587,6 +588,16 @@ function createColumns(): DataTableColumns<TransactionItem> {
const columns = computed(() => createColumns()) // 表格列配置(随语言切换响应式更新)
/** 从后端加载支付方式列表,供筛选与编辑表单使用 */
async function loadPaymentMethods() {
try {
const res: any = await getPaymentMethods()
paymentMethodCodes.value = res.data || []
} catch (e) {
// 静默处理,支付方式下拉保持为空
}
}
/** 加载分类列表 */
async function fetchCategories() {
try {
@@ -630,6 +641,7 @@ async function fetchData() {
transactions.value = res.data.records
total.value = res.data.total
revealedRemarkIds.value = []
checkedTransactionIds.value = []
} catch (e: any) {
message.error(e.message || t('common.loadFailed'))
} finally {
@@ -739,6 +751,31 @@ function handleDelete(id: number) {
})
}
/** 批量删除选中的记录:确认后调用接口并刷新列表 */
function handleBatchDelete() {
const count = checkedTransactionIds.value.length
if (count === 0) {
message.warning(t('transaction.batchDeleteNone'))
return
}
dialog.warning({
title: t('common.confirmDelete'),
content: t('transaction.batchDeleteConfirm', { count }),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
try {
const res: any = await batchDeleteTransactions(checkedTransactionIds.value)
message.success(t('transaction.batchDeleted', { count: res.data }))
checkedTransactionIds.value = []
await fetchData()
} catch (e: any) {
message.error(e.message || t('common.deleteFailed'))
}
},
})
}
/** 翻页事件处理 */
function handlePageChange(p: number) {
page.value = p
@@ -1558,6 +1595,7 @@ onMounted(() => {
loadAiConfig()
fetchCategories()
fetchTags()
loadPaymentMethods()
fetchData()
})
</script>
@@ -1572,6 +1610,9 @@ onMounted(() => {
<NSelect v-model:value="sortValue" :options="sortOptions" style="width: 170px;" />
<NDivider vertical style="margin: 0 4px;" />
<NButton @click="handleBatchOpen">{{ t('transaction.batchSet') }}</NButton>
<NButton type="error" :disabled="checkedTransactionIds.length === 0" @click="handleBatchDelete">
{{ t('transaction.batchDelete') }}
</NButton>
<NButton @click="showImportModal = true">{{ t('transaction.importBill') }}</NButton>
<NButton @click="handleExport">{{ t('transaction.exportExcel') }}</NButton>
<NButton v-if="aiEnabled" @click="handleAiRecordOpen">
@@ -1661,6 +1702,15 @@ onMounted(() => {
@select="handleRemarkSelect"
@blur="handleRemarkBlur"
/>
<NSelect
v-model:value="filterPaymentMethod"
:options="paymentOptions"
multiple
clearable
:max-tag-count="2"
:placeholder="t('transaction.column.paymentMethod')"
style="width: 180px;"
/>
<NPopover v-model:show="showMoreFilter" trigger="click" placement="bottom-start">
<template #trigger>
<NButton>
@@ -1671,18 +1721,6 @@ onMounted(() => {
</NButton>
</template>
<NSpace vertical :size="12" style="width: 260px;">
<div>
<div class="filter-label">{{ t('transaction.column.paymentMethod') }}</div>
<NSelect
v-model:value="filterPaymentMethod"
:options="paymentOptions"
multiple
clearable
:max-tag-count="2"
:placeholder="t('common.all')"
style="width: 100%;"
/>
</div>
<div>
<div class="filter-label">{{ t('transaction.column.tags') }}</div>
<NSelect
@@ -1768,7 +1806,14 @@ onMounted(() => {
</NSpace>
<!-- 交易记录数据表格 -->
<NDataTable :columns="columns" :data="transactions" :loading="loading" :bordered="false" />
<NDataTable
:columns="columns"
:data="transactions"
:loading="loading"
:bordered="false"
:row-key="(row: TransactionItem) => row.id"
v-model:checked-row-keys="checkedTransactionIds"
/>
<!-- 分页控件 -->
<NSpace justify="center" style="margin-top: 16px;">