Download vetcopilot-app/src/components/PushNotificationToggle.tsx from WWsCa/VetNet-LCPS: direct link, hf CLI and curl.
- Browser
- Download file 11.4 kB
-
https://huggingface.co/WWsCa/VetNet-LCPS/resolve/main/vetcopilot-app/src/components/PushNotificationToggle.tsx
- Command line
-
hf download hf://WWsCa/VetNet-LCPS/vetcopilot-app/src/components/PushNotificationToggle.tsx
-
curl -L -o PushNotificationToggle.tsx https://huggingface.co/WWsCa/VetNet-LCPS/resolve/main/vetcopilot-app/src/components/PushNotificationToggle.tsx
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> | |
| ) | |
| } | |