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