Files
ledgerly/frontend/src/components/NotificationConfigPanel.vue
T
rosercode e49b6b7b81 feat: 新增定时账单导入提醒与通知直达导入页
- 新增通知配置、渠道、发送日志三张表(MySQL/SQLite 双建表脚本)及实体、Mapper、Service、Controller
- 支持邮箱(SMTP)与 Server 酱两种推送渠道,抽象 NotificationSender 工厂统一发送
- 新增 NotificationScheduler 定时任务,按每天/每周/每月周期检查并推送账单导入提醒
- 新增全局通知链接地址配置(/api/app/notification-url),随提醒一并下发
- 前端系统设置页新增「定时通知」面板,支持周期配置、渠道增删改测、立即发送与发送日志
- 新增 /bill-import 直达页,供提醒链接一键打开账单上传弹窗
- 登录支持 redirect 回跳,仅允许站内路径以防开放重定向
2026-09-18 16:47:09 +08:00

549 lines
17 KiB
Vue

<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>