feat: 邮箱账单自动拉取
- 新增邮箱配置、发件人规则、待处理账单三张表(SQLite/MySQL 双脚本)及 CRUD 接口 - 按邮件发件人匹配账单类型,未命中忽略;发件人规则可配置解压密码与默认分类/标签 - 支持 IMAP IDLE 实时监听、定时兜底与手动立即检查;拉取范围可配置(仅新邮件/最近 N 天/最近 N 封) - 附件保存至本地 data/email-bill,解析预览后手动导入,复用账单解析与规则引擎 - 立即检查通过 SSE 实时推送执行进度 - 前端新增「邮箱账单」页(邮箱配置/发件人规则/待处理账单三 Tab),支持子路由与 Tab 偏好记忆,配置弹窗提供常用 IMAP 服务器候选项 - 新用户注册自动初始化默认发件人规则(支付宝/微信/美团/招商银行/交通银行) - 抽取共用账单预览与结果组件,供上传导入与邮箱账单复用 同时包含: - 统计新增口径筛选(仅计入/全部/仅不计入),支付方式改为后端动态获取 - 统一实体 createdAt/updatedAt 位置至 id 之后,调整部分日志级别
This commit is contained in:
@@ -1,18 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
// 账单导入面板:先解析、后勾选导入(默认全选),当前支持支付宝与微信账单
|
||||
// 以纯内容形式提供,可由弹窗(BillImportModal)或独立页面(BillImport)承载
|
||||
import { ref, computed, h, watch, onMounted } from 'vue'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
useMessage, NSpace, NButton, NSelect, NUpload, NUploadDragger, NDataTable,
|
||||
NCheckbox, NTag, NAlert, NText, NIcon, NInput, NInputNumber, NModal,
|
||||
useMessage, NSpace, NButton, NSelect, NUpload, NUploadDragger, NAlert, NText, NIcon, NInput, NModal,
|
||||
} from 'naive-ui'
|
||||
import type { DataTableColumns, UploadFileInfo } 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 { getCategoryList } from '../api/category'
|
||||
import { getTagList } from '../api/tag'
|
||||
import BillPreviewPanel from '../components/BillPreviewPanel.vue'
|
||||
import BillImportResult from '../components/BillImportResult.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
@@ -95,233 +94,10 @@ const importing = ref(false)
|
||||
const passwordModalVisible = ref(false)
|
||||
const archivePassword = ref('')
|
||||
|
||||
// 解析结果与勾选状态
|
||||
// 解析结果与导入结果
|
||||
const rows = ref<ParsedBillRow[]>([])
|
||||
const checkedRowNos = ref<number[]>([])
|
||||
|
||||
// 预览筛选条件
|
||||
const filterKeyword = ref('')
|
||||
const filterType = ref<string | null>(null)
|
||||
const filterStatus = ref<string | null>(null)
|
||||
const filterMinAmount = ref<number | null>(null)
|
||||
const filterMaxAmount = ref<number | null>(null)
|
||||
|
||||
// 类型筛选选项
|
||||
const typeFilterOptions = computed(() => [
|
||||
{ label: t('import.typeFilter.income'), value: 'INCOME' },
|
||||
{ label: t('import.typeFilter.expense'), value: 'EXPENSE' },
|
||||
{ label: t('import.typeFilter.neutral'), value: 'NEUTRAL' },
|
||||
])
|
||||
|
||||
// 状态筛选选项
|
||||
const statusFilterOptions = computed(() => [
|
||||
{ label: t('import.statusFilter.importable'), value: 'importable' },
|
||||
{ label: t('import.statusFilter.duplicate'), value: 'duplicate' },
|
||||
{ label: t('import.statusFilter.error'), value: 'error' },
|
||||
])
|
||||
|
||||
/** 重置预览筛选条件 */
|
||||
function resetFilters() {
|
||||
filterKeyword.value = ''
|
||||
filterType.value = null
|
||||
filterStatus.value = null
|
||||
filterMinAmount.value = null
|
||||
filterMaxAmount.value = null
|
||||
}
|
||||
|
||||
// 默认分类
|
||||
const expenseCategories = ref<{ label: string; value: number; icon: string }[]>([])
|
||||
const incomeCategories = ref<{ label: string; value: number; icon: string }[]>([])
|
||||
const expenseCategoryId = ref<number | null>(null)
|
||||
const incomeCategoryId = ref<number | null>(null)
|
||||
|
||||
// 默认标签
|
||||
const allTags = ref<{ label: string; value: number }[]>([])
|
||||
const importTagIds = ref<number[]>([])
|
||||
|
||||
// 导入结果
|
||||
const result = ref<{ total: number; imported: number; skipped: number; failed: number; messages: string[] } | null>(null)
|
||||
|
||||
/** 按筛选条件过滤后的账单行 */
|
||||
const filteredRows = computed(() => {
|
||||
const kw = filterKeyword.value.trim().toLowerCase()
|
||||
return rows.value.filter((r) => {
|
||||
if (kw) {
|
||||
const text = `${r.counterparty || ''} ${r.product || ''} ${r.externalNo || ''}`.toLowerCase()
|
||||
if (!text.includes(kw)) return false
|
||||
}
|
||||
if (filterType.value === 'NEUTRAL') {
|
||||
if (r.includeInStats) return false
|
||||
} else if (filterType.value && r.type !== filterType.value) {
|
||||
return false
|
||||
}
|
||||
if (filterStatus.value === 'importable' && !r.importable) return false
|
||||
if (filterStatus.value === 'duplicate' && !r.duplicate) return false
|
||||
if (filterStatus.value === 'error' && !r.error) return false
|
||||
if (filterMinAmount.value != null && Number(r.amount) < filterMinAmount.value) return false
|
||||
if (filterMaxAmount.value != null && Number(r.amount) > filterMaxAmount.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
/** 可导入的行(受筛选影响) */
|
||||
const selectableRows = computed(() => filteredRows.value.filter((r) => r.importable))
|
||||
/** 已勾选的行 */
|
||||
const checkedRows = computed(() => rows.value.filter((r) => checkedRowNos.value.includes(r.rowNo)))
|
||||
/** 是否全选(基于当前筛选出的可导入行) */
|
||||
const allChecked = computed(
|
||||
() => selectableRows.value.length > 0 && selectableRows.value.every((r) => checkedRowNos.value.includes(r.rowNo)),
|
||||
)
|
||||
/** 是否存在部分勾选(基于当前筛选出的可导入行) */
|
||||
const someChecked = computed(() => {
|
||||
const checkedVisible = selectableRows.value.filter((r) => checkedRowNos.value.includes(r.rowNo))
|
||||
return checkedVisible.length > 0 && !allChecked.value
|
||||
})
|
||||
|
||||
/** 表格列定义(随语言切换响应式更新) */
|
||||
const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
|
||||
{
|
||||
title: () =>
|
||||
h(NCheckbox, {
|
||||
checked: allChecked.value,
|
||||
indeterminate: someChecked.value,
|
||||
disabled: selectableRows.value.length === 0,
|
||||
onUpdateChecked: (v: boolean) => toggleAll(v),
|
||||
}),
|
||||
key: 'checked',
|
||||
width: 50,
|
||||
render: (row) =>
|
||||
h(NCheckbox, {
|
||||
checked: checkedRowNos.value.includes(row.rowNo),
|
||||
disabled: !row.importable,
|
||||
onUpdateChecked: (v: boolean) => toggleRow(row, v),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('import.column.transactionTime'),
|
||||
key: 'transactionTime',
|
||||
width: 150,
|
||||
render: (row) => (row.transactionTime ? row.transactionTime.replace('T', ' ').substring(0, 16) : '-'),
|
||||
},
|
||||
{
|
||||
title: t('import.column.type'),
|
||||
key: 'type',
|
||||
width: 90,
|
||||
render: (row) => {
|
||||
if (!row.importable) {
|
||||
return h(NTag, { type: 'error', size: 'small' }, { default: () => t('import.notImportable') })
|
||||
}
|
||||
if (!row.includeInStats) {
|
||||
return h(NTag, { type: 'default', size: 'small' }, { default: () => t('import.notIncluded') })
|
||||
}
|
||||
return h(
|
||||
NTag,
|
||||
{ type: row.type === 'INCOME' ? 'success' : 'error', size: 'small' },
|
||||
{ default: () => (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')) },
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('import.column.includeInStats'),
|
||||
key: 'includeInStats',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (row) =>
|
||||
h(NCheckbox, {
|
||||
checked: !!row.includeInStats,
|
||||
disabled: !row.importable,
|
||||
onUpdateChecked: (v: boolean) => toggleIncludeInStats(row, v),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('import.column.amount'),
|
||||
key: 'amount',
|
||||
width: 110,
|
||||
render: (row) => {
|
||||
const color = row.type === 'INCOME' ? '#18a058' : '#d03050'
|
||||
return h('span', { style: { color, fontWeight: 'bold' } }, `${row.type === 'INCOME' ? '+' : '-'}${Number(row.amount).toFixed(2)}`)
|
||||
},
|
||||
},
|
||||
{ title: t('import.column.counterparty'), key: 'counterparty', width: 160, ellipsis: { tooltip: true } },
|
||||
{ title: t('import.column.product'), key: 'product', ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: t('import.column.status'),
|
||||
key: 'tradeStatus',
|
||||
width: 150,
|
||||
render: (row) => {
|
||||
if (row.error) {
|
||||
return h(NTag, { type: 'error', size: 'small' }, { default: () => row.error })
|
||||
}
|
||||
if (row.duplicate) {
|
||||
return h(NTag, { type: 'warning', size: 'small' }, { default: () => t('import.statusFilter.duplicate') })
|
||||
}
|
||||
return h('span', null, row.tradeStatus || '-')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('import.column.matchedRule'),
|
||||
key: 'matchedRule',
|
||||
width: 220,
|
||||
render: (row) => {
|
||||
const ruleNames = row.matchedRuleNames || []
|
||||
const suggestedTags = row.suggestedTags || []
|
||||
if (ruleNames.length === 0 && !row.suggestedCategoryId && suggestedTags.length === 0) {
|
||||
return h('span', { style: { color: '#ccc' } }, '—')
|
||||
}
|
||||
const nodes: any[] = []
|
||||
// 命中规则名,多个时折叠为 +N
|
||||
if (ruleNames.length) {
|
||||
nodes.push(h(NTag, { size: 'small', type: 'warning' }, {
|
||||
default: () => (ruleNames.length > 1 ? `${ruleNames[0]} +${ruleNames.length - 1}` : ruleNames[0]),
|
||||
}))
|
||||
}
|
||||
// 建议分类
|
||||
if (row.suggestedCategoryId) {
|
||||
nodes.push(h(NTag, { size: 'small', type: 'info' }, {
|
||||
default: () => `${row.suggestedCategoryIcon || ''}${row.suggestedCategoryName || ''}`,
|
||||
}))
|
||||
}
|
||||
// 建议标签
|
||||
for (const tag of suggestedTags) {
|
||||
nodes.push(h(NTag, {
|
||||
size: 'small',
|
||||
color: tag.color ? { color: tag.color, textColor: '#fff' } : undefined,
|
||||
}, { default: () => tag.name }))
|
||||
}
|
||||
return h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '4px' } }, nodes)
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
/** 加载分类选项 */
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const [expenseRes, incomeRes]: any = await Promise.all([getCategoryList('EXPENSE'), getCategoryList('INCOME')])
|
||||
expenseCategories.value = (expenseRes.data || []).map((c: any) => ({ label: c.name, value: c.id, icon: c.icon || '' }))
|
||||
incomeCategories.value = (incomeRes.data || []).map((c: any) => ({ label: c.name, value: c.id, icon: c.icon || '' }))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('import.loadCategoriesFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 分类下拉项渲染:图标 + 名称 */
|
||||
function renderCategoryLabel(option: any) {
|
||||
return h('span', null, [
|
||||
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
|
||||
option.label,
|
||||
])
|
||||
}
|
||||
|
||||
/** 加载标签选项 */
|
||||
async function loadTags() {
|
||||
try {
|
||||
const res: any = await getTagList()
|
||||
allTags.value = (res.data || []).map((tag: any) => ({ label: tag.name, value: tag.id }))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('import.loadTagsFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断文件名是否符合当前账单类型允许的扩展名 */
|
||||
function isAllowedFile(name: string) {
|
||||
const exts = allowedExtensions[billType.value] || []
|
||||
@@ -354,9 +130,6 @@ async function handleParse(password?: string) {
|
||||
try {
|
||||
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
|
||||
@@ -385,62 +158,21 @@ async function submitPassword() {
|
||||
await handleParse(archivePassword.value)
|
||||
}
|
||||
|
||||
/** 切换单行勾选 */
|
||||
function toggleRow(row: ParsedBillRow, checked: boolean) {
|
||||
if (checked) {
|
||||
if (!checkedRowNos.value.includes(row.rowNo)) {
|
||||
checkedRowNos.value = [...checkedRowNos.value, row.rowNo]
|
||||
}
|
||||
} else {
|
||||
checkedRowNos.value = checkedRowNos.value.filter((n) => n !== row.rowNo)
|
||||
}
|
||||
}
|
||||
|
||||
/** 全选/取消全选 */
|
||||
function toggleAll(checked: boolean) {
|
||||
checkedRowNos.value = checked ? selectableRows.value.map((r) => r.rowNo) : []
|
||||
}
|
||||
|
||||
/** 切换单行是否计入统计 */
|
||||
function toggleIncludeInStats(row: ParsedBillRow, value: boolean) {
|
||||
row.includeInStats = value
|
||||
}
|
||||
|
||||
/** 批量设置已勾选行的计入统计标记 */
|
||||
function setCheckedIncludeInStats(value: boolean) {
|
||||
if (checkedRows.value.length === 0) {
|
||||
message.warning(t('import.selectAtLeastOne'))
|
||||
return
|
||||
}
|
||||
checkedRows.value.forEach((row) => {
|
||||
row.includeInStats = value
|
||||
})
|
||||
}
|
||||
|
||||
/** 确认导入 */
|
||||
async function handleConfirm() {
|
||||
if (checkedRows.value.length === 0) {
|
||||
message.warning(t('import.selectAtLeastOne'))
|
||||
return
|
||||
}
|
||||
const items: BillImportItem[] = checkedRows.value.map((r) => ({
|
||||
externalNo: r.externalNo,
|
||||
transactionTime: r.transactionTime,
|
||||
type: r.type,
|
||||
amount: r.amount,
|
||||
counterparty: r.counterparty,
|
||||
product: r.product,
|
||||
includeInStats: r.includeInStats,
|
||||
rawData: r.rawData,
|
||||
}))
|
||||
/** 确认导入:接收预览面板抛出的勾选行与默认归类 */
|
||||
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: expenseCategoryId.value,
|
||||
incomeCategoryId: incomeCategoryId.value,
|
||||
tagIds: importTagIds.value.length ? importTagIds.value : undefined,
|
||||
items,
|
||||
expenseCategoryId: payload.expenseCategoryId,
|
||||
incomeCategoryId: payload.incomeCategoryId,
|
||||
tagIds: payload.tagIds.length ? payload.tagIds : undefined,
|
||||
items: payload.items,
|
||||
})
|
||||
result.value = res.data
|
||||
stage.value = 'result'
|
||||
@@ -458,14 +190,9 @@ function reset() {
|
||||
file.value = null
|
||||
uploadFileList.value = []
|
||||
rows.value = []
|
||||
checkedRowNos.value = []
|
||||
result.value = null
|
||||
expenseCategoryId.value = null
|
||||
incomeCategoryId.value = null
|
||||
importTagIds.value = []
|
||||
passwordModalVisible.value = false
|
||||
archivePassword.value = ''
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
/** 关闭面板(取消或完成) */
|
||||
@@ -480,11 +207,9 @@ watch(billType, (value) => {
|
||||
localStorage.setItem(BILL_TYPE_PREFS_KEY, value)
|
||||
})
|
||||
|
||||
// 挂载时重置并加载分类/标签(页面进入或弹窗打开时均会触发)
|
||||
// 挂载时重置(页面进入或弹窗打开时均会触发)
|
||||
onMounted(() => {
|
||||
reset()
|
||||
loadCategories()
|
||||
loadTags()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -530,99 +255,17 @@ onMounted(() => {
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<!-- 第二步:预览并勾选 -->
|
||||
<NSpace v-else-if="stage === 'preview'" vertical :size="12">
|
||||
<NSpace align="center" :wrap="true" :size="[12, 12]">
|
||||
<NText>{{ t('import.expenseDefaultCategory') }}</NText>
|
||||
<NSelect
|
||||
v-model:value="expenseCategoryId"
|
||||
:options="expenseCategories"
|
||||
:render-label="renderCategoryLabel"
|
||||
filterable
|
||||
clearable
|
||||
:placeholder="t('import.notSetCategory')"
|
||||
style="width: 180px;"
|
||||
/>
|
||||
<NText>{{ t('import.incomeDefaultCategory') }}</NText>
|
||||
<NSelect
|
||||
v-model:value="incomeCategoryId"
|
||||
:options="incomeCategories"
|
||||
:render-label="renderCategoryLabel"
|
||||
filterable
|
||||
clearable
|
||||
:placeholder="t('import.notSetCategory')"
|
||||
style="width: 180px;"
|
||||
/>
|
||||
<NText>{{ t('import.defaultTags') }}</NText>
|
||||
<NSelect
|
||||
v-model:value="importTagIds"
|
||||
:options="allTags"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
:max-tag-count="2"
|
||||
:placeholder="t('import.notSetTag')"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<NCheckbox
|
||||
:checked="allChecked"
|
||||
:indeterminate="someChecked"
|
||||
:disabled="selectableRows.length === 0"
|
||||
@update:checked="toggleAll"
|
||||
>
|
||||
{{ t('import.selectAll') }}
|
||||
</NCheckbox>
|
||||
<NButton size="small" :disabled="checkedRows.length === 0" @click="setCheckedIncludeInStats(true)">
|
||||
{{ t('import.setIncluded') }}
|
||||
</NButton>
|
||||
<NButton size="small" :disabled="checkedRows.length === 0" @click="setCheckedIncludeInStats(false)">
|
||||
{{ t('import.setExcluded') }}
|
||||
</NButton>
|
||||
<NText depth="3">{{ t('import.previewSummary', { total: rows.length, filtered: filteredRows.length, checked: checkedRows.length }) }}</NText>
|
||||
</NSpace>
|
||||
<!-- 第二步:预览并勾选(复用共用预览组件) -->
|
||||
<BillPreviewPanel
|
||||
v-else-if="stage === 'preview'"
|
||||
:rows="rows"
|
||||
:importing="importing"
|
||||
@confirm="handleConfirm"
|
||||
@back="stage = 'select'"
|
||||
/>
|
||||
|
||||
<!-- 筛选条件 -->
|
||||
<NSpace align="center" :wrap="true" :size="[12, 12]">
|
||||
<NInput v-model:value="filterKeyword" clearable :placeholder="t('import.searchPlaceholder')" style="width: 220px;" />
|
||||
<NSelect v-model:value="filterType" :options="typeFilterOptions" clearable :placeholder="t('import.typePlaceholder')" style="width: 120px;" />
|
||||
<NSelect v-model:value="filterStatus" :options="statusFilterOptions" clearable :placeholder="t('import.statusPlaceholder')" style="width: 130px;" />
|
||||
<NInputNumber v-model:value="filterMinAmount" :min="0" :precision="2" clearable :placeholder="t('common.min')" style="width: 120px;" />
|
||||
<span style="color: #999;">~</span>
|
||||
<NInputNumber v-model:value="filterMaxAmount" :min="0" :precision="2" clearable :placeholder="t('common.max')" style="width: 120px;" />
|
||||
<NButton size="small" @click="resetFilters">{{ t('common.reset') }}</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="filteredRows"
|
||||
:max-height="420"
|
||||
:row-key="(row) => row.rowNo"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
/>
|
||||
|
||||
<NSpace justify="end">
|
||||
<NButton @click="stage = 'select'">{{ t('import.prevStep') }}</NButton>
|
||||
<NButton type="primary" :loading="importing" @click="handleConfirm">
|
||||
{{ t('import.confirmImport', { count: checkedRows.length }) }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<!-- 第三步:导入结果 -->
|
||||
<NSpace v-else vertical :size="12">
|
||||
<NAlert type="success" :show-icon="true">
|
||||
{{ t('import.resultSummary', { imported: result?.imported ?? 0, skipped: result?.skipped ?? 0, failed: result?.failed ?? 0 }) }}
|
||||
</NAlert>
|
||||
<div v-if="result?.messages?.length" style="max-height: 320px; overflow: auto;">
|
||||
<NText v-for="(msg, i) in result.messages" :key="i" depth="3" style="display: block; font-size: 12px;">
|
||||
{{ msg }}
|
||||
</NText>
|
||||
</div>
|
||||
<NSpace justify="end">
|
||||
<NButton type="primary" @click="handleClose">{{ t('import.done') }}</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
<!-- 第三步:导入结果(复用共用结果组件) -->
|
||||
<BillImportResult v-else :result="result" @done="handleClose" />
|
||||
|
||||
<!-- 压缩包加密时弹出,要求输入解压密码 -->
|
||||
<NModal
|
||||
|
||||
Reference in New Issue
Block a user