优化路径,添加消息推送

This commit is contained in:
2026-04-17 12:14:57 +08:00
parent b8a842367b
commit 0da5936ce0
34 changed files with 2472 additions and 435 deletions
+113
View File
@@ -0,0 +1,113 @@
import { PanelShell } from "@/components/panel-shell";
import { getCaregiverActivity } from "@/lib/caregiver-panel";
import {
formatDateTime,
getConversationRoleLabel,
getDeviceName,
getUsageLabel,
truncateText,
} from "@/lib/panel-format";
import { requireCaregiverSession } from "@/lib/page-auth";
export const dynamic = "force-dynamic";
export default async function ActivityPage() {
const caregiver = await requireCaregiverSession("/activity");
const { usageEvents, conversationTurns, toolCalls } = await getCaregiverActivity(
caregiver.id,
);
return (
<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>
);
}
+5 -1
View File
@@ -35,7 +35,11 @@ export async function POST(request: Request) {
importance: typeof body.importance === "string" ? body.importance : undefined,
});
return NextResponse.json({ ok: true, messageId: message.id });
return NextResponse.json({
ok: true,
messageId: message.id,
publicId: message.publicId,
});
} catch (error) {
return NextResponse.json(
{
+69
View File
@@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { getOrCreateCaregiverSession } from "@/lib/session";
import { savePushSubscription } from "@/lib/push";
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as
| {
subscription?: {
endpoint?: string;
expirationTime?: number | null;
keys?: {
p256dh?: string;
auth?: string;
};
};
}
| null;
const rawSubscription = body?.subscription;
if (
!rawSubscription ||
typeof rawSubscription.endpoint !== "string" ||
!rawSubscription.endpoint.trim()
) {
return NextResponse.json({ error: "subscription 是必填项。" }, { status: 400 });
}
const subscription = {
endpoint: rawSubscription.endpoint,
expirationTime:
typeof rawSubscription.expirationTime === "number" ||
rawSubscription.expirationTime === null
? rawSubscription.expirationTime
: undefined,
keys:
rawSubscription.keys && typeof rawSubscription.keys === "object"
? {
p256dh:
typeof rawSubscription.keys.p256dh === "string"
? rawSubscription.keys.p256dh
: undefined,
auth:
typeof rawSubscription.keys.auth === "string"
? rawSubscription.keys.auth
: undefined,
}
: undefined,
};
try {
const caregiver = await getOrCreateCaregiverSession();
await savePushSubscription({
caregiverId: caregiver.id,
subscription,
userAgent: request.headers.get("user-agent"),
});
return NextResponse.json({ ok: true });
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "保存推送订阅失败。",
},
{ status: 400 },
);
}
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { removePushSubscription } from "@/lib/push";
import { getCaregiverSession } from "@/lib/session";
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as
| {
endpoint?: string;
}
| null;
if (!body?.endpoint || typeof body.endpoint !== "string") {
return NextResponse.json({ error: "endpoint 是必填项。" }, { status: 400 });
}
const caregiver = await getCaregiverSession();
if (!caregiver) {
return NextResponse.json({ ok: true });
}
await removePushSubscription({
caregiverId: caregiver.id,
endpoint: body.endpoint,
});
return NextResponse.json({ ok: true });
}
+6 -6
View File
@@ -43,10 +43,10 @@ export default async function BindPage({ searchParams }: BindPageProps) {
去扫码
</Link>
<Link
href="/"
href="/devices"
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>
</div>
</section>
@@ -88,16 +88,16 @@ export default async function BindPage({ searchParams }: BindPageProps) {
<div className="mt-6 flex flex-wrap gap-3">
<Link
href="/"
href={`/devices/${device.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="/scan"
href="/devices"
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>
+220
View File
@@ -0,0 +1,220 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { SendMessageForm } from "@/components/dashboard-actions";
import { PanelShell } from "@/components/panel-shell";
import { getCaregiverDeviceDetail } from "@/lib/caregiver-panel";
import {
formatDateTime,
getConversationRoleLabel,
getDeviceName,
getImportanceLabel,
getMessageHref,
getUsageLabel,
truncateText,
} from "@/lib/panel-format";
import { requireCaregiverSession } from "@/lib/page-auth";
export const dynamic = "force-dynamic";
type DeviceDetailPageProps = {
params: Promise<{ deviceUuid: string }>;
};
export default async function DeviceDetailPage({ params }: DeviceDetailPageProps) {
const { deviceUuid } = await params;
const caregiver = await requireCaregiverSession(`/devices/${deviceUuid}`);
const binding = await getCaregiverDeviceDetail(caregiver.id, deviceUuid);
if (!binding) {
notFound();
}
const elderMessages = binding.elderDevice.messages.filter(
(message) => message.direction === "ELDER_TO_FAMILY",
);
const familyMessages = binding.elderDevice.messages.filter(
(message) => message.direction === "FAMILY_TO_ELDER",
);
return (
<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>
);
}
+146
View File
@@ -0,0 +1,146 @@
import Link from "next/link";
import { BindDeviceForm } from "@/components/dashboard-actions";
import { PanelShell } from "@/components/panel-shell";
import { PwaControls } from "@/components/pwa-controls";
import { getCaregiverDevices } from "@/lib/caregiver-panel";
import { formatDateTime, getDeviceName, truncateText } from "@/lib/panel-format";
import { requireCaregiverSession } from "@/lib/page-auth";
export const dynamic = "force-dynamic";
export default async function DevicesPage() {
const caregiver = await requireCaregiverSession("/devices");
const devices = await getCaregiverDevices(caregiver.id);
return (
<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>
);
}
+33 -1
View File
@@ -1,4 +1,4 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Noto_Sans_SC, Noto_Serif_SC } from "next/font/google";
import "./globals.css";
@@ -18,6 +18,38 @@ export const metadata: Metadata = {
metadataBase: new URL("https://digital-human.xn--876a.net"),
title: "家人连线台",
description: "给老人设备做绑定、查看留言、追踪陪伴记录的家属端。",
manifest: "/manifest.webmanifest",
icons: {
icon: [
{
url: "/pwa/icon-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
url: "/pwa/icon-512x512.png",
sizes: "512x512",
type: "image/png",
},
],
apple: [
{
url: "/pwa/apple-touch-icon.png",
sizes: "180x180",
type: "image/png",
},
],
shortcut: "/pwa/icon-192x192.png",
},
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "家人连线台",
},
};
export const viewport: Viewport = {
themeColor: "#35548d",
};
export default function RootLayout({
+33
View File
@@ -0,0 +1,33 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "家人连线台",
short_name: "家人连线台",
description: "给老人设备做绑定、查看留言、追踪陪伴记录的家属端。",
start_url: "/",
display: "standalone",
background_color: "#f6eee1",
theme_color: "#35548d",
lang: "zh-CN",
categories: ["utilities", "lifestyle", "health"],
icons: [
{
src: "/pwa/icon-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/pwa/icon-512x512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "/pwa/icon-maskable-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}
+120
View File
@@ -0,0 +1,120 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { PanelShell } from "@/components/panel-shell";
import { getCaregiverMessageDetail } from "@/lib/caregiver-panel";
import {
formatDateTime,
getDeviceName,
getImportanceLabel,
getMessageDirectionLabel,
getMessageStatusLabel,
} from "@/lib/panel-format";
import { requireCaregiverSession } from "@/lib/page-auth";
export const dynamic = "force-dynamic";
type MessageDetailPageProps = {
params: Promise<{ messageId: string }>;
};
export default async function MessageDetailPage({ params }: MessageDetailPageProps) {
const { messageId } = await params;
const publicId = Number.parseInt(messageId, 10);
if (!Number.isFinite(publicId) || publicId <= 0) {
notFound();
}
const caregiver = await requireCaregiverSession(`/messages/${publicId}`);
const message = await getCaregiverMessageDetail(caregiver.id, publicId);
if (!message) {
notFound();
}
return (
<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>
);
}
+146
View File
@@ -0,0 +1,146 @@
import Link from "next/link";
import { PanelShell } from "@/components/panel-shell";
import { PwaControls } from "@/components/pwa-controls";
import { getCaregiverMessages } from "@/lib/caregiver-panel";
import {
formatDateTime,
getDeviceName,
getImportanceLabel,
getMessageDirectionLabel,
getMessageHref,
getMessageStatusLabel,
truncateText,
} from "@/lib/panel-format";
import { requireCaregiverSession } from "@/lib/page-auth";
export const dynamic = "force-dynamic";
export default async function MessagesPage() {
const caregiver = await requireCaregiverSession("/messages");
const { messages, unreadIncomingCount, unreadOutgoingCount } =
await getCaregiverMessages(caregiver.id);
const incomingMessages = messages.filter(
(message) => message.direction === "ELDER_TO_FAMILY",
);
const outgoingMessages = messages.filter(
(message) => message.direction === "FAMILY_TO_ELDER",
);
return (
<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>
);
}
+208 -408
View File
@@ -1,70 +1,23 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { BindDeviceForm, SendMessageForm } from "@/components/dashboard-actions";
import { getCaregiverDashboard } from "@/lib/monitor-data";
import { PanelShell } from "@/components/panel-shell";
import { PwaControls } from "@/components/pwa-controls";
import { getCaregiverOverview } from "@/lib/caregiver-panel";
import {
buildCaregiverSessionBootstrapPath,
getCaregiverSession,
} from "@/lib/session";
formatDateTime,
getDeviceName,
getImportanceLabel,
getMessageHref,
truncateText,
} from "@/lib/panel-format";
import { requireCaregiverSession } from "@/lib/page-auth";
export const dynamic = "force-dynamic";
function formatTime(date: Date | null | undefined) {
if (!date) {
return "暂时还没有记录";
}
return new Intl.DateTimeFormat("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function getImportanceLabel(level: string) {
switch (level) {
case "LOW":
return "温和提醒";
case "HIGH":
return "希望尽快看到";
case "URGENT":
return "需要尽快联系";
default:
return "日常问候";
}
}
function getUsageLabel(eventType: string) {
switch (eventType) {
case "APP_OPEN":
return "打开了老人端应用";
case "SETTINGS_OPENED":
return "打开了家人连接页";
case "AI_SESSION_STARTED":
return "开始了一次陪伴对话";
case "AI_SESSION_ENDED":
return "结束了一次陪伴对话";
case "CAMERA_ENABLED":
return "打开了镜头模式";
case "CAMERA_DISABLED":
return "关闭了镜头模式";
case "TOOL_CALLED":
return "模型调用了一次桥接工具";
default:
return eventType;
}
}
export default async function Home() {
const caregiver = await getCaregiverSession();
if (!caregiver) {
redirect(buildCaregiverSessionBootstrapPath("/"));
}
const dashboard = await getCaregiverDashboard(caregiver.id);
const caregiver = await requireCaregiverSession("/");
const { dashboard, recentIncomingMessages, recentOutgoingMessages } =
await getCaregiverOverview(caregiver.id);
const totalUnreadForElder = dashboard.devices.reduce(
(total, device) => total + device.familyUnreadCount,
@@ -80,360 +33,207 @@ export default async function Home() {
);
return (
<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-64 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.78),transparent_68%)]" />
<div className="mx-auto max-w-7xl space-y-6">
<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">
<div className="absolute -right-10 top-[-52px] h-40 w-40 rounded-full bg-[rgba(200,103,51,0.10)] blur-2xl" />
<div className="absolute left-10 top-8 h-20 w-20 rounded-full border border-[rgba(118,132,94,0.18)]" />
<PanelShell
currentPath="/"
title="首页只保留摘要,不再把所有操作挤在一处。"
description="设备绑定、留言管理、活动回看和扫码入口已经拆到独立路径。这里保留今天最值得先看的摘要,以及最近的新留言。"
caregiverToken={dashboard.caregiver.sessionToken}
actions={<PwaControls />}
>
<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 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)]">
{dashboard.devices.length}
</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)]">
{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)]">
{totalUnreadForElder}
</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)]">
{totalConversations}
</p>
</div>
</section>
<div className="grid gap-8 lg:grid-cols-[1.2fr_0.8fr] lg:items-end">
<div>
<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>
<section className="grid gap-4 lg:grid-cols-4">
{[
{
href: "/messages",
title: "留言中心",
text: "集中看所有老人留言和家属留言,每条消息都有 URL 编号可直接打开。",
tone: "bg-[rgba(53,84,141,0.12)]",
},
{
href: "/devices",
title: "设备与绑定",
text: "去管理设备、扫码绑定、查看某一台设备的专属详情页和留言入口。",
tone: "bg-[rgba(118,132,94,0.14)]",
},
{
href: "/activity",
title: "活动回看",
text: "把使用事件、工具调用和对话片段拆出来,回看时不再和留言混在一起。",
tone: "bg-[rgba(199,103,51,0.12)]",
},
{
href: "/scan",
title: "扫码绑定",
text: "拿起另一台手机时,直接走扫码页,不必再回首页寻找入口。",
tone: "bg-[rgba(36,27,20,0.08)]",
},
].map((item) => (
<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 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)]">
{dashboard.devices.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)]">
{totalUnreadForElder}
</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)]">
{totalConversations}
</p>
</div>
</div>
</div>
</section>
<BindDeviceForm />
<section className="grid gap-6 lg:grid-cols-[0.85fr_1.15fr]">
<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 space-y-4">
<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-3xl font-semibold text-[var(--ink)]">
{totalUnreadForCaregiver}
</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-3xl font-semibold text-[var(--ink)]">
{totalUnreadForElder}
</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>
</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>
{dashboard.devices.length === 0 ? (
<section className="rounded-[32px] border border-dashed border-[var(--line)] bg-white/70 px-6 py-10 text-center shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
还没有绑定老人设备
</p>
<h2 className="mt-3 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
先扫一下老人手机里的二维码
<h2 className="mt-4 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
{item.title}
</h2>
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
绑定完成后,这里会出现老人的留言、陪伴轨迹、最近对话和工具调用记录。当前页面已经替您准备好扫码与手动绑定入口。
</p>
</section>
) : (
<section className="space-y-6">
{dashboard.devices.map((binding) => {
const familyMessages = binding.elderDevice.messages.filter(
(message) => message.direction === "FAMILY_TO_ELDER",
);
const elderMessages = binding.elderDevice.messages.filter(
(message) => message.direction === "ELDER_TO_FAMILY",
);
<p className="mt-3 text-sm leading-7 text-[var(--muted)]">{item.text}</p>
<p className="mt-4 text-sm font-semibold text-[var(--copper)]">前往 {item.href}</p>
</Link>
))}
</section>
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>
<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)]">
{binding.elderDevice.displayName || "未命名陪伴设备"}
</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>
{dashboard.devices.length === 0 ? (
<section className="rounded-[32px] border border-dashed border-[var(--line)] bg-white/70 px-6 py-10 text-center shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
还没有绑定老人设备
</p>
<h2 className="mt-3 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)]">
绑定完成后,留言中心会出现 URL 编号消息卡,活动页会出现陪伴记录,推送也能直达具体留言。
</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">
<div className="rounded-[32px] border border-white/75 bg-white/80 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">
{recentIncomingMessages.length === 0 ? (
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
目前还没有新的老人留言。
</p>
) : (
recentIncomingMessages.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>{getDeviceName(message.elderDevice.displayName)}</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)}
</p>
</Link>
))
)}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-4">
<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.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 className="rounded-[32px] border border-white/75 bg-white/80 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="/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">
{recentOutgoingMessages.length === 0 ? (
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
还没有新的家属留言,可以到设备页针对某一台设备写下问候。
</p>
) : (
recentOutgoingMessages.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>{getDeviceName(message.elderDevice.displayName)}</span>
<span>{getImportanceLabel(message.importance)}</span>
<span>{formatDateTime(message.createdAt)}</span>
</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>
) : (
elderMessages.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>
{message.recipientRelation ? (
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1">
目标:{message.recipientRelation}
</span>
) : null}
<span>{formatTime(message.createdAt)}</span>
</div>
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
{message.content}
</p>
</div>
))
)}
</div>
</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.usageEvents.length === 0 ? (
<p className="rounded-[22px] bg-white/80 px-4 py-4 text-sm leading-7 text-[var(--muted)]">
暂时还没有同步到新的使用记录。
</p>
) : (
binding.elderDevice.usageEvents.map((event) => (
<div key={event.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>{getUsageLabel(event.eventType)}</span>
<span>{formatTime(event.createdAt)}</span>
</div>
</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>
</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>
))
)}
</div>
</section>
</div>
</div>
</article>
);
})}
</section>
)}
</div>
</main>
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
{truncateText(message.content)}
</p>
</Link>
))
)}
</div>
</div>
</section>
)}
</PanelShell>
);
}
+2 -2
View File
@@ -25,10 +25,10 @@ export default function ScanPage() {
<div className="mt-6 flex flex-wrap gap-3">
<Link
href="/"
href="/devices"
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>