feat: 新增定时账单导入提醒与通知直达导入页

- 新增通知配置、渠道、发送日志三张表(MySQL/SQLite 双建表脚本)及实体、Mapper、Service、Controller
- 支持邮箱(SMTP)与 Server 酱两种推送渠道,抽象 NotificationSender 工厂统一发送
- 新增 NotificationScheduler 定时任务,按每天/每周/每月周期检查并推送账单导入提醒
- 新增全局通知链接地址配置(/api/app/notification-url),随提醒一并下发
- 前端系统设置页新增「定时通知」面板,支持周期配置、渠道增删改测、立即发送与发送日志
- 新增 /bill-import 直达页,供提醒链接一键打开账单上传弹窗
- 登录支持 redirect 回跳,仅允许站内路径以防开放重定向
This commit is contained in:
2026-09-18 16:47:09 +08:00
parent c1f5d02564
commit e49b6b7b81
45 changed files with 3469 additions and 513 deletions
+511
View File
@@ -0,0 +1,511 @@
<script setup lang="ts">
// 账单导入面板:先解析、后勾选导入(默认全选),当前支持支付宝与微信账单
// 以纯内容形式提供,可由弹窗(BillImportModal)或独立页面(BillImport)承载
import { ref, computed, h, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, NSpace, NButton, NSelect, NUpload, NUploadDragger, NDataTable,
NCheckbox, NTag, NAlert, NText, NIcon, NInput, NInputNumber,
} from 'naive-ui'
import type { DataTableColumns, 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'
const emit = defineEmits<{
(e: 'close'): void
(e: 'imported'): void
}>()
const message = useMessage()
const { t } = useI18n() // 国际化
// 账单类型偏好存储键
const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType'
// 账单类型选项,当前支持支付宝与微信
const billTypeOptions = computed(() => [
{ label: t('import.billTypeALIPAY'), value: 'ALIPAY' },
{ label: t('import.billTypeWECHAT'), value: 'WECHAT' },
])
/** 读取上次选择的账单类型偏好,非法值回退为支付宝 */
function loadBillTypePref(): BillType {
const saved = localStorage.getItem(BILL_TYPE_PREFS_KEY)
return saved === 'ALIPAY' || saved === 'WECHAT' ? saved : 'ALIPAY'
}
// 当前账单类型,初始值取上次选择(个人偏好)
const billType = ref<BillType>(loadBillTypePref())
// 各账单类型允许上传的文件扩展名
const allowedExtensions: Record<BillType, string[]> = {
ALIPAY: ['.csv'],
WECHAT: ['.xlsx'],
}
// 当前账单类型允许的扩展名,用于上传组件 accept 属性
const acceptTypes = computed(() => (allowedExtensions[billType.value] || []).join(','))
// 当前账单类型的文件格式提示
const fileHint = computed(() => {
const label = billType.value === 'WECHAT' ? t('import.billTypeWECHAT') : t('import.billTypeALIPAY')
const exts = (allowedExtensions[billType.value] || []).join(t('import.extSeparator'))
return t('import.fileHint', { label, exts })
})
// 当前账单类型的导出路径提示
const exportHint = computed(() =>
billType.value === 'WECHAT' ? t('import.exportHintWECHAT') : t('import.exportHintALIPAY'),
)
// 面板阶段:选择文件 -> 预览勾选 -> 导入结果
const stage = ref<'select' | 'preview' | 'result'>('select')
const file = ref<File | null>(null)
const uploadFileList = ref<UploadFileInfo[]>([])
const parsing = ref(false)
const importing = ref(false)
// 解析结果与勾选状态
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.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 || '-')
},
},
])
/** 加载分类选项 */
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] || []
const lower = name.toLowerCase()
return exts.length === 0 || exts.some((ext) => lower.endsWith(ext))
}
/** 文件选择变化(支持点击或拖拽),按账单类型校验扩展名,非法文件直接拒绝 */
function handleFileChange(options: { fileList: UploadFileInfo[] }) {
const last = options.fileList[options.fileList.length - 1]
if (last?.file && !isAllowedFile(last.name)) {
const label = billType.value === 'WECHAT' ? t('import.billTypeWECHAT') : t('import.billTypeALIPAY')
const exts = allowedExtensions[billType.value].join(t('import.extSeparator'))
message.error(t('import.errorFileType', { label, exts }))
uploadFileList.value = []
file.value = null
return
}
uploadFileList.value = options.fileList
file.value = last?.file ?? null
}
/** 解析账单 */
async function handleParse() {
if (!file.value) {
message.warning(t('import.selectFileFirst'))
return
}
parsing.value = true
try {
const res: any = await parseBill(file.value, billType.value)
rows.value = res.data.rows || []
// 默认勾选解析器建议勾选的行
checkedRowNos.value = rows.value.filter((r) => r.selected).map((r) => r.rowNo)
resetFilters()
stage.value = 'preview'
} catch (e: any) {
message.error(e.message || t('import.parseFailed'))
} finally {
parsing.value = false
}
}
/** 切换单行勾选 */
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) : []
}
/** 确认导入 */
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,
}))
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,
})
result.value = res.data
stage.value = 'result'
emit('imported')
} catch (e: any) {
message.error(e.message || t('import.importFailed'))
} finally {
importing.value = false
}
}
/** 重置面板状态 */
function reset() {
stage.value = 'select'
file.value = null
uploadFileList.value = []
rows.value = []
checkedRowNos.value = []
result.value = null
expenseCategoryId.value = null
incomeCategoryId.value = null
importTagIds.value = []
resetFilters()
}
/** 关闭面板(取消或完成) */
function handleClose() {
emit('close')
}
// 切换账单类型时清空已选文件、记录偏好,避免文件格式与类型不匹配
watch(billType, (value) => {
uploadFileList.value = []
file.value = null
localStorage.setItem(BILL_TYPE_PREFS_KEY, value)
})
// 挂载时重置并加载分类/标签(页面进入或弹窗打开时均会触发)
onMounted(() => {
reset()
loadCategories()
loadTags()
})
</script>
<template>
<!-- 第一步:选择账单类型与文件 -->
<NSpace v-if="stage === 'select'" vertical :size="16">
<NSpace align="center">
<NText>{{ t('import.billType') }}</NText>
<NSelect v-model:value="billType" :options="billTypeOptions" style="width: 160px;" />
</NSpace>
<div>
<NText>{{ t('import.billFile') }}</NText>
<NUpload
:file-list="uploadFileList"
:default-upload="false"
:max="1"
:accept="acceptTypes"
style="margin-top: 8px;"
@change="handleFileChange"
>
<NUploadDragger>
<div style="padding: 24px 0; text-align: center;">
<NIcon size="48" :depth="3">
<CloudUploadOutline />
</NIcon>
<div style="font-size: 15px; margin-top: 8px;">{{ t('import.dropText') }}</div>
<div style="font-size: 12px; color: #999; margin-top: 4px;">
{{ fileHint }}
</div>
</div>
</NUploadDragger>
</NUpload>
</div>
<NAlert type="info" :show-icon="true">
{{ exportHint }}
</NAlert>
<NSpace justify="end">
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="parsing" :disabled="!file" @click="handleParse">{{ t('import.parse') }}</NButton>
</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>
<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="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>
</template>