Files
ledgerly/frontend/src/views/EmailBillManage.vue
T
rosercode 1f520f4376 feat: 用户级偏好配置与外观设置,枚举统一抽取与账单类型后端下发
- 新增用户偏好表 t_user_preference 与进程内缓存,账单来源统计默认值由全局改为按用户存储(/api/setting)
- 新增外观设置:不计入统计记录支持删除线与灰色标签两种展示样式
- 抽取 ChannelType / TransactionType / StatisticsScope 枚举,替换各处硬编码的渠道、类型与统计口径
- BillType 内聚各来源扩展名,新增 /transaction/import/types 接口,前端改为按后端下发渲染
- 仪表盘趋势图支持点击柱子跳转收支记录并带入筛选条件
- 布局内容区按路由记忆滚动位置;已登录访问登录/注册页自动跳转
2026-09-21 10:51:15 +08:00

909 lines
34 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">
// 邮箱账单管理页:邮箱配置、发件人规则与待处理账单(解析 - 选择 - 入库)
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, NLog,
} 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 { getBillTypes } from '../api/transaction'
import type { BillTypeOption } from '../api/transaction'
import { getCategoryList } from '../api/category'
import { getTagList } from '../api/tag'
import { renderCategoryLabel } from '../utils/render'
import BillPreviewPanel from '../components/BillPreviewPanel.vue'
import BillImportResult from '../components/BillImportResult.vue'
import { openSse } from '../utils/sse'
const message = useMessage()
const dialog = useDialog()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
/** 待处理列表自动刷新间隔(毫秒) */
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 billTypes = ref<BillTypeOption[]>([])
// 账单类型对应的名称文案 key(文案由前端按语言维护,新增来源未补文案时回退编码)
const billTypeLabelKeys: Record<string, string> = {
ALIPAY: 'import.billTypeALIPAY',
WECHAT: 'import.billTypeWECHAT',
MEITUAN: 'import.billTypeMEITUAN',
CMB: 'import.billTypeCMB',
BOCOM: 'import.billTypeBOCOM',
}
// 账单类型选项,来源于后端
const billTypeOptions = computed(() =>
billTypes.value.map((item) => ({
label: billTypeLabelKeys[item.code] ? t(billTypeLabelKeys[item.code]) : item.code,
value: item.code,
})),
)
/** 账单类型编码转当前语言标签,未知值回退原编码 */
function billTypeLabel(value: string | null) {
if (!value) return '-'
return billTypeOptions.value.find((option) => option.value === value)?.label || value
}
/** 默认账单类型:取后端下发的第一个 */
function defaultBillType(): BillType {
return billTypeOptions.value[0]?.value || ''
}
/** 加载后端支持的账单类型 */
async function loadBillTypes() {
try {
const res: any = await getBillTypes()
billTypes.value = res.data || []
} catch (e) {
// 静默处理,账单类型下拉保持为空
}
}
/** 常用 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 }[]>([])
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 configSaving = ref(false) // 邮箱配置保存状态
// ===== 立即检查实时进度 =====
const checkLogs = ref<string[]>([])
const checkRunning = ref(false)
let checkController: AbortController | 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() {
configSaving.value = true
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'))
} finally {
configSaving.value = false
}
}
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
checkController = openSse(`/api/email-bill/configs/${row.id}/check/stream`, {
onEvent: async (event, data) => {
if (event === 'line') {
checkLogs.value.push(data)
} else if (event === 'done') {
let count = 0
try {
count = JSON.parse(data).count ?? 0
} catch {
count = 0
}
checkLogs.value.push(t('emailBill.config.checkResult', { count }))
finishCheck()
await Promise.all([loadConfigs(), loadMessages()])
} else if (event === 'failed') {
let reason = ''
try {
reason = JSON.parse(data).message ?? ''
} catch {
reason = ''
}
checkLogs.value.push(reason ? `${t('emailBill.config.checkFailed')}:${reason}` : t('emailBill.config.checkFailed'))
finishCheck()
loadConfigs()
}
},
onError: () => {
// 连接异常(含认证失败)时结束检查
if (!checkFinished) {
finishCheck()
}
},
onClose: () => {
// 服务端正常结束但未收到 done/failed 时兜底收尾
if (!checkFinished) {
finishCheck()
}
},
})
}
/** 结束检查状态并关闭 SSE 连接 */
function finishCheck() {
checkFinished = true
checkRunning.value = false
stopCheckStream()
}
/** 关闭当前 SSE 连接 */
function stopCheckStream() {
if (checkController) {
checkController.abort()
checkController = null
}
}
// ===== 发件人规则 =====
const senderRules = ref<EmailBillSenderRule[]>([])
const ruleLoading = ref(false)
const ruleModalVisible = ref(false)
const ruleEditingId = ref<number | null>(null)
const ruleForm = ref<Omit<EmailBillSenderRulePayload, 'senderPatterns'> & { patternsText: string }>(emptyRuleForm())
const ruleSaving = ref(false) // 发件人规则保存状态
function emptyRuleForm() {
return {
name: '', enabled: true, sortOrder: 0, patternsText: '', billType: defaultBillType(),
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,
}
ruleSaving.value = true
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)
} finally {
ruleSaving.value = false
}
}
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>(defaultBillType())
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 || defaultBillType()) 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 Promise.all([loadOptions(), loadBillTypes()])
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 :lines="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" :loading="configSaving" @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" :loading="ruleSaving" @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>