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
+191
View File
@@ -0,0 +1,191 @@
// 邮箱账单相关 API:IMAP 配置、发件人规则与待处理账单的解析导入
import request from './request'
import type { BillType, ParsedBillRow, BillImportItem } from './transaction'
/** 邮箱配置 */
export interface EmailBillConfig {
id: number
name: string
host: string
port: number
ssl: boolean
username: string
hasPassword: boolean
folder: string
enabled: boolean
status: string
lastError: string | null
lastConnectedAt: string | null
lastUid: number | null
expenseCategoryId: number | null
incomeCategoryId: number | null
tagIds: number[]
fetchScope: string
fetchLimit: number | null
createdAt: string
updatedAt: string
}
/** 发件人识别规则 */
export interface EmailBillSenderRule {
id: number
name: string
enabled: boolean
sortOrder: number
senderPatterns: string[]
billType: BillType
archivePassword: string | null
expenseCategoryId: number | null
incomeCategoryId: number | null
tagIds: number[]
createdAt: string
updatedAt: string
}
/** 待处理账单消息 */
export interface EmailBillMessage {
id: number
configId: number
uid: number
subject: string | null
fromAddress: string | null
receivedAt: string | null
attachmentName: string | null
billType: BillType | null
status: string
error: string | null
createdAt: string
}
/** 邮箱配置保存请求 */
export interface EmailBillConfigPayload {
name: string
host: string
port: number
ssl: boolean
username: string
password?: string
folder: string
enabled: boolean
expenseCategoryId?: number | null
incomeCategoryId?: number | null
tagIds?: number[]
fetchScope?: string
fetchLimit?: number | null
}
/** 发件人规则保存请求 */
export interface EmailBillSenderRulePayload {
name: string
enabled: boolean
sortOrder: number
senderPatterns: string[]
billType: BillType
archivePassword?: string | null
expenseCategoryId?: number | null
incomeCategoryId?: number | null
tagIds?: number[]
}
/** 解析结果 */
export interface EmailBillParseResult {
billType: string
total: number
rows: ParsedBillRow[]
}
/** 查询邮箱配置列表 */
export function listEmailBillConfigs() {
return request.get('/email-bill/configs')
}
/** 新增邮箱配置 */
export function createEmailBillConfig(data: EmailBillConfigPayload) {
return request.post('/email-bill/configs', data)
}
/** 更新邮箱配置,授权码留空表示不修改 */
export function updateEmailBillConfig(id: number, data: EmailBillConfigPayload) {
return request.put(`/email-bill/configs/${id}`, data)
}
/** 删除邮箱配置 */
export function deleteEmailBillConfig(id: number) {
return request.delete(`/email-bill/configs/${id}`)
}
/** 启用或停用邮箱配置 */
export function toggleEmailBillConfig(id: number, enabled: boolean) {
return request.put(`/email-bill/configs/${id}/enabled`, { enabled })
}
/** 测试邮箱连接 */
export function testEmailBillConfig(id: number) {
return request.post(`/email-bill/configs/${id}/test`, {}, { timeout: 60000 })
}
/** 立即检查邮箱并增量拉取 */
export function checkEmailBillConfig(id: number) {
return request.post(`/email-bill/configs/${id}/check`, {}, { timeout: 120000 })
}
/** 查询发件人规则列表 */
export function listEmailBillSenderRules() {
return request.get('/email-bill/sender-rules')
}
/** 新增发件人规则 */
export function createEmailBillSenderRule(data: EmailBillSenderRulePayload) {
return request.post('/email-bill/sender-rules', data)
}
/** 更新发件人规则 */
export function updateEmailBillSenderRule(id: number, data: EmailBillSenderRulePayload) {
return request.put(`/email-bill/sender-rules/${id}`, data)
}
/** 删除发件人规则 */
export function deleteEmailBillSenderRule(id: number) {
return request.delete(`/email-bill/sender-rules/${id}`)
}
/** 启用或停用发件人规则 */
export function toggleEmailBillSenderRule(id: number, enabled: boolean) {
return request.put(`/email-bill/sender-rules/${id}/enabled`, { enabled })
}
/** 分页查询账单消息 */
export function pageEmailBillMessages(params: {
status?: string
configId?: number
page?: number
size?: number
}) {
return request.get('/email-bill/messages', { params })
}
/** 解析账单附件(不落库),可覆盖账单类型与解压密码 */
export function parseEmailBillMessage(id: number, data: { billType?: string; password?: string }) {
return request.post(`/email-bill/messages/${id}/parse`, data, { timeout: 120000 })
}
/** 确认导入勾选的账单行 */
export function importEmailBillMessage(id: number, data: {
billType: BillType
expenseCategoryId?: number | null
incomeCategoryId?: number | null
tagIds?: number[]
items: BillImportItem[]
}) {
return request.post(`/email-bill/messages/${id}/import`, data, { timeout: 120000 })
}
/** 忽略账单消息 */
export function ignoreEmailBillMessage(id: number) {
return request.put(`/email-bill/messages/${id}/ignore`)
}
/** 删除账单消息 */
export function deleteEmailBillMessage(id: number) {
return request.delete(`/email-bill/messages/${id}`)
}
+2
View File
@@ -27,6 +27,8 @@ export interface StatisticsFilterParams {
minAmount?: number
maxAmount?: number
remark?: string
/** 统计口径:INCLUDED-仅计入(默认)、ALL-全部、EXCLUDED-仅不计入 */
statsScope?: 'INCLUDED' | 'ALL' | 'EXCLUDED'
}
/** 获取指定时间范围的统计概览(收支汇总、日均、最大单笔、平均每笔) */
@@ -0,0 +1,31 @@
<script setup lang="ts">
// 账单导入结果面板:展示导入统计与失败信息,供上传导入与邮箱账单复用
import { useI18n } from 'vue-i18n'
import { NSpace, NButton, NAlert, NText } from 'naive-ui'
defineProps<{
result: { total: number; imported: number; skipped: number; failed: number; messages: string[] } | null
}>()
const emit = defineEmits<{
(e: 'done'): void
}>()
const { t } = useI18n()
</script>
<template>
<NSpace vertical :size="12">
<NAlert type="success" :show-icon="true">
{{ t('import.resultSummary', { imported: result?.imported ?? 0, skipped: result?.skipped ?? 0, failed: result?.failed ?? 0 }) }}
</NAlert>
<div v-if="result?.messages?.length" style="max-height: 320px; overflow: auto;">
<NText v-for="(msg, i) in result.messages" :key="i" depth="3" style="display: block; font-size: 12px;">
{{ msg }}
</NText>
</div>
<NSpace justify="end">
<NButton type="primary" @click="emit('done')">{{ t('import.done') }}</NButton>
</NSpace>
</NSpace>
</template>
@@ -0,0 +1,403 @@
<script setup lang="ts">
// 账单预览面板:预览解析结果、筛选、勾选,并设置默认分类/标签后确认导入
// 供上传导入(BillImportPanel)与邮箱账单解析弹窗复用;导入动作由父组件执行
import { ref, computed, h, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, NSpace, NButton, NSelect, NDataTable, NCheckbox, NTag, NText, NInput, NInputNumber,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import type { ParsedBillRow, BillImportItem } from '../api/transaction'
import { getCategoryList } from '../api/category'
import { getTagList } from '../api/tag'
const props = defineProps<{
/** 解析结果行 */
rows: ParsedBillRow[]
/** 父组件导入进行中 */
importing?: boolean
}>()
const emit = defineEmits<{
(e: 'confirm', payload: {
items: BillImportItem[]
expenseCategoryId: number | null
incomeCategoryId: number | null
tagIds: number[]
}): void
(e: 'back'): void
}>()
const message = useMessage()
const { t } = useI18n()
// 勾选状态
const checkedRowNos = ref<number[]>([])
// 筛选条件
const filterKeyword = ref('')
const filterType = ref<string | null>(null)
const filterStatus = ref<string | null>(null)
const filterMinAmount = ref<number | null>(null)
const filterMaxAmount = ref<number | null>(null)
// 类型筛选选项
const typeFilterOptions = computed(() => [
{ label: t('import.typeFilter.income'), value: 'INCOME' },
{ label: t('import.typeFilter.expense'), value: 'EXPENSE' },
{ label: t('import.typeFilter.neutral'), value: 'NEUTRAL' },
])
// 状态筛选选项
const statusFilterOptions = computed(() => [
{ label: t('import.statusFilter.importable'), value: 'importable' },
{ label: t('import.statusFilter.duplicate'), value: 'duplicate' },
{ label: t('import.statusFilter.error'), value: 'error' },
])
/** 重置预览筛选条件 */
function resetFilters() {
filterKeyword.value = ''
filterType.value = null
filterStatus.value = null
filterMinAmount.value = null
filterMaxAmount.value = null
}
// 默认分类
const expenseCategories = ref<{ label: string; value: number; icon: string }[]>([])
const incomeCategories = ref<{ label: string; value: number; icon: string }[]>([])
const expenseCategoryId = ref<number | null>(null)
const incomeCategoryId = ref<number | null>(null)
// 默认标签
const allTags = ref<{ label: string; value: number }[]>([])
const importTagIds = ref<number[]>([])
/** 按筛选条件过滤后的账单行 */
const filteredRows = computed(() => {
const kw = filterKeyword.value.trim().toLowerCase()
return props.rows.filter((r) => {
if (kw) {
const text = `${r.counterparty || ''} ${r.product || ''} ${r.externalNo || ''}`.toLowerCase()
if (!text.includes(kw)) return false
}
if (filterType.value === 'NEUTRAL') {
if (r.includeInStats) return false
} else if (filterType.value && r.type !== filterType.value) {
return false
}
if (filterStatus.value === 'importable' && !r.importable) return false
if (filterStatus.value === 'duplicate' && !r.duplicate) return false
if (filterStatus.value === 'error' && !r.error) return false
if (filterMinAmount.value != null && Number(r.amount) < filterMinAmount.value) return false
if (filterMaxAmount.value != null && Number(r.amount) > filterMaxAmount.value) return false
return true
})
})
/** 可导入的行(受筛选影响) */
const selectableRows = computed(() => filteredRows.value.filter((r) => r.importable))
/** 已勾选的行 */
const checkedRows = computed(() => props.rows.filter((r) => checkedRowNos.value.includes(r.rowNo)))
/** 是否全选(基于当前筛选出的可导入行) */
const allChecked = computed(
() => selectableRows.value.length > 0 && selectableRows.value.every((r) => checkedRowNos.value.includes(r.rowNo)),
)
/** 是否存在部分勾选(基于当前筛选出的可导入行) */
const someChecked = computed(() => {
const checkedVisible = selectableRows.value.filter((r) => checkedRowNos.value.includes(r.rowNo))
return checkedVisible.length > 0 && !allChecked.value
})
/** 表格列定义(随语言切换响应式更新) */
const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
{
title: () =>
h(NCheckbox, {
checked: allChecked.value,
indeterminate: someChecked.value,
disabled: selectableRows.value.length === 0,
onUpdateChecked: (v: boolean) => toggleAll(v),
}),
key: 'checked',
width: 50,
render: (row) =>
h(NCheckbox, {
checked: checkedRowNos.value.includes(row.rowNo),
disabled: !row.importable,
onUpdateChecked: (v: boolean) => toggleRow(row, v),
}),
},
{
title: t('import.column.transactionTime'),
key: 'transactionTime',
width: 150,
render: (row) => (row.transactionTime ? row.transactionTime.replace('T', ' ').substring(0, 16) : '-'),
},
{
title: t('import.column.type'),
key: 'type',
width: 90,
render: (row) => {
if (!row.importable) {
return h(NTag, { type: 'error', size: 'small' }, { default: () => t('import.notImportable') })
}
if (!row.includeInStats) {
return h(NTag, { type: 'default', size: 'small' }, { default: () => t('import.notIncluded') })
}
return h(
NTag,
{ type: row.type === 'INCOME' ? 'success' : 'error', size: 'small' },
{ default: () => (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')) },
)
},
},
{
title: t('import.column.includeInStats'),
key: 'includeInStats',
width: 100,
align: 'center',
render: (row) =>
h(NCheckbox, {
checked: !!row.includeInStats,
disabled: !row.importable,
onUpdateChecked: (v: boolean) => toggleIncludeInStats(row, v),
}),
},
{
title: t('import.column.amount'),
key: 'amount',
width: 110,
render: (row) => {
const color = row.type === 'INCOME' ? '#18a058' : '#d03050'
return h('span', { style: { color, fontWeight: 'bold' } }, `${row.type === 'INCOME' ? '+' : '-'}${Number(row.amount).toFixed(2)}`)
},
},
{ title: t('import.column.counterparty'), key: 'counterparty', width: 160, ellipsis: { tooltip: true } },
{ title: t('import.column.product'), key: 'product', ellipsis: { tooltip: true } },
{
title: t('import.column.status'),
key: 'tradeStatus',
width: 150,
render: (row) => {
if (row.error) {
return h(NTag, { type: 'error', size: 'small' }, { default: () => row.error })
}
if (row.duplicate) {
return h(NTag, { type: 'warning', size: 'small' }, { default: () => t('import.statusFilter.duplicate') })
}
return h('span', null, row.tradeStatus || '-')
},
},
{
title: t('import.column.matchedRule'),
key: 'matchedRule',
width: 220,
render: (row) => {
const ruleNames = row.matchedRuleNames || []
const suggestedTags = row.suggestedTags || []
if (ruleNames.length === 0 && !row.suggestedCategoryId && suggestedTags.length === 0) {
return h('span', { style: { color: '#ccc' } }, '—')
}
const nodes: any[] = []
// 命中规则名,多个时折叠为 +N
if (ruleNames.length) {
nodes.push(h(NTag, { size: 'small', type: 'warning' }, {
default: () => (ruleNames.length > 1 ? `${ruleNames[0]} +${ruleNames.length - 1}` : ruleNames[0]),
}))
}
// 建议分类
if (row.suggestedCategoryId) {
nodes.push(h(NTag, { size: 'small', type: 'info' }, {
default: () => `${row.suggestedCategoryIcon || ''}${row.suggestedCategoryName || ''}`,
}))
}
// 建议标签
for (const tag of suggestedTags) {
nodes.push(h(NTag, {
size: 'small',
color: tag.color ? { color: tag.color, textColor: '#fff' } : undefined,
}, { default: () => tag.name }))
}
return h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '4px' } }, nodes)
},
},
])
/** 加载分类选项 */
async function loadCategories() {
try {
const [expenseRes, incomeRes]: any = await Promise.all([getCategoryList('EXPENSE'), getCategoryList('INCOME')])
expenseCategories.value = (expenseRes.data || []).map((c: any) => ({ label: c.name, value: c.id, icon: c.icon || '' }))
incomeCategories.value = (incomeRes.data || []).map((c: any) => ({ label: c.name, value: c.id, icon: c.icon || '' }))
} catch (e: any) {
message.error(e.message || t('import.loadCategoriesFailed'))
}
}
/** 分类下拉项渲染:图标 + 名称 */
function renderCategoryLabel(option: any) {
return h('span', null, [
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
option.label,
])
}
/** 加载标签选项 */
async function loadTags() {
try {
const res: any = await getTagList()
allTags.value = (res.data || []).map((tag: any) => ({ label: tag.name, value: tag.id }))
} catch (e: any) {
message.error(e.message || t('import.loadTagsFailed'))
}
}
/** 切换单行勾选 */
function toggleRow(row: ParsedBillRow, checked: boolean) {
if (checked) {
if (!checkedRowNos.value.includes(row.rowNo)) {
checkedRowNos.value = [...checkedRowNos.value, row.rowNo]
}
} else {
checkedRowNos.value = checkedRowNos.value.filter((n) => n !== row.rowNo)
}
}
/** 全选/取消全选 */
function toggleAll(checked: boolean) {
checkedRowNos.value = checked ? selectableRows.value.map((r) => r.rowNo) : []
}
/** 切换单行是否计入统计 */
function toggleIncludeInStats(row: ParsedBillRow, value: boolean) {
row.includeInStats = value
}
/** 批量设置已勾选行的计入统计标记 */
function setCheckedIncludeInStats(value: boolean) {
if (checkedRows.value.length === 0) {
message.warning(t('import.selectAtLeastOne'))
return
}
checkedRows.value.forEach((row) => {
row.includeInStats = value
})
}
/** 确认导入:向父组件抛出勾选行与默认归类 */
function handleConfirm() {
if (checkedRows.value.length === 0) {
message.warning(t('import.selectAtLeastOne'))
return
}
const items: BillImportItem[] = checkedRows.value.map((r) => ({
externalNo: r.externalNo,
transactionTime: r.transactionTime,
type: r.type,
amount: r.amount,
counterparty: r.counterparty,
product: r.product,
includeInStats: r.includeInStats,
rawData: r.rawData,
}))
emit('confirm', {
items,
expenseCategoryId: expenseCategoryId.value,
incomeCategoryId: incomeCategoryId.value,
tagIds: importTagIds.value,
})
}
// 解析结果变化时重置勾选(默认选中解析器建议行)与筛选条件
watch(() => props.rows, (rows) => {
checkedRowNos.value = rows.filter((r) => r.selected).map((r) => r.rowNo)
resetFilters()
}, { immediate: true })
onMounted(() => {
loadCategories()
loadTags()
})
</script>
<template>
<NSpace vertical :size="12">
<NSpace align="center" :wrap="true" :size="[12, 12]">
<NText>{{ t('import.expenseDefaultCategory') }}</NText>
<NSelect
v-model:value="expenseCategoryId"
:options="expenseCategories"
:render-label="renderCategoryLabel"
filterable
clearable
:placeholder="t('import.notSetCategory')"
style="width: 180px;"
/>
<NText>{{ t('import.incomeDefaultCategory') }}</NText>
<NSelect
v-model:value="incomeCategoryId"
:options="incomeCategories"
:render-label="renderCategoryLabel"
filterable
clearable
:placeholder="t('import.notSetCategory')"
style="width: 180px;"
/>
<NText>{{ t('import.defaultTags') }}</NText>
<NSelect
v-model:value="importTagIds"
:options="allTags"
multiple
clearable
filterable
:max-tag-count="2"
:placeholder="t('import.notSetTag')"
style="width: 200px;"
/>
<NCheckbox
:checked="allChecked"
:indeterminate="someChecked"
:disabled="selectableRows.length === 0"
@update:checked="toggleAll"
>
{{ t('import.selectAll') }}
</NCheckbox>
<NButton size="small" :disabled="checkedRows.length === 0" @click="setCheckedIncludeInStats(true)">
{{ t('import.setIncluded') }}
</NButton>
<NButton size="small" :disabled="checkedRows.length === 0" @click="setCheckedIncludeInStats(false)">
{{ t('import.setExcluded') }}
</NButton>
<NText depth="3">{{ t('import.previewSummary', { total: rows.length, filtered: filteredRows.length, checked: checkedRows.length }) }}</NText>
</NSpace>
<!-- 筛选条件 -->
<NSpace align="center" :wrap="true" :size="[12, 12]">
<NInput v-model:value="filterKeyword" clearable :placeholder="t('import.searchPlaceholder')" style="width: 220px;" />
<NSelect v-model:value="filterType" :options="typeFilterOptions" clearable :placeholder="t('import.typePlaceholder')" style="width: 120px;" />
<NSelect v-model:value="filterStatus" :options="statusFilterOptions" clearable :placeholder="t('import.statusPlaceholder')" style="width: 130px;" />
<NInputNumber v-model:value="filterMinAmount" :min="0" :precision="2" clearable :placeholder="t('common.min')" style="width: 120px;" />
<span style="color: #999;">~</span>
<NInputNumber v-model:value="filterMaxAmount" :min="0" :precision="2" clearable :placeholder="t('common.max')" style="width: 120px;" />
<NButton size="small" @click="resetFilters">{{ t('common.reset') }}</NButton>
</NSpace>
<NDataTable
:columns="columns"
:data="filteredRows"
:max-height="420"
:row-key="(row) => row.rowNo"
size="small"
:bordered="false"
/>
<NSpace justify="end">
<NButton @click="emit('back')">{{ t('import.prevStep') }}</NButton>
<NButton type="primary" :loading="importing" @click="handleConfirm">
{{ t('import.confirmImport', { count: checkedRows.length }) }}
</NButton>
</NSpace>
</NSpace>
</template>
+147
View File
@@ -81,11 +81,13 @@ export default {
// 主布局
layout: {
appTitle: { zh: '收支管理', en: 'Finance Manager', ja: '収支管理' },
appSubtitle: { zh: '个人财务管理', en: 'Personal Finance', ja: '個人財務管理' },
dashboard: { zh: '统计面板', en: 'Dashboard', ja: 'ダッシュボード' },
transactions: { zh: '收支记录', en: 'Transactions', ja: '収支記録' },
categories: { zh: '分类管理', en: 'Categories', ja: 'カテゴリ管理' },
tags: { zh: '标签管理', en: 'Tags', ja: 'タグ管理' },
rules: { zh: '规则管理', en: 'Rules', ja: 'ルール管理' },
emailBills: { zh: '邮箱账单', en: 'Email Bills', ja: 'メール請求' },
logs: { zh: '系统日志', en: 'Logs', ja: 'システムログ' },
settings: { zh: '系统设置', en: 'Settings', ja: 'システム設定' },
user: { zh: '用户', en: 'User', ja: 'ユーザー' },
@@ -285,6 +287,12 @@ export default {
quarter: { zh: '按季', en: 'Quarterly', ja: '四半期別' },
year: { zh: '按年', en: 'Yearly', ja: '年別' },
},
filterStatsScope: { zh: '统计口径', en: 'Stats Scope', ja: '集計範囲' },
statsScope: {
included: { zh: '仅计入统计', en: 'Included Only', ja: '集計対象のみ' },
all: { zh: '全部记录', en: 'All Records', ja: 'すべての記録' },
excluded: { zh: '仅不计入', en: 'Excluded Only', ja: '集計対象外のみ' },
},
filterType: { zh: '类型', en: 'Type', ja: '種類' },
filterCategory: { zh: '分类', en: 'Category', ja: 'カテゴリ' },
filterPaymentMethod: { zh: '支付方式', en: 'Payment Method', ja: '支払方法' },
@@ -688,4 +696,143 @@ export default {
},
},
},
// 邮箱账单
emailBill: {
title: { zh: '邮箱账单', en: 'Email Bills', ja: 'メール請求' },
tab: {
configs: { zh: '邮箱配置', en: 'Mailboxes', ja: 'メール設定' },
senderRules: { zh: '发件人规则', en: 'Sender Rules', ja: '送信者ルール' },
messages: { zh: '待处理账单', en: 'Pending Bills', ja: '未処理の請求' },
},
imapHosts: {
qq: { zh: 'QQ 邮箱', en: 'QQ Mail', ja: 'QQメール' },
qqExmail: { zh: '腾讯企业邮', en: 'Tencent Exmail', ja: 'Tencent Exmail' },
netease163: { zh: '网易 163 邮箱', en: 'NetEase 163', ja: 'NetEase 163' },
netease126: { zh: '网易 126 邮箱', en: 'NetEase 126', ja: 'NetEase 126' },
neteaseQiye: { zh: '网易企业邮', en: 'NetEase Enterprise', ja: 'NetEase Enterprise' },
aliyun: { zh: '阿里云邮箱', en: 'Alibaba Mail', ja: 'Alibaba Mail' },
sina: { zh: '新浪邮箱', en: 'Sina Mail', ja: 'Sina Mail' },
mail139: { zh: '139 邮箱', en: '139 Mail', ja: '139メール' },
mail189: { zh: '189 邮箱', en: '189 Mail', ja: '189メール' },
gmail: { zh: 'Gmail', en: 'Gmail', ja: 'Gmail' },
outlook: { zh: 'Outlook / Hotmail', en: 'Outlook / Hotmail', ja: 'Outlook / Hotmail' },
icloud: { zh: 'iCloud 邮箱', en: 'iCloud Mail', ja: 'iCloudメール' },
},
common: {
add: { zh: '新增', en: 'Add', ja: '追加' },
edit: { zh: '编辑', en: 'Edit', ja: '編集' },
delete: { zh: '删除', en: 'Delete', ja: '削除' },
actions: { zh: '操作', en: 'Actions', ja: '操作' },
enabled: { zh: '启用', en: 'Enabled', ja: '有効' },
save: { zh: '保存', en: 'Save', ja: '保存' },
cancel: { zh: '取消', en: 'Cancel', ja: 'キャンセル' },
confirm: { zh: '确定', en: 'Confirm', ja: '確定' },
refresh: { zh: '刷新', en: 'Refresh', ja: '更新' },
loadFailed: { zh: '加载失败', en: 'Failed to load', ja: '読み込みに失敗しました' },
saveSuccess: { zh: '保存成功', en: 'Saved', ja: '保存しました' },
deleteSuccess: { zh: '删除成功', en: 'Deleted', ja: '削除しました' },
deleteConfirm: { zh: '确定删除吗?', en: 'Delete this item?', ja: '削除しますか?' },
empty: { zh: '暂无数据', en: 'No data', ja: 'データがありません' },
},
config: {
name: { zh: '名称', en: 'Name', ja: '名称' },
host: { zh: 'IMAP 服务器', en: 'IMAP Server', ja: 'IMAPサーバー' },
hostPlaceholder: { zh: '选择或输入 IMAP 服务器', en: 'Select or enter IMAP server', ja: 'IMAPサーバーを選択または入力' },
port: { zh: '端口', en: 'Port', ja: 'ポート' },
ssl: { zh: '使用 SSL', en: 'Use SSL', ja: 'SSLを使用' },
username: { zh: '邮箱账号', en: 'Email', ja: 'メールアドレス' },
password: { zh: '授权码', en: 'Auth Code', ja: '認証コード' },
passwordPlaceholder: { zh: '留空表示不修改', en: 'Leave blank to keep unchanged', ja: '空欄なら変更しません' },
folder: { zh: '监听文件夹', en: 'Folder', ja: 'フォルダ' },
status: { zh: '状态', en: 'Status', ja: '状態' },
lastError: { zh: '最近错误', en: 'Last Error', ja: '最新エラー' },
defaultExpenseCategory: { zh: '默认支出分类', en: 'Default Expense Category', ja: '既定の支出カテゴリ' },
defaultIncomeCategory: { zh: '默认收入分类', en: 'Default Income Category', ja: '既定の収入カテゴリ' },
defaultTags: { zh: '默认标签', en: 'Default Tags', ja: '既定のタグ' },
fetchScope: { zh: '拉取范围', en: 'Fetch Scope', ja: '取得範囲' },
fetchScopeIncrement: { zh: '仅新邮件', en: 'New only', ja: '新着のみ' },
fetchScopeDays: { zh: '最近 N 天', en: 'Last N days', ja: '直近 N 日' },
fetchScopeCount: { zh: '最近 N 封', en: 'Last N emails', ja: '直近 N 件' },
fetchLimitDays: { zh: '天数', en: 'Days', ja: '日数' },
fetchLimitCount: { zh: '封数', en: 'Count', ja: '件数' },
test: { zh: '测试连接', en: 'Test', ja: '接続テスト' },
check: { zh: '立即检查', en: 'Check Now', ja: '今すぐ確認' },
testSuccess: { zh: '连接成功', en: 'Connected', ja: '接続に成功しました' },
testFailed: { zh: '连接失败', en: 'Connection failed', ja: '接続に失敗しました' },
checkResult: { zh: '新增 {count} 条待处理账单', en: '{count} new pending bill(s)', ja: '未処理の請求を {count} 件追加しました' },
checkLogTitle: { zh: '检查进度', en: 'Check Progress', ja: '確認の進行状況' },
checkRunning: { zh: '检查中…', en: 'Checking…', ja: '確認中…' },
checkClear: { zh: '清空', en: 'Clear', ja: 'クリア' },
checkFailed: { zh: '检查失败', en: 'Check failed', ja: '確認に失敗しました' },
statusText: {
DISCONNECTED: { zh: '未连接', en: 'Disconnected', ja: '未接続' },
CONNECTED: { zh: '已连接', en: 'Connected', ja: '接続済み' },
POLLING: { zh: '轮询中', en: 'Polling', ja: 'ポーリング中' },
ERROR: { zh: '异常', en: 'Error', ja: 'エラー' },
},
},
senderRule: {
name: { zh: '规则名称', en: 'Rule Name', ja: 'ルール名' },
patterns: { zh: '发件人匹配串', en: 'Sender Patterns', ja: '送信者パターン' },
patternsHint: { zh: '每行一个,支持完整地址、@域名或关键字,包含匹配且忽略大小写', en: 'One per line; full address, @domain or keyword, case-insensitive contains', ja: '1行に1つ。完全なアドレス、@ドメイン、キーワードに対応(大文字小文字を区別しない部分一致)' },
billType: { zh: '账单类型', en: 'Bill Type', ja: '請求タイプ' },
archivePassword: { zh: '解压密码', en: 'Archive Password', ja: '解凍パスワード' },
archivePasswordPlaceholder: { zh: '压缩包加密时填写', en: 'Fill if the archive is encrypted', ja: '暗号化されている場合に入力' },
sortOrder: { zh: '优先级', en: 'Priority', ja: '優先度' },
defaultExpenseCategory: { zh: '默认支出分类', en: 'Default Expense Category', ja: '既定の支出カテゴリ' },
defaultIncomeCategory: { zh: '默认收入分类', en: 'Default Income Category', ja: '既定の収入カテゴリ' },
defaultTags: { zh: '默认标签', en: 'Default Tags', ja: '既定のタグ' },
patternsRequired: { zh: '请至少填写一个发件人匹配串', en: 'Please enter at least one sender pattern', ja: '送信者パターンを1つ以上入力してください' },
},
message: {
receivedAt: { zh: '接收时间', en: 'Received', ja: '受信日時' },
fromAddress: { zh: '发件人', en: 'From', ja: '送信者' },
subject: { zh: '主题', en: 'Subject', ja: '件名' },
attachmentName: { zh: '附件', en: 'Attachment', ja: '添付ファイル' },
billType: { zh: '账单类型', en: 'Bill Type', ja: '請求タイプ' },
status: { zh: '状态', en: 'Status', ja: '状態' },
error: { zh: '错误', en: 'Error', ja: 'エラー' },
parse: { zh: '解析', en: 'Parse', ja: '解析' },
ignore: { zh: '忽略', en: 'Ignore', ja: '無視' },
statusText: {
PULLED: { zh: '待解析', en: 'Pending', ja: '未解析' },
CONFIRMED: { zh: '已导入', en: 'Imported', ja: '取込済み' },
IGNORED: { zh: '已忽略', en: 'Ignored', ja: '無視' },
},
filterStatus: { zh: '状态筛选', en: 'Filter by status', ja: '状態で絞り込み' },
},
parse: {
title: { zh: '解析账单', en: 'Parse Bill', ja: '請求を解析' },
billType: { zh: '账单类型', en: 'Bill Type', ja: '請求タイプ' },
password: { zh: '解压密码', en: 'Archive Password', ja: '解凍パスワード' },
parseButton: { zh: '解析', en: 'Parse', ja: '解析' },
parseFailed: { zh: '解析失败', en: 'Parse failed', ja: '解析に失敗しました' },
needParse: { zh: '请先解析账单', en: 'Please parse the bill first', ja: '先に請求を解析してください' },
expenseDefaultCategory: { zh: '支出默认分类', en: 'Expense Category', ja: '支出カテゴリ' },
incomeDefaultCategory: { zh: '收入默认分类', en: 'Income Category', ja: '収入カテゴリ' },
defaultTags: { zh: '默认标签', en: 'Default Tags', ja: '既定のタグ' },
selectAll: { zh: '全选', en: 'Select All', ja: 'すべて選択' },
setIncluded: { zh: '计入统计', en: 'Include', ja: '集計に含める' },
setExcluded: { zh: '不计入', en: 'Exclude', ja: '集計から除外' },
previewSummary: { zh: '共 {total} 行,筛选后 {filtered} 行,已选 {checked} 行', en: '{total} rows, {filtered} after filter, {checked} selected', ja: '全 {total} 行、絞り込み後 {filtered} 行、選択 {checked} 行' },
confirmImport: { zh: '导入 {count} 条', en: 'Import {count}', ja: '{count} 件を取込' },
importSuccess: { zh: '导入完成', en: 'Import completed', ja: '取込が完了しました' },
resultSummary: { zh: '成功 {imported} 条,跳过 {skipped} 条,失败 {failed} 条', en: 'Imported {imported}, skipped {skipped}, failed {failed}', ja: '成功 {imported} 件、スキップ {skipped} 件、失敗 {failed} 件' },
done: { zh: '完成', en: 'Done', ja: '完了' },
prevStep: { zh: '返回', en: 'Back', ja: '戻る' },
column: {
transactionTime: { zh: '交易时间', en: 'Time', ja: '取引日時' },
type: { zh: '类型', en: 'Type', ja: '種別' },
amount: { zh: '金额', en: 'Amount', ja: '金額' },
counterparty: { zh: '交易对方', en: 'Counterparty', ja: '取引先' },
product: { zh: '商品', en: 'Item', ja: '商品' },
status: { zh: '状态', en: 'Status', ja: '状態' },
matchedRule: { zh: '匹配规则/建议', en: 'Rule / Suggestion', ja: 'ルール/提案' },
},
notImportable: { zh: '不可导入', en: 'Not importable', ja: '取込不可' },
notIncluded: { zh: '不计收支', en: 'Excluded', ja: '集計外' },
duplicate: { zh: '重复', en: 'Duplicate', ja: '重複' },
},
},
}
+14
View File
@@ -58,6 +58,20 @@ const router = createRouter({
name: 'Rules',
component: () => import('../views/RuleManage.vue'), // 规则管理(自定义分类与标签规则)
},
{
path: 'email-bills',
// 跳转到上次访问的标签页,无记录时默认邮箱配置
redirect: () => {
const tabs = ['configs', 'senderRules', 'messages']
const saved = typeof localStorage !== 'undefined' ? localStorage.getItem('emailBill.tab') : null
return `/email-bills/${saved && tabs.includes(saved) ? saved : 'configs'}`
},
},
{
path: 'email-bills/:tab(configs|senderRules|messages)',
name: 'EmailBills',
component: () => import('../views/EmailBillManage.vue'), // 邮箱账单(邮箱配置/发件人规则/待处理账单)
},
{
path: 'settings',
name: 'Settings',
+14
View File
@@ -0,0 +1,14 @@
// 支付方式相关工具:将后端返回的支付方式编码映射为当前语言标签
import i18n from '../locales'
/** i18n 全局实例的翻译函数签名(避免动态 key 触发过深的类型推断) */
type Translate = (key: string) => string
type HasKey = (key: string) => boolean
/** 将支付方式编码转换为当前语言的标签,无对应文案时回退为原始编码 */
export function getPaymentLabel(val: string): string {
if (!val) return '-'
const { t, te } = i18n.global as unknown as { t: Translate; te: HasKey }
const key = `common.payment.${val.toLowerCase()}`
return te(key) ? t(key) : val
}
+28 -385
View File
@@ -1,18 +1,17 @@
<script setup lang="ts">
// 账单导入面板:先解析、后勾选导入(默认全选),当前支持支付宝与微信账单
// 以纯内容形式提供,可由弹窗(BillImportModal)或独立页面(BillImport)承载
import { ref, computed, h, watch, onMounted } from 'vue'
import { ref, computed, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, NSpace, NButton, NSelect, NUpload, NUploadDragger, NDataTable,
NCheckbox, NTag, NAlert, NText, NIcon, NInput, NInputNumber, NModal,
useMessage, NSpace, NButton, NSelect, NUpload, NUploadDragger, NAlert, NText, NIcon, NInput, NModal,
} from 'naive-ui'
import type { DataTableColumns, UploadFileInfo } from 'naive-ui'
import type { UploadFileInfo } from 'naive-ui'
import { CloudUploadOutline } from '@vicons/ionicons5'
import { parseBill, confirmImport } from '../api/transaction'
import type { BillType, ParsedBillRow, BillImportItem } from '../api/transaction'
import { getCategoryList } from '../api/category'
import { getTagList } from '../api/tag'
import BillPreviewPanel from '../components/BillPreviewPanel.vue'
import BillImportResult from '../components/BillImportResult.vue'
const emit = defineEmits<{
(e: 'close'): void
@@ -95,233 +94,10 @@ const importing = ref(false)
const passwordModalVisible = ref(false)
const archivePassword = ref('')
// 解析结果与勾选状态
// 解析结果与导入结果
const rows = ref<ParsedBillRow[]>([])
const checkedRowNos = ref<number[]>([])
// 预览筛选条件
const filterKeyword = ref('')
const filterType = ref<string | null>(null)
const filterStatus = ref<string | null>(null)
const filterMinAmount = ref<number | null>(null)
const filterMaxAmount = ref<number | null>(null)
// 类型筛选选项
const typeFilterOptions = computed(() => [
{ label: t('import.typeFilter.income'), value: 'INCOME' },
{ label: t('import.typeFilter.expense'), value: 'EXPENSE' },
{ label: t('import.typeFilter.neutral'), value: 'NEUTRAL' },
])
// 状态筛选选项
const statusFilterOptions = computed(() => [
{ label: t('import.statusFilter.importable'), value: 'importable' },
{ label: t('import.statusFilter.duplicate'), value: 'duplicate' },
{ label: t('import.statusFilter.error'), value: 'error' },
])
/** 重置预览筛选条件 */
function resetFilters() {
filterKeyword.value = ''
filterType.value = null
filterStatus.value = null
filterMinAmount.value = null
filterMaxAmount.value = null
}
// 默认分类
const expenseCategories = ref<{ label: string; value: number; icon: string }[]>([])
const incomeCategories = ref<{ label: string; value: number; icon: string }[]>([])
const expenseCategoryId = ref<number | null>(null)
const incomeCategoryId = ref<number | null>(null)
// 默认标签
const allTags = ref<{ label: string; value: number }[]>([])
const importTagIds = ref<number[]>([])
// 导入结果
const result = ref<{ total: number; imported: number; skipped: number; failed: number; messages: string[] } | null>(null)
/** 按筛选条件过滤后的账单行 */
const filteredRows = computed(() => {
const kw = filterKeyword.value.trim().toLowerCase()
return rows.value.filter((r) => {
if (kw) {
const text = `${r.counterparty || ''} ${r.product || ''} ${r.externalNo || ''}`.toLowerCase()
if (!text.includes(kw)) return false
}
if (filterType.value === 'NEUTRAL') {
if (r.includeInStats) return false
} else if (filterType.value && r.type !== filterType.value) {
return false
}
if (filterStatus.value === 'importable' && !r.importable) return false
if (filterStatus.value === 'duplicate' && !r.duplicate) return false
if (filterStatus.value === 'error' && !r.error) return false
if (filterMinAmount.value != null && Number(r.amount) < filterMinAmount.value) return false
if (filterMaxAmount.value != null && Number(r.amount) > filterMaxAmount.value) return false
return true
})
})
/** 可导入的行(受筛选影响) */
const selectableRows = computed(() => filteredRows.value.filter((r) => r.importable))
/** 已勾选的行 */
const checkedRows = computed(() => rows.value.filter((r) => checkedRowNos.value.includes(r.rowNo)))
/** 是否全选(基于当前筛选出的可导入行) */
const allChecked = computed(
() => selectableRows.value.length > 0 && selectableRows.value.every((r) => checkedRowNos.value.includes(r.rowNo)),
)
/** 是否存在部分勾选(基于当前筛选出的可导入行) */
const someChecked = computed(() => {
const checkedVisible = selectableRows.value.filter((r) => checkedRowNos.value.includes(r.rowNo))
return checkedVisible.length > 0 && !allChecked.value
})
/** 表格列定义(随语言切换响应式更新) */
const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
{
title: () =>
h(NCheckbox, {
checked: allChecked.value,
indeterminate: someChecked.value,
disabled: selectableRows.value.length === 0,
onUpdateChecked: (v: boolean) => toggleAll(v),
}),
key: 'checked',
width: 50,
render: (row) =>
h(NCheckbox, {
checked: checkedRowNos.value.includes(row.rowNo),
disabled: !row.importable,
onUpdateChecked: (v: boolean) => toggleRow(row, v),
}),
},
{
title: t('import.column.transactionTime'),
key: 'transactionTime',
width: 150,
render: (row) => (row.transactionTime ? row.transactionTime.replace('T', ' ').substring(0, 16) : '-'),
},
{
title: t('import.column.type'),
key: 'type',
width: 90,
render: (row) => {
if (!row.importable) {
return h(NTag, { type: 'error', size: 'small' }, { default: () => t('import.notImportable') })
}
if (!row.includeInStats) {
return h(NTag, { type: 'default', size: 'small' }, { default: () => t('import.notIncluded') })
}
return h(
NTag,
{ type: row.type === 'INCOME' ? 'success' : 'error', size: 'small' },
{ default: () => (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')) },
)
},
},
{
title: t('import.column.includeInStats'),
key: 'includeInStats',
width: 100,
align: 'center',
render: (row) =>
h(NCheckbox, {
checked: !!row.includeInStats,
disabled: !row.importable,
onUpdateChecked: (v: boolean) => toggleIncludeInStats(row, v),
}),
},
{
title: t('import.column.amount'),
key: 'amount',
width: 110,
render: (row) => {
const color = row.type === 'INCOME' ? '#18a058' : '#d03050'
return h('span', { style: { color, fontWeight: 'bold' } }, `${row.type === 'INCOME' ? '+' : '-'}${Number(row.amount).toFixed(2)}`)
},
},
{ title: t('import.column.counterparty'), key: 'counterparty', width: 160, ellipsis: { tooltip: true } },
{ title: t('import.column.product'), key: 'product', ellipsis: { tooltip: true } },
{
title: t('import.column.status'),
key: 'tradeStatus',
width: 150,
render: (row) => {
if (row.error) {
return h(NTag, { type: 'error', size: 'small' }, { default: () => row.error })
}
if (row.duplicate) {
return h(NTag, { type: 'warning', size: 'small' }, { default: () => t('import.statusFilter.duplicate') })
}
return h('span', null, row.tradeStatus || '-')
},
},
{
title: t('import.column.matchedRule'),
key: 'matchedRule',
width: 220,
render: (row) => {
const ruleNames = row.matchedRuleNames || []
const suggestedTags = row.suggestedTags || []
if (ruleNames.length === 0 && !row.suggestedCategoryId && suggestedTags.length === 0) {
return h('span', { style: { color: '#ccc' } }, '—')
}
const nodes: any[] = []
// 命中规则名,多个时折叠为 +N
if (ruleNames.length) {
nodes.push(h(NTag, { size: 'small', type: 'warning' }, {
default: () => (ruleNames.length > 1 ? `${ruleNames[0]} +${ruleNames.length - 1}` : ruleNames[0]),
}))
}
// 建议分类
if (row.suggestedCategoryId) {
nodes.push(h(NTag, { size: 'small', type: 'info' }, {
default: () => `${row.suggestedCategoryIcon || ''}${row.suggestedCategoryName || ''}`,
}))
}
// 建议标签
for (const tag of suggestedTags) {
nodes.push(h(NTag, {
size: 'small',
color: tag.color ? { color: tag.color, textColor: '#fff' } : undefined,
}, { default: () => tag.name }))
}
return h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '4px' } }, nodes)
},
},
])
/** 加载分类选项 */
async function loadCategories() {
try {
const [expenseRes, incomeRes]: any = await Promise.all([getCategoryList('EXPENSE'), getCategoryList('INCOME')])
expenseCategories.value = (expenseRes.data || []).map((c: any) => ({ label: c.name, value: c.id, icon: c.icon || '' }))
incomeCategories.value = (incomeRes.data || []).map((c: any) => ({ label: c.name, value: c.id, icon: c.icon || '' }))
} catch (e: any) {
message.error(e.message || t('import.loadCategoriesFailed'))
}
}
/** 分类下拉项渲染:图标 + 名称 */
function renderCategoryLabel(option: any) {
return h('span', null, [
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
option.label,
])
}
/** 加载标签选项 */
async function loadTags() {
try {
const res: any = await getTagList()
allTags.value = (res.data || []).map((tag: any) => ({ label: tag.name, value: tag.id }))
} catch (e: any) {
message.error(e.message || t('import.loadTagsFailed'))
}
}
/** 判断文件名是否符合当前账单类型允许的扩展名 */
function isAllowedFile(name: string) {
const exts = allowedExtensions[billType.value] || []
@@ -354,9 +130,6 @@ async function handleParse(password?: string) {
try {
const res: any = await parseBill(file.value, billType.value, password)
rows.value = res.data.rows || []
// 默认勾选解析器建议勾选的行
checkedRowNos.value = rows.value.filter((r) => r.selected).map((r) => r.rowNo)
resetFilters()
stage.value = 'preview'
// 解析成功时关闭密码弹窗并清空密码
passwordModalVisible.value = false
@@ -385,62 +158,21 @@ async function submitPassword() {
await handleParse(archivePassword.value)
}
/** 切换单行勾选 */
function toggleRow(row: ParsedBillRow, checked: boolean) {
if (checked) {
if (!checkedRowNos.value.includes(row.rowNo)) {
checkedRowNos.value = [...checkedRowNos.value, row.rowNo]
}
} else {
checkedRowNos.value = checkedRowNos.value.filter((n) => n !== row.rowNo)
}
}
/** 全选/取消全选 */
function toggleAll(checked: boolean) {
checkedRowNos.value = checked ? selectableRows.value.map((r) => r.rowNo) : []
}
/** 切换单行是否计入统计 */
function toggleIncludeInStats(row: ParsedBillRow, value: boolean) {
row.includeInStats = value
}
/** 批量设置已勾选行的计入统计标记 */
function setCheckedIncludeInStats(value: boolean) {
if (checkedRows.value.length === 0) {
message.warning(t('import.selectAtLeastOne'))
return
}
checkedRows.value.forEach((row) => {
row.includeInStats = value
})
}
/** 确认导入 */
async function handleConfirm() {
if (checkedRows.value.length === 0) {
message.warning(t('import.selectAtLeastOne'))
return
}
const items: BillImportItem[] = checkedRows.value.map((r) => ({
externalNo: r.externalNo,
transactionTime: r.transactionTime,
type: r.type,
amount: r.amount,
counterparty: r.counterparty,
product: r.product,
includeInStats: r.includeInStats,
rawData: r.rawData,
}))
/** 确认导入:接收预览面板抛出的勾选行与默认归类 */
async function handleConfirm(payload: {
items: BillImportItem[]
expenseCategoryId: number | null
incomeCategoryId: number | null
tagIds: number[]
}) {
importing.value = true
try {
const res: any = await confirmImport({
billType: billType.value,
expenseCategoryId: expenseCategoryId.value,
incomeCategoryId: incomeCategoryId.value,
tagIds: importTagIds.value.length ? importTagIds.value : undefined,
items,
expenseCategoryId: payload.expenseCategoryId,
incomeCategoryId: payload.incomeCategoryId,
tagIds: payload.tagIds.length ? payload.tagIds : undefined,
items: payload.items,
})
result.value = res.data
stage.value = 'result'
@@ -458,14 +190,9 @@ function reset() {
file.value = null
uploadFileList.value = []
rows.value = []
checkedRowNos.value = []
result.value = null
expenseCategoryId.value = null
incomeCategoryId.value = null
importTagIds.value = []
passwordModalVisible.value = false
archivePassword.value = ''
resetFilters()
}
/** 关闭面板(取消或完成) */
@@ -480,11 +207,9 @@ watch(billType, (value) => {
localStorage.setItem(BILL_TYPE_PREFS_KEY, value)
})
// 挂载时重置并加载分类/标签(页面进入或弹窗打开时均会触发)
// 挂载时重置(页面进入或弹窗打开时均会触发)
onMounted(() => {
reset()
loadCategories()
loadTags()
})
</script>
@@ -530,99 +255,17 @@ onMounted(() => {
</NSpace>
</NSpace>
<!-- 第二步:预览并勾选 -->
<NSpace v-else-if="stage === 'preview'" vertical :size="12">
<NSpace align="center" :wrap="true" :size="[12, 12]">
<NText>{{ t('import.expenseDefaultCategory') }}</NText>
<NSelect
v-model:value="expenseCategoryId"
:options="expenseCategories"
:render-label="renderCategoryLabel"
filterable
clearable
:placeholder="t('import.notSetCategory')"
style="width: 180px;"
/>
<NText>{{ t('import.incomeDefaultCategory') }}</NText>
<NSelect
v-model:value="incomeCategoryId"
:options="incomeCategories"
:render-label="renderCategoryLabel"
filterable
clearable
:placeholder="t('import.notSetCategory')"
style="width: 180px;"
/>
<NText>{{ t('import.defaultTags') }}</NText>
<NSelect
v-model:value="importTagIds"
:options="allTags"
multiple
clearable
filterable
:max-tag-count="2"
:placeholder="t('import.notSetTag')"
style="width: 200px;"
/>
<NCheckbox
:checked="allChecked"
:indeterminate="someChecked"
:disabled="selectableRows.length === 0"
@update:checked="toggleAll"
>
{{ t('import.selectAll') }}
</NCheckbox>
<NButton size="small" :disabled="checkedRows.length === 0" @click="setCheckedIncludeInStats(true)">
{{ t('import.setIncluded') }}
</NButton>
<NButton size="small" :disabled="checkedRows.length === 0" @click="setCheckedIncludeInStats(false)">
{{ t('import.setExcluded') }}
</NButton>
<NText depth="3">{{ t('import.previewSummary', { total: rows.length, filtered: filteredRows.length, checked: checkedRows.length }) }}</NText>
</NSpace>
<!-- 第二步:预览并勾选(复用共用预览组件) -->
<BillPreviewPanel
v-else-if="stage === 'preview'"
:rows="rows"
:importing="importing"
@confirm="handleConfirm"
@back="stage = 'select'"
/>
<!-- 筛选条件 -->
<NSpace align="center" :wrap="true" :size="[12, 12]">
<NInput v-model:value="filterKeyword" clearable :placeholder="t('import.searchPlaceholder')" style="width: 220px;" />
<NSelect v-model:value="filterType" :options="typeFilterOptions" clearable :placeholder="t('import.typePlaceholder')" style="width: 120px;" />
<NSelect v-model:value="filterStatus" :options="statusFilterOptions" clearable :placeholder="t('import.statusPlaceholder')" style="width: 130px;" />
<NInputNumber v-model:value="filterMinAmount" :min="0" :precision="2" clearable :placeholder="t('common.min')" style="width: 120px;" />
<span style="color: #999;">~</span>
<NInputNumber v-model:value="filterMaxAmount" :min="0" :precision="2" clearable :placeholder="t('common.max')" style="width: 120px;" />
<NButton size="small" @click="resetFilters">{{ t('common.reset') }}</NButton>
</NSpace>
<NDataTable
:columns="columns"
:data="filteredRows"
:max-height="420"
:row-key="(row) => row.rowNo"
size="small"
:bordered="false"
/>
<NSpace justify="end">
<NButton @click="stage = 'select'">{{ t('import.prevStep') }}</NButton>
<NButton type="primary" :loading="importing" @click="handleConfirm">
{{ t('import.confirmImport', { count: checkedRows.length }) }}
</NButton>
</NSpace>
</NSpace>
<!-- 第三步:导入结果 -->
<NSpace v-else vertical :size="12">
<NAlert type="success" :show-icon="true">
{{ t('import.resultSummary', { imported: result?.imported ?? 0, skipped: result?.skipped ?? 0, failed: result?.failed ?? 0 }) }}
</NAlert>
<div v-if="result?.messages?.length" style="max-height: 320px; overflow: auto;">
<NText v-for="(msg, i) in result.messages" :key="i" depth="3" style="display: block; font-size: 12px;">
{{ msg }}
</NText>
</div>
<NSpace justify="end">
<NButton type="primary" @click="handleClose">{{ t('import.done') }}</NButton>
</NSpace>
</NSpace>
<!-- 第三步:导入结果(复用共用结果组件) -->
<BillImportResult v-else :result="result" @done="handleClose" />
<!-- 压缩包加密时弹出,要求输入解压密码 -->
<NModal
+39 -7
View File
@@ -10,6 +10,8 @@ import { LineChart, PieChart, BarChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
import { getStatisticsSummary, getStatisticsGroup } from '../api/statistics'
import { getCategoryList } from '../api/category'
import { getPaymentMethods } from '../api/transaction'
import { getPaymentLabel } from '../utils/payment'
// 注册 ECharts 所需组件
use([CanvasRenderer, LineChart, PieChart, BarChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
@@ -37,6 +39,7 @@ const customRange = ref<[number, number] | null>(null) // 自定义起止时间
const granularity = ref<'day' | 'week' | 'month' | 'quarter' | 'year'>('day') // 趋势粒度
// 过滤条件
const filterStatsScope = ref<'INCLUDED' | 'ALL' | 'EXCLUDED'>('INCLUDED') // 统计口径
const filterType = ref<string[]>([]) // 类型(多选)
const filterCategory = ref<number[]>([]) // 分类(多选,0 表示未分类)
const filterPaymentMethod = ref<string[]>([]) // 支付方式(多选)
@@ -44,17 +47,24 @@ const filterMinAmount = ref<number | null>(null) // 最小金额
const filterMaxAmount = ref<number | null>(null) // 最大金额
const filterRemark = ref('') // 备注关键字
// 统计口径选项:仅计入 / 全部 / 仅不计入
const statsScopeOptions = computed(() => [
{ label: t('dashboard.statsScope.included'), value: 'INCLUDED' },
{ label: t('dashboard.statsScope.all'), value: 'ALL' },
{ label: t('dashboard.statsScope.excluded'), value: 'EXCLUDED' },
])
// 类型与支付方式选项
const typeOptions = computed(() => [
{ label: t('common.types.expense'), value: 'EXPENSE' },
{ label: t('common.types.income'), value: 'INCOME' },
])
const paymentOptions = computed(() => [
{ label: t('common.payment.wechat'), value: 'WECHAT' },
{ label: t('common.payment.alipay'), value: 'ALIPAY' },
{ label: t('common.payment.cash'), value: 'CASH' },
{ label: t('common.payment.bank'), value: 'BANK' },
])
// 支付方式编码列表(从后端动态获取,含账单导入来源与人工支付方式)
const paymentMethodCodes = ref<string[]>([])
// 支付方式选项:由后端返回的编码映射为当前语言标签,locale 变化时重新生成
const paymentOptions = computed(() => {
void locale.value
return paymentMethodCodes.value.map((code) => ({ label: getPaymentLabel(code), value: code }))
})
/** 分类选项数据结构 */
interface CategoryOption {
@@ -90,6 +100,7 @@ function buildCategoryFilter() {
/** 统计接口的过滤条件 */
const filterParams = computed(() => ({
statsScope: filterStatsScope.value,
types: filterType.value.length > 0 ? filterType.value : undefined,
...buildCategoryFilter(),
paymentMethods: filterPaymentMethod.value.length > 0 ? filterPaymentMethod.value : undefined,
@@ -272,6 +283,7 @@ function loadPrefs() {
suppressRangeWatch = true
if (['month', '3month', '12month', 'custom'].includes(p.rangeType)) rangeType.value = p.rangeType
if (Array.isArray(p.customRange) && p.customRange.length === 2) customRange.value = p.customRange
if (['INCLUDED', 'ALL', 'EXCLUDED'].includes(p.filterStatsScope)) filterStatsScope.value = p.filterStatsScope
if (Array.isArray(p.filterType)) filterType.value = p.filterType
if (Array.isArray(p.filterCategory)) filterCategory.value = p.filterCategory
if (Array.isArray(p.filterPaymentMethod)) filterPaymentMethod.value = p.filterPaymentMethod
@@ -291,6 +303,7 @@ function savePrefs() {
rangeType: rangeType.value,
customRange: customRange.value,
granularity: granularity.value,
filterStatsScope: filterStatsScope.value,
filterType: filterType.value,
filterCategory: filterCategory.value,
filterPaymentMethod: filterPaymentMethod.value,
@@ -313,8 +326,19 @@ async function fetchCategories() {
}
}
/** 从后端加载支付方式列表,供筛选下拉使用 */
async function loadPaymentMethods() {
try {
const res: any = await getPaymentMethods()
paymentMethodCodes.value = res.data || []
} catch (e) {
// 静默处理,支付方式下拉保持为空
}
}
/** 重置所有过滤条件(保留时间范围与粒度) */
function handleResetFilters() {
filterStatsScope.value = 'INCLUDED'
filterType.value = []
filterCategory.value = []
filterPaymentMethod.value = []
@@ -334,7 +358,7 @@ watch(rangeType, (v) => {
// 范围、粒度或过滤条件变化时重新加载
watch([() => range.value.startTime, () => range.value.endTime, granularity, filterParams], fetchData)
// 任一偏好变化时自动保存
watch([rangeType, customRange, granularity, filterType, filterCategory, filterPaymentMethod, filterMinAmount, filterMaxAmount, filterRemark], savePrefs, { deep: true })
watch([rangeType, customRange, granularity, filterStatsScope, filterType, filterCategory, filterPaymentMethod, filterMinAmount, filterMaxAmount, filterRemark], savePrefs, { deep: true })
// 语言切换时重绘图表,更新标题与图例文案
watch(locale, () => {
updatePieChart()
@@ -345,6 +369,7 @@ watch(locale, () => {
onMounted(async () => {
loadPrefs()
fetchCategories()
loadPaymentMethods()
await nextTick()
suppressRangeWatch = false
fetchData()
@@ -372,6 +397,12 @@ onMounted(async () => {
<!-- 过滤条件(偏好自动记忆) -->
<NCard :bordered="false" style="margin-bottom: 16px;">
<NSpace align="center" :wrap="true" :size="[12, 12]">
<NSelect
v-model:value="filterStatsScope"
:options="statsScopeOptions"
:placeholder="t('dashboard.filterStatsScope')"
style="width: 160px;"
/>
<NSelect
v-model:value="filterType"
:options="typeOptions"
@@ -397,6 +428,7 @@ onMounted(async () => {
:options="paymentOptions"
multiple
clearable
filterable
:max-tag-count="2"
:placeholder="t('dashboard.filterPaymentMethod')"
style="width: 200px;"
+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>
+56 -4
View File
@@ -5,7 +5,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } from 'naive-ui'
import type { MenuOption } from 'naive-ui'
import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, OptionsOutline, DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline } from '@vicons/ionicons5'
import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, OptionsOutline, MailOutline, DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline, WalletOutline } from '@vicons/ionicons5'
import { useUserStore } from '../store/user'
import LocaleSwitch from '../components/LocaleSwitch.vue'
@@ -22,8 +22,15 @@ const activeKey = computed(() => {
'/categories': 'categories',
'/tags': 'tags',
'/rules': 'rules',
'/email-bills': 'email-bills',
}
return map[route.path] || 'dashboard'
// 支持子路径(如 /email-bills/messages)匹配到父级菜单
for (const [path, key] of Object.entries(map)) {
if (route.path === path || route.path.startsWith(`${path}/`)) {
return key
}
}
return 'dashboard'
})
/** 渲染菜单图标的高阶函数 */
@@ -38,6 +45,7 @@ const menuOptions = computed<MenuOption[]>(() => [
{ label: t('layout.categories'), key: 'categories', icon: renderIcon(FolderOpenOutline) },
{ label: t('layout.tags'), key: 'tags', icon: renderIcon(PricetagsOutline) },
{ label: t('layout.rules'), key: 'rules', icon: renderIcon(OptionsOutline) },
{ label: t('layout.emailBills'), key: 'email-bills', icon: renderIcon(MailOutline) },
])
/** 菜单点击事件:根据 key 导航到对应路由 */
@@ -79,8 +87,14 @@ function handleLogout() {
content-style="padding: 12px;"
>
<!-- 系统标题 -->
<div style="text-align: center; padding: 16px 0; font-size: 18px; font-weight: bold; color: #333;">
{{ t('layout.appTitle') }}
<div class="app-brand">
<div class="app-brand__logo">
<NIcon :component="WalletOutline" size="18" />
</div>
<div class="app-brand__text">
<span class="app-brand__title">{{ t('layout.appTitle') }}</span>
<span class="app-brand__subtitle">{{ t('layout.appSubtitle') }}</span>
</div>
</div>
<!-- 导航菜单 -->
<NMenu
@@ -109,3 +123,41 @@ function handleLogout() {
</NLayout>
</NLayout>
</template>
<style scoped>
/* 左上角品牌区:图标徽标 + 主标题 + 副标题 */
.app-brand {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0 16px 6px;
}
.app-brand__logo {
width: 32px;
height: 32px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: #fff;
background: linear-gradient(135deg, #36ad6a, #18a058);
box-shadow: 0 2px 6px rgba(24, 160, 88, 0.28);
}
.app-brand__text {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.app-brand__title {
font-size: 15px;
font-weight: 600;
color: #1f1f1f;
}
.app-brand__subtitle {
font-size: 11px;
color: #9aa0a6;
letter-spacing: 0.4px;
margin-top: 1px;
}
</style>
+2 -8
View File
@@ -10,11 +10,12 @@ import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
import { getCategoryList, createCategory } from '../api/category'
import { getTagList, createTag } from '../api/tag'
import { getPaymentLabel } from '../utils/payment'
import BillImportModal from './BillImportModal.vue'
const message = useMessage() // 消息提示
const dialog = useDialog() // 对话框
const { t, te } = useI18n() // 国际化
const { t } = useI18n() // 国际化
/** 交易记录数据结构 */
interface TransactionItem {
@@ -431,13 +432,6 @@ function formatTimestamp(ts: number, isEnd = false): string {
return `${date}T${time}`
}
/** 将支付方式编码转换为当前语言的标签 */
function getPaymentLabel(val: string) {
if (!val) return '-'
const key = `common.payment.${val.toLowerCase()}`
return te(key) ? t(key) : val
}
/** 分类下拉项渲染:图标 + 名称(下拉选项与已选项均生效) */
function renderCategoryLabel(option: any) {
return h('span', null, [