diff --git a/.env.example b/.env.example index 1904db7..eb2fd1f 100644 --- a/.env.example +++ b/.env.example @@ -19,4 +19,10 @@ MINIO_BUCKET_NAME=winupdate # QQ Bot URL QQ_BOT_URL=http://localhost:30000/send_private_msg # QQ Bot Target ID -QQ_BOT_TARGET_ID=1234567890 \ No newline at end of file +QQ_BOT_TARGET_ID=1234567890 + +# Web Push VAPID Keys +# Generate with: bunx web-push generate-vapid-keys +NEXT_PUBLIC_VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT=mailto:admin@example.com \ No newline at end of file diff --git a/app/api/push/subscribe/route.ts b/app/api/push/subscribe/route.ts new file mode 100644 index 0000000..2745963 --- /dev/null +++ b/app/api/push/subscribe/route.ts @@ -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 }); + } +} diff --git a/app/api/push/test/route.ts b/app/api/push/test/route.ts new file mode 100644 index 0000000..281367d --- /dev/null +++ b/app/api/push/test/route.ts @@ -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 }); + } +} diff --git a/app/api/push/vapid-public-key/route.ts b/app/api/push/vapid-public-key/route.ts new file mode 100644 index 0000000..5914558 --- /dev/null +++ b/app/api/push/vapid-public-key/route.ts @@ -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 }); +} diff --git a/app/components/PushSubscription.tsx b/app/components/PushSubscription.tsx new file mode 100644 index 0000000..a5045c4 --- /dev/null +++ b/app/components/PushSubscription.tsx @@ -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(null); + const [registration, setRegistration] = useState(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 ( + + ); + } + + return ( +
+ + + {isMenuOpen && ( + <> +
setIsMenuOpen(false)} + /> +
+
+ + +
+
+ + )} +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 62c9231..57bf582 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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() {
+