feat: 添加推送通知功能,支持Web Push订阅和发送

This commit is contained in:
2025-11-28 22:35:45 +08:00
parent a4bb8e984f
commit 35a948c515
13 changed files with 338 additions and 2 deletions
+30
View File
@@ -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 });
}
}
+12
View File
@@ -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 });
}
}
+9
View File
@@ -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 });
}
+143
View File
@@ -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>
);
}
+2
View File
@@ -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"
+14
View File
@@ -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;
}