- 新增邮箱配置、发件人规则、待处理账单三张表(SQLite/MySQL 双脚本)及 CRUD 接口 - 按邮件发件人匹配账单类型,未命中忽略;发件人规则可配置解压密码与默认分类/标签 - 支持 IMAP IDLE 实时监听、定时兜底与手动立即检查;拉取范围可配置(仅新邮件/最近 N 天/最近 N 封) - 附件保存至本地 data/email-bill,解析预览后手动导入,复用账单解析与规则引擎 - 立即检查通过 SSE 实时推送执行进度 - 前端新增「邮箱账单」页(邮箱配置/发件人规则/待处理账单三 Tab),支持子路由与 Tab 偏好记忆,配置弹窗提供常用 IMAP 服务器候选项 - 新用户注册自动初始化默认发件人规则(支付宝/微信/美团/招商银行/交通银行) - 抽取共用账单预览与结果组件,供上传导入与邮箱账单复用 同时包含: - 统计新增口径筛选(仅计入/全部/仅不计入),支付方式改为后端动态获取 - 统一实体 createdAt/updatedAt 位置至 id 之后,调整部分日志级别
296 lines
9.7 KiB
Vue
296 lines
9.7 KiB
Vue
<script setup lang="ts">
|
||
// 账单导入面板:先解析、后勾选导入(默认全选),当前支持支付宝与微信账单
|
||
// 以纯内容形式提供,可由弹窗(BillImportModal)或独立页面(BillImport)承载
|
||
import { ref, computed, watch, onMounted } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import {
|
||
useMessage, NSpace, NButton, NSelect, NUpload, NUploadDragger, NAlert, NText, NIcon, NInput, NModal,
|
||
} from 'naive-ui'
|
||
import type { UploadFileInfo } from 'naive-ui'
|
||
import { CloudUploadOutline } from '@vicons/ionicons5'
|
||
import { parseBill, confirmImport } from '../api/transaction'
|
||
import type { BillType, ParsedBillRow, BillImportItem } from '../api/transaction'
|
||
import BillPreviewPanel from '../components/BillPreviewPanel.vue'
|
||
import BillImportResult from '../components/BillImportResult.vue'
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'close'): void
|
||
(e: 'imported'): void
|
||
}>()
|
||
|
||
const message = useMessage()
|
||
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 === 'MEITUAN' || saved === 'CMB' || saved === 'BOCOM'
|
||
? saved
|
||
: 'ALIPAY'
|
||
}
|
||
|
||
// 当前账单类型,初始值取上次选择(个人偏好)
|
||
const billType = ref<BillType>(loadBillTypePref())
|
||
|
||
// 各账单类型允许上传的文件扩展名(支持原始文件或 zip 压缩包)
|
||
const allowedExtensions: Record<BillType, string[]> = {
|
||
ALIPAY: ['.csv', '.zip'],
|
||
WECHAT: ['.xlsx', '.zip'],
|
||
MEITUAN: ['.csv', '.zip'],
|
||
CMB: ['.csv', '.pdf', '.zip'],
|
||
BOCOM: ['.xls', '.zip'],
|
||
}
|
||
|
||
// 当前账单类型允许的扩展名,用于上传组件 accept 属性
|
||
const acceptTypes = computed(() => (allowedExtensions[billType.value] || []).join(','))
|
||
|
||
// 当前账单类型的文件格式提示
|
||
const fileHint = computed(() => {
|
||
const label = t(billTypeLabelKeys[billType.value])
|
||
const exts = (allowedExtensions[billType.value] || []).join(t('import.extSeparator'))
|
||
return t('import.fileHint', { label, exts })
|
||
})
|
||
|
||
// 当前账单类型的导出路径提示
|
||
const exportHint = computed(() => t(billTypeExportHintKeys[billType.value]))
|
||
|
||
// 面板阶段:选择文件 -> 预览勾选 -> 导入结果
|
||
const stage = ref<'select' | 'preview' | 'result'>('select')
|
||
|
||
const file = ref<File | null>(null)
|
||
const uploadFileList = ref<UploadFileInfo[]>([])
|
||
const parsing = ref(false)
|
||
const importing = ref(false)
|
||
|
||
// 压缩包解压密码弹窗状态
|
||
const passwordModalVisible = ref(false)
|
||
const archivePassword = ref('')
|
||
|
||
// 解析结果与导入结果
|
||
const rows = ref<ParsedBillRow[]>([])
|
||
const result = ref<{ total: number; imported: number; skipped: number; failed: number; messages: string[] } | null>(null)
|
||
|
||
/** 判断文件名是否符合当前账单类型允许的扩展名 */
|
||
function isAllowedFile(name: string) {
|
||
const exts = allowedExtensions[billType.value] || []
|
||
const lower = name.toLowerCase()
|
||
return exts.length === 0 || exts.some((ext) => lower.endsWith(ext))
|
||
}
|
||
|
||
/** 文件选择变化(支持点击或拖拽),按账单类型校验扩展名,非法文件直接拒绝 */
|
||
function handleFileChange(options: { fileList: UploadFileInfo[] }) {
|
||
const last = options.fileList[options.fileList.length - 1]
|
||
if (last?.file && !isAllowedFile(last.name)) {
|
||
const label = t(billTypeLabelKeys[billType.value])
|
||
const exts = allowedExtensions[billType.value].join(t('import.extSeparator'))
|
||
message.error(t('import.errorFileType', { label, exts }))
|
||
uploadFileList.value = []
|
||
file.value = null
|
||
return
|
||
}
|
||
uploadFileList.value = options.fileList
|
||
file.value = last?.file ?? null
|
||
}
|
||
|
||
/** 解析账单,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, password)
|
||
rows.value = res.data.rows || []
|
||
stage.value = 'preview'
|
||
// 解析成功时关闭密码弹窗并清空密码
|
||
passwordModalVisible.value = false
|
||
archivePassword.value = ''
|
||
} catch (e: any) {
|
||
// 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)
|
||
}
|
||
|
||
/** 确认导入:接收预览面板抛出的勾选行与默认归类 */
|
||
async function handleConfirm(payload: {
|
||
items: BillImportItem[]
|
||
expenseCategoryId: number | null
|
||
incomeCategoryId: number | null
|
||
tagIds: number[]
|
||
}) {
|
||
importing.value = true
|
||
try {
|
||
const res: any = await confirmImport({
|
||
billType: billType.value,
|
||
expenseCategoryId: payload.expenseCategoryId,
|
||
incomeCategoryId: payload.incomeCategoryId,
|
||
tagIds: payload.tagIds.length ? payload.tagIds : undefined,
|
||
items: payload.items,
|
||
})
|
||
result.value = res.data
|
||
stage.value = 'result'
|
||
emit('imported')
|
||
} catch (e: any) {
|
||
message.error(e.message || t('import.importFailed'))
|
||
} finally {
|
||
importing.value = false
|
||
}
|
||
}
|
||
|
||
/** 重置面板状态 */
|
||
function reset() {
|
||
stage.value = 'select'
|
||
file.value = null
|
||
uploadFileList.value = []
|
||
rows.value = []
|
||
result.value = null
|
||
passwordModalVisible.value = false
|
||
archivePassword.value = ''
|
||
}
|
||
|
||
/** 关闭面板(取消或完成) */
|
||
function handleClose() {
|
||
emit('close')
|
||
}
|
||
|
||
// 切换账单类型时清空已选文件、记录偏好,避免文件格式与类型不匹配
|
||
watch(billType, (value) => {
|
||
uploadFileList.value = []
|
||
file.value = null
|
||
localStorage.setItem(BILL_TYPE_PREFS_KEY, value)
|
||
})
|
||
|
||
// 挂载时重置(页面进入或弹窗打开时均会触发)
|
||
onMounted(() => {
|
||
reset()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<!-- 第一步:选择账单类型与文件 -->
|
||
<NSpace v-if="stage === 'select'" vertical :size="16">
|
||
<NSpace align="center">
|
||
<NText>{{ t('import.billType') }}</NText>
|
||
<NSelect v-model:value="billType" :options="billTypeOptions" style="width: 160px;" />
|
||
</NSpace>
|
||
|
||
<div>
|
||
<NText>{{ t('import.billFile') }}</NText>
|
||
<NUpload
|
||
:file-list="uploadFileList"
|
||
:default-upload="false"
|
||
:max="1"
|
||
:accept="acceptTypes"
|
||
style="margin-top: 8px;"
|
||
@change="handleFileChange"
|
||
>
|
||
<NUploadDragger>
|
||
<div style="padding: 24px 0; text-align: center;">
|
||
<NIcon size="48" :depth="3">
|
||
<CloudUploadOutline />
|
||
</NIcon>
|
||
<div style="font-size: 15px; margin-top: 8px;">{{ t('import.dropText') }}</div>
|
||
<div style="font-size: 12px; color: #999; margin-top: 4px;">
|
||
{{ fileHint }}
|
||
</div>
|
||
</div>
|
||
</NUploadDragger>
|
||
</NUpload>
|
||
</div>
|
||
|
||
<NAlert type="info" :show-icon="true">
|
||
{{ exportHint }}
|
||
</NAlert>
|
||
|
||
<NSpace justify="end">
|
||
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
|
||
<NButton type="primary" :loading="parsing" :disabled="!file" @click="handleParse()">{{ t('import.parse') }}</NButton>
|
||
</NSpace>
|
||
</NSpace>
|
||
|
||
<!-- 第二步:预览并勾选(复用共用预览组件) -->
|
||
<BillPreviewPanel
|
||
v-else-if="stage === 'preview'"
|
||
:rows="rows"
|
||
:importing="importing"
|
||
@confirm="handleConfirm"
|
||
@back="stage = 'select'"
|
||
/>
|
||
|
||
<!-- 第三步:导入结果(复用共用结果组件) -->
|
||
<BillImportResult v-else :result="result" @done="handleClose" />
|
||
|
||
<!-- 压缩包加密时弹出,要求输入解压密码 -->
|
||
<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>
|