VetNet-LCPS / vetcopilot-app /src /utils /pushNotifications.ts
thomasm291678
VetCopilot-LCPS: LCPS 2.0 本地推理引擎替换 DeepSeek API
793e953
Raw History Blame Contribute Delete
8.46 kB
/**
* Push Notification 工具模块
*
* 封装浏览器 Push API 的订阅、取消订阅、权限管理等逻辑。
* 所有后端通信委托给 src/api/index.ts 中的 push 相关 API 函数。
*/
import {
subscribePush,
unsubscribePush,
getVapidPublicKey,
} from '../api/index'
// ================================================================
// 常量
// ================================================================
/** Service Worker 注册路径(相对于 public 目录,构建后位于根路径) */
const SW_PATH = '/sw.js'
/** 缓存的 VAPID 公钥(从后端动态获取,避免前后端密钥不匹配) */
let _cachedVapidPublicKey: string | null = null
/**
* 获取 VAPID 公钥(优先缓存,其次云端获取,兜底环境变量)
*/
async function _getVapidPublicKey(): Promise<string> {
if (_cachedVapidPublicKey) return _cachedVapidPublicKey
try {
_cachedVapidPublicKey = await getVapidPublicKey()
console.log('[Push] VAPID 公钥已从后端获取')
return _cachedVapidPublicKey!
} catch (err) {
console.warn('[Push] 无法从后端获取 VAPID 公钥,回退到环境变量:', err)
const fallback = import.meta.env.VITE_VAPID_PUBLIC_KEY
if (fallback) {
_cachedVapidPublicKey = fallback
return fallback
}
throw new Error('VAPID 公钥不可用,推送通知功能暂无法使用')
}
}
// ================================================================
// 工具函数
// ================================================================
/**
* 将 URL-safe Base64 字符串转换为 Uint8Array
*
* Push API 的 applicationServerKey 需要 ArrayBuffer 格式,
* 而 VAPID 公钥通常以 URL-safe Base64 存储,此函数完成该转换。
*
* @param base64String - URL-safe Base64 编码的 VAPID 公钥
* @returns 对应的 Uint8Array
*/
export function urlBase64ToUint8Array(base64String: string): Uint8Array {
// 补齐 Base64 标准长度所需的 padding
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
}
// ================================================================
// Service Worker 注册
// ================================================================
let swRegistration: ServiceWorkerRegistration | null = null
/**
* 获取或注册 Service Worker。
*
* 已注册的实例会被缓存,多次调用返回同一个 registration。
*/
export async function getSWRegistration(): Promise<ServiceWorkerRegistration | null> {
if (swRegistration) {
return swRegistration
}
if (!('serviceWorker' in navigator)) {
console.warn('[Push] 当前浏览器不支持 Service Worker')
return null
}
try {
swRegistration = await navigator.serviceWorker.register(SW_PATH, {
scope: '/',
})
console.log('[Push] Service Worker 已注册:', swRegistration.scope)
return swRegistration
} catch (err) {
console.error('[Push] Service Worker 注册失败:', err)
return null
}
}
// ================================================================
// 通知权限
// ================================================================
/**
* 检查浏览器是否支持 Push Notification。
*/
export function isPushSupported(): boolean {
return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
}
/**
* 获取当前通知权限状态。
*/
export function getNotificationPermission(): NotificationPermission {
if (!('Notification' in window)) {
return 'denied'
}
return Notification.permission
}
/**
* 请求通知权限。
*
* @returns 用户选择的权限状态
*/
export async function requestNotificationPermission(): Promise<NotificationPermission> {
if (!('Notification' in window)) {
console.warn('[Push] 当前浏览器不支持 Notification API')
return 'denied'
}
try {
const permission = await Notification.requestPermission()
console.log('[Push] 通知权限:', permission)
return permission
} catch (err) {
console.error('[Push] 请求通知权限失败:', err)
return 'denied'
}
}
// ================================================================
// Push 订阅管理
// ================================================================
/**
* 订阅 Push Notification。
*
* 流程:
* 1. 确认浏览器支持 & 获取 SW registration
* 2. 使用 VAPID 公钥创建 push subscription
* 3. 将 subscription 序列化后返回
*
* @param publicKey - VAPID 公钥(Base64 字符串),不传则使用默认配置
* @returns 订阅对象 { endpoint, keys: { p256dh, auth } }
*/
export async function subscribeToPush(
publicKey?: string,
): Promise<PushSubscriptionJSON> {
if (!isPushSupported()) {
throw new Error('当前浏览器不支持 Push Notification')
}
const registration = await getSWRegistration()
if (!registration) {
throw new Error('Service Worker 未注册,无法订阅推送')
}
const permission = getNotificationPermission()
if (permission !== 'granted') {
throw new Error('通知权限未授予,无法订阅推送')
}
const applicationServerKey = urlBase64ToUint8Array(
await _getVapidPublicKey(),
)
try {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey as unknown as BufferSource,
})
console.log('[Push] 订阅成功:', subscription.endpoint)
const subJSON = subscription.toJSON()
return subJSON
} catch (err) {
console.error('[Push] 订阅失败:', err)
throw new Error(`Push 订阅失败: ${err instanceof Error ? err.message : String(err)}`)
}
}
/**
* 取消 Push Notification 订阅。
*
* @returns 是否成功取消
*/
export async function unsubscribeFromPush(): Promise<boolean> {
const registration = await getSWRegistration()
if (!registration) {
console.warn('[Push] Service Worker 未注册,无需取消订阅')
return false
}
try {
const subscription = await registration.pushManager.getSubscription()
if (!subscription) {
console.log('[Push] 当前没有活跃的推送订阅')
return false
}
const result = await subscription.unsubscribe()
console.log('[Push] 取消订阅:', result ? '成功' : '失败')
return result
} catch (err) {
console.error('[Push] 取消订阅失败:', err)
return false
}
}
/**
* 获取当前 Push Subscription。
*
* @returns 当前订阅对象的 JSON 表示,无订阅时返回 null
*/
export async function getPushSubscription(): Promise<PushSubscriptionJSON | null> {
const registration = await getSWRegistration()
if (!registration) {
return null
}
try {
const subscription = await registration.pushManager.getSubscription()
if (!subscription) {
return null
}
return subscription.toJSON()
} catch (err) {
console.error('[Push] 获取订阅状态失败:', err)
return null
}
}
// ================================================================
// 后端同步
// ================================================================
/**
* 将订阅对象注册到后端。
*
* @param subscription - push 订阅 JSON 对象
*/
export async function registerSubscriptionWithBackend(
subscription: PushSubscriptionJSON,
): Promise<void> {
if (!subscription.endpoint) {
throw new Error('订阅对象缺少 endpoint')
}
try {
await subscribePush({
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.keys?.p256dh || '',
auth: subscription.keys?.auth || '',
},
})
console.log('[Push] 订阅已同步到后端')
} catch (err) {
console.error('[Push] 后端订阅注册失败:', err)
throw err
}
}
/**
* 从后端取消注册指定端点的订阅。
*
* @param endpoint - push 订阅的 endpoint URL
*/
export async function unregisterSubscriptionFromBackend(
endpoint: string,
): Promise<void> {
try {
await unsubscribePush(endpoint)
console.log('[Push] 后端订阅已取消')
} catch (err) {
console.error('[Push] 后端取消订阅失败:', err)
throw err
}
}