feat: 扩展账单导入(zip/美团/招行/交行)并新增批量删除与动态支付方式
- 账单导入支持 zip 压缩包,加密包自动检测并弹窗输入解压密码(引入 zip4j) - 新增美团、招商银行、交通银行账单解析器及对应账单类型 - 支付方式列表改为后端动态获取,前端筛选与新增/编辑表单动态渲染 - 支付方式筛选从「更多筛选」移至常用筛选栏,移除「银行卡」选项 - 收支记录支持多选批量删除 - 补充相关三语文案并透传业务错误码
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user