VetNet-LCPS / vetcopilot-app /src /components /PushNotificationToggle.tsx
thomasm291678
VetCopilot-LCPS: LCPS 2.0 本地推理引擎替换 DeepSeek API
793e953
Raw History Blame Contribute Delete
11.4 kB
/**
* PushNotificationToggle - 推送通知开关组件
*
* 显示当前推送订阅状态,提供订阅/取消订阅的切换按钮。
* 可在设置页面或任意位置嵌入使用。
*
* 交互流程:
* 1. 检查浏览器支持 → 不支持则显示提示
* 2. 检查通知权限 → 未授权则引导用户授权
* 3. 检查当前订阅状态 → 显示对应按钮
* 4. 点击按钮 → 执行订阅/取消订阅 + 后端同步
*/
import React, { useState, useEffect, useCallback } from 'react'
import { useTranslation } from '../i18n'
import {
isPushSupported,
getNotificationPermission,
requestNotificationPermission,
subscribeToPush,
unsubscribeFromPush,
getPushSubscription,
registerSubscriptionWithBackend,
unregisterSubscriptionFromBackend,
} from '../utils/pushNotifications'
// ================================================================
// 状态枚举
// ================================================================
type SubscriptionStatus =
| 'unsupported' // 浏览器不支持 Push
| 'loading' // 正在检查/执行操作
| 'denied' // 通知权限被拒绝
| 'unsubscribed' // 未订阅
| 'subscribed' // 已订阅
// ================================================================
// PushNotificationToggle 组件
// ================================================================
export default function PushNotificationToggle() {
const { t } = useTranslation()
const [status, setStatus] = useState<SubscriptionStatus>('loading')
const [error, setError] = useState<string | null>(null)
const [subscriptionEndpoint, setSubscriptionEndpoint] =
useState<string | null>(null)
// ----------------------------------------------------------
// 初始化:检查当前状态
// ----------------------------------------------------------
useEffect(() => {
checkStatus()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const checkStatus = useCallback(async () => {
setError(null)
// 1. 检查浏览器支持
if (!isPushSupported()) {
setStatus('unsupported')
return
}
// 2. 检查通知权限
const permission = getNotificationPermission()
if (permission === 'denied') {
setStatus('denied')
return
}
// 3. 检查当前订阅
setStatus('loading')
try {
const sub = await getPushSubscription()
if (sub) {
setSubscriptionEndpoint(sub.endpoint || null)
setStatus('subscribed')
} else {
setSubscriptionEndpoint(null)
setStatus('unsubscribed')
}
} catch {
setSubscriptionEndpoint(null)
setStatus('unsubscribed')
}
}, [])
// ----------------------------------------------------------
// 开启通知
// ----------------------------------------------------------
const handleEnable = useCallback(async () => {
setError(null)
setStatus('loading')
try {
console.log('[PushToggle] 开始开启推送通知...')
// 先请求通知权限(如果尚未授予)
let permission = getNotificationPermission()
if (permission !== 'granted') {
permission = await requestNotificationPermission()
}
if (permission !== 'granted') {
console.warn('[PushToggle] 通知权限未授予')
setStatus('denied')
setError(t('components.push.permissionNotGranted'))
return
}
// 订阅 Push(调用浏览器 PushManager.subscribe)
console.log('[PushToggle] 调用 subscribeToPush...')
const subscription = await subscribeToPush()
console.log('[PushToggle] 浏览器订阅成功:', subscription.endpoint)
// 立刻更新 UI 状态为已订阅(不等后端同步完成)
setSubscriptionEndpoint(subscription.endpoint || null)
setStatus('subscribed')
console.log('[PushToggle] UI 已切换为已订阅状态')
// 后台同步到后端(不阻塞 UI)
try {
console.log('[PushToggle] 同步订阅到后端...')
await registerSubscriptionWithBackend(subscription)
console.log('[PushToggle] 后端订阅注册成功')
} catch (backendErr) {
console.warn('[PushToggle] 后端同步失败(浏览器订阅已生效):', backendErr)
setError(t('components.push.backendSyncFailed'))
}
} catch (err) {
console.error('[PushToggle] 开启失败:', err)
const message =
err instanceof Error ? err.message : t('components.push.enableFailed')
setError(message)
// 回退到检查当前实际订阅状态
await checkStatus()
}
}, [checkStatus])
// ----------------------------------------------------------
// 关闭通知
// ----------------------------------------------------------
const handleDisable = useCallback(async () => {
setError(null)
setStatus('loading')
try {
// 获取当前端点用于后端取消注册
const sub = await getPushSubscription()
const endpoint = sub?.endpoint || subscriptionEndpoint
// 取消浏览器 Push 订阅
await unsubscribeFromPush()
// 从后端取消注册
if (endpoint) {
try {
await unregisterSubscriptionFromBackend(endpoint)
} catch {
// 后端取消失败不影响前端状态
console.warn('[PushToggle] 后端取消订阅失败,已在前端取消')
}
}
setSubscriptionEndpoint(null)
setStatus('unsubscribed')
} catch (err) {
const message =
err instanceof Error ? err.message : t('components.push.disableFailed')
setError(message)
await checkStatus()
}
}, [subscriptionEndpoint, checkStatus])
// ----------------------------------------------------------
// 渲染:不支持
// ----------------------------------------------------------
if (status === 'unsupported') {
return (
<div className="card p-4">
<div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5">
<svg
className="w-5 h-5 text-surface-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
</div>
<div>
<p className="text-sm font-medium text-gray-700">{t('components.push.notSupported')}</p>
<p className="text-xs text-surface-400 mt-0.5">
{t('components.push.browserNotSupported')}
</p>
</div>
</div>
</div>
)
}
// ----------------------------------------------------------
// 渲染:加载中
// ----------------------------------------------------------
if (status === 'loading') {
return (
<div className="card p-4">
<div className="flex items-center gap-3">
<div className="w-5 h-5 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-surface-400">{t('components.push.checking')}</p>
</div>
</div>
)
}
// ----------------------------------------------------------
// 渲染:权限被拒绝
// ----------------------------------------------------------
if (status === 'denied') {
return (
<div className="card p-4">
<div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5">
<svg
className="w-5 h-5 text-amber-500"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700">
{t('components.push.permissionDenied')}
</p>
<p className="text-xs text-surface-400 mt-0.5">
{t('components.push.permissionDeniedHint')}
</p>
</div>
</div>
</div>
)
}
// ----------------------------------------------------------
// 渲染:已订阅 / 未订阅
// ----------------------------------------------------------
return (
<div className="card p-4">
{/* 标题行 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{/* 状态图标 */}
{status === 'subscribed' ? (
<div className="w-5 h-5 rounded-full bg-emerald-100 flex items-center justify-center">
<svg
className="w-3 h-3 text-emerald-600"
fill="none"
stroke="currentColor"
strokeWidth={3}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
) : (
<div className="w-5 h-5 rounded-full bg-surface-100 flex items-center justify-center">
<svg
className="w-3 h-3 text-surface-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
/>
</svg>
</div>
)}
{/* 状态文字 */}
<div>
<p className="text-sm font-medium text-gray-700">
{status === 'subscribed' ? t('components.push.enabled') : t('components.push.disabled')}
</p>
<p className="text-xs text-surface-400 mt-0.5">
{status === 'subscribed'
? t('components.push.enabledDesc')
: t('components.push.disabledDesc')}
</p>
</div>
</div>
{/* 切换按钮 */}
{status === 'subscribed' ? (
<button
type="button"
onClick={handleDisable}
className="btn-danger text-xs px-3 py-1.5"
>
{t('components.push.disableButton')}
</button>
) : (
<button
type="button"
onClick={handleEnable}
className="btn-primary text-xs px-3 py-1.5"
>
{t('components.push.enableButton')}
</button>
)}
</div>
{/* 错误提示 */}
{error && (
<div className="mt-3 px-3 py-2 bg-red-50 border border-red-200 rounded-lg">
<p className="text-xs text-red-600">{error}</p>
</div>
)}
</div>
)
}