Files
ledgerly/frontend/src/views/BillImportPanel.vue
T
rosercode 9bc858eefc feat: 邮箱账单自动拉取
- 新增邮箱配置、发件人规则、待处理账单三张表(SQLite/MySQL 双脚本)及 CRUD 接口
- 按邮件发件人匹配账单类型,未命中忽略;发件人规则可配置解压密码与默认分类/标签
- 支持 IMAP IDLE 实时监听、定时兜底与手动立即检查;拉取范围可配置(仅新邮件/最近 N 天/最近 N 封)
- 附件保存至本地 data/email-bill,解析预览后手动导入,复用账单解析与规则引擎
- 立即检查通过 SSE 实时推送执行进度
- 前端新增「邮箱账单」页(邮箱配置/发件人规则/待处理账单三 Tab),支持子路由与 Tab 偏好记忆,配置弹窗提供常用 IMAP 服务器候选项
- 新用户注册自动初始化默认发件人规则(支付宝/微信/美团/招商银行/交通银行)
- 抽取共用账单预览与结果组件,供上传导入与邮箱账单复用

同时包含:
- 统计新增口径筛选(仅计入/全部/仅不计入),支付方式改为后端动态获取
- 统一实体 createdAt/updatedAt 位置至 id 之后,调整部分日志级别
2026-09-19 17:50:55 +08:00

296 lines
9.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>