feat: 邮箱账单自动拉取
- 新增邮箱配置、发件人规则、待处理账单三张表(SQLite/MySQL 双脚本)及 CRUD 接口 - 按邮件发件人匹配账单类型,未命中忽略;发件人规则可配置解压密码与默认分类/标签 - 支持 IMAP IDLE 实时监听、定时兜底与手动立即检查;拉取范围可配置(仅新邮件/最近 N 天/最近 N 封) - 附件保存至本地 data/email-bill,解析预览后手动导入,复用账单解析与规则引擎 - 立即检查通过 SSE 实时推送执行进度 - 前端新增「邮箱账单」页(邮箱配置/发件人规则/待处理账单三 Tab),支持子路由与 Tab 偏好记忆,配置弹窗提供常用 IMAP 服务器候选项 - 新用户注册自动初始化默认发件人规则(支付宝/微信/美团/招商银行/交通银行) - 抽取共用账单预览与结果组件,供上传导入与邮箱账单复用 同时包含: - 统计新增口径筛选(仅计入/全部/仅不计入),支付方式改为后端动态获取 - 统一实体 createdAt/updatedAt 位置至 id 之后,调整部分日志级别
This commit is contained in:
@@ -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}`)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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: '重複' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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, [
|
||||
|
||||
Reference in New Issue
Block a user