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
+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>