feat: 添加推送通知功能,支持订阅和发送通知
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 40s
Docker Build & Deploy / Deploy to Production (push) Successful in 8s

This commit is contained in:
2026-01-02 12:25:44 +08:00
parent a055e43451
commit e250a7df2f
17 changed files with 382 additions and 13 deletions

View File

@@ -79,12 +79,15 @@ const updateTheme = () => {
let mediaQuery
onMounted(() => {
updateTheme()
messageStore.updateUnreadCount()
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', updateTheme)
setActive(route.path)
})
setInterval(() => {
messageStore.updateUnreadCount()
}, 30 * 1000) // 每30秒更新一次未读消息数
// 监听路由变化调整
watch(() => route.path, (newPath) => {
setActive(newPath)
@@ -127,6 +130,48 @@ const handleAddTransactionSuccess = () => {
window.dispatchEvent(event)
}
// 辅助函数:将 Base64 字符串转换为 Uint8Array
const urlBase64ToUint8Array = (base64String) => {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
const subscribeToPush = async () => {
if (!('serviceWorker' in navigator)) return;
// 1. 获取 VAPID 公钥
const response = await fetch('/api/notification/vapid-public-key');
const { publicKey } = await response.json();
// 2. 等待 Service Worker 准备就绪
const registration = await navigator.serviceWorker.ready;
// 3. 请求订阅
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey)
});
// 4. 将订阅信息发送给后端
// 注意:后端 PushSubscriptionEntity 字段首字母大写,这里需要转换或让后端兼容
const subJson = subscription.toJSON();
await fetch('/api/notification/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: subJson.endpoint,
p256dh: subJson.keys.p256dh,
auth: subJson.keys.auth
})
});
}
</script>
<style scoped>

View File

@@ -0,0 +1,24 @@
import request from './request'
export function getVapidPublicKey() {
return request({
url: '/notification/vapid-public-key',
method: 'get'
})
}
export function subscribe(data) {
return request({
url: '/notification/subscribe',
method: 'post',
data
})
}
export function testNotification(message) {
return request({
url: '/notification/test',
method: 'post',
params: { message }
})
}

View File

@@ -23,6 +23,19 @@
<van-cell title="智能分类" is-link @click="handleSmartClassification" />
<!-- <van-cell title="自然语言分类" is-link @click="handleNaturalLanguageClassification" /> -->
</van-cell-group>
<div class="detail-header" style="padding-bottom: 5px;">
<p>通知</p>
</div>
<van-cell-group inset>
<van-cell title="开启消息通知">
<template #right-icon>
<van-switch v-model="notificationEnabled" @change="handleNotificationToggle" size="24" :loading="notificationLoading" />
</template>
</van-cell>
<van-cell title="测试通知" is-link @click="handleTestNotification" v-if="notificationEnabled" />
</van-cell-group>
<div class="detail-header" style="padding-bottom: 5px;">
<p>开发者</p>
</div>
@@ -41,16 +54,101 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showLoadingToast, showSuccessToast, showToast, closeToast, showConfirmDialog } from 'vant'
import { uploadBillFile } from '@/api/billImport'
import { useAuthStore } from '@/stores/auth'
import { getVapidPublicKey, subscribe, testNotification } from '@/api/notification'
const router = useRouter()
const authStore = useAuthStore()
const fileInputRef = ref(null)
const currentType = ref('')
const notificationEnabled = ref(false)
const notificationLoading = ref(false)
onMounted(async () => {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
notificationEnabled.value = !!subscription
}
})
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
const handleNotificationToggle = async (checked) => {
if (!('serviceWorker' in navigator)) {
showToast('您的浏览器不支持推送通知')
notificationEnabled.value = false
return
}
notificationLoading.value = true
try {
const registration = await navigator.serviceWorker.ready
if (checked) {
// 开启通知
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
showToast('请允许通知权限')
notificationEnabled.value = false
return
}
const { data: { publicKey } } = await getVapidPublicKey()
const convertedVapidKey = urlBase64ToUint8Array(publicKey)
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedVapidKey
})
const subJson = subscription.toJSON()
await subscribe({
endpoint: subJson.endpoint,
p256DH: subJson.keys.p256dh,
auth: subJson.keys.auth
})
showSuccessToast('开启成功')
} else {
// 关闭通知
const subscription = await registration.pushManager.getSubscription()
if (subscription) {
await subscription.unsubscribe()
}
showSuccessToast('已关闭')
}
} catch (error) {
console.error(error)
showToast('操作失败: ' + (error.message || '未知错误'))
notificationEnabled.value = !checked // 回滚状态
} finally {
notificationLoading.value = false
}
}
const handleTestNotification = async () => {
try {
await testNotification('这是一条测试消息')
showSuccessToast('发送成功,请查看通知栏')
} catch (error) {
console.error(error)
showToast('发送失败')
}
}
/**
* 处理导入按钮点击