From 6a65d956a83b1a16308e8c9a63373a4e24cc3e5d Mon Sep 17 00:00:00 2001 From: feie9456 Date: Fri, 17 Apr 2026 14:26:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=AE=BE=E7=BD=AE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/push/test/route.ts | 31 +++++++ app/devices/page.tsx | 2 - app/messages/page.tsx | 2 - app/page.tsx | 2 - app/settings/page.tsx | 45 ++++++++++ components/panel-shell.tsx | 10 ++- components/push-test-panel.tsx | 120 ++++++++++++++++++++++++++ lib/caregiver-panel.ts | 16 ++++ lib/push.ts | 151 ++++++++++++++++++++++++++------- 9 files changed, 340 insertions(+), 39 deletions(-) create mode 100644 app/api/push/test/route.ts create mode 100644 app/settings/page.tsx create mode 100644 components/push-test-panel.tsx diff --git a/app/api/push/test/route.ts b/app/api/push/test/route.ts new file mode 100644 index 0000000..d795bc9 --- /dev/null +++ b/app/api/push/test/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; + +import { sendCaregiverTestPush } from "@/lib/push"; +import { getCaregiverDisplayName, getCaregiverSession } from "@/lib/session"; + +export async function POST() { + const caregiver = await getCaregiverSession(); + + if (!caregiver) { + return NextResponse.json({ error: "请先登录。" }, { status: 401 }); + } + + try { + const result = await sendCaregiverTestPush({ + caregiverId: caregiver.id, + caregiverLabel: getCaregiverDisplayName(caregiver), + }); + + return NextResponse.json({ + ok: true, + ...result, + }); + } catch (error) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : "测试推送发送失败。", + }, + { status: 400 }, + ); + } +} \ No newline at end of file diff --git a/app/devices/page.tsx b/app/devices/page.tsx index fdccf07..1ea2089 100644 --- a/app/devices/page.tsx +++ b/app/devices/page.tsx @@ -2,7 +2,6 @@ import Link from "next/link"; import { BindDeviceForm } from "@/components/dashboard-actions"; import { PanelShell } from "@/components/panel-shell"; -import { PwaControls } from "@/components/pwa-controls"; import { getCaregiverDevices } from "@/lib/caregiver-panel"; import { formatDateTime, getDeviceName, truncateText } from "@/lib/panel-format"; import { requireCaregiverSession } from "@/lib/page-auth"; @@ -20,7 +19,6 @@ export default async function DevicesPage() { title="我的设备" description="管理已连接的长辈设备,点击进入设备详情查看留言和使用动态。" caregiverLabel={getCaregiverDisplayName(caregiver)} - actions={} >
diff --git a/app/messages/page.tsx b/app/messages/page.tsx index e32579a..77a8747 100644 --- a/app/messages/page.tsx +++ b/app/messages/page.tsx @@ -1,7 +1,6 @@ import Link from "next/link"; import { PanelShell } from "@/components/panel-shell"; -import { PwaControls } from "@/components/pwa-controls"; import { getCaregiverMessages } from "@/lib/caregiver-panel"; import { formatDateTime, @@ -35,7 +34,6 @@ export default async function MessagesPage() { title="留言板" description="长辈捆来的话和您发出的问候,都在这里。" caregiverLabel={getCaregiverDisplayName(caregiver)} - actions={} >
diff --git a/app/page.tsx b/app/page.tsx index 52001b4..d38dae7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,7 +2,6 @@ import Link from "next/link"; import { MessageCircle, Smartphone, Activity, ScanLine } from "lucide-react"; import { PanelShell } from "@/components/panel-shell"; -import { PwaControls } from "@/components/pwa-controls"; import { getCaregiverOverview } from "@/lib/caregiver-panel"; import { formatDateTime, @@ -40,7 +39,6 @@ export default async function Home() { title="今日概览" description="随时了解长辈的近况,留言和动态一目了然。" caregiverLabel={getCaregiverDisplayName(dashboard.caregiver)} - actions={} >
diff --git a/app/settings/page.tsx b/app/settings/page.tsx new file mode 100644 index 0000000..0bd09fa --- /dev/null +++ b/app/settings/page.tsx @@ -0,0 +1,45 @@ +import { PanelShell } from "@/components/panel-shell"; +import { PwaControls } from "@/components/pwa-controls"; +import { PushTestPanel } from "@/components/push-test-panel"; +import { getCaregiverSettingsOverview } from "@/lib/caregiver-panel"; +import { requireCaregiverSession } from "@/lib/page-auth"; +import { getCaregiverDisplayName } from "@/lib/session"; + +export const dynamic = "force-dynamic"; + +export default async function SettingsPage() { + const caregiver = await requireCaregiverSession("/settings"); + const overview = await getCaregiverSettingsOverview(caregiver.id); + + return ( + +
+
+

已连接设备

+

+ {overview.deviceCount} +

+
+
+

已开启提醒的设备

+

+ {overview.pushSubscriptionCount} +

+
+
+ +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/components/panel-shell.tsx b/components/panel-shell.tsx index dbabd77..979a99a 100644 --- a/components/panel-shell.tsx +++ b/components/panel-shell.tsx @@ -1,5 +1,12 @@ import Link from "next/link"; -import { Home, MessageCircle, Smartphone, Activity, ScanLine } from "lucide-react"; +import { + Home, + MessageCircle, + Smartphone, + Activity, + ScanLine, + Settings, +} from "lucide-react"; import type { ReactNode } from "react"; const navigationItems = [ @@ -8,6 +15,7 @@ const navigationItems = [ { href: "/devices", label: "设备", icon: Smartphone }, { href: "/activity", label: "动态", icon: Activity }, { href: "/scan", label: "扫码", icon: ScanLine }, + { href: "/settings", label: "设置", icon: Settings }, ]; function isActivePath(currentPath: string, href: string) { diff --git a/components/push-test-panel.tsx b/components/push-test-panel.tsx new file mode 100644 index 0000000..fa6849a --- /dev/null +++ b/components/push-test-panel.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useState } from "react"; + +type PushTestPanelProps = { + initialSubscriptionCount: number; + deviceCount: number; +}; + +export function PushTestPanel({ + initialSubscriptionCount, + deviceCount, +}: PushTestPanelProps) { + const [submitting, setSubmitting] = useState(false); + const [subscriptionCount, setSubscriptionCount] = useState( + initialSubscriptionCount, + ); + const [notice, setNotice] = useState(null); + + async function handleTestPush() { + setSubmitting(true); + setNotice(null); + + try { + const response = await fetch("/api/push/test", { + method: "POST", + }); + + const payload = (await response.json().catch(() => null)) as + | { + error?: string; + targetedCount?: number; + sentCount?: number; + removedCount?: number; + failedCount?: number; + } + | null; + + if (!response.ok) { + throw new Error(payload?.error || "测试推送发送失败。",); + } + + const targetedCount = payload?.targetedCount || 0; + const removedCount = payload?.removedCount || 0; + const sentCount = payload?.sentCount || 0; + const failedCount = payload?.failedCount || 0; + + setSubscriptionCount(Math.max(0, targetedCount - removedCount)); + + if (targetedCount === 0) { + setNotice("当前账号还没有任何开启消息提醒的设备,先在要接收通知的设备上打开消息提醒。\n"); + return; + } + + const fragments = [`已向 ${targetedCount} 台设备发出测试通知。`]; + + if (sentCount > 0) { + fragments.push(`成功送出 ${sentCount} 条。`); + } + + if (removedCount > 0) { + fragments.push(`清理了 ${removedCount} 个失效订阅。`); + } + + if (failedCount > 0) { + fragments.push(`${failedCount} 台设备本次发送失败。`); + } + + setNotice(`${fragments.join(" ")}\n`); + } catch (error) { + setNotice( + error instanceof Error ? error.message : "测试推送发送失败。", + ); + } finally { + setSubmitting(false); + } + } + + return ( +
+

+ 推送测试 +

+

+ 给当前账号的所有设备发一条测试通知 +

+

+ 用来确认这位家属账号下已经开启提醒的设备,是否都能正常收到推送。 +

+ +
+ + 已连接设备 {deviceCount} + + + 已开启提醒的设备 {subscriptionCount} + +
+ +
+ +
+ + {notice ? ( +

+ {notice} +

+ ) : null} +
+ ); +} \ No newline at end of file diff --git a/lib/caregiver-panel.ts b/lib/caregiver-panel.ts index 5cab80f..d21aef0 100644 --- a/lib/caregiver-panel.ts +++ b/lib/caregiver-panel.ts @@ -283,4 +283,20 @@ export async function getCaregiverActivity(caregiverId: string) { conversationTurns, toolCalls, }; +} + +export async function getCaregiverSettingsOverview(caregiverId: string) { + const [deviceCount, pushSubscriptionCount] = await prisma.$transaction([ + prisma.deviceBinding.count({ + where: { caregiverId }, + }), + prisma.pushSubscription.count({ + where: { caregiverId }, + }), + ]); + + return { + deviceCount, + pushSubscriptionCount, + }; } \ No newline at end of file diff --git a/lib/push.ts b/lib/push.ts index 823f0dd..c34bfd9 100644 --- a/lib/push.ts +++ b/lib/push.ts @@ -13,6 +13,20 @@ type SerializablePushSubscription = { }; }; +type StoredPushSubscription = { + endpoint: string; + expirationTime: Date | null; + p256dh: string; + auth: string; +}; + +type PushDeliverySummary = { + targetedCount: number; + sentCount: number; + removedCount: number; + failedCount: number; +}; + const APP_BASE_URL = ( process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net" ).replace(/\/+$/, ""); @@ -119,6 +133,70 @@ export async function removePushSubscription(input: { }); } +async function deliverPushToSubscriptions(input: { + subscriptions: StoredPushSubscription[]; + payload: string; + topic: string; +}) { + if (input.subscriptions.length === 0) { + return { + targetedCount: 0, + sentCount: 0, + removedCount: 0, + failedCount: 0, + } satisfies PushDeliverySummary; + } + + ensureVapidDetails(); + + const results = await Promise.all( + input.subscriptions.map(async (subscription) => { + try { + await webpush.sendNotification( + { + endpoint: subscription.endpoint, + expirationTime: subscription.expirationTime?.getTime() || null, + keys: { + p256dh: subscription.p256dh, + auth: subscription.auth, + }, + }, + input.payload, + { + TTL: 60 * 30, + urgency: "high", + topic: input.topic, + }, + ); + + return "sent" as const; + } catch (error) { + const statusCode = + error && typeof error === "object" && "statusCode" in error + ? Number((error as { statusCode?: number }).statusCode) + : 0; + + if (statusCode === 404 || statusCode === 410) { + await prisma.pushSubscription.deleteMany({ + where: { endpoint: subscription.endpoint }, + }); + + return "removed" as const; + } + + return "failed" as const; + } + }), + ); + + return { + targetedCount: input.subscriptions.length, + sentCount: results.filter((result) => result === "sent").length, + removedCount: results.filter((result) => result === "removed").length, + failedCount: results.filter((result) => result === "failed").length, + } satisfies PushDeliverySummary; +} + export async function sendIncomingMessagePush(publicId: number) { if (!hasPushConfiguration()) { return; @@ -172,37 +250,46 @@ export async function sendIncomingMessagePush(publicId: number) { tag: `message-${message.publicId}`, }); - await Promise.allSettled( - subscriptions.map(async (subscription) => { - try { - await webpush.sendNotification( - { - endpoint: subscription.endpoint, - expirationTime: subscription.expirationTime?.getTime() || null, - keys: { - p256dh: subscription.p256dh, - auth: subscription.auth, - }, - }, - payload, - { - TTL: 60 * 30, - urgency: "high", - topic: `msg-${message.publicId}`, - }, - ); - } catch (error) { - const statusCode = - error && typeof error === "object" && "statusCode" in error - ? Number((error as { statusCode?: number }).statusCode) - : 0; + await deliverPushToSubscriptions({ + subscriptions, + payload, + topic: `msg-${message.publicId}`, + }); +} - if (statusCode === 404 || statusCode === 410) { - await prisma.pushSubscription.deleteMany({ - where: { endpoint: subscription.endpoint }, - }); - } - } - }), - ); +export async function sendCaregiverTestPush(input: { + caregiverId: string; + caregiverLabel: string; +}) { + if (!hasPushConfiguration()) { + throw new Error("当前环境还没有配置推送服务。", + ); + } + + const subscriptions = await prisma.pushSubscription.findMany({ + where: { + caregiverId: input.caregiverId, + }, + select: { + endpoint: true, + expirationTime: true, + p256dh: true, + auth: true, + }, + }); + + const payload = JSON.stringify({ + title: "安智伴推送测试", + body: `${input.caregiverLabel} 账号下的通知链路工作正常。`, + url: absoluteUrl("/settings"), + icon: absoluteUrl("/pwa/icon-192x192.png"), + badge: absoluteUrl("/pwa/badge-96x96.png"), + tag: `push-test-${input.caregiverId}`, + }); + + return deliverPushToSubscriptions({ + subscriptions, + payload, + topic: `test-${input.caregiverId.slice(0, 20)}`, + }); } \ No newline at end of file