- 新增用户偏好表 t_user_preference 与进程内缓存,账单来源统计默认值由全局改为按用户存储(/api/setting) - 新增外观设置:不计入统计记录支持删除线与灰色标签两种展示样式 - 抽取 ChannelType / TransactionType / StatisticsScope 枚举,替换各处硬编码的渠道、类型与统计口径 - BillType 内聚各来源扩展名,新增 /transaction/import/types 接口,前端改为按后端下发渲染 - 仪表盘趋势图支持点击柱子跳转收支记录并带入筛选条件 - 布局内容区按路由记忆滚动位置;已登录访问登录/注册页自动跳转
909 lines
34 KiB
Vue
909 lines
34 KiB
Vue
<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>
|