feat: 添加推送通知功能,支持Web Push订阅和发送
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const subscription = await request.json();
|
||||
|
||||
if (!subscription || !subscription.endpoint || !subscription.keys) {
|
||||
return NextResponse.json({ error: 'Invalid subscription object' }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.pushSubscription.upsert({
|
||||
where: { endpoint: subscription.endpoint },
|
||||
update: {
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth,
|
||||
},
|
||||
create: {
|
||||
endpoint: subscription.endpoint,
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error saving subscription:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { push } from '@/lib/push';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
push('这是一条测试推送消息 / This is a test push notification');
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Test push failed:', error);
|
||||
return NextResponse.json({ error: 'Test push failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
const publicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
|
||||
if (!publicKey) {
|
||||
return NextResponse.json({ error: 'VAPID public key not found' }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ publicKey });
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Bell, BellOff, Send } from 'lucide-react';
|
||||
import { urlBase64ToUint8Array } from '../utils/webPush';
|
||||
|
||||
export default function PushSubscription() {
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
const [subscription, setSubscription] = useState<PushSubscription | null>(null);
|
||||
const [registration, setRegistration] = useState<ServiceWorkerRegistration | null>(null);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window) {
|
||||
// Register SW if not already (though it might be done in layout or separate file)
|
||||
navigator.serviceWorker.ready.then(reg => {
|
||||
setRegistration(reg);
|
||||
reg.pushManager.getSubscription().then(sub => {
|
||||
if (sub) {
|
||||
setSubscription(sub);
|
||||
setIsSubscribed(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const subscribeUser = async () => {
|
||||
if (!registration) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/push/vapid-public-key');
|
||||
const { publicKey } = await response.json();
|
||||
const convertedVapidKey = urlBase64ToUint8Array(publicKey);
|
||||
|
||||
const sub = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: convertedVapidKey
|
||||
});
|
||||
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(sub),
|
||||
});
|
||||
|
||||
setSubscription(sub);
|
||||
setIsSubscribed(true);
|
||||
alert('订阅成功!');
|
||||
} catch (error) {
|
||||
console.error('Failed to subscribe the user: ', error);
|
||||
alert('订阅失败,请检查控制台日志。');
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribeUser = async () => {
|
||||
if (!subscription) return;
|
||||
try {
|
||||
await subscription.unsubscribe();
|
||||
// Optionally notify backend to remove subscription
|
||||
setSubscription(null);
|
||||
setIsSubscribed(false);
|
||||
alert('已取消订阅。');
|
||||
} catch (error) {
|
||||
console.error('Error unsubscribing', error);
|
||||
}
|
||||
};
|
||||
|
||||
const sendTestPush = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/push/test', { method: 'POST' });
|
||||
if (!res.ok) throw new Error('Failed to send test push');
|
||||
alert('测试推送已发送,请检查通知!');
|
||||
} catch (error) {
|
||||
console.error('Failed to send test push', error);
|
||||
alert('发送测试推送失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (!registration) {
|
||||
return null; // Service Worker not ready or not supported
|
||||
}
|
||||
|
||||
if (!isSubscribed) {
|
||||
return (
|
||||
<button
|
||||
onClick={subscribeUser}
|
||||
className="inline-flex items-center justify-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 dark:text-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
title="开启推送通知"
|
||||
>
|
||||
<BellOff className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">订阅通知</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className="inline-flex items-center justify-center px-3 py-2 border border-transparent text-sm font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 text-blue-600 bg-blue-50 hover:bg-blue-100 dark:bg-blue-900/20 dark:text-blue-400 dark:hover:bg-blue-900/40"
|
||||
>
|
||||
<Bell className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">已订阅</span>
|
||||
</button>
|
||||
|
||||
{isMenuOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 top-full w-40 pt-1 z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
sendTestPush();
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center"
|
||||
>
|
||||
<Send className="h-3 w-3 mr-2" />
|
||||
发送测试
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
unsubscribeUser();
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center"
|
||||
>
|
||||
<BellOff className="h-3 w-3 mr-2" />
|
||||
取消订阅
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
import { format, differenceInMinutes } from 'date-fns';
|
||||
import PushSubscription from './components/PushSubscription';
|
||||
|
||||
interface Host {
|
||||
hostname: string;
|
||||
@@ -87,6 +88,7 @@ export default function Home() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<PushSubscription />
|
||||
<button
|
||||
onClick={() => router.push('/tasks')}
|
||||
className="inline-flex items-center justify-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export function 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;
|
||||
}
|
||||
Reference in New Issue
Block a user