添加家人绑定
This commit is contained in:
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user