新增设备别名管理功能,支持设备别名的保存与解绑

This commit is contained in:
feie9456 2026-04-17 15:08:39 +08:00
parent 71760cbb1f
commit 70d42e6792
9 changed files with 570 additions and 72 deletions

View File

@ -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 },
);
}
}

View File

@ -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
<section className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<div className="space-y-6">
<ManageDeviceForm
deviceUuid={binding.elderDevice.deviceUuid}
initialAlias={binding.alias}
deviceLabel={getDeviceName(binding.elderDevice.displayName)}
/>
<SendMessageForm deviceUuid={binding.elderDevice.deviceUuid} />
<section className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">

View File

@ -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();
@ -197,3 +197,157 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
</form>
);
}
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<string | null>(null);
useEffect(() => {
setAlias(initialAlias ?? "");
}, [initialAlias]);
async function handleSaveAlias(event: React.FormEvent<HTMLFormElement>) {
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 (
<section className="rounded-[28px] border border-white/70 bg-white/88 p-5">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
</p>
<h4 className="mt-2 font-[family-name:var(--font-display)] text-xl text-[var(--ink)]">
</h4>
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
</p>
<form className="mt-4 flex flex-col gap-3 sm:flex-row" onSubmit={handleSaveAlias}>
<input
value={alias}
onChange={(event) => 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"
/>
<button
type="submit"
disabled={saving || unbinding}
className="inline-flex h-12 min-w-32 items-center justify-center rounded-full bg-[var(--olive)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--olive-deep)] disabled:cursor-not-allowed disabled:opacity-60"
>
{saving ? "正在保存" : "保存别名"}
</button>
</form>
<div className="mt-4 flex flex-wrap gap-3">
<button
type="button"
onClick={() => {
void handleUnbind();
}}
disabled={saving || unbinding}
className="inline-flex h-11 items-center justify-center rounded-full border border-[rgba(160,74,48,0.28)] bg-white px-4 text-sm font-semibold text-[var(--ink)] transition hover:bg-[rgba(199,103,51,0.08)] disabled:cursor-not-allowed disabled:opacity-60"
>
{unbinding ? "正在解绑" : "解绑这台设备"}
</button>
</div>
{notice ? (
<p className="mt-3 whitespace-pre-line text-sm leading-7 text-[var(--muted)]">
{notice}
</p>
) : null}
</section>
);
}

View File

@ -11,6 +11,61 @@ const deviceSummarySelect = {
lastSeenAt: true,
} as const;
async function loadCaregiverDeviceNameMap(
caregiverId: string,
elderDeviceIds: string[],
) {
if (elderDeviceIds.length === 0) {
return new Map<string, string>();
}
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<string, string>) {
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,

View File

@ -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),

View File

@ -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() || "长辈的设备";
}

View File

@ -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 sendPushNotification({
subscription,
payload: input.payload,
topic: input.topic,
collectFailureDetails: input.collectFailureDetails,
});
return "removed" as const;
}
return "failed" as const;
}
}),
);
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,7 +360,13 @@ export async function sendIncomingMessagePush(publicId: number) {
ensureVapidDetails();
const deviceName = getDeviceName(message.elderDevice.displayName);
await Promise.all(
subscriptions.map(async (subscription) => {
const deviceName = getDeviceName(
subscription.caregiver.bindings[0]?.alias,
message.elderDevice.displayName,
);
const payload = JSON.stringify({
title: `${deviceName} 发来一条新留言`,
body: truncateText(message.content, 72),
@ -326,11 +376,18 @@ export async function sendIncomingMessagePush(publicId: number) {
tag: `message-${message.publicId}`,
});
await deliverPushToSubscriptions({
subscriptions,
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: {

View File

@ -0,0 +1,2 @@
ALTER TABLE "DeviceBinding"
ADD COLUMN IF NOT EXISTS "alias" TEXT;

View File

@ -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)