增加浏览器离线通知功能

This commit is contained in:
mtvpls
2026-06-02 20:50:33 +08:00
parent 16e48bef8b
commit d600317ade
20 changed files with 1739 additions and 91 deletions
+165 -55
View File
@@ -1,6 +1,6 @@
'use client';
import { X } from 'lucide-react';
import { Bell, Info, Mail, MonitorSmartphone, X } from 'lucide-react';
import { createPortal } from 'react-dom';
interface EmailSettingsPanelProps {
@@ -11,6 +11,11 @@ interface EmailSettingsPanelProps {
onUserEmailChange: (value: string) => void;
emailNotifications: boolean;
onEmailNotificationsChange: (value: boolean) => void;
pushNotifications: boolean;
onPushNotificationsChange: (value: boolean) => void;
pushNotificationsSupported: boolean;
pushNotificationsConfigured: boolean;
pushNotificationsBusy: boolean;
emailSettingsLoading: boolean;
emailSettingsSaving: boolean;
onSave: () => void;
@@ -18,6 +23,44 @@ interface EmailSettingsPanelProps {
statusType?: 'success' | 'error' | null;
}
function Toggle({
checked,
disabled,
busy,
label,
onChange,
}: {
checked: boolean;
disabled?: boolean;
busy?: boolean;
label: string;
onChange: () => void;
}) {
return (
<button
type='button'
role='switch'
aria-checked={checked}
aria-label={label}
onClick={onChange}
disabled={disabled}
className={`relative inline-flex h-7 w-12 shrink-0 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-gray-900 ${
checked ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
}`}
>
<span
className={`inline-flex h-5 w-5 transform items-center justify-center rounded-full bg-white shadow-sm transition-transform duration-200 ${
checked ? 'translate-x-6' : 'translate-x-1'
}`}
>
{busy ? (
<span className='h-3 w-3 animate-spin rounded-full border-2 border-blue-600 border-t-transparent' />
) : null}
</span>
</button>
);
}
export function EmailSettingsPanel({
isOpen,
mounted,
@@ -26,6 +69,11 @@ export function EmailSettingsPanel({
onUserEmailChange,
emailNotifications,
onEmailNotificationsChange,
pushNotifications,
onPushNotificationsChange,
pushNotificationsSupported,
pushNotificationsConfigured,
pushNotificationsBusy,
emailSettingsLoading,
emailSettingsSaving,
onSave,
@@ -34,56 +82,81 @@ export function EmailSettingsPanel({
}: EmailSettingsPanelProps) {
if (!isOpen || !mounted) return null;
const pushDisabled =
emailSettingsSaving ||
pushNotificationsBusy ||
(!pushNotifications && (!pushNotificationsConfigured || !pushNotificationsSupported));
return createPortal(
<>
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
className='fixed inset-0 z-[1000] bg-black/50 backdrop-blur-sm'
onClick={onClose}
onTouchMove={(e) => e.preventDefault()}
onWheel={(e) => e.preventDefault()}
style={{ touchAction: 'none' }}
/>
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'>
<div className='fixed left-1/2 top-1/2 z-[1001] w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-2xl bg-white shadow-xl dark:bg-gray-900'>
<div
className='h-full p-6'
className='max-h-[85vh] overflow-y-auto p-6'
data-panel-content
onTouchMove={(e) => e.stopPropagation()}
style={{ touchAction: 'auto' }}
>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h3>
<div className='mb-6 flex items-start justify-between gap-4'>
<div>
<div className='mb-2 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-300'>
<Bell className='h-5 w-5' />
</div>
<h3 className='text-xl font-bold text-gray-900 dark:text-gray-100'>
</h3>
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>
</p>
</div>
<button
onClick={onClose}
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
aria-label='Close'
className='flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:hover:bg-gray-800'
aria-label='关闭通知设置'
>
<X className='w-full h-full' />
<X className='h-5 w-5' />
</button>
</div>
{emailSettingsLoading ? (
<div className='space-y-4'>
<div className='animate-pulse'>
<div className='h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2'></div>
<div className='h-10 bg-gray-200 dark:bg-gray-700 rounded'></div>
<div className='space-y-4' aria-live='polite'>
<div className='animate-pulse rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800'>
<div className='mb-3 h-5 w-28 rounded bg-gray-200 dark:bg-gray-700' />
<div className='h-10 rounded bg-gray-200 dark:bg-gray-700' />
</div>
<div className='animate-pulse'>
<div className='h-20 bg-gray-200 dark:bg-gray-700 rounded'></div>
<div className='animate-pulse rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800'>
<div className='mb-3 h-5 w-32 rounded bg-gray-200 dark:bg-gray-700' />
<div className='h-16 rounded bg-gray-200 dark:bg-gray-700' />
</div>
<div className='animate-pulse'>
<div className='h-10 bg-gray-200 dark:bg-gray-700 rounded'></div>
</div>
<div className='text-center text-sm text-gray-500 dark:text-gray-400'>
<p className='text-center text-sm text-gray-500 dark:text-gray-400'>
...
</div>
</p>
</div>
) : (
<div className='space-y-4'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
<section className='rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800'>
<div className='mb-4 flex items-start gap-3'>
<div className='flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-sky-100 text-sky-600 dark:bg-sky-900/30 dark:text-sky-300'>
<Mail className='h-5 w-5' />
</div>
<div className='min-w-0 flex-1'>
<h4 className='text-base font-semibold text-gray-900 dark:text-gray-100'>
</h4>
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>
</p>
</div>
</div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
</label>
<input
@@ -92,52 +165,88 @@ export function EmailSettingsPanel({
onChange={(e) => onUserEmailChange(e.target.value)}
placeholder='输入您的邮箱地址'
disabled={emailSettingsSaving}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed'
className='mb-4 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm text-gray-900 transition-colors placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-900 dark:text-white'
/>
</div>
<div className='flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<button
onClick={() => onEmailNotificationsChange(!emailNotifications)}
disabled={emailSettingsSaving}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
emailNotifications ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
emailNotifications ? 'translate-x-6' : 'translate-x-1'
}`}
<div className='flex items-center justify-between gap-4 rounded-xl bg-white p-3 dark:bg-gray-900/70'>
<div>
<h5 className='text-sm font-medium text-gray-800 dark:text-gray-200'>
</h5>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
</div>
<Toggle
checked={emailNotifications}
disabled={emailSettingsSaving}
label='切换收藏更新邮件通知'
onChange={() => onEmailNotificationsChange(!emailNotifications)}
/>
</button>
</div>
</div>
</section>
<section className='rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800'>
<div className='mb-4 flex items-start gap-3'>
<div className='flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-indigo-100 text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-300'>
<MonitorSmartphone className='h-5 w-5' />
</div>
<div className='min-w-0 flex-1'>
<h4 className='text-base font-semibold text-gray-900 dark:text-gray-100'>
</h4>
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>
</p>
</div>
<Toggle
checked={pushNotifications}
disabled={pushDisabled}
busy={pushNotificationsBusy}
label='切换当前设备浏览器系统通知'
onChange={() => onPushNotificationsChange(!pushNotifications)}
/>
</div>
<div className='space-y-2 rounded-xl bg-white p-3 dark:bg-gray-900/70'>
<div className='flex items-center justify-between gap-3 text-sm'>
<span className='text-gray-600 dark:text-gray-400'></span>
<span className={`font-medium ${pushNotificationsSupported ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}`}>
{pushNotificationsSupported ? '可用' : '需支持或授权'}
</span>
</div>
{!pushNotificationsConfigured && (
<p className='text-xs text-amber-600 dark:text-amber-400' role='alert'>
Web Push
</p>
)}
{pushNotificationsConfigured && !pushNotificationsSupported && (
<p className='text-xs text-amber-600 dark:text-amber-400' role='alert'>
</p>
)}
</div>
</section>
<button
onClick={onSave}
disabled={emailSettingsSaving}
className='w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 dark:disabled:bg-blue-500 text-white text-sm font-medium rounded-lg transition-colors disabled:cursor-not-allowed flex items-center justify-center gap-2'
className='flex w-full items-center justify-center gap-2 rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:bg-blue-400 dark:focus:ring-offset-gray-900 dark:disabled:bg-blue-500'
>
{emailSettingsSaving ? (
<>
<div className='w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin'></div>
<span className='h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent' />
<span>...</span>
</>
) : (
'保存设置'
'保存通知设置'
)}
</button>
{statusMessage ? (
<p
className={`text-xs text-center ${
role={statusType === 'error' ? 'alert' : 'status'}
className={`text-center text-xs ${
statusType === 'success'
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
@@ -149,9 +258,10 @@ export function EmailSettingsPanel({
</div>
)}
<div className='mt-6 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg'>
<p className='text-xs text-blue-800 dark:text-blue-200'>
💡
<div className='mt-6 flex gap-2 rounded-xl border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20'>
<Info className='mt-0.5 h-4 w-4 shrink-0 text-blue-600 dark:text-blue-300' />
<p className='text-xs leading-5 text-blue-800 dark:text-blue-200'>
</p>
</div>
</div>
+4 -4
View File
@@ -1,6 +1,6 @@
'use client';
import { KeyRound, Mail, Monitor, X } from 'lucide-react';
import { Bell, KeyRound, Monitor, X } from 'lucide-react';
import { createPortal } from 'react-dom';
interface PersonalCenterPanelProps {
@@ -88,14 +88,14 @@ export function PersonalCenterPanel({
className='flex w-full items-center gap-3 rounded-2xl border border-gray-200 bg-gray-50 px-4 py-4 text-left transition-colors hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-750'
>
<div className='flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-300'>
<Mail className='w-6 h-6' />
<Bell className='w-6 h-6' />
</div>
<div>
<div className='text-base font-semibold text-gray-900 dark:text-gray-100'>
</div>
<div className='mt-1 text-sm text-gray-500 dark:text-gray-400'>
</div>
</div>
</button>
+207 -5
View File
@@ -213,9 +213,13 @@ export const UserMenu: React.FC = () => {
);
const [filesystemSavePath, setFilesystemSavePath] = useState<string>('');
// 邮件通知设置
// 通知设置
const [userEmail, setUserEmail] = useState('');
const [emailNotifications, setEmailNotifications] = useState(false);
const [pushNotifications, setPushNotifications] = useState(false);
const [pushNotificationsConfigured, setPushNotificationsConfigured] = useState(false);
const [pushNotificationsSupported, setPushNotificationsSupported] = useState(false);
const [pushNotificationsBusy, setPushNotificationsBusy] = useState(false);
const [emailSettingsLoading, setEmailSettingsLoading] = useState(false);
const [emailSettingsSaving, setEmailSettingsSaving] = useState(false);
const [emailSettingsMessage, setEmailSettingsMessage] = useState('');
@@ -838,7 +842,7 @@ export const UserMenu: React.FC = () => {
}
}, []);
// 加载邮件通知设置
// 加载通知设置
const loadEmailSettings = async () => {
setEmailSettingsLoading(true);
setEmailSettingsMessage('');
@@ -850,14 +854,207 @@ export const UserMenu: React.FC = () => {
setUserEmail(data.email || '');
setEmailNotifications(data.emailNotifications || false);
}
const pushResponse = await fetch('/api/notifications/push');
if (pushResponse.ok) {
const pushData = await pushResponse.json();
setPushNotificationsConfigured(Boolean(pushData.configured && pushData.publicKey));
setPushNotificationsSupported(
Boolean(
pushData.configured &&
pushData.publicKey &&
pushData.hasDeviceToken &&
typeof window !== 'undefined' &&
'Notification' in window &&
'serviceWorker' in navigator &&
'PushManager' in window
)
);
setPushNotifications(Boolean(pushData.pushNotifications));
}
} catch (error) {
console.error('加载邮件设置失败:', error);
console.error('加载通知设置失败:', error);
} finally {
setEmailSettingsLoading(false);
}
};
// 保存邮件通知设置
const urlBase64ToUint8Array = (base64String: string) => {
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 arrayBufferToBase64Url = (buffer: ArrayBuffer | null) => {
if (!buffer) return '';
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window
.btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
};
const isSubscriptionUsingPublicKey = (
subscription: PushSubscription,
publicKey: string
) => {
const subscriptionKey = arrayBufferToBase64Url(
subscription.options?.applicationServerKey || null
);
return subscriptionKey === publicKey;
};
const waitForServiceWorkerActivation = async (
registration: ServiceWorkerRegistration
) => {
let pendingWorker = registration.installing || registration.waiting;
if (!pendingWorker) {
await registration.update();
pendingWorker = registration.installing || registration.waiting;
}
// 没有新的 installing/waiting worker 时,说明当前 active registration 可直接使用。
if (!pendingWorker) {
if (registration.active) return registration;
throw new Error('Service Worker 注册失败,请刷新页面后重试');
}
const activatingWorker = pendingWorker;
if (activatingWorker.state === 'activated') return registration;
await new Promise<void>((resolve, reject) => {
const handleStateChange = () => {
if (activatingWorker.state === 'activated') {
activatingWorker.removeEventListener('statechange', handleStateChange);
resolve();
} else if (activatingWorker.state === 'redundant') {
activatingWorker.removeEventListener('statechange', handleStateChange);
reject(new Error('Service Worker 激活失败,请刷新页面后重试'));
}
};
activatingWorker.addEventListener('statechange', handleStateChange);
handleStateChange();
});
return registration;
};
const getReadyServiceWorkerRegistration = async () => {
if (!('serviceWorker' in navigator)) {
throw new Error('当前浏览器不支持 Service Worker');
}
// 开启系统通知时明确使用带 push 事件处理器的 Service Worker。
// 如果浏览器里已有旧 /sw.js 注册,重新注册同一 scope 的 /push-sw.js 会更新该注册;
// push-sw.js 内部会 skipWaiting + clients.claim,激活后再订阅,确保 Push 到达能展示通知。
const registration = await navigator.serviceWorker.register('/push-sw.js', {
scope: '/',
updateViaCache: 'none',
});
return waitForServiceWorkerActivation(registration);
};
const handlePushNotificationsChange = async (enabled: boolean) => {
if (!enabled) {
setPushNotificationsBusy(true);
try {
let endpoint: string | undefined;
const registration =
'serviceWorker' in navigator
? await navigator.serviceWorker.getRegistration()
: undefined;
const subscription = await registration?.pushManager.getSubscription();
endpoint = subscription?.endpoint;
await subscription?.unsubscribe();
await fetch('/api/notifications/push', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint }),
});
setPushNotifications(false);
} catch (error) {
console.error('关闭浏览器通知失败:', error);
setEmailSettingsMessage('关闭浏览器通知失败,请重试');
setEmailSettingsMessageType('error');
} finally {
setPushNotificationsBusy(false);
}
return;
}
setPushNotificationsBusy(true);
setEmailSettingsMessage('');
setEmailSettingsMessageType(null);
try {
const statusResponse = await fetch('/api/notifications/push');
const status = statusResponse.ok ? await statusResponse.json() : null;
if (!status?.configured || !status?.publicKey) {
throw new Error('管理员尚未配置 Web Push VAPID 密钥');
}
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
throw new Error('浏览器通知权限未授权');
}
const registration = await getReadyServiceWorkerRegistration();
let subscription = await registration.pushManager.getSubscription();
if (subscription && !isSubscriptionUsingPublicKey(subscription, status.publicKey)) {
await subscription.unsubscribe();
subscription = null;
}
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(status.publicKey),
});
}
const response = await fetch('/api/notifications/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: true,
subscription: subscription.toJSON(),
}),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || '保存浏览器通知订阅失败');
}
setPushNotifications(true);
setEmailSettingsMessage('浏览器系统通知已开启');
setEmailSettingsMessageType('success');
} catch (error) {
console.error('开启浏览器通知失败:', error);
setPushNotifications(false);
setEmailSettingsMessage(error instanceof Error ? error.message : '开启浏览器通知失败');
setEmailSettingsMessageType('error');
} finally {
setPushNotificationsBusy(false);
}
};
// 保存通知设置
const handleSaveEmailSettings = async () => {
setEmailSettingsSaving(true);
setEmailSettingsMessage('');
@@ -885,7 +1082,7 @@ export const UserMenu: React.FC = () => {
setEmailSettingsMessageType('error');
}
} catch (error) {
console.error('保存邮件设置失败:', error);
console.error('保存通知设置失败:', error);
setEmailSettingsMessage('保存失败,请重试');
setEmailSettingsMessageType('error');
} finally {
@@ -4866,6 +5063,11 @@ export const UserMenu: React.FC = () => {
onUserEmailChange={setUserEmail}
emailNotifications={emailNotifications}
onEmailNotificationsChange={setEmailNotifications}
pushNotifications={pushNotifications}
onPushNotificationsChange={handlePushNotificationsChange}
pushNotificationsSupported={pushNotificationsSupported}
pushNotificationsConfigured={pushNotificationsConfigured}
pushNotificationsBusy={pushNotificationsBusy}
emailSettingsLoading={emailSettingsLoading}
emailSettingsSaving={emailSettingsSaving}
onSave={handleSaveEmailSettings}