feat: 添加推送通知功能,支持订阅和发送通知
This commit is contained in:
@@ -107,17 +107,29 @@ self.addEventListener('sync', (event) => {
|
||||
// 推送通知
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[Service Worker] 收到推送消息');
|
||||
let data = { title: '账单管理', body: '您有新的消息', url: '/', icon: '/icons/icon-192x192.png' };
|
||||
|
||||
if (event.data) {
|
||||
try {
|
||||
const json = event.data.json();
|
||||
data = { ...data, ...json };
|
||||
} catch (e) {
|
||||
data.body = event.data.text();
|
||||
}
|
||||
}
|
||||
|
||||
const options = {
|
||||
body: event.data ? event.data.text() : '您有新的账单消息',
|
||||
icon: '/icons/icon-192x192.png',
|
||||
body: data.body,
|
||||
icon: data.icon,
|
||||
badge: '/icons/icon-72x72.png',
|
||||
vibrate: [200, 100, 200],
|
||||
tag: 'emailbill-notification',
|
||||
requireInteraction: false
|
||||
requireInteraction: false,
|
||||
data: { url: data.url }
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification('账单管理', options)
|
||||
self.registration.showNotification(data.title, options)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -125,8 +137,21 @@ self.addEventListener('push', (event) => {
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
console.log('[Service Worker] 通知被点击');
|
||||
event.notification.close();
|
||||
const urlToOpen = event.notification.data?.url || '/';
|
||||
event.waitUntil(
|
||||
clients.openWindow('/')
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
||||
// 如果已经打开了该 URL,则聚焦
|
||||
for (let i = 0; i < windowClients.length; i++) {
|
||||
const client = windowClients[i];
|
||||
if (client.url === urlToOpen && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// 否则打开新窗口
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(urlToOpen);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
24
Web/src/api/notification.js
Normal file
24
Web/src/api/notification.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
@@ -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('发送失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理导入按钮点击
|
||||
|
||||
Reference in New Issue
Block a user