From 70d42e6792fd1ccd95c492a2cb0b48dc6b5c19c2 Mon Sep 17 00:00:00 2001 From: feie9456 Date: Fri, 17 Apr 2026 15:08:39 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=AE=BE=E5=A4=87=E5=88=AB?= =?UTF-8?q?=E5=90=8D=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=AE=BE=E5=A4=87=E5=88=AB=E5=90=8D=E7=9A=84=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E4=B8=8E=E8=A7=A3=E7=BB=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/family/device-binding/route.ts | 82 ++++++++ app/devices/[deviceUuid]/page.tsx | 8 +- components/dashboard-actions.tsx | 156 +++++++++++++- lib/caregiver-panel.ts | 95 +++++++++ lib/monitor-data.ts | 100 ++++++++- lib/panel-format.ts | 7 +- lib/push.ts | 191 ++++++++++++------ .../migration.sql | 2 + prisma/schema.prisma | 1 + 9 files changed, 570 insertions(+), 72 deletions(-) create mode 100644 app/api/family/device-binding/route.ts create mode 100644 prisma/migrations/20260417070000_add_device_binding_alias/migration.sql diff --git a/app/api/family/device-binding/route.ts b/app/api/family/device-binding/route.ts new file mode 100644 index 0000000..75be832 --- /dev/null +++ b/app/api/family/device-binding/route.ts @@ -0,0 +1,82 @@ +import { NextResponse } from "next/server"; + +import { + unbindDeviceFromCaregiver, + updateCaregiverDeviceAlias, +} from "@/lib/monitor-data"; +import { getDeviceName } from "@/lib/panel-format"; +import { getCaregiverSession } from "@/lib/session"; + +export async function PATCH(request: Request) { + const body = (await request.json().catch(() => null)) as + | { + deviceUuid?: string; + alias?: string | null; + } + | null; + + if (!body?.deviceUuid || typeof body.deviceUuid !== "string") { + return NextResponse.json({ error: "请先提供目标设备。" }, { status: 400 }); + } + + try { + const caregiver = await getCaregiverSession(); + + if (!caregiver) { + return NextResponse.json({ error: "请先登录账号。" }, { status: 401 }); + } + + const binding = await updateCaregiverDeviceAlias({ + caregiverId: caregiver.id, + rawDeviceValue: body.deviceUuid, + alias: typeof body.alias === "string" || body.alias === null ? body.alias : undefined, + }); + + return NextResponse.json({ + ok: true, + alias: binding.alias, + deviceName: getDeviceName(binding.alias, binding.elderDevice.displayName), + }); + } catch (error) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : "设备别名保存失败。", + }, + { status: 400 }, + ); + } +} + +export async function DELETE(request: Request) { + const body = (await request.json().catch(() => null)) as + | { + deviceUuid?: string; + } + | null; + + if (!body?.deviceUuid || typeof body.deviceUuid !== "string") { + return NextResponse.json({ error: "请先提供目标设备。" }, { status: 400 }); + } + + try { + const caregiver = await getCaregiverSession(); + + if (!caregiver) { + return NextResponse.json({ error: "请先登录账号。" }, { status: 401 }); + } + + const result = await unbindDeviceFromCaregiver(caregiver.id, body.deviceUuid); + + return NextResponse.json({ + ok: true, + deviceUuid: result.deviceUuid, + }); + } catch (error) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : "解绑设备失败。", + }, + { status: 400 }, + ); + } +} \ No newline at end of file diff --git a/app/devices/[deviceUuid]/page.tsx b/app/devices/[deviceUuid]/page.tsx index 8fed237..c588d44 100644 --- a/app/devices/[deviceUuid]/page.tsx +++ b/app/devices/[deviceUuid]/page.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import { notFound } from "next/navigation"; -import { SendMessageForm } from "@/components/dashboard-actions"; +import { ManageDeviceForm, SendMessageForm } from "@/components/dashboard-actions"; import { PanelShell } from "@/components/panel-shell"; import { getCaregiverDeviceDetail } from "@/lib/caregiver-panel"; import { @@ -74,6 +74,12 @@ export default async function DeviceDetailPage({ params }: DeviceDetailPageProps
+ +
diff --git a/components/dashboard-actions.tsx b/components/dashboard-actions.tsx index 65b8a30..4da908b 100644 --- a/components/dashboard-actions.tsx +++ b/components/dashboard-actions.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; -import { startTransition, useState } from "react"; +import { startTransition, useEffect, useState } from "react"; export function BindDeviceForm() { const router = useRouter(); @@ -196,4 +196,158 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) { ) : null} ); +} + +type ManageDeviceFormProps = { + deviceUuid: string; + initialAlias?: string | null; + deviceLabel: string; +}; + +export function ManageDeviceForm({ + deviceUuid, + initialAlias, + deviceLabel, +}: ManageDeviceFormProps) { + const router = useRouter(); + const [alias, setAlias] = useState(initialAlias ?? ""); + const [saving, setSaving] = useState(false); + const [unbinding, setUnbinding] = useState(false); + const [notice, setNotice] = useState(null); + + useEffect(() => { + setAlias(initialAlias ?? ""); + }, [initialAlias]); + + async function handleSaveAlias(event: React.FormEvent) { + event.preventDefault(); + setSaving(true); + setNotice("正在保存设备别名……"); + + try { + const response = await fetch("/api/family/device-binding", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + deviceUuid, + alias, + }), + }); + + const payload = (await response.json().catch(() => null)) as + | { error?: string; alias?: string | null; deviceName?: string } + | null; + + if (!response.ok) { + throw new Error(payload?.error || "设备别名保存失败。"); + } + + setAlias(payload?.alias?.trim() || ""); + setNotice( + payload?.alias?.trim() + ? `已将这台设备备注为“${payload.alias.trim()}”。\n` + : "已恢复默认设备名称。\n", + ); + startTransition(() => { + router.refresh(); + }); + } catch (error) { + setNotice( + error instanceof Error ? error.message : "设备别名保存失败。", + ); + } finally { + setSaving(false); + } + } + + async function handleUnbind() { + if (!window.confirm(`解绑后将不再收到“${deviceLabel}”的留言和推送提醒,确定继续吗?`)) { + return; + } + + setUnbinding(true); + setNotice("正在解绑这台设备……"); + + try { + const response = await fetch("/api/family/device-binding", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + deviceUuid, + }), + }); + + const payload = (await response.json().catch(() => null)) as + | { error?: string } + | null; + + if (!response.ok) { + throw new Error(payload?.error || "解绑设备失败。",); + } + + startTransition(() => { + router.push("/devices"); + router.refresh(); + }); + } catch (error) { + setNotice( + error instanceof Error ? error.message : "解绑设备失败。", + ); + setUnbinding(false); + } + } + + return ( +
+

+ 设备管理 +

+

+ 修改别名或解绑设备 +

+

+ 设备别名会显示在设备列表、留言页和推送通知里,例如“外婆的手机”。 +

+ +
+ setAlias(event.target.value)} + maxLength={24} + placeholder="给这台设备起个好记的名字" + className="h-12 flex-1 rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-5 text-sm text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white" + /> + +
+ +
+ +
+ + {notice ? ( +

+ {notice} +

+ ) : null} +
+ ); } \ No newline at end of file diff --git a/lib/caregiver-panel.ts b/lib/caregiver-panel.ts index d21aef0..93d4af2 100644 --- a/lib/caregiver-panel.ts +++ b/lib/caregiver-panel.ts @@ -11,6 +11,61 @@ const deviceSummarySelect = { lastSeenAt: true, } as const; +async function loadCaregiverDeviceNameMap( + caregiverId: string, + elderDeviceIds: string[], +) { + if (elderDeviceIds.length === 0) { + return new Map(); + } + + const bindings = await prisma.deviceBinding.findMany({ + where: { + caregiverId, + elderDeviceId: { + in: elderDeviceIds, + }, + }, + select: { + elderDeviceId: true, + alias: true, + elderDevice: { + select: { + displayName: true, + }, + }, + }, + }); + + return new Map( + bindings + .map((binding) => [ + binding.elderDeviceId, + binding.alias?.trim() || binding.elderDevice.displayName?.trim() || "", + ] as const) + .filter((entry) => entry[1].length > 0), + ); +} + +function applyCaregiverDeviceNames< + T extends { + elderDeviceId: string; + elderDevice: { + displayName: string | null; + }; + }, +>(items: T[], deviceNameMap: Map) { + items.forEach((item) => { + const resolvedName = deviceNameMap.get(item.elderDeviceId); + + if (resolvedName) { + item.elderDevice.displayName = resolvedName; + } + }); + + return items; +} + export async function getCaregiverOverview(caregiverId: string) { const dashboard = await getCaregiverDashboard(caregiverId); @@ -36,6 +91,11 @@ export async function getCaregiverOverview(caregiverId: string) { where: { direction: MessageDirection.FAMILY_TO_ELDER, caregiverId, + elderDevice: { + bindings: { + some: { caregiverId }, + }, + }, }, include: { elderDevice: { @@ -47,6 +107,15 @@ export async function getCaregiverOverview(caregiverId: string) { }), ]); + const deviceNameMap = new Map( + dashboard.devices + .map((device) => [device.elderDeviceId, device.elderDevice.displayName?.trim() || ""] as const) + .filter((entry) => entry[1].length > 0), + ); + + applyCaregiverDeviceNames(recentIncomingMessages, deviceNameMap); + applyCaregiverDeviceNames(recentOutgoingMessages, deviceNameMap); + return { dashboard, recentIncomingMessages, @@ -87,11 +156,23 @@ export async function getCaregiverMessages(caregiverId: string) { where: { direction: MessageDirection.FAMILY_TO_ELDER, caregiverId, + elderDevice: { + bindings: { + some: { caregiverId }, + }, + }, readAt: null, }, }), ]); + const deviceNameMap = await loadCaregiverDeviceNameMap( + caregiverId, + [...new Set(messages.map((message) => message.elderDeviceId))], + ); + + applyCaregiverDeviceNames(messages, deviceNameMap); + return { messages, unreadIncomingCount, @@ -140,6 +221,9 @@ export async function getCaregiverMessageDetail( }); } + const deviceNameMap = await loadCaregiverDeviceNameMap(caregiverId, [message.elderDeviceId]); + applyCaregiverDeviceNames([message], deviceNameMap); + return message; } @@ -215,6 +299,11 @@ export async function getCaregiverDeviceDetail( return { ...binding, + elderDevice: { + ...binding.elderDevice, + displayName: + binding.alias?.trim() || binding.elderDevice.displayName?.trim() || null, + }, bindUrl: createBindUrl(binding.elderDevice.deviceUuid), familyUnreadCount, elderUnreadCount, @@ -278,6 +367,12 @@ export async function getCaregiverActivity(caregiverId: string) { }), ]); + const deviceNameMap = await loadCaregiverDeviceNameMap(caregiverId, elderDeviceIds); + + applyCaregiverDeviceNames(usageEvents, deviceNameMap); + applyCaregiverDeviceNames(conversationTurns, deviceNameMap); + applyCaregiverDeviceNames(toolCalls, deviceNameMap); + return { usageEvents, conversationTurns, diff --git a/lib/monitor-data.ts b/lib/monitor-data.ts index a375366..2b319f0 100644 --- a/lib/monitor-data.ts +++ b/lib/monitor-data.ts @@ -46,6 +46,7 @@ type ToolCallInput = { const PUBLIC_BASE_URL = ( process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net" ).replace(/\/+$/, ""); +const DEVICE_ALIAS_MAX_LENGTH = 24; const DEVICE_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -90,6 +91,24 @@ export function extractDeviceUuid(rawValue: string) { return null; } +function normalizeDeviceAlias(rawAlias?: string | null) { + const alias = rawAlias?.trim() || null; + + if (!alias) { + return null; + } + + if (alias.length > DEVICE_ALIAS_MAX_LENGTH) { + throw new Error(`设备别名请控制在 ${DEVICE_ALIAS_MAX_LENGTH} 个字以内。`); + } + + return alias; +} + +function resolveBindingDisplayName(alias?: string | null, displayName?: string | null) { + return alias?.trim() || displayName?.trim() || null; +} + export async function ensureDeviceRegistration(input: RegisterDeviceInput) { const deviceUuid = normalizeDeviceUuid(input.deviceUuid); @@ -177,6 +196,7 @@ async function migrateLegacyCaregiverDataForDevice( }, select: { elderDeviceId: true, + alias: true, }, }); @@ -189,10 +209,11 @@ async function migrateLegacyCaregiverDataForDevice( elderDeviceId: binding.elderDeviceId, }, }, - update: {}, + update: binding.alias ? { alias: binding.alias } : {}, create: { caregiverId, elderDeviceId: binding.elderDeviceId, + alias: binding.alias, }, }), ), @@ -393,6 +414,76 @@ export async function createFamilyMessageFromCaregiver(input: { }); } +async function findCaregiverDeviceBinding( + caregiverId: string, + rawDeviceValue: string, +) { + const deviceUuid = extractDeviceUuid(rawDeviceValue); + + if (!deviceUuid) { + throw new Error("设备码格式不正确,请重新扫码或粘贴。"); + } + + const binding = await prisma.deviceBinding.findFirst({ + where: { + caregiverId, + elderDevice: { + deviceUuid, + }, + }, + include: { + elderDevice: true, + }, + }); + + if (!binding) { + throw new Error("当前家属账号还没有绑定这台设备。"); + } + + return binding; +} + +export async function updateCaregiverDeviceAlias(input: { + caregiverId: string; + rawDeviceValue: string; + alias?: string | null; +}) { + const binding = await findCaregiverDeviceBinding( + input.caregiverId, + input.rawDeviceValue, + ); + const alias = normalizeDeviceAlias(input.alias); + + return prisma.deviceBinding.update({ + where: { + id: binding.id, + }, + data: { + alias, + }, + include: { + elderDevice: true, + }, + }); +} + +export async function unbindDeviceFromCaregiver( + caregiverId: string, + rawDeviceValue: string, +) { + const binding = await findCaregiverDeviceBinding(caregiverId, rawDeviceValue); + + await prisma.deviceBinding.delete({ + where: { + id: binding.id, + }, + }); + + return { + deviceUuid: binding.elderDevice.deviceUuid, + }; +} + export async function recordUsageEvent(input: UsageEventInput) { const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid }); @@ -591,6 +682,13 @@ export async function getCaregiverDashboard(caregiverId: string) { caregiver, devices: caregiver.bindings.map((binding) => ({ ...binding, + elderDevice: { + ...binding.elderDevice, + displayName: resolveBindingDisplayName( + binding.alias, + binding.elderDevice.displayName, + ), + }, familyUnreadCount: familyUnreadMap.get(binding.elderDeviceId) || 0, elderUnreadCount: elderUnreadMap.get(binding.elderDeviceId) || 0, bindUrl: createBindUrl(binding.elderDevice.deviceUuid), diff --git a/lib/panel-format.ts b/lib/panel-format.ts index 5bb1584..8550de6 100644 --- a/lib/panel-format.ts +++ b/lib/panel-format.ts @@ -109,6 +109,9 @@ export function getMessageHref(publicId: number) { return `/messages/${publicId}`; } -export function getDeviceName(displayName?: string | null) { - return displayName?.trim() || "长辈的设备"; +export function getDeviceName( + primaryName?: string | null, + fallbackName?: string | null, +) { + return primaryName?.trim() || fallbackName?.trim() || "长辈的设备"; } \ No newline at end of file diff --git a/lib/push.ts b/lib/push.ts index ade108b..ed3d1db 100644 --- a/lib/push.ts +++ b/lib/push.ts @@ -39,6 +39,11 @@ type PushDeliveryResult = PushDeliverySummary & { failureDetails?: PushFailureDetail[]; }; +type PushAttemptResult = { + outcome: "sent" | "removed" | "failed"; + detail?: PushFailureDetail; +}; + const APP_BASE_URL = ( process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net" ).replace(/\/+$/, ""); @@ -191,6 +196,66 @@ function getPushFailureDetail(subscription: StoredPushSubscription, error: unkno } satisfies PushFailureDetail; } +async function sendPushNotification(input: { + subscription: StoredPushSubscription; + payload: string; + topic?: string; + collectFailureDetails?: boolean; +}) { + const requestOptions: { + TTL: number; + urgency: "high"; + topic?: string; + } = { + TTL: 60 * 30, + urgency: "high", + }; + + if (input.topic && !shouldOmitTopic(input.subscription.endpoint)) { + requestOptions.topic = input.topic; + } + + try { + await webpush.sendNotification( + { + endpoint: input.subscription.endpoint, + expirationTime: input.subscription.expirationTime?.getTime() || null, + keys: { + p256dh: input.subscription.p256dh, + auth: input.subscription.auth, + }, + }, + input.payload, + requestOptions, + ); + + return { + outcome: "sent", + } satisfies PushAttemptResult; + } catch (error) { + const detail = getPushFailureDetail(input.subscription, error); + const statusCode = detail.statusCode || 0; + + console.error("Push notification delivery failed", detail); + + if (statusCode === 404 || statusCode === 410) { + await prisma.pushSubscription.deleteMany({ + where: { endpoint: input.subscription.endpoint }, + }); + + return { + outcome: "removed", + detail: input.collectFailureDetails ? detail : undefined, + } satisfies PushAttemptResult; + } + + return { + outcome: "failed", + detail: input.collectFailureDetails ? detail : undefined, + } satisfies PushAttemptResult; + } +} + async function deliverPushToSubscriptions(input: { subscriptions: StoredPushSubscription[]; payload: string; @@ -209,66 +274,26 @@ async function deliverPushToSubscriptions(input: { ensureVapidDetails(); - const failureDetails: PushFailureDetail[] = []; - const results = await Promise.all( input.subscriptions.map(async (subscription) => { - try { - const requestOptions: { - TTL: number; - urgency: "high"; - topic?: string; - } = { - TTL: 60 * 30, - urgency: "high", - }; - - if (input.topic && !shouldOmitTopic(subscription.endpoint)) { - requestOptions.topic = input.topic; - } - - await webpush.sendNotification( - { - endpoint: subscription.endpoint, - expirationTime: subscription.expirationTime?.getTime() || null, - keys: { - p256dh: subscription.p256dh, - auth: subscription.auth, - }, - }, - input.payload, - requestOptions, - ); - - return "sent" as const; - } catch (error) { - const detail = getPushFailureDetail(subscription, error); - const statusCode = detail.statusCode || 0; - - console.error("Push notification delivery failed", detail); - - if (input.collectFailureDetails) { - failureDetails.push(detail); - } - - if (statusCode === 404 || statusCode === 410) { - await prisma.pushSubscription.deleteMany({ - where: { endpoint: subscription.endpoint }, - }); - - return "removed" as const; - } - - return "failed" as const; - } + return sendPushNotification({ + subscription, + payload: input.payload, + topic: input.topic, + collectFailureDetails: input.collectFailureDetails, + }); }), ); + const failureDetails = results + .map((result) => result.detail) + .filter((detail): detail is PushFailureDetail => Boolean(detail)); + return { targetedCount: input.subscriptions.length, - sentCount: results.filter((result) => result === "sent").length, - removedCount: results.filter((result) => result === "removed").length, - failedCount: results.filter((result) => result === "failed").length, + sentCount: results.filter((result) => result.outcome === "sent").length, + removedCount: results.filter((result) => result.outcome === "removed").length, + failedCount: results.filter((result) => result.outcome === "failed").length, failureDetails, } satisfies PushDeliveryResult; } @@ -308,6 +333,25 @@ export async function sendIncomingMessagePush(publicId: number) { }, }, }, + select: { + endpoint: true, + expirationTime: true, + p256dh: true, + auth: true, + caregiver: { + select: { + bindings: { + where: { + elderDeviceId: message.elderDeviceId, + }, + select: { + alias: true, + }, + take: 1, + }, + }, + }, + }, }); if (subscriptions.length === 0) { @@ -316,21 +360,34 @@ export async function sendIncomingMessagePush(publicId: number) { 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.all( + subscriptions.map(async (subscription) => { + const deviceName = getDeviceName( + subscription.caregiver.bindings[0]?.alias, + message.elderDevice.displayName, + ); - await deliverPushToSubscriptions({ - subscriptions, - payload, - topic: `msg-${message.publicId}`, - }); + 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 sendPushNotification({ + subscription: { + endpoint: subscription.endpoint, + expirationTime: subscription.expirationTime, + p256dh: subscription.p256dh, + auth: subscription.auth, + }, + payload, + topic: `msg-${message.publicId}`, + }); + }), + ); } export async function sendCaregiverTestPush(input: { diff --git a/prisma/migrations/20260417070000_add_device_binding_alias/migration.sql b/prisma/migrations/20260417070000_add_device_binding_alias/migration.sql new file mode 100644 index 0000000..d5863c5 --- /dev/null +++ b/prisma/migrations/20260417070000_add_device_binding_alias/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "DeviceBinding" +ADD COLUMN IF NOT EXISTS "alias" TEXT; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b6bb398..8864ec2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -90,6 +90,7 @@ model DeviceBinding { id String @id @default(cuid()) caregiverId String elderDeviceId String + alias String? createdAt DateTime @default(now()) caregiver CaregiverAccount @relation(fields: [caregiverId], references: [id], onDelete: Cascade) elderDevice ElderDevice @relation(fields: [elderDeviceId], references: [id], onDelete: Cascade)