添加家人绑定

This commit is contained in:
2026-04-17 11:32:17 +08:00
parent 399bdf079b
commit 8c9d86e858
25 changed files with 2631 additions and 87 deletions
+74
View File
@@ -0,0 +1,74 @@
import { ConversationRole } from "@prisma/client";
import { NextResponse } from "next/server";
import { recordConversationTurns } from "@/lib/monitor-data";
function toConversationRole(rawRole: unknown) {
switch (rawRole) {
case ConversationRole.USER:
return ConversationRole.USER;
case ConversationRole.TOOL:
return ConversationRole.TOOL;
default:
return ConversationRole.ASSISTANT;
}
}
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as
| {
deviceUuid?: string;
sessionId?: string;
turns?: unknown;
}
| null;
if (!body?.deviceUuid || typeof body.deviceUuid !== "string") {
return NextResponse.json(
{ error: "deviceUuid 是必填项。" },
{ status: 400 },
);
}
if (!Array.isArray(body.turns)) {
return NextResponse.json(
{ error: "turns 必须是数组。" },
{ status: 400 },
);
}
const turns = body.turns
.filter((turn): turn is Record<string, unknown> => !!turn && typeof turn === "object")
.map((turn) => ({
role: toConversationRole(turn.role),
content: typeof turn.content === "string" ? turn.content : "",
metaJson: typeof turn.metaJson === "string" ? turn.metaJson : undefined,
createdAt: typeof turn.createdAt === "string" ? turn.createdAt : undefined,
}))
.filter((turn) => turn.content.trim().length > 0);
if (turns.length === 0) {
return NextResponse.json(
{ error: "至少需要一条有效对话内容。" },
{ status: 400 },
);
}
try {
const result = await recordConversationTurns({
deviceUuid: body.deviceUuid,
sessionId: typeof body.sessionId === "string" ? body.sessionId : undefined,
turns,
});
return NextResponse.json({ ok: true, count: result.count });
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "对话记录保存失败,请稍后再试。",
},
{ status: 400 },
);
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { markFamilyMessagesAsRead } from "@/lib/monitor-data";
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as
| {
deviceUuid?: string;
messageIds?: unknown;
}
| null;
if (!body?.deviceUuid || typeof body.deviceUuid !== "string") {
return NextResponse.json(
{ error: "deviceUuid 是必填项。" },
{ status: 400 },
);
}
const messageIds = Array.isArray(body.messageIds)
? body.messageIds.filter(
(messageId): messageId is string => typeof messageId === "string",
)
: undefined;
try {
await markFamilyMessagesAsRead(body.deviceUuid, messageIds);
return NextResponse.json({ ok: true });
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "更新留言状态失败,请稍后再试。",
},
{ status: 400 },
);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { listFamilyMessagesForDevice } from "@/lib/monitor-data";
export async function GET(request: NextRequest) {
const rawDeviceValue =
request.nextUrl.searchParams.get("deviceUuid") ||
request.nextUrl.searchParams.get("device") ||
"";
const take = Number.parseInt(
request.nextUrl.searchParams.get("take") || "12",
10,
);
if (!rawDeviceValue) {
return NextResponse.json(
{ error: "deviceUuid 是必填项。" },
{ status: 400 },
);
}
try {
const data = await listFamilyMessagesForDevice(rawDeviceValue, take);
return NextResponse.json({
deviceUuid: data.device.deviceUuid,
unreadCount: data.unreadCount,
summaryText: data.summaryText,
messages: data.messages.map((message) => ({
id: message.id,
content: message.content,
importance: message.importance,
status: message.status,
createdAt: message.createdAt.toISOString(),
deliveredAt: message.deliveredAt?.toISOString() || null,
readAt: message.readAt?.toISOString() || null,
})),
});
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "读取家属留言失败,请稍后再试。",
},
{ status: 400 },
);
}
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { createBindUrl, ensureDeviceRegistration } from "@/lib/monitor-data";
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as
| {
deviceUuid?: string;
displayName?: string;
appVersion?: string;
}
| null;
if (!body?.deviceUuid || typeof body.deviceUuid !== "string") {
return NextResponse.json(
{ error: "deviceUuid 是必填项。" },
{ status: 400 },
);
}
try {
const device = await ensureDeviceRegistration({
deviceUuid: body.deviceUuid,
displayName:
typeof body.displayName === "string" ? body.displayName : undefined,
appVersion:
typeof body.appVersion === "string" ? body.appVersion : undefined,
});
return NextResponse.json({
deviceUuid: device.deviceUuid,
bindUrl: createBindUrl(device.deviceUuid),
});
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "设备注册失败,请稍后再试。",
},
{ status: 500 },
);
}
}
+73
View File
@@ -0,0 +1,73 @@
import { ToolCallStatus } from "@prisma/client";
import { NextResponse } from "next/server";
import { recordToolCall } from "@/lib/monitor-data";
function toToolCallStatus(rawStatus: unknown) {
return rawStatus === ToolCallStatus.FAILURE
? ToolCallStatus.FAILURE
: ToolCallStatus.SUCCESS;
}
function toArguments(rawArguments: unknown) {
if (
rawArguments &&
typeof rawArguments === "object" &&
!Array.isArray(rawArguments)
) {
return rawArguments as Record<string, unknown>;
}
return {};
}
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as
| {
deviceUuid?: string;
toolName?: string;
arguments?: unknown;
outputText?: string;
status?: string;
callId?: string;
sessionId?: string;
}
| null;
if (!body?.deviceUuid || typeof body.deviceUuid !== "string") {
return NextResponse.json(
{ error: "deviceUuid 是必填项。" },
{ status: 400 },
);
}
if (!body.toolName || typeof body.toolName !== "string") {
return NextResponse.json(
{ error: "toolName 是必填项。" },
{ status: 400 },
);
}
try {
const log = await recordToolCall({
deviceUuid: body.deviceUuid,
toolName: body.toolName,
arguments: toArguments(body.arguments),
outputText: typeof body.outputText === "string" ? body.outputText : undefined,
status: toToolCallStatus(body.status),
callId: typeof body.callId === "string" ? body.callId : undefined,
sessionId:
typeof body.sessionId === "string" ? body.sessionId : undefined,
});
return NextResponse.json({ ok: true, logId: log.id });
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "工具调用记录失败,请稍后再试。",
},
{ status: 400 },
);
}
}
+65
View File
@@ -0,0 +1,65 @@
import { UsageEventType } from "@prisma/client";
import { NextResponse } from "next/server";
import { recordUsageEvent } from "@/lib/monitor-data";
function toUsageEventType(rawType: unknown) {
switch (rawType) {
case UsageEventType.APP_OPEN:
return UsageEventType.APP_OPEN;
case UsageEventType.SETTINGS_OPENED:
return UsageEventType.SETTINGS_OPENED;
case UsageEventType.AI_SESSION_STARTED:
return UsageEventType.AI_SESSION_STARTED;
case UsageEventType.AI_SESSION_ENDED:
return UsageEventType.AI_SESSION_ENDED;
case UsageEventType.CAMERA_ENABLED:
return UsageEventType.CAMERA_ENABLED;
case UsageEventType.CAMERA_DISABLED:
return UsageEventType.CAMERA_DISABLED;
default:
return UsageEventType.TOOL_CALLED;
}
}
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as
| {
deviceUuid?: string;
eventType?: string;
detailJson?: string;
}
| null;
if (!body?.deviceUuid || typeof body.deviceUuid !== "string") {
return NextResponse.json(
{ error: "deviceUuid 是必填项。" },
{ status: 400 },
);
}
if (!body.eventType || typeof body.eventType !== "string") {
return NextResponse.json(
{ error: "eventType 是必填项。" },
{ status: 400 },
);
}
try {
await recordUsageEvent({
deviceUuid: body.deviceUuid,
eventType: toUsageEventType(body.eventType),
detailJson: typeof body.detailJson === "string" ? body.detailJson : undefined,
});
return NextResponse.json({ ok: true });
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "使用记录保存失败,请稍后再试。",
},
{ status: 400 },
);
}
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { bindDeviceToCaregiver, createBindUrl } from "@/lib/monitor-data";
import { getOrCreateCaregiverSession } from "@/lib/session";
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as
| {
deviceUuid?: string;
}
| null;
if (!body?.deviceUuid || typeof body.deviceUuid !== "string") {
return NextResponse.json(
{ error: "请先提供设备码或二维码链接。" },
{ status: 400 },
);
}
try {
const caregiver = await getOrCreateCaregiverSession();
const device = await bindDeviceToCaregiver(caregiver.id, body.deviceUuid);
return NextResponse.json({
ok: true,
deviceUuid: device.deviceUuid,
bindUrl: createBindUrl(device.deviceUuid),
});
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "绑定失败,请稍后再试。",
},
{ status: 400 },
);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { createFamilyMessageFromCaregiver } from "@/lib/monitor-data";
import { getOrCreateCaregiverSession } from "@/lib/session";
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as
| {
deviceUuid?: string;
content?: string;
importance?: string;
}
| null;
if (!body?.deviceUuid || typeof body.deviceUuid !== "string") {
return NextResponse.json(
{ error: "请先提供目标老人设备码。" },
{ status: 400 },
);
}
if (!body.content || typeof body.content !== "string" || !body.content.trim()) {
return NextResponse.json(
{ error: "留言内容不能为空。" },
{ status: 400 },
);
}
try {
const caregiver = await getOrCreateCaregiverSession();
const message = await createFamilyMessageFromCaregiver({
caregiverId: caregiver.id,
rawDeviceValue: body.deviceUuid,
content: body.content,
importance: typeof body.importance === "string" ? body.importance : undefined,
});
return NextResponse.json({ ok: true, messageId: message.id });
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "留言发送失败,请稍后再试。",
},
{ status: 400 },
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import { type NextRequest, NextResponse } from "next/server";
import {
CAREGIVER_SESSION_COOKIE,
createCaregiverSessionCookieValue,
getCaregiverSession,
} from "@/lib/session";
import { prisma } from "@/lib/prisma";
function normalizeRedirectPath(redirectTo?: string | null) {
if (!redirectTo || !redirectTo.startsWith("/") || redirectTo.startsWith("//")) {
return "/";
}
return redirectTo;
}
export async function GET(request: NextRequest) {
const redirectTo = normalizeRedirectPath(
request.nextUrl.searchParams.get("redirectTo"),
);
const existingCaregiver = await getCaregiverSession();
const response = NextResponse.redirect(new URL(redirectTo, request.url));
if (existingCaregiver) {
return response;
}
const { sessionToken, options } = createCaregiverSessionCookieValue();
await prisma.caregiverAccount.upsert({
where: { sessionToken },
update: {},
create: { sessionToken },
});
response.cookies.set({
name: CAREGIVER_SESSION_COOKIE,
value: sessionToken,
...options,
});
return response;
}
+106
View File
@@ -0,0 +1,106 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { bindDeviceToCaregiver } from "@/lib/monitor-data";
import {
buildCaregiverSessionBootstrapPath,
getCaregiverSession,
} from "@/lib/session";
export const dynamic = "force-dynamic";
type BindPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export default async function BindPage({ searchParams }: BindPageProps) {
const params = await searchParams;
const rawDeviceValue =
typeof params.deviceUuid === "string"
? params.deviceUuid
: typeof params.device === "string"
? params.device
: "";
if (!rawDeviceValue) {
return (
<main className="mx-auto flex min-h-screen max-w-3xl items-center px-4 py-8 sm:px-6">
<section className="w-full rounded-[36px] border border-white/70 bg-white/82 p-8 text-center shadow-[0_30px_70px_rgba(115,76,42,0.14)] backdrop-blur">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
连接家人设备
</p>
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
还没有收到设备码
</h1>
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
请让老人打开家人连接页,把二维码给您扫一下,或者把设备码复制过来。
</p>
<div className="mt-6 flex justify-center gap-3">
<Link
href="/scan"
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="/"
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>
</main>
);
}
const caregiver = await getCaregiverSession();
if (!caregiver) {
redirect(
buildCaregiverSessionBootstrapPath(
`/bind?deviceUuid=${encodeURIComponent(rawDeviceValue)}`,
),
);
}
const device = await bindDeviceToCaregiver(caregiver.id, rawDeviceValue);
return (
<main className="mx-auto flex min-h-screen max-w-3xl items-center px-4 py-8 sm:px-6">
<section className="w-full rounded-[36px] border border-white/70 bg-white/84 p-8 shadow-[0_30px_70px_rgba(115,76,42,0.14)] backdrop-blur">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
绑定完成
</p>
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
这位老人已经连到当前家属账号
</h1>
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
现在您可以在控制台查看老人给家里的留言、最近陪伴记录,也能马上给老人写下一句问候。
</p>
<div className="mt-6 rounded-[28px] bg-[var(--paper-soft)] p-5">
<p className="text-sm text-[var(--muted)]">设备 UUID</p>
<p className="mt-2 break-all text-lg font-semibold text-[var(--ink)]">
{device.deviceUuid}
</p>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<Link
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)]"
>
回到控制台
</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>
</main>
);
}
+54 -14
View File
@@ -1,26 +1,66 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--paper: #f4ece0;
--paper-soft: #fbf7f1;
--paper-strong: #fffdf8;
--ink: #241b14;
--ink-soft: #3d2d20;
--muted: #726153;
--line: rgba(85, 60, 42, 0.15);
--copper: #c76733;
--copper-deep: #964722;
--olive: #76845e;
--olive-deep: #596946;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-background: var(--paper);
--color-foreground: var(--ink);
--font-sans: var(--font-body);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
* {
box-sizing: border-box;
}
html {
background: #ecdfcf;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
color: var(--ink);
font-family: var(--font-body), sans-serif;
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.82), transparent 34%),
radial-gradient(circle at 88% 10%, rgba(205, 126, 76, 0.22), transparent 22%),
linear-gradient(180deg, #f6eee1 0%, #efe2d1 52%, #e8d6c3 100%);
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
textarea,
select {
font: inherit;
}
::selection {
background: rgba(199, 103, 51, 0.18);
}
.paper-panel {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 249, 241, 0.84)),
repeating-linear-gradient(
135deg,
rgba(198, 163, 127, 0.06),
rgba(198, 163, 127, 0.06) 10px,
rgba(255, 255, 255, 0.08) 10px,
rgba(255, 255, 255, 0.08) 20px
);
}
+12 -12
View File
@@ -1,20 +1,23 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Noto_Sans_SC, Noto_Serif_SC } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
const bodyFont = Noto_Sans_SC({
variable: "--font-body",
subsets: ["latin"],
weight: ["400", "500", "700"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const displayFont = Noto_Serif_SC({
variable: "--font-display",
subsets: ["latin"],
weight: ["400", "600", "700"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
metadataBase: new URL("https://digital-human.xn--876a.net"),
title: "家人连线台",
description: "给老人设备做绑定、查看留言、追踪陪伴记录的家属端。",
};
export default function RootLayout({
@@ -23,11 +26,8 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="zh-CN" className={`${bodyFont.variable} ${displayFont.variable}`}>
<body className="min-h-screen">{children}</body>
</html>
);
}
+435 -61
View File
@@ -1,65 +1,439 @@
import Image from "next/image";
import Link from "next/link";
import { redirect } from "next/navigation";
import { BindDeviceForm, SendMessageForm } from "@/components/dashboard-actions";
import { getCaregiverDashboard } from "@/lib/monitor-data";
import {
buildCaregiverSessionBootstrapPath,
getCaregiverSession,
} from "@/lib/session";
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 totalUnreadForElder = dashboard.devices.reduce(
(total, device) => total + device.familyUnreadCount,
0,
);
const totalUnreadForCaregiver = dashboard.devices.reduce(
(total, device) => total + device.elderUnreadCount,
0,
);
const totalConversations = dashboard.devices.reduce(
(total, device) => total + device.elderDevice._count.conversationTurns,
0,
);
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
<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)]" />
<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>
</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>
<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",
);
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>
</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>
</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>
);
}
+40
View File
@@ -0,0 +1,40 @@
import Link from "next/link";
import { ScanClient } from "@/components/scan-client";
export default function ScanPage() {
return (
<main className="mx-auto min-h-screen max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
<section className="grid gap-6 lg:grid-cols-[0.82fr_1.18fr]">
<div className="rounded-[36px] border border-white/70 bg-white/78 p-6 shadow-[0_30px_70px_rgba(115,76,42,0.14)] backdrop-blur">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
扫码绑定
</p>
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl leading-[1.15] text-[var(--ink)]">
对准老人手机里的家人连接二维码
</h1>
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
扫描成功后,系统会自动把老人的本机 UUID 绑定到当前家属账号。之后,您就能看见老人的留言、最近对话和使用记录。
</p>
<div className="mt-6 space-y-3 rounded-[28px] 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="/"
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>
<ScanClient />
</section>
</main>
);
}