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