feat: 邮箱账单自动拉取

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

同时包含:
- 统计新增口径筛选(仅计入/全部/仅不计入),支付方式改为后端动态获取
- 统一实体 createdAt/updatedAt 位置至 id 之后,调整部分日志级别
This commit is contained in:
2026-09-19 17:50:55 +08:00
parent dedf30c74d
commit 9bc858eefc
65 changed files with 5940 additions and 529 deletions
+880
View File
@@ -0,0 +1,880 @@
<script setup lang="ts">
// 邮箱账单管理页:邮箱配置、发件人规则与待处理账单(解析 - 选择 - 入库)
import { ref, computed, h, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
useMessage, useDialog, NSpace, NButton, NDataTable, NTag, NModal, NForm, NFormItem,
NInput, NInputNumber, NSwitch, NSelect, NAlert, NText, NCard, NTabs, NTabPane,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import {
listEmailBillConfigs, createEmailBillConfig, updateEmailBillConfig, deleteEmailBillConfig,
toggleEmailBillConfig, testEmailBillConfig,
listEmailBillSenderRules, createEmailBillSenderRule, updateEmailBillSenderRule,
deleteEmailBillSenderRule, toggleEmailBillSenderRule,
pageEmailBillMessages, parseEmailBillMessage, importEmailBillMessage,
ignoreEmailBillMessage, deleteEmailBillMessage,
} from '../api/emailBill'
import type {
EmailBillConfig, EmailBillSenderRule, EmailBillMessage, EmailBillConfigPayload,
EmailBillSenderRulePayload,
} from '../api/emailBill'
import type { ParsedBillRow, BillType, 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'
import { useUserStore } from '../store/user'
const message = useMessage()
const dialog = useDialog()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
/** 待处理列表自动刷新间隔(毫秒) */
const MESSAGE_REFRESH_MS = 30000
// 标签页偏好存储键,与路由 /email-bills 的重定向逻辑保持一致
const EMAIL_BILL_TAB_KEY = 'emailBill.tab'
// 当前标签页取自 URL 子路径(/email-bills/configs、/senderRules、/messages)
const activeTab = ref<string>((route.params.tab as string) || 'configs')
// ===== 公共选项 =====
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' },
])
/** 账单类型编码转当前语言标签,未知值回退原编码 */
function billTypeLabel(value: string | null) {
if (!value) return '-'
return billTypeOptions.value.find((option) => option.value === value)?.label || value
}
/** 常用 IMAP 服务器候选项,选择后自动填充默认端口与 SSL */
const imapHostOptions = computed(() => [
{ label: `${t('emailBill.imapHosts.qq')} · imap.qq.com`, value: 'imap.qq.com' },
{ label: `${t('emailBill.imapHosts.qqExmail')} · imap.exmail.qq.com`, value: 'imap.exmail.qq.com' },
{ label: `${t('emailBill.imapHosts.netease163')} · imap.163.com`, value: 'imap.163.com' },
{ label: `${t('emailBill.imapHosts.netease126')} · imap.126.com`, value: 'imap.126.com' },
{ label: `${t('emailBill.imapHosts.neteaseQiye')} · imap.qiye.163.com`, value: 'imap.qiye.163.com' },
{ label: `${t('emailBill.imapHosts.aliyun')} · imap.aliyun.com`, value: 'imap.aliyun.com' },
{ label: `${t('emailBill.imapHosts.sina')} · imap.sina.com`, value: 'imap.sina.com' },
{ label: `${t('emailBill.imapHosts.mail139')} · imap.139.com`, value: 'imap.139.com' },
{ label: `${t('emailBill.imapHosts.mail189')} · imap.189.cn`, value: 'imap.189.cn' },
{ label: `${t('emailBill.imapHosts.gmail')} · imap.gmail.com`, value: 'imap.gmail.com' },
{ label: `${t('emailBill.imapHosts.outlook')} · outlook.office365.com`, value: 'outlook.office365.com' },
{ label: `${t('emailBill.imapHosts.icloud')} · imap.mail.me.com`, value: 'imap.mail.me.com' },
])
const expenseCategories = ref<{ label: string; value: number; icon: string }[]>([])
const incomeCategories = ref<{ label: string; value: number; icon: string }[]>([])
const allTags = ref<{ label: string; value: number }[]>([])
/** 分类下拉项渲染:图标 + 名称 */
function renderCategoryLabel(option: any) {
return h('span', null, [
option.icon ? h('span', { style: 'margin-right:6px' }, option.icon) : null,
option.label,
])
}
async function loadOptions() {
try {
const [expenseRes, incomeRes, tagRes]: any = await Promise.all([
getCategoryList('EXPENSE'), getCategoryList('INCOME'), getTagList(),
])
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 || '' }))
allTags.value = (tagRes.data || []).map((tag: any) => ({ label: tag.name, value: tag.id }))
} catch (e: any) {
message.error(e.message || t('emailBill.common.loadFailed'))
}
}
// ===== 邮箱配置 =====
const configs = ref<EmailBillConfig[]>([])
const configLoading = ref(false)
const configModalVisible = ref(false)
const configEditingId = ref<number | null>(null)
const configForm = ref<EmailBillConfigPayload>(emptyConfigForm())
// ===== 立即检查实时进度 =====
const checkLogs = ref<string[]>([])
const checkRunning = ref(false)
let checkSource: EventSource | null = null
let checkFinished = false
function emptyConfigForm(): EmailBillConfigPayload {
return {
name: '', host: '', port: 993, ssl: true, username: '', password: '',
folder: 'INBOX', enabled: false, expenseCategoryId: null, incomeCategoryId: null, tagIds: [],
fetchScope: 'INCREMENT', fetchLimit: null,
}
}
/** 拉取范围选项 */
const fetchScopeOptions = computed(() => [
{ label: t('emailBill.config.fetchScopeIncrement'), value: 'INCREMENT' },
{ label: t('emailBill.config.fetchScopeDays'), value: 'DAYS' },
{ label: t('emailBill.config.fetchScopeCount'), value: 'COUNT' },
])
/** 选择内置 IMAP 服务器时自动填充默认端口与 SSL */
function handleHostChange(value: string) {
if (value && imapHostOptions.value.some((option) => option.value === value)) {
configForm.value.port = 993
configForm.value.ssl = true
}
}
/** 连接状态标签类型 */
function statusTagType(status: string) {
if (status === 'CONNECTED') return 'success'
if (status === 'POLLING') return 'info'
if (status === 'ERROR') return 'error'
return 'default'
}
const configColumns = computed<DataTableColumns<EmailBillConfig>>(() => [
{ title: t('emailBill.config.name'), key: 'name', width: 140, ellipsis: { tooltip: true } },
{
title: t('emailBill.config.host'), key: 'host', width: 200,
render: (row) => `${row.host}:${row.port}${row.ssl ? ' (SSL)' : ''}`,
},
{ title: t('emailBill.config.username'), key: 'username', width: 180, ellipsis: { tooltip: true } },
{ title: t('emailBill.config.folder'), key: 'folder', width: 100 },
{
title: t('emailBill.config.status'), key: 'status', width: 110,
render: (row) => h(NTag, { size: 'small', type: statusTagType(row.status) as any }, {
default: () => t(`emailBill.config.statusText.${row.status}`),
}),
},
{
title: t('emailBill.common.enabled'), key: 'enabled', width: 80,
render: (row) => h(NSwitch, {
value: row.enabled, size: 'small',
onUpdateValue: (v: boolean) => handleToggleConfig(row, v),
}),
},
{
title: t('emailBill.common.actions'), key: 'actions', width: 320,
render: (row) => h(NSpace, { size: 4 }, {
default: () => [
h(NButton, { size: 'tiny', onClick: () => handleTestConfig(row) }, { default: () => t('emailBill.config.test') }),
h(NButton, { size: 'tiny', onClick: () => handleCheckConfig(row) }, { default: () => t('emailBill.config.check') }),
h(NButton, { size: 'tiny', onClick: () => openConfigModal(row) }, { default: () => t('emailBill.common.edit') }),
h(NButton, { size: 'tiny', type: 'error', onClick: () => handleDeleteConfig(row) }, { default: () => t('emailBill.common.delete') }),
],
}),
},
])
async function loadConfigs() {
configLoading.value = true
try {
const res: any = await listEmailBillConfigs()
configs.value = res.data || []
} catch (e: any) {
message.error(e.message || t('emailBill.common.loadFailed'))
} finally {
configLoading.value = false
}
}
function openConfigModal(row?: EmailBillConfig) {
if (row) {
configEditingId.value = row.id
configForm.value = {
name: row.name, host: row.host, port: row.port, ssl: row.ssl, username: row.username,
password: '', folder: row.folder, enabled: row.enabled,
expenseCategoryId: row.expenseCategoryId, incomeCategoryId: row.incomeCategoryId,
tagIds: [...(row.tagIds || [])],
fetchScope: row.fetchScope || 'INCREMENT', fetchLimit: row.fetchLimit,
}
} else {
configEditingId.value = null
configForm.value = emptyConfigForm()
}
configModalVisible.value = true
}
async function saveConfig() {
try {
if (configEditingId.value) {
await updateEmailBillConfig(configEditingId.value, configForm.value)
} else {
await createEmailBillConfig(configForm.value)
}
message.success(t('emailBill.common.saveSuccess'))
configModalVisible.value = false
await loadConfigs()
} catch (e: any) {
message.error(e.message || t('emailBill.common.loadFailed'))
}
}
function handleDeleteConfig(row: EmailBillConfig) {
dialog.warning({
title: t('emailBill.common.delete'),
content: t('emailBill.common.deleteConfirm'),
positiveText: t('emailBill.common.confirm'),
negativeText: t('emailBill.common.cancel'),
onPositiveClick: async () => {
try {
await deleteEmailBillConfig(row.id)
message.success(t('emailBill.common.deleteSuccess'))
await loadConfigs()
} catch (e: any) {
message.error(e.message)
}
},
})
}
async function handleToggleConfig(row: EmailBillConfig, enabled: boolean) {
try {
await toggleEmailBillConfig(row.id, enabled)
await loadConfigs()
} catch (e: any) {
message.error(e.message)
}
}
async function handleTestConfig(row: EmailBillConfig) {
try {
await testEmailBillConfig(row.id)
message.success(t('emailBill.config.testSuccess'))
await loadConfigs()
} catch (e: any) {
message.error(e.message || t('emailBill.config.testFailed'))
}
}
/** 立即检查:建立 SSE 连接并实时展示执行进度 */
function handleCheckConfig(row: EmailBillConfig) {
stopCheckStream()
checkLogs.value = []
checkRunning.value = true
checkFinished = false
const params = new URLSearchParams()
// EventSource 无法自定义请求头,token 通过查询参数传递
params.set('token', userStore.token || '')
const source = new EventSource(`/api/email-bill/configs/${row.id}/check/stream?${params.toString()}`)
checkSource = source
source.addEventListener('line', (e: MessageEvent) => {
checkLogs.value.push(e.data)
})
source.addEventListener('done', async (e: MessageEvent) => {
let count = 0
try {
count = JSON.parse(e.data).count ?? 0
} catch {
count = 0
}
checkLogs.value.push(t('emailBill.config.checkResult', { count }))
finishCheck()
await Promise.all([loadConfigs(), loadMessages()])
})
source.addEventListener('failed', (e: MessageEvent) => {
let reason = ''
try {
reason = JSON.parse(e.data).message ?? ''
} catch {
reason = ''
}
checkLogs.value.push(reason ? `${t('emailBill.config.checkFailed')}:${reason}` : t('emailBill.config.checkFailed'))
finishCheck()
loadConfigs()
})
source.onerror = () => {
// 连接异常或服务端结束后关闭,避免 EventSource 自动重连导致重复检查
if (!checkFinished) {
finishCheck()
}
}
}
/** 结束检查状态并关闭 SSE 连接 */
function finishCheck() {
checkFinished = true
checkRunning.value = false
stopCheckStream()
}
/** 关闭当前 SSE 连接 */
function stopCheckStream() {
if (checkSource) {
checkSource.close()
checkSource = null
}
}
// ===== 发件人规则 =====
const senderRules = ref<EmailBillSenderRule[]>([])
const ruleLoading = ref(false)
const ruleModalVisible = ref(false)
const ruleEditingId = ref<number | null>(null)
const ruleForm = ref<EmailBillSenderRulePayload & { patternsText: string }>(emptyRuleForm())
function emptyRuleForm() {
return {
name: '', enabled: true, sortOrder: 0, patternsText: '', billType: 'ALIPAY' as BillType,
archivePassword: '', expenseCategoryId: null, incomeCategoryId: null, tagIds: [] as number[],
}
}
const ruleColumns = computed<DataTableColumns<EmailBillSenderRule>>(() => [
{ title: t('emailBill.senderRule.name'), key: 'name', width: 140, ellipsis: { tooltip: true } },
{
title: t('emailBill.senderRule.patterns'), key: 'senderPatterns', width: 220, ellipsis: { tooltip: true },
render: (row) => (row.senderPatterns || []).join(', '),
},
{
title: t('emailBill.senderRule.billType'), key: 'billType', width: 100,
render: (row) => h(NTag, { size: 'small', type: 'info' }, { default: () => billTypeLabel(row.billType) }),
},
{ title: t('emailBill.senderRule.sortOrder'), key: 'sortOrder', width: 80 },
{
title: t('emailBill.common.enabled'), key: 'enabled', width: 80,
render: (row) => h(NSwitch, {
value: row.enabled, size: 'small',
onUpdateValue: (v: boolean) => handleToggleRule(row, v),
}),
},
{
title: t('emailBill.common.actions'), key: 'actions', width: 140,
render: (row) => h(NSpace, { size: 4 }, {
default: () => [
h(NButton, { size: 'tiny', onClick: () => openRuleModal(row) }, { default: () => t('emailBill.common.edit') }),
h(NButton, { size: 'tiny', type: 'error', onClick: () => handleDeleteRule(row) }, { default: () => t('emailBill.common.delete') }),
],
}),
},
])
async function loadSenderRules() {
ruleLoading.value = true
try {
const res: any = await listEmailBillSenderRules()
senderRules.value = res.data || []
} catch (e: any) {
message.error(e.message || t('emailBill.common.loadFailed'))
} finally {
ruleLoading.value = false
}
}
function openRuleModal(row?: EmailBillSenderRule) {
if (row) {
ruleEditingId.value = row.id
ruleForm.value = {
name: row.name, enabled: row.enabled, sortOrder: row.sortOrder,
patternsText: (row.senderPatterns || []).join('\n'), billType: row.billType,
archivePassword: row.archivePassword || '',
expenseCategoryId: row.expenseCategoryId, incomeCategoryId: row.incomeCategoryId,
tagIds: [...(row.tagIds || [])],
}
} else {
ruleEditingId.value = null
ruleForm.value = emptyRuleForm()
}
ruleModalVisible.value = true
}
async function saveRule() {
const patterns = ruleForm.value.patternsText.split('\n').map((s) => s.trim()).filter(Boolean)
if (patterns.length === 0) {
message.warning(t('emailBill.senderRule.patternsRequired'))
return
}
const payload: EmailBillSenderRulePayload = {
name: ruleForm.value.name,
enabled: ruleForm.value.enabled,
sortOrder: ruleForm.value.sortOrder,
senderPatterns: patterns,
billType: ruleForm.value.billType,
archivePassword: ruleForm.value.archivePassword,
expenseCategoryId: ruleForm.value.expenseCategoryId,
incomeCategoryId: ruleForm.value.incomeCategoryId,
tagIds: ruleForm.value.tagIds,
}
try {
if (ruleEditingId.value) {
await updateEmailBillSenderRule(ruleEditingId.value, payload)
} else {
await createEmailBillSenderRule(payload)
}
message.success(t('emailBill.common.saveSuccess'))
ruleModalVisible.value = false
await loadSenderRules()
} catch (e: any) {
message.error(e.message)
}
}
function handleDeleteRule(row: EmailBillSenderRule) {
dialog.warning({
title: t('emailBill.common.delete'),
content: t('emailBill.common.deleteConfirm'),
positiveText: t('emailBill.common.confirm'),
negativeText: t('emailBill.common.cancel'),
onPositiveClick: async () => {
try {
await deleteEmailBillSenderRule(row.id)
message.success(t('emailBill.common.deleteSuccess'))
await loadSenderRules()
} catch (e: any) {
message.error(e.message)
}
},
})
}
async function handleToggleRule(row: EmailBillSenderRule, enabled: boolean) {
try {
await toggleEmailBillSenderRule(row.id, enabled)
await loadSenderRules()
} catch (e: any) {
message.error(e.message)
}
}
// ===== 待处理账单 =====
const messages = ref<EmailBillMessage[]>([])
const messageLoading = ref(false)
const messageStatus = ref<string | null>('PULLED')
const messagePage = ref(1)
const messageSize = ref(20)
const messageTotal = ref(0)
const messageColumns = computed<DataTableColumns<EmailBillMessage>>(() => [
{
title: t('emailBill.message.receivedAt'), key: 'receivedAt', width: 150,
render: (row) => (row.receivedAt ? row.receivedAt.replace('T', ' ').substring(0, 16) : '-'),
},
{ title: t('emailBill.message.fromAddress'), key: 'fromAddress', width: 180, ellipsis: { tooltip: true } },
{ title: t('emailBill.message.subject'), key: 'subject', ellipsis: { tooltip: true } },
{ title: t('emailBill.message.attachmentName'), key: 'attachmentName', width: 180, ellipsis: { tooltip: true } },
{
title: t('emailBill.message.billType'), key: 'billType', width: 100,
render: (row) => billTypeLabel(row.billType),
},
{
title: t('emailBill.message.status'), key: 'status', width: 100,
render: (row) => {
if (row.error) {
return h(NTag, { size: 'small', type: 'error' }, { default: () => row.error })
}
const type = row.status === 'CONFIRMED' ? 'success' : row.status === 'IGNORED' ? 'default' : 'warning'
return h(NTag, { size: 'small', type: type as any }, {
default: () => t(`emailBill.message.statusText.${row.status}`),
})
},
},
{
title: t('emailBill.common.actions'), key: 'actions', width: 220,
render: (row) => h(NSpace, { size: 4 }, {
default: () => [
h(NButton, {
size: 'tiny', type: 'primary', disabled: row.status !== 'PULLED',
onClick: () => openParseModal(row),
}, { default: () => t('emailBill.message.parse') }),
h(NButton, {
size: 'tiny', disabled: row.status !== 'PULLED',
onClick: () => handleIgnoreMessage(row),
}, { default: () => t('emailBill.message.ignore') }),
h(NButton, {
size: 'tiny', type: 'error',
onClick: () => handleDeleteMessage(row),
}, { default: () => t('emailBill.common.delete') }),
],
}),
},
])
async function loadMessages() {
messageLoading.value = true
try {
const res: any = await pageEmailBillMessages({
status: messageStatus.value || undefined,
page: messagePage.value,
size: messageSize.value,
})
messages.value = res.data?.records || []
messageTotal.value = res.data?.total || 0
} catch (e: any) {
message.error(e.message || t('emailBill.common.loadFailed'))
} finally {
messageLoading.value = false
}
}
function handleIgnoreMessage(row: EmailBillMessage) {
dialog.warning({
title: t('emailBill.message.ignore'),
content: t('emailBill.common.deleteConfirm'),
positiveText: t('emailBill.common.confirm'),
negativeText: t('emailBill.common.cancel'),
onPositiveClick: async () => {
try {
await ignoreEmailBillMessage(row.id)
message.success(t('emailBill.common.saveSuccess'))
await loadMessages()
} catch (e: any) {
message.error(e.message)
}
},
})
}
function handleDeleteMessage(row: EmailBillMessage) {
dialog.warning({
title: t('emailBill.common.delete'),
content: t('emailBill.common.deleteConfirm'),
positiveText: t('emailBill.common.confirm'),
negativeText: t('emailBill.common.cancel'),
onPositiveClick: async () => {
try {
await deleteEmailBillMessage(row.id)
message.success(t('emailBill.common.deleteSuccess'))
await loadMessages()
} catch (e: any) {
message.error(e.message)
}
},
})
}
// ===== 解析弹窗 =====
const parseModalVisible = ref(false)
const parseStage = ref<'select' | 'preview' | 'result'>('select')
const parseMessageRow = ref<EmailBillMessage | null>(null)
const parseBillType = ref<BillType>('ALIPAY')
const parsePassword = ref('')
const parsing = ref(false)
const importing = ref(false)
const parsedRows = ref<ParsedBillRow[]>([])
const parseResult = ref<{ total: number; imported: number; skipped: number; failed: number; messages: string[] } | null>(null)
function openParseModal(row: EmailBillMessage) {
parseMessageRow.value = row
parseStage.value = 'select'
parseBillType.value = (row.billType || 'ALIPAY') as BillType
parsePassword.value = ''
parsedRows.value = []
parseResult.value = null
parseModalVisible.value = true
}
async function doParse() {
if (!parseMessageRow.value) return
parsing.value = true
try {
const res: any = await parseEmailBillMessage(parseMessageRow.value.id, {
billType: parseBillType.value,
password: parsePassword.value || undefined,
})
parsedRows.value = res.data.rows || []
parseStage.value = 'preview'
await loadMessages()
} catch (e: any) {
message.error(e.message || t('emailBill.parse.parseFailed'))
} finally {
parsing.value = false
}
}
async function handleImport(payload: {
items: BillImportItem[]
expenseCategoryId: number | null
incomeCategoryId: number | null
tagIds: number[]
}) {
if (!parseMessageRow.value) return
importing.value = true
try {
const res: any = await importEmailBillMessage(parseMessageRow.value.id, {
billType: parseBillType.value,
expenseCategoryId: payload.expenseCategoryId,
incomeCategoryId: payload.incomeCategoryId,
tagIds: payload.tagIds.length ? payload.tagIds : undefined,
items: payload.items,
})
parseResult.value = res.data
parseStage.value = 'result'
message.success(t('emailBill.parse.importSuccess'))
await loadMessages()
} catch (e: any) {
message.error(e.message || t('import.importFailed'))
} finally {
importing.value = false
}
}
// ===== 路由与标签页同步 =====
// URL 子路径变化(如浏览器前进/后退)时同步标签页
watch(() => route.params.tab, (tab) => {
const target = (tab as string) || 'configs'
if (target !== activeTab.value) {
activeTab.value = target
}
})
// 标签页切换:记住偏好并同步 URL 子路径
watch(activeTab, (tab) => {
localStorage.setItem(EMAIL_BILL_TAB_KEY, tab)
if ((route.params.tab as string) !== tab) {
router.push(`/email-bills/${tab}`)
}
}, { immediate: true })
// ===== 生命周期 =====
let timer: number | null = null
onMounted(async () => {
await loadOptions()
await Promise.all([loadConfigs(), loadSenderRules(), loadMessages()])
timer = window.setInterval(() => {
if (activeTab.value === 'messages') {
loadMessages()
}
}, MESSAGE_REFRESH_MS)
})
onUnmounted(() => {
if (timer) window.clearInterval(timer)
stopCheckStream()
})
</script>
<template>
<NCard :title="t('emailBill.title')" :bordered="true">
<NTabs v-model:value="activeTab" type="line">
<!-- 邮箱配置 -->
<NTabPane name="configs" :tab="t('emailBill.tab.configs')">
<NSpace vertical :size="12">
<NSpace justify="end">
<NButton type="primary" @click="openConfigModal()">{{ t('emailBill.common.add') }}</NButton>
</NSpace>
<NDataTable :columns="configColumns" :data="configs" :loading="configLoading" :row-key="(r: EmailBillConfig) => r.id" size="small" :bordered="false" />
<NCard
v-if="checkRunning || checkLogs.length"
size="small"
:title="t('emailBill.config.checkLogTitle')"
>
<template #header-extra>
<NSpace align="center" :size="8">
<NTag v-if="checkRunning" size="small" type="info">{{ t('emailBill.config.checkRunning') }}</NTag>
<NButton v-else size="tiny" @click="checkLogs = []">{{ t('emailBill.config.checkClear') }}</NButton>
</NSpace>
</template>
<NLog :rows="checkLogs" :loading="checkRunning" style="max-height: 240px;" />
</NCard>
</NSpace>
</NTabPane>
<!-- 发件人规则 -->
<NTabPane name="senderRules" :tab="t('emailBill.tab.senderRules')">
<NSpace vertical :size="12">
<NSpace justify="end">
<NButton type="primary" @click="openRuleModal()">{{ t('emailBill.common.add') }}</NButton>
</NSpace>
<NDataTable :columns="ruleColumns" :data="senderRules" :loading="ruleLoading" :row-key="(r: EmailBillSenderRule) => r.id" size="small" :bordered="false" />
</NSpace>
</NTabPane>
<!-- 待处理账单 -->
<NTabPane name="messages" :tab="t('emailBill.tab.messages')">
<NSpace vertical :size="12">
<NSpace align="center">
<NSelect
v-model:value="messageStatus"
:options="[
{ label: t('emailBill.message.statusText.PULLED'), value: 'PULLED' },
{ label: t('emailBill.message.statusText.CONFIRMED'), value: 'CONFIRMED' },
{ label: t('emailBill.message.statusText.IGNORED'), value: 'IGNORED' },
]"
clearable
:placeholder="t('emailBill.message.filterStatus')"
style="width: 160px;"
@update:value="() => { messagePage = 1; loadMessages() }"
/>
<NButton @click="loadMessages">{{ t('emailBill.common.refresh') }}</NButton>
</NSpace>
<NDataTable
:columns="messageColumns"
:data="messages"
:loading="messageLoading"
:row-key="(r: EmailBillMessage) => r.id"
size="small"
:bordered="false"
remote
:pagination="{
page: messagePage, pageSize: messageSize, itemCount: messageTotal, showSizePicker: false,
onUpdatePage: (p: number) => { messagePage = p; loadMessages() },
}"
/>
</NSpace>
</NTabPane>
</NTabs>
<!-- 邮箱配置弹窗 -->
<NModal v-model:show="configModalVisible" preset="card" :title="t('emailBill.tab.configs')" style="width: 560px; max-width: 92vw;">
<NForm label-placement="left" label-width="130">
<NFormItem :label="t('emailBill.config.name')">
<NInput v-model:value="configForm.name" />
</NFormItem>
<NFormItem :label="t('emailBill.config.host')">
<NSelect
v-model:value="configForm.host"
:options="imapHostOptions"
filterable
tag
clearable
:placeholder="t('emailBill.config.hostPlaceholder')"
@update:value="handleHostChange"
/>
</NFormItem>
<NFormItem :label="t('emailBill.config.port')">
<NInputNumber v-model:value="configForm.port" :min="1" :max="65535" style="width: 160px;" />
</NFormItem>
<NFormItem :label="t('emailBill.config.ssl')">
<NSwitch v-model:value="configForm.ssl" />
</NFormItem>
<NFormItem :label="t('emailBill.config.username')">
<NInput v-model:value="configForm.username" />
</NFormItem>
<NFormItem :label="t('emailBill.config.password')">
<NInput v-model:value="configForm.password" type="password" show-password-on="click" :placeholder="t('emailBill.config.passwordPlaceholder')" />
</NFormItem>
<NFormItem :label="t('emailBill.config.folder')">
<NInput v-model:value="configForm.folder" placeholder="INBOX" />
</NFormItem>
<NFormItem :label="t('emailBill.config.defaultExpenseCategory')">
<NSelect v-model:value="configForm.expenseCategoryId" :options="expenseCategories" :render-label="renderCategoryLabel" filterable clearable style="width: 100%;" />
</NFormItem>
<NFormItem :label="t('emailBill.config.defaultIncomeCategory')">
<NSelect v-model:value="configForm.incomeCategoryId" :options="incomeCategories" :render-label="renderCategoryLabel" filterable clearable style="width: 100%;" />
</NFormItem>
<NFormItem :label="t('emailBill.config.defaultTags')">
<NSelect v-model:value="configForm.tagIds" :options="allTags" multiple clearable filterable style="width: 100%;" />
</NFormItem>
<NFormItem :label="t('emailBill.config.fetchScope')">
<NSpace align="center" :size="8">
<NSelect v-model:value="configForm.fetchScope" :options="fetchScopeOptions" style="width: 160px;" />
<NInputNumber
v-if="configForm.fetchScope === 'DAYS'"
v-model:value="configForm.fetchLimit"
:min="1"
:max="365"
:placeholder="t('emailBill.config.fetchLimitDays')"
style="width: 140px;"
/>
<NInputNumber
v-else-if="configForm.fetchScope === 'COUNT'"
v-model:value="configForm.fetchLimit"
:min="1"
:max="500"
:placeholder="t('emailBill.config.fetchLimitCount')"
style="width: 140px;"
/>
</NSpace>
</NFormItem>
<NFormItem :label="t('emailBill.common.enabled')">
<NSwitch v-model:value="configForm.enabled" />
</NFormItem>
</NForm>
<template #footer>
<NSpace justify="end">
<NButton @click="configModalVisible = false">{{ t('emailBill.common.cancel') }}</NButton>
<NButton type="primary" @click="saveConfig">{{ t('emailBill.common.save') }}</NButton>
</NSpace>
</template>
</NModal>
<!-- 发件人规则弹窗 -->
<NModal v-model:show="ruleModalVisible" preset="card" :title="t('emailBill.tab.senderRules')" style="width: 560px; max-width: 92vw;">
<NForm label-placement="left" label-width="130">
<NFormItem :label="t('emailBill.senderRule.name')">
<NInput v-model:value="ruleForm.name" />
</NFormItem>
<NFormItem :label="t('emailBill.senderRule.patterns')">
<NInput v-model:value="ruleForm.patternsText" type="textarea" :autosize="{ minRows: 3, maxRows: 6 }" :placeholder="t('emailBill.senderRule.patternsHint')" />
</NFormItem>
<NFormItem :label="t('emailBill.senderRule.billType')">
<NSelect v-model:value="ruleForm.billType" :options="billTypeOptions" style="width: 100%;" />
</NFormItem>
<NFormItem :label="t('emailBill.senderRule.archivePassword')">
<NInput v-model:value="ruleForm.archivePassword" :placeholder="t('emailBill.senderRule.archivePasswordPlaceholder')" />
</NFormItem>
<NFormItem :label="t('emailBill.senderRule.defaultExpenseCategory')">
<NSelect v-model:value="ruleForm.expenseCategoryId" :options="expenseCategories" :render-label="renderCategoryLabel" filterable clearable style="width: 100%;" />
</NFormItem>
<NFormItem :label="t('emailBill.senderRule.defaultIncomeCategory')">
<NSelect v-model:value="ruleForm.incomeCategoryId" :options="incomeCategories" :render-label="renderCategoryLabel" filterable clearable style="width: 100%;" />
</NFormItem>
<NFormItem :label="t('emailBill.senderRule.defaultTags')">
<NSelect v-model:value="ruleForm.tagIds" :options="allTags" multiple clearable filterable style="width: 100%;" />
</NFormItem>
<NFormItem :label="t('emailBill.senderRule.sortOrder')">
<NInputNumber v-model:value="ruleForm.sortOrder" :min="0" style="width: 160px;" />
</NFormItem>
<NFormItem :label="t('emailBill.common.enabled')">
<NSwitch v-model:value="ruleForm.enabled" />
</NFormItem>
</NForm>
<template #footer>
<NSpace justify="end">
<NButton @click="ruleModalVisible = false">{{ t('emailBill.common.cancel') }}</NButton>
<NButton type="primary" @click="saveRule">{{ t('emailBill.common.save') }}</NButton>
</NSpace>
</template>
</NModal>
<!-- 解析弹窗 -->
<NModal v-model:show="parseModalVisible" preset="card" :title="t('emailBill.parse.title')" style="width: 1000px; max-width: 94vw;" :mask-closable="false">
<!-- 第一步:选择类型与密码并解析 -->
<NSpace v-if="parseStage === 'select'" vertical :size="12">
<NAlert type="info" :show-icon="true">
{{ parseMessageRow?.fromAddress }} · {{ parseMessageRow?.attachmentName }}
</NAlert>
<NSpace align="center">
<NText>{{ t('emailBill.parse.billType') }}</NText>
<NSelect v-model:value="parseBillType" :options="billTypeOptions" style="width: 160px;" />
<NText>{{ t('emailBill.parse.password') }}</NText>
<NInput v-model:value="parsePassword" type="password" show-password-on="click" :placeholder="t('emailBill.senderRule.archivePasswordPlaceholder')" style="width: 200px;" />
</NSpace>
<NSpace justify="end">
<NButton @click="parseModalVisible = false">{{ t('emailBill.common.cancel') }}</NButton>
<NButton type="primary" :loading="parsing" @click="doParse">{{ t('emailBill.parse.parseButton') }}</NButton>
</NSpace>
</NSpace>
<!-- 第二步:预览并勾选(复用共用预览组件) -->
<BillPreviewPanel
v-else-if="parseStage === 'preview'"
:rows="parsedRows"
:importing="importing"
@confirm="handleImport"
@back="parseStage = 'select'"
/>
<!-- 第三步:导入结果(复用共用结果组件) -->
<BillImportResult v-else :result="parseResult" @done="parseModalVisible = false" />
</NModal>
</NCard>
</template>