feat: 新增定时账单导入提醒与通知直达导入页
- 新增通知配置、渠道、发送日志三张表(MySQL/SQLite 双建表脚本)及实体、Mapper、Service、Controller - 支持邮箱(SMTP)与 Server 酱两种推送渠道,抽象 NotificationSender 工厂统一发送 - 新增 NotificationScheduler 定时任务,按每天/每周/每月周期检查并推送账单导入提醒 - 新增全局通知链接地址配置(/api/app/notification-url),随提醒一并下发 - 前端系统设置页新增「定时通知」面板,支持周期配置、渠道增删改测、立即发送与发送日志 - 新增 /bill-import 直达页,供提醒链接一键打开账单上传弹窗 - 登录支持 redirect 回跳,仅允许站内路径以防开放重定向
This commit is contained in:
@@ -0,0 +1,548 @@
|
||||
<script setup lang="ts">
|
||||
// 定时通知配置面板:设置账单导入提醒周期,管理邮箱/Server 酱推送渠道,并查看发送日志。
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
useMessage, useDialog, NCard, NForm, NFormItem, NInput, NInputNumber, NSwitch, NButton,
|
||||
NSpace, NAlert, NText, NTag, NIcon, NSelect, NModal, NDivider,
|
||||
} from 'naive-ui'
|
||||
import {
|
||||
NotificationsOutline, SaveOutline, AddOutline, TrashOutline, CreateOutline, SendOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
import {
|
||||
getNotificationConfig, saveNotificationConfig, listNotificationChannels,
|
||||
createNotificationChannel, updateNotificationChannel, deleteNotificationChannel,
|
||||
testNotificationChannel, sendNotificationNow, listNotificationLogs,
|
||||
getNotificationUrl, updateNotificationUrl,
|
||||
} from '../api/notification'
|
||||
import type { NotificationChannel, NotificationLog } from '../api/notification'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 提醒周期表单
|
||||
const config = ref({ enabled: false, frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: 1, sendTime: '09:00' })
|
||||
const saving = ref(false)
|
||||
const sendingNow = ref(false)
|
||||
|
||||
// 通知链接地址(全局应用配置,用于通知正文中的跳转链接)
|
||||
const linkUrl = ref('')
|
||||
const savingLink = ref(false)
|
||||
|
||||
// 渠道与日志
|
||||
const channels = ref<NotificationChannel[]>([])
|
||||
const logs = ref<NotificationLog[]>([])
|
||||
const testingId = ref<number | null>(null)
|
||||
|
||||
// 渠道编辑弹窗
|
||||
const showChannelModal = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const editingHasPassword = ref(false)
|
||||
const savingChannel = ref(false)
|
||||
const channelForm = ref(emptyChannelForm())
|
||||
|
||||
/** 渠道表单默认值 */
|
||||
function emptyChannelForm() {
|
||||
return {
|
||||
type: 'SERVERCHAN',
|
||||
name: '',
|
||||
enabled: true,
|
||||
sendKey: '',
|
||||
smtpHost: 'smtp.qq.com',
|
||||
smtpPort: 465,
|
||||
username: '',
|
||||
password: '',
|
||||
to: '',
|
||||
ssl: true,
|
||||
}
|
||||
}
|
||||
|
||||
const frequencyOptions = computed(() => [
|
||||
{ label: t('notification.freqDaily'), value: 'DAILY' },
|
||||
{ label: t('notification.freqWeekly'), value: 'WEEKLY' },
|
||||
{ label: t('notification.freqMonthly'), value: 'MONTHLY' },
|
||||
])
|
||||
|
||||
const weekOptions = computed(() => [
|
||||
{ label: t('notification.weekday1'), value: 1 },
|
||||
{ label: t('notification.weekday2'), value: 2 },
|
||||
{ label: t('notification.weekday3'), value: 3 },
|
||||
{ label: t('notification.weekday4'), value: 4 },
|
||||
{ label: t('notification.weekday5'), value: 5 },
|
||||
{ label: t('notification.weekday6'), value: 6 },
|
||||
{ label: t('notification.weekday7'), value: 7 },
|
||||
])
|
||||
|
||||
// 每月提醒日限制在 1-28,避免不同月份天数差异
|
||||
const monthDayOptions = computed(() => Array.from({ length: 28 }, (_, i) => ({ label: String(i + 1), value: i + 1 })))
|
||||
|
||||
// 前端开放 Server 酱与邮箱两种推送渠道
|
||||
const channelTypeOptions = computed(() => [
|
||||
{ label: t('notification.typeServerChan'), value: 'SERVERCHAN' },
|
||||
{ label: t('notification.typeEmail'), value: 'EMAIL' },
|
||||
])
|
||||
|
||||
/** 渠道类型显示名 */
|
||||
function channelTypeLabel(type: string | null) {
|
||||
if (type === 'SERVERCHAN') return t('notification.typeServerChan')
|
||||
if (type === 'EMAIL') return t('notification.typeEmail')
|
||||
return type || ''
|
||||
}
|
||||
|
||||
/** 渠道类型标签颜色 */
|
||||
function channelTagType(type: string) {
|
||||
if (type === 'SERVERCHAN') return 'success'
|
||||
if (type === 'EMAIL') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
/** 格式化后端时间字符串用于展示 */
|
||||
function formatTime(value: string | null) {
|
||||
return value ? value.replace('T', ' ').slice(0, 19) : ''
|
||||
}
|
||||
|
||||
/** 加载通知配置、渠道与日志 */
|
||||
async function loadAll() {
|
||||
try {
|
||||
const [configRes, channelRes, logRes, linkRes]: any = await Promise.all([
|
||||
getNotificationConfig(), listNotificationChannels(), listNotificationLogs(20), getNotificationUrl(),
|
||||
])
|
||||
linkUrl.value = linkRes.data || ''
|
||||
const data = configRes.data
|
||||
config.value = {
|
||||
enabled: !!data.enabled,
|
||||
frequency: data.frequency || 'MONTHLY',
|
||||
dayOfMonth: data.dayOfMonth || 1,
|
||||
dayOfWeek: data.dayOfWeek || 1,
|
||||
sendTime: data.sendTime || '09:00',
|
||||
}
|
||||
channels.value = channelRes.data || []
|
||||
logs.value = logRes.data || []
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('notification.loadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存通知链接地址(全局应用配置) */
|
||||
async function handleSaveLinkUrl() {
|
||||
savingLink.value = true
|
||||
try {
|
||||
const res: any = await updateNotificationUrl(linkUrl.value.trim())
|
||||
linkUrl.value = res.data || ''
|
||||
message.success(t('notification.linkUrlSaveSuccess'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('notification.linkUrlSaveFailed'))
|
||||
} finally {
|
||||
savingLink.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存提醒周期 */
|
||||
async function handleSaveConfig() {
|
||||
if (!/^\d{2}:\d{2}$/.test(config.value.sendTime)) {
|
||||
message.error(t('notification.sendTimeInvalid'))
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await saveNotificationConfig({ ...config.value })
|
||||
message.success(t('notification.saveSuccess'))
|
||||
await loadAll()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.operationFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开渠道弹窗,传入渠道表示编辑 */
|
||||
function openChannelModal(channel: NotificationChannel | null) {
|
||||
editingId.value = channel ? channel.id : null
|
||||
editingHasPassword.value = !!channel?.hasPassword
|
||||
const base = emptyChannelForm()
|
||||
if (channel) {
|
||||
const cfg = channel.config || {}
|
||||
Object.assign(base, {
|
||||
type: channel.type,
|
||||
name: channel.name,
|
||||
enabled: channel.enabled,
|
||||
sendKey: cfg.sendKey || '',
|
||||
smtpHost: cfg.smtpHost || 'smtp.qq.com',
|
||||
smtpPort: cfg.smtpPort || 465,
|
||||
username: cfg.username || '',
|
||||
password: '',
|
||||
to: cfg.to || '',
|
||||
ssl: cfg.ssl !== false,
|
||||
})
|
||||
}
|
||||
channelForm.value = base
|
||||
showChannelModal.value = true
|
||||
}
|
||||
|
||||
/** 根据类型构建渠道配置,邮箱授权码为空时不提交 */
|
||||
function buildChannelConfig() {
|
||||
const form = channelForm.value
|
||||
if (form.type === 'SERVERCHAN') {
|
||||
return { sendKey: form.sendKey.trim() }
|
||||
}
|
||||
const config: Record<string, any> = {
|
||||
smtpHost: form.smtpHost.trim(),
|
||||
smtpPort: form.smtpPort,
|
||||
username: form.username.trim(),
|
||||
to: form.to.trim(),
|
||||
ssl: form.ssl,
|
||||
}
|
||||
if (form.password) {
|
||||
config.password = form.password
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
/** 保存渠道 */
|
||||
async function handleSaveChannel() {
|
||||
const form = channelForm.value
|
||||
if (form.type === 'SERVERCHAN') {
|
||||
if (!form.sendKey.trim()) {
|
||||
message.error(t('notification.sendKeyRequired'))
|
||||
return
|
||||
}
|
||||
} else if (!form.username.trim()) {
|
||||
message.error(t('notification.usernameRequired'))
|
||||
return
|
||||
}
|
||||
savingChannel.value = true
|
||||
try {
|
||||
const payload = { type: form.type, name: form.name, enabled: form.enabled, config: buildChannelConfig() }
|
||||
if (editingId.value) {
|
||||
await updateNotificationChannel(editingId.value, payload)
|
||||
} else {
|
||||
await createNotificationChannel(payload)
|
||||
}
|
||||
message.success(t('notification.saveChannelSuccess'))
|
||||
showChannelModal.value = false
|
||||
await loadAll()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.operationFailed'))
|
||||
} finally {
|
||||
savingChannel.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 测试渠道 */
|
||||
async function handleTestChannel(channel: NotificationChannel) {
|
||||
testingId.value = channel.id
|
||||
try {
|
||||
await testNotificationChannel(channel.id)
|
||||
message.success(t('notification.testSuccess'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('notification.testFailed'))
|
||||
} finally {
|
||||
testingId.value = null
|
||||
await loadAll()
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除渠道 */
|
||||
function handleDeleteChannel(channel: NotificationChannel) {
|
||||
dialog.warning({
|
||||
title: t('common.confirmDelete'),
|
||||
content: t('notification.confirmDeleteChannel', { name: channel.name }),
|
||||
positiveText: t('common.confirm'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await deleteNotificationChannel(channel.id)
|
||||
message.success(t('common.deleteSuccess'))
|
||||
await loadAll()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.deleteFailed'))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 立即发送提醒 */
|
||||
async function handleSendNow() {
|
||||
sendingNow.value = true
|
||||
try {
|
||||
const res: any = await sendNotificationNow()
|
||||
message.success(t('notification.sendNowSuccess', { count: res.data }))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('notification.sendNowFailed'))
|
||||
} finally {
|
||||
sendingNow.value = false
|
||||
await loadAll()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadAll)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard :bordered="false" class="config-card">
|
||||
<template #header>
|
||||
<NSpace align="center" :size="8">
|
||||
<NIcon :component="NotificationsOutline" />
|
||||
<span>{{ t('settings.notificationSection') }}</span>
|
||||
</NSpace>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<NSpace align="center" :size="8">
|
||||
<NTag :type="config.enabled ? 'success' : 'default'" size="small" :bordered="false">
|
||||
{{ config.enabled ? t('notification.statusOn') : t('notification.statusOff') }}
|
||||
</NTag>
|
||||
<NButton size="small" quaternary :loading="sendingNow" @click="handleSendNow">
|
||||
<template #icon>
|
||||
<NIcon :component="SendOutline" />
|
||||
</template>
|
||||
{{ t('notification.sendNow') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<NAlert type="info" :show-icon="true" style="margin-bottom: 20px;">
|
||||
{{ t('notification.intro') }}
|
||||
</NAlert>
|
||||
|
||||
<NForm label-placement="left" label-width="130">
|
||||
<NFormItem :label="t('notification.linkUrl')">
|
||||
<NSpace align="center" :size="8" style="width: 100%;">
|
||||
<NInput
|
||||
v-model:value="linkUrl"
|
||||
:placeholder="t('notification.linkUrlPlaceholder')"
|
||||
style="max-width: 420px"
|
||||
/>
|
||||
<NButton :loading="savingLink" @click="handleSaveLinkUrl">
|
||||
<template #icon>
|
||||
<NIcon :component="SaveOutline" />
|
||||
</template>
|
||||
{{ t('notification.save') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('notification.enabled')">
|
||||
<div class="switch-row">
|
||||
<NSwitch v-model:value="config.enabled" />
|
||||
<NText depth="3" style="font-size: 12px;">{{ t('notification.enabledHint') }}</NText>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('notification.frequency')">
|
||||
<NSpace align="center" :size="8">
|
||||
<NSelect v-model:value="config.frequency" :options="frequencyOptions" style="width: 120px" />
|
||||
<NSelect
|
||||
v-if="config.frequency === 'WEEKLY'"
|
||||
v-model:value="config.dayOfWeek"
|
||||
:options="weekOptions"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<template v-else-if="config.frequency === 'MONTHLY'">
|
||||
<NSelect v-model:value="config.dayOfMonth" :options="monthDayOptions" style="width: 90px" />
|
||||
<NText depth="3">{{ t('notification.dayOfMonthSuffix') }}</NText>
|
||||
</template>
|
||||
</NSpace>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('notification.sendTime')">
|
||||
<NInput
|
||||
v-model:value="config.sendTime"
|
||||
:placeholder="t('notification.sendTimePlaceholder')"
|
||||
style="width: 160px"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem>
|
||||
<NSpace justify="end" style="width: 100%;">
|
||||
<NButton type="primary" :loading="saving" @click="handleSaveConfig">
|
||||
<template #icon>
|
||||
<NIcon :component="SaveOutline" />
|
||||
</template>
|
||||
{{ t('notification.save') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
<NDivider />
|
||||
|
||||
<div class="section-header">
|
||||
<span class="section-title">{{ t('notification.channels') }}</span>
|
||||
<NButton size="small" @click="openChannelModal(null)">
|
||||
<template #icon>
|
||||
<NIcon :component="AddOutline" />
|
||||
</template>
|
||||
{{ t('notification.addChannel') }}
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div v-if="channels.length" class="channel-list">
|
||||
<div v-for="channel in channels" :key="channel.id" class="channel-item">
|
||||
<div class="channel-main">
|
||||
<NTag size="small" :bordered="false" :type="channelTagType(channel.type)">
|
||||
{{ channelTypeLabel(channel.type) }}
|
||||
</NTag>
|
||||
<span class="channel-name">{{ channel.name }}</span>
|
||||
<NTag v-if="channel.enabled" size="small" type="success" :bordered="false">
|
||||
{{ t('notification.enabledLabel') }}
|
||||
</NTag>
|
||||
<NTag v-else size="small" :bordered="false">{{ t('notification.disabledLabel') }}</NTag>
|
||||
</div>
|
||||
<NSpace :size="4" align="center">
|
||||
<NButton size="tiny" quaternary :loading="testingId === channel.id" @click="handleTestChannel(channel)">
|
||||
{{ t('notification.test') }}
|
||||
</NButton>
|
||||
<NButton size="tiny" quaternary @click="openChannelModal(channel)">
|
||||
<template #icon>
|
||||
<NIcon :component="CreateOutline" />
|
||||
</template>
|
||||
</NButton>
|
||||
<NButton size="tiny" quaternary type="error" @click="handleDeleteChannel(channel)">
|
||||
<template #icon>
|
||||
<NIcon :component="TrashOutline" />
|
||||
</template>
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
</div>
|
||||
<NText v-else depth="3">{{ t('notification.noChannels') }}</NText>
|
||||
|
||||
<NDivider />
|
||||
|
||||
<div class="section-header">
|
||||
<span class="section-title">{{ t('notification.logs') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="logs.length" class="log-list">
|
||||
<div v-for="log in logs" :key="log.id" class="log-item">
|
||||
<NTag size="tiny" :bordered="false" :type="log.success ? 'success' : 'error'">
|
||||
{{ log.success ? t('notification.success') : t('notification.failed') }}
|
||||
</NTag>
|
||||
<span class="log-time">{{ formatTime(log.createdAt) }}</span>
|
||||
<span class="log-channel">{{ channelTypeLabel(log.channelType) }}</span>
|
||||
<span class="log-message">{{ log.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<NText v-else depth="3">{{ t('notification.noLogs') }}</NText>
|
||||
|
||||
<NModal
|
||||
v-model:show="showChannelModal"
|
||||
preset="card"
|
||||
:title="editingId ? t('notification.editChannel') : t('notification.addChannel')"
|
||||
style="width: 560px"
|
||||
>
|
||||
<NForm label-placement="left" label-width="120">
|
||||
<NFormItem :label="t('notification.channelType')">
|
||||
<NSelect v-model:value="channelForm.type" :options="channelTypeOptions" :disabled="!!editingId" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('notification.channelName')">
|
||||
<NInput v-model:value="channelForm.name" :placeholder="t('notification.channelNamePlaceholder')" />
|
||||
</NFormItem>
|
||||
|
||||
<template v-if="channelForm.type === 'SERVERCHAN'">
|
||||
<NFormItem :label="t('notification.sendKey')">
|
||||
<NInput v-model:value="channelForm.sendKey" :placeholder="t('notification.sendKeyPlaceholder')" />
|
||||
</NFormItem>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<NFormItem :label="t('notification.smtpHost')">
|
||||
<NInput v-model:value="channelForm.smtpHost" placeholder="smtp.qq.com" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('notification.smtpPort')">
|
||||
<NInputNumber v-model:value="channelForm.smtpPort" :min="1" :max="65535" style="width: 160px" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('notification.username')">
|
||||
<NInput v-model:value="channelForm.username" placeholder="example@qq.com" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('notification.password')">
|
||||
<NInput
|
||||
v-model:value="channelForm.password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="editingHasPassword ? t('notification.passwordKeep') : t('notification.passwordPlaceholder')"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('notification.to')">
|
||||
<NInput v-model:value="channelForm.to" :placeholder="t('notification.toPlaceholder')" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('notification.ssl')">
|
||||
<NSwitch v-model:value="channelForm.ssl" />
|
||||
</NFormItem>
|
||||
</template>
|
||||
|
||||
<NFormItem :label="t('notification.enabledLabel')">
|
||||
<NSwitch v-model:value="channelForm.enabled" />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
<template #footer>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="showChannelModal = false">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="savingChannel" @click="handleSaveChannel">
|
||||
{{ t('common.confirm') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.channel-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.channel-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #efefef;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.channel-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.channel-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
.log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.log-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.log-time {
|
||||
color: #999;
|
||||
}
|
||||
.log-channel {
|
||||
color: #666;
|
||||
min-width: 100px;
|
||||
}
|
||||
.log-message {
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user