Compare commits
No commits in common. "0da5936ce0a7b969f01b865c7fcf3060863acd73" and "8c9d86e85835fac60ca1ecf89a06bd41fd1bdd4a" have entirely different histories.
0da5936ce0
...
8c9d86e858
@ -1,113 +0,0 @@
|
|||||||
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 (
|
|
||||||
<PanelShell
|
|
||||||
currentPath="/activity"
|
|
||||||
title="活动记录独立成页,回看陪伴过程更清楚。"
|
|
||||||
description="这里专门收下老人端的使用事件、最近对话片段和工具调用,不再和留言流混排。"
|
|
||||||
caregiverToken={caregiver.sessionToken}
|
|
||||||
>
|
|
||||||
{usageEvents.length === 0 && conversationTurns.length === 0 && toolCalls.length === 0 ? (
|
|
||||||
<section className="rounded-[32px] border border-dashed border-[var(--line)] bg-white/72 px-6 py-10 text-center shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
|
||||||
<h2 className="font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
还没有活动记录
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
|
|
||||||
等老人端开始对话、切换镜头或触发桥接工具后,这里会自动累积活动时间线。
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
<section className="grid gap-6 xl:grid-cols-3">
|
|
||||||
<div className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
使用事件
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
老人端操作轨迹
|
|
||||||
</h2>
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{usageEvents.map((event) => (
|
|
||||||
<div key={event.id} className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<div className="flex items-center justify-between gap-3 text-xs text-[var(--muted)]">
|
|
||||||
<span>{getDeviceName(event.elderDevice.displayName)}</span>
|
|
||||||
<span>{formatDateTime(event.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-sm leading-7 text-[var(--ink)]">
|
|
||||||
{getUsageLabel(event.eventType)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
对话片段
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
最近说了什么
|
|
||||||
</h2>
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{conversationTurns.map((turn) => (
|
|
||||||
<div key={turn.id} className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<div className="flex items-center justify-between gap-3 text-xs text-[var(--muted)]">
|
|
||||||
<span>{getConversationRoleLabel(turn.role)}</span>
|
|
||||||
<span>{formatDateTime(turn.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-[var(--muted)]">
|
|
||||||
{getDeviceName(turn.elderDevice.displayName)}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm leading-7 text-[var(--ink)]">
|
|
||||||
{truncateText(turn.content, 132)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
工具调用
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
function calling 执行记录
|
|
||||||
</h2>
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{toolCalls.map((log) => (
|
|
||||||
<div key={log.id} className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<div className="flex items-center justify-between gap-3 text-xs text-[var(--muted)]">
|
|
||||||
<span>{getDeviceName(log.elderDevice.displayName)}</span>
|
|
||||||
<span>{formatDateTime(log.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-sm font-semibold text-[var(--ink)]">
|
|
||||||
{log.toolName}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
{truncateText(log.outputText || log.argumentsJson, 136)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</PanelShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -35,11 +35,7 @@ export async function POST(request: Request) {
|
|||||||
importance: typeof body.importance === "string" ? body.importance : undefined,
|
importance: typeof body.importance === "string" ? body.importance : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({ ok: true, messageId: message.id });
|
||||||
ok: true,
|
|
||||||
messageId: message.id,
|
|
||||||
publicId: message.publicId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -20,12 +20,7 @@ export async function GET(request: NextRequest) {
|
|||||||
request.nextUrl.searchParams.get("redirectTo"),
|
request.nextUrl.searchParams.get("redirectTo"),
|
||||||
);
|
);
|
||||||
const existingCaregiver = await getCaregiverSession();
|
const existingCaregiver = await getCaregiverSession();
|
||||||
const response = new NextResponse(null, {
|
const response = NextResponse.redirect(new URL(redirectTo, request.url));
|
||||||
status: 307,
|
|
||||||
headers: {
|
|
||||||
Location: redirectTo,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingCaregiver) {
|
if (existingCaregiver) {
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
@ -43,10 +43,10 @@ export default async function BindPage({ searchParams }: BindPageProps) {
|
|||||||
去扫码
|
去扫码
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/devices"
|
href="/"
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-white"
|
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-white"
|
||||||
>
|
>
|
||||||
回到设备页
|
回到控制台
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -88,16 +88,16 @@ export default async function BindPage({ searchParams }: BindPageProps) {
|
|||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
<Link
|
<Link
|
||||||
href={`/devices/${device.deviceUuid}`}
|
href="/"
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
||||||
>
|
>
|
||||||
打开这台设备页
|
回到控制台
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/devices"
|
href="/scan"
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
||||||
>
|
>
|
||||||
查看全部设备
|
继续绑定其他老人
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,220 +0,0 @@
|
|||||||
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 (
|
|
||||||
<PanelShell
|
|
||||||
currentPath="/devices"
|
|
||||||
title={getDeviceName(binding.elderDevice.displayName)}
|
|
||||||
description="这是一台设备的专属页面。给老人写问候、查看这台设备最近的留言、活动和对话,都在这里完成。"
|
|
||||||
caregiverToken={caregiver.sessionToken}
|
|
||||||
>
|
|
||||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
|
||||||
<p className="text-sm text-[var(--muted)]">老人新留言</p>
|
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
|
||||||
{binding.elderUnreadCount}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
|
||||||
<p className="text-sm text-[var(--muted)]">待老人查看</p>
|
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
|
||||||
{binding.familyUnreadCount}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
|
||||||
<p className="text-sm text-[var(--muted)]">最近在线</p>
|
|
||||||
<p className="mt-3 text-lg font-semibold text-[var(--ink)]">
|
|
||||||
{formatDateTime(binding.elderDevice.lastSeenAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
|
||||||
<p className="text-sm text-[var(--muted)]">绑定链接</p>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
|
|
||||||
{truncateText(binding.bindUrl, 52)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<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)]">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
老人给家里的话
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
最近通过桥接工具送出的内容
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/messages"
|
|
||||||
className="inline-flex h-11 items-center justify-center rounded-full border border-[var(--line)] bg-white px-4 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
|
||||||
>
|
|
||||||
去留言中心
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{elderMessages.length === 0 ? (
|
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
这台设备暂时还没有新的老人留言。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
elderMessages.map((message) => (
|
|
||||||
<Link
|
|
||||||
key={message.id}
|
|
||||||
href={getMessageHref(message.publicId)}
|
|
||||||
className="block rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 transition hover:bg-white"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--muted)]">
|
|
||||||
<span className="rounded-full bg-white px-3 py-1 font-semibold text-[var(--ink)]">
|
|
||||||
#{message.publicId}
|
|
||||||
</span>
|
|
||||||
<span>{getImportanceLabel(message.importance)}</span>
|
|
||||||
<span>{formatDateTime(message.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
|
|
||||||
{truncateText(message.content, 112)}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
最近活动
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
这台设备刚刚发生了什么
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{binding.elderDevice.usageEvents.length === 0 ? (
|
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
暂时还没有同步到新的使用事件。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
binding.elderDevice.usageEvents.map((event) => (
|
|
||||||
<div key={event.id} className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<div className="flex items-center justify-between gap-3 text-xs text-[var(--muted)]">
|
|
||||||
<span>{getUsageLabel(event.eventType)}</span>
|
|
||||||
<span>{formatDateTime(event.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<section className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
家属给老人的问候
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
最近写给这位老人的内容
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{familyMessages.length === 0 ? (
|
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
还没有发给这位老人的问候。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
familyMessages.map((message) => (
|
|
||||||
<Link
|
|
||||||
key={message.id}
|
|
||||||
href={getMessageHref(message.publicId)}
|
|
||||||
className="block rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 transition hover:bg-white"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--muted)]">
|
|
||||||
<span className="rounded-full bg-white px-3 py-1 font-semibold text-[var(--ink)]">
|
|
||||||
#{message.publicId}
|
|
||||||
</span>
|
|
||||||
<span>{getImportanceLabel(message.importance)}</span>
|
|
||||||
<span>{formatDateTime(message.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
|
|
||||||
{truncateText(message.content, 112)}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
最近对话片段
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
快速回看陪伴内容
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{binding.elderDevice.conversationTurns.length === 0 ? (
|
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
这台设备最近还没有同步到对话片段。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
binding.elderDevice.conversationTurns.map((turn) => (
|
|
||||||
<div key={turn.id} className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<div className="flex items-center justify-between gap-3 text-xs text-[var(--muted)]">
|
|
||||||
<span>{getConversationRoleLabel(turn.role)}</span>
|
|
||||||
<span>{formatDateTime(turn.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
|
|
||||||
{truncateText(turn.content, 124)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</PanelShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,146 +0,0 @@
|
|||||||
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 (
|
|
||||||
<PanelShell
|
|
||||||
currentPath="/devices"
|
|
||||||
title="设备管理和绑定入口单独放在这里。"
|
|
||||||
description="手动粘贴设备码、扫码绑定、查看某台设备的专属页,都统一放在设备页,不再和首页摘要、活动记录混在一起。"
|
|
||||||
caregiverToken={caregiver.sessionToken}
|
|
||||||
actions={<PwaControls />}
|
|
||||||
>
|
|
||||||
<section className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
|
||||||
<BindDeviceForm />
|
|
||||||
|
|
||||||
<div className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
设备入口说明
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
一台设备一个详情页
|
|
||||||
</h2>
|
|
||||||
<div className="mt-4 space-y-3 rounded-[24px] bg-[var(--paper-soft)] p-5 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
<p>1. 先通过扫码或粘贴设备码建立连接。</p>
|
|
||||||
<p>2. 进入设备详情页给这位老人写留言,不再在首页里混排。</p>
|
|
||||||
<p>3. 每台设备都有自己的最近留言、对话片段和工具调用摘要。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
|
||||||
<Link
|
|
||||||
href="/scan"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--olive)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--olive-deep)]"
|
|
||||||
>
|
|
||||||
打开扫码页
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/messages"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
|
||||||
>
|
|
||||||
去留言中心
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{devices.length === 0 ? (
|
|
||||||
<section className="rounded-[32px] border border-dashed border-[var(--line)] bg-white/72 px-6 py-10 text-center shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
|
||||||
<h2 className="font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
还没有绑定设备
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
|
|
||||||
等待绑定成功后,每台设备都会在这里生成自己的专属入口,后续写留言和查看状态都从设备详情页进入。
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
<section className="grid gap-6 lg:grid-cols-2">
|
|
||||||
{devices.map((binding) => {
|
|
||||||
const latestMessage = binding.elderDevice.messages[0];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
key={binding.id}
|
|
||||||
className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]"
|
|
||||||
>
|
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
设备详情
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
{getDeviceName(binding.elderDevice.displayName)}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
|
|
||||||
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
|
||||||
最近在线:{formatDateTime(binding.elderDevice.lastSeenAt)}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
|
||||||
老人新留言 {binding.elderUnreadCount}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
|
||||||
待老人查看 {binding.familyUnreadCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
|
||||||
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
|
|
||||||
<p className="text-xs text-[var(--muted)]">留言总数</p>
|
|
||||||
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
|
|
||||||
{binding.elderDevice._count.messages}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
|
|
||||||
<p className="text-xs text-[var(--muted)]">对话条数</p>
|
|
||||||
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
|
|
||||||
{binding.elderDevice._count.conversationTurns}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
|
|
||||||
<p className="text-xs text-[var(--muted)]">工具调用</p>
|
|
||||||
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
|
|
||||||
{binding.elderDevice._count.toolCalls}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<p className="text-sm font-semibold text-[var(--ink)]">最近一条动态</p>
|
|
||||||
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
{latestMessage
|
|
||||||
? truncateText(latestMessage.content, 96)
|
|
||||||
: "这台设备还没有同步到新的留言或问候。"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
|
||||||
<Link
|
|
||||||
href={`/devices/${binding.elderDevice.deviceUuid}`}
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
|
||||||
>
|
|
||||||
打开设备页
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/messages"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
|
||||||
>
|
|
||||||
去留言中心
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</PanelShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Noto_Sans_SC, Noto_Serif_SC } from "next/font/google";
|
import { Noto_Sans_SC, Noto_Serif_SC } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@ -18,38 +18,6 @@ export const metadata: Metadata = {
|
|||||||
metadataBase: new URL("https://digital-human.xn--876a.net"),
|
metadataBase: new URL("https://digital-human.xn--876a.net"),
|
||||||
title: "家人连线台",
|
title: "家人连线台",
|
||||||
description: "给老人设备做绑定、查看留言、追踪陪伴记录的家属端。",
|
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({
|
export default function RootLayout({
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
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 (
|
|
||||||
<PanelShell
|
|
||||||
currentPath="/messages"
|
|
||||||
title={`留言 #${message.publicId}`}
|
|
||||||
description="这是单条留言详情页。无论从通知点击进来,还是从留言中心点开,都能在这里看到完整内容与所属设备信息。"
|
|
||||||
caregiverToken={caregiver.sessionToken}
|
|
||||||
>
|
|
||||||
<section className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
|
||||||
<article className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm text-[var(--muted)]">
|
|
||||||
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1 font-semibold text-[var(--ink)]">
|
|
||||||
#{message.publicId}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1">
|
|
||||||
{getMessageDirectionLabel(message.direction)}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1">
|
|
||||||
{getImportanceLabel(message.importance)}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1">
|
|
||||||
{getMessageStatusLabel(message)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="mt-5 font-[family-name:var(--font-display)] text-4xl leading-[1.2] text-[var(--ink)]">
|
|
||||||
{message.content}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{message.recipientRelation ? (
|
|
||||||
<p className="mt-4 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
目标对象:{message.recipientRelation}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<aside className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
留言信息
|
|
||||||
</p>
|
|
||||||
<div className="mt-5 space-y-4 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<p>所属设备</p>
|
|
||||||
<p className="mt-2 font-semibold text-[var(--ink)]">
|
|
||||||
{getDeviceName(message.elderDevice.displayName)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<p>设备 UUID</p>
|
|
||||||
<p className="mt-2 break-all font-semibold text-[var(--ink)]">
|
|
||||||
{message.elderDevice.deviceUuid}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<p>创建时间</p>
|
|
||||||
<p className="mt-2 font-semibold text-[var(--ink)]">
|
|
||||||
{formatDateTime(message.createdAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<p>最近在线</p>
|
|
||||||
<p className="mt-2 font-semibold text-[var(--ink)]">
|
|
||||||
{formatDateTime(message.elderDevice.lastSeenAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
|
||||||
<Link
|
|
||||||
href="/messages"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
|
||||||
>
|
|
||||||
回到留言中心
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/devices/${message.elderDevice.deviceUuid}`}
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
|
||||||
>
|
|
||||||
打开这台设备页
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
</PanelShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,146 +0,0 @@
|
|||||||
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 (
|
|
||||||
<PanelShell
|
|
||||||
currentPath="/messages"
|
|
||||||
title="留言独立成页后,每条消息都能用编号直达。"
|
|
||||||
description="不论是老人留给家里的话,还是家属写给老人的问候,都集中在这里。点击任何一条卡片,都会进入带 URL 编号的详情页。"
|
|
||||||
caregiverToken={caregiver.sessionToken}
|
|
||||||
actions={<PwaControls />}
|
|
||||||
>
|
|
||||||
<section className="grid gap-4 md:grid-cols-3">
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
|
||||||
<p className="text-sm text-[var(--muted)]">全部留言</p>
|
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
|
||||||
{messages.length}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
|
||||||
<p className="text-sm text-[var(--muted)]">老人给家里的未读留言</p>
|
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
|
||||||
{unreadIncomingCount}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
|
||||||
<p className="text-sm text-[var(--muted)]">待老人查看的家属留言</p>
|
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
|
||||||
{unreadOutgoingCount}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{messages.length === 0 ? (
|
|
||||||
<section className="rounded-[32px] border border-dashed border-[var(--line)] bg-white/72 px-6 py-10 text-center shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
|
||||||
<h2 className="font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
还没有留言记录
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
|
|
||||||
去设备页给老人留一句问候,或等待老人通过桥接工具给家里捎话;新记录会自动出现在这里,并带有可分享的消息编号。
|
|
||||||
</p>
|
|
||||||
<div className="mt-6 flex justify-center gap-3">
|
|
||||||
<Link
|
|
||||||
href="/devices"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
|
||||||
>
|
|
||||||
去设备页
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/scan"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
|
||||||
>
|
|
||||||
去扫码绑定
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
<section className="grid gap-6 xl:grid-cols-2">
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
title: "老人给家里的留言",
|
|
||||||
subtitle: "收到新话时也会从这里直达详情页。",
|
|
||||||
items: incomingMessages,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "家属给老人的问候",
|
|
||||||
subtitle: "写出去的每条问候也有独立 URL,可回看送达状态。",
|
|
||||||
items: outgoingMessages,
|
|
||||||
},
|
|
||||||
].map((section) => (
|
|
||||||
<div
|
|
||||||
key={section.title}
|
|
||||||
className="rounded-[32px] border border-white/75 bg-white/80 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]"
|
|
||||||
>
|
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
留言列表
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
{section.title}
|
|
||||||
</h2>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--muted)]">{section.subtitle}</p>
|
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{section.items.length === 0 ? (
|
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
这一侧暂时还没有留言。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
section.items.map((message) => (
|
|
||||||
<Link
|
|
||||||
key={message.id}
|
|
||||||
href={getMessageHref(message.publicId)}
|
|
||||||
className="block rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 transition hover:bg-white"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--muted)]">
|
|
||||||
<span className="rounded-full bg-white px-3 py-1 font-semibold text-[var(--ink)]">
|
|
||||||
#{message.publicId}
|
|
||||||
</span>
|
|
||||||
<span>{getMessageDirectionLabel(message.direction)}</span>
|
|
||||||
<span>{getDeviceName(message.elderDevice.displayName)}</span>
|
|
||||||
<span>{getImportanceLabel(message.importance)}</span>
|
|
||||||
<span>{getMessageStatusLabel(message)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
|
|
||||||
{truncateText(message.content, 112)}
|
|
||||||
</p>
|
|
||||||
<p className="mt-3 text-xs text-[var(--muted)]">
|
|
||||||
创建于 {formatDateTime(message.createdAt)}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</PanelShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
510
app/page.tsx
510
app/page.tsx
@ -1,23 +1,70 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { PanelShell } from "@/components/panel-shell";
|
import { BindDeviceForm, SendMessageForm } from "@/components/dashboard-actions";
|
||||||
import { PwaControls } from "@/components/pwa-controls";
|
import { getCaregiverDashboard } from "@/lib/monitor-data";
|
||||||
import { getCaregiverOverview } from "@/lib/caregiver-panel";
|
|
||||||
import {
|
import {
|
||||||
formatDateTime,
|
buildCaregiverSessionBootstrapPath,
|
||||||
getDeviceName,
|
getCaregiverSession,
|
||||||
getImportanceLabel,
|
} from "@/lib/session";
|
||||||
getMessageHref,
|
|
||||||
truncateText,
|
|
||||||
} from "@/lib/panel-format";
|
|
||||||
import { requireCaregiverSession } from "@/lib/page-auth";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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() {
|
export default async function Home() {
|
||||||
const caregiver = await requireCaregiverSession("/");
|
const caregiver = await getCaregiverSession();
|
||||||
const { dashboard, recentIncomingMessages, recentOutgoingMessages } =
|
|
||||||
await getCaregiverOverview(caregiver.id);
|
if (!caregiver) {
|
||||||
|
redirect(buildCaregiverSessionBootstrapPath("/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboard = await getCaregiverDashboard(caregiver.id);
|
||||||
|
|
||||||
const totalUnreadForElder = dashboard.devices.reduce(
|
const totalUnreadForElder = dashboard.devices.reduce(
|
||||||
(total, device) => total + device.familyUnreadCount,
|
(total, device) => total + device.familyUnreadCount,
|
||||||
@ -33,82 +80,124 @@ export default async function Home() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<main className="relative overflow-hidden px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
|
||||||
currentPath="/"
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-64 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.78),transparent_68%)]" />
|
||||||
title="首页只保留摘要,不再把所有操作挤在一处。"
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
description="设备绑定、留言管理、活动回看和扫码入口已经拆到独立路径。这里保留今天最值得先看的摘要,以及最近的新留言。"
|
<section className="paper-panel relative overflow-hidden rounded-[36px] border border-white/70 px-6 py-8 shadow-[0_35px_90px_rgba(105,68,41,0.13)] sm:px-8 lg:px-10 lg:py-10">
|
||||||
caregiverToken={dashboard.caregiver.sessionToken}
|
<div className="absolute -right-10 top-[-52px] h-40 w-40 rounded-full bg-[rgba(200,103,51,0.10)] blur-2xl" />
|
||||||
actions={<PwaControls />}
|
<div className="absolute left-10 top-8 h-20 w-20 rounded-full border border-[rgba(118,132,94,0.18)]" />
|
||||||
>
|
|
||||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-8 lg:grid-cols-[1.2fr_0.8fr] lg:items-end">
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
<div>
|
||||||
<p className="text-sm text-[var(--muted)]">已连接老人设备</p>
|
<p className="text-sm font-semibold tracking-[0.24em] text-[var(--copper)] uppercase">
|
||||||
|
Digital Human Bridge
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-4 max-w-3xl font-[family-name:var(--font-display)] text-4xl leading-[1.15] text-[var(--ink)] sm:text-5xl">
|
||||||
|
把老人的陪伴对话、留言和家人问候,收进同一张暖色桌面。
|
||||||
|
</h1>
|
||||||
|
<p className="mt-5 max-w-2xl text-base leading-8 text-[var(--muted)] sm:text-lg">
|
||||||
|
这里是家属端控制台。扫码绑定以后,您能看到老人留给家里的话、老人最近的使用轨迹、重要对话片段,也能反过来给老人写下一句问候。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
|
||||||
|
<span className="rounded-full border border-[var(--line)] bg-white/70 px-4 py-2">
|
||||||
|
当前家属账号:{dashboard.caregiver.sessionToken.slice(0, 8).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-[var(--line)] bg-white/70 px-4 py-2">
|
||||||
|
已连接 {dashboard.devices.length} 位老人设备
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-1">
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
||||||
|
<p className="text-sm text-[var(--muted)]">家属已绑定设备</p>
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
||||||
{dashboard.devices.length}
|
{dashboard.devices.length}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
||||||
<p className="text-sm text-[var(--muted)]">老人给家里的未读留言</p>
|
<p className="text-sm text-[var(--muted)]">老人未读家属留言</p>
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
|
||||||
{totalUnreadForCaregiver}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
|
||||||
<p className="text-sm text-[var(--muted)]">待老人查看的家属留言</p>
|
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
||||||
{totalUnreadForElder}
|
{totalUnreadForElder}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
||||||
<p className="text-sm text-[var(--muted)]">最近存档对话条数</p>
|
<p className="text-sm text-[var(--muted)]">最近存档对话条数</p>
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
||||||
{totalConversations}
|
{totalConversations}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-4 lg:grid-cols-4">
|
<BindDeviceForm />
|
||||||
{[
|
|
||||||
{
|
<section className="grid gap-6 lg:grid-cols-[0.85fr_1.15fr]">
|
||||||
href: "/messages",
|
<div className="rounded-[32px] border border-white/70 bg-white/78 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)] backdrop-blur">
|
||||||
title: "留言中心",
|
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
text: "集中看所有老人留言和家属留言,每条消息都有 URL 编号可直接打开。",
|
今日总览
|
||||||
tone: "bg-[rgba(53,84,141,0.12)]",
|
</p>
|
||||||
},
|
<div className="mt-4 space-y-4">
|
||||||
{
|
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
||||||
href: "/devices",
|
<p className="text-sm text-[var(--muted)]">老人给家里的新留言</p>
|
||||||
title: "设备与绑定",
|
<p className="mt-2 text-3xl font-semibold text-[var(--ink)]">
|
||||||
text: "去管理设备、扫码绑定、查看某一台设备的专属详情页和留言入口。",
|
{totalUnreadForCaregiver}
|
||||||
tone: "bg-[rgba(118,132,94,0.14)]",
|
</p>
|
||||||
},
|
|
||||||
{
|
|
||||||
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) => (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className="rounded-[28px] border border-white/80 bg-white/84 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)] transition hover:-translate-y-0.5"
|
|
||||||
>
|
|
||||||
<div className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold text-[var(--ink)] ${item.tone}`}>
|
|
||||||
跳转路径
|
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mt-4 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
||||||
{item.title}
|
<p className="text-sm text-[var(--muted)]">老人待接收的家属留言</p>
|
||||||
</h2>
|
<p className="mt-2 text-3xl font-semibold text-[var(--ink)]">
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--muted)]">{item.text}</p>
|
{totalUnreadForElder}
|
||||||
<p className="mt-4 text-sm font-semibold text-[var(--copper)]">前往 {item.href}</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
||||||
|
<p className="text-sm text-[var(--muted)]">扫码绑定更顺手</p>
|
||||||
|
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
|
||||||
|
如果您正拿着另一台手机,可以直接打开扫码页,对准老人的家人连接二维码。
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/scan"
|
||||||
|
className="mt-4 inline-flex h-11 items-center justify-center rounded-full bg-[var(--olive)] px-4 text-sm font-semibold text-white transition hover:bg-[var(--olive-deep)]"
|
||||||
|
>
|
||||||
|
去扫码
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[32px] border border-white/70 bg-white/78 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)] backdrop-blur">
|
||||||
|
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
|
面板说明
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="rounded-[24px] bg-[var(--paper-soft)] p-5">
|
||||||
|
<h3 className="font-[family-name:var(--font-display)] text-xl text-[var(--ink)]">
|
||||||
|
留言桥接
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
|
||||||
|
老人在数字人对话里说“给儿子带句话”时,模型会走 function calling,把内容存到这里。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] bg-[var(--paper-soft)] p-5">
|
||||||
|
<h3 className="font-[family-name:var(--font-display)] text-xl text-[var(--ink)]">
|
||||||
|
陪伴记录
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
|
||||||
|
面板会收下老人端的重要使用事件、最近对话片段和工具调用记录,方便您回看状态。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] bg-[var(--paper-soft)] p-5">
|
||||||
|
<h3 className="font-[family-name:var(--font-display)] text-xl text-[var(--ink)]">
|
||||||
|
反向留言
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
|
||||||
|
家属端写下的留言会进到老人设备的消息列表里,也能被 function calling 工具读取给老人听。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{dashboard.devices.length === 0 ? (
|
{dashboard.devices.length === 0 ? (
|
||||||
@ -117,123 +206,234 @@ export default async function Home() {
|
|||||||
还没有绑定老人设备
|
还没有绑定老人设备
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-3 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
<h2 className="mt-3 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
||||||
先去设备页或扫码页建立第一条连接
|
先扫一下老人手机里的二维码
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
|
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
|
||||||
绑定完成后,留言中心会出现 URL 编号消息卡,活动页会出现陪伴记录,推送也能直达具体留言。
|
绑定完成后,这里会出现老人的留言、陪伴轨迹、最近对话和工具调用记录。当前页面已经替您准备好扫码与手动绑定入口。
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 flex justify-center gap-3">
|
|
||||||
<Link
|
|
||||||
href="/devices"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
|
||||||
>
|
|
||||||
去设备页
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/scan"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
|
||||||
>
|
|
||||||
直接扫码
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<section className="grid gap-6 xl:grid-cols-2">
|
<section className="space-y-6">
|
||||||
<div className="rounded-[32px] border border-white/75 bg-white/80 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
{dashboard.devices.map((binding) => {
|
||||||
<div className="flex items-center justify-between gap-3">
|
const familyMessages = binding.elderDevice.messages.filter(
|
||||||
|
(message) => message.direction === "FAMILY_TO_ELDER",
|
||||||
|
);
|
||||||
|
const elderMessages = binding.elderDevice.messages.filter(
|
||||||
|
(message) => message.direction === "ELDER_TO_FAMILY",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={binding.id}
|
||||||
|
className="rounded-[36px] border border-white/75 bg-white/80 p-6 shadow-[0_28px_70px_rgba(108,73,41,0.12)] backdrop-blur lg:p-7"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
新收到的老人留言
|
老人设备
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
||||||
点一下就能按编号直达
|
{binding.elderDevice.displayName || "未命名陪伴设备"}
|
||||||
</h2>
|
</h2>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
|
||||||
|
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
||||||
|
UUID:{binding.elderDevice.deviceUuid}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
||||||
|
最近在线:{formatTime(binding.elderDevice.lastSeenAt)}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
||||||
|
绑定链接:{binding.bindUrl}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
|
||||||
href="/messages"
|
|
||||||
className="inline-flex h-11 items-center justify-center rounded-full border border-[var(--line)] bg-white px-4 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
|
||||||
>
|
|
||||||
查看全部留言
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
<div className="grid gap-3 sm:grid-cols-4">
|
||||||
{recentIncomingMessages.length === 0 ? (
|
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
<p className="text-xs text-[var(--muted)]">老人新留言</p>
|
||||||
目前还没有新的老人留言。
|
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
|
||||||
|
{binding.elderUnreadCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
|
||||||
|
<p className="text-xs text-[var(--muted)]">待老人查看</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
|
||||||
|
{binding.familyUnreadCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
|
||||||
|
<p className="text-xs text-[var(--muted)]">对话条数</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
|
||||||
|
{binding.elderDevice._count.conversationTurns}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
|
||||||
|
<p className="text-xs text-[var(--muted)]">工具调用</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
|
||||||
|
{binding.elderDevice._count.toolCalls}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="rounded-[28px] bg-[var(--paper-soft)] p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium tracking-[0.16em] text-[var(--copper)] uppercase">
|
||||||
|
老人给家里留的话
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
||||||
|
最近想转达给家人的内容
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-white px-4 py-2 text-sm text-[var(--ink)]">
|
||||||
|
未读 {binding.elderUnreadCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{elderMessages.length === 0 ? (
|
||||||
|
<p className="rounded-[22px] bg-white/80 px-4 py-4 text-sm leading-7 text-[var(--muted)]">
|
||||||
|
老人暂时还没有通过大模型给家里捎话。
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
recentIncomingMessages.map((message) => (
|
elderMessages.map((message) => (
|
||||||
<Link
|
<div key={message.id} className="rounded-[22px] bg-white/88 px-4 py-4">
|
||||||
key={message.id}
|
|
||||||
href={getMessageHref(message.publicId)}
|
|
||||||
className="block rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 transition hover:bg-white"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--muted)]">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--muted)]">
|
||||||
<span className="rounded-full bg-white px-3 py-1 font-semibold text-[var(--ink)]">
|
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1">
|
||||||
#{message.publicId}
|
{getImportanceLabel(message.importance)}
|
||||||
</span>
|
</span>
|
||||||
<span>{getDeviceName(message.elderDevice.displayName)}</span>
|
{message.recipientRelation ? (
|
||||||
<span>{getImportanceLabel(message.importance)}</span>
|
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1">
|
||||||
<span>{formatDateTime(message.createdAt)}</span>
|
目标:{message.recipientRelation}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span>{formatTime(message.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
|
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
|
||||||
{truncateText(message.content)}
|
{message.content}
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div className="rounded-[32px] border border-white/75 bg-white/80 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
<section className="rounded-[28px] bg-[var(--paper-soft)] p-5">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<p className="text-sm font-medium tracking-[0.16em] text-[var(--copper)] uppercase">
|
||||||
<div>
|
最近活动
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
最近发给老人的问候
|
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
<h3 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
||||||
发出后也能按编号回看
|
老人端刚刚发生了什么
|
||||||
</h2>
|
</h3>
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/devices"
|
|
||||||
className="inline-flex h-11 items-center justify-center rounded-full border border-[var(--line)] bg-white px-4 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
|
||||||
>
|
|
||||||
去设备页留言
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
{recentOutgoingMessages.length === 0 ? (
|
{binding.elderDevice.usageEvents.length === 0 ? (
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
<p className="rounded-[22px] bg-white/80 px-4 py-4 text-sm leading-7 text-[var(--muted)]">
|
||||||
还没有新的家属留言,可以到设备页针对某一台设备写下问候。
|
暂时还没有同步到新的使用记录。
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
recentOutgoingMessages.map((message) => (
|
binding.elderDevice.usageEvents.map((event) => (
|
||||||
<Link
|
<div key={event.id} className="rounded-[22px] bg-white/88 px-4 py-4">
|
||||||
key={message.id}
|
<div className="flex items-center justify-between gap-3 text-xs text-[var(--muted)]">
|
||||||
href={getMessageHref(message.publicId)}
|
<span>{getUsageLabel(event.eventType)}</span>
|
||||||
className="block rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 transition hover:bg-white"
|
<span>{formatTime(event.createdAt)}</span>
|
||||||
>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--muted)]">
|
|
||||||
<span className="rounded-full bg-white px-3 py-1 font-semibold text-[var(--ink)]">
|
|
||||||
#{message.publicId}
|
|
||||||
</span>
|
|
||||||
<span>{getDeviceName(message.elderDevice.displayName)}</span>
|
|
||||||
<span>{getImportanceLabel(message.importance)}</span>
|
|
||||||
<span>{formatDateTime(message.createdAt)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
|
|
||||||
{truncateText(message.content)}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SendMessageForm deviceUuid={binding.elderDevice.deviceUuid} />
|
||||||
|
|
||||||
|
<section className="rounded-[28px] bg-[var(--paper-soft)] p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium tracking-[0.16em] text-[var(--copper)] uppercase">
|
||||||
|
发给老人的留言
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
||||||
|
最近写给老人的问候
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-white px-4 py-2 text-sm text-[var(--ink)]">
|
||||||
|
待查看 {binding.familyUnreadCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{familyMessages.length === 0 ? (
|
||||||
|
<p className="rounded-[22px] bg-white/80 px-4 py-4 text-sm leading-7 text-[var(--muted)]">
|
||||||
|
您还没有给这位老人留下新的问候。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
familyMessages.map((message) => (
|
||||||
|
<div key={message.id} className="rounded-[22px] bg-white/88 px-4 py-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--muted)]">
|
||||||
|
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1">
|
||||||
|
{getImportanceLabel(message.importance)}
|
||||||
|
</span>
|
||||||
|
<span>{formatTime(message.createdAt)}</span>
|
||||||
|
<span>
|
||||||
|
{message.readAt ? "老人已看过" : "等待老人查看"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
|
||||||
|
{message.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-[28px] bg-[var(--paper-soft)] p-5">
|
||||||
|
<p className="text-sm font-medium tracking-[0.16em] text-[var(--copper)] uppercase">
|
||||||
|
最近对话片段
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
||||||
|
可快速回看的陪伴内容
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{binding.elderDevice.conversationTurns.length === 0 ? (
|
||||||
|
<p className="rounded-[22px] bg-white/80 px-4 py-4 text-sm leading-7 text-[var(--muted)]">
|
||||||
|
这位老人最近的实时对话还没有同步到这里。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
binding.elderDevice.conversationTurns.map((turn) => (
|
||||||
|
<div key={turn.id} className="rounded-[22px] bg-white/88 px-4 py-4">
|
||||||
|
<div className="flex items-center justify-between gap-3 text-xs text-[var(--muted)]">
|
||||||
|
<span>
|
||||||
|
{turn.role === "USER"
|
||||||
|
? "老人说"
|
||||||
|
: turn.role === "ASSISTANT"
|
||||||
|
? "数字人回复"
|
||||||
|
: "工具结果"}
|
||||||
|
</span>
|
||||||
|
<span>{formatTime(turn.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
|
||||||
|
{turn.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</PanelShell>
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,10 +25,10 @@ export default function ScanPage() {
|
|||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
<Link
|
<Link
|
||||||
href="/devices"
|
href="/"
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
||||||
>
|
>
|
||||||
回到设备页
|
回到控制台
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
49
bun.lock
49
bun.lock
@ -13,15 +13,12 @@
|
|||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"sharp": "^0.34.5",
|
|
||||||
"web-push": "^3.6.7",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/web-push": "^3.6.4",
|
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.4",
|
"eslint-config-next": "16.2.4",
|
||||||
"prisma": "^7.7.0",
|
"prisma": "^7.7.0",
|
||||||
@ -299,8 +296,6 @@
|
|||||||
|
|
||||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
"@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/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=="],
|
"@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=="],
|
||||||
@ -363,8 +358,6 @@
|
|||||||
|
|
||||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
"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=="],
|
"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=="],
|
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
@ -389,8 +382,6 @@
|
|||||||
|
|
||||||
"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=="],
|
"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=="],
|
"ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
|
||||||
|
|
||||||
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
||||||
@ -409,16 +400,12 @@
|
|||||||
|
|
||||||
"better-result": ["better-result@2.8.2", "", {}, "sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
@ -489,8 +476,6 @@
|
|||||||
|
|
||||||
"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=="],
|
"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=="],
|
"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=="],
|
"electron-to-chromium": ["electron-to-chromium@1.5.339", "", {}, "sha512-Is+0BBHJ4NrdpAYiperrmp53pLywG/yV/6lIMTAnhxvzj/Cmn5Q/ogSHC6AKe7X+8kPLxxFk0cs5oc/3j/fxIg=="],
|
||||||
@ -647,10 +632,6 @@
|
|||||||
|
|
||||||
"http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="],
|
"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=="],
|
"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=="],
|
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
@ -659,8 +640,6 @@
|
|||||||
|
|
||||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
@ -741,10 +720,6 @@
|
|||||||
|
|
||||||
"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=="],
|
"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=="],
|
"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=="],
|
"language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="],
|
||||||
@ -797,8 +772,6 @@
|
|||||||
|
|
||||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
"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=="],
|
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
@ -947,8 +920,6 @@
|
|||||||
|
|
||||||
"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-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-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=="],
|
"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=="],
|
||||||
@ -957,7 +928,7 @@
|
|||||||
|
|
||||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="],
|
"seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="],
|
||||||
|
|
||||||
@ -1059,8 +1030,6 @@
|
|||||||
|
|
||||||
"valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="],
|
"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": ["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=="],
|
"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=="],
|
||||||
@ -1087,10 +1056,6 @@
|
|||||||
|
|
||||||
"@babel/core/json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
"@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-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=="],
|
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||||
@ -1119,6 +1084,8 @@
|
|||||||
|
|
||||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
"@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=="],
|
"@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=="],
|
"c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||||
@ -1129,24 +1096,22 @@
|
|||||||
|
|
||||||
"eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
"eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||||
|
|
||||||
"eslint-plugin-import/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
|
||||||
|
|
||||||
"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=="],
|
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
|
"is-bun-module/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
|
|
||||||
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"@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=="],
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
|
||||||
|
|||||||
@ -109,14 +109,12 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
|||||||
const [importance, setImportance] = useState("NORMAL");
|
const [importance, setImportance] = useState("NORMAL");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
const [createdMessagePublicId, setCreatedMessagePublicId] = useState<number | null>(null);
|
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!content.trim()) {
|
if (!content.trim()) {
|
||||||
setNotice("先写下想对老人说的话。\n");
|
setNotice("先写下想对老人说的话。\n");
|
||||||
setCreatedMessagePublicId(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,7 +135,7 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const payload = (await response.json().catch(() => null)) as
|
const payload = (await response.json().catch(() => null)) as
|
||||||
| { error?: string; publicId?: number }
|
| { error?: string }
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -145,16 +143,12 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setNotice("留言已经写进家人的收件盒,老人下次打开或询问时就能看到。\n");
|
setNotice("留言已经写进家人的收件盒,老人下次打开或询问时就能看到。\n");
|
||||||
setCreatedMessagePublicId(
|
|
||||||
typeof payload?.publicId === "number" ? payload.publicId : null,
|
|
||||||
);
|
|
||||||
setContent("");
|
setContent("");
|
||||||
setImportance("NORMAL");
|
setImportance("NORMAL");
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setCreatedMessagePublicId(null);
|
|
||||||
setNotice(
|
setNotice(
|
||||||
error instanceof Error ? error.message : "留言发送失败,请稍后再试。",
|
error instanceof Error ? error.message : "留言发送失败,请稍后再试。",
|
||||||
);
|
);
|
||||||
@ -208,17 +202,9 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{notice ? (
|
{notice ? (
|
||||||
<div className="mt-3 space-y-2 text-sm leading-7 text-[var(--muted)]">
|
<p className="mt-3 whitespace-pre-line text-sm leading-7 text-[var(--muted)]">
|
||||||
<p className="whitespace-pre-line">{notice}</p>
|
{notice}
|
||||||
{createdMessagePublicId ? (
|
</p>
|
||||||
<Link
|
|
||||||
href={`/messages/${createdMessagePublicId}`}
|
|
||||||
className="inline-flex h-10 items-center justify-center rounded-full border border-[var(--line)] bg-white px-4 font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
|
||||||
>
|
|
||||||
打开留言 #{createdMessagePublicId}
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,97 +0,0 @@
|
|||||||
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 (
|
|
||||||
<main className="relative overflow-hidden px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-72 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.8),transparent_70%)]" />
|
|
||||||
<div className="mx-auto max-w-7xl space-y-6">
|
|
||||||
<header className="paper-panel relative overflow-hidden rounded-[36px] border border-white/70 px-6 py-7 shadow-[0_35px_90px_rgba(105,68,41,0.13)] sm:px-8 lg:px-10 lg:py-8">
|
|
||||||
<div className="absolute -right-12 top-[-50px] h-44 w-44 rounded-full bg-[rgba(53,84,141,0.14)] blur-2xl" />
|
|
||||||
<div className="absolute left-10 top-10 h-24 w-24 rounded-full border border-[rgba(118,132,94,0.18)]" />
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
|
|
||||||
<div className="max-w-4xl">
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
{navigationItems.map((item) => {
|
|
||||||
const active = isActivePath(currentPath, item.href);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className={`inline-flex h-11 items-center justify-center rounded-full px-4 text-sm font-semibold transition ${
|
|
||||||
active
|
|
||||||
? "bg-[var(--ink)] text-white shadow-[0_12px_30px_rgba(36,27,20,0.18)]"
|
|
||||||
: "border border-[var(--line)] bg-white/82 text-[var(--ink)] hover:bg-[var(--paper-soft)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="mt-6 text-sm font-semibold tracking-[0.24em] text-[var(--copper)] uppercase">
|
|
||||||
{eyebrow}
|
|
||||||
</p>
|
|
||||||
<h1 className="mt-4 max-w-3xl font-[family-name:var(--font-display)] text-4xl leading-[1.15] text-[var(--ink)] sm:text-5xl">
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-5 max-w-2xl text-base leading-8 text-[var(--muted)] sm:text-lg">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
|
|
||||||
<span className="rounded-full border border-[var(--line)] bg-white/76 px-4 py-2">
|
|
||||||
当前家属账号:{caregiverToken.slice(0, 8).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full border border-[var(--line)] bg-white/76 px-4 py-2">
|
|
||||||
不同功能已拆到独立路径,消息支持单条直达
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{actions ? <div className="w-full max-w-md xl:pt-2">{actions}</div> : null}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,287 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
type BeforeInstallPromptEvent = Event & {
|
|
||||||
prompt: () => Promise<void>;
|
|
||||||
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<BeforeInstallPromptEvent | null>(null);
|
|
||||||
const [installing, setInstalling] = useState(false);
|
|
||||||
const [isStandalone, setIsStandalone] = useState(false);
|
|
||||||
const [notificationState, setNotificationState] =
|
|
||||||
useState<NotificationState>("checking");
|
|
||||||
const [notice, setNotice] = useState<string | null>(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 (
|
|
||||||
<section className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_22px_55px_rgba(53,84,141,0.12)]">
|
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
安装与提醒
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
|
||||||
把家属面板装到桌面,并接收新留言提醒
|
|
||||||
</h2>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
安装后打开速度会更像原生应用;开启提醒后,老人一旦通过桥接工具留话,点击通知就能直达那条留言。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
|
|
||||||
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
|
||||||
{isStandalone ? "当前已可从桌面直接打开" : "浏览器内访问中"}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
|
||||||
{notificationState === "enabled"
|
|
||||||
? "消息提醒已开启"
|
|
||||||
: notificationState === "blocked"
|
|
||||||
? "通知权限已被拒绝"
|
|
||||||
: "消息提醒未开启"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 flex flex-wrap gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
void installApp();
|
|
||||||
}}
|
|
||||||
disabled={installing}
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--ink)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--ink-soft)] disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{installing ? "正在呼起安装" : "安装到桌面"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{notificationState === "enabled" ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
void disableNotifications();
|
|
||||||
}}
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
|
||||||
>
|
|
||||||
关闭消息提醒
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
void enableNotifications();
|
|
||||||
}}
|
|
||||||
disabled={notificationState === "pending" || notificationState === "unsupported"}
|
|
||||||
className="inline-flex h-12 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"
|
|
||||||
>
|
|
||||||
{notificationState === "pending" ? "正在开启提醒" : "打开消息提醒"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{notice ? (
|
|
||||||
<p className="mt-4 whitespace-pre-line text-sm leading-7 text-[var(--muted)]">
|
|
||||||
{notice}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,286 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -7,7 +7,6 @@ import {
|
|||||||
UsageEventType,
|
UsageEventType,
|
||||||
} from "@prisma/client";
|
} from "@prisma/client";
|
||||||
|
|
||||||
import { sendIncomingMessagePush } from "@/lib/push";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
type RegisterDeviceInput = {
|
type RegisterDeviceInput = {
|
||||||
@ -339,7 +338,7 @@ export async function recordConversationTurns(input: ConversationTurnInput) {
|
|||||||
export async function recordToolCall(input: ToolCallInput) {
|
export async function recordToolCall(input: ToolCallInput) {
|
||||||
const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid });
|
const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid });
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
const log = await tx.toolCallLog.create({
|
const log = await tx.toolCallLog.create({
|
||||||
data: {
|
data: {
|
||||||
elderDeviceId: device.id,
|
elderDeviceId: device.id,
|
||||||
@ -368,14 +367,13 @@ export async function recordToolCall(input: ToolCallInput) {
|
|||||||
typeof input.arguments.message === "string"
|
typeof input.arguments.message === "string"
|
||||||
? input.arguments.message.trim()
|
? input.arguments.message.trim()
|
||||||
: "";
|
: "";
|
||||||
let createdMessagePublicId: number | null = null;
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
input.status === ToolCallStatus.SUCCESS &&
|
input.status === ToolCallStatus.SUCCESS &&
|
||||||
input.toolName === "leave_message_for_family" &&
|
input.toolName === "leave_message_for_family" &&
|
||||||
messageText
|
messageText
|
||||||
) {
|
) {
|
||||||
const createdMessage = await tx.familyMessage.create({
|
await tx.familyMessage.create({
|
||||||
data: {
|
data: {
|
||||||
elderDeviceId: device.id,
|
elderDeviceId: device.id,
|
||||||
direction: MessageDirection.ELDER_TO_FAMILY,
|
direction: MessageDirection.ELDER_TO_FAMILY,
|
||||||
@ -394,23 +392,10 @@ export async function recordToolCall(input: ToolCallInput) {
|
|||||||
source: "MODEL_TOOL",
|
source: "MODEL_TOOL",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
createdMessagePublicId = createdMessage.publicId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return log;
|
||||||
log,
|
|
||||||
createdMessagePublicId,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.createdMessagePublicId) {
|
|
||||||
void sendIncomingMessagePush(result.createdMessagePublicId).catch(() => {
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.log;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCaregiverDashboard(caregiverId: string) {
|
export async function getCaregiverDashboard(caregiverId: string) {
|
||||||
|
|||||||
@ -1,16 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,114 +0,0 @@
|
|||||||
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() || "未命名陪伴设备";
|
|
||||||
}
|
|
||||||
208
lib/push.ts
208
lib/push.ts
@ -1,208 +0,0 @@
|
|||||||
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 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,32 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
async headers() {
|
/* config options here */
|
||||||
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;
|
export default nextConfig;
|
||||||
|
|||||||
@ -6,8 +6,7 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint"
|
||||||
"generate:icons": "node scripts/generate-icons.mjs"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.7.0",
|
"@prisma/adapter-pg": "^7.7.0",
|
||||||
@ -17,16 +16,13 @@
|
|||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4"
|
||||||
"sharp": "^0.34.5",
|
|
||||||
"web-push": "^3.6.7"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/web-push": "^3.6.4",
|
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.4",
|
"eslint-config-next": "16.2.4",
|
||||||
"prisma": "^7.7.0",
|
"prisma": "^7.7.0",
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
/*
|
|
||||||
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;
|
|
||||||
@ -53,7 +53,6 @@ model CaregiverAccount {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
bindings DeviceBinding[]
|
bindings DeviceBinding[]
|
||||||
messages FamilyMessage[]
|
messages FamilyMessage[]
|
||||||
pushSubscriptions PushSubscription[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model ElderDevice {
|
model ElderDevice {
|
||||||
@ -85,7 +84,6 @@ model DeviceBinding {
|
|||||||
|
|
||||||
model FamilyMessage {
|
model FamilyMessage {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
publicId Int @unique @default(autoincrement())
|
|
||||||
elderDeviceId String
|
elderDeviceId String
|
||||||
caregiverId String?
|
caregiverId String?
|
||||||
direction MessageDirection
|
direction MessageDirection
|
||||||
@ -104,21 +102,6 @@ model FamilyMessage {
|
|||||||
@@index([direction, status, createdAt(sort: Desc)])
|
@@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 {
|
model ConversationTurn {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
elderDeviceId String
|
elderDeviceId String
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 71 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 592 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 366 KiB |
66
public/sw.js
66
public/sw.js
@ -1,66 +0,0 @@
|
|||||||
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;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
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(`
|
|
||||||
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle cx="${size / 2}" cy="${size / 2}" r="${size / 2}" fill="white" />
|
|
||||||
</svg>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
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.");
|
|
||||||
Loading…
x
Reference in New Issue
Block a user