Files
ledgerly/frontend/src/components/NotificationConfigPanel.vue
T
rosercode 75e75ad190 feat: 前端适配移动端与小屏并优化触屏图表交互
新增断点基础设施与响应式布局,使手机(<768px)与平板(768–1023px)下可正常浏览和操作,桌面端(≥1024px)布局保持原有形态不变;引入此前未被入口引用的全局样式文件,使全局小屏兜底规则生效,并修复小屏分页控件被裁切、触屏端图表点击误触直接跳转的问题。

变更内容:
- 断点基础设施:新增 `frontend/src/composables/useBreakpoints.ts`(isMobile / isTablet / isDesktop / viewportWidth / isTouch),作为全站统一断点与触屏判定来源
- 全局样式:`frontend/src/main.ts` 引入 `./style.css`(此前从未被引入,导致其中规则一直无效),使弹层 max-width 92vw、body 防横向溢出等兜底规则实际生效
- 主布局 `views/Layout.vue`:小屏侧栏收起为 NDrawer、Header 增加菜单按钮,切回桌面自动收起;内容区高度改用 dvh,内边距随断点收窄;新增三语文案 `layout.menu`
- 表格:为列宽可能超出窄屏的 NDataTable 统一补 `:scroll-x`(收支记录、邮箱账单、规则/分类/标签/渠道、账单预览、AI 使用、关系编辑等)
- 弹窗:为固定宽度弹窗补 `max-width: 92vw`(分类/标签/渠道/规则、收支新增编辑与原始数据与一键设置、通知渠道、登录与注册卡片)
- 栅格与图表:`Dashboard.vue` / `SqlQuery.vue` 栅格改用断点列(responsive="screen"),图表高度随断点降低
- 浮窗:`FloatingPanel.vue` 按视口约束宽高、最小尺寸随视口下调;`AiQueryWindow.vue` / `EmojiPicker.vue` 小屏自适应
- 表单与列表:左标签表单小屏改为顶部标签(AI 配置、数据库配置、通知配置、邮箱账单弹窗);未换行 NSpace 与关系编辑器成员行小屏可换行
- 分页修复:≤1023px 下 `.n-pagination` 允许换行、居中、高度自适应(naive-ui 运行期注入样式优先级更高,用 !important 覆盖),表格内置分页容器同步居中,避免页大小选择与跳页输入被裁到视口外
- 触屏图表交互:`Dashboard.vue` 触屏端点击趋势柱或饼图扇区时按点击坐标弹出确认气泡(「查看明细 / 取消」),确认后才跳转;`handleTrendBlankClick` 在触屏端直接返回、禁用「绘图区整列可点」;桌面端保持点击即跳转;新增三语文案 `dashboard.chartActionHint` / `dashboard.chartActionView`

说明:
- 桌面端布局与跳转逻辑不变,点击柱子直接跳转、不显示气泡
- 分页控件在窄屏换行显示,页大小可切换、跳页输入可见可用
- 触屏端滑动/缩放不触发跳转(仅图形本体点击且需二次确认)
- 采用「基础可用适配」范围,未做移动端卡片式重排;断点统一走 `useBreakpoints`,后续新增页面按工作区 `AGENTS.md`《前端响应式适配(移动端 / 小屏)》约定实现

验证:
- 测试环境(5174)以 375 / 768 / 800 / 1280 宽度遍历统计面板、收支记录、分类管理、设置、邮箱账单、日志、智能查数等页面,均无横向溢出;手机端侧栏抽屉可开合;分页控件均在视口内且页大小可切换
- 触屏(375,hasTouch)点柱子/饼图扇区弹出确认气泡且不跳转,点空白不弹不跳,点「查看明细」跳转 `/transactions` 并写入筛选偏好;桌面(1280)点柱子直接跳转
- `./package.sh` 构建成功(bootJar + bootGuiJar,含前端 build)
- `npx vue-tsc -b --force` 类型检查通过
2026-09-30 17:10:07 +08:00

552 lines
18 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'
import { useBreakpoints } from '../composables/useBreakpoints'
const message = useMessage()
const dialog = useDialog()
const { t } = useI18n()
// 小屏时表单标签置顶,避免左侧固定标签挤压输入区
const { isMobile } = useBreakpoints()
// 提醒周期表单
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="isMobile ? 'top' : 'left'" :label-width="isMobile ? undefined : 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; max-width: 92vw"
>
<NForm :label-placement="isMobile ? 'top' : 'left'" :label-width="isMobile ? undefined : 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>