新增设备别名管理功能,支持设备别名的保存与解绑
This commit is contained in:
parent
71760cbb1f
commit
70d42e6792
82
app/api/family/device-binding/route.ts
Normal file
82
app/api/family/device-binding/route.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
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 { PanelShell } from "@/components/panel-shell";
|
||||||
import { getCaregiverDeviceDetail } from "@/lib/caregiver-panel";
|
import { getCaregiverDeviceDetail } from "@/lib/caregiver-panel";
|
||||||
import {
|
import {
|
||||||
@ -74,6 +74,12 @@ export default async function DeviceDetailPage({ params }: DeviceDetailPageProps
|
|||||||
|
|
||||||
<section className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
<section className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<ManageDeviceForm
|
||||||
|
deviceUuid={binding.elderDevice.deviceUuid}
|
||||||
|
initialAlias={binding.alias}
|
||||||
|
deviceLabel={getDeviceName(binding.elderDevice.displayName)}
|
||||||
|
/>
|
||||||
|
|
||||||
<SendMessageForm deviceUuid={binding.elderDevice.deviceUuid} />
|
<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)]">
|
<section className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { startTransition, useState } from "react";
|
import { startTransition, useEffect, useState } from "react";
|
||||||
|
|
||||||
export function BindDeviceForm() {
|
export function BindDeviceForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -196,4 +196,158 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</form>
|
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@ -11,6 +11,61 @@ const deviceSummarySelect = {
|
|||||||
lastSeenAt: true,
|
lastSeenAt: true,
|
||||||
} as const;
|
} 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) {
|
export async function getCaregiverOverview(caregiverId: string) {
|
||||||
const dashboard = await getCaregiverDashboard(caregiverId);
|
const dashboard = await getCaregiverDashboard(caregiverId);
|
||||||
|
|
||||||
@ -36,6 +91,11 @@ export async function getCaregiverOverview(caregiverId: string) {
|
|||||||
where: {
|
where: {
|
||||||
direction: MessageDirection.FAMILY_TO_ELDER,
|
direction: MessageDirection.FAMILY_TO_ELDER,
|
||||||
caregiverId,
|
caregiverId,
|
||||||
|
elderDevice: {
|
||||||
|
bindings: {
|
||||||
|
some: { caregiverId },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
elderDevice: {
|
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 {
|
return {
|
||||||
dashboard,
|
dashboard,
|
||||||
recentIncomingMessages,
|
recentIncomingMessages,
|
||||||
@ -87,11 +156,23 @@ export async function getCaregiverMessages(caregiverId: string) {
|
|||||||
where: {
|
where: {
|
||||||
direction: MessageDirection.FAMILY_TO_ELDER,
|
direction: MessageDirection.FAMILY_TO_ELDER,
|
||||||
caregiverId,
|
caregiverId,
|
||||||
|
elderDevice: {
|
||||||
|
bindings: {
|
||||||
|
some: { caregiverId },
|
||||||
|
},
|
||||||
|
},
|
||||||
readAt: null,
|
readAt: null,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const deviceNameMap = await loadCaregiverDeviceNameMap(
|
||||||
|
caregiverId,
|
||||||
|
[...new Set(messages.map((message) => message.elderDeviceId))],
|
||||||
|
);
|
||||||
|
|
||||||
|
applyCaregiverDeviceNames(messages, deviceNameMap);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
unreadIncomingCount,
|
unreadIncomingCount,
|
||||||
@ -140,6 +221,9 @@ export async function getCaregiverMessageDetail(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deviceNameMap = await loadCaregiverDeviceNameMap(caregiverId, [message.elderDeviceId]);
|
||||||
|
applyCaregiverDeviceNames([message], deviceNameMap);
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -215,6 +299,11 @@ export async function getCaregiverDeviceDetail(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...binding,
|
...binding,
|
||||||
|
elderDevice: {
|
||||||
|
...binding.elderDevice,
|
||||||
|
displayName:
|
||||||
|
binding.alias?.trim() || binding.elderDevice.displayName?.trim() || null,
|
||||||
|
},
|
||||||
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
|
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
|
||||||
familyUnreadCount,
|
familyUnreadCount,
|
||||||
elderUnreadCount,
|
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 {
|
return {
|
||||||
usageEvents,
|
usageEvents,
|
||||||
conversationTurns,
|
conversationTurns,
|
||||||
|
|||||||
@ -46,6 +46,7 @@ type ToolCallInput = {
|
|||||||
const PUBLIC_BASE_URL = (
|
const PUBLIC_BASE_URL = (
|
||||||
process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net"
|
process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net"
|
||||||
).replace(/\/+$/, "");
|
).replace(/\/+$/, "");
|
||||||
|
const DEVICE_ALIAS_MAX_LENGTH = 24;
|
||||||
|
|
||||||
const DEVICE_UUID_PATTERN =
|
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;
|
/^[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;
|
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) {
|
export async function ensureDeviceRegistration(input: RegisterDeviceInput) {
|
||||||
const deviceUuid = normalizeDeviceUuid(input.deviceUuid);
|
const deviceUuid = normalizeDeviceUuid(input.deviceUuid);
|
||||||
|
|
||||||
@ -177,6 +196,7 @@ async function migrateLegacyCaregiverDataForDevice(
|
|||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
elderDeviceId: true,
|
elderDeviceId: true,
|
||||||
|
alias: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -189,10 +209,11 @@ async function migrateLegacyCaregiverDataForDevice(
|
|||||||
elderDeviceId: binding.elderDeviceId,
|
elderDeviceId: binding.elderDeviceId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
update: {},
|
update: binding.alias ? { alias: binding.alias } : {},
|
||||||
create: {
|
create: {
|
||||||
caregiverId,
|
caregiverId,
|
||||||
elderDeviceId: binding.elderDeviceId,
|
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) {
|
export async function recordUsageEvent(input: UsageEventInput) {
|
||||||
const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid });
|
const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid });
|
||||||
|
|
||||||
@ -591,6 +682,13 @@ export async function getCaregiverDashboard(caregiverId: string) {
|
|||||||
caregiver,
|
caregiver,
|
||||||
devices: caregiver.bindings.map((binding) => ({
|
devices: caregiver.bindings.map((binding) => ({
|
||||||
...binding,
|
...binding,
|
||||||
|
elderDevice: {
|
||||||
|
...binding.elderDevice,
|
||||||
|
displayName: resolveBindingDisplayName(
|
||||||
|
binding.alias,
|
||||||
|
binding.elderDevice.displayName,
|
||||||
|
),
|
||||||
|
},
|
||||||
familyUnreadCount: familyUnreadMap.get(binding.elderDeviceId) || 0,
|
familyUnreadCount: familyUnreadMap.get(binding.elderDeviceId) || 0,
|
||||||
elderUnreadCount: elderUnreadMap.get(binding.elderDeviceId) || 0,
|
elderUnreadCount: elderUnreadMap.get(binding.elderDeviceId) || 0,
|
||||||
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
|
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
|
||||||
|
|||||||
@ -109,6 +109,9 @@ export function getMessageHref(publicId: number) {
|
|||||||
return `/messages/${publicId}`;
|
return `/messages/${publicId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDeviceName(displayName?: string | null) {
|
export function getDeviceName(
|
||||||
return displayName?.trim() || "长辈的设备";
|
primaryName?: string | null,
|
||||||
|
fallbackName?: string | null,
|
||||||
|
) {
|
||||||
|
return primaryName?.trim() || fallbackName?.trim() || "长辈的设备";
|
||||||
}
|
}
|
||||||
191
lib/push.ts
191
lib/push.ts
@ -39,6 +39,11 @@ type PushDeliveryResult = PushDeliverySummary & {
|
|||||||
failureDetails?: PushFailureDetail[];
|
failureDetails?: PushFailureDetail[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PushAttemptResult = {
|
||||||
|
outcome: "sent" | "removed" | "failed";
|
||||||
|
detail?: PushFailureDetail;
|
||||||
|
};
|
||||||
|
|
||||||
const APP_BASE_URL = (
|
const APP_BASE_URL = (
|
||||||
process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net"
|
process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net"
|
||||||
).replace(/\/+$/, "");
|
).replace(/\/+$/, "");
|
||||||
@ -191,6 +196,66 @@ function getPushFailureDetail(subscription: StoredPushSubscription, error: unkno
|
|||||||
} satisfies PushFailureDetail;
|
} 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: {
|
async function deliverPushToSubscriptions(input: {
|
||||||
subscriptions: StoredPushSubscription[];
|
subscriptions: StoredPushSubscription[];
|
||||||
payload: string;
|
payload: string;
|
||||||
@ -209,66 +274,26 @@ async function deliverPushToSubscriptions(input: {
|
|||||||
|
|
||||||
ensureVapidDetails();
|
ensureVapidDetails();
|
||||||
|
|
||||||
const failureDetails: PushFailureDetail[] = [];
|
|
||||||
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
input.subscriptions.map(async (subscription) => {
|
input.subscriptions.map(async (subscription) => {
|
||||||
try {
|
return sendPushNotification({
|
||||||
const requestOptions: {
|
subscription,
|
||||||
TTL: number;
|
payload: input.payload,
|
||||||
urgency: "high";
|
topic: input.topic,
|
||||||
topic?: string;
|
collectFailureDetails: input.collectFailureDetails,
|
||||||
} = {
|
});
|
||||||
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;
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const failureDetails = results
|
||||||
|
.map((result) => result.detail)
|
||||||
|
.filter((detail): detail is PushFailureDetail => Boolean(detail));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
targetedCount: input.subscriptions.length,
|
targetedCount: input.subscriptions.length,
|
||||||
sentCount: results.filter((result) => result === "sent").length,
|
sentCount: results.filter((result) => result.outcome === "sent").length,
|
||||||
removedCount: results.filter((result) => result === "removed").length,
|
removedCount: results.filter((result) => result.outcome === "removed").length,
|
||||||
failedCount: results.filter((result) => result === "failed").length,
|
failedCount: results.filter((result) => result.outcome === "failed").length,
|
||||||
failureDetails,
|
failureDetails,
|
||||||
} satisfies PushDeliveryResult;
|
} 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) {
|
if (subscriptions.length === 0) {
|
||||||
@ -316,21 +360,34 @@ export async function sendIncomingMessagePush(publicId: number) {
|
|||||||
|
|
||||||
ensureVapidDetails();
|
ensureVapidDetails();
|
||||||
|
|
||||||
const deviceName = getDeviceName(message.elderDevice.displayName);
|
await Promise.all(
|
||||||
const payload = JSON.stringify({
|
subscriptions.map(async (subscription) => {
|
||||||
title: `${deviceName} 发来一条新留言`,
|
const deviceName = getDeviceName(
|
||||||
body: truncateText(message.content, 72),
|
subscription.caregiver.bindings[0]?.alias,
|
||||||
url: absoluteUrl(`/messages/${message.publicId}`),
|
message.elderDevice.displayName,
|
||||||
icon: absoluteUrl("/pwa/icon-192x192.png"),
|
);
|
||||||
badge: absoluteUrl("/pwa/badge-96x96.png"),
|
|
||||||
tag: `message-${message.publicId}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
await deliverPushToSubscriptions({
|
const payload = JSON.stringify({
|
||||||
subscriptions,
|
title: `${deviceName} 发来一条新留言`,
|
||||||
payload,
|
body: truncateText(message.content, 72),
|
||||||
topic: `msg-${message.publicId}`,
|
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: {
|
export async function sendCaregiverTestPush(input: {
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "DeviceBinding"
|
||||||
|
ADD COLUMN IF NOT EXISTS "alias" TEXT;
|
||||||
@ -90,6 +90,7 @@ model DeviceBinding {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
caregiverId String
|
caregiverId String
|
||||||
elderDeviceId String
|
elderDeviceId String
|
||||||
|
alias String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
caregiver CaregiverAccount @relation(fields: [caregiverId], references: [id], onDelete: Cascade)
|
caregiver CaregiverAccount @relation(fields: [caregiverId], references: [id], onDelete: Cascade)
|
||||||
elderDevice ElderDevice @relation(fields: [elderDeviceId], references: [id], onDelete: Cascade)
|
elderDevice ElderDevice @relation(fields: [elderDeviceId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user