diff --git a/app/activity/page.tsx b/app/activity/page.tsx
new file mode 100644
index 0000000..49ddf43
--- /dev/null
+++ b/app/activity/page.tsx
@@ -0,0 +1,113 @@
+import { PanelShell } from "@/components/panel-shell";
+import { getCaregiverActivity } from "@/lib/caregiver-panel";
+import {
+ formatDateTime,
+ getConversationRoleLabel,
+ getDeviceName,
+ getUsageLabel,
+ truncateText,
+} from "@/lib/panel-format";
+import { requireCaregiverSession } from "@/lib/page-auth";
+
+export const dynamic = "force-dynamic";
+
+export default async function ActivityPage() {
+ const caregiver = await requireCaregiverSession("/activity");
+ const { usageEvents, conversationTurns, toolCalls } = await getCaregiverActivity(
+ caregiver.id,
+ );
+
+ return (
+
+ {usageEvents.length === 0 && conversationTurns.length === 0 && toolCalls.length === 0 ? (
+
+
+ 还没有活动记录
+
+
+ 等老人端开始对话、切换镜头或触发桥接工具后,这里会自动累积活动时间线。
+
+
+ ) : (
+
+
+
+ 使用事件
+
+
+ 老人端操作轨迹
+
+
+ {usageEvents.map((event) => (
+
+
+ {getDeviceName(event.elderDevice.displayName)}
+ {formatDateTime(event.createdAt)}
+
+
+ {getUsageLabel(event.eventType)}
+
+
+ ))}
+
+
+
+
+
+ 对话片段
+
+
+ 最近说了什么
+
+
+ {conversationTurns.map((turn) => (
+
+
+ {getConversationRoleLabel(turn.role)}
+ {formatDateTime(turn.createdAt)}
+
+
+ {getDeviceName(turn.elderDevice.displayName)}
+
+
+ {truncateText(turn.content, 132)}
+
+
+ ))}
+
+
+
+
+
+ 工具调用
+
+
+ function calling 执行记录
+
+
+ {toolCalls.map((log) => (
+
+
+ {getDeviceName(log.elderDevice.displayName)}
+ {formatDateTime(log.createdAt)}
+
+
+ {log.toolName}
+
+
+ {truncateText(log.outputText || log.argumentsJson, 136)}
+
+
+ ))}
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/app/api/family/messages/route.ts b/app/api/family/messages/route.ts
index 6fc54ea..d8ef406 100644
--- a/app/api/family/messages/route.ts
+++ b/app/api/family/messages/route.ts
@@ -35,7 +35,11 @@ export async function POST(request: Request) {
importance: typeof body.importance === "string" ? body.importance : undefined,
});
- return NextResponse.json({ ok: true, messageId: message.id });
+ return NextResponse.json({
+ ok: true,
+ messageId: message.id,
+ publicId: message.publicId,
+ });
} catch (error) {
return NextResponse.json(
{
diff --git a/app/api/push/subscribe/route.ts b/app/api/push/subscribe/route.ts
new file mode 100644
index 0000000..f3d46e4
--- /dev/null
+++ b/app/api/push/subscribe/route.ts
@@ -0,0 +1,69 @@
+import { NextResponse } from "next/server";
+
+import { getOrCreateCaregiverSession } from "@/lib/session";
+import { savePushSubscription } from "@/lib/push";
+
+export async function POST(request: Request) {
+ const body = (await request.json().catch(() => null)) as
+ | {
+ subscription?: {
+ endpoint?: string;
+ expirationTime?: number | null;
+ keys?: {
+ p256dh?: string;
+ auth?: string;
+ };
+ };
+ }
+ | null;
+
+ const rawSubscription = body?.subscription;
+
+ if (
+ !rawSubscription ||
+ typeof rawSubscription.endpoint !== "string" ||
+ !rawSubscription.endpoint.trim()
+ ) {
+ return NextResponse.json({ error: "subscription 是必填项。" }, { status: 400 });
+ }
+
+ const subscription = {
+ endpoint: rawSubscription.endpoint,
+ expirationTime:
+ typeof rawSubscription.expirationTime === "number" ||
+ rawSubscription.expirationTime === null
+ ? rawSubscription.expirationTime
+ : undefined,
+ keys:
+ rawSubscription.keys && typeof rawSubscription.keys === "object"
+ ? {
+ p256dh:
+ typeof rawSubscription.keys.p256dh === "string"
+ ? rawSubscription.keys.p256dh
+ : undefined,
+ auth:
+ typeof rawSubscription.keys.auth === "string"
+ ? rawSubscription.keys.auth
+ : undefined,
+ }
+ : undefined,
+ };
+
+ try {
+ const caregiver = await getOrCreateCaregiverSession();
+ await savePushSubscription({
+ caregiverId: caregiver.id,
+ subscription,
+ userAgent: request.headers.get("user-agent"),
+ });
+
+ return NextResponse.json({ ok: true });
+ } catch (error) {
+ return NextResponse.json(
+ {
+ error: error instanceof Error ? error.message : "保存推送订阅失败。",
+ },
+ { status: 400 },
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/api/push/unsubscribe/route.ts b/app/api/push/unsubscribe/route.ts
new file mode 100644
index 0000000..03b353f
--- /dev/null
+++ b/app/api/push/unsubscribe/route.ts
@@ -0,0 +1,29 @@
+import { NextResponse } from "next/server";
+
+import { removePushSubscription } from "@/lib/push";
+import { getCaregiverSession } from "@/lib/session";
+
+export async function POST(request: Request) {
+ const body = (await request.json().catch(() => null)) as
+ | {
+ endpoint?: string;
+ }
+ | null;
+
+ if (!body?.endpoint || typeof body.endpoint !== "string") {
+ return NextResponse.json({ error: "endpoint 是必填项。" }, { status: 400 });
+ }
+
+ const caregiver = await getCaregiverSession();
+
+ if (!caregiver) {
+ return NextResponse.json({ ok: true });
+ }
+
+ await removePushSubscription({
+ caregiverId: caregiver.id,
+ endpoint: body.endpoint,
+ });
+
+ return NextResponse.json({ ok: true });
+}
\ No newline at end of file
diff --git a/app/bind/page.tsx b/app/bind/page.tsx
index b1a8297..d234464 100644
--- a/app/bind/page.tsx
+++ b/app/bind/page.tsx
@@ -43,10 +43,10 @@ export default async function BindPage({ searchParams }: BindPageProps) {
去扫码
- 回到控制台
+ 回到设备页
@@ -88,16 +88,16 @@ export default async function BindPage({ searchParams }: BindPageProps) {
- 回到控制台
+ 打开这台设备页
- 继续绑定其他老人
+ 查看全部设备
diff --git a/app/devices/[deviceUuid]/page.tsx b/app/devices/[deviceUuid]/page.tsx
new file mode 100644
index 0000000..d6a228f
--- /dev/null
+++ b/app/devices/[deviceUuid]/page.tsx
@@ -0,0 +1,220 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+
+import { SendMessageForm } from "@/components/dashboard-actions";
+import { PanelShell } from "@/components/panel-shell";
+import { getCaregiverDeviceDetail } from "@/lib/caregiver-panel";
+import {
+ formatDateTime,
+ getConversationRoleLabel,
+ getDeviceName,
+ getImportanceLabel,
+ getMessageHref,
+ getUsageLabel,
+ truncateText,
+} from "@/lib/panel-format";
+import { requireCaregiverSession } from "@/lib/page-auth";
+
+export const dynamic = "force-dynamic";
+
+type DeviceDetailPageProps = {
+ params: Promise<{ deviceUuid: string }>;
+};
+
+export default async function DeviceDetailPage({ params }: DeviceDetailPageProps) {
+ const { deviceUuid } = await params;
+ const caregiver = await requireCaregiverSession(`/devices/${deviceUuid}`);
+ const binding = await getCaregiverDeviceDetail(caregiver.id, deviceUuid);
+
+ if (!binding) {
+ notFound();
+ }
+
+ const elderMessages = binding.elderDevice.messages.filter(
+ (message) => message.direction === "ELDER_TO_FAMILY",
+ );
+ const familyMessages = binding.elderDevice.messages.filter(
+ (message) => message.direction === "FAMILY_TO_ELDER",
+ );
+
+ return (
+
+
+
+
老人新留言
+
+ {binding.elderUnreadCount}
+
+
+
+
待老人查看
+
+ {binding.familyUnreadCount}
+
+
+
+
最近在线
+
+ {formatDateTime(binding.elderDevice.lastSeenAt)}
+
+
+
+
绑定链接
+
+ {truncateText(binding.bindUrl, 52)}
+
+
+
+
+
+
+
+
+
+
+
+
+ 老人给家里的话
+
+
+ 最近通过桥接工具送出的内容
+
+
+
+ 去留言中心
+
+
+
+
+ {elderMessages.length === 0 ? (
+
+ 这台设备暂时还没有新的老人留言。
+
+ ) : (
+ elderMessages.map((message) => (
+
+
+
+ #{message.publicId}
+
+ {getImportanceLabel(message.importance)}
+ {formatDateTime(message.createdAt)}
+
+
+ {truncateText(message.content, 112)}
+
+
+ ))
+ )}
+
+
+
+
+
+ 最近活动
+
+
+ 这台设备刚刚发生了什么
+
+
+
+ {binding.elderDevice.usageEvents.length === 0 ? (
+
+ 暂时还没有同步到新的使用事件。
+
+ ) : (
+ binding.elderDevice.usageEvents.map((event) => (
+
+
+ {getUsageLabel(event.eventType)}
+ {formatDateTime(event.createdAt)}
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+ 家属给老人的问候
+
+
+ 最近写给这位老人的内容
+
+
+
+ {familyMessages.length === 0 ? (
+
+ 还没有发给这位老人的问候。
+
+ ) : (
+ familyMessages.map((message) => (
+
+
+
+ #{message.publicId}
+
+ {getImportanceLabel(message.importance)}
+ {formatDateTime(message.createdAt)}
+
+
+ {truncateText(message.content, 112)}
+
+
+ ))
+ )}
+
+
+
+
+
+ 最近对话片段
+
+
+ 快速回看陪伴内容
+
+
+
+ {binding.elderDevice.conversationTurns.length === 0 ? (
+
+ 这台设备最近还没有同步到对话片段。
+
+ ) : (
+ binding.elderDevice.conversationTurns.map((turn) => (
+
+
+ {getConversationRoleLabel(turn.role)}
+ {formatDateTime(turn.createdAt)}
+
+
+ {truncateText(turn.content, 124)}
+
+
+ ))
+ )}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/devices/page.tsx b/app/devices/page.tsx
new file mode 100644
index 0000000..3471e8f
--- /dev/null
+++ b/app/devices/page.tsx
@@ -0,0 +1,146 @@
+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";
+
+export const dynamic = "force-dynamic";
+
+export default async function DevicesPage() {
+ const caregiver = await requireCaregiverSession("/devices");
+ const devices = await getCaregiverDevices(caregiver.id);
+
+ return (
+ }
+ >
+
+
+
+
+
+ 设备入口说明
+
+
+ 一台设备一个详情页
+
+
+
1. 先通过扫码或粘贴设备码建立连接。
+
2. 进入设备详情页给这位老人写留言,不再在首页里混排。
+
3. 每台设备都有自己的最近留言、对话片段和工具调用摘要。
+
+
+
+
+ 打开扫码页
+
+
+ 去留言中心
+
+
+
+
+
+ {devices.length === 0 ? (
+
+
+ 还没有绑定设备
+
+
+ 等待绑定成功后,每台设备都会在这里生成自己的专属入口,后续写留言和查看状态都从设备详情页进入。
+
+
+ ) : (
+
+ {devices.map((binding) => {
+ const latestMessage = binding.elderDevice.messages[0];
+
+ return (
+
+
+ 设备详情
+
+
+ {getDeviceName(binding.elderDevice.displayName)}
+
+
+
+
+ 最近在线:{formatDateTime(binding.elderDevice.lastSeenAt)}
+
+
+ 老人新留言 {binding.elderUnreadCount}
+
+
+ 待老人查看 {binding.familyUnreadCount}
+
+
+
+
+
+
留言总数
+
+ {binding.elderDevice._count.messages}
+
+
+
+
对话条数
+
+ {binding.elderDevice._count.conversationTurns}
+
+
+
+
工具调用
+
+ {binding.elderDevice._count.toolCalls}
+
+
+
+
+
+
最近一条动态
+
+ {latestMessage
+ ? truncateText(latestMessage.content, 96)
+ : "这台设备还没有同步到新的留言或问候。"}
+
+
+
+
+
+ 打开设备页
+
+
+ 去留言中心
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/app/layout.tsx b/app/layout.tsx
index a6a626c..3119be1 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,4 +1,4 @@
-import type { Metadata } from "next";
+import type { Metadata, Viewport } from "next";
import { Noto_Sans_SC, Noto_Serif_SC } from "next/font/google";
import "./globals.css";
@@ -18,6 +18,38 @@ export const metadata: Metadata = {
metadataBase: new URL("https://digital-human.xn--876a.net"),
title: "家人连线台",
description: "给老人设备做绑定、查看留言、追踪陪伴记录的家属端。",
+ manifest: "/manifest.webmanifest",
+ icons: {
+ icon: [
+ {
+ url: "/pwa/icon-192x192.png",
+ sizes: "192x192",
+ type: "image/png",
+ },
+ {
+ url: "/pwa/icon-512x512.png",
+ sizes: "512x512",
+ type: "image/png",
+ },
+ ],
+ apple: [
+ {
+ url: "/pwa/apple-touch-icon.png",
+ sizes: "180x180",
+ type: "image/png",
+ },
+ ],
+ shortcut: "/pwa/icon-192x192.png",
+ },
+ appleWebApp: {
+ capable: true,
+ statusBarStyle: "default",
+ title: "家人连线台",
+ },
+};
+
+export const viewport: Viewport = {
+ themeColor: "#35548d",
};
export default function RootLayout({
diff --git a/app/manifest.ts b/app/manifest.ts
new file mode 100644
index 0000000..b8f181b
--- /dev/null
+++ b/app/manifest.ts
@@ -0,0 +1,33 @@
+import type { MetadataRoute } from "next";
+
+export default function manifest(): MetadataRoute.Manifest {
+ return {
+ name: "家人连线台",
+ short_name: "家人连线台",
+ description: "给老人设备做绑定、查看留言、追踪陪伴记录的家属端。",
+ start_url: "/",
+ display: "standalone",
+ background_color: "#f6eee1",
+ theme_color: "#35548d",
+ lang: "zh-CN",
+ categories: ["utilities", "lifestyle", "health"],
+ icons: [
+ {
+ src: "/pwa/icon-192x192.png",
+ sizes: "192x192",
+ type: "image/png",
+ },
+ {
+ src: "/pwa/icon-512x512.png",
+ sizes: "512x512",
+ type: "image/png",
+ },
+ {
+ src: "/pwa/icon-maskable-512x512.png",
+ sizes: "512x512",
+ type: "image/png",
+ purpose: "maskable",
+ },
+ ],
+ };
+}
\ No newline at end of file
diff --git a/app/messages/[messageId]/page.tsx b/app/messages/[messageId]/page.tsx
new file mode 100644
index 0000000..d2e3f69
--- /dev/null
+++ b/app/messages/[messageId]/page.tsx
@@ -0,0 +1,120 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+
+import { PanelShell } from "@/components/panel-shell";
+import { getCaregiverMessageDetail } from "@/lib/caregiver-panel";
+import {
+ formatDateTime,
+ getDeviceName,
+ getImportanceLabel,
+ getMessageDirectionLabel,
+ getMessageStatusLabel,
+} from "@/lib/panel-format";
+import { requireCaregiverSession } from "@/lib/page-auth";
+
+export const dynamic = "force-dynamic";
+
+type MessageDetailPageProps = {
+ params: Promise<{ messageId: string }>;
+};
+
+export default async function MessageDetailPage({ params }: MessageDetailPageProps) {
+ const { messageId } = await params;
+ const publicId = Number.parseInt(messageId, 10);
+
+ if (!Number.isFinite(publicId) || publicId <= 0) {
+ notFound();
+ }
+
+ const caregiver = await requireCaregiverSession(`/messages/${publicId}`);
+ const message = await getCaregiverMessageDetail(caregiver.id, publicId);
+
+ if (!message) {
+ notFound();
+ }
+
+ return (
+
+
+
+
+
+ #{message.publicId}
+
+
+ {getMessageDirectionLabel(message.direction)}
+
+
+ {getImportanceLabel(message.importance)}
+
+
+ {getMessageStatusLabel(message)}
+
+
+
+
+ {message.content}
+
+
+ {message.recipientRelation ? (
+
+ 目标对象:{message.recipientRelation}
+
+ ) : null}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/messages/page.tsx b/app/messages/page.tsx
new file mode 100644
index 0000000..195a31d
--- /dev/null
+++ b/app/messages/page.tsx
@@ -0,0 +1,146 @@
+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,
+ getDeviceName,
+ getImportanceLabel,
+ getMessageDirectionLabel,
+ getMessageHref,
+ getMessageStatusLabel,
+ truncateText,
+} from "@/lib/panel-format";
+import { requireCaregiverSession } from "@/lib/page-auth";
+
+export const dynamic = "force-dynamic";
+
+export default async function MessagesPage() {
+ const caregiver = await requireCaregiverSession("/messages");
+ const { messages, unreadIncomingCount, unreadOutgoingCount } =
+ await getCaregiverMessages(caregiver.id);
+
+ const incomingMessages = messages.filter(
+ (message) => message.direction === "ELDER_TO_FAMILY",
+ );
+ const outgoingMessages = messages.filter(
+ (message) => message.direction === "FAMILY_TO_ELDER",
+ );
+
+ return (
+ }
+ >
+
+
+
全部留言
+
+ {messages.length}
+
+
+
+
老人给家里的未读留言
+
+ {unreadIncomingCount}
+
+
+
+
待老人查看的家属留言
+
+ {unreadOutgoingCount}
+
+
+
+
+ {messages.length === 0 ? (
+
+
+ 还没有留言记录
+
+
+ 去设备页给老人留一句问候,或等待老人通过桥接工具给家里捎话;新记录会自动出现在这里,并带有可分享的消息编号。
+
+
+
+ 去设备页
+
+
+ 去扫码绑定
+
+
+
+ ) : (
+
+ {[
+ {
+ title: "老人给家里的留言",
+ subtitle: "收到新话时也会从这里直达详情页。",
+ items: incomingMessages,
+ },
+ {
+ title: "家属给老人的问候",
+ subtitle: "写出去的每条问候也有独立 URL,可回看送达状态。",
+ items: outgoingMessages,
+ },
+ ].map((section) => (
+
+
+ 留言列表
+
+
+ {section.title}
+
+
{section.subtitle}
+
+
+ {section.items.length === 0 ? (
+
+ 这一侧暂时还没有留言。
+
+ ) : (
+ section.items.map((message) => (
+
+
+
+ #{message.publicId}
+
+ {getMessageDirectionLabel(message.direction)}
+ {getDeviceName(message.elderDevice.displayName)}
+ {getImportanceLabel(message.importance)}
+ {getMessageStatusLabel(message)}
+
+
+ {truncateText(message.content, 112)}
+
+
+ 创建于 {formatDateTime(message.createdAt)}
+
+
+ ))
+ )}
+
+
+ ))}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/app/page.tsx b/app/page.tsx
index 01af07b..2d35787 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,70 +1,23 @@
import Link from "next/link";
-import { redirect } from "next/navigation";
-import { BindDeviceForm, SendMessageForm } from "@/components/dashboard-actions";
-import { getCaregiverDashboard } from "@/lib/monitor-data";
+import { PanelShell } from "@/components/panel-shell";
+import { PwaControls } from "@/components/pwa-controls";
+import { getCaregiverOverview } from "@/lib/caregiver-panel";
import {
- buildCaregiverSessionBootstrapPath,
- getCaregiverSession,
-} from "@/lib/session";
+ formatDateTime,
+ getDeviceName,
+ getImportanceLabel,
+ getMessageHref,
+ truncateText,
+} from "@/lib/panel-format";
+import { requireCaregiverSession } from "@/lib/page-auth";
export const dynamic = "force-dynamic";
-function formatTime(date: Date | null | undefined) {
- if (!date) {
- return "暂时还没有记录";
- }
-
- return new Intl.DateTimeFormat("zh-CN", {
- month: "numeric",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- }).format(date);
-}
-
-function getImportanceLabel(level: string) {
- switch (level) {
- case "LOW":
- return "温和提醒";
- case "HIGH":
- return "希望尽快看到";
- case "URGENT":
- return "需要尽快联系";
- default:
- return "日常问候";
- }
-}
-
-function getUsageLabel(eventType: string) {
- switch (eventType) {
- case "APP_OPEN":
- return "打开了老人端应用";
- case "SETTINGS_OPENED":
- return "打开了家人连接页";
- case "AI_SESSION_STARTED":
- return "开始了一次陪伴对话";
- case "AI_SESSION_ENDED":
- return "结束了一次陪伴对话";
- case "CAMERA_ENABLED":
- return "打开了镜头模式";
- case "CAMERA_DISABLED":
- return "关闭了镜头模式";
- case "TOOL_CALLED":
- return "模型调用了一次桥接工具";
- default:
- return eventType;
- }
-}
-
export default async function Home() {
- const caregiver = await getCaregiverSession();
-
- if (!caregiver) {
- redirect(buildCaregiverSessionBootstrapPath("/"));
- }
-
- const dashboard = await getCaregiverDashboard(caregiver.id);
+ const caregiver = await requireCaregiverSession("/");
+ const { dashboard, recentIncomingMessages, recentOutgoingMessages } =
+ await getCaregiverOverview(caregiver.id);
const totalUnreadForElder = dashboard.devices.reduce(
(total, device) => total + device.familyUnreadCount,
@@ -80,360 +33,207 @@ export default async function Home() {
);
return (
-
-
-
-
-
-
+ }
+ >
+
+
+
已连接老人设备
+
+ {dashboard.devices.length}
+
+
+
+
老人给家里的未读留言
+
+ {totalUnreadForCaregiver}
+
+
+
+
待老人查看的家属留言
+
+ {totalUnreadForElder}
+
+
+
+
最近存档对话条数
+
+ {totalConversations}
+
+
+
-
-
-
- Digital Human Bridge
-
-
- 把老人的陪伴对话、留言和家人问候,收进同一张暖色桌面。
-
-
- 这里是家属端控制台。扫码绑定以后,您能看到老人留给家里的话、老人最近的使用轨迹、重要对话片段,也能反过来给老人写下一句问候。
-
-
-
-
- 当前家属账号:{dashboard.caregiver.sessionToken.slice(0, 8).toUpperCase()}
-
-
- 已连接 {dashboard.devices.length} 位老人设备
-
-
+
+ {[
+ {
+ href: "/messages",
+ title: "留言中心",
+ text: "集中看所有老人留言和家属留言,每条消息都有 URL 编号可直接打开。",
+ tone: "bg-[rgba(53,84,141,0.12)]",
+ },
+ {
+ href: "/devices",
+ title: "设备与绑定",
+ text: "去管理设备、扫码绑定、查看某一台设备的专属详情页和留言入口。",
+ tone: "bg-[rgba(118,132,94,0.14)]",
+ },
+ {
+ href: "/activity",
+ title: "活动回看",
+ text: "把使用事件、工具调用和对话片段拆出来,回看时不再和留言混在一起。",
+ tone: "bg-[rgba(199,103,51,0.12)]",
+ },
+ {
+ href: "/scan",
+ title: "扫码绑定",
+ text: "拿起另一台手机时,直接走扫码页,不必再回首页寻找入口。",
+ tone: "bg-[rgba(36,27,20,0.08)]",
+ },
+ ].map((item) => (
+
+
+ 跳转路径
-
-
-
-
家属已绑定设备
-
- {dashboard.devices.length}
-
-
-
-
老人未读家属留言
-
- {totalUnreadForElder}
-
-
-
-
最近存档对话条数
-
- {totalConversations}
-
-
-
-
-
-
-
-
-
-
-
- 今日总览
-
-
-
-
老人给家里的新留言
-
- {totalUnreadForCaregiver}
-
-
-
-
老人待接收的家属留言
-
- {totalUnreadForElder}
-
-
-
-
扫码绑定更顺手
-
- 如果您正拿着另一台手机,可以直接打开扫码页,对准老人的家人连接二维码。
-
-
- 去扫码
-
-
-
-
-
-
-
- 面板说明
-
-
-
-
- 留言桥接
-
-
- 老人在数字人对话里说“给儿子带句话”时,模型会走 function calling,把内容存到这里。
-
-
-
-
- 陪伴记录
-
-
- 面板会收下老人端的重要使用事件、最近对话片段和工具调用记录,方便您回看状态。
-
-
-
-
- 反向留言
-
-
- 家属端写下的留言会进到老人设备的消息列表里,也能被 function calling 工具读取给老人听。
-
-
-
-
-
-
- {dashboard.devices.length === 0 ? (
-
-
- 还没有绑定老人设备
-
-
- 先扫一下老人手机里的二维码
+
+ {item.title}
-
- 绑定完成后,这里会出现老人的留言、陪伴轨迹、最近对话和工具调用记录。当前页面已经替您准备好扫码与手动绑定入口。
-
-
- ) : (
-
- {dashboard.devices.map((binding) => {
- const familyMessages = binding.elderDevice.messages.filter(
- (message) => message.direction === "FAMILY_TO_ELDER",
- );
- const elderMessages = binding.elderDevice.messages.filter(
- (message) => message.direction === "ELDER_TO_FAMILY",
- );
+ {item.text}
+ 前往 {item.href}
+
+ ))}
+
- return (
-
-
-
-
- 老人设备
-
-
- {binding.elderDevice.displayName || "未命名陪伴设备"}
-
-
-
- UUID:{binding.elderDevice.deviceUuid}
-
-
- 最近在线:{formatTime(binding.elderDevice.lastSeenAt)}
-
-
- 绑定链接:{binding.bindUrl}
-
-
+ {dashboard.devices.length === 0 ? (
+
+
+ 还没有绑定老人设备
+
+
+ 先去设备页或扫码页建立第一条连接
+
+
+ 绑定完成后,留言中心会出现 URL 编号消息卡,活动页会出现陪伴记录,推送也能直达具体留言。
+
+
+
+ 去设备页
+
+
+ 直接扫码
+
+
+
+ ) : (
+
+
+
+
+
+ 新收到的老人留言
+
+
+ 点一下就能按编号直达
+
+
+
+ 查看全部留言
+
+
+
+
+ {recentIncomingMessages.length === 0 ? (
+
+ 目前还没有新的老人留言。
+
+ ) : (
+ recentIncomingMessages.map((message) => (
+
+
+
+ #{message.publicId}
+
+ {getDeviceName(message.elderDevice.displayName)}
+ {getImportanceLabel(message.importance)}
+ {formatDateTime(message.createdAt)}
+
+ {truncateText(message.content)}
+
+
+ ))
+ )}
+
+
-
-
-
老人新留言
-
- {binding.elderUnreadCount}
-
-
-
-
待老人查看
-
- {binding.familyUnreadCount}
-
-
-
-
对话条数
-
- {binding.elderDevice._count.conversationTurns}
-
-
-
-
工具调用
-
- {binding.elderDevice._count.toolCalls}
-
-
+
+
+
+
+ 最近发给老人的问候
+
+
+ 发出后也能按编号回看
+
+
+
+ 去设备页留言
+
+
+
+
+ {recentOutgoingMessages.length === 0 ? (
+
+ 还没有新的家属留言,可以到设备页针对某一台设备写下问候。
+
+ ) : (
+ recentOutgoingMessages.map((message) => (
+
+
+
+ #{message.publicId}
+
+ {getDeviceName(message.elderDevice.displayName)}
+ {getImportanceLabel(message.importance)}
+ {formatDateTime(message.createdAt)}
-
-
-
-
-
-
-
-
- 老人给家里留的话
-
-
- 最近想转达给家人的内容
-
-
-
- 未读 {binding.elderUnreadCount}
-
-
-
-
- {elderMessages.length === 0 ? (
-
- 老人暂时还没有通过大模型给家里捎话。
-
- ) : (
- elderMessages.map((message) => (
-
-
-
- {getImportanceLabel(message.importance)}
-
- {message.recipientRelation ? (
-
- 目标:{message.recipientRelation}
-
- ) : null}
- {formatTime(message.createdAt)}
-
-
- {message.content}
-
-
- ))
- )}
-
-
-
-
-
- 最近活动
-
-
- 老人端刚刚发生了什么
-
-
-
- {binding.elderDevice.usageEvents.length === 0 ? (
-
- 暂时还没有同步到新的使用记录。
-
- ) : (
- binding.elderDevice.usageEvents.map((event) => (
-
-
- {getUsageLabel(event.eventType)}
- {formatTime(event.createdAt)}
-
-
- ))
- )}
-
-
-
-
-
-
-
-
-
-
-
- 发给老人的留言
-
-
- 最近写给老人的问候
-
-
-
- 待查看 {binding.familyUnreadCount}
-
-
-
-
- {familyMessages.length === 0 ? (
-
- 您还没有给这位老人留下新的问候。
-
- ) : (
- familyMessages.map((message) => (
-
-
-
- {getImportanceLabel(message.importance)}
-
- {formatTime(message.createdAt)}
-
- {message.readAt ? "老人已看过" : "等待老人查看"}
-
-
-
- {message.content}
-
-
- ))
- )}
-
-
-
-
-
- 最近对话片段
-
-
- 可快速回看的陪伴内容
-
-
-
- {binding.elderDevice.conversationTurns.length === 0 ? (
-
- 这位老人最近的实时对话还没有同步到这里。
-
- ) : (
- binding.elderDevice.conversationTurns.map((turn) => (
-
-
-
- {turn.role === "USER"
- ? "老人说"
- : turn.role === "ASSISTANT"
- ? "数字人回复"
- : "工具结果"}
-
- {formatTime(turn.createdAt)}
-
-
- {turn.content}
-
-
- ))
- )}
-
-
-
-
-
- );
- })}
-
- )}
-
-
+
+ {truncateText(message.content)}
+
+
+ ))
+ )}
+
+
+
+ )}
+
);
}
diff --git a/app/scan/page.tsx b/app/scan/page.tsx
index f4e38b9..6f2fec0 100644
--- a/app/scan/page.tsx
+++ b/app/scan/page.tsx
@@ -25,10 +25,10 @@ export default function ScanPage() {
- 回到控制台
+ 回到设备页
diff --git a/bun.lock b/bun.lock
index daa74a9..dff13c9 100644
--- a/bun.lock
+++ b/bun.lock
@@ -13,12 +13,15 @@
"qr-scanner": "^1.4.2",
"react": "19.2.4",
"react-dom": "19.2.4",
+ "sharp": "^0.34.5",
+ "web-push": "^3.6.7",
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@types/web-push": "^3.6.4",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"prisma": "^7.7.0",
@@ -296,6 +299,8 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
+ "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="],
+
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/type-utils": "8.58.2", "@typescript-eslint/utils": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg=="],
@@ -358,6 +363,8 @@
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
+ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
@@ -382,6 +389,8 @@
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
+ "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
+
"ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
@@ -400,12 +409,16 @@
"better-result": ["better-result@2.8.2", "", {}, "sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A=="],
+ "bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
+
"brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
+ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
+
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
@@ -476,6 +489,8 @@
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
+ "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
+
"effect": ["effect@3.20.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw=="],
"electron-to-chromium": ["electron-to-chromium@1.5.339", "", {}, "sha512-Is+0BBHJ4NrdpAYiperrmp53pLywG/yV/6lIMTAnhxvzj/Cmn5Q/ogSHC6AKe7X+8kPLxxFk0cs5oc/3j/fxIg=="],
@@ -632,6 +647,10 @@
"http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="],
+ "http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
+
+ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
+
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -640,6 +659,8 @@
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
+ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
+
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
@@ -720,6 +741,10 @@
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
+ "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
+
+ "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
+
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="],
@@ -772,6 +797,8 @@
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
+ "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
+
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -920,6 +947,8 @@
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
+ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
+
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
@@ -928,7 +957,7 @@
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
- "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+ "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="],
@@ -1030,6 +1059,8 @@
"valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="],
+ "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
+
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@@ -1056,6 +1087,10 @@
"@babel/core/json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
+ "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+
+ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
@@ -1084,8 +1119,6 @@
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
- "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
-
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
@@ -1096,22 +1129,24 @@
"eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
- "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+ "eslint-plugin-import/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
- "is-bun-module/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
+ "eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+
+ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
+ "node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+
"nypm/citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
"pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
- "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
-
"@prisma/streams-local/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
diff --git a/components/dashboard-actions.tsx b/components/dashboard-actions.tsx
index 56ffae0..0b5ae71 100644
--- a/components/dashboard-actions.tsx
+++ b/components/dashboard-actions.tsx
@@ -109,12 +109,14 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
const [importance, setImportance] = useState("NORMAL");
const [submitting, setSubmitting] = useState(false);
const [notice, setNotice] = useState(null);
+ const [createdMessagePublicId, setCreatedMessagePublicId] = useState(null);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (!content.trim()) {
setNotice("先写下想对老人说的话。\n");
+ setCreatedMessagePublicId(null);
return;
}
@@ -135,7 +137,7 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
});
const payload = (await response.json().catch(() => null)) as
- | { error?: string }
+ | { error?: string; publicId?: number }
| null;
if (!response.ok) {
@@ -143,12 +145,16 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
}
setNotice("留言已经写进家人的收件盒,老人下次打开或询问时就能看到。\n");
+ setCreatedMessagePublicId(
+ typeof payload?.publicId === "number" ? payload.publicId : null,
+ );
setContent("");
setImportance("NORMAL");
startTransition(() => {
router.refresh();
});
} catch (error) {
+ setCreatedMessagePublicId(null);
setNotice(
error instanceof Error ? error.message : "留言发送失败,请稍后再试。",
);
@@ -202,9 +208,17 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
{notice ? (
-
- {notice}
-
+
+
{notice}
+ {createdMessagePublicId ? (
+
+ 打开留言 #{createdMessagePublicId}
+
+ ) : null}
+
) : null}
);
diff --git a/components/panel-shell.tsx b/components/panel-shell.tsx
new file mode 100644
index 0000000..7cb9d16
--- /dev/null
+++ b/components/panel-shell.tsx
@@ -0,0 +1,97 @@
+import Link from "next/link";
+import type { ReactNode } from "react";
+
+const navigationItems = [
+ { href: "/", label: "总览" },
+ { href: "/messages", label: "留言" },
+ { href: "/devices", label: "设备" },
+ { href: "/activity", label: "活动" },
+ { href: "/scan", label: "扫码" },
+];
+
+function isActivePath(currentPath: string, href: string) {
+ if (href === "/") {
+ return currentPath === "/";
+ }
+
+ return currentPath === href || currentPath.startsWith(`${href}/`);
+}
+
+type PanelShellProps = {
+ currentPath: string;
+ title: string;
+ description: string;
+ caregiverToken: string;
+ children: ReactNode;
+ actions?: ReactNode;
+ eyebrow?: string;
+};
+
+export function PanelShell({
+ currentPath,
+ title,
+ description,
+ caregiverToken,
+ children,
+ actions,
+ eyebrow = "Digital Human Bridge",
+}: PanelShellProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ {navigationItems.map((item) => {
+ const active = isActivePath(currentPath, item.href);
+
+ return (
+
+ {item.label}
+
+ );
+ })}
+
+
+
+ {eyebrow}
+
+
+ {title}
+
+
+ {description}
+
+
+
+
+ 当前家属账号:{caregiverToken.slice(0, 8).toUpperCase()}
+
+
+ 不同功能已拆到独立路径,消息支持单条直达
+
+
+
+
+ {actions ?
{actions}
: null}
+
+
+
+ {children}
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/pwa-controls.tsx b/components/pwa-controls.tsx
new file mode 100644
index 0000000..308e42e
--- /dev/null
+++ b/components/pwa-controls.tsx
@@ -0,0 +1,287 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+type BeforeInstallPromptEvent = Event & {
+ prompt: () => Promise;
+ userChoice: Promise<{
+ outcome: "accepted" | "dismissed";
+ platform: string;
+ }>;
+};
+
+type NotificationState =
+ | "checking"
+ | "idle"
+ | "pending"
+ | "enabled"
+ | "blocked"
+ | "unsupported";
+
+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 index = 0; index < rawData.length; index += 1) {
+ outputArray[index] = rawData.charCodeAt(index);
+ }
+
+ return outputArray;
+}
+
+export function PwaControls() {
+ const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "";
+ const [installPromptEvent, setInstallPromptEvent] =
+ useState(null);
+ const [installing, setInstalling] = useState(false);
+ const [isStandalone, setIsStandalone] = useState(false);
+ const [notificationState, setNotificationState] =
+ useState("checking");
+ const [notice, setNotice] = useState(null);
+
+ useEffect(() => {
+ let active = true;
+
+ function handleBeforeInstallPrompt(event: Event) {
+ event.preventDefault();
+ if (!active) {
+ return;
+ }
+
+ setInstallPromptEvent(event as BeforeInstallPromptEvent);
+ }
+
+ async function syncExistingSubscription(subscription: PushSubscription) {
+ await fetch("/api/push/subscribe", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ subscription: subscription.toJSON(),
+ }),
+ });
+ }
+
+ async function bootstrap() {
+ const standalone =
+ window.matchMedia("(display-mode: standalone)").matches ||
+ Boolean((navigator as Navigator & { standalone?: boolean }).standalone);
+ setIsStandalone(standalone);
+
+ if (
+ !("serviceWorker" in navigator) ||
+ !("PushManager" in window) ||
+ typeof Notification === "undefined"
+ ) {
+ setNotificationState("unsupported");
+ return;
+ }
+
+ try {
+ await navigator.serviceWorker.register("/sw.js");
+ const registration = await navigator.serviceWorker.ready;
+ const existingSubscription = await registration.pushManager.getSubscription();
+
+ if (!active) {
+ return;
+ }
+
+ if (existingSubscription) {
+ if (vapidPublicKey) {
+ await syncExistingSubscription(existingSubscription);
+ }
+
+ setNotificationState("enabled");
+ return;
+ }
+
+ setNotificationState(
+ Notification.permission === "denied" ? "blocked" : "idle",
+ );
+ } catch {
+ if (active) {
+ setNotificationState("unsupported");
+ }
+ }
+ }
+
+ window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
+ void bootstrap();
+
+ return () => {
+ active = false;
+ window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
+ };
+ }, [vapidPublicKey]);
+
+ async function enableNotifications() {
+ if (!vapidPublicKey) {
+ setNotice("当前环境还没配置推送密钥,先把面板安装到桌面即可。\n");
+ return;
+ }
+
+ setNotificationState("pending");
+ setNotice(null);
+
+ try {
+ const permission = await Notification.requestPermission();
+
+ if (permission !== "granted") {
+ setNotificationState(permission === "denied" ? "blocked" : "idle");
+ setNotice(
+ permission === "denied"
+ ? "浏览器已经拒绝消息提醒,请在系统设置里重新打开通知权限。\n"
+ : "这次没有打开通知权限,稍后仍可再次开启。\n",
+ );
+ return;
+ }
+
+ const registration = await navigator.serviceWorker.ready;
+ let subscription = await registration.pushManager.getSubscription();
+
+ if (!subscription) {
+ subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
+ });
+ }
+
+ await fetch("/api/push/subscribe", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ subscription: subscription.toJSON(),
+ }),
+ });
+
+ setNotificationState("enabled");
+ setNotice("新留言会直接推到这台设备上,点开即可直达对应留言。\n");
+ } catch {
+ setNotificationState("idle");
+ setNotice("打开消息提醒失败,请稍后再试。\n");
+ }
+ }
+
+ async function disableNotifications() {
+ try {
+ const registration = await navigator.serviceWorker.ready;
+ const subscription = await registration.pushManager.getSubscription();
+
+ if (subscription) {
+ await fetch("/api/push/unsubscribe", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ endpoint: subscription.endpoint,
+ }),
+ });
+ await subscription.unsubscribe();
+ }
+
+ setNotificationState("idle");
+ setNotice("这台设备的消息提醒已关闭。\n");
+ } catch {
+ setNotice("关闭消息提醒失败,请稍后再试。\n");
+ }
+ }
+
+ async function installApp() {
+ if (!installPromptEvent) {
+ setNotice("如果浏览器没有弹出安装按钮,可以从菜单里选择“安装应用”或“添加到主屏幕”。\n");
+ return;
+ }
+
+ setInstalling(true);
+ setNotice(null);
+
+ try {
+ await installPromptEvent.prompt();
+ const choice = await installPromptEvent.userChoice;
+
+ if (choice.outcome === "accepted") {
+ setInstallPromptEvent(null);
+ setNotice("安装提示已经确认,完成后就能像应用一样直接打开。\n");
+ } else {
+ setNotice("这次先关闭了安装提示,之后仍可从浏览器菜单安装。\n");
+ }
+ } finally {
+ setInstalling(false);
+ }
+ }
+
+ return (
+
+
+ 安装与提醒
+
+
+ 把家属面板装到桌面,并接收新留言提醒
+
+
+ 安装后打开速度会更像原生应用;开启提醒后,老人一旦通过桥接工具留话,点击通知就能直达那条留言。
+
+
+
+
+ {isStandalone ? "当前已可从桌面直接打开" : "浏览器内访问中"}
+
+
+ {notificationState === "enabled"
+ ? "消息提醒已开启"
+ : notificationState === "blocked"
+ ? "通知权限已被拒绝"
+ : "消息提醒未开启"}
+
+
+
+
+
+
+ {notificationState === "enabled" ? (
+
+ ) : (
+
+ )}
+
+
+ {notice ? (
+
+ {notice}
+
+ ) : null}
+
+ );
+}
\ No newline at end of file
diff --git a/icon.png b/icon.png
new file mode 100644
index 0000000..a315a3a
Binary files /dev/null and b/icon.png differ
diff --git a/lib/caregiver-panel.ts b/lib/caregiver-panel.ts
new file mode 100644
index 0000000..5cab80f
--- /dev/null
+++ b/lib/caregiver-panel.ts
@@ -0,0 +1,286 @@
+import { MessageDirection, MessageStatus } from "@prisma/client";
+
+import { createBindUrl, getCaregiverDashboard } from "@/lib/monitor-data";
+import { prisma } from "@/lib/prisma";
+
+const deviceSummarySelect = {
+ id: true,
+ deviceUuid: true,
+ displayName: true,
+ appVersion: true,
+ lastSeenAt: true,
+} as const;
+
+export async function getCaregiverOverview(caregiverId: string) {
+ const dashboard = await getCaregiverDashboard(caregiverId);
+
+ const [recentIncomingMessages, recentOutgoingMessages] = await prisma.$transaction([
+ prisma.familyMessage.findMany({
+ where: {
+ direction: MessageDirection.ELDER_TO_FAMILY,
+ elderDevice: {
+ bindings: {
+ some: { caregiverId },
+ },
+ },
+ },
+ include: {
+ elderDevice: {
+ select: deviceSummarySelect,
+ },
+ },
+ orderBy: { createdAt: "desc" },
+ take: 4,
+ }),
+ prisma.familyMessage.findMany({
+ where: {
+ direction: MessageDirection.FAMILY_TO_ELDER,
+ caregiverId,
+ },
+ include: {
+ elderDevice: {
+ select: deviceSummarySelect,
+ },
+ },
+ orderBy: { createdAt: "desc" },
+ take: 4,
+ }),
+ ]);
+
+ return {
+ dashboard,
+ recentIncomingMessages,
+ recentOutgoingMessages,
+ };
+}
+
+export async function getCaregiverMessages(caregiverId: string) {
+ const [messages, unreadIncomingCount, unreadOutgoingCount] = await prisma.$transaction([
+ prisma.familyMessage.findMany({
+ where: {
+ elderDevice: {
+ bindings: {
+ some: { caregiverId },
+ },
+ },
+ },
+ include: {
+ elderDevice: {
+ select: deviceSummarySelect,
+ },
+ },
+ orderBy: [{ createdAt: "desc" }, { publicId: "desc" }],
+ take: 120,
+ }),
+ prisma.familyMessage.count({
+ where: {
+ direction: MessageDirection.ELDER_TO_FAMILY,
+ elderDevice: {
+ bindings: {
+ some: { caregiverId },
+ },
+ },
+ readAt: null,
+ },
+ }),
+ prisma.familyMessage.count({
+ where: {
+ direction: MessageDirection.FAMILY_TO_ELDER,
+ caregiverId,
+ readAt: null,
+ },
+ }),
+ ]);
+
+ return {
+ messages,
+ unreadIncomingCount,
+ unreadOutgoingCount,
+ };
+}
+
+export async function getCaregiverMessageDetail(
+ caregiverId: string,
+ publicId: number,
+) {
+ let message = await prisma.familyMessage.findFirst({
+ where: {
+ publicId,
+ elderDevice: {
+ bindings: {
+ some: { caregiverId },
+ },
+ },
+ },
+ include: {
+ elderDevice: {
+ select: deviceSummarySelect,
+ },
+ },
+ });
+
+ if (!message) {
+ return null;
+ }
+
+ if (message.direction === MessageDirection.ELDER_TO_FAMILY && !message.readAt) {
+ const now = new Date();
+ message = await prisma.familyMessage.update({
+ where: { id: message.id },
+ data: {
+ readAt: now,
+ deliveredAt: message.deliveredAt ?? now,
+ status: MessageStatus.READ,
+ },
+ include: {
+ elderDevice: {
+ select: deviceSummarySelect,
+ },
+ },
+ });
+ }
+
+ return message;
+}
+
+export async function getCaregiverDevices(caregiverId: string) {
+ const dashboard = await getCaregiverDashboard(caregiverId);
+
+ return dashboard.devices;
+}
+
+export async function getCaregiverDeviceDetail(
+ caregiverId: string,
+ deviceUuid: string,
+) {
+ const binding = await prisma.deviceBinding.findFirst({
+ where: {
+ caregiverId,
+ elderDevice: {
+ deviceUuid,
+ },
+ },
+ include: {
+ elderDevice: {
+ include: {
+ messages: {
+ orderBy: [{ createdAt: "desc" }, { publicId: "desc" }],
+ take: 14,
+ },
+ usageEvents: {
+ orderBy: { createdAt: "desc" },
+ take: 10,
+ },
+ conversationTurns: {
+ orderBy: { createdAt: "desc" },
+ take: 12,
+ },
+ toolCalls: {
+ orderBy: { createdAt: "desc" },
+ take: 8,
+ },
+ _count: {
+ select: {
+ messages: true,
+ usageEvents: true,
+ conversationTurns: true,
+ toolCalls: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!binding) {
+ return null;
+ }
+
+ const [familyUnreadCount, elderUnreadCount] = await prisma.$transaction([
+ prisma.familyMessage.count({
+ where: {
+ elderDeviceId: binding.elderDeviceId,
+ direction: MessageDirection.FAMILY_TO_ELDER,
+ readAt: null,
+ },
+ }),
+ prisma.familyMessage.count({
+ where: {
+ elderDeviceId: binding.elderDeviceId,
+ direction: MessageDirection.ELDER_TO_FAMILY,
+ readAt: null,
+ },
+ }),
+ ]);
+
+ return {
+ ...binding,
+ bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
+ familyUnreadCount,
+ elderUnreadCount,
+ };
+}
+
+export async function getCaregiverActivity(caregiverId: string) {
+ const deviceBindings = await prisma.deviceBinding.findMany({
+ where: { caregiverId },
+ select: {
+ elderDeviceId: true,
+ },
+ });
+
+ const elderDeviceIds = deviceBindings.map((binding) => binding.elderDeviceId);
+
+ if (elderDeviceIds.length === 0) {
+ return {
+ usageEvents: [],
+ conversationTurns: [],
+ toolCalls: [],
+ };
+ }
+
+ const [usageEvents, conversationTurns, toolCalls] = await prisma.$transaction([
+ prisma.usageEvent.findMany({
+ where: {
+ elderDeviceId: { in: elderDeviceIds },
+ },
+ include: {
+ elderDevice: {
+ select: deviceSummarySelect,
+ },
+ },
+ orderBy: { createdAt: "desc" },
+ take: 24,
+ }),
+ prisma.conversationTurn.findMany({
+ where: {
+ elderDeviceId: { in: elderDeviceIds },
+ },
+ include: {
+ elderDevice: {
+ select: deviceSummarySelect,
+ },
+ },
+ orderBy: { createdAt: "desc" },
+ take: 24,
+ }),
+ prisma.toolCallLog.findMany({
+ where: {
+ elderDeviceId: { in: elderDeviceIds },
+ },
+ include: {
+ elderDevice: {
+ select: deviceSummarySelect,
+ },
+ },
+ orderBy: { createdAt: "desc" },
+ take: 24,
+ }),
+ ]);
+
+ return {
+ usageEvents,
+ conversationTurns,
+ toolCalls,
+ };
+}
\ No newline at end of file
diff --git a/lib/monitor-data.ts b/lib/monitor-data.ts
index fc82a31..8103674 100644
--- a/lib/monitor-data.ts
+++ b/lib/monitor-data.ts
@@ -7,6 +7,7 @@ import {
UsageEventType,
} from "@prisma/client";
+import { sendIncomingMessagePush } from "@/lib/push";
import { prisma } from "@/lib/prisma";
type RegisterDeviceInput = {
@@ -338,7 +339,7 @@ export async function recordConversationTurns(input: ConversationTurnInput) {
export async function recordToolCall(input: ToolCallInput) {
const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid });
- return prisma.$transaction(async (tx) => {
+ const result = await prisma.$transaction(async (tx) => {
const log = await tx.toolCallLog.create({
data: {
elderDeviceId: device.id,
@@ -367,13 +368,14 @@ export async function recordToolCall(input: ToolCallInput) {
typeof input.arguments.message === "string"
? input.arguments.message.trim()
: "";
+ let createdMessagePublicId: number | null = null;
if (
input.status === ToolCallStatus.SUCCESS &&
input.toolName === "leave_message_for_family" &&
messageText
) {
- await tx.familyMessage.create({
+ const createdMessage = await tx.familyMessage.create({
data: {
elderDeviceId: device.id,
direction: MessageDirection.ELDER_TO_FAMILY,
@@ -392,10 +394,23 @@ export async function recordToolCall(input: ToolCallInput) {
source: "MODEL_TOOL",
},
});
+
+ createdMessagePublicId = createdMessage.publicId;
}
- return log;
+ return {
+ log,
+ createdMessagePublicId,
+ };
});
+
+ if (result.createdMessagePublicId) {
+ void sendIncomingMessagePush(result.createdMessagePublicId).catch(() => {
+ return undefined;
+ });
+ }
+
+ return result.log;
}
export async function getCaregiverDashboard(caregiverId: string) {
diff --git a/lib/page-auth.ts b/lib/page-auth.ts
new file mode 100644
index 0000000..80defe0
--- /dev/null
+++ b/lib/page-auth.ts
@@ -0,0 +1,16 @@
+import { redirect } from "next/navigation";
+
+import {
+ buildCaregiverSessionBootstrapPath,
+ getCaregiverSession,
+} from "@/lib/session";
+
+export async function requireCaregiverSession(redirectTo: string) {
+ const caregiver = await getCaregiverSession();
+
+ if (!caregiver) {
+ redirect(buildCaregiverSessionBootstrapPath(redirectTo));
+ }
+
+ return caregiver;
+}
\ No newline at end of file
diff --git a/lib/panel-format.ts b/lib/panel-format.ts
new file mode 100644
index 0000000..32921d2
--- /dev/null
+++ b/lib/panel-format.ts
@@ -0,0 +1,114 @@
+import {
+ ConversationRole,
+ MessageDirection,
+ MessageImportance,
+ MessageStatus,
+ UsageEventType,
+} from "@prisma/client";
+
+export function formatDateTime(value: Date | string | null | undefined) {
+ if (!value) {
+ return "暂时还没有记录";
+ }
+
+ const date = value instanceof Date ? value : new Date(value);
+
+ return new Intl.DateTimeFormat("zh-CN", {
+ month: "numeric",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(date);
+}
+
+export function getImportanceLabel(level: MessageImportance | string) {
+ switch (level) {
+ case MessageImportance.LOW:
+ return "温和提醒";
+ case MessageImportance.HIGH:
+ return "希望尽快看到";
+ case MessageImportance.URGENT:
+ return "需要尽快联系";
+ default:
+ return "日常问候";
+ }
+}
+
+export function getUsageLabel(eventType: UsageEventType | string) {
+ switch (eventType) {
+ case UsageEventType.APP_OPEN:
+ return "打开了老人端应用";
+ case UsageEventType.SETTINGS_OPENED:
+ return "打开了家人连接页";
+ case UsageEventType.AI_SESSION_STARTED:
+ return "开始了一次陪伴对话";
+ case UsageEventType.AI_SESSION_ENDED:
+ return "结束了一次陪伴对话";
+ case UsageEventType.CAMERA_ENABLED:
+ return "打开了镜头模式";
+ case UsageEventType.CAMERA_DISABLED:
+ return "关闭了镜头模式";
+ case UsageEventType.TOOL_CALLED:
+ return "模型调用了一次桥接工具";
+ default:
+ return eventType;
+ }
+}
+
+export function getConversationRoleLabel(role: ConversationRole | string) {
+ switch (role) {
+ case ConversationRole.USER:
+ return "老人说";
+ case ConversationRole.TOOL:
+ return "工具结果";
+ default:
+ return "数字人回复";
+ }
+}
+
+export function getMessageDirectionLabel(direction: MessageDirection | string) {
+ return direction === MessageDirection.ELDER_TO_FAMILY
+ ? "老人给家里"
+ : "家属给老人";
+}
+
+export function getMessageStatusLabel(input: {
+ direction: MessageDirection | string;
+ status: MessageStatus | string;
+ readAt?: Date | string | null;
+ deliveredAt?: Date | string | null;
+}) {
+ if (input.direction === MessageDirection.ELDER_TO_FAMILY) {
+ return input.readAt ? "家属已打开" : "等待家属查看";
+ }
+
+ if (input.readAt) {
+ return "老人已看过";
+ }
+
+ if (input.deliveredAt) {
+ return "已送到老人设备";
+ }
+
+ if (input.status === MessageStatus.PENDING) {
+ return "等待老人设备拉取";
+ }
+
+ return "等待老人查看";
+}
+
+export function truncateText(content: string, maxLength = 88) {
+ if (content.length <= maxLength) {
+ return content;
+ }
+
+ return `${content.slice(0, maxLength).trimEnd()}...`;
+}
+
+export function getMessageHref(publicId: number) {
+ return `/messages/${publicId}`;
+}
+
+export function getDeviceName(displayName?: string | null) {
+ return displayName?.trim() || "未命名陪伴设备";
+}
\ No newline at end of file
diff --git a/lib/push.ts b/lib/push.ts
new file mode 100644
index 0000000..823f0dd
--- /dev/null
+++ b/lib/push.ts
@@ -0,0 +1,208 @@
+import { MessageDirection } from "@prisma/client";
+import webpush from "web-push";
+
+import { getDeviceName, truncateText } from "@/lib/panel-format";
+import { prisma } from "@/lib/prisma";
+
+type SerializablePushSubscription = {
+ endpoint: string;
+ expirationTime?: number | null;
+ keys?: {
+ p256dh?: string;
+ auth?: string;
+ };
+};
+
+const APP_BASE_URL = (
+ process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net"
+).replace(/\/+$/, "");
+
+let vapidReady = false;
+
+function hasPushConfiguration() {
+ return Boolean(
+ process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY &&
+ process.env.VAPID_PRIVATE_KEY &&
+ process.env.VAPID_SUBJECT,
+ );
+}
+
+function ensureVapidDetails() {
+ if (!hasPushConfiguration()) {
+ throw new Error("VAPID 配置尚未完成,暂时不能发送推送通知。");
+ }
+
+ if (vapidReady) {
+ return;
+ }
+
+ webpush.setVapidDetails(
+ process.env.VAPID_SUBJECT!,
+ process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
+ process.env.VAPID_PRIVATE_KEY!,
+ );
+ vapidReady = true;
+}
+
+function normalizeSubscription(input: SerializablePushSubscription) {
+ const endpoint = input.endpoint?.trim();
+ const p256dh = input.keys?.p256dh?.trim();
+ const auth = input.keys?.auth?.trim();
+
+ if (!endpoint || !p256dh || !auth) {
+ throw new Error("推送订阅信息不完整。");
+ }
+
+ return {
+ endpoint,
+ p256dh,
+ auth,
+ expirationTime:
+ typeof input.expirationTime === "number"
+ ? new Date(input.expirationTime)
+ : null,
+ };
+}
+
+function absoluteUrl(path: string) {
+ if (/^https?:\/\//i.test(path)) {
+ return path;
+ }
+
+ return `${APP_BASE_URL}${path.startsWith("/") ? path : `/${path}`}`;
+}
+
+export function getPushPublicKey() {
+ return process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "";
+}
+
+export function isPushAvailable() {
+ return hasPushConfiguration();
+}
+
+export async function savePushSubscription(input: {
+ caregiverId: string;
+ subscription: SerializablePushSubscription;
+ userAgent?: string | null;
+}) {
+ const subscription = normalizeSubscription(input.subscription);
+
+ return prisma.pushSubscription.upsert({
+ where: { endpoint: subscription.endpoint },
+ update: {
+ caregiverId: input.caregiverId,
+ p256dh: subscription.p256dh,
+ auth: subscription.auth,
+ expirationTime: subscription.expirationTime,
+ userAgent: input.userAgent?.trim() || null,
+ },
+ create: {
+ caregiverId: input.caregiverId,
+ endpoint: subscription.endpoint,
+ p256dh: subscription.p256dh,
+ auth: subscription.auth,
+ expirationTime: subscription.expirationTime,
+ userAgent: input.userAgent?.trim() || null,
+ },
+ });
+}
+
+export async function removePushSubscription(input: {
+ caregiverId: string;
+ endpoint: string;
+}) {
+ await prisma.pushSubscription.deleteMany({
+ where: {
+ caregiverId: input.caregiverId,
+ endpoint: input.endpoint.trim(),
+ },
+ });
+}
+
+export async function sendIncomingMessagePush(publicId: number) {
+ if (!hasPushConfiguration()) {
+ return;
+ }
+
+ const message = await prisma.familyMessage.findUnique({
+ where: { publicId },
+ select: {
+ publicId: true,
+ content: true,
+ direction: true,
+ elderDeviceId: true,
+ elderDevice: {
+ select: {
+ deviceUuid: true,
+ displayName: true,
+ },
+ },
+ },
+ });
+
+ if (!message || message.direction !== MessageDirection.ELDER_TO_FAMILY) {
+ return;
+ }
+
+ const subscriptions = await prisma.pushSubscription.findMany({
+ where: {
+ caregiver: {
+ bindings: {
+ some: {
+ elderDeviceId: message.elderDeviceId,
+ },
+ },
+ },
+ },
+ });
+
+ if (subscriptions.length === 0) {
+ return;
+ }
+
+ ensureVapidDetails();
+
+ const deviceName = getDeviceName(message.elderDevice.displayName);
+ const payload = JSON.stringify({
+ title: `${deviceName} 发来一条新留言`,
+ body: truncateText(message.content, 72),
+ url: absoluteUrl(`/messages/${message.publicId}`),
+ icon: absoluteUrl("/pwa/icon-192x192.png"),
+ badge: absoluteUrl("/pwa/badge-96x96.png"),
+ 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;
+
+ if (statusCode === 404 || statusCode === 410) {
+ await prisma.pushSubscription.deleteMany({
+ where: { endpoint: subscription.endpoint },
+ });
+ }
+ }
+ }),
+ );
+}
\ No newline at end of file
diff --git a/next.config.ts b/next.config.ts
index e9ffa30..8a63b0a 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,32 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ async headers() {
+ return [
+ {
+ source: "/sw.js",
+ headers: [
+ {
+ key: "Content-Type",
+ value: "application/javascript; charset=utf-8",
+ },
+ {
+ key: "Cache-Control",
+ value: "no-cache, no-store, must-revalidate",
+ },
+ ],
+ },
+ {
+ source: "/manifest.webmanifest",
+ headers: [
+ {
+ key: "Cache-Control",
+ value: "public, max-age=0, must-revalidate",
+ },
+ ],
+ },
+ ];
+ },
};
export default nextConfig;
diff --git a/package.json b/package.json
index 576b819..b30e244 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "eslint"
+ "lint": "eslint",
+ "generate:icons": "node scripts/generate-icons.mjs"
},
"dependencies": {
"@prisma/adapter-pg": "^7.7.0",
@@ -16,13 +17,16 @@
"pg": "^8.20.0",
"qr-scanner": "^1.4.2",
"react": "19.2.4",
- "react-dom": "19.2.4"
+ "react-dom": "19.2.4",
+ "sharp": "^0.34.5",
+ "web-push": "^3.6.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@types/web-push": "^3.6.4",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"prisma": "^7.7.0",
diff --git a/prisma/migrations/20260417040955_add_message_urls_and_push_subscriptions/migration.sql b/prisma/migrations/20260417040955_add_message_urls_and_push_subscriptions/migration.sql
new file mode 100644
index 0000000..ba70ec6
--- /dev/null
+++ b/prisma/migrations/20260417040955_add_message_urls_and_push_subscriptions/migration.sql
@@ -0,0 +1,35 @@
+/*
+ Warnings:
+
+ - A unique constraint covering the columns `[publicId]` on the table `FamilyMessage` will be added. If there are existing duplicate values, this will fail.
+
+*/
+-- AlterTable
+ALTER TABLE "FamilyMessage" ADD COLUMN "publicId" SERIAL NOT NULL;
+
+-- CreateTable
+CREATE TABLE "PushSubscription" (
+ "id" TEXT NOT NULL,
+ "caregiverId" TEXT NOT NULL,
+ "endpoint" TEXT NOT NULL,
+ "p256dh" TEXT NOT NULL,
+ "auth" TEXT NOT NULL,
+ "expirationTime" TIMESTAMP(3),
+ "userAgent" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");
+
+-- CreateIndex
+CREATE INDEX "PushSubscription_caregiverId_updatedAt_idx" ON "PushSubscription"("caregiverId", "updatedAt" DESC);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "FamilyMessage_publicId_key" ON "FamilyMessage"("publicId");
+
+-- AddForeignKey
+ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_caregiverId_fkey" FOREIGN KEY ("caregiverId") REFERENCES "CaregiverAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 94db3e1..040b3d7 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -53,6 +53,7 @@ model CaregiverAccount {
updatedAt DateTime @updatedAt
bindings DeviceBinding[]
messages FamilyMessage[]
+ pushSubscriptions PushSubscription[]
}
model ElderDevice {
@@ -84,6 +85,7 @@ model DeviceBinding {
model FamilyMessage {
id String @id @default(cuid())
+ publicId Int @unique @default(autoincrement())
elderDeviceId String
caregiverId String?
direction MessageDirection
@@ -102,6 +104,21 @@ model FamilyMessage {
@@index([direction, status, createdAt(sort: Desc)])
}
+model PushSubscription {
+ id String @id @default(cuid())
+ caregiverId String
+ endpoint String @unique
+ p256dh String
+ auth String
+ expirationTime DateTime?
+ userAgent String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ caregiver CaregiverAccount @relation(fields: [caregiverId], references: [id], onDelete: Cascade)
+
+ @@index([caregiverId, updatedAt(sort: Desc)])
+}
+
model ConversationTurn {
id String @id @default(cuid())
elderDeviceId String
diff --git a/public/pwa/apple-touch-icon.png b/public/pwa/apple-touch-icon.png
new file mode 100644
index 0000000..558226f
Binary files /dev/null and b/public/pwa/apple-touch-icon.png differ
diff --git a/public/pwa/badge-96x96.png b/public/pwa/badge-96x96.png
new file mode 100644
index 0000000..8fbe4ed
Binary files /dev/null and b/public/pwa/badge-96x96.png differ
diff --git a/public/pwa/icon-192x192.png b/public/pwa/icon-192x192.png
new file mode 100644
index 0000000..5220eac
Binary files /dev/null and b/public/pwa/icon-192x192.png differ
diff --git a/public/pwa/icon-512x512.png b/public/pwa/icon-512x512.png
new file mode 100644
index 0000000..cf10daf
Binary files /dev/null and b/public/pwa/icon-512x512.png differ
diff --git a/public/pwa/icon-maskable-512x512.png b/public/pwa/icon-maskable-512x512.png
new file mode 100644
index 0000000..872daea
Binary files /dev/null and b/public/pwa/icon-maskable-512x512.png differ
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..ce8923e
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,66 @@
+self.addEventListener("push", (event) => {
+ let data = {
+ title: "家人连线台",
+ body: "收到一条新的家人留言。",
+ url: "/",
+ icon: "/pwa/icon-192x192.png",
+ badge: "/pwa/badge-96x96.png",
+ tag: "digital-human-message",
+ };
+
+ if (event.data) {
+ try {
+ data = { ...data, ...event.data.json() };
+ } catch {
+ data = { ...data, body: event.data.text() };
+ }
+ }
+
+ event.waitUntil(
+ self.registration.showNotification(data.title, {
+ body: data.body,
+ icon: data.icon,
+ badge: data.badge,
+ tag: data.tag,
+ data: {
+ url: data.url,
+ },
+ }),
+ );
+});
+
+self.addEventListener("notificationclick", (event) => {
+ const targetUrl = new URL(
+ event.notification.data?.url || "/",
+ self.location.origin,
+ ).toString();
+
+ event.notification.close();
+
+ event.waitUntil(
+ clients.matchAll({ type: "window", includeUncontrolled: true }).then(
+ async (clientList) => {
+ for (const client of clientList) {
+ if (!("focus" in client)) {
+ continue;
+ }
+
+ if (client.url === targetUrl) {
+ return client.focus();
+ }
+
+ if (client.url.startsWith(self.location.origin) && "navigate" in client) {
+ await client.navigate(targetUrl);
+ return client.focus();
+ }
+ }
+
+ if (clients.openWindow) {
+ return clients.openWindow(targetUrl);
+ }
+
+ return undefined;
+ },
+ ),
+ );
+});
\ No newline at end of file
diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs
new file mode 100644
index 0000000..9dbe9f0
--- /dev/null
+++ b/scripts/generate-icons.mjs
@@ -0,0 +1,106 @@
+import { mkdir } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import sharp from "sharp";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const projectRoot = path.resolve(__dirname, "..");
+const sourceIconPath = path.join(projectRoot, "icon.png");
+const pwaOutputDir = path.join(projectRoot, "public", "pwa");
+
+const androidResArg = process.argv.find((arg) =>
+ arg.startsWith("--android-res-dir="),
+);
+const androidResDir = androidResArg
+ ? androidResArg.slice("--android-res-dir=".length)
+ : process.env.ANDROID_RES_DIR;
+
+function createCircleMask(size) {
+ return Buffer.from(`
+
+ `);
+}
+
+async function writePng(size, outputPath) {
+ await sharp(sourceIconPath).resize(size, size).png().toFile(outputPath);
+}
+
+async function writeMaskablePng(size, outputPath) {
+ const foreground = await sharp(sourceIconPath)
+ .resize(Math.round(size * 0.82), Math.round(size * 0.82), {
+ fit: "contain",
+ })
+ .png()
+ .toBuffer();
+
+ await sharp({
+ create: {
+ width: size,
+ height: size,
+ channels: 4,
+ background: "#35548d",
+ },
+ })
+ .composite([
+ {
+ input: foreground,
+ gravity: "center",
+ },
+ ])
+ .png()
+ .toFile(outputPath);
+}
+
+async function writeAndroidIcons(resDir) {
+ const densities = [
+ { dir: "mipmap-mdpi", size: 48 },
+ { dir: "mipmap-hdpi", size: 72 },
+ { dir: "mipmap-xhdpi", size: 96 },
+ { dir: "mipmap-xxhdpi", size: 144 },
+ { dir: "mipmap-xxxhdpi", size: 192 },
+ ];
+
+ for (const density of densities) {
+ const targetDir = path.join(resDir, density.dir);
+ await mkdir(targetDir, { recursive: true });
+
+ const squareBuffer = await sharp(sourceIconPath)
+ .resize(density.size, density.size)
+ .png()
+ .toBuffer();
+
+ await sharp(squareBuffer)
+ .webp({ quality: 92 })
+ .toFile(path.join(targetDir, "ic_launcher.webp"));
+
+ await sharp(squareBuffer)
+ .composite([
+ {
+ input: createCircleMask(density.size),
+ blend: "dest-in",
+ },
+ ])
+ .webp({ quality: 92 })
+ .toFile(path.join(targetDir, "ic_launcher_round.webp"));
+ }
+}
+
+await mkdir(pwaOutputDir, { recursive: true });
+
+await Promise.all([
+ writePng(192, path.join(pwaOutputDir, "icon-192x192.png")),
+ writePng(512, path.join(pwaOutputDir, "icon-512x512.png")),
+ writePng(180, path.join(pwaOutputDir, "apple-touch-icon.png")),
+ writePng(96, path.join(pwaOutputDir, "badge-96x96.png")),
+ writeMaskablePng(512, path.join(pwaOutputDir, "icon-maskable-512x512.png")),
+]);
+
+if (androidResDir) {
+ await writeAndroidIcons(androidResDir);
+}
+
+console.log("Icons generated successfully.");
\ No newline at end of file